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.
This commit is contained in:
cloudwithax 2026-02-15 16:46:19 -05:00
parent aa640bf81d
commit e468f5ae44
31 changed files with 2107 additions and 78 deletions

135
bun.lock
View File

@ -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=="],
}
}

View File

@ -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",

View File

@ -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,

View File

@ -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<u32>,
) -> Result<GifResponse, String> {
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<u32>,
) -> Result<GifResponse, String> {
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())?
}

View File

@ -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)

View File

@ -1,5 +1,6 @@
pub mod chat;
pub mod community;
pub mod dm;
pub mod gif;
pub mod identity;
pub mod voice;

View File

@ -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");

View File

@ -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<GifRequest, GifResponse>,
}

View File

@ -78,6 +78,10 @@ pub enum NodeCommand {
GetListenAddrs {
reply: tokio::sync::oneshot::Sender<Vec<String>>,
},
// 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<Result<crate::protocol::gif::GifResponse, String>>,
},
}
// 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<String> = 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<Result<crate::protocol::gif::GifResponse, String>>,
> = 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()));
}
}
}
}
}

View File

@ -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::<GifRequest, GifResponse>::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)))

View File

@ -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<GifResult>,
}
#[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],
}

View File

@ -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,
}

View File

@ -1,3 +1,4 @@
pub mod community;
pub mod gif;
pub mod identity;
pub mod messages;

View File

@ -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": {

View File

@ -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);

View File

@ -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<EmojiPickerProps> = (props) => {
const [search, setSearch] = createSignal("");
const [activeCategory, setActiveCategory] = createSignal("smileys");
const [recentEmojis, setRecentEmojis] = createSignal<string[]>([]);
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 (
<div ref={panelRef} class="dusk-picker-panel">
{/* header */}
<div class="dusk-picker-header">
<div class="dusk-picker-search">
<Search size={14} class="text-white/40 shrink-0" />
<input
ref={searchRef}
type="text"
class="flex-1 bg-transparent text-[14px] text-white outline-none placeholder:text-white/30"
placeholder="search emoji"
value={search()}
onInput={(e) => setSearch(e.currentTarget.value)}
/>
<Show when={search()}>
<button
type="button"
class="text-white/40 hover:text-white cursor-pointer"
onClick={() => setSearch("")}
>
<X size={14} />
</button>
</Show>
</div>
</div>
{/* category tabs */}
<Show when={!search()}>
<div class="dusk-picker-tabs">
<Show when={recentEmojis().length > 0}>
<button
type="button"
class={`dusk-picker-tab ${activeCategory() === "recent" ? "active" : ""}`}
onClick={() => scrollToCategory("recent")}
title="recently used"
>
{"\u{1F552}"}
</button>
</Show>
<For each={EMOJI_CATEGORIES}>
{(cat) => (
<button
type="button"
class={`dusk-picker-tab ${activeCategory() === cat.id ? "active" : ""}`}
onClick={() => scrollToCategory(cat.id)}
title={cat.name}
>
{cat.icon}
</button>
)}
</For>
</div>
</Show>
{/* emoji grid */}
<div class="dusk-picker-grid-container">
<Show
when={!search()}
fallback={
<div class="p-2">
<Show
when={filteredCategories()?.length}
fallback={
<div class="text-center text-white/30 text-[13px] py-8">
no emojis found
</div>
}
>
<div class="dusk-emoji-grid">
<For each={filteredCategories()}>
{(emoji) => (
<button
type="button"
class="dusk-emoji-btn"
onClick={() => selectEmoji(emoji)}
>
{emoji}
</button>
)}
</For>
</div>
</Show>
</div>
}
>
{/* recent */}
<Show when={recentEmojis().length > 0}>
<div id="emoji-cat-recent" class="p-2">
<div class="dusk-picker-label">recently used</div>
<div class="dusk-emoji-grid">
<For each={recentEmojis()}>
{(emoji) => (
<button
type="button"
class="dusk-emoji-btn"
onClick={() => selectEmoji(emoji)}
>
{emoji}
</button>
)}
</For>
</div>
</div>
</Show>
{/* categories */}
<For each={EMOJI_CATEGORIES}>
{(cat) => (
<div id={`emoji-cat-${cat.id}`} class="p-2">
<div class="dusk-picker-label">{cat.name}</div>
<div class="dusk-emoji-grid">
<For each={cat.emojis}>
{(emoji) => (
<button
type="button"
class="dusk-emoji-btn"
onClick={() => selectEmoji(emoji)}
>
{emoji}
</button>
)}
</For>
</div>
</div>
)}
</For>
</Show>
</div>
</div>
);
};
export default EmojiPicker;

View File

