voip: initial test run of store for managing socket connections
This commit is contained in:
@@ -29,6 +29,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.9.3",
|
"react-router": "^7.9.3",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
"@types/react-dom": "^18.2.21",
|
"@types/react-dom": "^18.2.21",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"@types/socket.io-client": "^3.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||||
"@typescript-eslint/parser": "^7.1.1",
|
"@typescript-eslint/parser": "^7.1.1",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
@@ -331,6 +333,8 @@
|
|||||||
|
|
||||||
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
"@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="],
|
||||||
|
|
||||||
|
"@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="],
|
||||||
|
|
||||||
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
|
"@szmarczak/http-timer": ["@szmarczak/http-timer@4.0.6", "", { "dependencies": { "defer-to-connect": "^2.0.0" } }, "sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.1.13", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.18", "source-map-js": "^1.2.1", "tailwindcss": "4.1.13" } }, "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.13", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.5.1", "lightningcss": "1.30.1", "magic-string": "^0.30.18", "source-map-js": "^1.2.1", "tailwindcss": "4.1.13" } }, "sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw=="],
|
||||||
@@ -419,6 +423,8 @@
|
|||||||
|
|
||||||
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
"@types/responselike": ["@types/responselike@1.0.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw=="],
|
||||||
|
|
||||||
|
"@types/socket.io-client": ["@types/socket.io-client@3.0.0", "", { "dependencies": { "socket.io-client": "*" } }, "sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg=="],
|
||||||
|
|
||||||
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
|
||||||
|
|
||||||
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
|
"@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="],
|
||||||
@@ -659,6 +665,10 @@
|
|||||||
|
|
||||||
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
"end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="],
|
||||||
|
|
||||||
|
"engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="],
|
||||||
|
|
||||||
|
"engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
"enhanced-resolve": ["enhanced-resolve@5.18.3", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww=="],
|
||||||
|
|
||||||
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
"env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||||
@@ -1191,6 +1201,10 @@
|
|||||||
|
|
||||||
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
"smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="],
|
||||||
|
|
||||||
|
"socket.io-client": ["socket.io-client@4.8.1", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ=="],
|
||||||
|
|
||||||
|
"socket.io-parser": ["socket.io-parser@4.2.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" } }, "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew=="],
|
||||||
|
|
||||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
@@ -1317,8 +1331,12 @@
|
|||||||
|
|
||||||
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
|
||||||
|
|
||||||
|
"ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
|
||||||
|
|
||||||
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
"xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="],
|
||||||
|
|
||||||
|
"xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="],
|
||||||
|
|
||||||
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
"xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="],
|
||||||
|
|
||||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||||
@@ -1391,6 +1409,8 @@
|
|||||||
|
|
||||||
"decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
"decode-named-character-reference/character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
|
||||||
|
|
||||||
|
"engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||||
|
|
||||||
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||||
|
|
||||||
"filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
"filelist/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="],
|
||||||
@@ -1433,6 +1453,10 @@
|
|||||||
|
|
||||||
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
"serialize-error/type-fest": ["type-fest@0.13.1", "", {}, "sha512-34R7HTnG0XIJcBSn5XhDd7nNFPRcXYRZrBB2O2jdKqYODldSzBAqzsWoZYYvduky73toYS/ESqxPvkDf/F0XMg=="],
|
||||||
|
|
||||||
|
"socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||||
|
|
||||||
|
"socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="],
|
||||||
|
|
||||||
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||||
|
|
||||||
"stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
"stringify-entities/character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"react-router": "^7.9.3",
|
"react-router": "^7.9.3",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
"socket.io-client": "^4.8.1",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"tailwindcss": "^4.1.13",
|
"tailwindcss": "^4.1.13",
|
||||||
@@ -46,6 +47,7 @@
|
|||||||
"@types/react": "^18.2.64",
|
"@types/react": "^18.2.64",
|
||||||
"@types/react-dom": "^18.2.21",
|
"@types/react-dom": "^18.2.21",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
|
"@types/socket.io-client": "^3.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
"@typescript-eslint/eslint-plugin": "^7.1.1",
|
||||||
"@typescript-eslint/parser": "^7.1.1",
|
"@typescript-eslint/parser": "^7.1.1",
|
||||||
"@vitejs/plugin-react": "^4.2.1",
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
|
import { BrowserRouter as Router, Routes, Route, Navigate } from "react-router";
|
||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
@@ -10,11 +10,13 @@ import LoginPage from "@/pages/LoginPage";
|
|||||||
import ChatPage from "@/pages/ChatPage";
|
import ChatPage from "@/pages/ChatPage";
|
||||||
import SettingsPage from "@/pages/SettingsPage";
|
import SettingsPage from "@/pages/SettingsPage";
|
||||||
import NotFoundPage from "@/pages/NotFoundPage";
|
import NotFoundPage from "@/pages/NotFoundPage";
|
||||||
|
import { useVoiceStore } from "@/stores/voiceStore";
|
||||||
|
|
||||||
import { queryClient } from "@/lib/api-client";
|
import { queryClient } from "@/lib/api-client";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import ErrorBoundary from "@/components/common/ErrorBoundary";
|
import ErrorBoundary from "@/components/common/ErrorBoundary";
|
||||||
import { Home } from "lucide-react";
|
import { Home } from "lucide-react";
|
||||||
|
import { Socket } from "socket.io-client";
|
||||||
|
|
||||||
// Protected Route wrapper
|
// Protected Route wrapper
|
||||||
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
|
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({
|
||||||
@@ -50,7 +52,16 @@ const HomePage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function App() {
|
function App(props: { socket: Socket }) {
|
||||||
|
const initVoiceStore = useVoiceStore((state) => state.init);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
initVoiceStore(props.socket);
|
||||||
|
return () => {
|
||||||
|
useVoiceStore.getState().cleanup();
|
||||||
|
};
|
||||||
|
}, [props.socket, initVoiceStore]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|||||||
108
concord-client/src/components/channel/ChannelItem.tsx
Normal file
108
concord-client/src/components/channel/ChannelItem.tsx
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Hash, Volume2 } from "lucide-react";
|
||||||
|
import { useNavigate, useParams } from "react-router";
|
||||||
|
import { Channel } from "@/lib/api-client";
|
||||||
|
import { useVoiceStore } from "@/stores/voiceStore";
|
||||||
|
import { useInstanceMembers } from "@/hooks/useServers";
|
||||||
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
|
|
||||||
|
interface ChannelItemProps {
|
||||||
|
channel: Channel;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ChannelItem: React.FC<ChannelItemProps> = ({ channel }) => {
|
||||||
|
const { instanceId, channelId: activeChannelId } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Voice store hooks
|
||||||
|
const {
|
||||||
|
joinChannel,
|
||||||
|
leaveChannel,
|
||||||
|
activeVoiceChannelId,
|
||||||
|
remoteStreams,
|
||||||
|
localStream,
|
||||||
|
} = useVoiceStore();
|
||||||
|
|
||||||
|
// Data hooks
|
||||||
|
const { data: members } = useInstanceMembers(instanceId);
|
||||||
|
const { user: currentUser, token } = useAuthStore(); // Get token from auth store
|
||||||
|
|
||||||
|
const isConnectedToThisChannel = activeVoiceChannelId === channel.id;
|
||||||
|
const isActive = activeChannelId === channel.id;
|
||||||
|
|
||||||
|
const handleChannelClick = () => {
|
||||||
|
if (channel.type === "text") {
|
||||||
|
navigate(`/channels/${instanceId}/${channel.id}`);
|
||||||
|
} else if (channel.type === "voice") {
|
||||||
|
if (isConnectedToThisChannel) {
|
||||||
|
leaveChannel();
|
||||||
|
} else if (currentUser && token) {
|
||||||
|
console.log({
|
||||||
|
channelId: channel.id,
|
||||||
|
currentUser: currentUser.id,
|
||||||
|
token: token,
|
||||||
|
});
|
||||||
|
joinChannel(channel.id, currentUser.id, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const Icon = channel.type === "voice" ? Volume2 : Hash;
|
||||||
|
const connectedUserIds = Array.from(remoteStreams.keys());
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={handleChannelClick}
|
||||||
|
className={`w-full flex items-center p-1.5 rounded-md text-left transition-colors ${
|
||||||
|
isActive || isConnectedToThisChannel
|
||||||
|
? "bg-concord-secondary text-concord-primary"
|
||||||
|
: "text-concord-secondary hover:bg-concord-secondary/50 hover:text-concord-primary"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon className="w-5 h-5 mr-2 flex-shrink-0" />
|
||||||
|
<span className="truncate flex-1">{channel.name}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Render connected users for this voice channel */}
|
||||||
|
{isConnectedToThisChannel && (
|
||||||
|
<div className="pl-4 mt-1 space-y-1">
|
||||||
|
{/* Current User */}
|
||||||
|
{localStream && currentUser && (
|
||||||
|
<div className="flex items-center p-1">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={currentUser.picture || ""} />
|
||||||
|
<AvatarFallback>
|
||||||
|
{currentUser.username.slice(0, 2)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="ml-2 text-sm text-concord-primary">
|
||||||
|
{currentUser.nickname || currentUser.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Remote Users */}
|
||||||
|
{connectedUserIds.map((userId) => {
|
||||||
|
const member = members?.find((m) => m.id === userId);
|
||||||
|
if (!member) return null;
|
||||||
|
return (
|
||||||
|
<div key={userId} className="flex items-center p-1">
|
||||||
|
<Avatar className="h-6 w-6">
|
||||||
|
<AvatarImage src={member.picture || ""} />
|
||||||
|
<AvatarFallback>{member.username.slice(0, 2)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="ml-2 text-sm text-concord-primary">
|
||||||
|
{member.nickname || member.username}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ChannelItem;
|
||||||
@@ -1,13 +1,6 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useNavigate, useParams } from "react-router";
|
import { useNavigate, useParams } from "react-router";
|
||||||
import {
|
import { ChevronDown, ChevronRight, Plus, Edit } from "lucide-react";
|
||||||
Hash,
|
|
||||||
Volume2,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Plus,
|
|
||||||
Edit,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -18,35 +11,7 @@ import {
|
|||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { CategoryWithChannels } from "@/types/api";
|
import { CategoryWithChannels } from "@/types/api";
|
||||||
import { Channel } from "@/types/database";
|
import { Channel } from "@/types/database";
|
||||||
|
import ChannelItem from "@/components/channel/ChannelItem";
|
||||||
interface ChannelItemProps {
|
|
||||||
channel: Channel;
|
|
||||||
isActive: boolean;
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ChannelItem: React.FC<ChannelItemProps> = ({
|
|
||||||
channel,
|
|
||||||
isActive,
|
|
||||||
onClick,
|
|
||||||
}) => {
|
|
||||||
const Icon = channel.type === "voice" ? Volume2 : Hash;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
className={`w-full justify-start px-2 py-2 h-8 text-left font-medium p-3 rounded-lg transition-all ${
|
|
||||||
isActive
|
|
||||||
? "border-primary bg-primary/10 border-2 "
|
|
||||||
: "hover:border-primary/50"
|
|
||||||
}`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<Icon size={16} className="mr-2 flex-shrink-0" />
|
|
||||||
<span className="truncate">{channel.name}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface CategoryHeaderProps {
|
interface CategoryHeaderProps {
|
||||||
category: CategoryWithChannels;
|
category: CategoryWithChannels;
|
||||||
@@ -110,18 +75,11 @@ interface ChannelListProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ChannelList: React.FC<ChannelListProps> = ({ categories }) => {
|
const ChannelList: React.FC<ChannelListProps> = ({ categories }) => {
|
||||||
const navigate = useNavigate();
|
|
||||||
const { instanceId, channelId } = useParams();
|
|
||||||
|
|
||||||
// Track expanded categories
|
// Track expanded categories
|
||||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
|
||||||
new Set(categories.map((cat) => cat.id)), // Start with all expanded
|
new Set(categories.map((cat) => cat.id)), // Start with all expanded
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChannelClick = (channel: Channel) => {
|
|
||||||
navigate(`/channels/${instanceId}/${channel.id}`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleCategory = (categoryId: string) => {
|
const toggleCategory = (categoryId: string) => {
|
||||||
setExpandedCategories((prev) => {
|
setExpandedCategories((prev) => {
|
||||||
const newSet = new Set(prev);
|
const newSet = new Set(prev);
|
||||||
@@ -164,12 +122,7 @@ const ChannelList: React.FC<ChannelListProps> = ({ categories }) => {
|
|||||||
{category.channels
|
{category.channels
|
||||||
.sort((a, b) => a.position - b.position)
|
.sort((a, b) => a.position - b.position)
|
||||||
.map((channel) => (
|
.map((channel) => (
|
||||||
<ChannelItem
|
<ChannelItem key={channel.id} channel={channel} />
|
||||||
key={channel.id}
|
|
||||||
channel={channel}
|
|
||||||
isActive={channelId === channel.id}
|
|
||||||
onClick={() => handleChannelClick(channel)}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import ChannelSidebar from "@/components/layout/ChannelSidebar";
|
|||||||
import UserPanel from "@/components/layout/UserPanel";
|
import UserPanel from "@/components/layout/UserPanel";
|
||||||
import MemberList from "@/components/layout/MemberList";
|
import MemberList from "@/components/layout/MemberList";
|
||||||
import LoadingSpinner from "@/components/common/LoadingSpinner";
|
import LoadingSpinner from "@/components/common/LoadingSpinner";
|
||||||
|
import VoiceConnectionManager from "@/components/voice/VoiceConnectionManager";
|
||||||
|
|
||||||
const AppLayout: React.FC = () => {
|
const AppLayout: React.FC = () => {
|
||||||
const { isLoading } = useAuthStore();
|
const { isLoading } = useAuthStore();
|
||||||
@@ -32,22 +33,15 @@ const AppLayout: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Uncomment if auth is required
|
|
||||||
// if (!user) {
|
|
||||||
// return (
|
|
||||||
// <div className="h-screen w-screen flex items-center justify-center bg-concord-primary">
|
|
||||||
// <div className="text-red-400">Authentication required</div>
|
|
||||||
// </div>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen overflow-hidden bg-concord-primary text-concord-primary">
|
<div className="flex h-screen overflow-hidden bg-concord-primary text-concord-primary">
|
||||||
|
{/* This component handles playing audio from remote users */}
|
||||||
|
<VoiceConnectionManager />
|
||||||
|
|
||||||
{/* Server List Sidebar - Always visible on desktop, overlay on mobile */}
|
{/* Server List Sidebar - Always visible on desktop, overlay on mobile */}
|
||||||
<div className="relative w-[72px] sidebar-primary flex-shrink-0">
|
<div className="relative w-[72px] sidebar-primary flex-shrink-0">
|
||||||
<ServerSidebar />
|
<ServerSidebar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Channel Sidebar - Only shown when in a server context and not collapsed */}
|
{/* Channel Sidebar - Only shown when in a server context and not collapsed */}
|
||||||
{shouldShowChannelSidebar && (
|
{shouldShowChannelSidebar && (
|
||||||
<div
|
<div
|
||||||
@@ -64,7 +58,6 @@ const AppLayout: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
<div
|
<div
|
||||||
className={`flex-1 flex flex-col min-w-0 ${
|
className={`flex-1 flex flex-col min-w-0 ${
|
||||||
@@ -73,7 +66,6 @@ const AppLayout: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Member List - Only shown when in a channel and member list is enabled */}
|
{/* Member List - Only shown when in a channel and member list is enabled */}
|
||||||
{showMemberList && shouldShowChannelSidebar && (
|
{showMemberList && shouldShowChannelSidebar && (
|
||||||
<div className="flex-0 sidebar-secondary order-l border-sidebar">
|
<div className="flex-0 sidebar-secondary order-l border-sidebar">
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import { useUiStore } from "@/stores/uiStore";
|
import { useUiStore } from "@/stores/uiStore";
|
||||||
import { useLogout } from "@/hooks/useAuth";
|
import { useLogout } from "@/hooks/useAuth";
|
||||||
|
import { useVoiceStore } from "@/stores/voiceStore";
|
||||||
|
|
||||||
// Status color utility
|
// Status color utility
|
||||||
const getStatusColor = (status: string) => {
|
const getStatusColor = (status: string) => {
|
||||||
@@ -211,46 +212,18 @@ const UserPanel: React.FC = () => {
|
|||||||
const { user } = useAuthStore();
|
const { user } = useAuthStore();
|
||||||
const { openUserSettings } = useUiStore();
|
const { openUserSettings } = useUiStore();
|
||||||
|
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const { isConnected, isMuted, isDeafened, toggleMute, toggleDeafen } =
|
||||||
const [isDeafened, setIsDeafened] = useState(false);
|
useVoiceStore();
|
||||||
|
|
||||||
// If no authenticated user, show login prompt
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="flex-shrink-0 p-2 bg-concord-tertiary border-t border-sidebar">
|
|
||||||
<div className="text-center text-concord-secondary text-sm">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => (window.location.href = "/login")}
|
|
||||||
>
|
|
||||||
Login Required
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStatusChange = (newStatus: string) => {
|
const handleStatusChange = (newStatus: string) => {
|
||||||
console.log("Status change to:", newStatus);
|
console.log("Status change to:", newStatus);
|
||||||
// TODO: Implement API call to update user status
|
|
||||||
// You can add a useUpdateUserStatus hook here
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMuteToggle = () => setIsMuted(!isMuted);
|
|
||||||
const handleDeafenToggle = () => {
|
|
||||||
const newDeafenState = !isDeafened;
|
|
||||||
setIsDeafened(newDeafenState);
|
|
||||||
if (newDeafenState) {
|
|
||||||
setIsMuted(true); // Deafening also mutes
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar">
|
<div className="user-panel flex items-center p-2 bg-concord-tertiary border-t border-sidebar">
|
||||||
{/* User Info with Dropdown */}
|
{/* User Info with Dropdown */}
|
||||||
<UserStatusDropdown
|
<UserStatusDropdown
|
||||||
currentStatus={user.status}
|
currentStatus={user?.status as string}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
@@ -260,23 +233,25 @@ const UserPanel: React.FC = () => {
|
|||||||
<UserAvatar user={user} size="md" />
|
<UserAvatar user={user} size="md" />
|
||||||
<div className="ml-2 flex-1 min-w-0 text-left">
|
<div className="ml-2 flex-1 min-w-0 text-left">
|
||||||
<div className="text-sm font-medium text-concord-primary truncate">
|
<div className="text-sm font-medium text-concord-primary truncate">
|
||||||
{user.nickname || user.username}
|
{user?.nickname || user?.username}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-concord-secondary truncate capitalize">
|
<div className="text-xs text-concord-secondary truncate capitalize">
|
||||||
{user.status}
|
{user?.status}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Button>
|
</Button>
|
||||||
</UserStatusDropdown>
|
</UserStatusDropdown>
|
||||||
|
|
||||||
{/* Voice Controls */}
|
{/* Voice Controls */}
|
||||||
<VoiceControls
|
{isConnected && (
|
||||||
isMuted={isMuted}
|
<VoiceControls
|
||||||
isDeafened={isDeafened}
|
isMuted={isMuted}
|
||||||
onMuteToggle={handleMuteToggle}
|
isDeafened={isDeafened}
|
||||||
onDeafenToggle={handleDeafenToggle}
|
onMuteToggle={toggleMute}
|
||||||
onSettingsClick={openUserSettings}
|
onDeafenToggle={toggleDeafen}
|
||||||
/>
|
onSettingsClick={openUserSettings}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { useVoiceStore } from "@/stores/voiceStore";
|
||||||
|
|
||||||
|
interface AudioPlayerProps {
|
||||||
|
stream: MediaStream;
|
||||||
|
isDeafened: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AudioPlayer: React.FC<AudioPlayerProps> = ({ stream, isDeafened }) => {
|
||||||
|
const audioRef = useRef<HTMLAudioElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (audioRef.current) {
|
||||||
|
audioRef.current.srcObject = stream;
|
||||||
|
audioRef.current.volume = isDeafened ? 0 : 1;
|
||||||
|
}
|
||||||
|
}, [stream, isDeafened]);
|
||||||
|
|
||||||
|
return <audio ref={audioRef} autoPlay playsInline />;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VoiceConnectionManager: React.FC = () => {
|
||||||
|
const remoteStreams = useVoiceStore((state) => state.remoteStreams);
|
||||||
|
const isDeafened = useVoiceStore((state) => state.isDeafened);
|
||||||
|
|
||||||
|
if (remoteStreams.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: "none" }}>
|
||||||
|
{Array.from(remoteStreams.entries()).map(([userId, stream]) => (
|
||||||
|
<AudioPlayer key={userId} stream={stream} isDeafened={isDeafened} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VoiceConnectionManager;
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
// src/hooks/useAuth.ts - Fixed with proper types
|
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useAuthStore } from "@/stores/authStore";
|
import { useAuthStore } from "@/stores/authStore";
|
||||||
import {
|
import {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// src/hooks/useServers.ts - Fixed with proper types
|
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
apiClient,
|
apiClient,
|
||||||
|
|||||||
@@ -1,15 +1,33 @@
|
|||||||
import React from 'react'
|
import React from "react";
|
||||||
import ReactDOM from 'react-dom/client'
|
import ReactDOM from "react-dom/client";
|
||||||
import App from './App.tsx'
|
import App from "./App.tsx";
|
||||||
import './index.css'
|
import { io } from "socket.io-client";
|
||||||
|
import "./index.css";
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
|
||||||
<React.StrictMode>
|
function printPayload(data: unknown) {
|
||||||
<App />
|
console.log(data);
|
||||||
</React.StrictMode>,
|
}
|
||||||
)
|
|
||||||
|
const socket = io("http://localhost:3000");
|
||||||
|
socket.on("connect", () => {
|
||||||
|
console.log("connected!");
|
||||||
|
socket.emit("ping", "world");
|
||||||
|
});
|
||||||
|
socket.on("pong", () => {
|
||||||
|
console.log("pong");
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("joined-voicechannel", printPayload);
|
||||||
|
socket.on("user-joined-voicechannel", printPayload);
|
||||||
|
socket.on("error-voicechannel", printPayload);
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App socket={socket} />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
|
|
||||||
// Use contextBridge
|
// Use contextBridge
|
||||||
window.ipcRenderer.on('main-process-message', (_event, message) => {
|
window.ipcRenderer.on("main-process-message", (_event, message) => {
|
||||||
console.log(message)
|
console.log(message);
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { io } from "socket.io-client";
|
|
||||||
|
|
||||||
const URL = import.meta.env.PROD === true ? undefined : "http://localhost:5173";
|
|
||||||
|
|
||||||
export const socket = io(URL);
|
|
||||||
342
concord-client/src/stores/voiceStore.ts
Normal file
342
concord-client/src/stores/voiceStore.ts
Normal file
@@ -0,0 +1,342 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { Socket } from "socket.io-client";
|
||||||
|
import { BackendUser, User } from "@/types";
|
||||||
|
|
||||||
|
// --- TYPE DEFINITIONS ---
|
||||||
|
|
||||||
|
interface IceServerConfig {
|
||||||
|
urls: string | string[];
|
||||||
|
username?: string;
|
||||||
|
credential?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The state managed by the store
|
||||||
|
interface VoiceState {
|
||||||
|
socket: Socket | null;
|
||||||
|
localStream: MediaStream | null;
|
||||||
|
remoteStreams: Map<string, MediaStream>;
|
||||||
|
peerConnections: Map<string, RTCPeerConnection>;
|
||||||
|
iceServers: IceServerConfig[];
|
||||||
|
isConnected: boolean;
|
||||||
|
isConnecting: boolean;
|
||||||
|
activeVoiceChannelId: string | null;
|
||||||
|
isDeafened: boolean;
|
||||||
|
isMuted: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Actions that can be performed on the store
|
||||||
|
interface VoiceActions {
|
||||||
|
init: (socket: Socket) => void;
|
||||||
|
joinChannel: (
|
||||||
|
channelId: string,
|
||||||
|
userId: string,
|
||||||
|
token: string,
|
||||||
|
) => Promise<void>;
|
||||||
|
leaveChannel: () => void;
|
||||||
|
cleanup: () => void;
|
||||||
|
toggleMute: () => void;
|
||||||
|
toggleDeafen: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ZUSTAND STORE IMPLEMENTATION ---
|
||||||
|
|
||||||
|
export const useVoiceStore = create<VoiceState & VoiceActions>((set, get) => {
|
||||||
|
// --- INTERNAL HELPERS (not exposed in the store's public interface) ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely closes and removes a single peer connection.
|
||||||
|
* @param userId The ID of the user whose connection to clean up.
|
||||||
|
*/
|
||||||
|
const cleanupPeerConnection = (userId: string) => {
|
||||||
|
const { peerConnections } = get();
|
||||||
|
const peerConnection = peerConnections.get(userId);
|
||||||
|
|
||||||
|
if (peerConnection) {
|
||||||
|
peerConnection.close();
|
||||||
|
peerConnections.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
set((state) => {
|
||||||
|
const newStreams = new Map(state.remoteStreams);
|
||||||
|
newStreams.delete(userId);
|
||||||
|
return {
|
||||||
|
remoteStreams: newStreams,
|
||||||
|
peerConnections: new Map(peerConnections),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new RTCPeerConnection for a target user and configures it.
|
||||||
|
* @param targetUserId The user to connect to.
|
||||||
|
* @returns The configured RTCPeerConnection instance.
|
||||||
|
*/
|
||||||
|
const createPeerConnection = (targetUserId: string): RTCPeerConnection => {
|
||||||
|
console.log(`Creating peer connection for: ${targetUserId}`);
|
||||||
|
const { iceServers, localStream, socket, peerConnections } = get();
|
||||||
|
|
||||||
|
const peerConnection = new RTCPeerConnection({ iceServers });
|
||||||
|
|
||||||
|
// Add local stream tracks to the new connection
|
||||||
|
if (localStream) {
|
||||||
|
localStream
|
||||||
|
.getTracks()
|
||||||
|
.forEach((track) => peerConnection.addTrack(track, localStream));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle ICE candidates
|
||||||
|
peerConnection.onicecandidate = (event) => {
|
||||||
|
if (event.candidate && socket) {
|
||||||
|
socket.emit("webrtc-ice-candidate", {
|
||||||
|
targetUserId,
|
||||||
|
candidate: event.candidate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle incoming remote tracks
|
||||||
|
peerConnection.ontrack = (event) => {
|
||||||
|
console.log(`Received remote track from: ${targetUserId}`);
|
||||||
|
set((state) => {
|
||||||
|
const newStreams = new Map(state.remoteStreams);
|
||||||
|
newStreams.set(targetUserId, event.streams[0]);
|
||||||
|
return { remoteStreams: newStreams };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// For debugging connection state
|
||||||
|
peerConnection.onconnectionstatechange = () => {
|
||||||
|
console.log(
|
||||||
|
`Connection state change for ${targetUserId}: ${peerConnection.connectionState}`,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
peerConnection.connectionState === "disconnected" ||
|
||||||
|
peerConnection.connectionState === "failed"
|
||||||
|
) {
|
||||||
|
cleanupPeerConnection(targetUserId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
peerConnections.set(targetUserId, peerConnection);
|
||||||
|
set({ peerConnections: new Map(peerConnections) });
|
||||||
|
return peerConnection;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- SOCKET EVENT HANDLERS ---
|
||||||
|
// These are defined once and can be reused by the join/leave actions.
|
||||||
|
|
||||||
|
const onJoinedVoiceChannel = async (data: {
|
||||||
|
connectedUserIds: string[];
|
||||||
|
iceServers: IceServerConfig[];
|
||||||
|
}) => {
|
||||||
|
console.log(
|
||||||
|
"Successfully joined voice channel. Users:",
|
||||||
|
data.connectedUserIds,
|
||||||
|
);
|
||||||
|
set({
|
||||||
|
iceServers: data.iceServers,
|
||||||
|
isConnecting: false,
|
||||||
|
isConnected: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const userId of data.connectedUserIds) {
|
||||||
|
const peerConnection = createPeerConnection(userId);
|
||||||
|
const offer = await peerConnection.createOffer();
|
||||||
|
await peerConnection.setLocalDescription(offer);
|
||||||
|
get().socket?.emit("webrtc-offer", {
|
||||||
|
targetUserId: userId,
|
||||||
|
sdp: peerConnection.localDescription,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUserLeft = (data: { userId: string }) => {
|
||||||
|
console.log(`User ${data.userId} left the channel.`);
|
||||||
|
cleanupPeerConnection(data.userId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWebRTCOffer = async (data: {
|
||||||
|
senderUserId: string;
|
||||||
|
sdp: RTCSessionDescriptionInit;
|
||||||
|
}) => {
|
||||||
|
console.log("Received WebRTC offer from:", data.senderUserId);
|
||||||
|
const peerConnection = createPeerConnection(data.senderUserId);
|
||||||
|
await peerConnection.setRemoteDescription(
|
||||||
|
new RTCSessionDescription(data.sdp),
|
||||||
|
);
|
||||||
|
const answer = await peerConnection.createAnswer();
|
||||||
|
await peerConnection.setLocalDescription(answer);
|
||||||
|
get().socket?.emit("webrtc-answer", {
|
||||||
|
targetUserId: data.senderUserId,
|
||||||
|
sdp: peerConnection.localDescription,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onWebRTCAnswer = async (data: {
|
||||||
|
senderUserId: string;
|
||||||
|
sdp: RTCSessionDescriptionInit;
|
||||||
|
}) => {
|
||||||
|
console.log("Received WebRTC answer from:", data.senderUserId);
|
||||||
|
const peerConnection = get().peerConnections.get(data.senderUserId);
|
||||||
|
if (peerConnection) {
|
||||||
|
await peerConnection.setRemoteDescription(
|
||||||
|
new RTCSessionDescription(data.sdp),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onICECandidate = async (data: {
|
||||||
|
senderUserId: string;
|
||||||
|
candidate: RTCIceCandidateInit;
|
||||||
|
}) => {
|
||||||
|
const peerConnection = get().peerConnections.get(data.senderUserId);
|
||||||
|
if (peerConnection && data.candidate) {
|
||||||
|
try {
|
||||||
|
await peerConnection.addIceCandidate(
|
||||||
|
new RTCIceCandidate(data.candidate),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error adding received ICE candidate", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (error: { message: string }) => {
|
||||||
|
console.error("Voice channel error:", error.message);
|
||||||
|
get().leaveChannel(); // Disconnect on error
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- STORE DEFINITION (STATE & ACTIONS) ---
|
||||||
|
return {
|
||||||
|
// Initial State
|
||||||
|
socket: null,
|
||||||
|
localStream: null,
|
||||||
|
remoteStreams: new Map(),
|
||||||
|
peerConnections: new Map(),
|
||||||
|
iceServers: [],
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
activeVoiceChannelId: null,
|
||||||
|
isMuted: false,
|
||||||
|
isDeafened: false,
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
init: (socketInstance) => {
|
||||||
|
set({ socket: socketInstance });
|
||||||
|
},
|
||||||
|
|
||||||
|
joinChannel: async (channelId: string, userId: string, token: string) => {
|
||||||
|
const { socket, activeVoiceChannelId, leaveChannel, isConnecting } =
|
||||||
|
get();
|
||||||
|
if (!socket || isConnecting || activeVoiceChannelId === channelId) return;
|
||||||
|
if (!userId || !token) {
|
||||||
|
console.error("Join channel requires user and token.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeVoiceChannelId) {
|
||||||
|
leaveChannel();
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isConnecting: true, activeVoiceChannelId: channelId });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: true,
|
||||||
|
video: false,
|
||||||
|
});
|
||||||
|
set({ localStream: stream });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Could not get user media:", error);
|
||||||
|
set({ isConnecting: false, activeVoiceChannelId: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach all necessary listeners for a voice session
|
||||||
|
socket.on("joined-voicechannel", onJoinedVoiceChannel);
|
||||||
|
socket.on("user-left-voicechannel", onUserLeft);
|
||||||
|
socket.on("webrtc-offer", onWebRTCOffer);
|
||||||
|
socket.on("webrtc-answer", onWebRTCAnswer);
|
||||||
|
socket.on("webrtc-ice-candidate", onICECandidate);
|
||||||
|
socket.on("error-voicechannel", onError);
|
||||||
|
|
||||||
|
// *** THE FIX: Send user credentials with the join request ***
|
||||||
|
socket.emit("join-voicechannel", {
|
||||||
|
channelId,
|
||||||
|
userId,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
leaveChannel: () => {
|
||||||
|
const { socket, peerConnections, localStream, activeVoiceChannelId } =
|
||||||
|
get();
|
||||||
|
if (!socket || !activeVoiceChannelId) return;
|
||||||
|
|
||||||
|
console.log(`Leaving voice channel: ${activeVoiceChannelId}`);
|
||||||
|
socket.emit("leave-voicechannel", { channelId: activeVoiceChannelId });
|
||||||
|
|
||||||
|
// Clean up all event listeners
|
||||||
|
socket.off("joined-voicechannel");
|
||||||
|
socket.off("user-left-voicechannel");
|
||||||
|
socket.off("webrtc-offer");
|
||||||
|
socket.off("webrtc-answer");
|
||||||
|
socket.off("webrtc-ice-candidate");
|
||||||
|
socket.off("error-voicechannel");
|
||||||
|
|
||||||
|
// Close all peer connections
|
||||||
|
peerConnections.forEach((pc) => pc.close());
|
||||||
|
|
||||||
|
// Stop local media tracks
|
||||||
|
localStream?.getTracks().forEach((track) => track.stop());
|
||||||
|
|
||||||
|
// Reset state to initial values
|
||||||
|
set({
|
||||||
|
localStream: null,
|
||||||
|
remoteStreams: new Map(),
|
||||||
|
peerConnections: new Map(),
|
||||||
|
isConnected: false,
|
||||||
|
isConnecting: false,
|
||||||
|
activeVoiceChannelId: null,
|
||||||
|
iceServers: [],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMute: () => {
|
||||||
|
set((state) => {
|
||||||
|
const newMutedState = !state.isMuted;
|
||||||
|
if (state.localStream) {
|
||||||
|
state.localStream.getAudioTracks().forEach((track) => {
|
||||||
|
track.enabled = !newMutedState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Cannot be deafened and unmuted
|
||||||
|
if (state.isDeafened && !newMutedState) {
|
||||||
|
return { isMuted: newMutedState, isDeafened: false };
|
||||||
|
}
|
||||||
|
return { isMuted: newMutedState };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleDeafen: () => {
|
||||||
|
set((state) => {
|
||||||
|
const newDeafenedState = !state.isDeafened;
|
||||||
|
// When deafening, you are also muted
|
||||||
|
if (newDeafenedState && !state.isMuted) {
|
||||||
|
// Manually mute logic without toggling deafen state again
|
||||||
|
if (state.localStream) {
|
||||||
|
state.localStream.getAudioTracks().forEach((track) => {
|
||||||
|
track.enabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { isDeafened: newDeafenedState, isMuted: true };
|
||||||
|
}
|
||||||
|
return { isDeafened: newDeafenedState };
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
cleanup: () => {
|
||||||
|
get().leaveChannel();
|
||||||
|
set({ socket: null });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
# Please do not edit this file manually
|
|
||||||
# It should be added in your version-control system (e.g., Git)
|
|
||||||
provider = "postgresql"
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
import { Hono } from "hono";
|
|
||||||
import { cors } from "hono/cors";
|
|
||||||
import { Server as Engine } from "@socket.io/bun-engine";
|
|
||||||
import { Server } from "socket.io";
|
|
||||||
import routes from "./routes/index";
|
|
||||||
import { Scalar } from "@scalar/hono-api-reference";
|
|
||||||
import { openAPIRouteHandler } from "hono-openapi";
|
|
||||||
import { registerSocketHandlers } from "./sockets";
|
|
||||||
|
|
||||||
// Routes
|
|
||||||
const app = new Hono();
|
|
||||||
|
|
||||||
app.use(
|
|
||||||
"*",
|
|
||||||
cors({
|
|
||||||
origin: ["http://localhost:5173", "https://concord.kpuig.net"],
|
|
||||||
allowHeaders: [
|
|
||||||
"Content-Type",
|
|
||||||
"Authorization",
|
|
||||||
"Access-Control-Allow-Origin",
|
|
||||||
],
|
|
||||||
allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
||||||
credentials: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
app.route("/api", routes);
|
|
||||||
|
|
||||||
app.get(
|
|
||||||
"/openapi",
|
|
||||||
openAPIRouteHandler(app, {
|
|
||||||
documentation: {
|
|
||||||
info: {
|
|
||||||
title: "Hono API",
|
|
||||||
version: "1.0.0",
|
|
||||||
description: "Greeting API",
|
|
||||||
},
|
|
||||||
servers: [{ url: "http://localhost:3000", description: "Local Server" }],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
app.get("/scalar", Scalar({ url: "/openapi" }));
|
|
||||||
|
|
||||||
// initialize socket.io server
|
|
||||||
const io = new Server({
|
|
||||||
cors: {
|
|
||||||
origin: ["http://localhost:5173", "https://concord.kpuig.net"],
|
|
||||||
credentials: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const engine = new Engine();
|
|
||||||
io.bind(engine);
|
|
||||||
|
|
||||||
// Register socket.io events
|
|
||||||
registerSocketHandlers(io);
|
|
||||||
|
|
||||||
const { websocket } = engine.handler();
|
|
||||||
|
|
||||||
export default {
|
|
||||||
port: 3000,
|
|
||||||
idleTimeout: 30, // must be greater than the "pingInterval" option of the engine, which defaults to 25 seconds
|
|
||||||
|
|
||||||
async fetch(req: Request, server: Bun.Server) {
|
|
||||||
const url = new URL(req.url);
|
|
||||||
|
|
||||||
if (url.pathname === "/socket.io/") {
|
|
||||||
const response = await engine.handleRequest(req, server);
|
|
||||||
// Add CORS headers explicitly
|
|
||||||
const origin = req.headers.get("Origin");
|
|
||||||
if (
|
|
||||||
origin &&
|
|
||||||
["http://localhost:5173", "https://concord.kpuig.net"].includes(origin)
|
|
||||||
) {
|
|
||||||
response.headers.set("Access-Control-Allow-Origin", origin);
|
|
||||||
}
|
|
||||||
response.headers.set("Access-Control-Allow-Credentials", "true");
|
|
||||||
return response;
|
|
||||||
} else {
|
|
||||||
return app.fetch(req, server);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
websocket,
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user