init commit

This commit is contained in:
cloudwithax 2026-02-13 18:12:13 -05:00
commit 99e5a1d0ec
96 changed files with 16980 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
dist
.env
relay-server/
.vscode

428
bun.lock Normal file
View File

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

347
dusk-design-guidelines.md Executable file
View File

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

13
index.html Normal file
View File

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

31
package.json Normal file
View File

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

7
src-tauri/.gitignore vendored Normal file
View File

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

7289
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

58
src-tauri/Cargo.toml Normal file
View File

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

3
src-tauri/build.rs Normal file
View File

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

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

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

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

View File

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

View File

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

View File

@ -0,0 +1,3 @@
pub mod chat;
pub mod community;
pub mod identity;

View File

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

259
src-tauri/src/crdt/mod.rs Normal file
View File

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

View File

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

83
src-tauri/src/lib.rs Normal file
View File

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

6
src-tauri/src/main.rs Normal file
View File

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

View File

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

View File

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

View File

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

710
src-tauri/src/node/mod.rs Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,4 @@
mod disk;
pub use disk::DiskStorage;
pub use disk::UserSettings;

38
src-tauri/tauri.conf.json Normal file
View File

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

984
src/App.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

12
src/index.tsx Normal file
View File

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

180
src/lib/tauri.ts Normal file
View File

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

99
src/lib/types.ts Normal file
View File

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

88
src/lib/utils.ts Normal file
View File

@ -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%)`;
}

15
src/stores/channels.ts Normal file
View File

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

49
src/stores/communities.ts Normal file
View File

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

16
src/stores/connection.ts Normal file
View File

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

79
src/stores/directory.ts Normal file
View File

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

85
src/stores/dms.ts Normal file
View File

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

16
src/stores/identity.ts Normal file
View File

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

84
src/stores/members.ts Normal file
View File

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

25
src/stores/messages.ts Normal file
View File

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

101
src/stores/settings.ts Normal file
View File

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

29
src/stores/sidebar.ts Normal file
View File

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

70
src/stores/ui.ts Normal file
View File

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

150
src/styles/app.css Normal file
View File

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

1
src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

29
tsconfig.json Normal file
View File

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

10
tsconfig.node.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

33
vite.config.ts Normal file
View File

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