From 3bda18cf127b37b74849b17d25a70e4b8e1ba8f9 Mon Sep 17 00:00:00 2001 From: Vlad Date: Fri, 27 Jun 2025 15:12:13 +0300 Subject: [PATCH 1/3] save --- CHANGELOG.md | 6 + package-lock.json | 370 +++++++++++++++++++--------------- package.json | 15 +- src/ChatContext.tsx | 6 +- src/hooks/index.ts | 2 + src/hooks/useBlockList.ts | 5 +- src/hooks/useChatStore.ts | 90 +++++++++ src/hooks/useChatStoreRef.ts | 20 ++ src/hooks/useNetworkStatus.ts | 10 +- src/hooks/useUsers.ts | 125 ++++++------ 10 files changed, 413 insertions(+), 236 deletions(-) create mode 100644 src/hooks/useChatStore.ts create mode 100644 src/hooks/useChatStoreRef.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c94415a..0e2664a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.27.0 + +### xxx + +- xxx + ## 0.26.1 ### Bug fixes diff --git a/package-lock.json b/package-lock.json index 2ef53ee..9c6572b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,27 +1,28 @@ { "name": "@connectycube/use-chat", - "version": "0.26.1", + "version": "0.27.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@connectycube/use-chat", - "version": "0.26.1", + "version": "0.27.0", "license": "Apache-2.0", "dependencies": { "date-fns": "^4.1.0", - "react-usestateref": "^1.0.9" + "react-usestateref": "^1.0.9", + "zustand": "^5.0.6" }, "devDependencies": { "@rollup/plugin-commonjs": "^28.0.6", "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.3", - "@types/node": "^24.0.3", + "@types/node": "^24.0.4", "@types/react": "^19.1.8", "@vitest/coverage-v8": "^3.2.4", - "prettier": "^3.5.3", - "rollup": "^4.43.0", + "prettier": "^3.6.1", + "rollup": "^4.44.1", "rollup-plugin-peer-deps-external": "^2.2.4", "tslib": "^2.8.1", "typescript": "^5.8.3", @@ -32,10 +33,10 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "^4.43.0" + "@rollup/rollup-linux-x64-gnu": "^4.44.1" }, "peerDependencies": { - "connectycube": ">=4.6.0", + "connectycube": ">=4.6.2", "react": ">=18.0.0", "react-dom": ">=18.0.0" } @@ -106,9 +107,9 @@ "peer": true }, "node_modules/@babel/compat-data": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz", - "integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.7.tgz", + "integrity": "sha512-xgu/ySj2mTiUFmdE9yCMfBxLp4DHd5DwmbbD05YAuICfodYT3VvRxbrh81LGQ/8UpSdtMdfKMn3KouYDX59DGQ==", "license": "MIT", "peer": true, "engines": { @@ -116,22 +117,22 @@ } }, "node_modules/@babel/core": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", - "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.7.tgz", + "integrity": "sha512-BU2f9tlKQ5CAthiMIgpzAh4eDTLWo1mqi9jqE2OxMG0E/OM199VJt2q8BztTxpnSW0i1ymdwLXRJnYzvDM5r2w==", "license": "MIT", "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", + "@babel/generator": "^7.27.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.4", - "@babel/parser": "^7.27.4", + "@babel/helpers": "^7.27.6", + "@babel/parser": "^7.27.7", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.27.4", - "@babel/types": "^7.27.3", + "@babel/traverse": "^7.27.7", + "@babel/types": "^7.27.7", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -302,12 +303,12 @@ } }, "node_modules/@babel/parser": { - "version": "7.27.5", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", - "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.7.tgz", + "integrity": "sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.27.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -388,17 +389,17 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.4", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", - "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.7.tgz", + "integrity": "sha512-X6ZlfR/O/s5EQ/SnUSLzr+6kGnkg8HXGMzpgsMsrJVcfDtH1vIp6ctCN4eZ1LS5c0+te5Cb6Y514fASjMRJ1nw==", "license": "MIT", "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/parser": "^7.27.7", "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", + "@babel/types": "^7.27.7", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -407,9 +408,9 @@ } }, "node_modules/@babel/types": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", - "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", + "version": "7.27.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.7.tgz", + "integrity": "sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -1086,9 +1087,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.43.0.tgz", - "integrity": "sha512-Krjy9awJl6rKbruhQDgivNbD1WuLb8xAclM4IR4cN5pHGAs2oIMMQJEiC3IC/9TZJ+QZkmZhlMO/6MBGxPidpw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.1.tgz", + "integrity": "sha512-JAcBr1+fgqx20m7Fwe1DxPUl/hPkee6jA6Pl7n1v2EFiktAHenTaXl5aIFjUIEsfn9w3HE4gK1lEgNGMzBDs1w==", "cpu": [ "arm" ], @@ -1100,9 +1101,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.43.0.tgz", - "integrity": "sha512-ss4YJwRt5I63454Rpj+mXCXicakdFmKnUNxr1dLK+5rv5FJgAxnN7s31a5VchRYxCFWdmnDWKd0wbAdTr0J5EA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.1.tgz", + "integrity": "sha512-RurZetXqTu4p+G0ChbnkwBuAtwAbIwJkycw1n6GvlGlBuS4u5qlr5opix8cBAYFJgaY05TWtM+LaoFggUmbZEQ==", "cpu": [ "arm64" ], @@ -1114,9 +1115,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.43.0.tgz", - "integrity": "sha512-eKoL8ykZ7zz8MjgBenEF2OoTNFAPFz1/lyJ5UmmFSz5jW+7XbH1+MAgCVHy72aG59rbuQLcJeiMrP8qP5d/N0A==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.1.tgz", + "integrity": "sha512-fM/xPesi7g2M7chk37LOnmnSTHLG/v2ggWqKj3CCA1rMA4mm5KVBT1fNoswbo1JhPuNNZrVwpTvlCVggv8A2zg==", "cpu": [ "arm64" ], @@ -1128,9 +1129,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.43.0.tgz", - "integrity": "sha512-SYwXJgaBYW33Wi/q4ubN+ldWC4DzQY62S4Ll2dgfr/dbPoF50dlQwEaEHSKrQdSjC6oIe1WgzosoaNoHCdNuMg==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.1.tgz", + "integrity": "sha512-gDnWk57urJrkrHQ2WVx9TSVTH7lSlU7E3AFqiko+bgjlh78aJ88/3nycMax52VIVjIm3ObXnDL2H00e/xzoipw==", "cpu": [ "x64" ], @@ -1142,9 +1143,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.43.0.tgz", - "integrity": "sha512-SV+U5sSo0yujrjzBF7/YidieK2iF6E7MdF6EbYxNz94lA+R0wKl3SiixGyG/9Klab6uNBIqsN7j4Y/Fya7wAjQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.1.tgz", + "integrity": "sha512-wnFQmJ/zPThM5zEGcnDcCJeYJgtSLjh1d//WuHzhf6zT3Md1BvvhJnWoy+HECKu2bMxaIcfWiu3bJgx6z4g2XA==", "cpu": [ "arm64" ], @@ -1156,9 +1157,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.43.0.tgz", - "integrity": "sha512-J7uCsiV13L/VOeHJBo5SjasKiGxJ0g+nQTrBkAsmQBIdil3KhPnSE9GnRon4ejX1XDdsmK/l30IYLiAaQEO0Cg==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.1.tgz", + "integrity": "sha512-uBmIxoJ4493YATvU2c0upGz87f99e3wop7TJgOA/bXMFd2SvKCI7xkxY/5k50bv7J6dw1SXT4MQBQSLn8Bb/Uw==", "cpu": [ "x64" ], @@ -1170,9 +1171,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.43.0.tgz", - "integrity": "sha512-gTJ/JnnjCMc15uwB10TTATBEhK9meBIY+gXP4s0sHD1zHOaIh4Dmy1X9wup18IiY9tTNk5gJc4yx9ctj/fjrIw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.1.tgz", + "integrity": "sha512-n0edDmSHlXFhrlmTK7XBuwKlG5MbS7yleS1cQ9nn4kIeW+dJH+ExqNgQ0RrFRew8Y+0V/x6C5IjsHrJmiHtkxQ==", "cpu": [ "arm" ], @@ -1184,9 +1185,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.43.0.tgz", - "integrity": "sha512-ZJ3gZynL1LDSIvRfz0qXtTNs56n5DI2Mq+WACWZ7yGHFUEirHBRt7fyIk0NsCKhmRhn7WAcjgSkSVVxKlPNFFw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.1.tgz", + "integrity": "sha512-8WVUPy3FtAsKSpyk21kV52HCxB+me6YkbkFHATzC2Yd3yuqHwy2lbFL4alJOLXKljoRw08Zk8/xEj89cLQ/4Nw==", "cpu": [ "arm" ], @@ -1198,9 +1199,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.43.0.tgz", - "integrity": "sha512-8FnkipasmOOSSlfucGYEu58U8cxEdhziKjPD2FIa0ONVMxvl/hmONtX/7y4vGjdUhjcTHlKlDhw3H9t98fPvyA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.1.tgz", + "integrity": "sha512-yuktAOaeOgorWDeFJggjuCkMGeITfqvPgkIXhDqsfKX8J3jGyxdDZgBV/2kj/2DyPaLiX6bPdjJDTu9RB8lUPQ==", "cpu": [ "arm64" ], @@ -1212,9 +1213,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.43.0.tgz", - "integrity": "sha512-KPPyAdlcIZ6S9C3S2cndXDkV0Bb1OSMsX0Eelr2Bay4EsF9yi9u9uzc9RniK3mcUGCLhWY9oLr6er80P5DE6XA==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.1.tgz", + "integrity": "sha512-W+GBM4ifET1Plw8pdVaecwUgxmiH23CfAUj32u8knq0JPFyK4weRy6H7ooxYFD19YxBulL0Ktsflg5XS7+7u9g==", "cpu": [ "arm64" ], @@ -1226,9 +1227,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.43.0.tgz", - "integrity": "sha512-HPGDIH0/ZzAZjvtlXj6g+KDQ9ZMHfSP553za7o2Odegb/BEfwJcR0Sw0RLNpQ9nC6Gy8s+3mSS9xjZ0n3rhcYg==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.1.tgz", + "integrity": "sha512-1zqnUEMWp9WrGVuVak6jWTl4fEtrVKfZY7CvcBmUUpxAJ7WcSowPSAWIKa/0o5mBL/Ij50SIf9tuirGx63Ovew==", "cpu": [ "loong64" ], @@ -1240,9 +1241,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.43.0.tgz", - "integrity": "sha512-gEmwbOws4U4GLAJDhhtSPWPXUzDfMRedT3hFMyRAvM9Mrnj+dJIFIeL7otsv2WF3D7GrV0GIewW0y28dOYWkmw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.1.tgz", + "integrity": "sha512-Rl3JKaRu0LHIx7ExBAAnf0JcOQetQffaw34T8vLlg9b1IhzcBgaIdnvEbbsZq9uZp3uAH+JkHd20Nwn0h9zPjA==", "cpu": [ "ppc64" ], @@ -1254,9 +1255,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.43.0.tgz", - "integrity": "sha512-XXKvo2e+wFtXZF/9xoWohHg+MuRnvO29TI5Hqe9xwN5uN8NKUYy7tXUG3EZAlfchufNCTHNGjEx7uN78KsBo0g==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.1.tgz", + "integrity": "sha512-j5akelU3snyL6K3N/iX7otLBIl347fGwmd95U5gS/7z6T4ftK288jKq3A5lcFKcx7wwzb5rgNvAg3ZbV4BqUSw==", "cpu": [ "riscv64" ], @@ -1268,9 +1269,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.43.0.tgz", - "integrity": "sha512-ruf3hPWhjw6uDFsOAzmbNIvlXFXlBQ4nk57Sec8E8rUxs/AI4HD6xmiiasOOx/3QxS2f5eQMKTAwk7KHwpzr/Q==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.1.tgz", + "integrity": "sha512-ppn5llVGgrZw7yxbIm8TTvtj1EoPgYUAbfw0uDjIOzzoqlZlZrLJ/KuiE7uf5EpTpCTrNt1EdtzF0naMm0wGYg==", "cpu": [ "riscv64" ], @@ -1282,9 +1283,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.43.0.tgz", - "integrity": "sha512-QmNIAqDiEMEvFV15rsSnjoSmO0+eJLoKRD9EAa9rrYNwO/XRCtOGM3A5A0X+wmG+XRrw9Fxdsw+LnyYiZWWcVw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.1.tgz", + "integrity": "sha512-Hu6hEdix0oxtUma99jSP7xbvjkUM/ycke/AQQ4EC5g7jNRLLIwjcNwaUy95ZKBJJwg1ZowsclNnjYqzN4zwkAw==", "cpu": [ "s390x" ], @@ -1296,9 +1297,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz", - "integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.1.tgz", + "integrity": "sha512-EtnsrmZGomz9WxK1bR5079zee3+7a+AdFlghyd6VbAjgRJDbTANJ9dcPIPAi76uG05micpEL+gPGmAKYTschQw==", "cpu": [ "x64" ], @@ -1309,9 +1310,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.43.0.tgz", - "integrity": "sha512-3yATWgdeXyuHtBhrLt98w+5fKurdqvs8B53LaoKD7P7H7FKOONLsBVMNl9ghPQZQuYcceV5CDyPfyfGpMWD9mQ==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.1.tgz", + "integrity": "sha512-iAS4p+J1az6Usn0f8xhgL4PaU878KEtutP4hqw52I4IO6AGoyOkHCxcc4bqufv1tQLdDWFx8lR9YlwxKuv3/3g==", "cpu": [ "x64" ], @@ -1323,9 +1324,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.43.0.tgz", - "integrity": "sha512-wVzXp2qDSCOpcBCT5WRWLmpJRIzv23valvcTwMHEobkjippNf+C3ys/+wf07poPkeNix0paTNemB2XrHr2TnGw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.1.tgz", + "integrity": "sha512-NtSJVKcXwcqozOl+FwI41OH3OApDyLk3kqTJgx8+gp6On9ZEt5mYhIsKNPGuaZr3p9T6NWPKGU/03Vw4CNU9qg==", "cpu": [ "arm64" ], @@ -1337,9 +1338,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.43.0.tgz", - "integrity": "sha512-fYCTEyzf8d+7diCw8b+asvWDCLMjsCEA8alvtAutqJOJp/wL5hs1rWSqJ1vkjgW0L2NB4bsYJrpKkiIPRR9dvw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.1.tgz", + "integrity": "sha512-JYA3qvCOLXSsnTR3oiyGws1Dm0YTuxAAeaYGVlGpUsHqloPcFjPg+X0Fj2qODGLNwQOAcCiQmHub/V007kiH5A==", "cpu": [ "ia32" ], @@ -1351,9 +1352,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.43.0.tgz", - "integrity": "sha512-SnGhLiE5rlK0ofq8kzuDkM0g7FN1s5VYY+YSMTibP7CqShxCQvqtNxTARS4xX4PFJfHjG0ZQYX9iGzI3FQh5Aw==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.1.tgz", + "integrity": "sha512-J8o22LuF0kTe7m+8PvW9wk3/bRq5+mRo5Dqo6+vXb7otCm3TPhYOJqOaQtGU9YMWQSL3krMnoOxMr0+9E6F3Ug==", "cpu": [ "x64" ], @@ -1389,9 +1390,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.0.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.3.tgz", - "integrity": "sha512-R4I/kzCYAdRLzfiCabn9hxWfbuHS573x+r0dJMkkzThEa7pbrcDWK+9zu3e7aBOouf+rQAciqPFMnxwr0aWgKg==", + "version": "24.0.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.4.tgz", + "integrity": "sha512-ulyqAkrhnuNq9pB76DRBTkcS6YsmDALy6Ua63V8OhrOBgbcYt6IOdzpw5P1+dyRIyMerzLkeYWBeOXPpA9GMAA==", "dev": true, "license": "MIT", "dependencies": { @@ -1402,7 +1403,7 @@ "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -2227,9 +2228,9 @@ } }, "node_modules/browserslist": { - "version": "4.25.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", - "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", "funding": [ { "type": "opencollective", @@ -2247,8 +2248,8 @@ "license": "MIT", "peer": true, "dependencies": { - "caniuse-lite": "^1.0.30001718", - "electron-to-chromium": "^1.5.160", + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, @@ -2326,9 +2327,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001723", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001723.tgz", - "integrity": "sha512-1R/elMjtehrFejxwmexeXAtae5UO9iSyFn6G/I806CYC/BLyyBk1EPhrKBkWhy6wM6Xnm47dSJQec+tLJ39WHw==", + "version": "1.0.30001726", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001726.tgz", + "integrity": "sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==", "funding": [ { "type": "opencollective", @@ -2485,9 +2486,9 @@ "peer": true }, "node_modules/connectycube": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/connectycube/-/connectycube-4.6.0.tgz", - "integrity": "sha512-Mzif5d1x4azu0ThYjL3NBW3fbVgsCMAo7tuKsNVUH01a5fUET1l/0KDOIhOyy5en4hulMXi2je/b6gIcWhLCoA==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/connectycube/-/connectycube-4.6.2.tgz", + "integrity": "sha512-Xxuu+qRuAITTqgJDh3ZPohh3eZgui2Wva3qS3BOoHcv0GD4xRyr3tWp7/gyX4BZXezg5CRr5erUAiZaQbdHEtg==", "license": "Apache-2.0", "peer": true, "dependencies": { @@ -2495,8 +2496,25 @@ "eventemitter3": "^5.0.1", "form-data": "4.0.3", "node-fetch": "2.7.0" + }, + "optionalDependencies": { + "@rollup/rollup-linux-x64-gnu": "4.43.0" } }, + "node_modules/connectycube/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.43.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.43.0.tgz", + "integrity": "sha512-jAHr/S0iiBtFyzjhOkAics/2SrXE092qyqEg96e90L3t9Op8OTzS6+IX0Fy5wCt2+KqeHAkti+eitV0wvblEoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2564,7 +2582,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/data-uri-to-buffer": { @@ -2747,9 +2765,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.170", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.170.tgz", - "integrity": "sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA==", + "version": "1.5.176", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.176.tgz", + "integrity": "sha512-2nDK9orkm7M9ZZkjO3PjbEd3VUulQLyg5T9O3enJdFvUg46Hzd4DUvTvAuEgbdHYXyFsiG4A5sO9IzToMH1cDg==", "license": "ISC", "peer": true }, @@ -4613,9 +4631,9 @@ "license": "MIT" }, "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", "dev": true, "license": "MIT", "engines": { @@ -4714,9 +4732,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.1.tgz", + "integrity": "sha512-5xGWRa90Sp2+x1dQtNpIpeOQpTDBs9cZDmA/qs2vDNN2i18PdapqY7CmBeyLlMuGqXJRIOPaCaVZTLNQRWUH/A==", "dev": true, "license": "MIT", "bin": { @@ -4897,13 +4915,13 @@ } }, "node_modules/rollup": { - "version": "4.43.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.43.0.tgz", - "integrity": "sha512-wdN2Kd3Twh8MAEOEJZsuxuLKCsBEo4PVNLK6tQWAn10VhsVewQLzcucMgLolRlhFybGxfclbPeEYBaP6RvUFGg==", + "version": "4.44.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.1.tgz", + "integrity": "sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -4913,26 +4931,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.43.0", - "@rollup/rollup-android-arm64": "4.43.0", - "@rollup/rollup-darwin-arm64": "4.43.0", - "@rollup/rollup-darwin-x64": "4.43.0", - "@rollup/rollup-freebsd-arm64": "4.43.0", - "@rollup/rollup-freebsd-x64": "4.43.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.43.0", - "@rollup/rollup-linux-arm-musleabihf": "4.43.0", - "@rollup/rollup-linux-arm64-gnu": "4.43.0", - "@rollup/rollup-linux-arm64-musl": "4.43.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.43.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.43.0", - "@rollup/rollup-linux-riscv64-gnu": "4.43.0", - "@rollup/rollup-linux-riscv64-musl": "4.43.0", - "@rollup/rollup-linux-s390x-gnu": "4.43.0", - "@rollup/rollup-linux-x64-gnu": "4.43.0", - "@rollup/rollup-linux-x64-musl": "4.43.0", - "@rollup/rollup-win32-arm64-msvc": "4.43.0", - "@rollup/rollup-win32-ia32-msvc": "4.43.0", - "@rollup/rollup-win32-x64-msvc": "4.43.0", + "@rollup/rollup-android-arm-eabi": "4.44.1", + "@rollup/rollup-android-arm64": "4.44.1", + "@rollup/rollup-darwin-arm64": "4.44.1", + "@rollup/rollup-darwin-x64": "4.44.1", + "@rollup/rollup-freebsd-arm64": "4.44.1", + "@rollup/rollup-freebsd-x64": "4.44.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.44.1", + "@rollup/rollup-linux-arm-musleabihf": "4.44.1", + "@rollup/rollup-linux-arm64-gnu": "4.44.1", + "@rollup/rollup-linux-arm64-musl": "4.44.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.44.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-gnu": "4.44.1", + "@rollup/rollup-linux-riscv64-musl": "4.44.1", + "@rollup/rollup-linux-s390x-gnu": "4.44.1", + "@rollup/rollup-linux-x64-gnu": "4.44.1", + "@rollup/rollup-linux-x64-musl": "4.44.1", + "@rollup/rollup-win32-arm64-msvc": "4.44.1", + "@rollup/rollup-win32-ia32-msvc": "4.44.1", + "@rollup/rollup-win32-x64-msvc": "4.44.1", "fsevents": "~2.3.2" } }, @@ -4946,13 +4964,6 @@ "rollup": "*" } }, - "node_modules/rollup/node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", - "dev": true, - "license": "MIT" - }, "node_modules/safe-array-concat": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz", @@ -5576,9 +5587,9 @@ } }, "node_modules/terser": { - "version": "5.43.0", - "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.0.tgz", - "integrity": "sha512-CqNNxKSGKSZCunSvwKLTs8u8sGGlp27sxNZ4quGh0QeNuyHM0JSEM/clM9Mf4zUp6J+tO2gUXhgXT2YMMkwfKQ==", + "version": "5.43.1", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.43.1.tgz", + "integrity": "sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -5922,24 +5933,24 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.0.tgz", + "integrity": "sha512-ixXJB1YRgDIw2OszKQS9WxGHKwLdCsbQNkpJN171udl6szi/rIySHL6/Os3s2+oE4P/FLD4dxg4mD7Wust+u5g==", "dev": true, "license": "MIT", "dependencies": { "esbuild": "^0.25.0", - "fdir": "^6.4.4", + "fdir": "^6.4.6", "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "postcss": "^8.5.6", + "rollup": "^4.40.0", + "tinyglobby": "^0.2.14" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -5948,14 +5959,14 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", - "less": "*", + "less": "^4.0.0", "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -6375,6 +6386,35 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "license": "ISC", "peer": true + }, + "node_modules/zustand": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.6.tgz", + "integrity": "sha512-ihAqNeUVhe0MAD+X8M5UzqyZ9k3FFZLBTtqo6JLPwV53cbRB/mJwBI0PxcIgqhBBHlEs8G45OTDTMq3gNcLq3A==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index e3dd018..40b5737 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@connectycube/use-chat", "description": "A React hook for state management in ConnectyCube-powered chat solutions", - "version": "0.26.1", + "version": "0.27.0", "homepage": "https://github.com/ConnectyCube/use-chat", "keywords": [ "react", @@ -39,30 +39,31 @@ }, "dependencies": { "date-fns": "^4.1.0", - "react-usestateref": "^1.0.9" + "react-usestateref": "^1.0.9", + "zustand": "^5.0.6" }, "devDependencies": { "@rollup/plugin-commonjs": "^28.0.6", "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-typescript": "^12.1.3", - "@types/node": "^24.0.3", + "@types/node": "^24.0.4", "@types/react": "^19.1.8", "@vitest/coverage-v8": "^3.2.4", - "prettier": "^3.5.3", - "rollup": "^4.43.0", + "prettier": "^3.6.1", + "rollup": "^4.44.1", "rollup-plugin-peer-deps-external": "^2.2.4", "tslib": "^2.8.1", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { - "connectycube": ">=4.6.0", + "connectycube": ">=4.6.2", "react": ">=18.0.0", "react-dom": ">=18.0.0" }, "optionalDependencies": { - "@rollup/rollup-linux-x64-gnu": "^4.43.0" + "@rollup/rollup-linux-x64-gnu": "^4.44.1" }, "engines": { "node": ">=18.0.0", diff --git a/src/ChatContext.tsx b/src/ChatContext.tsx index c610c16..e607b85 100644 --- a/src/ChatContext.tsx +++ b/src/ChatContext.tsx @@ -4,7 +4,7 @@ import ConnectyCube from "connectycube"; import { Chat, ChatEvent, ChatType, Dialogs, DialogType, Messages } from "connectycube/types"; import { formatDistanceToNow } from "date-fns"; import { ChatContextType, ChatProviderType, ChatStatus, DialogEventSignal, MessageStatus } from "./types"; -import { useBlockList, useNetworkStatus, useUsers } from "./hooks"; +import { useBlockList, useChatStore, useNetworkStatus, useUsers } from "./hooks"; import { getDialogTimestamp, parseDate } from "./helpers"; const ChatContext = createContext(undefined); @@ -44,8 +44,10 @@ export const ChatProvider = ({ children }: ChatProviderType): React.ReactElement // internal hooks const chatBlockList = useBlockList(isConnected); const chatUsers = useUsers(currentUserId); - const { isOnline } = useNetworkStatus(); + useNetworkStatus(); const { _retrieveAndStoreUsers } = chatUsers; + // global state + const isOnline = useChatStore((state) => state.isOnline); const connect = async (credentials: Chat.ConnectionParams): Promise => { setChatStatus(ChatStatus.CONNECTING); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index f442fef..39b998a 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,5 @@ export { default as useBlockList } from "./useBlockList"; +export { default as useChatStore } from "./useChatStore"; +export { default as useChatStoreRef } from "./useChatStoreRef"; export { default as useNetworkStatus } from "./useNetworkStatus"; export { default as useUsers } from "./useUsers"; diff --git a/src/hooks/useBlockList.ts b/src/hooks/useBlockList.ts index 647567c..48abcc9 100644 --- a/src/hooks/useBlockList.ts +++ b/src/hooks/useBlockList.ts @@ -1,6 +1,8 @@ import ConnectyCube from "connectycube"; import { PrivacyListAction } from "connectycube/types"; import { useEffect, useState, useRef } from "react"; +import useChatStore from "./useChatStore"; +import { useShallow } from "zustand/shallow"; export const BLOCK_LIST_LOG_TAG = "[useChat][useBlockList]"; export const BLOCK_LIST_NAME = "ConnectyCubeBlockList"; @@ -13,10 +15,11 @@ export type BlockListHook = { }; function useBlockList(isConnected: boolean): BlockListHook { + const [blockedUsers] = useChatStore(useShallow((state) => [state.blockedUsers, state.setBlockedUsers])); const [state, setState] = useState>(new Set()); const isApplied = useRef(false); - const isBlocked = (userId: number): boolean => state.has(userId); + const isBlocked = (userId: number): boolean => blockedUsers.has(userId); const fetch = async (): Promise => { if (!isConnected) { diff --git a/src/hooks/useChatStore.ts b/src/hooks/useChatStore.ts new file mode 100644 index 0000000..b1f8168 --- /dev/null +++ b/src/hooks/useChatStore.ts @@ -0,0 +1,90 @@ +import { Users } from "connectycube/types"; +import { create } from "zustand"; +import { subscribeWithSelector } from "zustand/middleware"; + +export type UserItem = Users.User; +export type UsersArray = UserItem[]; +export type UsersObject = { [key: number]: UserItem }; +export type UsersLastActivity = { [key: number]: string }; + +export interface BlockListStoreState { + blockedUsers: Set; +} +export interface NetworkStatusStoreState { + isOnline: boolean; +} +export interface UsersStoreState { + users: UsersObject; + onlineUsers: UsersObject; + onlineUsersCount: number; + lastActivity: UsersLastActivity; +} +export interface ChatStoreState extends NetworkStatusStoreState, BlockListStoreState, UsersStoreState {} + +export interface BlockListStoreActions {} +export interface NetworkStatusStoreActions { + setIsOnline: (isOnline: boolean) => void; +} +export interface UsersStoreActions { + upsertUser: (user: UserItem) => void; + upsertUsers: (users: UsersArray) => void; + setOnlineUsers: (onlineUsers: UsersArray) => void; + updateOnlineUser: (onlineUser: UserItem) => void; + updateOnlineUsers: (onlineUsers: UsersArray) => void; + setOnlineUsersCount: (onlineUsersCount: number) => void; + upsertLastActivity: (userId: number, status: string) => void; +} +export interface ChatStoreActions extends NetworkStatusStoreActions, BlockListStoreActions, UsersStoreActions { + resetStore: () => void; +} + +interface ChatStore extends ChatStoreState, ChatStoreActions {} + +const initialBlockListState = { + blockedUsers: new Set(), +}; +const initialNetworkStatusState = { + isOnline: navigator.onLine, +}; +const initialUsersState = { + users: {}, + onlineUsers: {}, + onlineUsersCount: 0, + lastActivity: {}, +}; +const initialState: ChatStoreState = { + ...initialBlockListState, + ...initialNetworkStatusState, + ...initialUsersState, +}; + +const useChatStore = create()( + subscribeWithSelector((set, get) => ({ + ...initialState, + upsertUser: (user: UserItem) => set({ users: { ...get().users, [user.id]: user } }), + upsertUsers: (users: UsersArray) => + set({ users: users.reduce((map, user) => ({ ...map, [user.id]: user }), { ...get().users }) }), + setOnlineUsers: (onlineUsers: UsersArray) => + set({ + onlineUsers: onlineUsers.reduce((map, user) => ({ ...map, [user.id]: user }), {}), + }), + updateOnlineUser: (onlineUser: UserItem) => + get().onlineUsers[onlineUser.id] + ? set({ onlineUsers: { ...get().onlineUsers, [onlineUser.id]: onlineUser } }) + : void 0, + updateOnlineUsers: (users: UsersArray) => + set({ + onlineUsers: users.reduce((map, user) => (map[user.id] ? { ...map, [user.id]: user } : map), { + ...get().onlineUsers, + }), + }), + setOnlineUsersCount: (onlineUsersCount: number) => set({ onlineUsersCount }), + upsertLastActivity: (userId: number, status: string) => + set({ lastActivity: { ...get().lastActivity, [userId]: status } }), + + setIsOnline: (isOnline?: boolean) => set({ isOnline }), + resetStore: () => set({ ...initialState }), + })), +); + +export default useChatStore; diff --git a/src/hooks/useChatStoreRef.ts b/src/hooks/useChatStoreRef.ts new file mode 100644 index 0000000..8d974e0 --- /dev/null +++ b/src/hooks/useChatStoreRef.ts @@ -0,0 +1,20 @@ +import { useEffect, useRef, RefObject } from "react"; +import useChatStore, { ChatStoreState } from "./useChatStore"; + +const useChatStoreRef = (key: K): RefObject => { + const ref = useRef(useChatStore.getState()[key]); + + useEffect(() => { + const unsubscribe = useChatStore.subscribe( + (state) => state[key], + (value) => { + ref.current = value; + }, + ); + return () => unsubscribe(); + }, [key]); + + return ref; +}; + +export default useChatStoreRef; diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts index b4cf6c3..6e63cc5 100644 --- a/src/hooks/useNetworkStatus.ts +++ b/src/hooks/useNetworkStatus.ts @@ -1,11 +1,13 @@ -import { useEffect, useState } from "react"; +import { useEffect } from "react"; +import useChatStore from "./useChatStore"; +import { useShallow } from "zustand/shallow"; -export type NetworkStatusHook = { +export interface NetworkStatusHook { isOnline: boolean; -}; +} function useNetworkStatus(): NetworkStatusHook { - const [isOnline, setIsOnline] = useState(navigator.onLine); + const [isOnline, setIsOnline] = useChatStore(useShallow((state) => [state.isOnline, state.setIsOnline])); useEffect(() => { const abortController1 = new AbortController(); diff --git a/src/hooks/useUsers.ts b/src/hooks/useUsers.ts index c6f2e14..6fd9256 100644 --- a/src/hooks/useUsers.ts +++ b/src/hooks/useUsers.ts @@ -1,34 +1,36 @@ import ConnectyCube from "connectycube"; import { Chat, ChatEvent, Users } from "connectycube/types"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useRef } from "react"; import { getLastActivityText } from "../helpers"; +import useChatStore from "./useChatStore"; +import { useShallow } from "zustand/shallow"; export const USERS_LOG_TAG = "[useChat][useUsers]"; export const LIMIT_ONLINE_USERS_INTERVAL = 60000; export const LIMIT_FETCH_USER_INTERVAL = 30000; export const MAX_REQUEST_LIMIT = 100; -export type FetchUsersLastRequestAt = { [userId: Users.User["id"]]: number }; -export type OnlineUsersLastRequestAt = number; export type UsersArray = Users.User[]; export type UsersObject = { [userId: Users.User["id"]]: Users.User }; export type UsersLastActivity = { [userId: number]: string }; +export type FetchUsersLastRequestAt = { [userId: Users.User["id"]]: number }; +export type OnlineUsersLastRequestAt = number; -export type UsersHookExports = { +export interface UsersHookExports { users: UsersObject; getAndStoreUsers: (params: Users.GetV2Params) => Promise; searchUsers: (term: string) => Promise; fetchUserById: (id: Users.User["id"], force?: boolean) => Promise; + onlineUsers: UsersArray; listOnlineUsers: (force?: boolean) => Promise; listOnlineUsersWithParams: (params: Users.ListOnlineParams) => Promise; - onlineUsers: UsersArray; - getOnlineUsersCount: () => Promise; onlineUsersCount: number; + getOnlineUsersCount: () => Promise; lastActivity: UsersLastActivity; getLastActivity: (userId: number) => Promise; subscribeToUserLastActivityStatus: (userId: number) => void; unsubscribeFromUserLastActivityStatus: (userId: number) => void; -}; +} export type UsersHook = { exports: UsersHookExports; @@ -36,10 +38,33 @@ export type UsersHook = { }; function useUsers(currentUserId?: number): UsersHook { - const [users, setUsers] = useState({}); - const [onlineUsers, setOnlineUsers] = useState({}); - const [onlineUsersCount, setOnlineUsersCount] = useState(0); - const [lastActivity, setLastActivity] = useState({}); + const [ + users, + upsertUser, + upsertUsers, + onlineUsers, + setOnlineUsers, + updateOnlineUser, + updateOnlineUsers, + onlineUsersCount, + setOnlineUsersCount, + lastActivity, + upsertLastActivity, + ] = useChatStore( + useShallow((state) => [ + state.users, + state.upsertUser, + state.upsertUsers, + state.onlineUsers, + state.setOnlineUsers, + state.updateOnlineUser, + state.updateOnlineUsers, + state.onlineUsersCount, + state.setOnlineUsersCount, + state.lastActivity, + state.upsertLastActivity, + ]), + ); const onlineUsersLastRequestAtRef = useRef(0); const fetchUsersLastRequestAtRef = useRef({}); @@ -47,12 +72,8 @@ function useUsers(currentUserId?: number): UsersHook { const getAndStoreUsers = async (params: Users.GetV2Params): Promise => { const { items } = await ConnectyCube.users.getV2(params); - setUsers((prevUsersState) => - items.reduce((map, user) => ({ ...map, [user.id]: user }), { ...prevUsersState }), - ); - setOnlineUsers((prevState) => - items.reduce((map, user) => (map[user.id] ? { ...map, [user.id]: user } : map), { ...prevState }), - ); + upsertUsers(items); + updateOnlineUsers(items); items.forEach((user) => { fetchUsersLastRequestAtRef.current[user.id] = Date.now(); @@ -81,8 +102,9 @@ function useUsers(currentUserId?: number): UsersHook { const fetchedUser = result?.items?.[0]; if (fetchedUser) { - setUsers((prevState) => ({ ...prevState, [id]: fetchedUser })); - setOnlineUsers((prevState) => (prevState[id] ? { ...prevState, [id]: fetchedUser } : prevState)); + upsertUser(fetchedUser); + updateOnlineUser(fetchedUser); + fetchUsersLastRequestAtRef.current[id] = Date.now(); user = fetchedUser; } @@ -117,6 +139,7 @@ function useUsers(currentUserId?: number): UsersHook { try { const { count } = await ConnectyCube.users.getOnlineCount(); + nextOnlineUsersCount = count; setOnlineUsersCount(nextOnlineUsersCount); } catch (error) { @@ -126,12 +149,10 @@ function useUsers(currentUserId?: number): UsersHook { return nextOnlineUsersCount; }; - const _listOnline = async (): Promise => { + const _listOnline = async (): Promise => { const onlineUsersCount = await getOnlineUsersCount(); const promises = []; - let onlineUsersState: UsersObject = {}; - try { let limit = MAX_REQUEST_LIMIT; let offset = 0; @@ -142,40 +163,30 @@ function useUsers(currentUserId?: number): UsersHook { } const results = await Promise.all(promises); - const allUsers = results.flat(); + const onlineUsers = results.flat(); - onlineUsersState = allUsers.reduce((map, user) => { - map[user.id] = user; - return map; - }, {}); + upsertUsers(onlineUsers); + setOnlineUsers(onlineUsers); - setUsers((prevUsersState) => ({ ...prevUsersState, ...onlineUsersState })); - setOnlineUsers(onlineUsersState); + return onlineUsers; } catch (error) { console.error(`${USERS_LOG_TAG}[listOnline][Error]:`, error); + return []; } - - return onlineUsersState; }; const listOnlineUsersWithParams = async (params: Users.ListOnlineParams): Promise => { - let onlineUsersState: UsersObject = {}; - try { - const { users: allUsers } = await ConnectyCube.users.listOnline(params); + const { users: onlineUsers } = await ConnectyCube.users.listOnline(params); - onlineUsersState = allUsers.reduce((map, user) => { - map[user.id] = user; - return map; - }, {}); + upsertUsers(onlineUsers); + setOnlineUsers(onlineUsers); - setUsers((prevUsersState) => ({ ...prevUsersState, ...onlineUsersState })); - setOnlineUsers(onlineUsersState); + return onlineUsers; } catch (error) { console.error(`${USERS_LOG_TAG}[listOnlineWithParams][Error]:`, error); + return []; } - - return Object.values(onlineUsersState); }; const listOnlineUsers = async (force: boolean = false): Promise => { @@ -183,27 +194,28 @@ function useUsers(currentUserId?: number): UsersHook { const currentTimestamp = Date.now(); const shouldRequest = currentTimestamp - lastRequestedAt > LIMIT_ONLINE_USERS_INTERVAL; - let onlineUsersState = onlineUsers; - if (shouldRequest || force) { - onlineUsersState = await _listOnline(); + const newOnlineUsers = await _listOnline(); + onlineUsersLastRequestAtRef.current = Date.now(); - } - return Object.values(onlineUsersState); + return newOnlineUsers; + } else { + return Object.values(onlineUsers); + } }; const getLastActivity = async (userId: number): Promise => { - let status = "Last seen recently"; - try { const { seconds } = await ConnectyCube.chat.getLastUserActivity(userId); - status = getLastActivityText(seconds); + const status = getLastActivityText(seconds); + + upsertLastActivity(userId, status); + + return status; } catch (error) { console.error(`${USERS_LOG_TAG}[getLastActivity][Error]:`, error); - } finally { - setLastActivity((prevLastActivity) => ({ ...prevLastActivity, [userId]: status })); - return status; + return "Last seen recently"; } }; @@ -221,8 +233,7 @@ function useUsers(currentUserId?: number): UsersHook { seconds: Chat.LastActivity["seconds"], ) => { if (typeof userId === "number" && seconds >= 0) { - const status = getLastActivityText(seconds); - setLastActivity((prevLastActivity) => ({ ...prevLastActivity, [userId]: status })); + upsertLastActivity(userId, getLastActivityText(seconds)); } }; @@ -240,11 +251,11 @@ function useUsers(currentUserId?: number): UsersHook { getAndStoreUsers, searchUsers, fetchUserById, + onlineUsers: Object.values(onlineUsers), listOnlineUsers, listOnlineUsersWithParams, - onlineUsers: Object.values(onlineUsers), - getOnlineUsersCount, onlineUsersCount, + getOnlineUsersCount, lastActivity, getLastActivity, subscribeToUserLastActivityStatus, From e70a5188cec1e8b96aab500c0df9369fdcfcb61c Mon Sep 17 00:00:00 2001 From: Vlad Date: Mon, 14 Jul 2025 16:34:09 +0300 Subject: [PATCH 2/3] . --- src/hooks/useBlockList.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/hooks/useBlockList.ts b/src/hooks/useBlockList.ts index 48abcc9..dd1a71b 100644 --- a/src/hooks/useBlockList.ts +++ b/src/hooks/useBlockList.ts @@ -15,7 +15,9 @@ export type BlockListHook = { }; function useBlockList(isConnected: boolean): BlockListHook { - const [blockedUsers] = useChatStore(useShallow((state) => [state.blockedUsers, state.setBlockedUsers])); + const [blockedUsers, setBlockedUsers] = useChatStore( + useShallow((state) => [state.blockedUsers, state.setBlockedUsers]), + ); const [state, setState] = useState>(new Set()); const isApplied = useRef(false); From 75d60baa98ab28ecbd127c57734f5d6d13951c04 Mon Sep 17 00:00:00 2001 From: Vlad Date: Wed, 30 Jul 2025 16:56:25 +0300 Subject: [PATCH 3/3] save --- src/ChatContext.tsx | 80 ++- src/hooks/index.ts | 5 +- src/hooks/useBlockList.ts | 19 +- src/hooks/useChat.tsx | 1021 +++++++++++++++++++++++++++++++++ src/hooks/useChatStore.ts | 14 +- src/hooks/useNetworkStatus.ts | 4 +- src/index.ts | 1 - src/tests/helpers.test.ts | 2 +- src/types/index.ts | 76 --- 9 files changed, 1121 insertions(+), 101 deletions(-) create mode 100644 src/hooks/useChat.tsx delete mode 100644 src/types/index.ts diff --git a/src/ChatContext.tsx b/src/ChatContext.tsx index d603ff2..c19d449 100644 --- a/src/ChatContext.tsx +++ b/src/ChatContext.tsx @@ -1,13 +1,85 @@ -import { createContext, useContext, useEffect, useRef, useState } from "react"; +import { createContext, ReactNode, useContext, useEffect, useRef, useState } from "react"; import useStateRef from "react-usestateref"; import ConnectyCube from "connectycube"; import { Chat, ChatEvent, ChatType, Dialogs, DialogType, Messages } from "connectycube/types"; import { formatDistanceToNow } from "date-fns"; -import { ChatContextType, ChatProviderType, ChatStatus, DialogEventSignal, MessageStatus } from "./types"; import { useBlockList, useChatStore, useNetworkStatus, useUsers } from "./hooks"; import { getDialogTimestamp, parseDate } from "./helpers"; +import { BlockListHook } from "./hooks/useBlockList"; +import { UsersHookExports } from "./hooks/useUsers"; + +export enum DialogEventSignal { + ADDED_TO_DIALOG = "dialog/ADDED_TO_DIALOG", + REMOVED_FROM_DIALOG = "dialog/REMOVED_FROM_DIALOG", + ADD_PARTICIPANTS = "dialog/ADD_PARTICIPANTS", + REMOVE_PARTICIPANTS = "dialog/REMOVE_PARTICIPANTS", + NEW_DIALOG = "dialog/NEW_DIALOG", +} + +export enum MessageStatus { + WAIT = "wait", + LOST = "lost", + SENT = "sent", + READ = "read", +} + +export enum ChatStatus { + DISCONNECTED = "disconnected", + CONNECTING = "connecting", + CONNECTED = "connected", + NOT_AUTHORIZED = "not-authorized", + ERROR = "error", +} +export interface ChatProviderType { + children?: ReactNode; +} +export interface ChatContextType extends BlockListHook, UsersHookExports { + isOnline: boolean; + isConnected: boolean; + chatStatus: ChatStatus; + connect: (credentials: Chat.ConnectionParams) => Promise; + disconnect: () => Promise; + terminate: () => void; + currentUserId?: number; + createChat: (userId: number, extensions?: { [key: string]: any }) => Promise; + createGroupChat: ( + usersIds: number[], + name: string, + photo?: string, + extensions?: { [key: string]: any }, + ) => Promise; + getDialogs: (filters?: Dialogs.ListParams) => Promise; + getNextDialogs: () => Promise; + totalDialogReached: boolean; + dialogs: Dialogs.Dialog[]; + selectedDialog?: Dialogs.Dialog; + selectDialog: (dialog?: Dialogs.Dialog) => Promise; + getDialogOpponentId: (dialog?: Dialogs.Dialog) => number | undefined; + unreadMessagesCount: { total: number; [dialogId: string]: number }; + getMessages: (dialogId: string) => Promise; + getNextMessages: (dialogId: string) => Promise; + totalMessagesReached: { [dialogId: string]: boolean }; + messages: { [key: string]: Messages.Message[] }; + markDialogAsRead: (dialog: Dialogs.Dialog) => Promise; + addUsersToGroupChat: (usersIds: number[]) => Promise; + removeUsersFromGroupChat: (usersIds: number[]) => Promise; + leaveGroupChat: () => Promise; + sendSignal: (userIdOrIds: number | number[], signal: string, params?: any) => void; + sendMessage: (body: string, dialog?: Dialogs.Dialog) => void; + sendMessageWithAttachment: (files: File[], dialog?: Dialogs.Dialog) => Promise; + readMessage: (messageId: string, userId: number, dialogId: string) => void; + sendTypingStatus: (dialog?: Dialogs.Dialog, isTyping?: boolean) => void; + typingStatus: { [dialogId: string]: number[] }; + lastMessageSentTimeString: (dialog: Dialogs.Dialog) => string; + messageSentTimeString: (message: Messages.Message) => string; + processOnSignal: (fn: Chat.OnMessageSystemListener | null) => void; + processOnMessage: (fn: Chat.OnMessageListener | null) => void; + processOnMessageError: (fn: Chat.OnMessageErrorListener | null) => void; + processOnMessageSent: (fn: Chat.OnMessageSentListener | null) => void; +} const ChatContext = createContext(undefined); + ChatContext.displayName = "ChatContext"; export const useChat = (): ChatContextType => { @@ -44,10 +116,10 @@ export const ChatProvider = ({ children }: ChatProviderType): React.ReactElement // internal hooks const chatBlockList = useBlockList(isConnected); const chatUsers = useUsers(currentUserId); - useNetworkStatus(isConnected); + const isOnline = useNetworkStatus(isConnected); const { _retrieveAndStoreUsers } = chatUsers; // global state - const isOnline = useChatStore((state) => state.isOnline); + // const isOnline = useChatStore((state) => state.isOnline); const connect = async (credentials: Chat.ConnectionParams): Promise => { setChatStatus(ChatStatus.CONNECTING); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 39b998a..2f99f47 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,5 +1,6 @@ export { default as useBlockList } from "./useBlockList"; -export { default as useChatStore } from "./useChatStore"; -export { default as useChatStoreRef } from "./useChatStoreRef"; export { default as useNetworkStatus } from "./useNetworkStatus"; export { default as useUsers } from "./useUsers"; +export { useChat as useChat } from "./useChat"; +export { default as useChatStore } from "./useChatStore"; +export { default as useChatStoreRef } from "./useChatStoreRef"; diff --git a/src/hooks/useBlockList.ts b/src/hooks/useBlockList.ts index dd1a71b..4357345 100644 --- a/src/hooks/useBlockList.ts +++ b/src/hooks/useBlockList.ts @@ -1,6 +1,6 @@ import ConnectyCube from "connectycube"; import { PrivacyListAction } from "connectycube/types"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useRef } from "react"; import useChatStore from "./useChatStore"; import { useShallow } from "zustand/shallow"; @@ -15,11 +15,10 @@ export type BlockListHook = { }; function useBlockList(isConnected: boolean): BlockListHook { + const isApplied = useRef(false); const [blockedUsers, setBlockedUsers] = useChatStore( useShallow((state) => [state.blockedUsers, state.setBlockedUsers]), ); - const [state, setState] = useState>(new Set()); - const isApplied = useRef(false); const isBlocked = (userId: number): boolean => blockedUsers.has(userId); @@ -42,7 +41,7 @@ function useBlockList(isConnected: boolean): BlockListHook { isApplied.current = true; - setState(newState); + setBlockedUsers(newState); } }; @@ -52,7 +51,7 @@ function useBlockList(isConnected: boolean): BlockListHook { return; } - const newState = new Set(state); + const newBlockedUsers = new Set(blockedUsers); const blockList = { name: BLOCK_LIST_NAME, @@ -61,15 +60,15 @@ function useBlockList(isConnected: boolean): BlockListHook { try { if (action === PrivacyListAction.DENY) { - newState.add(user_id); + newBlockedUsers.add(user_id); } else if (action === PrivacyListAction.ALLOW) { - newState.delete(user_id); + newBlockedUsers.delete(user_id); } if (isApplied.current) { await ConnectyCube.chat.privacylist.setAsDefault(null); await ConnectyCube.chat.privacylist.update(blockList); - if (newState.size > 0) { + if (newBlockedUsers.size > 0) { await ConnectyCube.chat.privacylist.setAsDefault(BLOCK_LIST_NAME); } } else { @@ -79,7 +78,7 @@ function useBlockList(isConnected: boolean): BlockListHook { } catch (error) { return; } finally { - setState(newState); + setBlockedUsers(blockedUsers); } }; @@ -108,7 +107,7 @@ function useBlockList(isConnected: boolean): BlockListHook { }, [isConnected]); return { - blockedUsers: Array.from(state), + blockedUsers: Array.from(blockedUsers), isBlockedUser: isBlocked, unblockUser: unblock, blockUser: block, diff --git a/src/hooks/useChat.tsx b/src/hooks/useChat.tsx new file mode 100644 index 0000000..c93704d --- /dev/null +++ b/src/hooks/useChat.tsx @@ -0,0 +1,1021 @@ +import { useEffect, useRef, useState } from "react"; +import useStateRef from "react-usestateref"; +import ConnectyCube from "connectycube"; +import { Chat, ChatEvent, ChatType, Dialogs, DialogType, Messages } from "connectycube/types"; +import { formatDistanceToNow } from "date-fns"; +import useChatStore from "./useChatStore"; +import useBlockList, { BlockListHook } from "./useBlockList"; +import useUsers, { UsersHookExports } from "./useUsers"; +import useNetworkStatus, { NetworkStatusHook } from "./useNetworkStatus"; +import { getDialogTimestamp, parseDate } from "../helpers"; +import { useShallow } from "zustand/shallow"; +import useChatStoreRef from "./useChatStoreRef"; + +export enum DialogEventSignal { + ADDED_TO_DIALOG = "dialog/ADDED_TO_DIALOG", + REMOVED_FROM_DIALOG = "dialog/REMOVED_FROM_DIALOG", + ADD_PARTICIPANTS = "dialog/ADD_PARTICIPANTS", + REMOVE_PARTICIPANTS = "dialog/REMOVE_PARTICIPANTS", + NEW_DIALOG = "dialog/NEW_DIALOG", +} + +export enum MessageStatus { + WAIT = "wait", + LOST = "lost", + SENT = "sent", + READ = "read", +} + +export enum ChatStatus { + DISCONNECTED = "disconnected", + CONNECTING = "connecting", + CONNECTED = "connected", + NOT_AUTHORIZED = "not-authorized", + ERROR = "error", +} + +export interface ChatHook extends BlockListHook, UsersHookExports, NetworkStatusHook { + isConnected: boolean; + chatStatus: ChatStatus; + connect: (credentials: Chat.ConnectionParams) => Promise; + disconnect: () => Promise; + terminate: () => void; + currentUserId?: number; + createChat: (userId: number, extensions?: { [key: string]: any }) => Promise; + createGroupChat: ( + usersIds: number[], + name: string, + photo?: string, + extensions?: { [key: string]: any }, + ) => Promise; + getDialogs: (filters?: Dialogs.ListParams) => Promise; + getNextDialogs: () => Promise; + totalDialogReached: boolean; + dialogs: Dialogs.Dialog[]; + selectedDialog?: Dialogs.Dialog; + selectDialog: (dialog?: Dialogs.Dialog) => Promise; + getDialogOpponentId: (dialog?: Dialogs.Dialog) => number | undefined; + unreadMessagesCount: { total: number; [dialogId: string]: number }; + getMessages: (dialogId: string) => Promise; + getNextMessages: (dialogId: string) => Promise; + totalMessagesReached: { [dialogId: string]: boolean }; + messages: { [key: string]: Messages.Message[] }; + markDialogAsRead: (dialog: Dialogs.Dialog) => Promise; + addUsersToGroupChat: (usersIds: number[]) => Promise; + removeUsersFromGroupChat: (usersIds: number[]) => Promise; + leaveGroupChat: () => Promise; + sendSignal: (userIdOrIds: number | number[], signal: string, params?: any) => void; + sendMessage: (body: string, dialog?: Dialogs.Dialog) => void; + sendMessageWithAttachment: (files: File[], dialog?: Dialogs.Dialog) => Promise; + readMessage: (messageId: string, userId: number, dialogId: string) => void; + sendTypingStatus: (dialog?: Dialogs.Dialog, isTyping?: boolean) => void; + typingStatus: { [dialogId: string]: number[] }; + lastMessageSentTimeString: (dialog: Dialogs.Dialog) => string; + messageSentTimeString: (message: Messages.Message) => string; + processOnSignal: (fn: Chat.OnMessageSystemListener | null) => void; + processOnMessage: (fn: Chat.OnMessageListener | null) => void; + processOnMessageError: (fn: Chat.OnMessageErrorListener | null) => void; + processOnMessageSent: (fn: Chat.OnMessageSentListener | null) => void; +} + +export const ChatProvider = (): ChatHook => { + // refs + const typingTimers = useRef<{ [dialogId: string]: { [userId: number | string]: NodeJS.Timeout } }>({}); + const onMessageRef = useRef(null); + const onSignalRef = useRef(null); + const onMessageSentRef = useRef(null); + const onMessageErrorRef = useRef(null); + const activatedDialogsRef = useRef<{ [dialogId: string]: boolean }>({}); + const privateDialogsIdsRef = useRef<{ [userId: number | string]: string }>({}); + // global state + const [ + isOnline, + isConnected, + setIsConnected, + unreadMessagesCount, + setUnreadMessagesCount, + typingStatus, + setTypingStatus, + totalMessagesReached, + setTotalMessagesReached, + totalDialogReached, + setTotalDialogReached, + ] = useChatStore( + useShallow((state) => [ + state.isOnline, + state.isConnected, + state.setIsConnected, + state.unreadMessagesCount, + state.setUnreadMessagesCount, + state.typingStatus, + state.setTypingStatus, + state.totalMessagesReached, + state.setTotalMessagesReached, + state.totalDialogReached, + state.setTotalDialogReached, + ]), + ); + // state + const [isConnected, setIsConnected] = useState(false); + const [unreadMessagesCount, setUnreadMessagesCount] = useState({ total: 0 }); + const [typingStatus, setTypingStatus] = useState<{ [dialogId: string]: number[] }>({}); + const [totalMessagesReached, setTotalMessagesReached] = useState<{ [dialogId: string]: boolean }>({}); + const [totalDialogReached, setTotalDialogReached] = useState(false); + // state refs + // const messagesRef = useChatStoreRef('messages'); + // const dialogsRef = useChatStoreRef('dialogs'); + // const currentUserIdRef = useChatStoreRef('currentUserId'); + // const selectedDialogRef = useChatStoreRef('selectedDialog'); + // const chatStatusRef = useChatStoreRef('chatStatus'); + const [messages, setMessages, messagesRef] = useStateRef<{ [dialogId: string]: Messages.Message[] }>({}); + const [dialogs, setDialogs, dialogsRef] = useStateRef([]); + const [currentUserId, setCurrentUserId, currentUserIdRef] = useStateRef(); + const [selectedDialog, setSelectedDialog, selectedDialogRef] = useStateRef(); + const [chatStatus, setChatStatus, chatStatusRef] = useStateRef(ChatStatus.DISCONNECTED); + // internal hooks + const chatBlockList = useBlockList(isConnected); + const chatUsers = useUsers(currentUserId); + const networkStatus = useNetworkStatus(isConnected); + const { _retrieveAndStoreUsers } = chatUsers; + + const connect = async (credentials: Chat.ConnectionParams): Promise => { + setChatStatus(ChatStatus.CONNECTING); + + try { + const _isConnected = await ConnectyCube.chat.connect(credentials); + + if (_isConnected) { + setChatStatus(ChatStatus.CONNECTED); + setIsConnected(_isConnected); + setCurrentUserId(credentials.userId); + } + + return _isConnected; + } catch (error) { + setChatStatus(ChatStatus.DISCONNECTED); + console.error(`Failed to connect due to ${error}`); + + return false; + } + }; + + const disconnect = async (status: ChatStatus = ChatStatus.DISCONNECTED): Promise => { + let disconnected = false; + + if (ConnectyCube.chat.isConnected) { + disconnected = await ConnectyCube.chat.disconnect(); + + setIsConnected(false); + setCurrentUserId(undefined); + setChatStatus(status); + _resetDialogsAndMessagesProgress(); + } + + return disconnected; + }; + + const terminate = (status: ChatStatus = ChatStatus.DISCONNECTED): void => { + ConnectyCube.chat.terminate(); + setChatStatus(status); + _resetDialogsAndMessagesProgress(); + _markMessagesAsLostInStore(); + }; + + const _resetDialogsAndMessagesProgress = () => { + activatedDialogsRef.current = {}; + setTotalDialogReached(false); + setTotalMessagesReached({}); + }; + + const _establishConnection = async (online: boolean) => { + if (online && chatStatusRef.current !== ChatStatus.ERROR) { + if (chatStatusRef.current === ChatStatus.DISCONNECTED || chatStatusRef.current === ChatStatus.NOT_AUTHORIZED) { + setChatStatus(ChatStatus.CONNECTING); + } + } else { + try { + await ConnectyCube.chat.pingWithTimeout(1000); + setChatStatus(ChatStatus.CONNECTED); + } catch (error) { + terminate(); + } + } + }; + + const createChat = async (userId: number, extensions?: { [key: string]: any }): Promise => { + const params = { type: DialogType.PRIVATE, occupants_ids: [userId], extensions }; + const dialog = await ConnectyCube.chat.dialog.create(params); + + setDialogs((prevDialogs) => [dialog, ...prevDialogs.filter((d) => d._id !== dialog._id)]); + setTotalMessagesReached((prevState) => ({ ...prevState, [dialog._id]: true })); + + privateDialogsIdsRef.current[userId] = dialog._id; + + _notifyUsers(DialogEventSignal.NEW_DIALOG, dialog._id, userId); + _retrieveAndStoreUsers([userId, currentUserId as number]); + + return dialog; + }; + + const createGroupChat = async ( + usersIds: number[], + name: string, + photo?: string, + extensions?: { [key: string]: any }, + ): Promise => { + const params = { name, photo, type: DialogType.GROUP, occupants_ids: usersIds, extensions }; + const dialog = await ConnectyCube.chat.dialog.create(params); + + setDialogs((prevDialogs) => [dialog, ...prevDialogs.filter((d) => d._id !== dialog._id)]); + setTotalMessagesReached((prevState) => ({ ...prevState, [dialog._id]: true })); + + usersIds.forEach((userId) => { + _notifyUsers(DialogEventSignal.NEW_DIALOG, dialog._id, userId); + }); + _retrieveAndStoreUsers([...usersIds, currentUserId as number]); + + return dialog; + }; + + const getDialogs = async (filters?: Dialogs.ListParams): Promise => { + const params = { sort_desc: "date_sent", limit: 100, skip: 0, ...filters }; + const { items: fetchedDialogs, skip, limit, total_entries } = await ConnectyCube.chat.dialog.list(params); + const reached = skip + limit >= total_entries; + + setTotalDialogReached(reached); + setDialogs((prevDialogs) => { + const allDialogs = [...prevDialogs, ...fetchedDialogs]; + const uniqueDialogs = Array.from(new Map(allDialogs.map((d) => [d._id, d])).values()); + return uniqueDialogs.sort((a, b) => getDialogTimestamp(b) - getDialogTimestamp(a)); + }); + + const usersIds = fetchedDialogs.flatMap((dialog) => dialog.occupants_ids); + const uniqueUsersIds = Array.from(new Set(usersIds)); + + _retrieveAndStoreUsers(uniqueUsersIds); + + return fetchedDialogs; + }; + + const getNextDialogs = async (): Promise => { + const skip = dialogsRef.current.length; + + return getDialogs({ skip }); + }; + + const _listMessagesByDialogId = async ( + dialogId: string, + listParams: Messages.ListParams = {}, + ): Promise => { + const params = { chat_dialog_id: dialogId, sort_desc: "date_sent", limit: 100, skip: 0, ...listParams }; + + try { + const { items: fetchedMessages, skip, limit } = await ConnectyCube.chat.message.list(params); + const existedMessages = messagesRef.current[dialogId] ?? []; + const reached = skip + limit > fetchedMessages.length + existedMessages.length; + + setTotalMessagesReached((prevState) => ({ ...prevState, [dialogId]: reached })); + + return fetchedMessages + .sort((a: Messages.Message, b: Messages.Message) => { + return a._id.toString().localeCompare(b._id.toString()); // revers sort + }) + .map((msg) => { + const attachments = msg.attachments?.map((attachment) => ({ + ...attachment, + url: ConnectyCube.storage.privateUrl(attachment.uid), + })); + return { ...msg, attachments, status: msg.read ? MessageStatus.READ : MessageStatus.SENT }; + }); + } catch (error: any) { + if (error.code === 404) { + return []; // dialog not found + } + throw error; + } + }; + + const getMessages = async (dialogId: string): Promise => { + try { + const retrievedMessages = await _listMessagesByDialogId(dialogId); + + setMessages((prevMessages) => ({ ...prevMessages, [dialogId]: retrievedMessages })); + + return retrievedMessages; + } catch (error: any) { + throw error; + } + }; + + const getNextMessages = async (dialogId: string): Promise => { + const dialogMessages = messagesRef.current[dialogId] ?? []; + const skip = dialogMessages.length; + + try { + const retrievedMessages = await _listMessagesByDialogId(dialogId, { skip }); + const allDialogMessages = [...retrievedMessages, ...dialogMessages]; + + setMessages((prevMessages) => ({ ...prevMessages, [dialogId]: allDialogMessages })); + + return allDialogMessages; + } catch (error: any) { + throw error; + } + }; + + const selectDialog = async (dialog?: Dialogs.Dialog): Promise => { + setSelectedDialog(dialog); + + if (!dialog) return; + + // retrieve messages if chat is not activated yet + if (!activatedDialogsRef.current[dialog._id]) { + await getMessages(dialog._id); + activatedDialogsRef.current[dialog._id] = true; + } + + if (dialog.unread_messages_count > 0) { + await markDialogAsRead(dialog).catch((_error) => {}); + } + }; + + const getDialogOpponentId = (dialog?: Dialogs.Dialog): number | undefined => { + dialog ??= selectedDialog; + + if (!dialog) { + throw "No dialog provided. You need to provide a dialog via function argument or select a dialog via 'selectDialog'."; + } + + if (dialog.type !== DialogType.PRIVATE) { + return undefined; + } + + const opponentId = dialog.occupants_ids.find((oid) => oid !== currentUserId); + + if (opponentId) { + privateDialogsIdsRef.current[opponentId] = dialog._id; + } + + return opponentId; + }; + + const _updateUnreadMessagesCount = () => { + const count: ChatHook["unreadMessagesCount"] = { total: 0 }; + + dialogs.forEach(({ _id, unread_messages_count = 0 }: Dialogs.Dialog) => { + if (_id !== selectedDialog?._id) { + count[_id] = unread_messages_count; + count.total += unread_messages_count; + } + }); + + setUnreadMessagesCount(count); + }; + + const markDialogAsRead = async (dialog: Dialogs.Dialog): Promise => { + const params = { read: 1, chat_dialog_id: dialog._id }; + await ConnectyCube.chat.message.update("", params); + + setDialogs((prevDialogs) => + prevDialogs.map((d) => (d._id === dialog._id ? { ...d, unread_messages_count: 0 } : d)), + ); + }; + + const addUsersToGroupChat = async (usersIds: number[]): Promise => { + if (!selectedDialog) { + throw new Error("No dialog selected"); + } + + const dialogId = selectedDialog._id; + const toUpdateParams = { push_all: { occupants_ids: usersIds } }; + + await ConnectyCube.chat.dialog.update(dialogId, toUpdateParams); + + selectedDialog.occupants_ids + .filter((userId) => userId !== currentUserId) + .forEach((userId) => { + _notifyUsers(DialogEventSignal.ADD_PARTICIPANTS, dialogId, userId, { + addedParticipantsIds: usersIds.join(), + }); + }); + + usersIds.forEach((userId) => { + _notifyUsers(DialogEventSignal.ADDED_TO_DIALOG, dialogId, userId); + }); + + _retrieveAndStoreUsers(usersIds); + + const updatedDialog = { + ...selectedDialog, + occupants_ids: Array.from(new Set([...selectedDialog.occupants_ids, ...usersIds])), + }; + + setDialogs((prevDialogs) => prevDialogs.map((d) => (d._id === dialogId ? updatedDialog : d))); + setSelectedDialog(updatedDialog); + }; + + const removeUsersFromGroupChat = async (usersIds: number[]): Promise => { + if (!selectedDialog) { + throw new Error("No dialog selected"); + } + + const dialogId = selectedDialog._id; + const toUpdateParams = { pull_all: { occupants_ids: usersIds } }; + + await ConnectyCube.chat.dialog.update(dialogId, toUpdateParams); + + usersIds.forEach((userId) => { + _notifyUsers(DialogEventSignal.REMOVED_FROM_DIALOG, dialogId, userId); + }); + + selectedDialog.occupants_ids + .filter((userId) => { + return !usersIds.includes(userId) && userId !== currentUserId; + }) + .forEach((userId) => { + _notifyUsers(DialogEventSignal.REMOVE_PARTICIPANTS, dialogId, userId, { + removedParticipantsIds: usersIds.join(), + }); + }); + + const updatedDialog = { + ...selectedDialog, + occupants_ids: selectedDialog.occupants_ids.filter((userId) => !usersIds.includes(userId)), + }; + + setDialogs((prevDialogs) => prevDialogs.map((d) => (d._id === dialogId ? updatedDialog : d))); + setSelectedDialog(updatedDialog); + }; + + const leaveGroupChat = async (): Promise => { + if (!selectedDialog) { + throw new Error("No dialog selected"); + } + + await ConnectyCube.chat.dialog.delete(selectedDialog._id); + + selectedDialog.occupants_ids + .filter((userId) => userId !== currentUserId) + .forEach((userId) => { + _notifyUsers(DialogEventSignal.REMOVED_FROM_DIALOG, selectedDialog._id, userId); + }); + + setDialogs(dialogs.filter((dialog) => dialog._id !== selectedDialog._id)); + setSelectedDialog(undefined); + }; + + const sendMessage = (body: string, dialog?: Dialogs.Dialog) => { + dialog ??= selectedDialog; + + if (!dialog) { + throw "No dialog provided. You need to provide a dialog via function argument or select a dialog via 'selectDialog'."; + } + + const opponentId = getDialogOpponentId(dialog); + const messageId = _sendMessage(body, null, dialog, opponentId); + + _addMessageToStore(messageId, body, dialog._id, currentUserId as number, opponentId); + }; + + const sendMessageWithAttachment = async (files: File[], dialog?: Dialogs.Dialog): Promise => { + dialog ??= selectedDialog; + + if (!dialog) { + throw "No dialog provided. You need to provide a dialog via function argument or select a dialog via 'selectDialog'."; + } + + const opponentId = getDialogOpponentId(dialog); + const tempId = Date.now() + ""; + const attachments = files.map((file, index) => ({ + uid: `local-${tempId}-${index}`, // temporary uid + type: file.type, + url: URL.createObjectURL(file), + })); + + _addMessageToStore(tempId, "Attachment", dialog._id, currentUserId as number, opponentId, attachments, true); + + const uploadFilesPromises = files.map((file) => { + const { name, type, size } = file; + const fileParams = { file, name, type, size, public: false }; + return ConnectyCube.storage.createAndUpload(fileParams); + }); + const uploadedFilesResults = await Promise.all(uploadFilesPromises); + const uploadedAttachments = uploadedFilesResults.map(({ uid, content_type = "" }) => ({ + uid, + type: content_type, + url: ConnectyCube.storage.privateUrl(uid), + })); + const messageId = _sendMessage("Attachment", uploadedAttachments, dialog, opponentId); + + setMessages((prevMessages) => ({ + ...prevMessages, + [dialog._id]: prevMessages[dialog._id].map((msg) => + msg._id === tempId + ? { + ...msg, + _id: messageId, + attachments, + isLoading: false, + status: chatStatusRef.current === ChatStatus.CONNECTED ? MessageStatus.WAIT : MessageStatus.LOST, + } + : msg, + ), + })); + }; + + const _sendMessage = ( + body: string, + attachments: Messages.Attachment[] | null, + dialog: Dialogs.Dialog, + opponentId?: number, + ): string => { + const messageParams: Chat.MessageParams = { + type: dialog.type === DialogType.PRIVATE ? ChatType.CHAT : ChatType.GROUPCHAT, + body, + extension: { + save_to_history: 1, + dialog_id: dialog._id, + }, + }; + + if (attachments) { + messageParams.extension.attachments = attachments; + } + + const messageId = ConnectyCube.chat.send( + dialog.type === DialogType.PRIVATE ? (opponentId as number) : dialog._id, + messageParams, + ); + + return messageId; + }; + + const _addMessageToStore = ( + messageId: string, + body: string, + dialogId: string, + senderId: number, + recipientId?: number, + attachments?: Messages.Attachment[], + isLoading?: boolean, + ) => { + const ts = Math.round(new Date().getTime() / 1000); + + setDialogs((prevDialogs) => + prevDialogs + .map((dialog) => + dialog._id === dialogId + ? { + ...dialog, + last_message: body, + last_message_user_id: senderId, + last_message_date_sent: ts, + } + : dialog, + ) + .sort((a, b) => { + const dateA = parseDate(a.last_message_date_sent) || (parseDate(a.created_at) as number); + const dateB = parseDate(b.last_message_date_sent) || (parseDate(b.created_at) as number); + return dateB - dateA; + }), + ); + + setMessages((prevMessages) => ({ + ...prevMessages, + [dialogId]: [ + ...(prevMessages[dialogId] || []), + { + _id: messageId, + created_at: ts, + updated_at: ts, + chat_dialog_id: dialogId, + message: body, + sender_id: senderId, + recipient_id: recipientId as any, + date_sent: ts, + read: 0, + read_ids: [senderId], + delivered_ids: [senderId], + views_count: 0, + attachments: attachments ? attachments : [], + reactions: {} as any, + isLoading, + status: chatStatusRef.current === ChatStatus.CONNECTED ? MessageStatus.WAIT : MessageStatus.LOST, + }, + ], + })); + }; + + const _updateMessageStatusInStore = (status: MessageStatus, messageId: string, dialogId: string, userId?: number) => { + setMessages((prevMessages) => ({ + ...prevMessages, + [dialogId]: + prevMessages[dialogId]?.map((message) => + message._id === messageId + ? { + ...message, + read_ids: userId + ? message.read_ids + ? [...new Set([...message.read_ids, userId])] + : [userId] + : message.read_ids, + read: status === MessageStatus.READ ? 1 : message.read, + status: + status === MessageStatus.SENT && message.status === MessageStatus.LOST ? message.status : status, + } + : message, + ) ?? [], + })); + }; + + const _markMessagesAsLostInStore = () => { + setMessages((prevMessages) => + Object.fromEntries( + Object.entries(prevMessages).map(([dialogId, messages]) => [ + dialogId, + messages.map((message) => + message.status === MessageStatus.WAIT ? { ...message, status: MessageStatus.LOST } : message, + ), + ]), + ), + ); + }; + + const readMessage = (messageId: string, userId: number, dialogId: string) => { + ConnectyCube.chat.sendReadStatus({ messageId, userId, dialogId }); + + _updateMessageStatusInStore(MessageStatus.READ, messageId, dialogId, userId); + + setDialogs((prevDialogs) => + prevDialogs.map((dialog) => + dialog._id === dialogId + ? { + ...dialog, + unread_messages_count: Math.max(0, dialog.unread_messages_count - 1), + } + : dialog, + ), + ); + }; + + const _notifyUsers = (command: string, dialogId: string, userId: number, params: any = {}) => { + const msg = { body: command, extension: { dialogId, ...params } }; + + ConnectyCube.chat.sendSystemMessage(userId, msg); + }; + + const sendSignal = (userIdOrIds: number | number[], signal: string, params: any = {}) => { + const receivers = Array.isArray(userIdOrIds) ? userIdOrIds : [userIdOrIds]; + const msg = { body: signal, extension: params }; + + receivers.forEach((userId) => { + ConnectyCube.chat.sendSystemMessage(userId, msg); + }); + }; + + const sendTypingStatus = (dialog?: Dialogs.Dialog) => { + dialog ??= selectedDialog; + + if (!dialog) { + throw "No dialog provided. You need to provide a dialog via function argument or select a dialog via 'selectDialog'."; + } + + ConnectyCube.chat.sendIsTypingStatus( + dialog.type === DialogType.PRIVATE ? (getDialogOpponentId(dialog) as number) : dialog._id, + ); + }; + + const _updateTypingStatus = (dialogId: string, userId: number, isTyping: boolean) => { + setTypingStatus((prevTypingStatus) => { + const prevUsersIds = prevTypingStatus[dialogId]; + const nextUsersIds = prevUsersIds ? new Set(prevUsersIds) : new Set(); + + isTyping ? nextUsersIds.add(userId) : nextUsersIds.delete(userId); + + return { ...prevTypingStatus, [dialogId]: [...nextUsersIds] }; + }); + }; + + const _clearTypingStatus = (dialogId: string, userId: number) => { + _updateTypingStatus(dialogId, userId, false); + clearTimeout(typingTimers.current[dialogId]?.[userId]); + delete typingTimers.current[dialogId]?.[userId]; + }; + + const _getPrivateDialogIdByUserId = (userId: number): string | undefined => { + let dialogId: string | undefined = privateDialogsIdsRef.current[userId]; + + if (!dialogId) { + const dialog = dialogsRef.current.find( + (dialog) => dialog.type === DialogType.PRIVATE && getDialogOpponentId(dialog) === userId, + ); + + if (dialog) { + dialogId = dialog._id; + privateDialogsIdsRef.current[userId] = dialogId; + } + } + + return dialogId; + }; + + const lastMessageSentTimeString = (dialog: Dialogs.Dialog): string => { + return formatDistanceToNow( + dialog.last_message_date_sent ? (dialog.last_message_date_sent as number) * 1000 : (dialog.created_at as string), + { + addSuffix: true, + }, + ); + }; + + const messageSentTimeString = (message: Messages.Message): string => { + return formatDistanceToNow((message.date_sent as number) * 1000, { + addSuffix: true, + }); + }; + + const processOnMessage = (callbackFn: Chat.OnMessageListener | null) => { + onMessageRef.current = callbackFn; + }; + + const processOnSignal = (callbackFn: Chat.OnMessageSystemListener | null) => { + onSignalRef.current = callbackFn; + }; + + const processOnMessageError = (callbackFn: Chat.OnMessageErrorListener | null) => { + onMessageErrorRef.current = callbackFn; + }; + + const processOnMessageSent = (callbackFn: Chat.OnMessageSentListener | null) => { + onMessageSentRef.current = callbackFn; + }; + + const _processDisconnect = () => { + if (chatStatusRef.current !== ChatStatus.CONNECTING) { + setChatStatus(ChatStatus.DISCONNECTED); + _resetDialogsAndMessagesProgress(); + } + + _markMessagesAsLostInStore(); + }; + + const _processReconnect = () => { + setChatStatus(ChatStatus.CONNECTED); + }; + + const _processConnectionError = async ( + error: { + name?: string; + text?: string; + condition?: string; + [key: string]: any; + } = {}, + ) => { + if ( + error?.condition === "not-authorized" || + error?.text === "Password not verified" || + error?.name === "SASLError" + ) { + const isDisconnected = await disconnect(ChatStatus.NOT_AUTHORIZED); + + if (!isDisconnected) { + terminate(ChatStatus.NOT_AUTHORIZED); + } + } else { + setChatStatus(ChatStatus.ERROR); + } + }; + + const _processMessage = (userId: number, message: Chat.Message) => { + if (onMessageRef.current) { + onMessageRef.current(userId, message); + } + + // TODO: handle multi-device & delivered private messages with delay (from offline) + if (userId === currentUserIdRef.current || (message.delay && message.type === ChatType.CHAT)) { + return; + } + + const currentDialog = selectedDialogRef.current; + const dialogId = message.dialog_id as string; + const messageId = message.id; + const body = message.body || ""; + const opponentId = message.type === ChatType.CHAT ? (currentUserIdRef.current as number) : undefined; + + const attachments = + message.extension.attachments?.length > 0 + ? message.extension.attachments.map((attachment: Messages.Attachment) => ({ + ...attachment, + url: ConnectyCube.storage.privateUrl(attachment.uid), + })) + : undefined; + + _addMessageToStore(messageId, body, dialogId, userId, opponentId, attachments); + _clearTypingStatus(dialogId, userId); + + setDialogs((prevDialogs) => + prevDialogs.map((dialog) => + dialog._id === dialogId + ? { + ...dialog, + unread_messages_count: + !currentDialog || currentDialog._id !== message.dialog_id + ? (dialog.unread_messages_count || 0) + 1 + : dialog.unread_messages_count, + last_message: message.body, + last_message_date_sent: parseInt(message.extension.date_sent), + } + : dialog, + ), + ); + }; + + const _processErrorMessage = (messageId: string, error: { code: number; info: string }) => { + if (onMessageErrorRef.current) { + onMessageErrorRef.current(messageId, error); + } + }; + + const _processSentMessage = (lost: Chat.MessageParams | null, sent: Chat.MessageParams | null) => { + if (onMessageSentRef.current) { + onMessageSentRef.current(lost, sent); + } + + const nextStatus = sent ? MessageStatus.SENT : lost ? MessageStatus.LOST : undefined; + const messageId = sent ? sent.id : lost ? lost.id : undefined; + const dialogId = sent ? sent.extension.dialog_id : lost ? lost.extension.dialog_id : undefined; + + if (nextStatus && messageId && dialogId) { + _updateMessageStatusInStore(nextStatus, messageId, dialogId); + } + }; + + const _processSystemMessage = async (message: Chat.SystemMessage) => { + const dialogId = message.extension.dialogId; + const senderId = message.userId; + + if (onSignalRef.current) { + onSignalRef.current(message); + } + + // TODO: handle multi-device + if (senderId === currentUserIdRef.current) return; + + switch (message.body) { + case DialogEventSignal.NEW_DIALOG: + case DialogEventSignal.ADDED_TO_DIALOG: { + const result = await ConnectyCube.chat.dialog.list({ _id: dialogId }); + const dialog = result.items[0]; + + _retrieveAndStoreUsers(dialog.occupants_ids); + setDialogs((prevDialogs) => [dialog, ...prevDialogs.filter((d) => d._id !== dialog._id)]); + + break; + } + + case DialogEventSignal.ADD_PARTICIPANTS: { + const usersIds = message.extension.addedParticipantsIds.split(",").map(Number) as number[]; + + _retrieveAndStoreUsers(usersIds); + setDialogs((prevDialogs) => + prevDialogs.map((d) => { + if (d._id === dialogId) { + d.occupants_ids = Array.from(new Set([...d.occupants_ids, ...usersIds])); + } + return d; + }), + ); + + break; + } + + case DialogEventSignal.REMOVE_PARTICIPANTS: { + const usersIds = message.extension.removedParticipantsIds.split(",").map(Number); + + setDialogs((prevDialogs) => + prevDialogs.map((d) => { + if (d._id === dialogId) { + d.occupants_ids = d.occupants_ids.filter((id) => !usersIds.includes(id)); + } + return d; + }), + ); + + break; + } + + case DialogEventSignal.REMOVED_FROM_DIALOG: { + setDialogs((prevDialogs) => + prevDialogs.map((d) => { + if (d._id === dialogId && d.type !== DialogType.PRIVATE) { + d.occupants_ids = d.occupants_ids.filter((id) => id !== senderId); + } + return d; + }), + ); + + break; + } + } + }; + + const _processReadMessageStatus = (messageId: string, dialogId: string, userId: number) => { + // TODO: handle multi-device + if (userId === currentUserIdRef.current) return; + + _updateMessageStatusInStore(MessageStatus.READ, messageId, dialogId, userId); + }; + + const _processTypingMessageStatus = (isTyping: boolean, userId: number, dialogId: string | null) => { + const _dialogId = dialogId || _getPrivateDialogIdByUserId(userId); + + // TODO: handle multi-device + if (!_dialogId || !userId || userId === currentUserIdRef.current) return; + + _updateTypingStatus(_dialogId, userId, isTyping); + + if (!typingTimers.current[_dialogId]) { + typingTimers.current[_dialogId] = {}; + } + + if (isTyping) { + if (typingTimers.current[_dialogId][userId]) { + clearTimeout(typingTimers.current[_dialogId][userId]); + delete typingTimers.current[_dialogId][userId]; + } + + typingTimers.current[_dialogId][userId] = setTimeout(() => { + _clearTypingStatus(_dialogId, userId); + }, 6000); + } else { + _clearTypingStatus(_dialogId, userId); + } + }; + + useEffect(() => { + ConnectyCube.chat.addListener(ChatEvent.DISCONNECTED, _processDisconnect); + ConnectyCube.chat.addListener(ChatEvent.RECONNECTED, _processReconnect); + ConnectyCube.chat.addListener(ChatEvent.ERROR, _processConnectionError); + ConnectyCube.chat.addListener(ChatEvent.MESSAGE, _processMessage); + ConnectyCube.chat.addListener(ChatEvent.ERROR_MESSAGE, _processErrorMessage); + ConnectyCube.chat.addListener(ChatEvent.SENT_MESSAGE, _processSentMessage); + ConnectyCube.chat.addListener(ChatEvent.SYSTEM_MESSAGE, _processSystemMessage); + ConnectyCube.chat.addListener(ChatEvent.READ_MESSAGE, _processReadMessageStatus); + ConnectyCube.chat.addListener(ChatEvent.TYPING_MESSAGE, _processTypingMessageStatus); + + return () => { + ConnectyCube.chat.removeAllListeners(); + }; + }, []); + + useEffect(() => { + _updateUnreadMessagesCount(); + }, [dialogs]); + + useEffect(() => { + _establishConnection(isOnline); + }, [isOnline]); + + return { + isOnline, + isConnected, + chatStatus, + connect, + disconnect, + terminate, + currentUserId, + selectDialog, + selectedDialog, + getDialogOpponentId, + unreadMessagesCount, + getMessages, + getNextMessages, + totalMessagesReached, + messages, + sendSignal, + sendMessage, + dialogs, + getDialogs, + getNextDialogs, + totalDialogReached, + createChat, + createGroupChat, + sendTypingStatus, + typingStatus, + sendMessageWithAttachment, + markDialogAsRead, + removeUsersFromGroupChat, + addUsersToGroupChat, + leaveGroupChat, + readMessage, + lastMessageSentTimeString, + messageSentTimeString, + processOnSignal, + processOnMessage, + processOnMessageError, + processOnMessageSent, + ...chatBlockList, + ...chatUsers.exports, + ...networkStatus, + }; +}; diff --git a/src/hooks/useChatStore.ts b/src/hooks/useChatStore.ts index b1f8168..811b371 100644 --- a/src/hooks/useChatStore.ts +++ b/src/hooks/useChatStore.ts @@ -19,9 +19,11 @@ export interface UsersStoreState { onlineUsersCount: number; lastActivity: UsersLastActivity; } -export interface ChatStoreState extends NetworkStatusStoreState, BlockListStoreState, UsersStoreState {} +export interface ChatStoreState extends BlockListStoreState, NetworkStatusStoreState, UsersStoreState {} -export interface BlockListStoreActions {} +export interface BlockListStoreActions { + setBlockedUsers: (blockedUsers: Set) => void; +} export interface NetworkStatusStoreActions { setIsOnline: (isOnline: boolean) => void; } @@ -61,6 +63,11 @@ const initialState: ChatStoreState = { const useChatStore = create()( subscribeWithSelector((set, get) => ({ ...initialState, + // Block list + setBlockedUsers: (blockedUsers: Set) => set({ blockedUsers }), + // Network + setIsOnline: (isOnline?: boolean) => set({ isOnline }), + // Users & online users upsertUser: (user: UserItem) => set({ users: { ...get().users, [user.id]: user } }), upsertUsers: (users: UsersArray) => set({ users: users.reduce((map, user) => ({ ...map, [user.id]: user }), { ...get().users }) }), @@ -81,8 +88,7 @@ const useChatStore = create()( setOnlineUsersCount: (onlineUsersCount: number) => set({ onlineUsersCount }), upsertLastActivity: (userId: number, status: string) => set({ lastActivity: { ...get().lastActivity, [userId]: status } }), - - setIsOnline: (isOnline?: boolean) => set({ isOnline }), + // Chat, Dialogs resetStore: () => set({ ...initialState }), })), ); diff --git a/src/hooks/useNetworkStatus.ts b/src/hooks/useNetworkStatus.ts index 0342ae9..fd480fd 100644 --- a/src/hooks/useNetworkStatus.ts +++ b/src/hooks/useNetworkStatus.ts @@ -59,9 +59,7 @@ function useNetworkStatus(isConnected: boolean): NetworkStatusHook { }; }, []); - return { - isOnline, - }; + return { isOnline }; } export default useNetworkStatus; diff --git a/src/index.ts b/src/index.ts index 8a19b8c..e0851b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,2 +1 @@ export * from "./ChatContext"; -export { ChatStatus, MessageStatus } from "./types"; diff --git a/src/tests/helpers.test.ts b/src/tests/helpers.test.ts index 735c825..879318d 100644 --- a/src/tests/helpers.test.ts +++ b/src/tests/helpers.test.ts @@ -1,6 +1,6 @@ +import { describe } from "node:test"; import { expect, test } from "vitest"; import { parseDate, getLastActivityText, getDialogTimestamp } from "../helpers"; -import { describe } from "node:test"; import { LastMessageMessageStatus } from "connectycube/types"; describe("parseDate", () => { diff --git a/src/types/index.ts b/src/types/index.ts deleted file mode 100644 index 5b35f9f..0000000 --- a/src/types/index.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { Chat, Dialogs, Messages } from "connectycube/types"; -import { ReactNode } from "react"; -import { BlockListHook } from "../hooks/useBlockList"; -import { UsersHookExports } from "../hooks/useUsers"; -import { NetworkStatusHook } from "../hooks/useNetworkStatus"; - -export interface ChatProviderType { - children?: ReactNode; -} - -export interface ChatContextType extends BlockListHook, UsersHookExports, NetworkStatusHook { - isConnected: boolean; - chatStatus: ChatStatus; - connect: (credentials: Chat.ConnectionParams) => Promise; - disconnect: () => Promise; - terminate: () => void; - currentUserId?: number; - createChat: (userId: number, extensions?: { [key: string]: any }) => Promise; - createGroupChat: ( - usersIds: number[], - name: string, - photo?: string, - extensions?: { [key: string]: any }, - ) => Promise; - getDialogs: (filters?: Dialogs.ListParams) => Promise; - getNextDialogs: () => Promise; - totalDialogReached: boolean; - dialogs: Dialogs.Dialog[]; - selectedDialog?: Dialogs.Dialog; - selectDialog: (dialog?: Dialogs.Dialog) => Promise; - getDialogOpponentId: (dialog?: Dialogs.Dialog) => number | undefined; - unreadMessagesCount: { total: number; [dialogId: string]: number }; - getMessages: (dialogId: string) => Promise; - getNextMessages: (dialogId: string) => Promise; - totalMessagesReached: { [dialogId: string]: boolean }; - messages: { [key: string]: Messages.Message[] }; - markDialogAsRead: (dialog: Dialogs.Dialog) => Promise; - addUsersToGroupChat: (usersIds: number[]) => Promise; - removeUsersFromGroupChat: (usersIds: number[]) => Promise; - leaveGroupChat: () => Promise; - sendSignal: (userIdOrIds: number | number[], signal: string, params?: any) => void; - sendMessage: (body: string, dialog?: Dialogs.Dialog) => void; - sendMessageWithAttachment: (files: File[], dialog?: Dialogs.Dialog) => Promise; - readMessage: (messageId: string, userId: number, dialogId: string) => void; - sendTypingStatus: (dialog?: Dialogs.Dialog, isTyping?: boolean) => void; - typingStatus: { [dialogId: string]: number[] }; - lastMessageSentTimeString: (dialog: Dialogs.Dialog) => string; - messageSentTimeString: (message: Messages.Message) => string; - processOnSignal: (fn: Chat.OnMessageSystemListener | null) => void; - processOnMessage: (fn: Chat.OnMessageListener | null) => void; - processOnMessageError: (fn: Chat.OnMessageErrorListener | null) => void; - processOnMessageSent: (fn: Chat.OnMessageSentListener | null) => void; -} - -export enum DialogEventSignal { - ADDED_TO_DIALOG = "dialog/ADDED_TO_DIALOG", - REMOVED_FROM_DIALOG = "dialog/REMOVED_FROM_DIALOG", - ADD_PARTICIPANTS = "dialog/ADD_PARTICIPANTS", - REMOVE_PARTICIPANTS = "dialog/REMOVE_PARTICIPANTS", - NEW_DIALOG = "dialog/NEW_DIALOG", -} - -export enum MessageStatus { - WAIT = "wait", - LOST = "lost", - SENT = "sent", - READ = "read", -} - -export enum ChatStatus { - DISCONNECTED = "disconnected", - CONNECTING = "connecting", - CONNECTED = "connected", - NOT_AUTHORIZED = "not-authorized", - ERROR = "error", -}