@ -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<GifResult[]> {
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<GifResult[]> {
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<GifPickerProps> = (props) => {
const [search, setSearch] = createSignal("");
const [gifs, setGifs] = createSignal<GifResult[]>([]);
const [loading, setLoading] = createSignal(false);
const [proxyAvailable, setProxyAvailable] = createSignal(true);
let panelRef: HTMLDivElement | undefined;
let searchRef: HTMLInputElement | undefined;
let debounceTimer: ReturnType<typeof setTimeout> | 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 (
<div ref={panelRef} class="dusk-picker-panel dusk-gif-panel">
{/* header */}
<div class="dusk-picker-header">
<div class="dusk-picker-search">
<Search size={14} class="text-white/40 shrink-0" />
<input
ref={searchRef}
type="text"
class="flex-1 bg-transparent text-[14px] text-white outline-none placeholder:text-white/30"
placeholder="search gifs"
value={search()}
onInput={(e) => handleSearchInput(e.currentTarget.value)}
/>
<Show when={search()}>
<button
type="button"
class="text-white/40 hover:text-white cursor-pointer"
onClick={() => handleSearchInput("")}
>
<X size={14} />
</button>
</Show>
</div>
</div>
{/* content */}
<div class="dusk-picker-grid-container">
<Show
when={proxyAvailable()}
fallback={
<div class="flex flex-col items-center justify-center py-12 px-4 text-center">
<p class="text-[14px] text-white/50 mb-2">
gif search unavailable
</p>
<p class="text-[12px] text-white/30 font-mono">
not connected to relay
</p>
</div>
}
>
<Show when={loading()}>
<div class="flex items-center justify-center py-8">
<Loader size={20} class="text-white/40 animate-spin" />
</div>
</Show>
<Show when={!loading() && gifs().length === 0}>
<div class="text-center text-white/30 text-[13px] py-8">
<Show when={search()} fallback="no trending gifs available">
no gifs found for "{search()}"
</Show>
</div>
</Show>
<Show when={!loading() && gifs().length > 0}>
<div class="dusk-gif-grid">
<For each={gifs()}>
{(gif) => (
<button
type="button"
class="dusk-gif-item"
onClick={() => selectGif(gif)}
title={gif.title}
>
<img
src={gif.preview}
alt={gif.title}
loading="lazy"
class="w-full h-full object-cover"
/>
</button>
)}
</For>
</div>
{/* klipy attribution */}
<div class="text-center py-2">
<span class="text-[10px] font-mono text-white/20">
powered by klipy
</span>
</div>
</Show>
</Show>
</div>
</div>
);
};
export default GifPicker;

View File

@ -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<MessageProps> = (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<MessageProps> = (props) => {
</div>
</Show>
<p class="text-[16px] leading-[22px] text-white/90 break-words whitespace-pre-wrap m-0">
{props.message.content}
</p>
<Show
when={!isImage()}
fallback={
<div
class="dusk-msg-content dusk-msg-image-wrapper"
innerHTML={renderedContent()}
/>
}
>
<div class="dusk-msg-content" innerHTML={renderedContent()} />
</Show>
</div>
{/* context menu */}

View File

@ -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<MessageInputProps> = (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 (
<div class="shrink-0 px-4 py-2 bg-black border-t border-white/10">
<div class="flex items-end gap-2">
<textarea
ref={textareaRef}
class="flex-1 bg-gray-800 border-2 border-white/20 text-white text-[16px] leading-[22px] px-4 py-2 resize-none outline-none placeholder:font-mono placeholder:text-white/40 focus:border-orange transition-colors duration-200 min-h-[47px] max-h-[200px]"
style={{ "field-sizing": "content" }}
rows={1}
placeholder={`message #${props.channelName}`}
value={value()}
onInput={handleInput}
onKeyDown={handleKeyDown}
/>
<button
type="button"
class="w-10 h-10 shrink-0 flex items-center justify-center bg-orange text-white hover:bg-orange-hover transition-colors duration-200 cursor-pointer disabled:opacity-40 disabled:pointer-events-none"
onClick={handleSubmit}
disabled={!value().trim()}
>
<SendHorizontal size={20} />
</button>
<div class="shrink-0 px-4 py-2 bg-black border-t border-white/10 relative">
{/* picker popovers positioned above the input */}
<Show when={showEmojiPicker()}>
<div class="absolute bottom-full right-4 mb-2 z-50">
<EmojiPicker
onSelect={insertEmoji}
onClose={() => setShowEmojiPicker(false)}
/>
</div>
</Show>
<Show when={showGifPicker()}>
<div class="absolute bottom-full right-4 mb-2 z-50">
<GifPicker
onSelect={sendGif}
onClose={() => setShowGifPicker(false)}
/>
</div>
</Show>
<div
class={`dusk-editor-row ${isFocused() ? "dusk-editor-focused" : ""}`}
>
{/* editor area takes up remaining space */}
<div class="flex-1 min-w-0">
<Show when={editor()}>{(e) => <EditorContent editor={e()} />}</Show>
</div>
{/* action buttons pinned to the right */}
<div class="dusk-editor-actions">
<button
type="button"
class={`dusk-toolbar-btn ${showEmojiPicker() ? "active" : ""}`}
onClick={toggleEmojiPicker}
title="emoji"
>
<Smile size={20} />
</button>
<button
type="button"
class={`dusk-toolbar-btn ${showGifPicker() ? "active" : ""}`}
onClick={toggleGifPicker}
title="gif"
>
<Image size={20} />
</button>
<button
type="button"
class="dusk-send-btn"
onClick={handleSubmit}
disabled={isEmpty()}
title="send message"
>
<SendHorizontal size={20} />
</button>
</div>
</div>
</div>
);

View File

@ -19,6 +19,7 @@ interface AvatarProps {
status?:
| "Online"
| "Idle"
| "Dnd"
| "Offline"
| "online"
| "idle"
@ -39,6 +40,7 @@ const statusColorMap: Record<string, string> = {
online: "bg-success",
Idle: "bg-warning",
idle: "bg-warning",
Dnd: "bg-error",
dnd: "bg-error",
invisible: "bg-gray-500",
Offline: "bg-gray-300",

View File

@ -9,9 +9,10 @@ import {
closeProfileCard,
openProfileModal,
} from "../../stores/ui";
import { members } from "../../stores/members";
import { members, isPeerOnline } from "../../stores/members";
import { knownPeers } from "../../stores/directory";
import { identity } from "../../stores/identity";
import { settings } from "../../stores/settings";
import { markAsFriend, unmarkAsFriend } from "../../stores/directory";
import * as tauri from "../../lib/tauri";
import { formatTime } from "../../lib/utils";
@ -41,12 +42,30 @@ const ProfileCard: Component = () => {
memberInfo()?.display_name ??
directoryInfo()?.display_name ??
target()?.displayName ??
(isSelf() ? identity()?.display_name : null) ??
"Unknown";
const bio = () =>
directoryInfo()?.bio || (isSelf() ? identity()?.bio : "") || "";
const isFriend = () => directoryInfo()?.is_friend ?? false;
const status = () => memberInfo()?.status ?? "Offline";
// local user always knows their own status from settings;
// remote peers use member list status or online tracking as fallback
const status = () => {
if (isSelf()) {
const s = settings().status;
if (s === "invisible") return "Offline";
return (s.charAt(0).toUpperCase() + s.slice(1)) as
| "Online"
| "Idle"
| "Dnd"
| "Offline";
}
const member = memberInfo();
if (member) return member.status;
const t = target();
if (t && isPeerOnline(t.peerId)) return "Online";
return "Offline";
};
const roles = () => memberInfo()?.roles ?? [];
const joinedAt = () => memberInfo()?.joined_at ?? 0;

View File

@ -22,13 +22,14 @@ import {
} from "lucide-solid";
import Avatar from "./Avatar";
import { profileModalPeerId, closeProfileModal } from "../../stores/ui";
import { members } from "../../stores/members";
import { members, isPeerOnline } from "../../stores/members";
import {
knownPeers,
markAsFriend,
unmarkAsFriend,
} from "../../stores/directory";
import { identity } from "../../stores/identity";
import { settings } from "../../stores/settings";
import { communities } from "../../stores/communities";
import * as tauri from "../../lib/tauri";
import { formatTime } from "../../lib/utils";
@ -56,13 +57,33 @@ const ProfileModal: Component = () => {
});
const displayName = () =>
memberInfo()?.display_name ?? directoryInfo()?.display_name ?? "Unknown";
memberInfo()?.display_name ??
directoryInfo()?.display_name ??
(isSelf() ? identity()?.display_name : null) ??
"Unknown";
const bio = () =>
directoryInfo()?.bio || (isSelf() ? identity()?.bio : "") || "";
const isFriend = () => directoryInfo()?.is_friend ?? false;
const status = () => memberInfo()?.status ?? "Offline";
// local user always knows their own status from settings;
// remote peers use member list status or online tracking as fallback
const status = () => {
if (isSelf()) {
const s = settings().status;
if (s === "invisible") return "Offline";
return (s.charAt(0).toUpperCase() + s.slice(1)) as
| "Online"
| "Idle"
| "Dnd"
| "Offline";
}
const member = memberInfo();
if (member) return member.status;
const id = peerId();
if (id && isPeerOnline(id)) return "Online";
return "Offline";
};
const roles = () => memberInfo()?.roles ?? [];
const joinedAt = () => memberInfo()?.joined_at ?? 0;
const publicKey = () =>

View File

@ -11,6 +11,7 @@ import { identity } from "../../stores/identity";
import { settings, updateStatus } from "../../stores/settings";
import type { UserStatus } from "../../lib/types";
import Avatar from "../common/Avatar";
import * as tauri from "../../lib/tauri";
interface UserFooterProps {
showSettings?: boolean;
@ -37,6 +38,8 @@ const UserFooter: Component<UserFooterProps> = (props) => {
function handleStatusChange(status: UserStatus) {
updateStatus(status);
// persist and broadcast the new status to peers
tauri.saveSettings(settings()).catch(() => {});
setIsOpen(false);
}

View File

@ -1,6 +1,6 @@
import type { Component } from "solid-js";
import { For, Show, createSignal } from "solid-js";
import { MessageCircle, Search, X, Plus } from "lucide-solid";
import { MessageCircle, Search, X, Plus, Group, Users } from "lucide-solid";
import {
dmConversations,
activeDMPeerId,
@ -89,7 +89,7 @@ const DMSidebar: Component = () => {
}`}
onClick={() => setActiveDM(null)}
>
<MessageCircle size={20} class="shrink-0" />
<Users size={18} />
<span class="font-medium">friends</span>
</button>

532
src/lib/emoji-data.ts Normal file
View File

@ -0,0 +1,532 @@
// curated unicode emoji organized by category
// keeps bundle small while covering the most commonly used emojis
export interface EmojiCategory {
id: string;
name: string;
icon: string;
emojis: string[];
}
const RECENT_STORAGE_KEY = "dusk_recent_emojis";
const MAX_RECENT = 32;
export function getRecentEmojis(): string[] {
try {
const stored = localStorage.getItem(RECENT_STORAGE_KEY);
if (!stored) return [];
return JSON.parse(stored) as string[];
} catch {
return [];
}
}
export function addRecentEmoji(emoji: string): void {
const recent = getRecentEmojis().filter((e) => e !== emoji);
recent.unshift(emoji);
if (recent.length > MAX_RECENT) recent.length = MAX_RECENT;
localStorage.setItem(RECENT_STORAGE_KEY, JSON.stringify(recent));
}
export const EMOJI_CATEGORIES: EmojiCategory[] = [
{
id: "smileys",
name: "smileys",
icon: "\u{1F600}",
emojis: [
"\u{1F600}",
"\u{1F603}",
"\u{1F604}",
"\u{1F601}",
"\u{1F606}",
"\u{1F605}",
"\u{1F602}",
"\u{1F923}",
"\u{1F642}",
"\u{1F643}",
"\u{1F609}",
"\u{1F60A}",
"\u{1F607}",
"\u{1F970}",
"\u{1F60D}",
"\u{1F929}",
"\u{1F618}",
"\u{1F617}",
"\u{1F61A}",
"\u{1F619}",
"\u{1F60B}",
"\u{1F61B}",
"\u{1F61C}",
"\u{1F92A}",
"\u{1F61D}",
"\u{1F911}",
"\u{1F917}",
"\u{1F92D}",
"\u{1F92B}",
"\u{1F914}",
"\u{1F910}",
"\u{1F928}",
"\u{1F610}",
"\u{1F611}",
"\u{1F636}",
"\u{1F60F}",
"\u{1F612}",
"\u{1F644}",
"\u{1F62C}",
"\u{1F624}",
"\u{1F620}",
"\u{1F621}",
"\u{1F62E}",
"\u{1F631}",
"\u{1F628}",
"\u{1F630}",
"\u{1F625}",
"\u{1F622}",
"\u{1F62D}",
"\u{1F633}",
"\u{1F616}",
"\u{1F623}",
"\u{1F629}",
"\u{1F97A}",
"\u{1F62F}",
"\u{1F634}",
"\u{1F637}",
"\u{1F912}",
"\u{1F915}",
"\u{1F922}",
"\u{1F92E}",
"\u{1F927}",
"\u{1F975}",
"\u{1F976}",
"\u{1F974}",
"\u{1F635}",
"\u{1F92F}",
"\u{1F920}",
"\u{1F973}",
"\u{1F60E}",
"\u{1F913}",
"\u{1F9D0}",
"\u{1F615}",
"\u{1F61F}",
"\u{1F641}",
],
},
{
id: "gestures",
name: "hands & gestures",
icon: "\u{1F44B}",
emojis: [
"\u{1F44B}",
"\u{1F91A}",
"\u{270B}",
"\u{1F596}",
"\u{1F44C}",
"\u{1F90C}",
"\u{1F90F}",
"\u{270C}\u{FE0F}",
"\u{1F91E}",
"\u{1F91F}",
"\u{1F918}",
"\u{1F919}",
"\u{1F448}",
"\u{1F449}",
"\u{1F446}",
"\u{1F595}",
"\u{1F447}",
"\u{261D}\u{FE0F}",
"\u{1F44D}",
"\u{1F44E}",
"\u{270A}",
"\u{1F44A}",
"\u{1F91B}",
"\u{1F91C}",
"\u{1F44F}",
"\u{1F64C}",
"\u{1F450}",
"\u{1F64F}",
"\u{1F4AA}",
"\u{1F9B6}",
"\u{1F9B5}",
"\u{1F442}",
"\u{1F443}",
"\u{1F440}",
"\u{1F441}\u{FE0F}",
"\u{1F445}",
"\u{1F444}",
"\u{1F48B}",
"\u{1F498}",
"\u{2764}\u{FE0F}",
"\u{1F9E1}",
"\u{1F49B}",
"\u{1F49A}",
"\u{1F499}",
"\u{1F49C}",
"\u{1F5A4}",
"\u{1F90D}",
"\u{1F90E}",
"\u{1F494}",
"\u{1F495}",
],
},
{
id: "nature",
name: "animals & nature",
icon: "\u{1F431}",
emojis: [
"\u{1F436}",
"\u{1F431}",
"\u{1F42D}",
"\u{1F439}",
"\u{1F430}",
"\u{1F98A}",
"\u{1F43B}",
"\u{1F43C}",
"\u{1F428}",
"\u{1F42F}",
"\u{1F981}",
"\u{1F434}",
"\u{1F984}",
"\u{1F42E}",
"\u{1F437}",
"\u{1F438}",
"\u{1F435}",
"\u{1F648}",
"\u{1F649}",
"\u{1F64A}",
"\u{1F412}",
"\u{1F414}",
"\u{1F427}",
"\u{1F426}",
"\u{1F985}",
"\u{1F989}",
"\u{1F987}",
"\u{1F43A}",
"\u{1F417}",
"\u{1F40E}",
"\u{1F41D}",
"\u{1F41B}",
"\u{1F98B}",
"\u{1F40C}",
"\u{1F41A}",
"\u{1F41E}",
"\u{1F41C}",
"\u{1F997}",
"\u{1F577}\u{FE0F}",
"\u{1F982}",
"\u{1F422}",
"\u{1F40D}",
"\u{1F98E}",
"\u{1F420}",
"\u{1F41F}",
"\u{1F42C}",
"\u{1F433}",
"\u{1F40B}",
"\u{1F40A}",
"\u{1F406}",
"\u{1F331}",
"\u{1F332}",
"\u{1F333}",
"\u{1F334}",
"\u{1F335}",
"\u{1F33A}",
"\u{1F33B}",
"\u{1F337}",
"\u{1F339}",
"\u{1F33C}",
],
},
{
id: "food",
name: "food & drink",
icon: "\u{1F355}",
emojis: [
"\u{1F34E}",
"\u{1F34A}",
"\u{1F34B}",
"\u{1F34C}",
"\u{1F349}",
"\u{1F347}",
"\u{1F353}",
"\u{1F348}",
"\u{1F352}",
"\u{1F351}",
"\u{1F34D}",
"\u{1F345}",
"\u{1F346}",
"\u{1F336}\u{FE0F}",
"\u{1F33D}",
"\u{1F955}",
"\u{1F954}",
"\u{1F9C5}",
"\u{1F9C4}",
"\u{1F35E}",
"\u{1F950}",
"\u{1F956}",
"\u{1F968}",
"\u{1F96F}",
"\u{1F9C0}",
"\u{1F356}",
"\u{1F357}",
"\u{1F969}",
"\u{1F354}",
"\u{1F35F}",
"\u{1F355}",
"\u{1F32D}",
"\u{1F32E}",
"\u{1F32F}",
"\u{1F959}",
"\u{1F35D}",
"\u{1F35C}",
"\u{1F363}",
"\u{1F371}",
"\u{1F35B}",
"\u{1F364}",
"\u{1F370}",
"\u{1F382}",
"\u{1F36E}",
"\u{1F36D}",
"\u{1F36C}",
"\u{1F36B}",
"\u{1F37F}",
"\u{2615}",
"\u{1F375}",
"\u{1F37A}",
"\u{1F37B}",
"\u{1F377}",
"\u{1F378}",
"\u{1F379}",
],
},
{
id: "activities",
name: "activities",
icon: "\u{26BD}",
emojis: [
"\u{26BD}",
"\u{1F3C0}",
"\u{1F3C8}",
"\u{26BE}",
"\u{1F94E}",
"\u{1F3BE}",
"\u{1F3D0}",
"\u{1F3C9}",
"\u{1F94F}",
"\u{1F3B1}",
"\u{1F3D3}",
"\u{1F3F8}",
"\u{1F94D}",
"\u{1F3D2}",
"\u{1F94C}",
"\u{26F3}",
"\u{1F3AF}",
"\u{1F3A3}",
"\u{1F3BD}",
"\u{1F3BF}",
"\u{26F7}\u{FE0F}",
"\u{1F3CB}\u{FE0F}",
"\u{1F6B4}",
"\u{1F3C4}",
"\u{1F3CA}",
"\u{1F938}",
"\u{1F3AE}",
"\u{1F579}\u{FE0F}",
"\u{1F3B2}",
"\u{265F}\u{FE0F}",
"\u{1F3C6}",
"\u{1F3C5}",
"\u{1F947}",
"\u{1F948}",
"\u{1F949}",
"\u{1F396}\u{FE0F}",
"\u{1F3AB}",
"\u{1F3AA}",
"\u{1F3AD}",
"\u{1F3A8}",
"\u{1F3B5}",
"\u{1F3B6}",
"\u{1F3A4}",
"\u{1F3A7}",
"\u{1F3B8}",
"\u{1F3B9}",
"\u{1F3BA}",
"\u{1F941}",
"\u{1F3BB}",
],
},
{
id: "travel",
name: "travel & places",
icon: "\u{2708}\u{FE0F}",
emojis: [
"\u{1F697}",
"\u{1F695}",
"\u{1F699}",
"\u{1F68C}",
"\u{1F68E}",
"\u{1F3CE}\u{FE0F}",
"\u{1F693}",
"\u{1F691}",
"\u{1F692}",
"\u{1F6F5}",
"\u{1F3CD}\u{FE0F}",
"\u{1F6B2}",
"\u{1F6F4}",
"\u{1F6A8}",
"\u{1F681}",
"\u{2708}\u{FE0F}",
"\u{1F680}",
"\u{1F6F8}",
"\u{1F6F6}",
"\u{26F5}",
"\u{1F6A4}",
"\u{1F6A2}",
"\u{1F3D7}\u{FE0F}",
"\u{1F3D8}\u{FE0F}",
"\u{1F3D9}\u{FE0F}",
"\u{1F3E0}",
"\u{1F3E2}",
"\u{1F3E5}",
"\u{1F3EB}",
"\u{1F3F0}",
"\u{26EA}",
"\u{1F54C}",
"\u{1F5FC}",
"\u{1F5FD}",
"\u{1F30D}",
"\u{1F30E}",
"\u{1F30F}",
"\u{1F5FA}\u{FE0F}",
"\u{1F30B}",
"\u{26F0}\u{FE0F}",
"\u{1F3D4}\u{FE0F}",
"\u{1F3D6}\u{FE0F}",
"\u{1F3DD}\u{FE0F}",
"\u{1F305}",
"\u{1F304}",
"\u{1F307}",
"\u{1F306}",
"\u{1F3DE}\u{FE0F}",
"\u{1F301}",
"\u{1F303}",
"\u{1F309}",
"\u{1F30C}",
],
},
{
id: "objects",
name: "objects",
icon: "\u{1F4A1}",
emojis: [
"\u{231A}",
"\u{1F4F1}",
"\u{1F4BB}",
"\u{2328}\u{FE0F}",
"\u{1F5A5}\u{FE0F}",
"\u{1F4BD}",
"\u{1F4BF}",
"\u{1F4C0}",
"\u{1F3A5}",
"\u{1F4F7}",
"\u{1F4F8}",
"\u{1F4F9}",
"\u{1F4FA}",
"\u{1F4FB}",
"\u{1F4E1}",
"\u{1F50B}",
"\u{1F50C}",
"\u{1F4A1}",
"\u{1F526}",
"\u{1F56F}\u{FE0F}",
"\u{1F4B0}",
"\u{1F4B3}",
"\u{1F48E}",
"\u{1F527}",
"\u{1F528}",
"\u{1F6E0}\u{FE0F}",
"\u{1F5E1}\u{FE0F}",
"\u{2694}\u{FE0F}",
"\u{1F52B}",
"\u{1F6E1}\u{FE0F}",
"\u{1F512}",
"\u{1F513}",
"\u{1F510}",
"\u{1F511}",
"\u{1F4E6}",
"\u{1F4EC}",
"\u{1F4EE}",
"\u{1F4E9}",
"\u{1F4E8}",
"\u{1F4DD}",
"\u{1F4C4}",
"\u{1F4D6}",
"\u{1F4DA}",
"\u{1F4D3}",
"\u{1F4D2}",
"\u{1F4D1}",
"\u{1F4CB}",
"\u{1F4CC}",
"\u{1F4CE}",
],
},
{
id: "symbols",
name: "symbols",
icon: "\u{2764}\u{FE0F}",
emojis: [
"\u{2764}\u{FE0F}",
"\u{1F9E1}",
"\u{1F49B}",
"\u{1F49A}",
"\u{1F499}",
"\u{1F49C}",
"\u{1F5A4}",
"\u{1F90D}",
"\u{1F90E}",
"\u{1F494}",
"\u{2763}\u{FE0F}",
"\u{1F495}",
"\u{1F49E}",
"\u{1F493}",
"\u{1F497}",
"\u{1F496}",
"\u{1F49D}",
"\u{1F4AF}",
"\u{1F4A2}",
"\u{1F4A5}",
"\u{1F4AB}",
"\u{1F4A6}",
"\u{1F4A8}",
"\u{2705}",
"\u{274C}",
"\u{274E}",
"\u{2B55}",
"\u{1F4A4}",
"\u{26A0}\u{FE0F}",
"\u{26D4}",
"\u{1F6AB}",
"\u{2049}\u{FE0F}",
"\u{2753}",
"\u{2757}",
"\u{203C}\u{FE0F}",
"\u{1F51E}",
"\u{1F4F5}",
"\u{1F6B7}",
"\u{1F6AF}",
"\u{1F6B3}",
"\u{1F51F}",
"\u{1F520}",
"\u{1F521}",
"\u{1F522}",
"\u{1F523}",
"\u{1F170}\u{FE0F}",
"\u{1F171}\u{FE0F}",
"\u{1F18E}",
"\u{1F191}",
"\u{1F192}",
"\u{1F193}",
"\u{1F194}",
"\u{1F195}",
"\u{1F196}",
],
},
];

121
src/lib/markdown.ts Normal file
View File

@ -0,0 +1,121 @@
import type { JSONContent } from "@tiptap/core";
// convert tiptap's json document tree to markdown-formatted text
// preserves formatting marks as markdown syntax for the wire format
export function tiptapToMarkdown(doc: JSONContent): string {
if (!doc.content) return "";
return doc.content
.map((node) => {
if (node.type === "paragraph") {
if (!node.content) return "";
return node.content.map(textNodeToMarkdown).join("");
}
// hard breaks become newlines
if (node.type === "hardBreak") return "\n";
return "";
})
.join("\n");
}
function textNodeToMarkdown(node: JSONContent): string {
if (node.type === "hardBreak") return "\n";
if (node.type !== "text" || !node.text) return "";
let text = node.text;
const marks = node.marks || [];
const hasCode = marks.some((m) => m.type === "code");
const hasBold = marks.some((m) => m.type === "bold");
const hasItalic = marks.some((m) => m.type === "italic");
const hasStrike = marks.some((m) => m.type === "strike");
// code is exclusive - no other formatting applies inside it
if (hasCode) {
return `\`${text}\``;
}
if (hasBold && hasItalic) {
text = `***${text}***`;
} else if (hasBold) {
text = `**${text}**`;
} else if (hasItalic) {
text = `*${text}*`;
}
if (hasStrike) {
text = `~~${text}~~`;
}
return text;
}
// check if a string is a standalone image/gif url (no other text)
export function isStandaloneImageUrl(text: string): boolean {
return /^https?:\/\/\S+\.(gif|png|jpg|jpeg|webp)(\?\S*)?$/i.test(text.trim());
}
// parse markdown-formatted text into safe html for display
// only produces a limited set of elements - no script injection possible
export function renderMarkdown(text: string): string {
// standalone image url gets rendered as a full image
if (isStandaloneImageUrl(text)) {
const url = escapeHtml(text.trim());
return `<img src="${url}" class="dusk-msg-image" alt="image" loading="lazy" />`;
}
// split by inline code spans to avoid parsing markdown inside code
const segments = text.split(/(`[^`\n]+`)/g);
let html = "";
for (const segment of segments) {
if (
segment.startsWith("`") &&
segment.endsWith("`") &&
segment.length > 2
) {
// inline code - escape and wrap, skip markdown processing
const code = escapeHtml(segment.slice(1, -1));
html += `<code class="dusk-msg-code">${code}</code>`;
} else {
let s = escapeHtml(segment);
// bold + italic combined
s = s.replace(
/\*\*\*(.+?)\*\*\*/g,
'<strong class="dusk-msg-bold"><em>$1</em></strong>',
);
// bold
s = s.replace(
/\*\*(.+?)\*\*/g,
'<strong class="dusk-msg-bold">$1</strong>',
);
// italic
s = s.replace(/\*(.+?)\*/g, '<em class="dusk-msg-italic">$1</em>');
// strikethrough
s = s.replace(/~~(.+?)~~/g, '<s class="dusk-msg-strike">$1</s>');
// auto-link urls
s = s.replace(
/(https?:\/\/[^\s<]+)/g,
'<a href="$1" target="_blank" rel="noopener noreferrer" class="dusk-msg-link">$1</a>',
);
html += s;
}
}
// newlines to breaks
html = html.replace(/\n/g, "<br />");
return html;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}

View File

@ -15,6 +15,7 @@ import type {
VoiceMediaState,
DirectMessage,
DMConversationMeta,
GifResponse,
} from "./types";
// -- identity --
@ -158,6 +159,10 @@ export async function sendTypingIndicator(channelId: string): Promise<void> {
return invoke("send_typing", { channelId });
}
export async function broadcastPresence(status: string): Promise<void> {
return invoke("broadcast_presence", { status });
}
// -- moderation --
export async function deleteMessage(
@ -336,3 +341,18 @@ export async function openDMConversation(
): Promise<DMConversationMeta> {
return invoke("open_dm_conversation", { peerId, displayName });
}
// -- gifs --
export async function searchGifs(
query: string,
limit?: number,
): Promise<GifResponse> {
return invoke("search_gifs", { query, limit });
}
export async function getTrendingGifs(
limit?: number,
): Promise<GifResponse> {
return invoke("get_trending_gifs", { limit });
}

View File

@ -128,7 +128,7 @@ export interface DMConversationMeta {
export interface Member {
peer_id: string;
display_name: string;
status: "Online" | "Idle" | "Offline";
status: "Online" | "Idle" | "Dnd" | "Offline";
roles: string[];
trust_level: number;
joined_at: number;
@ -165,6 +165,19 @@ export interface VoiceParticipant {
media_state: VoiceMediaState;
}
// gif search result from the relay klipy proxy
export interface GifResult {
id: string;
title: string;
url: string;
preview: string;
dims: [number, number];
}
export interface GifResponse {
results: GifResult[];
}
// discriminated union for events emitted from rust
export type DuskEvent =
| { kind: "message_received"; payload: ChatMessage }
@ -172,6 +185,10 @@ export type DuskEvent =
| { kind: "member_kicked"; payload: { peer_id: string } }
| { kind: "peer_connected"; payload: { peer_id: string } }
| { kind: "peer_disconnected"; payload: { peer_id: string } }
| {
kind: "presence_updated";
payload: { peer_id: string; status: string };
}
| { kind: "typing"; payload: { peer_id: string; channel_id: string } }
| { kind: "node_status"; payload: NodeStatus }
| { kind: "sync_complete"; payload: { community_id: string } }

View File

@ -13,7 +13,9 @@ export function addTypingPeer(peerId: string) {
const existing = typingTimeouts.get(peerId);
if (existing) clearTimeout(existing);
setTypingPeerIds((prev) => (prev.includes(peerId) ? prev : [...prev, peerId]));
setTypingPeerIds((prev) =>
prev.includes(peerId) ? prev : [...prev, peerId],
);
// auto-remove after 5 seconds of no new typing events
const timeout = setTimeout(() => {
@ -46,11 +48,14 @@ export function setPeerOnline(peerId: string) {
next.add(peerId);
return next;
});
// also update the member status
// update status only if the member isn't already in a non-offline state
// (presence_updated events carry the real status, this is a fallback)
setMembers((prev) =>
prev.map((m) =>
m.peer_id === peerId ? { ...m, status: "Online" as const } : m
)
m.peer_id === peerId && m.status === "Offline"
? { ...m, status: "Online" as const }
: m,
),
);
}
@ -60,11 +65,30 @@ export function setPeerOffline(peerId: string) {
next.delete(peerId);
return next;
});
// also update the member status
setMembers((prev) =>
prev.map((m) =>
m.peer_id === peerId ? { ...m, status: "Offline" as const } : m
)
m.peer_id === peerId ? { ...m, status: "Offline" as const } : m,
),
);
}
// set a peer's status from a presence broadcast
export function setPeerStatus(
peerId: string,
status: "Online" | "Idle" | "Dnd" | "Offline",
) {
if (status === "Offline") {
setPeerOffline(peerId);
return;
}
// mark them as online in the tracking set
setOnlinePeerIds((prev) => {
const next = new Set(prev);
next.add(peerId);
return next;
});
setMembers((prev) =>
prev.map((m) => (m.peer_id === peerId ? { ...m, status } : m)),
);
}

View File

@ -272,3 +272,301 @@ body {
.splash-fadeout {
animation: splash-fadeout 500ms ease forwards;
}
/* tiptap editor styles - single row layout */
.dusk-editor-row {
display: flex;
align-items: flex-end;
background: var(--color-bg-tertiary);
border: 2px solid var(--color-border-default);
transition: border-color var(--transition-fast);
}
.dusk-editor-row.dusk-editor-focused {
border-color: var(--color-accent);
}
.dusk-editor-actions {
display: flex;
align-items: center;
gap: 2px;
padding: 6px 6px 6px 0;
flex-shrink: 0;
}
.dusk-toolbar-btn {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
color: var(--color-text-tertiary);
cursor: pointer;
transition:
color var(--transition-fast),
background var(--transition-fast);
}
.dusk-toolbar-btn:hover {
color: var(--color-text-primary);
background: rgba(255, 255, 255, 0.06);
}
.dusk-toolbar-btn.active {
color: var(--color-accent);
}
.dusk-send-btn {
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
background: var(--color-accent);
color: white;
cursor: pointer;
transition:
background var(--transition-fast),
opacity var(--transition-fast);
}
.dusk-send-btn:hover {
background: var(--color-accent-hover);
}
.dusk-send-btn:disabled {
opacity: 0.3;
pointer-events: none;
}
.dusk-editor-content {
font-family: var(--font-sans);
font-size: 16px;
line-height: 22px;
color: var(--color-text-body);
padding: 10px 16px;
min-height: 44px;
max-height: 200px;
overflow-y: auto;
outline: none;
}
.dusk-editor-content p {
margin: 0;
}
.dusk-editor-content p + p {
margin-top: 4px;
}
.dusk-editor-content p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
font-family: var(--font-mono);
color: var(--color-text-tertiary);
pointer-events: none;
float: left;
height: 0;
}
.dusk-editor-content strong {
font-weight: 700;
color: var(--color-text-primary);
}
.dusk-editor-content em {
font-style: italic;
}
.dusk-editor-content s {
text-decoration: line-through;
color: var(--color-text-secondary);
}
.dusk-editor-content code {
font-family: var(--font-mono);
font-size: 14px;
background: rgba(255, 255, 255, 0.08);
padding: 1px 4px;
color: var(--color-accent);
}
/* ============================================================
picker panels (emoji + gif)
============================================================ */
.dusk-picker-panel {
width: 352px;
height: 420px;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border-default);
display: flex;
flex-direction: column;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
}
.dusk-gif-panel {
width: 420px;
height: 460px;
}
.dusk-picker-header {
padding: 8px;
border-bottom: 1px solid var(--color-border-subtle);
flex-shrink: 0;
}
.dusk-picker-search {
display: flex;
align-items: center;
gap: 8px;
background: var(--color-bg-tertiary);
padding: 6px 10px;
}
.dusk-picker-tabs {
display: flex;
align-items: center;
padding: 4px 8px;
gap: 2px;
border-bottom: 1px solid var(--color-border-subtle);
flex-shrink: 0;
overflow-x: auto;
}
.dusk-picker-tab {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
font-size: 18px;
cursor: pointer;
flex-shrink: 0;
opacity: 0.5;
transition:
opacity var(--transition-fast),
background var(--transition-fast);
}
.dusk-picker-tab:hover {
opacity: 1;
background: rgba(255, 255, 255, 0.06);
}
.dusk-picker-tab.active {
opacity: 1;
border-bottom: 2px solid var(--color-accent);
}
.dusk-picker-grid-container {
flex: 1;
overflow-y: auto;
min-height: 0;
}
.dusk-picker-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-tertiary);
padding: 4px 0 6px;
}
.dusk-emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 2px;
}
.dusk-emoji-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 1;
font-size: 22px;
cursor: pointer;
transition: background var(--transition-fast);
}
.dusk-emoji-btn:hover {
background: rgba(255, 255, 255, 0.08);
}
.dusk-gif-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
padding: 4px;
}
.dusk-gif-item {
aspect-ratio: 16 / 9;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: border-color var(--transition-fast);
}
.dusk-gif-item:hover {
border-color: var(--color-accent);
}
/* ============================================================
message markdown rendering
============================================================ */
.dusk-msg-content {
font-size: 16px;
line-height: 22px;
color: rgba(255, 255, 255, 0.9);
word-break: break-word;
white-space: pre-wrap;
margin: 0;
}
.dusk-msg-content strong,
.dusk-msg-bold {
font-weight: 700;
color: var(--color-text-primary);
}
.dusk-msg-content em,
.dusk-msg-italic {
font-style: italic;
}
.dusk-msg-content s,
.dusk-msg-strike {
text-decoration: line-through;
color: var(--color-text-secondary);
}
.dusk-msg-code {
font-family: var(--font-mono);
font-size: 14px;
background: rgba(255, 255, 255, 0.08);
padding: 2px 6px;
color: var(--color-accent);
}
.dusk-msg-link {
color: var(--color-accent);
text-decoration: none;
transition: opacity var(--transition-fast);
}
.dusk-msg-link:hover {
text-decoration: underline;
opacity: 0.85;
}
.dusk-msg-image-wrapper {
white-space: normal;
}
.dusk-msg-image {
max-width: 400px;
max-height: 300px;
object-fit: contain;
margin-top: 4px;
}

View File

@ -8,6 +8,16 @@ const host = process.env.TAURI_DEV_HOST;
export default defineConfig(async () => ({
plugins: [solid(), tailwindcss()],
// prosemirror deduplication - prevents keyed plugin conflicts with tiptap
optimizeDeps: {
include: [
"prosemirror-state",
"prosemirror-transform",
"prosemirror-model",
"prosemirror-view",
],
},
clearScreen: false,
server: {
port: 1420,