Merge branch 'main' into perm-x

This commit is contained in:
Philipinho
2026-02-15 23:53:58 +00:00
129 changed files with 8680 additions and 3833 deletions
+10 -10
View File
@@ -14,14 +14,14 @@
"@docmost/editor-ext": "workspace:*", "@docmost/editor-ext": "workspace:*",
"@emoji-mart/data": "^1.2.1", "@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1", "@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-c158187", "@excalidraw/excalidraw": "0.18.0-3a5ef40",
"@mantine/core": "^8.3.12", "@mantine/core": "^8.3.14",
"@mantine/dates": "^8.3.12", "@mantine/dates": "^8.3.14",
"@mantine/form": "^8.3.12", "@mantine/form": "^8.3.14",
"@mantine/hooks": "^8.3.12", "@mantine/hooks": "^8.3.14",
"@mantine/modals": "^8.3.12", "@mantine/modals": "^8.3.14",
"@mantine/notifications": "^8.3.12", "@mantine/notifications": "^8.3.14",
"@mantine/spotlight": "^8.3.12", "@mantine/spotlight": "^8.3.14",
"@tabler/icons-react": "^3.36.1", "@tabler/icons-react": "^3.36.1",
"@tanstack/react-query": "^5.90.17", "@tanstack/react-query": "^5.90.17",
"alfaaz": "^1.1.0", "alfaaz": "^1.1.0",
@@ -41,7 +41,7 @@
"mantine-form-zod-resolver": "^1.3.0", "mantine-form-zod-resolver": "^1.3.0",
"mermaid": "^11.12.2", "mermaid": "^11.12.2",
"mitt": "^3.0.1", "mitt": "^3.0.1",
"posthog-js": "^1.255.1", "posthog-js": "1.345.5",
"react": "^18.3.1", "react": "^18.3.1",
"react-arborist": "3.4.0", "react-arborist": "3.4.0",
"react-clear-modal": "^2.0.17", "react-clear-modal": "^2.0.17",
@@ -66,7 +66,7 @@
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1", "@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.15.0", "eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.2", "eslint-plugin-react": "^7.37.2",
"eslint-plugin-react-hooks": "^5.1.0", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.16",
@@ -355,6 +355,8 @@
"Insert current date": "Aktuelles Datum einfügen", "Insert current date": "Aktuelles Datum einfügen",
"Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren", "Draw and sketch excalidraw diagrams": "Excalidraw-Diagramme zeichnen und skizzieren",
"Multiple": "Mehrere", "Multiple": "Mehrere",
"Turn into": "In verwandeln",
"Text align": "Text ausrichten",
"Heading {{level}}": "Überschrift {{level}}", "Heading {{level}}": "Überschrift {{level}}",
"Toggle title": "Titel umschalten", "Toggle title": "Titel umschalten",
"Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein", "Write anything. Enter \"/\" for commands": "Schreiben Sie irgendetwas. Geben Sie \"/\" für Befehle ein",
@@ -582,13 +584,33 @@
"Ask AI": "KI fragen", "Ask AI": "KI fragen",
"AI is thinking...": "Die KI überlegt...", "AI is thinking...": "Die KI überlegt...",
"Ask a question...": "Fragen stellen...", "Ask a question...": "Fragen stellen...",
"AI-powered search (Ask AI)": "KI-gestützte Suche (KI fragen)", "AI Answers": "KI-Antworten",
"AI-powered search (AI Answers)": "KI-unterstützte Suche (KI-Antworten)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Die KI-Suche verwendet Vektor-Einbettungen, um semantische Suchfunktionen in Ihrem Arbeitsbereich bereitzustellen.",
"Toggle AI search": "KI-Suche umschalten", "Toggle AI search": "KI-Suche umschalten",
"Generative AI (Ask AI)": "Generative KI (KI fragen)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Aktivieren Sie die KI-unterstützte Inhaltserstellung im Editor. Ermöglicht Benutzern das Erzeugen, Verbessern, Übersetzen und Transformieren von Text.",
"Toggle generative AI": "Generative KI umschalten",
"Sources": "Quellen", "Sources": "Quellen",
"Ask AI not available for attachments": "KI fragen nicht für Anhänge verfügbar", "AI Answers not available for attachments": "KI-Antworten sind für Anhänge nicht verfügbar",
"No answer available": "Keine Antwort verfügbar", "No answer available": "Keine Antwort verfügbar",
"Background color": "Hintergrundfarbe", "Background color": "Hintergrundfarbe",
"Highlight color": "Hervorhebungsfarbe", "Highlight color": "Hervorhebungsfarbe",
"Remove color": "Farbe entfernen" "Remove color": "Farbe entfernen",
"Notifications": "Benachrichtigungen",
"No notifications": "Keine Benachrichtigungen",
"No unread notifications": "Keine ungelesenen Benachrichtigungen",
"All notifications": "Alle Benachrichtigungen",
"Unread only": "Nur ungelesen",
"Mark all as read": "Alle als gelesen markieren",
"Mark as read": "Als gelesen markieren",
"More options": "Weitere Optionen",
"mentioned you in a comment": "hat Sie in einem Kommentar erwähnt",
"commented on a page": "hat auf einer Seite kommentiert",
"resolved a comment": "hat einen Kommentar gelöst",
"mentioned you on a page": "hat Sie auf einer Seite erwähnt",
"Today": "Heute",
"Yesterday": "Gestern",
"This week": "Diese Woche",
"Older": "Älter"
} }
@@ -355,6 +355,8 @@
"Insert current date": "Insert current date", "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", "Multiple": "Multiple",
"Turn into": "Turn into",
"Text align": "Text align",
"Heading {{level}}": "Heading {{level}}", "Heading {{level}}": "Heading {{level}}",
"Toggle title": "Toggle title", "Toggle title": "Toggle title",
"Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands", "Write anything. Enter \"/\" for commands": "Write anything. Enter \"/\" for commands",
@@ -582,13 +584,33 @@
"Ask AI": "Ask AI", "Ask AI": "Ask AI",
"AI is thinking...": "AI is thinking...", "AI is thinking...": "AI is thinking...",
"Ask a question...": "Ask a question...", "Ask a question...": "Ask a question...",
"AI-powered search (Ask AI)": "AI-powered search (Ask AI)", "AI Answers": "AI Answers",
"AI-powered search (AI Answers)": "AI-powered search (AI Answers)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
"Toggle AI search": "Toggle AI search", "Toggle AI search": "Toggle AI search",
"Generative AI (Ask AI)": "Generative AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
"Toggle generative AI": "Toggle generative AI",
"Sources": "Sources", "Sources": "Sources",
"Ask AI not available for attachments": "Ask AI not available for attachments", "AI Answers not available for attachments": "AI Answers not available for attachments",
"No answer available": "No answer available", "No answer available": "No answer available",
"Background color": "Background color", "Background color": "Background color",
"Highlight color": "Highlight color", "Highlight color": "Highlight color",
"Remove color": "Remove color" "Remove color": "Remove color",
"Notifications": "Notifications",
"No notifications": "No notifications",
"No unread notifications": "No unread notifications",
"All notifications": "All notifications",
"Unread only": "Unread only",
"Mark all as read": "Mark all as read",
"Mark as read": "Mark as read",
"More options": "More options",
"mentioned you in a comment": "mentioned you in a comment",
"commented on a page": "commented on a page",
"resolved a comment": "resolved a comment",
"mentioned you on a page": "mentioned you on a page",
"Today": "Today",
"Yesterday": "Yesterday",
"This week": "This week",
"Older": "Older"
} }
@@ -355,6 +355,8 @@
"Insert current date": "Insertar fecha actual", "Insert current date": "Insertar fecha actual",
"Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw", "Draw and sketch excalidraw diagrams": "Dibujar y esbozar diagramas de Excalidraw",
"Multiple": "Múltiple", "Multiple": "Múltiple",
"Turn into": "Convertir en",
"Text align": "Alineación del texto",
"Heading {{level}}": "Encabezado {{level}}", "Heading {{level}}": "Encabezado {{level}}",
"Toggle title": "Alternar título", "Toggle title": "Alternar título",
"Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos", "Write anything. Enter \"/\" for commands": "Escribe cualquier cosa. Ingresa \"/\" para comandos",
@@ -582,13 +584,33 @@
"Ask AI": "Preguntar a IA", "Ask AI": "Preguntar a IA",
"AI is thinking...": "IA está pensando...", "AI is thinking...": "IA está pensando...",
"Ask a question...": "Haz una pregunta...", "Ask a question...": "Haz una pregunta...",
"AI-powered search (Ask AI)": "Búsqueda impulsada por IA (Preguntar a IA)", "AI Answers": "Respuestas de IA",
"AI-powered search (AI Answers)": "Búsqueda impulsada por IA (Respuestas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La búsqueda de IA utiliza incrustaciones vectoriales para proporcionar capacidades de búsqueda semántica en todo el contenido de su espacio de trabajo.",
"Toggle AI search": "Alternar búsqueda de IA", "Toggle AI search": "Alternar búsqueda de IA",
"Generative AI (Ask AI)": "IA generativa (Preguntar a la IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar la generación de contenido impulsada por IA en el editor. Permite a los usuarios generar, mejorar, traducir y transformar texto.",
"Toggle generative AI": "Activar IA generativa",
"Sources": "Fuentes", "Sources": "Fuentes",
"Ask AI not available for attachments": "Preguntar a IA no está disponible para adjuntos", "AI Answers not available for attachments": "Respuestas de IA no disponibles para archivos adjuntos",
"No answer available": "No hay respuesta disponible", "No answer available": "No hay respuesta disponible",
"Background color": "Color de fondo", "Background color": "Color de fondo",
"Highlight color": "Color de resaltado", "Highlight color": "Color de resaltado",
"Remove color": "Eliminar color" "Remove color": "Eliminar color",
"Notifications": "Notificaciones",
"No notifications": "Sin notificaciones",
"No unread notifications": "No hay notificaciones no leídas",
"All notifications": "Todas las notificaciones",
"Unread only": "Solo no leídas",
"Mark all as read": "Marcar todo como leído",
"Mark as read": "Marcar como leído",
"More options": "Más opciones",
"mentioned you in a comment": "te mencionó en un comentario",
"commented on a page": "comentó en una página",
"resolved a comment": "resolvió un comentario",
"mentioned you on a page": "te mencionó en una página",
"Today": "Hoy",
"Yesterday": "Ayer",
"This week": "Esta semana",
"Older": "Más antiguo"
} }
@@ -355,6 +355,8 @@
"Insert current date": "Insérer la date actuelle", "Insert current date": "Insérer la date actuelle",
"Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw", "Draw and sketch excalidraw diagrams": "Dessiner et esquisser des diagrammes Excalidraw",
"Multiple": "Multiple", "Multiple": "Multiple",
"Turn into": "Transformer en",
"Text align": "Alignement du texte",
"Heading {{level}}": "Titre {{level}}", "Heading {{level}}": "Titre {{level}}",
"Toggle title": "Basculer le titre", "Toggle title": "Basculer le titre",
"Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes", "Write anything. Enter \"/\" for commands": "Écrivez n'importe quoi. Entrez \"/\" pour les commandes",
@@ -582,13 +584,33 @@
"Ask AI": "Demander à l'IA", "Ask AI": "Demander à l'IA",
"AI is thinking...": "L'IA réfléchit...", "AI is thinking...": "L'IA réfléchit...",
"Ask a question...": "Posez une question...", "Ask a question...": "Posez une question...",
"AI-powered search (Ask AI)": "Recherche assistée par l'IA (Demander à l'IA)", "AI Answers": "Réponses IA",
"AI-powered search (AI Answers)": "Recherche propulsée par IA (Réponses IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La recherche IA utilise des incorporations vectorielles pour fournir des capacités de recherche sémantique à travers le contenu de votre espace de travail.",
"Toggle AI search": "Basculer la recherche IA", "Toggle AI search": "Basculer la recherche IA",
"Generative AI (Ask AI)": "IA générative (Demandez à l'IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Activer la génération de contenu assistée par IA dans l'éditeur. Permet aux utilisateurs de générer, améliorer, traduire et transformer du texte.",
"Toggle generative AI": "Activer/désactiver l'IA générative",
"Sources": "Sources", "Sources": "Sources",
"Ask AI not available for attachments": "Demande à l'IA non disponible pour les pièces jointes", "AI Answers not available for attachments": "Réponses IA non disponibles pour les pièces jointes",
"No answer available": "Pas de réponse disponible", "No answer available": "Pas de réponse disponible",
"Background color": "Couleur de fond", "Background color": "Couleur de fond",
"Highlight color": "Couleur de surbrillance", "Highlight color": "Couleur de surbrillance",
"Remove color": "Supprimer la couleur" "Remove color": "Supprimer la couleur",
"Notifications": "Notifications",
"No notifications": "Aucune notification",
"No unread notifications": "Aucune notification non lue",
"All notifications": "Toutes les notifications",
"Unread only": "Non lues uniquement",
"Mark all as read": "Tout marquer comme lu",
"Mark as read": "Marquer comme lu",
"More options": "Plus d'options",
"mentioned you in a comment": "vous a mentionné dans un commentaire",
"commented on a page": "a commenté une page",
"resolved a comment": "a résolu un commentaire",
"mentioned you on a page": "vous a mentionné sur une page",
"Today": "Aujourd'hui",
"Yesterday": "Hier",
"This week": "Cette semaine",
"Older": "Plus ancien"
} }
@@ -355,6 +355,8 @@
"Insert current date": "Inserisci la data corrente", "Insert current date": "Inserisci la data corrente",
"Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw", "Draw and sketch excalidraw diagrams": "Disegna e schizza diagrammi excalidraw",
"Multiple": "Multiplo", "Multiple": "Multiplo",
"Turn into": "Trasforma in",
"Text align": "Allinea testo",
"Heading {{level}}": "Intestazione {{level}}", "Heading {{level}}": "Intestazione {{level}}",
"Toggle title": "Attiva/disattiva titolo", "Toggle title": "Attiva/disattiva titolo",
"Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi", "Write anything. Enter \"/\" for commands": "Scrivi qualcosa. Digita \"/\" per i comandi",
@@ -582,13 +584,33 @@
"Ask AI": "Chiedi all'AI", "Ask AI": "Chiedi all'AI",
"AI is thinking...": "L'AI sta pensando...", "AI is thinking...": "L'AI sta pensando...",
"Ask a question...": "Fai una domanda...", "Ask a question...": "Fai una domanda...",
"AI-powered search (Ask AI)": "Ricerca potenziata dall'AI (Chiedi all'AI)", "AI Answers": "Risposte AI",
"AI-powered search (AI Answers)": "Ricerca con AI (Risposte AI)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "La ricerca AI utilizza embeddings vettoriali per fornire capacità di ricerca semantica nel contenuto della tua area di lavoro.",
"Toggle AI search": "Attiva/disattiva ricerca AI", "Toggle AI search": "Attiva/disattiva ricerca AI",
"Generative AI (Ask AI)": "AI generativa (Chiedi AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Abilita la generazione di contenuti con AI nell'editor. Consente agli utenti di generare, migliorare, tradurre e trasformare il testo.",
"Toggle generative AI": "Attiva/Disattiva AI generativa",
"Sources": "Fonti", "Sources": "Fonti",
"Ask AI not available for attachments": "Chiedere all'AI non è disponibile per gli allegati", "AI Answers not available for attachments": "Risposte AI non disponibili per gli allegati",
"No answer available": "Nessuna risposta disponibile", "No answer available": "Nessuna risposta disponibile",
"Background color": "Colore di sfondo", "Background color": "Colore di sfondo",
"Highlight color": "Colore evidenziato", "Highlight color": "Colore evidenziato",
"Remove color": "Rimuovi colore" "Remove color": "Rimuovi colore",
"Notifications": "Notifiche",
"No notifications": "Nessuna notifica",
"No unread notifications": "Nessuna notifica non letta",
"All notifications": "Tutte le notifiche",
"Unread only": "Solo non lette",
"Mark all as read": "Segna tutto come letto",
"Mark as read": "Segna come letto",
"More options": "Altre opzioni",
"mentioned you in a comment": "ti ha menzionato in un commento",
"commented on a page": "ha commentato una pagina",
"resolved a comment": "ha risolto un commento",
"mentioned you on a page": "ti ha menzionato in una pagina",
"Today": "Oggi",
"Yesterday": "Ieri",
"This week": "Questa settimana",
"Older": "Più vecchie"
} }
@@ -355,6 +355,8 @@
"Insert current date": "現在の日付を挿入します", "Insert current date": "現在の日付を挿入します",
"Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します", "Draw and sketch excalidraw diagrams": "Excalidraw 図を挿入します",
"Multiple": "複数", "Multiple": "複数",
"Turn into": "変換する",
"Text align": "テキストの配置",
"Heading {{level}}": "見出し {{level}}", "Heading {{level}}": "見出し {{level}}",
"Toggle title": "タイトルの表示/非表示を切り替える", "Toggle title": "タイトルの表示/非表示を切り替える",
"Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します", "Write anything. Enter \"/\" for commands": "文字を入力するか、「/」でコマンドを呼び出します",
@@ -582,13 +584,33 @@
"Ask AI": "AIに質問する", "Ask AI": "AIに質問する",
"AI is thinking...": "AIが考え中...", "AI is thinking...": "AIが考え中...",
"Ask a question...": "質問を入力...", "Ask a question...": "質問を入力...",
"AI-powered search (Ask AI)": "AIによる検索(AIに質問)", "AI Answers": "AI回答",
"AI-powered search (AI Answers)": "AI搭載検索 (AI回答)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI検索はベクター埋め込みを使用してワークスペース全体の意味検索を実現します",
"Toggle AI search": "AI検索を切り替え", "Toggle AI search": "AI検索を切り替え",
"Generative AI (Ask AI)": "生成AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "エディターでAIを活用したコンテンツ生成を有効にします。ユーザーがテキストの生成、改善、翻訳、および変換を行うことができます。",
"Toggle generative AI": "生成AIを切り替える",
"Sources": "ソース", "Sources": "ソース",
"Ask AI not available for attachments": "添付ファイルにはAI質問は利用できません", "AI Answers not available for attachments": "添付ファイルにはAI回答を利用できません",
"No answer available": "回答がありません", "No answer available": "回答がありません",
"Background color": "背景色", "Background color": "背景色",
"Highlight color": "ハイライト色", "Highlight color": "ハイライト色",
"Remove color": "色を削除" "Remove color": "色を削除",
"Notifications": "通知",
"No notifications": "通知なし",
"No unread notifications": "未読の通知はありません",
"All notifications": "すべての通知",
"Unread only": "未読のみ",
"Mark all as read": "すべてを既読にする",
"Mark as read": "既読にする",
"More options": "その他のオプション",
"mentioned you in a comment": "コメントであなたに言及しました",
"commented on a page": "ページにコメントしました",
"resolved a comment": "コメントを解決しました",
"mentioned you on a page": "ページ上であなたに言及しました",
"Today": "今日",
"Yesterday": "昨日",
"This week": "今週",
"Older": "以前のもの"
} }
@@ -355,6 +355,8 @@
"Insert current date": "현재 날짜 삽입", "Insert current date": "현재 날짜 삽입",
"Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치", "Draw and sketch excalidraw diagrams": "Excalidraw diagram 그리기 및 스케치",
"Multiple": "복제", "Multiple": "복제",
"Turn into": "변경하기",
"Text align": "텍스트 정렬",
"Heading {{level}}": "제목 {{level}}", "Heading {{level}}": "제목 {{level}}",
"Toggle title": "제목 토글", "Toggle title": "제목 토글",
"Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요", "Write anything. Enter \"/\" for commands": "아무거나 입력하세요. 명령어를 사용하려면 \"/\"를 입력하세요",
@@ -582,13 +584,33 @@
"Ask AI": "AI에게 묻기", "Ask AI": "AI에게 묻기",
"AI is thinking...": "AI가 생각 중입니다...", "AI is thinking...": "AI가 생각 중입니다...",
"Ask a question...": "질문하세요...", "Ask a question...": "질문하세요...",
"AI-powered search (Ask AI)": "AI 지원 검색 (AI에게 묻기)", "AI Answers": "AI 답변",
"AI-powered search (AI Answers)": "AI 구동 검색 (AI 답변)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI 검색은 벡터 임베딩을 사용하여 작업공간 콘텐츠에 대한 의미 검색 기능을 제공합니다.",
"Toggle AI search": "AI 검색 전환", "Toggle AI search": "AI 검색 전환",
"Generative AI (Ask AI)": "생성 AI (Ask AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "편집기에서 AI 구동 콘텐츠 생성을 활성화합니다. 사용자가 텍스트를 생성, 개선, 번역 및 변환할 수 있습니다.",
"Toggle generative AI": "생성 AI 토글",
"Sources": "출처", "Sources": "출처",
"Ask AI not available for attachments": "AI에게 묻기 기능은 첨부 파일에 대해 사용할 수 없습니다", "AI Answers not available for attachments": "첨부 파일에 대해 AI 답변을 사용할 수 없습니다",
"No answer available": "답변을 제공할 수 없습니다", "No answer available": "답변을 제공할 수 없습니다",
"Background color": "배경 색", "Background color": "배경 색",
"Highlight color": "강조 색", "Highlight color": "강조 색",
"Remove color": "색 제거" "Remove color": "색 제거",
"Notifications": "알림",
"No notifications": "알림 없음",
"No unread notifications": "읽지 않은 알림 없음",
"All notifications": "모든 알림",
"Unread only": "읽지 않음만",
"Mark all as read": "모두 읽음으로 표시",
"Mark as read": "읽음으로 표시",
"More options": "추가 옵션",
"mentioned you in a comment": "댓글에서 당신을 언급했습니다",
"commented on a page": "페이지에 댓글을 달았습니다",
"resolved a comment": "댓글을 해결했습니다",
"mentioned you on a page": "페이지에서 당신을 언급했습니다",
"Today": "오늘",
"Yesterday": "어제",
"This week": "이번 주",
"Older": "이전"
} }
@@ -355,6 +355,8 @@
"Insert current date": "Huidige datum invoeren", "Insert current date": "Huidige datum invoeren",
"Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen", "Draw and sketch excalidraw diagrams": "Teken en schets excalidraw diagrammen",
"Multiple": "Meerdere", "Multiple": "Meerdere",
"Turn into": "Omzetten naar",
"Text align": "Tekstuitlijning",
"Heading {{level}}": "Kop {{level}}", "Heading {{level}}": "Kop {{level}}",
"Toggle title": "Schakel titel in/uit", "Toggle title": "Schakel titel in/uit",
"Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's", "Write anything. Enter \"/\" for commands": "Schrijf iets. Voer \"/\" in voor commando's",
@@ -582,13 +584,33 @@
"Ask AI": "Vraag AI", "Ask AI": "Vraag AI",
"AI is thinking...": "AI is aan het nadenken...", "AI is thinking...": "AI is aan het nadenken...",
"Ask a question...": "Stel een vraag...", "Ask a question...": "Stel een vraag...",
"AI-powered search (Ask AI)": "AI-ondersteunde zoekopdracht (Vraag AI)", "AI Answers": "AI Antwoorden",
"AI-powered search (AI Answers)": "AI-gestuurde zoekopdracht (AI Antwoorden)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI-zoekopdracht maakt gebruik van vectorembeddings om semantische zoekmogelijkheden te bieden in uw werkruimte-inhoud.",
"Toggle AI search": "Schakel AI-zoekopdracht in/uit", "Toggle AI search": "Schakel AI-zoekopdracht in/uit",
"Generative AI (Ask AI)": "Generatieve AI (Vraag het AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Schakel AI-gestuurde inhoudsgeneratie in de editor in. Hiermee kunnen gebruikers tekst genereren, verbeteren, vertalen en transformeren.",
"Toggle generative AI": "Generatieve AI schakelen",
"Sources": "Bronnen", "Sources": "Bronnen",
"Ask AI not available for attachments": "Vraag AI is niet beschikbaar voor bijlages", "AI Answers not available for attachments": "AI Antwoorden niet beschikbaar voor bijlagen",
"No answer available": "Geen antwoord beschikbaar", "No answer available": "Geen antwoord beschikbaar",
"Background color": "Achtergrondkleur", "Background color": "Achtergrondkleur",
"Highlight color": "Markeerkleur", "Highlight color": "Markeerkleur",
"Remove color": "Kleur verwijderen" "Remove color": "Kleur verwijderen",
"Notifications": "Meldingen",
"No notifications": "Geen meldingen",
"No unread notifications": "Geen ongelezen meldingen",
"All notifications": "Alle meldingen",
"Unread only": "Alleen ongelezen",
"Mark all as read": "Markeer alles als gelezen",
"Mark as read": "Markeer als gelezen",
"More options": "Meer opties",
"mentioned you in a comment": "noemde je in een reactie",
"commented on a page": "reageerde op een pagina",
"resolved a comment": "heeft een opmerking opgelost",
"mentioned you on a page": "noemde je op een pagina",
"Today": "Vandaag",
"Yesterday": "Gisteren",
"This week": "Deze week",
"Older": "Ouder"
} }
@@ -355,6 +355,8 @@
"Insert current date": "Insira a data atual", "Insert current date": "Insira a data atual",
"Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw", "Draw and sketch excalidraw diagrams": "Desenhe e esboce diagramas Excalidraw",
"Multiple": "Múltiplo", "Multiple": "Múltiplo",
"Turn into": "Transformar em",
"Text align": "Alinhar texto",
"Heading {{level}}": "Título {{level}}", "Heading {{level}}": "Título {{level}}",
"Toggle title": "Alternar título", "Toggle title": "Alternar título",
"Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos", "Write anything. Enter \"/\" for commands": "Escreva qualquer coisa. Digite \"/\" para comandos",
@@ -582,13 +584,33 @@
"Ask AI": "Pergunte à IA", "Ask AI": "Pergunte à IA",
"AI is thinking...": "IA está pensando...", "AI is thinking...": "IA está pensando...",
"Ask a question...": "Faça uma pergunta...", "Ask a question...": "Faça uma pergunta...",
"AI-powered search (Ask AI)": "Pesquisa com IA (Pergunte à IA)", "AI Answers": "Respostas de IA",
"AI-powered search (AI Answers)": "Pesquisa com IA (Respostas de IA)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "A pesquisa IA usa vetores de incorporação para fornecer capacidades de pesquisa semântica em todo o conteúdo do seu espaço de trabalho.",
"Toggle AI search": "Alternar pesquisa de IA", "Toggle AI search": "Alternar pesquisa de IA",
"Generative AI (Ask AI)": "IA generativa (Perguntar à IA)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Habilitar geração de conteúdo com IA no editor. Permite aos usuários gerar, melhorar, traduzir e transformar texto.",
"Toggle generative AI": "Alternar IA generativa",
"Sources": "Fontes", "Sources": "Fontes",
"Ask AI not available for attachments": "Perguntar à IA não está disponível para anexos", "AI Answers not available for attachments": "Respostas de IA não disponíveis para anexos",
"No answer available": "Nenhuma resposta disponível", "No answer available": "Nenhuma resposta disponível",
"Background color": "Cor de fundo", "Background color": "Cor de fundo",
"Highlight color": "Cor de destaque", "Highlight color": "Cor de destaque",
"Remove color": "Remover cor" "Remove color": "Remover cor",
"Notifications": "Notificações",
"No notifications": "Sem notificações",
"No unread notifications": "Sem notificações não lidas",
"All notifications": "Todas as notificações",
"Unread only": "Somente não lidas",
"Mark all as read": "Marcar todas como lidas",
"Mark as read": "Marcar como lida",
"More options": "Mais opções",
"mentioned you in a comment": "mencionou você em um comentário",
"commented on a page": "comentou em uma página",
"resolved a comment": "resolveu um comentário",
"mentioned you on a page": "mencionou você em uma página",
"Today": "Hoje",
"Yesterday": "Ontem",
"This week": "Esta semana",
"Older": "Mais antigo"
} }
@@ -355,6 +355,8 @@
"Insert current date": "Вставить текущую дату", "Insert current date": "Вставить текущую дату",
"Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw", "Draw and sketch excalidraw diagrams": "Вставить и рисовать диаграммы Excalidraw",
"Multiple": "Несколько", "Multiple": "Несколько",
"Turn into": "Преобразовать в",
"Text align": "Выравнивание текста",
"Heading {{level}}": "Заголовок {{level}}", "Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Переключить заголовок", "Toggle title": "Переключить заголовок",
"Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд", "Write anything. Enter \"/\" for commands": "Начните писать. Введите \"/\" для списка команд",
@@ -582,13 +584,33 @@
"Ask AI": "Спросить ИИ", "Ask AI": "Спросить ИИ",
"AI is thinking...": "ИИ обрабатывает запрос...", "AI is thinking...": "ИИ обрабатывает запрос...",
"Ask a question...": "Задайте вопрос...", "Ask a question...": "Задайте вопрос...",
"AI-powered search (Ask AI)": "Поиск на базе ИИ (Спросить ИИ)", "AI Answers": "Ответы ИИ",
"AI-powered search (AI Answers)": "Поиск на базе ИИ (Ответы ИИ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Поиск ИИ использует векторные встраивания для обеспечения семантического поиска по содержимому вашего рабочего пространства.",
"Toggle AI search": "Переключить поиск ИИ", "Toggle AI search": "Переключить поиск ИИ",
"Generative AI (Ask AI)": "Генеративный ИИ (Спросить ИИ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Включите создание контента на базе ИИ в редакторе. Позволяет пользователям генерировать, улучшать, переводить и преобразовывать текст.",
"Toggle generative AI": "Переключить генеративный ИИ",
"Sources": "Источники", "Sources": "Источники",
"Ask AI not available for attachments": "Функция \"Спросить ИИ\" недоступна для вложений", "AI Answers not available for attachments": "Ответы ИИ недоступны для вложений",
"No answer available": "Ответ недоступен", "No answer available": "Ответ недоступен",
"Background color": "Цвет фона", "Background color": "Цвет фона",
"Highlight color": "Цвет выделения", "Highlight color": "Цвет выделения",
"Remove color": "Удалить цвет" "Remove color": "Удалить цвет",
"Notifications": "Уведомления",
"No notifications": "Нет уведомлений",
"No unread notifications": "Нет непрочитанных уведомлений",
"All notifications": "Все уведомления",
"Unread only": "Только непрочитанные",
"Mark all as read": "Отметить все как прочитанные",
"Mark as read": "Отметить как прочитанное",
"More options": "Больше возможностей",
"mentioned you in a comment": "упомянул вас в комментарии",
"commented on a page": "прокомментировал на странице",
"resolved a comment": "разрешил комментарий",
"mentioned you on a page": "упомянул вас на странице",
"Today": "Сегодня",
"Yesterday": "Вчера",
"This week": "На этой неделе",
"Older": "Старше"
} }
@@ -355,6 +355,8 @@
"Insert current date": "Вставити поточну дату", "Insert current date": "Вставити поточну дату",
"Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw", "Draw and sketch excalidraw diagrams": "Вставити та малювати діаграми Excalidraw",
"Multiple": "Декілька", "Multiple": "Декілька",
"Turn into": "Перетворити",
"Text align": "Вирівнювання тексту",
"Heading {{level}}": "Заголовок {{level}}", "Heading {{level}}": "Заголовок {{level}}",
"Toggle title": "Перемкнути заголовок", "Toggle title": "Перемкнути заголовок",
"Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд", "Write anything. Enter \"/\" for commands": "Почніть писати. Введіть \"/\" для списку команд",
@@ -582,13 +584,33 @@
"Ask AI": "Запитати ШІ", "Ask AI": "Запитати ШІ",
"AI is thinking...": "ШІ думає...", "AI is thinking...": "ШІ думає...",
"Ask a question...": "Задайте питання...", "Ask a question...": "Задайте питання...",
"AI-powered search (Ask AI)": "Пошук на базі ШІ (Запитати ШІ)", "AI Answers": "Відповіді ШІ",
"AI-powered search (AI Answers)": "Пошук на базі ШІ (Відповіді ШІ)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "Пошук з ШІ використовує векторні вбудовування для надання можливостей семантичного пошуку у вашому робочому вмісті.",
"Toggle AI search": "Переключити пошук з ШІ", "Toggle AI search": "Переключити пошук з ШІ",
"Generative AI (Ask AI)": "Генеративний ШІ (Запитати ШІ)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "Увімкнути генерацію контенту за допомогою ШІ в редакторі. Дозволяє користувачам генерувати, покращувати, перекладати та трансформувати текст.",
"Toggle generative AI": "Переключити генеративний ШІ",
"Sources": "Джерела", "Sources": "Джерела",
"Ask AI not available for attachments": "Запитати ШІ недоступно для вкладень", "AI Answers not available for attachments": "Відповіді ШІ недоступні для вкладень",
"No answer available": "Відповідь недоступна", "No answer available": "Відповідь недоступна",
"Background color": "Колір фону", "Background color": "Колір фону",
"Highlight color": "Колір підсвічування", "Highlight color": "Колір підсвічування",
"Remove color": "Видалити колір" "Remove color": "Видалити колір",
"Notifications": "Сповіщення",
"No notifications": "Немає сповіщень",
"No unread notifications": "Немає непрочитаних сповіщень",
"All notifications": "Усі сповіщення",
"Unread only": "Тільки непрочитані",
"Mark all as read": "Позначити все як прочитане",
"Mark as read": "Позначити як прочитане",
"More options": "Більше опцій",
"mentioned you in a comment": "згадали вас у коментарі",
"commented on a page": "прокоментували на сторінці",
"resolved a comment": "вирішили коментар",
"mentioned you on a page": "згадали вас на сторінці",
"Today": "Сьогодні",
"Yesterday": "Вчора",
"This week": "Цього тижня",
"Older": "Старіші"
} }
@@ -355,6 +355,8 @@
"Insert current date": "插入当前日期", "Insert current date": "插入当前日期",
"Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表", "Draw and sketch excalidraw diagrams": "绘制 Excalidraw 图表",
"Multiple": "多个", "Multiple": "多个",
"Turn into": "变成",
"Text align": "文本对齐",
"Heading {{level}}": "{{level}} 级标题", "Heading {{level}}": "{{level}} 级标题",
"Toggle title": "切换标题", "Toggle title": "切换标题",
"Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令", "Write anything. Enter \"/\" for commands": "开始编写内容,输入 \"/\" 以使用指令",
@@ -582,13 +584,33 @@
"Ask AI": "询问AI", "Ask AI": "询问AI",
"AI is thinking...": "AI正在思考...", "AI is thinking...": "AI正在思考...",
"Ask a question...": "提问...", "Ask a question...": "提问...",
"AI-powered search (Ask AI)": "AI驱动的搜索(询问AI", "AI Answers": "AI答案",
"AI-powered search (AI Answers)": "AI驱动的搜索 (AI答案)",
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.": "AI搜索使用向量嵌入提供跨工作空间内容的语义搜索功能。",
"Toggle AI search": "切换AI搜索", "Toggle AI search": "切换AI搜索",
"Generative AI (Ask AI)": "生成型AI (询问AI)",
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.": "在编辑器中启用AI驱动的内容生成。允许用户生成、改进、翻译和转换文本。",
"Toggle generative AI": "切换生成型AI",
"Sources": "来源", "Sources": "来源",
"Ask AI not available for attachments": "附件不支持询问AI", "AI Answers not available for attachments": "AI答案不适用于附件",
"No answer available": "无可用答案", "No answer available": "无可用答案",
"Background color": "背景颜色", "Background color": "背景颜色",
"Highlight color": "突出显示颜色", "Highlight color": "突出显示颜色",
"Remove color": "移除颜色" "Remove color": "移除颜色",
"Notifications": "通知",
"No notifications": "没有通知",
"No unread notifications": "没有未读通知",
"All notifications": "所有通知",
"Unread only": "仅未读",
"Mark all as read": "标记所有为已读",
"Mark as read": "标记为已读",
"More options": "更多选项",
"mentioned you in a comment": "在评论中提到你",
"commented on a page": "在页面上评论",
"resolved a comment": "解决了一个评论",
"mentioned you on a page": "在页面上提到你",
"Today": "今天",
"Yesterday": "昨天",
"This week": "本周",
"Older": "较早"
} }
@@ -22,6 +22,7 @@ import {
searchSpotlight, searchSpotlight,
shareSearchSpotlight, shareSearchSpotlight,
} from "@/features/search/constants.ts"; } from "@/features/search/constants.ts";
import { NotificationPopover } from "@/features/notification/components/notification-popover.tsx";
const links = [{ link: APP_ROUTE.HOME, label: "Home" }]; const links = [{ link: APP_ROUTE.HOME, label: "Home" }];
@@ -97,6 +98,7 @@ export function AppHeader() {
</div> </div>
<Group px={"xl"} wrap="nowrap"> <Group px={"xl"} wrap="nowrap">
<NotificationPopover />
{isCloud() && isTrial && trialDaysLeft !== 0 && ( {isCloud() && isTrial && trialDaysLeft !== 0 && (
<Badge <Badge
variant="light" variant="light"
@@ -115,7 +115,6 @@ const groupedData: DataGroup[] = [
icon: IconSparkles, icon: IconSparkles,
path: "/settings/ai", path: "/settings/ai",
isAdmin: true, isAdmin: true,
isSelfhosted: true,
}, },
], ],
}, },
@@ -0,0 +1,61 @@
.aiMenu {
display: flex;
flex-direction: column;
width: 100%;
max-width: 600px;
min-height: 2.25rem;
}
.aiInput {
width: 100%;
& input {
height: 44px;
border-radius: 22px;
padding-left: 20px;
padding-right: 40px;
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
font-size: var(--mantine-font-size-sm);
&:focus {
border-color: light-dark(
var(--mantine-color-gray-4),
var(--mantine-color-dark-3)
);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12);
}
}
}
.menuItemSelected {
background-color: var(--mantine-color-gray-1);
@mixin dark {
background-color: var(--mantine-color-dark-5);
}
}
.resultPreview {
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4));
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.resultPreviewWrapper {
font-size: var(--mantine-font-size-md);
line-height: 1.6;
padding: var(--mantine-spacing-md);
*:first-child {
margin-top: 0;
}
*:last-child {
margin-bottom: 0;
}
}
@@ -0,0 +1,325 @@
import { Editor } from "@tiptap/react";
import { ActionIcon, TextInput, Tooltip } from "@mantine/core";
import { useDebouncedCallback, useMediaQuery } from "@mantine/hooks";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useAtom } from "jotai";
import { IconArrowUp } from "@tabler/icons-react";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms.ts";
import { useAiGenerateStreamMutation } from "@/ee/ai/queries/ai-query.ts";
import { AiAction } from "@/ee/ai/types/ai.types.ts";
import { CommandItem, commandItems, CommandSet } from "./command-items.ts";
import { CommandSelector } from "./command-selector.tsx";
import { ResultPreview } from "./result-preview.tsx";
import classes from "./ai-menu.module.css";
import { marked } from "marked";
import { DOMSerializer } from "@tiptap/pm/model";
import { htmlToMarkdown } from "@docmost/editor-ext";
import { useLocation } from "react-router-dom";
interface EditorAiMenuProps {
editor: Editor | null;
}
const EditorAiMenu = ({ editor }: EditorAiMenuProps): JSX.Element | null => {
const aiGenerateStreamMutation = useAiGenerateStreamMutation();
const location = useLocation();
const isSmBreakpoint = useMediaQuery("(max-width: 48em)");
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const containerRef = useRef<HTMLDivElement | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
const [prompt, setPrompt] = useState("");
const [output, setOutput] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [activeCommandSet, setActiveCommandSet] = useState<CommandSet>("main");
const [lastAction, setLastAction] = useState<CommandItem | null>(null);
const [menuPlacement, setMenuPlacement] = useState<{
top: number;
left: number;
width: number;
}>({
top: 0,
left: 0,
width: 0,
});
const currentItems = useMemo(() => {
return commandItems[activeCommandSet].filter((item) => {
return item.name.toLowerCase().includes(prompt.toLowerCase());
});
}, [prompt, output, activeCommandSet]);
const updateMenuPlacement = useCallback(() => {
if (!editor || !showAiMenu) return;
const { view } = editor;
const { to } = editor.state.selection;
const editorRect = view.dom.getBoundingClientRect();
const cursorCoords = view.coordsAtPos(to);
const topOffset = 8;
const editorPadding = isSmBreakpoint ? 16 : 48;
setMenuPlacement({
top: cursorCoords.bottom + topOffset + window.scrollY,
left: editorRect.left + editorPadding + window.scrollX,
width: editorRect.width - editorPadding * 2,
});
}, [editor, showAiMenu, isSmBreakpoint]);
const resetMenu = useCallback(() => {
setPrompt("");
setOutput("");
setActiveCommandSet("main");
setLastAction(null);
aiGenerateStreamMutation.reset();
}, [aiGenerateStreamMutation.reset]);
const debouncedUpdateMenuPlacement = useDebouncedCallback(
updateMenuPlacement,
60,
);
const handleGenerate = useCallback(
(item?: CommandItem) => {
if (!editor || isLoading) return;
let command: CommandItem | null = item || null;
if (!command) {
if (!prompt) return;
command = {
id: "custom",
name: "Custom",
action: AiAction.CUSTOM,
prompt,
};
}
const { from, to } = editor.state.selection;
const slice = editor.state.doc.slice(from, to);
const serializer = DOMSerializer.fromSchema(editor.schema);
const fragment = serializer.serializeFragment(slice.content);
const wrapper = document.createElement("div");
wrapper.appendChild(fragment);
const content = htmlToMarkdown(wrapper.innerHTML);
setOutput("");
setIsLoading(true);
aiGenerateStreamMutation.mutate({
action: command.action,
prompt: command.prompt,
content,
onChunk: (chunk) => {
setOutput((output) => output + chunk.content);
},
onComplete: () => {
setIsLoading(false);
setActiveCommandSet("result");
},
onError: () => {
setIsLoading(false);
resetMenu();
},
});
setLastAction(command);
},
[
editor,
prompt,
isLoading,
aiGenerateStreamMutation.mutateAsync,
resetMenu,
],
);
const handleCommand = useCallback(
(item?: CommandItem) => {
setPrompt("");
if (!item) {
return handleGenerate();
}
if (item.id === "back") {
return setActiveCommandSet("main");
}
if (item.id === "result-replace") {
const chain = editor.chain().focus();
if (lastAction.action === AiAction.CONTINUE_WRITING) {
chain.setTextSelection(editor.state.selection.to);
}
const html = (marked.parse(output) as string).trim();
// Strip <p> wrapper for single-paragraph output to preserve inline context
const content =
html.startsWith("<p>") &&
html.endsWith("</p>") &&
html.lastIndexOf("<p>") === 0
? html.slice(3, -4)
: html;
chain.insertContent(content).run();
return setShowAiMenu(false);
}
if (item.id === "result-insert-below") {
editor
.chain()
.focus()
.setTextSelection(editor.state.selection.to)
.insertContent(marked.parse(output))
.run();
return setShowAiMenu(false);
}
if (item.id === "result-copy") {
navigator.clipboard.writeText(output);
return setShowAiMenu(false);
}
if (item.id === "result-discard") {
setOutput("");
return resetMenu();
}
if (item.id === "result-try-again" && lastAction) {
return handleGenerate(lastAction);
}
if (item.subCommandSet) {
return setActiveCommandSet(item.subCommandSet);
}
return handleGenerate(item);
},
[editor, output, lastAction, handleGenerate, resetMenu],
);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
const totalItems = currentItems.length;
const cycleSize = totalItems + 1;
if (event.key === "Escape") {
return setShowAiMenu(false);
}
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
return setSelectedIndex((selectedIndex) => {
const direction = event.key === "ArrowDown" ? 1 : -1;
const newIndex = selectedIndex + direction;
if (newIndex < -1) return cycleSize - 1;
if (newIndex >= cycleSize) return 0;
return newIndex;
});
}
if (event.key === "Enter") {
event.preventDefault();
return handleCommand(currentItems[selectedIndex]);
}
},
[currentItems, selectedIndex],
);
useEffect(() => {
if (!editor) return;
const handleClose = () => setShowAiMenu(false);
const observer = new ResizeObserver(() => {
debouncedUpdateMenuPlacement();
});
updateMenuPlacement();
editor.on("focus", handleClose);
editor.on("blur", handleClose);
window.addEventListener("resize", debouncedUpdateMenuPlacement);
window.addEventListener("scroll", debouncedUpdateMenuPlacement, true);
observer.observe(editor.view.dom);
return () => {
editor.off("focus", handleClose);
editor.off("blur", handleClose);
window.removeEventListener("resize", debouncedUpdateMenuPlacement);
window.removeEventListener("scroll", debouncedUpdateMenuPlacement, true);
observer.disconnect();
};
}, [editor, updateMenuPlacement, debouncedUpdateMenuPlacement]);
useEffect(() => {
setShowAiMenu(false);
}, [location]);
useEffect(() => {
if (showAiMenu) {
resetMenu();
}
}, [showAiMenu, resetMenu]);
useEffect(() => {
// Focus input when menu opens or command set changes
requestAnimationFrame(() => {
inputRef.current?.focus({ preventScroll: true });
});
}, [showAiMenu, isLoading, currentItems]);
useEffect(() => {
if (!currentItems.length) {
setSelectedIndex(-1);
}
setSelectedIndex(prompt || activeCommandSet !== "main" ? 0 : -1);
}, [prompt, activeCommandSet, currentItems]);
if (!showAiMenu) return null;
return createPortal(
<div
style={{
zIndex: 200,
position: "absolute",
top: menuPlacement.top,
left: menuPlacement.left,
width: menuPlacement.width,
pointerEvents: "none",
}}
>
<div
className={classes.aiMenu}
style={{ pointerEvents: "auto" }}
tabIndex={0}
ref={containerRef}
>
<ResultPreview output={output} isLoading={isLoading} />
<CommandSelector
selectedIndex={selectedIndex}
isLoading={isLoading}
output={output}
currentItems={currentItems}
handleCommand={handleCommand}
>
<TextInput
ref={inputRef}
className={classes.aiInput}
placeholder="Ask AI..."
data-autofocus
value={prompt}
disabled={isLoading}
onChange={(e) => setPrompt(e.currentTarget.value)}
rightSection={
<ActionIcon
disabled={!prompt || isLoading}
variant="filled"
color="blue"
radius="xl"
size="sm"
onClick={() => handleGenerate()}
>
<IconArrowUp size={14} stroke={2.5} />
</ActionIcon>
}
onKeyDown={handleKeyDown}
/>
</CommandSelector>
</div>
</div>,
document.body,
);
};
export { EditorAiMenu };
@@ -0,0 +1,219 @@
import { AiAction } from "@/ee/ai/types/ai.types.ts";
import {
IconSparkles,
IconArrowsMaximize,
IconArrowsMinimize,
IconWriting,
IconHelp,
IconList,
IconMoodSmile,
IconLanguage,
IconTrash,
IconRefresh,
IconChevronLeft,
IconCheck,
IconArrowDownLeft,
IconCopy,
IconTextPlus,
IconAlignJustified,
} from "@tabler/icons-react";
interface CommandItem {
name: string;
id: string;
icon?: typeof IconSparkles;
action?: AiAction;
prompt?: string;
subCommandSet?: CommandSet;
}
type CommandSet = "main" | "tone" | "translate" | "result";
const mainItems: CommandItem[] = [
{
id: "improve-writing",
name: "Improve writing",
icon: IconSparkles,
action: AiAction.IMPROVE_WRITING,
},
{
id: "fix-spelling-grammar",
name: "Fix spelling & grammar",
icon: IconCheck,
action: AiAction.FIX_SPELLING_GRAMMAR,
},
{
id: "make-longer",
name: "Make longer",
icon: IconTextPlus,
action: AiAction.MAKE_LONGER,
},
{
id: "make-shorter",
name: "Make shorter",
icon: IconAlignJustified,
action: AiAction.MAKE_SHORTER,
},
{
id: "continue-writing",
name: "Continue writing",
icon: IconWriting,
action: AiAction.CONTINUE_WRITING,
},
{
id: "explain",
name: "Explain",
icon: IconHelp,
action: AiAction.EXPLAIN,
},
{
id: "summarize",
name: "Summarize",
icon: IconList,
action: AiAction.SUMMARIZE,
},
{
id: "change-tone",
name: "Change tone",
icon: IconMoodSmile,
subCommandSet: "tone",
},
{
id: "translate",
name: "Translate",
icon: IconLanguage,
subCommandSet: "translate",
},
];
const toneItems: CommandItem[] = [
{
id: "back",
name: "Back",
icon: IconChevronLeft,
},
{
id: "tone-professional",
name: "Professional",
icon: IconMoodSmile,
action: AiAction.CHANGE_TONE,
prompt: "Professional",
},
{
id: "tone-casual",
name: "Casual",
icon: IconMoodSmile,
action: AiAction.CHANGE_TONE,
prompt: "Casual",
},
{
id: "tone-friendly",
name: "Friendly",
icon: IconMoodSmile,
action: AiAction.CHANGE_TONE,
prompt: "Friendly",
},
];
const translateItems: CommandItem[] = [
{
id: "back",
name: "Back",
icon: IconChevronLeft,
},
{
id: "translate-english",
name: "English",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "English",
},
{
id: "translate-spanish",
name: "Spanish",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Spanish",
},
{
id: "translate-german",
name: "German",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "German",
},
{
id: "translate-french",
name: "French",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "French",
},
{
id: "translate-dutch",
name: "Dutch",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Dutch",
},
{
id: "translate-portuguese",
name: "Portuguese",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Portuguese",
},
{
id: "translate-italian",
name: "Italian",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Italian",
},
{
id: "translate-japanese",
name: "Japanese",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Japanese",
},
{
id: "translate-korean",
name: "Korean",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Korean",
},
{
id: "translate-swedish",
name: "Swedish",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Swedish",
},
{
id: "translate-chinese",
name: "Chinese (Simplified)",
icon: IconLanguage,
action: AiAction.TRANSLATE,
prompt: "Simplified Chinese",
},
];
const resultItems: CommandItem[] = [
{ id: "result-replace", name: "Replace", icon: IconCheck },
{ id: "result-insert-below", name: "Insert below", icon: IconArrowDownLeft },
{ id: "result-copy", name: "Copy", icon: IconCopy },
{ id: "result-discard", name: "Discard", icon: IconTrash },
{
id: "result-try-again",
name: "Try again",
icon: IconRefresh,
},
];
const commandItems: Record<CommandSet, CommandItem[]> = {
main: mainItems,
tone: toneItems,
translate: translateItems,
result: resultItems,
};
export type { CommandItem, CommandSet };
export { commandItems };
@@ -0,0 +1,72 @@
import { Loader, Menu, ScrollArea } from "@mantine/core";
import { IconChevronRight } from "@tabler/icons-react";
import { ReactNode } from "react";
import { CommandItem } from "./command-items.ts";
import classes from "./ai-menu.module.css";
interface CommandSelectorProps {
selectedIndex: number;
isLoading: boolean;
output: string;
currentItems: CommandItem[];
children: ReactNode;
handleCommand(item: CommandItem): void;
}
const CommandSelector = ({
selectedIndex,
children,
isLoading,
output,
currentItems,
handleCommand,
}: CommandSelectorProps) => {
return (
<Menu
opened={!isLoading && currentItems.length > 0}
middlewares={{ flip: false }}
position="bottom-start"
offset={4}
width={250}
trapFocus={false}
shadow="lg"
>
<Menu.Target>{children}</Menu.Target>
<Menu.Dropdown>
<ScrollArea.Autosize type="scroll" scrollbarSize={5} mah={300}>
{currentItems.map((item, index) => {
const isSelected = selectedIndex === index;
const showLoader =
isLoading && output === "" && !item.subCommandSet;
return (
<Menu.Item
key={item.id}
className={isSelected ? classes.menuItemSelected : undefined}
leftSection={
showLoader ? (
<Loader size={14} />
) : item.icon ? (
<item.icon size={16} />
) : undefined
}
rightSection={
item.subCommandSet ? (
<IconChevronRight size={14} />
) : undefined
}
onClick={() => handleCommand(item)}
disabled={isLoading}
>
{item.name}
</Menu.Item>
);
})}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
);
};
export { CommandSelector };
@@ -0,0 +1,32 @@
import { Loader, Paper, ScrollArea } from "@mantine/core";
import DOMPurify from "dompurify";
import { marked } from "marked";
import { memo } from "react";
import classes from "./ai-menu.module.css";
interface ResultPreviewProps {
output: string;
isLoading: boolean;
}
const ResultPreview = memo(({ output, isLoading }: ResultPreviewProps) => {
if (!output && !isLoading) return;
const parsedOutput = `${marked.parse(output)}`;
return (
<Paper mb={4} shadow="lg" radius="md" className={classes.resultPreview}>
<ScrollArea.Autosize mah={300} type="scroll" scrollbarSize={5}>
<div className={classes.resultPreviewWrapper}>
{parsedOutput && (
<div
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(parsedOutput) }}
/>
)}
{isLoading && <Loader size={12} ml="xs" display="inline-block" />}
</div>
</ScrollArea.Autosize>
</Paper>
);
});
export { ResultPreview };
@@ -15,7 +15,7 @@ export default function EnableAiSearch() {
<> <>
<Group justify="space-between" wrap="nowrap" gap="xl"> <Group justify="space-between" wrap="nowrap" gap="xl">
<div> <div>
<Text size="md">{t("AI-powered search (Ask AI)")}</Text> <Text size="md">{t("AI-powered search (AI Answers)")}</Text>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{t( {t(
"AI search uses vector embeddings to provide semantic search capabilities across your workspace content.", "AI search uses vector embeddings to provide semantic search capabilities across your workspace content.",
@@ -0,0 +1,48 @@
import { Group, Text, Switch } from "@mantine/core";
import { useAtom } from "jotai";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { notifications } from "@mantine/notifications";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
export default function EnableGenerativeAi() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.generative);
const hasAccess = useIsCloudEE();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ generativeAi: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Generative AI (Ask AI)")}</Text>
<Text size="sm" c="dimmed">
{t(
"Enable AI-powered content generation in the editor. Allows users to generate, improve, translate and transform text.",
)}
</Text>
</div>
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
/>
</Group>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import { useMutation, UseMutationResult } from "@tanstack/react-query"; import { useMutation, UseMutationResult } from "@tanstack/react-query";
import { useState, useCallback } from "react"; import { useState, useCallback } from "react";
import { askAi, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts"; import { aiAnswers, IAiSearchResponse } from "@/ee/ai/services/ai-search-service.ts";
import { IPageSearchParams } from "@/features/search/types/search.types.ts"; import { IPageSearchParams } from "@/features/search/types/search.types.ts";
// @ts-ignore // @ts-ignore
@@ -26,7 +26,7 @@ export function useAiSearch(): UseAiSearchResult {
const { contentType, ...apiParams } = params; const { contentType, ...apiParams } = params;
return await askAi(apiParams, (chunk) => { return await aiAnswers(apiParams, (chunk) => {
if (chunk.content) { if (chunk.content) {
setStreamingAnswer((prev) => prev + chunk.content); setStreamingAnswer((prev) => prev + chunk.content);
} }
+10 -7
View File
@@ -1,25 +1,25 @@
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
import { getAppName, isCloud } from "@/lib/config.ts"; import { getAppName } from "@/lib/config.ts";
import SettingsTitle from "@/components/settings/settings-title.tsx"; import SettingsTitle from "@/components/settings/settings-title.tsx";
import React from "react"; import React from "react";
import useUserRole from "@/hooks/use-user-role.tsx"; import useUserRole from "@/hooks/use-user-role.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import useLicense from "@/ee/hooks/use-license.tsx";
import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx"; import EnableAiSearch from "@/ee/ai/components/enable-ai-search.tsx";
import { Alert } from "@mantine/core"; import EnableGenerativeAi from "@/ee/ai/components/enable-generative-ai.tsx";
import { Alert, Stack } from "@mantine/core";
import { IconInfoCircle } from "@tabler/icons-react"; import { IconInfoCircle } from "@tabler/icons-react";
import { useIsCloudEE } from "@/hooks/use-is-cloud-ee.tsx";
import { isCloud } from "@/lib/config.ts";
export default function AiSettings() { export default function AiSettings() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isAdmin } = useUserRole(); const { isAdmin } = useUserRole();
const { hasLicenseKey } = useLicense(); const hasAccess = useIsCloudEE();
if (!isAdmin) { if (!isAdmin) {
return null; return null;
} }
const hasAccess = isCloud() || (!isCloud() && hasLicenseKey);
return ( return (
<> <>
<Helmet> <Helmet>
@@ -40,7 +40,10 @@ export default function AiSettings() {
</Alert> </Alert>
)} )}
<EnableAiSearch /> <Stack gap="md">
{!isCloud() && <EnableAiSearch />}
<EnableGenerativeAi />
</Stack>
</> </>
); );
} }
@@ -15,11 +15,11 @@ export interface IAiSearchResponse {
}>; }>;
} }
export async function askAi( export async function aiAnswers(
params: IPageSearchParams, params: IPageSearchParams,
onChunk?: (chunk: { content?: string; sources?: any[] }) => void, onChunk?: (chunk: { content?: string; sources?: any[] }) => void,
): Promise<IAiSearchResponse> { ): Promise<IAiSearchResponse> {
const response = await fetch("/api/ai/ask", { const response = await fetch("/api/ai/answers", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
+6 -3
View File
@@ -43,13 +43,16 @@ export async function generateAiContentStream(
} }
const processStream = async () => { const processStream = async () => {
let buffer = "";
try { try {
while (true) { while (true) {
const { done, value } = await reader.read(); const { done, value } = await reader.read();
if (done) break; if (done) break;
const chunk = decoder.decode(value, { stream: true }); buffer += decoder.decode(value, { stream: true });
const lines = chunk.split("\n"); const lines = buffer.split("\n");
buffer = lines.pop() || "";
for (const line of lines) { for (const line of lines) {
if (line.startsWith("data: ")) { if (line.startsWith("data: ")) {
@@ -66,7 +69,7 @@ export async function generateAiContentStream(
onChunk(parsed); onChunk(parsed);
} }
} catch (e) { } catch (e) {
// Ignore parse errors for incomplete chunks // Skip invalid JSON
} }
} }
} }
+1
View File
@@ -6,6 +6,7 @@ export enum AiAction {
SIMPLIFY = "simplify", SIMPLIFY = "simplify",
CHANGE_TONE = "change_tone", CHANGE_TONE = "change_tone",
SUMMARIZE = "summarize", SUMMARIZE = "summarize",
EXPLAIN = "explain",
CONTINUE_WRITING = "continue_writing", CONTINUE_WRITING = "continue_writing",
TRANSLATE = "translate", TRANSLATE = "translate",
CUSTOM = "custom", CUSTOM = "custom",
@@ -31,6 +31,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
const [currentUser] = useAtom(currentUserAtom); const [currentUser] = useAtom(currentUserAtom);
const [, setAsideState] = useAtom(asideStateAtom); const [, setAsideState] = useAtom(asideStateAtom);
const useClickOutsideRef = useClickOutside(() => { const useClickOutsideRef = useClickOutside(() => {
if (document.querySelector("#mention")) return;
handleDialogClose(); handleDialogClose();
}); });
const createCommentMutation = useCreateCommentMutation(); const createCommentMutation = useCreateCommentMutation();
@@ -105,6 +106,7 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
position={{ bottom: 500, right: 50 }} position={{ bottom: 500, right: 50 }}
withCloseButton withCloseButton
withBorder withBorder
data-comment-dialog
> >
<Stack gap={2}> <Stack gap={2}>
<Group> <Group>
@@ -1,14 +1,15 @@
import { EditorContent, useEditor } from "@tiptap/react"; import { EditorContent, ReactNodeViewRenderer, useEditor } from "@tiptap/react";
import { Placeholder } from "@tiptap/extension-placeholder"; import { Placeholder } from "@tiptap/extension-placeholder";
import { Underline } from "@tiptap/extension-underline";
import { Link } from "@tiptap/extension-link";
import { StarterKit } from "@tiptap/starter-kit"; import { StarterKit } from "@tiptap/starter-kit";
import { Mention, LinkExtension } from "@docmost/editor-ext";
import classes from "./comment.module.css"; import classes from "./comment.module.css";
import { useFocusWithin } from "@mantine/hooks"; import { useFocusWithin } from "@mantine/hooks";
import clsx from "clsx"; import clsx from "clsx";
import { forwardRef, useEffect, useImperativeHandle } from "react"; import { forwardRef, useEffect, useImperativeHandle } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import EmojiCommand from "@/features/editor/extensions/emoji-command"; import EmojiCommand from "@/features/editor/extensions/emoji-command";
import mentionRenderItems from "@/features/editor/components/mention/mention-suggestion";
import MentionView from "@/features/editor/components/mention/mention-view";
interface CommentEditorProps { interface CommentEditorProps {
defaultContent?: any; defaultContent?: any;
@@ -39,13 +40,29 @@ const CommentEditor = forwardRef(
StarterKit.configure({ StarterKit.configure({
gapcursor: false, gapcursor: false,
dropcursor: false, dropcursor: false,
link: false,
}), }),
Placeholder.configure({ Placeholder.configure({
placeholder: placeholder || t("Reply..."), placeholder: placeholder || t("Reply..."),
}), }),
Underline, LinkExtension,
Link,
EmojiCommand, EmojiCommand,
Mention.configure({
suggestion: {
allowSpaces: true,
items: () => [],
// @ts-ignore
render: mentionRenderItems,
},
HTMLAttributes: {
class: "mention",
},
}).extend({
addNodeView() {
this.editor.isInitialized = true;
return ReactNodeViewRenderer(MentionView);
},
}),
], ],
editorProps: { editorProps: {
handleDOMEvents: { handleDOMEvents: {
@@ -60,7 +77,8 @@ const CommentEditor = forwardRef(
].includes(event.key) ].includes(event.key)
) { ) {
const emojiCommand = document.querySelector("#emoji-command"); const emojiCommand = document.querySelector("#emoji-command");
if (emojiCommand) { const mentionPopup = document.querySelector("#mention");
if (emojiCommand || mentionPopup) {
return true; return true;
} }
} }
@@ -108,7 +126,11 @@ const CommentEditor = forwardRef(
})); }));
return ( return (
<div ref={focusRef} className={classes.commentEditor}> <div
ref={focusRef}
className={classes.commentEditor}
data-editable={editable || undefined}
>
<EditorContent <EditorContent
editor={commentEditor} editor={commentEditor}
className={clsx(classes.ProseMirror, { [classes.focused]: focused })} className={clsx(classes.ProseMirror, { [classes.focused]: focused })}
@@ -32,11 +32,14 @@
max-width: 100%; max-width: 100%;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
max-height: 20vh;
padding-left: 6px; padding-left: 6px;
padding-right: 6px; padding-right: 6px;
margin-top: 10px; margin-top: 10px;
margin-bottom: 2px; margin-bottom: 2px;
}
&[data-editable] .ProseMirror :global(.ProseMirror){
max-height: 50vh;
overflow: hidden auto; overflow: hidden auto;
} }
@@ -8,3 +8,5 @@ export const titleEditorAtom = atom<Editor | null>(null);
export const readOnlyEditorAtom = atom<Editor | null>(null); export const readOnlyEditorAtom = atom<Editor | null>(null);
export const yjsConnectionStatusAtom = atom<string>(""); export const yjsConnectionStatusAtom = atom<string>("");
export const showAiMenuAtom = atom(false);
@@ -1,11 +1,53 @@
.bubbleMenu { .bubbleMenu {
display: flex; display: flex;
flex-wrap: nowrap;
overflow-x: auto;
max-width: 100vw;
width: fit-content; width: fit-content;
border-radius: 2px; box-shadow: 0 4px 12px light-dark(#cfcfcf, #0f0f0f);
border-radius: 6px;
border: 1px solid border: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8)); light-dark(var(--mantine-color-gray-3), var(--mantine-color-gray-8));
background-color: light-dark(
var(--mantine-color-white),
var(--mantine-color-dark-6)
);
> * {
flex-shrink: 0;
}
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
.active { .active {
color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5)); color: light-dark(var(--mantine-color-blue-8), var(--mantine-color-gray-5));
} }
} }
.buttonRoot {
height: 34px;
padding-left: rem(8);
padding-right: rem(4);
border: none;
border-radius: 6px;
}
.buttonSeparator {
border-right: 1px solid
light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4)) !important;
}
.divider {
width: 1px;
height: 16px;
align-self: center;
margin: 0 4px;
background-color: light-dark(
var(--mantine-color-gray-3),
var(--mantine-color-dark-3)
);
}
@@ -9,10 +9,11 @@ import {
IconStrikethrough, IconStrikethrough,
IconUnderline, IconUnderline,
IconMessage, IconMessage,
IconSparkles,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import clsx from "clsx"; import clsx from "clsx";
import classes from "./bubble-menu.module.css"; import classes from "./bubble-menu.module.css";
import { ActionIcon, rem, Tooltip } from "@mantine/core"; import { ActionIcon, Button, rem, Tooltip } from "@mantine/core";
import { ColorSelector } from "./color-selector"; import { ColorSelector } from "./color-selector";
import { NodeSelector } from "./node-selector"; import { NodeSelector } from "./node-selector";
import { TextAlignmentSelector } from "./text-alignment-selector"; import { TextAlignmentSelector } from "./text-alignment-selector";
@@ -20,11 +21,13 @@ import {
draftCommentIdAtom, draftCommentIdAtom,
showCommentPopupAtom, showCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom"; } from "@/features/comment/atoms/comment-atom";
import { useAtom } from "jotai"; import { useAtom, useAtomValue } from "jotai";
import { v7 as uuid7 } from "uuid"; import { v7 as uuid7 } from "uuid";
import { isCellSelection, isTextSelected } from "@docmost/editor-ext"; import { isCellSelection, isTextSelected } from "@docmost/editor-ext";
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx"; import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { showAiMenuAtom } from "@/features/editor/atoms/editor-atoms";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom";
export interface BubbleMenuItem { export interface BubbleMenuItem {
name: string; name: string;
@@ -39,14 +42,22 @@ type EditorBubbleMenuProps = Omit<BubbleMenuProps, "children" | "editor"> & {
export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => { export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showAiMenu, setShowAiMenu] = useAtom(showAiMenuAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom); const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const workspace = useAtomValue(workspaceAtom);
const isGenerativeAiEnabled = workspace?.settings?.ai?.generative === true;
const [, setDraftCommentId] = useAtom(draftCommentIdAtom); const [, setDraftCommentId] = useAtom(draftCommentIdAtom);
const showCommentPopupRef = useRef(showCommentPopup); const showCommentPopupRef = useRef(showCommentPopup);
const showAiMenuRef = useRef(showAiMenu);
useEffect(() => { useEffect(() => {
showCommentPopupRef.current = showCommentPopup; showCommentPopupRef.current = showCommentPopup;
}, [showCommentPopup]); }, [showCommentPopup]);
useEffect(() => {
showAiMenuRef.current = showAiMenu;
}, [showAiMenu]);
const editorState = useEditorState({ const editorState = useEditorState({
editor: props.editor, editor: props.editor,
selector: (ctx) => { selector: (ctx) => {
@@ -123,6 +134,7 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
empty || empty ||
isNodeSelection(selection) || isNodeSelection(selection) ||
isCellSelection(selection) || isCellSelection(selection) ||
showAiMenuRef.current ||
showCommentPopupRef?.current showCommentPopupRef?.current
) { ) {
return false; return false;
@@ -146,9 +158,31 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false); const [isLinkSelectorOpen, setIsLinkSelectorOpen] = useState(false);
const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false); const [isColorSelectorOpen, setIsColorSelectorOpen] = useState(false);
// Hide the bubble menu immediately when AI menu is shown
if (showAiMenu) return;
return ( return (
<BubbleMenu {...bubbleMenuProps} style={{ zIndex: 200, position: "relative"}}> <BubbleMenu
{...bubbleMenuProps}
style={{ zIndex: 200, position: "relative" }}
>
<div className={classes.bubbleMenu}> <div className={classes.bubbleMenu}>
{isGenerativeAiEnabled && (
<>
<Button
variant="default"
className={clsx(classes.buttonRoot)}
radius="0"
leftSection={<IconSparkles size={16} />}
onClick={() => {
setShowAiMenu(true);
}}
>
{t("Ask AI")}
</Button>
<div className={classes.divider} />
</>
)}
<NodeSelector <NodeSelector
editor={props.editor} editor={props.editor}
isOpen={isNodeSelectorOpen} isOpen={isNodeSelectorOpen}
@@ -212,16 +246,18 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
}} }}
/> />
<ActionIcon <Tooltip label={t(commentItem.name)} withArrow withinPortal={false}>
variant="default" <ActionIcon
size="lg" variant="default"
radius="0" size="lg"
aria-label={t(commentItem.name)} radius="6px"
style={{ border: "none" }} aria-label={t(commentItem.name)}
onClick={commentItem.command} style={{ border: "none" }}
> onClick={commentItem.command}
<IconMessage size={16} stroke={2} /> >
</ActionIcon> <IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
</div> </div>
</BubbleMenu> </BubbleMenu>
); );
@@ -1,7 +1,6 @@
import React, { Dispatch, FC, SetStateAction } from "react"; import React, { Dispatch, FC, SetStateAction } from "react";
import { IconCheck, IconChevronDown, IconPalette } from "@tabler/icons-react"; import { IconCheck, IconChevronDown } from "@tabler/icons-react";
import { import {
ActionIcon,
Button, Button,
Popover, Popover,
rem, rem,
@@ -15,6 +14,8 @@ import {
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import clsx from "clsx";
import classes from "./bubble-menu.module.css";
export interface BubbleColorMenuItem { export interface BubbleColorMenuItem {
name: string; name: string;
@@ -166,14 +167,10 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
onClick={() => setIsOpen(!isOpen)} onClick={() => setIsOpen(!isOpen)}
data-text-color={activeColorItem?.color || ""} data-text-color={activeColorItem?.color || ""}
data-highlight-color={activeHighlightItem?.color || ""} data-highlight-color={activeHighlightItem?.color || ""}
className="color-selector-trigger" className={clsx(["color-selector-trigger", classes.buttonRoot])}
style={{ style={{
height: "34px",
border: "none",
fontWeight: 500, fontWeight: 500,
fontSize: rem(16), fontSize: rem(16),
paddingLeft: rem(8),
paddingRight: rem(4),
}} }}
> >
A A
@@ -1,6 +1,7 @@
import React, { Dispatch, FC, SetStateAction } from "react"; import React, { Dispatch, FC, SetStateAction } from "react";
import { import {
IconBlockquote, IconBlockquote,
IconCaretRightFilled,
IconCheck, IconCheck,
IconCheckbox, IconCheckbox,
IconChevronDown, IconChevronDown,
@@ -8,14 +9,16 @@ import {
IconH1, IconH1,
IconH2, IconH2,
IconH3, IconH3,
IconInfoCircle,
IconList, IconList,
IconListNumbers, IconListNumbers,
IconTypography, IconTypography,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea } from "@mantine/core"; import { Popover, Button, ScrollArea, Tooltip } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import classes from "./bubble-menu.module.css";
interface NodeSelectorProps { interface NodeSelectorProps {
editor: Editor | null; editor: Editor | null;
@@ -54,6 +57,8 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
isTaskItem: ctx.editor.isActive("taskItem"), isTaskItem: ctx.editor.isActive("taskItem"),
isBlockquote: ctx.editor.isActive("blockquote"), isBlockquote: ctx.editor.isActive("blockquote"),
isCodeBlock: ctx.editor.isActive("codeBlock"), isCodeBlock: ctx.editor.isActive("codeBlock"),
isCallout: ctx.editor.isActive("callout"),
isDetails: ctx.editor.isActive("details"),
}; };
}, },
}); });
@@ -123,6 +128,18 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
command: () => editor.chain().focus().toggleCodeBlock().run(), command: () => editor.chain().focus().toggleCodeBlock().run(),
isActive: () => editorState?.isCodeBlock, isActive: () => editorState?.isCodeBlock,
}, },
{
name: "Callout",
icon: IconInfoCircle,
command: () => editor.chain().focus().toggleCallout().run(),
isActive: () => editorState?.isCallout,
},
{
name: "Toggle block",
icon: IconCaretRightFilled,
command: () => editor.chain().focus().setDetails().run(),
isActive: () => editorState?.isDetails,
},
]; ];
const activeItem = items.filter((item) => item.isActive()).pop() ?? { const activeItem = items.filter((item) => item.isActive()).pop() ?? {
@@ -132,15 +149,18 @@ export const NodeSelector: FC<NodeSelectorProps> = ({
return ( return (
<Popover opened={isOpen} withArrow> <Popover opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Button <Tooltip label={t("Turn into")} withArrow withinPortal={false} disabled={isOpen}>
variant="default" <Button
style={{ border: "none", height: "34px" }} className={classes.buttonRoot}
radius="0" variant="default"
rightSection={<IconChevronDown size={16} />} style={{ border: "none", height: "34px" }}
onClick={() => setIsOpen(!isOpen)} radius="0"
> rightSection={<IconChevronDown size={16} />}
{t(activeItem?.name)} onClick={() => setIsOpen(!isOpen)}
</Button> >
{t(activeItem?.name)}
</Button>
</Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
@@ -7,7 +7,7 @@ import {
IconCheck, IconCheck,
IconChevronDown, IconChevronDown,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { Popover, Button, ScrollArea, rem } from "@mantine/core"; import { Popover, Button, ScrollArea, Tooltip, rem } from "@mantine/core";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { useEditorState } from "@tiptap/react"; import { useEditorState } from "@tiptap/react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -84,16 +84,18 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
return ( return (
<Popover opened={isOpen} withArrow> <Popover opened={isOpen} withArrow>
<Popover.Target> <Popover.Target>
<Button <Tooltip label={t("Text align")} withArrow withinPortal={false} disabled={isOpen}>
variant="default" <Button
style={{ border: "none", height: "34px" }} variant="default"
px="5" style={{ border: "none", height: "34px" }}
radius="0" px="5"
rightSection={<IconChevronDown size={16} />} radius="0"
onClick={() => setIsOpen(!isOpen)} rightSection={<IconChevronDown size={16} />}
> onClick={() => setIsOpen(!isOpen)}
<activeItem.icon style={{ width: rem(16) }} stroke={2} /> >
</Button> <activeItem.icon style={{ width: rem(16) }} stroke={2} />
</Button>
</Tooltip>
</Popover.Target> </Popover.Target>
<Popover.Dropdown> <Popover.Dropdown>
@@ -10,6 +10,7 @@ import React, {
import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts"; import { useSearchSuggestionsQuery } from "@/features/search/queries/search-query.ts";
import { import {
ActionIcon, ActionIcon,
Divider,
Group, Group,
Paper, Paper,
ScrollArea, ScrollArea,
@@ -51,6 +52,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]); const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation(); const createPageMutation = useCreatePageMutation();
const emit = useQueryEmit(); const emit = useQueryEmit();
const isInCommentContext = props.isInCommentContext ?? false;
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({ const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: props.query, query: props.query,
@@ -58,6 +60,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
includePages: true, includePages: true,
spaceId: space.id, spaceId: space.id,
limit: 10, limit: 10,
preload: true,
}); });
const createPageItem = (label: string) : MentionSuggestionItem => { const createPageItem = (label: string) : MentionSuggestionItem => {
@@ -102,7 +105,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
})), })),
); );
} }
items.push(createPageItem(props.query)); if (!isInCommentContext && props.query) {
items.push(createPageItem(props.query));
}
setRenderItems(items); setRenderItems(items);
// update editor storage // update editor storage
@@ -250,35 +255,51 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
} }
} }
// if no results and enter what to do?
useEffect(() => { useEffect(() => {
viewportRef.current viewportRef.current
?.querySelector(`[data-item-index="${selectedIndex}"]`) ?.querySelector(`[data-item-index="${selectedIndex}"]`)
?.scrollIntoView({ block: "nearest" }); ?.scrollIntoView({ block: "nearest" });
}, [selectedIndex]); }, [selectedIndex]);
const popupWidth = isInCommentContext ? 280 : 320;
if (renderItems.length === 0) { if (renderItems.length === 0) {
return ( return (
<Paper shadow="md" p="xs" withBorder> <Paper id="mention" shadow="md" py="xs" withBorder radius="md">
{ t("No results") } <Text c="dimmed" size="sm" px="sm">
{ t("No results") }
</Text>
</Paper> </Paper>
); );
} }
const hasUsers = renderItems.some((item) => item.entityType === "user");
const hasPages = renderItems.some((item) => item.entityType === "page" && item.id !== null);
const createPageItemData = renderItems.find((item) => item.entityType === "page" && item.id === null);
return ( return (
<Paper id="mention" shadow="md" p="xs" withBorder> <Paper id="mention" shadow="md" withBorder radius="md" py={6}>
<ScrollArea.Autosize <ScrollArea.Autosize
viewportRef={viewportRef} viewportRef={viewportRef}
mah={350} mah={350}
w={320} w={popupWidth}
scrollbarSize={8} scrollbarSize={6}
> >
{renderItems?.map((item, index) => { {renderItems?.map((item, index) => {
if (item.entityType === "header") { if (item.entityType === "header") {
const isFirst = index === 0;
return ( return (
<div key={`${item.label}-${index}`}> <div key={`${item.label}-${index}`}>
<Text c="dimmed" mb={4} tt="uppercase"> {!isFirst && <Divider my={6} />}
<Text
c="dimmed"
size="xs"
fw={500}
px="sm"
pt={isFirst ? 2 : 4}
pb={4}
tt="uppercase"
>
{item.label} {item.label}
</Text> </Text>
</div> </div>
@@ -292,8 +313,9 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex, [classes.selectedItem]: index === selectedIndex,
})} })}
px="sm"
> >
<Group> <Group gap="sm">
<CustomAvatar <CustomAvatar
size={"sm"} size={"sm"}
avatarUrl={item.avatarUrl} avatarUrl={item.avatarUrl}
@@ -308,7 +330,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
</Group> </Group>
</UnstyledButton> </UnstyledButton>
); );
} else if (item.entityType === "page") { } else if (item.entityType === "page" && item.id !== null) {
return ( return (
<UnstyledButton <UnstyledButton
data-item-index={index} data-item-index={index}
@@ -317,28 +339,24 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
className={clsx(classes.menuBtn, { className={clsx(classes.menuBtn, {
[classes.selectedItem]: index === selectedIndex, [classes.selectedItem]: index === selectedIndex,
})} })}
px="sm"
> >
<Group> <Group gap="sm" wrap="nowrap">
<ActionIcon <ActionIcon
variant="default" variant="subtle"
component="div" component="div"
aria-label={item.label} aria-label={item.label}
color="gray"
size="sm"
> >
{item.icon || ( {item.icon || (
<ActionIcon <IconFileDescription size={18} stroke={1.5} />
component="span"
variant="transparent"
color="gray"
size={18}
>
{ (item.id) ? <IconFileDescription size={18} /> : <IconPlus size={18} /> }
</ActionIcon>
)} )}
</ActionIcon> </ActionIcon>
<div style={{ flex: 1 }}> <div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500}> <Text size="sm" fw={500} truncate>
{ (item.id) ? item.label : t("Create page") + ': ' + item.label } {item.label}
</Text> </Text>
</div> </div>
</Group> </Group>
@@ -348,6 +366,37 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
return null; return null;
} }
})} })}
{createPageItemData && !isInCommentContext && (
<>
{(hasUsers || hasPages) && <Divider my={6} />}
<UnstyledButton
data-item-index={renderItems.indexOf(createPageItemData)}
onClick={() => selectItem(renderItems.indexOf(createPageItemData))}
className={clsx(classes.menuBtn, {
[classes.selectedItem]: renderItems.indexOf(createPageItemData) === selectedIndex,
})}
px="sm"
>
<Group gap="sm" wrap="nowrap">
<ActionIcon
variant="subtle"
component="div"
color="gray"
size="sm"
>
<IconPlus size={16} stroke={1.5} />
</ActionIcon>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} truncate>
{t("Create page")}: {createPageItemData.label}
</Text>
</div>
</Group>
</UnstyledButton>
</>
)}
</ScrollArea.Autosize> </ScrollArea.Autosize>
</Paper> </Paper>
); );
@@ -17,8 +17,13 @@ const mentionRenderItems = () => {
let component: ReactRenderer | null = null; let component: ReactRenderer | null = null;
let activeClientRect: (() => DOMRect) | null = null; let activeClientRect: (() => DOMRect) | null = null;
let updatePositionCleanup: (() => void) | null = null; let updatePositionCleanup: (() => void) | null = null;
let outsideClickHandler: ((e: MouseEvent) => void) | null = null;
const destroy = () => { const destroy = () => {
if (outsideClickHandler) {
document.removeEventListener("pointerdown", outsideClickHandler);
outsideClickHandler = null;
}
updatePositionCleanup?.(); updatePositionCleanup?.();
updatePositionCleanup = null; updatePositionCleanup = null;
component?.destroy(); component?.destroy();
@@ -45,8 +50,14 @@ const mentionRenderItems = () => {
return; return;
} }
const editorDom = props.editor?.view?.dom;
const asideEl = editorDom?.closest(".mantine-AppShell-aside");
const dialogEl = editorDom?.closest("[data-comment-dialog]");
const isInCommentContext = !!(asideEl || dialogEl);
// const isInCommentContext = !!asideEl;
component = new ReactRenderer(MentionList, { component = new ReactRenderer(MentionList, {
props, props: { ...props, isInCommentContext },
editor: props.editor, editor: props.editor,
}); });
@@ -59,6 +70,18 @@ const mentionRenderItems = () => {
const { element } = component; const { element } = component;
document.body.appendChild(element); document.body.appendChild(element);
outsideClickHandler = (e: MouseEvent) => {
const target = e.target as Node;
if (element && !element.contains(target)) {
destroy();
}
};
document.addEventListener("pointerdown", outsideClickHandler);
const shiftMiddleware = asideEl
? shift({ boundary: asideEl, crossAxis: true, padding: 8 })
: shift();
updatePositionCleanup = autoUpdate( updatePositionCleanup = autoUpdate(
{ {
getBoundingClientRect: () => getBoundingClientRect: () =>
@@ -76,7 +99,7 @@ const mentionRenderItems = () => {
element, element,
{ {
placement: "bottom-start", placement: "bottom-start",
middleware: [offset(0), flip(), shift()], middleware: [offset(4), flip(), shiftMiddleware],
}, },
).then(({ x, y }) => { ).then(({ x, y }) => {
Object.assign(element.style, { Object.assign(element.style, {
@@ -31,14 +31,14 @@
.menuBtn { .menuBtn {
width: 100%; width: 100%;
padding: 4px; padding: 6px 4px;
margin-bottom: 2px; margin-bottom: 1px;
color: var(--mantine-color-text); color: var(--mantine-color-text);
border-radius: var(--mantine-radius-sm); border-radius: var(--mantine-radius-sm);
&:hover { &:hover {
@mixin light { @mixin light {
background: var(--mantine-color-gray-2); background: var(--mantine-color-gray-1);
} }
@mixin dark { @mixin dark {
@@ -49,7 +49,7 @@
.selectedItem { .selectedItem {
@mixin light { @mixin light {
background: var(--mantine-color-gray-2); background: var(--mantine-color-gray-1);
} }
@mixin dark { @mixin dark {
@@ -7,6 +7,7 @@ export interface MentionListProps {
range: Range; range: Range;
text: string; text: string;
editor: Editor; editor: Editor;
isInCommentContext?: boolean;
} }
export type MentionSuggestionItem = export type MentionSuggestionItem =
@@ -66,6 +66,7 @@ import { PageEditMode } from "@/features/user/types/user.types.ts";
import { jwtDecode } from "jwt-decode"; import { jwtDecode } from "jwt-decode";
import { searchSpotlight } from "@/features/search/constants.ts"; import { searchSpotlight } from "@/features/search/constants.ts";
import { useEditorScroll } from "./hooks/use-editor-scroll"; import { useEditorScroll } from "./hooks/use-editor-scroll";
import { EditorAiMenu } from "@/ee/ai/components/editor/ai-menu/ai-menu";
interface PageEditorProps { interface PageEditorProps {
pageId: string; pageId: string;
@@ -405,6 +406,7 @@ export default function PageEditor({
{editor && editorIsEditable && ( {editor && editorIsEditable && (
<div> <div>
<EditorAiMenu editor={editor} />
<EditorBubbleMenu editor={editor} /> <EditorBubbleMenu editor={editor} />
<TableMenu editor={editor} /> <TableMenu editor={editor} />
<TableCellMenu editor={editor} appendTo={menuContainerRef} /> <TableCellMenu editor={editor} appendTo={menuContainerRef} />
@@ -160,7 +160,7 @@ export function TitleEditor({
// guard against Cannot access view['hasFocus'] error // guard against Cannot access view['hasFocus'] error
if (!titleEditor?.isInitialized) return; if (!titleEditor?.isInitialized) return;
titleEditor?.commands?.focus("end"); titleEditor?.commands?.focus("end");
}, 500); }, 300);
}, [titleEditor]); }, [titleEditor]);
useEffect(() => { useEffect(() => {
@@ -0,0 +1,148 @@
import {
ActionIcon,
Group,
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import {
IconCheck,
IconFileDescription,
IconPointFilled,
} from "@tabler/icons-react";
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { INotification } from "../types/notification.types";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { useMarkReadMutation } from "../queries/notification-query";
import { buildPageUrl } from "@/features/page/page.utils";
import { formatRelativeTime } from "../notification.utils";
import classes from "../notification.module.css";
type NotificationItemProps = {
notification: INotification;
onNavigate: () => void;
};
export function NotificationItem({
notification,
onNavigate,
}: NotificationItemProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const markRead = useMarkReadMutation();
const [hovered, setHovered] = useState(false);
const isUnread = !notification.readAt;
const getNotificationMessage = (): string => {
switch (notification.type) {
case "comment.user_mention":
return t("mentioned you in a comment");
case "comment.created":
return t("commented on a page");
case "comment.resolved":
return t("resolved a comment");
case "page.user_mention":
return t("mentioned you on a page");
default:
return "";
}
};
const handleClick = () => {
if (notification.page && notification.space) {
if (isUnread) {
markRead.mutate([notification.id]);
}
navigate(
buildPageUrl(
notification.space.slug,
notification.page.slugId,
notification.page.title,
),
);
onNavigate();
}
};
const handleMarkRead = (e: React.MouseEvent) => {
e.stopPropagation();
if (isUnread) {
markRead.mutate([notification.id]);
}
};
return (
<UnstyledButton
onClick={handleClick}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
w="100%"
className={classes.notificationItem}
>
<Group wrap="nowrap" align="flex-start" gap="sm">
<CustomAvatar
avatarUrl={notification.actor?.avatarUrl}
name={notification.actor?.name || "?"}
size="sm"
/>
<div style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" lineClamp={2}>
<Text span fw={600}>
{notification.actor?.name}
</Text>{" "}
{getNotificationMessage()}
</Text>
{notification.page && (
<Group gap={4} mt={2} wrap="nowrap">
{notification.page.icon ? (
<Text size="xs" style={{ flexShrink: 0 }}>
{notification.page.icon}
</Text>
) : (
<IconFileDescription
size={14}
stroke={1.5}
style={{ flexShrink: 0, color: "var(--mantine-color-dimmed)" }}
/>
)}
<Text size="xs" c="dimmed" lineClamp={1}>
{notification.page.title || t("Untitled")}
</Text>
</Group>
)}
</div>
<Group gap={4} wrap="nowrap" align="center" style={{ flexShrink: 0 }}>
{hovered && isUnread ? (
<Tooltip label={t("Mark as read")} withArrow>
<ActionIcon
variant="subtle"
size="sm"
onClick={handleMarkRead}
>
<IconCheck size={14} />
</ActionIcon>
</Tooltip>
) : (
<Text size="xs" c="dimmed" style={{ whiteSpace: "nowrap" }}>
{formatRelativeTime(notification.createdAt)}
</Text>
)}
{isUnread && (
<IconPointFilled
size={12}
color="var(--mantine-color-blue-filled)"
style={{ flexShrink: 0 }}
/>
)}
</Group>
</Group>
</UnstyledButton>
);
}
@@ -0,0 +1,115 @@
import { Center, Divider, Loader, Stack, Text } from "@mantine/core";
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 { groupNotificationsByTime } from "../notification.utils";
import { useNotificationsQuery } from "../queries/notification-query";
import classes from "../notification.module.css";
type NotificationListProps = {
filter: NotificationFilter;
onNavigate: () => void;
};
export function NotificationList({
filter,
onNavigate,
}: NotificationListProps) {
const { t } = useTranslation();
const {
data,
isLoading,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useNotificationsQuery();
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const sentinel = sentinelRef.current;
if (!sentinel) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 0.1 },
);
observer.observe(sentinel);
return () => observer.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
if (isLoading) {
return (
<Center py="xl">
<Loader size="sm" />
</Center>
);
}
const allNotifications =
data?.pages.flatMap((page) => page.items) ?? [];
const filtered =
filter === "unread"
? allNotifications.filter((n) => !n.readAt)
: allNotifications;
if (filtered.length === 0) {
return (
<Center py="xl">
<Stack align="center" gap="xs">
<IconBellOff size={32} stroke={1.5} color="var(--mantine-color-dimmed)" />
<Text size="sm" c="dimmed">
{filter === "unread"
? t("No unread notifications")
: t("No notifications")}
</Text>
</Stack>
</Center>
);
}
const timeGroupLabels = {
today: t("Today"),
yesterday: t("Yesterday"),
this_week: t("This week"),
older: t("Older"),
};
const groups = groupNotificationsByTime(filtered, timeGroupLabels);
return (
<Stack gap={0}>
{groups.map((group, groupIndex) => (
<div key={group.key}>
{groupIndex > 0 && <Divider className={classes.divider} />}
<Text size="xs" fw={600} c="dimmed" px="md" pt="sm" pb={4}>
{group.label}
</Text>
{group.notifications.map((notification: INotification) => (
<NotificationItem
key={notification.id}
notification={notification}
onNavigate={onNavigate}
/>
))}
</div>
))}
<div ref={sentinelRef} style={{ height: 1 }} />
{isFetchingNextPage && (
<Center py="xs">
<Loader size="xs" />
</Center>
)}
</Stack>
);
}
@@ -0,0 +1,142 @@
import { useState } from "react";
import {
ActionIcon,
Group,
Indicator,
Menu,
Popover,
ScrollArea,
Text,
Tooltip,
} from "@mantine/core";
import {
IconBell,
IconCheck,
IconChecks,
IconDots,
IconFilter,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { NotificationList } from "./notification-list";
import { NotificationFilter } from "../types/notification.types";
import {
useMarkAllReadMutation,
useUnreadCountQuery,
} from "../queries/notification-query";
export function NotificationPopover() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [filter, setFilter] = useState<NotificationFilter>("all");
const { data: unreadData } = useUnreadCountQuery();
const markAllRead = useMarkAllReadMutation();
const unreadCount = unreadData?.count ?? 0;
const handleMarkAllRead = () => {
markAllRead.mutate();
};
return (
<Popover
position="bottom-end"
shadow="lg"
opened={opened}
onChange={setOpened}
withArrow
>
<Popover.Target>
<Tooltip label={t("Notifications")} withArrow>
<ActionIcon
variant="subtle"
color="dark"
size="sm"
onClick={() => setOpened((o) => !o)}
>
<Indicator
offset={5}
color="red"
withBorder
disabled={unreadCount === 0}
>
<IconBell size={20} />
</Indicator>
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown
p={0}
style={{ width: "min(420px, calc(100vw - 24px))" }}
>
<Group justify="space-between" px="md" py="sm">
<Text fw={600} size="sm">
{t("Notifications")}
</Text>
<Group gap={4}>
<Menu position="bottom-end" withArrow withinPortal={false}>
<Menu.Target>
<Tooltip label={t("Filter")} withArrow>
<ActionIcon variant="subtle" color="dark" size="sm">
<IconFilter size={16} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t("Filter")}</Menu.Label>
<Menu.Item
onClick={() => setFilter("all")}
rightSection={
filter === "all" ? <IconCheck size={14} /> : null
}
>
{t("All notifications")}
</Menu.Item>
<Menu.Item
onClick={() => setFilter("unread")}
rightSection={
filter === "unread" ? <IconCheck size={14} /> : null
}
>
{t("Unread only")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Menu position="bottom-end" withArrow withinPortal={false}>
<Menu.Target>
<Tooltip label={t("More options")} withArrow>
<ActionIcon variant="subtle" color="dark" size="sm">
<IconDots size={16} />
</ActionIcon>
</Tooltip>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconChecks size={16} />}
onClick={handleMarkAllRead}
disabled={unreadCount === 0}
>
{t("Mark all as read")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Group>
</Group>
<ScrollArea.Autosize
mah={500}
type="auto"
offsetScrollbars
scrollbarSize={6}
>
<NotificationList
filter={filter}
onNavigate={() => setOpened(false)}
/>
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
);
}
@@ -0,0 +1,23 @@
import { useEffect } from "react";
import { useAtom } from "jotai";
import { useQueryClient } from "@tanstack/react-query";
import { socketAtom } from "@/features/websocket/atoms/socket-atom";
import { NOTIFICATION_KEY } from "../queries/notification-query";
export function useNotificationSocket() {
const queryClient = useQueryClient();
const [socket] = useAtom(socketAtom);
useEffect(() => {
if (!socket) return;
const handler = () => {
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
};
socket.on("notification", handler);
return () => {
socket.off("notification", handler);
};
}, [socket, queryClient]);
}
@@ -0,0 +1,13 @@
.notificationItem {
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
}
.notificationItem:hover {
background-color: var(--mantine-color-default-hover);
}
.divider {
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
@@ -0,0 +1,75 @@
import { INotification } from "./types/notification.types";
export function formatRelativeTime(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMs / 3_600_000);
const diffDays = Math.floor(diffMs / 86_400_000);
if (diffMin < 1) return "now";
if (diffMin < 60) return `${diffMin}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
type TimeGroup = "today" | "yesterday" | "this_week" | "older";
export function getTimeGroup(dateStr: string): TimeGroup {
const date = new Date(dateStr);
const now = new Date();
const startOfToday = new Date(
now.getFullYear(),
now.getMonth(),
now.getDate(),
);
const startOfYesterday = new Date(startOfToday);
startOfYesterday.setDate(startOfYesterday.getDate() - 1);
const startOfWeek = new Date(startOfToday);
startOfWeek.setDate(startOfWeek.getDate() - 7);
if (date >= startOfToday) return "today";
if (date >= startOfYesterday) return "yesterday";
if (date >= startOfWeek) return "this_week";
return "older";
}
export type GroupedNotifications = {
key: TimeGroup;
label: string;
notifications: INotification[];
};
export function groupNotificationsByTime(
notifications: INotification[],
labels: Record<TimeGroup, string>,
): GroupedNotifications[] {
const groups: Record<TimeGroup, INotification[]> = {
today: [],
yesterday: [],
this_week: [],
older: [],
};
for (const notification of notifications) {
const group = getTimeGroup(notification.createdAt);
groups[group].push(notification);
}
const order: TimeGroup[] = ["today", "yesterday", "this_week", "older"];
return order
.filter((key) => groups[key].length > 0)
.map((key) => ({
key,
label: labels[key],
notifications: groups[key],
}));
}
@@ -0,0 +1,59 @@
import {
keepPreviousData,
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
getNotifications,
getUnreadCount,
markNotificationsRead,
markAllNotificationsRead,
} from "../services/notification-service";
export const NOTIFICATION_KEY = ["notifications"];
export const UNREAD_COUNT_KEY = ["notifications", "unread-count"];
export function useNotificationsQuery() {
return useInfiniteQuery({
queryKey: NOTIFICATION_KEY,
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
staleTime: 0,
gcTime: 0,
placeholderData: keepPreviousData,
});
}
export function useUnreadCountQuery() {
return useQuery({
queryKey: UNREAD_COUNT_KEY,
queryFn: getUnreadCount,
});
}
export function useMarkReadMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (notificationIds: string[]) =>
markNotificationsRead(notificationIds),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
},
});
}
export function useMarkAllReadMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: markAllNotificationsRead,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: NOTIFICATION_KEY });
},
});
}
@@ -0,0 +1,31 @@
import api from "@/lib/api-client";
import { INotification } from "../types/notification.types";
import { IPagination } from "@/lib/types";
export async function getNotifications(params: {
limit?: number;
cursor?: string;
}): Promise<IPagination<INotification>> {
const req = await api.post<IPagination<INotification>>(
"/notifications",
params,
);
return req.data;
}
export async function getUnreadCount(): Promise<{ count: number }> {
const req = await api.post<{ count: number }>(
"/notifications/unread-count",
);
return req.data;
}
export async function markNotificationsRead(
notificationIds: string[],
): Promise<void> {
await api.post("/notifications/mark-read", { notificationIds });
}
export async function markAllNotificationsRead(): Promise<void> {
await api.post("/notifications/mark-all-read");
}
@@ -0,0 +1,39 @@
export type NotificationType =
| "comment.user_mention"
| "comment.created"
| "comment.resolved"
| "page.user_mention";
export type INotification = {
id: string;
userId: string;
workspaceId: string;
type: NotificationType;
actorId: string | null;
pageId: string | null;
spaceId: string | null;
commentId: string | null;
data: Record<string, unknown> | null;
readAt: string | null;
emailedAt: string | null;
archivedAt: string | null;
createdAt: string;
actor: {
id: string;
name: string;
avatarUrl: string | null;
} | null;
page: {
id: string;
title: string;
slugId: string;
icon: string | null;
} | null;
space: {
id: string;
name: string;
slug: string;
} | null;
};
export type NotificationFilter = "all" | "unread";
@@ -82,8 +82,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<Tooltip label={t("Comments")} openDelay={250} withArrow> <Tooltip label={t("Comments")} openDelay={250} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="subtle"
style={{ border: "none" }} color="dark"
onClick={() => toggleAside("comments")} onClick={() => toggleAside("comments")}
> >
<IconMessage size={20} stroke={2} /> <IconMessage size={20} stroke={2} />
@@ -92,8 +92,8 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
<Tooltip label={t("Table of contents")} openDelay={250} withArrow> <Tooltip label={t("Table of contents")} openDelay={250} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="subtle"
style={{ border: "none" }} color="dark"
onClick={() => toggleAside("toc")} onClick={() => toggleAside("toc")}
> >
<IconList size={20} stroke={2} /> <IconList size={20} stroke={2} />
@@ -169,7 +169,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
arrowPosition="center" arrowPosition="center"
> >
<Menu.Target> <Menu.Target>
<ActionIcon variant="default" style={{ border: "none" }}> <ActionIcon variant="subtle" color="dark">
<IconDots size={20} /> <IconDots size={20} />
</ActionIcon> </ActionIcon>
</Menu.Target> </Menu.Target>
@@ -106,10 +106,16 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
} }
}, sizeRef); }, sizeRef);
const [isDataLoaded, setIsDataLoaded] = useState(false); const [isDataLoaded, setIsDataLoaded] = useState(false);
const spaceIdRef = useRef(spaceId);
spaceIdRef.current = spaceId;
const { data: currentPage } = usePageQuery({ const { data: currentPage } = usePageQuery({
pageId: extractPageSlugId(pageSlug), pageId: extractPageSlugId(pageSlug),
}); });
useEffect(() => {
setIsDataLoaded(false);
}, [spaceId]);
useEffect(() => { useEffect(() => {
if (hasNextPage && !isFetching) { if (hasNextPage && !isFetching) {
fetchNextPage(); fetchNextPage();
@@ -130,12 +136,15 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
} }
// same space; append only missing roots // same space; append only missing roots
setIsDataLoaded(true);
return mergeRootTrees(prev, treeData); return mergeRootTrees(prev, treeData);
}); });
} }
}, [pagesData, hasNextPage]); }, [pagesData, hasNextPage, spaceId]);
useEffect(() => { useEffect(() => {
const effectSpaceId = spaceId;
const fetchData = async () => { const fetchData = async () => {
if (isDataLoaded && currentPage) { if (isDataLoaded && currentPage) {
// check if pageId node is present in the tree // check if pageId node is present in the tree
@@ -149,6 +158,8 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
if (!currentPage.id) return; if (!currentPage.id) return;
const ancestors = await getPageBreadcrumbs(currentPage.id); const ancestors = await getPageBreadcrumbs(currentPage.id);
if (spaceIdRef.current !== effectSpaceId) return;
if (ancestors && ancestors?.length > 1) { if (ancestors && ancestors?.length > 1) {
let flatTreeItems = [...buildTree(ancestors)]; let flatTreeItems = [...buildTree(ancestors)];
@@ -176,22 +187,22 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
// Wait for all fetch operations to complete // Wait for all fetch operations to complete
Promise.all(fetchPromises).then(() => { Promise.all(fetchPromises).then(() => {
if (spaceIdRef.current !== effectSpaceId) return;
// build tree with children // build tree with children
const ancestorsTree = buildTreeWithChildren(flatTreeItems); const ancestorsTree = buildTreeWithChildren(flatTreeItems);
// child of root page we're attaching the built ancestors to // child of root page we're attaching the built ancestors to
const rootChild = ancestorsTree[0]; const rootChild = ancestorsTree[0];
// attach built ancestors to tree // attach built ancestors to tree using functional updater
const updatedTree = appendNodeChildren( // to avoid stale closure overwriting the current tree data
data, setData((currentData) =>
rootChild.id, appendNodeChildren(currentData, rootChild.id, rootChild.children),
rootChild.children,
); );
setData(updatedTree);
setTimeout(() => { setTimeout(() => {
// focus on node and open all parents // focus on node and open all parents
treeApiRef.current.select(currentPage.id); treeApiRef.current?.select(currentPage.id);
}, 100); }, 100);
}); });
} }
@@ -44,8 +44,8 @@ export function SearchMobileControl({ onSearch }: SearchMobileControlProps) {
return ( return (
<Tooltip label={t("Search")} withArrow> <Tooltip label={t("Search")} withArrow>
<ActionIcon <ActionIcon
variant="default" variant="subtle"
style={{ border: "none" }} color="dark"
onClick={onSearch} onClick={onSearch}
size="sm" size="sm"
> >
@@ -140,7 +140,7 @@ export function SearchSpotlightFilters({
<Switch <Switch
checked={isAiMode} checked={isAiMode}
onChange={(event) => onAskClick()} onChange={(event) => onAskClick()}
label={t("Ask AI")} label={t("AI Answers")}
size="sm" size="sm"
color="blue" color="blue"
labelPosition="left" labelPosition="left"
@@ -279,7 +279,7 @@ export function SearchSpotlightFilters({
isAiMode && isAiMode &&
option.value === "attachment" && ( option.value === "attachment" && (
<Text size="xs" mt={4}> <Text size="xs" mt={4}>
{t("Ask AI not available for attachments")} {t("AI Answers not available for attachments")}
</Text> </Text>
)} )}
</div> </div>
@@ -24,13 +24,14 @@ export function usePageSearchQuery(
} }
export function useSearchSuggestionsQuery( export function useSearchSuggestionsQuery(
params: SearchSuggestionParams, params: SearchSuggestionParams & { preload?: boolean },
): UseQueryResult<ISuggestionResult, Error> { ): UseQueryResult<ISuggestionResult, Error> {
const { preload, ...queryParams } = params;
return useQuery({ return useQuery({
queryKey: ["search-suggestion", params.query], queryKey: ["search-suggestion", params.query],
staleTime: 60 * 1000, // 1min staleTime: 60 * 1000, // 1min
queryFn: () => searchSuggestions(params), queryFn: () => searchSuggestions(queryParams),
enabled: !!params.query, enabled: preload || !!params.query,
}); });
} }
@@ -45,8 +45,7 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
const { isTrial } = useTrial(); const { isTrial } = useTrial();
const [workspace] = useAtom(workspaceAtom); const [workspace] = useAtom(workspaceAtom);
const { data: space } = useSpaceQuery(spaceSlug); const { data: space } = useSpaceQuery(spaceSlug);
const workspaceDisabled = const workspaceDisabled = workspace?.settings?.sharing?.disabled === true;
workspace?.settings?.sharing?.disabled === true;
const spaceDisabled = space?.settings?.sharing?.disabled === true; const spaceDisabled = space?.settings?.sharing?.disabled === true;
const sharingDisabled = workspaceDisabled || spaceDisabled; const sharingDisabled = workspaceDisabled || spaceDisabled;
const createShareMutation = useCreateShareMutation(); const createShareMutation = useCreateShareMutation();
@@ -134,7 +133,6 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
<Popover width={350} position="bottom" withArrow shadow="md"> <Popover width={350} position="bottom" withArrow shadow="md">
<Popover.Target> <Popover.Target>
<Button <Button
style={{ border: "none" }}
size="compact-sm" size="compact-sm"
leftSection={ leftSection={
<Indicator <Indicator
@@ -146,7 +144,8 @@ export default function ShareModal({ readOnly }: ShareModalProps) {
<IconWorld size={20} stroke={1.5} /> <IconWorld size={20} stroke={1.5} />
</Indicator> </Indicator>
} }
variant="default" color="dark"
variant="subtle"
> >
{t("Share")} {t("Share")}
</Button> </Button>
@@ -8,6 +8,7 @@ import { io } from "socket.io-client";
import { SOCKET_URL } from "@/features/websocket/types"; import { SOCKET_URL } from "@/features/websocket/types";
import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts"; import { useQuerySubscription } from "@/features/websocket/use-query-subscription.ts";
import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts"; import { useTreeSocket } from "@/features/websocket/use-tree-socket.ts";
import { useNotificationSocket } from "@/features/notification/hooks/use-notification-socket.ts";
import { useCollabToken } from "@/features/auth/queries/auth-query.tsx"; import { useCollabToken } from "@/features/auth/queries/auth-query.tsx";
import { Error404 } from "@/components/ui/error-404.tsx"; import { Error404 } from "@/components/ui/error-404.tsx";
@@ -44,6 +45,7 @@ export function UserProvider({ children }: React.PropsWithChildren) {
useQuerySubscription(); useQuerySubscription();
useTreeSocket(); useTreeSocket();
useNotificationSocket();
useEffect(() => { useEffect(() => {
if (data && data.user && data.workspace) { if (data && data.user && data.workspace) {
@@ -23,6 +23,7 @@ export interface IWorkspace {
hasLicenseKey?: boolean; hasLicenseKey?: boolean;
enforceMfa?: boolean; enforceMfa?: boolean;
aiSearch?: boolean; aiSearch?: boolean;
generativeAi?: boolean;
disablePublicSharing?: boolean; disablePublicSharing?: boolean;
} }
@@ -33,6 +34,7 @@ export interface IWorkspaceSettings {
export interface IWorkspaceAiSettings { export interface IWorkspaceAiSettings {
search?: boolean; search?: boolean;
generative?: boolean;
} }
export interface IWorkspaceSharingSettings { export interface IWorkspaceSharingSettings {
+25 -2
View File
@@ -35,12 +35,35 @@ export const theme = createTheme({
blue, blue,
red, red,
}, },
/***
components: {
ActionIcon: ActionIcon.extend({
vars: (_theme, props) => {
return {
root: {
...(props.variant === "subtle" &&
props.color === "dark" && {
"--ai-color": "var(--mantine-color-default-color)",
"--ai-hover": "var(--mantine-color-default-hover)",
}),
},
};
},
}),
},
***/
}); });
export const mantineCssResolver: CSSVariablesResolver = (theme) => ({ export const mantineCssResolver: CSSVariablesResolver = (theme) => ({
variables: { variables: {
"--input-error-size": theme.fontSizes.sm, "--input-error-size": theme.fontSizes.sm,
}, },
light: {}, light: {
dark: {}, "--mantine-color-dark-light-color": "#4e5359",
"--mantine-color-dark-light-hover": "var(--mantine-color-gray-light-hover)",
},
dark: {
"--mantine-color-dark-light-color": "var(--mantine-color-gray-4)",
"--mantine-color-dark-light-hover": "var(--mantine-color-default-hover)",
},
}); });
+11 -11
View File
@@ -58,8 +58,8 @@
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",
"@nestjs/websockets": "^11.1.13", "@nestjs/websockets": "^11.1.13",
"@node-saml/passport-saml": "^5.1.0", "@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "0.0.28", "@react-email/components": "1.0.7",
"@react-email/render": "1.0.2", "@react-email/render": "2.0.4",
"@socket.io/redis-adapter": "^8.3.0", "@socket.io/redis-adapter": "^8.3.0",
"ai": "^6.0.37", "ai": "^6.0.37",
"ai-sdk-ollama": "^3.1.1", "ai-sdk-ollama": "^3.1.1",
@@ -113,32 +113,32 @@
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.20.0", "@eslint/js": "^9.20.0",
"@nestjs/cli": "^11.0.4", "@nestjs/cli": "^11.0.16",
"@nestjs/schematics": "^11.0.1", "@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.0.10", "@nestjs/testing": "^11.0.10",
"@types/bcrypt": "^5.0.2", "@types/bcrypt": "^5.0.2",
"@types/debounce": "^1.2.4", "@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.14", "@types/jest": "^30.0.0",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",
"@types/nodemailer": "^6.4.17", "@types/nodemailer": "^6.4.17",
"@types/passport-google-oauth20": "^2.0.16", "@types/passport-google-oauth20": "^2.0.16",
"@types/passport-jwt": "^4.0.1", "@types/passport-jwt": "^4.0.1",
"@types/supertest": "^6.0.2", "@types/supertest": "^6.0.3",
"@types/ws": "^8.5.14", "@types/ws": "^8.5.14",
"@types/yauzl": "^2.10.3", "@types/yauzl": "^2.10.3",
"eslint": "^9.20.1", "eslint": "^9.39.2",
"eslint-config-prettier": "^10.0.1", "eslint-config-prettier": "^10.0.1",
"globals": "^15.15.0", "globals": "^15.15.0",
"jest": "^29.7.0", "jest": "^30.2.0",
"kysely-codegen": "^0.19.0", "kysely-codegen": "^0.19.0",
"prettier": "^3.5.1", "prettier": "^3.5.1",
"react-email": "3.0.2", "react-email": "5.2.8",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.2.2",
"ts-jest": "^29.2.5", "ts-jest": "^29.4.6",
"ts-loader": "^9.5.2", "ts-loader": "^9.5.4",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0", "tsconfig-paths": "^4.2.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
@@ -1,5 +1,12 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Hocuspocus, Document } from '@hocuspocus/server'; import { Hocuspocus, Document } from '@hocuspocus/server';
import { TiptapTransformer } from '@hocuspocus/transformer';
import {
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
export type CollabEventHandlers = ReturnType< export type CollabEventHandlers = ReturnType<
CollaborationHandler['getHandlers'] CollaborationHandler['getHandlers']
@@ -20,6 +27,44 @@ export class CollaborationHandler {
// const fragment = doc.getXmlFragment('default'); // const fragment = doc.getXmlFragment('default');
//}); //});
}, },
updatePageContent: async (
documentName: string,
payload: {
prosemirrorJson: any;
operation: string;
user: User;
},
) => {
const { prosemirrorJson, operation, user } = payload;
this.logger.debug('Updating page content via yjs', documentName);
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
if (operation === 'replace') {
if (fragment.length > 0) {
fragment.delete(0, fragment.length);
}
const newDoc = TiptapTransformer.toYdoc(
prosemirrorJson,
'default',
tiptapExtensions,
);
Y.applyUpdate(doc, Y.encodeStateAsUpdate(newDoc));
} else {
const newContent = prosemirrorJson.content || [];
const yElements = newContent.map(prosemirrorNodeToYElement);
const position =
operation === 'prepend' ? 0 : fragment.length;
fragment.insert(position, yElements);
}
},
);
},
}; };
} }
@@ -1,4 +1,10 @@
import { Logger, Module, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import {
Global,
Logger,
Module,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { AuthenticationExtension } from './extensions/authentication.extension'; import { AuthenticationExtension } from './extensions/authentication.extension';
import { PersistenceExtension } from './extensions/persistence.extension'; import { PersistenceExtension } from './extensions/persistence.extension';
import { CollaborationGateway } from './collaboration.gateway'; import { CollaborationGateway } from './collaboration.gateway';
@@ -11,6 +17,7 @@ import { HistoryProcessor } from './processors/history.processor';
import { LoggerExtension } from './extensions/logger.extension'; import { LoggerExtension } from './extensions/logger.extension';
import { CollaborationHandler } from './collaboration.handler'; import { CollaborationHandler } from './collaboration.handler';
import { CollabHistoryService } from './services/collab-history.service'; import { CollabHistoryService } from './services/collab-history.service';
import { WatcherModule } from '../core/watcher/watcher.module';
@Module({ @Module({
providers: [ providers: [
@@ -23,7 +30,7 @@ import { CollabHistoryService } from './services/collab-history.service';
CollaborationHandler, CollaborationHandler,
], ],
exports: [CollaborationGateway], exports: [CollaborationGateway],
imports: [TokenModule], imports: [TokenModule, WatcherModule],
}) })
export class CollaborationModule implements OnModuleInit, OnModuleDestroy { export class CollaborationModule implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(CollaborationModule.name); private readonly logger = new Logger(CollaborationModule.name);
@@ -34,6 +34,7 @@ import {
Highlight, Highlight,
UniqueID, UniqueID,
addUniqueIdsToDoc, addUniqueIdsToDoc,
htmlToMarkdown,
} from '@docmost/editor-ext'; } from '@docmost/editor-ext';
import { generateText, getSchema, JSONContent } from '@tiptap/core'; import { generateText, getSchema, JSONContent } from '@tiptap/core';
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html'; import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
@@ -42,6 +43,7 @@ import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
// see:https://github.com/ueberdosis/tiptap/issues/4089 // see:https://github.com/ueberdosis/tiptap/issues/4089
//import { generateJSON } from '@tiptap/html'; //import { generateJSON } from '@tiptap/html';
import { Node, Schema } from '@tiptap/pm/model'; import { Node, Schema } from '@tiptap/pm/model';
import * as Y from 'yjs';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
export const tiptapExtensions = [ export const tiptapExtensions = [
@@ -161,3 +163,37 @@ function stripUnknownNodes(
return json; return json;
} }
export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
if (node.type === 'text') {
const ytext = new Y.XmlText();
ytext.insert(0, node.text || '');
if (node.marks?.length > 0) {
const attrs: Record<string, any> = {};
for (const mark of node.marks) {
attrs[mark.type] = mark.attrs || true;
}
ytext.format(0, node.text?.length || 0, attrs);
}
return ytext;
}
const element = new Y.XmlElement(node.type);
if (node.attrs) {
for (const [key, value] of Object.entries(node.attrs)) {
if (value !== null && value !== undefined) {
element.setAttribute(key, value as any);
}
}
}
if (node.content?.length > 0) {
const children = node.content.map(prosemirrorNodeToYElement);
element.insert(0, children);
}
return element;
}
export function jsonToMarkdown(tiptapJson: any): string {
const html = jsonToHtml(tiptapJson);
return htmlToMarkdown(html);
}
@@ -19,11 +19,13 @@ import { Queue } from 'bullmq';
import { import {
extractMentions, extractMentions,
extractPageMentions, extractPageMentions,
extractUserMentions,
} from '../../common/helpers/prosemirror/utils'; } from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { import {
IPageBacklinkJob, IPageBacklinkJob,
IPageHistoryJob, IPageHistoryJob,
IPageMentionNotificationJob,
} from '../../integrations/queue/constants/queue.interface'; } from '../../integrations/queue/constants/queue.interface';
import { Page } from '@docmost/db/types/entity.types'; import { Page } from '@docmost/db/types/entity.types';
import { CollabHistoryService } from '../services/collab-history.service'; import { CollabHistoryService } from '../services/collab-history.service';
@@ -44,6 +46,7 @@ export class PersistenceExtension implements Extension {
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue, @InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue, @InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
private readonly collabHistory: CollabHistoryService, private readonly collabHistory: CollabHistoryService,
) {} ) {}
@@ -170,6 +173,24 @@ export class PersistenceExtension implements Extension {
mentions: pageMentions, mentions: pageMentions,
} as IPageBacklinkJob); } as IPageBacklinkJob);
const userMentions = extractUserMentions(mentions);
const oldMentions = page.content ? extractMentions(page.content) : [];
const oldMentionedUserIds = extractUserMentions(oldMentions).map((m) => m.entityId);
if (userMentions.length > 0) {
await this.notificationQueue.add(QueueJob.PAGE_MENTION_NOTIFICATION, {
userMentions: userMentions.map((m) => ({
userId: m.entityId,
mentionId: m.id,
creatorId: m.creatorId,
})),
oldMentionedUserIds,
pageId,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
} as IPageMentionNotificationJob);
}
await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, { await this.aiQueue.add(QueueJob.PAGE_CONTENT_UPDATED, {
pageIds: [pageId], pageIds: [pageId],
workspaceId: page.workspaceId, workspaceId: page.workspaceId,
@@ -181,7 +202,8 @@ export class PersistenceExtension implements Extension {
async onChange(data: onChangePayload) { async onChange(data: onChangePayload) {
const documentName = data.documentName; const documentName = data.documentName;
const userId = data.context?.user.id; const userId = data.context?.user?.id;
if (!userId) return; if (!userId) return;
if (!this.contributors.has(documentName)) { if (!this.contributors.has(documentName)) {
@@ -7,6 +7,7 @@ import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { CollabHistoryService } from '../services/collab-history.service'; import { CollabHistoryService } from '../services/collab-history.service';
import { WatcherService } from '../../core/watcher/watcher.service';
@Processor(QueueName.HISTORY_QUEUE) @Processor(QueueName.HISTORY_QUEUE)
export class HistoryProcessor extends WorkerHost implements OnModuleDestroy { export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
@@ -16,6 +17,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly pageHistoryRepo: PageHistoryRepo, private readonly pageHistoryRepo: PageHistoryRepo,
private readonly pageRepo: PageRepo, private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService, private readonly collabHistory: CollabHistoryService,
private readonly watcherService: WatcherService,
) { ) {
super(); super();
} }
@@ -49,6 +51,13 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
await this.collabHistory.popContributors(pageId); await this.collabHistory.popContributors(pageId);
try { try {
await this.watcherService.addPageWatchers(
contributorIds,
pageId,
page.spaceId,
page.workspaceId,
);
await this.pageHistoryRepo.saveHistory(page, { contributorIds }); await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`); this.logger.debug(`History created for page: ${pageId}`);
} catch (err) { } catch (err) {
@@ -7,6 +7,7 @@ import {
import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor'; import { TransformHttpResponseInterceptor } from '../../common/interceptors/http-response.interceptor';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Logger as PinoLogger } from 'nestjs-pino'; import { Logger as PinoLogger } from 'nestjs-pino';
import { InternalLogFilter } from '../../common/logger/internal-log-filter';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestFastifyApplication>( const app = await NestFactory.create<NestFastifyApplication>(
@@ -19,7 +20,7 @@ async function bootstrap() {
}, },
}), }),
{ {
logger: false, logger: new InternalLogFilter(),
bufferLogs: false, bufferLogs: false,
}, },
); );
+177
View File
@@ -0,0 +1,177 @@
import {
initProseMirrorDoc,
relativePositionToAbsolutePosition,
} from 'y-prosemirror';
import * as Y from 'yjs';
import { Document } from '@hocuspocus/server';
import { getSchema } from '@tiptap/core';
import { tiptapExtensions } from './collaboration.util';
export type YjsSelection = {
anchor: any;
head: any;
};
export function setYjsMark(
doc: Document,
fragment: Y.XmlFragment,
yjsSelection: YjsSelection,
markName: string,
markAttributes: Record<string, any>,
) {
const schema = getSchema(tiptapExtensions);
const { mapping } = initProseMirrorDoc(fragment, schema);
// Convert JSON positions to Y.js RelativePosition objects
const anchorRelPos = Y.createRelativePositionFromJSON(yjsSelection.anchor);
const headRelPos = Y.createRelativePositionFromJSON(yjsSelection.head);
const anchor = relativePositionToAbsolutePosition(
doc,
fragment,
anchorRelPos,
mapping,
);
const head = relativePositionToAbsolutePosition(
doc,
fragment,
headRelPos,
mapping,
);
if (anchor === null || head === null) {
throw new Error(
'Could not resolve Y.js relative positions to absolute positions',
);
}
const from = Math.min(anchor, head);
const to = Math.max(anchor, head);
// Apply mark directly to Y.js XmlText nodes
// This bypasses updateYFragment which has compatibility issues
applyMarkToYFragment(fragment, from, to, markName, markAttributes);
}
function applyMarkToYFragment(
fragment: Y.XmlFragment,
from: number,
to: number,
markName: string,
markAttributes: Record<string, any>,
) {
let pos = 0;
const processItem = (item: any): boolean => {
if (pos >= to) return false;
if (item instanceof Y.XmlText) {
const textLength = item.length;
const itemEnd = pos + textLength;
if (itemEnd > from && pos < to) {
const formatFrom = Math.max(0, from - pos);
const formatTo = Math.min(textLength, to - pos);
const formatLength = formatTo - formatFrom;
if (formatLength > 0) {
item.format(formatFrom, formatLength, { [markName]: markAttributes });
}
}
pos = itemEnd;
} else if (item instanceof Y.XmlElement) {
pos++; // Opening tag
for (let i = 0; i < item.length; i++) {
if (!processItem(item.get(i))) return false;
}
pos++; // Closing tag
}
return true;
};
for (let i = 0; i < fragment.length; i++) {
if (!processItem(fragment.get(i))) break;
}
}
/**
* Removes a mark from all text in the fragment that has the specified attribute value.
* Useful for deleting comments by commentId.
*/
export function removeYjsMarkByAttribute(
fragment: Y.XmlFragment,
markName: string,
attributeName: string,
attributeValue: string,
) {
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
// Get all formatting deltas to find ranges with this mark
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const length = delta.insert?.length ?? 0;
const attributes = delta.attributes ?? {};
const markAttr = attributes[markName];
if (markAttr && markAttr[attributeName] === attributeValue) {
// Remove the mark by setting it to null
item.format(offset, length, { [markName]: null });
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
};
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
}
/**
* Updates a mark's attributes for all text that has the specified attribute value.
* Useful for resolving/unresolving comments by commentId.
*/
export function updateYjsMarkAttribute(
fragment: Y.XmlFragment,
markName: string,
findByAttribute: { name: string; value: string },
newAttributes: Record<string, any>,
) {
const processItem = (item: any) => {
if (item instanceof Y.XmlText) {
const deltas = item.toDelta();
let offset = 0;
for (const delta of deltas) {
const length = delta.insert?.length ?? 0;
const attributes = delta.attributes ?? {};
const markAttr = attributes[markName];
if (
markAttr &&
markAttr[findByAttribute.name] === findByAttribute.value
) {
// Update the mark with new attributes (merge with existing)
item.format(offset, length, {
[markName]: { ...markAttr, ...newAttributes },
});
}
offset += length;
}
} else if (item instanceof Y.XmlElement) {
for (let i = 0; i < item.length; i++) {
processItem(item.get(i));
}
}
};
for (let i = 0; i < fragment.length; i++) {
processItem(fragment.get(i));
}
}
@@ -9,3 +9,7 @@ export const LOCAL_STORAGE_PATH = path.resolve(
'..', '..',
LOCAL_STORAGE_DIR, LOCAL_STORAGE_DIR,
); );
export function getPageTitle(title: string | null | undefined): string {
return title || 'untitled';
}
@@ -64,6 +64,30 @@ export function extractPageMentions(mentionList: MentionNode[]): MentionNode[] {
return pageMentionList as MentionNode[]; return pageMentionList as MentionNode[];
} }
export function extractUserMentionIdsFromJson(json: any): string[] {
const userIds: string[] = [];
function walk(node: any) {
if (!node) return;
if (
node.type === 'mention' &&
node.attrs?.entityType === 'user' &&
node.attrs?.entityId &&
!userIds.includes(node.attrs.entityId)
) {
userIds.push(node.attrs.entityId);
}
if (Array.isArray(node.content)) {
for (const child of node.content) {
walk(child);
}
}
}
walk(json);
return userIds;
}
export function getProsemirrorContent(content: any) { export function getProsemirrorContent(content: any) {
return ( return (
content ?? { content ?? {
@@ -1,7 +1,8 @@
import { ConsoleLogger } from '@nestjs/common'; import { ConsoleLogger, LogLevel } from '@nestjs/common';
export class InternalLogFilter extends ConsoleLogger { export class InternalLogFilter extends ConsoleLogger {
static contextsToIgnore = [ static contextsToIgnore = [
'NestFactory',
'InstanceLoader', 'InstanceLoader',
'RoutesResolver', 'RoutesResolver',
'RouterExplorer', 'RouterExplorer',
@@ -11,14 +12,23 @@ export class InternalLogFilter extends ConsoleLogger {
private allowedLogLevels: string[]; private allowedLogLevels: string[];
constructor() { constructor() {
super();
const isProduction = process.env.NODE_ENV === 'production'; const isProduction = process.env.NODE_ENV === 'production';
super({
json: isProduction,
});
const isDebugMode = process.env.DEBUG_MODE === 'true'; const isDebugMode = process.env.DEBUG_MODE === 'true';
if (isProduction && !isDebugMode) { if (isProduction && !isDebugMode) {
this.allowedLogLevels = ['log', 'error', 'fatal']; this.allowedLogLevels = ['info', 'error', 'fatal'];
} else { } else {
this.allowedLogLevels = ['log', 'debug', 'verbose', 'warn', 'error', 'fatal']; this.allowedLogLevels = [
'info',
'debug',
'verbose',
'warn',
'error',
'fatal',
];
} }
} }
@@ -28,9 +38,8 @@ export class InternalLogFilter extends ConsoleLogger {
log(_: any, context?: string): void { log(_: any, context?: string): void {
if ( if (
this.isLogLevelAllowed('log') && this.isLogLevelAllowed('info') &&
(process.env.NODE_ENV !== 'production' || !InternalLogFilter.contextsToIgnore.includes(context)
!InternalLogFilter.contextsToIgnore.includes(context))
) { ) {
super.log.apply(this, arguments); super.log.apply(this, arguments);
} }
@@ -59,4 +68,15 @@ export class InternalLogFilter extends ConsoleLogger {
super.verbose.apply(this, arguments); super.verbose.apply(this, arguments);
} }
} }
protected printMessages(
messages: unknown[],
context?: string,
logLevel?: LogLevel,
writeStreamType?: 'stdout' | 'stderr',
errorStack?: unknown,
): void {
const level = logLevel === 'log' ? ('info' as LogLevel) : logLevel;
super.printMessages(messages, context, level, writeStreamType, errorStack);
}
} }
@@ -3,7 +3,6 @@ import { CommentService } from './comment.service';
import { CommentController } from './comment.controller'; import { CommentController } from './comment.controller';
@Module({ @Module({
imports: [],
controllers: [CommentController], controllers: [CommentController],
providers: [CommentService], providers: [CommentService],
exports: [CommentService], exports: [CommentService],
@@ -2,8 +2,11 @@ import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
Injectable, Injectable,
Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { CreateCommentDto } from './dto/create-comment.dto'; import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto'; import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo'; import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
@@ -11,12 +14,21 @@ import { Comment, Page, User } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { extractUserMentionIdsFromJson } from '../../common/helpers/prosemirror/utils';
import { ICommentNotificationJob } from '../../integrations/queue/constants/queue.interface';
@Injectable() @Injectable()
export class CommentService { export class CommentService {
private readonly logger = new Logger(CommentService.name);
constructor( constructor(
private commentRepo: CommentRepo, private commentRepo: CommentRepo,
private pageRepo: PageRepo, private pageRepo: PageRepo,
@InjectQueue(QueueName.GENERAL_QUEUE)
private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
private notificationQueue: Queue,
) {} ) {}
async findById(commentId: string) { async findById(commentId: string) {
@@ -51,7 +63,7 @@ export class CommentService {
} }
} }
return await this.commentRepo.insertComment({ const comment = await this.commentRepo.insertComment({
pageId: page.id, pageId: page.id,
content: commentContent, content: commentContent,
selection: createCommentDto?.selection?.substring(0, 250), selection: createCommentDto?.selection?.substring(0, 250),
@@ -61,6 +73,33 @@ export class CommentService {
workspaceId: workspaceId, workspaceId: workspaceId,
spaceId: page.spaceId, spaceId: page.spaceId,
}); });
this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [userId],
pageId: page.id,
spaceId: page.spaceId,
workspaceId,
})
.catch((err) =>
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
);
const isReply = !!createCommentDto.parentCommentId;
await this.queueCommentNotification(
commentContent,
[],
comment.id,
page.id,
page.spaceId,
workspaceId,
userId,
!isReply,
createCommentDto.parentCommentId,
);
return comment;
} }
async findByPageId( async findByPageId(
@@ -87,6 +126,8 @@ export class CommentService {
throw new ForbiddenException('You can only edit your own comments'); throw new ForbiddenException('You can only edit your own comments');
} }
const oldMentionIds = extractUserMentionIdsFromJson(comment.content);
const editedAt = new Date(); const editedAt = new Date();
await this.commentRepo.updateComment( await this.commentRepo.updateComment(
@@ -97,10 +138,57 @@ export class CommentService {
}, },
comment.id, comment.id,
); );
await this.queueCommentNotification(
commentContent,
oldMentionIds,
comment.id,
comment.pageId,
comment.spaceId,
comment.workspaceId,
authUser.id,
false,
);
comment.content = commentContent; comment.content = commentContent;
comment.editedAt = editedAt; comment.editedAt = editedAt;
comment.updatedAt = editedAt; comment.updatedAt = editedAt;
return comment; return comment;
} }
private async queueCommentNotification(
content: any,
oldMentionIds: string[],
commentId: string,
pageId: string,
spaceId: string,
workspaceId: string,
actorId: string,
notifyWatchers: boolean,
parentCommentId?: string,
) {
const mentionedUserIds = extractUserMentionIdsFromJson(content);
const newMentionIds = mentionedUserIds.filter(
(id) => id !== actorId && !oldMentionIds.includes(id),
);
if (newMentionIds.length === 0 && !notifyWatchers && !parentCommentId) return;
const jobData: ICommentNotificationJob = {
commentId,
parentCommentId,
pageId,
spaceId,
workspaceId,
actorId,
mentionedUserIds: newMentionIds,
notifyWatchers,
};
await this.notificationQueue.add(
QueueJob.COMMENT_NOTIFICATION,
jobData,
);
}
} }
+4
View File
@@ -17,6 +17,8 @@ import { CaslModule } from './casl/casl.module';
import { PageAccessModule } from './page-access/page-access.module'; import { PageAccessModule } from './page-access/page-access.module';
import { DomainMiddleware } from '../common/middlewares/domain.middleware'; import { DomainMiddleware } from '../common/middlewares/domain.middleware';
import { ShareModule } from './share/share.module'; import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module';
@Module({ @Module({
imports: [ imports: [
@@ -32,6 +34,8 @@ import { ShareModule } from './share/share.module';
CaslModule, CaslModule,
PageAccessModule, PageAccessModule,
ShareModule, ShareModule,
NotificationModule,
WatcherModule,
], ],
}) })
export class CoreModule implements NestModule { export class CoreModule implements NestModule {
@@ -4,6 +4,7 @@ import { GroupController } from './group.controller';
import { GroupUserService } from './services/group-user.service'; import { GroupUserService } from './services/group-user.service';
@Module({ @Module({
imports: [],
controllers: [GroupController], controllers: [GroupController],
providers: [GroupService, GroupUserService], providers: [GroupService, GroupUserService],
exports: [GroupService, GroupUserService], exports: [GroupService, GroupUserService],
@@ -10,15 +10,20 @@ import { GroupService } from './group.service';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo'; import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { executeTx } from '@docmost/db/utils';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
@Injectable() @Injectable()
export class GroupUserService { export class GroupUserService {
constructor( constructor(
private groupUserRepo: GroupUserRepo, private groupUserRepo: GroupUserRepo,
private spaceMemberRepo: SpaceMemberRepo,
private userRepo: UserRepo, private userRepo: UserRepo,
@Inject(forwardRef(() => GroupService)) @Inject(forwardRef(() => GroupService))
private groupService: GroupService, private groupService: GroupService,
private readonly watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
) {} ) {}
@@ -100,6 +105,18 @@ export class GroupUserService {
throw new BadRequestException('Group member not found'); throw new BadRequestException('Group member not found');
} }
await this.groupUserRepo.delete(userId, groupId); const spaceIds = await this.spaceMemberRepo.getSpaceIdsByGroupId(groupId);
// TODO: use queue instead
await executeTx(this.db, async (trx) => {
await this.groupUserRepo.delete(userId, groupId, { trx });
for (const spaceId of spaceIds) {
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
[userId],
spaceId,
);
}
});
} }
} }
@@ -8,18 +8,27 @@ import {
import { CreateGroupDto, DefaultGroup } from '../dto/create-group.dto'; import { CreateGroupDto, DefaultGroup } from '../dto/create-group.dto';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { UpdateGroupDto } from '../dto/update-group.dto'; import { UpdateGroupDto } from '../dto/update-group.dto';
import { KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { GroupRepo } from '@docmost/db/repos/group/group.repo'; import { GroupRepo } from '@docmost/db/repos/group/group.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types'; import { Group, InsertableGroup, User } from '@docmost/db/types/entity.types';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { GroupUserService } from './group-user.service'; import { GroupUserService } from './group-user.service';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
@Injectable() @Injectable()
export class GroupService { export class GroupService {
constructor( constructor(
private groupRepo: GroupRepo, private groupRepo: GroupRepo,
private groupUserRepo: GroupUserRepo,
private spaceMemberRepo: SpaceMemberRepo,
@Inject(forwardRef(() => GroupUserService)) @Inject(forwardRef(() => GroupUserService))
private groupUserService: GroupUserService, private groupUserService: GroupUserService,
private readonly watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB,
) {} ) {}
async getGroupInfo(groupId: string, workspaceId: string): Promise<Group> { async getGroupInfo(groupId: string, workspaceId: string): Promise<Group> {
@@ -68,20 +77,6 @@ export class GroupService {
return createdGroup; return createdGroup;
} }
async createDefaultGroup(
workspaceId: string,
userId?: string,
trx?: KyselyTransaction,
): Promise<Group> {
const insertableGroup: InsertableGroup = {
name: DefaultGroup.EVERYONE,
isDefault: true,
creatorId: userId ?? null,
workspaceId: workspaceId,
};
return await this.groupRepo.insertGroup(insertableGroup, trx);
}
async updateGroup( async updateGroup(
workspaceId: string, workspaceId: string,
updateGroupDto: UpdateGroupDto, updateGroupDto: UpdateGroupDto,
@@ -141,7 +136,24 @@ export class GroupService {
if (group.isDefault) { if (group.isDefault) {
throw new BadRequestException('You cannot delete a default group'); throw new BadRequestException('You cannot delete a default group');
} }
await this.groupRepo.delete(groupId, workspaceId);
const [userIds, spaceIds] = await Promise.all([
this.groupUserRepo.getUserIdsByGroupId(groupId),
this.spaceMemberRepo.getSpaceIdsByGroupId(groupId),
]);
// TODO: use queue instead
await executeTx(this.db, async (trx) => {
await this.groupRepo.delete(groupId, workspaceId, { trx });
for (const spaceId of spaceIds) {
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
userIds,
spaceId,
{ trx },
);
}
});
} }
async findAndValidateGroup( async findAndValidateGroup(
@@ -0,0 +1,13 @@
import { IsArray, IsOptional, IsUUID } from 'class-validator';
export class NotificationIdDto {
@IsUUID()
notificationId: string;
}
export class MarkNotificationsReadDto {
@IsArray()
@IsUUID(undefined, { each: true })
@IsOptional()
notificationIds?: string[];
}
@@ -0,0 +1,9 @@
export const NotificationType = {
COMMENT_USER_MENTION: 'comment.user_mention',
COMMENT_CREATED: 'comment.created',
COMMENT_RESOLVED: 'comment.resolved',
PAGE_USER_MENTION: 'page.user_mention',
} as const;
export type NotificationType =
(typeof NotificationType)[keyof typeof NotificationType];
@@ -0,0 +1,56 @@
import {
Body,
Controller,
HttpCode,
HttpStatus,
Post,
UseGuards,
} from '@nestjs/common';
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';
@UseGuards(JwtAuthGuard)
@Controller('notifications')
export class NotificationController {
constructor(private readonly notificationService: NotificationService) {}
@HttpCode(HttpStatus.OK)
@Post('/')
async getNotifications(
@Body() pagination: PaginationOptions,
@AuthUser() user: User,
) {
return this.notificationService.findByUserId(user.id, pagination);
}
@HttpCode(HttpStatus.OK)
@Post('unread-count')
async getUnreadCount(@AuthUser() user: User) {
const count = await this.notificationService.getUnreadCount(user.id);
return { count };
}
@HttpCode(HttpStatus.OK)
@Post('mark-read')
async markAsRead(
@Body() dto: MarkNotificationsReadDto,
@AuthUser() user: User,
) {
if (dto.notificationIds?.length) {
await this.notificationService.markMultipleAsRead(
dto.notificationIds,
user.id,
);
}
}
@HttpCode(HttpStatus.OK)
@Post('mark-all-read')
async markAllAsRead(@AuthUser() user: User) {
await this.notificationService.markAllAsRead(user.id);
}
}
@@ -0,0 +1,19 @@
import { Module } from '@nestjs/common';
import { NotificationService } from './notification.service';
import { NotificationController } from './notification.controller';
import { NotificationProcessor } from './notification.processor';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
@Module({
imports: [],
controllers: [NotificationController],
providers: [
NotificationService,
NotificationProcessor,
CommentNotificationService,
PageNotificationService,
],
exports: [NotificationService],
})
export class NotificationModule {}
@@ -0,0 +1,101 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import {
ICommentNotificationJob,
ICommentResolvedNotificationJob,
IPageMentionNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
import { DomainService } from '../../integrations/environment/domain.service';
@Processor(QueueName.NOTIFICATION_QUEUE)
export class NotificationProcessor
extends WorkerHost
implements OnModuleDestroy
{
private readonly logger = new Logger(NotificationProcessor.name);
constructor(
private readonly commentNotificationService: CommentNotificationService,
private readonly pageNotificationService: PageNotificationService,
private readonly domainService: DomainService,
@InjectKysely() private readonly db: KyselyDB,
) {
super();
}
async process(
job: Job<
| ICommentNotificationJob
| ICommentResolvedNotificationJob
| IPageMentionNotificationJob,
void
>,
): Promise<void> {
try {
const workspaceId = (job.data as { workspaceId: string }).workspaceId;
const appUrl = await this.getWorkspaceUrl(workspaceId);
switch (job.name) {
case QueueJob.COMMENT_NOTIFICATION: {
await this.commentNotificationService.processComment(
job.data as ICommentNotificationJob,
appUrl,
);
break;
}
case QueueJob.COMMENT_RESOLVED_NOTIFICATION: {
await this.commentNotificationService.processResolved(
job.data as ICommentResolvedNotificationJob,
appUrl,
);
break;
}
case QueueJob.PAGE_MENTION_NOTIFICATION: {
await this.pageNotificationService.processPageMention(
job.data as IPageMentionNotificationJob,
appUrl,
);
break;
}
default:
this.logger.warn(`Unknown notification job: ${job.name}`);
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
this.logger.error(`Failed to process ${job.name}: ${message}`);
throw err;
}
}
private async getWorkspaceUrl(workspaceId: string): Promise<string> {
const workspace = await this.db
.selectFrom('workspaces')
.select('hostname')
.where('id', '=', workspaceId)
.executeTakeFirst();
return this.domainService.getUrl(workspace?.hostname);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}
@@ -0,0 +1,80 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
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';
@Injectable()
export class NotificationService {
private readonly logger = new Logger(NotificationService.name);
constructor(
private readonly notificationRepo: NotificationRepo,
private readonly wsGateway: WsGateway,
private readonly mailService: MailService,
@InjectKysely() private readonly db: KyselyDB,
) {}
async create(data: InsertableNotification) {
const notification = await this.notificationRepo.insert(data);
this.wsGateway.server
.to(`user-${data.userId}`)
.emit('notification', { id: notification.id, type: notification.type });
return notification;
}
async findByUserId(userId: string, pagination: PaginationOptions) {
return this.notificationRepo.findByUserId(userId, pagination);
}
async getUnreadCount(userId: string) {
return this.notificationRepo.getUnreadCount(userId);
}
async markAsRead(notificationId: string, userId: string) {
return this.notificationRepo.markAsRead(notificationId, userId);
}
async markMultipleAsRead(notificationIds: string[], userId: string) {
return this.notificationRepo.markMultipleAsRead(notificationIds, userId);
}
async markAllAsRead(userId: string) {
return this.notificationRepo.markAllAsRead(userId);
}
async queueEmail(
userId: string,
notificationId: string,
subject: string,
template: any,
) {
try {
const user = await this.db
.selectFrom('users')
.select(['email'])
.where('id', '=', userId)
.where('deletedAt', 'is', null)
.executeTakeFirst();
if (!user?.email) return;
await this.mailService.sendToQueue({
to: user.email,
subject,
template,
notificationId,
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Unknown error';
this.logger.error(
`Failed to queue email for notification ${notificationId}: ${message}`,
);
}
}
}
@@ -0,0 +1,219 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
ICommentNotificationJob,
ICommentResolvedNotificationJob,
} from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { CommentMentionEmail } from '@docmost/transactional/emails/comment-mention-email';
import { CommentCreateEmail } from '@docmost/transactional/emails/comment-created-email';
import { CommentResolvedEmail } from '@docmost/transactional/emails/comment-resolved-email';
import { getPageTitle } from '../../../common/helpers';
@Injectable()
export class CommentNotificationService {
private readonly logger = new Logger(CommentNotificationService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly watcherRepo: WatcherRepo,
) {}
async processComment(data: ICommentNotificationJob, appUrl: string) {
const {
commentId,
parentCommentId,
pageId,
spaceId,
workspaceId,
actorId,
mentionedUserIds,
notifyWatchers,
} = data;
const context = await this.getCommentContext(
actorId,
pageId,
spaceId,
commentId,
appUrl,
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const notifiedUserIds = new Set<string>();
notifiedUserIds.add(actorId);
const recipientIds = parentCommentId
? await this.getThreadParticipantIds(parentCommentId)
: notifyWatchers
? await this.watcherRepo.getPageWatcherIds(pageId)
: [];
const allCandidateIds = [
...new Set([...mentionedUserIds, ...recipientIds]),
];
const usersWithAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
allCandidateIds,
spaceId,
);
for (const userId of mentionedUserIds) {
if (!usersWithAccess.has(userId)) continue;
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.COMMENT_USER_MENTION,
actorId,
pageId,
spaceId,
commentId,
});
await this.notificationService.queueEmail(
userId,
notification.id,
`${actor.name} mentioned you in a comment`,
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
);
notifiedUserIds.add(userId);
}
for (const recipientId of recipientIds) {
if (notifiedUserIds.has(recipientId)) continue;
if (!usersWithAccess.has(recipientId)) continue;
const notification = await this.notificationService.create({
userId: recipientId,
workspaceId,
type: NotificationType.COMMENT_CREATED,
actorId,
pageId,
spaceId,
commentId,
});
await this.notificationService.queueEmail(
recipientId,
notification.id,
`${actor.name} commented on ${pageTitle}`,
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
);
}
}
async processResolved(data: ICommentResolvedNotificationJob, appUrl: string) {
const {
commentId,
commentCreatorId,
pageId,
spaceId,
workspaceId,
actorId,
} = data;
if (commentCreatorId === actorId) return;
const context = await this.getCommentContext(
actorId,
pageId,
spaceId,
commentId,
appUrl,
);
if (!context) return;
const { actor, pageTitle, pageUrl } = context;
const roles = await this.spaceMemberRepo.getUserSpaceRoles(
commentCreatorId,
spaceId,
);
if (!roles) {
this.logger.debug(
`Skipping resolved notification for user ${commentCreatorId}: no access to space ${spaceId}`,
);
return;
}
const notification = await this.notificationService.create({
userId: commentCreatorId,
workspaceId,
type: NotificationType.COMMENT_RESOLVED,
actorId,
pageId,
spaceId,
commentId,
});
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
await this.notificationService.queueEmail(
commentCreatorId,
notification.id,
subject,
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
);
}
private async getThreadParticipantIds(
parentCommentId: string,
): Promise<string[]> {
const participants = await this.db
.selectFrom('comments')
.select('creatorId')
.where((eb) =>
eb.or([
eb('id', '=', parentCommentId),
eb('parentCommentId', '=', parentCommentId),
]),
)
.execute();
return [...new Set(participants.map((p) => p.creatorId))];
}
private async getCommentContext(
actorId: string,
pageId: string,
spaceId: string,
commentId: string,
appUrl: string,
) {
const [actor, page, space] = await Promise.all([
this.db
.selectFrom('users')
.select(['id', 'name'])
.where('id', '=', actorId)
.executeTakeFirst(),
this.db
.selectFrom('pages')
.select(['id', 'title', 'slugId'])
.where('id', '=', pageId)
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
if (!actor || !page || !space) {
return null;
}
const pageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { actor, pageTitle: getPageTitle(page.title), pageUrl };
}
}
@@ -0,0 +1,132 @@
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { IPageMentionNotificationJob } from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
import { getPageTitle } from '../../../common/helpers';
@Injectable()
export class PageNotificationService {
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly spaceMemberRepo: SpaceMemberRepo,
) {}
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
const { userMentions, oldMentionedUserIds, pageId, spaceId, workspaceId } =
data;
const oldIds = new Set(oldMentionedUserIds);
const newMentions = userMentions.filter(
(m) => !oldIds.has(m.userId) && m.creatorId !== m.userId,
);
if (newMentions.length === 0) return;
const candidateUserIds = newMentions.map((m) => m.userId);
const usersWithAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
candidateUserIds,
spaceId,
);
const accessibleMentions = newMentions.filter((m) =>
usersWithAccess.has(m.userId),
);
if (accessibleMentions.length === 0) return;
const mentionsByCreator = new Map<
string,
{ userId: string; mentionId: string }[]
>();
for (const m of accessibleMentions) {
const list = mentionsByCreator.get(m.creatorId) || [];
list.push({ userId: m.userId, mentionId: m.mentionId });
mentionsByCreator.set(m.creatorId, list);
}
for (const [actorId, mentions] of mentionsByCreator) {
await this.notifyMentionedUsers(
mentions,
actorId,
pageId,
spaceId,
workspaceId,
appUrl,
);
}
}
private async notifyMentionedUsers(
mentions: { userId: string; mentionId: string }[],
actorId: string,
pageId: string,
spaceId: string,
workspaceId: string,
appUrl: string,
) {
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
if (!context) return;
const { actor, pageTitle, basePageUrl } = context;
for (const { userId, mentionId } of mentions) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_USER_MENTION,
actorId,
pageId,
spaceId,
data: { mentionId },
});
const pageUrl = `${basePageUrl}`;
const subject = `${actor.name} mentioned you in ${pageTitle}`;
await this.notificationService.queueEmail(
userId,
notification.id,
subject,
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
);
}
}
private async getPageContext(
actorId: string,
pageId: string,
spaceId: string,
appUrl: string,
) {
const [actor, page, space] = await Promise.all([
this.db
.selectFrom('users')
.select(['id', 'name'])
.where('id', '=', actorId)
.executeTakeFirst(),
this.db
.selectFrom('pages')
.select(['id', 'title', 'slugId'])
.where('id', '=', pageId)
.executeTakeFirst(),
this.db
.selectFrom('spaces')
.select(['id', 'slug'])
.where('id', '=', spaceId)
.executeTakeFirst(),
]);
if (!actor || !page || !space) {
return null;
}
const basePageUrl = `${appUrl}/s/${space.slug}/p/${page.slugId}`;
return { actor, pageTitle: getPageTitle(page.title), basePageUrl };
}
}
@@ -1,4 +1,13 @@
import { IsOptional, IsString, IsUUID } from 'class-validator'; import {
IsIn,
IsOptional,
IsString,
IsUUID,
ValidateIf,
} from 'class-validator';
import { Transform } from 'class-transformer';
export type ContentFormat = 'json' | 'markdown' | 'html';
export class CreatePageDto { export class CreatePageDto {
@IsOptional() @IsOptional()
@@ -15,4 +24,12 @@ export class CreatePageDto {
@IsUUID() @IsUUID()
spaceId: string; spaceId: string;
@IsOptional()
content?: string | object;
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
} }
@@ -1,10 +1,14 @@
import { import {
IsBoolean, IsBoolean,
IsIn,
IsNotEmpty, IsNotEmpty,
IsOptional, IsOptional,
IsString, IsString,
IsUUID, IsUUID,
} from 'class-validator'; } from 'class-validator';
import { Transform } from 'class-transformer';
import { ContentFormat } from './create-page.dto';
export class PageIdDto { export class PageIdDto {
@IsString() @IsString()
@@ -30,6 +34,11 @@ export class PageInfoDto extends PageIdDto {
@IsOptional() @IsOptional()
@IsBoolean() @IsBoolean()
includeContent: boolean; includeContent: boolean;
@IsOptional()
@Transform(({ value }) => value?.toLowerCase())
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
} }
export class DeletePageDto extends PageIdDto { export class DeletePageDto extends PageIdDto {
@@ -1,8 +1,24 @@
import { PartialType } from '@nestjs/mapped-types'; import { PartialType } from '@nestjs/mapped-types';
import { CreatePageDto } from './create-page.dto'; import { CreatePageDto, ContentFormat } from './create-page.dto';
import { IsString } from 'class-validator'; import { IsIn, IsOptional, IsString, ValidateIf } from 'class-validator';
import { Transform } from 'class-transformer';
export type ContentOperation = 'append' | 'prepend' | 'replace';
export class UpdatePageDto extends PartialType(CreatePageDto) { export class UpdatePageDto extends PartialType(CreatePageDto) {
@IsString() @IsString()
pageId: string; pageId: string;
@IsOptional()
content?: string | object;
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase())
@IsIn(['append', 'prepend', 'replace'])
operation?: ContentOperation;
@ValidateIf((o) => o.content !== undefined)
@Transform(({ value }) => value?.toLowerCase() ?? 'json')
@IsIn(['json', 'markdown', 'html'])
format?: ContentFormat;
} }
+53 -2
View File
@@ -36,6 +36,10 @@ import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto'; import { RecentPageDto } from './dto/recent-page.dto';
import { DuplicatePageDto } from './dto/duplicate-page.dto'; import { DuplicatePageDto } from './dto/duplicate-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto'; import { DeletedPageDto } from './dto/deleted-page.dto';
import {
jsonToHtml,
jsonToMarkdown,
} from '../../collaboration/collaboration.util';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('pages') @Controller('pages')
@@ -65,6 +69,17 @@ export class PageController {
await this.pageAccessService.validateCanView(page, user); await this.pageAccessService.validateCanView(page, user);
if (dto.format && dto.format !== 'json' && page.content) {
const contentOutput =
dto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return {
...page,
content: contentOutput,
};
}
return page; return page;
} }
@@ -99,7 +114,25 @@ export class PageController {
} }
} }
return this.pageService.create(user.id, workspace.id, createPageDto); const page = await this.pageService.create(
user.id,
workspace.id,
createPageDto,
);
if (
createPageDto.format &&
createPageDto.format !== 'json' &&
page.content
) {
const contentOutput =
createPageDto.format === 'markdown'
? jsonToMarkdown(page.content)
: jsonToHtml(page.content);
return { ...page, content: contentOutput };
}
return page;
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@@ -113,7 +146,25 @@ export class PageController {
await this.pageAccessService.validateCanEdit(page, user); await this.pageAccessService.validateCanEdit(page, user);
return this.pageService.update(page, updatePageDto, user.id); const updatedPage = await this.pageService.update(
page,
updatePageDto,
user,
);
if (
updatePageDto.format &&
updatePageDto.format !== 'json' &&
updatedPage.content
) {
const contentOutput =
updatePageDto.format === 'markdown'
? jsonToMarkdown(updatedPage.content)
: jsonToHtml(updatedPage.content);
return { ...updatedPage, content: contentOutput };
}
return updatedPage;
} }
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
+3 -1
View File
@@ -6,6 +6,8 @@ import { TrashCleanupService } from './services/trash-cleanup.service';
import { PagePermissionService } from './services/page-permission.service'; import { PagePermissionService } from './services/page-permission.service';
import { PagePermissionController } from './page-permission.controller'; import { PagePermissionController } from './page-permission.controller';
import { StorageModule } from '../../integrations/storage/storage.module'; import { StorageModule } from '../../integrations/storage/storage.module';
import { CollaborationModule } from '../../collaboration/collaboration.module';
import { WatcherModule } from '../watcher/watcher.module';
@Module({ @Module({
controllers: [PageController, PagePermissionController], controllers: [PageController, PagePermissionController],
@@ -16,6 +18,6 @@ import { StorageModule } from '../../integrations/storage/storage.module';
PagePermissionService, PagePermissionService,
], ],
exports: [PageService, PageHistoryService, PagePermissionService], exports: [PageService, PageHistoryService, PagePermissionService],
imports: [StorageModule], imports: [StorageModule, CollaborationModule, WatcherModule],
}) })
export class PageModule {} export class PageModule {}
@@ -4,8 +4,8 @@ import {
Logger, Logger,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { CreatePageDto } from '../dto/create-page.dto'; import { CreatePageDto, ContentFormat } from '../dto/create-page.dto';
import { UpdatePageDto } from '../dto/update-page.dto'; import { ContentOperation, UpdatePageDto } from '../dto/update-page.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo'; import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { InsertablePage, Page, User } from '@docmost/db/types/entity.types'; import { InsertablePage, Page, User } from '@docmost/db/types/entity.types';
@@ -19,6 +19,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered'; import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
import { MovePageDto } from '../dto/move-page.dto'; import { MovePageDto } from '../dto/move-page.dto';
import { generateSlugId } from '../../../common/helpers'; import { generateSlugId } from '../../../common/helpers';
import { getPageTitle } from '../../../common/helpers';
import { executeTx } from '@docmost/db/utils'; import { executeTx } from '@docmost/db/utils';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo'; import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { v7 as uuid7 } from 'uuid'; import { v7 as uuid7 } from 'uuid';
@@ -29,7 +30,11 @@ import {
isAttachmentNode, isAttachmentNode,
removeMarkTypeFromDoc, removeMarkTypeFromDoc,
} from '../../../common/helpers/prosemirror/utils'; } from '../../../common/helpers/prosemirror/utils';
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util'; import {
htmlToJson,
jsonToNode,
jsonToText,
} from 'src/collaboration/collaboration.util';
import { import {
CopyPageMapEntry, CopyPageMapEntry,
ICopyPageAttachment, ICopyPageAttachment,
@@ -41,6 +46,9 @@ import { Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants'; import { QueueJob, QueueName } from '../../../integrations/queue/constants';
import { EventName } from '../../../common/events/event.contants'; import { EventName } from '../../../common/events/event.contants';
import { EventEmitter2 } from '@nestjs/event-emitter'; import { EventEmitter2 } from '@nestjs/event-emitter';
import { CollaborationGateway } from '../../../collaboration/collaboration.gateway';
import { markdownToHtml } from '@docmost/editor-ext';
import { WatcherService } from '../../watcher/watcher.service';
@Injectable() @Injectable()
export class PageService { export class PageService {
@@ -54,7 +62,10 @@ export class PageService {
private readonly storageService: StorageService, private readonly storageService: StorageService,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue, @InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
private eventEmitter: EventEmitter2, private eventEmitter: EventEmitter2,
private collaborationGateway: CollaborationGateway,
private readonly watcherService: WatcherService,
) {} ) {}
async findById( async findById(
@@ -94,7 +105,22 @@ export class PageService {
parentPageId = parentPage.id; parentPageId = parentPage.id;
} }
const createdPage = await this.pageRepo.insertPage({ let content = undefined;
let textContent = undefined;
let ydoc = undefined;
if (createPageDto?.content && createPageDto?.format) {
const prosemirrorJson = await this.parseProsemirrorContent(
createPageDto.content,
createPageDto.format,
);
content = prosemirrorJson;
textContent = jsonToText(prosemirrorJson);
ydoc = createYdocFromJson(prosemirrorJson);
}
const page = await this.pageRepo.insertPage({
slugId: generateSlugId(), slugId: generateSlugId(),
title: createPageDto.title, title: createPageDto.title,
position: await this.nextPagePosition( position: await this.nextPagePosition(
@@ -107,9 +133,23 @@ export class PageService {
creatorId: userId, creatorId: userId,
workspaceId: workspaceId, workspaceId: workspaceId,
lastUpdatedById: userId, lastUpdatedById: userId,
content,
textContent,
ydoc,
}); });
return createdPage; this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [userId],
pageId: page.id,
spaceId: createPageDto.spaceId,
workspaceId,
})
.catch((err) =>
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
);
return page;
} }
async nextPagePosition(spaceId: string, parentPageId?: string) { async nextPagePosition(spaceId: string, parentPageId?: string) {
@@ -156,24 +196,49 @@ export class PageService {
async update( async update(
page: Page, page: Page,
updatePageDto: UpdatePageDto, updatePageDto: UpdatePageDto,
userId: string, user: User,
): Promise<Page> { ): Promise<Page> {
const contributors = new Set<string>(page.contributorIds); const contributors = new Set<string>(page.contributorIds);
contributors.add(userId); contributors.add(user.id);
const contributorIds = Array.from(contributors); const contributorIds = Array.from(contributors);
await this.pageRepo.updatePage( await this.pageRepo.updatePage(
{ {
title: updatePageDto.title, title: updatePageDto.title,
icon: updatePageDto.icon, icon: updatePageDto.icon,
lastUpdatedById: userId, lastUpdatedById: user.id,
updatedAt: new Date(), updatedAt: new Date(),
contributorIds: contributorIds, contributorIds: contributorIds,
}, },
page.id, page.id,
); );
return this.pageRepo.findById(page.id, { this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [user.id],
pageId: page.id,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
})
.catch((err) =>
this.logger.warn(`Failed to queue add-page-watchers: ${err.message}`),
);
if (
updatePageDto.content &&
updatePageDto.operation &&
updatePageDto.format
) {
await this.updatePageContent(
page.id,
updatePageDto.content,
updatePageDto.operation,
updatePageDto.format,
user,
);
}
return await this.pageRepo.findById(page.id, {
includeSpace: true, includeSpace: true,
includeContent: true, includeContent: true,
includeCreator: true, includeCreator: true,
@@ -182,6 +247,23 @@ export class PageService {
}); });
} }
async updatePageContent(
pageId: string,
content: string | object,
operation: ContentOperation,
format: ContentFormat,
user: User,
): Promise<void> {
const prosemirrorJson = await this.parseProsemirrorContent(content, format);
const documentName = `page.${pageId}`;
await this.collaborationGateway.handleYjsEvent(
'updatePageContent',
documentName,
{ operation, prosemirrorJson, user },
);
}
async getSidebarPages( async getSidebarPages(
spaceId: string, spaceId: string,
pagination: PaginationOptions, pagination: PaginationOptions,
@@ -368,6 +450,11 @@ export class PageService {
trx, trx,
); );
// Update watchers and remove those without access to new space
await this.watcherService.movePageWatchersToSpace(pageIdsToMove, spaceId, {
trx,
});
await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, { await this.aiQueue.add(QueueJob.PAGE_MOVED_TO_SPACE, {
pageId: pageIdsToMove, pageId: pageIdsToMove,
workspaceId: rootPage.workspaceId, workspaceId: rootPage.workspaceId,
@@ -489,7 +576,7 @@ export class PageService {
// Add "Copy of " prefix to the root page title only for duplicates in same space // Add "Copy of " prefix to the root page title only for duplicates in same space
let title = page.title; let title = page.title;
if (isDuplicateInSameSpace && page.id === rootPage.id) { if (isDuplicateInSameSpace && page.id === rootPage.id) {
const originalTitle = page.title || 'Untitled'; const originalTitle = getPageTitle(page.title);
title = `Copy of ${originalTitle}`; title = `Copy of ${originalTitle}`;
} }
@@ -820,6 +907,38 @@ export class PageService {
await this.pageRepo.removePage(pageId, userId, workspaceId); await this.pageRepo.removePage(pageId, userId, workspaceId);
} }
private async parseProsemirrorContent(
content: string | object,
format: ContentFormat,
): Promise<any> {
let prosemirrorJson: any;
switch (format) {
case 'markdown': {
const html = await markdownToHtml(content as string);
prosemirrorJson = htmlToJson(html as string);
break;
}
case 'html': {
prosemirrorJson = htmlToJson(content as string);
break;
}
case 'json':
default: {
prosemirrorJson = content;
break;
}
}
try {
jsonToNode(prosemirrorJson);
} catch (err) {
throw new BadRequestException('Invalid content format');
}
return prosemirrorJson;
}
/** /**
* Filters a list of pages to only those accessible to the user while maintaining tree integrity. * Filters a list of pages to only those accessible to the user while maintaining tree integrity.
* A page is included only if: * A page is included only if:
@@ -6,6 +6,7 @@ import {
import { PaginationOptions } from '@docmost/db/pagination/pagination-options'; import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types'; import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
import { AddSpaceMembersDto } from '../dto/add-space-members.dto'; import { AddSpaceMembersDto } from '../dto/add-space-members.dto';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { Space, SpaceMember, User } from '@docmost/db/types/entity.types'; import { Space, SpaceMember, User } from '@docmost/db/types/entity.types';
@@ -14,12 +15,16 @@ import { RemoveSpaceMemberDto } from '../dto/remove-space-member.dto';
import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto'; import { UpdateSpaceMemberRoleDto } from '../dto/update-space-member-role.dto';
import { SpaceRole } from '../../../common/helpers/types/permission'; import { SpaceRole } from '../../../common/helpers/types/permission';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { executeTx } from '@docmost/db/utils';
@Injectable() @Injectable()
export class SpaceMemberService { export class SpaceMemberService {
constructor( constructor(
private spaceMemberRepo: SpaceMemberRepo, private spaceMemberRepo: SpaceMemberRepo,
private groupUserRepo: GroupUserRepo,
private spaceRepo: SpaceRepo, private spaceRepo: SpaceRepo,
private watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
) {} ) {}
@@ -203,10 +208,28 @@ export class SpaceMemberService {
await this.validateLastAdmin(dto.spaceId); await this.validateLastAdmin(dto.spaceId);
} }
await this.spaceMemberRepo.removeSpaceMemberById( let affectedUserIds: string[] = [];
spaceMember.id, if (dto.userId) {
dto.spaceId, affectedUserIds = [dto.userId];
); } else if (dto.groupId) {
affectedUserIds = await this.groupUserRepo.getUserIdsByGroupId(
dto.groupId,
);
}
await executeTx(this.db, async (trx) => {
await this.spaceMemberRepo.removeSpaceMemberById(
spaceMember.id,
dto.spaceId,
{ trx },
);
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(
affectedUserIds,
dto.spaceId,
{ trx },
);
});
} }
async updateSpaceMemberRole( async updateSpaceMemberRole(
@@ -4,6 +4,7 @@ import { SpaceController } from './space.controller';
import { SpaceMemberService } from './services/space-member.service'; import { SpaceMemberService } from './services/space-member.service';
@Module({ @Module({
imports: [],
controllers: [SpaceController], controllers: [SpaceController],
providers: [SpaceService, SpaceMemberService], providers: [SpaceService, SpaceMemberService],
exports: [SpaceService, SpaceMemberService], exports: [SpaceService, SpaceMemberService],
@@ -0,0 +1,7 @@
import { IsString, IsNotEmpty } from 'class-validator';
export class WatcherPageDto {
@IsString()
@IsNotEmpty()
pageId: string;
}
@@ -0,0 +1,99 @@
/***
import {
Body,
Controller,
ForbiddenException,
HttpCode,
HttpStatus,
NotFoundException,
Post,
UseGuards,
} from '@nestjs/common';
import { WatcherService } from './watcher.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { WatcherPageDto } from './dto/watcher.dto';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
@UseGuards(JwtAuthGuard)
@Controller('pages')
export class WatcherController {
constructor(
private readonly watcherService: WatcherService,
private readonly pageRepo: PageRepo,
private readonly spaceAbility: SpaceAbilityFactory,
) {}
@HttpCode(HttpStatus.OK)
@Post('watch')
async watchPage(
@Body() dto: WatcherPageDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.watcherService.watchPage(
user.id,
page.id,
page.spaceId,
workspace.id,
);
return { watching: true };
}
@HttpCode(HttpStatus.OK)
@Post('unwatch')
async unwatchPage(@Body() dto: WatcherPageDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
await this.watcherService.unwatchPage(user.id, page.id);
return { watching: false };
}
@HttpCode(HttpStatus.OK)
@Post('watch-status')
async getWatchStatus(@Body() dto: WatcherPageDto, @AuthUser() user: User) {
const page = await this.pageRepo.findById(dto.pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const watching = await this.watcherService.isWatchingPage(user.id, page.id);
return { watching };
}
}
***/
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { WatcherService } from './watcher.service';
import { CaslModule } from '../casl/casl.module';
@Module({
imports: [CaslModule],
controllers: [],
providers: [WatcherService],
exports: [WatcherService],
})
export class WatcherModule {}
@@ -0,0 +1,99 @@
import { Injectable } from '@nestjs/common';
import {
WatcherRepo,
WatcherType,
} from '@docmost/db/repos/watcher/watcher.repo';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
import { InsertableWatcher } from '@docmost/db/types/entity.types';
@Injectable()
export class WatcherService {
constructor(private readonly watcherRepo: WatcherRepo) {}
async watchPage(
userId: string,
pageId: string,
spaceId: string,
workspaceId: string,
trx?: KyselyTransaction,
) {
const watcher: InsertableWatcher = {
userId,
pageId,
spaceId,
workspaceId,
type: WatcherType.PAGE,
addedById: userId,
};
return this.watcherRepo.upsert(watcher, trx);
}
async addPageWatchers(
userIds: string[],
pageId: string,
spaceId: string,
workspaceId: string,
trx?: KyselyTransaction,
) {
if (userIds.length === 0) return;
const watchers: InsertableWatcher[] = userIds.map((userId) => ({
userId,
pageId,
spaceId,
workspaceId,
type: WatcherType.PAGE,
addedById: userId,
}));
return this.watcherRepo.insertMany(watchers, trx);
}
async unwatchPage(userId: string, pageId: string) {
return this.watcherRepo.mute(userId, pageId);
}
async isWatchingPage(userId: string, pageId: string): Promise<boolean> {
return this.watcherRepo.isWatching(userId, pageId);
}
async getPageWatchers(pageId: string, pagination: PaginationOptions) {
return this.watcherRepo.findPageWatchers(pageId, pagination);
}
async getPageWatcherIds(
pageId: string,
trx?: KyselyTransaction,
): Promise<string[]> {
return this.watcherRepo.getPageWatcherIds(pageId, trx);
}
async countPageWatchers(pageId: string): Promise<number> {
return this.watcherRepo.countPageWatchers(pageId);
}
async cleanupOnSpaceAccessChange(
userIds: string[],
spaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
const { trx } = opts;
await this.watcherRepo.deleteByUsersWithoutSpaceAccess(userIds, spaceId, {
trx,
});
}
async movePageWatchersToSpace(
pageIds: string[],
spaceId: string,
opts?: { trx?: KyselyTransaction },
): Promise<void> {
await this.watcherRepo.updateSpaceIdByPageIds(spaceId, pageIds, opts);
await this.watcherRepo.deleteByPageIdsWithoutSpaceAccess(
pageIds,
spaceId,
opts,
);
}
}
@@ -35,6 +35,7 @@ import { generateRandomSuffixNumbers } from '../../../common/helpers';
import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers'; import { isPageEmbeddingsTableExists } from '@docmost/db/helpers/helpers';
import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination'; import { CursorPaginationResult } from '@docmost/db/pagination/cursor-pagination';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
@Injectable() @Injectable()
export class WorkspaceService { export class WorkspaceService {
@@ -51,6 +52,7 @@ export class WorkspaceService {
private domainService: DomainService, private domainService: DomainService,
private licenseCheckService: LicenseCheckService, private licenseCheckService: LicenseCheckService,
private shareRepo: ShareRepo, private shareRepo: ShareRepo,
private watcherRepo: WatcherRepo,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue, @InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue, @InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
@@ -116,6 +118,7 @@ export class WorkspaceService {
let status = undefined; let status = undefined;
let plan = undefined; let plan = undefined;
let billingEmail = undefined; let billingEmail = undefined;
let settings = undefined;
if (this.environmentService.isCloud()) { if (this.environmentService.isCloud()) {
// generate unique hostname // generate unique hostname
@@ -129,6 +132,7 @@ export class WorkspaceService {
status = WorkspaceStatus.Active; status = WorkspaceStatus.Active;
plan = 'standard'; plan = 'standard';
billingEmail = user.email; billingEmail = user.email;
settings = { ai: { generative: true } };
} }
// create workspace // create workspace
@@ -141,6 +145,7 @@ export class WorkspaceService {
trialEndAt, trialEndAt,
plan, plan,
billingEmail, billingEmail,
settings,
}, },
trx, trx,
); );
@@ -553,6 +558,10 @@ export class WorkspaceService {
.deleteFrom('authAccounts') .deleteFrom('authAccounts')
.where('userId', '=', userId) .where('userId', '=', userId)
.execute(); .execute();
await this.watcherRepo.deleteByUserAndWorkspace(userId, workspaceId, {
trx,
});
}); });
try { try {
@@ -25,6 +25,8 @@ import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo'; import { UserTokenRepo } from './repos/user-token/user-token.repo';
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo'; import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
import { ShareRepo } from '@docmost/db/repos/share/share.repo'; import { ShareRepo } from '@docmost/db/repos/share/share.repo';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { PageListener } from '@docmost/db/listeners/page.listener'; import { PageListener } from '@docmost/db/listeners/page.listener';
import { PostgresJSDialect } from 'kysely-postgres-js'; import { PostgresJSDialect } from 'kysely-postgres-js';
import * as postgres from 'postgres'; import * as postgres from 'postgres';
@@ -82,6 +84,8 @@ import { normalizePostgresUrl } from '../common/helpers';
UserTokenRepo, UserTokenRepo,
BacklinkRepo, BacklinkRepo,
ShareRepo, ShareRepo,
NotificationRepo,
WatcherRepo,
PageListener, PageListener,
], ],
exports: [ exports: [
@@ -99,6 +103,8 @@ import { normalizePostgresUrl } from '../common/helpers';
UserTokenRepo, UserTokenRepo,
BacklinkRepo, BacklinkRepo,
ShareRepo, ShareRepo,
NotificationRepo,
WatcherRepo,
], ],
}) })
export class DatabaseModule export class DatabaseModule
@@ -0,0 +1,53 @@
import { type Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('notifications')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('user_id', 'uuid', (col) =>
col.references('users.id').onDelete('cascade').notNull(),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade').notNull(),
)
.addColumn('type', 'text', (col) => col.notNull())
.addColumn('actor_id', 'uuid', (col) =>
col.references('users.id').onDelete('set null'),
)
.addColumn('page_id', 'uuid', (col) =>
col.references('pages.id').onDelete('cascade'),
)
.addColumn('space_id', 'uuid', (col) =>
col.references('spaces.id').onDelete('cascade'),
)
.addColumn('comment_id', 'uuid', (col) =>
col.references('comments.id').onDelete('cascade'),
)
.addColumn('data', 'jsonb')
.addColumn('read_at', 'timestamptz')
.addColumn('emailed_at', 'timestamptz')
.addColumn('archived_at', 'timestamptz')
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
await db.schema
.createIndex('idx_notifications_user_id')
.on('notifications')
.columns(['user_id', 'id desc'])
.execute();
await db.schema
.createIndex('idx_notifications_user_unread')
.on('notifications')
.column('user_id')
.where(sql.ref('read_at'), 'is', null)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('notifications').execute();
}

Some files were not shown because too many files have changed in this diff Show More