diff --git a/apps/client/src/features/editor/components/image/image-view.module.css b/apps/client/src/features/editor/components/image/image-view.module.css
index d326ee5a..987ec0d7 100644
--- a/apps/client/src/features/editor/components/image/image-view.module.css
+++ b/apps/client/src/features/editor/components/image/image-view.module.css
@@ -5,6 +5,9 @@
max-width: 100%;
border-radius: 8px;
overflow: hidden;
+}
+
+.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
diff --git a/apps/client/src/features/editor/components/image/image-view.tsx b/apps/client/src/features/editor/components/image/image-view.tsx
index defb64c4..7ec3e26f 100644
--- a/apps/client/src/features/editor/components/image/image-view.tsx
+++ b/apps/client/src/features/editor/components/image/image-view.tsx
@@ -33,6 +33,7 @@ export default function ImageView(props: NodeViewProps) {
className={clsx(
selected && "ProseMirror-selectednode",
classes.imageWrapper,
+ !src && classes.skeleton,
alignClass,
)}
style={{
diff --git a/apps/client/src/features/editor/components/pdf/pdf-menu.tsx b/apps/client/src/features/editor/components/pdf/pdf-menu.tsx
new file mode 100644
index 00000000..2104bfbc
--- /dev/null
+++ b/apps/client/src/features/editor/components/pdf/pdf-menu.tsx
@@ -0,0 +1,145 @@
+import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
+import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
+import { useCallback } from "react";
+import { Node as PMNode } from "@tiptap/pm/model";
+import {
+ EditorMenuProps,
+ ShouldShowProps,
+} from "@/features/editor/components/table/types/types.ts";
+import { ActionIcon, Tooltip } from "@mantine/core";
+import {
+ IconPaperclip,
+ IconTrash,
+} from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import classes from "../common/toolbar-menu.module.css";
+
+export function PdfMenu({ editor }: EditorMenuProps) {
+ const { t } = useTranslation();
+
+ const editorState = useEditorState({
+ editor,
+ selector: (ctx) => {
+ if (!ctx.editor) {
+ return null;
+ }
+
+ const pdfAttrs = ctx.editor.getAttributes("pdf");
+
+ return {
+ isPdf: ctx.editor.isActive("pdf"),
+ src: pdfAttrs?.src || null,
+ name: pdfAttrs?.name || null,
+ attachmentId: pdfAttrs?.attachmentId || null,
+ };
+ },
+ });
+
+ const shouldShow = useCallback(
+ ({ state }: ShouldShowProps) => {
+ if (!state || !editor.isActive("pdf")) {
+ return false;
+ }
+
+ const { selection } = state;
+ const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
+ if (!dom) return false;
+
+ return !!dom.querySelector("[data-pdf-error]");
+ },
+ [editor],
+ );
+
+ const getReferencedVirtualElement = useCallback(() => {
+ if (!editor) return;
+ const { selection } = editor.state;
+ const predicate = (node: PMNode) => node.type.name === "pdf";
+ const parent = findParentNode(predicate)(selection);
+
+ if (parent) {
+ const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
+ const domRect = dom.getBoundingClientRect();
+ return {
+ getBoundingClientRect: () => domRect,
+ getClientRects: () => [domRect],
+ };
+ }
+
+ const domRect = posToDOMRect(editor.view, selection.from, selection.to);
+ return {
+ getBoundingClientRect: () => domRect,
+ getClientRects: () => [domRect],
+ };
+ }, [editor]);
+
+ const handleConvertToAttachment = useCallback(() => {
+ if (!editorState?.src) return;
+
+ const { selection } = editor.state;
+ const { from } = selection;
+ const node = editor.state.doc.nodeAt(from);
+ if (!node || node.type.name !== "pdf") return;
+
+ editor
+ .chain()
+ .insertContentAt(
+ { from, to: from + node.nodeSize },
+ {
+ type: "attachment",
+ attrs: {
+ url: node.attrs.src,
+ name: node.attrs.name,
+ attachmentId: node.attrs.attachmentId,
+ size: node.attrs.size,
+ mime: "application/pdf",
+ },
+ },
+ )
+ .run();
+ }, [editor, editorState]);
+
+ const handleDelete = useCallback(() => {
+ editor.commands.deleteSelection();
+ }, [editor]);
+
+ return (
+
+
+
+ );
+}
+
+export default PdfMenu;
diff --git a/apps/client/src/features/editor/components/pdf/pdf-view.module.css b/apps/client/src/features/editor/components/pdf/pdf-view.module.css
new file mode 100644
index 00000000..df5af87f
--- /dev/null
+++ b/apps/client/src/features/editor/components/pdf/pdf-view.module.css
@@ -0,0 +1,100 @@
+.pdfWrapper {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ max-width: 100%;
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+.skeleton {
+ animation: pulse 1.2s ease-in-out infinite;
+
+ @mixin light {
+ background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
+ background-size: 400% 400%;
+ }
+
+ @mixin dark {
+ background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
+ background-size: 400% 400%;
+ }
+
+ @keyframes pulse {
+ 0% {
+ background-position: 0% 0%;
+ }
+ 100% {
+ background-position: -135% 0%;
+ }
+ }
+}
+
+.pdfContainer {
+ display: flex;
+ justify-content: center;
+}
+
+.pdfResizeWrapper {
+ @mixin light {
+ background-color: var(--mantine-color-gray-0);
+ }
+
+ @mixin dark {
+ background-color: var(--mantine-color-dark-7);
+ }
+}
+
+.pdfIframe {
+ width: 100%;
+ height: 100%;
+ border: none;
+ border-radius: 8px;
+}
+
+.hoverMenu {
+ position: absolute;
+ top: 56px;
+ right: 8px;
+ z-index: 2;
+ display: flex;
+ gap: 4px;
+ padding: 4px;
+ border-radius: 6px;
+ opacity: 0;
+ transition: opacity 0.15s ease;
+ background-color: rgba(0, 0, 0, 0.5);
+}
+
+.hoverMenu::before {
+ content: "";
+ position: absolute;
+ inset: -12px;
+}
+
+.hoverMenu:hover {
+ opacity: 1;
+}
+
+.pdfResizeWrapper:hover .hoverMenu {
+ opacity: 1;
+}
+
+.pdfError {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding: 12px 32px;
+ border-radius: 8px;
+ cursor: pointer;
+
+ @mixin light {
+ background-color: var(--mantine-color-gray-0);
+ }
+
+ @mixin dark {
+ background-color: var(--mantine-color-dark-7);
+ }
+}
diff --git a/apps/client/src/features/editor/components/pdf/pdf-view.tsx b/apps/client/src/features/editor/components/pdf/pdf-view.tsx
new file mode 100644
index 00000000..6207da9f
--- /dev/null
+++ b/apps/client/src/features/editor/components/pdf/pdf-view.tsx
@@ -0,0 +1,168 @@
+import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
+import { ActionIcon, Group, Loader, Text, Tooltip } from "@mantine/core";
+import { useCallback, useMemo, useState } from "react";
+import { getFileUrl } from "@/lib/config.ts";
+import { ResizableWrapper } from "../common/resizable-wrapper";
+import clsx from "clsx";
+import classes from "./pdf-view.module.css";
+import { useTranslation } from "react-i18next";
+import { isInternalFileUrl } from "@docmost/editor-ext";
+import {
+ IconFileTypePdf,
+ IconPaperclip,
+ IconTrash,
+} from "@tabler/icons-react";
+
+export default function PdfView(props: NodeViewProps) {
+ const { t } = useTranslation();
+ const { editor, node, getPos, selected, updateAttributes } = props;
+ const { src, placeholder, width: nodeWidth, height: nodeHeight } = node.attrs;
+ const [hasError, setHasError] = useState(false);
+
+ const safeSrc = useMemo(() => {
+ if (!src || !isInternalFileUrl(src)) return null;
+ return getFileUrl(src);
+ }, [src]);
+
+ const handleSelect = useCallback(() => {
+ const pos = getPos();
+ if (pos !== undefined) {
+ editor.commands.setNodeSelection(pos);
+ }
+ }, [editor, getPos]);
+
+ const handleResize = useCallback(
+ (newWidth: number, newHeight: number) => {
+ updateAttributes({ width: newWidth, height: newHeight });
+ },
+ [updateAttributes],
+ );
+
+ const handleConvertToAttachment = useCallback(() => {
+ if (!src) return;
+ const pos = getPos();
+ if (pos === undefined) return;
+ const currentNode = editor.state.doc.nodeAt(pos);
+ if (!currentNode || currentNode.type.name !== "pdf") return;
+
+ editor
+ .chain()
+ .insertContentAt(
+ { from: pos, to: pos + currentNode.nodeSize },
+ {
+ type: "attachment",
+ attrs: {
+ url: currentNode.attrs.src,
+ name: currentNode.attrs.name,
+ attachmentId: currentNode.attrs.attachmentId,
+ size: currentNode.attrs.size,
+ mime: "application/pdf",
+ },
+ },
+ )
+ .run();
+ }, [editor, src, getPos]);
+
+ const handleDelete = useCallback(() => {
+ const pos = getPos();
+ if (pos === undefined) return;
+ editor.commands.setNodeSelection(pos);
+ editor.commands.deleteSelection();
+ }, [editor, getPos]);
+
+ if (!src || !safeSrc) {
+ return (
+
+
+
+
+
+ {placeholder?.name
+ ? t("Uploading {{name}}", { name: placeholder.name })
+ : t("Uploading file")}
+
+
+
+
+ );
+ }
+
+ if (hasError) {
+ return (
+
+
+
+
+ {t("Failed to load PDF")}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/client/src/features/editor/components/pdf/upload-pdf-action.tsx b/apps/client/src/features/editor/components/pdf/upload-pdf-action.tsx
new file mode 100644
index 00000000..31a56f5b
--- /dev/null
+++ b/apps/client/src/features/editor/components/pdf/upload-pdf-action.tsx
@@ -0,0 +1,36 @@
+import { handlePdfUpload } from "@docmost/editor-ext";
+import { uploadFile } from "@/features/page/services/page-service.ts";
+import { notifications } from "@mantine/notifications";
+import { getFileUploadSizeLimit } from "@/lib/config.ts";
+import { formatBytes } from "@/lib";
+import i18n from "@/i18n.ts";
+
+export const uploadPdfAction = handlePdfUpload({
+ onUpload: async (file: File, pageId: string): Promise
=> {
+ try {
+ return await uploadFile(file, pageId);
+ } catch (err) {
+ notifications.show({
+ color: "red",
+ message: err?.response.data.message,
+ });
+ throw err;
+ }
+ },
+ validateFn: (file) => {
+ if (file.type !== "application/pdf") {
+ return false;
+ }
+
+ if (file.size > getFileUploadSizeLimit()) {
+ notifications.show({
+ color: "red",
+ message: i18n.t("File exceeds the {{limit}} attachment limit", {
+ limit: formatBytes(getFileUploadSizeLimit()),
+ }),
+ });
+ return false;
+ }
+ return true;
+ },
+});
diff --git a/apps/client/src/features/editor/components/slash-menu/menu-items.ts b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
index d31bdb18..58228ed9 100644
--- a/apps/client/src/features/editor/components/slash-menu/menu-items.ts
+++ b/apps/client/src/features/editor/components/slash-menu/menu-items.ts
@@ -12,7 +12,9 @@ import {
IconMath,
IconMathFunction,
IconMovie,
+ IconMusic,
IconPaperclip,
+ IconFileTypePdf,
IconPhoto,
IconTable,
IconTypography,
@@ -30,7 +32,9 @@ import {
} from "@/features/editor/components/slash-menu/types";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
+import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
+import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-action.tsx";
import IconExcalidraw from "@/components/icons/icon-excalidraw";
import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio";
@@ -161,7 +165,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Image",
description: "Upload any image from your device.",
- searchTerms: ["photo", "picture", "media"],
+ searchTerms: ["photo", "picture", "media", "file", "attachment"],
icon: IconPhoto,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -194,7 +198,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Video",
description: "Upload any video from your device.",
- searchTerms: ["video", "mp4", "media"],
+ searchTerms: ["video", "mp4", "media", "file", "attachment"],
icon: IconMovie,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -224,10 +228,74 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.click();
},
},
+ {
+ title: "Audio",
+ description: "Upload any audio from your device.",
+ searchTerms: ["audio", "music", "sound", "mp3", "media", "file", "attachment"],
+ icon: IconMusic,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).run();
+
+ // @ts-ignore
+ const pageId = editor.storage?.pageId;
+ if (!pageId) return;
+
+ // upload audio
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "audio/*";
+ input.multiple = true;
+ input.style.display = "none";
+ document.body.appendChild(input);
+ input.onchange = async () => {
+ if (input.files?.length) {
+ for (const file of input.files) {
+ const pos = editor.view.state.selection.from;
+
+ uploadAudioAction(file, editor, pos, pageId);
+ }
+ }
+
+ input.remove();
+ };
+ input.click();
+ },
+ },
+ {
+ title: "Embed PDF",
+ description: "Upload and embed a PDF file.",
+ searchTerms: ["pdf", "document", "embed"],
+ icon: IconFileTypePdf,
+ command: ({ editor, range }) => {
+ editor.chain().focus().deleteRange(range).run();
+
+ // @ts-ignore
+ const pageId = editor.storage?.pageId;
+ if (!pageId) return;
+
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = "application/pdf";
+ input.style.display = "none";
+ document.body.appendChild(input);
+ input.onchange = async () => {
+ if (input.files?.length) {
+ for (const file of input.files) {
+ const pos = editor.view.state.selection.from;
+
+ uploadPdfAction(file, editor, pos, pageId);
+ }
+ }
+
+ input.remove();
+ };
+ input.click();
+ },
+ },
{
title: "File attachment",
description: "Upload any file from your device.",
- searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
+ searchTerms: ["file", "attachment", "upload", "csv", "zip"],
icon: IconPaperclip,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -359,7 +427,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
editor.chain().focus().deleteRange(range).setDrawio().run(),
},
{
- title: "Excalidraw diagram",
+ title: "Excalidraw (Whiteboard)",
description: "Draw and sketch excalidraw diagrams",
searchTerms: ["diagrams", "draw", "sketch", "whiteboard"],
icon: IconExcalidraw,
@@ -548,7 +616,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "YouTube",
description: "Embed YouTube video",
- searchTerms: ["youtube", "yt"],
+ searchTerms: ["youtube", "yt", "media", "video"],
icon: YoutubeIcon,
command: ({ editor, range }: CommandProps) => {
editor
@@ -647,7 +715,11 @@ export const getSuggestionItems = ({
});
if (filteredItems.length) {
- filteredGroups[group] = filteredItems;
+ filteredGroups[group] = filteredItems.sort((a, b) => {
+ const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
+ const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
+ return aTitle - bTitle;
+ });
}
}
diff --git a/apps/client/src/features/editor/components/video/video-view.module.css b/apps/client/src/features/editor/components/video/video-view.module.css
index 8d1d28c2..f1a51846 100644
--- a/apps/client/src/features/editor/components/video/video-view.module.css
+++ b/apps/client/src/features/editor/components/video/video-view.module.css
@@ -5,6 +5,9 @@
max-width: 100%;
border-radius: 8px;
overflow: hidden;
+}
+
+.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
@@ -26,6 +29,7 @@
}
}
}
+
.video {
display: block;
width: 100%;
diff --git a/apps/client/src/features/editor/components/video/video-view.tsx b/apps/client/src/features/editor/components/video/video-view.tsx
index e2473afc..1e662640 100644
--- a/apps/client/src/features/editor/components/video/video-view.tsx
+++ b/apps/client/src/features/editor/components/video/video-view.tsx
@@ -33,6 +33,7 @@ export default function VideoView(props: NodeViewProps) {
className={clsx(
selected && "ProseMirror-selectednode",
classes.videoWrapper,
+ !src && classes.skeleton,
alignClass,
)}
style={{
diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts
index 0532156b..37f173dc 100644
--- a/apps/client/src/features/editor/extensions/extensions.ts
+++ b/apps/client/src/features/editor/extensions/extensions.ts
@@ -30,6 +30,7 @@ import {
TiptapImage,
Callout,
TiptapVideo,
+ TiptapAudio,
LinkExtension,
Selection,
Attachment,
@@ -37,6 +38,7 @@ import {
Drawio,
Excalidraw,
Embed,
+ TiptapPdf,
SearchAndReplace,
Mention,
TableDndExtension,
@@ -68,11 +70,13 @@ import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import StatusView from "@/features/editor/components/status/status-view.tsx";
import VideoView from "@/features/editor/components/video/video-view.tsx";
+import AudioView from "@/features/editor/components/audio/audio-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
+import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext";
@@ -269,6 +273,9 @@ export const mainExtensions = [
className: buildResizeClasses("node-video"),
},
}),
+ TiptapAudio.configure({
+ view: AudioView,
+ }),
Callout.configure({
view: CalloutView,
}),
@@ -313,6 +320,9 @@ export const mainExtensions = [
Embed.configure({
view: EmbedView,
}),
+ TiptapPdf.configure({
+ view: PdfView,
+ }),
Subpages.configure({
view: SubpagesView,
}),
diff --git a/apps/client/src/features/editor/page-editor.tsx b/apps/client/src/features/editor/page-editor.tsx
index a226d3d2..d51a5a4c 100644
--- a/apps/client/src/features/editor/page-editor.tsx
+++ b/apps/client/src/features/editor/page-editor.tsx
@@ -45,6 +45,7 @@ import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
+import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import {
handleFileDrop,
@@ -414,6 +415,7 @@ export default function PageEditor({
+
diff --git a/apps/client/src/features/editor/styles/core.css b/apps/client/src/features/editor/styles/core.css
index 1f8861f3..34ddaca3 100644
--- a/apps/client/src/features/editor/styles/core.css
+++ b/apps/client/src/features/editor/styles/core.css
@@ -133,10 +133,18 @@
border-top: 1px solid #68cef8;
}
+ &[contenteditable="false"] hr.ProseMirror-selectednode {
+ border-top: none;
+ }
+
.ProseMirror-selectednode {
outline: 2px solid #70cff8;
}
+ &[contenteditable="false"] .ProseMirror-selectednode {
+ outline: none;
+ }
+
& > .react-renderer {
margin-top: var(--mantine-spacing-sm);
margin-bottom: var(--mantine-spacing-sm);
diff --git a/apps/client/src/features/editor/styles/media.css b/apps/client/src/features/editor/styles/media.css
index ac9cf3f7..0b02cdbe 100644
--- a/apps/client/src/features/editor/styles/media.css
+++ b/apps/client/src/features/editor/styles/media.css
@@ -8,7 +8,7 @@
}
}
- .node-image, .node-video, .node-excalidraw, .node-drawio {
+ .node-image, .node-video, .node-pdf, .node-excalidraw, .node-drawio {
&.ProseMirror-selectednode {
outline: none;
}
@@ -37,5 +37,28 @@
font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md);
}
+
+ .media-pulse {
+ animation: media-pulse 1.2s ease-in-out infinite;
+
+ @mixin light {
+ background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
+ background-size: 400% 400%;
+ }
+
+ @mixin dark {
+ background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
+ background-size: 400% 400%;
+ }
+
+ @keyframes media-pulse {
+ 0% {
+ background-position: 0% 0%;
+ }
+ 100% {
+ background-position: -135% 0%;
+ }
+ }
+ }
}
diff --git a/apps/client/src/i18n.ts b/apps/client/src/i18n.ts
index 2f240dcf..d5c8a99e 100644
--- a/apps/client/src/i18n.ts
+++ b/apps/client/src/i18n.ts
@@ -14,6 +14,7 @@ i18n
.init({
fallbackLng: "en-US",
debug: false,
+ showSupportNotice: false,
load: 'currentOnly',
interpolation: {
diff --git a/apps/server/src/collaboration/collaboration.util.ts b/apps/server/src/collaboration/collaboration.util.ts
index 9fa2f7a6..6cfed052 100644
--- a/apps/server/src/collaboration/collaboration.util.ts
+++ b/apps/server/src/collaboration/collaboration.util.ts
@@ -24,6 +24,8 @@ import {
CustomTable,
TiptapImage,
TiptapVideo,
+ TiptapAudio,
+ TiptapPdf,
TrailingNode,
Attachment,
Drawio,
@@ -86,6 +88,8 @@ export const tiptapExtensions = [
Youtube,
TiptapImage,
TiptapVideo,
+ TiptapAudio,
+ TiptapPdf,
Callout,
Attachment,
CustomCodeBlock,
diff --git a/apps/server/src/common/helpers/prosemirror/utils.ts b/apps/server/src/common/helpers/prosemirror/utils.ts
index 424cd787..7704306e 100644
--- a/apps/server/src/common/helpers/prosemirror/utils.ts
+++ b/apps/server/src/common/helpers/prosemirror/utils.ts
@@ -102,6 +102,8 @@ export function isAttachmentNode(nodeType: string) {
'attachment',
'image',
'video',
+ 'audio',
+ 'pdf',
'excalidraw',
'drawio',
];
diff --git a/apps/server/src/core/attachment/attachment.constants.ts b/apps/server/src/core/attachment/attachment.constants.ts
index 7fb7e126..a9bce5c1 100644
--- a/apps/server/src/core/attachment/attachment.constants.ts
+++ b/apps/server/src/core/attachment/attachment.constants.ts
@@ -15,4 +15,9 @@ export const inlineFileExtensions = [
'.pdf',
'.mp4',
'.mov',
+ '.mp3',
+ '.wav',
+ '.ogg',
+ '.m4a',
+ '.webm',
];
diff --git a/apps/server/src/core/attachment/attachment.controller.ts b/apps/server/src/core/attachment/attachment.controller.ts
index d70f0034..6382b34e 100644
--- a/apps/server/src/core/attachment/attachment.controller.ts
+++ b/apps/server/src/core/attachment/attachment.controller.ts
@@ -457,6 +457,10 @@ export class AttachmentController {
const rangeHeader = req.headers.range;
res.header('Accept-Ranges', 'bytes');
+ res.header(
+ 'Content-Security-Policy',
+ "base-uri 'none'; object-src 'self'; default-src 'self';",
+ );
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
diff --git a/apps/server/src/integrations/import/services/import-attachment.service.ts b/apps/server/src/integrations/import/services/import-attachment.service.ts
index a797ecc6..3c14d854 100644
--- a/apps/server/src/integrations/import/services/import-attachment.service.ts
+++ b/apps/server/src/integrations/import/services/import-attachment.service.ts
@@ -190,13 +190,32 @@ export class ImportAttachmentService {
}
}
+ // Build a map from resolved archive path → real filename from Confluence
+ // metadata. Confluence Server archives often store files under numeric IDs
+ // (e.g. "attachments/65601/65602") instead of the original filename.
+ const pageDir = path.dirname(pageRelativePath);
+ const attachmentNameByRelPath = new Map();
+ for (const attachment of pageAttachments) {
+ const relPath = resolveRelativeAttachmentPath(
+ attachment.href,
+ pageDir,
+ attachmentCandidates,
+ );
+ if (relPath && attachment.fileName) {
+ attachmentNameByRelPath.set(relPath, attachment.fileName);
+ }
+ }
+
const uploadOnce = (relPath: string) => {
const abs = attachmentCandidates.get(relPath)!;
const attachmentId = v7();
- const ext = path.extname(abs);
+
+ const realName = attachmentNameByRelPath.get(relPath);
+ const baseName = realName || path.basename(abs);
+ const ext = path.extname(baseName);
const fileNameWithExt =
- sanitizeFileName(path.basename(abs, ext)) + ext.toLowerCase();
+ sanitizeFileName(path.basename(baseName, ext)) + ext.toLowerCase();
const storageFilePath = `${getAttachmentFolderPath(
AttachmentType.File,
@@ -240,7 +259,6 @@ export class ImportAttachmentService {
return fresh;
};
- const pageDir = path.dirname(pageRelativePath);
const $ = load(html);
// image
@@ -335,6 +353,28 @@ export class ImportAttachmentService {
unwrapFromParagraph($, $vid);
}
+ // audio
+ for (const audEl of $('audio').toArray()) {
+ const $aud = $(audEl);
+ const src = cleanUrlString($aud.attr('src') ?? '')!;
+ if (!src || src.startsWith('http')) continue;
+
+ const relPath = resolveRelativeAttachmentPath(
+ src,
+ pageDir,
+ attachmentCandidates,
+ );
+ if (!relPath) continue;
+
+ const { attachmentId, apiFilePath } = processFile(relPath);
+
+ $aud
+ .attr('src', apiFilePath)
+ .attr('data-attachment-id', attachmentId);
+
+ unwrapFromParagraph($, $aud);
+ }
+
//
for (const el of $('div[data-type="attachment"]').toArray()) {
const $oldDiv = $(el);
@@ -401,7 +441,18 @@ export class ImportAttachmentService {
const { attachmentId, apiFilePath, abs } = processFile(relPath);
const ext = path.extname(relPath).toLowerCase();
- if (ext === '.mp4') {
+ const audioExtensions = new Set(['.mp3', '.wav', '.ogg', '.m4a', '.webm', '.flac', '.aac']);
+
+ if (ext === '.pdf') {
+ const $pdf = $('
')
+ .attr('data-type', 'pdf')
+ .attr('src', apiFilePath)
+ .attr('data-attachment-id', attachmentId)
+ .attr('width', '800')
+ .attr('height', '600');
+ $a.replaceWith($pdf);
+ unwrapFromParagraph($, $pdf);
+ } else if (ext === '.mp4') {
const $video = $('
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
From aa27d576243b4a8d8dd24526b68cd03543a73754 Mon Sep 17 00:00:00 2001
From: Julien Fontanet
Date: Sat, 28 Mar 2026 21:23:21 +0100
Subject: [PATCH 35/57] fix: notification items are now real links (#2039)
Replace UnstyledButton with UnstyledButton component={Link} so each
notification renders as a real anchor element. Regular left-clicks use
SPA navigation and close the popover; Ctrl/Cmd/middle-click open the
page in a new tab. All click types mark the notification as read.
---
.../components/notification-item.tsx | 38 +++++++++++--------
.../notification/notification.module.css | 1 +
2 files changed, 23 insertions(+), 16 deletions(-)
diff --git a/apps/client/src/features/notification/components/notification-item.tsx b/apps/client/src/features/notification/components/notification-item.tsx
index 0d7db515..0ef81e44 100644
--- a/apps/client/src/features/notification/components/notification-item.tsx
+++ b/apps/client/src/features/notification/components/notification-item.tsx
@@ -13,7 +13,7 @@ import {
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { INotification } from "../types/notification.types";
import { Trans, useTranslation } from "react-i18next";
-import { useNavigate } from "react-router-dom";
+import { Link } from "react-router-dom";
import { useState } from "react";
import { useMarkReadMutation } from "../queries/notification-query";
import { buildPageUrl } from "@/features/page/page.utils";
@@ -30,7 +30,6 @@ export function NotificationItem({
onNavigate,
}: NotificationItemProps) {
const { t } = useTranslation();
- const navigate = useNavigate();
const markRead = useMarkReadMutation();
const [hovered, setHovered] = useState(false);
@@ -55,32 +54,39 @@ export function NotificationItem({
}
};
- const handleClick = () => {
- if (notification.page && notification.space) {
- if (isUnread) {
- markRead.mutate([notification.id]);
- }
- navigate(
- buildPageUrl(
+ const pageUrl =
+ notification.page && notification.space
+ ? buildPageUrl(
notification.space.slug,
notification.page.slugId,
notification.page.title,
- ),
- );
- onNavigate();
- }
- };
+ )
+ : undefined;
- const handleMarkRead = (e: React.MouseEvent) => {
- e.stopPropagation();
+ const markReadIfNeeded = () => {
if (isUnread) {
markRead.mutate([notification.id]);
}
};
+ const handleClick = () => {
+ markReadIfNeeded();
+ onNavigate();
+ };
+
+ const handleMarkRead = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ markReadIfNeeded();
+ };
+
return (
e.button === 1 && markReadIfNeeded()}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
w="100%"
diff --git a/apps/client/src/features/notification/notification.module.css b/apps/client/src/features/notification/notification.module.css
index 8a80bcf8..54e790cc 100644
--- a/apps/client/src/features/notification/notification.module.css
+++ b/apps/client/src/features/notification/notification.module.css
@@ -1,4 +1,5 @@
.notificationItem {
+ display: block;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
From cbdb37ed0a5ce3dbafd89a8f43251ab082f046fa Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Sat, 28 Mar 2026 20:29:06 +0000
Subject: [PATCH 36/57] New Crowdin updates (#2061)
---
.../public/locales/de-DE/translation.json | 10 ++++++++++
.../public/locales/en-US/translation.json | 18 +++++++++---------
.../public/locales/es-ES/translation.json | 10 ++++++++++
.../public/locales/fr-FR/translation.json | 12 +++++++++++-
.../public/locales/it-IT/translation.json | 10 ++++++++++
.../public/locales/ja-JP/translation.json | 10 ++++++++++
.../public/locales/ko-KR/translation.json | 10 ++++++++++
.../public/locales/nl-NL/translation.json | 10 ++++++++++
.../public/locales/pt-BR/translation.json | 10 ++++++++++
.../public/locales/ru-RU/translation.json | 10 ++++++++++
.../public/locales/uk-UA/translation.json | 10 ++++++++++
.../public/locales/zh-CN/translation.json | 10 ++++++++++
12 files changed, 120 insertions(+), 10 deletions(-)
diff --git a/apps/client/public/locales/de-DE/translation.json b/apps/client/public/locales/de-DE/translation.json
index feb8b46b..437181d4 100644
--- a/apps/client/public/locales/de-DE/translation.json
+++ b/apps/client/public/locales/de-DE/translation.json
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Horizontale Trennlinie einfügen",
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
+ "Upload any audio from your device.": "Laden Sie beliebige Audiodateien von Ihrem Gerät hoch.",
"Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
"Uploading {{name}}": "Lade {{name}} hoch",
"Uploading file": "Datei wird hochgeladen",
@@ -351,6 +352,12 @@
"Divider": "Trennlinie",
"Quote": "Zitat",
"Image": "Bild",
+ "Audio": "Audio.",
+ "Embed PDF": "PDF einbetten",
+ "Upload and embed a PDF file.": "Laden Sie eine PDF-Datei hoch und betten Sie sie ein.",
+ "Embed as PDF": "Als PDF einbetten",
+ "Failed to load PDF": "Fehler beim Laden der PDF",
+ "Convert to attachment": "In Anhang umwandeln",
"File attachment": "Dateianhang",
"Toggle block": "Block umschalten",
"Callout": "Hinweisbox",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Verhindern Sie, dass Mitglieder Seiten öffentlich teilen.",
"Toggle public sharing": "Öffentliches Teilen umschalten",
"Toggle space public sharing": "Öffentliches Teilen im Bereich umschalten",
+ "Allow viewers to comment": "Zuschauern erlauben, Kommentare zu hinterlassen",
+ "Allow viewers to add comments on pages in this space.": "Erlauben Sie Zuschauern, Kommentare auf Seiten in diesem Bereich hinzuzufügen.",
+ "Toggle viewer comments": "Zuschauerkommentare umschalten",
"Public sharing is disabled at the workspace level": "Öffentliches Teilen ist auf der Arbeitsbereichsebene deaktiviert",
"Prevent pages in this space from being shared publicly.": "Verhindern Sie, dass Seiten in diesem Bereich öffentlich geteilt werden.",
"Page permissions": "Seitenberechtigungen",
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index b0dd7d53..97a5abb3 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -184,20 +184,20 @@
"Updated successfully": "Updated successfully.",
"User": "User.",
"Workspace": "Workspace.",
- "Workspace Name": "Workspace Name.",
+ "Workspace Name": "Workspace name.",
"Workspace settings": "Workspace settings.",
"You can change your password here.": "You can change your password here.",
- "Your Email": "Your Email.",
+ "Your Email": "Your email.",
"Your import is complete.": "Your import is complete.",
"Your name": "Your name.",
- "Your Name": "Your Name.",
+ "Your Name": "Your name.",
"Your password": "Your password.",
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
"Sidebar toggle": "Sidebar toggle.",
"Comments": "Comments.",
"404 page not found": "404 page not found.",
"Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.",
- "Take me back to homepage": "Take me back to homepage.",
+ "Take me back to homepage": "Take me back to the homepage.",
"Forgot password": "Forgot password.",
"Forgot your password?": "Forgot your password?",
"A password reset link has been sent to your email. Please check your inbox.": "A password reset link has been sent to your email. Please check your inbox.",
@@ -223,12 +223,12 @@
"Failed to delete comment": "Failed to delete comment",
"Comment resolved successfully": "Comment resolved successfully",
"Comment re-opened successfully": "Comment re-opened successfully.",
- "Comment unresolved successfully": "Comment unresolved successfully.",
+ "Comment unresolved successfully": "Comment marked as unresolved successfully.",
"Failed to resolve comment": "Failed to resolve comment",
"Resolve comment": "Resolve comment.",
- "Unresolve comment": "Unresolve comment.",
- "Resolve Comment Thread": "Resolve Comment Thread.",
- "Unresolve Comment Thread": "Unresolve Comment Thread.",
+ "Unresolve comment": "Mark comment as unresolved.",
+ "Resolve Comment Thread": "Resolve comment thread.",
+ "Unresolve Comment Thread": "Mark comment thread as unresolved.",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Are you sure you want to resolve this comment thread? This will mark it as completed.",
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
"Resolved": "Resolved.",
@@ -370,7 +370,7 @@
"Insert mermaid diagram": "Insert mermaid diagram.",
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams.",
"Insert current date": "Insert current date.",
- "Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams.",
+ "Draw and sketch excalidraw diagrams": "Draw and sketch Excalidraw diagrams.",
"Multiple": "Multiple.",
"Turn into": "Turn into",
"Text align": "Text align",
diff --git a/apps/client/public/locales/es-ES/translation.json b/apps/client/public/locales/es-ES/translation.json
index 797523d7..2b6e88da 100644
--- a/apps/client/public/locales/es-ES/translation.json
+++ b/apps/client/public/locales/es-ES/translation.json
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insertar regla horizontal",
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
+ "Upload any audio from your device.": "Sube cualquier audio desde tu dispositivo.",
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
"Uploading {{name}}": "Subiendo {{name}}",
"Uploading file": "Subiendo archivo",
@@ -351,6 +352,12 @@
"Divider": "Divisor",
"Quote": "Cita",
"Image": "Imagen",
+ "Audio": "Audio.",
+ "Embed PDF": "Adjuntar PDF",
+ "Upload and embed a PDF file.": "Sube y adjunta un archivo PDF.",
+ "Embed as PDF": "Adjuntar como PDF",
+ "Failed to load PDF": "Error al cargar el PDF",
+ "Convert to attachment": "Convertir en adjunto",
"File attachment": "Adjunto de archivo",
"Toggle block": "Alternar bloque",
"Callout": "Aviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Evitar que los miembros compartan páginas públicamente.",
"Toggle public sharing": "Alternar el uso compartido público",
"Toggle space public sharing": "Alternar el uso compartido público del espacio",
+ "Allow viewers to comment": "Permitir que los espectadores comenten",
+ "Allow viewers to add comments on pages in this space.": "Permitir que los espectadores agreguen comentarios en las páginas de este espacio.",
+ "Toggle viewer comments": "Activar/desactivar comentarios de los espectadores",
"Public sharing is disabled at the workspace level": "El uso compartido público está desactivado a nivel de espacio de trabajo",
"Prevent pages in this space from being shared publicly.": "Evitar que las páginas en este espacio se compartan públicamente.",
"Page permissions": "Permisos de la página},{",
diff --git a/apps/client/public/locales/fr-FR/translation.json b/apps/client/public/locales/fr-FR/translation.json
index d3fdf33f..288dfe84 100644
--- a/apps/client/public/locales/fr-FR/translation.json
+++ b/apps/client/public/locales/fr-FR/translation.json
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insérer un séparateur de règle horizontale",
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
+ "Upload any audio from your device.": "Téléchargez n'importe quel fichier audio depuis votre appareil.",
"Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
"Uploading {{name}}": "Téléchargement de {{name}}",
"Uploading file": "Téléchargement du fichier",
@@ -351,6 +352,12 @@
"Divider": "Diviseur",
"Quote": "Citation",
"Image": "Image",
+ "Audio": "Audio.",
+ "Embed PDF": "Intégrer un PDF",
+ "Upload and embed a PDF file.": "Téléchargez et intégrez un fichier PDF.",
+ "Embed as PDF": "Intégrer comme PDF",
+ "Failed to load PDF": "Échec du chargement du PDF",
+ "Convert to attachment": "Convertir en pièce jointe",
"File attachment": "Pièce jointe",
"Toggle block": "Basculer le bloc",
"Callout": "Appel",
@@ -415,7 +422,7 @@
"Move page": "Déplacer la page",
"Move page to a different space.": "Déplacer la page vers un autre espace.",
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
- "Table of contents": "",
+ "Table of contents": "Table des matières.",
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.",
"Share": "Partager",
"Public sharing": "Partage public",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Empêcher les membres de partager des pages publiquement.",
"Toggle public sharing": "Basculer le partage public",
"Toggle space public sharing": "Basculer le partage public de l'espace",
+ "Allow viewers to comment": "Autoriser les spectateurs à commenter",
+ "Allow viewers to add comments on pages in this space.": "Autoriser les spectateurs à ajouter des commentaires sur les pages de cet espace.",
+ "Toggle viewer comments": "Basculer les commentaires des spectateurs",
"Public sharing is disabled at the workspace level": "Le partage public est désactivé au niveau de l'espace de travail",
"Prevent pages in this space from being shared publicly.": "Empêcher les pages de cet espace d'être partagées publiquement.",
"Page permissions": "Autorisations de la page",
diff --git a/apps/client/public/locales/it-IT/translation.json b/apps/client/public/locales/it-IT/translation.json
index fdcaa8d1..f69650f0 100644
--- a/apps/client/public/locales/it-IT/translation.json
+++ b/apps/client/public/locales/it-IT/translation.json
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Inserisci divisore di regola orizzontale",
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
+ "Upload any audio from your device.": "Carica qualsiasi audio dal tuo dispositivo.",
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
"Uploading {{name}}": "Caricamento di {{name}}",
"Uploading file": "Caricamento file",
@@ -351,6 +352,12 @@
"Divider": "Divisore",
"Quote": "Preventivo",
"Image": "Immagine",
+ "Audio": "Audio.",
+ "Embed PDF": "Incorpora PDF",
+ "Upload and embed a PDF file.": "Carica e incorpora un file PDF.",
+ "Embed as PDF": "Incorpora come PDF",
+ "Failed to load PDF": "Caricamento del PDF non riuscito",
+ "Convert to attachment": "Converti in allegato",
"File attachment": "Allegato file",
"Toggle block": "Attiva blocco",
"Callout": "Avviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.",
"Toggle public sharing": "Attiva/disattiva la condivisione pubblica",
"Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio",
+ "Allow viewers to comment": "Consenti agli utenti di commentare",
+ "Allow viewers to add comments on pages in this space.": "Consenti agli utenti di aggiungere commenti alle pagine in questo spazio.",
+ "Toggle viewer comments": "Attiva/disattiva i commenti degli utenti",
"Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro",
"Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.",
"Page permissions": "Autorizzazioni della pagina.",
diff --git a/apps/client/public/locales/ja-JP/translation.json b/apps/client/public/locales/ja-JP/translation.json
index b14adb53..1d913114 100644
--- a/apps/client/public/locales/ja-JP/translation.json
+++ b/apps/client/public/locales/ja-JP/translation.json
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "区切り線を挿入します",
"Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします",
+ "Upload any audio from your device.": "デバイスから音声ファイルをアップロードします。",
"Upload any file from your device.": "デバイスからファイルをアップロードします",
"Uploading {{name}}": "{{name}} をアップロード中",
"Uploading file": "ファイルをアップロード中",
@@ -351,6 +352,12 @@
"Divider": "区切り線",
"Quote": "引用",
"Image": "画像",
+ "Audio": "音声。",
+ "Embed PDF": "PDFを埋め込む",
+ "Upload and embed a PDF file.": "PDFファイルをアップロードして埋め込みます。",
+ "Embed as PDF": "PDFとして埋め込む",
+ "Failed to load PDF": "PDFの読み込みに失敗しました",
+ "Convert to attachment": "添付ファイルに変換",
"File attachment": "ファイル添付",
"Toggle block": "ブロックを切り替える",
"Callout": "コールアウト",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。",
"Toggle public sharing": "公開共有を切り替える",
"Toggle space public sharing": "スペースの公開共有を切り替える",
+ "Allow viewers to comment": "閲覧者によるコメントを許可",
+ "Allow viewers to add comments on pages in this space.": "このスペース内のページに閲覧者がコメントを追加できるようにします。",
+ "Toggle viewer comments": "閲覧者コメントの切り替え",
"Public sharing is disabled at the workspace level": "ワークスペースレベルで公開共有が無効になっています",
"Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。",
"Page permissions": "ページのアクセス権",
diff --git a/apps/client/public/locales/ko-KR/translation.json b/apps/client/public/locales/ko-KR/translation.json
index 8bc26c55..24186a6f 100644
--- a/apps/client/public/locales/ko-KR/translation.json
+++ b/apps/client/public/locales/ko-KR/translation.json
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "가로 구분선 삽입",
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
+ "Upload any audio from your device.": "기기에서 오디오를 업로드하세요.",
"Upload any file from your device.": "기기에서 파일을 업로드하세요.",
"Uploading {{name}}": "{{name}} 업로드 중",
"Uploading file": "파일 업로드 중",
@@ -351,6 +352,12 @@
"Divider": "구분선",
"Quote": "인용",
"Image": "이미지",
+ "Audio": "오디오.",
+ "Embed PDF": "PDF 임베드",
+ "Upload and embed a PDF file.": "PDF 파일을 업로드하고 임베드하세요.",
+ "Embed as PDF": "PDF로 임베드",
+ "Failed to load PDF": "PDF 로드 실패",
+ "Convert to attachment": "첨부 파일로 변환",
"File attachment": "파일 첨부",
"Toggle block": "블록 토글",
"Callout": "경고 상자",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.",
"Toggle public sharing": "공유 전환",
"Toggle space public sharing": "공간 공유 전환",
+ "Allow viewers to comment": "뷰어가 댓글을 달 수 있도록 허용",
+ "Allow viewers to add comments on pages in this space.": "이 공간 내 페이지에 뷰어가 댓글을 추가할 수 있도록 허용합니다.",
+ "Toggle viewer comments": "뷰어 댓글 전환",
"Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.",
"Page permissions": "페이지 권한},{",
diff --git a/apps/client/public/locales/nl-NL/translation.json b/apps/client/public/locales/nl-NL/translation.json
index d6eb9d5e..f1349d0f 100644
--- a/apps/client/public/locales/nl-NL/translation.json
+++ b/apps/client/public/locales/nl-NL/translation.json
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Horizontale lijn invoegen",
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
+ "Upload any audio from your device.": "Upload een audio vanaf uw apparaat.",
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
"Uploading {{name}}": "Uploaden {{name}}",
"Uploading file": "Bestand uploaden",
@@ -351,6 +352,12 @@
"Divider": "Scheidingslijn",
"Quote": "Quote",
"Image": "Afbeelding",
+ "Audio": "Audio.",
+ "Embed PDF": "PDF insluiten",
+ "Upload and embed a PDF file.": "Upload en sluit een PDF-bestand in.",
+ "Embed as PDF": "Insluiten als PDF",
+ "Failed to load PDF": "Laden van PDF mislukt",
+ "Convert to attachment": "Converteren naar bijlage",
"File attachment": "Bestand bijlage",
"Toggle block": "Schakel blok in/uit",
"Callout": "Opmerking",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Voorkom dat leden pagina's openbaar delen.",
"Toggle public sharing": "Wissel openbaar delen",
"Toggle space public sharing": "Wissel openbaar delen van ruimte",
+ "Allow viewers to comment": "Toestaan dat kijkers reageren",
+ "Allow viewers to add comments on pages in this space.": "Sta kijkers toe om reacties toe te voegen op pagina\u0019s in deze ruimte.",
+ "Toggle viewer comments": "Reacties van kijkers in- of uitschakelen",
"Public sharing is disabled at the workspace level": "Openbaar delen is uitgeschakeld op werkruimteniveau",
"Prevent pages in this space from being shared publicly.": "Voorkom dat pagina's in deze ruimte openbaar worden gedeeld.",
"Page permissions": "Pagina rechten",
diff --git a/apps/client/public/locales/pt-BR/translation.json b/apps/client/public/locales/pt-BR/translation.json
index 94750861..04ae0830 100644
--- a/apps/client/public/locales/pt-BR/translation.json
+++ b/apps/client/public/locales/pt-BR/translation.json
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insira um divisor horizontal",
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
+ "Upload any audio from your device.": "Envie qualquer áudio do seu dispositivo.",
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
"Uploading {{name}}": "Enviando {{name}}",
"Uploading file": "Enviando arquivo",
@@ -351,6 +352,12 @@
"Divider": "Divisor",
"Quote": "Citação",
"Image": "Imagem",
+ "Audio": "Áudio.",
+ "Embed PDF": "Incorporar PDF",
+ "Upload and embed a PDF file.": "Envie e incorpore um arquivo PDF.",
+ "Embed as PDF": "Incorporar como PDF",
+ "Failed to load PDF": "Falha ao carregar PDF",
+ "Convert to attachment": "Converter em anexo",
"File attachment": "Anexo de arquivo",
"Toggle block": "Bloco colapsável",
"Callout": "Aviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.",
"Toggle public sharing": "Alternar compartilhamento público",
"Toggle space public sharing": "Alternar compartilhamento público do espaço",
+ "Allow viewers to comment": "Permitir que os visualizadores comentem",
+ "Allow viewers to add comments on pages in this space.": "Permitir que os visualizadores adicionem comentários em páginas deste espaço.",
+ "Toggle viewer comments": "Ativar/desativar comentários de visualizadores",
"Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho",
"Prevent pages in this space from being shared publicly.": "Impedir que as páginas neste espaço sejam compartilhadas publicamente.",
"Page permissions": "Permissões da página},{",
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index f602bdd6..02a4b861 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Вставить горизонтальный разделитель",
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
+ "Upload any audio from your device.": "Загрузите любой аудиофайл с вашего устройства.",
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
"Uploading {{name}}": "Загрузка {{name}}",
"Uploading file": "Загрузка файла",
@@ -351,6 +352,12 @@
"Divider": "Разделитель",
"Quote": "Цитата",
"Image": "Изображение",
+ "Audio": "Аудио.",
+ "Embed PDF": "Встроить PDF",
+ "Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
+ "Embed as PDF": "Встроить как PDF",
+ "Failed to load PDF": "Не удалось загрузить PDF",
+ "Convert to attachment": "Преобразовать в вложение",
"File attachment": "Прикрепленный файл",
"Toggle block": "Сворачиваемый блок",
"Callout": "Выноска",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.",
"Toggle public sharing": "Переключить общий доступ",
"Toggle space public sharing": "Переключить общий доступ для пространства",
+ "Allow viewers to comment": "Разрешить зрителям комментировать",
+ "Allow viewers to add comments on pages in this space.": "Разрешить зрителям добавлять комментарии на страницах в этом пространстве.",
+ "Toggle viewer comments": "Переключить комментарии зрителей",
"Public sharing is disabled at the workspace level": "Общий доступ отключен на уровне рабочего пространства",
"Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.",
"Page permissions": "Права доступа к странице},{",
diff --git a/apps/client/public/locales/uk-UA/translation.json b/apps/client/public/locales/uk-UA/translation.json
index 96ec50a9..194cf161 100644
--- a/apps/client/public/locales/uk-UA/translation.json
+++ b/apps/client/public/locales/uk-UA/translation.json
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
+ "Upload any audio from your device.": "Завантажте будь-який аудіофайл зі свого пристрою.",
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
"Uploading {{name}}": "Завантаження {{name}}",
"Uploading file": "Завантаження файлу",
@@ -351,6 +352,12 @@
"Divider": "Роздільник",
"Quote": "Цитата",
"Image": "Зображення",
+ "Audio": "Аудіо.",
+ "Embed PDF": "Вбудувати PDF",
+ "Upload and embed a PDF file.": "Завантажте та вбудуйте файл PDF.",
+ "Embed as PDF": "Вбудувати як PDF",
+ "Failed to load PDF": "Не вдалося завантажити PDF",
+ "Convert to attachment": "Перетворити на вкладення",
"File attachment": "Прикріплений файл",
"Toggle block": "Блок, що згортається",
"Callout": "Виноска",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.",
"Toggle public sharing": "Перемикання публічного доступу",
"Toggle space public sharing": "Перемикання публічного доступу до просторів",
+ "Allow viewers to comment": "Дозволити глядачам коментувати",
+ "Allow viewers to add comments on pages in this space.": "Дозволити глядачам додавати коментарі на сторінках у цьому просторі.",
+ "Toggle viewer comments": "Увімкнути або вимкнути коментарі глядачів",
"Public sharing is disabled at the workspace level": "Публічний доступ вимкнуто на рівні робочого простору",
"Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.",
"Page permissions": "Права доступу до сторінки.",
diff --git a/apps/client/public/locales/zh-CN/translation.json b/apps/client/public/locales/zh-CN/translation.json
index 9ff28f8e..13f4f334 100644
--- a/apps/client/public/locales/zh-CN/translation.json
+++ b/apps/client/public/locales/zh-CN/translation.json
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "插入水平分割线",
"Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频",
+ "Upload any audio from your device.": "从您的设备上传任意音频文件。",
"Upload any file from your device.": "从设备上传任何文件",
"Uploading {{name}}": "正在上传{{name}}",
"Uploading file": "正在上传文件",
@@ -351,6 +352,12 @@
"Divider": "分割线",
"Quote": "引用",
"Image": "图像",
+ "Audio": "音频。",
+ "Embed PDF": "嵌入 PDF",
+ "Upload and embed a PDF file.": "上传并嵌入 PDF 文件。",
+ "Embed as PDF": "作为 PDF 嵌入",
+ "Failed to load PDF": "加载 PDF 失败",
+ "Convert to attachment": "转换为附件",
"File attachment": "文件附件",
"Toggle block": "切换块",
"Callout": "标注块",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "阻止成员公开分享页面。",
"Toggle public sharing": "切换公开分享",
"Toggle space public sharing": "切换空间公开分享",
+ "Allow viewers to comment": "允许观众评论",
+ "Allow viewers to add comments on pages in this space.": "允许观众在此空间的页面上添加评论。",
+ "Toggle viewer comments": "切换观众评论",
"Public sharing is disabled at the workspace level": "公开分享在工作区级别被禁用",
"Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。",
"Page permissions": "页面权限},{",
From ccb35517bb248e62c7f428b7746d259e71b8ca1c Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Sat, 28 Mar 2026 20:29:31 +0000
Subject: [PATCH 37/57] sync
---
apps/server/src/ee | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/server/src/ee b/apps/server/src/ee
index c70a29cb..f4867260 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit c70a29cb2532364d235f00409588448f9d995e6b
+Subproject commit f48672608889233c0247c6d4ef7fcddd29540315
From 642c92f7790977c0467212ed6289227988a55a3e Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Sat, 28 Mar 2026 20:34:44 +0000
Subject: [PATCH 38/57] fix select
---
apps/client/src/features/notification/notification.module.css | 1 +
1 file changed, 1 insertion(+)
diff --git a/apps/client/src/features/notification/notification.module.css b/apps/client/src/features/notification/notification.module.css
index 54e790cc..d56986ac 100644
--- a/apps/client/src/features/notification/notification.module.css
+++ b/apps/client/src/features/notification/notification.module.css
@@ -3,6 +3,7 @@
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
+ user-select: none;
}
.notificationItem:hover {
From a42ac3d45085ee85bc34fdbfaa6ed1b5b8407f81 Mon Sep 17 00:00:00 2001
From: Olivier Lambert
Date: Sat, 28 Mar 2026 23:26:47 +0100
Subject: [PATCH 39/57] fix: strip trailing whitespace-only paragraphs from
pasted content (#2050)
---
.../editor/extensions/markdown-clipboard.ts | 37 +++++++++++++++++--
1 file changed, 34 insertions(+), 3 deletions(-)
diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.ts
index e1e3707d..0d6ab263 100644
--- a/apps/client/src/features/editor/extensions/markdown-clipboard.ts
+++ b/apps/client/src/features/editor/extensions/markdown-clipboard.ts
@@ -1,7 +1,7 @@
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
-import { DOMParser } from "@tiptap/pm/model";
+import { DOMParser, Fragment, Slice } from "@tiptap/pm/model";
import { find } from "linkifyjs";
import { markdownToHtml } from "@docmost/editor-ext";
@@ -40,7 +40,7 @@ export const MarkdownClipboard = Extension.create({
const { tr } = view.state;
const { from, to } = view.state.selection;
- const html = markdownToHtml(text);
+ const html = markdownToHtml(text.replace(/\n+$/, ""));
const contentNodes = DOMParser.fromSchema(
this.editor.schema,
@@ -53,6 +53,37 @@ export const MarkdownClipboard = Extension.create({
view.dispatch(tr);
return true;
},
+ // Strip trailing whitespace-only paragraphs from pasted content.
+ // Terminals (GNOME Terminal, etc.) often include trailing
+ // whitespace in their HTML clipboard data, which ProseMirror
+ // parses as an extra paragraph. Inside a list item this creates
+ // an orphan empty line that breaks the list structure.
+ transformPasted: (slice) => {
+ let { content, openStart, openEnd } = slice;
+
+ // Remove trailing paragraphs that contain only whitespace
+ while (content.childCount > 1) {
+ const lastChild = content.lastChild;
+ if (
+ lastChild?.type.name === "paragraph" &&
+ lastChild.textContent.trim() === ""
+ ) {
+ const children = [];
+ for (let i = 0; i < content.childCount - 1; i++) {
+ children.push(content.child(i));
+ }
+ content = Fragment.from(children);
+ } else {
+ break;
+ }
+ }
+
+ if (content !== slice.content) {
+ return new Slice(content, openStart, Math.max(openEnd, 1));
+ }
+
+ return slice;
+ },
clipboardTextParser: (text, context, plainText) => {
const link = find(text, {
defaultProtocol: "http",
@@ -64,7 +95,7 @@ export const MarkdownClipboard = Extension.create({
return null;
}
- const parsed = markdownToHtml(text);
+ const parsed = markdownToHtml(text.replace(/\n+$/, ""));
return DOMParser.fromSchema(this.editor.schema).parseSlice(
elementFromString(parsed),
{
From 412962204c84e0899474a52a0a06a5e1d85d59d1 Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Sun, 29 Mar 2026 02:19:09 +0100
Subject: [PATCH 40/57] fix: editor fixes (#2067)
* autojoiner
* fix marked
* return clipboardTextSerializer as markdown
* fix clipboardTextSerializer for single lines
* cleanup two preceeding spaces in ordered lists item
* fix extra paragraph in task list
* don't zip sinple page exports
---
.../features/editor/extensions/autojoiner.ts | 105 ++++++++++++++++++
.../features/editor/extensions/extensions.ts | 6 +-
.../editor/extensions/markdown-clipboard.ts | 25 ++++-
.../integrations/export/export.controller.ts | 30 +++--
.../src/integrations/export/export.service.ts | 9 +-
.../src/lib/markdown/utils/marked.utils.ts | 21 ++--
.../src/lib/markdown/utils/turndown.utils.ts | 55 ++++++---
7 files changed, 217 insertions(+), 34 deletions(-)
create mode 100644 apps/client/src/features/editor/extensions/autojoiner.ts
diff --git a/apps/client/src/features/editor/extensions/autojoiner.ts b/apps/client/src/features/editor/extensions/autojoiner.ts
new file mode 100644
index 00000000..b69ed042
--- /dev/null
+++ b/apps/client/src/features/editor/extensions/autojoiner.ts
@@ -0,0 +1,105 @@
+// https://github.com/NiclasDev63/tiptap-extension-auto-joiner - MIT
+import { Extension } from "@tiptap/core";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { canJoin } from "@tiptap/pm/transform";
+import { getNodeType } from "@tiptap/react";
+import { NodeType } from "@tiptap/pm/model";
+import { Transaction } from "@tiptap/pm/state";
+
+// https://discuss.prosemirror.net/t/how-to-autojoin-all-the-time/2957/4
+// Adapted from prosemirror-commands wrapDispatchForJoin
+function autoJoin(
+ transactions: readonly Transaction[],
+ newTr: Transaction,
+ nodeTypes: NodeType[]
+) {
+ // Collect changed ranges across all transactions, mapping earlier ranges
+ // forward through later mappings so every position lands in newTr.doc space.
+ let ranges: number[] = [];
+ for (const tr of transactions) {
+ for (let i = 0; i < tr.mapping.maps.length; i++) {
+ let map = tr.mapping.maps[i];
+ if (!map) continue;
+ for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]!);
+ map.forEach((_s, _e, from, to) => ranges.push(from, to));
+ }
+ }
+
+ // Figure out which joinable points exist inside those ranges,
+ // by checking all node boundaries in their parent nodes.
+ // Resolve against newTr.doc — the same document we will join on.
+ let joinable: number[] = [];
+ for (let i = 0; i < ranges.length; i += 2) {
+ let from = ranges[i]!,
+ to = ranges[i + 1]!;
+ let $from = newTr.doc.resolve(from),
+ depth = $from.sharedDepth(to),
+ parent = $from.node(depth);
+ for (
+ let index = $from.indexAfter(depth), pos = $from.after(depth + 1);
+ pos <= to;
+ ++index
+ ) {
+ let after = parent.maybeChild(index);
+ if (!after) break;
+ if (index && joinable.indexOf(pos) == -1) {
+ let before = parent.child(index - 1);
+ if (before.type == after.type && nodeTypes.includes(before.type))
+ joinable.push(pos);
+ }
+ pos += after.nodeSize;
+ }
+ }
+
+ // Join the joinable points (reverse order to preserve earlier positions)
+ let joined = false;
+ joinable.sort((a, b) => a - b);
+ for (let i = joinable.length - 1; i >= 0; i--) {
+ if (canJoin(newTr.doc, joinable[i]!)) {
+ newTr.join(joinable[i]!);
+ joined = true;
+ }
+ }
+
+ return joined;
+}
+
+export interface AutoJoinerOptions {
+ elementsToJoin: string[];
+}
+
+const AutoJoiner = Extension.create({
+ name: "autoJoiner",
+
+ addOptions() {
+ return {
+ elementsToJoin: [],
+ };
+ },
+
+ addProseMirrorPlugins() {
+ const plugin = new PluginKey(this.name);
+ const joinableNodes = [
+ this.editor.schema.nodes.bulletList,
+ this.editor.schema.nodes.orderedList,
+ ];
+ this.options.elementsToJoin.forEach((element) => {
+ const nodeTyp = getNodeType(element, this.editor.schema);
+ joinableNodes.push(nodeTyp);
+ });
+
+ return [
+ new Plugin({
+ key: plugin,
+ appendTransaction(transactions, _, newState) {
+ let newTr = newState.tr;
+ if (autoJoin(transactions, newTr, joinableNodes as NodeType[])) {
+ return newTr;
+ }
+ },
+ }),
+ ];
+ },
+});
+
+export default AutoJoiner;
diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts
index 37f173dc..c5ca4cd1 100644
--- a/apps/client/src/features/editor/extensions/extensions.ts
+++ b/apps/client/src/features/editor/extensions/extensions.ts
@@ -49,7 +49,7 @@ import {
SharedStorage,
Columns,
Column,
- Status
+ Status,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -97,6 +97,7 @@ import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
import { countWords } from "alfaaz";
+import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@@ -353,6 +354,9 @@ export const mainExtensions = [
}).configure(),
Columns,
Column,
+ AutoJoiner.configure({
+ elementsToJoin: [],
+ }),
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.ts
index 0d6ab263..de0ca144 100644
--- a/apps/client/src/features/editor/extensions/markdown-clipboard.ts
+++ b/apps/client/src/features/editor/extensions/markdown-clipboard.ts
@@ -1,9 +1,9 @@
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
-import { DOMParser, Fragment, Slice } from "@tiptap/pm/model";
+import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
import { find } from "linkifyjs";
-import { markdownToHtml } from "@docmost/editor-ext";
+import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
@@ -19,6 +19,27 @@ export const MarkdownClipboard = Extension.create({
new Plugin({
key: new PluginKey("markdownClipboard"),
props: {
+ clipboardTextSerializer: (slice) => {
+ const listTypes = ["bulletList", "orderedList", "taskList"];
+ let topLevelCount = 0;
+ let hasList = false;
+ slice.content.forEach((node) => {
+ if (listTypes.includes(node.type.name)) {
+ hasList = true;
+ topLevelCount += node.childCount;
+ } else {
+ topLevelCount++;
+ }
+ });
+
+ if (!hasList || topLevelCount < 2) return null;
+
+ const div = document.createElement("div");
+ const serializer = DOMSerializer.fromSchema(this.editor.schema);
+ const fragment = serializer.serializeFragment(slice.content);
+ div.appendChild(fragment);
+ return htmlToMarkdown(div.innerHTML);
+ },
handlePaste: (view, event, slice) => {
if (!event.clipboardData) {
return false;
diff --git a/apps/server/src/integrations/export/export.controller.ts b/apps/server/src/integrations/export/export.controller.ts
index 0fc5fb96..1ce3f8c8 100644
--- a/apps/server/src/integrations/export/export.controller.ts
+++ b/apps/server/src/integrations/export/export.controller.ts
@@ -61,7 +61,7 @@ export class ExportController {
await this.pageAccessService.validateCanView(page, user);
- const zipFileStream = await this.exportService.exportPages(
+ const result = await this.exportService.exportPages(
dto.pageId,
dto.format,
dto.includeAttachments,
@@ -83,15 +83,29 @@ export class ExportController {
},
});
- const fileName = sanitize(page.title || 'untitled') + '.zip';
+ if (result.type === 'file') {
+ const ext = getExportExtension(dto.format);
+ const fileName = sanitize(page.title || 'untitled') + ext;
+ const contentType = getMimeType(path.extname(fileName));
- res.headers({
- 'Content-Type': 'application/zip',
- 'Content-Disposition':
- 'attachment; filename="' + encodeURIComponent(fileName) + '"',
- });
+ res.headers({
+ 'Content-Type': contentType,
+ 'Content-Disposition':
+ 'attachment; filename="' + encodeURIComponent(fileName) + '"',
+ });
- res.send(zipFileStream);
+ res.send(result.content);
+ } else {
+ const fileName = sanitize(page.title || 'untitled') + '.zip';
+
+ res.headers({
+ 'Content-Type': 'application/zip',
+ 'Content-Disposition':
+ 'attachment; filename="' + encodeURIComponent(fileName) + '"',
+ });
+
+ res.send(result.stream);
+ }
}
@UseGuards(JwtAuthGuard)
diff --git a/apps/server/src/integrations/export/export.service.ts b/apps/server/src/integrations/export/export.service.ts
index 4e1350f3..d93f9ba0 100644
--- a/apps/server/src/integrations/export/export.service.ts
+++ b/apps/server/src/integrations/export/export.service.ts
@@ -150,6 +150,13 @@ export class ExportService {
// set to null to make export of pages with parentId work
pages[parentPageIndex].parentPageId = null;
+ const isSinglePage = pages.length === 1 && !includeAttachments;
+
+ if (isSinglePage) {
+ const pageContent = await this.exportPage(format, pages[0], true);
+ return { type: 'file' as const, content: pageContent, page: pages[0] };
+ }
+
const tree = buildTree(pages as Page[]);
const baseUrl = await this.getWorkspaceBaseUrl(pages[0].workspaceId);
@@ -170,7 +177,7 @@ export class ExportService {
compression: 'DEFLATE',
});
- return zipFile;
+ return { type: 'zip' as const, stream: zipFile, page: pages[0] };
}
async exportSpace(
diff --git a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
index 15797711..04dc1978 100644
--- a/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
+++ b/packages/editor-ext/src/lib/markdown/utils/marked.utils.ts
@@ -5,18 +5,23 @@ import { mathInlineExtension } from "./math-inline.marked";
marked.use({
renderer: {
- // @ts-ignore
- list(body: string, isOrdered: boolean, start: number) {
- if (isOrdered) {
- const startAttr = start !== 1 ? ` start="${start}"` : "";
- return `\n${body}
\n`;
+ list({ ordered, start, items }) {
+ let body = "";
+ for (const item of items) {
+ body += this.listitem(item);
}
- const dataType = body.includes(`\n${body}\n`;
+ }
+
+ const isTaskList = items.some((item) => item.task);
+ const dataType = isTaskList ? ' data-type="taskList"' : "";
return `\n`;
},
- // @ts-ignore
- listitem({ text, raw, task: isTask, checked: isChecked }): string {
+ listitem({ tokens, task: isTask, checked: isChecked }) {
+ const text = this.parser.parse(tokens);
if (!isTask) {
return `${text}\n`;
}
diff --git a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
index 71a2b512..635983df 100644
--- a/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
+++ b/packages/editor-ext/src/lib/markdown/utils/turndown.utils.ts
@@ -21,6 +21,7 @@ export function htmlToMarkdown(html: string): string {
callout,
preserveDetail,
listParagraph,
+ orderedListItem,
mathInline,
mathBlock,
iframeEmbed,
@@ -41,6 +42,40 @@ function listParagraph(turndownService: _TurndownService) {
});
}
+function orderedListItem(turndownService: _TurndownService) {
+ turndownService.addRule('orderedListItem', {
+ filter: function (node: HTMLInputElement) {
+ return node.nodeName === 'LI' && node.getAttribute('data-type') !== 'taskItem';
+ },
+ replacement: (content: string, node: HTMLInputElement, options: any) => {
+ const parent = node.parentNode as HTMLElement;
+ if (parent.nodeName !== 'OL' && parent.nodeName !== 'UL') {
+ return content;
+ }
+
+ content = content
+ .replace(/^\n+/, '')
+ .replace(/\n+$/, '\n')
+ .replace(/\n/gm, '\n ');
+
+ let prefix: string;
+ if (parent.nodeName === 'OL') {
+ const start = parseInt(parent.getAttribute('start') || '1', 10);
+ const index = Array.prototype.indexOf.call(parent.children, node);
+ prefix = `${start + index}. `;
+ } else {
+ prefix = `${options.bulletListMarker} `;
+ }
+
+ return (
+ prefix +
+ content +
+ (node.nextSibling && !/\n$/.test(content) ? '\n' : '')
+ );
+ },
+ });
+}
+
function callout(turndownService: _TurndownService) {
turndownService.addRule('callout', {
filter: function (node: HTMLInputElement) {
@@ -63,25 +98,17 @@ function taskList(turndownService: _TurndownService) {
node.parentNode.nodeName === 'UL'
);
},
- replacement: function (content: string, node: HTMLInputElement) {
- const checkbox = node.querySelector(
- 'input[type="checkbox"]',
- ) as HTMLInputElement;
- const isChecked = checkbox.checked;
+ replacement: function (_content: string, node: HTMLInputElement) {
+ const isChecked = node.getAttribute('data-checked') === 'true';
+ const div = node.querySelector('div');
+ const text = div ? div.textContent.trim() : node.textContent.trim();
- // Process content like regular list items
- content = content
- .replace(/^\n+/, '') // remove leading newlines
- .replace(/\n+$/, '\n') // replace trailing newlines with just a single one
- .replace(/\n/gm, '\n '); // indent nested content with 2 spaces
-
- // Create the checkbox prefix
const prefix = `- ${isChecked ? '[x]' : '[ ]'} `;
return (
prefix +
- content +
- (node.nextSibling && !/\n$/.test(content) ? '\n' : '')
+ text +
+ (node.nextSibling && !/\n$/.test(text) ? '\n' : '')
);
},
});
From c9cdfa0f17f57351b867bb9a81cf24d085da68e5 Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Sun, 29 Mar 2026 02:20:56 +0100
Subject: [PATCH 41/57] fix
---
apps/client/src/features/comment/components/comment-menu.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/apps/client/src/features/comment/components/comment-menu.tsx b/apps/client/src/features/comment/components/comment-menu.tsx
index b9cd1e0e..fe047232 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")}
From bca85a49d65967c3bd7bd721db64808e1f4f6a0c Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Sun, 29 Mar 2026 03:03:35 +0100
Subject: [PATCH 42/57] pin marked version
---
packages/editor-ext/package.json | 4 +++-
pnpm-lock.yaml | 6 +++++-
2 files changed, 8 insertions(+), 2 deletions(-)
diff --git a/packages/editor-ext/package.json b/packages/editor-ext/package.json
index 022c4b6a..23ddcaff 100644
--- a/packages/editor-ext/package.json
+++ b/packages/editor-ext/package.json
@@ -9,5 +9,7 @@
"main": "dist/index.js",
"module": "./src/index.ts",
"types": "dist/index.d.ts",
- "dependencies": {}
+ "dependencies": {
+ "marked": "17.0.5"
+ }
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5b5cf19c..f0389d23 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -801,7 +801,11 @@ importers:
specifier: ^8.57.1
version: 8.57.1(eslint@9.39.4(jiti@2.4.2))(typescript@5.9.3)
- packages/editor-ext: {}
+ packages/editor-ext:
+ dependencies:
+ marked:
+ specifier: 17.0.5
+ version: 17.0.5
packages:
From 5cea30cc5ca3b8dbbd7e7b2fb57bc243618e882a Mon Sep 17 00:00:00 2001
From: Philipinho <16838612+Philipinho@users.noreply.github.com>
Date: Sun, 29 Mar 2026 16:11:21 +0100
Subject: [PATCH 43/57] fix markdown paste
---
.../editor/extensions/markdown-clipboard.ts | 48 +++++++++----------
1 file changed, 24 insertions(+), 24 deletions(-)
diff --git a/apps/client/src/features/editor/extensions/markdown-clipboard.ts b/apps/client/src/features/editor/extensions/markdown-clipboard.ts
index de0ca144..230798c5 100644
--- a/apps/client/src/features/editor/extensions/markdown-clipboard.ts
+++ b/apps/client/src/features/editor/extensions/markdown-clipboard.ts
@@ -1,6 +1,6 @@
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
import { Extension } from "@tiptap/core";
-import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
import { find } from "linkifyjs";
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
@@ -50,26 +50,46 @@ export const MarkdownClipboard = Extension.create({
}
const text = event.clipboardData.getData("text/plain");
+ const html = event.clipboardData.getData("text/html");
const vscode = event.clipboardData.getData("vscode-editor-data");
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
const language = vscodeData?.mode;
- if (language !== "markdown") {
+ const isVscodeMarkdown = language === "markdown";
+ const isPlainTextOnly = !html && !vscode && !!text;
+
+ if (!isVscodeMarkdown && !isPlainTextOnly) {
return false;
}
+ if (isPlainTextOnly) {
+ if ((view as any).input?.shiftKey || !this.options.transformPastedText) {
+ return false;
+ }
+
+ const link = find(text, {
+ defaultProtocol: "http",
+ }).find((item) => item.isLink && item.value === text);
+
+ if (link) {
+ return false;
+ }
+ }
+
const { tr } = view.state;
const { from, to } = view.state.selection;
- const html = markdownToHtml(text.replace(/\n+$/, ""));
+ const parsed = markdownToHtml(text.replace(/\n+$/, ""));
const contentNodes = DOMParser.fromSchema(
this.editor.schema,
- ).parseSlice(elementFromString(html), {
+ ).parseSlice(elementFromString(parsed), {
preserveWhitespace: true,
});
tr.replaceRange(from, to, contentNodes);
+ const insertEnd = tr.mapping.map(from, 1);
+ tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
tr.setMeta('paste', true)
view.dispatch(tr);
return true;
@@ -105,26 +125,6 @@ export const MarkdownClipboard = Extension.create({
return slice;
},
- clipboardTextParser: (text, context, plainText) => {
- const link = find(text, {
- defaultProtocol: "http",
- }).find((item) => item.isLink && item.value === text);
-
- if (plainText || !this.options.transformPastedText || link) {
- // don't parse plaintext link to allow link paste handler to work
- // pasting with shift key prevents formatting
- return null;
- }
-
- const parsed = markdownToHtml(text.replace(/\n+$/, ""));
- return DOMParser.fromSchema(this.editor.schema).parseSlice(
- elementFromString(parsed),
- {
- preserveWhitespace: true,
- context,
- },
- );
- },
},
}),
];
From 2d6d82958181069ca7749cff3195458aebda1e22 Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Sun, 29 Mar 2026 16:25:45 +0100
Subject: [PATCH 44/57] New translations translation.json (English) (#2066)
---
apps/client/public/locales/en-US/translation.json | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 97a5abb3..19149612 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -733,7 +733,5 @@
"Publish": "Publish.",
"Security": "Security.",
"Enforce SSO": "Enforce SSO.",
- "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password.",
- "Uploading {{name}}": "Uploading {{name}}",
- "Uploading file": "Uploading file"
+ "Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to log in with email and password."
}
From cbd0dd4a0bd44cf12bb40f4809bd1a9c88e6792f Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Sun, 29 Mar 2026 20:29:12 +0100
Subject: [PATCH 45/57] feat: indexes (#2071)
---
.../20260329T163516-add-new-indexes.ts | 333 ++++++++++++++++++
1 file changed, 333 insertions(+)
create mode 100644 apps/server/src/database/migrations/20260329T163516-add-new-indexes.ts
diff --git a/apps/server/src/database/migrations/20260329T163516-add-new-indexes.ts b/apps/server/src/database/migrations/20260329T163516-add-new-indexes.ts
new file mode 100644
index 00000000..559366b6
--- /dev/null
+++ b/apps/server/src/database/migrations/20260329T163516-add-new-indexes.ts
@@ -0,0 +1,333 @@
+import { type Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createIndex('idx_group_users_user_id')
+ .ifNotExists()
+ .on('group_users')
+ .column('user_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_space_members_user_id')
+ .ifNotExists()
+ .on('space_members')
+ .column('user_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_space_members_group_id')
+ .ifNotExists()
+ .on('space_members')
+ .column('group_id')
+ .execute();
+
+ // Page tree
+ await sql`
+ CREATE INDEX IF NOT EXISTS idx_pages_space_parent_position
+ ON pages (space_id, parent_page_id, position COLLATE "C")
+ WHERE deleted_at IS NULL
+ `.execute(db);
+
+ await sql`
+ CREATE INDEX IF NOT EXISTS idx_pages_parent_page_id
+ ON pages (parent_page_id)
+ WHERE deleted_at IS NULL
+ `.execute(db);
+
+ // Recent pages query
+ await sql`
+ CREATE INDEX IF NOT EXISTS idx_pages_space_updated
+ ON pages (space_id, updated_at DESC)
+ WHERE deleted_at IS NULL
+ `.execute(db);
+
+ // Trash view
+ await sql`
+ CREATE INDEX IF NOT EXISTS idx_pages_space_deleted
+ ON pages (space_id, deleted_at DESC)
+ WHERE deleted_at IS NOT NULL
+ `.execute(db);
+
+ await sql`
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_hostname_lower
+ ON workspaces (LOWER(hostname))
+ `.execute(db);
+
+ await db.schema
+ .createIndex('idx_workspaces_created_at')
+ .ifNotExists()
+ .on('workspaces')
+ .column('created_at')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_users_workspace_deleted')
+ .ifNotExists()
+ .on('users')
+ .columns(['workspace_id', 'deleted_at'])
+ .execute();
+
+ await sql`
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_spaces_slug_lower_workspace
+ ON spaces (LOWER(slug), workspace_id)
+ `.execute(db);
+
+ await db.schema
+ .createIndex('idx_spaces_workspace_id')
+ .ifNotExists()
+ .on('spaces')
+ .column('workspace_id')
+ .execute();
+
+ await sql`
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_name_lower_workspace
+ ON groups (LOWER(name), workspace_id)
+ `.execute(db);
+
+ await db.schema
+ .createIndex('idx_groups_workspace_id')
+ .ifNotExists()
+ .on('groups')
+ .column('workspace_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_shares_page_id')
+ .ifNotExists()
+ .on('shares')
+ .column('page_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_attachments_page_id')
+ .ifNotExists()
+ .on('attachments')
+ .column('page_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_attachments_space_id')
+ .ifNotExists()
+ .on('attachments')
+ .column('space_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_comments_page_id')
+ .ifNotExists()
+ .on('comments')
+ .column('page_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_comments_parent_comment_id')
+ .ifNotExists()
+ .on('comments')
+ .column('parent_comment_id')
+ .execute();
+
+ await sql`
+ CREATE INDEX IF NOT EXISTS idx_page_history_page_created
+ ON page_history (page_id, created_at DESC)
+ `.execute(db);
+
+ await db.schema
+ .createIndex('idx_attachments_workspace_id')
+ .ifNotExists()
+ .on('attachments')
+ .column('workspace_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_backlinks_target_page_id')
+ .ifNotExists()
+ .on('backlinks')
+ .column('target_page_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_pages_workspace_id')
+ .ifNotExists()
+ .on('pages')
+ .column('workspace_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_pages_creator_id')
+ .ifNotExists()
+ .on('pages')
+ .column('creator_id')
+ .execute();
+
+ // Notifications: FK cascade from pages, spaces, comments
+ await db.schema
+ .createIndex('idx_notifications_page_id')
+ .ifNotExists()
+ .on('notifications')
+ .column('page_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_notifications_space_id')
+ .ifNotExists()
+ .on('notifications')
+ .column('space_id')
+ .execute();
+
+ await db.schema
+ .createIndex('idx_notifications_comment_id')
+ .ifNotExists()
+ .on('notifications')
+ .column('comment_id')
+ .execute();
+
+ // Watchers: cleanup queries and FK cascade
+ await db.schema
+ .createIndex('idx_watchers_user_workspace')
+ .ifNotExists()
+ .on('watchers')
+ .columns(['user_id', 'workspace_id'])
+ .execute();
+
+ await db.schema
+ .createIndex('idx_watchers_space_id')
+ .ifNotExists()
+ .on('watchers')
+ .column('space_id')
+ .execute();
+
+ // Auth providers: all queries filter by workspaceId
+ await db.schema
+ .createIndex('idx_auth_providers_workspace_id')
+ .ifNotExists()
+ .on('auth_providers')
+ .column('workspace_id')
+ .execute();
+
+ // Auth accounts: SSO login lookup by provider user
+ await db.schema
+ .createIndex('idx_auth_accounts_provider_user_id')
+ .ifNotExists()
+ .on('auth_accounts')
+ .columns(['provider_user_id', 'auth_provider_id'])
+ .execute();
+
+ // Workspace invitations: listing and SSO lookup
+ await db.schema
+ .createIndex('idx_workspace_invitations_workspace_id')
+ .ifNotExists()
+ .on('workspace_invitations')
+ .column('workspace_id')
+ .execute();
+
+ // API keys: query and FK cascade
+ await db.schema
+ .createIndex('idx_api_keys_workspace_id')
+ .ifNotExists()
+ .on('api_keys')
+ .column('workspace_id')
+ .execute();
+
+ // User sessions: delete queries and FK cascade on all session states
+ await db.schema
+ .createIndex('idx_user_sessions_user_workspace')
+ .ifNotExists()
+ .on('user_sessions')
+ .columns(['user_id', 'workspace_id'])
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropIndex('idx_group_users_user_id').ifExists().execute();
+ await db.schema.dropIndex('idx_space_members_user_id').ifExists().execute();
+ await db.schema.dropIndex('idx_space_members_group_id').ifExists().execute();
+ await db.schema
+ .dropIndex('idx_pages_space_parent_position')
+ .ifExists()
+ .execute();
+ await db.schema.dropIndex('idx_pages_parent_page_id').ifExists().execute();
+ await db.schema.dropIndex('idx_pages_space_updated').ifExists().execute();
+ await db.schema.dropIndex('idx_pages_space_deleted').ifExists().execute();
+ await db.schema
+ .dropIndex('idx_workspaces_hostname_lower')
+ .ifExists()
+ .execute();
+ await db.schema.dropIndex('idx_workspaces_created_at').ifExists().execute();
+ await db.schema
+ .dropIndex('idx_users_workspace_deleted')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_spaces_slug_lower_workspace')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_spaces_workspace_id')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_groups_name_lower_workspace')
+ .ifExists()
+ .execute();
+ await db.schema.dropIndex('idx_groups_workspace_id').ifExists().execute();
+ await db.schema.dropIndex('idx_shares_page_id').ifExists().execute();
+ await db.schema.dropIndex('idx_attachments_page_id').ifExists().execute();
+ await db.schema.dropIndex('idx_attachments_space_id').ifExists().execute();
+ await db.schema.dropIndex('idx_comments_page_id').ifExists().execute();
+ await db.schema
+ .dropIndex('idx_comments_parent_comment_id')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_page_history_page_created')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_attachments_workspace_id')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_backlinks_target_page_id')
+ .ifExists()
+ .execute();
+ await db.schema.dropIndex('idx_pages_workspace_id').ifExists().execute();
+ await db.schema.dropIndex('idx_pages_creator_id').ifExists().execute();
+ await db.schema
+ .dropIndex('idx_notifications_page_id')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_notifications_space_id')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_notifications_comment_id')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_watchers_user_workspace')
+ .ifExists()
+ .execute();
+ await db.schema.dropIndex('idx_watchers_space_id').ifExists().execute();
+ await db.schema
+ .dropIndex('idx_auth_providers_workspace_id')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_auth_accounts_provider_user_id')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_workspace_invitations_workspace_id')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_api_keys_workspace_id')
+ .ifExists()
+ .execute();
+ await db.schema
+ .dropIndex('idx_user_sessions_user_workspace')
+ .ifExists()
+ .execute();
+}
From a062f7a165359e7fce15d6a88e6befb349749cfb Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Mon, 30 Mar 2026 13:16:40 +0100
Subject: [PATCH 46/57] fix: enhance confluence importer (#2072)
* fix placeholder
* min resize dimensions
* fix media links
* fix
---
.../components/attachment/attachment-view.tsx | 10 +++---
.../editor/components/audio/audio-view.tsx | 7 ++--
.../editor/components/image/image-view.tsx | 4 +--
.../editor/components/pdf/pdf-view.tsx | 20 ++++++-----
.../editor/components/video/video-view.tsx | 7 ++--
.../features/editor/extensions/extensions.ts | 16 ++++-----
apps/server/src/ee | 2 +-
.../services/import-attachment.service.ts | 36 +++++++++++++++----
8 files changed, 66 insertions(+), 36 deletions(-)
diff --git a/apps/client/src/features/editor/components/attachment/attachment-view.tsx b/apps/client/src/features/editor/components/attachment/attachment-view.tsx
index f6c13c80..b72bb00a 100644
--- a/apps/client/src/features/editor/components/attachment/attachment-view.tsx
+++ b/apps/client/src/features/editor/components/attachment/attachment-view.tsx
@@ -10,7 +10,7 @@ import { useCallback } from "react";
export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node, getPos, selected } = props;
- const { url, name, size, mime, attachmentId } = node.attrs;
+ const { url, name, size, mime, attachmentId, placeholder } = node.attrs;
const { hovered, ref } = useHover();
const isPdf = mime === "application/pdf" || name?.toLowerCase().endsWith(".pdf");
@@ -49,14 +49,14 @@ export default function AttachmentView(props: NodeViewProps) {
h={25}
>
- {url ? (
-
- ) : (
+ {!url && placeholder ? (
+ ) : (
+
)}
- {url ? name : t("Uploading {{name}}", { name })}
+ {!url && placeholder ? t("Uploading {{name}}", { name }) : name}
diff --git a/apps/client/src/features/editor/components/audio/audio-view.tsx b/apps/client/src/features/editor/components/audio/audio-view.tsx
index a353ce45..9e5f619f 100644
--- a/apps/client/src/features/editor/components/audio/audio-view.tsx
+++ b/apps/client/src/features/editor/components/audio/audio-view.tsx
@@ -29,7 +29,7 @@ export default function AudioView(props: NodeViewProps) {
return (
-
+
{safeSrc && (
)}
- {!safeSrc && !previewSrc && (
+ {!safeSrc && !previewSrc && placeholder && (
@@ -59,6 +59,9 @@ export default function AudioView(props: NodeViewProps) {
)}
+ {!safeSrc && !previewSrc && !placeholder && (
+
+ )}
);
diff --git a/apps/client/src/features/editor/components/image/image-view.tsx b/apps/client/src/features/editor/components/image/image-view.tsx
index 7ec3e26f..1f874694 100644
--- a/apps/client/src/features/editor/components/image/image-view.tsx
+++ b/apps/client/src/features/editor/components/image/image-view.tsx
@@ -33,7 +33,7 @@ export default function ImageView(props: NodeViewProps) {
className={clsx(
selected && "ProseMirror-selectednode",
classes.imageWrapper,
- !src && classes.skeleton,
+ !src && placeholder && classes.skeleton,
alignClass,
)}
style={{
@@ -55,7 +55,7 @@ export default function ImageView(props: NodeViewProps) {
)}
- {!src && !previewSrc && (
+ {!src && !previewSrc && placeholder && (
diff --git a/apps/client/src/features/editor/components/pdf/pdf-view.tsx b/apps/client/src/features/editor/components/pdf/pdf-view.tsx
index 6207da9f..4d06402b 100644
--- a/apps/client/src/features/editor/components/pdf/pdf-view.tsx
+++ b/apps/client/src/features/editor/components/pdf/pdf-view.tsx
@@ -73,15 +73,17 @@ export default function PdfView(props: NodeViewProps) {
if (!src || !safeSrc) {
return (
-
-
-
-
- {placeholder?.name
- ? t("Uploading {{name}}", { name: placeholder.name })
- : t("Uploading file")}
-
-
+
+ {placeholder && (
+
+
+
+ {placeholder?.name
+ ? t("Uploading {{name}}", { name: placeholder.name })
+ : t("Uploading file")}
+
+
+ )}
);
diff --git a/apps/client/src/features/editor/components/video/video-view.tsx b/apps/client/src/features/editor/components/video/video-view.tsx
index 1e662640..46ff7908 100644
--- a/apps/client/src/features/editor/components/video/video-view.tsx
+++ b/apps/client/src/features/editor/components/video/video-view.tsx
@@ -33,7 +33,7 @@ export default function VideoView(props: NodeViewProps) {
className={clsx(
selected && "ProseMirror-selectednode",
classes.videoWrapper,
- !src && classes.skeleton,
+ !src && placeholder && classes.skeleton,
alignClass,
)}
style={{
@@ -60,7 +60,7 @@ export default function VideoView(props: NodeViewProps) {
)}
- {!src && !previewSrc && (
+ {!src && !previewSrc && placeholder && (
@@ -70,6 +70,9 @@ export default function VideoView(props: NodeViewProps) {
)}
+ {!src && !previewSrc && !placeholder && (
+
+ )}
);
diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts
index c5ca4cd1..8f6e6cdb 100644
--- a/apps/client/src/features/editor/extensions/extensions.ts
+++ b/apps/client/src/features/editor/extensions/extensions.ts
@@ -253,8 +253,8 @@ export const mainExtensions = [
resize: {
enabled: true,
directions: ["left", "right"],
- minWidth: 80,
- minHeight: 40,
+ minWidth: 24,
+ minHeight: 16,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createImageHandle,
@@ -266,8 +266,8 @@ export const mainExtensions = [
resize: {
enabled: true,
directions: ["left", "right"],
- minWidth: 80,
- minHeight: 40,
+ minWidth: 24,
+ minHeight: 16,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
@@ -297,8 +297,8 @@ export const mainExtensions = [
resize: {
enabled: true,
directions: ["left", "right"],
- minWidth: 80,
- minHeight: 40,
+ minWidth: 24,
+ minHeight: 16,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
@@ -310,8 +310,8 @@ export const mainExtensions = [
resize: {
enabled: true,
directions: ["left", "right"],
- minWidth: 80,
- minHeight: 40,
+ minWidth: 24,
+ minHeight: 16,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
diff --git a/apps/server/src/ee b/apps/server/src/ee
index f4867260..05f1c816 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit f48672608889233c0247c6d4ef7fcddd29540315
+Subproject commit 05f1c816a839072efc1143cce71322a9ed6b4a0a
diff --git a/apps/server/src/integrations/import/services/import-attachment.service.ts b/apps/server/src/integrations/import/services/import-attachment.service.ts
index 3c14d854..9100149b 100644
--- a/apps/server/src/integrations/import/services/import-attachment.service.ts
+++ b/apps/server/src/integrations/import/services/import-attachment.service.ts
@@ -193,6 +193,8 @@ export class ImportAttachmentService {
// Build a map from resolved archive path → real filename from Confluence
// metadata. Confluence Server archives often store files under numeric IDs
// (e.g. "attachments/65601/65602") instead of the original filename.
+ // Also register aliases so HTML references using the original filename
+ // (e.g. "attachments/pageId/original.mp3") resolve to the numeric path.
const pageDir = path.dirname(pageRelativePath);
const attachmentNameByRelPath = new Map();
for (const attachment of pageAttachments) {
@@ -203,6 +205,13 @@ export class ImportAttachmentService {
);
if (relPath && attachment.fileName) {
attachmentNameByRelPath.set(relPath, attachment.fileName);
+
+ const dir = path.posix.dirname(relPath);
+ const aliasKey = `${dir}/${attachment.fileName}`;
+ if (!attachmentCandidates.has(aliasKey)) {
+ attachmentCandidates.set(aliasKey, attachmentCandidates.get(relPath)!);
+ attachmentNameByRelPath.set(aliasKey, attachment.fileName);
+ }
}
}
@@ -562,18 +571,31 @@ export class ImportAttachmentService {
continue;
}
- // Check if already processed (was referenced in HTML)
- if (processed.has(href)) {
- continue;
- }
+ // Resolve the metadata href to the actual archive path
+ const resolvedHref = resolveRelativeAttachmentPath(
+ href,
+ pageDir,
+ attachmentCandidates,
+ );
+ if (!resolvedHref) continue;
- // Skip if the file doesn't exist
- if (!attachmentCandidates.has(href)) {
+ // Check if already processed (was referenced in HTML).
+ // Inline elements may have been processed under an alias key (original
+ // filename) rather than the numeric archive path, so also check whether
+ // the underlying absolute file path has already been uploaded.
+ const absPath = attachmentCandidates.get(resolvedHref);
+ const alreadyProcessed =
+ processed.has(resolvedHref) ||
+ (absPath &&
+ Array.from(processed.values()).some(
+ (entry) => entry.abs === absPath,
+ ));
+ if (alreadyProcessed) {
continue;
}
// This attachment was in the list but not referenced in HTML - add it
- const { attachmentId, apiFilePath, abs } = processFile(href);
+ const { attachmentId, apiFilePath, abs } = processFile(resolvedHref);
const mime = mimeType || getMimeType(abs);
// Add as attachment node at the end
From c180d0e48755b614731ff5e92b900eb2365b0543 Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Mon, 30 Mar 2026 15:38:44 +0100
Subject: [PATCH 47/57] feat: ratelimits (#2073)
* feat: rate limits
* ip
---
apps/server/package.json | 3 ++
apps/server/src/app.module.ts | 2 +
apps/server/src/common/logger/pino.config.ts | 20 +++-----
.../middlewares/audit-context.middleware.ts | 20 +-------
apps/server/src/core/auth/auth.controller.ts | 5 ++
apps/server/src/ee | 2 +-
.../integrations/throttle/throttle.module.ts | 35 ++++++++++++++
apps/server/src/main.ts | 2 +
pnpm-lock.yaml | 48 +++++++++++++++++++
9 files changed, 104 insertions(+), 33 deletions(-)
create mode 100644 apps/server/src/integrations/throttle/throttle.module.ts
diff --git a/apps/server/package.json b/apps/server/package.json
index d8bab08c..a8869302 100644
--- a/apps/server/package.json
+++ b/apps/server/package.json
@@ -44,6 +44,7 @@
"@langchain/core": "1.1.34",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "^1.27.1",
+ "@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0",
@@ -58,6 +59,7 @@
"@nestjs/platform-socket.io": "^11.1.17",
"@nestjs/schedule": "^6.1.1",
"@nestjs/terminus": "^11.1.1",
+ "@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.17",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.10",
@@ -73,6 +75,7 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie": "^1.1.1",
+ "fastify-ip": "^2.0.0",
"fs-extra": "^11.3.4",
"happy-dom": "20.8.9",
"ioredis": "^5.10.1",
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
index 6280ee09..b8cfc587 100644
--- a/apps/server/src/app.module.ts
+++ b/apps/server/src/app.module.ts
@@ -26,6 +26,7 @@ import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
+import { ThrottleModule } from './integrations/throttle/throttle.module';
const enterpriseModules = [];
try {
@@ -83,6 +84,7 @@ try {
EventEmitterModule.forRoot(),
SecurityModule,
TelemetryModule,
+ ThrottleModule,
...enterpriseModules,
],
controllers: [AppController],
diff --git a/apps/server/src/common/logger/pino.config.ts b/apps/server/src/common/logger/pino.config.ts
index 7299a8e9..0b8cd11a 100644
--- a/apps/server/src/common/logger/pino.config.ts
+++ b/apps/server/src/common/logger/pino.config.ts
@@ -50,20 +50,12 @@ export function createPinoConfig(): Params {
},
},
serializers: {
- req: (req) => {
- const forwardedFor = req.headers?.['x-forwarded-for'];
- const ip =
- req.headers?.['cf-connecting-ip'] ||
- (typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() : undefined) ||
- req.remoteAddress;
-
- return {
- method: req.method,
- url: req.url,
- ip,
- userAgent: req.headers?.['user-agent'],
- };
- },
+ req: (req) => ({
+ method: req.method,
+ url: req.url,
+ ip: req.ip || req.remoteAddress,
+ userAgent: req.headers?.['user-agent'],
+ }),
res: (res) => ({
statusCode: res.statusCode,
}),
diff --git a/apps/server/src/common/middlewares/audit-context.middleware.ts b/apps/server/src/common/middlewares/audit-context.middleware.ts
index f5066535..52956219 100644
--- a/apps/server/src/common/middlewares/audit-context.middleware.ts
+++ b/apps/server/src/common/middlewares/audit-context.middleware.ts
@@ -18,7 +18,8 @@ export class AuditContextMiddleware implements NestMiddleware {
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
const workspaceId = (req as any).workspaceId ?? null;
- const ipAddress = this.extractIpAddress(req);
+
+ const ipAddress = (req as any).ip ?? (req as any).socket?.remoteAddress ?? null;
const userAgent =
(req.headers['user-agent'] as string) ?? null;
@@ -35,21 +36,4 @@ export class AuditContextMiddleware implements NestMiddleware {
next();
}
-
- private extractIpAddress(req: FastifyRequest['raw']): string | null {
- const xForwardedFor = req.headers['x-forwarded-for'];
- if (xForwardedFor) {
- const ips = Array.isArray(xForwardedFor)
- ? xForwardedFor[0]
- : xForwardedFor.split(',')[0];
- return ips?.trim() ?? null;
- }
-
- const xRealIp = req.headers['x-real-ip'];
- if (xRealIp) {
- return Array.isArray(xRealIp) ? xRealIp[0] : xRealIp;
- }
-
- return (req as any).socket?.remoteAddress ?? null;
- }
}
diff --git a/apps/server/src/core/auth/auth.controller.ts b/apps/server/src/core/auth/auth.controller.ts
index 6eab6539..441bfc1c 100644
--- a/apps/server/src/core/auth/auth.controller.ts
+++ b/apps/server/src/core/auth/auth.controller.ts
@@ -10,6 +10,7 @@ import {
UseGuards,
Logger,
} from '@nestjs/common';
+import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './services/auth.service';
import { SessionService } from '../session/session.service';
@@ -33,6 +34,7 @@ import {
IAuditService,
} from '../../integrations/audit/audit.service';
+@UseGuards(ThrottlerGuard)
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
@@ -111,6 +113,7 @@ export class AuthController {
return workspace;
}
+ @SkipThrottle()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('change-password')
@@ -173,6 +176,7 @@ export class AuthController {
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id);
}
+ @SkipThrottle()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('collab-token')
@@ -183,6 +187,7 @@ export class AuthController {
return this.authService.getCollabToken(user, workspace.id);
}
+ @SkipThrottle()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('logout')
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 05f1c816..350ef574 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 05f1c816a839072efc1143cce71322a9ed6b4a0a
+Subproject commit 350ef574e398c318aa57ce5f79ab12e9d8329dcb
diff --git a/apps/server/src/integrations/throttle/throttle.module.ts b/apps/server/src/integrations/throttle/throttle.module.ts
new file mode 100644
index 00000000..8f080e1d
--- /dev/null
+++ b/apps/server/src/integrations/throttle/throttle.module.ts
@@ -0,0 +1,35 @@
+import { Module } from '@nestjs/common';
+import { ThrottlerModule } from '@nestjs/throttler';
+import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis';
+import { EnvironmentService } from '../environment/environment.service';
+import { EnvironmentModule } from '../environment/environment.module';
+import { parseRedisUrl } from '../../common/helpers';
+import Redis from 'ioredis';
+
+@Module({
+ imports: [
+ ThrottlerModule.forRootAsync({
+ imports: [EnvironmentModule],
+ useFactory: (environmentService: EnvironmentService) => {
+ const redisConfig = parseRedisUrl(environmentService.getRedisUrl());
+
+ return {
+ throttlers: [{ name: 'auth', ttl: 60_000, limit: 10 }],
+ errorMessage: 'Too many requests',
+ storage: new ThrottlerStorageRedisService(
+ new Redis({
+ host: redisConfig.host,
+ port: redisConfig.port,
+ password: redisConfig.password,
+ db: redisConfig.db,
+ family: redisConfig.family,
+ keyPrefix: 'throttle:',
+ }),
+ ),
+ };
+ },
+ inject: [EnvironmentService],
+ }),
+ ],
+})
+export class ThrottleModule {}
diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
index 0f2a82a1..d47bf547 100644
--- a/apps/server/src/main.ts
+++ b/apps/server/src/main.ts
@@ -10,6 +10,7 @@ import { TransformHttpResponseInterceptor } from './common/interceptors/http-res
import { WsRedisIoAdapter } from './ws/adapter/ws-redis.adapter';
import fastifyMultipart from '@fastify/multipart';
import fastifyCookie from '@fastify/cookie';
+import fastifyIp from 'fastify-ip';
import { InternalLogFilter } from './common/logger/internal-log-filter';
async function bootstrap() {
@@ -45,6 +46,7 @@ async function bootstrap() {
app.useWebSocketAdapter(redisIoAdapter);
+ await app.register(fastifyIp);
await app.register(fastifyMultipart);
await app.register(fastifyCookie);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f0389d23..6599b07a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -493,6 +493,9 @@ importers:
'@modelcontextprotocol/sdk':
specifier: ^1.27.1
version: 1.27.1(@cfworker/json-schema@4.1.1)(zod@4.3.6)
+ '@nest-lab/throttler-storage-redis':
+ specifier: ^1.2.0
+ version: 1.2.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/throttler@6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2))(ioredis@5.10.1)(reflect-metadata@0.2.2)
'@nestjs-labs/nestjs-ioredis':
specifier: ^11.0.4
version: 11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(ioredis@5.10.1)
@@ -535,6 +538,9 @@ importers:
'@nestjs/terminus':
specifier: ^11.1.1
version: 11.1.1(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/throttler':
+ specifier: ^6.5.0
+ version: 6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)
'@nestjs/websockets':
specifier: ^11.1.17
version: 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -580,6 +586,9 @@ importers:
cookie:
specifier: ^1.1.1
version: 1.1.1
+ fastify-ip:
+ specifier: ^2.0.0
+ version: 2.0.0
fs-extra:
specifier: ^11.3.4
version: 11.3.4
@@ -2925,6 +2934,15 @@ packages:
'@napi-rs/wasm-runtime@1.1.1':
resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==}
+ '@nest-lab/throttler-storage-redis@1.2.0':
+ resolution: {integrity: sha512-tMkUyo68NCKTR+zILk+EC35SMYBtDPZY2mCj7ZaCietWGVTnuP4zwq9ERYfvU6kJv6h8teNZrC6MJCmY6/dljw==}
+ peerDependencies:
+ '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
+ '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
+ '@nestjs/throttler': '>=6.0.0'
+ ioredis: '>=5.0.0'
+ reflect-metadata: ^0.2.1
+
'@nestjs-labs/nestjs-ioredis@11.0.4':
resolution: {integrity: sha512-4jPNOrxDiwNMIN5OLmsMWhA782kxv/ZBxkySX9l8n6sr55acHX/BciaFsOXVa/ILsm+Y7893y98/6WNhmEoiNQ==}
engines: {node: '>=16'}
@@ -3127,6 +3145,13 @@ packages:
'@nestjs/platform-express':
optional: true
+ '@nestjs/throttler@6.5.0':
+ resolution: {integrity: sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==}
+ peerDependencies:
+ '@nestjs/common': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
+ '@nestjs/core': ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
+ reflect-metadata: ^0.1.13 || ^0.2.0
+
'@nestjs/websockets@11.1.17':
resolution: {integrity: sha512-YbwQ0QfVj0lxkKQhdIIgk14ZSVWDqGk1J8nNSN6SLjf36sVv58Ma5ro+dtQua8wj3l2Ub7JJCVFixEhKtYc/rQ==}
peerDependencies:
@@ -7016,6 +7041,10 @@ packages:
resolution: {integrity: sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ==}
hasBin: true
+ fastify-ip@2.0.0:
+ resolution: {integrity: sha512-7mQyAc7sapawpiriEFoJyQIs41nNIO42UCzgMKrjNGsIegnevj2VhOlXLLTa+q7cxXfJ5fDGmOAdQpaIgA9ObA==}
+ engines: {node: '>=20.x'}
+
fastify-plugin@5.0.1:
resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==}
@@ -13463,6 +13492,15 @@ snapshots:
'@tybys/wasm-util': 0.10.1
optional: true
+ '@nest-lab/throttler-storage-redis@1.2.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/throttler@6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2))(ioredis@5.10.1)(reflect-metadata@0.2.2)':
+ dependencies:
+ '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/throttler': 6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)
+ ioredis: 5.10.1
+ reflect-metadata: 0.2.2
+ tslib: 2.8.1
+
'@nestjs-labs/nestjs-ioredis@11.0.4(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(ioredis@5.10.1)':
dependencies:
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -13643,6 +13681,12 @@ snapshots:
'@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
tslib: 2.8.1
+ '@nestjs/throttler@6.5.0(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(reflect-metadata@0.2.2)':
+ dependencies:
+ '@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ '@nestjs/core': 11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)
+ reflect-metadata: 0.2.2
+
'@nestjs/websockets@11.1.17(@nestjs/common@11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.17)(@nestjs/platform-socket.io@11.1.17)(reflect-metadata@0.2.2)(rxjs@7.8.2)':
dependencies:
'@nestjs/common': 11.1.17(class-transformer@0.5.1)(class-validator@0.15.1)(reflect-metadata@0.2.2)(rxjs@7.8.2)
@@ -18072,6 +18116,10 @@ snapshots:
path-expression-matcher: 1.2.0
strnum: 2.2.1
+ fastify-ip@2.0.0:
+ dependencies:
+ fastify-plugin: 5.1.0
+
fastify-plugin@5.0.1: {}
fastify-plugin@5.1.0: {}
From 879aa2c3d820dcb532b793c98476c9b4bef6d167 Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Tue, 31 Mar 2026 16:03:59 +0100
Subject: [PATCH 48/57] feat: page update notifications (#2074)
* feat: watchers notification and email preferences
* fix: email copy
* digests
* clean up
* fix
* clean up
* move backlinks queue-up to history processor
* fix
* fix keys
* feat: group notifications
* filter
* adjust email digest window
---
.../public/locales/en-US/translation.json | 18 ++
.../components/mention/mention-list.tsx | 1 +
.../components/slash-menu/command-list.tsx | 13 +-
.../components/slash-menu/render-items.ts | 2 +-
.../components/notification-item.tsx | 3 +
.../components/notification-list.tsx | 10 +-
.../components/notification-popover.tsx | 22 +-
.../notification/notification.module.css | 1 +
.../queries/notification-query.ts | 6 +-
.../services/notification-service.ts | 1 +
.../notification/types/notification.types.ts | 5 +-
.../components/header/page-header-menu.tsx | 27 ++
.../features/page/queries/watcher-query.ts | 43 +++
.../features/page/services/watcher-service.ts | 16 ++
.../user/components/notification-pref.tsx | 117 ++++++++
.../src/features/user/types/user.types.ts | 12 +
.../settings/account/account-preferences.tsx | 5 +
.../extensions/persistence.extension.ts | 10 -
.../processors/history.processor.ts | 53 +++-
.../core/notification/dto/notification.dto.ts | 10 +-
.../notification/notification.constants.ts | 38 +++
.../notification/notification.controller.ts | 7 +-
.../core/notification/notification.module.ts | 2 +
.../notification/notification.processor.ts | 16 ++
.../core/notification/notification.service.ts | 56 +++-
.../services/comment.notification.ts | 6 +
.../page-update-email-rate-limiter.ts | 43 +++
.../services/page.notification.ts | 256 +++++++++++++++++-
.../src/core/user/dto/update-user.dto.ts | 20 ++
apps/server/src/core/user/user.service.ts | 19 ++
.../src/core/watcher/watcher.controller.ts | 30 +-
.../server/src/core/watcher/watcher.module.ts | 7 +-
.../repos/notification/notification.repo.ts | 38 ++-
.../src/database/repos/user/user.repo.ts | 19 ++
.../queue/constants/queue.constants.ts | 1 +
.../queue/constants/queue.interface.ts | 7 +
.../emails/page-update-digest-email.tsx | 76 ++++++
.../emails/page-update-email.tsx | 36 +++
.../transactional/partials/partials.tsx | 4 +
39 files changed, 983 insertions(+), 73 deletions(-)
create mode 100644 apps/client/src/features/page/queries/watcher-query.ts
create mode 100644 apps/client/src/features/page/services/watcher-service.ts
create mode 100644 apps/client/src/features/user/components/notification-pref.tsx
create mode 100644 apps/server/src/core/notification/services/page-update-email-rate-limiter.ts
create mode 100644 apps/server/src/integrations/transactional/emails/page-update-digest-email.tsx
create mode 100644 apps/server/src/integrations/transactional/emails/page-update-email.tsx
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 19149612..b1c1ed6c 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -674,6 +674,24 @@
"{{name}} mentioned you on a page": "{{name}} mentioned you on a page.",
"{{name}} gave you edit access to a page": "{{name}} gave you edit access to a page.",
"{{name}} gave you view access to a page": "{{name}} gave you view access to a page.",
+ "{{name}} updated a page": "{{name}} updated a page.",
+ "Watch page": "Watch page",
+ "Stop watching": "Stop watching",
+ "Email notifications": "Email notifications",
+ "Page updates": "Page updates",
+ "Get notified when pages you watch are updated.": "Get notified when pages you watch are updated.",
+ "Page mentions": "Page mentions",
+ "Get notified when someone mentions you on a page.": "Get notified when someone mentions you on a page.",
+ "Comment mentions": "Comment mentions",
+ "Get notified when someone mentions you in a comment.": "Get notified when someone mentions you in a comment.",
+ "New comments": "New comments",
+ "Get notified about new comments on threads you participate in.": "Get notified about new comments on threads you participate in.",
+ "Resolved comments": "Resolved comments",
+ "Get notified when your comment is resolved.": "Get notified when your comment is resolved.",
+ "You are now watching this page": "You are now watching this page",
+ "You are no longer watching this page": "You are no longer watching this page",
+ "Direct": "Direct",
+ "Updates": "Updates",
"Today": "Today",
"Yesterday": "Yesterday",
"This week": "This week",
diff --git a/apps/client/src/features/editor/components/mention/mention-list.tsx b/apps/client/src/features/editor/components/mention/mention-list.tsx
index f086df49..af6f8a1d 100644
--- a/apps/client/src/features/editor/components/mention/mention-list.tsx
+++ b/apps/client/src/features/editor/components/mention/mention-list.tsx
@@ -294,6 +294,7 @@ const MentionList = forwardRef((props, ref) => {
w={popupWidth}
scrollbars={"y"}
scrollbarSize={6}
+ overscrollBehavior={"contain"}
styles={{ content: { minWidth: 0 } }}
>
{renderItems?.map((item, index) => {
diff --git a/apps/client/src/features/editor/components/slash-menu/command-list.tsx b/apps/client/src/features/editor/components/slash-menu/command-list.tsx
index ab1dcafd..54d6cd17 100644
--- a/apps/client/src/features/editor/components/slash-menu/command-list.tsx
+++ b/apps/client/src/features/editor/components/slash-menu/command-list.tsx
@@ -87,7 +87,13 @@ const CommandList = ({
return flatItems.length > 0 ? (
-
+
{Object.entries(items).map(([category, categoryItems]) => (
@@ -103,10 +109,7 @@ const CommandList = ({
})}
>
-
+
diff --git a/apps/client/src/features/editor/components/slash-menu/render-items.ts b/apps/client/src/features/editor/components/slash-menu/render-items.ts
index 057e8214..041aa036 100644
--- a/apps/client/src/features/editor/components/slash-menu/render-items.ts
+++ b/apps/client/src/features/editor/components/slash-menu/render-items.ts
@@ -49,7 +49,7 @@ const renderItems = () => {
getReferenceClientRect = props.clientRect;
popup = document.createElement("div");
- popup.style.zIndex = "9999";
+ popup.style.zIndex = "199";
popup.style.position = "absolute";
popup.style.top = "0";
popup.style.left = "0";
diff --git a/apps/client/src/features/notification/components/notification-item.tsx b/apps/client/src/features/notification/components/notification-item.tsx
index 0ef81e44..0fd4f44b 100644
--- a/apps/client/src/features/notification/components/notification-item.tsx
+++ b/apps/client/src/features/notification/components/notification-item.tsx
@@ -49,6 +49,8 @@ export function NotificationItem({
return notification.data?.role === "writer"
? "{{name}} gave you edit access to a page"
: "{{name}} gave you view access to a page";
+ case "page.updated":
+ return "{{name}} updated a page";
default:
return "";
}
@@ -75,6 +77,7 @@ export function NotificationItem({
};
const handleMarkRead = (e: React.MouseEvent) => {
+ e.preventDefault();
e.stopPropagation();
markReadIfNeeded();
};
diff --git a/apps/client/src/features/notification/components/notification-list.tsx b/apps/client/src/features/notification/components/notification-list.tsx
index 4c992c57..4cd30677 100644
--- a/apps/client/src/features/notification/components/notification-list.tsx
+++ b/apps/client/src/features/notification/components/notification-list.tsx
@@ -3,17 +3,23 @@ import { IconBellOff } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useEffect, useRef } from "react";
import { NotificationItem } from "./notification-item";
-import { INotification, NotificationFilter } from "../types/notification.types";
+import {
+ INotification,
+ NotificationFilter,
+ NotificationTab,
+} from "../types/notification.types";
import { groupNotificationsByTime } from "../notification.utils";
import { useNotificationsQuery } from "../queries/notification-query";
import classes from "../notification.module.css";
type NotificationListProps = {
+ tab: NotificationTab;
filter: NotificationFilter;
onNavigate: () => void;
};
export function NotificationList({
+ tab,
filter,
onNavigate,
}: NotificationListProps) {
@@ -24,7 +30,7 @@ export function NotificationList({
hasNextPage,
fetchNextPage,
isFetchingNextPage,
- } = useNotificationsQuery();
+ } = useNotificationsQuery(tab as string);
const sentinelRef = useRef(null);
diff --git a/apps/client/src/features/notification/components/notification-popover.tsx b/apps/client/src/features/notification/components/notification-popover.tsx
index 8ebfedad..161ac1e6 100644
--- a/apps/client/src/features/notification/components/notification-popover.tsx
+++ b/apps/client/src/features/notification/components/notification-popover.tsx
@@ -6,6 +6,7 @@ import {
Menu,
Popover,
ScrollArea,
+ Tabs,
Text,
Tooltip,
} from "@mantine/core";
@@ -18,15 +19,20 @@ import {
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { NotificationList } from "./notification-list";
-import { NotificationFilter } from "../types/notification.types";
+import {
+ NotificationFilter,
+ NotificationTab,
+} from "../types/notification.types";
import {
useMarkAllReadMutation,
useUnreadCountQuery,
} from "../queries/notification-query";
+import classes from "../notification.module.css";
export function NotificationPopover() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
+ const [tab, setTab] = useState("direct");
const [filter, setFilter] = useState("all");
const { data: unreadData } = useUnreadCountQuery();
@@ -125,13 +131,27 @@ export function NotificationPopover() {
+ setTab(value as NotificationTab)}
+ variant="default"
+ color="dark"
+ >
+
+ {t("Direct")}
+ {t("Updates")}
+
+
+
setOpened(false)}
/>
diff --git a/apps/client/src/features/notification/notification.module.css b/apps/client/src/features/notification/notification.module.css
index d56986ac..09802628 100644
--- a/apps/client/src/features/notification/notification.module.css
+++ b/apps/client/src/features/notification/notification.module.css
@@ -13,3 +13,4 @@
.divider {
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
+
diff --git a/apps/client/src/features/notification/queries/notification-query.ts b/apps/client/src/features/notification/queries/notification-query.ts
index 363482b1..92c46560 100644
--- a/apps/client/src/features/notification/queries/notification-query.ts
+++ b/apps/client/src/features/notification/queries/notification-query.ts
@@ -15,10 +15,10 @@ import {
export const NOTIFICATION_KEY = ["notifications"];
export const UNREAD_COUNT_KEY = ["notifications", "unread-count"];
-export function useNotificationsQuery() {
+export function useNotificationsQuery(type?: string) {
return useInfiniteQuery({
- queryKey: NOTIFICATION_KEY,
- queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }),
+ queryKey: [...NOTIFICATION_KEY, type],
+ queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam, type }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
diff --git a/apps/client/src/features/notification/services/notification-service.ts b/apps/client/src/features/notification/services/notification-service.ts
index 8adf4909..7e4b8d2c 100644
--- a/apps/client/src/features/notification/services/notification-service.ts
+++ b/apps/client/src/features/notification/services/notification-service.ts
@@ -5,6 +5,7 @@ import { IPagination } from "@/lib/types";
export async function getNotifications(params: {
limit?: number;
cursor?: string;
+ type?: string;
}): Promise> {
const req = await api.post>(
"/notifications",
diff --git a/apps/client/src/features/notification/types/notification.types.ts b/apps/client/src/features/notification/types/notification.types.ts
index 811805d0..f64e3648 100644
--- a/apps/client/src/features/notification/types/notification.types.ts
+++ b/apps/client/src/features/notification/types/notification.types.ts
@@ -3,7 +3,8 @@ export type NotificationType =
| "comment.created"
| "comment.resolved"
| "page.user_mention"
- | "page.permission_granted";
+ | "page.permission_granted"
+ | "page.updated";
export type INotification = {
id: string;
@@ -38,3 +39,5 @@ export type INotification = {
};
export type NotificationFilter = "all" | "unread";
+
+export type NotificationTab = "direct" | "updates" | "all";
diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx
index 2660b2ba..5ba9d40e 100644
--- a/apps/client/src/features/page/components/header/page-header-menu.tsx
+++ b/apps/client/src/features/page/components/header/page-header-menu.tsx
@@ -3,6 +3,8 @@ import {
IconArrowRight,
IconArrowsHorizontal,
IconDots,
+ IconEye,
+ IconEyeOff,
IconFileExport,
IconHistory,
IconLink,
@@ -40,6 +42,11 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
+import {
+ useWatchStatusQuery,
+ useWatchPageMutation,
+ useUnwatchPageMutation,
+} from "@/features/page/queries/watcher-query";
interface PageHeaderMenuProps {
readOnly?: boolean;
@@ -123,6 +130,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
+ const { data: watchStatus } = useWatchStatusQuery(page?.id);
+ const watchPage = useWatchPageMutation();
+ const unwatchPage = useUnwatchPageMutation();
const handleCopyLink = () => {
const pageUrl =
@@ -185,6 +195,23 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
>
{t("Copy as Markdown")}
+
+ {watchStatus?.watching ? (
+ }
+ onClick={() => unwatchPage.mutate(page.id)}
+ >
+ {t("Stop watching")}
+
+ ) : (
+ }
+ onClick={() => watchPage.mutate(page.id)}
+ >
+ {t("Watch page")}
+
+ )}
+
}>
diff --git a/apps/client/src/features/page/queries/watcher-query.ts b/apps/client/src/features/page/queries/watcher-query.ts
new file mode 100644
index 00000000..0c9eba0f
--- /dev/null
+++ b/apps/client/src/features/page/queries/watcher-query.ts
@@ -0,0 +1,43 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import {
+ watchPage,
+ unwatchPage,
+ getWatchStatus,
+} from "@/features/page/services/watcher-service";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+
+const WATCHER_KEY = "watcher";
+
+export function useWatchStatusQuery(pageId: string) {
+ return useQuery({
+ queryKey: [WATCHER_KEY, pageId],
+ queryFn: () => getWatchStatus(pageId),
+ enabled: !!pageId,
+ staleTime: 60_000,
+ });
+}
+
+export function useWatchPageMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (pageId: string) => watchPage(pageId),
+ onSuccess: (_data, pageId) => {
+ queryClient.setQueryData([WATCHER_KEY, pageId], { watching: true });
+ notifications.show({ message: t("You are now watching this page") });
+ },
+ });
+}
+
+export function useUnwatchPageMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+ return useMutation({
+ mutationFn: (pageId: string) => unwatchPage(pageId),
+ onSuccess: (_data, pageId) => {
+ queryClient.setQueryData([WATCHER_KEY, pageId], { watching: false });
+ notifications.show({ message: t("You are no longer watching this page") });
+ },
+ });
+}
diff --git a/apps/client/src/features/page/services/watcher-service.ts b/apps/client/src/features/page/services/watcher-service.ts
new file mode 100644
index 00000000..d0c1416b
--- /dev/null
+++ b/apps/client/src/features/page/services/watcher-service.ts
@@ -0,0 +1,16 @@
+import api from "@/lib/api-client";
+
+export async function watchPage(pageId: string): Promise<{ watching: boolean }> {
+ const req = await api.post<{ watching: boolean }>("/pages/watch", { pageId });
+ return req.data;
+}
+
+export async function unwatchPage(pageId: string): Promise<{ watching: boolean }> {
+ const req = await api.post<{ watching: boolean }>("/pages/unwatch", { pageId });
+ return req.data;
+}
+
+export async function getWatchStatus(pageId: string): Promise<{ watching: boolean }> {
+ const req = await api.post<{ watching: boolean }>("/pages/watch-status", { pageId });
+ return req.data;
+}
diff --git a/apps/client/src/features/user/components/notification-pref.tsx b/apps/client/src/features/user/components/notification-pref.tsx
new file mode 100644
index 00000000..e8a983ed
--- /dev/null
+++ b/apps/client/src/features/user/components/notification-pref.tsx
@@ -0,0 +1,117 @@
+import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
+import { updateUser } from "@/features/user/services/user-service.ts";
+import { IUser, IUserSettings } from "@/features/user/types/user.types.ts";
+import { Switch, Text, Title, Stack } from "@mantine/core";
+import { useAtom } from "jotai";
+import React, { useState } from "react";
+import { useTranslation } from "react-i18next";
+import {
+ ResponsiveSettingsRow,
+ ResponsiveSettingsContent,
+ ResponsiveSettingsControl,
+} from "@/components/ui/responsive-settings-row";
+
+type NotificationKey = keyof NonNullable;
+
+const notificationItems: {
+ key: NotificationKey;
+ dtoField: keyof IUser;
+ label: string;
+ description: string;
+}[] = [
+ {
+ key: "page.updated",
+ dtoField: "notificationPageUpdates",
+ label: "Page updates",
+ description: "Get notified when pages you watch are updated.",
+ },
+ {
+ key: "page.userMention",
+ dtoField: "notificationPageUserMention",
+ label: "Page mentions",
+ description: "Get notified when someone mentions you on a page.",
+ },
+ {
+ key: "comment.userMention",
+ dtoField: "notificationCommentUserMention",
+ label: "Comment mentions",
+ description: "Get notified when someone mentions you in a comment.",
+ },
+ {
+ key: "comment.created",
+ dtoField: "notificationCommentCreated",
+ label: "New comments",
+ description:
+ "Get notified about new comments on threads you participate in.",
+ },
+ {
+ key: "comment.resolved",
+ dtoField: "notificationCommentResolved",
+ label: "Resolved comments",
+ description: "Get notified when your comment is resolved.",
+ },
+];
+
+function NotificationToggle({
+ settingKey,
+ dtoField,
+ label,
+ description,
+}: {
+ settingKey: NotificationKey;
+ dtoField: keyof IUser;
+ label: string;
+ description: string;
+}) {
+ const { t } = useTranslation();
+ const [user, setUser] = useAtom(userAtom);
+ const [checked, setChecked] = useState(
+ user.settings?.notifications?.[settingKey] !== false,
+ );
+
+ const handleChange = async (event: React.ChangeEvent) => {
+ const value = event.currentTarget.checked;
+ setChecked(value);
+ try {
+ const updatedUser = await updateUser({ [dtoField]: value } as any);
+ setUser(updatedUser);
+ } catch {
+ setChecked(!value);
+ }
+ };
+
+ return (
+
+
+ {t(label)}
+
+ {t(description)}
+
+
+
+
+
+
+
+ );
+}
+
+export default function NotificationPref() {
+ const { t } = useTranslation();
+
+ return (
+
+ {t("Email notifications")}
+
+ {notificationItems.map((item) => (
+
+ ))}
+
+ );
+}
diff --git a/apps/client/src/features/user/types/user.types.ts b/apps/client/src/features/user/types/user.types.ts
index 80d86706..75d45bfd 100644
--- a/apps/client/src/features/user/types/user.types.ts
+++ b/apps/client/src/features/user/types/user.types.ts
@@ -20,6 +20,11 @@ export interface IUser {
deletedAt: Date;
fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update
+ notificationPageUpdates: boolean; // used for update
+ notificationPageUserMention: boolean; // used for update
+ notificationCommentUserMention: boolean; // used for update
+ notificationCommentCreated: boolean; // used for update
+ notificationCommentResolved: boolean; // used for update
hasGeneratedPassword?: boolean;
}
@@ -33,6 +38,13 @@ export interface IUserSettings {
fullPageWidth: boolean;
pageEditMode: string;
};
+ notifications?: {
+ "page.updated"?: boolean;
+ "page.userMention"?: boolean;
+ "comment.userMention"?: boolean;
+ "comment.created"?: boolean;
+ "comment.resolved"?: boolean;
+ };
}
export enum PageEditMode {
diff --git a/apps/client/src/pages/settings/account/account-preferences.tsx b/apps/client/src/pages/settings/account/account-preferences.tsx
index f082ea1b..caedc1b0 100644
--- a/apps/client/src/pages/settings/account/account-preferences.tsx
+++ b/apps/client/src/pages/settings/account/account-preferences.tsx
@@ -3,6 +3,7 @@ import AccountLanguage from "@/features/user/components/account-language.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref";
+import NotificationPref from "@/features/user/components/notification-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
@@ -33,6 +34,10 @@ export default function AccountPreferences() {
+
+
+
+
>
);
}
diff --git a/apps/server/src/collaboration/extensions/persistence.extension.ts b/apps/server/src/collaboration/extensions/persistence.extension.ts
index 642d0761..d32e4778 100644
--- a/apps/server/src/collaboration/extensions/persistence.extension.ts
+++ b/apps/server/src/collaboration/extensions/persistence.extension.ts
@@ -18,12 +18,10 @@ import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
import {
extractMentions,
- extractPageMentions,
extractUserMentions,
} from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util';
import {
- IPageBacklinkJob,
IPageHistoryJob,
IPageMentionNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
@@ -43,7 +41,6 @@ export class PersistenceExtension implements Extension {
constructor(
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
- @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
@@ -165,13 +162,6 @@ export class PersistenceExtension implements Extension {
await this.collabHistory.addContributors(pageId, editingUserIds);
const mentions = extractMentions(tiptapJson);
- const pageMentions = extractPageMentions(mentions);
-
- await this.generalQueue.add(QueueJob.PAGE_BACKLINKS, {
- pageId: pageId,
- workspaceId: page.workspaceId,
- mentions: pageMentions,
- } as IPageBacklinkJob);
const userMentions = extractUserMentions(mentions);
const oldMentions = page.content ? extractMentions(page.content) : [];
diff --git a/apps/server/src/collaboration/processors/history.processor.ts b/apps/server/src/collaboration/processors/history.processor.ts
index 315dba0b..d7e27f60 100644
--- a/apps/server/src/collaboration/processors/history.processor.ts
+++ b/apps/server/src/collaboration/processors/history.processor.ts
@@ -1,8 +1,17 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
-import { Job } from 'bullmq';
+import { InjectQueue } from '@nestjs/bullmq';
+import { Job, Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
-import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
+import {
+ IPageBacklinkJob,
+ IPageHistoryJob,
+ IPageUpdateNotificationJob,
+} from '../../integrations/queue/constants/queue.interface';
+import {
+ extractMentions,
+ extractPageMentions,
+} from '../../common/helpers/prosemirror/utils';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util';
@@ -18,6 +27,8 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
private readonly watcherService: WatcherService,
+ @InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
+ @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
) {
super();
}
@@ -47,8 +58,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
) {
- const contributorIds =
- await this.collabHistory.popContributors(pageId);
+ const contributorIds = await this.collabHistory.popContributors(pageId);
try {
await this.watcherService.addPageWatchers(
@@ -61,12 +71,39 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
- await this.collabHistory.addContributors(
- pageId,
- contributorIds,
- );
+ await this.collabHistory.addContributors(pageId, contributorIds);
throw err;
}
+
+ const mentions = extractMentions(page.content);
+ const pageMentions = extractPageMentions(mentions);
+
+ await this.generalQueue
+ .add(QueueJob.PAGE_BACKLINKS, {
+ pageId,
+ workspaceId: page.workspaceId,
+ mentions: pageMentions,
+ } as IPageBacklinkJob)
+ .catch((err) => {
+ this.logger.error(
+ `Failed to queue backlinks for ${pageId}: ${err.message}`,
+ );
+ });
+
+ if (contributorIds.length > 0 && lastHistory?.content) {
+ await this.notificationQueue
+ .add(QueueJob.PAGE_UPDATED, {
+ pageId,
+ spaceId: page.spaceId,
+ workspaceId: page.workspaceId,
+ actorIds: contributorIds,
+ } as IPageUpdateNotificationJob)
+ .catch((err) => {
+ this.logger.error(
+ `Failed to queue page update notification for ${pageId}: ${err.message}`,
+ );
+ });
+ }
}
} catch (err) {
throw err;
diff --git a/apps/server/src/core/notification/dto/notification.dto.ts b/apps/server/src/core/notification/dto/notification.dto.ts
index 0b0bde94..b583c746 100644
--- a/apps/server/src/core/notification/dto/notification.dto.ts
+++ b/apps/server/src/core/notification/dto/notification.dto.ts
@@ -1,4 +1,5 @@
-import { IsArray, IsOptional, IsUUID } from 'class-validator';
+import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
+import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
export class NotificationIdDto {
@IsUUID()
@@ -11,3 +12,10 @@ export class MarkNotificationsReadDto {
@IsOptional()
notificationIds?: string[];
}
+
+export class ListNotificationsDto extends PaginationOptions {
+ @IsOptional()
+ @IsString()
+ @IsIn(['direct', 'updates', 'all'])
+ type?: 'direct' | 'updates' | 'all' = 'all';
+}
diff --git a/apps/server/src/core/notification/notification.constants.ts b/apps/server/src/core/notification/notification.constants.ts
index 56d2ecad..8f7f5049 100644
--- a/apps/server/src/core/notification/notification.constants.ts
+++ b/apps/server/src/core/notification/notification.constants.ts
@@ -4,7 +4,45 @@ export const NotificationType = {
COMMENT_RESOLVED: 'comment.resolved',
PAGE_USER_MENTION: 'page.user_mention',
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
+ PAGE_UPDATED: 'page.updated',
} as const;
export type NotificationType =
(typeof NotificationType)[keyof typeof NotificationType];
+
+export type NotificationSettingKey =
+ | 'page.updated'
+ | 'page.userMention'
+ | 'comment.userMention'
+ | 'comment.created'
+ | 'comment.resolved';
+
+export const NotificationTypeToSettingKey: Partial<
+ Record
+> = {
+ [NotificationType.PAGE_UPDATED]: 'page.updated',
+ [NotificationType.PAGE_USER_MENTION]: 'page.userMention',
+ [NotificationType.COMMENT_USER_MENTION]: 'comment.userMention',
+ [NotificationType.COMMENT_CREATED]: 'comment.created',
+ [NotificationType.COMMENT_RESOLVED]: 'comment.resolved',
+};
+
+export type NotificationTab = 'direct' | 'updates' | 'all';
+
+export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [
+ NotificationType.COMMENT_USER_MENTION,
+ NotificationType.COMMENT_CREATED,
+ NotificationType.COMMENT_RESOLVED,
+ NotificationType.PAGE_USER_MENTION,
+ NotificationType.PAGE_PERMISSION_GRANTED,
+];
+
+export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [
+ NotificationType.PAGE_UPDATED,
+];
+
+export function getTypesForTab(tab: NotificationTab): NotificationType[] | undefined {
+ if (tab === 'direct') return DIRECT_NOTIFICATION_TYPES;
+ if (tab === 'updates') return UPDATES_NOTIFICATION_TYPES;
+ return undefined;
+}
diff --git a/apps/server/src/core/notification/notification.controller.ts b/apps/server/src/core/notification/notification.controller.ts
index d041414f..be5ee1d3 100644
--- a/apps/server/src/core/notification/notification.controller.ts
+++ b/apps/server/src/core/notification/notification.controller.ts
@@ -9,9 +9,8 @@ import {
import { NotificationService } from './notification.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
-import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User } from '@docmost/db/types/entity.types';
-import { MarkNotificationsReadDto } from './dto/notification.dto';
+import { ListNotificationsDto, MarkNotificationsReadDto } from './dto/notification.dto';
@UseGuards(JwtAuthGuard)
@Controller('notifications')
@@ -21,10 +20,10 @@ export class NotificationController {
@HttpCode(HttpStatus.OK)
@Post('/')
async getNotifications(
- @Body() pagination: PaginationOptions,
+ @Body() dto: ListNotificationsDto,
@AuthUser() user: User,
) {
- return this.notificationService.findByUserId(user.id, pagination);
+ return this.notificationService.findByUserId(user.id, dto, dto.type);
}
@HttpCode(HttpStatus.OK)
diff --git a/apps/server/src/core/notification/notification.module.ts b/apps/server/src/core/notification/notification.module.ts
index a142eaf8..83778294 100644
--- a/apps/server/src/core/notification/notification.module.ts
+++ b/apps/server/src/core/notification/notification.module.ts
@@ -4,6 +4,7 @@ import { NotificationController } from './notification.controller';
import { NotificationProcessor } from './notification.processor';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
+import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter';
@Module({
imports: [],
@@ -13,6 +14,7 @@ import { PageNotificationService } from './services/page.notification';
NotificationProcessor,
CommentNotificationService,
PageNotificationService,
+ PageUpdateEmailRateLimiter,
],
exports: [NotificationService],
})
diff --git a/apps/server/src/core/notification/notification.processor.ts b/apps/server/src/core/notification/notification.processor.ts
index f7c8b577..e3d3a883 100644
--- a/apps/server/src/core/notification/notification.processor.ts
+++ b/apps/server/src/core/notification/notification.processor.ts
@@ -8,6 +8,7 @@ import {
ICommentNotificationJob,
ICommentResolvedNotificationJob,
IPageMentionNotificationJob,
+ IPageUpdateNotificationJob,
IPermissionGrantedNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
@@ -35,6 +36,7 @@ export class NotificationProcessor
| ICommentNotificationJob
| ICommentResolvedNotificationJob
| IPageMentionNotificationJob
+ | IPageUpdateNotificationJob
| IPermissionGrantedNotificationJob,
void
>,
@@ -76,6 +78,20 @@ export class NotificationProcessor
break;
}
+ case QueueJob.PAGE_UPDATED: {
+ await this.pageNotificationService.processPageUpdate(
+ job.data as IPageUpdateNotificationJob,
+ appUrl,
+ );
+ break;
+ }
+
+ case QueueJob.PAGE_UPDATE_DIGEST: {
+ const { userId } = job.data as unknown as { userId: string };
+ await this.pageNotificationService.processDigest(userId, appUrl);
+ break;
+ }
+
default:
this.logger.warn(`Unknown notification job: ${job.name}`);
}
diff --git a/apps/server/src/core/notification/notification.service.ts b/apps/server/src/core/notification/notification.service.ts
index 493b673e..1f88bf59 100644
--- a/apps/server/src/core/notification/notification.service.ts
+++ b/apps/server/src/core/notification/notification.service.ts
@@ -6,6 +6,8 @@ import { InsertableNotification } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { WsGateway } from '../../ws/ws.gateway';
import { MailService } from '../../integrations/mail/mail.service';
+import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants';
+import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
@Injectable()
export class NotificationService {
@@ -13,12 +15,23 @@ export class NotificationService {
constructor(
private readonly notificationRepo: NotificationRepo,
+ private readonly pagePermissionRepo: PagePermissionRepo,
private readonly wsGateway: WsGateway,
private readonly mailService: MailService,
@InjectKysely() private readonly db: KyselyDB,
) {}
async create(data: InsertableNotification) {
+ const user = await this.db
+ .selectFrom('users')
+ .select(['id'])
+ .where('id', '=', data.userId)
+ .where('deletedAt', 'is', null)
+ .where('deactivatedAt', 'is', null)
+ .executeTakeFirst();
+
+ if (!user) return null;
+
const notification = await this.notificationRepo.insert(data);
this.wsGateway.server
@@ -28,8 +41,35 @@ export class NotificationService {
return notification;
}
- async findByUserId(userId: string, pagination: PaginationOptions) {
- return this.notificationRepo.findByUserId(userId, pagination);
+ async findByUserId(
+ userId: string,
+ pagination: PaginationOptions,
+ type: NotificationTab = 'all',
+ ) {
+ const result = await this.notificationRepo.findByUserId(
+ userId,
+ pagination,
+ type,
+ );
+
+ const pageIds = result.items
+ .map((n: any) => n.pageId)
+ .filter(Boolean);
+
+ if (pageIds.length > 0) {
+ const accessiblePageIds =
+ await this.pagePermissionRepo.filterAccessiblePageIds({
+ pageIds,
+ userId,
+ });
+ const accessibleSet = new Set(accessiblePageIds);
+
+ result.items = result.items.filter(
+ (n: any) => !n.pageId || accessibleSet.has(n.pageId),
+ );
+ }
+
+ return result;
}
async getUnreadCount(userId: string) {
@@ -53,17 +93,27 @@ export class NotificationService {
notificationId: string,
subject: string,
template: any,
+ type?: NotificationType,
) {
try {
const user = await this.db
.selectFrom('users')
- .select(['email'])
+ .select(['email', 'settings'])
.where('id', '=', userId)
.where('deletedAt', 'is', null)
+ .where('deactivatedAt', 'is', null)
.executeTakeFirst();
if (!user?.email) return;
+ if (type) {
+ const settingKey = NotificationTypeToSettingKey[type];
+ if (settingKey) {
+ const settings = user.settings as any;
+ if (settings?.notifications?.[settingKey] === false) return;
+ }
+ }
+
await this.mailService.sendToQueue({
to: user.email,
subject,
diff --git a/apps/server/src/core/notification/services/comment.notification.ts b/apps/server/src/core/notification/services/comment.notification.ts
index e75da302..c79c2895 100644
--- a/apps/server/src/core/notification/services/comment.notification.ts
+++ b/apps/server/src/core/notification/services/comment.notification.ts
@@ -86,12 +86,14 @@ export class CommentNotificationService {
spaceId,
commentId,
});
+ if (!notification) continue;
await this.notificationService.queueEmail(
userId,
notification.id,
`${actor.name} mentioned you in a comment`,
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
+ NotificationType.COMMENT_USER_MENTION,
);
notifiedUserIds.add(userId);
@@ -110,12 +112,14 @@ export class CommentNotificationService {
spaceId,
commentId,
});
+ if (!notification) continue;
await this.notificationService.queueEmail(
recipientId,
notification.id,
`${actor.name} commented on ${pageTitle}`,
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
+ NotificationType.COMMENT_CREATED,
);
}
}
@@ -171,6 +175,7 @@ export class CommentNotificationService {
spaceId,
commentId,
});
+ if (!notification) return;
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
@@ -179,6 +184,7 @@ export class CommentNotificationService {
notification.id,
subject,
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
+ NotificationType.COMMENT_RESOLVED,
);
}
diff --git a/apps/server/src/core/notification/services/page-update-email-rate-limiter.ts b/apps/server/src/core/notification/services/page-update-email-rate-limiter.ts
new file mode 100644
index 00000000..59867f41
--- /dev/null
+++ b/apps/server/src/core/notification/services/page-update-email-rate-limiter.ts
@@ -0,0 +1,43 @@
+import { Injectable } from '@nestjs/common';
+import { RedisService } from '@nestjs-labs/nestjs-ioredis';
+import type { Redis } from 'ioredis';
+
+const KEY_PREFIX = 'page-update:emails:';
+const DIGEST_PREFIX = 'page-update:digest:';
+const TTL_SECONDS = 86400; // 24 hours
+const MAX_IMMEDIATE_EMAILS = 4;
+
+@Injectable()
+export class PageUpdateEmailRateLimiter {
+ private readonly redis: Redis;
+
+ constructor(private readonly redisService: RedisService) {
+ this.redis = this.redisService.getOrThrow();
+ }
+
+ async canSendEmail(userId: string): Promise {
+ const key = KEY_PREFIX + userId;
+ const count = await this.redis.incr(key);
+ await this.redis.expire(key, TTL_SECONDS, 'NX');
+ return count <= MAX_IMMEDIATE_EMAILS;
+ }
+
+ async addToDigest(userId: string, notificationId: string): Promise {
+ const key = DIGEST_PREFIX + userId;
+ const len = await this.redis.rpush(key, notificationId);
+ await this.redis.expire(key, TTL_SECONDS);
+ return len === 1;
+ }
+
+ async popDigest(userId: string): Promise {
+ const key = DIGEST_PREFIX + userId;
+ const [ids] = await this.redis
+ .multi()
+ .lrange(key, 0, -1)
+ .del(key)
+ .exec();
+
+ return (ids?.[1] as string[]) ?? [];
+ }
+
+}
diff --git a/apps/server/src/core/notification/services/page.notification.ts b/apps/server/src/core/notification/services/page.notification.ts
index a8d951dd..9e5c75dd 100644
--- a/apps/server/src/core/notification/services/page.notification.ts
+++ b/apps/server/src/core/notification/services/page.notification.ts
@@ -1,25 +1,43 @@
-import { Injectable } from '@nestjs/common';
+import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
+import { InjectQueue } from '@nestjs/bullmq';
+import { Queue } from 'bullmq';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
IPageMentionNotificationJob,
+ IPageUpdateNotificationJob,
IPermissionGrantedNotificationJob,
} from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
+import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
+import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
+import { PageUpdateEmailRateLimiter } from './page-update-email-rate-limiter';
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
+import { PageUpdateEmail } from '@docmost/transactional/emails/page-update-email';
+import { PageUpdateDigestEmail } from '@docmost/transactional/emails/page-update-digest-email';
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
import { getPageTitle } from '../../../common/helpers';
+import { QueueJob, QueueName } from '../../../integrations/queue/constants';
+
+const PAGE_UPDATE_COOLDOWN_HOURS = 7;
+const DIGEST_DELAY_MS = 12 * 60 * 60 * 1000; // 12 hours
@Injectable()
export class PageNotificationService {
+ private readonly logger = new Logger(PageNotificationService.name);
+
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
+ private readonly notificationRepo: NotificationRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
+ private readonly watcherRepo: WatcherRepo,
+ private readonly rateLimiter: PageUpdateEmailRateLimiter,
+ @InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
) {}
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
@@ -41,10 +59,9 @@ export class PageNotificationService {
);
const usersWithPageAccess =
- await this.pagePermissionRepo.getUserIdsWithPageAccess(
- pageId,
- [...usersWithSpaceAccess],
- );
+ await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
+ ...usersWithSpaceAccess,
+ ]);
const usersWithAccess = new Set(usersWithPageAccess);
const accessibleMentions = newMentions.filter((m) =>
@@ -97,6 +114,7 @@ export class PageNotificationService {
spaceId,
data: { mentionId },
});
+ if (!notification) continue;
const pageUrl = `${basePageUrl}`;
const subject = `${actor.name} mentioned you in ${pageTitle}`;
@@ -106,6 +124,7 @@ export class PageNotificationService {
notification.id,
subject,
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
+ NotificationType.PAGE_USER_MENTION,
);
}
}
@@ -139,6 +158,7 @@ export class PageNotificationService {
spaceId,
data: { role },
});
+ if (!notification) continue;
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
@@ -156,6 +176,232 @@ export class PageNotificationService {
}
}
+ async processPageUpdate(data: IPageUpdateNotificationJob, appUrl: string) {
+ const { pageId, spaceId, workspaceId, actorIds } = data;
+
+ const watcherIds = await this.watcherRepo.getPageWatcherIds(pageId);
+ if (watcherIds.length === 0) return;
+
+ const actorSet = new Set(actorIds);
+ const candidateIds = watcherIds.filter((id) => !actorSet.has(id));
+ if (candidateIds.length === 0) return;
+
+ const eligibleUsers = await this.getEligiblePageUpdateUsers(candidateIds);
+ if (eligibleUsers.size === 0) return;
+
+ const afterPrefs = [...eligibleUsers.keys()];
+
+ const recentlyNotified =
+ await this.notificationRepo.getRecentlyNotifiedUserIds(
+ afterPrefs,
+ pageId,
+ NotificationType.PAGE_UPDATED,
+ PAGE_UPDATE_COOLDOWN_HOURS,
+ );
+ const afterCooldown = afterPrefs.filter((id) => !recentlyNotified.has(id));
+ if (afterCooldown.length === 0) return;
+
+ const usersWithSpaceAccess =
+ await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
+ afterCooldown,
+ spaceId,
+ );
+
+ const usersWithPageAccess =
+ await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
+ ...usersWithSpaceAccess,
+ ]);
+ if (usersWithPageAccess.length === 0) return;
+
+ const recipientIds = new Set(usersWithPageAccess);
+ const actorId = actorIds[0];
+
+ const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
+ if (!context) return;
+
+ const { actor, pageTitle, basePageUrl } = context;
+
+ for (const userId of recipientIds) {
+ const notification = await this.notificationService.create({
+ userId,
+ workspaceId,
+ type: NotificationType.PAGE_UPDATED,
+ actorId,
+ pageId,
+ spaceId,
+ });
+ if (!notification) continue;
+
+ const canSend = await this.rateLimiter.canSendEmail(userId);
+ if (canSend) {
+ await this.notificationService.queueEmail(
+ userId,
+ notification.id,
+ `${actor.name} updated ${pageTitle}`,
+ PageUpdateEmail({
+ userName: eligibleUsers.get(userId) ?? '',
+ actorName: actor.name,
+ pageTitle,
+ pageUrl: basePageUrl,
+ }),
+ NotificationType.PAGE_UPDATED,
+ );
+ } else {
+ const isFirst = await this.rateLimiter.addToDigest(
+ userId,
+ notification.id,
+ );
+ if (isFirst) {
+ await this.scheduleDigest(userId, workspaceId);
+ }
+ }
+ }
+ }
+
+ private async getEligiblePageUpdateUsers(
+ userIds: string[],
+ ): Promise