diff --git a/desktop/package.json b/desktop/package.json
index 25848ae1b..8643690ec 100644
--- a/desktop/package.json
+++ b/desktop/package.json
@@ -34,6 +34,7 @@
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
+ "@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.90.21",
"@tanstack/react-router": "^1.168.10",
@@ -42,11 +43,18 @@
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.0",
+ "@tiptap/core": "^3.22.3",
+ "@tiptap/extension-link": "^3.22.3",
+ "@tiptap/extension-placeholder": "^3.22.3",
+ "@tiptap/pm": "^3.22.3",
+ "@tiptap/react": "^3.22.3",
+ "@tiptap/starter-kit": "^3.22.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"emoji-mart": "^5.6.0",
"jdenticon": "^3.3.0",
"lucide-react": "^0.577.0",
+ "motion": "^12.38.0",
"react": "^19.1.0",
"react-diff-view": "^3.3.2",
"react-dom": "^19.1.0",
@@ -55,6 +63,7 @@
"remark-gfm": "^4.0.1",
"shiki": "^4.0.2",
"tailwind-merge": "^3.5.0",
+ "tiptap-markdown": "^0.9.0",
"yaml": "^2.8.3"
},
"devDependencies": {
diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml
index 51b5e5d47..96ef23994 100644
--- a/desktop/pnpm-lock.yaml
+++ b/desktop/pnpm-lock.yaml
@@ -38,6 +38,9 @@ importers:
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@19.2.14)(react@19.2.5)
+ '@radix-ui/react-toggle':
+ specifier: ^1.1.10
+ version: 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
@@ -62,6 +65,24 @@ importers:
'@tauri-apps/plugin-updater':
specifier: ^2.10.0
version: 2.10.0
+ '@tiptap/core':
+ specifier: ^3.22.3
+ version: 3.22.3(@tiptap/pm@3.22.3)
+ '@tiptap/extension-link':
+ specifier: ^3.22.3
+ version: 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+ '@tiptap/extension-placeholder':
+ specifier: ^3.22.3
+ version: 3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
+ '@tiptap/pm':
+ specifier: ^3.22.3
+ version: 3.22.3
+ '@tiptap/react':
+ specifier: ^3.22.3
+ version: 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@tiptap/starter-kit':
+ specifier: ^3.22.3
+ version: 3.22.3
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@@ -77,6 +98,9 @@ importers:
lucide-react:
specifier: ^0.577.0
version: 0.577.0(react@19.2.5)
+ motion:
+ specifier: ^12.38.0
+ version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
react:
specifier: ^19.1.0
version: 19.2.5
@@ -101,6 +125,9 @@ importers:
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
+ tiptap-markdown:
+ specifier: ^0.9.0
+ version: 0.9.0(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
yaml:
specifier: ^2.8.3
version: 2.8.3
@@ -837,6 +864,19 @@ packages:
'@types/react':
optional: true
+ '@radix-ui/react-toggle@1.1.10':
+ resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==}
+ peerDependencies:
+ '@types/react': '*'
+ '@types/react-dom': '*'
+ react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
+ peerDependenciesMeta:
+ '@types/react':
+ optional: true
+ '@types/react-dom':
+ optional: true
+
'@radix-ui/react-tooltip@1.2.8':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
@@ -938,6 +978,9 @@ packages:
'@radix-ui/rect@1.1.1':
resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==}
+ '@remirror/core-constants@3.0.0':
+ resolution: {integrity: sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg==}
+
'@rolldown/pluginutils@1.0.0-beta.27':
resolution: {integrity: sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==}
@@ -993,7 +1036,6 @@ packages:
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64]
os: [linux]
- libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
@@ -1035,7 +1077,6 @@ packages:
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x]
os: [linux]
- libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
@@ -1278,6 +1319,160 @@ packages:
'@tauri-apps/plugin-updater@2.10.0':
resolution: {integrity: sha512-ljN8jPlnT0aSn8ecYhuBib84alxfMx6Hc8vJSKMJyzGbTPFZAC44T2I1QNFZssgWKrAlofvJqCC6Rr472JWfkQ==}
+ '@tiptap/core@3.22.3':
+ resolution: {integrity: sha512-Dv9MKK5BDWCF0N2l6/Pxv3JNCce2kwuWf2cKMBc2bEetx0Pn6o7zlFmSxMvYK4UtG1Tw9Yg/ZHi6QOFWK0Zm9Q==}
+ peerDependencies:
+ '@tiptap/pm': ^3.22.3
+
+ '@tiptap/extension-blockquote@3.22.3':
+ resolution: {integrity: sha512-IaUx3zh7yLHXzIXKL+fw/jzFhsIImdhJyw0lMhe8FfYrefFqXJFYW/sey6+L/e8B3AWvTksPA6VBwefzbH77JA==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+
+ '@tiptap/extension-bold@3.22.3':
+ resolution: {integrity: sha512-tysipHla2zCWr8XNIWRaW9O+7i7/SoEqnRqSRUUi2ailcJjlia+RBy3RykhkgyThrQDStu5KGBS/UvrXwA+O1A==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+
+ '@tiptap/extension-bubble-menu@3.22.3':
+ resolution: {integrity: sha512-Y6zQjh0ypDg32HWgICEvmPSKjGLr39k3aDxxt/H0uQEZSfw4smT0hxUyyyjVjx68C6t6MTnwdfz0hPI5lL68vQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+ '@tiptap/pm': ^3.22.3
+
+ '@tiptap/extension-bullet-list@3.22.3':
+ resolution: {integrity: sha512-xOmW/b1hgECIE6r3IeZvKn4VVlG3+dfTjCWE6lnnyLaqdNkNhKS1CwUmDZdYNLUS2ryIUtgz5ID1W/8A3PhbiA==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.22.3
+
+ '@tiptap/extension-code-block@3.22.3':
+ resolution: {integrity: sha512-RiQtEjDAPrHpdo6sw6b7fOw/PijqgFIsozKKkGcSeBgWHQuFg7q9OxJTj+l0e60rVwSu/5gmKEEobzM9bX+t2Q==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+ '@tiptap/pm': ^3.22.3
+
+ '@tiptap/extension-code@3.22.3':
+ resolution: {integrity: sha512-wafWTDQOuMKtXpZEuk1PFQmzopabBciNLryL90MB9S03MNLaQQZYLnmYkDBlzAaLAbgF5QiC+2XZQEBQuTVjFQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+
+ '@tiptap/extension-document@3.22.3':
+ resolution: {integrity: sha512-MCSr1PFPtTd++lA3H1RNgqAczAE59XXJ5wUFIQf2F+/0DPY5q2SU4g5QsNJVxPPft5mrNT4C6ty8xBPrALFEdA==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+
+ '@tiptap/extension-dropcursor@3.22.3':
+ resolution: {integrity: sha512-taXq9Tl5aybdFbptJtFRHX9LFJzbXphAbPp4/vutFyTrBu5meXDxuS+B9pEmE+Or0XcolTlW2nDZB0Tqnr18JQ==}
+ peerDependencies:
+ '@tiptap/extensions': ^3.22.3
+
+ '@tiptap/extension-floating-menu@3.22.3':
+ resolution: {integrity: sha512-0f8b4KZ3XKai8GXWseIYJGdOfQr3evtFbBo3U08zy2aYzMMXWG0zEF7qe5/oiYp2aZ95edjjITnEceviTsZkIg==}
+ peerDependencies:
+ '@floating-ui/dom': ^1.0.0
+ '@tiptap/core': ^3.22.3
+ '@tiptap/pm': ^3.22.3
+
+ '@tiptap/extension-gapcursor@3.22.3':
+ resolution: {integrity: sha512-L/Px4UeQEVG/D9WIlcAOIej+4wyIBCMUSYicSR+hW68UsObe4rxVbUas1QgidQKm6DOhoT7U7D4KQHA/Gdg/7A==}
+ peerDependencies:
+ '@tiptap/extensions': ^3.22.3
+
+ '@tiptap/extension-hard-break@3.22.3':
+ resolution: {integrity: sha512-J0v8I99y9tbvVmgKYKzKP/JYNsWaZYS7avn4rzLft2OhnyTfwt3OoY8DtpHmmi6apSUaCtoWHWta/TmoEfK1nQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+
+ '@tiptap/extension-heading@3.22.3':
+ resolution: {integrity: sha512-XBHuhiEV2EEhZHpOLcplLqAmBIhJciU3I6AtwmqeEqDC0P114uMEfAO7JGlbBZdCYotNer26PKnu44TBTeNtkw==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+
+ '@tiptap/extension-horizontal-rule@3.22.3':
+ resolution: {integrity: sha512-wI2bFzScs+KgWeBH/BtypcVKeYelCyqV0RG8nxsZMWtPrBhqixzNd0Oi3gEKtjSjKUqMQ/kjJAIRuESr5UzlHA==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+ '@tiptap/pm': ^3.22.3
+
+ '@tiptap/extension-italic@3.22.3':
+ resolution: {integrity: sha512-LteA4cb4EGCiUtrK2JHvDF/Zg0/YqV4DUyHhAAho+oGEQDupZlsS6m0ia5wQcclkiTLzsoPrwcSNu6RDGQ16wQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+
+ '@tiptap/extension-link@3.22.3':
+ resolution: {integrity: sha512-S8/P2o9pv6B3kqLjH2TRWwSAximGbciNc6R8/QcN6HWLYxp0N0JoqN3rZHl9VWIBAGRWc4zkt80dhqrl2xmgfQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+ '@tiptap/pm': ^3.22.3
+
+ '@tiptap/extension-list-item@3.22.3':
+ resolution: {integrity: sha512-80CNf4oO5y8+LdckT4CyMe1t01EyhpRrQC9H45JW20P7559Nrchp5my3vvMtIAJbpTPPZtcB7LwdzWGKsG5drg==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.22.3
+
+ '@tiptap/extension-list-keymap@3.22.3':
+ resolution: {integrity: sha512-pKuyj5llu35zd/s2u/H9aydKZjmPRAIK5P1q/YXULhhCNln2RnmuRfQ5NklAqTD3yGciQ2lxDwwf7J6iw3ergA==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.22.3
+
+ '@tiptap/extension-list@3.22.3':
+ resolution: {integrity: sha512-rqvv/dtqwbX+8KnPv0eMYp6PnBcuhPMol5cv1GlS8Nq/Cxt68EWGUHBuTFesw+hdnRQLmKwzoO1DlRn7PhxYRQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+ '@tiptap/pm': ^3.22.3
+
+ '@tiptap/extension-ordered-list@3.22.3':
+ resolution: {integrity: sha512-orAghtmd+K4Euu4BgI1hG+iZDXBYOyl5YTwiLBc2mQn+pqtZ9LqaH2us4ETwEwNP3/IWXGSAimUZ19nuL+eM2w==}
+ peerDependencies:
+ '@tiptap/extension-list': ^3.22.3
+
+ '@tiptap/extension-paragraph@3.22.3':
+ resolution: {integrity: sha512-oO7rhfyhEuwm+50s9K3GZPjYyEEEvFAvm1wXopvZnhbkBLydIWImBfrZoC5IQh4/sRDlTIjosV2C+ji5y0tUSg==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+
+ '@tiptap/extension-placeholder@3.22.3':
+ resolution: {integrity: sha512-7vbtlDVO00odqCnsMSmA4b6wjL5PFdfExFsdsDO0K0VemqHZ/doIRx/tosNUD1VYSOyKQd8U7efUjkFyVoIPlg==}
+ peerDependencies:
+ '@tiptap/extensions': ^3.22.3
+
+ '@tiptap/extension-strike@3.22.3':
+ resolution: {integrity: sha512-jY2InoUlKkuk5KHoIDGdML1OCA2n6PRHAtxwHNkAmiYh0Khf0zaVPGFpx4dgQrN7W5Q1WE6oBZnjrvy6qb7w0g==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+
+ '@tiptap/extension-text@3.22.3':
+ resolution: {integrity: sha512-Q9R7JsTdomP5uUjtPjNKxHT1xoh/i9OJZnmgJLe7FcgZEaPOQ3bWxmKZoLZQfDfZjyB8BtH+Hc7nUvhCMOePxw==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+
+ '@tiptap/extension-underline@3.22.3':
+ resolution: {integrity: sha512-Ch6CBWRa5w90yYSPUW6x9Py9JdrXMqk3pZ9OIlMYD8A7BqyZGfiHerX7XDMYDS09KjyK3U9XH60/zxYOzXdDLA==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+
+ '@tiptap/extensions@3.22.3':
+ resolution: {integrity: sha512-s5eiMq0m5N6N+W7dU6rd60KgZyyCD7FvtPNNswISfPr12EQwJBfbjWwTqd0UKNzA4fNrhQEERXnzORkykttPeA==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+ '@tiptap/pm': ^3.22.3
+
+ '@tiptap/pm@3.22.3':
+ resolution: {integrity: sha512-NjfWjZuvrqmpICT+GZWNIjtOdhPyqFKDMtQy7tsQ5rErM9L2ZQdy/+T/BKSO1JdTeBhdg9OP+0yfsqoYp2aT6A==}
+
+ '@tiptap/react@3.22.3':
+ resolution: {integrity: sha512-6MNr6z0PxwfJFs+BKhHcvPNvY+UV1PXgqzTiTM4Z9guml84iVZxv7ZOCSj1dFYTr3Bf1MiOs4hT1yvBFlTfIaQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.22.3
+ '@tiptap/pm': ^3.22.3
+ '@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
+ react-dom: ^17.0.0 || ^18.0.0 || ^19.0.0
+
+ '@tiptap/starter-kit@3.22.3':
+ resolution: {integrity: sha512-vdW/Oo1fdwTL1VOQ5YYbTov00ANeHLquBVEZyL/EkV7Xv5io9rXQsCysJfTSHhiQlyr2MtWFB4+CPGuwXjQWOQ==}
+
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -1302,9 +1497,27 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
+ '@types/linkify-it@3.0.5':
+ resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
+
+ '@types/linkify-it@5.0.0':
+ resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
+
+ '@types/markdown-it@13.0.9':
+ resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
+
+ '@types/markdown-it@14.1.2':
+ resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
+
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
+ '@types/mdurl@1.0.5':
+ resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
+
+ '@types/mdurl@2.0.0':
+ resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
+
'@types/ms@2.1.0':
resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
@@ -1325,6 +1538,9 @@ packages:
'@types/unist@3.0.3':
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
+ '@types/use-sync-external-store@0.0.6':
+ resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
+
'@ungap/structured-clone@1.3.0':
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
@@ -1353,6 +1569,9 @@ packages:
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
+ argparse@2.0.1:
+ resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
+
aria-hidden@1.2.6:
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
engines: {node: '>=10'}
@@ -1444,6 +1663,9 @@ packages:
cookie-es@2.0.1:
resolution: {integrity: sha512-aVf4A4hI2w70LnF7GG+7xDQUkliwiXWXFvTjkip4+b64ygDQ2sJPRSKFDHbxn8o0xu9QzPkMuuiWIXyFSE2slA==}
+ crelt@1.0.6:
+ resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
+
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -1493,6 +1715,10 @@ packages:
emoji-mart@5.6.0:
resolution: {integrity: sha512-eJp3QRe79pjwa+duv+n7+5YsNhRcMl812EcFVwrnRvYKoNPoQb5qxU8DG6Bgwji0akHdp6D4Ln6tYLG58MFSow==}
+ entities@4.5.0:
+ resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
+ engines: {node: '>=0.12'}
+
esbuild@0.27.3:
resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==}
engines: {node: '>=18'}
@@ -1502,6 +1728,10 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
+ escape-string-regexp@4.0.0:
+ resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==}
+ engines: {node: '>=10'}
+
escape-string-regexp@5.0.0:
resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==}
engines: {node: '>=12'}
@@ -1517,6 +1747,10 @@ packages:
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
+ fast-equals@5.4.0:
+ resolution: {integrity: sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==}
+ engines: {node: '>=6.0.0'}
+
fast-glob@3.3.3:
resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==}
engines: {node: '>=8.6.0'}
@@ -1540,6 +1774,20 @@ packages:
fraction.js@5.3.4:
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
+ framer-motion@12.38.0:
+ resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
fsevents@2.3.2:
resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1666,6 +1914,12 @@ packages:
lines-and-columns@1.2.4:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
+ linkify-it@5.0.0:
+ resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
+
+ linkifyjs@4.3.2:
+ resolution: {integrity: sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==}
+
lodash@4.17.23:
resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==}
@@ -1684,6 +1938,13 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+ markdown-it-task-lists@2.1.1:
+ resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
+
+ markdown-it@14.1.1:
+ resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
+ hasBin: true
+
markdown-table@3.0.4:
resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==}
@@ -1735,6 +1996,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
+ mdurl@2.0.0:
+ resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==}
+
merge2@1.4.1:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
@@ -1827,6 +2091,26 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ motion-dom@12.38.0:
+ resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
+
+ motion-utils@12.36.0:
+ resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
+
+ motion@12.38.0:
+ resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -1870,6 +2154,9 @@ packages:
oniguruma-to-es@4.3.5:
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
+ orderedmap@2.1.1:
+ resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==}
+
parse-entities@4.0.2:
resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==}
@@ -1963,6 +2250,68 @@ packages:
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
+ prosemirror-changeset@2.4.0:
+ resolution: {integrity: sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng==}
+
+ prosemirror-collab@1.3.1:
+ resolution: {integrity: sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ==}
+
+ prosemirror-commands@1.7.1:
+ resolution: {integrity: sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==}
+
+ prosemirror-dropcursor@1.8.2:
+ resolution: {integrity: sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==}
+
+ prosemirror-gapcursor@1.4.1:
+ resolution: {integrity: sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==}
+
+ prosemirror-history@1.5.0:
+ resolution: {integrity: sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==}
+
+ prosemirror-inputrules@1.5.1:
+ resolution: {integrity: sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==}
+
+ prosemirror-keymap@1.2.3:
+ resolution: {integrity: sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==}
+
+ prosemirror-markdown@1.13.4:
+ resolution: {integrity: sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==}
+
+ prosemirror-menu@1.3.0:
+ resolution: {integrity: sha512-TImyPXCHPcDsSka2/lwJ6WjTASr4re/qWq1yoTTuLOqfXucwF6VcRa2LWCkM/EyTD1UO3CUwiH8qURJoWJRxwg==}
+
+ prosemirror-model@1.25.4:
+ resolution: {integrity: sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==}
+
+ prosemirror-schema-basic@1.2.4:
+ resolution: {integrity: sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ==}
+
+ prosemirror-schema-list@1.5.1:
+ resolution: {integrity: sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==}
+
+ prosemirror-state@1.4.4:
+ resolution: {integrity: sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==}
+
+ prosemirror-tables@1.8.5:
+ resolution: {integrity: sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==}
+
+ prosemirror-trailing-node@3.0.0:
+ resolution: {integrity: sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ==}
+ peerDependencies:
+ prosemirror-model: ^1.22.1
+ prosemirror-state: ^1.4.2
+ prosemirror-view: ^1.33.8
+
+ prosemirror-transform@1.12.0:
+ resolution: {integrity: sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==}
+
+ prosemirror-view@1.41.8:
+ resolution: {integrity: sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==}
+
+ punycode.js@2.3.1:
+ resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
+ engines: {node: '>=6'}
+
queue-microtask@1.2.3:
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
@@ -2072,6 +2421,9 @@ packages:
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
hasBin: true
+ rope-sequence@1.3.4:
+ resolution: {integrity: sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==}
+
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -2159,6 +2511,11 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
+ tiptap-markdown@0.9.0:
+ resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==}
+ peerDependencies:
+ '@tiptap/core': ^3.0.1
+
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@@ -2185,6 +2542,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
+ uc.micro@2.1.0:
+ resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
+
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
@@ -2290,6 +2650,9 @@ packages:
yaml:
optional: true
+ w3c-keyname@2.2.8:
+ resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
+
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
@@ -2917,6 +3280,17 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.14
+ '@radix-ui/react-toggle@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@radix-ui/primitive': 1.1.3
+ '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@radix-ui/react-use-controllable-state': 1.2.2(@types/react@19.2.14)(react@19.2.5)
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ optionalDependencies:
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+
'@radix-ui/react-tooltip@1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -3002,6 +3376,8 @@ snapshots:
'@radix-ui/rect@1.1.1': {}
+ '@remirror/core-constants@3.0.0': {}
+
'@rolldown/pluginutils@1.0.0-beta.27': {}
'@rollup/rollup-android-arm-eabi@4.59.0':
@@ -3281,6 +3657,187 @@ snapshots:
dependencies:
'@tauri-apps/api': 2.10.1
+ '@tiptap/core@3.22.3(@tiptap/pm@3.22.3)':
+ dependencies:
+ '@tiptap/pm': 3.22.3
+
+ '@tiptap/extension-blockquote@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-bold@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-bubble-menu@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+ '@tiptap/pm': 3.22.3
+ optional: true
+
+ '@tiptap/extension-bullet-list@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-code-block@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+ '@tiptap/pm': 3.22.3
+
+ '@tiptap/extension-code@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-document@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-dropcursor@3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-floating-menu@3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
+ dependencies:
+ '@floating-ui/dom': 1.7.6
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+ '@tiptap/pm': 3.22.3
+ optional: true
+
+ '@tiptap/extension-gapcursor@3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-hard-break@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-heading@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-horizontal-rule@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+ '@tiptap/pm': 3.22.3
+
+ '@tiptap/extension-italic@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-link@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+ '@tiptap/pm': 3.22.3
+ linkifyjs: 4.3.2
+
+ '@tiptap/extension-list-item@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-list-keymap@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+ '@tiptap/pm': 3.22.3
+
+ '@tiptap/extension-ordered-list@3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-paragraph@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-placeholder@3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-strike@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-text@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+
+ '@tiptap/extension-underline@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+
+ '@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+ '@tiptap/pm': 3.22.3
+
+ '@tiptap/pm@3.22.3':
+ dependencies:
+ prosemirror-changeset: 2.4.0
+ prosemirror-collab: 1.3.1
+ prosemirror-commands: 1.7.1
+ prosemirror-dropcursor: 1.8.2
+ prosemirror-gapcursor: 1.4.1
+ prosemirror-history: 1.5.0
+ prosemirror-inputrules: 1.5.1
+ prosemirror-keymap: 1.2.3
+ prosemirror-markdown: 1.13.4
+ prosemirror-menu: 1.3.0
+ prosemirror-model: 1.25.4
+ prosemirror-schema-basic: 1.2.4
+ prosemirror-schema-list: 1.5.1
+ prosemirror-state: 1.4.4
+ prosemirror-tables: 1.8.5
+ prosemirror-trailing-node: 3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8)
+ prosemirror-transform: 1.12.0
+ prosemirror-view: 1.41.8
+
+ '@tiptap/react@3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+ '@tiptap/pm': 3.22.3
+ '@types/react': 19.2.14
+ '@types/react-dom': 19.2.3(@types/react@19.2.14)
+ '@types/use-sync-external-store': 0.0.6
+ fast-equals: 5.4.0
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+ use-sync-external-store: 1.6.0(react@19.2.5)
+ optionalDependencies:
+ '@tiptap/extension-bubble-menu': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+ '@tiptap/extension-floating-menu': 3.22.3(@floating-ui/dom@1.7.6)(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+ transitivePeerDependencies:
+ - '@floating-ui/dom'
+
+ '@tiptap/starter-kit@3.22.3':
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+ '@tiptap/extension-blockquote': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
+ '@tiptap/extension-bold': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
+ '@tiptap/extension-bullet-list': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
+ '@tiptap/extension-code': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
+ '@tiptap/extension-code-block': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+ '@tiptap/extension-document': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
+ '@tiptap/extension-dropcursor': 3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
+ '@tiptap/extension-gapcursor': 3.22.3(@tiptap/extensions@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
+ '@tiptap/extension-hard-break': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
+ '@tiptap/extension-heading': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
+ '@tiptap/extension-horizontal-rule': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+ '@tiptap/extension-italic': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
+ '@tiptap/extension-link': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+ '@tiptap/extension-list': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+ '@tiptap/extension-list-item': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
+ '@tiptap/extension-list-keymap': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
+ '@tiptap/extension-ordered-list': 3.22.3(@tiptap/extension-list@3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3))
+ '@tiptap/extension-paragraph': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
+ '@tiptap/extension-strike': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
+ '@tiptap/extension-text': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
+ '@tiptap/extension-underline': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))
+ '@tiptap/extensions': 3.22.3(@tiptap/core@3.22.3(@tiptap/pm@3.22.3))(@tiptap/pm@3.22.3)
+ '@tiptap/pm': 3.22.3
+
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.0
@@ -3316,10 +3873,28 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
+ '@types/linkify-it@3.0.5': {}
+
+ '@types/linkify-it@5.0.0': {}
+
+ '@types/markdown-it@13.0.9':
+ dependencies:
+ '@types/linkify-it': 3.0.5
+ '@types/mdurl': 1.0.5
+
+ '@types/markdown-it@14.1.2':
+ dependencies:
+ '@types/linkify-it': 5.0.0
+ '@types/mdurl': 2.0.0
+
'@types/mdast@4.0.4':
dependencies:
'@types/unist': 3.0.3
+ '@types/mdurl@1.0.5': {}
+
+ '@types/mdurl@2.0.0': {}
+
'@types/ms@2.1.0': {}
'@types/node@25.5.2':
@@ -3338,6 +3913,8 @@ snapshots:
'@types/unist@3.0.3': {}
+ '@types/use-sync-external-store@0.0.6': {}
+
'@ungap/structured-clone@1.3.0': {}
'@vitejs/plugin-react@4.7.0(vite@7.3.1(@types/node@25.5.2)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.3))':
@@ -3365,6 +3942,8 @@ snapshots:
arg@5.0.2: {}
+ argparse@2.0.1: {}
+
aria-hidden@1.2.6:
dependencies:
tslib: 2.8.1
@@ -3455,6 +4034,8 @@ snapshots:
cookie-es@2.0.1: {}
+ crelt@1.0.6: {}
+
cssesc@3.0.0: {}
csstype@3.2.3: {}
@@ -3487,6 +4068,8 @@ snapshots:
emoji-mart@5.6.0: {}
+ entities@4.5.0: {}
+
esbuild@0.27.3:
optionalDependencies:
'@esbuild/aix-ppc64': 0.27.3
@@ -3518,6 +4101,8 @@ snapshots:
escalade@3.2.0: {}
+ escape-string-regexp@4.0.0: {}
+
escape-string-regexp@5.0.0: {}
esprima@4.0.1: {}
@@ -3526,6 +4111,8 @@ snapshots:
extend@3.0.2: {}
+ fast-equals@5.4.0: {}
+
fast-glob@3.3.3:
dependencies:
'@nodelib/fs.stat': 2.0.5
@@ -3548,6 +4135,15 @@ snapshots:
fraction.js@5.3.4: {}
+ framer-motion@12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
+ dependencies:
+ motion-dom: 12.38.0
+ motion-utils: 12.36.0
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+
fsevents@2.3.2:
optional: true
@@ -3669,6 +4265,12 @@ snapshots:
lines-and-columns@1.2.4: {}
+ linkify-it@5.0.0:
+ dependencies:
+ uc.micro: 2.1.0
+
+ linkifyjs@4.3.2: {}
+
lodash@4.17.23: {}
longest-streak@3.1.0: {}
@@ -3685,6 +4287,17 @@ snapshots:
dependencies:
react: 19.2.5
+ markdown-it-task-lists@2.1.1: {}
+
+ markdown-it@14.1.1:
+ dependencies:
+ argparse: 2.0.1
+ entities: 4.5.0
+ linkify-it: 5.0.0
+ mdurl: 2.0.0
+ punycode.js: 2.3.1
+ uc.micro: 2.1.0
+
markdown-table@3.0.4: {}
mdast-util-find-and-replace@3.0.2:
@@ -3845,6 +4458,8 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
+ mdurl@2.0.0: {}
+
merge2@1.4.1: {}
micromark-core-commonmark@2.0.3:
@@ -4043,6 +4658,20 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
+ motion-dom@12.38.0:
+ dependencies:
+ motion-utils: 12.36.0
+
+ motion-utils@12.36.0: {}
+
+ motion@12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5):
+ dependencies:
+ framer-motion: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+
ms@2.1.3: {}
mz@2.7.0:
@@ -4083,6 +4712,8 @@ snapshots:
regex: 6.1.0
regex-recursion: 6.0.2
+ orderedmap@2.1.1: {}
+
parse-entities@4.0.2:
dependencies:
'@types/unist': 2.0.11
@@ -4158,6 +4789,111 @@ snapshots:
property-information@7.1.0: {}
+ prosemirror-changeset@2.4.0:
+ dependencies:
+ prosemirror-transform: 1.12.0
+
+ prosemirror-collab@1.3.1:
+ dependencies:
+ prosemirror-state: 1.4.4
+
+ prosemirror-commands@1.7.1:
+ dependencies:
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.12.0
+
+ prosemirror-dropcursor@1.8.2:
+ dependencies:
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.12.0
+ prosemirror-view: 1.41.8
+
+ prosemirror-gapcursor@1.4.1:
+ dependencies:
+ prosemirror-keymap: 1.2.3
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-view: 1.41.8
+
+ prosemirror-history@1.5.0:
+ dependencies:
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.12.0
+ prosemirror-view: 1.41.8
+ rope-sequence: 1.3.4
+
+ prosemirror-inputrules@1.5.1:
+ dependencies:
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.12.0
+
+ prosemirror-keymap@1.2.3:
+ dependencies:
+ prosemirror-state: 1.4.4
+ w3c-keyname: 2.2.8
+
+ prosemirror-markdown@1.13.4:
+ dependencies:
+ '@types/markdown-it': 14.1.2
+ markdown-it: 14.1.1
+ prosemirror-model: 1.25.4
+
+ prosemirror-menu@1.3.0:
+ dependencies:
+ crelt: 1.0.6
+ prosemirror-commands: 1.7.1
+ prosemirror-history: 1.5.0
+ prosemirror-state: 1.4.4
+
+ prosemirror-model@1.25.4:
+ dependencies:
+ orderedmap: 2.1.1
+
+ prosemirror-schema-basic@1.2.4:
+ dependencies:
+ prosemirror-model: 1.25.4
+
+ prosemirror-schema-list@1.5.1:
+ dependencies:
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.12.0
+
+ prosemirror-state@1.4.4:
+ dependencies:
+ prosemirror-model: 1.25.4
+ prosemirror-transform: 1.12.0
+ prosemirror-view: 1.41.8
+
+ prosemirror-tables@1.8.5:
+ dependencies:
+ prosemirror-keymap: 1.2.3
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.12.0
+ prosemirror-view: 1.41.8
+
+ prosemirror-trailing-node@3.0.0(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.8):
+ dependencies:
+ '@remirror/core-constants': 3.0.0
+ escape-string-regexp: 4.0.0
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-view: 1.41.8
+
+ prosemirror-transform@1.12.0:
+ dependencies:
+ prosemirror-model: 1.25.4
+
+ prosemirror-view@1.41.8:
+ dependencies:
+ prosemirror-model: 1.25.4
+ prosemirror-state: 1.4.4
+ prosemirror-transform: 1.12.0
+
+ punycode.js@2.3.1: {}
+
queue-microtask@1.2.3: {}
react-diff-view@3.3.2(react@19.2.5):
@@ -4331,6 +5067,8 @@ snapshots:
'@rollup/rollup-win32-x64-msvc': 4.59.0
fsevents: 2.3.3
+ rope-sequence@1.3.4: {}
+
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -4440,6 +5178,14 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
+ tiptap-markdown@0.9.0(@tiptap/core@3.22.3(@tiptap/pm@3.22.3)):
+ dependencies:
+ '@tiptap/core': 3.22.3(@tiptap/pm@3.22.3)
+ '@types/markdown-it': 13.0.9
+ markdown-it: 14.1.1
+ markdown-it-task-lists: 2.1.1
+ prosemirror-markdown: 1.13.4
+
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@@ -4461,6 +5207,8 @@ snapshots:
typescript@5.9.3: {}
+ uc.micro@2.1.0: {}
+
undici-types@7.18.2: {}
unified@11.0.5:
@@ -4555,6 +5303,8 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.3
+ w3c-keyname@2.2.8: {}
+
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
diff --git a/desktop/src/features/channels/ui/ChannelPane.tsx b/desktop/src/features/channels/ui/ChannelPane.tsx
index 3a43091de..82eeb6e6a 100644
--- a/desktop/src/features/channels/ui/ChannelPane.tsx
+++ b/desktop/src/features/channels/ui/ChannelPane.tsx
@@ -103,8 +103,7 @@ export const ChannelPane = React.memo(function ChannelPane({
!activeChannel ||
!activeChannel.isMember ||
activeChannel.archivedAt !== null ||
- activeChannel.channelType === "forum" ||
- isSending;
+ activeChannel.channelType === "forum";
return (
diff --git a/desktop/src/features/messages/lib/imageRefExtension.ts b/desktop/src/features/messages/lib/imageRefExtension.ts
new file mode 100644
index 000000000..614b2f7f0
--- /dev/null
+++ b/desktop/src/features/messages/lib/imageRefExtension.ts
@@ -0,0 +1,67 @@
+import { mergeAttributes, Node } from "@tiptap/core";
+
+/**
+ * Custom Tiptap inline node for image reference chips.
+ *
+ * Stores the attachment URL and short hash. Renders as a non-editable
+ * inline pill like `![a3f2]`. On send, the composer resolves these to
+ * `` markdown.
+ */
+export const ImageRefNode = Node.create({
+ name: "imageRef",
+ group: "inline",
+ inline: true,
+ atom: true, // non-editable, treated as a single unit
+
+ addAttributes() {
+ return {
+ url: { default: null },
+ hash: { default: null },
+ mediaType: { default: "image" },
+ thumb: { default: null },
+ };
+ },
+
+ parseHTML() {
+ return [{ tag: "span[data-image-ref]" }];
+ },
+
+ addStorage() {
+ return {
+ markdown: {
+ serialize(
+ state: { write: (text: string) => void },
+ node: { attrs: { hash?: string } },
+ ) {
+ state.write(`![${node.attrs.hash ?? "?"}]`);
+ },
+ parse: {},
+ },
+ };
+ },
+
+ renderHTML({ HTMLAttributes }: { HTMLAttributes: Record
}) {
+ const thumb = HTMLAttributes.thumb || HTMLAttributes.url || "";
+ const hash = HTMLAttributes.hash ?? "?";
+
+ return [
+ "span",
+ mergeAttributes(HTMLAttributes, {
+ "data-image-ref": "",
+ class:
+ "inline-flex items-center rounded-lg overflow-hidden align-baseline cursor-default select-none border border-border/50",
+ contenteditable: "false",
+ title: `![${hash}]`,
+ }),
+ [
+ "img",
+ {
+ src: thumb,
+ alt: `![${hash}]`,
+ class: "inline-block h-16 max-w-48 rounded-lg object-contain",
+ draggable: "false",
+ },
+ ],
+ ];
+ },
+});
diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs
new file mode 100644
index 000000000..9adec5446
--- /dev/null
+++ b/desktop/src/features/messages/lib/mentionHighlightExtension.test.mjs
@@ -0,0 +1,140 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ buildHighlightPatterns,
+ findHighlightMatches,
+} from "./mentionHighlightExtension.ts";
+
+// ── buildHighlightPatterns ────────────────────────────────────────────
+
+test("returns empty array when no names or channels provided", () => {
+ assert.deepEqual(buildHighlightPatterns([], []), []);
+});
+
+test("builds a single pattern for mentions only", () => {
+ const patterns = buildHighlightPatterns(["alice"], []);
+ assert.equal(patterns.length, 1);
+});
+
+test("builds a single pattern for channels only", () => {
+ const patterns = buildHighlightPatterns([], ["general"]);
+ assert.equal(patterns.length, 1);
+});
+
+test("builds two patterns when both names and channels provided", () => {
+ const patterns = buildHighlightPatterns(["alice"], ["general"]);
+ assert.equal(patterns.length, 2);
+});
+
+test("escapes regex special characters in names", () => {
+ const patterns = buildHighlightPatterns(["alice (admin)"], []);
+ // Should not throw when used as regex
+ const matches = findHighlightMatches("@alice (admin) hello", patterns);
+ assert.equal(matches.length, 1);
+ assert.equal(matches[0].match, "@alice (admin)");
+});
+
+test("escapes regex special characters in channel names", () => {
+ const patterns = buildHighlightPatterns([], ["c++ help"]);
+ const matches = findHighlightMatches("#c++ help", patterns);
+ assert.equal(matches.length, 1);
+});
+
+// ── findHighlightMatches — @mentions ──────────────────────────────────
+
+test("matches @mention at start of text", () => {
+ const patterns = buildHighlightPatterns(["alice"], []);
+ const matches = findHighlightMatches("@alice hello", patterns);
+ assert.equal(matches.length, 1);
+ assert.equal(matches[0].match, "@alice");
+ assert.equal(matches[0].from, 0);
+ assert.equal(matches[0].to, 6);
+});
+
+test("matches @mention after whitespace", () => {
+ const patterns = buildHighlightPatterns(["bob"], []);
+ const matches = findHighlightMatches("hey @bob", patterns);
+ assert.equal(matches.length, 1);
+ assert.equal(matches[0].match, "@bob");
+});
+
+test("does not match @mention embedded in a word", () => {
+ const patterns = buildHighlightPatterns(["bob"], []);
+ const matches = findHighlightMatches("email@bob.com", patterns);
+ assert.equal(matches.length, 0);
+});
+
+test("matches are case-insensitive", () => {
+ const patterns = buildHighlightPatterns(["Alice"], []);
+ const matches = findHighlightMatches("@alice @ALICE @Alice", patterns);
+ assert.equal(matches.length, 3);
+});
+
+test("matches multiple different mentions in one string", () => {
+ const patterns = buildHighlightPatterns(["alice", "bob"], []);
+ const matches = findHighlightMatches("@alice and @bob", patterns);
+ assert.equal(matches.length, 2);
+ assert.equal(matches[0].match, "@alice");
+ assert.equal(matches[1].match, "@bob");
+});
+
+test("longer names matched first (no partial overlap)", () => {
+ const patterns = buildHighlightPatterns(["al", "alice"], []);
+ const matches = findHighlightMatches("@alice", patterns);
+ // Should match "alice" not just "al"
+ assert.equal(matches.length, 1);
+ assert.equal(matches[0].match, "@alice");
+});
+
+// ── findHighlightMatches — #channels ──────────────────────────────────
+
+test("matches #channel at start of text", () => {
+ const patterns = buildHighlightPatterns([], ["general"]);
+ const matches = findHighlightMatches("#general is cool", patterns);
+ assert.equal(matches.length, 1);
+ assert.equal(matches[0].match, "#general");
+});
+
+test("matches #channel after whitespace", () => {
+ const patterns = buildHighlightPatterns([], ["random"]);
+ const matches = findHighlightMatches("check #random", patterns);
+ assert.equal(matches.length, 1);
+});
+
+test("does not match #channel embedded in a word", () => {
+ const patterns = buildHighlightPatterns([], ["foo"]);
+ const matches = findHighlightMatches("bar#foo", patterns);
+ assert.equal(matches.length, 0);
+});
+
+test("channel matches are case-insensitive", () => {
+ const patterns = buildHighlightPatterns([], ["General"]);
+ const matches = findHighlightMatches("#general #GENERAL", patterns);
+ assert.equal(matches.length, 2);
+});
+
+// ── findHighlightMatches — mixed ──────────────────────────────────────
+
+test("matches both @mentions and #channels in the same text", () => {
+ const patterns = buildHighlightPatterns(["alice"], ["general"]);
+ const matches = findHighlightMatches("@alice in #general", patterns);
+ assert.equal(matches.length, 2);
+});
+
+test("returns empty array for text with no matches", () => {
+ const patterns = buildHighlightPatterns(["alice"], ["general"]);
+ const matches = findHighlightMatches("nothing here", patterns);
+ assert.equal(matches.length, 0);
+});
+
+test("handles empty text", () => {
+ const patterns = buildHighlightPatterns(["alice"], []);
+ const matches = findHighlightMatches("", patterns);
+ assert.equal(matches.length, 0);
+});
+
+test("handles empty patterns against non-empty text", () => {
+ const matches = findHighlightMatches("@alice #general", []);
+ assert.equal(matches.length, 0);
+});
diff --git a/desktop/src/features/messages/lib/mentionHighlightExtension.ts b/desktop/src/features/messages/lib/mentionHighlightExtension.ts
new file mode 100644
index 000000000..fa06ad628
--- /dev/null
+++ b/desktop/src/features/messages/lib/mentionHighlightExtension.ts
@@ -0,0 +1,135 @@
+import { Extension } from "@tiptap/core";
+import { Plugin, PluginKey } from "@tiptap/pm/state";
+import { Decoration, DecorationSet } from "@tiptap/pm/view";
+
+export const mentionHighlightKey = new PluginKey("mentionHighlight");
+
+/**
+ * TipTap extension that applies inline `mention-highlight` decorations
+ * to `@Name` and `#channel-name` patterns in the document.
+ *
+ * Accepts `names` (display names) and `channelNames` storage options.
+ * On every doc update the plugin scans text nodes and decorates matches.
+ */
+export const MentionHighlightExtension = Extension.create({
+ name: "mentionHighlight",
+
+ addStorage() {
+ return {
+ names: [] as string[],
+ channelNames: [] as string[],
+ };
+ },
+
+ addProseMirrorPlugins() {
+ const extension = this;
+
+ return [
+ new Plugin({
+ key: mentionHighlightKey,
+ state: {
+ init(_, state) {
+ return buildDecorations(state.doc, extension.storage.names, extension.storage.channelNames);
+ },
+ apply(tr, oldDecorations) {
+ if (tr.docChanged || tr.getMeta(mentionHighlightKey)) {
+ return buildDecorations(
+ tr.doc,
+ extension.storage.names,
+ extension.storage.channelNames,
+ );
+ }
+ return oldDecorations;
+ },
+ },
+ props: {
+ decorations(state) {
+ return this.getState(state) ?? DecorationSet.empty;
+ },
+ },
+ }),
+ ];
+ },
+});
+
+/**
+ * Build highlight patterns for @Name and #channel-name matching.
+ * Exported for testing — the patterns are the core logic of this extension.
+ */
+export function buildHighlightPatterns(
+ names: string[],
+ channelNames: string[],
+): RegExp[] {
+ const patterns: RegExp[] = [];
+
+ if (names.length > 0) {
+ const sortedNames = [...names].sort((a, b) => b.length - a.length);
+ const escapedNames = sortedNames.map((n) =>
+ n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
+ );
+ patterns.push(
+ new RegExp(`(?:^|(?<=\\s))@(${escapedNames.join("|")})`, "gi"),
+ );
+ }
+
+ if (channelNames.length > 0) {
+ const sortedChannels = [...channelNames].sort((a, b) => b.length - a.length);
+ const escapedChannels = sortedChannels.map((n) =>
+ n.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
+ );
+ patterns.push(
+ new RegExp(`(?:^|(?<=\\s))#(${escapedChannels.join("|")})`, "gi"),
+ );
+ }
+
+ return patterns;
+}
+
+/**
+ * Find all highlight matches in a text string given a set of patterns.
+ * Returns an array of { from, to } offsets relative to the text start.
+ * Exported for testing.
+ */
+export function findHighlightMatches(
+ text: string,
+ patterns: RegExp[],
+): { from: number; to: number; match: string }[] {
+ const results: { from: number; to: number; match: string }[] = [];
+ for (const pattern of patterns) {
+ pattern.lastIndex = 0;
+ let m: RegExpExecArray | null;
+ while ((m = pattern.exec(text)) !== null) {
+ results.push({ from: m.index, to: m.index + m[0].length, match: m[0] });
+ }
+ }
+ return results;
+}
+
+function buildDecorations(
+ doc: Parameters[0],
+ names: string[],
+ channelNames: string[],
+): DecorationSet {
+ if (names.length === 0 && channelNames.length === 0) return DecorationSet.empty;
+
+ const decorations: Decoration[] = [];
+ const patterns = buildHighlightPatterns(names, channelNames);
+
+ doc.descendants((node, pos) => {
+ if (!node.isText || !node.text) return;
+
+ for (const pattern of patterns) {
+ pattern.lastIndex = 0;
+ let match: RegExpExecArray | null;
+ while ((match = pattern.exec(node.text)) !== null) {
+ const from = pos + match.index;
+ const to = from + match[0].length;
+ decorations.push(
+ Decoration.inline(from, to, { class: "mention-highlight" }),
+ );
+ }
+ }
+ });
+
+ return DecorationSet.create(doc, decorations);
+}
diff --git a/desktop/src/features/messages/lib/useImageRefSuggestions.ts b/desktop/src/features/messages/lib/useImageRefSuggestions.ts
new file mode 100644
index 000000000..721addc52
--- /dev/null
+++ b/desktop/src/features/messages/lib/useImageRefSuggestions.ts
@@ -0,0 +1,113 @@
+import * as React from "react";
+
+import type { BlobDescriptor } from "@/shared/api/tauri";
+import { shortHash } from "./useMediaUpload";
+
+export type ImageRefSuggestion = {
+ url: string;
+ hash: string;
+ type: string;
+ thumb?: string;
+};
+
+/**
+ * Detects `![` typed in the editor and shows suggestions from attached
+ * images. Lightweight alternative to @tiptap/suggestion — works with
+ * plain text + cursor position from the existing bridge.
+ */
+export function useImageRefSuggestions(attachments: BlobDescriptor[]) {
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [query, setQuery] = React.useState("");
+ const [selectedIndex, setSelectedIndex] = React.useState(0);
+
+ const suggestions: ImageRefSuggestion[] = React.useMemo(() => {
+ const items = attachments.map((a) => ({
+ url: a.url,
+ hash: shortHash(a.sha256),
+ type: a.type,
+ thumb: a.thumb,
+ }));
+ if (!query) return items;
+ return items.filter((s) =>
+ s.hash.toLowerCase().includes(query.toLowerCase()),
+ );
+ }, [attachments, query]);
+
+ /** Call on every editor update with the plain text and cursor position. */
+ const updateQuery = React.useCallback(
+ (text: string, cursor: number) => {
+ if (attachments.length === 0) {
+ if (isOpen) setIsOpen(false);
+ return;
+ }
+
+ // Look for `![` before cursor, not yet closed with `]`
+ const before = text.slice(0, cursor);
+ const match = /!\[([^\]]*)$/.exec(before);
+
+ if (match) {
+ setQuery(match[1]);
+ setIsOpen(true);
+ setSelectedIndex(0);
+ } else if (isOpen) {
+ setIsOpen(false);
+ setQuery("");
+ }
+ },
+ [attachments.length, isOpen],
+ );
+
+ /** Handle keyboard navigation. Returns { handled, suggestion? }. */
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ if (!isOpen || suggestions.length === 0) {
+ return { handled: false } as const;
+ }
+
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setSelectedIndex((i) => (i + 1) % suggestions.length);
+ return { handled: true } as const;
+ }
+
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setSelectedIndex((i) => (i <= 0 ? suggestions.length - 1 : i - 1));
+ return { handled: true } as const;
+ }
+
+ if (event.key === "Tab" || event.key === "Enter") {
+ event.preventDefault();
+ const suggestion = suggestions[selectedIndex];
+ setIsOpen(false);
+ setQuery("");
+ return { handled: true, suggestion } as const;
+ }
+
+ if (event.key === "Escape") {
+ event.preventDefault();
+ setIsOpen(false);
+ setQuery("");
+ return { handled: true } as const;
+ }
+
+ return { handled: false } as const;
+ },
+ [isOpen, suggestions, selectedIndex],
+ );
+
+ const clear = React.useCallback(() => {
+ setIsOpen(false);
+ setQuery("");
+ setSelectedIndex(0);
+ }, []);
+
+ return {
+ isOpen,
+ suggestions,
+ selectedIndex,
+ updateQuery,
+ handleKeyDown,
+ clear,
+ };
+}
diff --git a/desktop/src/features/messages/lib/useMediaUpload.test.mjs b/desktop/src/features/messages/lib/useMediaUpload.test.mjs
new file mode 100644
index 000000000..674cce5ff
--- /dev/null
+++ b/desktop/src/features/messages/lib/useMediaUpload.test.mjs
@@ -0,0 +1,144 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+// shortHash is a simple utility: str.slice(0, 4)
+// Inline it here to avoid importing from useMediaUpload.ts which has
+// unresolvable @/shared path aliases outside the bundler.
+function shortHash(hex) {
+ return hex.slice(0, 4);
+}
+
+// ── shortHash ─────────────────────────────────────────────────────────
+
+test("shortHash returns first 4 hex characters", () => {
+ assert.equal(shortHash("abcdef1234567890"), "abcd");
+});
+
+test("shortHash handles minimum-length input", () => {
+ assert.equal(shortHash("abcd"), "abcd");
+});
+
+test("shortHash returns empty string for empty input", () => {
+ assert.equal(shortHash(""), "");
+});
+
+test("shortHash returns partial for short input", () => {
+ assert.equal(shortHash("ab"), "ab");
+});
+
+// ── Upload slot ordering (pure state-update logic) ────────────────────
+// The slot system uses reserveSlots → fillSlot to maintain insertion order
+// when concurrent uploads finish out of order. We test the state-update
+// functions in isolation (they're pure array transforms).
+
+test("reserveSlots creates null placeholders", () => {
+ // Simulate: start with empty slots, reserve 3
+ const prev = [];
+ const count = 3;
+ const next = [...prev, ...new Array(count).fill(null)];
+ assert.deepEqual(next, [null, null, null]);
+});
+
+test("fillSlot places descriptor at correct index", () => {
+ // Simulate: 3 reserved slots, fill index 1 first (out of order)
+ const slots = [null, null, null];
+ const descriptor = { url: "https://example.com/b.png", sha256: "bbbb" };
+ const next = [...slots];
+ next[1] = descriptor;
+ assert.equal(next[0], null);
+ assert.deepEqual(next[1], descriptor);
+ assert.equal(next[2], null);
+});
+
+test("concurrent uploads filling out of order preserves slot positions", () => {
+ // Simulate: reserve 3 slots, uploads finish in order 2, 0, 1
+ const slots = [null, null, null];
+ const a = { url: "a.png", sha256: "aaaa" };
+ const b = { url: "b.png", sha256: "bbbb" };
+ const c = { url: "c.png", sha256: "cccc" };
+
+ // Upload 2 finishes first
+ const step1 = [...slots];
+ step1[2] = c;
+ assert.deepEqual(step1, [null, null, c]);
+
+ // Upload 0 finishes second
+ const step2 = [...step1];
+ step2[0] = a;
+ assert.deepEqual(step2, [a, null, c]);
+
+ // Upload 1 finishes last
+ const step3 = [...step2];
+ step3[1] = b;
+ assert.deepEqual(step3, [a, b, c]);
+
+ // Filter nulls — final order matches original slot order
+ const result = step3.filter((d) => d !== null);
+ assert.deepEqual(result, [a, b, c]);
+});
+
+test("removing an attachment nulls the slot instead of compacting", () => {
+ const a = { url: "a.png", sha256: "aaaa" };
+ const b = { url: "b.png", sha256: "bbbb" };
+ const c = { url: "c.png", sha256: "cccc" };
+ const slots = [a, b, c];
+
+ // Remove b — null out, don't compact
+ const next = slots.map((d) => (d?.url === "b.png" ? null : d));
+ assert.deepEqual(next, [a, null, c]);
+ // Filtered view (what consumers see) drops nulls
+ const filtered = next.filter((d) => d !== null);
+ assert.deepEqual(filtered, [a, c]);
+});
+
+test("removing mid-upload does not corrupt in-flight slot indices", () => {
+ // Scenario: 3 images uploading, image 0 finishes, user removes image 0,
+ // then image 1 and 2 finish — they must land in their original slots.
+ const a = { url: "a.png", sha256: "aaaa" };
+ const b = { url: "b.png", sha256: "bbbb" };
+ const c = { url: "c.png", sha256: "cccc" };
+
+ // Start: 3 reserved slots
+ let slots = [null, null, null];
+
+ // Image 0 finishes
+ slots = [...slots];
+ slots[0] = a;
+ assert.deepEqual(slots, [a, null, null]);
+
+ // User removes image 0 — null out, don't compact
+ slots = slots.map((d) => (d?.url === "a.png" ? null : d));
+ assert.deepEqual(slots, [null, null, null]);
+
+ // Image 1 finishes — fillSlot(1) still works correctly
+ slots = [...slots];
+ slots[1] = b;
+ assert.deepEqual(slots, [null, b, null]);
+
+ // Image 2 finishes — fillSlot(2) still works correctly
+ slots = [...slots];
+ slots[2] = c;
+ assert.deepEqual(slots, [null, b, c]);
+
+ // Consumer view filters nulls
+ const result = slots.filter((d) => d !== null);
+ assert.deepEqual(result, [b, c]);
+});
+
+test("reserveSlots pads if slots array is shorter than expected start index", () => {
+ // Edge case: if somehow prev is shorter than startIndex
+ const prev = [{ url: "a.png", sha256: "aaaa" }];
+ const startIndex = 3;
+ const count = 2;
+ const padded =
+ prev.length < startIndex
+ ? [...prev, ...new Array(startIndex - prev.length).fill(null)]
+ : prev;
+ const next = [...padded, ...new Array(count).fill(null)];
+ assert.equal(next.length, 5);
+ assert.deepEqual(next[0], { url: "a.png", sha256: "aaaa" });
+ assert.equal(next[1], null); // padding
+ assert.equal(next[2], null); // padding
+ assert.equal(next[3], null); // reserved
+ assert.equal(next[4], null); // reserved
+});
diff --git a/desktop/src/features/messages/lib/useMediaUpload.ts b/desktop/src/features/messages/lib/useMediaUpload.ts
index 981492283..9a6b29626 100644
--- a/desktop/src/features/messages/lib/useMediaUpload.ts
+++ b/desktop/src/features/messages/lib/useMediaUpload.ts
@@ -6,7 +6,7 @@ import {
uploadMediaBytes,
} from "@/shared/api/tauri";
-const ALLOWED_TYPES = [
+export const ALLOWED_MEDIA_TYPES = [
"image/jpeg",
"image/png",
"image/gif",
@@ -18,48 +18,101 @@ const ALLOWED_TYPES = [
"video/x-msvideo",
];
+/**
+ * First 4 hex chars of the sha256 — used as a short display name.
+ * Note: 4 hex chars = 65,536 possible values. Collision is unlikely
+ * within a single message's attachments but theoretically possible.
+ * If collisions become an issue, extend to 6+ chars.
+ */
+export function shortHash(sha256: string): string {
+ return sha256.slice(0, 4);
+}
+
type UploadState = {
status: "idle" | "uploading" | "error";
message?: string;
};
-export function useMediaUpload(
- setContent: React.Dispatch>,
-) {
+export function useMediaUpload() {
const [uploadState, setUploadState] = React.useState({
status: "idle",
});
- const [pendingImeta, setPendingImeta] = React.useState([]);
+ /** Number of files currently in-flight. */
+ const [uploadingCount, setUploadingCount] = React.useState(0);
+ /**
+ * Internal slots array — may contain `null` for reserved-but-pending uploads.
+ * Consumers see the filtered `pendingImeta` (nulls stripped) so the public
+ * type stays `BlobDescriptor[]`.
+ */
+ const [imetaSlots, setImetaSlots] = React.useState<
+ (BlobDescriptor | null)[]
+ >([]);
+
+ const pendingImeta = React.useMemo(
+ () => imetaSlots.filter((d): d is BlobDescriptor => d !== null),
+ [imetaSlots],
+ );
const pendingImetaRef = React.useRef(pendingImeta);
pendingImetaRef.current = pendingImeta;
- const onUploaded = React.useCallback(
- (descriptor: BlobDescriptor) => {
- const isVideo = descriptor.type === "video/mp4";
- const markdown = isVideo
- ? `\n\n`
- : `\n\n`;
- setContent((prev) => prev + markdown);
- setPendingImeta((prev) => [...prev, descriptor]);
- setUploadState({ status: "idle" });
+ /** Monotonic slot counter — ensures each batch gets unique indices even
+ * before React flushes the state update. */
+ const nextSlotRef = React.useRef(0);
+
+ /** Reserve `count` null slots at the end; returns the starting index. */
+ const reserveSlots = React.useCallback((count: number): number => {
+ const startIndex = nextSlotRef.current;
+ nextSlotRef.current += count;
+ setImetaSlots((prev) => {
+ // Pad prev if needed (should already be the right length, but be safe)
+ const padded =
+ prev.length < startIndex
+ ? [...prev, ...new Array(startIndex - prev.length).fill(null)]
+ : prev;
+ return [...padded, ...new Array(count).fill(null)];
+ });
+ return startIndex;
+ }, []);
+
+ /** Fill a previously-reserved slot by index. */
+ const fillSlot = React.useCallback(
+ (index: number, descriptor: BlobDescriptor) => {
+ setImetaSlots((prev) => {
+ const next = [...prev];
+ next[index] = descriptor;
+ return next;
+ });
+ setUploadingCount((c) => Math.max(0, c - 1));
},
- [setContent],
+ [],
);
+ /** Append a single descriptor (no pre-reserved slot). */
+ const onUploaded = React.useCallback((descriptor: BlobDescriptor) => {
+ nextSlotRef.current += 1;
+ setImetaSlots((prev) => [...prev, descriptor]);
+ setUploadingCount((c) => Math.max(0, c - 1));
+ }, []);
+
+ const onUploadError = React.useCallback((err: unknown) => {
+ setUploadingCount((c) => Math.max(0, c - 1));
+ setUploadState({ status: "error", message: String(err) });
+ }, []);
+
const handlePaperclip = React.useCallback(async () => {
- setUploadState({ status: "uploading" });
+ setUploadingCount((c) => c + 1);
try {
const descriptor = await pickAndUploadMedia();
if (descriptor) {
onUploaded(descriptor);
} else {
- setUploadState({ status: "idle" });
+ setUploadingCount((c) => Math.max(0, c - 1));
}
} catch (err) {
- setUploadState({ status: "error", message: String(err) });
+ onUploadError(err);
}
- }, [onUploaded]);
+ }, [onUploaded, onUploadError]);
const handleDrop = React.useCallback(
async (event: React.DragEvent) => {
@@ -67,10 +120,11 @@ export function useMediaUpload(
const files = Array.from(event.dataTransfer.files);
if (files.length === 0) return;
- const file = files[0];
- if (!file) return;
+ const validFiles = files.filter((f) =>
+ ALLOWED_MEDIA_TYPES.includes(f.type),
+ );
- if (!ALLOWED_TYPES.includes(file.type)) {
+ if (validFiles.length === 0) {
setUploadState({
status: "error",
message:
@@ -79,16 +133,27 @@ export function useMediaUpload(
return;
}
- setUploadState({ status: "uploading" });
- try {
- const buffer = await file.arrayBuffer();
- const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
- onUploaded(descriptor);
- } catch (err) {
- setUploadState({ status: "error", message: String(err) });
+ setUploadingCount((c) => c + validFiles.length);
+ const baseIndex = reserveSlots(validFiles.length);
+
+ for (let i = 0; i < validFiles.length; i++) {
+ const file = validFiles[i];
+ const slotIndex = baseIndex + i;
+ // Fire-and-forget each upload concurrently — slot preserves order
+ (async () => {
+ try {
+ const buffer = await file.arrayBuffer();
+ const descriptor = await uploadMediaBytes([
+ ...new Uint8Array(buffer),
+ ]);
+ fillSlot(slotIndex, descriptor);
+ } catch (err) {
+ onUploadError(err);
+ }
+ })();
}
},
- [onUploaded],
+ [reserveSlots, fillSlot, onUploadError],
);
const handleDragOver = React.useCallback(
@@ -99,28 +164,77 @@ export function useMediaUpload(
);
const handlePaste = React.useCallback(
- async (event: React.ClipboardEvent) => {
+ async (event: {
+ clipboardData: DataTransfer;
+ preventDefault: () => void;
+ }) => {
const items = Array.from(event.clipboardData.items);
- const mediaItem = items.find((item) => ALLOWED_TYPES.includes(item.type));
- if (!mediaItem) return;
+ const mediaFiles = items
+ .filter((item) => ALLOWED_MEDIA_TYPES.includes(item.type))
+ .map((item) => item.getAsFile())
+ .filter((f): f is File => f !== null);
+ if (mediaFiles.length === 0) return;
event.preventDefault();
- const file = mediaItem.getAsFile();
- if (!file) return;
- setUploadState({ status: "uploading" });
+ setUploadingCount((c) => c + mediaFiles.length);
+ const baseIndex = reserveSlots(mediaFiles.length);
+
+ for (let i = 0; i < mediaFiles.length; i++) {
+ const file = mediaFiles[i];
+ const slotIndex = baseIndex + i;
+ (async () => {
+ try {
+ const buffer = await file.arrayBuffer();
+ const descriptor = await uploadMediaBytes([
+ ...new Uint8Array(buffer),
+ ]);
+ fillSlot(slotIndex, descriptor);
+ } catch (err) {
+ onUploadError(err);
+ }
+ })();
+ }
+ },
+ [reserveSlots, fillSlot, onUploadError],
+ );
+
+ /** Upload a File directly — used by Tiptap's editorProps.handlePaste. */
+ const uploadFile = React.useCallback(
+ async (file: File) => {
+ if (!ALLOWED_MEDIA_TYPES.includes(file.type)) return;
+ setUploadingCount((c) => c + 1);
try {
const buffer = await file.arrayBuffer();
const descriptor = await uploadMediaBytes([...new Uint8Array(buffer)]);
onUploaded(descriptor);
} catch (err) {
- setUploadState({ status: "error", message: String(err) });
+ onUploadError(err);
}
},
- [onUploaded],
+ [onUploaded, onUploadError],
+ );
+
+ const removeAttachment = React.useCallback((url: string) => {
+ setImetaSlots((prev) =>
+ prev.map((d) => (d?.url === url ? null : d)),
+ );
+ }, []);
+
+ /** Public setter — replaces all slots (used by MessageComposer to clear/restore). */
+ const setPendingImeta = React.useCallback(
+ (action: React.SetStateAction) => {
+ setImetaSlots((prev) => {
+ const current = prev.filter((d): d is BlobDescriptor => d !== null);
+ const next = typeof action === "function" ? action(current) : action;
+ nextSlotRef.current = next.length;
+ return next;
+ });
+ },
+ [],
);
- const isUploading = uploadState.status === "uploading";
+ const isUploading = uploadingCount > 0;
return {
handleDragOver,
@@ -130,8 +244,11 @@ export function useMediaUpload(
isUploading,
pendingImeta,
pendingImetaRef,
+ removeAttachment,
setPendingImeta,
setUploadState,
+ uploadFile,
+ uploadingCount,
uploadState,
};
}
diff --git a/desktop/src/features/messages/lib/useRichTextEditor.test.mjs b/desktop/src/features/messages/lib/useRichTextEditor.test.mjs
new file mode 100644
index 000000000..0b87d341f
--- /dev/null
+++ b/desktop/src/features/messages/lib/useRichTextEditor.test.mjs
@@ -0,0 +1,126 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+/**
+ * Pure extraction of the ProseMirror → plain-text cursor mapping logic
+ * from getTextAndCursor in useRichTextEditor.ts.
+ *
+ * Takes a list of "visited nodes" (as the descendants callback would see them)
+ * and a ProseMirror anchor position, returns the plain-text offset.
+ */
+function mapAnchorToPlainText(nodes, anchor) {
+ let offset = 0;
+ let found = false;
+ for (const { isText, isBlock, pos, nodeSize } of nodes) {
+ if (found) break;
+ if (isText) {
+ const nodeEnd = pos + nodeSize;
+ if (anchor <= nodeEnd) {
+ offset += anchor - pos;
+ found = true;
+ break;
+ }
+ offset += nodeSize;
+ } else if (isBlock && pos > 0) {
+ offset += 1;
+ }
+ }
+ return found ? offset : -1; // -1 means "fell through"
+}
+
+// ── Single paragraph ──────────────────────────────────────────────────
+
+test("cursor at start of single paragraph", () => {
+ // doc > paragraph(pos=0) > text "hello"(pos=1, size=5)
+ const nodes = [
+ { isText: false, isBlock: true, pos: 0, nodeSize: 7 },
+ { isText: true, isBlock: false, pos: 1, nodeSize: 5 },
+ ];
+ // Anchor at pos=1 → plain-text offset 0
+ assert.equal(mapAnchorToPlainText(nodes, 1), 0);
+});
+
+test("cursor at end of single paragraph", () => {
+ const nodes = [
+ { isText: false, isBlock: true, pos: 0, nodeSize: 7 },
+ { isText: true, isBlock: false, pos: 1, nodeSize: 5 },
+ ];
+ // Anchor at pos=6 → plain-text offset 5
+ assert.equal(mapAnchorToPlainText(nodes, 6), 5);
+});
+
+test("cursor mid-word in single paragraph", () => {
+ const nodes = [
+ { isText: false, isBlock: true, pos: 0, nodeSize: 7 },
+ { isText: true, isBlock: false, pos: 1, nodeSize: 5 },
+ ];
+ // Anchor at pos=3 → plain-text offset 2 (after "he")
+ assert.equal(mapAnchorToPlainText(nodes, 3), 2);
+});
+
+// ── Two paragraphs (the bug scenario) ─────────────────────────────────
+// doc structure: doc > p1("hello") > p2("world")
+// ProseMirror positions: doc=0, p1=0, "hello"=1..5, /p1=6, p2=7, "world"=8..12, /p2=13
+// textContent = "hello\nworld" (11 chars)
+
+test("cursor in second paragraph accounts for block boundary newline", () => {
+ const nodes = [
+ { isText: false, isBlock: true, pos: 0, nodeSize: 7 }, // p1
+ { isText: true, isBlock: false, pos: 1, nodeSize: 5 }, // "hello"
+ { isText: false, isBlock: true, pos: 7, nodeSize: 7 }, // p2 (pos > 0 → newline)
+ { isText: true, isBlock: false, pos: 8, nodeSize: 5 }, // "world"
+ ];
+ // Anchor at pos=8 → start of "world" → plain-text offset 6 ("hello\n" = 6 chars)
+ assert.equal(mapAnchorToPlainText(nodes, 8), 6);
+});
+
+test("cursor mid-word in second paragraph", () => {
+ const nodes = [
+ { isText: false, isBlock: true, pos: 0, nodeSize: 7 },
+ { isText: true, isBlock: false, pos: 1, nodeSize: 5 },
+ { isText: false, isBlock: true, pos: 7, nodeSize: 7 },
+ { isText: true, isBlock: false, pos: 8, nodeSize: 5 },
+ ];
+ // Anchor at pos=10 → "wo|rld" → plain-text offset 8 ("hello\nwo" = 8 chars)
+ assert.equal(mapAnchorToPlainText(nodes, 10), 8);
+});
+
+// ── Three paragraphs (cumulative drift) ───────────────────────────────
+// "aaa\nbbb\nccc" — without the fix, offset would drift by 1 per boundary
+
+test("cursor in third paragraph accounts for two block boundaries", () => {
+ const nodes = [
+ { isText: false, isBlock: true, pos: 0, nodeSize: 5 }, // p1
+ { isText: true, isBlock: false, pos: 1, nodeSize: 3 }, // "aaa"
+ { isText: false, isBlock: true, pos: 5, nodeSize: 5 }, // p2
+ { isText: true, isBlock: false, pos: 6, nodeSize: 3 }, // "bbb"
+ { isText: false, isBlock: true, pos: 10, nodeSize: 5 }, // p3
+ { isText: true, isBlock: false, pos: 11, nodeSize: 3 }, // "ccc"
+ ];
+ // Anchor at pos=11 → start of "ccc" → plain-text offset 8 ("aaa\nbbb\n" = 8 chars)
+ assert.equal(mapAnchorToPlainText(nodes, 11), 8);
+});
+
+test("cursor at end of third paragraph", () => {
+ const nodes = [
+ { isText: false, isBlock: true, pos: 0, nodeSize: 5 },
+ { isText: true, isBlock: false, pos: 1, nodeSize: 3 },
+ { isText: false, isBlock: true, pos: 5, nodeSize: 5 },
+ { isText: true, isBlock: false, pos: 6, nodeSize: 3 },
+ { isText: false, isBlock: true, pos: 10, nodeSize: 5 },
+ { isText: true, isBlock: false, pos: 11, nodeSize: 3 },
+ ];
+ // Anchor at pos=14 → end of "ccc" → plain-text offset 11 ("aaa\nbbb\nccc" = 11 chars)
+ assert.equal(mapAnchorToPlainText(nodes, 14), 11);
+});
+
+// ── First paragraph is unaffected (pos === 0, no newline) ─────────────
+
+test("first block boundary at pos 0 does not add newline", () => {
+ const nodes = [
+ { isText: false, isBlock: true, pos: 0, nodeSize: 7 },
+ { isText: true, isBlock: false, pos: 1, nodeSize: 5 },
+ ];
+ // Anchor at pos=4 → plain-text offset 3 (no extra newline for first block)
+ assert.equal(mapAnchorToPlainText(nodes, 4), 3);
+});
diff --git a/desktop/src/features/messages/lib/useRichTextEditor.ts b/desktop/src/features/messages/lib/useRichTextEditor.ts
new file mode 100644
index 000000000..69d9b49a6
--- /dev/null
+++ b/desktop/src/features/messages/lib/useRichTextEditor.ts
@@ -0,0 +1,352 @@
+import * as React from "react";
+
+import { Markdown as TiptapMarkdown } from "tiptap-markdown";
+import { useEditor, type Editor } from "@tiptap/react";
+import StarterKit from "@tiptap/starter-kit";
+import Placeholder from "@tiptap/extension-placeholder";
+import Link from "@tiptap/extension-link";
+import { Extension } from "@tiptap/core";
+import { TextSelection } from "@tiptap/pm/state";
+
+import { ImageRefNode } from "./imageRefExtension";
+import { MentionHighlightExtension, mentionHighlightKey } from "./mentionHighlightExtension";
+
+export type RichTextEditorOptions = {
+ placeholder?: string;
+ onUpdate?: (info: { markdown: string; text: string }) => void;
+ editable?: boolean;
+ mentionNames?: string[];
+ channelNames?: string[];
+};
+
+/**
+ * Creates and manages a Tiptap editor configured for Markdown output.
+ *
+ * The editor uses StarterKit (bold, italic, strike, code, blockquote, lists,
+ * headings, code blocks, hard breaks) plus Link and the tiptap-markdown
+ * extension for serialisation.
+ *
+ * `getMarkdown()` returns the current document as a Markdown string.
+ */
+export function useRichTextEditor({
+ placeholder,
+ onUpdate,
+ editable = true,
+ mentionNames,
+ channelNames,
+}: RichTextEditorOptions) {
+ const onUpdateRef = React.useRef(onUpdate);
+ onUpdateRef.current = onUpdate;
+
+ const editor = useEditor(
+ {
+ extensions: [
+ StarterKit.configure({
+ // Use hard breaks (Shift+Enter) — Enter submits the message.
+ hardBreak: {
+ keepMarks: true,
+ },
+ // Disable heading input rules — in a chat composer, typing "# "
+ // should keep the literal "#", not convert to a heading node.
+ // Users type #channel-name and the "#" would get eaten otherwise.
+ heading: false,
+ // Disable the trailing-node plugin — it forces an empty paragraph
+ // after block nodes (lists, blockquotes, code blocks) which creates
+ // a phantom empty line in the compact message composer.
+ trailingNode: false,
+ }),
+ // Shift+Enter inside lists/blockquotes: split the node instead of
+ // inserting a hard break so continuation lines keep their formatting.
+ Extension.create({
+ name: "smartShiftEnter",
+ addKeyboardShortcuts() {
+ // Exit a list by removing the empty last item and inserting a
+ // paragraph after the list. Works for both single-item and
+ // multi-item lists.
+ const exitListIfEmptyLast = (ed: typeof this.editor): boolean => {
+ if (!ed.isActive("listItem")) return false;
+ const { $from } = ed.state.selection;
+
+ // Walk up to find the listItem node (handles nested structures).
+ let listItemDepth = -1;
+ for (let d = $from.depth; d >= 1; d--) {
+ if ($from.node(d).type.name === "listItem") {
+ listItemDepth = d;
+ break;
+ }
+ }
+ if (listItemDepth < 1) return false;
+
+ const listItem = $from.node(listItemDepth);
+ const isEmpty =
+ listItem.childCount === 1 &&
+ listItem.firstChild?.textContent === "";
+ if (!isEmpty) return false;
+
+ // Only trigger on the last item in the list.
+ const listDepth = listItemDepth - 1;
+ const list = $from.node(listDepth);
+ const itemIndex = $from.index(listDepth);
+ if (itemIndex !== list.childCount - 1) return false;
+
+ const { tr, schema } = ed.state;
+ if (list.childCount === 1) {
+ // Only item → replace the entire list with an empty paragraph.
+ const listStart = $from.before(listDepth);
+ const listEnd = $from.after(listDepth);
+ const para = schema.nodes.paragraph.create();
+ tr.replaceWith(listStart, listEnd, para);
+ tr.setSelection(
+ TextSelection.near(tr.doc.resolve(listStart + 1)),
+ );
+ } else {
+ // Multiple items → delete the empty item, insert paragraph
+ // after the list, and move cursor there.
+ const itemStart = $from.before(listItemDepth);
+ const itemEnd = $from.after(listItemDepth);
+ tr.delete(itemStart, itemEnd);
+ const listEnd = tr.mapping.map($from.after(listDepth));
+ const para = schema.nodes.paragraph.create();
+ tr.insert(listEnd, para);
+ tr.setSelection(
+ TextSelection.near(tr.doc.resolve(listEnd + 1)),
+ );
+ }
+ ed.view.dispatch(tr);
+ return true;
+ };
+
+ return {
+ "Shift-Enter": ({ editor: ed }) => {
+ // Empty last list item → exit list to paragraph below.
+ if (exitListIfEmptyLast(ed)) return true;
+ // Non-empty or non-last list item → split.
+ if (ed.isActive("listItem")) {
+ return ed.commands.splitListItem("listItem");
+ }
+ if (ed.isActive("blockquote")) {
+ // Empty blockquote paragraph → exit the blockquote.
+ const { $from } = ed.state.selection;
+ if ($from.parent.textContent === "") {
+ return ed.commands.lift("blockquote");
+ }
+ // Non-empty → split the paragraph within the blockquote.
+ return ed.chain().splitBlock().focus().run();
+ }
+ // Default: hard break (StarterKit handles it).
+ return false;
+ },
+ ArrowDown: ({ editor: ed }) => {
+ // Empty last list item + Down → exit list to paragraph below.
+ return exitListIfEmptyLast(ed);
+ },
+ };
+ },
+ }),
+ MentionHighlightExtension,
+ Placeholder.configure({
+ placeholder: placeholder ?? "Write a message…",
+ }),
+ Link.configure({
+ openOnClick: false,
+ autolink: true,
+ linkOnPaste: true,
+ HTMLAttributes: {
+ class: "text-primary underline underline-offset-4 cursor-pointer",
+ },
+ }),
+ TiptapMarkdown.configure({
+ html: false,
+ transformPastedText: true,
+ transformCopiedText: true,
+ breaks: true,
+ }),
+ ImageRefNode,
+ ],
+ editorProps: {
+ attributes: {
+ class:
+ "min-h-0 resize-none overflow-y-hidden border-0 bg-transparent px-0 py-0 text-sm leading-6 md:leading-6 shadow-none focus-visible:ring-0 caret-foreground outline-none prose-sm max-w-none",
+ "data-testid": "message-input",
+ },
+ },
+ onUpdate: ({ editor: ed }) => {
+ const markdown = getMarkdownFromEditor(ed);
+ const text = ed.state.doc.textContent;
+ onUpdateRef.current?.({ markdown, text });
+ },
+ },
+ [placeholder],
+ );
+
+ // Toggle editable without destroying the editor instance.
+ React.useEffect(() => {
+ if (editor && editor.isEditable !== editable) {
+ editor.setEditable(editable);
+ }
+ }, [editor, editable]);
+
+ // Keep mention/channel-highlight decorations in sync with known names.
+ // NOTE: We use `editor.storage.mentionHighlight` (the mutable storage object
+ // shared with the ProseMirror plugin closure) rather than finding the
+ // extension instance via extensionManager — the instance's `.storage` getter
+ // returns a fresh spread-copy on every access, so mutations are silently lost.
+ React.useEffect(() => {
+ if (!editor) return;
+ // biome-ignore lint/suspicious/noExplicitAny: TipTap's Storage type doesn't include dynamic extension keys
+ const storage = (editor.storage as any).mentionHighlight as
+ | { names: string[]; channelNames: string[] }
+ | undefined;
+ if (storage) {
+ storage.names = mentionNames ?? [];
+ storage.channelNames = channelNames ?? [];
+ // Force the plugin to re-decorate by dispatching a metadata transaction.
+ const { tr } = editor.state;
+ editor.view.dispatch(tr.setMeta(mentionHighlightKey, true));
+ }
+ }, [editor, mentionNames, channelNames]);
+
+ const getMarkdown = React.useCallback((): string => {
+ if (!editor) return "";
+ return getMarkdownFromEditor(editor);
+ }, [editor]);
+
+ const isEmpty = React.useCallback((): boolean => {
+ if (!editor) return true;
+ return editor.isEmpty;
+ }, [editor]);
+
+ const clearContent = React.useCallback(() => {
+ editor?.commands.clearContent(true);
+ }, [editor]);
+
+ const setContent = React.useCallback(
+ (markdown: string) => {
+ if (!editor) return;
+ editor.commands.setContent(markdown);
+ },
+ [editor],
+ );
+
+ const focus = React.useCallback(() => {
+ editor?.commands.focus("end");
+ }, [editor]);
+
+ /**
+ * Replace editor content and append a trailing space that survives parsing.
+ *
+ * `setContent(markdown)` roundtrips through TipTap's markdown parser which
+ * strips trailing whitespace from text nodes. TipTap's `insertContent(" ")`
+ * also normalises it away. This method bypasses both by creating a raw
+ * ProseMirror text node and inserting it via a direct transaction — the
+ * only path that reliably preserves a literal trailing space.
+ *
+ * Used by mention and channel-link autocomplete insertion.
+ */
+ const setContentWithTrailingSpace = React.useCallback(
+ (markdown: string) => {
+ if (!editor) return;
+ editor.commands.setContent(markdown);
+ // Insert a literal space via a raw ProseMirror transaction so it
+ // bypasses TipTap's content parser which strips trailing whitespace.
+ const { tr, schema, doc } = editor.state;
+ const endPos = doc.content.size - 1; // before the closing node token
+ const spaceNode = schema.text(" ");
+ tr.insert(endPos, spaceNode);
+ // Place cursor after the inserted space.
+ const cursorPos = endPos + spaceNode.nodeSize;
+ tr.setSelection(TextSelection.create(tr.doc, cursorPos));
+ editor.view.dispatch(tr);
+ editor.view.focus();
+ },
+ [editor],
+ );
+
+ /**
+ * Returns the plain-text content and an approximate cursor offset.
+ * Used to bridge the existing useMentions / useChannelLinks hooks which
+ * were designed for a plain