diff --git a/src/frontend/package-lock.json b/src/frontend/package-lock.json index 1c67eab9f..67cbe9329 100644 --- a/src/frontend/package-lock.json +++ b/src/frontend/package-lock.json @@ -8,9 +8,9 @@ "name": "st-messages", "version": "0.5.0", "dependencies": { - "@blocknote/core": "0.46.2", - "@blocknote/mantine": "0.46.2", - "@blocknote/react": "0.46.2", + "@blocknote/core": "0.49.0", + "@blocknote/mantine": "0.49.0", + "@blocknote/react": "0.49.0", "@gouvfr-lasuite/cunningham-react": "4.3.0", "@gouvfr-lasuite/drive-sdk": "0.0.1", "@gouvfr-lasuite/ui-kit": "0.20.0", @@ -473,21 +473,20 @@ } }, "node_modules/@blocknote/core": { - "version": "0.46.2", - "resolved": "https://registry.npmjs.org/@blocknote/core/-/core-0.46.2.tgz", - "integrity": "sha512-p+QFwrTQfQC08f7JoFg9uuwbAUkGhRakCXhHhY63Wd8ARS0ktHfc9eUnJderXQTEtWfHjACjZOyt2FifX6Lalw==", + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@blocknote/core/-/core-0.49.0.tgz", + "integrity": "sha512-WrkJ9DubvjfMP7+bfrKfp4Oe1SSYpVhojqSORHqh1IjU/qx7CWhwG62k2yyAJdr9K63aoVnS9c6p+xxbuW0PWA==", "license": "MPL-2.0", "dependencies": { "@emoji-mart/data": "^1.2.1", "@handlewithcare/prosemirror-inputrules": "^0.1.4", - "@shikijs/types": "^3", + "@shikijs/types": "^4", "@tanstack/store": "^0.7.7", "@tiptap/core": "^3.13.0", "@tiptap/extension-bold": "^3.13.0", "@tiptap/extension-code": "^3.13.0", "@tiptap/extension-horizontal-rule": "^3.13.0", "@tiptap/extension-italic": "^3.13.0", - "@tiptap/extension-link": "^3.13.0", "@tiptap/extension-paragraph": "^3.13.0", "@tiptap/extension-strike": "^3.13.0", "@tiptap/extension-text": "^3.13.0", @@ -497,12 +496,12 @@ "emoji-mart": "^5.6.0", "fast-deep-equal": "^3.1.3", "hast-util-from-dom": "^5.0.1", - "prosemirror-dropcursor": "^1.8.2", - "prosemirror-highlight": "^0.13.0", + "lib0": "^0.2.99", + "prosemirror-highlight": "^0.15.1", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-tables": "^1.8.3", - "prosemirror-transform": "^1.10.5", + "prosemirror-transform": "^1.11.0", "prosemirror-view": "^1.41.4", "rehype-format": "^5.0.1", "rehype-parse": "^9.0.1", @@ -514,28 +513,19 @@ "remark-stringify": "^11.0.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", - "uuid": "^8.3.2", "y-prosemirror": "^1.3.7", "y-protocols": "^1.0.6", "yjs": "^13.6.27" - }, - "peerDependencies": { - "@hocuspocus/provider": "^2.15.2 || ^3.0.0" - }, - "peerDependenciesMeta": { - "@hocuspocus/provider": { - "optional": true - } } }, "node_modules/@blocknote/mantine": { - "version": "0.46.2", - "resolved": "https://registry.npmjs.org/@blocknote/mantine/-/mantine-0.46.2.tgz", - "integrity": "sha512-2/A82VIby8NNuQbJrXZURnGsksVMWiGWtUOfhvaawCTiB2thYDOV1XONFF1G4xZ2UreodOKLUTwhLm3u25lGrw==", + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@blocknote/mantine/-/mantine-0.49.0.tgz", + "integrity": "sha512-MdR6WHk1yJsemhWw3d8ZDiuIigiit7DhBvbtG0XSceBU01jWJca5OYq/W4QMNS9aDEOML83a0sn7aU9dRhp8MQ==", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "0.46.2", - "@blocknote/react": "0.46.2", + "@blocknote/core": "0.49.0", + "@blocknote/react": "0.49.0", "react-icons": "^5.5.0" }, "peerDependencies": { @@ -547,15 +537,15 @@ } }, "node_modules/@blocknote/react": { - "version": "0.46.2", - "resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.46.2.tgz", - "integrity": "sha512-2cl7MkOSa6Wxun5LFTT0BCeYq3Qw9DCw6VZ9qdtflrnsefds0cDx/SHMVEQygYwF5MhlDmSbIt61hkQ4j2QWSw==", + "version": "0.49.0", + "resolved": "https://registry.npmjs.org/@blocknote/react/-/react-0.49.0.tgz", + "integrity": "sha512-yqThZhMuxbyejWXWc4/gIRiOzNnhtZeoQqlqwGRwexPuSeZLYV/HvmquN98KAiBCYn2ORN5k37O5VerKG2dfcw==", "license": "MPL-2.0", "dependencies": { - "@blocknote/core": "0.46.2", + "@blocknote/core": "0.49.0", "@emoji-mart/data": "^1.2.1", - "@floating-ui/react": "^0.27.16", - "@floating-ui/utils": "0.2.10", + "@floating-ui/react": "^0.27.18", + "@floating-ui/utils": "^0.2.10", "@tanstack/react-store": "0.7.7", "@tiptap/core": "^3.13.0", "@tiptap/pm": "^3.13.0", @@ -1369,26 +1359,32 @@ "license": "MIT" }, "node_modules/@floating-ui/core": { - "version": "1.7.3", + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", "license": "MIT", "dependencies": { - "@floating-ui/utils": "^0.2.10" + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/dom": { - "version": "1.7.4", + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", "license": "MIT", "dependencies": { - "@floating-ui/core": "^1.7.3", - "@floating-ui/utils": "^0.2.10" + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" } }, "node_modules/@floating-ui/react": { - "version": "0.27.16", + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", "license": "MIT", "dependencies": { - "@floating-ui/react-dom": "^2.1.6", - "@floating-ui/utils": "^0.2.10", + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", "tabbable": "^6.0.0" }, "peerDependencies": { @@ -1397,10 +1393,12 @@ } }, "node_modules/@floating-ui/react-dom": { - "version": "2.1.6", + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", "license": "MIT", "dependencies": { - "@floating-ui/dom": "^1.7.4" + "@floating-ui/dom": "^1.7.6" }, "peerDependencies": { "react": ">=16.8.0", @@ -1408,7 +1406,9 @@ } }, "node_modules/@floating-ui/utils": { - "version": "0.2.10", + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", "license": "MIT" }, "node_modules/@fontsource-variable/roboto-flex": { @@ -2895,6 +2895,8 @@ }, "node_modules/@mantine/utils": { "version": "6.0.22", + "resolved": "https://registry.npmjs.org/@mantine/utils/-/utils-6.0.22.tgz", + "integrity": "sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ==", "license": "MIT", "peer": true, "peerDependencies": { @@ -6575,10 +6577,6 @@ "react": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, - "node_modules/@remirror/core-constants": { - "version": "3.0.0", - "license": "MIT" - }, "node_modules/@rollup/plugin-commonjs": { "version": "28.0.1", "license": "MIT", @@ -7588,11 +7586,16 @@ } }, "node_modules/@shikijs/types": { - "version": "3.2.1", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", + "integrity": "sha512-qzbeRooUTPnLE+sHD/Z8DStmaDgnbbc/pMrU203950aRqjX/6AFHeDYT+j00y2lPdz0ywJKx7o/7qnqTivtlXg==", "license": "MIT", "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" + }, + "engines": { + "node": ">=20" } }, "node_modules/@shikijs/vscode-textmate": { @@ -8378,6 +8381,8 @@ }, "node_modules/@tanstack/react-store": { "version": "0.7.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.7.7.tgz", + "integrity": "sha512-qqT0ufegFRDGSof9D/VqaZgjNgp4tRPHZIJq2+QIHkMUtHjaJ0lYrrXjeIUJvjnTbgPfSD1XgOMEt0lmANn6Zg==", "license": "MIT", "dependencies": { "@tanstack/store": "0.7.7", @@ -8430,16 +8435,16 @@ } }, "node_modules/@tiptap/core": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.18.0.tgz", - "integrity": "sha512-Gczd4GbK1DNgy/QUPElMVozoa0GW9mW8E31VIi7Q4a9PHHz8PcrxPmuWwtJ2q0PF8MWpOSLuBXoQTWaXZRPRnQ==", + "version": "3.23.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.1.tgz", + "integrity": "sha512-8YvSGiJTeU5wPuGiYIIYgyiyaaT1CAx+kJL0bju0w871OvbJJj0T/ywhcmxGXW6pOal2T8X2xt9ZqE+vib0VJw==", "license": "MIT", "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/pm": "^3.18.0" + "@tiptap/pm": "3.23.1" } }, "node_modules/@tiptap/extension-bold": { @@ -8454,7 +8459,9 @@ } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "3.14.0", + "version": "3.23.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.1.tgz", + "integrity": "sha512-1advMCpPkHD/3ucZhYmNau8B4tF0L6iRAFhUOglp5bBZDuq13+rYujh3cm4vFmjH9KqThzpcUDn+ZU2c+mTMyw==", "license": "MIT", "optional": true, "dependencies": { @@ -8465,8 +8472,8 @@ "url": "https://github.com/sponsors/ueberdosis" }, "peerDependencies": { - "@tiptap/core": "^3.14.0", - "@tiptap/pm": "^3.14.0" + "@tiptap/core": "3.23.1", + "@tiptap/pm": "3.23.1" } }, "node_modules/@tiptap/extension-code": { @@ -8481,7 +8488,9 @@ } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "3.14.0", + "version": "3.23.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.1.tgz", + "integrity": "sha512-XrYHpLn1DpLFSGTko9F9xgbNamL6fGpWkK4wqgwPVbg/SJwQCDO/9p5D3DtJTwD+xgw4sQ9as4O6rt6jx8JT+Q==", "license": "MIT", "optional": true, "funding": { @@ -8490,8 +8499,8 @@ }, "peerDependencies": { "@floating-ui/dom": "^1.0.0", - "@tiptap/core": "^3.14.0", - "@tiptap/pm": "^3.14.0" + "@tiptap/core": "3.23.1", + "@tiptap/pm": "3.23.1" } }, "node_modules/@tiptap/extension-horizontal-rule": { @@ -8517,21 +8526,6 @@ "@tiptap/core": "^3.14.0" } }, - "node_modules/@tiptap/extension-link": { - "version": "3.14.0", - "license": "MIT", - "dependencies": { - "linkifyjs": "^4.3.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/ueberdosis" - }, - "peerDependencies": { - "@tiptap/core": "^3.14.0", - "@tiptap/pm": "^3.14.0" - } - }, "node_modules/@tiptap/extension-paragraph": { "version": "3.14.0", "license": "MIT", @@ -8591,27 +8585,21 @@ } }, "node_modules/@tiptap/pm": { - "version": "3.18.0", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.18.0.tgz", - "integrity": "sha512-8RoI5gW0xBVCsuxahpK8vx7onAw6k2/uR3hbGBBnH+HocDMaAZKot3nTyY546ij8ospIC1mnQ7k4BhVUZesZDQ==", + "version": "3.23.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.1.tgz", + "integrity": "sha512-8G+TkNsUHHAAJYREpA6fw+Dw/m2Y3Go4/QMQM8RYepid+wTeE1wSv7sBA/CBrphhYmJSWeTyCPtgQIxnTJXMCA==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.3.0", - "prosemirror-collab": "^1.3.1", "prosemirror-commands": "^1.6.2", "prosemirror-dropcursor": "^1.8.1", "prosemirror-gapcursor": "^1.3.2", "prosemirror-history": "^1.4.1", - "prosemirror-inputrules": "^1.4.0", "prosemirror-keymap": "^1.2.2", - "prosemirror-markdown": "^1.13.1", - "prosemirror-menu": "^1.2.4", "prosemirror-model": "^1.24.1", - "prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-list": "^1.5.0", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", - "prosemirror-trailing-node": "^3.0.0", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.38.1" }, @@ -8621,7 +8609,9 @@ } }, "node_modules/@tiptap/react": { - "version": "3.14.0", + "version": "3.23.1", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.1.tgz", + "integrity": "sha512-43zUwKOcsxRIcgiDbcEUagojhPIez2OIryaNG/uiDcRzkrUteiTu2wSJndkQqwouwh3wJEm+KOw8xybNYvU+qA==", "license": "MIT", "dependencies": { "@types/use-sync-external-store": "^0.0.6", @@ -8633,12 +8623,12 @@ "url": "https://github.com/sponsors/ueberdosis" }, "optionalDependencies": { - "@tiptap/extension-bubble-menu": "^3.14.0", - "@tiptap/extension-floating-menu": "^3.14.0" + "@tiptap/extension-bubble-menu": "^3.23.1", + "@tiptap/extension-floating-menu": "^3.23.1" }, "peerDependencies": { - "@tiptap/core": "^3.14.0", - "@tiptap/pm": "^3.14.0", + "@tiptap/core": "3.23.1", + "@tiptap/pm": "3.23.1", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -8647,6 +8637,8 @@ }, "node_modules/@tiptap/react/node_modules/@types/use-sync-external-store": { "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, "node_modules/@tsconfig/node10": { @@ -8776,18 +8768,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, "node_modules/@types/mdast": { "version": "4.0.4", "license": "MIT", @@ -8795,10 +8775,6 @@ "@types/unist": "*" } }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "license": "MIT" - }, "node_modules/@types/ms": { "version": "2.1.0", "license": "MIT" @@ -8891,6 +8867,8 @@ }, "node_modules/@types/use-sync-external-store": { "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==", "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { @@ -9911,6 +9889,7 @@ }, "node_modules/argparse": { "version": "2.0.1", + "dev": true, "license": "Python-2.0" }, "node_modules/aria-hidden": { @@ -10743,10 +10722,6 @@ "version": "1.1.1", "license": "MIT" }, - "node_modules/crelt": { - "version": "1.0.6", - "license": "MIT" - }, "node_modules/cross-fetch": { "version": "4.0.0", "license": "MIT", @@ -11563,6 +11538,7 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", + "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -12079,6 +12055,8 @@ }, "node_modules/fast-equals": { "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", "license": "MIT", "engines": { "node": ">=6.0.0" @@ -14526,15 +14504,12 @@ }, "node_modules/linkify-it": { "version": "5.0.0", + "dev": true, "license": "MIT", "dependencies": { "uc.micro": "^2.0.0" } }, - "node_modules/linkifyjs": { - "version": "4.3.2", - "license": "MIT" - }, "node_modules/loader-runner": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", @@ -14736,6 +14711,7 @@ }, "node_modules/markdown-it": { "version": "14.1.0", + "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1", @@ -15032,6 +15008,7 @@ }, "node_modules/mdurl": { "version": "2.0.0", + "dev": true, "license": "MIT" }, "node_modules/memoize-one": { @@ -16688,15 +16665,10 @@ "prosemirror-transform": "^1.0.0" } }, - "node_modules/prosemirror-collab": { - "version": "1.3.1", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-commands": { "version": "1.7.1", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz", + "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.0.0", @@ -16724,13 +16696,17 @@ } }, "node_modules/prosemirror-highlight": { - "version": "0.13.0", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/prosemirror-highlight/-/prosemirror-highlight-0.15.1.tgz", + "integrity": "sha512-KcJUGNgqLED+eK/cisNtY3M+eDNLkZyWCdyi7B3RoW3rKHnhkKawnJAcr9p1F/e3q+oDB5Y5OiIrC11bxP7tFA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/ocavue" }, "peerDependencies": { - "@shikijs/types": "^1.29.2 || ^2.0.0 || ^3.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@shikijs/types": "^1.29.2 || ^2.0.0 || ^3.0.0 || ^4.0.0", "@types/hast": "^3.0.0", "highlight.js": "^11.9.0", "lowlight": "^3.1.0", @@ -16739,9 +16715,15 @@ "prosemirror-transform": "^1.8.0", "prosemirror-view": "^1.32.4", "refractor": "^5.0.0", - "sugar-high": "^0.6.1 || ^0.7.0 || ^0.8.0 || ^0.9.0" + "sugar-high": "^0.6.1 || ^0.7.0 || ^0.8.0 || ^0.9.0 || ^1.0.0" }, "peerDependenciesMeta": { + "@lezer/common": { + "optional": true + }, + "@lezer/highlight": { + "optional": true + }, "@shikijs/types": { "optional": true }, @@ -16784,14 +16766,6 @@ "rope-sequence": "^1.3.0" } }, - "node_modules/prosemirror-inputrules": { - "version": "1.5.1", - "license": "MIT", - "dependencies": { - "prosemirror-state": "^1.0.0", - "prosemirror-transform": "^1.0.0" - } - }, "node_modules/prosemirror-keymap": { "version": "1.2.3", "license": "MIT", @@ -16800,25 +16774,6 @@ "w3c-keyname": "^2.2.0" } }, - "node_modules/prosemirror-markdown": { - "version": "1.13.2", - "license": "MIT", - "dependencies": { - "@types/markdown-it": "^14.0.0", - "markdown-it": "^14.0.0", - "prosemirror-model": "^1.25.0" - } - }, - "node_modules/prosemirror-menu": { - "version": "1.2.5", - "license": "MIT", - "dependencies": { - "crelt": "^1.0.0", - "prosemirror-commands": "^1.0.0", - "prosemirror-history": "^1.0.0", - "prosemirror-state": "^1.0.0" - } - }, "node_modules/prosemirror-model": { "version": "1.25.4", "license": "MIT", @@ -16826,13 +16781,6 @@ "orderedmap": "^2.0.0" } }, - "node_modules/prosemirror-schema-basic": { - "version": "1.2.4", - "license": "MIT", - "dependencies": { - "prosemirror-model": "^1.25.0" - } - }, "node_modules/prosemirror-schema-list": { "version": "1.5.1", "license": "MIT", @@ -16862,21 +16810,10 @@ "prosemirror-view": "^1.41.4" } }, - "node_modules/prosemirror-trailing-node": { - "version": "3.0.0", - "license": "MIT", - "dependencies": { - "@remirror/core-constants": "3.0.0", - "escape-string-regexp": "^4.0.0" - }, - "peerDependencies": { - "prosemirror-model": "^1.22.1", - "prosemirror-state": "^1.4.2", - "prosemirror-view": "^1.33.8" - } - }, "node_modules/prosemirror-transform": { - "version": "1.10.5", + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz", + "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.21.0" @@ -16907,6 +16844,7 @@ }, "node_modules/punycode.js": { "version": "2.3.1", + "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -17298,7 +17236,9 @@ } }, "node_modules/react-icons": { - "version": "5.5.0", + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz", + "integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==", "license": "MIT", "peerDependencies": { "react": "*" @@ -19465,6 +19405,7 @@ }, "node_modules/uc.micro": { "version": "2.1.0", + "dev": true, "license": "MIT" }, "node_modules/uint8array-extras": { @@ -19844,13 +19785,6 @@ "node": ">= 4" } }, - "node_modules/uuid": { - "version": "8.3.2", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "license": "MIT" diff --git a/src/frontend/package.json b/src/frontend/package.json index 58a3c7345..4fccd4565 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -21,9 +21,9 @@ "i18n:extract": "i18next-cli extract" }, "dependencies": { - "@blocknote/core": "0.46.2", - "@blocknote/mantine": "0.46.2", - "@blocknote/react": "0.46.2", + "@blocknote/core": "0.49.0", + "@blocknote/mantine": "0.49.0", + "@blocknote/react": "0.49.0", "@gouvfr-lasuite/drive-sdk": "0.0.1", "@gouvfr-lasuite/ui-kit": "0.20.0", "@hookform/resolvers": "5.2.2", diff --git a/src/frontend/src/features/blocknote/__tests__/block-factories.ts b/src/frontend/src/features/blocknote/__tests__/block-factories.ts new file mode 100644 index 000000000..609186bff --- /dev/null +++ b/src/frontend/src/features/blocknote/__tests__/block-factories.ts @@ -0,0 +1,252 @@ +import type { Block, InlineContent, StyledText } from '@blocknote/core'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyBlock = Block; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyStyledText = StyledText; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AnyInlineContent = InlineContent; + +export function styledText( + text: string, + styles: Record = {}, +): AnyStyledText { + return { type: 'text', text, styles } as AnyStyledText; +} + +export function link(href: string, text: string): AnyInlineContent { + return { + type: 'link', + href, + content: [styledText(text)], + } as unknown as AnyInlineContent; +} + +export function templateVariable(value: string, label?: string): AnyInlineContent { + return { + type: 'template-variable', + props: { value, label: label ?? value }, + } as unknown as AnyInlineContent; +} + +export function paragraph( + content: AnyInlineContent[] | string, + props: Record = {}, + children: AnyBlock[] = [], +): AnyBlock { + const inlineContent = + typeof content === 'string' ? [styledText(content)] : content; + return { + id: crypto.randomUUID(), + type: 'paragraph', + props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, + content: inlineContent, + children, + } as AnyBlock; +} + +export function heading( + content: AnyInlineContent[] | string, + level: number, + props: Record = {}, + children: AnyBlock[] = [], +): AnyBlock { + const inlineContent = + typeof content === 'string' ? [styledText(content)] : content; + return { + id: crypto.randomUUID(), + type: 'heading', + props: { level, textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, + content: inlineContent, + children, + } as AnyBlock; +} + +export function image( + url: string, + props: Record = {}, +): AnyBlock { + return { + id: crypto.randomUUID(), + type: 'image', + props: { url, caption: '', name: '', textAlignment: 'left', ...props }, + content: undefined, + children: [], + } as unknown as AnyBlock; +} + +export function bulletListItem( + content: AnyInlineContent[] | string, + props: Record = {}, + children: AnyBlock[] = [], +): AnyBlock { + const inlineContent = + typeof content === 'string' ? [styledText(content)] : content; + return { + id: crypto.randomUUID(), + type: 'bulletListItem', + props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, + content: inlineContent, + children, + } as AnyBlock; +} + +export function numberedListItem( + content: AnyInlineContent[] | string, + props: Record = {}, + children: AnyBlock[] = [], +): AnyBlock { + const inlineContent = + typeof content === 'string' ? [styledText(content)] : content; + return { + id: crypto.randomUUID(), + type: 'numberedListItem', + props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, + content: inlineContent, + children, + } as AnyBlock; +} + +export function checkListItem( + content: AnyInlineContent[] | string, + checked: boolean, + props: Record = {}, + children: AnyBlock[] = [], +): AnyBlock { + const inlineContent = + typeof content === 'string' ? [styledText(content)] : content; + return { + id: crypto.randomUUID(), + type: 'checkListItem', + props: { checked, textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, + content: inlineContent, + children, + } as AnyBlock; +} + +export function codeBlock( + content: AnyInlineContent[] | string, +): AnyBlock { + const inlineContent = + typeof content === 'string' ? [styledText(content)] : content; + return { + id: crypto.randomUUID(), + type: 'codeBlock', + props: {}, + content: inlineContent, + children: [], + } as AnyBlock; +} + +export function quote( + content: AnyInlineContent[] | string, + props: Record = {}, + children: AnyBlock[] = [], +): AnyBlock { + const inlineContent = + typeof content === 'string' ? [styledText(content)] : content; + return { + id: crypto.randomUUID(), + type: 'quote', + props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, + content: inlineContent, + children, + } as AnyBlock; +} + +export function divider(): AnyBlock { + return { + id: crypto.randomUUID(), + type: 'divider', + props: {}, + content: undefined, + children: [], + } as unknown as AnyBlock; +} + +export function block( + type: string, + content?: AnyInlineContent[] | string, + props: Record = {}, +): AnyBlock { + const inlineContent = + content === undefined + ? undefined + : typeof content === 'string' + ? [styledText(content)] + : content; + return { + id: crypto.randomUUID(), + type, + props, + content: inlineContent, + children: [], + } as unknown as AnyBlock; +} + +export function column( + children: AnyBlock[], + width: number = 1, +): AnyBlock { + return { + id: crypto.randomUUID(), + type: 'column', + props: { width }, + content: undefined, + children, + } as unknown as AnyBlock; +} + +export function columnList( + columns: AnyBlock[], +): AnyBlock { + return { + id: crypto.randomUUID(), + type: 'columnList', + props: {}, + content: undefined, + children: columns, + } as unknown as AnyBlock; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type TableCellShape = AnyInlineContent[] | { type: 'tableCell'; content: AnyInlineContent[]; props?: Record }; + +export function tableCell( + content: string | AnyInlineContent[], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props: Record = {}, +): TableCellShape { + const inlineContent = + typeof content === 'string' ? [styledText(content)] : content; + return { + type: 'tableCell', + content: inlineContent, + props, + }; +} + +export function table( + rows: TableCellShape[][], + options: { + columnWidths?: (number | undefined)[]; + headerRows?: number; + headerCols?: number; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + props?: Record; + } = {}, +): AnyBlock { + return { + id: crypto.randomUUID(), + type: 'table', + props: { textColor: 'default', ...(options.props || {}) }, + content: { + type: 'tableContent', + columnWidths: options.columnWidths || rows[0]?.map(() => undefined) || [], + headerRows: options.headerRows, + headerCols: options.headerCols, + rows: rows.map((cells) => ({ cells })), + }, + children: [], + } as unknown as AnyBlock; +} diff --git a/src/frontend/src/features/blocknote/blocknote-view-field/_index.scss b/src/frontend/src/features/blocknote/blocknote-view-field/_index.scss index b77a9c1ce..ee909905c 100644 --- a/src/frontend/src/features/blocknote/blocknote-view-field/_index.scss +++ b/src/frontend/src/features/blocknote/blocknote-view-field/_index.scss @@ -47,88 +47,94 @@ color: var(--c--components--forms-input--value-color--disabled); } } -} -.composer-field-input { - --min-height: 20rem; - --toolbar-height: 43px; - border-radius: var(--c--components--forms-input--border-radius); - border-width: var(--c--components--forms-input--border-width); - border-color: var(--c--components--forms-input--border-color); - border-style: var(--c--components--forms-input--border-style); - width: 100%; - display: flex; - flex-direction: column-reverse; - gap: var(--c--globals--spacings--2xs); - background-color: var(--bn-colors-editor-background); - position: relative; - min-height: var(--min-height); - transition: - border var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out), - border-radius var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out); - & .bn-toolbar.bn-formatting-toolbar { - background: none; - box-shadow: none; - border: none; - border-radius: 0; - border-top-left-radius: var(--c--components--forms-input--border-radius); - border-top-right-radius: var(--c--components--forms-input--border-radius); + // Nested under `.composer-field` so the rule does NOT apply to the + // detached portal `
` BlockNote >=0.48 appends to `document.body` + // (it carries the same `composer-field-input` class but has no parent + // `.composer-field`). Without this scope, the portal renders as an + // empty ~320px bordered block at the bottom of the page. + .composer-field-input { + --min-height: 20rem; + --toolbar-height: 43px; + border-radius: var(--c--components--forms-input--border-radius); + border-width: var(--c--components--forms-input--border-width); + border-color: var(--c--components--forms-input--border-color); + border-style: var(--c--components--forms-input--border-style); width: 100%; - border-bottom: 1px solid var(--c--components--forms-input--border-color); - padding-block: var(--c--globals--spacings--2xs); - flex-wrap: wrap; - flex-shrink: 0; - min-height: var(--toolbar-height); + display: flex; + flex-direction: column-reverse; + gap: var(--c--globals--spacings--2xs); + background-color: var(--bn-colors-editor-background); + position: relative; + min-height: var(--min-height); + transition: + border var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out), + border-radius var(--c--globals--transitions--duration) var(--c--globals--transitions--ease-out); - // Override the max-height set by the Floating UI `size` middleware - // on toolbar select dropdowns. Without portal rendering, the dropdown - // is constrained to the available space inside the toolbar container, - // which becomes too small when the toolbar wraps onto multiple lines. - .mantine-Menu-dropdown { - max-height: none !important; - } + & .bn-toolbar.bn-formatting-toolbar { + background: none; + box-shadow: none; + border: none; + border-radius: 0; + border-top-left-radius: var(--c--components--forms-input--border-radius); + border-top-right-radius: var(--c--components--forms-input--border-radius); + width: 100%; + border-bottom: 1px solid var(--c--components--forms-input--border-color); + padding-block: var(--c--globals--spacings--2xs); + flex-wrap: wrap; + flex-shrink: 0; + min-height: var(--toolbar-height); + + // Override the max-height set by the Floating UI `size` middleware + // on toolbar select dropdowns. Without portal rendering, the dropdown + // is constrained to the available space inside the toolbar container, + // which becomes too small when the toolbar wraps onto multiple lines. + .mantine-Menu-dropdown { + max-height: none !important; + } - .bn-toolbar-separator { - width: 1px; - align-self: stretch; - margin-block: var(--c--globals--spacings--3xs); - background-color: var(--c--components--forms-input--border-color); + .bn-toolbar-separator { + width: 1px; + align-self: stretch; + margin-block: var(--c--globals--spacings--3xs); + background-color: var(--c--components--forms-input--border-color); - // Hide separators without a visible neighbor: - // - at the start of the toolbar (no element before) - // - at the end of the toolbar (no element after) - // - consecutive (all elements between them are absent from the DOM) - &:first-child, - &:last-child, - & + .bn-toolbar-separator { - display: none; + // Hide separators without a visible neighbor: + // - at the start of the toolbar (no element before) + // - at the end of the toolbar (no element after) + // - consecutive (all elements between them are absent from the DOM) + &:first-child, + &:last-child, + & + .bn-toolbar-separator { + display: none; + } } } - } - & .bn-editor { - --max-block-width: 85ch; - // Even in dark mode, the editor stays light so we used global tokens color - color: var(--c--globals--colors--gray-850); - font-size: var(--c--globals--font--sizes--sm); - font-family: var(--c--globals--font--families--base); - border-radius: var(--c--components--forms-input--border-radius); - min-height: calc(100% - var(--toolbar-height)); - padding-inline: var(--c--globals--spacings--base); - // Extra left padding to make room for the side menu drag handle - padding-left: calc(var(--c--globals--spacings--base) + 8px); - flex: 1; - overflow: visible; + & .bn-editor { + --max-block-width: 85ch; + // Even in dark mode, the editor stays light so we used global tokens color + color: var(--c--globals--colors--gray-850); + font-size: var(--c--globals--font--sizes--sm); + font-family: var(--c--globals--font--families--base); + border-radius: var(--c--components--forms-input--border-radius); + min-height: calc(100% - var(--toolbar-height)); + padding-inline: var(--c--globals--spacings--base); + // Extra left padding to make room for the side menu drag handle + padding-left: calc(var(--c--globals--spacings--base) + 8px); + flex: 1; + overflow: visible; - // Overide blocknote default styles to fix some contrast issues - blockquote { - color: var(--c--globals--colors--gray-550); - border-color: var(--c--globals--colors--gray-550); + // Overide blocknote default styles to fix some contrast issues + blockquote { + color: var(--c--globals--colors--gray-550); + border-color: var(--c--globals--colors--gray-550); + } } - } - .tiptap > .bn-block-group { - min-height: calc(var(--min-height) - var(--toolbar-height)); - max-width: var(--max-block-width); + .tiptap > .bn-block-group { + min-height: calc(var(--min-height) - var(--toolbar-height)); + max-width: var(--max-block-width); + } } } diff --git a/src/frontend/src/features/blocknote/email-exporter/index.test.tsx b/src/frontend/src/features/blocknote/email-exporter/index.test.tsx index 785f1d758..bcaa6bf7e 100644 --- a/src/frontend/src/features/blocknote/email-exporter/index.test.tsx +++ b/src/frontend/src/features/blocknote/email-exporter/index.test.tsx @@ -1,6 +1,26 @@ import { vi } from 'vitest'; -import type { Block, InlineContent, StyledText } from '@blocknote/core'; import { EmailExporter } from './index'; +import { + AnyBlock, + AnyInlineContent, + block, + bulletListItem, + checkListItem, + codeBlock, + column, + columnList, + divider, + heading, + image, + link, + numberedListItem, + paragraph, + quote, + styledText, + table, + tableCell, + templateVariable, +} from '../__tests__/block-factories'; vi.mock('@/features/utils/mail-helper', () => ({ default: { @@ -8,211 +28,6 @@ vi.mock('@/features/utils/mail-helper', () => ({ }, })); -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyBlock = Block; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyStyledText = StyledText; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyInlineContent = InlineContent; - -// --------------------------------------------------------------------------- -// Block factories -// --------------------------------------------------------------------------- - -function styledText( - text: string, - styles: Record = {}, -): AnyStyledText { - return { type: 'text', text, styles } as AnyStyledText; -} - -function link(href: string, text: string): AnyInlineContent { - return { - type: 'link', - href, - content: [styledText(text)], - } as unknown as AnyInlineContent; -} - -function paragraph( - content: AnyInlineContent[] | string, - props: Record = {}, - children: AnyBlock[] = [], -): AnyBlock { - const inlineContent = - typeof content === 'string' ? [styledText(content)] : content; - return { - id: crypto.randomUUID(), - type: 'paragraph', - props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, - content: inlineContent, - children, - } as AnyBlock; -} - -function heading( - content: AnyInlineContent[] | string, - level: number, - props: Record = {}, - children: AnyBlock[] = [], -): AnyBlock { - const inlineContent = - typeof content === 'string' ? [styledText(content)] : content; - return { - id: crypto.randomUUID(), - type: 'heading', - props: { level, textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, - content: inlineContent, - children, - } as AnyBlock; -} - -function image( - url: string, - props: Record = {}, -): AnyBlock { - return { - id: crypto.randomUUID(), - type: 'image', - props: { url, caption: '', name: '', textAlignment: 'left', ...props }, - content: undefined, - children: [], - } as unknown as AnyBlock; -} - -function bulletListItem( - content: AnyInlineContent[] | string, - props: Record = {}, - children: AnyBlock[] = [], -): AnyBlock { - const inlineContent = - typeof content === 'string' ? [styledText(content)] : content; - return { - id: crypto.randomUUID(), - type: 'bulletListItem', - props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, - content: inlineContent, - children, - } as AnyBlock; -} - -function numberedListItem( - content: AnyInlineContent[] | string, - props: Record = {}, - children: AnyBlock[] = [], -): AnyBlock { - const inlineContent = - typeof content === 'string' ? [styledText(content)] : content; - return { - id: crypto.randomUUID(), - type: 'numberedListItem', - props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, - content: inlineContent, - children, - } as AnyBlock; -} - -function checkListItem( - content: AnyInlineContent[] | string, - checked: boolean, - props: Record = {}, - children: AnyBlock[] = [], -): AnyBlock { - const inlineContent = - typeof content === 'string' ? [styledText(content)] : content; - return { - id: crypto.randomUUID(), - type: 'checkListItem', - props: { checked, textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, - content: inlineContent, - children, - } as AnyBlock; -} - -function codeBlock( - content: AnyInlineContent[] | string, -): AnyBlock { - const inlineContent = - typeof content === 'string' ? [styledText(content)] : content; - return { - id: crypto.randomUUID(), - type: 'codeBlock', - props: {}, - content: inlineContent, - children: [], - } as AnyBlock; -} - -function quote( - content: AnyInlineContent[] | string, - props: Record = {}, -): AnyBlock { - const inlineContent = - typeof content === 'string' ? [styledText(content)] : content; - return { - id: crypto.randomUUID(), - type: 'quote', - props: { textAlignment: 'left', textColor: 'default', backgroundColor: 'default', ...props }, - content: inlineContent, - children: [], - } as AnyBlock; -} - -function divider(): AnyBlock { - return { - id: crypto.randomUUID(), - type: 'divider', - props: {}, - content: undefined, - children: [], - } as unknown as AnyBlock; -} - -function block( - type: string, - content?: AnyInlineContent[] | string, - props: Record = {}, -): AnyBlock { - const inlineContent = - content === undefined - ? undefined - : typeof content === 'string' - ? [styledText(content)] - : content; - return { - id: crypto.randomUUID(), - type, - props, - content: inlineContent, - children: [], - } as unknown as AnyBlock; -} - -function column( - children: AnyBlock[], - width: number = 1, -): AnyBlock { - return { - id: crypto.randomUUID(), - type: 'column', - props: { width }, - content: undefined, - children, - } as unknown as AnyBlock; -} - -function columnList( - columns: AnyBlock[], -): AnyBlock { - return { - id: crypto.randomUUID(), - type: 'columnList', - props: {}, - content: undefined, - children: columns, - } as unknown as AnyBlock; -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -578,23 +393,46 @@ describe('EmailExporter', () => { // 11. Special blocks // ----------------------------------------------------------------------- describe('special blocks', () => { - it('does not render table block', () => { - const html = exportBlocks([ - block('table'), - ]); - expect(html).not.toContain(''); - }); - + // EmailExporter short-circuits signature/quoted-message regardless of their + // `toExternalHTML`: the backend MDA composer embeds the real content. it('renders signature as empty ', () => { const html = exportBlocks([block('signature')]); expect(html).toContain(' { + const html = exportBlocks([ + block('signature', undefined, { + templateId: 'tpl-uuid', + mailboxId: 'mbx-uuid', + messageId: 'msg-uuid', + }), + ]); + expect(html).not.toContain('tpl-uuid'); + expect(html).not.toContain('mbx-uuid'); + expect(html).not.toContain('msg-uuid'); + }); + it('renders quoted-message as empty ', () => { const html = exportBlocks([block('quoted-message')]); expect(html).toContain(' { + const html = exportBlocks([ + block('quoted-message', undefined, { + subject: 'Confidential subject', + sender: 'alice@example.com', + recipients: 'bob@example.com', + received_at: '2025-01-15T10:00:00Z', + textBody: 'Original message body', + }), + ]); + expect(html).not.toContain('Confidential subject'); + expect(html).not.toContain('alice@example.com'); + expect(html).not.toContain('Original message body'); + }); + it('renders unknown block with content as
', () => { const html = exportBlocks([ block('custom-block', 'Some content'), @@ -611,6 +449,38 @@ describe('EmailExporter', () => { }); }); + // ----------------------------------------------------------------------- + // Inline template-variable — InlineTemplateVariable has no toExternalHTML; + // EmailExporter handles it explicitly at index.tsx:156-158. These tests + // pin the current behavior (literal `{var}` output). + // ----------------------------------------------------------------------- + describe('inline template-variable', () => { + it('renders a standalone template-variable as a placeholder span', () => { + const html = exportBlocks([ + paragraph([templateVariable('first_name')]), + ]); + expect(html).toContain('data-inline-content-type="template-variable"'); + expect(html).toContain('{first_name}'); + }); + + it('preserves order and styles around an inline template-variable', () => { + const html = exportBlocks([ + paragraph([ + styledText('Hi '), + templateVariable('first_name'), + styledText(', welcome!', { bold: true }), + ]), + ]); + const hiIdx = html.indexOf('Hi '); + const varIdx = html.indexOf('{first_name}'); + const welcomeIdx = html.indexOf('welcome!'); + expect(hiIdx).toBeGreaterThan(-1); + expect(varIdx).toBeGreaterThan(hiIdx); + expect(welcomeIdx).toBeGreaterThan(varIdx); + expect(html).toContain('font-weight:bold'); + }); + }); + // ----------------------------------------------------------------------- // 12. Column layout // ----------------------------------------------------------------------- @@ -711,6 +581,293 @@ describe('EmailExporter', () => { }); }); + // ----------------------------------------------------------------------- + // 13. Table (paste-only support — see HIDDEN_BLOCK_TYPES) + // ----------------------------------------------------------------------- + describe('table', () => { + it('renders a simple 2x2 table with
, and '); + expect(html).toContain(' { + const html = exportBlocks([ + table([[tableCell('cell')]]), + ]); + expect(html).toContain('border-collapse:collapse'); + expect(html).toContain('word-break:break-word'); + }); + + it('applies cell borders and padding', () => { + const html = exportBlocks([ + table([[tableCell('cell')]]), + ]); + expect(html).toContain('border:1px solid #ddd'); + expect(html).toContain('padding:5px 10px'); + }); + + it('returns null when content has no rows', () => { + const emptyTable = { + id: crypto.randomUUID(), + type: 'table', + props: { textColor: 'default' }, + content: { type: 'tableContent', columnWidths: [], rows: [] }, + children: [], + } as unknown as AnyBlock; + const html = exportBlocks([emptyTable]); + expect(html).not.toContain(' with bold weight', () => { + const html = exportBlocks([ + table( + [ + [tableCell('Header A'), tableCell('Header B')], + [tableCell('Body A'), tableCell('Body B')], + ], + { headerRows: 1 }, + ), + ]); + expect(html).toContain(' + expect(html).toContain(' cells should be bold + expect(html).toMatch(/]*font-weight:bold/); + }); + + it('renders header columns as when columnWidths contains explicit pixel widths', () => { + const html = exportBlocks([ + table( + [ + [tableCell('A'), tableCell('B')], + ], + { columnWidths: [120, 240] }, + ), + ]); + expect(html).toContain(''); + expect(html).toContain('width:120px'); + expect(html).toContain('width:240px'); + }); + + it('does not emit when every width is undefined', () => { + const html = exportBlocks([ + table( + [[tableCell('A'), tableCell('B')]], + { columnWidths: [undefined, undefined] }, + ), + ]); + expect(html).not.toContain(''); + }); + + it('applies cell-level textColor, backgroundColor and textAlignment', () => { + const html = exportBlocks([ + table([[ + tableCell('Styled', { + textColor: 'red', + backgroundColor: 'yellow', + textAlignment: 'center', + }), + ]]), + ]); + expect(html).toContain('color:#e03e3e'); + expect(html).toContain('background-color:#fbf3db'); + expect(html).toContain('text-align:center'); + }); + + it('emits colSpan and rowSpan when greater than 1', () => { + const html = exportBlocks([ + table([ + [tableCell('Spanning', { colspan: 2, rowspan: 2 })], + ]), + ]); + // HTML attributes are case-insensitive; React 19 preserves camelCase + expect(html).toMatch(/colSpan="2"/i); + expect(html).toMatch(/rowSpan="2"/i); + }); + + it('ignores colspan/rowspan of 1', () => { + const html = exportBlocks([ + table([[tableCell('Normal', { colspan: 1, rowspan: 1 })]]), + ]); + expect(html).not.toMatch(/colSpan=/i); + expect(html).not.toMatch(/rowSpan=/i); + }); + + it('handles legacy cell shape (bare InlineContent[])', () => { + const html = exportBlocks([ + table([ + [[styledText('Legacy A')], [styledText('Legacy B')]], + ]), + ]); + expect(html).toContain('Legacy A'); + expect(html).toContain('Legacy B'); + expect(html).toContain(' { + const html = exportBlocks([ + table( + [[tableCell('Blue table')]], + { props: { textColor: 'blue' } }, + ), + ]); + // textColor on the
', () => { + const html = exportBlocks([ + table([ + [tableCell('A1'), tableCell('A2')], + [tableCell('B1'), tableCell('B2')], + ]), + ]); + expect(html).toContain(''); + expect(html).toContain('
', () => { + const html = exportBlocks([ + table( + [ + [tableCell('Row 1 label'), tableCell('Row 1 value')], + [tableCell('Row 2 label'), tableCell('Row 2 value')], + ], + { headerCols: 1 }, + ), + ]); + const ths = html.match(/]*>/g) || []; + expect(ths).toHaveLength(2); + }); + + it('emits
should produce inline style + expect(html).toMatch(/]*color:#0b6e99/); + }); + + it('does not throw on malformed rows and cells', () => { + const malformed = { + id: crypto.randomUUID(), + type: 'table', + props: { textColor: 'default' }, + content: { + type: 'tableContent', + rows: [ + { cells: [null, { type: 'tableCell', content: 'invalid' }] }, + {} as unknown as { cells: unknown[] }, + null as unknown as { cells: unknown[] }, + { cells: [tableCell('Survivor')] }, + ], + }, + children: [], + } as unknown as AnyBlock; + + let html = ''; + expect(() => { + html = exportBlocks([malformed]); + }).not.toThrow(); + expect(html).toContain(' { + it('renders a bullet list inside a column', () => { + const html = exportBlocks([ + columnList([ + column([bulletListItem('Item A'), bulletListItem('Item B')], 1), + column([paragraph('Right')], 1), + ]), + ]); + // Both items must appear inside a single
    within the first
. + expect(html).toMatch(/]*>[\s\S]*
    [\s\S]*Item A[\s\S]*Item B[\s\S]*<\/ul>[\s\S]*<\/td>/); + expect(html).toContain('Right'); + }); + + it('renders styled text inside a table cell', () => { + const html = exportBlocks([ + table([ + [ + tableCell([ + styledText('Bold-italic', { bold: true, italic: true }), + ]), + ], + ]), + ]); + expect(html).toContain('font-weight:bold'); + expect(html).toContain('font-style:italic'); + expect(html).toContain('Bold-italic'); + }); + + it('renders a quote containing styled inline content', () => { + const html = exportBlocks([ + quote([ + styledText('Hello ', { bold: true }), + styledText('world', { italic: true }), + ]), + ]); + expect(html).toMatch(/]*>[\s\S]*font-weight:bold[\s\S]*font-style:italic[\s\S]*<\/blockquote>/); + }); + + it('renders nested bullet list inside a numbered list item', () => { + const html = exportBlocks([ + numberedListItem('Parent', {}, [ + bulletListItem('Nested A'), + bulletListItem('Nested B'), + ]), + ]); + // Outer
      wrapping a
    1. that itself contains an inner
        . + expect(html).toMatch(/
          [\s\S]*[\s\S]*Nested A[\s\S]*Nested B[\s\S]*<\/ul>[\s\S]*<\/li>[\s\S]*<\/ol>/); + }); + + it('renders hard breaks inside list items as
          ', () => { + const html = exportBlocks([ + bulletListItem([styledText('Line one\nLine two')]), + ]); + expect(html).toMatch(/]*>[\s\S]*Line one[\s\S]*[\s\S]*Line two[\s\S]*<\/li>/); + }); + + it('renders hard breaks inside a quote', () => { + const html = exportBlocks([ + quote([styledText('Quote line 1\nQuote line 2')]), + ]); + expect(html).toMatch(/]*>[\s\S]*Quote line 1[\s\S]*[\s\S]*Quote line 2[\s\S]*<\/blockquote>/); + }); + + it('flushes a quote child as a sibling after the blockquote', () => { + // transformBlocks pushes non-column children AFTER the parent block, + // so an image declared as a quote child appears OUTSIDE the
          . + // This pins the current behavior so a future change to recurse inside + // the blockquote shape is caught. + const html = exportBlocks([ + quote('Wisdom', {}, [ + image('https://example.com/quote.jpg', { name: 'photo' }), + ]), + ]); + const blockquoteEnd = html.indexOf('
          '); + const imgIdx = html.indexOf(' { + const html = exportBlocks([ + columnList([ + column([ + heading('Section', 2), + bulletListItem('First'), + bulletListItem('Second'), + paragraph('Closing paragraph.'), + ], 1), + column([paragraph('Sidebar')], 1), + ]), + ]); + expect(html).toContain('[\s\S]*First[\s\S]*Second[\s\S]*<\/ul>/); + expect(html).toContain('Closing paragraph.'); + expect(html).toContain('Sidebar'); + }); + }); + // ----------------------------------------------------------------------- // Golden snapshots — full HTML reference to detect structural changes // ----------------------------------------------------------------------- diff --git a/src/frontend/src/features/blocknote/email-exporter/index.tsx b/src/frontend/src/features/blocknote/email-exporter/index.tsx index 4bd9c5911..c7245fc15 100644 --- a/src/frontend/src/features/blocknote/email-exporter/index.tsx +++ b/src/frontend/src/features/blocknote/email-exporter/index.tsx @@ -409,14 +409,150 @@ function renderBlock( case 'quoted-message': return ; + case 'table': + return renderTable(block, key); + default: - if (content && content.length > 0) { + if (Array.isArray(content) && content.length > 0) { return
          {renderInlineContent(content)}
          ; } return null; } } +// --------------------------------------------------------------------------- +// Table rendering +// --------------------------------------------------------------------------- + +type TableCellLike = { + content: AnyInlineContent[]; + props?: Record; +}; + +/** + * Normalises a BlockNote table cell: cells can be either raw inline content + * arrays (legacy) or { type: 'tableCell', content, props } objects. + */ +function normalizeTableCell(cell: unknown): TableCellLike { + if (Array.isArray(cell)) { + return { content: cell as AnyInlineContent[] }; + } + if (!cell || typeof cell !== 'object') { + return { content: [] }; + } + const cellObj = cell as { content?: unknown; props?: Record }; + return { + content: Array.isArray(cellObj.content) ? (cellObj.content as AnyInlineContent[]) : [], + props: cellObj.props, + }; +} + +function tableCellStyle(props: Record | undefined): CSSProperties { + const style: CSSProperties = { + border: '1px solid #ddd', + padding: '5px 10px', + verticalAlign: 'top', + }; + if (!props) return style; + + const alignment = props.textAlignment as string | undefined; + if (alignment && alignment !== 'left') { + style.textAlign = alignment as CSSProperties['textAlign']; + } + const textColor = props.textColor as string | undefined; + if (textColor && textColor !== 'default') { + style.color = COLORS[textColor]?.text || textColor; + } + const bgColor = props.backgroundColor as string | undefined; + if (bgColor && bgColor !== 'default') { + style.backgroundColor = COLORS[bgColor]?.background || bgColor; + } + return style; +} + +/** + * Renders a BlockNote table block as an HTML . + * + * BlockNote tables can use `headerRows` / `headerCols` to mark the leading + * rows or columns as headers (rendered as so email clients allocate + * space deterministically. + */ +function renderTable(block: AnyBlock, key: number): React.ReactNode { + const tableContent = block.content as unknown as { + type: 'tableContent'; + columnWidths?: (number | undefined)[]; + headerRows?: number; + headerCols?: number; + rows: { cells: unknown[] }[]; + } | undefined; + + if (!tableContent || !Array.isArray(tableContent.rows) || tableContent.rows.length === 0) { + return null; + } + + const headerRows = tableContent.headerRows ?? 0; + const headerCols = tableContent.headerCols ?? 0; + const columnWidths = tableContent.columnWidths || []; + const blockProps = block.props as Record; + const blockTextColor = blockProps.textColor as string | undefined; + + const tableStyle: CSSProperties = { + borderCollapse: 'collapse', + wordBreak: 'break-word', + }; + if (blockTextColor && blockTextColor !== 'default') { + tableStyle.color = COLORS[blockTextColor]?.text || blockTextColor; + } + + const colgroup = columnWidths.some((w) => typeof w === 'number') ? ( + + {columnWidths.map((w, i) => ( + + ))} + + ) : null; + + return ( +
          ). Column widths from + * `columnWidths` are emitted via a
          + {colgroup} + + {tableContent.rows.map((row, rowIdx) => { + const cells = Array.isArray(row?.cells) ? row.cells : []; + return ( + + {cells.map((rawCell, colIdx) => { + const cell = normalizeTableCell(rawCell); + const isHeader = rowIdx < headerRows || colIdx < headerCols; + const Tag = isHeader ? 'th' : 'td'; + const style = tableCellStyle(cell.props); + if (isHeader) { + style.fontWeight = 'bold'; + if (!style.textAlign) { + style.textAlign = 'left'; + } + } + const colspan = cell.props?.colspan as number | undefined; + const rowspan = cell.props?.rowspan as number | undefined; + return ( + 1 ? colspan : undefined} + rowSpan={rowspan && rowspan > 1 ? rowspan : undefined} + > + {renderInlineContent(cell.content)} + + ); + })} + + ); + })} + +
          + ); +} + // --------------------------------------------------------------------------- // Block tree → React node list (groups consecutive list items) // --------------------------------------------------------------------------- diff --git a/src/frontend/src/features/blocknote/markdown-exporter.test.ts b/src/frontend/src/features/blocknote/markdown-exporter.test.ts new file mode 100644 index 000000000..49e457629 --- /dev/null +++ b/src/frontend/src/features/blocknote/markdown-exporter.test.ts @@ -0,0 +1,231 @@ +/** + * Regression net for `BlockNoteEditor.blocksToMarkdownLossy()`. + * + * This serializer is a BlockNote built-in (no source in this repo) used in + * `message-composer/index.tsx` to produce the email text body. A silent + * regression here breaks plain-text recipients. These tests pin a contract + * per block type against the production schema (`BLOCKNOTE_SCHEMA`), so a + * BlockNote upgrade that changes the markdown shape is caught at CI time. + * + * Note: snapshots are deliberately structural (`toContain`) rather than full + * inline snapshots to absorb cosmetic differences (trailing newlines, bullet + * marker, etc.) across BlockNote patch versions. The only inline snapshot is + * the empty-document case, which should never produce noise. + */ +import { BlockNoteEditor } from '@blocknote/core'; +import type { PartialBlock } from '@blocknote/core'; +import { BLOCKNOTE_SCHEMA } from '@/features/forms/components/message-composer'; + +// jsdom 27 ships without matchMedia/ResizeObserver/IntersectionObserver, which +// BlockNote/TipTap probe when an editor is instantiated. The schema import +// above is safe at module load (no DOM access), so we only need to stub before +// the first `createHeadlessEditor()` call. +beforeAll(() => { + if (typeof window === 'undefined') return; + + if (!window.matchMedia) { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: () => ({ + matches: false, + media: '', + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false, + }), + }); + } + + class NoopObserver { + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(globalThis as any).ResizeObserver) (globalThis as any).ResizeObserver = NoopObserver; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(globalThis as any).IntersectionObserver) (globalThis as any).IntersectionObserver = NoopObserver; +}); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type EditorType = BlockNoteEditor; + +function createHeadlessEditor(): EditorType { + return BlockNoteEditor.create({ schema: BLOCKNOTE_SCHEMA }) as EditorType; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +async function toMarkdown(blocks: PartialBlock[]): Promise { + const editor = createHeadlessEditor(); + return editor.blocksToMarkdownLossy(blocks); +} + +describe('blocksToMarkdownLossy', () => { + it('returns an empty string for an empty document', async () => { + const md = await toMarkdown([]); + expect(md).toBe(''); + }); + + it('serializes a plain paragraph', async () => { + const md = await toMarkdown([ + { type: 'paragraph', content: 'Hello world' }, + ]); + expect(md.trim()).toBe('Hello world'); + }); + + it('serializes headings level 1-3', async () => { + const md = await toMarkdown([ + { type: 'heading', props: { level: 1 }, content: 'H1' }, + { type: 'heading', props: { level: 2 }, content: 'H2' }, + { type: 'heading', props: { level: 3 }, content: 'H3' }, + ]); + expect(md).toContain('# H1'); + expect(md).toContain('## H2'); + expect(md).toContain('### H3'); + }); + + it('serializes bold, italic and strikethrough inline marks', async () => { + const md = await toMarkdown([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'bold', styles: { bold: true } }, + { type: 'text', text: ' ', styles: {} }, + { type: 'text', text: 'italic', styles: { italic: true } }, + { type: 'text', text: ' ', styles: {} }, + { type: 'text', text: 'struck', styles: { strike: true } }, + ], + }, + ]); + expect(md).toContain('**bold**'); + expect(md).toMatch(/[*_]italic[*_]/); + expect(md).toContain('~~struck~~'); + }); + + it('serializes a link with text', async () => { + const md = await toMarkdown([ + { + type: 'paragraph', + content: [ + { + type: 'link', + href: 'https://example.com', + content: 'Click here', + }, + ], + }, + ]); + expect(md).toContain('[Click here](https://example.com)'); + }); + + it('serializes a bullet list', async () => { + const md = await toMarkdown([ + { type: 'bulletListItem', content: 'Item A' }, + { type: 'bulletListItem', content: 'Item B' }, + ]); + expect(md).toMatch(/[-*] Item A/); + expect(md).toMatch(/[-*] Item B/); + }); + + it('serializes a numbered list', async () => { + const md = await toMarkdown([ + { type: 'numberedListItem', content: 'First' }, + { type: 'numberedListItem', content: 'Second' }, + ]); + expect(md).toContain('1. First'); + expect(md).toContain('2. Second'); + }); + + it('serializes nested bullet list children with indentation', async () => { + const md = await toMarkdown([ + { + type: 'bulletListItem', + content: 'Parent', + children: [{ type: 'bulletListItem', content: 'Child' }], + }, + ]); + expect(md).toMatch(/[-*] Parent/); + expect(md).toMatch(/\s+[-*] Child/); + }); + + it('serializes a code block as a fenced block', async () => { + const md = await toMarkdown([ + { type: 'codeBlock', content: 'console.log(1)' }, + ]); + expect(md).toContain('```'); + expect(md).toContain('console.log(1)'); + }); + + it('serializes a quote', async () => { + const md = await toMarkdown([ + { type: 'quote', content: 'A wise thought' }, + ]); + expect(md).toContain('> A wise thought'); + }); + + it('serializes an image as ![alt](url)', async () => { + const md = await toMarkdown([ + { + type: 'image', + props: { + url: 'https://example.com/photo.jpg', + name: 'photo.jpg', + }, + }, + ]); + expect(md).toContain('https://example.com/photo.jpg'); + expect(md).toContain('!['); + }); + + it('omits the signature block from markdown output', async () => { + const md = await toMarkdown([ + { type: 'paragraph', content: 'Above signature' }, + { + type: 'signature', + props: { + templateId: 'tpl-uuid', + mailboxId: 'mbx-uuid', + messageId: 'msg-uuid', + }, + }, + ]); + expect(md).toContain('Above signature'); + expect(md).not.toContain('tpl-uuid'); + expect(md).not.toContain('mbx-uuid'); + }); + + it('omits the quoted-message block from markdown output', async () => { + const md = await toMarkdown([ + { type: 'paragraph', content: 'Reply text' }, + { + type: 'quoted-message', + props: { + subject: 'Confidential subject', + sender: 'alice@example.com', + }, + }, + ]); + expect(md).toContain('Reply text'); + expect(md).not.toContain('Confidential subject'); + expect(md).not.toContain('alice@example.com'); + }); + + it('preserves text around a styled span across hard breaks', async () => { + const md = await toMarkdown([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'Line one\nLine two', styles: {} }, + ], + }, + ]); + expect(md).toContain('Line one'); + expect(md).toContain('Line two'); + }); +}); diff --git a/src/frontend/src/features/blocknote/utils.test.ts b/src/frontend/src/features/blocknote/utils.test.ts new file mode 100644 index 000000000..f08ab4748 --- /dev/null +++ b/src/frontend/src/features/blocknote/utils.test.ts @@ -0,0 +1,132 @@ +import type { Block } from '@blocknote/core'; +import { resolveTemplateVariables } from './utils'; +import { + bulletListItem, + divider, + image, + paragraph, + styledText, + templateVariable, +} from './__tests__/block-factories'; + +// resolveTemplateVariables is typed against the public Block schema; the +// factories return loosely-typed blocks, so we cast at the boundary. +const asBlocks = (blocks: unknown[]): Block[] => blocks as Block[]; + +describe('resolveTemplateVariables', () => { + it('replaces a single template-variable with its resolved value', () => { + const blocks = asBlocks([ + paragraph([ + styledText('Hello '), + templateVariable('first_name'), + ]), + ]); + + const resolved = resolveTemplateVariables(blocks, { first_name: 'Alice' }); + + const [block] = resolved; + const content = block.content as { type: string; text: string }[]; + expect(content).toHaveLength(2); + expect(content[1]).toEqual({ type: 'text', text: 'Alice', styles: {} }); + }); + + it('falls back to `{var}` when the value is missing from resolvedValues', () => { + const blocks = asBlocks([ + paragraph([templateVariable('missing_var')]), + ]); + + const resolved = resolveTemplateVariables(blocks, {}); + + const content = resolved[0].content as { type: string; text: string }[]; + expect(content[0].text).toBe('{missing_var}'); + }); + + it('returns blocks unchanged when there are no template-variables', () => { + const blocks = asBlocks([ + paragraph('Plain paragraph'), + paragraph([styledText('Bold', { bold: true })]), + ]); + + const resolved = resolveTemplateVariables(blocks, { unused: 'value' }); + + expect(resolved).toHaveLength(2); + expect(resolved[0].content).toEqual(blocks[0].content); + expect(resolved[1].content).toEqual(blocks[1].content); + }); + + it('preserves blocks without a `content` array (divider, image)', () => { + const blocks = asBlocks([divider(), image('https://example.com/a.png')]); + + const resolved = resolveTemplateVariables(blocks, {}); + + expect(resolved[0].type).toBe('divider'); + expect(resolved[0].content).toBeUndefined(); + expect(resolved[1].type).toBe('image'); + expect(resolved[1].content).toBeUndefined(); + }); + + it('recurses into children blocks', () => { + const blocks = asBlocks([ + bulletListItem('Parent', {}, [ + bulletListItem([templateVariable('nested')]), + ]), + ]); + + const resolved = resolveTemplateVariables(blocks, { nested: 'CHILD' }); + + const child = resolved[0].children[0]; + const childContent = child.content as { type: string; text: string }[]; + expect(childContent[0].text).toBe('CHILD'); + }); + + it('replaces multiple template-variables within a single block', () => { + const blocks = asBlocks([ + paragraph([ + styledText('Hi '), + templateVariable('first_name'), + styledText(', your code is '), + templateVariable('code'), + styledText('.'), + ]), + ]); + + const resolved = resolveTemplateVariables(blocks, { + first_name: 'Bob', + code: '1234', + }); + + const content = resolved[0].content as { type: string; text: string }[]; + expect(content).toHaveLength(5); + expect(content[1].text).toBe('Bob'); + expect(content[3].text).toBe('1234'); + }); + + it('preserves the `styles` object on neighbouring text', () => { + const blocks = asBlocks([ + paragraph([ + styledText('Bold prefix ', { bold: true }), + templateVariable('name'), + ]), + ]); + + const resolved = resolveTemplateVariables(blocks, { name: 'Carol' }); + + const content = resolved[0].content as { type: string; text: string; styles: Record }[]; + expect(content[0].styles).toEqual({ bold: true }); + expect(content[1].styles).toEqual({}); + }); + + it('does not mutate the input blocks', () => { + const blocks = asBlocks([ + paragraph([ + styledText('Hello '), + templateVariable('name'), + ]), + ]); + const snapshot = JSON.parse(JSON.stringify(blocks)); + + resolveTemplateVariables(blocks, { name: 'Dave' }); + + expect(blocks).toEqual(snapshot); + }); +}); diff --git a/src/frontend/src/features/forms/components/message-composer/index.tsx b/src/frontend/src/features/forms/components/message-composer/index.tsx index c3c6e9b6b..c0f2a2068 100644 --- a/src/frontend/src/features/forms/components/message-composer/index.tsx +++ b/src/frontend/src/features/forms/components/message-composer/index.tsx @@ -25,7 +25,7 @@ import { DriveFile } from '../message-form/drive-attachment-picker'; // Re-export for consumers that import from message-composer export { ALLOWED_IMAGE_MIME_TYPES } from '@/features/blocknote/image-block'; -const BLOCKNOTE_SCHEMA = BlockNoteSchema.create({ +export const BLOCKNOTE_SCHEMA = BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, 'image': imageBlockSpec, diff --git a/src/frontend/src/features/forms/components/message-form/index.tsx b/src/frontend/src/features/forms/components/message-form/index.tsx index ce9350c4b..1ab639f9a 100644 --- a/src/frontend/src/features/forms/components/message-form/index.tsx +++ b/src/frontend/src/features/forms/components/message-form/index.tsx @@ -6,7 +6,7 @@ import { FormProvider, useForm, useWatch } from "react-hook-form"; import { useTranslation } from "react-i18next"; import z from "zod"; import { zodResolver } from "@hookform/resolvers/zod"; -import { Attachment, DraftMessageRequestRequest, draftCreateResponse200, Message, sendCreateResponse200, useDraftCreate, useDraftUpdate2, useMessagesDestroy, useSendCreate } from "@/features/api/gen"; +import { Attachment, DraftMessageRequestRequest, draftCreateResponse200, Message, sendCreateResponse200, useDraftCreate, useDraftUpdate2, useMessagesDestroy, useSendCreate, ThreadAccessRoleChoices } from "@/features/api/gen"; import { MessageComposer, MessageComposerHandle, QuoteType } from "@/features/forms/components/message-composer"; import { useMailboxContext } from "@/features/providers/mailbox"; import MailHelper from "@/features/utils/mail-helper"; @@ -86,7 +86,16 @@ export const MessageForm = ({ const config = useConfig(); const modals = useModals(); const composerRef = useRef(null); - const [draft, setDraft] = useState(draftMessage); + const [draft, setDraftState] = useState(draftMessage); + // Synchronous mirror of `draft`. `saveDraftInner` may be re-entered (via + // composer onChange firing right after `ensureDraft` resolves) before + // React commits the previous setDraft, so the closure-captured `draft` + // would be stale and we'd POST a second time instead of PATCH. + const draftRef = useRef(draftMessage); + const setDraft = (next: Message | undefined) => { + draftRef.current = next; + setDraftState(next); + }; const [preferredSendMode, setPreferredSendMode] = useState(() => { if (mode === 'new') return PreferSendMode.SEND; return localStorage.getItem(PREFER_SEND_MODE_KEY) as PreferSendMode ?? PreferSendMode.SEND; @@ -99,16 +108,29 @@ export const MessageForm = ({ const quoteType: QuoteType | undefined = mode !== "new" ? (mode === "forward" ? "forward" : "reply") : undefined; const { selectedMailbox, selectedThread, mailboxes, removeMessages, invalidateMailbox, invalidateThreadsStats, unselectThread, unpinThreads, pinThreads } = useMailboxContext(); const hideSubjectField = Boolean(draftMessage?.parent_id ?? parentMessage); - const defaultSenderId = mailboxes?.find((mailbox) => { + // For replies/forwards, only allow sending from a mailbox that has access to the thread. + const availableMailboxes = useMemo(() => { + if (!mailboxes) return []; + if (mode === "new" || !selectedThread) return mailboxes; + const allowedMailboxIds = new Set(selectedThread.accesses.filter(access => access.role === ThreadAccessRoleChoices.editor).map((access) => access.mailbox.id)); + return mailboxes.filter((mailbox) => allowedMailboxIds.has(mailbox.id)); + }, [mailboxes, mode, selectedThread]); + const defaultSenderId = availableMailboxes.find((mailbox) => { if (draft?.sender) return draft.sender.email === mailbox.email; return selectedMailbox?.id === mailbox.id; - })?.id ?? mailboxes?.[0]?.id; - const hideFromField = defaultSenderId && (mailboxes?.length ?? 0) === 1; + })?.id ?? availableMailboxes[0]?.id; + // Effective sender used by reply prefills: defaultSenderId can diverge from + // selectedMailbox after the availableMailboxes filter, so recipient filters + // and the "reply to self" branch must align on the chosen sender. + const replySenderEmail = + draft?.sender?.email ?? + availableMailboxes.find((mailbox) => mailbox.id === defaultSenderId)?.email ?? + selectedMailbox?.email; + const hideFromField = defaultSenderId && availableMailboxes.length === 1; const { addQueuedMessage } = useSentBox(); const getMailboxOptions = () => { - if (!mailboxes) return []; - return mailboxes.map((mailbox) => ({ + return availableMailboxes.map((mailbox) => ({ label: mailbox.email, value: mailbox.id })); @@ -123,13 +145,13 @@ export const MessageForm = ({ { contact: { email: parentMessage.sender.email } }, ...parentMessage.to ] - .filter(({ contact }) => contact.email !== selectedMailbox!.email) + .filter(({ contact }) => contact.email !== replySenderEmail) .map(({ contact }) => contact.email) )] } // If the sender is replying to himself, we can consider that it prefers // to reply to the message recipient. - if (parentMessage.sender.email === selectedMailbox?.email) { + if (parentMessage.sender.email === replySenderEmail) { if (parentMessage.to.length > 0) { return parentMessage.to.map(({ contact }) => contact.email); } @@ -141,17 +163,17 @@ export const MessageForm = ({ } } return [parentMessage.sender.email]; - }, [parentMessage, mode, selectedMailbox]); + }, [parentMessage, mode, replySenderEmail]); const ccRecipients = useMemo(() => { if (draft) return draft.cc.map(({ contact }) => contact.email); if (mode === "reply_all" && parentMessage) { return parentMessage.cc - .filter(({ contact }) => contact.email !== selectedMailbox!.email) + .filter(({ contact }) => contact.email !== replySenderEmail) .map(({ contact }) => contact.email); } return []; - }, [parentMessage, mode, draft, selectedMailbox]); + }, [parentMessage, mode, draft, replySenderEmail]); const [showCCField, setShowCCField] = useState(ccRecipients.length > 0); const [showBCCField, setShowBCCField] = useState((draftMessage?.bcc?.length ?? 0) > 0); @@ -186,7 +208,7 @@ export const MessageForm = ({ driveAttachments: draftDriveAttachments, signatureId: draft?.signature?.id, } - }, [draft, selectedMailbox]) + }, [draft, defaultSenderId, toRecipients, ccRecipients, parentMessage, mode]) const form = useForm({ resolver: zodResolver(messageFormSchema), @@ -443,7 +465,7 @@ export const MessageForm = ({ bcc: data.bcc ?? [], subject: data.subject, senderId: data.from, - parentId: parentMessage?.id, + parentId: parentMessage?.id ?? draft?.parent_id, draftBody: MailHelper.attachDriveAttachmentsToDraft(data.messageDraftBody, data.driveAttachments), attachments: data.attachments, signatureId: data.signatureId ?? null, @@ -455,16 +477,20 @@ export const MessageForm = ({ stopAutoSave(); const isDirtyFrom = !!form.formState.dirtyFields.from; form.reset(form.getValues(), { keepSubmitCount: true, keepDirty: false, keepValues: true, keepDefaultValues: false }); - if (!draft) { + // Read the latest draft from the ref rather than the state: + // a previous saveDraftInner call may have just created the + // draft without React having committed setDraft yet. + const currentDraft = draftRef.current; + if (!currentDraft) { response = await draftCreateMutation.mutateAsync({ data: payload, }); } else if (isDirtyFrom) { await handleChangeSender(payload); - return draft?.id; + return currentDraft.id; } else { response = await draftUpdateMutation.mutateAsync({ - messageId: draft.id, + messageId: currentDraft.id, data: payload, }); } @@ -474,7 +500,7 @@ export const MessageForm = ({ return newDraft.id; } catch (error) { console.warn("Error in saveDraft:", error); - return draft?.id; + return draftRef.current?.id; } finally { saveDraftPromiseRef.current = null; startAutoSave();