Compare commits

...

24 Commits

Author SHA1 Message Date
Philip Okugbe 642024ba9d New Crowdin updates (#2078) 2026-03-31 21:14:41 +01:00
Philipinho 147d028036 v0.71.0 2026-03-31 20:42:37 +01:00
Philipinho 992691e6e0 fix module import 2026-03-31 20:41:09 +01:00
Philip Okugbe 9aaa6c731c feat: add AI_EMBEDDING_SUPPORTS_MRL env var to decouple pgvector dimensions from model API (#2079)
Some embedding models don't accept a `dimensions` parameter. This adds
an optional env var that controls whether the dimension is sent to the
model API, while always using it for pgvector indexing. Preset models
have this handled automatically; the env var allows explicit override
for custom models.
2026-03-31 19:39:49 +01:00
Philipinho fd91b11c6c pin version 2026-03-31 16:06:44 +01:00
Philipinho af8b0ddf3a sync 2026-03-31 16:05:09 +01:00
Philip Okugbe 879aa2c3d8 feat: page update notifications (#2074)
* feat: watchers notification and email preferences

* fix: email copy

* digests

* clean up

* fix

* clean up

* move backlinks queue-up to history processor

* fix

* fix keys

* feat: group notifications

* filter

* adjust email digest window
2026-03-31 16:03:59 +01:00
Philip Okugbe c180d0e487 feat: ratelimits (#2073)
* feat: rate limits

* ip
2026-03-30 15:38:44 +01:00
Philip Okugbe a062f7a165 fix: enhance confluence importer (#2072)
* fix placeholder

* min resize dimensions

* fix media links

* fix
2026-03-30 13:16:40 +01:00
Philip Okugbe cbd0dd4a0b feat: indexes (#2071) 2026-03-29 20:29:12 +01:00
Philip Okugbe 2d6d829581 New translations translation.json (English) (#2066) 2026-03-29 16:25:45 +01:00
Philipinho 5cea30cc5c fix markdown paste 2026-03-29 16:11:21 +01:00
Philipinho bca85a49d6 pin marked version 2026-03-29 03:03:35 +01:00
Philipinho c9cdfa0f17 fix 2026-03-29 02:20:56 +01:00
Philip Okugbe 412962204c fix: editor fixes (#2067)
* autojoiner

* fix marked

* return clipboardTextSerializer as markdown

* fix clipboardTextSerializer for single lines

* cleanup two preceeding spaces in ordered lists item

* fix extra paragraph in task list

* don't zip sinple page exports
2026-03-29 02:19:09 +01:00
Olivier Lambert a42ac3d450 fix: strip trailing whitespace-only paragraphs from pasted content (#2050) 2026-03-28 22:26:47 +00:00
Philipinho 642c92f779 fix select 2026-03-28 20:34:44 +00:00
Philipinho ccb35517bb sync 2026-03-28 20:29:31 +00:00
Philip Okugbe cbdb37ed0a New Crowdin updates (#2061) 2026-03-28 20:29:06 +00:00
Julien Fontanet aa27d57624 fix: notification items are now real links (#2039)
Replace UnstyledButton with UnstyledButton component={Link} so each
notification renders as a real anchor element. Regular left-clicks use
SPA navigation and close the popover; Ctrl/Cmd/middle-click open the
page in a new tab. All click types mark the notification as read.
2026-03-28 20:23:21 +00:00
Philip Okugbe 3829b6cbef feat(ee): viewer comments (#2060) 2026-03-28 19:32:52 +00:00
Philipinho 17da762984 overrides 2026-03-28 19:28:22 +00:00
Philipinho 859f16740b tooltip portal 2026-03-28 19:19:00 +00:00
Philip Okugbe 7981ef462e feat(editor): audio and PDF nodes (#2064)
* use local resizable

* feat: aduio

* support audio imports

* feat: use confluence real file names

* cleanup

* error handling

* hide notice

* add audio

* fix pulse

* Fix import and export

* unify pulse

* hide in readonly mode

* keywords

* keyword

* translations

* better sort

* feat: PDF embed

* cleanup

* remove audio menu

* open active

* hide focus on readonly mode

* increase iframe default dimension
2026-03-28 17:33:29 +00:00
145 changed files with 5653 additions and 498 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.70.3",
"version": "0.71.0",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -25,7 +25,7 @@
"@tabler/icons-react": "^3.40.0",
"@tanstack/react-query": "5.90.17",
"alfaaz": "^1.1.0",
"axios": "^1.13.6",
"axios": "1.13.6",
"blueimp-load-image": "^5.16.0",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Horizontale Trennlinie einfügen",
"Upload any image from your device.": "Laden Sie ein beliebiges Bild von Ihrem Gerät hoch.",
"Upload any video from your device.": "Laden Sie ein beliebiges Video von Ihrem Gerät hoch.",
"Upload any audio from your device.": "Laden Sie beliebige Audiodateien von Ihrem Gerät hoch.",
"Upload any file from your device.": "Laden Sie eine beliebige Datei von Ihrem Gerät hoch.",
"Uploading {{name}}": "Lade {{name}} hoch",
"Uploading file": "Datei wird hochgeladen",
@@ -351,6 +352,12 @@
"Divider": "Trennlinie",
"Quote": "Zitat",
"Image": "Bild",
"Audio": "Audio.",
"Embed PDF": "PDF einbetten",
"Upload and embed a PDF file.": "Laden Sie eine PDF-Datei hoch und betten Sie sie ein.",
"Embed as PDF": "Als PDF einbetten",
"Failed to load PDF": "Fehler beim Laden der PDF",
"Convert to attachment": "In Anhang umwandeln",
"File attachment": "Dateianhang",
"Toggle block": "Block umschalten",
"Callout": "Hinweisbox",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Verhindern Sie, dass Mitglieder Seiten öffentlich teilen.",
"Toggle public sharing": "Öffentliches Teilen umschalten",
"Toggle space public sharing": "Öffentliches Teilen im Bereich umschalten",
"Allow viewers to comment": "Zuschauern erlauben, Kommentare zu hinterlassen",
"Allow viewers to add comments on pages in this space.": "Erlauben Sie Zuschauern, Kommentare auf Seiten in diesem Bereich hinzuzufügen.",
"Toggle viewer comments": "Zuschauerkommentare umschalten",
"Public sharing is disabled at the workspace level": "Öffentliches Teilen ist auf der Arbeitsbereichsebene deaktiviert",
"Prevent pages in this space from being shared publicly.": "Verhindern Sie, dass Seiten in diesem Bereich öffentlich geteilt werden.",
"Page permissions": "Seitenberechtigungen",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> hat Sie auf einer Seite erwähnt",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> hat Ihnen Bearbeitungszugriff auf eine Seite gegeben",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> hat Ihnen Ansichtsrechte für eine Seite gegeben",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> hat eine Seite aktualisiert.",
"Watch page": "Seite beobachten",
"Stop watching": "Beobachtung beenden",
"Email notifications": "E-Mail-Benachrichtigungen",
"Page updates": "Seitenaktualisierungen",
"Get notified when pages you watch are updated.": "Erhalten Sie eine Benachrichtigung, wenn Seiten, die Sie beobachten, aktualisiert werden.",
"Page mentions": "Seiten-Erwähnungen",
"Get notified when someone mentions you on a page.": "Erhalten Sie eine Benachrichtigung, wenn Sie jemand auf einer Seite erwähnt.",
"Comment mentions": "Kommentar-Erwähnungen",
"Get notified when someone mentions you in a comment.": "Erhalten Sie eine Benachrichtigung, wenn Sie jemand in einem Kommentar erwähnt.",
"New comments": "Neue Kommentare",
"Get notified about new comments on threads you participate in.": "Erhalten Sie eine Benachrichtigung über neue Kommentare in Threads, an denen Sie teilnehmen.",
"Resolved comments": "Erledigte Kommentare",
"Get notified when your comment is resolved.": "Erhalten Sie eine Benachrichtigung, wenn Ihr Kommentar erledigt wurde.",
"You are now watching this page": "Sie beobachten diese Seite jetzt",
"You are no longer watching this page": "Sie beobachten diese Seite nicht mehr",
"Direct": "Direkt",
"Updates": "Aktualisierungen",
"Today": "Heute",
"Yesterday": "Gestern",
"This week": "Diese Woche",
@@ -184,20 +184,20 @@
"Updated successfully": "Updated successfully.",
"User": "User.",
"Workspace": "Workspace.",
"Workspace Name": "Workspace Name.",
"Workspace Name": "Workspace name.",
"Workspace settings": "Workspace settings.",
"You can change your password here.": "You can change your password here.",
"Your Email": "Your Email.",
"Your Email": "Your email.",
"Your import is complete.": "Your import is complete.",
"Your name": "Your name.",
"Your Name": "Your Name.",
"Your Name": "Your name.",
"Your password": "Your password.",
"Your password must be a minimum of 8 characters.": "Your password must be a minimum of 8 characters.",
"Sidebar toggle": "Sidebar toggle.",
"Comments": "Comments.",
"404 page not found": "404 page not found.",
"Sorry, we can't find the page you are looking for.": "Sorry, we can't find the page you are looking for.",
"Take me back to homepage": "Take me back to homepage.",
"Take me back to homepage": "Take me back to the homepage.",
"Forgot password": "Forgot password.",
"Forgot your password?": "Forgot your password?",
"A password reset link has been sent to your email. Please check your inbox.": "A password reset link has been sent to your email. Please check your inbox.",
@@ -223,12 +223,12 @@
"Failed to delete comment": "Failed to delete comment",
"Comment resolved successfully": "Comment resolved successfully",
"Comment re-opened successfully": "Comment re-opened successfully.",
"Comment unresolved successfully": "Comment unresolved successfully.",
"Comment unresolved successfully": "Comment marked as unresolved successfully.",
"Failed to resolve comment": "Failed to resolve comment",
"Resolve comment": "Resolve comment.",
"Unresolve comment": "Unresolve comment.",
"Resolve Comment Thread": "Resolve Comment Thread.",
"Unresolve Comment Thread": "Unresolve Comment Thread.",
"Unresolve comment": "Mark comment as unresolved.",
"Resolve Comment Thread": "Resolve comment thread.",
"Unresolve Comment Thread": "Mark comment thread as unresolved.",
"Are you sure you want to resolve this comment thread? This will mark it as completed.": "Are you sure you want to resolve this comment thread? This will mark it as completed.",
"Are you sure you want to unresolve this comment thread?": "Are you sure you want to unresolve this comment thread?",
"Resolved": "Resolved.",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insert horizontal rule divider",
"Upload any image from your device.": "Upload any image from your device.",
"Upload any video from your device.": "Upload any video from your device.",
"Upload any audio from your device.": "Upload any audio from your device.",
"Upload any file from your device.": "Upload any file from your device.",
"Uploading {{name}}": "Uploading {{name}}",
"Uploading file": "Uploading file",
@@ -351,6 +352,12 @@
"Divider": "Divider.",
"Quote": "Quote.",
"Image": "Image.",
"Audio": "Audio.",
"Embed PDF": "Embed PDF",
"Upload and embed a PDF file.": "Upload and embed a PDF file.",
"Embed as PDF": "Embed as PDF",
"Failed to load PDF": "Failed to load PDF",
"Convert to attachment": "Convert to attachment",
"File attachment": "File attachment.",
"Toggle block": "Toggle block.",
"Callout": "Callout.",
@@ -363,7 +370,7 @@
"Insert mermaid diagram": "Insert mermaid diagram.",
"Insert and design Drawio diagrams": "Insert and design Drawio diagrams.",
"Insert current date": "Insert current date.",
"Draw and sketch excalidraw diagrams": "Draw and sketch excalidraw diagrams.",
"Draw and sketch excalidraw diagrams": "Draw and sketch Excalidraw diagrams.",
"Multiple": "Multiple.",
"Turn into": "Turn into",
"Text align": "Text align",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
"Toggle public sharing": "Toggle public sharing",
"Toggle space public sharing": "Toggle space public sharing",
"Allow viewers to comment": "Allow viewers to comment",
"Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.",
"Toggle viewer comments": "Toggle viewer comments",
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
"Page permissions": "Page permissions",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mentioned you on a page.",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> gave you edit access to a page.",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> gave you view access to a page.",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> has updated a page.",
"Watch page": "Watch this page",
"Stop watching": "Stop watching this page",
"Email notifications": "Email notifications",
"Page updates": "Page updates",
"Get notified when pages you watch are updated.": "Receive notifications when the pages you watch are updated.",
"Page mentions": "Page mentions",
"Get notified when someone mentions you on a page.": "Receive notifications when someone mentions you on a page.",
"Comment mentions": "Comment mentions",
"Get notified when someone mentions you in a comment.": "Receive notifications when someone mentions you in a comment.",
"New comments": "New comments",
"Get notified about new comments on threads you participate in.": "Receive notifications about new comments in threads you are participating in.",
"Resolved comments": "Resolved comments",
"Get notified when your comment is resolved.": "Receive a notification when your comment is resolved.",
"You are now watching this page": "Youre now watching this page",
"You are no longer watching this page": "Youre no longer watching this page",
"Direct": "Direct",
"Updates": "Updates",
"Today": "Today",
"Yesterday": "Yesterday",
"This week": "This week",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insertar regla horizontal",
"Upload any image from your device.": "Sube cualquier imagen desde tu dispositivo.",
"Upload any video from your device.": "Sube cualquier video desde tu dispositivo.",
"Upload any audio from your device.": "Sube cualquier audio desde tu dispositivo.",
"Upload any file from your device.": "Sube cualquier archivo desde tu dispositivo.",
"Uploading {{name}}": "Subiendo {{name}}",
"Uploading file": "Subiendo archivo",
@@ -351,6 +352,12 @@
"Divider": "Divisor",
"Quote": "Cita",
"Image": "Imagen",
"Audio": "Audio.",
"Embed PDF": "Adjuntar PDF",
"Upload and embed a PDF file.": "Sube y adjunta un archivo PDF.",
"Embed as PDF": "Adjuntar como PDF",
"Failed to load PDF": "Error al cargar el PDF",
"Convert to attachment": "Convertir en adjunto",
"File attachment": "Adjunto de archivo",
"Toggle block": "Alternar bloque",
"Callout": "Aviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Evitar que los miembros compartan páginas públicamente.",
"Toggle public sharing": "Alternar el uso compartido público",
"Toggle space public sharing": "Alternar el uso compartido público del espacio",
"Allow viewers to comment": "Permitir que los espectadores comenten",
"Allow viewers to add comments on pages in this space.": "Permitir que los espectadores agreguen comentarios en las páginas de este espacio.",
"Toggle viewer comments": "Activar/desactivar comentarios de los espectadores",
"Public sharing is disabled at the workspace level": "El uso compartido público está desactivado a nivel de espacio de trabajo",
"Prevent pages in this space from being shared publicly.": "Evitar que las páginas en este espacio se compartan públicamente.",
"Page permissions": "Permisos de la página},{",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> te mencionó en una página",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> te dio acceso de edición a una página",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> te dio acceso de visualización a una página",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> actualizó una página.",
"Watch page": "Seguir página",
"Stop watching": "Dejar de seguir",
"Email notifications": "Notificaciones por correo electrónico",
"Page updates": "Actualizaciones de página",
"Get notified when pages you watch are updated.": "Recibe una notificación cuando se actualicen las páginas que sigues.",
"Page mentions": "Menciones en la página",
"Get notified when someone mentions you on a page.": "Recibe una notificación cuando alguien te mencione en una página.",
"Comment mentions": "Menciones en comentarios",
"Get notified when someone mentions you in a comment.": "Recibe una notificación cuando alguien te mencione en un comentario.",
"New comments": "Nuevos comentarios",
"Get notified about new comments on threads you participate in.": "Recibe una notificación sobre nuevos comentarios en los hilos donde participas.",
"Resolved comments": "Comentarios resueltos",
"Get notified when your comment is resolved.": "Recibe una notificación cuando tu comentario sea resuelto.",
"You are now watching this page": "Ahora sigues esta página",
"You are no longer watching this page": "Ya no sigues esta página",
"Direct": "Directo",
"Updates": "Actualizaciones",
"Today": "Hoy",
"Yesterday": "Ayer",
"This week": "Esta semana",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insérer un séparateur de règle horizontale",
"Upload any image from your device.": "Téléchargez n'importe quelle image depuis votre appareil.",
"Upload any video from your device.": "Téléchargez n'importe quelle vidéo depuis votre appareil.",
"Upload any audio from your device.": "Téléchargez n'importe quel fichier audio depuis votre appareil.",
"Upload any file from your device.": "Téléchargez n'importe quel fichier depuis votre appareil.",
"Uploading {{name}}": "Téléchargement de {{name}}",
"Uploading file": "Téléchargement du fichier",
@@ -351,6 +352,12 @@
"Divider": "Diviseur",
"Quote": "Citation",
"Image": "Image",
"Audio": "Audio.",
"Embed PDF": "Intégrer un PDF",
"Upload and embed a PDF file.": "Téléchargez et intégrez un fichier PDF.",
"Embed as PDF": "Intégrer comme PDF",
"Failed to load PDF": "Échec du chargement du PDF",
"Convert to attachment": "Convertir en pièce jointe",
"File attachment": "Pièce jointe",
"Toggle block": "Basculer le bloc",
"Callout": "Appel",
@@ -415,7 +422,7 @@
"Move page": "Déplacer la page",
"Move page to a different space.": "Déplacer la page vers un autre espace.",
"Real-time editor connection lost. Retrying...": "Connexion avec l'éditeur en temps réel perdue. Nouvelle tentative...",
"Table of contents": "",
"Table of contents": "Table des matières.",
"Add headings (H1, H2, H3) to generate a table of contents.": "Ajoutez des titres (H1, H2, H3) pour générer une table des matières.",
"Share": "Partager",
"Public sharing": "Partage public",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Empêcher les membres de partager des pages publiquement.",
"Toggle public sharing": "Basculer le partage public",
"Toggle space public sharing": "Basculer le partage public de l'espace",
"Allow viewers to comment": "Autoriser les spectateurs à commenter",
"Allow viewers to add comments on pages in this space.": "Autoriser les spectateurs à ajouter des commentaires sur les pages de cet espace.",
"Toggle viewer comments": "Basculer les commentaires des spectateurs",
"Public sharing is disabled at the workspace level": "Le partage public est désactivé au niveau de l'espace de travail",
"Prevent pages in this space from being shared publicly.": "Empêcher les pages de cet espace d'être partagées publiquement.",
"Page permissions": "Autorisations de la page",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> vous a mentionné sur une page",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> vous a donné l'accès en modification à une page",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> vous a donné l'accès en lecture à une page",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> a mis à jour une page.",
"Watch page": "Surveiller la page",
"Stop watching": "Ne plus surveiller",
"Email notifications": "Notifications par e-mail",
"Page updates": "Mises à jour de la page",
"Get notified when pages you watch are updated.": "Recevez une notification lorsque les pages que vous surveillez sont mises à jour.",
"Page mentions": "Mentions sur la page",
"Get notified when someone mentions you on a page.": "Recevez une notification lorsqu'une personne vous mentionne sur une page.",
"Comment mentions": "Mentions dans les commentaires",
"Get notified when someone mentions you in a comment.": "Recevez une notification lorsqu'une personne vous mentionne dans un commentaire.",
"New comments": "Nouveaux commentaires",
"Get notified about new comments on threads you participate in.": "Recevez une notification concernant les nouveaux commentaires dans les fils auxquels vous participez.",
"Resolved comments": "Commentaires résolus",
"Get notified when your comment is resolved.": "Recevez une notification lorsque votre commentaire est résolu.",
"You are now watching this page": "Vous surveillez désormais cette page",
"You are no longer watching this page": "Vous ne surveillez plus cette page",
"Direct": "Direct",
"Updates": "Mises à jour",
"Today": "Aujourd'hui",
"Yesterday": "Hier",
"This week": "Cette semaine",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Inserisci divisore di regola orizzontale",
"Upload any image from your device.": "Carica un'immagine dal tuo dispositivo.",
"Upload any video from your device.": "Carica qualsiasi video dal tuo dispositivo.",
"Upload any audio from your device.": "Carica qualsiasi audio dal tuo dispositivo.",
"Upload any file from your device.": "Carica qualsiasi file dal tuo dispositivo.",
"Uploading {{name}}": "Caricamento di {{name}}",
"Uploading file": "Caricamento file",
@@ -351,6 +352,12 @@
"Divider": "Divisore",
"Quote": "Preventivo",
"Image": "Immagine",
"Audio": "Audio.",
"Embed PDF": "Incorpora PDF",
"Upload and embed a PDF file.": "Carica e incorpora un file PDF.",
"Embed as PDF": "Incorpora come PDF",
"Failed to load PDF": "Caricamento del PDF non riuscito",
"Convert to attachment": "Converti in allegato",
"File attachment": "Allegato file",
"Toggle block": "Attiva blocco",
"Callout": "Avviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Impedisci ai membri di condividere pubblicamente le pagine.",
"Toggle public sharing": "Attiva/disattiva la condivisione pubblica",
"Toggle space public sharing": "Attiva/disattiva la condivisione pubblica nello spazio",
"Allow viewers to comment": "Consenti agli utenti di commentare",
"Allow viewers to add comments on pages in this space.": "Consenti agli utenti di aggiungere commenti alle pagine in questo spazio.",
"Toggle viewer comments": "Attiva/disattiva i commenti degli utenti",
"Public sharing is disabled at the workspace level": "La condivisione pubblica è disabilitata a livello di area di lavoro",
"Prevent pages in this space from being shared publicly.": "Impedisci che le pagine in questo spazio vengano condivise pubblicamente.",
"Page permissions": "Autorizzazioni della pagina.",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> ti ha menzionato su una pagina",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> ti ha dato l'accesso di modifica a una pagina",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> ti ha dato l'accesso di visualizzazione a una pagina",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> ha aggiornato una pagina.",
"Watch page": "Segui pagina",
"Stop watching": "Smetti di seguire",
"Email notifications": "Notifiche email",
"Page updates": "Aggiornamenti pagina",
"Get notified when pages you watch are updated.": "Ricevi una notifica quando le pagine che segui vengono aggiornate.",
"Page mentions": "Menzioni nella pagina",
"Get notified when someone mentions you on a page.": "Ricevi una notifica quando qualcuno ti menziona su una pagina.",
"Comment mentions": "Menzioni nei commenti",
"Get notified when someone mentions you in a comment.": "Ricevi una notifica quando qualcuno ti menziona in un commento.",
"New comments": "Nuovi commenti",
"Get notified about new comments on threads you participate in.": "Ricevi una notifica sui nuovi commenti nelle discussioni a cui partecipi.",
"Resolved comments": "Commenti risolti",
"Get notified when your comment is resolved.": "Ricevi una notifica quando il tuo commento viene risolto.",
"You are now watching this page": "Ora stai seguendo questa pagina",
"You are no longer watching this page": "Non stai più seguendo questa pagina",
"Direct": "Diretto",
"Updates": "Aggiornamenti",
"Today": "Oggi",
"Yesterday": "Ieri",
"This week": "Questa settimana",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "区切り線を挿入します",
"Upload any image from your device.": "デバイスから画像をアップロードします",
"Upload any video from your device.": "デバイスから動画をアップロードします",
"Upload any audio from your device.": "デバイスから音声ファイルをアップロードします。",
"Upload any file from your device.": "デバイスからファイルをアップロードします",
"Uploading {{name}}": "{{name}} をアップロード中",
"Uploading file": "ファイルをアップロード中",
@@ -351,6 +352,12 @@
"Divider": "区切り線",
"Quote": "引用",
"Image": "画像",
"Audio": "音声。",
"Embed PDF": "PDFを埋め込む",
"Upload and embed a PDF file.": "PDFファイルをアップロードして埋め込みます。",
"Embed as PDF": "PDFとして埋め込む",
"Failed to load PDF": "PDFの読み込みに失敗しました",
"Convert to attachment": "添付ファイルに変換",
"File attachment": "ファイル添付",
"Toggle block": "ブロックを切り替える",
"Callout": "コールアウト",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "メンバーがページを公開で共有するのを防ぐ。",
"Toggle public sharing": "公開共有を切り替える",
"Toggle space public sharing": "スペースの公開共有を切り替える",
"Allow viewers to comment": "閲覧者によるコメントを許可",
"Allow viewers to add comments on pages in this space.": "このスペース内のページに閲覧者がコメントを追加できるようにします。",
"Toggle viewer comments": "閲覧者コメントの切り替え",
"Public sharing is disabled at the workspace level": "ワークスペースレベルで公開共有が無効になっています",
"Prevent pages in this space from being shared publicly.": "このスペース内のページが公開で共有されるのを防ぐ。",
"Page permissions": "ページのアクセス権",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold>さんがページであなたに言及しました",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold>さんがページの編集権限をあなたに付与しました",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold>さんがページの閲覧権限をあなたに付与しました",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold>さんがページを更新しました。",
"Watch page": "ページをウォッチ",
"Stop watching": "ウォッチを解除",
"Email notifications": "メール通知",
"Page updates": "ページの更新",
"Get notified when pages you watch are updated.": "ウォッチしているページが更新されたときに通知を受け取ります。",
"Page mentions": "ページでの言及",
"Get notified when someone mentions you on a page.": "誰かがページであなたに言及したとき通知を受け取ります。",
"Comment mentions": "コメントでの言及",
"Get notified when someone mentions you in a comment.": "誰かがコメントであなたに言及したとき通知を受け取ります。",
"New comments": "新しいコメント",
"Get notified about new comments on threads you participate in.": "参加しているスレッドに新しいコメントがあると通知されます。",
"Resolved comments": "解決済みコメント",
"Get notified when your comment is resolved.": "あなたのコメントが解決されたとき通知を受け取ります。",
"You are now watching this page": "このページをウォッチしています",
"You are no longer watching this page": "このページのウォッチを解除しました",
"Direct": "直接",
"Updates": "アップデート",
"Today": "今日",
"Yesterday": "昨日",
"This week": "今週",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "가로 구분선 삽입",
"Upload any image from your device.": "기기에서 이미지를 업로드하세요.",
"Upload any video from your device.": "기기에서 비디오를 업로드하세요.",
"Upload any audio from your device.": "기기에서 오디오를 업로드하세요.",
"Upload any file from your device.": "기기에서 파일을 업로드하세요.",
"Uploading {{name}}": "{{name}} 업로드 중",
"Uploading file": "파일 업로드 중",
@@ -351,6 +352,12 @@
"Divider": "구분선",
"Quote": "인용",
"Image": "이미지",
"Audio": "오디오.",
"Embed PDF": "PDF 임베드",
"Upload and embed a PDF file.": "PDF 파일을 업로드하고 임베드하세요.",
"Embed as PDF": "PDF로 임베드",
"Failed to load PDF": "PDF 로드 실패",
"Convert to attachment": "첨부 파일로 변환",
"File attachment": "파일 첨부",
"Toggle block": "블록 토글",
"Callout": "경고 상자",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "멤버들이 페이지를 공개적으로 공유하지 못하도록 방지하십시오.",
"Toggle public sharing": "공유 전환",
"Toggle space public sharing": "공간 공유 전환",
"Allow viewers to comment": "뷰어가 댓글을 달 수 있도록 허용",
"Allow viewers to add comments on pages in this space.": "이 공간 내 페이지에 뷰어가 댓글을 추가할 수 있도록 허용합니다.",
"Toggle viewer comments": "뷰어 댓글 전환",
"Public sharing is disabled at the workspace level": "워크스페이스 수준에서 공유가 비활성화되었습니다.",
"Prevent pages in this space from being shared publicly.": "이 공간의 페이지가 공개적으로 공유되지 않도록 방지하십시오.",
"Page permissions": "페이지 권한},{",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold>님이 페이지에서 당신을 언급했습니다",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold>님이 페이지 편집 권한을 부여했습니다",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold>님이 페이지 조회 권한을 부여했습니다",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold>님이 페이지를 업데이트했습니다.",
"Watch page": "페이지 구독",
"Stop watching": "구독 취소",
"Email notifications": "이메일 알림",
"Page updates": "페이지 업데이트",
"Get notified when pages you watch are updated.": "구독한 페이지가 업데이트될 때 알림을 받으세요.",
"Page mentions": "페이지 언급",
"Get notified when someone mentions you on a page.": "누군가가 페이지에서 당신을 언급하면 알림을 받으세요.",
"Comment mentions": "댓글 언급",
"Get notified when someone mentions you in a comment.": "누군가가 댓글에서 당신을 언급하면 알림을 받으세요.",
"New comments": "새 댓글",
"Get notified about new comments on threads you participate in.": "참여 중인 스레드에 새 댓글이 달리면 알림을 받으세요.",
"Resolved comments": "해결된 댓글",
"Get notified when your comment is resolved.": "내 댓글이 해결되었을 때 알림을 받으세요.",
"You are now watching this page": "이제 이 페이지를 주시합니다.",
"You are no longer watching this page": "더 이상 이 페이지를 주시하지 않습니다.",
"Direct": "직접",
"Updates": "업데이트",
"Today": "오늘",
"Yesterday": "어제",
"This week": "이번 주",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Horizontale lijn invoegen",
"Upload any image from your device.": "Upload een afbeelding vanaf uw apparaat.",
"Upload any video from your device.": "Upload een video vanaf uw apparaat.",
"Upload any audio from your device.": "Upload een audio vanaf uw apparaat.",
"Upload any file from your device.": "Upload een bestand vanaf uw apparaat.",
"Uploading {{name}}": "Uploaden {{name}}",
"Uploading file": "Bestand uploaden",
@@ -351,6 +352,12 @@
"Divider": "Scheidingslijn",
"Quote": "Quote",
"Image": "Afbeelding",
"Audio": "Audio.",
"Embed PDF": "PDF insluiten",
"Upload and embed a PDF file.": "Upload en sluit een PDF-bestand in.",
"Embed as PDF": "Insluiten als PDF",
"Failed to load PDF": "Laden van PDF mislukt",
"Convert to attachment": "Converteren naar bijlage",
"File attachment": "Bestand bijlage",
"Toggle block": "Schakel blok in/uit",
"Callout": "Opmerking",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Voorkom dat leden pagina's openbaar delen.",
"Toggle public sharing": "Wissel openbaar delen",
"Toggle space public sharing": "Wissel openbaar delen van ruimte",
"Allow viewers to comment": "Toestaan dat kijkers reageren",
"Allow viewers to add comments on pages in this space.": "Sta kijkers toe om reacties toe te voegen op pagina\u0019s in deze ruimte.",
"Toggle viewer comments": "Reacties van kijkers in- of uitschakelen",
"Public sharing is disabled at the workspace level": "Openbaar delen is uitgeschakeld op werkruimteniveau",
"Prevent pages in this space from being shared publicly.": "Voorkom dat pagina's in deze ruimte openbaar worden gedeeld.",
"Page permissions": "Pagina rechten",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> noemde je op een pagina",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> heeft je toegang gegeven om een pagina te bewerken",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> heeft je toegang gegeven om een pagina te bekijken",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> heeft een pagina bijgewerkt.",
"Watch page": "Pagina volgen",
"Stop watching": "Volgen stoppen",
"Email notifications": "E-mailmeldingen",
"Page updates": "Pagina-updates",
"Get notified when pages you watch are updated.": "Ontvang een melding wanneer pagina's die je volgt worden bijgewerkt.",
"Page mentions": "Pagina-vermeldingen",
"Get notified when someone mentions you on a page.": "Ontvang een melding wanneer iemand je noemt op een pagina.",
"Comment mentions": "Vermeldingen in opmerkingen",
"Get notified when someone mentions you in a comment.": "Ontvang een melding wanneer iemand je noemt in een opmerking.",
"New comments": "Nieuwe opmerkingen",
"Get notified about new comments on threads you participate in.": "Ontvang meldingen over nieuwe reacties in threads waaraan je deelneemt.",
"Resolved comments": "Opgeloste opmerkingen",
"Get notified when your comment is resolved.": "Ontvang een melding wanneer je reactie is opgelost.",
"You are now watching this page": "Je volgt nu deze pagina",
"You are no longer watching this page": "Je volgt deze pagina niet meer",
"Direct": "Direct",
"Updates": "Updates",
"Today": "Vandaag",
"Yesterday": "Gisteren",
"This week": "Deze week",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Insira um divisor horizontal",
"Upload any image from your device.": "Envie qualquer imagem do seu dispositivo.",
"Upload any video from your device.": "Envie qualquer vídeo do seu dispositivo.",
"Upload any audio from your device.": "Envie qualquer áudio do seu dispositivo.",
"Upload any file from your device.": "Envie qualquer arquivo do seu dispositivo.",
"Uploading {{name}}": "Enviando {{name}}",
"Uploading file": "Enviando arquivo",
@@ -351,6 +352,12 @@
"Divider": "Divisor",
"Quote": "Citação",
"Image": "Imagem",
"Audio": "Áudio.",
"Embed PDF": "Incorporar PDF",
"Upload and embed a PDF file.": "Envie e incorpore um arquivo PDF.",
"Embed as PDF": "Incorporar como PDF",
"Failed to load PDF": "Falha ao carregar PDF",
"Convert to attachment": "Converter em anexo",
"File attachment": "Anexo de arquivo",
"Toggle block": "Bloco colapsável",
"Callout": "Aviso",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Impedir que os membros compartilhem páginas publicamente.",
"Toggle public sharing": "Alternar compartilhamento público",
"Toggle space public sharing": "Alternar compartilhamento público do espaço",
"Allow viewers to comment": "Permitir que os visualizadores comentem",
"Allow viewers to add comments on pages in this space.": "Permitir que os visualizadores adicionem comentários em páginas deste espaço.",
"Toggle viewer comments": "Ativar/desativar comentários de visualizadores",
"Public sharing is disabled at the workspace level": "O compartilhamento público está desativado no nível do espaço de trabalho",
"Prevent pages in this space from being shared publicly.": "Impedir que as páginas neste espaço sejam compartilhadas publicamente.",
"Page permissions": "Permissões da página},{",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> mencionou você em uma página",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> concedeu acesso de edição a uma página",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> concedeu acesso de visualização a uma página",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> atualizou uma página.",
"Watch page": "Observar página",
"Stop watching": "Parar de observar",
"Email notifications": "Notificações por e-mail",
"Page updates": "Atualizações da página",
"Get notified when pages you watch are updated.": "Receba notificações quando as páginas que você observa forem atualizadas.",
"Page mentions": "Menções na página",
"Get notified when someone mentions you on a page.": "Receba notificações quando alguém mencionar você em uma página.",
"Comment mentions": "Menções em comentários",
"Get notified when someone mentions you in a comment.": "Receba notificações quando alguém mencionar você em um comentário.",
"New comments": "Novos comentários",
"Get notified about new comments on threads you participate in.": "Receba notificações sobre novos comentários nas discussões em que você participa.",
"Resolved comments": "Comentários resolvidos",
"Get notified when your comment is resolved.": "Receba notificações quando seu comentário for resolvido.",
"You are now watching this page": "Agora você está observando esta página",
"You are no longer watching this page": "Você não está mais observando esta página",
"Direct": "Direto",
"Updates": "Atualizações",
"Today": "Hoje",
"Yesterday": "Ontem",
"This week": "Esta semana",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Вставить горизонтальный разделитель",
"Upload any image from your device.": "Загрузить любое изображение с вашего устройства.",
"Upload any video from your device.": "Загрузить любое видео с вашего устройства.",
"Upload any audio from your device.": "Загрузите любой аудиофайл с вашего устройства.",
"Upload any file from your device.": "Загрузить любой файл с вашего устройства.",
"Uploading {{name}}": "Загрузка {{name}}",
"Uploading file": "Загрузка файла",
@@ -351,6 +352,12 @@
"Divider": "Разделитель",
"Quote": "Цитата",
"Image": "Изображение",
"Audio": "Аудио.",
"Embed PDF": "Встроить PDF",
"Upload and embed a PDF file.": "Загрузите и встроите PDF-файл.",
"Embed as PDF": "Встроить как PDF",
"Failed to load PDF": "Не удалось загрузить PDF",
"Convert to attachment": "Преобразовать в вложение",
"File attachment": "Прикрепленный файл",
"Toggle block": "Сворачиваемый блок",
"Callout": "Выноска",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Запретить участникам делиться страницами публично.",
"Toggle public sharing": "Переключить общий доступ",
"Toggle space public sharing": "Переключить общий доступ для пространства",
"Allow viewers to comment": "Разрешить зрителям комментировать",
"Allow viewers to add comments on pages in this space.": "Разрешить зрителям добавлять комментарии на страницах в этом пространстве.",
"Toggle viewer comments": "Переключить комментарии зрителей",
"Public sharing is disabled at the workspace level": "Общий доступ отключен на уровне рабочего пространства",
"Prevent pages in this space from being shared publicly.": "Запретить делиться страницами в этом пространстве публично.",
"Page permissions": "Права доступа к странице},{",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> упомянул вас на странице",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> предоставил вам доступ для редактирования страницы",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> предоставил вам доступ к просмотру страницы",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> обновил страницу.",
"Watch page": "Следить за страницей",
"Stop watching": "Прекратить отслеживание",
"Email notifications": "Уведомления на email",
"Page updates": "Обновления страницы",
"Get notified when pages you watch are updated.": "Получайте уведомления, когда отслеживаемые вами страницы обновляются.",
"Page mentions": "Упоминания на странице",
"Get notified when someone mentions you on a page.": "Получайте уведомления, когда кто-то упоминает вас на странице.",
"Comment mentions": "Упоминания в комментариях",
"Get notified when someone mentions you in a comment.": "Получайте уведомления, когда кто-то упоминает вас в комментарии.",
"New comments": "Новые комментарии",
"Get notified about new comments on threads you participate in.": "Получайте уведомления о новых комментариях в цепочках, в которых вы участвуете.",
"Resolved comments": "Разрешённые комментарии",
"Get notified when your comment is resolved.": "Получайте уведомление, когда ваш комментарий разрешён.",
"You are now watching this page": "Вы теперь следите за этой страницей",
"You are no longer watching this page": "Вы больше не следите за этой страницей",
"Direct": "Прямые",
"Updates": "Обновления",
"Today": "Сегодня",
"Yesterday": "Вчера",
"This week": "На этой неделе",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "Вставити горизонтальний роздільник",
"Upload any image from your device.": "Завантажити будь-яке зображення з вашого пристрою.",
"Upload any video from your device.": "Завантажити будь-яке відео з вашого пристрою.",
"Upload any audio from your device.": "Завантажте будь-який аудіофайл зі свого пристрою.",
"Upload any file from your device.": "Завантажити будь-який файл з вашого пристрою.",
"Uploading {{name}}": "Завантаження {{name}}",
"Uploading file": "Завантаження файлу",
@@ -351,6 +352,12 @@
"Divider": "Роздільник",
"Quote": "Цитата",
"Image": "Зображення",
"Audio": "Аудіо.",
"Embed PDF": "Вбудувати PDF",
"Upload and embed a PDF file.": "Завантажте та вбудуйте файл PDF.",
"Embed as PDF": "Вбудувати як PDF",
"Failed to load PDF": "Не вдалося завантажити PDF",
"Convert to attachment": "Перетворити на вкладення",
"File attachment": "Прикріплений файл",
"Toggle block": "Блок, що згортається",
"Callout": "Виноска",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "Перешкодити учасникам публічно ділитися сторінками.",
"Toggle public sharing": "Перемикання публічного доступу",
"Toggle space public sharing": "Перемикання публічного доступу до просторів",
"Allow viewers to comment": "Дозволити глядачам коментувати",
"Allow viewers to add comments on pages in this space.": "Дозволити глядачам додавати коментарі на сторінках у цьому просторі.",
"Toggle viewer comments": "Увімкнути або вимкнути коментарі глядачів",
"Public sharing is disabled at the workspace level": "Публічний доступ вимкнуто на рівні робочого простору",
"Prevent pages in this space from being shared publicly.": "Перешкодити публічному поширенню сторінок у цьому просторі.",
"Page permissions": "Права доступу до сторінки.",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold> згадав вас на сторінці",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold> надав вам доступ до редагування сторінки",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold> надав вам доступ до перегляду сторінки",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold> оновив сторінку.",
"Watch page": "Стежити за сторінкою",
"Stop watching": "Припинити стежити",
"Email notifications": "Сповіщення електронною поштою",
"Page updates": "Оновлення сторінки",
"Get notified when pages you watch are updated.": "Отримуйте сповіщення, коли сторінки, за якими ви стежите, оновлюються.",
"Page mentions": "Згадки на сторінці",
"Get notified when someone mentions you on a page.": "Отримуйте сповіщення, коли хтось згадує вас на сторінці.",
"Comment mentions": "Згадки у коментарях",
"Get notified when someone mentions you in a comment.": "Отримуйте сповіщення, коли хтось згадує вас у коментарі.",
"New comments": "Нові коментарі",
"Get notified about new comments on threads you participate in.": "Отримуйте сповіщення про нові коментарі у темах, у яких ви берете участь.",
"Resolved comments": "Вирішені коментарі",
"Get notified when your comment is resolved.": "Отримайте сповіщення, коли ваш коментар вирішено.",
"You are now watching this page": "Ви зараз стежите за цією сторінкою",
"You are no longer watching this page": "Ви більше не стежите за цією сторінкою",
"Direct": "Прямі",
"Updates": "Оновлення",
"Today": "Сьогодні",
"Yesterday": "Вчора",
"This week": "Цього тижня",
@@ -341,6 +341,7 @@
"Insert horizontal rule divider": "插入水平分割线",
"Upload any image from your device.": "从设备上传任何图像",
"Upload any video from your device.": "从设备上传任何视频",
"Upload any audio from your device.": "从您的设备上传任意音频文件。",
"Upload any file from your device.": "从设备上传任何文件",
"Uploading {{name}}": "正在上传{{name}}",
"Uploading file": "正在上传文件",
@@ -351,6 +352,12 @@
"Divider": "分割线",
"Quote": "引用",
"Image": "图像",
"Audio": "音频。",
"Embed PDF": "嵌入 PDF",
"Upload and embed a PDF file.": "上传并嵌入 PDF 文件。",
"Embed as PDF": "作为 PDF 嵌入",
"Failed to load PDF": "加载 PDF 失败",
"Convert to attachment": "转换为附件",
"File attachment": "文件附件",
"Toggle block": "切换块",
"Callout": "标注块",
@@ -442,6 +449,9 @@
"Prevent members from sharing pages publicly.": "阻止成员公开分享页面。",
"Toggle public sharing": "切换公开分享",
"Toggle space public sharing": "切换空间公开分享",
"Allow viewers to comment": "允许观众评论",
"Allow viewers to add comments on pages in this space.": "允许观众在此空间的页面上添加评论。",
"Toggle viewer comments": "切换观众评论",
"Public sharing is disabled at the workspace level": "公开分享在工作区级别被禁用",
"Prevent pages in this space from being shared publicly.": "阻止此空间中的页面被公开分享。",
"Page permissions": "页面权限},{",
@@ -664,6 +674,24 @@
"<bold>{{name}}</bold> mentioned you on a page": "<bold>{{name}}</bold>在页面上提到你",
"<bold>{{name}}</bold> gave you edit access to a page": "<bold>{{name}}</bold>授予你页面编辑权限",
"<bold>{{name}}</bold> gave you view access to a page": "<bold>{{name}}</bold>授予你页面查看权限",
"<bold>{{name}}</bold> updated a page": "<bold>{{name}}</bold>更新了一个页面。",
"Watch page": "关注页面",
"Stop watching": "取消关注",
"Email notifications": "邮件通知",
"Page updates": "页面更新",
"Get notified when pages you watch are updated.": "当你关注的页面有更新时收到通知。",
"Page mentions": "页面提及",
"Get notified when someone mentions you on a page.": "当有人在页面上提到你时收到通知。",
"Comment mentions": "评论提及",
"Get notified when someone mentions you in a comment.": "当有人在评论中提到你时收到通知。",
"New comments": "新评论",
"Get notified about new comments on threads you participate in.": "当你参与的讨论有新评论时收到通知。",
"Resolved comments": "已解决的评论",
"Get notified when your comment is resolved.": "当你的评论被解决时收到通知。",
"You are now watching this page": "你现在正在关注此页面",
"You are no longer watching this page": "你已取消关注此页面",
"Direct": "直接",
"Updates": "更新",
"Today": "今天",
"Yesterday": "昨天",
"This week": "本周",
+1
View File
@@ -16,4 +16,5 @@ export const Feature = {
AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
} as const;
@@ -71,7 +71,10 @@ export function PageShareModal({ readOnly }: PageShareModalProps) {
) : null
}
variant="default"
onClick={open}
onClick={() => {
setActiveTab(isPubliclyShared ? "publish" : hasPagePermissions ? "access" : "publish");
open();
}}
>
{t("Share")}
</Button>
@@ -0,0 +1,61 @@
import { Group, Text, Switch, Tooltip } from "@mantine/core";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
import { Feature } from "@/ee/features.ts";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
type SpaceViewerCommentsToggleProps = {
space: ISpace;
};
export default function SpaceViewerCommentsToggle({
space,
}: SpaceViewerCommentsToggleProps) {
const { t } = useTranslation();
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
const upgradeLabel = useUpgradeLabel();
const isDisabled = !hasViewerComments;
const [checked, setChecked] = useState(
space.settings?.comments?.allowViewerComments === true,
);
const updateSpaceMutation = useUpdateSpaceMutation();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
await updateSpaceMutation.mutateAsync({
spaceId: space.id,
allowViewerComments: value,
});
setChecked(value);
} catch {
// error handled by mutation
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">{t("Allow viewers to comment")}</Text>
<Text size="sm" c="dimmed">
{t("Allow viewers to add comments on pages in this space.")}
</Text>
</div>
<Tooltip
label={upgradeLabel}
disabled={!isDisabled}
refProp="rootRef"
>
<Switch
checked={checked}
onChange={handleChange}
disabled={isDisabled}
aria-label={t("Toggle viewer comments")}
/>
</Tooltip>
</Group>
);
}
@@ -3,3 +3,15 @@ import { atom } from 'jotai';
export const showCommentPopupAtom = atom<boolean>(false);
export const activeCommentIdAtom = atom<string>('');
export const draftCommentIdAtom = atom<string>('');
// Read-only comment state
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
export type YjsSelection = {
anchor: any;
head: any;
};
export type ReadOnlyCommentData = {
yjsSelection: YjsSelection;
selectedText: string;
};
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
@@ -6,6 +6,8 @@ import {
activeCommentIdAtom,
draftCommentIdAtom,
showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
readOnlyCommentDataAtom,
} from "@/features/comment/atoms/comment-atom";
import CommentEditor from "@/features/comment/components/comment-editor";
import CommentActions from "@/features/comment/components/comment-actions";
@@ -19,12 +21,15 @@ import { useTranslation } from "react-i18next";
interface CommentDialogProps {
editor: ReturnType<typeof useEditor>;
pageId: string;
readOnly?: boolean;
}
function CommentDialog({ editor, pageId }: CommentDialogProps) {
function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
const { t } = useTranslation();
const [comment, setComment] = useState("");
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom);
const [currentUser] = useAtom(currentUserAtom);
@@ -34,11 +39,17 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
handleDialogClose();
});
const createCommentMutation = useCreateCommentMutation();
const { isPending } = createCommentMutation;
const isPending = createCommentMutation.isPending;
const handleDialogClose = () => {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
if (readOnly) {
setShowReadOnlyCommentPopup(false);
// @ts-ignore
setReadOnlyCommentData(null);
} else {
setShowCommentPopup(false);
editor.chain().focus().unsetCommentDecoration().run();
}
};
const getSelectedText = () => {
@@ -47,6 +58,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
};
const handleAddComment = async () => {
if (readOnly) {
await handleAddReadOnlyComment();
return;
}
try {
const selectedText = getSelectedText();
const commentData = {
@@ -65,7 +81,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
.run();
setActiveCommentId(createdComment.id);
//unselect text to close bubble menu
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
setAsideState({ tab: "comments", isAsideOpen: true });
@@ -85,6 +100,33 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
}
};
const handleAddReadOnlyComment = async () => {
if (!readOnlyCommentData) return;
try {
const createdComment = await createCommentMutation.mutateAsync({
pageId,
content: JSON.stringify(comment),
selection: readOnlyCommentData.selectedText,
type: "inline",
yjsSelection: readOnlyCommentData.yjsSelection,
});
setActiveCommentId(createdComment.id);
setAsideState({ tab: "comments", isAsideOpen: true });
setTimeout(() => {
const selector = `div[data-comment-id="${createdComment.id}"]`;
const commentElement = document.querySelector(selector);
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 400);
} finally {
setShowReadOnlyCommentPopup(false);
// @ts-ignore
setReadOnlyCommentData(null);
}
};
const handleCommentEditorChange = (newContent: any) => {
setComment(newContent);
};
@@ -44,7 +44,9 @@ function CommentListWithTabs() {
const [isLoading, setIsLoading] = useState(false);
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canComment = page?.permissions?.canEdit ?? false;
const canComment =
(page?.permissions?.canEdit ?? false) ||
(space?.settings?.comments?.allowViewerComments === true);
// Separate active and resolved comments
const { activeComments, resolvedComments } = useMemo(() => {
@@ -153,7 +155,7 @@ function CommentListWithTabs() {
)}
</Paper>
),
[comments, handleAddReply, isLoading, space?.membership?.role],
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
);
if (isCommentsLoading) {
@@ -75,7 +75,7 @@ function CommentMenu({
{isResolved ? t("Re-open comment") : t("Resolve comment")}
</Menu.Item>
) : (
<Tooltip label={upgradeLabel} position="left">
<Tooltip label={upgradeLabel} position="left" withinPortal={false}>
<Menu.Item disabled leftSection={<IconCircleCheck size={14} />}>
{t("Resolve comment")}
</Menu.Item>
@@ -17,6 +17,10 @@ export interface IComment {
deletedAt?: Date;
creator: IUser;
resolvedBy?: IUser;
yjsSelection?: {
anchor: any;
head: any;
};
}
export interface ICommentData {
@@ -1,17 +1,43 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon, Loader } from "@mantine/core";
import { Group, Text, Paper, ActionIcon, Loader, Tooltip } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { IconDownload, IconFileTypePdf, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib";
import { useTranslation } from "react-i18next";
import { useCallback } from "react";
export default function AttachmentView(props: NodeViewProps) {
const { t } = useTranslation();
const { node, selected } = props;
const { url, name, size } = node.attrs;
const { editor, node, getPos, selected } = props;
const { url, name, size, mime, attachmentId, placeholder } = node.attrs;
const { hovered, ref } = useHover();
const isPdf = mime === "application/pdf" || name?.toLowerCase().endsWith(".pdf");
const handleEmbedAsPdf = useCallback(() => {
const pos = getPos();
if (pos === undefined || !url) return;
const nodeSize = node.nodeSize;
editor
.chain()
.insertContentAt(
{ from: pos, to: pos + nodeSize },
{
type: "pdf",
attrs: {
src: url,
name,
attachmentId,
size,
},
},
)
.run();
}, [editor, getPos, node, url, name, attachmentId]);
return (
<NodeViewWrapper>
<Paper withBorder p="4px" ref={ref} data-drag-handle>
@@ -23,14 +49,14 @@ export default function AttachmentView(props: NodeViewProps) {
h={25}
>
<Group wrap="nowrap" gap="sm" style={{ minWidth: 0, flex: 1 }}>
{url ? (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
) : (
{!url && placeholder ? (
<Loader size={20} style={{ flexShrink: 0 }} />
) : (
<IconPaperclip size={20} style={{ flexShrink: 0 }} />
)}
<Text component="span" size="md" truncate="end" style={{ minWidth: 0 }}>
{url ? name : t("Uploading {{name}}", { name })}
{!url && placeholder ? t("Uploading {{name}}", { name }) : name}
</Text>
<Text component="span" size="sm" c="dimmed" style={{ flexShrink: 0 }}>
@@ -39,11 +65,20 @@ export default function AttachmentView(props: NodeViewProps) {
</Group>
{url && (selected || hovered) && (
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
{isPdf && editor.isEditable && (
<Tooltip label={t("Embed as PDF")} position="top" withinPortal={false}>
<ActionIcon variant="default" aria-label={t("Embed as PDF")} onClick={handleEmbedAsPdf}>
<IconFileTypePdf size={18} />
</ActionIcon>
</Tooltip>
)}
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
</Group>
)}
</Group>
</Paper>
@@ -0,0 +1,123 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconDownload,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { getFileUrl } from "@/lib/config.ts";
import classes from "../common/toolbar-menu.module.css";
export function AudioMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const audioAttrs = ctx.editor.getAttributes("audio");
return {
isAudio: ctx.editor.isActive("audio"),
src: audioAttrs?.src || null,
};
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive("audio") && editor.getAttributes("audio").src;
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "audio";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
const a = document.createElement("a");
a.href = url;
a.download = "";
a.click();
}, [editorState?.src]);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`audio-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Download")} withinPortal={false}>
<ActionIcon
onClick={handleDownload}
size="lg"
aria-label={t("Download")}
variant="subtle"
>
<IconDownload size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
);
}
export default AudioMenu;
@@ -0,0 +1,37 @@
.audioWrapper {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.audio {
display: block;
width: 100%;
border-radius: 8px;
}
@@ -0,0 +1,68 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Loader, Text } from "@mantine/core";
import { useMemo } from "react";
import { getFileUrl } from "@/lib/config.ts";
import { isInternalFileUrl } from "@docmost/editor-ext";
import classes from "./audio-view.module.css";
import { useTranslation } from "react-i18next";
export default function AudioView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node } = props;
const { src, placeholder } = node.attrs;
const safeSrc = useMemo(() => {
if (!src || !isInternalFileUrl(src)) return null;
return getFileUrl(src);
}, [src]);
const previewSrc = useMemo(() => {
editor.storage.shared.audioPreviews =
editor.storage.shared.audioPreviews || {};
if (placeholder?.id) {
return editor.storage.shared.audioPreviews[placeholder.id];
}
return null;
}, [placeholder, editor]);
return (
<NodeViewWrapper data-drag-handle>
<div className={`${classes.audioWrapper} ${!safeSrc && placeholder ? classes.skeleton : ''}`}>
{safeSrc && (
<audio
className={classes.audio}
preload="metadata"
controls
src={safeSrc}
/>
)}
{!safeSrc && previewSrc && (
<Group pos="relative" w="100%">
<audio
className={classes.audio}
preload="metadata"
controls
src={previewSrc}
/>
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!safeSrc && !previewSrc && placeholder && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md" h={54}>
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
{!safeSrc && !previewSrc && !placeholder && (
<audio className={classes.audio} controls />
)}
</div>
</NodeViewWrapper>
);
}
@@ -0,0 +1,36 @@
import { handleAudioUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "@/i18n.ts";
export const uploadAudioAction = handleAudioUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
return await uploadFile(file, pageId);
} catch (err) {
notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err;
}
},
validateFn: (file) => {
if (!file.type.includes("audio/")) {
return false;
}
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: i18n.t("File exceeds the {{limit}} attachment limit", {
limit: formatBytes(getFileUploadSizeLimit()),
}),
});
return false;
}
return true;
},
});
@@ -0,0 +1,159 @@
import type { Editor } from "@tiptap/react";
import { TextSelection } from "@tiptap/pm/state";
import { FC, useCallback, useEffect, useRef, useState } from "react";
import { IconMessage } from "@tabler/icons-react";
import classes from "./bubble-menu.module.css";
import { ActionIcon, Tooltip } from "@mantine/core";
import { useAtom } from "jotai";
import {
showReadOnlyCommentPopupAtom,
readOnlyCommentDataAtom,
} from "@/features/comment/atoms/comment-atom";
import { useTranslation } from "react-i18next";
import { getRelativeSelection, ySyncPluginKey } from "@tiptap/y-tiptap";
type ReadonlyBubbleMenuProps = {
editor: Editor;
};
export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
const { t } = useTranslation();
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
showReadOnlyCommentPopupAtom,
);
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
const menuRef = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [position, setPosition] = useState({ top: 0, left: 0 });
const isInteractingRef = useRef(false);
const updateMenuPosition = useCallback(() => {
if (isInteractingRef.current) return;
const pmSelection = editor.state.selection;
if (!(pmSelection instanceof TextSelection) || pmSelection.empty) {
setVisible(false);
return;
}
const selection = window.getSelection();
if (
!selection ||
selection.isCollapsed ||
selection.rangeCount === 0 ||
showReadOnlyCommentPopup
) {
setVisible(false);
return;
}
const editorDom = editor.view.dom;
if (
!editorDom.contains(selection.anchorNode) ||
!editorDom.contains(selection.focusNode)
) {
setVisible(false);
return;
}
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (rect.width === 0) {
setVisible(false);
return;
}
const editorRect = editorDom
.closest(".editor-container")
?.getBoundingClientRect();
if (!editorRect) {
setVisible(false);
return;
}
setPosition({
top: rect.top - editorRect.top - 44,
left: rect.left - editorRect.left + rect.width / 2,
});
setVisible(true);
}, [editor, showReadOnlyCommentPopup]);
useEffect(() => {
const handleSelectionChange = () => {
updateMenuPosition();
};
document.addEventListener("selectionchange", handleSelectionChange);
return () => {
document.removeEventListener("selectionchange", handleSelectionChange);
};
}, [updateMenuPosition]);
useEffect(() => {
if (showReadOnlyCommentPopup) {
setVisible(false);
}
}, [showReadOnlyCommentPopup]);
const handleCommentClick = () => {
if (!editor) return;
const view = editor.view;
const ystate = ySyncPluginKey.getState(view.state);
if (ystate?.binding) {
const selection = getRelativeSelection(ystate.binding, view.state);
const { from, to } = editor.state.selection;
const selectedText = editor.state.doc.textBetween(from, to);
// @ts-ignore
setReadOnlyCommentData({
yjsSelection: {
anchor: selection.anchor,
head: selection.head,
},
selectedText,
});
setShowReadOnlyCommentPopup(true);
setVisible(false);
}
};
if (!visible) return null;
return (
<div
ref={menuRef}
style={{
position: "absolute",
top: position.top,
left: position.left,
transform: "translateX(-50%)",
zIndex: 199,
}}
>
<div className={classes.bubbleMenu}>
<Tooltip label={t("Comment")} withArrow withinPortal={false}>
<ActionIcon
variant="default"
size="lg"
radius="6px"
aria-label={t("Comment")}
style={{ border: "none" }}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
isInteractingRef.current = true;
handleCommentClick();
isInteractingRef.current = false;
}}
>
<IconMessage size={16} stroke={2} />
</ActionIcon>
</Tooltip>
</div>
</div>
);
};
@@ -1,6 +1,7 @@
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
import { uploadPdfAction } from "../pdf/upload-pdf-action";
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
import { Editor } from "@tiptap/core";
@@ -12,6 +13,8 @@ import {
const ATTACHMENT_NODE_TYPES = [
"image",
"video",
"audio",
"pdf",
"attachment",
"excalidraw",
"drawio",
@@ -63,6 +66,7 @@ export const handlePaste = (
const pos = editor.state.selection.from;
uploadImageAction(file, editor, pos, pageId);
uploadVideoAction(file, editor, pos, pageId);
uploadPdfAction(file, editor, pos, pageId);
uploadAttachmentAction(file, editor, pos, pageId);
}
return true;
@@ -229,6 +233,7 @@ export const handleFileDrop = (
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadPdfAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
}
return true;
@@ -1,5 +1,5 @@
import type { ResizableNodeViewDirection } from "@tiptap/core";
import classes from "./node-resize.module.css";
import { ResizableNodeViewDirection } from "@docmost/editor-ext";
export function createResizeHandle(
direction: ResizableNodeViewDirection,
@@ -20,8 +20,8 @@
.cornerHandle {
position: absolute;
width: 36px;
height: 36px;
width: 24px;
height: 24px;
z-index: 2;
opacity: 0;
transition: opacity 0.2s ease;
@@ -42,13 +42,13 @@
}
&::before {
width: 28px;
width: 20px;
height: 3px;
}
&::after {
width: 3px;
height: 28px;
height: 20px;
}
&:hover::before,
@@ -74,6 +74,15 @@ export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight });
constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight };
useEffect(() => {
if (!dragRef.current && wrapperRef.current) {
widthRef.current = initialWidth;
heightRef.current = initialHeight;
wrapperRef.current.style.width = `${initialWidth}px`;
wrapperRef.current.style.height = `${initialHeight}px`;
}
}, [initialWidth, initialHeight]);
const handleMouseMove = useRef((e: MouseEvent) => {
const drag = dragRef.current;
if (!drag || !wrapperRef.current) return;
@@ -86,8 +86,8 @@ export default function EmbedView(props: NodeViewProps) {
{embedUrl ? (
<div className={classes.embedContainer}>
<ResizableWrapper
initialWidth={nodeWidth || 640}
initialHeight={nodeHeight || 480}
initialWidth={nodeWidth || 800}
initialHeight={nodeHeight || 600}
minWidth={200}
maxWidth={1200}
minHeight={200}
@@ -102,8 +102,9 @@ export default function EmbedView(props: NodeViewProps) {
<iframe
className={classes.embedIframe}
src={sanitizeUrl(embedUrl)}
allow="encrypted-media"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups"
allow="encrypted-media; clipboard-read; clipboard-write; picture-in-picture;"
loading="lazy"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
allowFullScreen
frameBorder="0"
/>
@@ -5,6 +5,9 @@
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
@@ -33,6 +33,7 @@ export default function ImageView(props: NodeViewProps) {
className={clsx(
selected && "ProseMirror-selectednode",
classes.imageWrapper,
!src && placeholder && classes.skeleton,
alignClass,
)}
style={{
@@ -54,7 +55,7 @@ export default function ImageView(props: NodeViewProps) {
<Loader size={20} pos="absolute" bottom={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
{!src && !previewSrc && placeholder && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
@@ -294,6 +294,7 @@ const MentionList = forwardRef<any, MentionListProps>((props, ref) => {
w={popupWidth}
scrollbars={"y"}
scrollbarSize={6}
overscrollBehavior={"contain"}
styles={{ content: { minWidth: 0 } }}
>
{renderItems?.map((item, index) => {
@@ -0,0 +1,145 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback } from "react";
import { Node as PMNode } from "@tiptap/pm/model";
import {
EditorMenuProps,
ShouldShowProps,
} from "@/features/editor/components/table/types/types.ts";
import { ActionIcon, Tooltip } from "@mantine/core";
import {
IconPaperclip,
IconTrash,
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import classes from "../common/toolbar-menu.module.css";
export function PdfMenu({ editor }: EditorMenuProps) {
const { t } = useTranslation();
const editorState = useEditorState({
editor,
selector: (ctx) => {
if (!ctx.editor) {
return null;
}
const pdfAttrs = ctx.editor.getAttributes("pdf");
return {
isPdf: ctx.editor.isActive("pdf"),
src: pdfAttrs?.src || null,
name: pdfAttrs?.name || null,
attachmentId: pdfAttrs?.attachmentId || null,
};
},
});
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state || !editor.isActive("pdf")) {
return false;
}
const { selection } = state;
const dom = editor.view.nodeDOM(selection.from) as HTMLElement | null;
if (!dom) return false;
return !!dom.querySelector("[data-pdf-error]");
},
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
if (!editor) return;
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === "pdf";
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
const domRect = dom.getBoundingClientRect();
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}
const domRect = posToDOMRect(editor.view, selection.from, selection.to);
return {
getBoundingClientRect: () => domRect,
getClientRects: () => [domRect],
};
}, [editor]);
const handleConvertToAttachment = useCallback(() => {
if (!editorState?.src) return;
const { selection } = editor.state;
const { from } = selection;
const node = editor.state.doc.nodeAt(from);
if (!node || node.type.name !== "pdf") return;
editor
.chain()
.insertContentAt(
{ from, to: from + node.nodeSize },
{
type: "attachment",
attrs: {
url: node.attrs.src,
name: node.attrs.name,
attachmentId: node.attrs.attachmentId,
size: node.attrs.size,
mime: "application/pdf",
},
},
)
.run();
}, [editor, editorState]);
const handleDelete = useCallback(() => {
editor.commands.deleteSelection();
}, [editor]);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`pdf-menu`}
updateDelay={0}
getReferencedVirtualElement={getReferencedVirtualElement}
options={{
placement: "top",
offset: 8,
flip: false,
}}
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Convert to attachment")} withinPortal={false}>
<ActionIcon
onClick={handleConvertToAttachment}
size="lg"
aria-label={t("Convert to attachment")}
variant="subtle"
>
<IconPaperclip size={18} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal={false}>
<ActionIcon
onClick={handleDelete}
size="lg"
aria-label={t("Delete")}
variant="subtle"
>
<IconTrash size={18} />
</ActionIcon>
</Tooltip>
</div>
</BaseBubbleMenu>
);
}
export default PdfMenu;
@@ -0,0 +1,100 @@
.pdfWrapper {
display: flex;
justify-content: center;
align-items: center;
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
.pdfContainer {
display: flex;
justify-content: center;
}
.pdfResizeWrapper {
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
.pdfIframe {
width: 100%;
height: 100%;
border: none;
border-radius: 8px;
}
.hoverMenu {
position: absolute;
top: 56px;
right: 8px;
z-index: 2;
display: flex;
gap: 4px;
padding: 4px;
border-radius: 6px;
opacity: 0;
transition: opacity 0.15s ease;
background-color: rgba(0, 0, 0, 0.5);
}
.hoverMenu::before {
content: "";
position: absolute;
inset: -12px;
}
.hoverMenu:hover {
opacity: 1;
}
.pdfResizeWrapper:hover .hoverMenu {
opacity: 1;
}
.pdfError {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px 32px;
border-radius: 8px;
cursor: pointer;
@mixin light {
background-color: var(--mantine-color-gray-0);
}
@mixin dark {
background-color: var(--mantine-color-dark-7);
}
}
@@ -0,0 +1,170 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { ActionIcon, Group, Loader, Text, Tooltip } from "@mantine/core";
import { useCallback, useMemo, useState } from "react";
import { getFileUrl } from "@/lib/config.ts";
import { ResizableWrapper } from "../common/resizable-wrapper";
import clsx from "clsx";
import classes from "./pdf-view.module.css";
import { useTranslation } from "react-i18next";
import { isInternalFileUrl } from "@docmost/editor-ext";
import {
IconFileTypePdf,
IconPaperclip,
IconTrash,
} from "@tabler/icons-react";
export default function PdfView(props: NodeViewProps) {
const { t } = useTranslation();
const { editor, node, getPos, selected, updateAttributes } = props;
const { src, placeholder, width: nodeWidth, height: nodeHeight } = node.attrs;
const [hasError, setHasError] = useState(false);
const safeSrc = useMemo(() => {
if (!src || !isInternalFileUrl(src)) return null;
return getFileUrl(src);
}, [src]);
const handleSelect = useCallback(() => {
const pos = getPos();
if (pos !== undefined) {
editor.commands.setNodeSelection(pos);
}
}, [editor, getPos]);
const handleResize = useCallback(
(newWidth: number, newHeight: number) => {
updateAttributes({ width: newWidth, height: newHeight });
},
[updateAttributes],
);
const handleConvertToAttachment = useCallback(() => {
if (!src) return;
const pos = getPos();
if (pos === undefined) return;
const currentNode = editor.state.doc.nodeAt(pos);
if (!currentNode || currentNode.type.name !== "pdf") return;
editor
.chain()
.insertContentAt(
{ from: pos, to: pos + currentNode.nodeSize },
{
type: "attachment",
attrs: {
url: currentNode.attrs.src,
name: currentNode.attrs.name,
attachmentId: currentNode.attrs.attachmentId,
size: currentNode.attrs.size,
mime: "application/pdf",
},
},
)
.run();
}, [editor, src, getPos]);
const handleDelete = useCallback(() => {
const pos = getPos();
if (pos === undefined) return;
editor.commands.setNodeSelection(pos);
editor.commands.deleteSelection();
}, [editor, getPos]);
if (!src || !safeSrc) {
return (
<NodeViewWrapper data-drag-handle>
<div className={`${classes.pdfWrapper} ${placeholder ? classes.skeleton : ''}`} style={{ height: placeholder ? 600 : undefined }}>
{placeholder && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
{placeholder?.name
? t("Uploading {{name}}", { name: placeholder.name })
: t("Uploading file")}
</Text>
</Group>
)}
</div>
</NodeViewWrapper>
);
}
if (hasError) {
return (
<NodeViewWrapper data-drag-handle>
<div data-pdf-error className={clsx(classes.pdfError, { "ProseMirror-selectednode": selected })} onClick={handleSelect}>
<IconFileTypePdf size={32} stroke={1.5} />
<Text size="sm" c="dimmed">
{t("Failed to load PDF")}
</Text>
</div>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper data-drag-handle className={classes.pdfNodeView}>
<div className={classes.pdfContainer}>
<ResizableWrapper
initialWidth={nodeWidth || 800}
initialHeight={nodeHeight || 600}
minWidth={200}
maxWidth={1200}
minHeight={200}
maxHeight={1200}
onResize={handleResize}
isEditable={editor.isEditable}
selected={selected}
className={clsx(classes.pdfResizeWrapper, {
"ProseMirror-selectednode": selected,
})}
>
<iframe
className={classes.pdfIframe}
src={safeSrc}
loading="lazy"
frameBorder="0"
onError={() => setHasError(true)}
onLoad={(e) => {
try {
const iframe = e.currentTarget;
const status = iframe.contentDocument?.querySelector("pre")?.textContent;
if (status && status.includes('"statusCode":404')) {
setHasError(true);
}
} catch {
// cross-origin - can't inspect, assume OK
}
}}
/>
{editor.isEditable && (
<div className={classes.hoverMenu}>
<Tooltip position="top" label={t("Convert to attachment")} withinPortal>
<ActionIcon
size="sm"
variant="filled"
color="dark"
onClick={handleConvertToAttachment}
aria-label={t("Convert to attachment")}
>
<IconPaperclip size={14} />
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete")} withinPortal>
<ActionIcon
size="sm"
variant="filled"
color="dark"
onClick={handleDelete}
aria-label={t("Delete")}
>
<IconTrash size={14} />
</ActionIcon>
</Tooltip>
</div>
)}
</ResizableWrapper>
</div>
</NodeViewWrapper>
);
}
@@ -0,0 +1,36 @@
import { handlePdfUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import { getFileUploadSizeLimit } from "@/lib/config.ts";
import { formatBytes } from "@/lib";
import i18n from "@/i18n.ts";
export const uploadPdfAction = handlePdfUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
return await uploadFile(file, pageId);
} catch (err) {
notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err;
}
},
validateFn: (file) => {
if (file.type !== "application/pdf") {
return false;
}
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: i18n.t("File exceeds the {{limit}} attachment limit", {
limit: formatBytes(getFileUploadSizeLimit()),
}),
});
return false;
}
return true;
},
});
@@ -87,7 +87,13 @@ const CommandList = ({
return flatItems.length > 0 ? (
<Paper id="slash-command" shadow="md" p="xs" withBorder>
<ScrollArea viewportRef={viewportRef} h={350} w={270} scrollbarSize={8}>
<ScrollArea
viewportRef={viewportRef}
h={350}
w={270}
scrollbarSize={8}
overscrollBehavior="contain"
>
{Object.entries(items).map(([category, categoryItems]) => (
<div key={category}>
<Text c="dimmed" mb={4} fw={500} tt="capitalize">
@@ -103,10 +109,7 @@ const CommandList = ({
})}
>
<Group>
<ActionIcon
variant="default"
component="div"
>
<ActionIcon variant="default" component="div">
<item.icon size={18} />
</ActionIcon>
@@ -12,7 +12,9 @@ import {
IconMath,
IconMathFunction,
IconMovie,
IconMusic,
IconPaperclip,
IconFileTypePdf,
IconPhoto,
IconTable,
IconTypography,
@@ -30,7 +32,9 @@ import {
} from "@/features/editor/components/slash-menu/types";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAudioAction } from "@/features/editor/components/audio/upload-audio-action.tsx";
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
import { uploadPdfAction } from "@/features/editor/components/pdf/upload-pdf-action.tsx";
import IconExcalidraw from "@/components/icons/icon-excalidraw";
import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio";
@@ -161,7 +165,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Image",
description: "Upload any image from your device.",
searchTerms: ["photo", "picture", "media"],
searchTerms: ["photo", "picture", "media", "file", "attachment"],
icon: IconPhoto,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -194,7 +198,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "Video",
description: "Upload any video from your device.",
searchTerms: ["video", "mp4", "media"],
searchTerms: ["video", "mp4", "media", "file", "attachment"],
icon: IconMovie,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -224,10 +228,74 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.click();
},
},
{
title: "Audio",
description: "Upload any audio from your device.",
searchTerms: ["audio", "music", "sound", "mp3", "media", "file", "attachment"],
icon: IconMusic,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
// upload audio
const input = document.createElement("input");
input.type = "file";
input.accept = "audio/*";
input.multiple = true;
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadAudioAction(file, editor, pos, pageId);
}
}
input.remove();
};
input.click();
},
},
{
title: "Embed PDF",
description: "Upload and embed a PDF file.",
searchTerms: ["pdf", "document", "embed"],
icon: IconFileTypePdf,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
// @ts-ignore
const pageId = editor.storage?.pageId;
if (!pageId) return;
const input = document.createElement("input");
input.type = "file";
input.accept = "application/pdf";
input.style.display = "none";
document.body.appendChild(input);
input.onchange = async () => {
if (input.files?.length) {
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadPdfAction(file, editor, pos, pageId);
}
}
input.remove();
};
input.click();
},
},
{
title: "File attachment",
description: "Upload any file from your device.",
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
searchTerms: ["file", "attachment", "upload", "csv", "zip"],
icon: IconPaperclip,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
@@ -359,7 +427,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
editor.chain().focus().deleteRange(range).setDrawio().run(),
},
{
title: "Excalidraw diagram",
title: "Excalidraw (Whiteboard)",
description: "Draw and sketch excalidraw diagrams",
searchTerms: ["diagrams", "draw", "sketch", "whiteboard"],
icon: IconExcalidraw,
@@ -548,7 +616,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
{
title: "YouTube",
description: "Embed YouTube video",
searchTerms: ["youtube", "yt"],
searchTerms: ["youtube", "yt", "media", "video"],
icon: YoutubeIcon,
command: ({ editor, range }: CommandProps) => {
editor
@@ -647,7 +715,11 @@ export const getSuggestionItems = ({
});
if (filteredItems.length) {
filteredGroups[group] = filteredItems;
filteredGroups[group] = filteredItems.sort((a, b) => {
const aTitle = a.title.toLowerCase().includes(search) ? 0 : 1;
const bTitle = b.title.toLowerCase().includes(search) ? 0 : 1;
return aTitle - bTitle;
});
}
}
@@ -49,7 +49,7 @@ const renderItems = () => {
getReferenceClientRect = props.clientRect;
popup = document.createElement("div");
popup.style.zIndex = "9999";
popup.style.zIndex = "199";
popup.style.position = "absolute";
popup.style.top = "0";
popup.style.left = "0";
@@ -34,7 +34,7 @@ export const TableMenu = React.memo(
if (isTextSelected(editor)) return false;
return editor.isActive("table") && !isCellSelection(state.selection);
},
[editor]
[editor],
);
const getReferencedVirtualElement = useCallback(() => {
@@ -121,7 +121,11 @@ export const TableMenu = React.memo(
shouldShow={shouldShow}
>
<div className={classes.toolbar}>
<Tooltip position="top" label={t("Add left column")}>
<Tooltip
position="top"
label={t("Add left column")}
withinPortal={false}
>
<ActionIcon
onClick={addColumnLeft}
variant="subtle"
@@ -132,7 +136,11 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Add right column")}>
<Tooltip
position="top"
label={t("Add right column")}
withinPortal={false}
>
<ActionIcon
onClick={addColumnRight}
variant="subtle"
@@ -143,7 +151,11 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete column")}>
<Tooltip
position="top"
label={t("Delete column")}
withinPortal={false}
>
<ActionIcon
onClick={deleteColumn}
variant="subtle"
@@ -156,7 +168,11 @@ export const TableMenu = React.memo(
<div className={classes.divider} />
<Tooltip position="top" label={t("Add row above")}>
<Tooltip
position="top"
label={t("Add row above")}
withinPortal={false}
>
<ActionIcon
onClick={addRowAbove}
variant="subtle"
@@ -167,7 +183,11 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Add row below")}>
<Tooltip
position="top"
label={t("Add row below")}
withinPortal={false}
>
<ActionIcon
onClick={addRowBelow}
variant="subtle"
@@ -178,7 +198,7 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Delete row")}>
<Tooltip position="top" label={t("Delete row")} withinPortal={false}>
<ActionIcon
onClick={deleteRow}
variant="subtle"
@@ -191,7 +211,11 @@ export const TableMenu = React.memo(
<div className={classes.divider} />
<Tooltip position="top" label={t("Toggle header row")}>
<Tooltip
position="top"
label={t("Toggle header row")}
withinPortal={false}
>
<ActionIcon
onClick={toggleHeaderRow}
variant="subtle"
@@ -202,7 +226,11 @@ export const TableMenu = React.memo(
</ActionIcon>
</Tooltip>
<Tooltip position="top" label={t("Toggle header column")}>
<Tooltip
position="top"
label={t("Toggle header column")}
withinPortal={false}
>
<ActionIcon
onClick={toggleHeaderColumn}
variant="subtle"
@@ -215,7 +243,11 @@ export const TableMenu = React.memo(
<div className={classes.divider} />
<Tooltip position="top" label={t("Delete table")}>
<Tooltip
position="top"
label={t("Delete table")}
withinPortal={false}
>
<ActionIcon
onClick={deleteTable}
variant="subtle"
@@ -228,7 +260,7 @@ export const TableMenu = React.memo(
</div>
</BubbleMenu>
);
}
},
);
export default TableMenu;
@@ -5,6 +5,9 @@
max-width: 100%;
border-radius: 8px;
overflow: hidden;
}
.skeleton {
animation: pulse 1.2s ease-in-out infinite;
@mixin light {
@@ -26,6 +29,7 @@
}
}
}
.video {
display: block;
width: 100%;
@@ -33,6 +33,7 @@ export default function VideoView(props: NodeViewProps) {
className={clsx(
selected && "ProseMirror-selectednode",
classes.videoWrapper,
!src && placeholder && classes.skeleton,
alignClass,
)}
style={{
@@ -59,7 +60,7 @@ export default function VideoView(props: NodeViewProps) {
<Loader size={20} pos="absolute" top={6} right={6} />
</Group>
)}
{!src && !previewSrc && (
{!src && !previewSrc && placeholder && (
<Group justify="center" wrap="nowrap" gap="xs" maw="100%" px="md">
<Loader size={20} style={{ flexShrink: 0 }} />
<Text component="span" size="sm" truncate="end">
@@ -69,6 +70,9 @@ export default function VideoView(props: NodeViewProps) {
</Text>
</Group>
)}
{!src && !previewSrc && !placeholder && (
<video className={classes.video} controls />
)}
</div>
</NodeViewWrapper>
);
@@ -0,0 +1,105 @@
// https://github.com/NiclasDev63/tiptap-extension-auto-joiner - MIT
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { canJoin } from "@tiptap/pm/transform";
import { getNodeType } from "@tiptap/react";
import { NodeType } from "@tiptap/pm/model";
import { Transaction } from "@tiptap/pm/state";
// https://discuss.prosemirror.net/t/how-to-autojoin-all-the-time/2957/4
// Adapted from prosemirror-commands wrapDispatchForJoin
function autoJoin(
transactions: readonly Transaction[],
newTr: Transaction,
nodeTypes: NodeType[]
) {
// Collect changed ranges across all transactions, mapping earlier ranges
// forward through later mappings so every position lands in newTr.doc space.
let ranges: number[] = [];
for (const tr of transactions) {
for (let i = 0; i < tr.mapping.maps.length; i++) {
let map = tr.mapping.maps[i];
if (!map) continue;
for (let j = 0; j < ranges.length; j++) ranges[j] = map.map(ranges[j]!);
map.forEach((_s, _e, from, to) => ranges.push(from, to));
}
}
// Figure out which joinable points exist inside those ranges,
// by checking all node boundaries in their parent nodes.
// Resolve against newTr.doc — the same document we will join on.
let joinable: number[] = [];
for (let i = 0; i < ranges.length; i += 2) {
let from = ranges[i]!,
to = ranges[i + 1]!;
let $from = newTr.doc.resolve(from),
depth = $from.sharedDepth(to),
parent = $from.node(depth);
for (
let index = $from.indexAfter(depth), pos = $from.after(depth + 1);
pos <= to;
++index
) {
let after = parent.maybeChild(index);
if (!after) break;
if (index && joinable.indexOf(pos) == -1) {
let before = parent.child(index - 1);
if (before.type == after.type && nodeTypes.includes(before.type))
joinable.push(pos);
}
pos += after.nodeSize;
}
}
// Join the joinable points (reverse order to preserve earlier positions)
let joined = false;
joinable.sort((a, b) => a - b);
for (let i = joinable.length - 1; i >= 0; i--) {
if (canJoin(newTr.doc, joinable[i]!)) {
newTr.join(joinable[i]!);
joined = true;
}
}
return joined;
}
export interface AutoJoinerOptions {
elementsToJoin: string[];
}
const AutoJoiner = Extension.create<AutoJoinerOptions>({
name: "autoJoiner",
addOptions() {
return {
elementsToJoin: [],
};
},
addProseMirrorPlugins() {
const plugin = new PluginKey(this.name);
const joinableNodes = [
this.editor.schema.nodes.bulletList,
this.editor.schema.nodes.orderedList,
];
this.options.elementsToJoin.forEach((element) => {
const nodeTyp = getNodeType(element, this.editor.schema);
joinableNodes.push(nodeTyp);
});
return [
new Plugin({
key: plugin,
appendTransaction(transactions, _, newState) {
let newTr = newState.tr;
if (autoJoin(transactions, newTr, joinableNodes as NodeType[])) {
return newTr;
}
},
}),
];
},
});
export default AutoJoiner;
@@ -30,6 +30,7 @@ import {
TiptapImage,
Callout,
TiptapVideo,
TiptapAudio,
LinkExtension,
Selection,
Attachment,
@@ -37,6 +38,7 @@ import {
Drawio,
Excalidraw,
Embed,
TiptapPdf,
SearchAndReplace,
Mention,
TableDndExtension,
@@ -47,7 +49,7 @@ import {
SharedStorage,
Columns,
Column,
Status
Status,
} from "@docmost/editor-ext";
import {
randomElement,
@@ -68,11 +70,13 @@ import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import StatusView from "@/features/editor/components/status/status-view.tsx";
import VideoView from "@/features/editor/components/video/video-view.tsx";
import AudioView from "@/features/editor/components/audio/audio-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import EmbedView from "@/features/editor/components/embed/embed-view.tsx";
import PdfView from "@/features/editor/components/pdf/pdf-view.tsx";
import SubpagesView from "@/features/editor/components/subpages/subpages-view.tsx";
import { common, createLowlight } from "lowlight";
import plaintext from "highlight.js/lib/languages/plaintext";
@@ -93,6 +97,7 @@ import i18n from "@/i18n.ts";
import { MarkdownClipboard } from "@/features/editor/extensions/markdown-clipboard.ts";
import EmojiCommand from "./emoji-command";
import { countWords } from "alfaaz";
import AutoJoiner from "@/features/editor/extensions/autojoiner.ts";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
@@ -248,8 +253,8 @@ export const mainExtensions = [
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
minWidth: 24,
minHeight: 16,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createImageHandle,
@@ -261,14 +266,17 @@ export const mainExtensions = [
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
minWidth: 24,
minHeight: 16,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
className: buildResizeClasses("node-video"),
},
}),
TiptapAudio.configure({
view: AudioView,
}),
Callout.configure({
view: CalloutView,
}),
@@ -289,8 +297,8 @@ export const mainExtensions = [
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
minWidth: 24,
minHeight: 16,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
@@ -302,8 +310,8 @@ export const mainExtensions = [
resize: {
enabled: true,
directions: ["left", "right"],
minWidth: 80,
minHeight: 40,
minWidth: 24,
minHeight: 16,
alwaysPreserveAspectRatio: true,
//@ts-ignore
createCustomHandle: createResizeHandle,
@@ -313,6 +321,9 @@ export const mainExtensions = [
Embed.configure({
view: EmbedView,
}),
TiptapPdf.configure({
view: PdfView,
}),
Subpages.configure({
view: SubpagesView,
}),
@@ -343,6 +354,9 @@ export const mainExtensions = [
}).configure(),
Columns,
Column,
AutoJoiner.configure({
elementsToJoin: [],
}),
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];
@@ -1,9 +1,9 @@
// adapted from: https://github.com/aguingand/tiptap-markdown/blob/main/src/extensions/tiptap/clipboard.js - MIT
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { DOMParser } from "@tiptap/pm/model";
import { Plugin, PluginKey, TextSelection } from "@tiptap/pm/state";
import { DOMParser, DOMSerializer, Fragment, Slice } from "@tiptap/pm/model";
import { find } from "linkifyjs";
import { markdownToHtml } from "@docmost/editor-ext";
import { markdownToHtml, htmlToMarkdown } from "@docmost/editor-ext";
export const MarkdownClipboard = Extension.create({
name: "markdownClipboard",
@@ -19,6 +19,27 @@ export const MarkdownClipboard = Extension.create({
new Plugin({
key: new PluginKey("markdownClipboard"),
props: {
clipboardTextSerializer: (slice) => {
const listTypes = ["bulletList", "orderedList", "taskList"];
let topLevelCount = 0;
let hasList = false;
slice.content.forEach((node) => {
if (listTypes.includes(node.type.name)) {
hasList = true;
topLevelCount += node.childCount;
} else {
topLevelCount++;
}
});
if (!hasList || topLevelCount < 2) return null;
const div = document.createElement("div");
const serializer = DOMSerializer.fromSchema(this.editor.schema);
const fragment = serializer.serializeFragment(slice.content);
div.appendChild(fragment);
return htmlToMarkdown(div.innerHTML);
},
handlePaste: (view, event, slice) => {
if (!event.clipboardData) {
return false;
@@ -29,49 +50,80 @@ export const MarkdownClipboard = Extension.create({
}
const text = event.clipboardData.getData("text/plain");
const html = event.clipboardData.getData("text/html");
const vscode = event.clipboardData.getData("vscode-editor-data");
const vscodeData = vscode ? JSON.parse(vscode) : undefined;
const language = vscodeData?.mode;
if (language !== "markdown") {
const isVscodeMarkdown = language === "markdown";
const isPlainTextOnly = !html && !vscode && !!text;
if (!isVscodeMarkdown && !isPlainTextOnly) {
return false;
}
if (isPlainTextOnly) {
if ((view as any).input?.shiftKey || !this.options.transformPastedText) {
return false;
}
const link = find(text, {
defaultProtocol: "http",
}).find((item) => item.isLink && item.value === text);
if (link) {
return false;
}
}
const { tr } = view.state;
const { from, to } = view.state.selection;
const html = markdownToHtml(text);
const parsed = markdownToHtml(text.replace(/\n+$/, ""));
const contentNodes = DOMParser.fromSchema(
this.editor.schema,
).parseSlice(elementFromString(html), {
).parseSlice(elementFromString(parsed), {
preserveWhitespace: true,
});
tr.replaceRange(from, to, contentNodes);
const insertEnd = tr.mapping.map(from, 1);
tr.setSelection(TextSelection.near(tr.doc.resolve(Math.max(from, insertEnd - 2)), -1));
tr.setMeta('paste', true)
view.dispatch(tr);
return true;
},
clipboardTextParser: (text, context, plainText) => {
const link = find(text, {
defaultProtocol: "http",
}).find((item) => item.isLink && item.value === text);
// Strip trailing whitespace-only paragraphs from pasted content.
// Terminals (GNOME Terminal, etc.) often include trailing
// whitespace in their HTML clipboard data, which ProseMirror
// parses as an extra paragraph. Inside a list item this creates
// an orphan empty line that breaks the list structure.
transformPasted: (slice) => {
let { content, openStart, openEnd } = slice;
if (plainText || !this.options.transformPastedText || link) {
// don't parse plaintext link to allow link paste handler to work
// pasting with shift key prevents formatting
return null;
// Remove trailing paragraphs that contain only whitespace
while (content.childCount > 1) {
const lastChild = content.lastChild;
if (
lastChild?.type.name === "paragraph" &&
lastChild.textContent.trim() === ""
) {
const children = [];
for (let i = 0; i < content.childCount - 1; i++) {
children.push(content.child(i));
}
content = Fragment.from(children);
} else {
break;
}
}
const parsed = markdownToHtml(text);
return DOMParser.fromSchema(this.editor.schema).parseSlice(
elementFromString(parsed),
{
preserveWhitespace: true,
context,
},
);
if (content !== slice.content) {
return new Slice(content, openStart, Math.max(openEnd, 1));
}
return slice;
},
},
}),
@@ -16,6 +16,7 @@ export interface FullEditorProps {
content: string;
spaceSlug: string;
editable: boolean;
canComment?: boolean;
}
export function FullEditor({
@@ -25,6 +26,7 @@ export function FullEditor({
content,
spaceSlug,
editable,
canComment,
}: FullEditorProps) {
const [user] = useAtom(userAtom);
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
@@ -46,6 +48,7 @@ export function FullEditor({
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</Container>
);
@@ -37,14 +37,17 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
import {
activeCommentIdAtom,
showCommentPopupAtom,
showReadOnlyCommentPopupAtom,
} from "@/features/comment/atoms/comment-atom";
import CommentDialog from "@/features/comment/components/comment-dialog";
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
import {
handleFileDrop,
@@ -73,12 +76,14 @@ interface PageEditorProps {
pageId: string;
editable: boolean;
content: any;
canComment?: boolean;
}
export default function PageEditor({
pageId,
editable,
content,
canComment,
}: PageEditorProps) {
const collaborationURL = useCollaborationUrl();
const isComponentMounted = useRef(false);
@@ -93,6 +98,7 @@ export default function PageEditor({
const [, setAsideState] = useAtom(asideStateAtom);
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
const [isLocalSynced, setIsLocalSynced] = useState(false);
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
@@ -414,6 +420,7 @@ export default function PageEditor({
<TableCellMenu editor={editor} appendTo={menuContainerRef} />
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<PdfMenu editor={editor} />
<CalloutMenu editor={editor} />
<SubpagesMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
@@ -421,7 +428,13 @@ export default function PageEditor({
<ColumnsMenu editor={editor} />
</div>
)}
{editor && !editorIsEditable && (editable || canComment) && providersRef.current && (
<ReadonlyBubbleMenu editor={editor} />
)}
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
{showReadOnlyCommentPopup && (
<CommentDialog editor={editor} pageId={pageId} readOnly />
)}
</div>
<div
onClick={() => editor.commands.focus("end")}
@@ -133,10 +133,18 @@
border-top: 1px solid #68cef8;
}
&[contenteditable="false"] hr.ProseMirror-selectednode {
border-top: none;
}
.ProseMirror-selectednode {
outline: 2px solid #70cff8;
}
&[contenteditable="false"] .ProseMirror-selectednode {
outline: none;
}
& > .react-renderer {
margin-top: var(--mantine-spacing-sm);
margin-bottom: var(--mantine-spacing-sm);
@@ -8,7 +8,7 @@
}
}
.node-image, .node-video, .node-excalidraw, .node-drawio {
.node-image, .node-video, .node-pdf, .node-excalidraw, .node-drawio {
&.ProseMirror-selectednode {
outline: none;
}
@@ -37,5 +37,28 @@
font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md);
}
.media-pulse {
animation: media-pulse 1.2s ease-in-out infinite;
@mixin light {
background: linear-gradient(-90deg, var(--mantine-color-gray-3) 0%, var(--mantine-color-gray-1) 50%, var(--mantine-color-gray-3) 100%);
background-size: 400% 400%;
}
@mixin dark {
background: linear-gradient(-90deg, var(--mantine-color-dark-6) 0%, var(--mantine-color-dark-5) 50%, var(--mantine-color-dark-6) 100%);
background-size: 400% 400%;
}
@keyframes media-pulse {
0% {
background-position: 0% 0%;
}
100% {
background-position: -135% 0%;
}
}
}
}
@@ -13,7 +13,7 @@ import {
import { CustomAvatar } from "@/components/ui/custom-avatar";
import { INotification } from "../types/notification.types";
import { Trans, useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Link } from "react-router-dom";
import { useState } from "react";
import { useMarkReadMutation } from "../queries/notification-query";
import { buildPageUrl } from "@/features/page/page.utils";
@@ -30,7 +30,6 @@ export function NotificationItem({
onNavigate,
}: NotificationItemProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const markRead = useMarkReadMutation();
const [hovered, setHovered] = useState(false);
@@ -50,37 +49,47 @@ export function NotificationItem({
return notification.data?.role === "writer"
? "<bold>{{name}}</bold> gave you edit access to a page"
: "<bold>{{name}}</bold> gave you view access to a page";
case "page.updated":
return "<bold>{{name}}</bold> updated a page";
default:
return "";
}
};
const handleClick = () => {
if (notification.page && notification.space) {
if (isUnread) {
markRead.mutate([notification.id]);
}
navigate(
buildPageUrl(
const pageUrl =
notification.page && notification.space
? buildPageUrl(
notification.space.slug,
notification.page.slugId,
notification.page.title,
),
);
onNavigate();
}
};
)
: undefined;
const handleMarkRead = (e: React.MouseEvent) => {
e.stopPropagation();
const markReadIfNeeded = () => {
if (isUnread) {
markRead.mutate([notification.id]);
}
};
const handleClick = () => {
markReadIfNeeded();
onNavigate();
};
const handleMarkRead = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
markReadIfNeeded();
};
return (
<UnstyledButton
component={Link}
to={pageUrl ?? ""}
onClick={handleClick}
// auxclick fires for all non-primary buttons; guard to middle-click only (button 1)
// so that right-click (button 2, context menu) does not mark as read
onAuxClick={(e: React.MouseEvent) => e.button === 1 && markReadIfNeeded()}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
w="100%"
@@ -3,17 +3,23 @@ import { IconBellOff } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { useEffect, useRef } from "react";
import { NotificationItem } from "./notification-item";
import { INotification, NotificationFilter } from "../types/notification.types";
import {
INotification,
NotificationFilter,
NotificationTab,
} from "../types/notification.types";
import { groupNotificationsByTime } from "../notification.utils";
import { useNotificationsQuery } from "../queries/notification-query";
import classes from "../notification.module.css";
type NotificationListProps = {
tab: NotificationTab;
filter: NotificationFilter;
onNavigate: () => void;
};
export function NotificationList({
tab,
filter,
onNavigate,
}: NotificationListProps) {
@@ -24,7 +30,7 @@ export function NotificationList({
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useNotificationsQuery();
} = useNotificationsQuery(tab as string);
const sentinelRef = useRef<HTMLDivElement>(null);
@@ -6,6 +6,7 @@ import {
Menu,
Popover,
ScrollArea,
Tabs,
Text,
Tooltip,
} from "@mantine/core";
@@ -18,15 +19,20 @@ import {
} from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { NotificationList } from "./notification-list";
import { NotificationFilter } from "../types/notification.types";
import {
NotificationFilter,
NotificationTab,
} from "../types/notification.types";
import {
useMarkAllReadMutation,
useUnreadCountQuery,
} from "../queries/notification-query";
import classes from "../notification.module.css";
export function NotificationPopover() {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const [tab, setTab] = useState<NotificationTab>("direct");
const [filter, setFilter] = useState<NotificationFilter>("all");
const { data: unreadData } = useUnreadCountQuery();
@@ -125,13 +131,27 @@ export function NotificationPopover() {
</Group>
</Group>
<Tabs
value={tab}
onChange={(value) => setTab(value as NotificationTab)}
variant="default"
color="dark"
>
<Tabs.List px="md">
<Tabs.Tab value="direct">{t("Direct")}</Tabs.Tab>
<Tabs.Tab value="updates">{t("Updates")}</Tabs.Tab>
</Tabs.List>
</Tabs>
<ScrollArea.Autosize
mah={500}
type="auto"
offsetScrollbars
scrollbarSize={6}
style={{ overscrollBehavior: "contain" }}
>
<NotificationList
tab={tab}
filter={filter}
onNavigate={() => setOpened(false)}
/>
@@ -1,7 +1,9 @@
.notificationItem {
display: block;
padding: 8px 12px;
border-radius: 4px;
cursor: pointer;
user-select: none;
}
.notificationItem:hover {
@@ -11,3 +13,4 @@
.divider {
border-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
}
@@ -15,10 +15,10 @@ import {
export const NOTIFICATION_KEY = ["notifications"];
export const UNREAD_COUNT_KEY = ["notifications", "unread-count"];
export function useNotificationsQuery() {
export function useNotificationsQuery(type?: string) {
return useInfiniteQuery({
queryKey: NOTIFICATION_KEY,
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam }),
queryKey: [...NOTIFICATION_KEY, type],
queryFn: ({ pageParam }) => getNotifications({ cursor: pageParam, type }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.nextCursor : undefined,
@@ -5,6 +5,7 @@ import { IPagination } from "@/lib/types";
export async function getNotifications(params: {
limit?: number;
cursor?: string;
type?: string;
}): Promise<IPagination<INotification>> {
const req = await api.post<IPagination<INotification>>(
"/notifications",
@@ -3,7 +3,8 @@ export type NotificationType =
| "comment.created"
| "comment.resolved"
| "page.user_mention"
| "page.permission_granted";
| "page.permission_granted"
| "page.updated";
export type INotification = {
id: string;
@@ -38,3 +39,5 @@ export type INotification = {
};
export type NotificationFilter = "all" | "unread";
export type NotificationTab = "direct" | "updates" | "all";
@@ -3,6 +3,8 @@ import {
IconArrowRight,
IconArrowsHorizontal,
IconDots,
IconEye,
IconEyeOff,
IconFileExport,
IconHistory,
IconLink,
@@ -40,6 +42,11 @@ import { PageStateSegmentedControl } from "@/features/user/components/page-state
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
import { PageShareModal } from "@/ee/page-permission";
import {
useWatchStatusQuery,
useWatchPageMutation,
useUnwatchPageMutation,
} from "@/features/page/queries/watcher-query";
interface PageHeaderMenuProps {
readOnly?: boolean;
@@ -123,6 +130,9 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
] = useDisclosure(false);
const [pageEditor] = useAtom(pageEditorAtom);
const pageUpdatedAt = useTimeAgo(page?.updatedAt);
const { data: watchStatus } = useWatchStatusQuery(page?.id);
const watchPage = useWatchPageMutation();
const unwatchPage = useUnwatchPageMutation();
const handleCopyLink = () => {
const pageUrl =
@@ -185,6 +195,23 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
>
{t("Copy as Markdown")}
</Menu.Item>
{watchStatus?.watching ? (
<Menu.Item
leftSection={<IconEyeOff size={16} />}
onClick={() => unwatchPage.mutate(page.id)}
>
{t("Stop watching")}
</Menu.Item>
) : (
<Menu.Item
leftSection={<IconEye size={16} />}
onClick={() => watchPage.mutate(page.id)}
>
{t("Watch page")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconArrowsHorizontal size={16} />}>
@@ -0,0 +1,43 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
watchPage,
unwatchPage,
getWatchStatus,
} from "@/features/page/services/watcher-service";
import { notifications } from "@mantine/notifications";
import { useTranslation } from "react-i18next";
const WATCHER_KEY = "watcher";
export function useWatchStatusQuery(pageId: string) {
return useQuery({
queryKey: [WATCHER_KEY, pageId],
queryFn: () => getWatchStatus(pageId),
enabled: !!pageId,
staleTime: 60_000,
});
}
export function useWatchPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => watchPage(pageId),
onSuccess: (_data, pageId) => {
queryClient.setQueryData([WATCHER_KEY, pageId], { watching: true });
notifications.show({ message: t("You are now watching this page") });
},
});
}
export function useUnwatchPageMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => unwatchPage(pageId),
onSuccess: (_data, pageId) => {
queryClient.setQueryData([WATCHER_KEY, pageId], { watching: false });
notifications.show({ message: t("You are no longer watching this page") });
},
});
}
@@ -0,0 +1,16 @@
import api from "@/lib/api-client";
export async function watchPage(pageId: string): Promise<{ watching: boolean }> {
const req = await api.post<{ watching: boolean }>("/pages/watch", { pageId });
return req.data;
}
export async function unwatchPage(pageId: string): Promise<{ watching: boolean }> {
const req = await api.post<{ watching: boolean }>("/pages/unwatch", { pageId });
return req.data;
}
export async function getWatchStatus(pageId: string): Promise<{ watching: boolean }> {
const req = await api.post<{ watching: boolean }>("/pages/watch-status", { pageId });
return req.data;
}
@@ -3,6 +3,7 @@ import SpaceMembersList from "@/features/space/components/space-members.tsx";
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
import React from "react";
import SpaceDetails from "@/features/space/components/space-details.tsx";
import SpaceSecuritySettings from "@/features/space/components/space-security-settings.tsx";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import {
@@ -59,6 +60,14 @@ export default function SpaceSettingsModal({
<Tabs.Tab fw={500} value="members">
{t("Members")}
</Tabs.Tab>
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
) && (
<Tabs.Tab fw={500} value="security">
{t("Security")}
</Tabs.Tab>
)}
</Tabs.List>
<Tabs.Panel value="general">
@@ -91,6 +100,20 @@ export default function SpaceSettingsModal({
)}
/>
</Tabs.Panel>
<Tabs.Panel value="security">
<ScrollArea h={580} scrollbarSize={5} pr={8}>
<div style={{ paddingBottom: "100px" }}>
<SpaceSecuritySettings
space={space}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Settings,
)}
/>
</div>
</ScrollArea>
</Tabs.Panel>
</Tabs>
</div>
</Modal.Body>
@@ -18,7 +18,7 @@ import {
ResponsiveSettingsControl,
ResponsiveSettingsRow,
} from "@/components/ui/responsive-settings-row.tsx";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
interface SpaceDetailsProps {
spaceId: string;
@@ -27,7 +27,6 @@ interface SpaceDetailsProps {
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
const { t } = useTranslation();
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
const showSharingToggle = !readOnly;
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
useDisclosure(false);
const [isIconUploading, setIsIconUploading] = useState(false);
@@ -89,13 +88,6 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
<EditSpaceForm space={space} readOnly={readOnly} />
{showSharingToggle && (
<>
<Divider my="lg" />
<SpacePublicSharingToggle space={space} />
</>
)}
{!readOnly && (
<>
<Divider my="lg" />
@@ -0,0 +1,34 @@
import { Text, Divider } from "@mantine/core";
import React from "react";
import { useTranslation } from "react-i18next";
import { ISpace } from "@/features/space/types/space.types.ts";
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
import SpaceViewerCommentsToggle from "@/ee/security/components/space-viewer-comments-toggle.tsx";
type SpaceSecuritySettingsProps = {
space: ISpace;
readOnly?: boolean;
};
export default function SpaceSecuritySettings({
space,
readOnly,
}: SpaceSecuritySettingsProps) {
const { t } = useTranslation();
if (readOnly) return null;
return (
<div>
<Text my="md" fw={600}>
{t("Security")}
</Text>
<SpacePublicSharingToggle space={space} />
<Divider my="lg" />
<SpaceViewerCommentsToggle space={space} />
</div>
);
}
@@ -9,8 +9,13 @@ export interface ISpaceSharingSettings {
disabled?: boolean;
}
export interface ISpaceCommentsSettings {
allowViewerComments?: boolean;
}
export interface ISpaceSettings {
sharing?: ISpaceSharingSettings;
comments?: ISpaceCommentsSettings;
}
export interface ISpace {
@@ -29,6 +34,7 @@ export interface ISpace {
settings?: ISpaceSettings;
// for updates
disablePublicSharing?: boolean;
allowViewerComments?: boolean;
}
interface IMembership {
@@ -0,0 +1,117 @@
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { updateUser } from "@/features/user/services/user-service.ts";
import { IUser, IUserSettings } from "@/features/user/types/user.types.ts";
import { Switch, Text, Title, Stack } from "@mantine/core";
import { useAtom } from "jotai";
import React, { useState } from "react";
import { useTranslation } from "react-i18next";
import {
ResponsiveSettingsRow,
ResponsiveSettingsContent,
ResponsiveSettingsControl,
} from "@/components/ui/responsive-settings-row";
type NotificationKey = keyof NonNullable<IUserSettings["notifications"]>;
const notificationItems: {
key: NotificationKey;
dtoField: keyof IUser;
label: string;
description: string;
}[] = [
{
key: "page.updated",
dtoField: "notificationPageUpdates",
label: "Page updates",
description: "Get notified when pages you watch are updated.",
},
{
key: "page.userMention",
dtoField: "notificationPageUserMention",
label: "Page mentions",
description: "Get notified when someone mentions you on a page.",
},
{
key: "comment.userMention",
dtoField: "notificationCommentUserMention",
label: "Comment mentions",
description: "Get notified when someone mentions you in a comment.",
},
{
key: "comment.created",
dtoField: "notificationCommentCreated",
label: "New comments",
description:
"Get notified about new comments on threads you participate in.",
},
{
key: "comment.resolved",
dtoField: "notificationCommentResolved",
label: "Resolved comments",
description: "Get notified when your comment is resolved.",
},
];
function NotificationToggle({
settingKey,
dtoField,
label,
description,
}: {
settingKey: NotificationKey;
dtoField: keyof IUser;
label: string;
description: string;
}) {
const { t } = useTranslation();
const [user, setUser] = useAtom(userAtom);
const [checked, setChecked] = useState(
user.settings?.notifications?.[settingKey] !== false,
);
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
setChecked(value);
try {
const updatedUser = await updateUser({ [dtoField]: value } as any);
setUser(updatedUser);
} catch {
setChecked(!value);
}
};
return (
<ResponsiveSettingsRow>
<ResponsiveSettingsContent>
<Text size="md">{t(label)}</Text>
<Text size="sm" c="dimmed">
{t(description)}
</Text>
</ResponsiveSettingsContent>
<ResponsiveSettingsControl>
<Switch checked={checked} onChange={handleChange} />
</ResponsiveSettingsControl>
</ResponsiveSettingsRow>
);
}
export default function NotificationPref() {
const { t } = useTranslation();
return (
<Stack gap="xs">
<Title order={5}>{t("Email notifications")}</Title>
{notificationItems.map((item) => (
<NotificationToggle
key={item.key}
settingKey={item.key}
dtoField={item.dtoField}
label={item.label}
description={item.description}
/>
))}
</Stack>
);
}
@@ -20,6 +20,11 @@ export interface IUser {
deletedAt: Date;
fullPageWidth: boolean; // used for update
pageEditMode: string; // used for update
notificationPageUpdates: boolean; // used for update
notificationPageUserMention: boolean; // used for update
notificationCommentUserMention: boolean; // used for update
notificationCommentCreated: boolean; // used for update
notificationCommentResolved: boolean; // used for update
hasGeneratedPassword?: boolean;
}
@@ -33,6 +38,13 @@ export interface IUserSettings {
fullPageWidth: boolean;
pageEditMode: string;
};
notifications?: {
"page.updated"?: boolean;
"page.userMention"?: boolean;
"comment.userMention"?: boolean;
"comment.created"?: boolean;
"comment.resolved"?: boolean;
};
}
export enum PageEditMode {
+1
View File
@@ -14,6 +14,7 @@ i18n
.init({
fallbackLng: "en-US",
debug: false,
showSupportNotice: false,
load: 'currentOnly',
interpolation: {
+4
View File
@@ -53,6 +53,9 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
const canEdit = page?.permissions?.canEdit ?? false;
const canComment =
canEdit ||
(space?.settings?.comments?.allowViewerComments === true);
if (isLoading) {
return <></>;
@@ -104,6 +107,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
slugId={page.slugId}
spaceSlug={page?.space?.slug}
editable={canEdit}
canComment={canComment}
/>
<MemoizedHistoryModal pageId={page.id} />
</div>
@@ -3,6 +3,7 @@ import AccountLanguage from "@/features/user/components/account-language.tsx";
import AccountTheme from "@/features/user/components/account-theme.tsx";
import PageWidthPref from "@/features/user/components/page-width-pref.tsx";
import PageEditPref from "@/features/user/components/page-state-pref";
import NotificationPref from "@/features/user/components/notification-pref";
import { getAppName } from "@/lib/config.ts";
import { Divider } from "@mantine/core";
import { Helmet } from "react-helmet-async";
@@ -33,6 +34,10 @@ export default function AccountPreferences() {
<Divider my={"md"} />
<PageEditPref />
<Divider my={"md"} />
<NotificationPref />
</>
);
}
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.70.3",
"version": "0.71.0",
"description": "",
"author": "",
"private": true,
@@ -44,6 +44,7 @@
"@langchain/core": "1.1.34",
"@langchain/textsplitters": "1.0.1",
"@modelcontextprotocol/sdk": "^1.27.1",
"@nest-lab/throttler-storage-redis": "^1.2.0",
"@nestjs-labs/nestjs-ioredis": "^11.0.4",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.1.0",
@@ -58,6 +59,7 @@
"@nestjs/platform-socket.io": "^11.1.17",
"@nestjs/schedule": "^6.1.1",
"@nestjs/terminus": "^11.1.1",
"@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.17",
"@node-saml/passport-saml": "^5.1.0",
"@react-email/components": "1.0.10",
@@ -73,8 +75,9 @@
"class-transformer": "^0.5.1",
"class-validator": "^0.15.1",
"cookie": "^1.1.1",
"fastify-ip": "^2.0.0",
"fs-extra": "^11.3.4",
"happy-dom": "20.8.4",
"happy-dom": "20.8.9",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.3",
"kysely": "^0.28.14",
@@ -89,7 +92,7 @@
"nestjs-cls": "^6.2.0",
"nestjs-kysely": "^3.1.2",
"nestjs-pino": "^4.6.1",
"nodemailer": "^8.0.3",
"nodemailer": "^8.0.4",
"openid-client": "^6.8.2",
"otpauth": "^9.5.0",
"p-limit": "^7.3.0",
+2
View File
@@ -26,6 +26,7 @@ import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
import { ThrottleModule } from './integrations/throttle/throttle.module';
const enterpriseModules = [];
try {
@@ -83,6 +84,7 @@ try {
EventEmitterModule.forRoot(),
SecurityModule,
TelemetryModule,
ThrottleModule,
...enterpriseModules,
],
controllers: [AppController],
@@ -5,6 +5,7 @@ import {
prosemirrorNodeToYElement,
tiptapExtensions,
} from './collaboration.util';
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
import * as Y from 'yjs';
import { User } from '@docmost/db/types/entity.types';
@@ -27,6 +28,53 @@ export class CollaborationHandler {
// const fragment = doc.getXmlFragment('default');
//});
},
setCommentMark: async (
documentName: string,
payload: {
yjsSelection: YjsSelection;
commentId: string;
resolved: boolean;
user: User;
},
) => {
const { yjsSelection, commentId, resolved, user } = payload;
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
setYjsMark(doc, fragment, yjsSelection, 'comment', {
commentId,
resolved,
});
},
);
},
resolveCommentMark: async (
documentName: string,
payload: {
commentId: string;
resolved: boolean;
user: User;
},
) => {
const { commentId, resolved, user } = payload;
await this.withYdocConnection(
hocuspocus,
documentName,
{ user },
(doc) => {
const fragment = doc.getXmlFragment('default');
updateYjsMarkAttribute(
fragment,
'comment',
{ name: 'commentId', value: commentId },
{ resolved },
);
},
);
},
updatePageContent: async (
documentName: string,
payload: {
@@ -58,8 +106,7 @@ export class CollaborationHandler {
} else {
const newContent = prosemirrorJson.content || [];
const yElements = newContent.map(prosemirrorNodeToYElement);
const position =
operation === 'prepend' ? 0 : fragment.length;
const position = operation === 'prepend' ? 0 : fragment.length;
fragment.insert(position, yElements);
}
},
@@ -24,6 +24,8 @@ import {
CustomTable,
TiptapImage,
TiptapVideo,
TiptapAudio,
TiptapPdf,
TrailingNode,
Attachment,
Drawio,
@@ -86,6 +88,8 @@ export const tiptapExtensions = [
Youtube,
TiptapImage,
TiptapVideo,
TiptapAudio,
TiptapPdf,
Callout,
Attachment,
CustomCodeBlock,
@@ -18,12 +18,10 @@ import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { Queue } from 'bullmq';
import {
extractMentions,
extractPageMentions,
extractUserMentions,
} from '../../common/helpers/prosemirror/utils';
import { isDeepStrictEqual } from 'node:util';
import {
IPageBacklinkJob,
IPageHistoryJob,
IPageMentionNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
@@ -43,7 +41,6 @@ export class PersistenceExtension implements Extension {
constructor(
private readonly pageRepo: PageRepo,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
@InjectQueue(QueueName.HISTORY_QUEUE) private historyQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
@@ -165,13 +162,6 @@ export class PersistenceExtension implements Extension {
await this.collabHistory.addContributors(pageId, editingUserIds);
const mentions = extractMentions(tiptapJson);
const pageMentions = extractPageMentions(mentions);
await this.generalQueue.add(QueueJob.PAGE_BACKLINKS, {
pageId: pageId,
workspaceId: page.workspaceId,
mentions: pageMentions,
} as IPageBacklinkJob);
const userMentions = extractUserMentions(mentions);
const oldMentions = page.content ? extractMentions(page.content) : [];
@@ -1,8 +1,17 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
import { Job, Queue } from 'bullmq';
import { QueueJob, QueueName } from '../../integrations/queue/constants';
import { IPageHistoryJob } from '../../integrations/queue/constants/queue.interface';
import {
IPageBacklinkJob,
IPageHistoryJob,
IPageUpdateNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import {
extractMentions,
extractPageMentions,
} from '../../common/helpers/prosemirror/utils';
import { PageHistoryRepo } from '@docmost/db/repos/page/page-history.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { isDeepStrictEqual } from 'node:util';
@@ -18,6 +27,8 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
private readonly pageRepo: PageRepo,
private readonly collabHistory: CollabHistoryService,
private readonly watcherService: WatcherService,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
@InjectQueue(QueueName.GENERAL_QUEUE) private generalQueue: Queue,
) {
super();
}
@@ -47,8 +58,7 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
!lastHistory ||
!isDeepStrictEqual(lastHistory.content, page.content)
) {
const contributorIds =
await this.collabHistory.popContributors(pageId);
const contributorIds = await this.collabHistory.popContributors(pageId);
try {
await this.watcherService.addPageWatchers(
@@ -61,12 +71,39 @@ export class HistoryProcessor extends WorkerHost implements OnModuleDestroy {
await this.pageHistoryRepo.saveHistory(page, { contributorIds });
this.logger.debug(`History created for page: ${pageId}`);
} catch (err) {
await this.collabHistory.addContributors(
pageId,
contributorIds,
);
await this.collabHistory.addContributors(pageId, contributorIds);
throw err;
}
const mentions = extractMentions(page.content);
const pageMentions = extractPageMentions(mentions);
await this.generalQueue
.add(QueueJob.PAGE_BACKLINKS, {
pageId,
workspaceId: page.workspaceId,
mentions: pageMentions,
} as IPageBacklinkJob)
.catch((err) => {
this.logger.error(
`Failed to queue backlinks for ${pageId}: ${err.message}`,
);
});
if (contributorIds.length > 0 && lastHistory?.content) {
await this.notificationQueue
.add(QueueJob.PAGE_UPDATED, {
pageId,
spaceId: page.spaceId,
workspaceId: page.workspaceId,
actorIds: contributorIds,
} as IPageUpdateNotificationJob)
.catch((err) => {
this.logger.error(
`Failed to queue page update notification for ${pageId}: ${err.message}`,
);
});
}
}
} catch (err) {
throw err;
@@ -11,12 +11,14 @@ import { CollaborationController } from './collaboration.controller';
import { LoggerModule } from '../../common/logger/logger.module';
import { RedisModule } from '@nestjs-labs/nestjs-ioredis';
import { RedisConfigService } from '../../integrations/redis/redis-config.service';
import { CaslModule } from '../../core/casl/casl.module';
@Module({
imports: [
LoggerModule,
DatabaseModule,
EnvironmentModule,
CaslModule,
CollaborationModule,
QueueModule,
HealthModule,
+1 -1
View File
@@ -1,7 +1,7 @@
import {
initProseMirrorDoc,
relativePositionToAbsolutePosition,
} from 'y-prosemirror';
} from '@tiptap/y-tiptap';
import * as Y from 'yjs';
import { Document } from '@hocuspocus/server';
import { getSchema } from '@tiptap/core';
+22
View File
@@ -0,0 +1,22 @@
export const Feature = {
SSO_CUSTOM: 'sso:custom',
SSO_GOOGLE: 'sso:google',
MFA: 'mfa',
API_KEYS: 'api:keys',
COMMENT_RESOLUTION: 'comment:resolution',
PAGE_PERMISSIONS: 'page:permissions',
AI: 'ai',
CONFLUENCE_IMPORT: 'import:confluence',
DOCX_IMPORT: 'import:docx',
ATTACHMENT_INDEXING: 'attachment:indexing',
SECURITY_SETTINGS: 'security:settings',
MCP: 'mcp',
SCIM: 'scim',
PAGE_VERIFICATION: 'page:verification',
AUDIT_LOGS: 'audit:logs',
RETENTION: 'retention',
SHARING_CONTROLS: 'sharing:controls',
VIEWER_COMMENTS: 'comment:viewer',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -102,6 +102,8 @@ export function isAttachmentNode(nodeType: string) {
'attachment',
'image',
'video',
'audio',
'pdf',
'excalidraw',
'drawio',
];
+6 -14
View File
@@ -50,20 +50,12 @@ export function createPinoConfig(): Params {
},
},
serializers: {
req: (req) => {
const forwardedFor = req.headers?.['x-forwarded-for'];
const ip =
req.headers?.['cf-connecting-ip'] ||
(typeof forwardedFor === 'string' ? forwardedFor.split(',')[0]?.trim() : undefined) ||
req.remoteAddress;
return {
method: req.method,
url: req.url,
ip,
userAgent: req.headers?.['user-agent'],
};
},
req: (req) => ({
method: req.method,
url: req.url,
ip: req.ip || req.remoteAddress,
userAgent: req.headers?.['user-agent'],
}),
res: (res) => ({
statusCode: res.statusCode,
}),
@@ -18,7 +18,8 @@ export class AuditContextMiddleware implements NestMiddleware {
use(req: FastifyRequest['raw'], res: FastifyReply['raw'], next: () => void) {
const workspaceId = (req as any).workspaceId ?? null;
const ipAddress = this.extractIpAddress(req);
const ipAddress = (req as any).ip ?? (req as any).socket?.remoteAddress ?? null;
const userAgent =
(req.headers['user-agent'] as string) ?? null;
@@ -35,21 +36,4 @@ export class AuditContextMiddleware implements NestMiddleware {
next();
}
private extractIpAddress(req: FastifyRequest['raw']): string | null {
const xForwardedFor = req.headers['x-forwarded-for'];
if (xForwardedFor) {
const ips = Array.isArray(xForwardedFor)
? xForwardedFor[0]
: xForwardedFor.split(',')[0];
return ips?.trim() ?? null;
}
const xRealIp = req.headers['x-real-ip'];
if (xRealIp) {
return Array.isArray(xRealIp) ? xRealIp[0] : xRealIp;
}
return (req as any).socket?.remoteAddress ?? null;
}
}
@@ -15,4 +15,9 @@ export const inlineFileExtensions = [
'.pdf',
'.mp4',
'.mov',
'.mp3',
'.wav',
'.ogg',
'.m4a',
'.webm',
];
@@ -457,6 +457,10 @@ export class AttachmentController {
const rangeHeader = req.headers.range;
res.header('Accept-Ranges', 'bytes');
res.header(
'Content-Security-Policy',
"base-uri 'none'; object-src 'self'; default-src 'self';",
);
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
@@ -10,6 +10,7 @@ import {
UseGuards,
Logger,
} from '@nestjs/common';
import { SkipThrottle, ThrottlerGuard } from '@nestjs/throttler';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './services/auth.service';
import { SessionService } from '../session/session.service';
@@ -33,6 +34,7 @@ import {
IAuditService,
} from '../../integrations/audit/audit.service';
@UseGuards(ThrottlerGuard)
@Controller('auth')
export class AuthController {
private readonly logger = new Logger(AuthController.name);
@@ -111,6 +113,7 @@ export class AuthController {
return workspace;
}
@SkipThrottle()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('change-password')
@@ -173,6 +176,7 @@ export class AuthController {
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id);
}
@SkipThrottle()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('collab-token')
@@ -183,6 +187,7 @@ export class AuthController {
return this.authService.getCollabToken(user, workspace.id);
}
@SkipThrottle()
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('logout')
@@ -58,13 +58,13 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
await this.pageAccessService.validateCanComment(page, user, workspace.id);
const comment = await this.commentService.create(
{
userId: user.id,
page,
workspaceId: workspace.id,
user,
},
createCommentDto,
);
@@ -120,7 +120,7 @@ export class CommentController {
@HttpCode(HttpStatus.OK)
@Post('update')
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
const comment = await this.commentRepo.findById(dto.commentId, {
includeCreator: true,
includeResolvedBy: true,
@@ -134,14 +134,14 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
await this.pageAccessService.validateCanEdit(page, user);
await this.pageAccessService.validateCanComment(page, user, workspace.id);
return this.commentService.update(comment, dto, user);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async delete(@Body() input: CommentIdDto, @AuthUser() user: User) {
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
const comment = await this.commentRepo.findById(input.commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
@@ -152,8 +152,7 @@ export class CommentController {
throw new NotFoundException('Page not found');
}
// Check page-level edit permission first
await this.pageAccessService.validateCanEdit(page, user);
await this.pageAccessService.validateCanComment(page, user, workspace.id);
// Check if user is the comment owner
const isOwner = comment.creatorId === user.id;
@@ -169,7 +168,7 @@ export class CommentController {
// Space admin can delete any comment
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException(
'You can only delete your own comments or must be a space admin',
'You can only delete your own comments',
);
}
await this.commentRepo.deleteComment(comment.id);
@@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { CommentService } from './comment.service';
import { CommentController } from './comment.controller';
import { CollaborationModule } from '../../collaboration/collaboration.module';
@Module({
imports: [CollaborationModule],
controllers: [CommentController],
providers: [CommentService],
exports: [CommentService],
@@ -7,7 +7,8 @@ import {
} from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { CreateCommentDto } from './dto/create-comment.dto';
import { CreateCommentDto, yjsSelectionSchema } from './dto/create-comment.dto';
import { CollaborationGateway } from '../../collaboration/collaboration.gateway';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
import { Comment, Page, User } from '@docmost/db/types/entity.types';
@@ -27,6 +28,7 @@ export class CommentService {
private commentRepo: CommentRepo,
private pageRepo: PageRepo,
private wsService: WsService,
private collaborationGateway: CollaborationGateway,
@InjectQueue(QueueName.GENERAL_QUEUE)
private generalQueue: Queue,
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
@@ -45,10 +47,10 @@ export class CommentService {
}
async create(
opts: { userId: string; page: Page; workspaceId: string },
opts: { page: Page; workspaceId: string; user: User },
createCommentDto: CreateCommentDto,
) {
const { userId, page, workspaceId } = opts;
const { page, workspaceId, user } = opts;
const commentContent = JSON.parse(createCommentDto.content);
if (createCommentDto.parentCommentId) {
@@ -71,11 +73,39 @@ export class CommentService {
selection: createCommentDto?.selection?.substring(0, 250) ?? null,
type: createCommentDto.type ?? 'page',
parentCommentId: createCommentDto?.parentCommentId,
creatorId: userId,
creatorId: user.id,
workspaceId: workspaceId,
spaceId: page.spaceId,
});
if (createCommentDto.yjsSelection) {
const parsed = yjsSelectionSchema.safeParse(createCommentDto.yjsSelection);
if (!parsed.success) {
this.logger.warn(
`Invalid yjsSelection for comment ${inserted.id}: ${parsed.error.message}`,
);
} else {
const documentName = `page.${page.id}`;
try {
await this.collaborationGateway.handleYjsEvent(
'setCommentMark',
documentName,
{
yjsSelection: parsed.data,
commentId: inserted.id,
resolved: false,
user,
},
);
} catch (error) {
this.logger.warn(
`Failed to apply comment mark for comment ${inserted.id}, comment saved without inline highlight`,
error,
);
}
}
}
const comment = await this.commentRepo.findById(inserted.id, {
includeCreator: true,
includeResolvedBy: true,
@@ -83,7 +113,7 @@ export class CommentService {
this.generalQueue
.add(QueueJob.ADD_PAGE_WATCHERS, {
userIds: [userId],
userIds: [user.id],
pageId: page.id,
spaceId: page.spaceId,
workspaceId,
@@ -101,7 +131,7 @@ export class CommentService {
page.id,
page.spaceId,
workspaceId,
userId,
user.id,
!isReply,
createCommentDto.parentCommentId,
);
@@ -1,4 +1,22 @@
import { IsIn, IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
import { IsIn, IsJSON, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
import { z } from 'zod';
const yjsIdSchema = z.object({
client: z.number().int().nonnegative(),
clock: z.number().int().nonnegative(),
});
const yjsRelativePositionSchema = z.object({
type: yjsIdSchema,
tname: z.string().nullable(),
item: yjsIdSchema.nullable(),
assoc: z.number().int(),
});
export const yjsSelectionSchema = z.object({
anchor: yjsRelativePositionSchema,
head: yjsRelativePositionSchema,
});
export class CreateCommentDto {
@IsString()
@@ -18,4 +36,11 @@ export class CreateCommentDto {
@IsOptional()
@IsUUID()
parentCommentId: string;
@IsOptional()
@IsObject()
yjsSelection?: {
anchor: any;
head: any;
};
}
@@ -1,4 +1,5 @@
import { IsArray, IsOptional, IsUUID } from 'class-validator';
import { IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
export class NotificationIdDto {
@IsUUID()
@@ -11,3 +12,10 @@ export class MarkNotificationsReadDto {
@IsOptional()
notificationIds?: string[];
}
export class ListNotificationsDto extends PaginationOptions {
@IsOptional()
@IsString()
@IsIn(['direct', 'updates', 'all'])
type?: 'direct' | 'updates' | 'all' = 'all';
}
@@ -4,7 +4,45 @@ export const NotificationType = {
COMMENT_RESOLVED: 'comment.resolved',
PAGE_USER_MENTION: 'page.user_mention',
PAGE_PERMISSION_GRANTED: 'page.permission_granted',
PAGE_UPDATED: 'page.updated',
} as const;
export type NotificationType =
(typeof NotificationType)[keyof typeof NotificationType];
export type NotificationSettingKey =
| 'page.updated'
| 'page.userMention'
| 'comment.userMention'
| 'comment.created'
| 'comment.resolved';
export const NotificationTypeToSettingKey: Partial<
Record<NotificationType, NotificationSettingKey>
> = {
[NotificationType.PAGE_UPDATED]: 'page.updated',
[NotificationType.PAGE_USER_MENTION]: 'page.userMention',
[NotificationType.COMMENT_USER_MENTION]: 'comment.userMention',
[NotificationType.COMMENT_CREATED]: 'comment.created',
[NotificationType.COMMENT_RESOLVED]: 'comment.resolved',
};
export type NotificationTab = 'direct' | 'updates' | 'all';
export const DIRECT_NOTIFICATION_TYPES: NotificationType[] = [
NotificationType.COMMENT_USER_MENTION,
NotificationType.COMMENT_CREATED,
NotificationType.COMMENT_RESOLVED,
NotificationType.PAGE_USER_MENTION,
NotificationType.PAGE_PERMISSION_GRANTED,
];
export const UPDATES_NOTIFICATION_TYPES: NotificationType[] = [
NotificationType.PAGE_UPDATED,
];
export function getTypesForTab(tab: NotificationTab): NotificationType[] | undefined {
if (tab === 'direct') return DIRECT_NOTIFICATION_TYPES;
if (tab === 'updates') return UPDATES_NOTIFICATION_TYPES;
return undefined;
}
@@ -9,9 +9,8 @@ import {
import { NotificationService } from './notification.service';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { User } from '@docmost/db/types/entity.types';
import { MarkNotificationsReadDto } from './dto/notification.dto';
import { ListNotificationsDto, MarkNotificationsReadDto } from './dto/notification.dto';
@UseGuards(JwtAuthGuard)
@Controller('notifications')
@@ -21,10 +20,10 @@ export class NotificationController {
@HttpCode(HttpStatus.OK)
@Post('/')
async getNotifications(
@Body() pagination: PaginationOptions,
@Body() dto: ListNotificationsDto,
@AuthUser() user: User,
) {
return this.notificationService.findByUserId(user.id, pagination);
return this.notificationService.findByUserId(user.id, dto, dto.type);
}
@HttpCode(HttpStatus.OK)
@@ -4,6 +4,7 @@ import { NotificationController } from './notification.controller';
import { NotificationProcessor } from './notification.processor';
import { CommentNotificationService } from './services/comment.notification';
import { PageNotificationService } from './services/page.notification';
import { PageUpdateEmailRateLimiter } from './services/page-update-email-rate-limiter';
@Module({
imports: [],
@@ -13,6 +14,7 @@ import { PageNotificationService } from './services/page.notification';
NotificationProcessor,
CommentNotificationService,
PageNotificationService,
PageUpdateEmailRateLimiter,
],
exports: [NotificationService],
})
@@ -8,6 +8,7 @@ import {
ICommentNotificationJob,
ICommentResolvedNotificationJob,
IPageMentionNotificationJob,
IPageUpdateNotificationJob,
IPermissionGrantedNotificationJob,
} from '../../integrations/queue/constants/queue.interface';
import { CommentNotificationService } from './services/comment.notification';
@@ -35,6 +36,7 @@ export class NotificationProcessor
| ICommentNotificationJob
| ICommentResolvedNotificationJob
| IPageMentionNotificationJob
| IPageUpdateNotificationJob
| IPermissionGrantedNotificationJob,
void
>,
@@ -76,6 +78,20 @@ export class NotificationProcessor
break;
}
case QueueJob.PAGE_UPDATED: {
await this.pageNotificationService.processPageUpdate(
job.data as IPageUpdateNotificationJob,
appUrl,
);
break;
}
case QueueJob.PAGE_UPDATE_DIGEST: {
const { userId } = job.data as unknown as { userId: string };
await this.pageNotificationService.processDigest(userId, appUrl);
break;
}
default:
this.logger.warn(`Unknown notification job: ${job.name}`);
}
@@ -6,6 +6,8 @@ import { InsertableNotification } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import { WsGateway } from '../../ws/ws.gateway';
import { MailService } from '../../integrations/mail/mail.service';
import { NotificationTab, NotificationType, NotificationTypeToSettingKey } from './notification.constants';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
@Injectable()
export class NotificationService {
@@ -13,12 +15,23 @@ export class NotificationService {
constructor(
private readonly notificationRepo: NotificationRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly wsGateway: WsGateway,
private readonly mailService: MailService,
@InjectKysely() private readonly db: KyselyDB,
) {}
async create(data: InsertableNotification) {
const user = await this.db
.selectFrom('users')
.select(['id'])
.where('id', '=', data.userId)
.where('deletedAt', 'is', null)
.where('deactivatedAt', 'is', null)
.executeTakeFirst();
if (!user) return null;
const notification = await this.notificationRepo.insert(data);
this.wsGateway.server
@@ -28,8 +41,35 @@ export class NotificationService {
return notification;
}
async findByUserId(userId: string, pagination: PaginationOptions) {
return this.notificationRepo.findByUserId(userId, pagination);
async findByUserId(
userId: string,
pagination: PaginationOptions,
type: NotificationTab = 'all',
) {
const result = await this.notificationRepo.findByUserId(
userId,
pagination,
type,
);
const pageIds = result.items
.map((n: any) => n.pageId)
.filter(Boolean);
if (pageIds.length > 0) {
const accessiblePageIds =
await this.pagePermissionRepo.filterAccessiblePageIds({
pageIds,
userId,
});
const accessibleSet = new Set(accessiblePageIds);
result.items = result.items.filter(
(n: any) => !n.pageId || accessibleSet.has(n.pageId),
);
}
return result;
}
async getUnreadCount(userId: string) {
@@ -53,17 +93,27 @@ export class NotificationService {
notificationId: string,
subject: string,
template: any,
type?: NotificationType,
) {
try {
const user = await this.db
.selectFrom('users')
.select(['email'])
.select(['email', 'settings'])
.where('id', '=', userId)
.where('deletedAt', 'is', null)
.where('deactivatedAt', 'is', null)
.executeTakeFirst();
if (!user?.email) return;
if (type) {
const settingKey = NotificationTypeToSettingKey[type];
if (settingKey) {
const settings = user.settings as any;
if (settings?.notifications?.[settingKey] === false) return;
}
}
await this.mailService.sendToQueue({
to: user.email,
subject,
@@ -86,12 +86,14 @@ export class CommentNotificationService {
spaceId,
commentId,
});
if (!notification) continue;
await this.notificationService.queueEmail(
userId,
notification.id,
`${actor.name} mentioned you in a comment`,
CommentMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
NotificationType.COMMENT_USER_MENTION,
);
notifiedUserIds.add(userId);
@@ -110,12 +112,14 @@ export class CommentNotificationService {
spaceId,
commentId,
});
if (!notification) continue;
await this.notificationService.queueEmail(
recipientId,
notification.id,
`${actor.name} commented on ${pageTitle}`,
CommentCreateEmail({ actorName: actor.name, pageTitle, pageUrl }),
NotificationType.COMMENT_CREATED,
);
}
}
@@ -171,6 +175,7 @@ export class CommentNotificationService {
spaceId,
commentId,
});
if (!notification) return;
const subject = `${actor.name} resolved a comment on ${pageTitle}`;
@@ -179,6 +184,7 @@ export class CommentNotificationService {
notification.id,
subject,
CommentResolvedEmail({ actorName: actor.name, pageTitle, pageUrl }),
NotificationType.COMMENT_RESOLVED,
);
}
@@ -0,0 +1,43 @@
import { Injectable } from '@nestjs/common';
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
import type { Redis } from 'ioredis';
const KEY_PREFIX = 'page-update:emails:';
const DIGEST_PREFIX = 'page-update:digest:';
const TTL_SECONDS = 86400; // 24 hours
const MAX_IMMEDIATE_EMAILS = 4;
@Injectable()
export class PageUpdateEmailRateLimiter {
private readonly redis: Redis;
constructor(private readonly redisService: RedisService) {
this.redis = this.redisService.getOrThrow();
}
async canSendEmail(userId: string): Promise<boolean> {
const key = KEY_PREFIX + userId;
const count = await this.redis.incr(key);
await this.redis.expire(key, TTL_SECONDS, 'NX');
return count <= MAX_IMMEDIATE_EMAILS;
}
async addToDigest(userId: string, notificationId: string): Promise<boolean> {
const key = DIGEST_PREFIX + userId;
const len = await this.redis.rpush(key, notificationId);
await this.redis.expire(key, TTL_SECONDS);
return len === 1;
}
async popDigest(userId: string): Promise<string[]> {
const key = DIGEST_PREFIX + userId;
const [ids] = await this.redis
.multi()
.lrange(key, 0, -1)
.del(key)
.exec();
return (ids?.[1] as string[]) ?? [];
}
}
@@ -1,25 +1,43 @@
import { Injectable } from '@nestjs/common';
import { Injectable, Logger } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import {
IPageMentionNotificationJob,
IPageUpdateNotificationJob,
IPermissionGrantedNotificationJob,
} from '../../../integrations/queue/constants/queue.interface';
import { NotificationService } from '../notification.service';
import { NotificationType } from '../notification.constants';
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
import { WatcherRepo } from '@docmost/db/repos/watcher/watcher.repo';
import { PageUpdateEmailRateLimiter } from './page-update-email-rate-limiter';
import { PageMentionEmail } from '@docmost/transactional/emails/page-mention-email';
import { PageUpdateEmail } from '@docmost/transactional/emails/page-update-email';
import { PageUpdateDigestEmail } from '@docmost/transactional/emails/page-update-digest-email';
import { PermissionGrantedEmail } from '@docmost/transactional/emails/permission-granted-email';
import { getPageTitle } from '../../../common/helpers';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
const PAGE_UPDATE_COOLDOWN_HOURS = 7;
const DIGEST_DELAY_MS = 12 * 60 * 60 * 1000; // 12 hours
@Injectable()
export class PageNotificationService {
private readonly logger = new Logger(PageNotificationService.name);
constructor(
@InjectKysely() private readonly db: KyselyDB,
private readonly notificationService: NotificationService,
private readonly notificationRepo: NotificationRepo,
private readonly spaceMemberRepo: SpaceMemberRepo,
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly watcherRepo: WatcherRepo,
private readonly rateLimiter: PageUpdateEmailRateLimiter,
@InjectQueue(QueueName.NOTIFICATION_QUEUE) private notificationQueue: Queue,
) {}
async processPageMention(data: IPageMentionNotificationJob, appUrl: string) {
@@ -41,10 +59,9 @@ export class PageNotificationService {
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(
pageId,
[...usersWithSpaceAccess],
);
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
...usersWithSpaceAccess,
]);
const usersWithAccess = new Set(usersWithPageAccess);
const accessibleMentions = newMentions.filter((m) =>
@@ -97,6 +114,7 @@ export class PageNotificationService {
spaceId,
data: { mentionId },
});
if (!notification) continue;
const pageUrl = `${basePageUrl}`;
const subject = `${actor.name} mentioned you in ${pageTitle}`;
@@ -106,6 +124,7 @@ export class PageNotificationService {
notification.id,
subject,
PageMentionEmail({ actorName: actor.name, pageTitle, pageUrl }),
NotificationType.PAGE_USER_MENTION,
);
}
}
@@ -139,6 +158,7 @@ export class PageNotificationService {
spaceId,
data: { role },
});
if (!notification) continue;
const subject = `${actor.name} gave you ${accessLabel} access to ${pageTitle}`;
@@ -156,6 +176,232 @@ export class PageNotificationService {
}
}
async processPageUpdate(data: IPageUpdateNotificationJob, appUrl: string) {
const { pageId, spaceId, workspaceId, actorIds } = data;
const watcherIds = await this.watcherRepo.getPageWatcherIds(pageId);
if (watcherIds.length === 0) return;
const actorSet = new Set(actorIds);
const candidateIds = watcherIds.filter((id) => !actorSet.has(id));
if (candidateIds.length === 0) return;
const eligibleUsers = await this.getEligiblePageUpdateUsers(candidateIds);
if (eligibleUsers.size === 0) return;
const afterPrefs = [...eligibleUsers.keys()];
const recentlyNotified =
await this.notificationRepo.getRecentlyNotifiedUserIds(
afterPrefs,
pageId,
NotificationType.PAGE_UPDATED,
PAGE_UPDATE_COOLDOWN_HOURS,
);
const afterCooldown = afterPrefs.filter((id) => !recentlyNotified.has(id));
if (afterCooldown.length === 0) return;
const usersWithSpaceAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess(
afterCooldown,
spaceId,
);
const usersWithPageAccess =
await this.pagePermissionRepo.getUserIdsWithPageAccess(pageId, [
...usersWithSpaceAccess,
]);
if (usersWithPageAccess.length === 0) return;
const recipientIds = new Set(usersWithPageAccess);
const actorId = actorIds[0];
const context = await this.getPageContext(actorId, pageId, spaceId, appUrl);
if (!context) return;
const { actor, pageTitle, basePageUrl } = context;
for (const userId of recipientIds) {
const notification = await this.notificationService.create({
userId,
workspaceId,
type: NotificationType.PAGE_UPDATED,
actorId,
pageId,
spaceId,
});
if (!notification) continue;
const canSend = await this.rateLimiter.canSendEmail(userId);
if (canSend) {
await this.notificationService.queueEmail(
userId,
notification.id,
`${actor.name} updated ${pageTitle}`,
PageUpdateEmail({
userName: eligibleUsers.get(userId) ?? '',
actorName: actor.name,
pageTitle,
pageUrl: basePageUrl,
}),
NotificationType.PAGE_UPDATED,
);
} else {
const isFirst = await this.rateLimiter.addToDigest(
userId,
notification.id,
);
if (isFirst) {
await this.scheduleDigest(userId, workspaceId);
}
}
}
}
private async getEligiblePageUpdateUsers(
userIds: string[],
): Promise<Map<string, string>> {
if (userIds.length === 0) return new Map();
const users = await this.db
.selectFrom('users')
.select(['id', 'name', 'settings'])
.where('id', 'in', userIds)
.where('deletedAt', 'is', null)
.where('deactivatedAt', 'is', null)
.execute();
const eligible = new Map<string, string>();
for (const u of users) {
const settings = u.settings as any;
if (settings?.notifications?.['page.updated'] !== false) {
eligible.set(u.id, u.name);
}
}
return eligible;
}
private async scheduleDigest(
userId: string,
workspaceId: string,
): Promise<void> {
await this.notificationQueue
.add(
QueueJob.PAGE_UPDATE_DIGEST,
{ userId, workspaceId },
{ delay: DIGEST_DELAY_MS, removeOnComplete: true },
)
.catch((err) => {
this.logger.error(
`Failed to schedule digest for ${userId}: ${err.message}`,
);
});
}
async processDigest(userId: string, appUrl: string): Promise<void> {
const notificationIds = await this.rateLimiter.popDigest(userId);
if (notificationIds.length === 0) return;
const [user, notifications] = await Promise.all([
this.db
.selectFrom('users')
.select(['id', 'name'])
.where('id', '=', userId)
.executeTakeFirst(),
this.db
.selectFrom('notifications')
.select(['id', 'pageId', 'actorId'])
.where('id', 'in', notificationIds)
.execute(),
]);
if (!user || notifications.length === 0) return;
const pageIds = [
...new Set(notifications.map((n) => n.pageId).filter(Boolean)),
];
const actorIds = [
...new Set(notifications.map((n) => n.actorId).filter(Boolean)),
];
const allPages = await this.db
.selectFrom('pages')
.innerJoin('spaces', 'spaces.id', 'pages.spaceId')
.select([
'pages.id',
'pages.title',
'pages.slugId',
'pages.spaceId',
'spaces.slug as spaceSlug',
])
.where('pages.id', 'in', pageIds)
.execute();
if (allPages.length === 0) return;
const spaceIds = [...new Set(allPages.map((p) => p.spaceId))];
const accessibleSpaceIds = new Set<string>();
for (const spaceId of spaceIds) {
const usersWithAccess =
await this.spaceMemberRepo.getUserIdsWithSpaceAccess([userId], spaceId);
if (usersWithAccess.has(userId)) accessibleSpaceIds.add(spaceId);
}
const spaceFilteredPages = allPages.filter((p) =>
accessibleSpaceIds.has(p.spaceId),
);
if (spaceFilteredPages.length === 0) return;
const accessiblePageIds = new Set<string>();
for (const p of spaceFilteredPages) {
const hasAccess = await this.pagePermissionRepo.getUserIdsWithPageAccess(
p.id,
[userId],
);
if (hasAccess.includes(userId)) accessiblePageIds.add(p.id);
}
const pages = spaceFilteredPages.filter((p) => accessiblePageIds.has(p.id));
if (pages.length === 0) return;
const actors = actorIds.length > 0
? await this.db
.selectFrom('users')
.select(['id', 'name'])
.where('id', 'in', actorIds)
.execute()
: [];
const actorMap = new Map(actors.map((a) => [a.id, a.name]));
const pageActors = new Map<string, Set<string>>();
for (const n of notifications) {
if (!n.pageId || !n.actorId) continue;
const names = pageActors.get(n.pageId) ?? new Set();
const name = actorMap.get(n.actorId);
if (name) names.add(name);
pageActors.set(n.pageId, names);
}
const pageUpdates = pages.map((p) => ({
title: getPageTitle(p.title),
url: `${appUrl}/s/${p.spaceSlug}/p/${p.slugId}`,
updatedBy: [...(pageActors.get(p.id) ?? [])],
}));
await this.notificationService.queueEmail(
userId,
notificationIds[0],
`Your digest: ${pageUpdates.length} page ${pageUpdates.length === 1 ? 'update' : 'updates'}`,
PageUpdateDigestEmail({
userName: user.name,
pageUpdates,
totalUpdates: pageUpdates.length,
}),
NotificationType.PAGE_UPDATED,
);
}
private async getPageContext(
actorId: string,
pageId: string,
@@ -6,12 +6,14 @@ import {
SpaceCaslAction,
SpaceCaslSubject,
} from '../../casl/interfaces/space-ability.type';
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
@Injectable()
export class PageAccessService {
constructor(
private readonly pagePermissionRepo: PagePermissionRepo,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly spaceRepo: SpaceRepo,
) {}
/**
@@ -99,4 +101,25 @@ export class PageAccessService {
return { hasRestriction: hasAnyRestriction };
}
async validateCanComment(
page: Page,
user: User,
workspaceId: string,
): Promise<void> {
try {
await this.validateCanEdit(page, user);
return;
} catch {
// User cannot edit — check if reader commenting is enabled
}
await this.validateCanView(page, user);
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
const settings = space?.settings as Record<string, any> | null;
if (!settings?.comments?.allowViewerComments) {
throw new ForbiddenException();
}
}
}

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