init commit
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
dist
|
||||
.env
|
||||
relay-server/
|
||||
.vscode
|
||||
|
|
@ -0,0 +1,428 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "dusk-chat",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.0.0",
|
||||
"@fontsource/space-grotesk": "^5.2.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"lucide-solid": "^0.469.0",
|
||||
"motion": "^12.0.0",
|
||||
"solid-js": "^1.9.3",
|
||||
"solid-motionone": "^1.0.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-solid": "^2.11.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="],
|
||||
|
||||
"@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="],
|
||||
|
||||
"@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="],
|
||||
|
||||
"@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="],
|
||||
|
||||
"@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="],
|
||||
|
||||
"@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="],
|
||||
|
||||
"@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="],
|
||||
|
||||
"@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="],
|
||||
|
||||
"@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="],
|
||||
|
||||
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
|
||||
|
||||
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
|
||||
|
||||
"@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="],
|
||||
|
||||
"@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="],
|
||||
|
||||
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
|
||||
|
||||
"@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.28.6", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w=="],
|
||||
|
||||
"@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="],
|
||||
|
||||
"@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="],
|
||||
|
||||
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
|
||||
|
||||
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
|
||||
|
||||
"@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
|
||||
|
||||
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
|
||||
|
||||
"@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
|
||||
|
||||
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
|
||||
|
||||
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
|
||||
|
||||
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
|
||||
|
||||
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
|
||||
|
||||
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
|
||||
|
||||
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
|
||||
|
||||
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
|
||||
|
||||
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
|
||||
|
||||
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
|
||||
|
||||
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
|
||||
|
||||
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
|
||||
|
||||
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
|
||||
|
||||
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
|
||||
|
||||
"@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
|
||||
|
||||
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
|
||||
|
||||
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
|
||||
|
||||
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
|
||||
|
||||
"@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
|
||||
|
||||
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
|
||||
|
||||
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
|
||||
|
||||
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||
|
||||
"@fontsource-variable/jetbrains-mono": ["@fontsource-variable/jetbrains-mono@5.2.8", "", {}, "sha512-WBA9elru6Jdp5df2mES55wuOO0WIrn3kpXnI4+W2ek5u3ZgLS9XS4gmIlcQhiZOWEKl95meYdvK7xI+ETLCq/Q=="],
|
||||
|
||||
"@fontsource/space-grotesk": ["@fontsource/space-grotesk@5.2.10", "", {}, "sha512-XNXEbT74OIITPqw2H6HXwPDp85fy43uxfBwFR5PU+9sLnjuLj12KlhVM9nZVN6q6dlKjkuN8JisW/OBxwxgUew=="],
|
||||
|
||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||
|
||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||
|
||||
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||
|
||||
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||
|
||||
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||
|
||||
"@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="],
|
||||
|
||||
"@motionone/dom": ["@motionone/dom@10.18.0", "", { "dependencies": { "@motionone/animation": "^10.18.0", "@motionone/generators": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "hey-listen": "^1.0.8", "tslib": "^2.3.1" } }, "sha512-bKLP7E0eyO4B2UaHBBN55tnppwRnaE3KFfh3Ps9HhnAkar3Cb69kUCJY9as8LrccVYKgHA+JY5dOQqJLOPhF5A=="],
|
||||
|
||||
"@motionone/easing": ["@motionone/easing@10.18.0", "", { "dependencies": { "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-VcjByo7XpdLS4o9T8t99JtgxkdMcNWD3yHU/n6CLEz3bkmKDRZyYQ/wmSf6daum8ZXqfUAgFeCZSpJZIMxaCzg=="],
|
||||
|
||||
"@motionone/generators": ["@motionone/generators@10.18.0", "", { "dependencies": { "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-+qfkC2DtkDj4tHPu+AFKVfR/C30O1vYdvsGYaR13W/1cczPrrcjdvYCj0VLFuRMN+lP1xvpNZHCRNM4fBzn1jg=="],
|
||||
|
||||
"@motionone/types": ["@motionone/types@10.17.1", "", {}, "sha512-KaC4kgiODDz8hswCrS0btrVrzyU2CSQKO7Ps90ibBVSQmjkrt2teqta6/sOG59v7+dPnKMAg13jyqtMKV2yJ7A=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="],
|
||||
|
||||
"@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="],
|
||||
|
||||
"@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="],
|
||||
|
||||
"@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="],
|
||||
|
||||
"@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="],
|
||||
|
||||
"@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="],
|
||||
|
||||
"@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="],
|
||||
|
||||
"@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="],
|
||||
|
||||
"@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="],
|
||||
|
||||
"@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="],
|
||||
|
||||
"@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="],
|
||||
|
||||
"@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="],
|
||||
|
||||
"@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="],
|
||||
|
||||
"@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="],
|
||||
|
||||
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="],
|
||||
|
||||
"@solid-primitives/props": ["@solid-primitives/props@3.2.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-lZOTwFJajBrshSyg14nBMEP0h8MXzPowGO0s3OeiR3z6nXHTfj0FhzDtJMv+VYoRJKQHG2QRnJTgCzK6erARAw=="],
|
||||
|
||||
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.2", "", { "dependencies": { "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-K7tf2thy7L+YJjdqXspXOg5xvNEOH8tgEWsp0+1mQk3obHBRD6hEjYZk7p7FlJphSZImS35je3UfmWuD7MhDfg=="],
|
||||
|
||||
"@solid-primitives/transition-group": ["@solid-primitives/transition-group@1.1.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-gnHS0OmcdjeoHN9n7Khu8KNrOlRc8a2weETDt2YT6o1zeW/XtUC6Db3Q9pkMU/9cCKdEmN4b0a/41MKAHRhzWA=="],
|
||||
|
||||
"@solid-primitives/utils": ["@solid-primitives/utils@6.3.2", "", { "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hZ/M/qr25QOCcwDPOHtGjxTD8w2mNyVAYvcfgwzBHq2RwNqHNdDNsMZYap20+ruRwW4A3Cdkczyoz0TSxLCAPQ=="],
|
||||
|
||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||
|
||||
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||
|
||||
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
||||
|
||||
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
||||
|
||||
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
||||
|
||||
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
||||
|
||||
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||
|
||||
"@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="],
|
||||
|
||||
"@tauri-apps/api": ["@tauri-apps/api@2.10.1", "", {}, "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw=="],
|
||||
|
||||
"@tauri-apps/cli": ["@tauri-apps/cli@2.10.0", "", { "optionalDependencies": { "@tauri-apps/cli-darwin-arm64": "2.10.0", "@tauri-apps/cli-darwin-x64": "2.10.0", "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0", "@tauri-apps/cli-linux-arm64-gnu": "2.10.0", "@tauri-apps/cli-linux-arm64-musl": "2.10.0", "@tauri-apps/cli-linux-riscv64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-gnu": "2.10.0", "@tauri-apps/cli-linux-x64-musl": "2.10.0", "@tauri-apps/cli-win32-arm64-msvc": "2.10.0", "@tauri-apps/cli-win32-ia32-msvc": "2.10.0", "@tauri-apps/cli-win32-x64-msvc": "2.10.0" }, "bin": { "tauri": "tauri.js" } }, "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-arm64": ["@tauri-apps/cli-darwin-arm64@2.10.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA=="],
|
||||
|
||||
"@tauri-apps/cli-darwin-x64": ["@tauri-apps/cli-darwin-x64@2.10.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": ["@tauri-apps/cli-linux-arm-gnueabihf@2.10.0", "", { "os": "linux", "cpu": "arm" }, "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-gnu": ["@tauri-apps/cli-linux-arm64-gnu@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA=="],
|
||||
|
||||
"@tauri-apps/cli-linux-arm64-musl": ["@tauri-apps/cli-linux-arm64-musl@2.10.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA=="],
|
||||
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": ["@tauri-apps/cli-linux-riscv64-gnu@2.10.0", "", { "os": "linux", "cpu": "none" }, "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-gnu": ["@tauri-apps/cli-linux-x64-gnu@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng=="],
|
||||
|
||||
"@tauri-apps/cli-linux-x64-musl": ["@tauri-apps/cli-linux-x64-musl@2.10.0", "", { "os": "linux", "cpu": "x64" }, "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q=="],
|
||||
|
||||
"@tauri-apps/cli-win32-arm64-msvc": ["@tauri-apps/cli-win32-arm64-msvc@2.10.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g=="],
|
||||
|
||||
"@tauri-apps/cli-win32-ia32-msvc": ["@tauri-apps/cli-win32-ia32-msvc@2.10.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A=="],
|
||||
|
||||
"@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.10.0", "", { "os": "win32", "cpu": "x64" }, "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ=="],
|
||||
|
||||
"@tauri-apps/plugin-shell": ["@tauri-apps/plugin-shell@2.3.5", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="],
|
||||
|
||||
"@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="],
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"caniuse-lite": ["caniuse-lite@1.0.30001769", "", {}, "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg=="],
|
||||
|
||||
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||
|
||||
"hey-listen": ["hey-listen@1.0.8", "", {}, "sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q=="],
|
||||
|
||||
"html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
|
||||
|
||||
"is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="],
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
|
||||
|
||||
"jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
|
||||
|
||||
"json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
|
||||
|
||||
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||
|
||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||
|
||||
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||
|
||||
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||
|
||||
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||
|
||||
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||
|
||||
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||
|
||||
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||
|
||||
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||
|
||||
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||
|
||||
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"motion-dom": ["motion-dom@12.34.0", "", { "dependencies": { "motion-utils": "^12.29.2" } }, "sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q=="],
|
||||
|
||||
"motion-utils": ["motion-utils@12.29.2", "", {}, "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
|
||||
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||
|
||||
"node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"seroval": ["seroval@1.5.0", "", {}, "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw=="],
|
||||
|
||||
"seroval-plugins": ["seroval-plugins@1.5.0", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-EAHqADIQondwRZIdeW2I636zgsODzoBDwb3PT/+7TLDWyw1Dy/Xv7iGUIEXXav7usHDE9HVhOU61irI3EnyyHA=="],
|
||||
|
||||
"solid-js": ["solid-js@1.9.11", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-WEJtcc5mkh/BnHA6Yrg4whlF8g6QwpmXXRg4P2ztPmcKeHHlH4+djYecBLhSpecZY2RRECXYUwIc/C2r3yzQ4Q=="],
|
||||
|
||||
"solid-motionone": ["solid-motionone@1.0.4", "", { "dependencies": { "@motionone/dom": "^10.17.0", "@motionone/utils": "^10.17.0", "@solid-primitives/props": "^3.1.11", "@solid-primitives/refs": "^1.0.8", "@solid-primitives/transition-group": "^1.0.5", "csstype": "^3.1.3" }, "peerDependencies": { "solid-js": "^1.8.0" } }, "sha512-aqEjgecoO9raDFznu/dEci7ORSmA26Kjj9J4Cn1Gyr0GZuOVdvsNxdxClTL9J40Aq/uYFx4GLwC8n70fMLHiuA=="],
|
||||
|
||||
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||
|
||||
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||
|
||||
"tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"vite-plugin-solid": ["vite-plugin-solid@2.11.10", "", { "dependencies": { "@babel/core": "^7.23.3", "@types/babel__core": "^7.20.4", "babel-preset-solid": "^1.8.4", "merge-anything": "^5.1.7", "solid-refresh": "^0.6.3", "vitefu": "^1.0.4" }, "peerDependencies": { "@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*", "solid-js": "^1.7.2", "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@testing-library/jest-dom"] }, "sha512-Yr1dQybmtDtDAHkii6hXuc1oVH9CPcS/Zb2jN/P36qqcrkNnVPsMTzQ06jyzFPFjj3U1IYKMVt/9ZqcwGCEbjw=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="],
|
||||
|
||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||
|
||||
"@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=="],
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
Discord-like Chat App — Design Specification
|
||||
|
||||
1. Overview
|
||||
This chat application embodies a minimalist, high-contrast aesthetic rooted in Dutch modernism and the systematic rigor of Wim Crouwel and De Stijl. The design employs a bold grotesque sans-serif for hierarchy paired with monospace type for technical details, creating a visual language that feels both contemporary and utilitarian.
|
||||
|
||||
International orange serves as the sole accent color—a deliberate, high-energy choice that activates the otherwise austere black-and-white foundation. The layout is governed by a strict modular grid, ensuring every element aligns to a mathematically consistent rhythm. Motion design follows Motion principles (https://motion.dev/): smooth, purposeful, and never gratuitous.
|
||||
|
||||
Navigation transforms the experience through a full-screen overlay menu, reinforcing the app's bold, confident spatial decisions. Every interaction is deliberate, every transition calculated, every whitespace meaningful.
|
||||
|
||||
2. Visual System
|
||||
Typography Hierarchy
|
||||
Primary Typeface: Space Grotesk (Bold Grotesque)
|
||||
|
||||
Weights: 400 (Regular), 500 (Medium), 700 (Bold)
|
||||
Usage:
|
||||
Display/Headers: 700 weight, tight letter-spacing (-0.02em)
|
||||
Body text: 400 weight, normal spacing
|
||||
UI labels: 500 weight, uppercase with +0.05em tracking
|
||||
Interactive elements: 500 weight
|
||||
Secondary Typeface: JetBrains Mono (Monospace)
|
||||
|
||||
Weights: 400 (Regular), 500 (Medium)
|
||||
Usage:
|
||||
Timestamps: 400 weight, 12px
|
||||
User IDs / technical metadata: 400 weight
|
||||
Code blocks or system messages: 400 weight
|
||||
Status indicators: 500 weight, uppercase
|
||||
Scale (Desktop):
|
||||
|
||||
Display: 48px / 56px line-height
|
||||
H1: 32px / 40px
|
||||
H2: 24px / 32px
|
||||
H3: 20px / 28px
|
||||
Body: 16px / 24px
|
||||
Small: 14px / 20px
|
||||
Micro: 12px / 16px
|
||||
Scale (Mobile):
|
||||
|
||||
Display: 32px / 40px
|
||||
H1: 24px / 32px
|
||||
H2: 20px / 28px
|
||||
Body: 16px / 24px
|
||||
Small: 14px / 20px
|
||||
Micro: 12px / 16px
|
||||
Color Palette
|
||||
Base Colors:
|
||||
|
||||
Pure Black: #000000 — primary background, text on light surfaces
|
||||
Off White: #FAFAFA — light background variant, cards
|
||||
True White: #FFFFFF — text on dark surfaces, highest contrast elements
|
||||
Contrast Layers:
|
||||
|
||||
Gray 900: #0A0A0A — secondary dark surface
|
||||
Gray 800: #1A1A1A — elevated dark surface
|
||||
Gray 200: #E5E5E5 — borders on light mode
|
||||
Gray 300: #D4D4D4 — subtle dividers
|
||||
Accent:
|
||||
|
||||
International Orange: #FF4F00 — primary accent for CTAs, active states, highlights
|
||||
Orange Hover: #E64500 — hover state darkening
|
||||
Orange Muted: #FF4F0015 — 8% opacity backgrounds for subtle emphasis
|
||||
Semantic Colors:
|
||||
|
||||
Success: #00FF00 — online status, success states
|
||||
Warning: #FFFF00 — warnings
|
||||
Error: #FF0000 — errors, destructive actions
|
||||
Spacing + Grid Philosophy
|
||||
Base Unit: 8px
|
||||
|
||||
Spacing Scale:
|
||||
|
||||
xs: 4px
|
||||
sm: 8px
|
||||
md: 16px
|
||||
lg: 24px
|
||||
xl: 32px
|
||||
2xl: 48px
|
||||
3xl: 64px
|
||||
4xl: 96px
|
||||
Grid System:
|
||||
|
||||
Desktop: 12-column grid, 24px gutter, 1440px max-width container
|
||||
Tablet: 8-column grid, 16px gutter, 100% width
|
||||
Mobile: 4-column grid, 16px gutter, 100% width
|
||||
Modular Rhythm:
|
||||
All components snap to 8px vertical rhythm. Section padding follows 64px (desktop) / 48px (tablet) / 32px (mobile) increments.
|
||||
|
||||
Iconography + Motifs
|
||||
Icon System: Lucide Vue
|
||||
|
||||
Stroke Width: 2px (consistent with grotesque weight)
|
||||
Size Scale: 16px, 20px, 24px, 32px
|
||||
Style: Sharp, geometric, minimal
|
||||
Geometric Motifs:
|
||||
|
||||
45° diagonal elements for dynamic composition
|
||||
Perfect squares and circles for buttons/avatars
|
||||
2px solid borders (never rounded corners on containers)
|
||||
Right angles maintained throughout
|
||||
Avatar System:
|
||||
|
||||
Perfect circles for user avatars (only curved element)
|
||||
32px (small), 40px (medium), 48px (large), 64px (profile)
|
||||
Status indicator: 8px circle, bottom-right overlap with 2px white border 3. Layout Design
|
||||
Page Structure
|
||||
Three-Column Layout (Desktop):
|
||||
|
||||
┌────────────┬─────────────────────────┬────────────┐
|
||||
│ Server │ Main Chat Area │ Sidebar │
|
||||
│ List │ │ (Users/ │
|
||||
│ 64px │ Fluid │ Info) │
|
||||
│ │ │ 280px │
|
||||
└────────────┴─────────────────────────┴────────────┘
|
||||
Two-Column Layout (Tablet):
|
||||
|
||||
┌────────────┬─────────────────────────┐
|
||||
│ Channel │ Main Chat Area │
|
||||
│ List │ │
|
||||
│ 240px │ Fluid │
|
||||
│ │ │
|
||||
└────────────┴─────────────────────────┘
|
||||
Single-Column Layout (Mobile):
|
||||
|
||||
Full-screen chat view
|
||||
Hamburger menu reveals server/channel navigation as full-screen overlay
|
||||
Swipe gestures for quick navigation
|
||||
Component Designs
|
||||
Server List (Left Sidebar - Desktop)
|
||||
64px fixed width
|
||||
Black background #000000
|
||||
Server icons: 48px squares, 8px margin top/bottom
|
||||
Active server: 4px international orange border-left
|
||||
Hover: subtle scale to 1.05, 200ms ease-out
|
||||
Channel List (Secondary Sidebar)
|
||||
240px width on desktop, collapses on tablet
|
||||
Background: #0A0A0A
|
||||
Section headers: uppercase, 500 weight, 12px, #FFFFFF at 60% opacity
|
||||
Channel items:
|
||||
16px font, 400 weight
|
||||
40px height, 8px padding-left
|
||||
Hover: #1A1A1A background
|
||||
Active: international orange left border (4px), #FFFFFF text
|
||||
Unread indicator: 6px orange circle, right-aligned
|
||||
Main Chat Area
|
||||
Background: #000000
|
||||
Messages:
|
||||
Avatar: 40px circle, left-aligned
|
||||
Username: 500 weight, 16px, #FFFFFF
|
||||
Timestamp: JetBrains Mono, 12px, #FFFFFF at 50% opacity
|
||||
Message text: 400 weight, 16px, #FFFFFF at 90% opacity
|
||||
Spacing: 16px between messages, 8px between text lines
|
||||
Hover: #0A0A0A background on entire message block
|
||||
Message Input
|
||||
Fixed bottom position
|
||||
Height: 64px
|
||||
Background: #1A1A1A
|
||||
Border-top: 1px solid #FFFFFF at 10% opacity
|
||||
Input field:
|
||||
Background: #000000
|
||||
Border: 2px solid #FFFFFF at 20% opacity
|
||||
Focus: 2px solid international orange
|
||||
Padding: 12px 16px
|
||||
Placeholder: JetBrains Mono, #FFFFFF at 40% opacity
|
||||
Buttons
|
||||
Primary (Orange):
|
||||
|
||||
Background: #FF4F00
|
||||
Text: #FFFFFF, 500 weight, uppercase, +0.05em tracking
|
||||
Padding: 12px 24px
|
||||
Height: 48px
|
||||
Border: none
|
||||
Hover: #E64500 background, scale 0.98
|
||||
Active: scale 0.96
|
||||
Secondary (Ghost):
|
||||
|
||||
Background: transparent
|
||||
Border: 2px solid #FFFFFF
|
||||
Text: #FFFFFF, 500 weight, uppercase
|
||||
Hover: background #FFFFFF, text #000000
|
||||
Icon Button:
|
||||
|
||||
40px × 40px square
|
||||
Background: #1A1A1A
|
||||
Hover: background #FF4F00, icon color #FFFFFF
|
||||
Cards/Modals
|
||||
Background: #0A0A0A
|
||||
Border: 2px solid #FFFFFF at 20% opacity
|
||||
Padding: 32px
|
||||
Shadow: none (rely on borders for hierarchy)
|
||||
Title: 24px, 700 weight, #FFFFFF
|
||||
Dividers: 1px solid #FFFFFF at 10% opacity
|
||||
Navigation Overlay (Full-Screen Menu)
|
||||
Background: #000000
|
||||
Z-index: 1000
|
||||
Menu items:
|
||||
48px font, 700 weight, #FFFFFF
|
||||
Hover: international orange color, translate-x 16px
|
||||
Stagger animation on entrance (100ms delay per item)
|
||||
Close button: top-right, 48px × 48px, white X icon
|
||||
Backdrop blur: none (solid black)
|
||||
User Sidebar (Right - Desktop)
|
||||
280px width
|
||||
Background: #0A0A0A
|
||||
Member list:
|
||||
Role headers: uppercase, JetBrains Mono, 12px, orange
|
||||
User items: 40px height, avatar + name
|
||||
Status dot: 8px, green/yellow/gray
|
||||
Hover: #1A1A1A background
|
||||
Responsive Variations
|
||||
Desktop (1440px+):
|
||||
|
||||
Full three-column layout
|
||||
All interactions visible
|
||||
Spacious padding (32px standard)
|
||||
Tablet (768px - 1439px):
|
||||
|
||||
Two-column: channels + chat
|
||||
Right sidebar accessible via icon toggle
|
||||
Reduced padding (24px)
|
||||
Mobile (< 768px):
|
||||
|
||||
Single column, full-screen chat
|
||||
Hamburger menu triggers full-screen overlay for navigation
|
||||
Server list becomes horizontal scrollable bar at top (48px height)
|
||||
Swipe right: open channel list
|
||||
Swipe left: open user list
|
||||
Message input remains fixed bottom
|
||||
Padding: 16px 4. Interaction + Motion
|
||||
Entrance Animations (Motion)
|
||||
Page Load:
|
||||
|
||||
Server list fades in from left: opacity 0 → 1, x: -20 → 0, 400ms ease-out
|
||||
Channel list follows: 100ms delay, same animation
|
||||
Chat messages stagger in: 50ms delay per message, opacity 0 → 1, y: 10 → 0, 300ms ease-out
|
||||
Right sidebar: 200ms delay, fade + slide from right
|
||||
New Message:
|
||||
|
||||
opacity 0 → 1, y: 20 → 0, 300ms ease-out
|
||||
Scale in from 0.95 → 1.0 simultaneously
|
||||
Modal/Overlay:
|
||||
|
||||
Background: opacity 0 → 1, 200ms
|
||||
Content: scale 0.95 → 1, opacity 0 → 1, 300ms, 100ms delay after background
|
||||
Hover States
|
||||
All Interactive Elements:
|
||||
|
||||
Transition duration: 200ms
|
||||
Easing: cubic-bezier(0.4, 0.0, 0.2, 1) [ease-out]
|
||||
Specific Behaviors:
|
||||
|
||||
Buttons: scale 0.98, background color shift
|
||||
Links/Text: color shift to orange, no underline
|
||||
Cards: border color brightens to #FFFFFF at 40% opacity
|
||||
Icons: rotate 90° (for settings/options icons), color shift to orange
|
||||
Server icons: scale 1.05, subtle glow effect (box-shadow)
|
||||
Transition Behavior
|
||||
Route Changes:
|
||||
|
||||
Current view: fade out + scale down to 0.98, 250ms
|
||||
New view: fade in + scale up from 0.98, 300ms, 100ms delay
|
||||
Sidebar Toggle:
|
||||
|
||||
Slide animation: 400ms ease-in-out
|
||||
Content reflow: 400ms synchronized with slide
|
||||
Expandable Sections:
|
||||
|
||||
Height: auto-animate with max-height trick
|
||||
Duration: 300ms ease-out
|
||||
Icon rotation: 180° synchronized
|
||||
Overlay Menu Behavior
|
||||
Open Animation:
|
||||
|
||||
Background fills from top: 400ms ease-out
|
||||
Menu items stagger in (100ms delay each): opacity 0 → 1, x: -30 → 0, 400ms ease-out
|
||||
Close button fades in: 300ms, 200ms delay
|
||||
Close Animation:
|
||||
|
||||
Menu items stagger out in reverse: opacity 1 → 0, x: 0 → -30, 300ms ease-in
|
||||
Background fades: 300ms, begins after items start
|
||||
Total duration: 600ms
|
||||
Navigation Interaction:
|
||||
|
||||
Click item: orange color + translate-x 16px (200ms)
|
||||
300ms delay before route transition initiates
|
||||
Menu closes as new view loads
|
||||
Microinteractions
|
||||
Typing Indicator:
|
||||
|
||||
Three dots: 4px circles, international orange
|
||||
Sequential scale animation: 0.8 → 1.2 → 0.8, 600ms loop, 150ms delay per dot
|
||||
Unread Badge:
|
||||
|
||||
Pop in: scale 0 → 1 with bounce (spring physics)
|
||||
Pulse every 3s: scale 1 → 1.1 → 1, opacity 1 → 0.8 → 1
|
||||
Message Reactions:
|
||||
|
||||
Hover: scale 1.1, 150ms
|
||||
Click: scale 0.9 → 1.2 with spring, add counter increment animation
|
||||
Status Indicator:
|
||||
|
||||
State change: scale 0.8 → 1.2 → 1, color crossfade 300ms 5. Implementation Notes
|
||||
Tailwind Configuration
|
||||
Custom Theme Extension:
|
||||
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'orange': '#FF4F00',
|
||||
'orange-hover': '#E64500',
|
||||
'orange-muted': '#FF4F0015',
|
||||
'gray-900': '#0A0A0A',
|
||||
'gray-800': '#1A1A1A',
|
||||
},
|
||||
fontFamily: {
|
||||
'sans': ['Space Grotesk', 'system-ui', 'sans-serif'],
|
||||
'mono': ['JetBrains Mono', 'monospace'],
|
||||
},
|
||||
spacing: {
|
||||
// 8px base unit system already default in Tailwind
|
||||
},
|
||||
maxWidth: {
|
||||
'container': '1440px',
|
||||
},
|
||||
},
|
||||
}
|
||||
CSS Custom Properties
|
||||
|
||||
:root {
|
||||
--color-bg-primary: #000000;
|
||||
--color-bg-secondary: #0A0A0A;
|
||||
--color-bg-tertiary: #1A1A1A;
|
||||
--color-text-primary: #FFFFFF;
|
||||
--color-text-secondary: rgba(255, 255, 255, 0.6);
|
||||
--color-accent: #FF4F00;
|
||||
--color-accent-hover: #E64500;
|
||||
|
||||
--spacing-unit: 8px;
|
||||
|
||||
--transition-fast: 200ms cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
--transition-base: 300ms cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
Conclusion
|
||||
This design system delivers a bold, systematic visual language rooted in Dutch modernism while maintaining contemporary usability standards. Every element serves the dual purpose of aesthetic clarity and functional efficiency. The international orange accent provides the necessary energy and wayfinding without compromising the minimalist foundation.
|
||||
|
||||
Implementation should prioritize: grid alignment, smooth Motion-powered animations (https://motion.dev/), strict typography hierarchy, and real-time functionality via WebSocket connections. The result will be a chat application that feels simultaneously timeless and cutting-edge—confident, fast, and unmistakably modern.
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<title>dusk</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="/src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "dusk-chat",
|
||||
"version": "0.1.0",
|
||||
"description": "peer-to-peer community platform",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "WEBKIT_DISABLE_DMABUF_RENDERER=1 GDK_BACKEND=x11 tauri"
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/jetbrains-mono": "^5.0.0",
|
||||
"@fontsource/space-grotesk": "^5.2.0",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2",
|
||||
"lucide-solid": "^0.469.0",
|
||||
"motion": "^12.0.0",
|
||||
"solid-js": "^1.9.3",
|
||||
"solid-motionone": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"vite-plugin-solid": "^2.11.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"tailwindcss": "^4.0.0"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
[package]
|
||||
name = "dusk-chat"
|
||||
version = "0.1.0"
|
||||
description = "peer-to-peer community platform"
|
||||
authors = ["dusk"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "dusk_chat_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
# p2p networking
|
||||
libp2p = { version = "0.54", features = [
|
||||
"gossipsub",
|
||||
"kad",
|
||||
"mdns",
|
||||
"noise",
|
||||
"yamux",
|
||||
"tcp",
|
||||
"tokio",
|
||||
"identify",
|
||||
"macros",
|
||||
"request-response",
|
||||
"cbor",
|
||||
"relay",
|
||||
"rendezvous",
|
||||
"ping",
|
||||
] }
|
||||
|
||||
# crdt engine
|
||||
automerge = "0.5"
|
||||
|
||||
# identity and encoding
|
||||
rand = "0.8"
|
||||
bs58 = "0.5"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
|
||||
# data storage
|
||||
directories = "5"
|
||||
|
||||
# env file support
|
||||
dotenvy = "0.15"
|
||||
|
||||
# async utilities
|
||||
futures = "0.3"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": [
|
||||
"main"
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-unlisten",
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
|
@ -0,0 +1,244 @@
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::node::gossip;
|
||||
use crate::node::{self, NodeCommand};
|
||||
use crate::protocol::messages::{ChatMessage, GossipMessage, ProfileAnnouncement, TypingIndicator};
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_node(app: tauri::AppHandle, state: State<'_, AppState>) -> Result<(), String> {
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity
|
||||
.as_ref()
|
||||
.ok_or("no identity loaded, create one first")?;
|
||||
|
||||
let handle = node::start(
|
||||
id.keypair.clone(),
|
||||
state.crdt_engine.clone(),
|
||||
state.storage.clone(),
|
||||
app,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// capture profile info for announcement before dropping identity lock
|
||||
let profile_announcement = ProfileAnnouncement {
|
||||
peer_id: id.peer_id.to_string(),
|
||||
display_name: id.display_name.clone(),
|
||||
bio: id.bio.clone(),
|
||||
public_key: hex::encode(id.keypair.public().encode_protobuf()),
|
||||
timestamp: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64,
|
||||
};
|
||||
drop(identity);
|
||||
|
||||
{
|
||||
let mut node_handle = state.node_handle.lock().await;
|
||||
*node_handle = Some(handle);
|
||||
}
|
||||
|
||||
// subscribe to the global sync topic for document exchange
|
||||
let sync_topic = gossip::topic_for_sync();
|
||||
let directory_topic = gossip::topic_for_directory();
|
||||
let handle_ref = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *handle_ref {
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe { topic: sync_topic })
|
||||
.await;
|
||||
|
||||
// subscribe to the directory topic for peer profile announcements
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe {
|
||||
topic: directory_topic.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
// announce our profile on the directory topic
|
||||
let announce_msg = GossipMessage::ProfileAnnounce(profile_announcement);
|
||||
if let Ok(data) = serde_json::to_vec(&announce_msg) {
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage {
|
||||
topic: directory_topic,
|
||||
data,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// subscribe to all known community topics
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
let community_ids = engine.community_ids();
|
||||
drop(engine);
|
||||
|
||||
if let Some(ref handle) = *handle_ref {
|
||||
for community_id in &community_ids {
|
||||
let channels = {
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
engine.get_channels(community_id).unwrap_or_default()
|
||||
};
|
||||
|
||||
for channel in &channels {
|
||||
let topic = gossip::topic_for_messages(community_id, &channel.id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe { topic })
|
||||
.await;
|
||||
|
||||
let typing_topic = gossip::topic_for_typing(community_id, &channel.id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe {
|
||||
topic: typing_topic,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let presence_topic = gossip::topic_for_presence(community_id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe {
|
||||
topic: presence_topic,
|
||||
})
|
||||
.await;
|
||||
|
||||
// register on rendezvous for each community so other peers can find us
|
||||
let namespace = format!("dusk/community/{}", community_id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::RegisterRendezvous { namespace })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
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() {
|
||||
let _ = handle.command_tx.send(NodeCommand::Shutdown).await;
|
||||
let _ = handle.task.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_message(
|
||||
state: State<'_, AppState>,
|
||||
channel_id: String,
|
||||
content: String,
|
||||
) -> Result<ChatMessage, String> {
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
|
||||
let msg = ChatMessage {
|
||||
id: format!("msg_{}_{}", id.peer_id, now),
|
||||
channel_id: channel_id.clone(),
|
||||
author_id: id.peer_id.to_string(),
|
||||
author_name: id.display_name.clone(),
|
||||
content,
|
||||
timestamp: now,
|
||||
edited: false,
|
||||
};
|
||||
|
||||
// figure out which community this channel belongs to
|
||||
let mut engine = state.crdt_engine.lock().await;
|
||||
let community_id = find_community_for_channel(&engine, &channel_id)?;
|
||||
|
||||
engine.append_message(&community_id, &msg)?;
|
||||
drop(engine);
|
||||
|
||||
// publish to gossipsub
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
let topic = gossip::topic_for_messages(&community_id, &channel_id);
|
||||
let data = serde_json::to_vec(&GossipMessage::Chat(msg.clone()))
|
||||
.map_err(|e| format!("serialize error: {}", e))?;
|
||||
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage { topic, data })
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_messages(
|
||||
state: State<'_, AppState>,
|
||||
channel_id: String,
|
||||
before: Option<u64>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Vec<ChatMessage>, String> {
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
let community_id = find_community_for_channel(&engine, &channel_id)?;
|
||||
engine.get_messages(&community_id, &channel_id, before, limit.unwrap_or(50))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_typing(state: State<'_, AppState>, channel_id: String) -> Result<(), String> {
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
|
||||
let indicator = TypingIndicator {
|
||||
peer_id: id.peer_id.to_string(),
|
||||
channel_id: channel_id.clone(),
|
||||
timestamp: now,
|
||||
};
|
||||
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
let community_id = find_community_for_channel(&engine, &channel_id)?;
|
||||
drop(engine);
|
||||
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
let topic = gossip::topic_for_typing(&community_id, &channel_id);
|
||||
let data = serde_json::to_vec(&GossipMessage::Typing(indicator))
|
||||
.map_err(|e| format!("serialize error: {}", e))?;
|
||||
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage { topic, data })
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// find which community a channel belongs to by checking all loaded documents
|
||||
fn find_community_for_channel(
|
||||
engine: &crate::crdt::CrdtEngine,
|
||||
channel_id: &str,
|
||||
) -> Result<String, String> {
|
||||
for community_id in engine.community_ids() {
|
||||
if let Ok(channels) = engine.get_channels(&community_id) {
|
||||
if channels.iter().any(|ch| ch.id == channel_id) {
|
||||
return Ok(community_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(format!(
|
||||
"no community found containing channel {}",
|
||||
channel_id
|
||||
))
|
||||
}
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
use tauri::State;
|
||||
|
||||
use crate::node::gossip;
|
||||
use crate::node::NodeCommand;
|
||||
use crate::protocol::community::{ChannelKind, ChannelMeta, CommunityMeta, Member};
|
||||
use crate::protocol::messages::PeerStatus;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_community(
|
||||
state: State<'_, AppState>,
|
||||
name: String,
|
||||
description: String,
|
||||
) -> Result<CommunityMeta, String> {
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
|
||||
// generate a deterministic community id from name + creator + timestamp
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(name.as_bytes());
|
||||
hasher.update(id.peer_id.to_bytes());
|
||||
hasher.update(now.to_le_bytes());
|
||||
let hash = hasher.finalize();
|
||||
let community_id = format!("com_{}", &hex::encode(hash)[..16]);
|
||||
|
||||
let peer_id_str = id.peer_id.to_string();
|
||||
drop(identity);
|
||||
|
||||
let mut engine = state.crdt_engine.lock().await;
|
||||
engine.create_community(&community_id, &name, &description, &peer_id_str)?;
|
||||
|
||||
let meta = engine.get_community_meta(&community_id)?;
|
||||
|
||||
// save metadata cache
|
||||
let _ = state.storage.save_community_meta(&meta);
|
||||
drop(engine);
|
||||
|
||||
// subscribe to community topics on the p2p node
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
let presence_topic = gossip::topic_for_presence(&community_id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe {
|
||||
topic: presence_topic,
|
||||
})
|
||||
.await;
|
||||
|
||||
// subscribe to the default general channel
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
if let Ok(channels) = engine.get_channels(&community_id) {
|
||||
for channel in &channels {
|
||||
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe { topic: msg_topic })
|
||||
.await;
|
||||
|
||||
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe {
|
||||
topic: typing_topic,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// register on rendezvous so peers joining via invite can discover us
|
||||
let namespace = format!("dusk/community/{}", community_id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::RegisterRendezvous { namespace })
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn join_community(
|
||||
state: State<'_, AppState>,
|
||||
invite_code: String,
|
||||
) -> Result<CommunityMeta, String> {
|
||||
let invite = crate::protocol::community::InviteCode::decode(&invite_code)?;
|
||||
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
let peer_id_str = id.peer_id.to_string();
|
||||
drop(identity);
|
||||
|
||||
// create a placeholder document that will be backfilled via crdt sync
|
||||
// once we connect to existing community members through the relay
|
||||
let mut engine = state.crdt_engine.lock().await;
|
||||
if !engine.has_community(&invite.community_id) {
|
||||
engine.create_community(
|
||||
&invite.community_id,
|
||||
&invite.community_name,
|
||||
"",
|
||||
&peer_id_str,
|
||||
)?;
|
||||
}
|
||||
|
||||
let meta = engine.get_community_meta(&invite.community_id)?;
|
||||
let _ = state.storage.save_community_meta(&meta);
|
||||
|
||||
// subscribe to gossipsub topics so we receive messages
|
||||
let channels = engine
|
||||
.get_channels(&invite.community_id)
|
||||
.unwrap_or_default();
|
||||
drop(engine);
|
||||
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
// subscribe to the community presence topic
|
||||
let presence_topic = gossip::topic_for_presence(&invite.community_id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe {
|
||||
topic: presence_topic,
|
||||
})
|
||||
.await;
|
||||
|
||||
// subscribe to all channel topics
|
||||
for channel in &channels {
|
||||
let msg_topic = gossip::topic_for_messages(&invite.community_id, &channel.id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe { topic: msg_topic })
|
||||
.await;
|
||||
|
||||
let typing_topic = gossip::topic_for_typing(&invite.community_id, &channel.id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe {
|
||||
topic: typing_topic,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// register on rendezvous so existing members can find us
|
||||
let namespace = format!("dusk/community/{}", invite.community_id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::RegisterRendezvous {
|
||||
namespace: namespace.clone(),
|
||||
})
|
||||
.await;
|
||||
|
||||
// discover existing members through rendezvous
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::DiscoverRendezvous { namespace })
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn leave_community(
|
||||
state: State<'_, AppState>,
|
||||
community_id: String,
|
||||
) -> Result<(), String> {
|
||||
// unsubscribe from all community topics
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
if let Ok(channels) = engine.get_channels(&community_id) {
|
||||
for channel in &channels {
|
||||
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Unsubscribe { topic: msg_topic })
|
||||
.await;
|
||||
|
||||
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Unsubscribe {
|
||||
topic: typing_topic,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
let presence_topic = gossip::topic_for_presence(&community_id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Unsubscribe {
|
||||
topic: presence_topic,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_communities(state: State<'_, AppState>) -> Result<Vec<CommunityMeta>, String> {
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
let mut communities = Vec::new();
|
||||
|
||||
for id in engine.community_ids() {
|
||||
if let Ok(meta) = engine.get_community_meta(&id) {
|
||||
communities.push(meta);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(communities)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_channel(
|
||||
state: State<'_, AppState>,
|
||||
community_id: String,
|
||||
name: String,
|
||||
topic: String,
|
||||
) -> Result<ChannelMeta, String> {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(community_id.as_bytes());
|
||||
hasher.update(name.as_bytes());
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
hasher.update(now.to_le_bytes());
|
||||
let hash = hasher.finalize();
|
||||
let channel_id = format!("ch_{}", &hex::encode(hash)[..12]);
|
||||
|
||||
let channel = ChannelMeta {
|
||||
id: channel_id,
|
||||
community_id: community_id.clone(),
|
||||
name,
|
||||
topic,
|
||||
kind: ChannelKind::Text,
|
||||
};
|
||||
|
||||
let mut engine = state.crdt_engine.lock().await;
|
||||
engine.create_channel(&community_id, &channel)?;
|
||||
drop(engine);
|
||||
|
||||
// subscribe to the new channel's topics
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
let msg_topic = gossip::topic_for_messages(&community_id, &channel.id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe { topic: msg_topic })
|
||||
.await;
|
||||
|
||||
let typing_topic = gossip::topic_for_typing(&community_id, &channel.id);
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::Subscribe {
|
||||
topic: typing_topic,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_channels(
|
||||
state: State<'_, AppState>,
|
||||
community_id: String,
|
||||
) -> Result<Vec<ChannelMeta>, String> {
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
engine.get_channels(&community_id)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_members(
|
||||
state: State<'_, AppState>,
|
||||
community_id: String,
|
||||
) -> Result<Vec<Member>, String> {
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
let mut members = engine.get_members(&community_id)?;
|
||||
drop(engine);
|
||||
|
||||
// overlay the local user's identity so their display name stays current
|
||||
let identity = state.identity.lock().await;
|
||||
if let Some(ref id) = *identity {
|
||||
let local_peer = id.peer_id.to_string();
|
||||
let found = members.iter_mut().find(|m| m.peer_id == local_peer);
|
||||
if let Some(member) = found {
|
||||
member.display_name = id.display_name.clone();
|
||||
member.status = PeerStatus::Online;
|
||||
} else {
|
||||
// local user isn't in the doc yet (shouldn't happen, but be safe)
|
||||
members.push(Member {
|
||||
peer_id: local_peer,
|
||||
display_name: id.display_name.clone(),
|
||||
status: PeerStatus::Online,
|
||||
roles: vec!["member".to_string()],
|
||||
trust_level: 1.0,
|
||||
joined_at: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(members)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_message(
|
||||
state: State<'_, AppState>,
|
||||
community_id: String,
|
||||
message_id: String,
|
||||
) -> Result<(), String> {
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
let peer_id_str = id.peer_id.to_string();
|
||||
drop(identity);
|
||||
|
||||
// verify the user is the message author or has admin rights
|
||||
let mut engine = state.crdt_engine.lock().await;
|
||||
let message = engine
|
||||
.get_message(&community_id, &message_id)?
|
||||
.ok_or_else(|| format!("message {} not found", message_id))?;
|
||||
|
||||
// only allow deletion by the author
|
||||
if message.author_id != peer_id_str {
|
||||
return Err("not authorized to delete this message".to_string());
|
||||
}
|
||||
|
||||
engine.delete_message(&community_id, &message_id)?;
|
||||
drop(engine);
|
||||
|
||||
// broadcast the deletion to peers
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
// find the channel for this message to get the correct topic
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
if let Ok(channels) = engine.get_channels(&community_id) {
|
||||
for channel in &channels {
|
||||
let topic = gossip::topic_for_messages(&community_id, &channel.id);
|
||||
let deletion = crate::protocol::messages::GossipMessage::DeleteMessage {
|
||||
message_id: message_id.clone(),
|
||||
};
|
||||
if let Ok(data) = serde_json::to_vec(&deletion) {
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage { topic, data })
|
||||
.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn kick_member(
|
||||
state: State<'_, AppState>,
|
||||
community_id: String,
|
||||
member_peer_id: String,
|
||||
) -> Result<(), String> {
|
||||
let identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
let requester_id = id.peer_id.to_string();
|
||||
drop(identity);
|
||||
|
||||
// verify the requester has admin rights
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
let members = engine.get_members(&community_id)?;
|
||||
|
||||
let requester = members
|
||||
.iter()
|
||||
.find(|m| m.peer_id == requester_id)
|
||||
.ok_or("requester not found in community")?;
|
||||
|
||||
let is_admin = requester.roles.iter().any(|r| r == "admin" || r == "owner");
|
||||
if !is_admin {
|
||||
return Err("not authorized to kick members".to_string());
|
||||
}
|
||||
|
||||
// cannot kick the owner
|
||||
let target = members
|
||||
.iter()
|
||||
.find(|m| m.peer_id == member_peer_id)
|
||||
.ok_or("member not found")?;
|
||||
|
||||
if target.roles.iter().any(|r| r == "owner") {
|
||||
return Err("cannot kick the community owner".to_string());
|
||||
}
|
||||
|
||||
drop(engine);
|
||||
|
||||
// remove the member from the community
|
||||
let mut engine = state.crdt_engine.lock().await;
|
||||
engine.remove_member(&community_id, &member_peer_id)?;
|
||||
drop(engine);
|
||||
|
||||
// broadcast the kick to peers
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
let presence_topic = gossip::topic_for_presence(&community_id);
|
||||
let kick_msg = crate::protocol::messages::GossipMessage::MemberKicked {
|
||||
peer_id: member_peer_id.clone(),
|
||||
};
|
||||
if let Ok(data) = serde_json::to_vec(&kick_msg) {
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage {
|
||||
topic: presence_topic,
|
||||
data,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn generate_invite(
|
||||
state: State<'_, AppState>,
|
||||
community_id: String,
|
||||
) -> Result<String, String> {
|
||||
let engine = state.crdt_engine.lock().await;
|
||||
let meta = engine.get_community_meta(&community_id)?;
|
||||
drop(engine);
|
||||
|
||||
// invite contains only the community id and name
|
||||
// no IP addresses or peer addresses are included
|
||||
// peers discover each other through the relay's rendezvous protocol
|
||||
let invite = crate::protocol::community::InviteCode {
|
||||
community_id: meta.id.clone(),
|
||||
community_name: meta.name.clone(),
|
||||
};
|
||||
|
||||
Ok(invite.encode())
|
||||
}
|
||||
|
|
@ -0,0 +1,248 @@
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::node::gossip;
|
||||
use crate::node::NodeCommand;
|
||||
use crate::protocol::identity::{DirectoryEntry, DuskIdentity, PublicIdentity};
|
||||
use crate::protocol::messages::{GossipMessage, ProfileRevocation};
|
||||
use crate::storage::UserSettings;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn has_identity(state: State<'_, AppState>) -> Result<bool, String> {
|
||||
Ok(state.storage.has_identity())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_identity(state: State<'_, AppState>) -> Result<Option<PublicIdentity>, String> {
|
||||
let mut identity = state.identity.lock().await;
|
||||
|
||||
if identity.is_some() {
|
||||
return Ok(identity.as_ref().map(|id| id.public_identity()));
|
||||
}
|
||||
|
||||
match DuskIdentity::load(&state.storage) {
|
||||
Ok(loaded) => {
|
||||
let public = loaded.public_identity();
|
||||
*identity = Some(loaded);
|
||||
Ok(Some(public))
|
||||
}
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_identity(
|
||||
state: State<'_, AppState>,
|
||||
display_name: String,
|
||||
bio: Option<String>,
|
||||
) -> Result<PublicIdentity, String> {
|
||||
let new_identity = DuskIdentity::generate(&display_name, &bio.unwrap_or_default());
|
||||
new_identity.save(&state.storage)?;
|
||||
|
||||
// also save initial settings with this display name so they're in sync
|
||||
let mut settings = state.storage.load_settings().unwrap_or_default();
|
||||
settings.display_name = display_name.clone();
|
||||
state
|
||||
.storage
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("failed to save initial settings: {}", e))?;
|
||||
|
||||
let public = new_identity.public_identity();
|
||||
let mut identity = state.identity.lock().await;
|
||||
*identity = Some(new_identity);
|
||||
|
||||
Ok(public)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_display_name(state: State<'_, AppState>, name: String) -> Result<(), String> {
|
||||
let mut identity = state.identity.lock().await;
|
||||
let id = identity.as_mut().ok_or("no identity loaded")?;
|
||||
|
||||
id.display_name = name;
|
||||
id.save(&state.storage)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_profile(
|
||||
state: State<'_, AppState>,
|
||||
display_name: String,
|
||||
bio: String,
|
||||
) -> Result<PublicIdentity, String> {
|
||||
let mut identity = state.identity.lock().await;
|
||||
let id = identity.as_mut().ok_or("no identity loaded")?;
|
||||
|
||||
id.display_name = display_name;
|
||||
id.bio = bio;
|
||||
id.save(&state.storage)?;
|
||||
|
||||
Ok(id.public_identity())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn load_settings(state: State<'_, AppState>) -> Result<UserSettings, String> {
|
||||
state
|
||||
.storage
|
||||
.load_settings()
|
||||
.map_err(|e| format!("failed to load settings: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_settings(
|
||||
state: State<'_, AppState>,
|
||||
settings: UserSettings,
|
||||
) -> Result<(), String> {
|
||||
// also update the identity display name if it changed
|
||||
let mut identity = state.identity.lock().await;
|
||||
if let Some(id) = identity.as_mut() {
|
||||
if id.display_name != settings.display_name {
|
||||
id.display_name = settings.display_name.clone();
|
||||
id.save(&state.storage)?;
|
||||
}
|
||||
}
|
||||
|
||||
state
|
||||
.storage
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("failed to save settings: {}", e))
|
||||
}
|
||||
|
||||
// -- user directory commands --
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_known_peers(state: State<'_, AppState>) -> Result<Vec<DirectoryEntry>, String> {
|
||||
let entries = state
|
||||
.storage
|
||||
.load_directory()
|
||||
.map_err(|e| format!("failed to load directory: {}", e))?;
|
||||
|
||||
let mut peers: Vec<DirectoryEntry> = entries.into_values().collect();
|
||||
// sort by last seen (most recent first)
|
||||
peers.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
|
||||
Ok(peers)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn search_directory(
|
||||
state: State<'_, AppState>,
|
||||
query: String,
|
||||
) -> Result<Vec<DirectoryEntry>, String> {
|
||||
let entries = state
|
||||
.storage
|
||||
.load_directory()
|
||||
.map_err(|e| format!("failed to load directory: {}", e))?;
|
||||
|
||||
let query_lower = query.to_lowercase();
|
||||
let mut results: Vec<DirectoryEntry> = entries
|
||||
.into_values()
|
||||
.filter(|entry| {
|
||||
entry.display_name.to_lowercase().contains(&query_lower)
|
||||
|| entry.peer_id.to_lowercase().contains(&query_lower)
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.sort_by(|a, b| b.last_seen.cmp(&a.last_seen));
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_friends(state: State<'_, AppState>) -> Result<Vec<DirectoryEntry>, String> {
|
||||
let entries = state
|
||||
.storage
|
||||
.load_directory()
|
||||
.map_err(|e| format!("failed to load directory: {}", e))?;
|
||||
|
||||
let mut friends: Vec<DirectoryEntry> = entries
|
||||
.into_values()
|
||||
.filter(|entry| entry.is_friend)
|
||||
.collect();
|
||||
|
||||
friends.sort_by(|a, b| {
|
||||
a.display_name
|
||||
.to_lowercase()
|
||||
.cmp(&b.display_name.to_lowercase())
|
||||
});
|
||||
Ok(friends)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_friend(state: State<'_, AppState>, peer_id: String) -> Result<(), String> {
|
||||
state
|
||||
.storage
|
||||
.set_friend_status(&peer_id, true)
|
||||
.map_err(|e| format!("failed to add friend: {}", e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_friend(state: State<'_, AppState>, peer_id: String) -> Result<(), String> {
|
||||
state
|
||||
.storage
|
||||
.set_friend_status(&peer_id, false)
|
||||
.map_err(|e| format!("failed to remove friend: {}", e))
|
||||
}
|
||||
|
||||
// broadcast a revocation to all peers, stop the node, and wipe all local data
|
||||
#[tauri::command]
|
||||
pub async fn reset_identity(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let mut identity = state.identity.lock().await;
|
||||
let id = identity.as_ref().ok_or("no identity loaded")?;
|
||||
|
||||
// build the revocation message before we destroy the identity
|
||||
let revocation = ProfileRevocation {
|
||||
peer_id: id.peer_id.to_string(),
|
||||
public_key: hex::encode(id.keypair.public().encode_protobuf()),
|
||||
timestamp: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64,
|
||||
};
|
||||
|
||||
// broadcast revocation on the directory gossip topic
|
||||
let node_handle = state.node_handle.lock().await;
|
||||
if let Some(ref handle) = *node_handle {
|
||||
let msg = GossipMessage::ProfileRevoke(revocation);
|
||||
if let Ok(data) = serde_json::to_vec(&msg) {
|
||||
let _ = handle
|
||||
.command_tx
|
||||
.send(NodeCommand::SendMessage {
|
||||
topic: gossip::topic_for_directory(),
|
||||
data,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
// give the message a moment to propagate before shutting down
|
||||
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
drop(node_handle);
|
||||
|
||||
// stop the p2p node
|
||||
{
|
||||
let mut node_handle = state.node_handle.lock().await;
|
||||
if let Some(handle) = node_handle.take() {
|
||||
let _ = handle.command_tx.send(NodeCommand::Shutdown).await;
|
||||
let _ = handle.task.await;
|
||||
}
|
||||
}
|
||||
|
||||
// clear the crdt engine so no community data lingers in memory
|
||||
{
|
||||
let mut engine = state.crdt_engine.lock().await;
|
||||
engine.clear();
|
||||
}
|
||||
|
||||
// clear in-memory identity
|
||||
*identity = None;
|
||||
|
||||
// wipe all data from disk
|
||||
state
|
||||
.storage
|
||||
.wipe_all_data()
|
||||
.map_err(|e| format!("failed to wipe data: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
pub mod chat;
|
||||
pub mod community;
|
||||
pub mod identity;
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
use automerge::{transaction::Transactable, AutoCommit, ObjType, ReadDoc, ROOT};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::protocol::community::{ChannelKind, ChannelMeta, CommunityMeta};
|
||||
use crate::protocol::messages::ChatMessage;
|
||||
|
||||
// initialize a new community document with metadata and a default general channel
|
||||
pub fn init_community_doc(
|
||||
doc: &mut AutoCommit,
|
||||
name: &str,
|
||||
description: &str,
|
||||
created_by: &str,
|
||||
) -> Result<(), automerge::AutomergeError> {
|
||||
let now = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
|
||||
// create the top-level structure
|
||||
let meta = doc.put_object(ROOT, "meta", ObjType::Map)?;
|
||||
doc.put(&meta, "name", name)?;
|
||||
doc.put(&meta, "description", description)?;
|
||||
doc.put(&meta, "created_by", created_by)?;
|
||||
doc.put(&meta, "created_at", now as i64)?;
|
||||
|
||||
let channels = doc.put_object(ROOT, "channels", ObjType::Map)?;
|
||||
let members = doc.put_object(ROOT, "members", ObjType::Map)?;
|
||||
let _roles = doc.put_object(ROOT, "roles", ObjType::Map)?;
|
||||
|
||||
// create a default general channel
|
||||
let general_id = format!("ch_{}", &hex::encode(&sha2_hash(format!("{}_general", name).as_bytes()))[..12]);
|
||||
let general = doc.put_object(&channels, &general_id, ObjType::Map)?;
|
||||
doc.put(&general, "name", "general")?;
|
||||
doc.put(&general, "topic", "general discussion")?;
|
||||
doc.put(&general, "kind", "text")?;
|
||||
let _messages = doc.put_object(&general, "messages", ObjType::List)?;
|
||||
|
||||
// add the creator as the first member with owner role
|
||||
let member = doc.put_object(&members, created_by, ObjType::Map)?;
|
||||
doc.put(&member, "display_name", "")?;
|
||||
doc.put(&member, "joined_at", now as i64)?;
|
||||
let roles = doc.put_object(&member, "roles", ObjType::List)?;
|
||||
doc.insert(&roles, 0, "owner")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// add a new channel to the community document
|
||||
pub fn add_channel(
|
||||
doc: &mut AutoCommit,
|
||||
channel: &ChannelMeta,
|
||||
) -> Result<(), automerge::AutomergeError> {
|
||||
let channels = doc
|
||||
.get(ROOT, "channels")?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("channels not found".to_string()))?;
|
||||
|
||||
let ch = doc.put_object(&channels, &channel.id, ObjType::Map)?;
|
||||
doc.put(&ch, "name", channel.name.as_str())?;
|
||||
doc.put(&ch, "topic", channel.topic.as_str())?;
|
||||
doc.put(
|
||||
&ch,
|
||||
"kind",
|
||||
match channel.kind {
|
||||
ChannelKind::Text => "text",
|
||||
ChannelKind::Voice => "voice",
|
||||
},
|
||||
)?;
|
||||
let _messages = doc.put_object(&ch, "messages", ObjType::List)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// read all channels from the community document
|
||||
pub fn get_channels(doc: &AutoCommit, community_id: &str) -> Result<Vec<ChannelMeta>, String> {
|
||||
let channels_obj = doc
|
||||
.get(ROOT, "channels")
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or("channels key not found")?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
let keys = doc.keys(&channels_obj);
|
||||
|
||||
for key in keys {
|
||||
let ch_obj = doc
|
||||
.get(&channels_obj, &key)
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id);
|
||||
|
||||
if let Some(ch_id) = ch_obj {
|
||||
let name = get_str(doc, &ch_id, "name").unwrap_or_default();
|
||||
let topic = get_str(doc, &ch_id, "topic").unwrap_or_default();
|
||||
let kind_str = get_str(doc, &ch_id, "kind").unwrap_or_else(|| "text".to_string());
|
||||
let kind = match kind_str.as_str() {
|
||||
"voice" => ChannelKind::Voice,
|
||||
_ => ChannelKind::Text,
|
||||
};
|
||||
|
||||
result.push(ChannelMeta {
|
||||
id: key.to_string(),
|
||||
community_id: community_id.to_string(),
|
||||
name,
|
||||
topic,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// append a message to a channel's message list
|
||||
pub fn append_message(
|
||||
doc: &mut AutoCommit,
|
||||
channel_id: &str,
|
||||
message: &ChatMessage,
|
||||
) -> Result<(), automerge::AutomergeError> {
|
||||
let channels = doc
|
||||
.get(ROOT, "channels")?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("channels not found".to_string()))?;
|
||||
|
||||
let channel = doc
|
||||
.get(&channels, channel_id)?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("channel not found".to_string()))?;
|
||||
|
||||
let messages = doc
|
||||
.get(&channel, "messages")?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or_else(|| automerge::AutomergeError::InvalidObjId("messages not found".to_string()))?;
|
||||
|
||||
let len = doc.length(&messages);
|
||||
let msg_obj = doc.insert_object(&messages, len, ObjType::Map)?;
|
||||
doc.put(&msg_obj, "id", message.id.as_str())?;
|
||||
doc.put(&msg_obj, "author_id", message.author_id.as_str())?;
|
||||
doc.put(&msg_obj, "author_name", message.author_name.as_str())?;
|
||||
doc.put(&msg_obj, "content", message.content.as_str())?;
|
||||
doc.put(&msg_obj, "timestamp", message.timestamp as i64)?;
|
||||
doc.put(&msg_obj, "edited", message.edited)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// read messages from a channel, optionally filtered and limited
|
||||
pub fn get_messages(
|
||||
doc: &AutoCommit,
|
||||
channel_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ChatMessage>, String> {
|
||||
let channels = doc
|
||||
.get(ROOT, "channels")
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or("channels not found")?;
|
||||
|
||||
let channel = doc
|
||||
.get(&channels, channel_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or("channel not found")?;
|
||||
|
||||
let messages = doc
|
||||
.get(&channel, "messages")
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or("messages not found")?;
|
||||
|
||||
let len = doc.length(&messages);
|
||||
let mut result = Vec::new();
|
||||
|
||||
// iterate backwards for most recent first, then reverse for chronological order
|
||||
for i in (0..len).rev() {
|
||||
let msg_obj = doc
|
||||
.get(&messages, i)
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id);
|
||||
|
||||
if let Some(msg_id) = msg_obj {
|
||||
let timestamp = get_i64(doc, &msg_id, "timestamp").unwrap_or(0) as u64;
|
||||
|
||||
if let Some(before_ts) = before {
|
||||
if timestamp >= before_ts {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let msg = ChatMessage {
|
||||
id: get_str(doc, &msg_id, "id").unwrap_or_default(),
|
||||
channel_id: channel_id.to_string(),
|
||||
author_id: get_str(doc, &msg_id, "author_id").unwrap_or_default(),
|
||||
author_name: get_str(doc, &msg_id, "author_name").unwrap_or_default(),
|
||||
content: get_str(doc, &msg_id, "content").unwrap_or_default(),
|
||||
timestamp,
|
||||
edited: get_bool(doc, &msg_id, "edited").unwrap_or(false),
|
||||
};
|
||||
|
||||
result.push(msg);
|
||||
|
||||
if result.len() >= limit {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reverse to get chronological order
|
||||
result.reverse();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// read community metadata from the document
|
||||
pub fn get_community_meta(doc: &AutoCommit, community_id: &str) -> Result<CommunityMeta, String> {
|
||||
let meta = doc
|
||||
.get(ROOT, "meta")
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or("meta not found")?;
|
||||
|
||||
Ok(CommunityMeta {
|
||||
id: community_id.to_string(),
|
||||
name: get_str(doc, &meta, "name").unwrap_or_default(),
|
||||
description: get_str(doc, &meta, "description").unwrap_or_default(),
|
||||
created_by: get_str(doc, &meta, "created_by").unwrap_or_default(),
|
||||
created_at: get_i64(doc, &meta, "created_at").unwrap_or(0) as u64,
|
||||
})
|
||||
}
|
||||
|
||||
// -- helpers for reading automerge values --
|
||||
|
||||
fn get_str(doc: &AutoCommit, obj: &automerge::ObjId, key: &str) -> Option<String> {
|
||||
doc.get(obj, key)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|(val, _)| val.into_string().ok())
|
||||
}
|
||||
|
||||
fn get_i64(doc: &AutoCommit, obj: &automerge::ObjId, key: &str) -> Option<i64> {
|
||||
doc.get(obj, key)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|(val, _)| val.to_i64())
|
||||
}
|
||||
|
||||
fn get_bool(doc: &AutoCommit, obj: &automerge::ObjId, key: &str) -> Option<bool> {
|
||||
doc.get(obj, key)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|(val, _)| val.to_bool())
|
||||
}
|
||||
|
||||
// simple sha256 hash for generating deterministic ids
|
||||
fn sha2_hash(data: &[u8]) -> Vec<u8> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
hasher.finalize().to_vec()
|
||||
}
|
||||
|
||||
// get a specific message by id from any channel in the community
|
||||
pub fn get_message_by_id(
|
||||
doc: &AutoCommit,
|
||||
message_id: &str,
|
||||
) -> Result<Option<ChatMessage>, String> {
|
||||
let channels_obj = doc
|
||||
.get(ROOT, "channels")
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or("channels key not found")?;
|
||||
|
||||
let keys = doc.keys(&channels_obj);
|
||||
|
||||
for channel_key in keys {
|
||||
let ch_obj = doc
|
||||
.get(&channels_obj, &channel_key)
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id);
|
||||
|
||||
if let Some(ch_id) = ch_obj {
|
||||
let messages = doc
|
||||
.get(&ch_id, "messages")
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id);
|
||||
|
||||
if let Some(msgs_id) = messages {
|
||||
let len = doc.length(&msgs_id);
|
||||
for i in 0..len {
|
||||
let msg_obj = doc
|
||||
.get(&msgs_id, i)
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id);
|
||||
|
||||
if let Some(msg_id) = msg_obj {
|
||||
let id = get_str(doc, &msg_id, "id").unwrap_or_default();
|
||||
if id == message_id {
|
||||
let msg = ChatMessage {
|
||||
id: id.clone(),
|
||||
channel_id: channel_key.to_string(),
|
||||
author_id: get_str(doc, &msg_id, "author_id").unwrap_or_default(),
|
||||
author_name: get_str(doc, &msg_id, "author_name").unwrap_or_default(),
|
||||
content: get_str(doc, &msg_id, "content").unwrap_or_default(),
|
||||
timestamp: get_i64(doc, &msg_id, "timestamp").unwrap_or(0) as u64,
|
||||
edited: get_bool(doc, &msg_id, "edited").unwrap_or(false),
|
||||
};
|
||||
return Ok(Some(msg));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// delete a message by id from any channel in the community
|
||||
pub fn delete_message_by_id(
|
||||
doc: &mut AutoCommit,
|
||||
message_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let channels_obj = doc
|
||||
.get(ROOT, "channels")
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or("channels key not found")?;
|
||||
|
||||
let keys: Vec<String> = doc.keys(&channels_obj).collect();
|
||||
|
||||
for channel_key in keys {
|
||||
let ch_obj = doc
|
||||
.get(&channels_obj, &channel_key)
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id);
|
||||
|
||||
if let Some(ch_id) = ch_obj {
|
||||
let messages = doc
|
||||
.get(&ch_id, "messages")
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id);
|
||||
|
||||
if let Some(msgs_id) = messages {
|
||||
let len = doc.length(&msgs_id);
|
||||
for i in 0..len {
|
||||
let msg_obj = doc
|
||||
.get(&msgs_id, i)
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id);
|
||||
|
||||
if let Some(msg_obj_id) = msg_obj {
|
||||
let id = get_str(doc, &msg_obj_id, "id").unwrap_or_default();
|
||||
if id == message_id {
|
||||
doc.delete(&msgs_id, i)
|
||||
.map_err(|e| e.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!("message {} not found", message_id))
|
||||
}
|
||||
|
||||
// get all members from the community document
|
||||
pub fn get_members(
|
||||
doc: &AutoCommit,
|
||||
) -> Result<Vec<crate::protocol::community::Member>, String> {
|
||||
let members_obj = doc
|
||||
.get(ROOT, "members")
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or("members key not found")?;
|
||||
|
||||
let mut result = Vec::new();
|
||||
let keys = doc.keys(&members_obj);
|
||||
|
||||
for peer_id in keys {
|
||||
let member_obj = doc
|
||||
.get(&members_obj, &peer_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id);
|
||||
|
||||
if let Some(member_id) = member_obj {
|
||||
let display_name = get_str(doc, &member_id, "display_name").unwrap_or_default();
|
||||
let joined_at = get_i64(doc, &member_id, "joined_at").unwrap_or(0) as u64;
|
||||
|
||||
// get roles list
|
||||
let roles: Vec<String> = doc
|
||||
.get(&member_id, "roles")
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id)
|
||||
.map(|roles_id| {
|
||||
let len = doc.length(&roles_id);
|
||||
(0..len)
|
||||
.filter_map(|i| {
|
||||
doc.get(&roles_id, i)
|
||||
.ok()
|
||||
.flatten()
|
||||
.and_then(|(val, _)| val.into_string().ok())
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
result.push(crate::protocol::community::Member {
|
||||
peer_id: peer_id.clone(),
|
||||
display_name,
|
||||
status: crate::protocol::messages::PeerStatus::Online,
|
||||
roles,
|
||||
trust_level: 1.0,
|
||||
joined_at,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
// remove a member from the community
|
||||
pub fn remove_member(
|
||||
doc: &mut AutoCommit,
|
||||
peer_id: &str,
|
||||
) -> Result<(), String> {
|
||||
let members_obj = doc
|
||||
.get(ROOT, "members")
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|(_, id)| id)
|
||||
.ok_or("members key not found")?;
|
||||
|
||||
doc.delete(&members_obj, peer_id)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
mod document;
|
||||
pub mod sync;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use automerge::AutoCommit;
|
||||
|
||||
use crate::protocol::community::{ChannelMeta, CommunityMeta};
|
||||
use crate::protocol::messages::ChatMessage;
|
||||
use crate::storage::DiskStorage;
|
||||
|
||||
// manages automerge documents for all joined communities
|
||||
pub struct CrdtEngine {
|
||||
documents: HashMap<String, AutoCommit>,
|
||||
storage: Arc<DiskStorage>,
|
||||
}
|
||||
|
||||
impl CrdtEngine {
|
||||
pub fn new(storage: Arc<DiskStorage>) -> Self {
|
||||
Self {
|
||||
documents: HashMap::new(),
|
||||
storage,
|
||||
}
|
||||
}
|
||||
|
||||
// load all persisted community documents from disk
|
||||
pub fn load_all(&mut self) -> Result<(), String> {
|
||||
let community_ids = self
|
||||
.storage
|
||||
.list_communities()
|
||||
.map_err(|e| format!("failed to list communities: {}", e))?;
|
||||
|
||||
for id in community_ids {
|
||||
if let Ok(bytes) = self.storage.load_document(&id) {
|
||||
match AutoCommit::load(&bytes) {
|
||||
Ok(doc) => {
|
||||
self.documents.insert(id, doc);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("failed to load document for community {}: {}", id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// create a new community with a default general channel
|
||||
pub fn create_community(
|
||||
&mut self,
|
||||
community_id: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
created_by: &str,
|
||||
) -> Result<(), String> {
|
||||
let mut doc = AutoCommit::new();
|
||||
document::init_community_doc(&mut doc, name, description, created_by)
|
||||
.map_err(|e| format!("failed to init community doc: {}", e))?;
|
||||
|
||||
self.documents.insert(community_id.to_string(), doc);
|
||||
self.persist(community_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// add a channel to an existing community
|
||||
pub fn create_channel(
|
||||
&mut self,
|
||||
community_id: &str,
|
||||
channel: &ChannelMeta,
|
||||
) -> Result<(), String> {
|
||||
let doc = self
|
||||
.documents
|
||||
.get_mut(community_id)
|
||||
.ok_or("community not found")?;
|
||||
|
||||
document::add_channel(doc, channel).map_err(|e| format!("failed to add channel: {}", e))?;
|
||||
|
||||
self.persist(community_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// get all channels in a community
|
||||
pub fn get_channels(&self, community_id: &str) -> Result<Vec<ChannelMeta>, String> {
|
||||
let doc = self
|
||||
.documents
|
||||
.get(community_id)
|
||||
.ok_or("community not found")?;
|
||||
|
||||
document::get_channels(doc, community_id)
|
||||
}
|
||||
|
||||
// append a message to a channel within a community
|
||||
pub fn append_message(
|
||||
&mut self,
|
||||
community_id: &str,
|
||||
message: &ChatMessage,
|
||||
) -> Result<(), String> {
|
||||
let doc = self
|
||||
.documents
|
||||
.get_mut(community_id)
|
||||
.ok_or("community not found")?;
|
||||
|
||||
document::append_message(doc, &message.channel_id, message)
|
||||
.map_err(|e| format!("failed to append message: {}", e))?;
|
||||
|
||||
self.persist(community_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// get messages for a channel, optionally paginated
|
||||
pub fn get_messages(
|
||||
&self,
|
||||
community_id: &str,
|
||||
channel_id: &str,
|
||||
before: Option<u64>,
|
||||
limit: usize,
|
||||
) -> Result<Vec<ChatMessage>, String> {
|
||||
let doc = self
|
||||
.documents
|
||||
.get(community_id)
|
||||
.ok_or("community not found")?;
|
||||
|
||||
document::get_messages(doc, channel_id, before, limit)
|
||||
}
|
||||
|
||||
// get community metadata
|
||||
pub fn get_community_meta(&self, community_id: &str) -> Result<CommunityMeta, String> {
|
||||
let doc = self
|
||||
.documents
|
||||
.get(community_id)
|
||||
.ok_or("community not found")?;
|
||||
|
||||
document::get_community_meta(doc, community_id)
|
||||
}
|
||||
|
||||
// get all community ids we have documents for
|
||||
pub fn community_ids(&self) -> Vec<String> {
|
||||
self.documents.keys().cloned().collect()
|
||||
}
|
||||
|
||||
// check if we have a document for a community
|
||||
pub fn has_community(&self, community_id: &str) -> bool {
|
||||
self.documents.contains_key(community_id)
|
||||
}
|
||||
|
||||
// save a document to disk
|
||||
pub fn persist(&mut self, community_id: &str) -> Result<(), String> {
|
||||
let doc = self
|
||||
.documents
|
||||
.get_mut(community_id)
|
||||
.ok_or("community not found")?;
|
||||
|
||||
let bytes = doc.save();
|
||||
self.storage
|
||||
.save_document(community_id, &bytes)
|
||||
.map_err(|e| format!("failed to persist document: {}", e))
|
||||
}
|
||||
|
||||
// get a mutable reference to a document for sync operations
|
||||
pub fn get_doc_mut(&mut self, community_id: &str) -> Option<&mut AutoCommit> {
|
||||
self.documents.get_mut(community_id)
|
||||
}
|
||||
|
||||
// get an immutable reference to a document
|
||||
pub fn get_doc(&self, community_id: &str) -> Option<&AutoCommit> {
|
||||
self.documents.get(community_id)
|
||||
}
|
||||
|
||||
// insert or replace a document (used when receiving a full doc via sync)
|
||||
pub fn insert_doc(&mut self, community_id: &str, doc: AutoCommit) {
|
||||
self.documents.insert(community_id.to_string(), doc);
|
||||
}
|
||||
|
||||
// get a specific message by id
|
||||
pub fn get_message(
|
||||
&self,
|
||||
community_id: &str,
|
||||
message_id: &str,
|
||||
) -> Result<Option<ChatMessage>, String> {
|
||||
let doc = self
|
||||
.documents
|
||||
.get(community_id)
|
||||
.ok_or("community not found")?;
|
||||
|
||||
document::get_message_by_id(doc, message_id)
|
||||
}
|
||||
|
||||
// delete a message by id
|
||||
pub fn delete_message(&mut self, community_id: &str, message_id: &str) -> Result<(), String> {
|
||||
let doc = self
|
||||
.documents
|
||||
.get_mut(community_id)
|
||||
.ok_or("community not found")?;
|
||||
|
||||
document::delete_message_by_id(doc, message_id)?;
|
||||
self.persist(community_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// get all members of a community
|
||||
pub fn get_members(
|
||||
&self,
|
||||
community_id: &str,
|
||||
) -> Result<Vec<crate::protocol::community::Member>, String> {
|
||||
let doc = self
|
||||
.documents
|
||||
.get(community_id)
|
||||
.ok_or("community not found")?;
|
||||
|
||||
document::get_members(doc)
|
||||
}
|
||||
|
||||
// remove a member from a community
|
||||
pub fn remove_member(&mut self, community_id: &str, peer_id: &str) -> Result<(), String> {
|
||||
let doc = self
|
||||
.documents
|
||||
.get_mut(community_id)
|
||||
.ok_or("community not found")?;
|
||||
|
||||
document::remove_member(doc, peer_id)?;
|
||||
self.persist(community_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// merge a remote document snapshot into our local state
|
||||
// if we don't have the community yet, insert it directly
|
||||
// if we do, merge the remote changes into our existing doc
|
||||
pub fn merge_remote_doc(
|
||||
&mut self,
|
||||
community_id: &str,
|
||||
remote_bytes: &[u8],
|
||||
) -> Result<(), String> {
|
||||
let remote_doc = AutoCommit::load(remote_bytes)
|
||||
.map_err(|e| format!("failed to load remote doc: {}", e))?;
|
||||
|
||||
if let Some(local_doc) = self.documents.get_mut(community_id) {
|
||||
local_doc
|
||||
.merge(&mut remote_doc.clone())
|
||||
.map_err(|e| format!("failed to merge docs: {}", e))?;
|
||||
} else {
|
||||
self.documents.insert(community_id.to_string(), remote_doc);
|
||||
}
|
||||
|
||||
self.persist(community_id)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// get the raw bytes of a document for sending to peers
|
||||
pub fn get_doc_bytes(&mut self, community_id: &str) -> Option<Vec<u8>> {
|
||||
self.documents.get_mut(community_id).map(|doc| doc.save())
|
||||
}
|
||||
|
||||
// drop all in-memory documents (used during identity reset)
|
||||
pub fn clear(&mut self) {
|
||||
self.documents.clear();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// a full document snapshot sent over gossipsub for initial sync
|
||||
// when a new peer discovers us, we broadcast our documents so they can merge
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DocumentSnapshot {
|
||||
pub community_id: String,
|
||||
// raw automerge bytes, base64 encoded for json transport
|
||||
pub doc_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
// envelope for sync-related gossipsub messages
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum SyncMessage {
|
||||
// request all documents from peers (sent when a new peer joins)
|
||||
RequestSync { peer_id: String },
|
||||
// response containing a full document snapshot
|
||||
DocumentOffer(DocumentSnapshot),
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
mod commands;
|
||||
mod crdt;
|
||||
mod node;
|
||||
mod protocol;
|
||||
mod storage;
|
||||
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::crdt::CrdtEngine;
|
||||
use crate::protocol::identity::DuskIdentity;
|
||||
use crate::storage::DiskStorage;
|
||||
|
||||
// shared application state accessible from all tauri commands
|
||||
pub struct AppState {
|
||||
pub identity: Arc<Mutex<Option<DuskIdentity>>>,
|
||||
pub crdt_engine: Arc<Mutex<CrdtEngine>>,
|
||||
pub storage: Arc<DiskStorage>,
|
||||
pub node_handle: Arc<Mutex<Option<node::NodeHandle>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new() -> Self {
|
||||
let storage = Arc::new(DiskStorage::new().expect("failed to initialize storage"));
|
||||
let mut engine = CrdtEngine::new(storage.clone());
|
||||
|
||||
// restore persisted communities from disk so data survives restarts
|
||||
if let Err(e) = engine.load_all() {
|
||||
log::warn!("failed to load persisted communities: {}", e);
|
||||
}
|
||||
|
||||
let crdt_engine = Arc::new(Mutex::new(engine));
|
||||
|
||||
Self {
|
||||
identity: Arc::new(Mutex::new(None)),
|
||||
crdt_engine,
|
||||
storage,
|
||||
node_handle: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// load .env from the project root so config like DUSK_RELAY_ADDR is available
|
||||
dotenvy::dotenv().ok();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.manage(AppState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::identity::has_identity,
|
||||
commands::identity::load_identity,
|
||||
commands::identity::create_identity,
|
||||
commands::identity::update_display_name,
|
||||
commands::identity::update_profile,
|
||||
commands::identity::load_settings,
|
||||
commands::identity::save_settings,
|
||||
commands::identity::get_known_peers,
|
||||
commands::identity::search_directory,
|
||||
commands::identity::get_friends,
|
||||
commands::identity::add_friend,
|
||||
commands::identity::remove_friend,
|
||||
commands::identity::reset_identity,
|
||||
commands::chat::send_message,
|
||||
commands::chat::get_messages,
|
||||
commands::chat::send_typing,
|
||||
commands::chat::start_node,
|
||||
commands::chat::stop_node,
|
||||
commands::community::create_community,
|
||||
commands::community::join_community,
|
||||
commands::community::leave_community,
|
||||
commands::community::get_communities,
|
||||
commands::community::create_channel,
|
||||
commands::community::get_channels,
|
||||
commands::community::get_members,
|
||||
commands::community::delete_message,
|
||||
commands::community::kick_member,
|
||||
commands::community::generate_invite,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running dusk");
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// prevents additional console window on windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
dusk_chat_lib::run()
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
use libp2p::{gossipsub, identify, kad, mdns, ping, relay, rendezvous, swarm::NetworkBehaviour};
|
||||
|
||||
#[derive(NetworkBehaviour)]
|
||||
pub struct DuskBehaviour {
|
||||
pub relay_client: relay::client::Behaviour,
|
||||
pub rendezvous: rendezvous::client::Behaviour,
|
||||
pub gossipsub: gossipsub::Behaviour,
|
||||
pub kademlia: kad::Behaviour<kad::store::MemoryStore>,
|
||||
pub mdns: mdns::tokio::Behaviour,
|
||||
pub identify: identify::Behaviour,
|
||||
pub ping: ping::Behaviour,
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
// peer discovery helpers for mdns and kademlia
|
||||
// the actual discovery handling is in the node event loop (mod.rs)
|
||||
// this module provides utility functions for discovery-related operations
|
||||
|
||||
use libp2p::Multiaddr;
|
||||
|
||||
// format a peer address for display
|
||||
#[allow(dead_code)]
|
||||
pub fn format_peer_addr(addr: &Multiaddr) -> String {
|
||||
addr.to_string()
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
// gossipsub topic naming conventions for the dusk protocol
|
||||
// topics encode the routing path for different message types
|
||||
|
||||
pub fn topic_for_messages(community_id: &str, channel_id: &str) -> String {
|
||||
format!(
|
||||
"dusk/community/{}/channel/{}/messages",
|
||||
community_id, channel_id
|
||||
)
|
||||
}
|
||||
|
||||
pub fn topic_for_typing(community_id: &str, channel_id: &str) -> String {
|
||||
format!(
|
||||
"dusk/community/{}/channel/{}/typing",
|
||||
community_id, channel_id
|
||||
)
|
||||
}
|
||||
|
||||
pub fn topic_for_presence(community_id: &str) -> String {
|
||||
format!("dusk/community/{}/presence", community_id)
|
||||
}
|
||||
|
||||
pub fn topic_for_meta(community_id: &str) -> String {
|
||||
format!("dusk/community/{}/meta", community_id)
|
||||
}
|
||||
|
||||
// global topic for user profile announcements and directory discovery
|
||||
pub fn topic_for_directory() -> String {
|
||||
"dusk/directory".to_string()
|
||||
}
|
||||
|
||||
// global sync topic used to exchange full document snapshots between peers
|
||||
pub fn topic_for_sync() -> String {
|
||||
"dusk/sync".to_string()
|
||||
}
|
||||
|
|
@ -0,0 +1,710 @@
|
|||
pub mod behaviour;
|
||||
pub mod discovery;
|
||||
pub mod gossip;
|
||||
pub mod swarm;
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use tauri::async_runtime::JoinHandle;
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::crdt::CrdtEngine;
|
||||
use crate::protocol::identity::DirectoryEntry;
|
||||
|
||||
// default relay address - override with DUSK_RELAY_ADDR env var
|
||||
// format: /ip4/<ip>/tcp/<port>/p2p/<peer_id>
|
||||
// left empty because 0.0.0.0 is a listen address, not a routable dial target.
|
||||
// users must set DUSK_RELAY_ADDR to a reachable relay for WAN connectivity.
|
||||
const DEFAULT_RELAY_ADDR: &str = "";
|
||||
|
||||
// relay reconnection parameters
|
||||
const RELAY_INITIAL_BACKOFF_SECS: u64 = 2;
|
||||
const RELAY_MAX_BACKOFF_SECS: u64 = 120;
|
||||
const RELAY_BACKOFF_MULTIPLIER: u64 = 2;
|
||||
// max time to hold pending rendezvous registrations before discarding (10 min)
|
||||
const PENDING_QUEUE_TTL_SECS: u64 = 600;
|
||||
|
||||
// resolve the relay multiaddr from env or default
|
||||
fn relay_addr() -> Option<libp2p::Multiaddr> {
|
||||
let addr_str = std::env::var("DUSK_RELAY_ADDR")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.or_else(|| {
|
||||
if DEFAULT_RELAY_ADDR.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(DEFAULT_RELAY_ADDR.to_string())
|
||||
}
|
||||
})?;
|
||||
|
||||
addr_str.parse().ok()
|
||||
}
|
||||
|
||||
// extract the peer id from a multiaddr (the /p2p/<peer_id> component)
|
||||
fn peer_id_from_multiaddr(addr: &libp2p::Multiaddr) -> Option<libp2p::PeerId> {
|
||||
use libp2p::multiaddr::Protocol;
|
||||
addr.iter().find_map(|p| match p {
|
||||
Protocol::P2p(peer_id) => Some(peer_id),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
// handle to the running p2p node, used to stop it
|
||||
pub struct NodeHandle {
|
||||
pub task: JoinHandle<()>,
|
||||
// channel to send commands to the running node
|
||||
pub command_tx: tokio::sync::mpsc::Sender<NodeCommand>,
|
||||
}
|
||||
|
||||
// commands that can be sent to the running node
|
||||
pub enum NodeCommand {
|
||||
Shutdown,
|
||||
SendMessage {
|
||||
topic: String,
|
||||
data: Vec<u8>,
|
||||
},
|
||||
Subscribe {
|
||||
topic: String,
|
||||
},
|
||||
Unsubscribe {
|
||||
topic: String,
|
||||
},
|
||||
// retrieve the swarm's external listen addresses for invite codes
|
||||
GetListenAddrs {
|
||||
reply: tokio::sync::oneshot::Sender<Vec<String>>,
|
||||
},
|
||||
// dial a specific multiaddr (used for relay connections)
|
||||
Dial {
|
||||
addr: libp2p::Multiaddr,
|
||||
},
|
||||
// register on rendezvous under a community namespace
|
||||
RegisterRendezvous {
|
||||
namespace: String,
|
||||
},
|
||||
// discover peers on rendezvous under a community namespace
|
||||
DiscoverRendezvous {
|
||||
namespace: String,
|
||||
},
|
||||
}
|
||||
|
||||
// events emitted from the node to the tauri frontend
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
#[serde(tag = "kind", content = "payload")]
|
||||
pub enum DuskEvent {
|
||||
#[serde(rename = "message_received")]
|
||||
MessageReceived(crate::protocol::messages::ChatMessage),
|
||||
#[serde(rename = "message_deleted")]
|
||||
MessageDeleted { message_id: String },
|
||||
#[serde(rename = "member_kicked")]
|
||||
MemberKicked { peer_id: String },
|
||||
#[serde(rename = "peer_connected")]
|
||||
PeerConnected { peer_id: String },
|
||||
#[serde(rename = "peer_disconnected")]
|
||||
PeerDisconnected { peer_id: String },
|
||||
#[serde(rename = "typing")]
|
||||
Typing { peer_id: String, channel_id: String },
|
||||
#[serde(rename = "node_status")]
|
||||
NodeStatus {
|
||||
is_connected: bool,
|
||||
peer_count: usize,
|
||||
},
|
||||
#[serde(rename = "sync_complete")]
|
||||
SyncComplete { community_id: String },
|
||||
#[serde(rename = "profile_received")]
|
||||
ProfileReceived {
|
||||
peer_id: String,
|
||||
display_name: String,
|
||||
bio: String,
|
||||
},
|
||||
#[serde(rename = "profile_revoked")]
|
||||
ProfileRevoked { peer_id: String },
|
||||
}
|
||||
|
||||
// extract the community id from a gossipsub topic string
|
||||
fn community_id_from_topic(topic: &str) -> Option<&str> {
|
||||
topic
|
||||
.strip_prefix("dusk/community/")
|
||||
.and_then(|rest| rest.split('/').next())
|
||||
}
|
||||
|
||||
// start the p2p node on a background task
|
||||
pub async fn start(
|
||||
keypair: libp2p::identity::Keypair,
|
||||
crdt_engine: Arc<Mutex<CrdtEngine>>,
|
||||
storage: Arc<crate::storage::DiskStorage>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<NodeHandle, String> {
|
||||
let mut swarm_instance =
|
||||
swarm::build_swarm(&keypair).map_err(|e| format!("failed to build swarm: {}", e))?;
|
||||
|
||||
// listen on all interfaces for LAN peer discovery via mDNS
|
||||
swarm_instance
|
||||
.listen_on("/ip4/0.0.0.0/tcp/0".parse().unwrap())
|
||||
.map_err(|e| format!("failed to listen: {}", e))?;
|
||||
|
||||
let (command_tx, mut command_rx) = tokio::sync::mpsc::channel::<NodeCommand>(256);
|
||||
|
||||
// emit initial node status
|
||||
let _ = app_handle.emit(
|
||||
"dusk-event",
|
||||
DuskEvent::NodeStatus {
|
||||
is_connected: false,
|
||||
peer_count: 0,
|
||||
},
|
||||
);
|
||||
|
||||
// resolve the relay address for WAN connectivity
|
||||
let relay_multiaddr = relay_addr();
|
||||
let relay_peer_id = relay_multiaddr.as_ref().and_then(peer_id_from_multiaddr);
|
||||
|
||||
// if a relay is configured, dial it immediately
|
||||
if let Some(ref addr) = relay_multiaddr {
|
||||
log::info!("dialing relay at {}", addr);
|
||||
if let Err(e) = swarm_instance.dial(addr.clone()) {
|
||||
log::warn!("failed to dial relay: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
let task = tauri::async_runtime::spawn(async move {
|
||||
use futures::StreamExt;
|
||||
|
||||
// track connected peers for accurate count
|
||||
let mut connected_peers: HashSet<String> = HashSet::new();
|
||||
|
||||
// track whether we have a relay reservation
|
||||
let mut relay_reservation_active = false;
|
||||
|
||||
// track the relay peer id for rendezvous operations
|
||||
let relay_peer = relay_peer_id;
|
||||
|
||||
// community namespaces we need to register on rendezvous
|
||||
// queued until the relay connection is ready
|
||||
let mut pending_registrations: Vec<String> = Vec::new();
|
||||
let mut pending_discoveries: Vec<String> = Vec::new();
|
||||
// timestamp when pending items were first queued (for TTL cleanup)
|
||||
let mut pending_queued_at: Option<std::time::Instant> = None;
|
||||
|
||||
// rendezvous registration refresh interval (registrations expire)
|
||||
let mut rendezvous_tick = tokio::time::interval(std::time::Duration::from_secs(120));
|
||||
|
||||
// all community namespaces we're registered under (for refresh)
|
||||
let mut registered_namespaces: HashSet<String> = HashSet::new();
|
||||
|
||||
// relay reconnection state with exponential backoff
|
||||
let mut relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS;
|
||||
// next instant at which we should attempt a relay reconnect
|
||||
let mut relay_retry_at: Option<tokio::time::Instant> = if relay_multiaddr.is_some() {
|
||||
// schedule initial retry in case the first dial failed synchronously
|
||||
Some(
|
||||
tokio::time::Instant::now()
|
||||
+ std::time::Duration::from_secs(RELAY_INITIAL_BACKOFF_SECS),
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
event = swarm_instance.select_next_some() => {
|
||||
match event {
|
||||
// --- gossipsub messages ---
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Gossipsub(
|
||||
libp2p::gossipsub::Event::Message { message, .. }
|
||||
)) => {
|
||||
let topic_str = message.topic.as_str().to_string();
|
||||
|
||||
// handle sync messages on the dedicated sync topic
|
||||
if topic_str == gossip::topic_for_sync() {
|
||||
if let Ok(sync_msg) = serde_json::from_slice::<crate::crdt::sync::SyncMessage>(&message.data) {
|
||||
match sync_msg {
|
||||
crate::crdt::sync::SyncMessage::RequestSync { .. } => {
|
||||
let mut engine = crdt_engine.lock().await;
|
||||
let ids = engine.community_ids();
|
||||
for cid in ids {
|
||||
if let Some(doc_bytes) = engine.get_doc_bytes(&cid) {
|
||||
let snapshot = crate::crdt::sync::DocumentSnapshot {
|
||||
community_id: cid.clone(),
|
||||
doc_bytes,
|
||||
};
|
||||
let offer = crate::crdt::sync::SyncMessage::DocumentOffer(snapshot);
|
||||
if let Ok(data) = serde_json::to_vec(&offer) {
|
||||
let sync_topic = libp2p::gossipsub::IdentTopic::new(gossip::topic_for_sync());
|
||||
let _ = swarm_instance.behaviour_mut().gossipsub.publish(sync_topic, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::crdt::sync::SyncMessage::DocumentOffer(snapshot) => {
|
||||
let mut engine = crdt_engine.lock().await;
|
||||
match engine.merge_remote_doc(&snapshot.community_id, &snapshot.doc_bytes) {
|
||||
Ok(()) => {
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::SyncComplete {
|
||||
community_id: snapshot.community_id,
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("failed to merge remote doc for {}: {}", snapshot.community_id, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// handle regular gossip messages on community topics
|
||||
if let Ok(gossip_msg) = serde_json::from_slice::<crate::protocol::messages::GossipMessage>(&message.data) {
|
||||
match gossip_msg {
|
||||
crate::protocol::messages::GossipMessage::Chat(chat_msg) => {
|
||||
if let Some(community_id) = community_id_from_topic(&topic_str) {
|
||||
let mut engine = crdt_engine.lock().await;
|
||||
let _ = engine.append_message(community_id, &chat_msg);
|
||||
}
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::MessageReceived(chat_msg));
|
||||
}
|
||||
crate::protocol::messages::GossipMessage::Typing(indicator) => {
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::Typing {
|
||||
peer_id: indicator.peer_id,
|
||||
channel_id: indicator.channel_id,
|
||||
});
|
||||
}
|
||||
crate::protocol::messages::GossipMessage::DeleteMessage { message_id } => {
|
||||
if let Some(community_id) = community_id_from_topic(&topic_str) {
|
||||
let mut engine = crdt_engine.lock().await;
|
||||
let _ = engine.delete_message(community_id, &message_id);
|
||||
}
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::MessageDeleted { message_id });
|
||||
}
|
||||
crate::protocol::messages::GossipMessage::MemberKicked { peer_id } => {
|
||||
if let Some(community_id) = community_id_from_topic(&topic_str) {
|
||||
let mut engine = crdt_engine.lock().await;
|
||||
let _ = engine.remove_member(community_id, &peer_id);
|
||||
}
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::MemberKicked { peer_id });
|
||||
}
|
||||
crate::protocol::messages::GossipMessage::Presence(update) => {
|
||||
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,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
crate::protocol::messages::GossipMessage::MetaUpdate(meta) => {
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::SyncComplete {
|
||||
community_id: meta.id,
|
||||
});
|
||||
}
|
||||
crate::protocol::messages::GossipMessage::ProfileAnnounce(profile) => {
|
||||
// cache the peer profile in our local directory
|
||||
let entry = DirectoryEntry {
|
||||
peer_id: profile.peer_id.clone(),
|
||||
display_name: profile.display_name.clone(),
|
||||
bio: profile.bio.clone(),
|
||||
public_key: profile.public_key.clone(),
|
||||
last_seen: profile.timestamp,
|
||||
is_friend: storage
|
||||
.load_directory()
|
||||
.ok()
|
||||
.and_then(|d| d.get(&profile.peer_id).map(|e| e.is_friend))
|
||||
.unwrap_or(false),
|
||||
};
|
||||
let _ = storage.save_directory_entry(&entry);
|
||||
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::ProfileReceived {
|
||||
peer_id: profile.peer_id,
|
||||
display_name: profile.display_name,
|
||||
bio: profile.bio,
|
||||
});
|
||||
}
|
||||
crate::protocol::messages::GossipMessage::ProfileRevoke(revocation) => {
|
||||
// peer is revoking their identity, remove them from our directory
|
||||
let _ = storage.remove_directory_entry(&revocation.peer_id);
|
||||
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::ProfileRevoked {
|
||||
peer_id: revocation.peer_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- mDNS discovery (LAN) ---
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Mdns(
|
||||
libp2p::mdns::Event::Discovered(peers)
|
||||
)) => {
|
||||
for (peer_id, addr) in &peers {
|
||||
swarm_instance.behaviour_mut().gossipsub.add_explicit_peer(peer_id);
|
||||
swarm_instance.behaviour_mut().kademlia.add_address(peer_id, addr.clone());
|
||||
connected_peers.insert(peer_id.to_string());
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::PeerConnected {
|
||||
peer_id: peer_id.to_string(),
|
||||
});
|
||||
}
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::NodeStatus {
|
||||
is_connected: !connected_peers.is_empty(),
|
||||
peer_count: connected_peers.len(),
|
||||
});
|
||||
|
||||
// sync documents with newly discovered LAN peers
|
||||
if !peers.is_empty() {
|
||||
let local_peer_id = *swarm_instance.local_peer_id();
|
||||
let request = crate::crdt::sync::SyncMessage::RequestSync {
|
||||
peer_id: local_peer_id.to_string(),
|
||||
};
|
||||
if let Ok(data) = serde_json::to_vec(&request) {
|
||||
let sync_topic = libp2p::gossipsub::IdentTopic::new(gossip::topic_for_sync());
|
||||
let _ = swarm_instance.behaviour_mut().gossipsub.publish(sync_topic, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Mdns(
|
||||
libp2p::mdns::Event::Expired(peers)
|
||||
)) => {
|
||||
for (peer_id, _) in peers {
|
||||
swarm_instance.behaviour_mut().gossipsub.remove_explicit_peer(&peer_id);
|
||||
connected_peers.remove(&peer_id.to_string());
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::PeerDisconnected {
|
||||
peer_id: peer_id.to_string(),
|
||||
});
|
||||
}
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::NodeStatus {
|
||||
is_connected: !connected_peers.is_empty(),
|
||||
peer_count: connected_peers.len(),
|
||||
});
|
||||
}
|
||||
|
||||
// --- relay client events ---
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::RelayClient(
|
||||
libp2p::relay::client::Event::ReservationReqAccepted { relay_peer_id, .. }
|
||||
)) => {
|
||||
log::info!("relay reservation accepted by {}", relay_peer_id);
|
||||
relay_reservation_active = true;
|
||||
|
||||
// now that we have a relay reservation, process any pending
|
||||
// rendezvous registrations that were queued before the relay was ready
|
||||
let queued = std::mem::take(&mut pending_registrations);
|
||||
for ns in queued {
|
||||
if let Some(rp) = relay_peer {
|
||||
match libp2p::rendezvous::Namespace::new(ns.clone()) {
|
||||
Ok(namespace) => {
|
||||
if let Err(e) = swarm_instance.behaviour_mut().rendezvous.register(
|
||||
namespace,
|
||||
rp,
|
||||
None,
|
||||
) {
|
||||
log::warn!("failed to register on rendezvous for {}: {:?}", ns, e);
|
||||
} else {
|
||||
registered_namespaces.insert(ns);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("invalid rendezvous namespace '{}': {:?}", ns, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let queued = std::mem::take(&mut pending_discoveries);
|
||||
for ns in queued {
|
||||
if let Some(rp) = relay_peer {
|
||||
swarm_instance.behaviour_mut().rendezvous.discover(
|
||||
Some(libp2p::rendezvous::Namespace::new(ns.clone()).unwrap()),
|
||||
None,
|
||||
None,
|
||||
rp,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// queues drained, reset the TTL tracker
|
||||
pending_queued_at = None;
|
||||
}
|
||||
|
||||
// --- rendezvous client events ---
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Rendezvous(
|
||||
libp2p::rendezvous::client::Event::Registered { namespace, .. }
|
||||
)) => {
|
||||
log::info!("registered on rendezvous under namespace '{}'", namespace);
|
||||
registered_namespaces.insert(namespace.to_string());
|
||||
}
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Rendezvous(
|
||||
libp2p::rendezvous::client::Event::Discovered { registrations, .. }
|
||||
)) => {
|
||||
// discovered peers on rendezvous, connect to them through the relay
|
||||
for registration in registrations {
|
||||
let discovered_peer = registration.record.peer_id();
|
||||
let local_id = *swarm_instance.local_peer_id();
|
||||
|
||||
// don't connect to ourselves
|
||||
if discovered_peer == local_id {
|
||||
continue;
|
||||
}
|
||||
|
||||
log::info!("discovered peer {} via rendezvous", discovered_peer);
|
||||
|
||||
// connect through the relay circuit so neither peer reveals their IP
|
||||
if let Some(ref relay_addr) = relay_multiaddr {
|
||||
let circuit_addr = relay_addr.clone()
|
||||
.with(libp2p::multiaddr::Protocol::P2pCircuit)
|
||||
.with(libp2p::multiaddr::Protocol::P2p(discovered_peer));
|
||||
|
||||
if let Err(e) = swarm_instance.dial(circuit_addr) {
|
||||
log::warn!("failed to dial peer {} through relay: {}", discovered_peer, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Rendezvous(
|
||||
libp2p::rendezvous::client::Event::RegisterFailed { namespace, error, .. }
|
||||
)) => {
|
||||
log::warn!("rendezvous registration failed for '{}': {:?}", namespace, error);
|
||||
}
|
||||
|
||||
// --- identify events ---
|
||||
libp2p::swarm::SwarmEvent::Behaviour(behaviour::DuskBehaviourEvent::Identify(
|
||||
libp2p::identify::Event::Received { peer_id, info, .. }
|
||||
)) => {
|
||||
// add observed addresses to kademlia so peers can find each other
|
||||
for addr in &info.listen_addrs {
|
||||
swarm_instance.behaviour_mut().kademlia.add_address(&peer_id, addr.clone());
|
||||
}
|
||||
log::debug!("identified peer {}: {} addresses", peer_id, info.listen_addrs.len());
|
||||
}
|
||||
|
||||
// --- outgoing dial failures ---
|
||||
libp2p::swarm::SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => {
|
||||
// if this was a failed dial to the relay, schedule a retry
|
||||
if let Some(failed_peer) = peer_id {
|
||||
if Some(failed_peer) == relay_peer {
|
||||
log::warn!("failed to connect to relay: {}", error);
|
||||
log::info!("scheduling relay reconnect in {}s", relay_backoff_secs);
|
||||
relay_retry_at = Some(
|
||||
tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs),
|
||||
);
|
||||
// exponential backoff capped at max
|
||||
relay_backoff_secs = (relay_backoff_secs * RELAY_BACKOFF_MULTIPLIER)
|
||||
.min(RELAY_MAX_BACKOFF_SECS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- connection lifecycle ---
|
||||
libp2p::swarm::SwarmEvent::ConnectionEstablished { peer_id, .. } => {
|
||||
// add to gossipsub mesh for WAN peers (mDNS handles LAN peers)
|
||||
swarm_instance.behaviour_mut().gossipsub.add_explicit_peer(&peer_id);
|
||||
connected_peers.insert(peer_id.to_string());
|
||||
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::PeerConnected {
|
||||
peer_id: peer_id.to_string(),
|
||||
});
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::NodeStatus {
|
||||
is_connected: true,
|
||||
peer_count: connected_peers.len(),
|
||||
});
|
||||
|
||||
// if we just connected to the relay, make a reservation
|
||||
// so other peers can reach us through it
|
||||
if Some(peer_id) == relay_peer && !relay_reservation_active {
|
||||
// reset backoff on successful connection
|
||||
relay_backoff_secs = RELAY_INITIAL_BACKOFF_SECS;
|
||||
// cancel any pending retry
|
||||
relay_retry_at = None;
|
||||
|
||||
if let Some(ref addr) = relay_multiaddr {
|
||||
let relay_circuit_addr = addr.clone()
|
||||
.with(libp2p::multiaddr::Protocol::P2pCircuit);
|
||||
|
||||
log::info!("connected to relay, requesting reservation");
|
||||
if let Err(e) = swarm_instance.listen_on(relay_circuit_addr) {
|
||||
log::warn!("failed to listen on relay circuit: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// request sync from newly connected peers
|
||||
let local_peer_id = *swarm_instance.local_peer_id();
|
||||
let request = crate::crdt::sync::SyncMessage::RequestSync {
|
||||
peer_id: local_peer_id.to_string(),
|
||||
};
|
||||
if let Ok(data) = serde_json::to_vec(&request) {
|
||||
let sync_topic = libp2p::gossipsub::IdentTopic::new(gossip::topic_for_sync());
|
||||
let _ = swarm_instance.behaviour_mut().gossipsub.publish(sync_topic, data);
|
||||
}
|
||||
}
|
||||
libp2p::swarm::SwarmEvent::ConnectionClosed { peer_id, num_established, .. } => {
|
||||
if num_established == 0 {
|
||||
connected_peers.remove(&peer_id.to_string());
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::PeerDisconnected {
|
||||
peer_id: peer_id.to_string(),
|
||||
});
|
||||
let _ = app_handle.emit("dusk-event", DuskEvent::NodeStatus {
|
||||
is_connected: !connected_peers.is_empty(),
|
||||
peer_count: connected_peers.len(),
|
||||
});
|
||||
|
||||
// if we lost the relay connection, mark reservation as inactive
|
||||
// and schedule a retry with backoff
|
||||
if Some(peer_id) == relay_peer {
|
||||
relay_reservation_active = false;
|
||||
log::warn!("lost connection to relay, scheduling reconnect in {}s", relay_backoff_secs);
|
||||
|
||||
relay_retry_at = Some(
|
||||
tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs),
|
||||
);
|
||||
relay_backoff_secs = (relay_backoff_secs * RELAY_BACKOFF_MULTIPLIER)
|
||||
.min(RELAY_MAX_BACKOFF_SECS);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// periodic rendezvous re-registration (registrations expire on the server)
|
||||
_ = rendezvous_tick.tick() => {
|
||||
if relay_reservation_active {
|
||||
if let Some(rp) = relay_peer {
|
||||
for ns in registered_namespaces.clone() {
|
||||
if let Err(e) = swarm_instance.behaviour_mut().rendezvous.register(
|
||||
libp2p::rendezvous::Namespace::new(ns.clone()).unwrap(),
|
||||
rp,
|
||||
None,
|
||||
) {
|
||||
log::warn!("failed to refresh rendezvous registration for {}: {:?}", ns, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// clean up stale pending registrations/discoveries that have been
|
||||
// queued too long without a relay connection
|
||||
if let Some(queued_at) = pending_queued_at {
|
||||
if queued_at.elapsed() > std::time::Duration::from_secs(PENDING_QUEUE_TTL_SECS) {
|
||||
if !pending_registrations.is_empty() || !pending_discoveries.is_empty() {
|
||||
log::warn!(
|
||||
"discarding {} pending registrations and {} pending discoveries (relay unavailable for {}s)",
|
||||
pending_registrations.len(),
|
||||
pending_discoveries.len(),
|
||||
PENDING_QUEUE_TTL_SECS,
|
||||
);
|
||||
pending_registrations.clear();
|
||||
pending_discoveries.clear();
|
||||
pending_queued_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// relay reconnection with exponential backoff
|
||||
_ = tokio::time::sleep_until(
|
||||
relay_retry_at.unwrap_or_else(|| tokio::time::Instant::now() + std::time::Duration::from_secs(86400))
|
||||
), if relay_retry_at.is_some() => {
|
||||
relay_retry_at = None;
|
||||
if !relay_reservation_active {
|
||||
if let Some(ref addr) = relay_multiaddr {
|
||||
log::info!("attempting relay reconnect to {}", addr);
|
||||
if let Err(e) = swarm_instance.dial(addr.clone()) {
|
||||
log::warn!("failed to dial relay: {}", e);
|
||||
// schedule another retry
|
||||
relay_retry_at = Some(
|
||||
tokio::time::Instant::now() + std::time::Duration::from_secs(relay_backoff_secs),
|
||||
);
|
||||
relay_backoff_secs = (relay_backoff_secs * RELAY_BACKOFF_MULTIPLIER)
|
||||
.min(RELAY_MAX_BACKOFF_SECS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cmd = command_rx.recv() => {
|
||||
match cmd {
|
||||
Some(NodeCommand::Shutdown) | None => break,
|
||||
Some(NodeCommand::SendMessage { topic, data }) => {
|
||||
let ident_topic = libp2p::gossipsub::IdentTopic::new(topic);
|
||||
let _ = swarm_instance.behaviour_mut().gossipsub.publish(ident_topic, data);
|
||||
}
|
||||
Some(NodeCommand::Subscribe { topic }) => {
|
||||
let ident_topic = libp2p::gossipsub::IdentTopic::new(topic);
|
||||
let _ = swarm_instance.behaviour_mut().gossipsub.subscribe(&ident_topic);
|
||||
}
|
||||
Some(NodeCommand::Unsubscribe { topic }) => {
|
||||
let ident_topic = libp2p::gossipsub::IdentTopic::new(topic);
|
||||
let _ = swarm_instance.behaviour_mut().gossipsub.unsubscribe(&ident_topic);
|
||||
}
|
||||
Some(NodeCommand::GetListenAddrs { reply }) => {
|
||||
let addrs: Vec<String> = swarm_instance
|
||||
.listeners()
|
||||
.map(|a| a.to_string())
|
||||
.collect();
|
||||
let _ = reply.send(addrs);
|
||||
}
|
||||
Some(NodeCommand::Dial { addr }) => {
|
||||
if let Err(e) = swarm_instance.dial(addr.clone()) {
|
||||
log::warn!("failed to dial {}: {}", addr, e);
|
||||
}
|
||||
}
|
||||
Some(NodeCommand::RegisterRendezvous { namespace }) => {
|
||||
if relay_reservation_active {
|
||||
if let Some(rp) = relay_peer {
|
||||
match libp2p::rendezvous::Namespace::new(namespace.clone()) {
|
||||
Ok(ns) => {
|
||||
if let Err(e) = swarm_instance.behaviour_mut().rendezvous.register(ns, rp, None) {
|
||||
log::warn!("failed to register on rendezvous: {:?}", e);
|
||||
} else {
|
||||
registered_namespaces.insert(namespace);
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("invalid rendezvous namespace '{}': {:?}", namespace, e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// queue for later once relay is ready
|
||||
if pending_queued_at.is_none() {
|
||||
pending_queued_at = Some(std::time::Instant::now());
|
||||
}
|
||||
pending_registrations.push(namespace);
|
||||
}
|
||||
}
|
||||
Some(NodeCommand::DiscoverRendezvous { namespace }) => {
|
||||
if relay_reservation_active {
|
||||
if let Some(rp) = relay_peer {
|
||||
match libp2p::rendezvous::Namespace::new(namespace.clone()) {
|
||||
Ok(ns) => {
|
||||
swarm_instance.behaviour_mut().rendezvous.discover(
|
||||
Some(ns),
|
||||
None,
|
||||
None,
|
||||
rp,
|
||||
);
|
||||
}
|
||||
Err(e) => log::warn!("invalid rendezvous namespace '{}': {:?}", namespace, e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// queue for later once relay is ready
|
||||
if pending_queued_at.is_none() {
|
||||
pending_queued_at = Some(std::time::Instant::now());
|
||||
}
|
||||
pending_discoveries.push(namespace);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("p2p node event loop exited");
|
||||
});
|
||||
|
||||
Ok(NodeHandle { task, command_tx })
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::time::Duration;
|
||||
|
||||
use libp2p::{
|
||||
gossipsub, identify, identity, kad, mdns, noise, ping, rendezvous, tcp, yamux, Swarm,
|
||||
SwarmBuilder,
|
||||
};
|
||||
|
||||
use super::behaviour::DuskBehaviour;
|
||||
|
||||
pub fn build_swarm(
|
||||
keypair: &identity::Keypair,
|
||||
) -> Result<Swarm<DuskBehaviour>, Box<dyn std::error::Error>> {
|
||||
// gossipsub config: content-addressed message deduplication
|
||||
let message_id_fn = |message: &gossipsub::Message| {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
message.data.hash(&mut hasher);
|
||||
if let Some(ref source) = message.source {
|
||||
source.hash(&mut hasher);
|
||||
}
|
||||
gossipsub::MessageId::from(hasher.finish().to_string())
|
||||
};
|
||||
|
||||
let gossipsub_config = gossipsub::ConfigBuilder::default()
|
||||
.heartbeat_interval(Duration::from_secs(1))
|
||||
.validation_mode(gossipsub::ValidationMode::Strict)
|
||||
.message_id_fn(message_id_fn)
|
||||
.mesh_n(6)
|
||||
.mesh_n_low(4)
|
||||
.mesh_n_high(12)
|
||||
.history_length(5)
|
||||
.history_gossip(3)
|
||||
.build()
|
||||
.map_err(|e| format!("invalid gossipsub config: {}", e))?;
|
||||
|
||||
let swarm = SwarmBuilder::with_existing_identity(keypair.clone())
|
||||
.with_tokio()
|
||||
.with_tcp(
|
||||
tcp::Config::default(),
|
||||
noise::Config::new,
|
||||
yamux::Config::default,
|
||||
)?
|
||||
// add relay client transport so we can connect through relay circuits
|
||||
.with_relay_client(noise::Config::new, yamux::Config::default)?
|
||||
.with_behaviour(|key, relay_client| {
|
||||
let peer_id = key.public().to_peer_id();
|
||||
|
||||
let gossipsub = gossipsub::Behaviour::new(
|
||||
gossipsub::MessageAuthenticity::Signed(key.clone()),
|
||||
gossipsub_config,
|
||||
)
|
||||
.expect("valid gossipsub behaviour");
|
||||
|
||||
let kademlia = kad::Behaviour::new(peer_id, kad::store::MemoryStore::new(peer_id));
|
||||
|
||||
let mdns = mdns::tokio::Behaviour::new(mdns::Config::default(), peer_id)
|
||||
.expect("valid mdns behaviour");
|
||||
|
||||
let identify = identify::Behaviour::new(identify::Config::new(
|
||||
"/dusk/1.0.0".to_string(),
|
||||
key.public(),
|
||||
));
|
||||
|
||||
let rendezvous = rendezvous::client::Behaviour::new(key.clone());
|
||||
|
||||
DuskBehaviour {
|
||||
relay_client,
|
||||
rendezvous,
|
||||
gossipsub,
|
||||
kademlia,
|
||||
mdns,
|
||||
identify,
|
||||
ping: ping::Behaviour::default(),
|
||||
}
|
||||
})?
|
||||
.with_swarm_config(|cfg| cfg.with_idle_connection_timeout(Duration::from_secs(60)))
|
||||
.build();
|
||||
|
||||
Ok(swarm)
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CommunityMeta {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub created_by: String,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelMeta {
|
||||
pub id: String,
|
||||
pub community_id: String,
|
||||
pub name: String,
|
||||
pub topic: String,
|
||||
pub kind: ChannelKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ChannelKind {
|
||||
Text,
|
||||
Voice,
|
||||
}
|
||||
|
||||
// invite codes encode the minimum information needed to join a community
|
||||
// deliberately excludes IP addresses to protect peer privacy
|
||||
// peers discover each other via the rendezvous protocol on the relay server
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InviteCode {
|
||||
pub community_id: String,
|
||||
pub community_name: String,
|
||||
}
|
||||
|
||||
impl InviteCode {
|
||||
// encode the invite as a base58 string for easy sharing
|
||||
pub fn encode(&self) -> String {
|
||||
let json = serde_json::to_vec(self).expect("failed to serialize invite code");
|
||||
bs58::encode(json).into_string()
|
||||
}
|
||||
|
||||
// decode a base58 invite string back into an InviteCode
|
||||
pub fn decode(encoded: &str) -> Result<Self, String> {
|
||||
let bytes = bs58::decode(encoded)
|
||||
.into_vec()
|
||||
.map_err(|e| format!("invalid invite code encoding: {}", e))?;
|
||||
|
||||
serde_json::from_slice(&bytes).map_err(|e| format!("invalid invite code format: {}", e))
|
||||
}
|
||||
}
|
||||
|
||||
// member within a community
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Member {
|
||||
pub peer_id: String,
|
||||
pub display_name: String,
|
||||
pub status: super::messages::PeerStatus,
|
||||
pub roles: Vec<String>,
|
||||
pub trust_level: f64,
|
||||
pub joined_at: u64,
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
use libp2p::identity;
|
||||
use libp2p::PeerId;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::storage::DiskStorage;
|
||||
|
||||
pub struct DuskIdentity {
|
||||
pub keypair: identity::Keypair,
|
||||
pub peer_id: PeerId,
|
||||
pub display_name: String,
|
||||
pub bio: String,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl DuskIdentity {
|
||||
// generate a fresh ed25519 identity
|
||||
pub fn generate(display_name: &str, bio: &str) -> Self {
|
||||
let keypair = identity::Keypair::generate_ed25519();
|
||||
let peer_id = PeerId::from(keypair.public());
|
||||
let created_at = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
|
||||
Self {
|
||||
keypair,
|
||||
peer_id,
|
||||
display_name: display_name.to_string(),
|
||||
bio: bio.to_string(),
|
||||
created_at,
|
||||
}
|
||||
}
|
||||
|
||||
// load an existing identity from disk
|
||||
pub fn load(storage: &DiskStorage) -> Result<Self, String> {
|
||||
let keypair_bytes = storage
|
||||
.load_keypair()
|
||||
.map_err(|e| format!("failed to load keypair: {}", e))?;
|
||||
|
||||
let keypair = identity::Keypair::from_protobuf_encoding(&keypair_bytes)
|
||||
.map_err(|e| format!("invalid keypair data: {}", e))?;
|
||||
|
||||
let peer_id = PeerId::from(keypair.public());
|
||||
|
||||
let profile = storage.load_profile().unwrap_or_default();
|
||||
|
||||
Ok(Self {
|
||||
keypair,
|
||||
peer_id,
|
||||
display_name: profile.display_name,
|
||||
bio: profile.bio,
|
||||
created_at: profile.created_at,
|
||||
})
|
||||
}
|
||||
|
||||
// persist identity to disk
|
||||
pub fn save(&self, storage: &DiskStorage) -> Result<(), String> {
|
||||
let keypair_bytes = self
|
||||
.keypair
|
||||
.to_protobuf_encoding()
|
||||
.map_err(|e| format!("failed to encode keypair: {}", e))?;
|
||||
|
||||
storage
|
||||
.save_keypair(&keypair_bytes)
|
||||
.map_err(|e| format!("failed to save keypair: {}", e))?;
|
||||
|
||||
let profile = ProfileData {
|
||||
display_name: self.display_name.clone(),
|
||||
bio: self.bio.clone(),
|
||||
created_at: self.created_at,
|
||||
};
|
||||
storage
|
||||
.save_profile(&profile)
|
||||
.map_err(|e| format!("failed to save profile: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// public-facing identity info safe to share
|
||||
pub fn public_identity(&self) -> PublicIdentity {
|
||||
let public_key_bytes = self.keypair.public().encode_protobuf();
|
||||
PublicIdentity {
|
||||
peer_id: self.peer_id.to_string(),
|
||||
display_name: self.display_name.clone(),
|
||||
public_key: hex::encode(public_key_bytes),
|
||||
bio: self.bio.clone(),
|
||||
created_at: self.created_at,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PublicIdentity {
|
||||
pub peer_id: String,
|
||||
pub display_name: String,
|
||||
pub public_key: String,
|
||||
pub bio: String,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
// profile data stored on disk alongside the keypair
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileData {
|
||||
pub display_name: String,
|
||||
pub bio: String,
|
||||
pub created_at: u64,
|
||||
}
|
||||
|
||||
impl Default for ProfileData {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
display_name: "anonymous".to_string(),
|
||||
bio: String::new(),
|
||||
created_at: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// a peer profile cached in the local user directory
|
||||
// this is what other peers announce about themselves on the network
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DirectoryEntry {
|
||||
pub peer_id: String,
|
||||
pub display_name: String,
|
||||
pub bio: String,
|
||||
pub public_key: String,
|
||||
pub last_seen: u64,
|
||||
pub is_friend: bool,
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub id: String,
|
||||
pub channel_id: String,
|
||||
pub author_id: String,
|
||||
pub author_name: String,
|
||||
pub content: String,
|
||||
pub timestamp: u64,
|
||||
pub edited: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TypingIndicator {
|
||||
pub peer_id: String,
|
||||
pub channel_id: String,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PresenceUpdate {
|
||||
pub peer_id: String,
|
||||
pub display_name: String,
|
||||
pub status: PeerStatus,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum PeerStatus {
|
||||
Online,
|
||||
Idle,
|
||||
Offline,
|
||||
}
|
||||
|
||||
// peer profile announcement broadcast on the directory topic
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileAnnouncement {
|
||||
pub peer_id: String,
|
||||
pub display_name: String,
|
||||
pub bio: String,
|
||||
pub public_key: String,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
// broadcast when a user resets their identity, tells peers to purge their data
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileRevocation {
|
||||
pub peer_id: String,
|
||||
pub public_key: String,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
// envelope for all gossipsub-published messages
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum GossipMessage {
|
||||
Chat(ChatMessage),
|
||||
Typing(TypingIndicator),
|
||||
Presence(PresenceUpdate),
|
||||
MetaUpdate(super::community::CommunityMeta),
|
||||
DeleteMessage { message_id: String },
|
||||
MemberKicked { peer_id: String },
|
||||
ProfileAnnounce(ProfileAnnouncement),
|
||||
ProfileRevoke(ProfileRevocation),
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
pub mod community;
|
||||
pub mod identity;
|
||||
pub mod messages;
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::protocol::community::CommunityMeta;
|
||||
use crate::protocol::identity::{DirectoryEntry, ProfileData};
|
||||
|
||||
// user settings that persist across sessions
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UserSettings {
|
||||
pub display_name: String,
|
||||
pub status: String,
|
||||
pub status_message: String,
|
||||
pub enable_sounds: bool,
|
||||
pub enable_desktop_notifications: bool,
|
||||
pub enable_message_preview: bool,
|
||||
pub show_online_status: bool,
|
||||
pub allow_dms_from_anyone: bool,
|
||||
pub message_display: String,
|
||||
pub font_size: String,
|
||||
}
|
||||
|
||||
impl Default for UserSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
display_name: "anonymous".to_string(),
|
||||
status: "online".to_string(),
|
||||
status_message: String::new(),
|
||||
enable_sounds: true,
|
||||
enable_desktop_notifications: true,
|
||||
enable_message_preview: true,
|
||||
show_online_status: true,
|
||||
allow_dms_from_anyone: true,
|
||||
message_display: "cozy".to_string(),
|
||||
font_size: "default".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// file-based persistence for identity, documents, and community metadata
|
||||
pub struct DiskStorage {
|
||||
base_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl DiskStorage {
|
||||
pub fn new() -> Result<Self, io::Error> {
|
||||
let project_dirs = ProjectDirs::from("app", "duskchat", "dusk")
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "no valid home directory"))?;
|
||||
|
||||
let base_dir = project_dirs.data_dir().to_path_buf();
|
||||
|
||||
// ensure the directory tree exists
|
||||
fs::create_dir_all(base_dir.join("identity"))?;
|
||||
fs::create_dir_all(base_dir.join("communities"))?;
|
||||
fs::create_dir_all(base_dir.join("directory"))?;
|
||||
|
||||
Ok(Self { base_dir })
|
||||
}
|
||||
|
||||
// -- identity --
|
||||
|
||||
pub fn save_keypair(&self, keypair_bytes: &[u8]) -> Result<(), io::Error> {
|
||||
fs::write(self.base_dir.join("identity/keypair.bin"), keypair_bytes)
|
||||
}
|
||||
|
||||
pub fn load_keypair(&self) -> Result<Vec<u8>, io::Error> {
|
||||
fs::read(self.base_dir.join("identity/keypair.bin"))
|
||||
}
|
||||
|
||||
pub fn save_display_name(&self, name: &str) -> Result<(), io::Error> {
|
||||
let profile = serde_json::json!({ "display_name": name });
|
||||
fs::write(
|
||||
self.base_dir.join("identity/profile.json"),
|
||||
serde_json::to_string_pretty(&profile).unwrap(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn load_display_name(&self) -> Result<String, io::Error> {
|
||||
let data = fs::read_to_string(self.base_dir.join("identity/profile.json"))?;
|
||||
let profile: serde_json::Value = serde_json::from_str(&data)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
|
||||
profile["display_name"]
|
||||
.as_str()
|
||||
.map(|s| s.to_string())
|
||||
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidData, "missing display_name"))
|
||||
}
|
||||
|
||||
// full profile data with bio and created_at
|
||||
pub fn save_profile(&self, profile: &ProfileData) -> Result<(), io::Error> {
|
||||
let json = serde_json::to_string_pretty(profile)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
fs::write(self.base_dir.join("identity/profile.json"), json)
|
||||
}
|
||||
|
||||
pub fn load_profile(&self) -> Result<ProfileData, io::Error> {
|
||||
let path = self.base_dir.join("identity/profile.json");
|
||||
if !path.exists() {
|
||||
return Ok(ProfileData::default());
|
||||
}
|
||||
let data = fs::read_to_string(path)?;
|
||||
serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
// check if identity exists without loading it
|
||||
pub fn has_identity(&self) -> bool {
|
||||
self.base_dir.join("identity/keypair.bin").exists()
|
||||
}
|
||||
|
||||
// -- automerge documents --
|
||||
|
||||
pub fn save_document(&self, community_id: &str, doc_bytes: &[u8]) -> Result<(), io::Error> {
|
||||
let dir = self.base_dir.join(format!("communities/{}", community_id));
|
||||
fs::create_dir_all(&dir)?;
|
||||
fs::write(dir.join("document.bin"), doc_bytes)
|
||||
}
|
||||
|
||||
pub fn load_document(&self, community_id: &str) -> Result<Vec<u8>, io::Error> {
|
||||
fs::read(
|
||||
self.base_dir
|
||||
.join(format!("communities/{}/document.bin", community_id)),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_communities(&self) -> Result<Vec<String>, io::Error> {
|
||||
let communities_dir = self.base_dir.join("communities");
|
||||
if !communities_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut ids = Vec::new();
|
||||
for entry in fs::read_dir(communities_dir)? {
|
||||
let entry = entry?;
|
||||
if entry.file_type()?.is_dir() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
ids.push(name.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
// -- community metadata cache --
|
||||
|
||||
pub fn save_community_meta(&self, meta: &CommunityMeta) -> Result<(), io::Error> {
|
||||
let dir = self.base_dir.join(format!("communities/{}", meta.id));
|
||||
fs::create_dir_all(&dir)?;
|
||||
let json = serde_json::to_string_pretty(meta)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
fs::write(dir.join("meta.json"), json)
|
||||
}
|
||||
|
||||
pub fn load_community_meta(&self, community_id: &str) -> Result<CommunityMeta, io::Error> {
|
||||
let data = fs::read_to_string(
|
||||
self.base_dir
|
||||
.join(format!("communities/{}/meta.json", community_id)),
|
||||
)?;
|
||||
serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
// -- user settings --
|
||||
|
||||
pub fn save_settings(&self, settings: &UserSettings) -> Result<(), io::Error> {
|
||||
let json = serde_json::to_string_pretty(settings)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
fs::write(self.base_dir.join("identity/settings.json"), json)
|
||||
}
|
||||
|
||||
pub fn load_settings(&self) -> Result<UserSettings, io::Error> {
|
||||
let path = self.base_dir.join("identity/settings.json");
|
||||
if !path.exists() {
|
||||
return Ok(UserSettings::default());
|
||||
}
|
||||
|
||||
let data = fs::read_to_string(path)?;
|
||||
serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
// -- peer directory --
|
||||
|
||||
// save a discovered peer to the local directory
|
||||
pub fn save_directory_entry(&self, entry: &DirectoryEntry) -> Result<(), io::Error> {
|
||||
let mut entries = self.load_directory().unwrap_or_default();
|
||||
entries.insert(entry.peer_id.clone(), entry.clone());
|
||||
let json = serde_json::to_string_pretty(&entries)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
fs::write(self.base_dir.join("directory/peers.json"), json)
|
||||
}
|
||||
|
||||
// load the entire peer directory
|
||||
pub fn load_directory(&self) -> Result<HashMap<String, DirectoryEntry>, io::Error> {
|
||||
let path = self.base_dir.join("directory/peers.json");
|
||||
if !path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
let data = fs::read_to_string(path)?;
|
||||
serde_json::from_str(&data).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))
|
||||
}
|
||||
|
||||
// remove a peer from the directory
|
||||
pub fn remove_directory_entry(&self, peer_id: &str) -> Result<(), io::Error> {
|
||||
let mut entries = self.load_directory().unwrap_or_default();
|
||||
entries.remove(peer_id);
|
||||
let json = serde_json::to_string_pretty(&entries)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
fs::write(self.base_dir.join("directory/peers.json"), json)
|
||||
}
|
||||
|
||||
// toggle friend status for a peer
|
||||
pub fn set_friend_status(&self, peer_id: &str, is_friend: bool) -> Result<(), io::Error> {
|
||||
let mut entries = self.load_directory().unwrap_or_default();
|
||||
if let Some(entry) = entries.get_mut(peer_id) {
|
||||
entry.is_friend = is_friend;
|
||||
let json = serde_json::to_string_pretty(&entries)
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
|
||||
fs::write(self.base_dir.join("directory/peers.json"), json)
|
||||
} else {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"peer not found in directory",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
// wipe all user data - identity, communities, directory, settings
|
||||
// used when resetting identity to leave no traces on this client
|
||||
pub fn wipe_all_data(&self) -> Result<(), io::Error> {
|
||||
let identity_dir = self.base_dir.join("identity");
|
||||
if identity_dir.exists() {
|
||||
fs::remove_dir_all(&identity_dir)?;
|
||||
}
|
||||
|
||||
let communities_dir = self.base_dir.join("communities");
|
||||
if communities_dir.exists() {
|
||||
fs::remove_dir_all(&communities_dir)?;
|
||||
}
|
||||
|
||||
let directory_dir = self.base_dir.join("directory");
|
||||
if directory_dir.exists() {
|
||||
fs::remove_dir_all(&directory_dir)?;
|
||||
}
|
||||
|
||||
// recreate the directory tree so the app can still function
|
||||
fs::create_dir_all(self.base_dir.join("identity"))?;
|
||||
fs::create_dir_all(self.base_dir.join("communities"))?;
|
||||
fs::create_dir_all(self.base_dir.join("directory"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
mod disk;
|
||||
|
||||
pub use disk::DiskStorage;
|
||||
pub use disk::UserSettings;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "dusk",
|
||||
"version": "0.1.0",
|
||||
"identifier": "app.duskchat.dusk",
|
||||
"build": {
|
||||
"beforeDevCommand": "bun run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "bun run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "dusk",
|
||||
"width": 1280,
|
||||
"height": 800,
|
||||
"minWidth": 400,
|
||||
"minHeight": 600,
|
||||
"decorations": true
|
||||
}
|
||||
],
|
||||
"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'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,984 @@
|
|||
import { Component, onMount, onCleanup, createSignal, Show } from "solid-js";
|
||||
import AppLayout from "./components/layout/AppLayout";
|
||||
import OverlayMenu from "./components/navigation/OverlayMenu";
|
||||
import MobileNav from "./components/navigation/MobileNav";
|
||||
import Modal from "./components/common/Modal";
|
||||
import Button from "./components/common/Button";
|
||||
import SettingsModal from "./components/settings/SettingsModal";
|
||||
import SignUpScreen from "./components/auth/SignUpScreen";
|
||||
import UserDirectoryModal from "./components/directory/UserDirectoryModal";
|
||||
|
||||
import {
|
||||
overlayMenuOpen,
|
||||
closeOverlay,
|
||||
activeModal,
|
||||
closeModal,
|
||||
openModal,
|
||||
initResponsive,
|
||||
} from "./stores/ui";
|
||||
import { setCurrentIdentity, identity } from "./stores/identity";
|
||||
import { settings, updateSettings } from "./stores/settings";
|
||||
import {
|
||||
setCommunities,
|
||||
setActiveCommunity,
|
||||
activeCommunityId,
|
||||
addCommunity,
|
||||
} from "./stores/communities";
|
||||
import {
|
||||
setChannels,
|
||||
setActiveChannel,
|
||||
activeChannelId,
|
||||
} from "./stores/channels";
|
||||
import {
|
||||
addMessage,
|
||||
setMessages,
|
||||
clearMessages,
|
||||
removeMessage,
|
||||
} from "./stores/messages";
|
||||
import {
|
||||
setMembers,
|
||||
addTypingPeer,
|
||||
setPeerOnline,
|
||||
setPeerOffline,
|
||||
removeMember,
|
||||
} from "./stores/members";
|
||||
import {
|
||||
setPeerCount,
|
||||
setNodeStatus,
|
||||
setIsConnected,
|
||||
} from "./stores/connection";
|
||||
import {
|
||||
setDMConversations,
|
||||
activeDMPeerId,
|
||||
addDMMessage,
|
||||
setActiveDM,
|
||||
updateDMLastMessage,
|
||||
} from "./stores/dms";
|
||||
import {
|
||||
setKnownPeers,
|
||||
setFriends,
|
||||
updatePeerProfile,
|
||||
removePeer,
|
||||
clearDirectory,
|
||||
} from "./stores/directory";
|
||||
|
||||
import * as tauri from "./lib/tauri";
|
||||
import type { DuskEvent } from "./lib/types";
|
||||
import { resetSettings } from "./stores/settings";
|
||||
|
||||
const App: Component = () => {
|
||||
let cleanupResize: (() => void) | undefined;
|
||||
let cleanupEvents: (() => void) | undefined;
|
||||
|
||||
const [tauriAvailable, setTauriAvailable] = createSignal(false);
|
||||
const [needsSignUp, setNeedsSignUp] = createSignal(false);
|
||||
const [appReady, setAppReady] = createSignal(false);
|
||||
const [newCommunityName, setNewCommunityName] = createSignal("");
|
||||
const [newCommunityDesc, setNewCommunityDesc] = createSignal("");
|
||||
const [joinInviteCode, setJoinInviteCode] = createSignal("");
|
||||
const [newChannelName, setNewChannelName] = createSignal("");
|
||||
const [newChannelTopic, setNewChannelTopic] = createSignal("");
|
||||
|
||||
onMount(async () => {
|
||||
cleanupResize = initResponsive();
|
||||
|
||||
// detect tauri environment via the injected runtime bridge
|
||||
const isTauri =
|
||||
typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
||||
|
||||
console.log("tauri detection result:", isTauri);
|
||||
|
||||
setTauriAvailable(isTauri);
|
||||
|
||||
if (isTauri) {
|
||||
// check if identity exists before loading
|
||||
const hasExisting = await tauri.hasIdentity();
|
||||
if (hasExisting) {
|
||||
await initWithTauri();
|
||||
setAppReady(true);
|
||||
} else {
|
||||
// show the signup screen
|
||||
setNeedsSignUp(true);
|
||||
}
|
||||
} else {
|
||||
loadDemoData();
|
||||
setAppReady(true);
|
||||
}
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
cleanupResize?.();
|
||||
cleanupEvents?.();
|
||||
});
|
||||
|
||||
async function initWithTauri() {
|
||||
try {
|
||||
const existing = await tauri.loadIdentity();
|
||||
if (existing) {
|
||||
setCurrentIdentity(existing);
|
||||
// ensure settings display name matches identity
|
||||
updateSettings({ display_name: existing.display_name });
|
||||
}
|
||||
|
||||
// load user settings from disk
|
||||
try {
|
||||
const loadedSettings = await tauri.loadSettings();
|
||||
// ensure identity display name takes precedence
|
||||
if (existing) {
|
||||
loadedSettings.display_name = existing.display_name;
|
||||
}
|
||||
updateSettings(loadedSettings);
|
||||
} catch {
|
||||
// settings not found, use defaults
|
||||
}
|
||||
|
||||
// load the peer directory and friends list
|
||||
try {
|
||||
const peers = await tauri.getKnownPeers();
|
||||
setKnownPeers(peers);
|
||||
const friendsList = await tauri.getFriends();
|
||||
setFriends(friendsList);
|
||||
} catch {
|
||||
// directory not populated yet, that's fine
|
||||
}
|
||||
|
||||
const communities = await tauri.getCommunities();
|
||||
setCommunities(communities);
|
||||
|
||||
// register the event listener before starting the node so we don't
|
||||
// miss the initial NodeStatus event emitted during startup
|
||||
const unlisten = await tauri.onDuskEvent(handleDuskEvent);
|
||||
cleanupEvents = unlisten;
|
||||
|
||||
setNodeStatus("starting");
|
||||
await tauri.startNode();
|
||||
// node is running but connection status is determined by backend events.
|
||||
// do not optimistically set isConnected here - the node_status event
|
||||
// from the backend will set the accurate state once peers are found.
|
||||
setNodeStatus("running");
|
||||
|
||||
if (communities.length > 0) {
|
||||
setActiveCommunity(communities[0].id);
|
||||
const channels = await tauri.getChannels(communities[0].id);
|
||||
setChannels(channels);
|
||||
|
||||
if (channels.length > 0) {
|
||||
setActiveChannel(channels[0].id);
|
||||
const messages = await tauri.getMessages(channels[0].id);
|
||||
setMessages(messages);
|
||||
}
|
||||
|
||||
const members = await tauri.getMembers(communities[0].id);
|
||||
setMembers(members);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("initialization error:", e);
|
||||
setNodeStatus("error");
|
||||
}
|
||||
}
|
||||
|
||||
function handleDuskEvent(event: DuskEvent) {
|
||||
switch (event.kind) {
|
||||
case "message_received":
|
||||
if (event.payload.channel_id === activeChannelId()) {
|
||||
addMessage(event.payload);
|
||||
}
|
||||
break;
|
||||
case "message_deleted":
|
||||
removeMessage(event.payload.message_id);
|
||||
break;
|
||||
case "member_kicked":
|
||||
removeMember(event.payload.peer_id);
|
||||
break;
|
||||
case "peer_connected":
|
||||
setPeerOnline(event.payload.peer_id);
|
||||
break;
|
||||
case "peer_disconnected":
|
||||
setPeerOffline(event.payload.peer_id);
|
||||
break;
|
||||
case "typing":
|
||||
if (event.payload.channel_id === activeChannelId()) {
|
||||
addTypingPeer(event.payload.peer_id);
|
||||
}
|
||||
break;
|
||||
case "node_status":
|
||||
setIsConnected(event.payload.is_connected);
|
||||
setPeerCount(event.payload.peer_count);
|
||||
// the node is still running even with zero peers, only mark stopped
|
||||
// if the node itself has shut down (handled by stop_node command)
|
||||
break;
|
||||
case "sync_complete":
|
||||
if (event.payload.community_id === activeCommunityId()) {
|
||||
reloadCurrentChannel();
|
||||
}
|
||||
break;
|
||||
case "profile_received":
|
||||
// update our local directory cache when a peer announces their profile
|
||||
updatePeerProfile(
|
||||
event.payload.peer_id,
|
||||
event.payload.display_name,
|
||||
event.payload.bio,
|
||||
);
|
||||
break;
|
||||
case "profile_revoked":
|
||||
// peer revoked their identity, remove them from our local directory
|
||||
removePeer(event.payload.peer_id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadCurrentChannel() {
|
||||
const channelId = activeChannelId();
|
||||
if (!channelId || !tauriAvailable()) return;
|
||||
try {
|
||||
const msgs = await tauri.getMessages(channelId);
|
||||
setMessages(msgs);
|
||||
} catch (e) {
|
||||
console.error("failed to reload messages:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSendMessage(content: string) {
|
||||
const channelId = activeChannelId();
|
||||
if (!channelId) return;
|
||||
|
||||
if (tauriAvailable()) {
|
||||
try {
|
||||
const msg = await tauri.sendMessage(channelId, content);
|
||||
addMessage(msg);
|
||||
} catch (e) {
|
||||
console.error("failed to send message:", e);
|
||||
}
|
||||
} else {
|
||||
const id = identity();
|
||||
addMessage({
|
||||
id: `demo_${Date.now()}`,
|
||||
channel_id: channelId,
|
||||
author_id: id?.peer_id ?? "local",
|
||||
author_name: id?.display_name ?? "you",
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
edited: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleTyping() {
|
||||
const channelId = activeChannelId();
|
||||
if (!channelId || !tauriAvailable()) return;
|
||||
tauri.sendTypingIndicator(channelId).catch(() => {});
|
||||
}
|
||||
|
||||
function handleSendDM(content: string) {
|
||||
const peerId = activeDMPeerId();
|
||||
if (!peerId) return;
|
||||
|
||||
const id = identity();
|
||||
const msg = {
|
||||
id: `dm_${Date.now()}`,
|
||||
channel_id: `dm_${peerId}`,
|
||||
author_id: id?.peer_id ?? "local",
|
||||
author_name: id?.display_name ?? "you",
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
edited: false,
|
||||
};
|
||||
|
||||
addDMMessage(msg);
|
||||
updateDMLastMessage(peerId, content, msg.timestamp);
|
||||
}
|
||||
|
||||
function handleOverlayNavigate(action: string) {
|
||||
switch (action) {
|
||||
case "create-community":
|
||||
openModal("create-community");
|
||||
break;
|
||||
case "join-community":
|
||||
openModal("join-community");
|
||||
break;
|
||||
case "settings":
|
||||
openModal("settings");
|
||||
break;
|
||||
case "directory":
|
||||
openModal("directory");
|
||||
break;
|
||||
case "home":
|
||||
setActiveCommunity(null);
|
||||
setActiveDM(null);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateCommunity() {
|
||||
const name = newCommunityName().trim();
|
||||
const desc = newCommunityDesc().trim();
|
||||
if (!name) return;
|
||||
|
||||
if (tauriAvailable()) {
|
||||
try {
|
||||
const community = await tauri.createCommunity(name, desc);
|
||||
addCommunity(community);
|
||||
setActiveCommunity(community.id);
|
||||
|
||||
const channels = await tauri.getChannels(community.id);
|
||||
setChannels(channels);
|
||||
if (channels.length > 0) {
|
||||
setActiveChannel(channels[0].id);
|
||||
clearMessages();
|
||||
}
|
||||
|
||||
const members = await tauri.getMembers(community.id);
|
||||
setMembers(members);
|
||||
} catch (e) {
|
||||
console.error("failed to create community:", e);
|
||||
}
|
||||
} else {
|
||||
const id = `com_demo_${Date.now()}`;
|
||||
const chId = `ch_general_${Date.now()}`;
|
||||
addCommunity({
|
||||
id,
|
||||
name,
|
||||
description: desc,
|
||||
created_by: "local",
|
||||
created_at: Date.now(),
|
||||
});
|
||||
setActiveCommunity(id);
|
||||
setChannels([
|
||||
{
|
||||
id: chId,
|
||||
community_id: id,
|
||||
name: "general",
|
||||
topic: "general discussion",
|
||||
kind: "Text",
|
||||
},
|
||||
]);
|
||||
setActiveChannel(chId);
|
||||
clearMessages();
|
||||
setMembers([
|
||||
{
|
||||
peer_id: "local",
|
||||
display_name: identity()?.display_name ?? "you",
|
||||
status: "Online",
|
||||
roles: ["owner"],
|
||||
trust_level: 1.0,
|
||||
joined_at: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
setNewCommunityName("");
|
||||
setNewCommunityDesc("");
|
||||
closeModal();
|
||||
}
|
||||
|
||||
async function handleJoinCommunity() {
|
||||
const inviteCode = joinInviteCode().trim();
|
||||
if (!inviteCode) return;
|
||||
|
||||
if (tauriAvailable()) {
|
||||
try {
|
||||
const community = await tauri.joinCommunity(inviteCode);
|
||||
addCommunity(community);
|
||||
setActiveCommunity(community.id);
|
||||
|
||||
const channels = await tauri.getChannels(community.id);
|
||||
setChannels(channels);
|
||||
if (channels.length > 0) {
|
||||
setActiveChannel(channels[0].id);
|
||||
clearMessages();
|
||||
}
|
||||
|
||||
const members = await tauri.getMembers(community.id);
|
||||
setMembers(members);
|
||||
} catch (e) {
|
||||
console.error("failed to join community:", e);
|
||||
}
|
||||
} else {
|
||||
// demo mode - simulate joining
|
||||
const id = `com_demo_joined_${Date.now()}`;
|
||||
const chId = `ch_general_${Date.now()}`;
|
||||
addCommunity({
|
||||
id,
|
||||
name: "joined community",
|
||||
description: "a community you joined",
|
||||
created_by: "remote",
|
||||
created_at: Date.now(),
|
||||
});
|
||||
setActiveCommunity(id);
|
||||
setChannels([
|
||||
{
|
||||
id: chId,
|
||||
community_id: id,
|
||||
name: "general",
|
||||
topic: "general discussion",
|
||||
kind: "Text",
|
||||
},
|
||||
]);
|
||||
setActiveChannel(chId);
|
||||
clearMessages();
|
||||
setMembers([
|
||||
{
|
||||
peer_id: "local",
|
||||
display_name: identity()?.display_name ?? "you",
|
||||
status: "Online",
|
||||
roles: ["member"],
|
||||
trust_level: 0.5,
|
||||
joined_at: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
setJoinInviteCode("");
|
||||
closeModal();
|
||||
}
|
||||
|
||||
async function handleCreateChannel() {
|
||||
const name = newChannelName().trim();
|
||||
const topic = newChannelTopic().trim();
|
||||
const communityId = activeCommunityId();
|
||||
if (!name || !communityId) return;
|
||||
|
||||
if (tauriAvailable()) {
|
||||
try {
|
||||
const channel = await tauri.createChannel(communityId, name, topic);
|
||||
setChannels((prev) => [...prev, channel]);
|
||||
setActiveChannel(channel.id);
|
||||
clearMessages();
|
||||
} catch (e) {
|
||||
console.error("failed to create channel:", e);
|
||||
}
|
||||
} else {
|
||||
// demo mode
|
||||
const chId = `ch_${name.toLowerCase().replace(/\s+/g, "_")}_${Date.now()}`;
|
||||
const channel = {
|
||||
id: chId,
|
||||
community_id: communityId,
|
||||
name,
|
||||
topic: topic || `${name} discussion`,
|
||||
kind: "Text" as const,
|
||||
};
|
||||
setChannels((prev) => [...prev, channel]);
|
||||
setActiveChannel(chId);
|
||||
clearMessages();
|
||||
}
|
||||
|
||||
setNewChannelName("");
|
||||
setNewChannelTopic("");
|
||||
closeModal();
|
||||
}
|
||||
|
||||
async function handleSaveSettings() {
|
||||
if (tauriAvailable()) {
|
||||
try {
|
||||
await tauri.saveSettings(settings());
|
||||
// also update the identity with new display name
|
||||
const current = settings();
|
||||
if (identity()?.display_name !== current.display_name) {
|
||||
await tauri.updateDisplayName(current.display_name);
|
||||
setCurrentIdentity({
|
||||
...identity()!,
|
||||
display_name: current.display_name,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("failed to save settings:", e);
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
}
|
||||
|
||||
async function handleSignUpComplete(displayName: string, bio: string) {
|
||||
if (tauriAvailable()) {
|
||||
try {
|
||||
const created = await tauri.createIdentity(displayName, bio);
|
||||
setCurrentIdentity(created);
|
||||
updateSettings({ display_name: displayName });
|
||||
|
||||
setNeedsSignUp(false);
|
||||
await initWithTauri();
|
||||
setAppReady(true);
|
||||
} catch (e) {
|
||||
console.error("failed to create identity:", e);
|
||||
}
|
||||
} else {
|
||||
// demo mode fallback
|
||||
setCurrentIdentity({
|
||||
peer_id: "12D3KooWDemo1234567890abcdef",
|
||||
display_name: displayName,
|
||||
public_key: "abcdef1234567890",
|
||||
bio,
|
||||
created_at: Date.now(),
|
||||
});
|
||||
updateSettings({ display_name: displayName });
|
||||
setNeedsSignUp(false);
|
||||
loadDemoData();
|
||||
setAppReady(true);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResetIdentity() {
|
||||
if (tauriAvailable()) {
|
||||
try {
|
||||
await tauri.resetIdentity();
|
||||
} catch (e) {
|
||||
console.error("failed to reset identity:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// clear all in-memory state
|
||||
setCurrentIdentity(null);
|
||||
clearDirectory();
|
||||
resetSettings();
|
||||
setCommunities([]);
|
||||
setActiveCommunity(null);
|
||||
setChannels([]);
|
||||
setActiveChannel(null);
|
||||
clearMessages();
|
||||
setMembers([]);
|
||||
setDMConversations([]);
|
||||
setActiveDM(null);
|
||||
setPeerCount(0);
|
||||
setIsConnected(false);
|
||||
setNodeStatus("stopped");
|
||||
localStorage.removeItem("dusk_user_settings");
|
||||
|
||||
// clean up event listener since the node is stopped
|
||||
cleanupEvents?.();
|
||||
cleanupEvents = undefined;
|
||||
|
||||
// return to signup screen
|
||||
setAppReady(false);
|
||||
setNeedsSignUp(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="h-screen w-screen overflow-hidden bg-black">
|
||||
<Show when={needsSignUp()}>
|
||||
<SignUpScreen onComplete={handleSignUpComplete} />
|
||||
</Show>
|
||||
|
||||
<Show when={appReady()}>
|
||||
<MobileNav />
|
||||
<AppLayout
|
||||
onSendMessage={handleSendMessage}
|
||||
onTyping={handleTyping}
|
||||
onSendDM={handleSendDM}
|
||||
/>
|
||||
|
||||
<OverlayMenu
|
||||
isOpen={overlayMenuOpen()}
|
||||
onClose={closeOverlay}
|
||||
onNavigate={handleOverlayNavigate}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
isOpen={activeModal() === "create-community"}
|
||||
onClose={closeModal}
|
||||
title="create community"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
|
||||
placeholder="my community"
|
||||
value={newCommunityName()}
|
||||
onInput={(e) => setNewCommunityName(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
description
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
|
||||
placeholder="what's this community about?"
|
||||
value={newCommunityDesc()}
|
||||
onInput={(e) => setNewCommunityDesc(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={handleCreateCommunity}
|
||||
disabled={!newCommunityName().trim()}
|
||||
>
|
||||
create
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={activeModal() === "join-community"}
|
||||
onClose={closeModal}
|
||||
title="join community"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
invite code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
|
||||
placeholder="paste your invite code here"
|
||||
value={joinInviteCode()}
|
||||
onInput={(e) => setJoinInviteCode(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={handleJoinCommunity}
|
||||
disabled={!joinInviteCode().trim()}
|
||||
>
|
||||
join
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
isOpen={activeModal() === "create-channel"}
|
||||
onClose={closeModal}
|
||||
title="create channel"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
|
||||
placeholder="channel name"
|
||||
value={newChannelName()}
|
||||
onInput={(e) => setNewChannelName(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
topic (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
|
||||
placeholder="what's this channel about?"
|
||||
value={newChannelTopic()}
|
||||
onInput={(e) => setNewChannelTopic(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={handleCreateChannel}
|
||||
disabled={!newChannelName().trim()}
|
||||
>
|
||||
create
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<SettingsModal
|
||||
isOpen={activeModal() === "settings"}
|
||||
onClose={closeModal}
|
||||
onSave={handleSaveSettings}
|
||||
onResetIdentity={handleResetIdentity}
|
||||
/>
|
||||
|
||||
<UserDirectoryModal
|
||||
isOpen={activeModal() === "directory"}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// populate stores with realistic static data for browser development
|
||||
function loadDemoData() {
|
||||
const now = Date.now();
|
||||
|
||||
setCurrentIdentity({
|
||||
peer_id: "12D3KooWDemo1234567890abcdef",
|
||||
display_name: "user",
|
||||
public_key: "abcdef1234567890",
|
||||
bio: "",
|
||||
created_at: now - 86400000 * 30,
|
||||
});
|
||||
|
||||
setCommunities([
|
||||
{
|
||||
id: "com_demo_001",
|
||||
name: "dusk dev",
|
||||
description: "development community for dusk",
|
||||
created_by: "12D3KooWDemo1234567890abcdef",
|
||||
created_at: now - 86400000 * 7,
|
||||
},
|
||||
{
|
||||
id: "com_demo_002",
|
||||
name: "rust p2p",
|
||||
description: "peer-to-peer networking in rust",
|
||||
created_by: "12D3KooWPeer_alice",
|
||||
created_at: now - 86400000 * 14,
|
||||
},
|
||||
]);
|
||||
|
||||
setActiveCommunity("com_demo_001");
|
||||
|
||||
setChannels([
|
||||
{
|
||||
id: "ch_general_001",
|
||||
community_id: "com_demo_001",
|
||||
name: "general",
|
||||
topic: "general discussion about dusk development",
|
||||
kind: "Text",
|
||||
},
|
||||
{
|
||||
id: "ch_design_001",
|
||||
community_id: "com_demo_001",
|
||||
name: "design",
|
||||
topic: "UI/UX design discussion",
|
||||
kind: "Text",
|
||||
},
|
||||
{
|
||||
id: "ch_voice_001",
|
||||
community_id: "com_demo_001",
|
||||
name: "voice",
|
||||
topic: "",
|
||||
kind: "Voice",
|
||||
},
|
||||
]);
|
||||
|
||||
setActiveChannel("ch_general_001");
|
||||
|
||||
setMessages([
|
||||
{
|
||||
id: "msg_001",
|
||||
channel_id: "ch_general_001",
|
||||
author_id: "12D3KooWPeer_alice",
|
||||
author_name: "alice",
|
||||
content:
|
||||
"just got the libp2p node running on my machine. peer discovery over mdns works perfectly on LAN.",
|
||||
timestamp: now - 3600000 * 2,
|
||||
edited: false,
|
||||
},
|
||||
{
|
||||
id: "msg_002",
|
||||
channel_id: "ch_general_001",
|
||||
author_id: "12D3KooWPeer_bob",
|
||||
author_name: "bob",
|
||||
content: "nice! how's the gossipsub performance? any message drops?",
|
||||
timestamp: now - 3600000 * 2 + 60000,
|
||||
edited: false,
|
||||
},
|
||||
{
|
||||
id: "msg_003",
|
||||
channel_id: "ch_general_001",
|
||||
author_id: "12D3KooWPeer_alice",
|
||||
author_name: "alice",
|
||||
content:
|
||||
"zero drops so far with 3 peers on the mesh. the heartbeat interval at 1s keeps things responsive.",
|
||||
timestamp: now - 3600000 * 2 + 120000,
|
||||
edited: false,
|
||||
},
|
||||
{
|
||||
id: "msg_004",
|
||||
channel_id: "ch_general_001",
|
||||
author_id: "12D3KooWPeer_alice",
|
||||
author_name: "alice",
|
||||
content:
|
||||
"the automerge sync is also working well for catch-up after reconnection",
|
||||
timestamp: now - 3600000 * 2 + 180000,
|
||||
edited: false,
|
||||
},
|
||||
{
|
||||
id: "msg_005",
|
||||
channel_id: "ch_general_001",
|
||||
author_id: "12D3KooWDemo1234567890abcdef",
|
||||
author_name: "user",
|
||||
content:
|
||||
"this is looking great. the CRDT approach means we never have to worry about message ordering conflicts.",
|
||||
timestamp: now - 3600000,
|
||||
edited: false,
|
||||
},
|
||||
{
|
||||
id: "msg_006",
|
||||
channel_id: "ch_general_001",
|
||||
author_id: "12D3KooWPeer_charlie",
|
||||
author_name: "charlie",
|
||||
content:
|
||||
"been testing NAT traversal with hole punching. works about 70% of the time which matches the libp2p docs estimates.",
|
||||
timestamp: now - 1800000,
|
||||
edited: false,
|
||||
},
|
||||
{
|
||||
id: "msg_007",
|
||||
channel_id: "ch_general_001",
|
||||
author_id: "12D3KooWPeer_bob",
|
||||
author_name: "bob",
|
||||
content:
|
||||
"for the remaining 30% we'll need TURN relay fallback. but that's a phase 3 concern.",
|
||||
timestamp: now - 1800000 + 30000,
|
||||
edited: false,
|
||||
},
|
||||
{
|
||||
id: "msg_008",
|
||||
channel_id: "ch_general_001",
|
||||
author_id: "12D3KooWPeer_charlie",
|
||||
author_name: "charlie",
|
||||
content:
|
||||
"agreed. the three-tier topology design should handle that gracefully when we get there.",
|
||||
timestamp: now - 1800000 + 60000,
|
||||
edited: false,
|
||||
},
|
||||
]);
|
||||
|
||||
setMembers([
|
||||
{
|
||||
peer_id: "12D3KooWDemo1234567890abcdef",
|
||||
display_name: "user",
|
||||
status: "Online",
|
||||
roles: ["owner"],
|
||||
trust_level: 1.0,
|
||||
joined_at: now - 86400000 * 7,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_alice",
|
||||
display_name: "alice",
|
||||
status: "Online",
|
||||
roles: ["admin"],
|
||||
trust_level: 0.95,
|
||||
joined_at: now - 86400000 * 6,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_bob",
|
||||
display_name: "bob",
|
||||
status: "Idle",
|
||||
roles: ["member"],
|
||||
trust_level: 0.8,
|
||||
joined_at: now - 86400000 * 5,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_charlie",
|
||||
display_name: "charlie",
|
||||
status: "Online",
|
||||
roles: ["member"],
|
||||
trust_level: 0.75,
|
||||
joined_at: now - 86400000 * 3,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_diana",
|
||||
display_name: "diana",
|
||||
status: "Offline",
|
||||
roles: ["member"],
|
||||
trust_level: 0.6,
|
||||
joined_at: now - 86400000 * 2,
|
||||
},
|
||||
]);
|
||||
|
||||
// seed dm conversations so the home screen has content
|
||||
setDMConversations([
|
||||
{
|
||||
peer_id: "12D3KooWPeer_alice",
|
||||
display_name: "alice",
|
||||
status: "Online",
|
||||
last_message: "the gossipsub refactor is merged, check it out",
|
||||
last_message_time: now - 600000,
|
||||
unread_count: 2,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_bob",
|
||||
display_name: "bob",
|
||||
status: "Idle",
|
||||
last_message: "sure, i'll review the PR tonight",
|
||||
last_message_time: now - 3600000,
|
||||
unread_count: 0,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_charlie",
|
||||
display_name: "charlie",
|
||||
status: "Online",
|
||||
last_message: "NAT traversal test results look promising",
|
||||
last_message_time: now - 7200000,
|
||||
unread_count: 1,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_diana",
|
||||
display_name: "diana",
|
||||
status: "Offline",
|
||||
last_message: "offline, will catch up tomorrow",
|
||||
last_message_time: now - 86400000,
|
||||
unread_count: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
// seed the user directory with known peers
|
||||
setKnownPeers([
|
||||
{
|
||||
peer_id: "12D3KooWPeer_alice",
|
||||
display_name: "alice",
|
||||
bio: "distributed systems engineer. libp2p contributor.",
|
||||
public_key: "alice_pubkey_hex",
|
||||
last_seen: now - 600000,
|
||||
is_friend: true,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_bob",
|
||||
display_name: "bob",
|
||||
bio: "rust developer, crdt enthusiast",
|
||||
public_key: "bob_pubkey_hex",
|
||||
last_seen: now - 3600000,
|
||||
is_friend: true,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_charlie",
|
||||
display_name: "charlie",
|
||||
bio: "networking and NAT traversal research",
|
||||
public_key: "charlie_pubkey_hex",
|
||||
last_seen: now - 7200000,
|
||||
is_friend: false,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_diana",
|
||||
display_name: "diana",
|
||||
bio: "",
|
||||
public_key: "diana_pubkey_hex",
|
||||
last_seen: now - 86400000,
|
||||
is_friend: false,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_eve",
|
||||
display_name: "eve",
|
||||
bio: "cryptography researcher, privacy advocate",
|
||||
public_key: "eve_pubkey_hex",
|
||||
last_seen: now - 172800000,
|
||||
is_friend: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// seed friends list
|
||||
setFriends([
|
||||
{
|
||||
peer_id: "12D3KooWPeer_alice",
|
||||
display_name: "alice",
|
||||
bio: "distributed systems engineer. libp2p contributor.",
|
||||
public_key: "alice_pubkey_hex",
|
||||
last_seen: now - 600000,
|
||||
is_friend: true,
|
||||
},
|
||||
{
|
||||
peer_id: "12D3KooWPeer_bob",
|
||||
display_name: "bob",
|
||||
bio: "rust developer, crdt enthusiast",
|
||||
public_key: "bob_pubkey_hex",
|
||||
last_seen: now - 3600000,
|
||||
is_friend: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
|
@ -0,0 +1,208 @@
|
|||
import { Component, createSignal, Show } from "solid-js";
|
||||
import { Key, User, ArrowRight, Shield } from "lucide-solid";
|
||||
import Button from "../common/Button";
|
||||
import Avatar from "../common/Avatar";
|
||||
|
||||
interface SignUpScreenProps {
|
||||
onComplete: (displayName: string, bio: string) => void;
|
||||
}
|
||||
|
||||
const SignUpScreen: Component<SignUpScreenProps> = (props) => {
|
||||
const [displayName, setDisplayName] = createSignal("");
|
||||
const [bio, setBio] = createSignal("");
|
||||
const [step, setStep] = createSignal<"welcome" | "profile">("welcome");
|
||||
const [isCreating, setIsCreating] = createSignal(false);
|
||||
|
||||
function handleBegin() {
|
||||
setStep("profile");
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
const name = displayName().trim();
|
||||
if (!name) return;
|
||||
|
||||
setIsCreating(true);
|
||||
props.onComplete(name, bio().trim());
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (step() === "welcome") {
|
||||
handleBegin();
|
||||
} else if (displayName().trim()) {
|
||||
handleCreate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class="h-screen w-screen bg-black flex items-center justify-center overflow-hidden"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
<Show
|
||||
when={step() === "profile"}
|
||||
fallback={
|
||||
// welcome screen
|
||||
<div class="max-w-[520px] w-full mx-4 animate-fade-in">
|
||||
<div class="mb-12">
|
||||
<h1 class="text-[48px] leading-[56px] font-bold text-white tracking-[-0.02em] mb-4">
|
||||
dusk
|
||||
</h1>
|
||||
<p class="text-[20px] leading-[28px] text-white/60">
|
||||
peer-to-peer communication. no servers, no surveillance, no
|
||||
compromise.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-6 mb-12">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 shrink-0 flex items-center justify-center border-2 border-white/20">
|
||||
<Key size={18} class="text-orange" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[16px] font-medium text-white mb-1">
|
||||
keypair identity
|
||||
</p>
|
||||
<p class="text-[14px] text-white/40">
|
||||
your identity is a cryptographic keypair generated on your
|
||||
device. no email, no phone number, no corporate account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 shrink-0 flex items-center justify-center border-2 border-white/20">
|
||||
<Shield size={18} class="text-orange" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[16px] font-medium text-white mb-1">
|
||||
your data, your hardware
|
||||
</p>
|
||||
<p class="text-[14px] text-white/40">
|
||||
everything is stored locally and synced directly between
|
||||
peers. no central server ever touches your messages.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="w-10 h-10 shrink-0 flex items-center justify-center border-2 border-white/20">
|
||||
<User size={18} class="text-orange" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[16px] font-medium text-white mb-1">
|
||||
portable identity
|
||||
</p>
|
||||
<p class="text-[14px] text-white/40">
|
||||
take your identity anywhere. your keypair is yours forever
|
||||
and works across any device running dusk.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" fullWidth onClick={handleBegin}>
|
||||
<span class="flex items-center gap-2">
|
||||
get started
|
||||
<ArrowRight size={16} />
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* profile creation screen */}
|
||||
<div class="max-w-[480px] w-full mx-4 animate-fade-in">
|
||||
<h2 class="text-[32px] leading-[40px] font-bold text-white tracking-[-0.02em] mb-2">
|
||||
create your identity
|
||||
</h2>
|
||||
<p class="text-[16px] text-white/40 mb-8">
|
||||
choose a display name for the network. you can change this later.
|
||||
</p>
|
||||
|
||||
{/* live preview */}
|
||||
<div class="flex items-center gap-4 p-4 border-2 border-white/10 mb-8">
|
||||
<Avatar
|
||||
name={displayName() || "?"}
|
||||
size="xl"
|
||||
status="Online"
|
||||
showStatus
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<p class="text-[20px] font-bold text-white truncate">
|
||||
{displayName() || "your name"}
|
||||
</p>
|
||||
<Show when={bio()}>
|
||||
<p class="text-[14px] text-white/40 truncate mt-1">{bio()}</p>
|
||||
</Show>
|
||||
<p class="text-[12px] font-mono text-white/20 mt-1">
|
||||
peer id will be generated
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-4 mb-8">
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
display name *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
|
||||
placeholder="what should people call you?"
|
||||
value={displayName()}
|
||||
onInput={(e) => setDisplayName(e.currentTarget.value)}
|
||||
maxLength={32}
|
||||
autofocus
|
||||
/>
|
||||
<p class="text-[12px] font-mono text-white/20 mt-1">
|
||||
{displayName().length}/32
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
bio (optional)
|
||||
</label>
|
||||
<textarea
|
||||
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200 resize-none"
|
||||
placeholder="tell peers a bit about yourself"
|
||||
value={bio()}
|
||||
onInput={(e) => setBio(e.currentTarget.value)}
|
||||
maxLength={160}
|
||||
rows={3}
|
||||
/>
|
||||
<p class="text-[12px] font-mono text-white/20 mt-1">
|
||||
{bio().length}/160
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
fullWidth
|
||||
onClick={handleCreate}
|
||||
disabled={!displayName().trim() || isCreating()}
|
||||
>
|
||||
{isCreating() ? "generating keypair..." : "create identity"}
|
||||
</Button>
|
||||
|
||||
<p class="text-[12px] font-mono text-white/20 text-center mt-4">
|
||||
an ed25519 keypair will be generated and stored locally on your
|
||||
device
|
||||
</p>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
{/* subtle branding at bottom */}
|
||||
<div class="fixed bottom-6 left-0 right-0 text-center">
|
||||
<p class="text-[11px] font-mono text-white/10 uppercase tracking-[0.1em]">
|
||||
dusk protocol v0.1.0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUpScreen;
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show, createSignal } from "solid-js";
|
||||
import type { ChatMessage } from "../../lib/types";
|
||||
import { formatTime, formatTimeShort } from "../../lib/utils";
|
||||
import { removeMessage } from "../../stores/messages";
|
||||
import { activeCommunityId } from "../../stores/communities";
|
||||
import { identity } from "../../stores/identity";
|
||||
import Avatar from "../common/Avatar";
|
||||
import * as tauri from "../../lib/tauri";
|
||||
|
||||
interface MessageProps {
|
||||
message: ChatMessage;
|
||||
isGrouped: boolean;
|
||||
isFirstInGroup: boolean;
|
||||
}
|
||||
|
||||
const Message: Component<MessageProps> = (props) => {
|
||||
const [contextMenu, setContextMenu] = createSignal<{ x: number; y: number } | null>(null);
|
||||
|
||||
const currentUser = () => identity();
|
||||
const currentCommunityId = () => activeCommunityId();
|
||||
|
||||
const isOwner = () => {
|
||||
const user = currentUser();
|
||||
return user?.peer_id === props.message.author_id;
|
||||
};
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
setContextMenu({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
setContextMenu(null);
|
||||
}
|
||||
|
||||
async function handleDeleteMessage() {
|
||||
const communityId = currentCommunityId();
|
||||
if (!communityId || !isOwner()) return;
|
||||
|
||||
try {
|
||||
await tauri.deleteMessage(communityId, props.message.id);
|
||||
removeMessage(props.message.id);
|
||||
} catch (e) {
|
||||
console.error("failed to delete message:", e);
|
||||
}
|
||||
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
// close context menu on click outside
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("click", closeContextMenu);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={`flex gap-4 hover:bg-gray-900 transition-colors duration-200 ${
|
||||
props.isFirstInGroup ? "pt-4 px-4 pb-1" : "px-4 py-0.5"
|
||||
}`}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<Show
|
||||
when={props.isFirstInGroup}
|
||||
fallback={
|
||||
<div class="w-10 shrink-0 flex items-start justify-center">
|
||||
<span class="text-[11px] font-mono text-white/0 hover:text-white/40 transition-colors duration-200 leading-[22px]">
|
||||
{formatTimeShort(props.message.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="w-10 shrink-0 pt-0.5">
|
||||
<Avatar name={props.message.author_name} size="md" />
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<Show when={props.isFirstInGroup}>
|
||||
<div class="flex items-baseline gap-2 mb-0.5">
|
||||
<span class="text-[16px] font-medium text-white">
|
||||
{props.message.author_name}
|
||||
</span>
|
||||
<span class="text-[12px] font-mono text-white/50">
|
||||
{formatTime(props.message.timestamp)}
|
||||
</span>
|
||||
<Show when={props.message.edited}>
|
||||
<span class="text-[11px] font-mono text-white/30">(edited)</span>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<p class="text-[16px] leading-[22px] text-white/90 break-words whitespace-pre-wrap m-0">
|
||||
{props.message.content}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* context menu */}
|
||||
<Show when={contextMenu()}>
|
||||
{(menu) => (
|
||||
<div
|
||||
class="fixed bg-gray-800 border border-white/20 py-1 z-[2000] min-w-[120px]"
|
||||
style={{ left: `${menu().x}px`, top: `${menu().y}px` }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="px-3 py-1.5 text-[12px] text-white/60 border-b border-white/10">
|
||||
message actions
|
||||
</div>
|
||||
<Show when={isOwner()}>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 text-[13px] text-left text-red-400 hover:bg-gray-700 transition-colors duration-200 cursor-pointer"
|
||||
onClick={handleDeleteMessage}
|
||||
>
|
||||
delete message
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={!isOwner()}>
|
||||
<div class="px-3 py-1.5 text-[12px] text-white/30">
|
||||
no actions available
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Message;
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { createSignal } from "solid-js";
|
||||
import { SendHorizontal } from "lucide-solid";
|
||||
|
||||
interface MessageInputProps {
|
||||
channelName: string;
|
||||
onSend: (content: string) => void;
|
||||
onTyping?: () => void;
|
||||
}
|
||||
|
||||
const MessageInput: Component<MessageInputProps> = (props) => {
|
||||
const [value, setValue] = createSignal("");
|
||||
let textareaRef: HTMLTextAreaElement | undefined;
|
||||
|
||||
function handleSubmit() {
|
||||
const content = value().trim();
|
||||
if (!content) return;
|
||||
props.onSend(content);
|
||||
setValue("");
|
||||
// reset textarea height
|
||||
if (textareaRef) {
|
||||
textareaRef.style.height = "auto";
|
||||
}
|
||||
}
|
||||
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(e: InputEvent) {
|
||||
const target = e.target as HTMLTextAreaElement;
|
||||
setValue(target.value);
|
||||
|
||||
// auto-resize textarea
|
||||
target.style.height = "auto";
|
||||
target.style.height = Math.min(target.scrollHeight, 200) + "px";
|
||||
|
||||
// fire typing indicator (debounced by the store)
|
||||
props.onTyping?.();
|
||||
}
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageInput;
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { For, Show, createEffect, createSignal, onMount } from "solid-js";
|
||||
import type { ChatMessage } from "../../lib/types";
|
||||
import { isWithinGroupWindow, isDifferentDay, formatDaySeparator } from "../../lib/utils";
|
||||
import Message from "./Message";
|
||||
import { ArrowDown } from "lucide-solid";
|
||||
|
||||
interface MessageListProps {
|
||||
messages: ChatMessage[];
|
||||
onLoadMore?: () => void;
|
||||
}
|
||||
|
||||
const MessageList: Component<MessageListProps> = (props) => {
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
const [showScrollButton, setShowScrollButton] = createSignal(false);
|
||||
const [isAtBottom, setIsAtBottom] = createSignal(true);
|
||||
|
||||
function scrollToBottom(smooth = true) {
|
||||
if (containerRef) {
|
||||
containerRef.scrollTo({
|
||||
top: containerRef.scrollHeight,
|
||||
behavior: smooth ? "smooth" : "instant",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleScroll() {
|
||||
if (!containerRef) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef;
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
const atBottom = distanceFromBottom < 50;
|
||||
setIsAtBottom(atBottom);
|
||||
setShowScrollButton(!atBottom);
|
||||
}
|
||||
|
||||
// auto-scroll when new messages arrive if user is at the bottom
|
||||
createEffect(() => {
|
||||
const _ = props.messages.length;
|
||||
if (isAtBottom()) {
|
||||
// defer to allow dom update
|
||||
requestAnimationFrame(() => scrollToBottom(true));
|
||||
}
|
||||
});
|
||||
|
||||
// scroll to bottom on mount
|
||||
onMount(() => {
|
||||
requestAnimationFrame(() => scrollToBottom(false));
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="relative flex-1 min-h-0">
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="h-full overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
<div class="flex flex-col py-4">
|
||||
<For each={props.messages}>
|
||||
{(message, index) => {
|
||||
const prev = () =>
|
||||
index() > 0 ? props.messages[index() - 1] : undefined;
|
||||
const isFirstInGroup = () => {
|
||||
const p = prev();
|
||||
if (!p) return true;
|
||||
if (p.author_id !== message.author_id) return true;
|
||||
if (!isWithinGroupWindow(p.timestamp, message.timestamp))
|
||||
return true;
|
||||
return false;
|
||||
};
|
||||
const isGrouped = () => !isFirstInGroup();
|
||||
const showDaySeparator = () => {
|
||||
const p = prev();
|
||||
if (!p) return true;
|
||||
return isDifferentDay(p.timestamp, message.timestamp);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Show when={showDaySeparator()}>
|
||||
<div class="flex items-center gap-4 px-4 py-2 my-2">
|
||||
<div class="flex-1 border-t border-white/10" />
|
||||
<span class="text-[12px] font-mono text-white/40 uppercase tracking-[0.05em]">
|
||||
{formatDaySeparator(message.timestamp)}
|
||||
</span>
|
||||
<div class="flex-1 border-t border-white/10" />
|
||||
</div>
|
||||
</Show>
|
||||
<div class="animate-message-in">
|
||||
<Message
|
||||
message={message}
|
||||
isGrouped={isGrouped()}
|
||||
isFirstInGroup={isFirstInGroup()}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</For>
|
||||
|
||||
<Show when={props.messages.length === 0}>
|
||||
<div class="flex flex-col items-center justify-center h-full py-16 text-white/40">
|
||||
<p class="text-[20px] font-medium">no messages yet</p>
|
||||
<p class="text-[14px] mt-2">be the first to say something</p>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={showScrollButton()}>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute bottom-4 right-4 w-10 h-10 bg-orange rounded-full flex items-center justify-center text-white shadow-lg hover:bg-orange-hover transition-all duration-200 cursor-pointer animate-scale-in"
|
||||
onClick={() => scrollToBottom(true)}
|
||||
>
|
||||
<ArrowDown size={20} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessageList;
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show } from "solid-js";
|
||||
|
||||
interface TypingIndicatorProps {
|
||||
typingUsers: string[];
|
||||
}
|
||||
|
||||
const TypingIndicator: Component<TypingIndicatorProps> = (props) => {
|
||||
const text = () => {
|
||||
const users = props.typingUsers;
|
||||
if (users.length === 0) return "";
|
||||
if (users.length === 1) return `${users[0]} is typing`;
|
||||
if (users.length === 2) return `${users[0]} and ${users[1]} are typing`;
|
||||
return "several people are typing";
|
||||
};
|
||||
|
||||
return (
|
||||
<Show when={props.typingUsers.length > 0}>
|
||||
<div class="flex items-center gap-2 px-4 py-1.5 text-[12px] font-mono text-white/50">
|
||||
<div class="flex gap-1">
|
||||
<div class="w-1 h-1 rounded-full bg-orange typing-dot" />
|
||||
<div class="w-1 h-1 rounded-full bg-orange typing-dot" />
|
||||
<div class="w-1 h-1 rounded-full bg-orange typing-dot" />
|
||||
</div>
|
||||
<span>{text()}</span>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default TypingIndicator;
|
||||
|
|
@ -0,0 +1,296 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show, createMemo } from "solid-js";
|
||||
|
||||
// deterministic hash ported from facehash
|
||||
function stringHash(str: string): number {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const char = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + char;
|
||||
hash &= hash;
|
||||
}
|
||||
return Math.abs(hash);
|
||||
}
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string;
|
||||
name: string;
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
status?:
|
||||
| "Online"
|
||||
| "Idle"
|
||||
| "Offline"
|
||||
| "online"
|
||||
| "idle"
|
||||
| "dnd"
|
||||
| "invisible";
|
||||
showStatus?: boolean;
|
||||
}
|
||||
|
||||
const sizeMap = {
|
||||
sm: 32,
|
||||
md: 40,
|
||||
lg: 48,
|
||||
xl: 64,
|
||||
};
|
||||
|
||||
const statusColorMap: Record<string, string> = {
|
||||
Online: "bg-success",
|
||||
online: "bg-success",
|
||||
Idle: "bg-warning",
|
||||
idle: "bg-warning",
|
||||
dnd: "bg-error",
|
||||
invisible: "bg-gray-500",
|
||||
Offline: "bg-gray-300",
|
||||
offline: "bg-gray-300",
|
||||
};
|
||||
|
||||
// deterministic color palette that facehash uses internally
|
||||
const COLORS = [
|
||||
"#f43f5e",
|
||||
"#ec4899",
|
||||
"#d946ef",
|
||||
"#a855f7",
|
||||
"#8b5cf6",
|
||||
"#6366f1",
|
||||
"#3b82f6",
|
||||
"#0ea5e9",
|
||||
"#06b6d4",
|
||||
"#14b8a6",
|
||||
"#10b981",
|
||||
"#22c55e",
|
||||
"#84cc16",
|
||||
"#eab308",
|
||||
"#f59e0b",
|
||||
"#f97316",
|
||||
"#ef4444",
|
||||
];
|
||||
|
||||
// sphere positions for 3d rotation (ported from facehash)
|
||||
const SPHERE_POSITIONS = [
|
||||
{ x: -1, y: 1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 0, y: 1 },
|
||||
{ x: -1, y: 0 },
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 0, y: -1 },
|
||||
{ x: -1, y: -1 },
|
||||
{ x: 1, y: -1 },
|
||||
];
|
||||
|
||||
const ROTATE_RANGE = 15;
|
||||
const TRANSLATE_Z = 12;
|
||||
|
||||
// face svgs ported from facehash - each is a pure svg path set
|
||||
function RoundEyes() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
viewBox="0 0 63 15"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
width: "60%",
|
||||
height: "auto",
|
||||
"max-width": "90%",
|
||||
"max-height": "40%",
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M62.4 7.2C62.4 11.1765 59.1765 14.4 55.2 14.4C51.2236 14.4 48 11.1765 48 7.2C48 3.22355 51.2236 0 55.2 0C59.1765 0 62.4 3.22355 62.4 7.2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M14.4 7.2C14.4 11.1765 11.1765 14.4 7.2 14.4C3.22355 14.4 0 11.1765 0 7.2C0 3.22355 3.22355 0 7.2 0C11.1765 0 14.4 3.22355 14.4 7.2Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CrossEyes() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
viewBox="0 0 71 23"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
width: "60%",
|
||||
height: "auto",
|
||||
"max-width": "90%",
|
||||
"max-height": "40%",
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M11.5 0C12.9411 0 13.6619 0.000460386 14.1748 0.354492C14.3742 0.49213 14.547 0.664882 14.6846 0.864258C15.0384 1.37711 15.0391 2.09739 15.0391 3.53809V7.96094H19.4619C20.9027 7.96094 21.6229 7.9615 22.1357 8.31543C22.3352 8.45308 22.5079 8.62578 22.6455 8.8252C22.9995 9.3381 23 10.0589 23 11.5C23 12.9408 22.9995 13.661 22.6455 14.1738C22.5079 14.3733 22.3352 14.5459 22.1357 14.6836C21.6229 15.0375 20.9027 15.0381 19.4619 15.0381H15.0391V19.4619C15.0391 20.9026 15.0384 21.6229 14.6846 22.1357C14.547 22.3351 14.3742 22.5079 14.1748 22.6455C13.6619 22.9995 12.9411 23 11.5 23C10.0592 23 9.33903 22.9994 8.82617 22.6455C8.62674 22.5079 8.45309 22.3352 8.31543 22.1357C7.96175 21.6229 7.96191 20.9024 7.96191 19.4619V15.0381H3.53809C2.0973 15.0381 1.37711 15.0375 0.864258 14.6836C0.664834 14.5459 0.492147 14.3733 0.354492 14.1738C0.000498831 13.661 -5.88036e-08 12.9408 0 11.5C6.2999e-08 10.0589 0.000460356 9.3381 0.354492 8.8252C0.492144 8.62578 0.664842 8.45308 0.864258 8.31543C1.37711 7.9615 2.09731 7.96094 3.53809 7.96094H7.96191V3.53809C7.96191 2.09765 7.96175 1.37709 8.31543 0.864258C8.45309 0.664828 8.62674 0.492149 8.82617 0.354492C9.33903 0.000555366 10.0592 1.62347e-09 11.5 0Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M58.7695 0C60.2107 0 60.9314 0.000460386 61.4443 0.354492C61.6437 0.49213 61.8165 0.664882 61.9541 0.864258C62.308 1.37711 62.3086 2.09739 62.3086 3.53809V7.96094H66.7314C68.1722 7.96094 68.8924 7.9615 69.4053 8.31543C69.6047 8.45308 69.7774 8.62578 69.915 8.8252C70.2691 9.3381 70.2695 10.0589 70.2695 11.5C70.2695 12.9408 70.269 13.661 69.915 14.1738C69.7774 14.3733 69.6047 14.5459 69.4053 14.6836C68.8924 15.0375 68.1722 15.0381 66.7314 15.0381H62.3086V19.4619C62.3086 20.9026 62.308 21.6229 61.9541 22.1357C61.8165 22.3351 61.6437 22.5079 61.4443 22.6455C60.9314 22.9995 60.2107 23 58.7695 23C57.3287 23 56.6086 22.9994 56.0957 22.6455C55.8963 22.5079 55.7226 22.3352 55.585 22.1357C55.2313 21.6229 55.2314 20.9024 55.2314 19.4619V15.0381H50.8076C49.3668 15.0381 48.6466 15.0375 48.1338 14.6836C47.9344 14.5459 47.7617 14.3733 47.624 14.1738C47.27 13.661 47.2695 12.9408 47.2695 11.5C47.2695 10.0589 47.27 9.3381 47.624 8.8252C47.7617 8.62578 47.9344 8.45308 48.1338 8.31543C48.6466 7.9615 49.3668 7.96094 50.8076 7.96094H55.2314V3.53809C55.2314 2.09765 55.2313 1.37709 55.585 0.864258C55.7226 0.664828 55.8963 0.492149 56.0957 0.354492C56.6086 0.000555366 57.3287 1.62347e-09 58.7695 0Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LineEyes() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
viewBox="0 0 82 8"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
width: "60%",
|
||||
height: "auto",
|
||||
"max-width": "90%",
|
||||
"max-height": "40%",
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M3.53125 0.164063C4.90133 0.164063 5.58673 0.163893 6.08301 0.485352C6.31917 0.638428 6.52075 0.840012 6.67383 1.07617C6.99555 1.57252 6.99512 2.25826 6.99512 3.62891C6.99512 4.99911 6.99536 5.68438 6.67383 6.18066C6.52075 6.41682 6.31917 6.61841 6.08301 6.77148C5.58672 7.09305 4.90147 7.09277 3.53125 7.09277C2.16062 7.09277 1.47486 7.09319 0.978516 6.77148C0.742356 6.61841 0.540772 6.41682 0.387695 6.18066C0.0662401 5.68439 0.0664063 4.999 0.0664063 3.62891C0.0664063 2.25838 0.0660571 1.57251 0.387695 1.07617C0.540772 0.840012 0.742356 0.638428 0.978516 0.485352C1.47485 0.163744 2.16076 0.164063 3.53125 0.164063Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M25.1836 0.164063C26.5542 0.164063 27.24 0.163638 27.7363 0.485352C27.9724 0.638384 28.1731 0.8401 28.3262 1.07617C28.6479 1.57252 28.6484 2.25825 28.6484 3.62891C28.6484 4.99931 28.6478 5.68436 28.3262 6.18066C28.1731 6.41678 27.9724 6.61842 27.7363 6.77148C27.24 7.09321 26.5542 7.09277 25.1836 7.09277H11.3262C9.95557 7.09277 9.26978 7.09317 8.77344 6.77148C8.53728 6.61841 8.33569 6.41682 8.18262 6.18066C7.86115 5.68438 7.86133 4.99902 7.86133 3.62891C7.86133 2.25835 7.86096 1.57251 8.18262 1.07617C8.33569 0.840012 8.53728 0.638428 8.77344 0.485352C9.26977 0.163768 9.95572 0.164063 11.3262 0.164063H25.1836Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M78.2034 7.09325C76.8333 7.09325 76.1479 7.09342 75.6516 6.77197C75.4155 6.61889 75.2139 6.4173 75.0608 6.18114C74.7391 5.6848 74.7395 4.99905 74.7395 3.62841C74.7395 2.2582 74.7393 1.57294 75.0608 1.07665C75.2139 0.840493 75.4155 0.638909 75.6516 0.485832C76.1479 0.164271 76.8332 0.164543 78.2034 0.164543C79.574 0.164543 80.2598 0.164122 80.7561 0.485832C80.9923 0.638909 81.1939 0.840493 81.347 1.07665C81.6684 1.57293 81.6682 2.25831 81.6682 3.62841C81.6682 4.99894 81.6686 5.68481 81.347 6.18114C81.1939 6.4173 80.9923 6.61889 80.7561 6.77197C80.2598 7.09357 79.5739 7.09325 78.2034 7.09325Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M56.5511 7.09325C55.1804 7.09325 54.4947 7.09368 53.9983 6.77197C53.7622 6.61893 53.5615 6.41722 53.4085 6.18114C53.0868 5.6848 53.0862 4.99907 53.0862 3.62841C53.0862 2.258 53.0868 1.57296 53.4085 1.07665C53.5615 0.840539 53.7622 0.638898 53.9983 0.485832C54.4947 0.164105 55.1804 0.164543 56.5511 0.164543H70.4085C71.7791 0.164543 72.4649 0.164146 72.9612 0.485832C73.1974 0.638909 73.399 0.840493 73.552 1.07665C73.8735 1.57293 73.8733 2.25829 73.8733 3.62841C73.8733 4.99896 73.8737 5.68481 73.552 6.18114C73.399 6.4173 73.1974 6.61889 72.9612 6.77197C72.4649 7.09355 71.7789 7.09325 70.4085 7.09325H56.5511Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CurvedEyes() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="none"
|
||||
viewBox="0 0 63 9"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
style={{
|
||||
width: "60%",
|
||||
height: "auto",
|
||||
"max-width": "90%",
|
||||
"max-height": "40%",
|
||||
}}
|
||||
>
|
||||
<path
|
||||
d="M0 5.06511C0 4.94513 0 4.88513 0.00771184 4.79757C0.0483059 4.33665 0.341025 3.76395 0.690821 3.46107C0.757274 3.40353 0.783996 3.38422 0.837439 3.34559C2.40699 2.21129 6.03888 0 10.5 0C14.9611 0 18.593 2.21129 20.1626 3.34559C20.216 3.38422 20.2427 3.40353 20.3092 3.46107C20.659 3.76395 20.9517 4.33665 20.9923 4.79757C21 4.88513 21 4.94513 21 5.06511C21 6.01683 21 6.4927 20.9657 6.6754C20.7241 7.96423 19.8033 8.55941 18.5289 8.25054C18.3483 8.20676 17.8198 7.96876 16.7627 7.49275C14.975 6.68767 12.7805 6 10.5 6C8.21954 6 6.02504 6.68767 4.23727 7.49275C3.18025 7.96876 2.65174 8.20676 2.47108 8.25054C1.19668 8.55941 0.275917 7.96423 0.0342566 6.6754C0 6.4927 0 6.01683 0 5.06511Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M42 5.06511C42 4.94513 42 4.88513 42.0077 4.79757C42.0483 4.33665 42.341 3.76395 42.6908 3.46107C42.7573 3.40353 42.784 3.38422 42.8374 3.34559C44.407 2.21129 48.0389 0 52.5 0C56.9611 0 60.593 2.21129 62.1626 3.34559C62.216 3.38422 62.2427 3.40353 62.3092 3.46107C62.659 3.76395 62.9517 4.33665 62.9923 4.79757C63 4.88513 63 4.94513 63 5.06511C63 6.01683 63 6.4927 62.9657 6.6754C62.7241 7.96423 61.8033 8.55941 60.5289 8.25054C60.3483 8.20676 59.8198 7.96876 58.7627 7.49275C56.975 6.68767 54.7805 6 52.5 6C50.2195 6 48.025 6.68767 46.2373 7.49275C45.1802 7.96876 44.6517 8.20676 44.4711 8.25054C43.1967 8.55941 42.2759 7.96423 42.0343 6.6754C42 6.4927 42 6.01683 42 5.06511Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const FACES: Component[] = [RoundEyes, CrossEyes, LineEyes, CurvedEyes];
|
||||
|
||||
const Avatar: Component<AvatarProps> = (props) => {
|
||||
const size = () => props.size ?? "md";
|
||||
const px = () => sizeMap[size()];
|
||||
|
||||
// deterministic face properties derived from name
|
||||
const faceData = createMemo(() => {
|
||||
const hash = stringHash(props.name);
|
||||
const faceIndex = hash % FACES.length;
|
||||
const colorIndex = hash % COLORS.length;
|
||||
const position = SPHERE_POSITIONS[hash % SPHERE_POSITIONS.length];
|
||||
const rotation = position ?? { x: 0, y: 0 };
|
||||
|
||||
return {
|
||||
FaceComponent: FACES[faceIndex],
|
||||
bgColor: COLORS[colorIndex],
|
||||
transform: `rotateX(${rotation.x * ROTATE_RANGE}deg) rotateY(${rotation.y * ROTATE_RANGE}deg) translateZ(${TRANSLATE_Z}px)`,
|
||||
initial: props.name.charAt(0).toUpperCase(),
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
class="relative inline-flex shrink-0"
|
||||
style={{ width: `${px()}px`, height: `${px()}px` }}
|
||||
>
|
||||
<Show
|
||||
when={props.src}
|
||||
fallback={
|
||||
<div
|
||||
class="w-full h-full rounded-full overflow-hidden text-white"
|
||||
style={{
|
||||
position: "relative",
|
||||
"background-color": faceData().bgColor,
|
||||
perspective: "300px",
|
||||
"transform-style": "preserve-3d",
|
||||
"container-type": "size",
|
||||
}}
|
||||
>
|
||||
{/* gradient overlay */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: "0",
|
||||
"pointer-events": "none",
|
||||
"z-index": "1",
|
||||
background:
|
||||
"radial-gradient(ellipse 100% 100% at 50% 50%, rgba(255,255,255,0.15) 0%, transparent 60%)",
|
||||
}}
|
||||
/>
|
||||
{/* face content */}
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
inset: "0",
|
||||
display: "flex",
|
||||
"flex-direction": "column",
|
||||
"align-items": "center",
|
||||
"justify-content": "center",
|
||||
"z-index": "2",
|
||||
transform: faceData().transform,
|
||||
"transform-style": "preserve-3d",
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const Face = faceData().FaceComponent;
|
||||
return <Face />;
|
||||
})()}
|
||||
<span
|
||||
style={{
|
||||
"margin-top": "8%",
|
||||
"font-size": "26cqw",
|
||||
"line-height": "1",
|
||||
}}
|
||||
>
|
||||
{faceData().initial}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<img
|
||||
src={props.src}
|
||||
alt={props.name}
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showStatus && props.status}>
|
||||
<div
|
||||
class={`absolute bottom-0 right-0 w-2 h-2 border-2 border-black ${statusColorMap[props.status!]}`}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Avatar;
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show } from "solid-js";
|
||||
|
||||
interface BadgeProps {
|
||||
count: number;
|
||||
pulse?: boolean;
|
||||
}
|
||||
|
||||
const Badge: Component<BadgeProps> = (props) => {
|
||||
return (
|
||||
<Show when={props.count > 0}>
|
||||
<div
|
||||
class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 bg-orange text-white text-[11px] font-medium rounded-full animate-pop-in"
|
||||
style={
|
||||
props.pulse
|
||||
? { animation: "pop-in 300ms ease-out, badge-pulse 3s ease-in-out 300ms infinite" }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{props.count > 99 ? "99+" : props.count}
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default Badge;
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import type { JSX, Component } from "solid-js";
|
||||
|
||||
interface ButtonProps {
|
||||
variant?: "primary" | "secondary" | "ghost";
|
||||
size?: "sm" | "md" | "lg";
|
||||
disabled?: boolean;
|
||||
fullWidth?: boolean;
|
||||
children: JSX.Element;
|
||||
onClick?: () => void;
|
||||
class?: string;
|
||||
type?: "button" | "submit";
|
||||
}
|
||||
|
||||
const Button: Component<ButtonProps> = (props) => {
|
||||
const variant = () => props.variant ?? "primary";
|
||||
const size = () => props.size ?? "md";
|
||||
|
||||
const baseStyles =
|
||||
"inline-flex items-center justify-center font-medium uppercase tracking-[0.05em] transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] cursor-pointer select-none";
|
||||
|
||||
const variantStyles = () => {
|
||||
switch (variant()) {
|
||||
case "primary":
|
||||
return "bg-orange text-white border-none hover:bg-orange-hover hover:scale-[0.98] active:scale-[0.96]";
|
||||
case "secondary":
|
||||
return "bg-transparent border-2 border-white text-white hover:bg-white hover:text-black";
|
||||
case "ghost":
|
||||
return "bg-transparent border-none text-white/60 hover:text-white";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const sizeStyles = () => {
|
||||
switch (size()) {
|
||||
case "sm":
|
||||
return "h-8 px-4 text-[12px]";
|
||||
case "md":
|
||||
return "h-12 px-6 text-[14px]";
|
||||
case "lg":
|
||||
return "h-14 px-8 text-[16px]";
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type={props.type ?? "button"}
|
||||
class={`${baseStyles} ${variantStyles()} ${sizeStyles()} ${props.fullWidth ? "w-full" : ""} ${props.disabled ? "opacity-40 pointer-events-none" : ""} ${props.class ?? ""}`}
|
||||
onClick={props.onClick}
|
||||
disabled={props.disabled}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import type { Component, JSX } from "solid-js";
|
||||
|
||||
interface CardProps {
|
||||
children: JSX.Element;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const Card: Component<CardProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
class={`bg-gray-900 border-2 border-white/20 p-8 hover:border-white/40 transition-colors duration-200 ${props.class ?? ""}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
import type { Component } from "solid-js";
|
||||
|
||||
const Divider: Component<{ class?: string }> = (props) => {
|
||||
return (
|
||||
<div
|
||||
class={`w-full border-t border-white/10 ${props.class ?? ""}`}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default Divider;
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { Component, JSX } from "solid-js";
|
||||
|
||||
interface IconButtonProps {
|
||||
children: JSX.Element;
|
||||
label: string;
|
||||
size?: number;
|
||||
onClick?: () => void;
|
||||
active?: boolean;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
const IconButton: Component<IconButtonProps> = (props) => {
|
||||
const px = () => props.size ?? 40;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={props.label}
|
||||
class={`inline-flex items-center justify-center shrink-0 transition-all duration-200 ease-[cubic-bezier(0.4,0,0.2,1)] cursor-pointer ${
|
||||
props.active
|
||||
? "bg-orange text-white"
|
||||
: "bg-gray-800 text-white/60 hover:bg-orange hover:text-white"
|
||||
} ${props.class ?? ""}`}
|
||||
style={{ width: `${px()}px`, height: `${px()}px` }}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconButton;
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
import type { Component, JSX } from "solid-js";
|
||||
import { Show, onMount, onCleanup } from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
import { X } from "lucide-solid";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: JSX.Element;
|
||||
}
|
||||
|
||||
const Modal: Component<ModalProps> = (props) => {
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") props.onClose();
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) props.onClose();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<Portal>
|
||||
<div
|
||||
class="fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 animate-fade-in"
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div class="bg-gray-900 border-2 border-white/20 p-8 w-full max-w-[480px] mx-4 animate-scale-in relative">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center text-white/60 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
<h2 class="text-[24px] leading-[32px] font-bold text-white mb-6">
|
||||
{props.title}
|
||||
</h2>
|
||||
|
||||
{props.children}
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import { createSignal, onMount, onCleanup, type JSX, type Component } from "solid-js";
|
||||
|
||||
interface ResizablePanelProps {
|
||||
width: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
side: "left" | "right";
|
||||
children: JSX.Element;
|
||||
onResize?: (width: number) => void;
|
||||
}
|
||||
|
||||
const ResizablePanel: Component<ResizablePanelProps> = (props) => {
|
||||
const [isResizing, setIsResizing] = createSignal(false);
|
||||
const [currentWidth, setCurrentWidth] = createSignal(props.width);
|
||||
let containerRef: HTMLDivElement | undefined;
|
||||
|
||||
const minWidth = props.minWidth ?? 150;
|
||||
const maxWidth = props.maxWidth ?? 500;
|
||||
|
||||
function handleMouseDown(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent) {
|
||||
if (!isResizing() || !containerRef) return;
|
||||
|
||||
const containerRect = containerRef.parentElement?.getBoundingClientRect();
|
||||
if (!containerRect) return;
|
||||
|
||||
let newWidth: number;
|
||||
|
||||
if (props.side === "left") {
|
||||
newWidth = e.clientX - containerRect.left;
|
||||
} else {
|
||||
newWidth = containerRect.right - e.clientX;
|
||||
}
|
||||
|
||||
// clamp the width between min and max
|
||||
newWidth = Math.max(minWidth, Math.min(maxWidth, newWidth));
|
||||
setCurrentWidth(newWidth);
|
||||
props.onResize?.(newWidth);
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
if (isResizing()) {
|
||||
setIsResizing(false);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
class="relative flex shrink-0"
|
||||
style={{ width: `${currentWidth()}px` }}
|
||||
>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* resize handle */}
|
||||
<div
|
||||
class={`absolute top-0 bottom-0 w-1 cursor-col-resize hover:bg-orange/50 transition-colors ${
|
||||
props.side === "left" ? "right-0" : "left-0"
|
||||
} ${isResizing() ? "bg-orange" : ""}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResizablePanel;
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show } from "solid-js";
|
||||
import { ChevronDown } from "lucide-solid";
|
||||
|
||||
interface SectionHeaderProps {
|
||||
label: string;
|
||||
collapsible?: boolean;
|
||||
collapsed?: boolean;
|
||||
onToggle?: () => void;
|
||||
}
|
||||
|
||||
const SectionHeader: Component<SectionHeaderProps> = (props) => {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 w-full px-2 py-1.5 text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 hover:text-white/80 transition-colors duration-200 cursor-pointer select-none"
|
||||
onClick={props.onToggle}
|
||||
>
|
||||
<Show when={props.collapsible}>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
class="transition-transform duration-300"
|
||||
style={{
|
||||
transform: props.collapsed ? "rotate(-90deg)" : "rotate(0deg)",
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
{props.label}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionHeader;
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import { type JSX, type Component, type ParentProps } from "solid-js";
|
||||
import UserFooter from "./UserFooter";
|
||||
|
||||
interface SidebarLayoutProps {
|
||||
header?: JSX.Element;
|
||||
footer?: JSX.Element;
|
||||
showFooter?: boolean;
|
||||
showFooterSettings?: boolean;
|
||||
onFooterSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
const SidebarLayout: Component<ParentProps<SidebarLayoutProps>> = (props) => {
|
||||
return (
|
||||
<div class="w-full h-full bg-gray-900 flex flex-col">
|
||||
{/* header */}
|
||||
{props.header && (
|
||||
<div class="shrink-0">
|
||||
{props.header}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* body */}
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{props.children}
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
{props.showFooter && (
|
||||
<UserFooter
|
||||
showSettings={props.showFooterSettings}
|
||||
onSettingsClick={props.onFooterSettingsClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SidebarLayout;
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import {
|
||||
Show,
|
||||
createSignal,
|
||||
onMount,
|
||||
onCleanup,
|
||||
type Component,
|
||||
For,
|
||||
} from "solid-js";
|
||||
import { Settings } from "lucide-solid";
|
||||
import { identity } from "../../stores/identity";
|
||||
import { settings, updateStatus } from "../../stores/settings";
|
||||
import type { UserStatus } from "../../lib/types";
|
||||
import Avatar from "../common/Avatar";
|
||||
|
||||
interface UserFooterProps {
|
||||
showSettings?: boolean;
|
||||
onSettingsClick?: () => void;
|
||||
}
|
||||
|
||||
const UserFooter: Component<UserFooterProps> = (props) => {
|
||||
const user = () => identity();
|
||||
const currentSettings = settings;
|
||||
const [isOpen, setIsOpen] = createSignal(false);
|
||||
let menuRef: HTMLDivElement | undefined;
|
||||
let triggerRef: HTMLDivElement | undefined;
|
||||
|
||||
const statusOptions: { label: string; value: UserStatus }[] = [
|
||||
{ label: "online", value: "online" },
|
||||
{ label: "idle", value: "idle" },
|
||||
{ label: "do not disturb", value: "dnd" },
|
||||
{ label: "invisible", value: "invisible" },
|
||||
];
|
||||
|
||||
function toggleMenu() {
|
||||
setIsOpen(!isOpen());
|
||||
}
|
||||
|
||||
function handleStatusChange(status: UserStatus) {
|
||||
updateStatus(status);
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
// click outside handler
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (
|
||||
isOpen() &&
|
||||
menuRef &&
|
||||
!menuRef.contains(e.target as Node) &&
|
||||
triggerRef &&
|
||||
!triggerRef.contains(e.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("click", handleClickOutside);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("click", handleClickOutside);
|
||||
});
|
||||
|
||||
return (
|
||||
<div class="h-16 shrink-0 flex items-center gap-3 px-3 bg-black border-t border-white/10 relative">
|
||||
<Show when={user()}>
|
||||
<div
|
||||
ref={triggerRef}
|
||||
class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer hover:bg-white/5 p-2 -ml-2 transition-colors relative select-none"
|
||||
onClick={toggleMenu}
|
||||
>
|
||||
<Avatar
|
||||
name={user()!.display_name}
|
||||
size="sm"
|
||||
status={currentSettings().status}
|
||||
showStatus
|
||||
/>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[14px] font-medium text-white truncate">
|
||||
{user()!.display_name}
|
||||
</p>
|
||||
<p class="text-[11px] font-mono text-white/30 truncate">
|
||||
{currentSettings().status}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Show when={isOpen()}>
|
||||
<div
|
||||
ref={menuRef}
|
||||
class="absolute bottom-full left-3 mb-2 w-48 bg-black border border-white/10 shadow-xl overflow-hidden z-50 animate-fade-in"
|
||||
>
|
||||
<div class="p-1">
|
||||
<For each={statusOptions}>
|
||||
{(option) => (
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 text-[13px] text-white hover:bg-white/10 transition-colors flex items-center gap-2 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStatusChange(option.value);
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class={`w-2 h-2 ${
|
||||
option.value === "online"
|
||||
? "bg-success"
|
||||
: option.value === "idle"
|
||||
? "bg-warning"
|
||||
: option.value === "dnd"
|
||||
? "bg-error"
|
||||
: "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
{option.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={props.showSettings}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white/40 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||
onClick={props.onSettingsClick}
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserFooter;
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
import { Component, createSignal, createMemo, For, Show } from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
import {
|
||||
X,
|
||||
Search,
|
||||
UserPlus,
|
||||
UserMinus,
|
||||
Users,
|
||||
Copy,
|
||||
Check,
|
||||
} from "lucide-solid";
|
||||
import Avatar from "../common/Avatar";
|
||||
import Button from "../common/Button";
|
||||
import Divider from "../common/Divider";
|
||||
import {
|
||||
knownPeers,
|
||||
markAsFriend,
|
||||
unmarkAsFriend,
|
||||
} from "../../stores/directory";
|
||||
import { identity } from "../../stores/identity";
|
||||
import { setActiveDM } from "../../stores/dms";
|
||||
import { addDMConversation } from "../../stores/dms";
|
||||
import * as tauri from "../../lib/tauri";
|
||||
|
||||
interface UserDirectoryModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type DirectoryTab = "all" | "friends";
|
||||
|
||||
const UserDirectoryModal: Component<UserDirectoryModalProps> = (props) => {
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
const [activeTab, setActiveTab] = createSignal<DirectoryTab>("all");
|
||||
const [copiedId, setCopiedId] = createSignal<string | null>(null);
|
||||
|
||||
// filter out our own peer id from the directory
|
||||
const filteredPeers = createMemo(() => {
|
||||
const myId = identity()?.peer_id;
|
||||
const query = searchQuery().toLowerCase().trim();
|
||||
const tab = activeTab();
|
||||
|
||||
let peers = knownPeers();
|
||||
|
||||
if (tab === "friends") {
|
||||
peers = peers.filter((p) => p.is_friend);
|
||||
}
|
||||
|
||||
if (query) {
|
||||
peers = peers.filter(
|
||||
(p) =>
|
||||
p.display_name.toLowerCase().includes(query) ||
|
||||
p.peer_id.toLowerCase().includes(query),
|
||||
);
|
||||
} else {
|
||||
// if not searching, hide self from the list to avoid confusion
|
||||
peers = peers.filter((p) => p.peer_id !== myId);
|
||||
}
|
||||
|
||||
return peers;
|
||||
});
|
||||
|
||||
async function handleToggleFriend(peerId: string, currentlyFriend: boolean) {
|
||||
try {
|
||||
if (currentlyFriend) {
|
||||
await tauri.removeFriend(peerId);
|
||||
unmarkAsFriend(peerId);
|
||||
} else {
|
||||
await tauri.addFriend(peerId);
|
||||
markAsFriend(peerId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("failed to toggle friend status:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessagePeer(peerId: string, displayName: string) {
|
||||
// start a dm conversation with this peer
|
||||
addDMConversation({
|
||||
peer_id: peerId,
|
||||
display_name: displayName,
|
||||
status: "Online",
|
||||
unread_count: 0,
|
||||
});
|
||||
setActiveDM(peerId);
|
||||
props.onClose();
|
||||
}
|
||||
|
||||
function handleCopyPeerId(peerId: string) {
|
||||
navigator.clipboard.writeText(peerId);
|
||||
setCopiedId(peerId);
|
||||
setTimeout(() => setCopiedId(null), 2000);
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") props.onClose();
|
||||
}
|
||||
|
||||
function handleBackdropClick(e: MouseEvent) {
|
||||
if (e.target === e.currentTarget) props.onClose();
|
||||
}
|
||||
|
||||
const tabs: { id: DirectoryTab; label: string }[] = [
|
||||
{ id: "all", label: "all peers" },
|
||||
{ id: "friends", label: "friends" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<Portal>
|
||||
<div
|
||||
class="fixed inset-0 z-[1000] flex items-center justify-center bg-black/80 animate-fade-in"
|
||||
onClick={handleBackdropClick}
|
||||
onKeyDown={handleKeydown}
|
||||
>
|
||||
<div class="bg-gray-900 border-2 border-white/20 w-full max-w-[640px] max-h-[80vh] mx-4 flex flex-col animate-scale-in relative">
|
||||
{/* header */}
|
||||
<div class="shrink-0 p-6 pb-4">
|
||||
<button
|
||||
type="button"
|
||||
class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center text-white/60 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<Users size={24} class="text-orange" />
|
||||
<h2 class="text-[24px] leading-[32px] font-bold text-white">
|
||||
user directory
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* tabs */}
|
||||
<div class="flex items-center gap-1 mb-4">
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1.5 text-[14px] font-medium transition-all duration-200 cursor-pointer ${
|
||||
activeTab() === tab.id
|
||||
? "bg-gray-800 text-white"
|
||||
: "text-white/50 hover:text-white hover:bg-gray-800/50"
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
|
||||
{/* search */}
|
||||
<div class="relative">
|
||||
<Search
|
||||
size={16}
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-white/30"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-black text-white text-[14px] pl-10 pr-4 py-2.5 outline-none placeholder:text-white/30 border-2 border-white/10 focus:border-orange transition-colors duration-200"
|
||||
placeholder="search by name or peer id..."
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-6" />
|
||||
|
||||
{/* peer list */}
|
||||
<div class="flex-1 overflow-y-auto p-3">
|
||||
<Show
|
||||
when={filteredPeers().length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<Users size={48} class="text-white/10 mb-4" />
|
||||
<p class="text-[16px] text-white/30 mb-1">
|
||||
{searchQuery()
|
||||
? "no peers matching your search"
|
||||
: activeTab() === "friends"
|
||||
? "no friends added yet"
|
||||
: "no peers discovered yet"}
|
||||
</p>
|
||||
<p class="text-[14px] text-white/20">
|
||||
{activeTab() === "friends"
|
||||
? "add friends from the all peers tab"
|
||||
: "peers will appear as you join communities"}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filteredPeers()}>
|
||||
{(peer) => (
|
||||
<div class="flex items-center justify-between px-3 py-3 hover:bg-gray-800/50 transition-colors duration-200 group">
|
||||
<div class="flex items-center gap-3 min-w-0 flex-1">
|
||||
<Avatar name={peer.display_name} size="lg" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-[16px] font-medium text-white truncate">
|
||||
{peer.display_name}
|
||||
</p>
|
||||
<Show when={peer.is_friend}>
|
||||
<span class="text-[10px] font-mono uppercase tracking-[0.05em] text-orange px-1.5 py-0.5 border border-orange/30 shrink-0">
|
||||
friend
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={peer.bio}>
|
||||
<p class="text-[13px] text-white/40 truncate">
|
||||
{peer.bio}
|
||||
</p>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 text-[11px] font-mono text-white/20 hover:text-white/40 transition-colors cursor-pointer mt-0.5"
|
||||
onClick={() => handleCopyPeerId(peer.peer_id)}
|
||||
>
|
||||
{peer.peer_id.slice(0, 16)}...
|
||||
<Show
|
||||
when={copiedId() === peer.peer_id}
|
||||
fallback={<Copy size={10} />}
|
||||
>
|
||||
<Check size={10} class="text-success" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center bg-gray-800 text-white/60 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||
title={
|
||||
peer.is_friend ? "remove friend" : "add friend"
|
||||
}
|
||||
onClick={() =>
|
||||
handleToggleFriend(peer.peer_id, peer.is_friend)
|
||||
}
|
||||
>
|
||||
<Show
|
||||
when={peer.is_friend}
|
||||
fallback={<UserPlus size={16} />}
|
||||
>
|
||||
<UserMinus size={16} />
|
||||
</Show>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleMessagePeer(peer.peer_id, peer.display_name)
|
||||
}
|
||||
>
|
||||
message
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div class="shrink-0 px-6 py-3 border-t border-white/10">
|
||||
<p class="text-[11px] font-mono text-white/20">
|
||||
{filteredPeers().length} peer
|
||||
{filteredPeers().length !== 1 ? "s" : ""} in directory
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserDirectoryModal;
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show } from "solid-js";
|
||||
import { Hash, Pin, Search, Users } from "lucide-solid";
|
||||
import ServerList from "./ServerList";
|
||||
import ChannelList from "./ChannelList";
|
||||
import ChatArea from "./ChatArea";
|
||||
import DMSidebar from "./DMSidebar";
|
||||
import HomeView from "./HomeView";
|
||||
import DMChatArea from "./DMChatArea";
|
||||
import UserSidebar from "./UserSidebar";
|
||||
import ResizablePanel from "../common/ResizablePanel";
|
||||
import IconButton from "../common/IconButton";
|
||||
import {
|
||||
sidebarVisible,
|
||||
channelListVisible,
|
||||
isMobile,
|
||||
toggleSidebar,
|
||||
} from "../../stores/ui";
|
||||
import { activeCommunityId } from "../../stores/communities";
|
||||
import { activeDMPeerId } from "../../stores/dms";
|
||||
import { activeChannel } from "../../stores/channels";
|
||||
import { sidebarWidth, updateSidebarWidth } from "../../stores/sidebar";
|
||||
|
||||
interface AppLayoutProps {
|
||||
onSendMessage: (content: string) => void;
|
||||
onTyping: () => void;
|
||||
onSendDM: (content: string) => void;
|
||||
}
|
||||
|
||||
const AppLayout: Component<AppLayoutProps> = (props) => {
|
||||
// whether home is active (no community selected)
|
||||
const isHome = () => activeCommunityId() === null;
|
||||
const channel = () => activeChannel();
|
||||
const showSidebar = () => sidebarVisible() && !isMobile() && !isHome();
|
||||
const showChannelHeader = () => !isHome() && channel();
|
||||
|
||||
return (
|
||||
<div class="flex h-screen w-screen overflow-hidden bg-black">
|
||||
{/* server list - always visible on desktop/tablet, horizontal on mobile */}
|
||||
<Show when={!isMobile()}>
|
||||
<ServerList />
|
||||
</Show>
|
||||
|
||||
{/* main content area */}
|
||||
<div class="flex flex-1 overflow-hidden min-w-0">
|
||||
<Show
|
||||
when={isHome()}
|
||||
fallback={
|
||||
<>
|
||||
{/* community view: channel list + chat */}
|
||||
<Show when={channelListVisible()}>
|
||||
<ResizablePanel
|
||||
width={sidebarWidth()}
|
||||
minWidth={300}
|
||||
maxWidth={600}
|
||||
side="left"
|
||||
onResize={updateSidebarWidth}
|
||||
>
|
||||
<ChannelList />
|
||||
</ResizablePanel>
|
||||
</Show>
|
||||
|
||||
{/* chat + header container */}
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
{/* channel header */}
|
||||
<Show when={showChannelHeader()}>
|
||||
<div class="h-15 shrink-0 border-b border-white/10 bg-black flex flex-col justify-end">
|
||||
<div class="h-12 flex items-center justify-between px-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Hash size={20} class="shrink-0 text-white/40" />
|
||||
<span class="text-[16px] font-bold text-white truncate">
|
||||
{channel()!.name}
|
||||
</span>
|
||||
<Show when={channel()!.topic}>
|
||||
<div class="w-px h-5 bg-white/20 mx-2 shrink-0" />
|
||||
<span class="text-[14px] text-white/40 truncate">
|
||||
{channel()!.topic}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
<IconButton label="Pinned messages">
|
||||
<Pin size={18} />
|
||||
</IconButton>
|
||||
<IconButton label="Search">
|
||||
<Search size={18} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="Toggle member list"
|
||||
active={sidebarVisible()}
|
||||
onClick={toggleSidebar}
|
||||
>
|
||||
<Users size={18} />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<div class="flex flex-1 min-w-0">
|
||||
<ChatArea
|
||||
onSendMessage={props.onSendMessage}
|
||||
onTyping={props.onTyping}
|
||||
/>
|
||||
<Show when={showSidebar()}>
|
||||
<ResizablePanel
|
||||
width={sidebarWidth()}
|
||||
minWidth={300}
|
||||
maxWidth={600}
|
||||
side="right"
|
||||
onResize={updateSidebarWidth}
|
||||
>
|
||||
<UserSidebar />
|
||||
</ResizablePanel>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{/* home view: dm sidebar + friends list or dm chat */}
|
||||
<ResizablePanel
|
||||
width={sidebarWidth()}
|
||||
minWidth={300}
|
||||
maxWidth={600}
|
||||
side="left"
|
||||
onResize={updateSidebarWidth}
|
||||
>
|
||||
<DMSidebar />
|
||||
</ResizablePanel>
|
||||
<Show when={activeDMPeerId()} fallback={<HomeView />}>
|
||||
<DMChatArea onSendDM={props.onSendDM} />
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppLayout;
|
||||
|
|
@ -0,0 +1,144 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import { Hash, Volume2, Plus, ChevronDown } from "lucide-solid";
|
||||
import {
|
||||
channels,
|
||||
activeChannelId,
|
||||
setActiveChannel,
|
||||
} from "../../stores/channels";
|
||||
import { activeCommunity } from "../../stores/communities";
|
||||
import { openModal } from "../../stores/ui";
|
||||
import SidebarLayout from "../common/SidebarLayout";
|
||||
|
||||
const ChannelList: Component = () => {
|
||||
const [textCollapsed, setTextCollapsed] = createSignal(false);
|
||||
const [voiceCollapsed, setVoiceCollapsed] = createSignal(false);
|
||||
|
||||
const textChannels = () => channels().filter((c) => c.kind === "Text");
|
||||
const voiceChannels = () => channels().filter((c) => c.kind === "Voice");
|
||||
const community = () => activeCommunity();
|
||||
|
||||
const header = (
|
||||
<div class="h-15 border-b border-white/10 flex flex-col justify-end">
|
||||
<div class="h-12 flex items-center justify-between px-4">
|
||||
<Show
|
||||
when={community()}
|
||||
fallback={
|
||||
<span class="text-[16px] font-bold text-white/40">dusk</span>
|
||||
}
|
||||
>
|
||||
<span class="text-[16px] font-bold text-white truncate">
|
||||
{community()!.name}
|
||||
</span>
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white/40 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
<ChevronDown size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div class="py-3">
|
||||
{/* text channels */}
|
||||
<Show when={textChannels().length > 0}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 w-full px-2 py-1.5 text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 hover:text-white/80 transition-colors duration-200 cursor-pointer select-none"
|
||||
onClick={() => setTextCollapsed((v) => !v)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
class="transition-transform duration-300"
|
||||
style={{
|
||||
transform: textCollapsed() ? "rotate(-90deg)" : "rotate(0deg)",
|
||||
}}
|
||||
/>
|
||||
text channels
|
||||
</button>
|
||||
<Show when={!textCollapsed()}>
|
||||
<For each={textChannels()}>
|
||||
{(channel) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`flex items-center gap-2 w-full h-10 px-2 text-[16px] transition-all duration-200 cursor-pointer group ${
|
||||
activeChannelId() === channel.id
|
||||
? "bg-gray-800 text-white border-l-4 border-orange pl-1.5"
|
||||
: "text-white/60 hover:bg-gray-800 hover:text-white"
|
||||
}`}
|
||||
onClick={() => setActiveChannel(channel.id)}
|
||||
>
|
||||
<Hash size={16} class="shrink-0 text-white/40" />
|
||||
<span class="truncate">{channel.name}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* voice channels */}
|
||||
<Show when={voiceChannels().length > 0}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-1 w-full px-2 py-1.5 mt-2 text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 hover:text-white/80 transition-colors duration-200 cursor-pointer select-none"
|
||||
onClick={() => setVoiceCollapsed((v) => !v)}
|
||||
>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
class="transition-transform duration-300"
|
||||
style={{
|
||||
transform: voiceCollapsed() ? "rotate(-90deg)" : "rotate(0deg)",
|
||||
}}
|
||||
/>
|
||||
voice channels
|
||||
</button>
|
||||
<Show when={!voiceCollapsed()}>
|
||||
<For each={voiceChannels()}>
|
||||
{(channel) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`flex items-center gap-2 w-full h-10 px-2 text-[16px] transition-all duration-200 cursor-pointer ${
|
||||
activeChannelId() === channel.id
|
||||
? "bg-gray-800 text-white border-l-4 border-orange pl-1.5"
|
||||
: "text-white/60 hover:bg-gray-800 hover:text-white"
|
||||
}`}
|
||||
onClick={() => setActiveChannel(channel.id)}
|
||||
>
|
||||
<Volume2 size={16} class="shrink-0 text-white/40" />
|
||||
<span class="truncate">{channel.name}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</Show>
|
||||
|
||||
{/* add channel button */}
|
||||
<Show when={community()}>
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 w-full h-8 px-2 mt-2 text-[13px] text-white/30 hover:text-white/60 transition-colors duration-200 cursor-pointer"
|
||||
onClick={() => openModal("create-channel")}
|
||||
>
|
||||
<Plus size={14} />
|
||||
<span>add channel</span>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarLayout
|
||||
header={header}
|
||||
showFooter
|
||||
showFooterSettings
|
||||
onFooterSettingsClick={() => openModal("settings")}
|
||||
>
|
||||
{body}
|
||||
</SidebarLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChannelList;
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show } from "solid-js";
|
||||
import { activeChannel } from "../../stores/channels";
|
||||
import { messages } from "../../stores/messages";
|
||||
import { typingUserNames } from "../../stores/members";
|
||||
import MessageList from "../chat/MessageList";
|
||||
import MessageInput from "../chat/MessageInput";
|
||||
import TypingIndicator from "../chat/TypingIndicator";
|
||||
|
||||
interface ChatAreaProps {
|
||||
onSendMessage: (content: string) => void;
|
||||
onTyping: () => void;
|
||||
}
|
||||
|
||||
const ChatArea: Component<ChatAreaProps> = (props) => {
|
||||
const channel = () => activeChannel();
|
||||
|
||||
return (
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-black">
|
||||
{/* message area */}
|
||||
<Show
|
||||
when={channel()}
|
||||
fallback={
|
||||
<div class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center text-white/30">
|
||||
<p class="text-[32px] font-bold mb-2">welcome to dusk</p>
|
||||
<p class="text-[16px]">select a community and channel to start chatting</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MessageList messages={messages()} />
|
||||
<TypingIndicator typingUsers={typingUserNames()} />
|
||||
<MessageInput
|
||||
channelName={channel()!.name}
|
||||
onSend={props.onSendMessage}
|
||||
onTyping={props.onTyping}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatArea;
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show } from "solid-js";
|
||||
import { AtSign } from "lucide-solid";
|
||||
import { activeDMConversation, dmMessages } from "../../stores/dms";
|
||||
import MessageList from "../chat/MessageList";
|
||||
import MessageInput from "../chat/MessageInput";
|
||||
import Avatar from "../common/Avatar";
|
||||
|
||||
interface DMChatAreaProps {
|
||||
onSendDM: (content: string) => void;
|
||||
}
|
||||
|
||||
const DMChatArea: Component<DMChatAreaProps> = (props) => {
|
||||
const dm = () => activeDMConversation();
|
||||
|
||||
return (
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-black">
|
||||
{/* dm header */}
|
||||
<div class="h-15 shrink-0 border-b border-white/10 flex flex-col justify-end">
|
||||
<div class="h-12 flex items-center justify-between px-4">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<Show when={dm()}>
|
||||
<AtSign size={20} class="shrink-0 text-white/40" />
|
||||
<span class="text-[16px] font-bold text-white truncate">
|
||||
{dm()!.display_name}
|
||||
</span>
|
||||
<span
|
||||
class={`text-[12px] font-mono ml-1 ${
|
||||
dm()!.status === "Online"
|
||||
? "text-success"
|
||||
: dm()!.status === "Idle"
|
||||
? "text-warning"
|
||||
: "text-white/30"
|
||||
}`}
|
||||
>
|
||||
{dm()!.status.toLowerCase()}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* conversation history */}
|
||||
<Show
|
||||
when={dmMessages().length > 0}
|
||||
fallback={
|
||||
<div class="flex-1 flex flex-col items-center justify-center">
|
||||
<Show when={dm()}>
|
||||
<Avatar name={dm()!.display_name} size="xl" />
|
||||
<p class="text-[24px] font-bold text-white mt-4">
|
||||
{dm()!.display_name}
|
||||
</p>
|
||||
<p class="text-[14px] text-white/40 mt-1">
|
||||
this is the beginning of your conversation with{" "}
|
||||
<span class="text-white font-medium">{dm()!.display_name}</span>
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<MessageList messages={dmMessages()} />
|
||||
</Show>
|
||||
|
||||
{/* message input */}
|
||||
<Show when={dm()}>
|
||||
<MessageInput
|
||||
channelName={dm()!.display_name}
|
||||
onSend={props.onSendDM}
|
||||
onTyping={() => {}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DMChatArea;
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { For, Show, createSignal } from "solid-js";
|
||||
import { MessageCircle, Search, X, Plus } from "lucide-solid";
|
||||
import {
|
||||
dmConversations,
|
||||
activeDMPeerId,
|
||||
setActiveDM,
|
||||
clearDMUnread,
|
||||
} from "../../stores/dms";
|
||||
import { openModal } from "../../stores/ui";
|
||||
import Avatar from "../common/Avatar";
|
||||
import Divider from "../common/Divider";
|
||||
import SidebarLayout from "../common/SidebarLayout";
|
||||
|
||||
const DMSidebar: Component = () => {
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
|
||||
const filteredConversations = () => {
|
||||
const query = searchQuery().toLowerCase().trim();
|
||||
if (!query) return dmConversations();
|
||||
return dmConversations().filter((dm) =>
|
||||
dm.display_name.toLowerCase().includes(query),
|
||||
);
|
||||
};
|
||||
|
||||
function handleSelectDM(peerId: string) {
|
||||
setActiveDM(peerId);
|
||||
clearDMUnread(peerId);
|
||||
}
|
||||
|
||||
const header = (
|
||||
<div class="h-15 border-b border-white/10 flex flex-col justify-end">
|
||||
<div class="h-12 flex items-center px-4">
|
||||
<div class="relative flex-1">
|
||||
<Search
|
||||
size={14}
|
||||
class="absolute left-2 top-1/2 -translate-y-1/2 text-white/30"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-black text-white text-[14px] pl-7 pr-7 py-1.5 outline-none placeholder:text-white/30 border border-white/10 focus:border-orange transition-colors duration-200"
|
||||
placeholder="find a conversation"
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
/>
|
||||
<Show when={searchQuery()}>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-1.5 top-1/2 -translate-y-1/2 text-white/30 hover:text-white transition-colors cursor-pointer"
|
||||
onClick={() => setSearchQuery("")}
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const body = (
|
||||
<div class="py-2">
|
||||
{/* friends button at top, like discord */}
|
||||
<button
|
||||
type="button"
|
||||
class={`flex items-center gap-3 w-full h-11 px-3 text-[16px] transition-all duration-200 cursor-pointer ${
|
||||
activeDMPeerId() === null
|
||||
? "bg-gray-800 text-white"
|
||||
: "text-white/60 hover:bg-gray-800 hover:text-white"
|
||||
}`}
|
||||
onClick={() => setActiveDM(null)}
|
||||
>
|
||||
<MessageCircle size={20} class="shrink-0" />
|
||||
<span class="font-medium">friends</span>
|
||||
</button>
|
||||
|
||||
<div class="px-3 py-2">
|
||||
<Divider />
|
||||
</div>
|
||||
|
||||
{/* section header */}
|
||||
<div class="flex items-center justify-between px-3 py-1.5">
|
||||
<span class="text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60">
|
||||
direct messages
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-white/40 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||
title="new dm"
|
||||
>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* conversation list */}
|
||||
<Show
|
||||
when={filteredConversations().length > 0}
|
||||
fallback={
|
||||
<div class="px-3 py-8 text-center">
|
||||
<p class="text-[14px] text-white/30">
|
||||
{searchQuery()
|
||||
? "no conversations found"
|
||||
: "no conversations yet"}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filteredConversations()}>
|
||||
{(dm) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`flex items-center gap-3 w-full px-3 py-2 transition-all duration-200 cursor-pointer group ${
|
||||
activeDMPeerId() === dm.peer_id
|
||||
? "bg-gray-800 text-white"
|
||||
: "text-white/60 hover:bg-gray-800/60 hover:text-white"
|
||||
}`}
|
||||
onClick={() => handleSelectDM(dm.peer_id)}
|
||||
>
|
||||
<Avatar
|
||||
name={dm.display_name}
|
||||
size="sm"
|
||||
status={dm.status}
|
||||
showStatus
|
||||
/>
|
||||
<div class="flex-1 min-w-0 text-left">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[14px] font-medium truncate">
|
||||
{dm.display_name}
|
||||
</span>
|
||||
<Show when={dm.unread_count > 0}>
|
||||
<span class="w-5 h-5 flex items-center justify-center bg-orange text-white text-[11px] font-bold rounded-full shrink-0">
|
||||
{dm.unread_count}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={dm.last_message}>
|
||||
<p class="text-[12px] text-white/40 truncate mt-0.5">
|
||||
{dm.last_message}
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarLayout
|
||||
header={header}
|
||||
showFooter
|
||||
showFooterSettings
|
||||
onFooterSettingsClick={() => openModal("settings")}
|
||||
>
|
||||
{body}
|
||||
</SidebarLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default DMSidebar;
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { For, Show, createSignal, createMemo } from "solid-js";
|
||||
import { Users, MessageCircle, Search, UserPlus } from "lucide-solid";
|
||||
import {
|
||||
dmConversations,
|
||||
setActiveDM,
|
||||
clearDMUnread,
|
||||
addDMConversation,
|
||||
} from "../../stores/dms";
|
||||
import { knownPeers, friends } from "../../stores/directory";
|
||||
import { identity } from "../../stores/identity";
|
||||
import { peerCount, nodeStatus } from "../../stores/connection";
|
||||
import { openModal } from "../../stores/ui";
|
||||
import Avatar from "../common/Avatar";
|
||||
import Divider from "../common/Divider";
|
||||
|
||||
type FriendsTab = "online" | "all" | "pending" | "directory";
|
||||
|
||||
const HomeView: Component = () => {
|
||||
const [activeTab, setActiveTab] = createSignal<FriendsTab>("online");
|
||||
const [searchQuery, setSearchQuery] = createSignal("");
|
||||
|
||||
// friends list comes from directory entries marked as friends
|
||||
// fall back to dm conversations for peers not yet in the directory
|
||||
const allPeers = createMemo(() => {
|
||||
const friendList = friends();
|
||||
const dms = dmConversations();
|
||||
|
||||
// merge friends from directory with dm conversations
|
||||
const merged = friendList.map((f) => ({
|
||||
peer_id: f.peer_id,
|
||||
display_name: f.display_name,
|
||||
bio: f.bio,
|
||||
// check dm conversations for status, default to offline
|
||||
status: (dms.find((d) => d.peer_id === f.peer_id)?.status ??
|
||||
"Offline") as "Online" | "Idle" | "Offline",
|
||||
}));
|
||||
|
||||
// also include dm peers that aren't yet in the friends list
|
||||
for (const dm of dms) {
|
||||
if (!merged.some((m) => m.peer_id === dm.peer_id)) {
|
||||
merged.push({
|
||||
peer_id: dm.peer_id,
|
||||
display_name: dm.display_name,
|
||||
bio: "",
|
||||
status: dm.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
});
|
||||
|
||||
// directory peers (all known, not just friends)
|
||||
const directoryPeers = createMemo(() => {
|
||||
const myId = identity()?.peer_id;
|
||||
return knownPeers().filter((p) => p.peer_id !== myId);
|
||||
});
|
||||
|
||||
const filteredPeers = createMemo(() => {
|
||||
const tab = activeTab();
|
||||
const query = searchQuery().toLowerCase().trim();
|
||||
|
||||
// directory tab shows all known peers from network discovery
|
||||
if (tab === "directory") {
|
||||
let peers = directoryPeers();
|
||||
if (query) {
|
||||
peers = peers.filter(
|
||||
(p) =>
|
||||
p.display_name.toLowerCase().includes(query) ||
|
||||
p.peer_id.toLowerCase().includes(query),
|
||||
);
|
||||
}
|
||||
return peers.map((p) => ({
|
||||
peer_id: p.peer_id,
|
||||
display_name: p.display_name,
|
||||
bio: p.bio,
|
||||
status: "Online" as const,
|
||||
is_friend: p.is_friend,
|
||||
}));
|
||||
}
|
||||
|
||||
let peers = allPeers();
|
||||
|
||||
// filter by tab
|
||||
if (tab === "online") {
|
||||
peers = peers.filter((p) => p.status === "Online" || p.status === "Idle");
|
||||
}
|
||||
|
||||
// filter by search
|
||||
if (query) {
|
||||
peers = peers.filter((p) => p.display_name.toLowerCase().includes(query));
|
||||
}
|
||||
|
||||
return peers.map((p) => ({ ...p, is_friend: undefined }));
|
||||
});
|
||||
|
||||
const onlineCount = () =>
|
||||
allPeers().filter((p) => p.status === "Online" || p.status === "Idle")
|
||||
.length;
|
||||
|
||||
function handleOpenDM(peerId: string) {
|
||||
// ensure a dm conversation exists for this peer
|
||||
const peer = allPeers().find((p) => p.peer_id === peerId);
|
||||
if (peer) {
|
||||
addDMConversation({
|
||||
peer_id: peer.peer_id,
|
||||
display_name: peer.display_name,
|
||||
status: peer.status,
|
||||
unread_count: 0,
|
||||
});
|
||||
}
|
||||
setActiveDM(peerId);
|
||||
clearDMUnread(peerId);
|
||||
}
|
||||
|
||||
const tabs: { id: FriendsTab; label: string }[] = [
|
||||
{ id: "online", label: "online" },
|
||||
{ id: "all", label: "all" },
|
||||
{ id: "directory", label: "directory" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div class="flex-1 flex flex-col min-w-0 bg-black">
|
||||
{/* header bar */}
|
||||
<div class="h-15 shrink-0 border-b border-white/10 flex flex-col justify-end">
|
||||
<div class="h-12 flex items-center justify-between px-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<Users size={20} class="text-white/60" />
|
||||
<span class="text-[16px] font-bold text-white">friends</span>
|
||||
</div>
|
||||
|
||||
<div class="w-px h-5 bg-white/20" />
|
||||
|
||||
{/* tab buttons */}
|
||||
<div class="flex items-center gap-1">
|
||||
<For each={tabs}>
|
||||
{(tab) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`px-3 py-1 text-[14px] font-medium transition-all duration-200 cursor-pointer ${
|
||||
activeTab() === tab.id
|
||||
? "bg-gray-800 text-white"
|
||||
: "text-white/50 hover:text-white hover:bg-gray-800/50"
|
||||
}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* add friend button */}
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-orange text-white text-[13px] font-medium uppercase tracking-[0.05em] hover:bg-orange-hover transition-colors duration-200 cursor-pointer"
|
||||
onClick={() => openModal("directory")}
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
user directory
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* search bar */}
|
||||
<div class="px-6 py-4">
|
||||
<div class="relative">
|
||||
<Search
|
||||
size={16}
|
||||
class="absolute left-3 top-1/2 -translate-y-1/2 text-white/30"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-gray-800 text-white text-[14px] pl-10 pr-4 py-2.5 outline-none placeholder:text-white/30 border-2 border-white/10 focus:border-orange transition-colors duration-200"
|
||||
placeholder="search"
|
||||
value={searchQuery()}
|
||||
onInput={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* section label */}
|
||||
<div class="px-6 pb-2">
|
||||
<span class="text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60">
|
||||
{activeTab() === "online" && `online - ${onlineCount()}`}
|
||||
{activeTab() === "all" && `all peers - ${allPeers().length}`}
|
||||
{activeTab() === "directory" &&
|
||||
`known peers - ${directoryPeers().length}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<Divider class="mx-6" />
|
||||
|
||||
{/* peer list */}
|
||||
<div class="flex-1 overflow-y-auto px-3">
|
||||
<Show
|
||||
when={filteredPeers().length > 0}
|
||||
fallback={
|
||||
<div class="flex flex-col items-center justify-center py-16">
|
||||
<Show
|
||||
when={activeTab() === "directory"}
|
||||
fallback={
|
||||
<>
|
||||
<Users size={48} class="text-white/10 mb-4" />
|
||||
<p class="text-[16px] text-white/30 mb-1">
|
||||
{searchQuery()
|
||||
? "no results found"
|
||||
: activeTab() === "online"
|
||||
? "no one is online right now"
|
||||
: "no peers yet"}
|
||||
</p>
|
||||
<p class="text-[14px] text-white/20">
|
||||
start a conversation from a community
|
||||
</p>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Users size={48} class="text-white/10 mb-4" />
|
||||
<p class="text-[16px] text-white/30 mb-1">
|
||||
{searchQuery()
|
||||
? "no peers matching your search"
|
||||
: "no peers discovered yet"}
|
||||
</p>
|
||||
<p class="text-[14px] text-white/20">
|
||||
peers will appear as you join communities and connect to the
|
||||
network
|
||||
</p>
|
||||
</Show>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<For each={filteredPeers()}>
|
||||
{(peer) => (
|
||||
<div class="flex items-center justify-between px-3 py-3 hover:bg-gray-800/50 transition-colors duration-200 group">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<Avatar
|
||||
name={peer.display_name}
|
||||
size="lg"
|
||||
status={peer.status}
|
||||
showStatus
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-[16px] font-medium text-white truncate">
|
||||
{peer.display_name}
|
||||
</p>
|
||||
<Show when={"is_friend" in peer && peer.is_friend}>
|
||||
<span class="text-[10px] font-mono uppercase tracking-[0.05em] text-orange px-1 py-0.5 border border-orange/30 shrink-0">
|
||||
friend
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={"bio" in peer && peer.bio}>
|
||||
<p class="text-[13px] text-white/30 truncate">
|
||||
{(peer as { bio: string }).bio}
|
||||
</p>
|
||||
</Show>
|
||||
<p class="text-[13px] font-mono text-white/40 lowercase">
|
||||
{peer.status === "Online"
|
||||
? "online"
|
||||
: peer.status === "Idle"
|
||||
? "idle"
|
||||
: "offline"}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||||
<button
|
||||
type="button"
|
||||
class="w-9 h-9 flex items-center justify-center bg-gray-800 text-white/60 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||
title="message"
|
||||
onClick={() => handleOpenDM(peer.peer_id)}
|
||||
>
|
||||
<MessageCircle size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* status bar at the bottom */}
|
||||
<div class="h-8 shrink-0 flex items-center justify-between px-6 border-t border-white/10">
|
||||
<span class="text-[11px] font-mono text-white/30">
|
||||
{nodeStatus() === "running"
|
||||
? peerCount() > 0
|
||||
? `connected - ${peerCount()} peers on network`
|
||||
: "searching for peers..."
|
||||
: nodeStatus() === "starting"
|
||||
? "connecting to network..."
|
||||
: "offline"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeView;
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { For, Show } from "solid-js";
|
||||
import { Home, Plus, Users } from "lucide-solid";
|
||||
import {
|
||||
communities,
|
||||
activeCommunityId,
|
||||
setActiveCommunity,
|
||||
} from "../../stores/communities";
|
||||
import { setActiveDM } from "../../stores/dms";
|
||||
import { getInitials, hashColor } from "../../lib/utils";
|
||||
import { openModal } from "../../stores/ui";
|
||||
|
||||
const ServerList: Component = () => {
|
||||
return (
|
||||
<div class="w-16 shrink-0 border-r bg-black flex flex-col items-center py-3 gap-2 overflow-y-auto no-select">
|
||||
{/* home button */}
|
||||
<button
|
||||
type="button"
|
||||
class={`w-12 h-12 flex items-center justify-center transition-all duration-200 cursor-pointer ${
|
||||
activeCommunityId() === null
|
||||
? "bg-orange text-white"
|
||||
: "bg-gray-800 text-white/60 hover:bg-gray-800 hover:text-white hover:scale-105"
|
||||
}`}
|
||||
onClick={() => {
|
||||
setActiveCommunity(null);
|
||||
setActiveDM(null);
|
||||
}}
|
||||
>
|
||||
<Home size={24} />
|
||||
</button>
|
||||
|
||||
<div class="w-8 border-t border-white/20 my-1" />
|
||||
|
||||
{/* server icons */}
|
||||
<For each={communities()}>
|
||||
{(community) => (
|
||||
<div class="relative">
|
||||
<Show when={activeCommunityId() === community.id}>
|
||||
<div class="absolute left-0 top-1/2 -translate-y-1/2 -translate-x-2 w-1 h-8 bg-orange rounded-r" />
|
||||
</Show>
|
||||
<button
|
||||
type="button"
|
||||
class={`w-12 h-12 flex items-center justify-center text-[14px] font-bold transition-all duration-200 cursor-pointer hover:scale-105 ${
|
||||
activeCommunityId() === community.id ? "ring-2 ring-orange" : ""
|
||||
}`}
|
||||
style={{ background: hashColor(community.name) }}
|
||||
onClick={() => {
|
||||
setActiveCommunity(community.id);
|
||||
setActiveDM(null);
|
||||
}}
|
||||
title={community.name}
|
||||
>
|
||||
{getInitials(community.name)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<div class="w-8 border-t border-white/20 my-1" />
|
||||
|
||||
{/* create community button */}
|
||||
<button
|
||||
type="button"
|
||||
class="w-12 h-12 flex items-center justify-center bg-gray-800 text-white/40 hover:bg-orange hover:text-white transition-all duration-200 cursor-pointer"
|
||||
onClick={() => openModal("create-community")}
|
||||
title="create community"
|
||||
>
|
||||
<Plus size={24} />
|
||||
</button>
|
||||
|
||||
{/* join community button */}
|
||||
<button
|
||||
type="button"
|
||||
class="w-12 h-12 flex items-center justify-center bg-gray-800 text-white/40 hover:bg-orange hover:text-white transition-all duration-200 cursor-pointer"
|
||||
onClick={() => openModal("join-community")}
|
||||
title="join community"
|
||||
>
|
||||
<Users size={24} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerList;
|
||||
|
|
@ -0,0 +1,164 @@
|
|||
import { For, Show, createMemo, createSignal, type Component } from "solid-js";
|
||||
import { members, removeMember } from "../../stores/members";
|
||||
import { activeCommunityId } from "../../stores/communities";
|
||||
import { identity } from "../../stores/identity";
|
||||
import Avatar from "../common/Avatar";
|
||||
import SidebarLayout from "../common/SidebarLayout";
|
||||
import * as tauri from "../../lib/tauri";
|
||||
|
||||
const UserSidebar: Component = () => {
|
||||
const [contextMenu, setContextMenu] = createSignal<{ x: number; y: number; memberId: string; memberName: string; memberRoles: string[] } | null>(null);
|
||||
|
||||
const groupedMembers = createMemo(() => {
|
||||
const memberList = members();
|
||||
const groups = new Map<string, typeof memberList>();
|
||||
|
||||
for (const member of memberList) {
|
||||
const role = member.roles[0] ?? "member";
|
||||
if (!groups.has(role)) {
|
||||
groups.set(role, []);
|
||||
}
|
||||
groups.get(role)!.push(member);
|
||||
}
|
||||
|
||||
return Array.from(groups.entries());
|
||||
});
|
||||
|
||||
const currentUser = () => identity();
|
||||
const currentCommunityId = () => activeCommunityId();
|
||||
|
||||
function handleContextMenu(e: MouseEvent, member: { peer_id: string; display_name: string; roles: string[] }) {
|
||||
e.preventDefault();
|
||||
setContextMenu({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
memberId: member.peer_id,
|
||||
memberName: member.display_name,
|
||||
memberRoles: member.roles,
|
||||
});
|
||||
}
|
||||
|
||||
function closeContextMenu() {
|
||||
setContextMenu(null);
|
||||
}
|
||||
|
||||
async function handleKickMember() {
|
||||
const menu = contextMenu();
|
||||
const communityId = currentCommunityId();
|
||||
if (!menu || !communityId) return;
|
||||
|
||||
const user = currentUser();
|
||||
if (!user) return;
|
||||
|
||||
const currentMember = members().find((m) => m.peer_id === user.peer_id);
|
||||
const isAdmin = currentMember?.roles.some((r) => r === "admin" || r === "owner");
|
||||
|
||||
if (!isAdmin) {
|
||||
console.error("not authorized to kick members");
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
if (menu.memberRoles.includes("owner")) {
|
||||
console.error("cannot kick the community owner");
|
||||
closeContextMenu();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await tauri.kickMember(communityId, menu.memberId);
|
||||
removeMember(menu.memberId);
|
||||
} catch (e) {
|
||||
console.error("failed to kick member:", e);
|
||||
}
|
||||
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("click", closeContextMenu);
|
||||
}
|
||||
|
||||
const body = (
|
||||
<div class="py-4">
|
||||
<For each={groupedMembers()}>
|
||||
{([role, roleMembers]) => (
|
||||
<div class="mb-4">
|
||||
<div class="px-4 py-1.5 text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-orange">
|
||||
{role} - {roleMembers.length}
|
||||
</div>
|
||||
|
||||
<For each={roleMembers}>
|
||||
{(member) => (
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-3 w-full h-10 px-4 text-left hover:bg-gray-800 transition-colors duration-200 cursor-pointer group"
|
||||
onContextMenu={(e) => handleContextMenu(e, member)}
|
||||
>
|
||||
<Avatar
|
||||
name={member.display_name}
|
||||
size="sm"
|
||||
status={member.status}
|
||||
showStatus
|
||||
/>
|
||||
<span class="text-[14px] text-white/80 group-hover:text-white truncate transition-colors duration-200">
|
||||
{member.display_name}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={members().length === 0}>
|
||||
<div class="px-4 py-8 text-[14px] text-white/30 text-center">
|
||||
no members to display
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<SidebarLayout showFooter={false}>
|
||||
{body}
|
||||
{/* context menu */}
|
||||
<Show when={contextMenu()}>
|
||||
{(menu) => {
|
||||
const user = currentUser();
|
||||
const currentMember = user ? members().find((m) => m.peer_id === user.peer_id) : null;
|
||||
const isAdmin = currentMember?.roles.some((r) => r === "admin" || r === "owner");
|
||||
const canKick = isAdmin && !menu().memberRoles.includes("owner") && menu().memberId !== user?.peer_id;
|
||||
|
||||
return (
|
||||
<div
|
||||
class="fixed bg-gray-800 border border-white/20 py-1 z-[2000] min-w-[120px]"
|
||||
style={{ left: `${menu().x}px`, top: `${menu().y}px` }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="px-3 py-1.5 text-[12px] text-white/60 border-b border-white/10">
|
||||
{menu().memberName}
|
||||
</div>
|
||||
<Show when={canKick}>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full px-3 py-1.5 text-[13px] text-left text-red-400 hover:bg-gray-700 transition-colors duration-200 cursor-pointer"
|
||||
onClick={handleKickMember}
|
||||
>
|
||||
kick member
|
||||
</button>
|
||||
</Show>
|
||||
<Show when={!canKick && menu().memberId !== user?.peer_id}>
|
||||
<div class="px-3 py-1.5 text-[12px] text-white/30">
|
||||
no actions available
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Show>
|
||||
</SidebarLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default UserSidebar;
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { Show } from "solid-js";
|
||||
import { Menu, Hash } from "lucide-solid";
|
||||
import { openOverlay, isMobile } from "../../stores/ui";
|
||||
import { activeChannel } from "../../stores/channels";
|
||||
|
||||
const MobileNav: Component = () => {
|
||||
return (
|
||||
<Show when={isMobile()}>
|
||||
<div class="h-15 shrink-0 flex items-center justify-between px-4 bg-gray-900 border-b border-white/10 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="w-10 h-10 flex items-center justify-center text-white/60 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||
onClick={openOverlay}
|
||||
>
|
||||
<Menu size={24} />
|
||||
</button>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<Show when={activeChannel()}>
|
||||
<Hash size={16} class="text-white/40" />
|
||||
<span class="text-[16px] font-medium text-white">
|
||||
{activeChannel()!.name}
|
||||
</span>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* spacer to balance the hamburger */}
|
||||
<div class="w-10" />
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobileNav;
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
import type { Component } from "solid-js";
|
||||
import { For, Show, onMount, onCleanup } from "solid-js";
|
||||
import { X } from "lucide-solid";
|
||||
|
||||
interface OverlayMenuProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onNavigate: (action: string) => void;
|
||||
}
|
||||
|
||||
const menuItems = [
|
||||
{ label: "home", action: "home" },
|
||||
{ label: "user directory", action: "directory" },
|
||||
{ label: "create community", action: "create-community" },
|
||||
{ label: "join community", action: "join-community" },
|
||||
{ label: "settings", action: "settings" },
|
||||
];
|
||||
|
||||
const OverlayMenu: Component<OverlayMenuProps> = (props) => {
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") props.onClose();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
document.addEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeydown);
|
||||
});
|
||||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<div class="fixed inset-0 z-[1000] bg-black flex flex-col animate-fade-in">
|
||||
{/* close button */}
|
||||
<div class="flex justify-end p-6">
|
||||
<button
|
||||
type="button"
|
||||
class="w-12 h-12 flex items-center justify-center text-white hover:text-orange transition-colors duration-200 cursor-pointer"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={32} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* menu items */}
|
||||
<div class="flex-1 flex flex-col justify-center px-12">
|
||||
<For each={menuItems}>
|
||||
{(item, index) => (
|
||||
<button
|
||||
type="button"
|
||||
class="text-left text-[48px] font-bold text-white hover:text-orange hover:translate-x-4 transition-all duration-200 py-2 cursor-pointer animate-slide-in-left"
|
||||
style={{ "animation-delay": `${index() * 100}ms` }}
|
||||
onClick={() => {
|
||||
props.onNavigate(item.action);
|
||||
props.onClose();
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverlayMenu;
|
||||
|
|
@ -0,0 +1,630 @@
|
|||
import { Component, createSignal, createEffect, For, Show } from "solid-js";
|
||||
import { Portal } from "solid-js/web";
|
||||
import {
|
||||
X,
|
||||
User,
|
||||
Bell,
|
||||
Eye,
|
||||
Palette,
|
||||
Info,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
} from "lucide-solid";
|
||||
import {
|
||||
settings,
|
||||
updateDisplayName,
|
||||
updateStatus,
|
||||
updateStatusMessage,
|
||||
toggleSounds,
|
||||
toggleDesktopNotifications,
|
||||
toggleMessagePreview,
|
||||
toggleShowOnlineStatus,
|
||||
toggleAllowDMsFromAnyone,
|
||||
setMessageDisplay,
|
||||
setFontSize,
|
||||
} from "../../stores/settings";
|
||||
import { identity, updateIdentity } from "../../stores/identity";
|
||||
import { updateProfile } from "../../lib/tauri";
|
||||
import type { UserStatus } from "../../lib/types";
|
||||
import Avatar from "../common/Avatar";
|
||||
import Button from "../common/Button";
|
||||
|
||||
type SettingsSection =
|
||||
| "profile"
|
||||
| "notifications"
|
||||
| "privacy"
|
||||
| "appearance"
|
||||
| "about";
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: () => void;
|
||||
onResetIdentity: () => void;
|
||||
}
|
||||
|
||||
const statusOptions: { value: UserStatus; label: string; color: string }[] = [
|
||||
{ value: "online", label: "online", color: "bg-green-500" },
|
||||
{ value: "idle", label: "idle", color: "bg-yellow-500" },
|
||||
{ value: "dnd", label: "do not disturb", color: "bg-red-500" },
|
||||
{ value: "invisible", label: "invisible", color: "bg-gray-500" },
|
||||
];
|
||||
|
||||
const SettingsModal: Component<SettingsModalProps> = (props) => {
|
||||
const [activeSection, setActiveSection] =
|
||||
createSignal<SettingsSection>("profile");
|
||||
const [localDisplayName, setLocalDisplayName] = createSignal(
|
||||
settings().display_name,
|
||||
);
|
||||
const [localStatusMessage, setLocalStatusMessage] = createSignal(
|
||||
settings().status_message,
|
||||
);
|
||||
const [localBio, setLocalBio] = createSignal(identity()?.bio || "");
|
||||
const [copied, setCopied] = createSignal(false);
|
||||
|
||||
// sync local state when modal opens
|
||||
createEffect(() => {
|
||||
if (props.isOpen) {
|
||||
setLocalDisplayName(settings().display_name);
|
||||
setLocalStatusMessage(settings().status_message);
|
||||
setLocalBio(identity()?.bio || "");
|
||||
}
|
||||
});
|
||||
|
||||
const sections: { id: SettingsSection; label: string; icon: typeof User }[] =
|
||||
[
|
||||
{ id: "profile", label: "profile", icon: User },
|
||||
{ id: "notifications", label: "notifications", icon: Bell },
|
||||
{ id: "privacy", label: "privacy", icon: Eye },
|
||||
{ id: "appearance", label: "appearance", icon: Palette },
|
||||
{ id: "about", label: "about", icon: Info },
|
||||
];
|
||||
|
||||
async function handleSave() {
|
||||
const name = localDisplayName();
|
||||
const bio = localBio();
|
||||
|
||||
// apply local state to store
|
||||
updateDisplayName(name);
|
||||
updateStatusMessage(localStatusMessage());
|
||||
updateIdentity({ display_name: name, bio });
|
||||
|
||||
// persist profile changes to backend
|
||||
try {
|
||||
await updateProfile(name, bio);
|
||||
} catch (e) {
|
||||
console.error("failed to update profile:", e);
|
||||
}
|
||||
|
||||
props.onSave();
|
||||
}
|
||||
|
||||
function copyPeerId() {
|
||||
const id = identity();
|
||||
if (id?.peer_id) {
|
||||
navigator.clipboard.writeText(id.peer_id);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={props.isOpen}>
|
||||
<Portal>
|
||||
<div class="fixed inset-0 z-[1000] flex items-center justify-center bg-black/90 animate-fade-in">
|
||||
<div class="bg-gray-900 border-2 border-white/20 w-full max-w-[800px] h-[600px] mx-4 animate-scale-in flex overflow-hidden">
|
||||
{/* sidebar navigation */}
|
||||
<div class="w-[200px] shrink-0 bg-black border-r border-white/10 flex flex-col">
|
||||
<div class="p-4 border-b border-white/10">
|
||||
<h2 class="text-[14px] font-mono uppercase tracking-[0.05em] text-white/60">
|
||||
settings
|
||||
</h2>
|
||||
</div>
|
||||
<nav class="flex-1 py-2">
|
||||
<For each={sections}>
|
||||
{(section) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`w-full flex items-center gap-3 px-4 py-3 text-left transition-colors duration-200 cursor-pointer ${
|
||||
activeSection() === section.id
|
||||
? "bg-gray-800 text-orange border-l-4 border-orange"
|
||||
: "text-white/60 hover:text-white hover:bg-gray-800/50 border-l-4 border-transparent"
|
||||
}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
<section.icon size={18} />
|
||||
<span class="text-[14px] font-medium">
|
||||
{section.label}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{/* main content */}
|
||||
<div class="flex-1 flex flex-col">
|
||||
{/* header */}
|
||||
<div class="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<h3 class="text-[20px] font-bold text-white capitalize">
|
||||
{activeSection()}
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="w-8 h-8 flex items-center justify-center text-white/60 hover:text-white transition-colors duration-200 cursor-pointer"
|
||||
onClick={props.onClose}
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* content */}
|
||||
<div class="flex-1 overflow-y-auto p-6">
|
||||
<Show when={activeSection() === "profile"}>
|
||||
<ProfileSection
|
||||
displayName={localDisplayName()}
|
||||
onDisplayNameChange={setLocalDisplayName}
|
||||
statusMessage={localStatusMessage()}
|
||||
onStatusMessageChange={setLocalStatusMessage}
|
||||
bio={localBio()}
|
||||
onBioChange={setLocalBio}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeSection() === "notifications"}>
|
||||
<NotificationsSection />
|
||||
</Show>
|
||||
|
||||
<Show when={activeSection() === "privacy"}>
|
||||
<PrivacySection
|
||||
onResetIdentity={props.onResetIdentity}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
</Show>
|
||||
|
||||
<Show when={activeSection() === "appearance"}>
|
||||
<AppearanceSection />
|
||||
</Show>
|
||||
|
||||
<Show when={activeSection() === "about"}>
|
||||
<AboutSection copied={copied()} onCopyPeerId={copyPeerId} />
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div class="p-4 border-t border-white/10 flex justify-end gap-3">
|
||||
<Button variant="ghost" onClick={props.onClose}>
|
||||
cancel
|
||||
</Button>
|
||||
<Button variant="primary" onClick={handleSave}>
|
||||
save changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Portal>
|
||||
</Show>
|
||||
);
|
||||
};
|
||||
|
||||
// profile section component
|
||||
interface ProfileSectionProps {
|
||||
displayName: string;
|
||||
onDisplayNameChange: (name: string) => void;
|
||||
statusMessage: string;
|
||||
onStatusMessageChange: (msg: string) => void;
|
||||
bio: string;
|
||||
onBioChange: (bio: string) => void;
|
||||
}
|
||||
|
||||
const ProfileSection: Component<ProfileSectionProps> = (props) => {
|
||||
const currentStatus = () => settings().status;
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* avatar preview */}
|
||||
<div class="flex items-center gap-4 p-4 bg-black/50 border border-white/10">
|
||||
<Avatar
|
||||
name={props.displayName || "user"}
|
||||
size="lg"
|
||||
status="Online"
|
||||
showStatus
|
||||
/>
|
||||
<div>
|
||||
<p class="text-[16px] font-medium text-white">
|
||||
{props.displayName || "anonymous"}
|
||||
</p>
|
||||
<p class="text-[12px] font-mono text-white/40">
|
||||
{props.bio
|
||||
? props.bio.slice(0, 30) + (props.bio.length > 30 ? "..." : "")
|
||||
: currentStatus()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* display name */}
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
display name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
|
||||
placeholder="your display name"
|
||||
value={props.displayName}
|
||||
onInput={(e) => props.onDisplayNameChange(e.currentTarget.value)}
|
||||
maxLength={32}
|
||||
/>
|
||||
<p class="mt-1 text-[11px] font-mono text-white/30">
|
||||
{props.displayName.length}/32 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* bio */}
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
bio
|
||||
</label>
|
||||
<textarea
|
||||
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200 resize-none h-24"
|
||||
placeholder="tell us about yourself"
|
||||
value={props.bio}
|
||||
onInput={(e) => props.onBioChange(e.currentTarget.value)}
|
||||
maxLength={160}
|
||||
/>
|
||||
<p class="mt-1 text-[11px] font-mono text-white/30">
|
||||
{props.bio.length}/160 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* status */}
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
status
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<For each={statusOptions}>
|
||||
{(option) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`flex items-center gap-2 px-4 py-2 border-2 transition-all duration-200 cursor-pointer ${
|
||||
currentStatus() === option.value
|
||||
? "border-orange bg-orange/10 text-white"
|
||||
: "border-white/20 text-white/60 hover:border-white/40 hover:text-white"
|
||||
}`}
|
||||
onClick={() => updateStatus(option.value)}
|
||||
>
|
||||
<span class={`w-2 h-2 rounded-full ${option.color}`} />
|
||||
<span class="text-[13px] font-medium">{option.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* status message */}
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
status message
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-black border-2 border-white/20 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/30 focus:border-orange transition-colors duration-200"
|
||||
placeholder="what are you up to?"
|
||||
value={props.statusMessage}
|
||||
onInput={(e) => props.onStatusMessageChange(e.currentTarget.value)}
|
||||
maxLength={128}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// notifications section component
|
||||
const NotificationsSection: Component = () => {
|
||||
const current = () => settings();
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<ToggleRow
|
||||
label="sounds"
|
||||
description="play sounds for new messages and events"
|
||||
checked={current().enable_sounds}
|
||||
onChange={toggleSounds}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="desktop notifications"
|
||||
description="show system notifications for new messages"
|
||||
checked={current().enable_desktop_notifications}
|
||||
onChange={toggleDesktopNotifications}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="message preview"
|
||||
description="show message content in notifications"
|
||||
checked={current().enable_message_preview}
|
||||
onChange={toggleMessagePreview}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// privacy section component
|
||||
const PrivacySection: Component<{
|
||||
onResetIdentity: () => void;
|
||||
onClose: () => void;
|
||||
}> = (props) => {
|
||||
const current = () => settings();
|
||||
const [confirmingReset, setConfirmingReset] = createSignal(false);
|
||||
const [confirmText, setConfirmText] = createSignal("");
|
||||
|
||||
function handleReset() {
|
||||
props.onClose();
|
||||
props.onResetIdentity();
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="space-y-4">
|
||||
<ToggleRow
|
||||
label="show online status"
|
||||
description="let others see when you're online"
|
||||
checked={current().show_online_status}
|
||||
onChange={toggleShowOnlineStatus}
|
||||
/>
|
||||
<ToggleRow
|
||||
label="allow dms from anyone"
|
||||
description="receive direct messages from people not in your communities"
|
||||
checked={current().allow_dms_from_anyone}
|
||||
onChange={toggleAllowDMsFromAnyone}
|
||||
/>
|
||||
|
||||
{/* danger zone */}
|
||||
<div class="mt-8 pt-6 border-t border-red-500/20">
|
||||
<div class="flex items-center gap-2 mb-4">
|
||||
<AlertTriangle size={16} class="text-red-500" />
|
||||
<h4 class="text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-red-500">
|
||||
danger zone
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<Show
|
||||
when={confirmingReset()}
|
||||
fallback={
|
||||
<div class="p-4 bg-black/50 border border-red-500/20">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-[14px] font-medium text-white">
|
||||
reset identity
|
||||
</p>
|
||||
<p class="text-[12px] text-white/50 mt-1">
|
||||
permanently destroy your keypair, wipe all local data, and
|
||||
broadcast a revocation to all connected peers
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setConfirmingReset(true)}
|
||||
>
|
||||
<span class="text-red-500">reset</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div class="p-4 bg-red-500/5 border-2 border-red-500/40 space-y-4">
|
||||
<p class="text-[14px] text-white">
|
||||
this action is{" "}
|
||||
<span class="font-bold text-red-500">irreversible</span>. your
|
||||
identity keypair will be destroyed and all peers will be notified
|
||||
to remove your profile from their directories.
|
||||
</p>
|
||||
<p class="text-[13px] text-white/60">
|
||||
type <span class="font-mono text-red-400">RESET</span> to confirm
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
class="w-full bg-black border-2 border-red-500/30 text-white text-[16px] px-4 py-3 outline-none placeholder:text-white/20 focus:border-red-500 transition-colors duration-200"
|
||||
placeholder="type RESET to confirm"
|
||||
value={confirmText()}
|
||||
onInput={(e) => setConfirmText(e.currentTarget.value)}
|
||||
/>
|
||||
<div class="flex gap-3 justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setConfirmingReset(false);
|
||||
setConfirmText("");
|
||||
}}
|
||||
>
|
||||
cancel
|
||||
</Button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={confirmText() !== "RESET"}
|
||||
class={`px-6 py-2 text-[14px] font-medium border-2 transition-all duration-200 ${
|
||||
confirmText() === "RESET"
|
||||
? "bg-red-500 border-red-500 text-white cursor-pointer hover:bg-red-600"
|
||||
: "bg-gray-800 border-white/10 text-white/30 cursor-not-allowed"
|
||||
}`}
|
||||
onClick={handleReset}
|
||||
>
|
||||
destroy identity
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// appearance section component
|
||||
const AppearanceSection: Component = () => {
|
||||
const current = () => settings();
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* message display */}
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-3">
|
||||
message display
|
||||
</label>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
class={`flex-1 p-4 border-2 transition-all duration-200 cursor-pointer ${
|
||||
current().message_display === "cozy"
|
||||
? "border-orange bg-orange/10"
|
||||
: "border-white/20 hover:border-white/40"
|
||||
}`}
|
||||
onClick={() => setMessageDisplay("cozy")}
|
||||
>
|
||||
<p class="text-[14px] font-medium text-white mb-1">cozy</p>
|
||||
<p class="text-[12px] text-white/50">
|
||||
larger avatars, more spacing
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class={`flex-1 p-4 border-2 transition-all duration-200 cursor-pointer ${
|
||||
current().message_display === "compact"
|
||||
? "border-orange bg-orange/10"
|
||||
: "border-white/20 hover:border-white/40"
|
||||
}`}
|
||||
onClick={() => setMessageDisplay("compact")}
|
||||
>
|
||||
<p class="text-[14px] font-medium text-white mb-1">compact</p>
|
||||
<p class="text-[12px] text-white/50">
|
||||
smaller elements, dense layout
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* font size */}
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-3">
|
||||
font size
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
{(["small", "default", "large"] as const).map((size) => (
|
||||
<button
|
||||
type="button"
|
||||
class={`px-6 py-2 border-2 transition-all duration-200 cursor-pointer ${
|
||||
current().font_size === size
|
||||
? "border-orange bg-orange/10 text-white"
|
||||
: "border-white/20 text-white/60 hover:border-white/40 hover:text-white"
|
||||
}`}
|
||||
onClick={() => setFontSize(size)}
|
||||
>
|
||||
<span
|
||||
class={`font-medium ${size === "small" ? "text-[12px]" : size === "large" ? "text-[18px]" : "text-[14px]"}`}
|
||||
>
|
||||
{size}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// about section component
|
||||
interface AboutSectionProps {
|
||||
copied: boolean;
|
||||
onCopyPeerId: () => void;
|
||||
}
|
||||
|
||||
const AboutSection: Component<AboutSectionProps> = (props) => {
|
||||
const id = () => identity();
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* peer id */}
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
peer id
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="flex-1 bg-black border-2 border-white/20 px-4 py-3 font-mono text-[13px] text-white/70 truncate">
|
||||
{id()?.peer_id || "not available"}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="p-3 bg-gray-800 border-2 border-white/20 hover:border-white/40 transition-colors duration-200 cursor-pointer"
|
||||
onClick={props.onCopyPeerId}
|
||||
>
|
||||
<Show
|
||||
when={props.copied}
|
||||
fallback={<Copy size={18} class="text-white/60" />}
|
||||
>
|
||||
<Check size={18} class="text-green-500" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-[11px] font-mono text-white/30">
|
||||
your unique identifier on the network
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* public key */}
|
||||
<div>
|
||||
<label class="block text-[12px] font-mono font-medium uppercase tracking-[0.05em] text-white/60 mb-2">
|
||||
public key
|
||||
</label>
|
||||
<div class="bg-black border-2 border-white/20 px-4 py-3 font-mono text-[11px] text-white/50 break-all">
|
||||
{id()?.public_key || "not available"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* version info */}
|
||||
<div class="pt-4 border-t border-white/10">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-[12px] font-mono text-white/40">version</span>
|
||||
<span class="text-[12px] font-mono text-white/60">0.1.0-dev</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mt-2">
|
||||
<span class="text-[12px] font-mono text-white/40">protocol</span>
|
||||
<span class="text-[12px] font-mono text-white/60">dusk/1.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// reusable toggle row component
|
||||
interface ToggleRowProps {
|
||||
label: string;
|
||||
description: string;
|
||||
checked: boolean;
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
const ToggleRow: Component<ToggleRowProps> = (props) => {
|
||||
return (
|
||||
<div
|
||||
class="flex items-center justify-between p-4 bg-black/50 border border-white/10 cursor-pointer hover:border-white/20 transition-colors duration-200"
|
||||
onClick={props.onChange}
|
||||
>
|
||||
<div>
|
||||
<p class="text-[14px] font-medium text-white">{props.label}</p>
|
||||
<p class="text-[12px] text-white/50">{props.description}</p>
|
||||
</div>
|
||||
<div
|
||||
class={`w-12 h-6 rounded-full p-1 transition-colors duration-200 ${
|
||||
props.checked ? "bg-orange" : "bg-gray-700"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
class={`w-4 h-4 rounded-full bg-white transition-transform duration-200 ${
|
||||
props.checked ? "translate-x-6" : "translate-x-0"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsModal;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/* @refresh reload */
|
||||
import { render } from "solid-js/web";
|
||||
import App from "./App";
|
||||
|
||||
import "@fontsource/space-grotesk/400.css";
|
||||
import "@fontsource/space-grotesk/500.css";
|
||||
import "@fontsource/space-grotesk/700.css";
|
||||
import "@fontsource-variable/jetbrains-mono";
|
||||
|
||||
import "./styles/app.css";
|
||||
|
||||
render(() => <App />, document.getElementById("root") as HTMLElement);
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import type {
|
||||
PublicIdentity,
|
||||
CommunityMeta,
|
||||
ChannelMeta,
|
||||
ChatMessage,
|
||||
Member,
|
||||
DuskEvent,
|
||||
UserSettings,
|
||||
DirectoryEntry,
|
||||
} from "./types";
|
||||
|
||||
// -- identity --
|
||||
|
||||
export async function hasIdentity(): Promise<boolean> {
|
||||
return invoke("has_identity");
|
||||
}
|
||||
|
||||
export async function loadIdentity(): Promise<PublicIdentity | null> {
|
||||
return invoke("load_identity");
|
||||
}
|
||||
|
||||
export async function createIdentity(
|
||||
displayName: string,
|
||||
bio?: string,
|
||||
): Promise<PublicIdentity> {
|
||||
return invoke("create_identity", { displayName, bio });
|
||||
}
|
||||
|
||||
export async function updateDisplayName(name: string): Promise<void> {
|
||||
return invoke("update_display_name", { name });
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
displayName: string,
|
||||
bio: string,
|
||||
): Promise<PublicIdentity> {
|
||||
return invoke("update_profile", { displayName, bio });
|
||||
}
|
||||
|
||||
// -- settings --
|
||||
|
||||
export async function loadSettings(): Promise<UserSettings> {
|
||||
return invoke("load_settings");
|
||||
}
|
||||
|
||||
export async function saveSettings(settings: UserSettings): Promise<void> {
|
||||
return invoke("save_settings", { settings });
|
||||
}
|
||||
|
||||
// -- node lifecycle --
|
||||
|
||||
export async function startNode(): Promise<void> {
|
||||
return invoke("start_node");
|
||||
}
|
||||
|
||||
export async function stopNode(): Promise<void> {
|
||||
return invoke("stop_node");
|
||||
}
|
||||
|
||||
// -- community --
|
||||
|
||||
export async function createCommunity(
|
||||
name: string,
|
||||
description: string,
|
||||
): Promise<CommunityMeta> {
|
||||
return invoke("create_community", { name, description });
|
||||
}
|
||||
|
||||
export async function joinCommunity(
|
||||
inviteCode: string,
|
||||
): Promise<CommunityMeta> {
|
||||
return invoke("join_community", { inviteCode });
|
||||
}
|
||||
|
||||
export async function leaveCommunity(communityId: string): Promise<void> {
|
||||
return invoke("leave_community", { communityId });
|
||||
}
|
||||
|
||||
export async function getCommunities(): Promise<CommunityMeta[]> {
|
||||
return invoke("get_communities");
|
||||
}
|
||||
|
||||
// -- channels --
|
||||
|
||||
export async function createChannel(
|
||||
communityId: string,
|
||||
name: string,
|
||||
topic: string,
|
||||
): Promise<ChannelMeta> {
|
||||
return invoke("create_channel", { communityId, name, topic });
|
||||
}
|
||||
|
||||
export async function getChannels(communityId: string): Promise<ChannelMeta[]> {
|
||||
return invoke("get_channels", { communityId });
|
||||
}
|
||||
|
||||
// -- messages --
|
||||
|
||||
export async function sendMessage(
|
||||
channelId: string,
|
||||
content: string,
|
||||
): Promise<ChatMessage> {
|
||||
return invoke("send_message", { channelId, content });
|
||||
}
|
||||
|
||||
export async function getMessages(
|
||||
channelId: string,
|
||||
before?: number,
|
||||
limit?: number,
|
||||
): Promise<ChatMessage[]> {
|
||||
return invoke("get_messages", { channelId, before, limit });
|
||||
}
|
||||
|
||||
// -- members --
|
||||
|
||||
export async function getMembers(communityId: string): Promise<Member[]> {
|
||||
return invoke("get_members", { communityId });
|
||||
}
|
||||
|
||||
export async function sendTypingIndicator(channelId: string): Promise<void> {
|
||||
return invoke("send_typing", { channelId });
|
||||
}
|
||||
|
||||
// -- moderation --
|
||||
|
||||
export async function deleteMessage(
|
||||
communityId: string,
|
||||
messageId: string,
|
||||
): Promise<void> {
|
||||
return invoke("delete_message", { communityId, messageId });
|
||||
}
|
||||
|
||||
export async function kickMember(
|
||||
communityId: string,
|
||||
memberPeerId: string,
|
||||
): Promise<void> {
|
||||
return invoke("kick_member", { communityId, memberPeerId });
|
||||
}
|
||||
|
||||
export async function generateInvite(communityId: string): Promise<string> {
|
||||
return invoke("generate_invite", { communityId });
|
||||
}
|
||||
|
||||
// -- user directory --
|
||||
|
||||
export async function getKnownPeers(): Promise<DirectoryEntry[]> {
|
||||
return invoke("get_known_peers");
|
||||
}
|
||||
|
||||
export async function searchDirectory(
|
||||
query: string,
|
||||
): Promise<DirectoryEntry[]> {
|
||||
return invoke("search_directory", { query });
|
||||
}
|
||||
|
||||
export async function getFriends(): Promise<DirectoryEntry[]> {
|
||||
return invoke("get_friends");
|
||||
}
|
||||
|
||||
export async function addFriend(peerId: string): Promise<void> {
|
||||
return invoke("add_friend", { peerId });
|
||||
}
|
||||
|
||||
export async function removeFriend(peerId: string): Promise<void> {
|
||||
return invoke("remove_friend", { peerId });
|
||||
}
|
||||
|
||||
export async function resetIdentity(): Promise<void> {
|
||||
return invoke("reset_identity");
|
||||
}
|
||||
|
||||
// -- events --
|
||||
|
||||
export function onDuskEvent(
|
||||
callback: (event: DuskEvent) => void,
|
||||
): Promise<UnlistenFn> {
|
||||
return listen<DuskEvent>("dusk-event", (e) => callback(e.payload));
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
// shared type definitions mirroring the rust structs
|
||||
// this is the single source of truth for the frontend-backend contract
|
||||
|
||||
export interface PublicIdentity {
|
||||
peer_id: string;
|
||||
display_name: string;
|
||||
public_key: string;
|
||||
bio: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export type UserStatus = "online" | "idle" | "dnd" | "invisible";
|
||||
|
||||
export interface UserSettings {
|
||||
// profile
|
||||
display_name: string;
|
||||
status: UserStatus;
|
||||
status_message: string;
|
||||
|
||||
// notifications
|
||||
enable_sounds: boolean;
|
||||
enable_desktop_notifications: boolean;
|
||||
enable_message_preview: boolean;
|
||||
|
||||
// privacy
|
||||
show_online_status: boolean;
|
||||
allow_dms_from_anyone: boolean;
|
||||
|
||||
// appearance
|
||||
message_display: "cozy" | "compact";
|
||||
font_size: "small" | "default" | "large";
|
||||
}
|
||||
|
||||
export interface CommunityMeta {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_by: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
export interface ChannelMeta {
|
||||
id: string;
|
||||
community_id: string;
|
||||
name: string;
|
||||
topic: string;
|
||||
kind: "Text" | "Voice";
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
channel_id: string;
|
||||
author_id: string;
|
||||
author_name: string;
|
||||
content: string;
|
||||
timestamp: number;
|
||||
edited: boolean;
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
peer_id: string;
|
||||
display_name: string;
|
||||
status: "Online" | "Idle" | "Offline";
|
||||
roles: string[];
|
||||
trust_level: number;
|
||||
joined_at: number;
|
||||
}
|
||||
|
||||
export interface NodeStatus {
|
||||
is_connected: boolean;
|
||||
peer_count: number;
|
||||
status: "starting" | "running" | "stopped" | "error";
|
||||
}
|
||||
|
||||
// a cached peer profile from the local directory
|
||||
export interface DirectoryEntry {
|
||||
peer_id: string;
|
||||
display_name: string;
|
||||
bio: string;
|
||||
public_key: string;
|
||||
last_seen: number;
|
||||
is_friend: boolean;
|
||||
}
|
||||
|
||||
// discriminated union for events emitted from rust
|
||||
export type DuskEvent =
|
||||
| { kind: "message_received"; payload: ChatMessage }
|
||||
| { kind: "message_deleted"; payload: { message_id: string } }
|
||||
| { kind: "member_kicked"; payload: { peer_id: string } }
|
||||
| { kind: "peer_connected"; payload: { peer_id: string } }
|
||||
| { kind: "peer_disconnected"; payload: { peer_id: string } }
|
||||
| { kind: "typing"; payload: { peer_id: string; channel_id: string } }
|
||||
| { kind: "node_status"; payload: NodeStatus }
|
||||
| { kind: "sync_complete"; payload: { community_id: string } }
|
||||
| {
|
||||
kind: "profile_received";
|
||||
payload: { peer_id: string; display_name: string; bio: string };
|
||||
}
|
||||
| { kind: "profile_revoked"; payload: { peer_id: string } };
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
// format a unix timestamp (ms) into a human-readable time string
|
||||
export function formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
const isToday = date.toDateString() === now.toDateString();
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
const isYesterday = date.toDateString() === yesterday.toDateString();
|
||||
|
||||
const time = date.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
if (isToday) return `Today at ${time}`;
|
||||
if (isYesterday) return `Yesterday at ${time}`;
|
||||
|
||||
return `${date.toLocaleDateString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
})} at ${time}`;
|
||||
}
|
||||
|
||||
// format a timestamp into just the time portion for grouped messages
|
||||
export function formatTimeShort(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
// check if two timestamps are within the same grouping window (5 minutes)
|
||||
export function isWithinGroupWindow(
|
||||
timestamp1: number,
|
||||
timestamp2: number,
|
||||
): boolean {
|
||||
return Math.abs(timestamp1 - timestamp2) < 5 * 60 * 1000;
|
||||
}
|
||||
|
||||
// check if two dates are on different calendar days
|
||||
export function isDifferentDay(
|
||||
timestamp1: number,
|
||||
timestamp2: number,
|
||||
): boolean {
|
||||
const d1 = new Date(timestamp1);
|
||||
const d2 = new Date(timestamp2);
|
||||
return d1.toDateString() !== d2.toDateString();
|
||||
}
|
||||
|
||||
// format a date for the day separator between messages
|
||||
export function formatDaySeparator(timestamp: number): string {
|
||||
const date = new Date(timestamp);
|
||||
const now = new Date();
|
||||
|
||||
if (date.toDateString() === now.toDateString()) return "Today";
|
||||
|
||||
const yesterday = new Date(now);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
if (date.toDateString() === yesterday.toDateString()) return "Yesterday";
|
||||
|
||||
return date.toLocaleDateString([], {
|
||||
weekday: "long",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// get initials from a display name (first two characters, uppercase)
|
||||
export function getInitials(name: string): string {
|
||||
const parts = name.trim().split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||
}
|
||||
return name.slice(0, 2).toUpperCase();
|
||||
}
|
||||
|
||||
// generate a deterministic color from a string (for avatar backgrounds)
|
||||
export function hashColor(str: string): string {
|
||||
let hash = 0;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const hue = Math.abs(hash % 360);
|
||||
return `hsl(${hue}, 50%, 35%)`;
|
||||
}
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
import { createSignal } from "solid-js";
|
||||
import type { ChannelMeta } from "../lib/types";
|
||||
|
||||
const [channels, setChannels] = createSignal<ChannelMeta[]>([]);
|
||||
const [activeChannelId, setActiveChannelId] = createSignal<string | null>(null);
|
||||
|
||||
export function setActiveChannel(id: string | null) {
|
||||
setActiveChannelId(id);
|
||||
}
|
||||
|
||||
export function activeChannel(): ChannelMeta | undefined {
|
||||
return channels().find((c) => c.id === activeChannelId());
|
||||
}
|
||||
|
||||
export { channels, activeChannelId, setChannels };
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
import { createSignal } from "solid-js";
|
||||
import type { CommunityMeta } from "../lib/types";
|
||||
import * as tauri from "../lib/tauri";
|
||||
|
||||
const [communities, setCommunities] = createSignal<CommunityMeta[]>([]);
|
||||
const [activeCommunityId, setActiveCommunityId] = createSignal<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
export function addCommunity(community: CommunityMeta) {
|
||||
setCommunities((prev) => [...prev, community]);
|
||||
}
|
||||
|
||||
export function removeCommunity(id: string) {
|
||||
setCommunities((prev) => prev.filter((c) => c.id !== id));
|
||||
if (activeCommunityId() === id) {
|
||||
setActiveCommunityId(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function setActiveCommunity(id: string | null) {
|
||||
setActiveCommunityId(id);
|
||||
}
|
||||
|
||||
export function activeCommunity(): CommunityMeta | undefined {
|
||||
return communities().find((c) => c.id === activeCommunityId());
|
||||
}
|
||||
|
||||
export async function createCommunity(
|
||||
name: string,
|
||||
description: string,
|
||||
): Promise<CommunityMeta> {
|
||||
const community = await tauri.createCommunity(name, description);
|
||||
addCommunity(community);
|
||||
return community;
|
||||
}
|
||||
|
||||
export async function joinCommunity(inviteCode: string): Promise<CommunityMeta> {
|
||||
const community = await tauri.joinCommunity(inviteCode);
|
||||
addCommunity(community);
|
||||
return community;
|
||||
}
|
||||
|
||||
export async function leaveCommunity(communityId: string): Promise<void> {
|
||||
await tauri.leaveCommunity(communityId);
|
||||
removeCommunity(communityId);
|
||||
}
|
||||
|
||||
export { communities, activeCommunityId, setCommunities };
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { createSignal } from "solid-js";
|
||||
|
||||
const [isConnected, setIsConnected] = createSignal(false);
|
||||
const [peerCount, setPeerCount] = createSignal(0);
|
||||
const [nodeStatus, setNodeStatus] = createSignal<
|
||||
"starting" | "running" | "stopped" | "error"
|
||||
>("stopped");
|
||||
|
||||
export {
|
||||
isConnected,
|
||||
setIsConnected,
|
||||
peerCount,
|
||||
setPeerCount,
|
||||
nodeStatus,
|
||||
setNodeStatus,
|
||||
};
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import { createSignal } from "solid-js";
|
||||
import type { DirectoryEntry } from "../lib/types";
|
||||
|
||||
const [knownPeers, setKnownPeers] = createSignal<DirectoryEntry[]>([]);
|
||||
const [friends, setFriends] = createSignal<DirectoryEntry[]>([]);
|
||||
|
||||
export function upsertPeerEntry(entry: DirectoryEntry) {
|
||||
setKnownPeers((prev) => {
|
||||
const existing = prev.findIndex((p) => p.peer_id === entry.peer_id);
|
||||
if (existing >= 0) {
|
||||
const updated = [...prev];
|
||||
// preserve local friend status when updating from network
|
||||
updated[existing] = { ...entry, is_friend: prev[existing].is_friend };
|
||||
return updated;
|
||||
}
|
||||
return [...prev, entry];
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePeerProfile(
|
||||
peerId: string,
|
||||
displayName: string,
|
||||
bio: string,
|
||||
) {
|
||||
const now = Date.now();
|
||||
|
||||
setKnownPeers((prev) =>
|
||||
prev.map((p) =>
|
||||
p.peer_id === peerId
|
||||
? { ...p, display_name: displayName, bio, last_seen: now }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
|
||||
setFriends((prev) =>
|
||||
prev.map((p) =>
|
||||
p.peer_id === peerId
|
||||
? { ...p, display_name: displayName, bio, last_seen: now }
|
||||
: p,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function markAsFriend(peerId: string) {
|
||||
setKnownPeers((prev) =>
|
||||
prev.map((p) => (p.peer_id === peerId ? { ...p, is_friend: true } : p)),
|
||||
);
|
||||
|
||||
// add to friends list if not already there
|
||||
const peer = knownPeers().find((p) => p.peer_id === peerId);
|
||||
if (peer) {
|
||||
setFriends((prev) => {
|
||||
if (prev.some((f) => f.peer_id === peerId)) return prev;
|
||||
return [...prev, { ...peer, is_friend: true }];
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function unmarkAsFriend(peerId: string) {
|
||||
setKnownPeers((prev) =>
|
||||
prev.map((p) => (p.peer_id === peerId ? { ...p, is_friend: false } : p)),
|
||||
);
|
||||
|
||||
setFriends((prev) => prev.filter((f) => f.peer_id !== peerId));
|
||||
}
|
||||
|
||||
// remove a peer entirely from local stores (used when they revoke their identity)
|
||||
export function removePeer(peerId: string) {
|
||||
setKnownPeers((prev) => prev.filter((p) => p.peer_id !== peerId));
|
||||
setFriends((prev) => prev.filter((f) => f.peer_id !== peerId));
|
||||
}
|
||||
|
||||
// clear all directory data (used during local identity reset)
|
||||
export function clearDirectory() {
|
||||
setKnownPeers([]);
|
||||
setFriends([]);
|
||||
}
|
||||
|
||||
export { knownPeers, friends, setKnownPeers, setFriends };
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
import { createSignal } from "solid-js";
|
||||
import type { ChatMessage } from "../lib/types";
|
||||
|
||||
// represents a direct message conversation with a peer
|
||||
export interface DMConversation {
|
||||
peer_id: string;
|
||||
display_name: string;
|
||||
status: "Online" | "Idle" | "Offline";
|
||||
last_message?: string;
|
||||
last_message_time?: number;
|
||||
unread_count: number;
|
||||
}
|
||||
|
||||
const [dmConversations, setDMConversations] = createSignal<DMConversation[]>(
|
||||
[],
|
||||
);
|
||||
const [activeDMPeerId, setActiveDMPeerId] = createSignal<string | null>(null);
|
||||
const [dmMessages, setDMMessages] = createSignal<ChatMessage[]>([]);
|
||||
|
||||
export function setActiveDM(peerId: string | null) {
|
||||
setActiveDMPeerId(peerId);
|
||||
}
|
||||
|
||||
export function activeDMConversation(): DMConversation | undefined {
|
||||
return dmConversations().find((dm) => dm.peer_id === activeDMPeerId());
|
||||
}
|
||||
|
||||
export function addDMConversation(dm: DMConversation) {
|
||||
setDMConversations((prev) => {
|
||||
// avoid duplicates
|
||||
if (prev.some((existing) => existing.peer_id === dm.peer_id)) return prev;
|
||||
return [...prev, dm];
|
||||
});
|
||||
}
|
||||
|
||||
export function removeDMConversation(peerId: string) {
|
||||
setDMConversations((prev) => prev.filter((dm) => dm.peer_id !== peerId));
|
||||
if (activeDMPeerId() === peerId) {
|
||||
setActiveDMPeerId(null);
|
||||
}
|
||||
}
|
||||
|
||||
export function updateDMLastMessage(
|
||||
peerId: string,
|
||||
content: string,
|
||||
timestamp: number,
|
||||
) {
|
||||
setDMConversations((prev) =>
|
||||
prev.map((dm) =>
|
||||
dm.peer_id === peerId
|
||||
? { ...dm, last_message: content, last_message_time: timestamp }
|
||||
: dm,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function incrementDMUnread(peerId: string) {
|
||||
setDMConversations((prev) =>
|
||||
prev.map((dm) =>
|
||||
dm.peer_id === peerId ? { ...dm, unread_count: dm.unread_count + 1 } : dm,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function clearDMUnread(peerId: string) {
|
||||
setDMConversations((prev) =>
|
||||
prev.map((dm) => (dm.peer_id === peerId ? { ...dm, unread_count: 0 } : dm)),
|
||||
);
|
||||
}
|
||||
|
||||
export function addDMMessage(message: ChatMessage) {
|
||||
setDMMessages((prev) => [...prev, message]);
|
||||
}
|
||||
|
||||
export function clearDMMessages() {
|
||||
setDMMessages([]);
|
||||
}
|
||||
|
||||
export {
|
||||
dmConversations,
|
||||
activeDMPeerId,
|
||||
dmMessages,
|
||||
setDMConversations,
|
||||
setDMMessages,
|
||||
};
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
import { createSignal } from "solid-js";
|
||||
import type { PublicIdentity } from "../lib/types";
|
||||
|
||||
const [identity, setIdentity] = createSignal<PublicIdentity | null>(null);
|
||||
const [isLoaded, setIsLoaded] = createSignal(false);
|
||||
|
||||
export function setCurrentIdentity(id: PublicIdentity | null) {
|
||||
setIdentity(id);
|
||||
setIsLoaded(true);
|
||||
}
|
||||
|
||||
export function updateIdentity(updates: Partial<PublicIdentity>) {
|
||||
setIdentity((prev) => (prev ? { ...prev, ...updates } : null));
|
||||
}
|
||||
|
||||
export { identity, isLoaded };
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { createSignal } from "solid-js";
|
||||
import type { Member } from "../lib/types";
|
||||
|
||||
const [members, setMembers] = createSignal<Member[]>([]);
|
||||
const [typingPeerIds, setTypingPeerIds] = createSignal<string[]>([]);
|
||||
const [onlinePeerIds, setOnlinePeerIds] = createSignal<Set<string>>(new Set());
|
||||
|
||||
// track typing timeouts so we can auto-clear after 5 seconds
|
||||
const typingTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
export function addTypingPeer(peerId: string) {
|
||||
// clear any existing timeout for this peer
|
||||
const existing = typingTimeouts.get(peerId);
|
||||
if (existing) clearTimeout(existing);
|
||||
|
||||
setTypingPeerIds((prev) => (prev.includes(peerId) ? prev : [...prev, peerId]));
|
||||
|
||||
// auto-remove after 5 seconds of no new typing events
|
||||
const timeout = setTimeout(() => {
|
||||
removeTypingPeer(peerId);
|
||||
}, 5000);
|
||||
typingTimeouts.set(peerId, timeout);
|
||||
}
|
||||
|
||||
export function removeTypingPeer(peerId: string) {
|
||||
setTypingPeerIds((prev) => prev.filter((id) => id !== peerId));
|
||||
const timeout = typingTimeouts.get(peerId);
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
typingTimeouts.delete(peerId);
|
||||
}
|
||||
}
|
||||
|
||||
export function typingUserNames(): string[] {
|
||||
const typing = typingPeerIds();
|
||||
const memberList = members();
|
||||
return typing
|
||||
.map((id) => memberList.find((m) => m.peer_id === id)?.display_name ?? id)
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// presence management
|
||||
export function setPeerOnline(peerId: string) {
|
||||
setOnlinePeerIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(peerId);
|
||||
return next;
|
||||
});
|
||||
// also update the member status
|
||||
setMembers((prev) =>
|
||||
prev.map((m) =>
|
||||
m.peer_id === peerId ? { ...m, status: "Online" as const } : m
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function setPeerOffline(peerId: string) {
|
||||
setOnlinePeerIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export function isPeerOnline(peerId: string): boolean {
|
||||
return onlinePeerIds().has(peerId);
|
||||
}
|
||||
|
||||
export function removeMember(peerId: string) {
|
||||
setMembers((prev) => prev.filter((m) => m.peer_id !== peerId));
|
||||
setOnlinePeerIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(peerId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export { members, typingPeerIds, setMembers, onlinePeerIds };
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { createSignal } from "solid-js";
|
||||
import type { ChatMessage } from "../lib/types";
|
||||
|
||||
const [messages, setMessages] = createSignal<ChatMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = createSignal(false);
|
||||
const [hasMore, setHasMore] = createSignal(true);
|
||||
|
||||
export function addMessage(message: ChatMessage) {
|
||||
setMessages((prev) => [...prev, message]);
|
||||
}
|
||||
|
||||
export function prependMessages(older: ChatMessage[]) {
|
||||
setMessages((prev) => [...older, ...prev]);
|
||||
}
|
||||
|
||||
export function clearMessages() {
|
||||
setMessages([]);
|
||||
setHasMore(true);
|
||||
}
|
||||
|
||||
export function removeMessage(messageId: string) {
|
||||
setMessages((prev) => prev.filter((m) => m.id !== messageId));
|
||||
}
|
||||
|
||||
export { messages, isLoading, hasMore, setMessages, setIsLoading, setHasMore };
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
import { createSignal, createEffect } from "solid-js";
|
||||
import type { UserSettings, UserStatus } from "../lib/types";
|
||||
|
||||
// default settings for new users
|
||||
const defaultSettings: UserSettings = {
|
||||
display_name: "anonymous",
|
||||
status: "online",
|
||||
status_message: "",
|
||||
enable_sounds: true,
|
||||
enable_desktop_notifications: true,
|
||||
enable_message_preview: true,
|
||||
show_online_status: true,
|
||||
allow_dms_from_anyone: true,
|
||||
message_display: "cozy",
|
||||
font_size: "default",
|
||||
};
|
||||
|
||||
const SETTINGS_KEY = "dusk_user_settings";
|
||||
|
||||
// load from local storage on init
|
||||
function loadFromStorage(): UserSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem(SETTINGS_KEY);
|
||||
if (stored) {
|
||||
return { ...defaultSettings, ...JSON.parse(stored) };
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
const [settings, setSettings] = createSignal<UserSettings>(loadFromStorage());
|
||||
|
||||
// persist to local storage on changes
|
||||
createEffect(() => {
|
||||
const current = settings();
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(current));
|
||||
});
|
||||
|
||||
export function updateSettings(updates: Partial<UserSettings>) {
|
||||
setSettings((prev) => ({ ...prev, ...updates }));
|
||||
}
|
||||
|
||||
export function updateDisplayName(name: string) {
|
||||
updateSettings({ display_name: name });
|
||||
}
|
||||
|
||||
export function updateStatus(status: UserStatus) {
|
||||
updateSettings({ status });
|
||||
}
|
||||
|
||||
export function updateStatusMessage(message: string) {
|
||||
updateSettings({ status_message: message });
|
||||
}
|
||||
|
||||
export function toggleSounds() {
|
||||
setSettings((prev) => ({ ...prev, enable_sounds: !prev.enable_sounds }));
|
||||
}
|
||||
|
||||
export function toggleDesktopNotifications() {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
enable_desktop_notifications: !prev.enable_desktop_notifications,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toggleMessagePreview() {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
enable_message_preview: !prev.enable_message_preview,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toggleShowOnlineStatus() {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
show_online_status: !prev.show_online_status,
|
||||
}));
|
||||
}
|
||||
|
||||
export function toggleAllowDMsFromAnyone() {
|
||||
setSettings((prev) => ({
|
||||
...prev,
|
||||
allow_dms_from_anyone: !prev.allow_dms_from_anyone,
|
||||
}));
|
||||
}
|
||||
|
||||
export function setMessageDisplay(mode: "cozy" | "compact") {
|
||||
updateSettings({ message_display: mode });
|
||||
}
|
||||
|
||||
export function setFontSize(size: "small" | "default" | "large") {
|
||||
updateSettings({ font_size: size });
|
||||
}
|
||||
|
||||
export function resetSettings() {
|
||||
setSettings(defaultSettings);
|
||||
}
|
||||
|
||||
export { settings, defaultSettings };
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
import { createSignal } from "solid-js";
|
||||
|
||||
const STORAGE_KEY = "dusk-sidebar-width";
|
||||
|
||||
function loadWidth(): number {
|
||||
if (typeof window === "undefined") return 300;
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
const parsed = parseInt(stored, 10);
|
||||
if (!isNaN(parsed) && parsed >= 300 && parsed <= 600) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
return 300;
|
||||
}
|
||||
|
||||
function saveWidth(width: number) {
|
||||
if (typeof window === "undefined") return;
|
||||
localStorage.setItem(STORAGE_KEY, width.toString());
|
||||
}
|
||||
|
||||
const [sidebarWidth, setSidebarWidth] = createSignal(loadWidth());
|
||||
|
||||
function updateSidebarWidth(width: number) {
|
||||
setSidebarWidth(width);
|
||||
saveWidth(width);
|
||||
}
|
||||
|
||||
export { sidebarWidth, updateSidebarWidth };
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import { createSignal, onMount, onCleanup } from "solid-js";
|
||||
|
||||
const [sidebarVisible, setSidebarVisible] = createSignal(true);
|
||||
const [channelListVisible, setChannelListVisible] = createSignal(true);
|
||||
const [overlayMenuOpen, setOverlayMenuOpen] = createSignal(false);
|
||||
const [isMobile, setIsMobile] = createSignal(false);
|
||||
const [isTablet, setIsTablet] = createSignal(false);
|
||||
const [activeModal, setActiveModal] = createSignal<string | null>(null);
|
||||
const [modalData, setModalData] = createSignal<unknown>(null);
|
||||
|
||||
function handleResize() {
|
||||
const width = window.innerWidth;
|
||||
setIsMobile(width < 768);
|
||||
setIsTablet(width >= 768 && width < 1440);
|
||||
|
||||
// auto-hide panels on smaller screens
|
||||
if (width < 768) {
|
||||
setSidebarVisible(false);
|
||||
setChannelListVisible(false);
|
||||
} else if (width < 1440) {
|
||||
setSidebarVisible(false);
|
||||
setChannelListVisible(true);
|
||||
} else {
|
||||
setSidebarVisible(true);
|
||||
setChannelListVisible(true);
|
||||
}
|
||||
}
|
||||
|
||||
// call this in the root component to set up the resize listener
|
||||
export function initResponsive() {
|
||||
handleResize();
|
||||
window.addEventListener("resize", handleResize);
|
||||
return () => window.removeEventListener("resize", handleResize);
|
||||
}
|
||||
|
||||
export function toggleSidebar() {
|
||||
setSidebarVisible((v) => !v);
|
||||
}
|
||||
|
||||
export function toggleChannelList() {
|
||||
setChannelListVisible((v) => !v);
|
||||
}
|
||||
|
||||
export function openOverlay() {
|
||||
setOverlayMenuOpen(true);
|
||||
}
|
||||
|
||||
export function closeOverlay() {
|
||||
setOverlayMenuOpen(false);
|
||||
}
|
||||
|
||||
export function openModal(name: string, data?: unknown) {
|
||||
setActiveModal(name);
|
||||
setModalData(data ?? null);
|
||||
}
|
||||
|
||||
export function closeModal() {
|
||||
setActiveModal(null);
|
||||
setModalData(null);
|
||||
}
|
||||
|
||||
export {
|
||||
sidebarVisible,
|
||||
channelListVisible,
|
||||
overlayMenuOpen,
|
||||
isMobile,
|
||||
isTablet,
|
||||
activeModal,
|
||||
modalData,
|
||||
};
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
@import "tailwindcss";
|
||||
|
||||
/* ==============================================================
|
||||
dusk design system
|
||||
dutch modernism aesthetic - high contrast black/white
|
||||
international orange (#FF4F00) accent
|
||||
============================================================== */
|
||||
|
||||
@theme {
|
||||
--color-black: #000000;
|
||||
--color-off-white: #FAFAFA;
|
||||
--color-white: #FFFFFF;
|
||||
|
||||
--color-gray-900: #0A0A0A;
|
||||
--color-gray-800: #1A1A1A;
|
||||
--color-gray-200: #E5E5E5;
|
||||
--color-gray-300: #D4D4D4;
|
||||
|
||||
--color-orange: #FF4F00;
|
||||
--color-orange-hover: #E64500;
|
||||
--color-orange-muted: rgba(255, 79, 0, 0.08);
|
||||
|
||||
--color-success: #00FF00;
|
||||
--color-warning: #FFFF00;
|
||||
--color-error: #FF0000;
|
||||
|
||||
--font-sans: "Space Grotesk", system-ui, sans-serif;
|
||||
--font-mono: "JetBrains Mono Variable", "JetBrains Mono", monospace;
|
||||
|
||||
--ease-out: cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
|
||||
--animate-fade-in: fade-in 300ms var(--ease-out);
|
||||
--animate-slide-in-left: slide-in-left 400ms var(--ease-out);
|
||||
--animate-slide-in-right: slide-in-right 400ms var(--ease-out);
|
||||
--animate-scale-in: scale-in 300ms var(--ease-out);
|
||||
--animate-message-in: message-in 300ms var(--ease-out);
|
||||
--animate-pop-in: pop-in 300ms var(--ease-out);
|
||||
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slide-in-left {
|
||||
from { opacity: 0; transform: translateX(-20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes slide-in-right {
|
||||
from { opacity: 0; transform: translateX(20px); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
|
||||
@keyframes scale-in {
|
||||
from { opacity: 0; transform: scale(0.95); }
|
||||
to { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes message-in {
|
||||
from { opacity: 0; transform: translateY(20px) scale(0.95); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes pop-in {
|
||||
0% { transform: scale(0); }
|
||||
70% { transform: scale(1.15); }
|
||||
100% { transform: scale(1); }
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--color-bg-primary: #000000;
|
||||
--color-bg-secondary: #0A0A0A;
|
||||
--color-bg-tertiary: #1A1A1A;
|
||||
--color-text-primary: #FFFFFF;
|
||||
--color-text-secondary: rgba(255, 255, 255, 0.6);
|
||||
--color-text-tertiary: rgba(255, 255, 255, 0.4);
|
||||
--color-text-body: rgba(255, 255, 255, 0.9);
|
||||
--color-border-default: rgba(255, 255, 255, 0.2);
|
||||
--color-border-subtle: rgba(255, 255, 255, 0.1);
|
||||
--color-accent: #FF4F00;
|
||||
--color-accent-hover: #E64500;
|
||||
|
||||
--transition-fast: 200ms cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
--transition-base: 300ms cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
--transition-slow: 400ms cubic-bezier(0.4, 0.0, 0.2, 1);
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: var(--color-border-default);
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg-primary);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.no-select {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
/* typing indicator dot animation */
|
||||
@keyframes typing-bounce {
|
||||
0%, 100% { transform: scale(0.8); opacity: 0.5; }
|
||||
50% { transform: scale(1.2); opacity: 1; }
|
||||
}
|
||||
|
||||
.typing-dot {
|
||||
animation: typing-bounce 600ms ease-in-out infinite;
|
||||
}
|
||||
|
||||
.typing-dot:nth-child(2) {
|
||||
animation-delay: 150ms;
|
||||
}
|
||||
|
||||
.typing-dot:nth-child(3) {
|
||||
animation-delay: 300ms;
|
||||
}
|
||||
|
||||
/* pulse animation for unread badges */
|
||||
@keyframes badge-pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.1); opacity: 0.8; }
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2021",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"jsxImportSource": "solid-js",
|
||||
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { defineConfig } from "vite";
|
||||
import solid from "vite-plugin-solid";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [solid(), tailwindcss()],
|
||||
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
// only bind to network if TAURI_DEV_HOST is set for mobile dev,
|
||||
// otherwise lock to loopback only
|
||||
host: host || "127.0.0.1",
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
headers: {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": "DENY",
|
||||
},
|
||||
},
|
||||
}));
|
||||