From e468f5ae44e1421a8390dd98133e89c5510bc6e5 Mon Sep 17 00:00:00 2001 From: cloudwithax Date: Sun, 15 Feb 2026 16:46:19 -0500 Subject: [PATCH] feat: add GIF search and trending functionality with Tauri commands - Implemented `search_gifs` and `get_trending_gifs` commands in Tauri for GIF retrieval. - Created a new protocol for GIF requests and responses. - Added `EmojiPicker` component for emoji selection with recent emoji storage. - Developed `GifPicker` component for searching and selecting GIFs. - Introduced emoji data management with recent emoji tracking. - Added markdown conversion utilities for rendering formatted text. - Updated Vite configuration to optimize dependencies for Prosemirror. --- bun.lock | 135 ++++++- package.json | 11 +- src-tauri/src/commands/chat.rs | 54 ++- src-tauri/src/commands/gif.rs | 63 +++ src-tauri/src/commands/identity.rs | 31 ++ src-tauri/src/commands/mod.rs | 1 + src-tauri/src/lib.rs | 3 + src-tauri/src/node/behaviour.rs | 8 +- src-tauri/src/node/mod.rs | 104 ++++- src-tauri/src/node/swarm.rs | 12 +- src-tauri/src/protocol/gif.rs | 30 ++ src-tauri/src/protocol/messages.rs | 3 +- src-tauri/src/protocol/mod.rs | 1 + src-tauri/tauri.conf.json | 2 +- src/App.tsx | 10 + src/components/chat/EmojiPicker.tsx | 216 ++++++++++ src/components/chat/GifPicker.tsx | 197 +++++++++ src/components/chat/Message.tsx | 23 +- src/components/chat/MessageInput.tsx | 184 ++++++--- src/components/common/Avatar.tsx | 2 + src/components/common/ProfileCard.tsx | 23 +- src/components/common/ProfileModal.tsx | 27 +- src/components/common/UserFooter.tsx | 3 + src/components/layout/DMSidebar.tsx | 4 +- src/lib/emoji-data.ts | 532 +++++++++++++++++++++++++ src/lib/markdown.ts | 121 ++++++ src/lib/tauri.ts | 20 + src/lib/types.ts | 19 +- src/stores/members.ts | 38 +- src/styles/app.css | 298 ++++++++++++++ vite.config.ts | 10 + 31 files changed, 2107 insertions(+), 78 deletions(-) create mode 100644 src-tauri/src/commands/gif.rs create mode 100644 src-tauri/src/protocol/gif.rs create mode 100644 src/components/chat/EmojiPicker.tsx create mode 100644 src/components/chat/GifPicker.tsx create mode 100644 src/lib/emoji-data.ts create mode 100644 src/lib/markdown.ts diff --git a/bun.lock b/bun.lock index a8b4d5e..4d0753c 100644 --- a/bun.lock +++ b/bun.lock @@ -10,10 +10,19 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-shell": "^2", "@thisbeyond/solid-dnd": "^0.7.5", + "@tiptap/core": "^2.12.0", + "@tiptap/extension-placeholder": "^2.12.0", + "@tiptap/pm": "^2.12.0", + "@tiptap/starter-kit": "^2.12.0", "lucide-solid": "^0.469.0", "motion": "^12.0.0", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.11.0", + "prosemirror-view": "^1.41.6", "solid-js": "^1.9.3", "solid-motionone": "^1.0.0", + "tiptap-solid": "^1.2.1", }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -140,6 +149,10 @@ "@motionone/utils": ["@motionone/utils@10.18.0", "", { "dependencies": { "@motionone/types": "^10.17.1", "hey-listen": "^1.0.8", "tslib": "^2.3.1" } }, "sha512-3XVF7sgyTSI2KWvTf6uLlBJ5iAgRgmvp3bpuOiQJvInd4nZ19ET8lX5unn30SlmRH7hXbBbH+Gxd0m0klJ3Xtw=="], + "@popperjs/core": ["@popperjs/core@2.11.8", "", {}, "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="], + + "@remirror/core-constants": ["@remirror/core-constants@3.0.0", "", {}, "sha512-42aWfPrimMfDKDi4YegyS7x+/0tlzaqwPQCULLanv3DMIlu96KTJR0fM5isWX2UViOqlGnX6YFgqWepcX+XMNg=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], @@ -258,6 +271,56 @@ "@thisbeyond/solid-dnd": ["@thisbeyond/solid-dnd@0.7.5", "", { "peerDependencies": { "solid-js": "^1.5" } }, "sha512-DfI5ff+yYGpK9M21LhYwIPlbP2msKxN2ARwuu6GF8tT1GgNVDTI8VCQvH4TJFoVApP9d44izmAcTh/iTCH2UUw=="], + "@tiptap/core": ["@tiptap/core@2.27.2", "", { "peerDependencies": { "@tiptap/pm": "^2.7.0" } }, "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ=="], + + "@tiptap/extension-blockquote": ["@tiptap/extension-blockquote@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw=="], + + "@tiptap/extension-bold": ["@tiptap/extension-bold@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A=="], + + "@tiptap/extension-bubble-menu": ["@tiptap/extension-bubble-menu@2.27.2", "", { "dependencies": { "tippy.js": "^6.3.7" }, "peerDependencies": { "@tiptap/core": "^2.7.0", "@tiptap/pm": "^2.7.0" } }, "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w=="], + + "@tiptap/extension-bullet-list": ["@tiptap/extension-bullet-list@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw=="], + + "@tiptap/extension-code": ["@tiptap/extension-code@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA=="], + + "@tiptap/extension-code-block": ["@tiptap/extension-code-block@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0", "@tiptap/pm": "^2.7.0" } }, "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw=="], + + "@tiptap/extension-document": ["@tiptap/extension-document@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA=="], + + "@tiptap/extension-dropcursor": ["@tiptap/extension-dropcursor@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0", "@tiptap/pm": "^2.7.0" } }, "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw=="], + + "@tiptap/extension-floating-menu": ["@tiptap/extension-floating-menu@2.27.2", "", { "dependencies": { "tippy.js": "^6.3.7" }, "peerDependencies": { "@tiptap/core": "^2.7.0", "@tiptap/pm": "^2.7.0" } }, "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ=="], + + "@tiptap/extension-gapcursor": ["@tiptap/extension-gapcursor@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0", "@tiptap/pm": "^2.7.0" } }, "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA=="], + + "@tiptap/extension-hard-break": ["@tiptap/extension-hard-break@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg=="], + + "@tiptap/extension-heading": ["@tiptap/extension-heading@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw=="], + + "@tiptap/extension-history": ["@tiptap/extension-history@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0", "@tiptap/pm": "^2.7.0" } }, "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw=="], + + "@tiptap/extension-horizontal-rule": ["@tiptap/extension-horizontal-rule@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0", "@tiptap/pm": "^2.7.0" } }, "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg=="], + + "@tiptap/extension-italic": ["@tiptap/extension-italic@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg=="], + + "@tiptap/extension-list-item": ["@tiptap/extension-list-item@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg=="], + + "@tiptap/extension-ordered-list": ["@tiptap/extension-ordered-list@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w=="], + + "@tiptap/extension-paragraph": ["@tiptap/extension-paragraph@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A=="], + + "@tiptap/extension-placeholder": ["@tiptap/extension-placeholder@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0", "@tiptap/pm": "^2.7.0" } }, "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg=="], + + "@tiptap/extension-strike": ["@tiptap/extension-strike@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw=="], + + "@tiptap/extension-text": ["@tiptap/extension-text@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA=="], + + "@tiptap/extension-text-style": ["@tiptap/extension-text-style@2.27.2", "", { "peerDependencies": { "@tiptap/core": "^2.7.0" } }, "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA=="], + + "@tiptap/pm": ["@tiptap/pm@2.27.2", "", { "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.23.0", "prosemirror-schema-basic": "^1.2.3", "prosemirror-schema-list": "^1.4.1", "prosemirror-state": "^1.4.3", "prosemirror-tables": "^1.6.4", "prosemirror-trailing-node": "^3.0.0", "prosemirror-transform": "^1.10.2", "prosemirror-view": "^1.37.0" } }, "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA=="], + + "@tiptap/starter-kit": ["@tiptap/starter-kit@2.27.2", "", { "dependencies": { "@tiptap/core": "^2.27.2", "@tiptap/extension-blockquote": "^2.27.2", "@tiptap/extension-bold": "^2.27.2", "@tiptap/extension-bullet-list": "^2.27.2", "@tiptap/extension-code": "^2.27.2", "@tiptap/extension-code-block": "^2.27.2", "@tiptap/extension-document": "^2.27.2", "@tiptap/extension-dropcursor": "^2.27.2", "@tiptap/extension-gapcursor": "^2.27.2", "@tiptap/extension-hard-break": "^2.27.2", "@tiptap/extension-heading": "^2.27.2", "@tiptap/extension-history": "^2.27.2", "@tiptap/extension-horizontal-rule": "^2.27.2", "@tiptap/extension-italic": "^2.27.2", "@tiptap/extension-list-item": "^2.27.2", "@tiptap/extension-ordered-list": "^2.27.2", "@tiptap/extension-paragraph": "^2.27.2", "@tiptap/extension-strike": "^2.27.2", "@tiptap/extension-text": "^2.27.2", "@tiptap/extension-text-style": "^2.27.2", "@tiptap/pm": "^2.27.2" } }, "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -268,6 +331,14 @@ "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], + + "@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], + + "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.3", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-5HOwwt0BYiv/zxl7j8Pf2bGL6rDXfV6nUhLs8ygBX+EFJXzBPHM/euj9j/6deMZ6wa52Wb2PBaAV5U/jKwIY1w=="], "babel-preset-solid": ["babel-preset-solid@1.9.10", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.3" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.10" }, "optionalPeers": ["solid-js"] }, "sha512-HCelrgua/Y+kqO8RyL04JBWS/cVdrtUv/h45GntgQY+cJl4eBcKkCDV3TdMjtKx1nXwRaR9QXslM/Npm1dxdZQ=="], @@ -280,6 +351,8 @@ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], @@ -290,12 +363,14 @@ "enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "framer-motion": ["framer-motion@12.34.0", "", { "dependencies": { "motion-dom": "^12.34.0", "motion-utils": "^12.29.2", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg=="], @@ -344,12 +419,18 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + "linkify-it": ["linkify-it@5.0.0", "", { "dependencies": { "uc.micro": "^2.0.0" } }, "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ=="], + "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], "lucide-solid": ["lucide-solid@0.469.0", "", { "peerDependencies": { "solid-js": "^1.4.7" } }, "sha512-kBZl5AFg02g/wcwaapTwOwjHw0VvyyFmZm3BE6McKjs0GjiauWtjYbrhf2bCtDpScEtcinhIG/LpRExBlIV3fA=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + "markdown-it": ["markdown-it@14.1.1", "", { "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", "linkify-it": "^5.0.0", "mdurl": "^2.0.0", "punycode.js": "^2.3.1", "uc.micro": "^2.1.0" }, "bin": { "markdown-it": "bin/markdown-it.mjs" } }, "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA=="], + + "mdurl": ["mdurl@2.0.0", "", {}, "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="], + "merge-anything": ["merge-anything@5.1.7", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ=="], "motion": ["motion@12.34.0", "", { "dependencies": { "framer-motion": "^12.34.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-01Sfa/zgsD/di8zA/uFW5Eb7/SPXoGyUfy+uMRMW5Spa8j0z/UbfQewAYvPMYFCXRlyD6e5aLHh76TxeeJD+RA=="], @@ -364,6 +445,8 @@ "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + "orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="], + "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -372,12 +455,52 @@ "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + "prosemirror-changeset": ["prosemirror-changeset@2.4.0", "", { "dependencies": { "prosemirror-transform": "^1.0.0" } }, "sha512-LvqH2v7Q2SF6yxatuPP2e8vSUKS/L+xAU7dPDC4RMyHMhZoGDfBC74mYuyYF4gLqOEG758wajtyhNnsTkuhvng=="], + + "prosemirror-collab": ["prosemirror-collab@1.3.1", "", { "dependencies": { "prosemirror-state": "^1.0.0" } }, "sha512-4SnynYR9TTYaQVXd/ieUvsVV4PDMBzrq2xPUWutHivDuOshZXqQ5rGbZM84HEaXKbLdItse7weMGOUdDVcLKEQ=="], + + "prosemirror-commands": ["prosemirror-commands@1.7.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.10.2" } }, "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w=="], + + "prosemirror-dropcursor": ["prosemirror-dropcursor@1.8.2", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0", "prosemirror-view": "^1.1.0" } }, "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw=="], + + "prosemirror-gapcursor": ["prosemirror-gapcursor@1.4.0", "", { "dependencies": { "prosemirror-keymap": "^1.0.0", "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-view": "^1.0.0" } }, "sha512-z00qvurSdCEWUIulij/isHaqu4uLS8r/Fi61IbjdIPJEonQgggbJsLnstW7Lgdk4zQ68/yr6B6bf7sJXowIgdQ=="], + + "prosemirror-history": ["prosemirror-history@1.5.0", "", { "dependencies": { "prosemirror-state": "^1.2.2", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.31.0", "rope-sequence": "^1.3.0" } }, "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg=="], + + "prosemirror-inputrules": ["prosemirror-inputrules@1.5.1", "", { "dependencies": { "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.0.0" } }, "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw=="], + + "prosemirror-keymap": ["prosemirror-keymap@1.2.3", "", { "dependencies": { "prosemirror-state": "^1.0.0", "w3c-keyname": "^2.2.0" } }, "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw=="], + + "prosemirror-markdown": ["prosemirror-markdown@1.13.4", "", { "dependencies": { "@types/markdown-it": "^14.0.0", "markdown-it": "^14.0.0", "prosemirror-model": "^1.25.0" } }, "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw=="], + + "prosemirror-menu": ["prosemirror-menu@1.2.5", "", { "dependencies": { "crelt": "^1.0.0", "prosemirror-commands": "^1.0.0", "prosemirror-history": "^1.0.0", "prosemirror-state": "^1.0.0" } }, "sha512-qwXzynnpBIeg1D7BAtjOusR+81xCp53j7iWu/IargiRZqRjGIlQuu1f3jFi+ehrHhWMLoyOQTSRx/IWZJqOYtQ=="], + + "prosemirror-model": ["prosemirror-model@1.25.4", "", { "dependencies": { "orderedmap": "^2.0.0" } }, "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA=="], + + "prosemirror-schema-basic": ["prosemirror-schema-basic@1.2.4", "", { "dependencies": { "prosemirror-model": "^1.25.0" } }, "sha512-ELxP4TlX3yr2v5rM7Sb70SqStq5NvI15c0j9j/gjsrO5vaw+fnnpovCLEGIcpeGfifkuqJwl4fon6b+KdrODYQ=="], + + "prosemirror-schema-list": ["prosemirror-schema-list@1.5.1", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.7.3" } }, "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q=="], + + "prosemirror-state": ["prosemirror-state@1.4.4", "", { "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", "prosemirror-view": "^1.27.0" } }, "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw=="], + + "prosemirror-tables": ["prosemirror-tables@1.8.5", "", { "dependencies": { "prosemirror-keymap": "^1.2.3", "prosemirror-model": "^1.25.4", "prosemirror-state": "^1.4.4", "prosemirror-transform": "^1.10.5", "prosemirror-view": "^1.41.4" } }, "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw=="], + + "prosemirror-trailing-node": ["prosemirror-trailing-node@3.0.0", "", { "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" } }, "sha512-xiun5/3q0w5eRnGYfNlW1uU9W6x5MoFKWwq/0TIRgt09lv7Hcser2QYV8t4muXbEr+Fwo0geYn79Xs4GKywrRQ=="], + + "prosemirror-transform": ["prosemirror-transform@1.11.0", "", { "dependencies": { "prosemirror-model": "^1.21.0" } }, "sha512-4I7Ce4KpygXb9bkiPS3hTEk4dSHorfRw8uI0pE8IhxlK2GXsqv5tIA7JUSxtSu7u8APVOTtbUBxTmnHIxVkIJw=="], + + "prosemirror-view": ["prosemirror-view@1.41.6", "", { "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", "prosemirror-transform": "^1.1.0" } }, "sha512-mxpcDG4hNQa/CPtzxjdlir5bJFDlm0/x5nGBbStB2BWX+XOQ9M8ekEG+ojqB5BcVu2Rc80/jssCMZzSstJuSYg=="], + + "punycode.js": ["punycode.js@2.3.1", "", {}, "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], + "rope-sequence": ["rope-sequence@1.3.4", "", {}, "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], @@ -400,10 +523,16 @@ "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tippy.js": ["tippy.js@6.3.7", "", { "dependencies": { "@popperjs/core": "^2.9.0" } }, "sha512-E1d3oP2emgJ9dRQZdf3Kkn0qJgI6ZLpyS5z6ZkY1DF3kaQaBsGZsndEpHwx+eC+tYM41HaSNvNtLx8tU57FzTQ=="], + + "tiptap-solid": ["tiptap-solid@1.2.1", "", { "peerDependencies": { "@tiptap/core": "^2.2.2", "@tiptap/extension-bubble-menu": "^2.2.2", "@tiptap/extension-floating-menu": "^2.2.2", "@tiptap/pm": "^2.12.0", "solid-js": "^1.8.17" } }, "sha512-TNeR7TysN8RHyZUGSTt/q9rbyLeK1EMk8Upksjf1eC0IomlCC983cpJQo+s3vuScrGVO4eJ/UAbRNNeNbB1tdA=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], + "uc.micro": ["uc.micro@2.1.0", "", {}, "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="], + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], @@ -412,6 +541,8 @@ "vitefu": ["vitefu@1.1.1", "", { "peerDependencies": { "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["vite"] }, "sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -427,5 +558,7 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], } } diff --git a/package.json b/package.json index d302e78..605be72 100644 --- a/package.json +++ b/package.json @@ -17,10 +17,19 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-shell": "^2", "@thisbeyond/solid-dnd": "^0.7.5", + "@tiptap/core": "^2.12.0", + "@tiptap/extension-placeholder": "^2.12.0", + "@tiptap/pm": "^2.12.0", + "@tiptap/starter-kit": "^2.12.0", "lucide-solid": "^0.469.0", "motion": "^12.0.0", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.11.0", + "prosemirror-view": "^1.41.6", "solid-js": "^1.9.3", - "solid-motionone": "^1.0.0" + "solid-motionone": "^1.0.0", + "tiptap-solid": "^1.2.1" }, "devDependencies": { "typescript": "~5.6.2", diff --git a/src-tauri/src/commands/chat.rs b/src-tauri/src/commands/chat.rs index cfe3cb0..ffa4bf2 100644 --- a/src-tauri/src/commands/chat.rs +++ b/src-tauri/src/commands/chat.rs @@ -6,7 +6,9 @@ use tokio::time::{timeout, Duration}; use crate::node::gossip; use crate::node::{self, NodeCommand}; -use crate::protocol::messages::{ChatMessage, GossipMessage, ProfileAnnouncement, TypingIndicator}; +use crate::protocol::messages::{ + ChatMessage, GossipMessage, PeerStatus, ProfileAnnouncement, TypingIndicator, +}; use crate::verification; use crate::AppState; @@ -175,6 +177,24 @@ pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Re namespace: "dusk/peers".to_string(), }) .await; + + // broadcast our initial presence status from saved settings + let initial_status = state + .storage + .load_settings() + .map(|s| match s.status.as_str() { + "idle" => PeerStatus::Idle, + "dnd" => PeerStatus::Dnd, + "invisible" => PeerStatus::Offline, + _ => PeerStatus::Online, + }) + .unwrap_or(PeerStatus::Online); + let _ = handle + .command_tx + .send(NodeCommand::BroadcastPresence { + status: initial_status, + }) + .await; } Ok(()) @@ -185,6 +205,13 @@ pub async fn stop_node(state: State<'_, AppState>) -> Result<(), String> { let mut node_handle = state.node_handle.lock().await; if let Some(handle) = node_handle.take() { + // broadcast offline presence before shutting down + let _ = handle + .command_tx + .send(NodeCommand::BroadcastPresence { + status: PeerStatus::Offline, + }) + .await; let _ = handle.command_tx.send(NodeCommand::Shutdown).await; let _ = handle.task.await; } @@ -286,6 +313,31 @@ pub async fn send_typing(state: State<'_, AppState>, channel_id: String) -> Resu Ok(()) } +// broadcast current user status to all joined communities +#[tauri::command] +pub async fn broadcast_presence(state: State<'_, AppState>, status: String) -> Result<(), String> { + let peer_status = match status.as_str() { + "online" => PeerStatus::Online, + "idle" => PeerStatus::Idle, + "dnd" => PeerStatus::Dnd, + // invisible users appear offline to others + "invisible" => PeerStatus::Offline, + _ => PeerStatus::Online, + }; + + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let _ = handle + .command_tx + .send(NodeCommand::BroadcastPresence { + status: peer_status, + }) + .await; + } + + Ok(()) +} + // find which community a channel belongs to by checking all loaded documents fn find_community_for_channel( engine: &crate::crdt::CrdtEngine, diff --git a/src-tauri/src/commands/gif.rs b/src-tauri/src/commands/gif.rs new file mode 100644 index 0000000..b8fae88 --- /dev/null +++ b/src-tauri/src/commands/gif.rs @@ -0,0 +1,63 @@ +use tauri::State; + +use crate::node::NodeCommand; +use crate::protocol::gif::{GifRequest, GifResponse}; +use crate::AppState; + +#[tauri::command] +pub async fn search_gifs( + state: State<'_, AppState>, + query: String, + limit: Option, +) -> Result { + let handle_ref = state.node_handle.lock().await; + let handle = handle_ref.as_ref().ok_or("node not running")?; + + let (tx, rx) = tokio::sync::oneshot::channel(); + + handle + .command_tx + .send(NodeCommand::GifSearch { + request: GifRequest { + kind: "search".to_string(), + query, + limit: limit.unwrap_or(20), + }, + reply: tx, + }) + .await + .map_err(|_| "failed to send gif search command".to_string())?; + + // drop the lock before awaiting the response + drop(handle_ref); + + rx.await.map_err(|_| "gif search response channel closed".to_string())? +} + +#[tauri::command] +pub async fn get_trending_gifs( + state: State<'_, AppState>, + limit: Option, +) -> Result { + let handle_ref = state.node_handle.lock().await; + let handle = handle_ref.as_ref().ok_or("node not running")?; + + let (tx, rx) = tokio::sync::oneshot::channel(); + + handle + .command_tx + .send(NodeCommand::GifSearch { + request: GifRequest { + kind: "trending".to_string(), + query: String::new(), + limit: limit.unwrap_or(20), + }, + reply: tx, + }) + .await + .map_err(|_| "failed to send trending gifs command".to_string())?; + + drop(handle_ref); + + rx.await.map_err(|_| "trending gifs response channel closed".to_string())? +} diff --git a/src-tauri/src/commands/identity.rs b/src-tauri/src/commands/identity.rs index b0911fe..43c89e5 100644 --- a/src-tauri/src/commands/identity.rs +++ b/src-tauri/src/commands/identity.rs @@ -159,6 +159,14 @@ pub async fn save_settings( state: State<'_, AppState>, settings: UserSettings, ) -> Result<(), String> { + // check if status changed so we can broadcast the new presence + let old_status = state + .storage + .load_settings() + .map(|s| s.status) + .unwrap_or_else(|_| "online".to_string()); + let status_changed = old_status != settings.status; + // also update the identity display name if it changed let mut identity = state.identity.lock().await; let mut name_changed = false; @@ -178,6 +186,29 @@ pub async fn save_settings( } drop(identity); + // broadcast presence if status changed + if status_changed { + use crate::node::NodeCommand; + use crate::protocol::messages::PeerStatus; + + let peer_status = match settings.status.as_str() { + "idle" => PeerStatus::Idle, + "dnd" => PeerStatus::Dnd, + "invisible" => PeerStatus::Offline, + _ => PeerStatus::Online, + }; + + let node_handle = state.node_handle.lock().await; + if let Some(ref handle) = *node_handle { + let _ = handle + .command_tx + .send(NodeCommand::BroadcastPresence { + status: peer_status, + }) + .await; + } + } + state .storage .save_settings(&settings) diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index cd459bb..4d35162 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ pub mod chat; pub mod community; pub mod dm; +pub mod gif; pub mod identity; pub mod voice; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dc8b7a4..62a7fe5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -117,6 +117,7 @@ pub fn run() { commands::chat::start_node, commands::chat::stop_node, commands::chat::check_internet_connectivity, + commands::chat::broadcast_presence, commands::community::create_community, commands::community::join_community, commands::community::leave_community, @@ -143,6 +144,8 @@ pub fn run() { commands::dm::delete_dm_conversation, commands::dm::send_dm_typing, commands::dm::open_dm_conversation, + commands::gif::search_gifs, + commands::gif::get_trending_gifs, ]) .run(tauri::generate_context!()) .expect("error while running dusk"); diff --git a/src-tauri/src/node/behaviour.rs b/src-tauri/src/node/behaviour.rs index 58ad129..5a348c9 100644 --- a/src-tauri/src/node/behaviour.rs +++ b/src-tauri/src/node/behaviour.rs @@ -1,4 +1,8 @@ -use libp2p::{gossipsub, identify, kad, mdns, ping, relay, rendezvous, swarm::NetworkBehaviour}; +use libp2p::{ + gossipsub, identify, kad, mdns, ping, relay, + rendezvous, request_response::cbor, swarm::NetworkBehaviour, +}; +use crate::protocol::gif::{GifRequest, GifResponse}; #[derive(NetworkBehaviour)] pub struct DuskBehaviour { @@ -9,4 +13,6 @@ pub struct DuskBehaviour { pub mdns: mdns::tokio::Behaviour, pub identify: identify::Behaviour, pub ping: ping::Behaviour, + // gif search: sends requests to the relay, receives responses + pub gif_service: cbor::Behaviour, } diff --git a/src-tauri/src/node/mod.rs b/src-tauri/src/node/mod.rs index 05736a8..bc846c5 100644 --- a/src-tauri/src/node/mod.rs +++ b/src-tauri/src/node/mod.rs @@ -78,6 +78,10 @@ pub enum NodeCommand { GetListenAddrs { reply: tokio::sync::oneshot::Sender>, }, + // broadcast our presence status to all community presence topics + BroadcastPresence { + status: crate::protocol::messages::PeerStatus, + }, // dial a specific multiaddr (used for relay connections) Dial { addr: libp2p::Multiaddr, @@ -90,6 +94,11 @@ pub enum NodeCommand { DiscoverRendezvous { namespace: String, }, + // send a gif search request to the relay peer via request-response + GifSearch { + request: crate::protocol::gif::GifRequest, + reply: tokio::sync::oneshot::Sender>, + }, } // events emitted from the node to the tauri frontend @@ -106,6 +115,8 @@ pub enum DuskEvent { PeerConnected { peer_id: String }, #[serde(rename = "peer_disconnected")] PeerDisconnected { peer_id: String }, + #[serde(rename = "presence_updated")] + PresenceUpdated { peer_id: String, status: String }, #[serde(rename = "typing")] Typing { peer_id: String, channel_id: String }, #[serde(rename = "node_status")] @@ -305,6 +316,12 @@ pub async fn start( // all community namespaces we're registered under (for refresh) let mut registered_namespaces: HashSet = HashSet::new(); + // pending gif search replies keyed by request_response request id + let mut pending_gif_replies: HashMap< + libp2p::request_response::OutboundRequestId, + tokio::sync::oneshot::Sender>, + > = HashMap::new(); + // relay reconnection state with exponential backoff let mut relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS; // deferred warning timer -- only notify the frontend after the grace @@ -409,18 +426,30 @@ pub async fn start( let _ = app_handle.emit("dusk-event", DuskEvent::MemberKicked { peer_id }); } crate::protocol::messages::GossipMessage::Presence(update) => { + // map PeerStatus to a string the frontend understands + let status_str = match &update.status { + crate::protocol::messages::PeerStatus::Online => "Online", + crate::protocol::messages::PeerStatus::Idle => "Idle", + crate::protocol::messages::PeerStatus::Dnd => "Dnd", + crate::protocol::messages::PeerStatus::Offline => "Offline", + }; + let _ = app_handle.emit("dusk-event", DuskEvent::PresenceUpdated { + peer_id: update.peer_id.clone(), + status: status_str.to_string(), + }); + + // also update online/offline tracking based on status match update.status { - crate::protocol::messages::PeerStatus::Online => { - let _ = app_handle.emit("dusk-event", DuskEvent::PeerConnected { - peer_id: update.peer_id, - }); - } crate::protocol::messages::PeerStatus::Offline => { let _ = app_handle.emit("dusk-event", DuskEvent::PeerDisconnected { peer_id: update.peer_id, }); } - _ => {} + _ => { + let _ = app_handle.emit("dusk-event", DuskEvent::PeerConnected { + peer_id: update.peer_id, + }); + } } } crate::protocol::messages::GossipMessage::MetaUpdate(meta) => { @@ -936,6 +965,28 @@ pub async fn start( } } + // gif service response from relay + libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::GifService( + libp2p::request_response::Event::Message { + message: libp2p::request_response::Message::Response { request_id, response }, + .. + } + )) => { + if let Some(reply) = pending_gif_replies.remove(&request_id) { + let _ = reply.send(Ok(response)); + } + } + // gif service outbound failure + libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::GifService( + libp2p::request_response::Event::OutboundFailure { request_id, error, .. } + )) => { + if let Some(reply) = pending_gif_replies.remove(&request_id) { + let _ = reply.send(Err(format!("gif request failed: {:?}", error))); + } + } + // ignore inbound requests (we only send outbound) and other events + libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::GifService(_)) => {} + _ => {} } } @@ -1035,6 +1086,36 @@ pub async fn start( log::warn!("failed to dial {}: {}", addr, e); } } + Some(NodeCommand::BroadcastPresence { status }) => { + // publish presence update on every subscribed community presence topic + let local_id = swarm_instance.local_peer_id().to_string(); + let display_name = storage + .load_profile() + .map(|p| p.display_name) + .unwrap_or_else(|_| "unknown".to_string()); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_millis() as u64; + let update = crate::protocol::messages::PresenceUpdate { + peer_id: local_id, + display_name, + status, + timestamp: now, + }; + let msg = crate::protocol::messages::GossipMessage::Presence(update); + if let Ok(data) = serde_json::to_vec(&msg) { + // broadcast to every community presence topic we're subscribed to + let engine = crdt_engine.lock().await; + let community_ids = engine.community_ids(); + drop(engine); + for cid in community_ids { + let topic_str = gossip::topic_for_presence(&cid); + let ident_topic = libp2p::gossipsub::IdentTopic::new(topic_str); + let _ = swarm_instance.behaviour_mut().gossipsub.publish(ident_topic, data.clone()); + } + } + } Some(NodeCommand::RegisterRendezvous { namespace }) => { if relay_reservation_active { if let Some(rp) = relay_peer { @@ -1080,6 +1161,17 @@ pub async fn start( pending_discoveries.push(namespace); } } + Some(NodeCommand::GifSearch { request, reply }) => { + if let Some(rp) = relay_peer { + let request_id = swarm_instance + .behaviour_mut() + .gif_service + .send_request(&rp, request); + pending_gif_replies.insert(request_id, reply); + } else { + let _ = reply.send(Err("not connected to relay".to_string())); + } + } } } } diff --git a/src-tauri/src/node/swarm.rs b/src-tauri/src/node/swarm.rs index 2e9512d..1e5dc60 100644 --- a/src-tauri/src/node/swarm.rs +++ b/src-tauri/src/node/swarm.rs @@ -3,11 +3,13 @@ use std::hash::{Hash, Hasher}; use std::time::Duration; use libp2p::{ - gossipsub, identify, identity, kad, mdns, noise, ping, rendezvous, tcp, yamux, Swarm, - SwarmBuilder, + gossipsub, identify, identity, kad, mdns, noise, ping, rendezvous, + request_response::{self, cbor, ProtocolSupport}, + tcp, yamux, Swarm, SwarmBuilder, }; use super::behaviour::DuskBehaviour; +use crate::protocol::gif::{GifRequest, GifResponse, GIF_PROTOCOL}; pub fn build_swarm( keypair: &identity::Keypair, @@ -75,6 +77,12 @@ pub fn build_swarm( identify, // ping every 30s to keep the relay connection alive ping: ping::Behaviour::new(ping::Config::new().with_interval(Duration::from_secs(30))), + // gif search via request-response to the relay (outbound only) + gif_service: cbor::Behaviour::::new( + [(GIF_PROTOCOL, ProtocolSupport::Outbound)], + request_response::Config::default() + .with_request_timeout(Duration::from_secs(15)), + ), } })? .with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(300))) diff --git a/src-tauri/src/protocol/gif.rs b/src-tauri/src/protocol/gif.rs new file mode 100644 index 0000000..dcc2686 --- /dev/null +++ b/src-tauri/src/protocol/gif.rs @@ -0,0 +1,30 @@ +// gif protocol types shared between the tauri client and the relay server. +// the client sends a GifRequest over libp2p request-response and the relay +// responds with a GifResponse after fetching from klipy. + +use libp2p::StreamProtocol; + +pub const GIF_PROTOCOL: StreamProtocol = StreamProtocol::new("/dusk/gif/1.0.0"); + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GifRequest { + // "search" or "trending" + pub kind: String, + // search query (only used when kind == "search") + pub query: String, + pub limit: u32, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GifResponse { + pub results: Vec, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GifResult { + pub id: String, + pub title: String, + pub url: String, + pub preview: String, + pub dims: [u32; 2], +} diff --git a/src-tauri/src/protocol/messages.rs b/src-tauri/src/protocol/messages.rs index f0dde6c..ff80a21 100644 --- a/src-tauri/src/protocol/messages.rs +++ b/src-tauri/src/protocol/messages.rs @@ -28,10 +28,11 @@ pub struct PresenceUpdate { pub timestamp: u64, } -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub enum PeerStatus { Online, Idle, + Dnd, Offline, } diff --git a/src-tauri/src/protocol/mod.rs b/src-tauri/src/protocol/mod.rs index 0f7c6c3..147f2a9 100644 --- a/src-tauri/src/protocol/mod.rs +++ b/src-tauri/src/protocol/mod.rs @@ -1,3 +1,4 @@ pub mod community; +pub mod gif; pub mod identity; pub mod messages; diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 1c25547..08d5eca 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' asset: http://asset.localhost data:; connect-src ipc: http://ipc.localhost; worker-src 'none'; object-src 'none'; base-uri 'self'" + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' asset: http://asset.localhost data: https://static.klipy.com; connect-src ipc: http://ipc.localhost; worker-src 'none'; object-src 'none'; base-uri 'self'" } }, "bundle": { diff --git a/src/App.tsx b/src/App.tsx index 3a9a041..1ac26ed 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -55,6 +55,7 @@ import { addTypingPeer, setPeerOnline, setPeerOffline, + setPeerStatus, removeMember, } from "./stores/members"; import { @@ -320,6 +321,15 @@ const App: Component = () => { case "peer_disconnected": setPeerOffline(event.payload.peer_id); break; + case "presence_updated": { + const status = event.payload.status as + | "Online" + | "Idle" + | "Dnd" + | "Offline"; + setPeerStatus(event.payload.peer_id, status); + break; + } case "typing": if (event.payload.channel_id === activeChannelId()) { addTypingPeer(event.payload.peer_id); diff --git a/src/components/chat/EmojiPicker.tsx b/src/components/chat/EmojiPicker.tsx new file mode 100644 index 0000000..5b55938 --- /dev/null +++ b/src/components/chat/EmojiPicker.tsx @@ -0,0 +1,216 @@ +import type { Component } from "solid-js"; +import { + createSignal, + For, + Show, + createMemo, + onMount, + onCleanup, +} from "solid-js"; +import { Search, X } from "lucide-solid"; +import { + EMOJI_CATEGORIES, + getRecentEmojis, + addRecentEmoji, +} from "../../lib/emoji-data"; + +interface EmojiPickerProps { + onSelect: (emoji: string) => void; + onClose: () => void; +} + +const EmojiPicker: Component = (props) => { + const [search, setSearch] = createSignal(""); + const [activeCategory, setActiveCategory] = createSignal("smileys"); + const [recentEmojis, setRecentEmojis] = createSignal([]); + let panelRef: HTMLDivElement | undefined; + let searchRef: HTMLInputElement | undefined; + + onMount(() => { + setRecentEmojis(getRecentEmojis()); + searchRef?.focus(); + + // close on click outside + const handleClickOutside = (e: MouseEvent) => { + if (panelRef && !panelRef.contains(e.target as Node)) { + props.onClose(); + } + }; + // defer to avoid the click that opened the picker from closing it + setTimeout(() => { + document.addEventListener("mousedown", handleClickOutside); + }, 0); + onCleanup(() => { + document.removeEventListener("mousedown", handleClickOutside); + }); + }); + + // filter emojis based on search (crude match on category name + position) + const filteredCategories = createMemo(() => { + const q = search().toLowerCase(); + if (!q) return null; + + // flatten all emojis and filter based on a simple approach + // since we dont have emoji names, search filters by category + const matched: string[] = []; + for (const cat of EMOJI_CATEGORIES) { + if (cat.name.includes(q) || cat.id.includes(q)) { + matched.push(...cat.emojis); + } + } + + // also search recent + const recent = recentEmojis(); + if ("recent".includes(q)) { + matched.push(...recent); + } + + return matched; + }); + + function selectEmoji(emoji: string) { + addRecentEmoji(emoji); + setRecentEmojis(getRecentEmojis()); + props.onSelect(emoji); + } + + function scrollToCategory(catId: string) { + setActiveCategory(catId); + setSearch(""); + const el = document.getElementById(`emoji-cat-${catId}`); + el?.scrollIntoView({ behavior: "smooth", block: "start" }); + } + + return ( +
+ {/* header */} +
+ +
+ + {/* category tabs */} + +
+ 0}> + + + + {(cat) => ( + + )} + +
+
+ + {/* emoji grid */} +
+ + + no emojis found +
+ } + > +
+ + {(emoji) => ( + + )} + +
+ +
+ } + > + {/* recent */} + 0}> +
+
recently used
+
+ + {(emoji) => ( + + )} + +
+
+
+ + {/* categories */} + + {(cat) => ( +
+
{cat.name}
+
+ + {(emoji) => ( + + )} + +
+
+ )} +
+ + + + ); +}; + +export default EmojiPicker; diff --git a/src/components/chat/GifPicker.tsx b/src/components/chat/GifPicker.tsx new file mode 100644 index 0000000..12a814f --- /dev/null +++ b/src/components/chat/GifPicker.tsx @@ -0,0 +1,197 @@ +import type { Component } from "solid-js"; +import { createSignal, For, Show, onMount, onCleanup } from "solid-js"; +import { Search, X, Loader } from "lucide-solid"; +import type { GifResult } from "../../lib/types"; +import * as tauri from "../../lib/tauri"; + +// detect if running inside tauri (vs standalone vite dev) +const isTauri = typeof window !== "undefined" && "__TAURI_INTERNALS__" in window; + +interface GifPickerProps { + onSelect: (gifUrl: string) => void; + onClose: () => void; +} + +async function fetchSearch(query: string): Promise { + if (!isTauri) return []; + try { + const res = await tauri.searchGifs(query, 30); + return res.results || []; + } catch (err) { + console.error("gif search failed:", err); + return []; + } +} + +async function fetchTrending(): Promise { + if (!isTauri) return []; + try { + const res = await tauri.getTrendingGifs(30); + return res.results || []; + } catch (err) { + console.error("gif trending failed:", err); + return []; + } +} + +const GifPicker: Component = (props) => { + const [search, setSearch] = createSignal(""); + const [gifs, setGifs] = createSignal([]); + const [loading, setLoading] = createSignal(false); + const [proxyAvailable, setProxyAvailable] = createSignal(true); + let panelRef: HTMLDivElement | undefined; + let searchRef: HTMLInputElement | undefined; + let debounceTimer: ReturnType | undefined; + + onMount(async () => { + searchRef?.focus(); + await loadTrending(); + + // close on click outside + const handleClickOutside = (e: MouseEvent) => { + if (panelRef && !panelRef.contains(e.target as Node)) { + props.onClose(); + } + }; + setTimeout(() => { + document.addEventListener("mousedown", handleClickOutside); + }, 0); + onCleanup(() => { + document.removeEventListener("mousedown", handleClickOutside); + if (debounceTimer) clearTimeout(debounceTimer); + }); + }); + + async function loadTrending() { + setLoading(true); + try { + const results = await fetchTrending(); + if (results.length === 0 && gifs().length === 0) { + // no results on first load likely means proxy is unreachable + setProxyAvailable(false); + } + setGifs(results); + } catch { + setProxyAvailable(false); + } + setLoading(false); + } + + function handleSearchInput(value: string) { + setSearch(value); + if (debounceTimer) clearTimeout(debounceTimer); + + if (!value.trim()) { + loadTrending(); + return; + } + + // debounce search by 400ms + debounceTimer = setTimeout(async () => { + setLoading(true); + try { + const results = await fetchSearch(value); + setGifs(results); + setProxyAvailable(true); + } catch { + console.error("gif search failed"); + } + setLoading(false); + }, 400); + } + + function selectGif(gif: GifResult) { + props.onSelect(gif.url); + props.onClose(); + } + + return ( +
+ {/* header */} +
+ +
+ + {/* content */} +
+ +

+ gif search unavailable +

+

+ not connected to relay +

+
+ } + > + +
+ +
+
+ + +
+ + no gifs found for "{search()}" + +
+
+ + 0}> +
+ + {(gif) => ( + + )} + +
+ + {/* klipy attribution */} +
+ + powered by klipy + +
+
+ +
+ + ); +}; + +export default GifPicker; diff --git a/src/components/chat/Message.tsx b/src/components/chat/Message.tsx index c6f93c6..256945f 100644 --- a/src/components/chat/Message.tsx +++ b/src/components/chat/Message.tsx @@ -1,7 +1,8 @@ import type { Component } from "solid-js"; -import { Show, createSignal } from "solid-js"; +import { Show, createSignal, createMemo } from "solid-js"; import type { ChatMessage } from "../../lib/types"; import { formatTime, formatTimeShort } from "../../lib/utils"; +import { renderMarkdown, isStandaloneImageUrl } from "../../lib/markdown"; import { removeMessage } from "../../stores/messages"; import { activeCommunityId } from "../../stores/communities"; import { identity } from "../../stores/identity"; @@ -24,6 +25,12 @@ const Message: Component = (props) => { const currentUser = () => identity(); const currentCommunityId = () => activeCommunityId(); + // pre-render markdown content so it only recalculates when content changes + const renderedContent = createMemo(() => + renderMarkdown(props.message.content), + ); + const isImage = createMemo(() => isStandaloneImageUrl(props.message.content)); + const isOwner = () => { const user = currentUser(); return user?.peer_id === props.message.author_id; @@ -112,9 +119,17 @@ const Message: Component = (props) => { -

- {props.message.content} -

+ + } + > +
+
{/* context menu */} diff --git a/src/components/chat/MessageInput.tsx b/src/components/chat/MessageInput.tsx index bc0ca3e..34f56bd 100644 --- a/src/components/chat/MessageInput.tsx +++ b/src/components/chat/MessageInput.tsx @@ -1,6 +1,13 @@ import type { Component } from "solid-js"; -import { createSignal } from "solid-js"; -import { SendHorizontal } from "lucide-solid"; +import { createSignal, Show } from "solid-js"; +import { Smile, Image, SendHorizontal } from "lucide-solid"; +import { createEditor, EditorContent } from "tiptap-solid"; +import StarterKit from "@tiptap/starter-kit"; +import Placeholder from "@tiptap/extension-placeholder"; +import { Extension } from "@tiptap/core"; +import { tiptapToMarkdown } from "../../lib/markdown"; +import EmojiPicker from "./EmojiPicker"; +import GifPicker from "./GifPicker"; interface MessageInputProps { channelName: string; @@ -9,60 +16,147 @@ interface MessageInputProps { } const MessageInput: Component = (props) => { - const [value, setValue] = createSignal(""); - let textareaRef: HTMLTextAreaElement | undefined; + const [isEmpty, setIsEmpty] = createSignal(true); + const [isFocused, setIsFocused] = createSignal(false); + const [showEmojiPicker, setShowEmojiPicker] = createSignal(false); + const [showGifPicker, setShowGifPicker] = createSignal(false); + + // custom extension to handle enter-to-send behavior + const SendOnEnter = Extension.create({ + name: "sendOnEnter", + addKeyboardShortcuts() { + return { + Enter: () => { + handleSubmit(); + return true; + }, + }; + }, + }); + + const editor = createEditor({ + extensions: [ + StarterKit.configure({ + // disable features that dont make sense in a chat input + heading: false, + blockquote: false, + bulletList: false, + orderedList: false, + codeBlock: false, + horizontalRule: false, + dropcursor: false, + gapcursor: false, + }), + Placeholder.configure({ + placeholder: `message #${props.channelName}`, + }), + SendOnEnter, + ], + editorProps: { + attributes: { + class: "dusk-editor-content", + }, + }, + onUpdate: ({ editor: e }) => { + setIsEmpty(e.isEmpty); + props.onTyping?.(); + }, + onFocus: () => setIsFocused(true), + onBlur: () => setIsFocused(false), + }); function handleSubmit() { - const content = value().trim(); - if (!content) return; - props.onSend(content); - setValue(""); - // reset textarea height - if (textareaRef) { - textareaRef.style.height = "auto"; - } + const e = editor(); + if (!e) return; + + // convert rich text to markdown for the wire format + const markdown = tiptapToMarkdown(e.getJSON()).trim(); + if (!markdown) return; + + props.onSend(markdown); + e.commands.clearContent(); + setIsEmpty(true); } - function handleKeyDown(e: KeyboardEvent) { - if (e.key === "Enter" && !e.shiftKey) { - e.preventDefault(); - handleSubmit(); - } + function insertEmoji(emoji: string) { + const e = editor(); + if (!e) return; + e.chain().focus().insertContent(emoji).run(); } - function handleInput(e: InputEvent) { - const target = e.target as HTMLTextAreaElement; - setValue(target.value); + function sendGif(gifUrl: string) { + // gifs are sent as standalone messages containing just the url + props.onSend(gifUrl); + setShowGifPicker(false); + } - // auto-resize textarea - target.style.height = "auto"; - target.style.height = Math.min(target.scrollHeight, 200) + "px"; + function toggleEmojiPicker() { + setShowGifPicker(false); + setShowEmojiPicker((v) => !v); + } - // fire typing indicator (debounced by the store) - props.onTyping?.(); + function toggleGifPicker() { + setShowEmojiPicker(false); + setShowGifPicker((v) => !v); } return ( -
-
-