implemented group admins
This commit is contained in:
538
client/package-lock.json
generated
538
client/package-lock.json
generated
@@ -9,6 +9,7 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.3",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
@@ -1173,6 +1174,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.1.tgz",
|
||||
"integrity": "sha512-LwT3pSho9Dljg+wY2KN2mrrh6y3qELfftINERIzBUO9e0N+t0oMTyn3k9iv+ZqgrwGkRnLpNJrsMv9BZlt2yuA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-slot": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
|
||||
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
|
||||
@@ -1203,6 +1286,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context-menu": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.3.tgz",
|
||||
"integrity": "sha512-i4ZjZNoiAKwxcaKBR5XdiOyEJQdBT4P6TeMtzP4fjlcDJpxwIcmmWkdd13YEzCHHcWYZOyl7fVHKT8dFMHdo3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-menu": "2.1.3",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
|
||||
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
|
||||
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-context-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dialog": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.2.tgz",
|
||||
@@ -1239,6 +1412,21 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-direction": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.0.tgz",
|
||||
"integrity": "sha512-BUuBvgThEiAXh2DWu93XsT+a3aWrGqolGlqqw5VU1kG7p/ZH2cuDlM1sRLNnY3QcBS69UIz2mcKhMxDsdewhjg==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz",
|
||||
@@ -1324,6 +1512,263 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.3.tgz",
|
||||
"integrity": "sha512-wY5SY6yCiJYP+DMIy7RrjF4shoFpB9LJltliVwejBm8T2yepWDJgKBhIFYOGWYR/lFHOCtbstN9duZFu6gmveQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-collection": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-dismissable-layer": "1.1.2",
|
||||
"@radix-ui/react-focus-guards": "1.1.1",
|
||||
"@radix-ui/react-focus-scope": "1.1.1",
|
||||
"@radix-ui/react-id": "1.1.0",
|
||||
"@radix-ui/react-popper": "1.2.1",
|
||||
"@radix-ui/react-portal": "1.1.3",
|
||||
"@radix-ui/react-presence": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-roving-focus": "1.1.1",
|
||||
"@radix-ui/react-slot": "1.1.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"aria-hidden": "^1.1.1",
|
||||
"react-remove-scroll": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
|
||||
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-arrow": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
|
||||
"integrity": "sha512-NaVpZfmv8SKeZbn4ijN2V3jlHA9ngBG16VnIIm22nUR0Yk8KUALyBxT3KYEUnNuch9sTE8UTsS3whzBgKOL30w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.0.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-dismissable-layer": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.2.tgz",
|
||||
"integrity": "sha512-kEHnlhv7wUggvhuJPkyw4qspXLJOdYoAP4dO2c8ngGuXTq1w/HZp1YeVB+NQ2KbH1iEG+pvOCGYSqh9HZOz6hg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-escape-keydown": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-focus-scope": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.1.tgz",
|
||||
"integrity": "sha512-01omzJAYRxXdG2/he/+xy+c8a8gCydoQ1yOxnWNcRhrrBW5W+RQJ22EK1SaO8tb3WoUsuEw7mJjBozPzihDFjA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.1.tgz",
|
||||
"integrity": "sha512-3kn5Me69L+jv82EKRuQCXdYyf1DqHwD2U/sxoNgBGCB7K9TRc3bQamQ+5EPM9EvyPdli0W41sROd+ZU1dTCztw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@floating-ui/react-dom": "^2.0.0",
|
||||
"@radix-ui/react-arrow": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0",
|
||||
"@radix-ui/react-use-rect": "1.1.0",
|
||||
"@radix-ui/react-use-size": "1.1.0",
|
||||
"@radix-ui/rect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-portal": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.3.tgz",
|
||||
"integrity": "sha512-NciRqhXnGojhT93RPyDaMPfLH3ZSl4jjIFbZQ1b/vxvZEdHsBZ49wP9w8L3HzUQwep01LcWtkUvm0OVB5JAHTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-presence": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
|
||||
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-use-layout-effect": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
|
||||
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-menu/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-popper": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",
|
||||
@@ -1442,6 +1887,99 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.1.tgz",
|
||||
"integrity": "sha512-QE1RoxPGJ/Nm8Qmk0PxP8ojmoaS67i0s7hVssS7KuI2FQoc/uzVlZsqKfQvxPE6D8hICCPHJ4D88zNhT3OOmkw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@radix-ui/react-collection": "1.1.1",
|
||||
"@radix-ui/react-compose-refs": "1.1.1",
|
||||
"@radix-ui/react-context": "1.1.1",
|
||||
"@radix-ui/react-direction": "1.1.0",
|
||||
"@radix-ui/react-id": "1.1.0",
|
||||
"@radix-ui/react-primitive": "2.0.1",
|
||||
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/primitive": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
|
||||
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-compose-refs": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
|
||||
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-primitive": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.1.tgz",
|
||||
"integrity": "sha512-sHCWTtxwNn3L3fH8qAfnF3WbUZycW93SM1j3NFDzXBiz8D6F5UTTy8G1+WFEaiCdvCVRJWj6N2R4Xq6HdiHmDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-slot": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"@types/react-dom": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/react-dom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-roving-focus/node_modules/@radix-ui/react-slot": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz",
|
||||
"integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "1.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "*",
|
||||
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-separator": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.0.tgz",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.2",
|
||||
"@radix-ui/react-context-menu": "^2.2.3",
|
||||
"@radix-ui/react-dialog": "^1.1.2",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
|
||||
@@ -111,7 +111,10 @@ function ContactsList({
|
||||
updateContactStatus(contact, true);
|
||||
}}
|
||||
>
|
||||
<span className="flex-shrink-0">{contact.username}</span>
|
||||
<span className="flex-shrink-0">
|
||||
usrId:{contact.user_id} cnvId:{contact.conversation_id}{' '}
|
||||
{contact.username}
|
||||
</span>
|
||||
{contact.type === 'group' ? (
|
||||
<img src={GroupIcon} alt="Group icon" className="ml-2 invert w-5" />
|
||||
) : null}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { ChatMessages } from '../../pages/Chat.tsx';
|
||||
import { ContactsProps } from '../../pages/Chat.tsx';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import AttachmentPreview from './AttachmentPreview.tsx';
|
||||
import { UsernameType } from '@/utils/ProtectedRoutes.tsx';
|
||||
|
||||
type MessagesAreaProps = {
|
||||
messages: ChatMessages[];
|
||||
@@ -32,7 +33,7 @@ function MessagesArea({
|
||||
contactsList,
|
||||
}: MessagesAreaProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const { username }: { username: string } = useOutletContext();
|
||||
const user: UsernameType = useOutletContext();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [shouldScrollToBottom, setShouldScrollToBottom] = useState(true);
|
||||
const previousMessagesLength = useRef(messages.length);
|
||||
@@ -104,7 +105,7 @@ function MessagesArea({
|
||||
console.log('Received message: ', msg);
|
||||
if (
|
||||
msg.conversation_id !== currentContact?.conversation_id &&
|
||||
msg.sender !== username
|
||||
msg.sender !== user.username
|
||||
) {
|
||||
setContactsList((prevContacts) => {
|
||||
// Find if contact already exists
|
||||
@@ -214,7 +215,7 @@ function MessagesArea({
|
||||
}
|
||||
socket.off('chat message');
|
||||
};
|
||||
}, [currentContact, username, setContactsList, updateContactStatus]);
|
||||
}, [currentContact, user.username, setContactsList, updateContactStatus]);
|
||||
|
||||
// Handle auto-scrolling when new messages arrive
|
||||
useEffect(() => {
|
||||
|
||||
@@ -2,14 +2,42 @@ import { useEffect, useState } from 'react';
|
||||
import { axiosClient } from '@/App.tsx';
|
||||
import { ContactsProps } from '@/pages/Chat.tsx';
|
||||
import { socket } from '@/socket/socket.tsx';
|
||||
import { Sword } from 'lucide-react';
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from '@/components/ui/context-menu';
|
||||
import { UsernameType } from '@/utils/ProtectedRoutes.tsx';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import LoadingWheel from '@/components/chat/LoadingWheel.tsx';
|
||||
|
||||
type ParticipantsProps = {
|
||||
user_id: number;
|
||||
username: string;
|
||||
isadmin: boolean;
|
||||
};
|
||||
|
||||
function ParticipantsBar({ contact }: { contact: ContactsProps | null }) {
|
||||
type ParticipantsBarProps = {
|
||||
contact: ContactsProps | null;
|
||||
};
|
||||
|
||||
function ParticipantsBar({ contact }: ParticipantsBarProps) {
|
||||
const [participants, setParticipants] = useState<ParticipantsProps[]>([]);
|
||||
const [isGroupAdmin, setIsGroupAdmin] = useState<boolean>(false);
|
||||
const user: UsernameType = useOutletContext();
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (participants.length > 0 && user?.user_id) {
|
||||
const userIsAdmin = participants.some(
|
||||
(participant) =>
|
||||
participant.user_id === user.user_id && participant.isadmin,
|
||||
);
|
||||
setIsGroupAdmin(userIsAdmin);
|
||||
}
|
||||
}, [participants, user?.user_id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (contact) {
|
||||
@@ -38,14 +66,18 @@ function ParticipantsBar({ contact }: { contact: ContactsProps | null }) {
|
||||
|
||||
socket.on(
|
||||
'added to group',
|
||||
(msg: { username: string; user_id: number; group_id: number }) => {
|
||||
(msg: {
|
||||
username: string;
|
||||
user_id: number;
|
||||
group_id: number;
|
||||
isadmin: false;
|
||||
}) => {
|
||||
const { group_id } = msg;
|
||||
console.log('Added to group: ', msg);
|
||||
console.log('Current participants: ', participants);
|
||||
|
||||
if (group_id === contact.conversation_id) {
|
||||
setParticipants((prevMembers) => {
|
||||
// Check if member already exists
|
||||
const existingMember = prevMembers.some(
|
||||
(m) => m.user_id === msg.user_id,
|
||||
);
|
||||
@@ -71,17 +103,68 @@ function ParticipantsBar({ contact }: { contact: ContactsProps | null }) {
|
||||
|
||||
return () => {
|
||||
socket?.off('added to group');
|
||||
socket?.off('left group');
|
||||
};
|
||||
}, [contact, participants]);
|
||||
|
||||
const handleRemoveUser = async (userId: number) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
socket?.emit(
|
||||
'remove user from group',
|
||||
{
|
||||
conversation_id: contact?.conversation_id,
|
||||
user_id: userId,
|
||||
},
|
||||
(response: { status: 'ok' | 'error'; message: string }) => {
|
||||
if (response.status == 'ok') {
|
||||
setIsLoading(false);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
console.error(
|
||||
'Failed to remove user from group: ',
|
||||
response.message,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
// setParticipants((prevMembers) =>
|
||||
// prevMembers.filter((member) => member.user_id !== userId),
|
||||
// );
|
||||
} catch (error) {
|
||||
console.error('Failed to remove user:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const ParticipantsList = participants?.map(
|
||||
(participant: ParticipantsProps) => (
|
||||
<li
|
||||
className="p-1 hover:bg-gray-700 rounded-md"
|
||||
key={participant.user_id}
|
||||
>
|
||||
{participant.username}
|
||||
</li>
|
||||
<ContextMenu key={participant.user_id}>
|
||||
<ContextMenuTrigger>
|
||||
<li className="p-2 hover:bg-gray-700 rounded-md flex items-center justify-between group">
|
||||
<span className="flex items-center gap-2">
|
||||
{participant.username}
|
||||
{participant.isadmin && (
|
||||
<span className="flex items-center text-green-300 text-xs">
|
||||
<Sword className="h-3 w-3" />
|
||||
<span className="ml-1 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
admin
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
</ContextMenuTrigger>
|
||||
{isGroupAdmin && user.user_id !== participant.user_id && (
|
||||
<ContextMenuContent className="p-0">
|
||||
<ContextMenuItem
|
||||
className="bg-zinc-900 text-white outline-1 hover:bg-zinc-800 hover:cursor-pointer"
|
||||
onClick={() => handleRemoveUser(participant.user_id)}
|
||||
>
|
||||
{isLoading ? <LoadingWheel /> : <p>Remove from group</p>}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)}
|
||||
</ContextMenu>
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useOutletContext } from 'react-router-dom';
|
||||
import { UsernameType } from '../../utils/ProtectedRoutes.tsx';
|
||||
|
||||
function UserProfile() {
|
||||
const { username }: UsernameType = useOutletContext();
|
||||
const user: UsernameType = useOutletContext();
|
||||
|
||||
function logout() {
|
||||
Cookies.remove('token');
|
||||
@@ -24,7 +24,7 @@ function UserProfile() {
|
||||
</div>
|
||||
|
||||
<div className="text-gray-200">
|
||||
<p>{username}</p>
|
||||
<p>{user.username}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
198
client/src/components/ui/context-menu.tsx
Normal file
198
client/src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,198 @@
|
||||
import * as React from 'react';
|
||||
import * as ContextMenuPrimitive from '@radix-ui/react-context-menu';
|
||||
import { Check, ChevronRight, Circle } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuGroup = ContextMenuPrimitive.Group;
|
||||
|
||||
const ContextMenuPortal = ContextMenuPrimitive.Portal;
|
||||
|
||||
const ContextMenuSub = ContextMenuPrimitive.Sub;
|
||||
|
||||
const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-gray-100 focus:text-gray-900 data-[state=open]:bg-gray-100 data-[state=open]:text-gray-900 dark:focus:bg-gray-800 dark:focus:text-gray-50 dark:data-[state=open]:bg-gray-800 dark:data-[state=open]:text-gray-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto h-4 w-4" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
));
|
||||
ContextMenuSubTrigger.displayName = ContextMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-950 shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSubContent.displayName = ContextMenuPrimitive.SubContent.displayName;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'z-50 min-w-[8rem] overflow-hidden rounded-md border border-gray-200 bg-white p-1 text-gray-950 shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.CheckboxItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.CheckboxItem>
|
||||
>(({ className, children, checked, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
));
|
||||
ContextMenuCheckboxItem.displayName =
|
||||
ContextMenuPrimitive.CheckboxItem.displayName;
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.RadioItem>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.RadioItem>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-100 focus:text-gray-900 data-[disabled]:pointer-events-none data-[disabled]:opacity-50 dark:focus:bg-gray-800 dark:focus:text-gray-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<Circle className="h-4 w-4 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
));
|
||||
ContextMenuRadioItem.displayName = ContextMenuPrimitive.RadioItem.displayName;
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'px-2 py-1.5 text-sm font-semibold text-gray-950 dark:text-gray-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuLabel.displayName = ContextMenuPrimitive.Label.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 my-1 h-px bg-gray-200 dark:bg-gray-800', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLSpanElement>) => {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-auto text-xs tracking-widest text-gray-500 dark:text-gray-400',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
ContextMenuShortcut.displayName = 'ContextMenuShortcut';
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
@@ -7,11 +7,15 @@ import { axiosClient } from '../App.tsx';
|
||||
|
||||
export type UsernameType = {
|
||||
username: string | null;
|
||||
user_id: number | null;
|
||||
};
|
||||
|
||||
function ProtectedRoutes() {
|
||||
const { authorized, setAuthorized } = useContext(AuthContext);
|
||||
const [username, setUsername] = useState<string | null>(null);
|
||||
const [user, setUser] = useState<UsernameType>({
|
||||
username: null,
|
||||
user_id: null,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
function validateToken() {
|
||||
@@ -24,8 +28,8 @@ function ProtectedRoutes() {
|
||||
axiosClient
|
||||
.get('/api/auth/validate', { withCredentials: true })
|
||||
.then(async (res) => {
|
||||
setUsername(res.data.username);
|
||||
console.log(res.data.username);
|
||||
setUser(res.data);
|
||||
console.log('User: ', res.data);
|
||||
setAuthorized(true);
|
||||
})
|
||||
.catch((err) => {
|
||||
@@ -42,7 +46,7 @@ function ProtectedRoutes() {
|
||||
}
|
||||
|
||||
return authorized ? (
|
||||
<Outlet context={{ username } satisfies UsernameType} />
|
||||
<Outlet context={user satisfies UsernameType} />
|
||||
) : (
|
||||
<Navigate to="/login" replace />
|
||||
);
|
||||
|
||||
230
server/db/db.js
230
server/db/db.js
@@ -43,8 +43,10 @@ async function createTables() {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_username ON Accounts (username);
|
||||
`);
|
||||
console.log("Successfully created Accounts table");
|
||||
} catch (e) {
|
||||
console.error("Failed to create Accounts table: ", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -59,8 +61,10 @@ async function createTables() {
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversation_type ON Conversations (conversation_type);
|
||||
`);
|
||||
console.log("Successfully created Conversations table");
|
||||
} catch (e) {
|
||||
console.error("Failed to create Conversations table: ", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -78,8 +82,10 @@ async function createTables() {
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_user_id ON Messages (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conversation_sent_at ON Messages (conversation_id, sent_at);
|
||||
`);
|
||||
console.log("Successfully created Messages table");
|
||||
} catch (e) {
|
||||
console.error("Failed to create Messages table: ", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -89,14 +95,16 @@ async function createTables() {
|
||||
conversation_id INT NOT NULL REFERENCES Conversations(conversation_id) ON DELETE CASCADE,
|
||||
user_id INT REFERENCES Accounts(user_id) ON DELETE CASCADE,
|
||||
joined_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (conversation_id, user_id) -- This composite primary key ensures uniqueness, but make sure that these fields are frequently queried together to avoid unnecessary performance overhead.
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_memberships_conversation_id ON Memberships (conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memberships_user_id ON Memberships (user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_memberships_conversation_joined_at ON Memberships (conversation_id, joined_at);
|
||||
`);
|
||||
console.log("Successfully created Memberships table");
|
||||
} catch (e) {
|
||||
console.error("Failed to create Memberships table: ", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -114,35 +122,70 @@ async function createTables() {
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_conversation_id ON Contacts (conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_contact_id ON Contacts (contact_id);
|
||||
`);
|
||||
console.log("Successfully created Contacts table");
|
||||
} catch (e) {
|
||||
console.error("Failed to create Contacts table: ", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
// Create GroupAdmins Table
|
||||
// Create GroupAdmins Table with Trigger
|
||||
await client.query(`
|
||||
-- Create the base table
|
||||
CREATE TABLE IF NOT EXISTS GroupAdmins (
|
||||
conversation_id INT NOT NULL REFERENCES Conversations(conversation_id) ON DELETE CASCADE,
|
||||
user_id INT NOT NULL REFERENCES Accounts(user_id) ON DELETE CASCADE,
|
||||
granted_by INT NOT NULL REFERENCES Accounts(user_id) ON DELETE CASCADE,
|
||||
granted_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (conversation_id, user_id),
|
||||
CONSTRAINT group_conversation_only CHECK (conversation_type = 'group'),
|
||||
CONSTRAINT admin_grants_admin CHECK (
|
||||
granted_by = user_id OR
|
||||
EXISTS (
|
||||
SELECT 1 FROM GroupAdmins ga
|
||||
WHERE ga.conversation_id = GroupAdmins.conversation_id
|
||||
AND ga.user_id = GroupAdmins.granted_by
|
||||
)
|
||||
)
|
||||
PRIMARY KEY (conversation_id, user_id)
|
||||
);
|
||||
|
||||
-- Create indexes
|
||||
CREATE INDEX IF NOT EXISTS idx_group_admins_conversation_id ON GroupAdmins (conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_group_admins_user_id ON GroupAdmins (user_id);
|
||||
|
||||
-- Create the validation function
|
||||
CREATE OR REPLACE FUNCTION validate_admin_grant()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Allow self-grant for the first admin of a conversation
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM GroupAdmins
|
||||
WHERE conversation_id = NEW.conversation_id
|
||||
) THEN
|
||||
-- For subsequent admins, verify that the granter is an admin
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM GroupAdmins
|
||||
WHERE conversation_id = NEW.conversation_id
|
||||
AND user_id = NEW.granted_by
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Only existing admins can grant admin privileges';
|
||||
END IF;
|
||||
ELSE
|
||||
-- For the first admin, only allow self-grant
|
||||
IF NEW.granted_by != NEW.user_id THEN
|
||||
RAISE EXCEPTION 'First admin must be self-granted';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create the trigger
|
||||
DROP TRIGGER IF EXISTS validate_admin_grant_trigger ON GroupAdmins;
|
||||
CREATE TRIGGER validate_admin_grant_trigger
|
||||
BEFORE INSERT OR UPDATE ON GroupAdmins
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_admin_grant();
|
||||
`);
|
||||
console.log("Successfully created GroupAdmins table with trigger");
|
||||
} catch (e) {
|
||||
console.error("Failed to create GroupAdmins table: ", e);
|
||||
throw e;
|
||||
}
|
||||
|
||||
console.log("All tables created successfully");
|
||||
}
|
||||
|
||||
async function insertUser(username, passwordHash) {
|
||||
@@ -167,7 +210,6 @@ async function getUserId(username) {
|
||||
try {
|
||||
const result = await client.query(query, [username]);
|
||||
if (result.rows.length > 0) {
|
||||
console.log("GETUSERID: ", result.rows[0]);
|
||||
return result.rows[0];
|
||||
} else {
|
||||
console.log("No user found with username: ", username);
|
||||
@@ -222,25 +264,53 @@ async function insertMessage(
|
||||
}
|
||||
|
||||
async function createGroup(user_id, groupName) {
|
||||
const query = `
|
||||
const createConversationQuery = `
|
||||
INSERT INTO Conversations (conversation_type, name)
|
||||
VALUES ('group', $1)
|
||||
RETURNING conversation_id AS group_id;
|
||||
`;
|
||||
try {
|
||||
const result = await client.query(query, [groupName]);
|
||||
const group_id = result.rows[0].group_id;
|
||||
|
||||
const contact_user_id = await addMemberToGroup(group_id, user_id);
|
||||
insertContactById(user_id, group_id, true);
|
||||
return { group_id, contact_user_id };
|
||||
const insertGroupAdminQuery = `
|
||||
INSERT INTO GroupAdmins (conversation_id, user_id, granted_by, granted_at)
|
||||
VALUES ($1, $2, $3, NOW())
|
||||
RETURNING granted_at;
|
||||
`;
|
||||
|
||||
try {
|
||||
const createConversation = await client.query(createConversationQuery, [
|
||||
groupName,
|
||||
]);
|
||||
const group_id = createConversation.rows[0].group_id;
|
||||
|
||||
const insertGroupAdmin = await client.query(insertGroupAdminQuery, [
|
||||
group_id,
|
||||
user_id,
|
||||
user_id,
|
||||
]);
|
||||
|
||||
if (insertGroupAdmin.rowCount > 0) {
|
||||
const contact_user_id = await addMemberToGroupById(group_id, user_id);
|
||||
// if (errorMessage) {
|
||||
// console.error("You are not an admin of the conversation");
|
||||
// return errorMessage;
|
||||
// }
|
||||
console.log("create group: ", group_id, contact_user_id);
|
||||
insertContactById(user_id, group_id, true);
|
||||
return { group_id, contact_user_id };
|
||||
}
|
||||
console.error("Failed to insert group admin");
|
||||
} catch (e) {
|
||||
console.error("Failed to create conversation ", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function addMemberToGroup(conversation_id, user_id) {
|
||||
async function addMemberToGroupById(conversation_id, user_id) {
|
||||
// const isAdminResult = await isAdmin(user_id, conversation_id);
|
||||
// if (!isAdminResult) {
|
||||
// return { errorMessage: "You are not an admin of the conversation" };
|
||||
// }
|
||||
|
||||
const query = `
|
||||
INSERT INTO Memberships (conversation_id, user_id)
|
||||
VALUES ($1, $2)
|
||||
@@ -259,6 +329,16 @@ async function addMemberToGroup(conversation_id, user_id) {
|
||||
}
|
||||
|
||||
async function addMemberToGroupByUsername(conversation_id, username) {
|
||||
// const { user_id } = await getUserId(username);
|
||||
// if (!user_id) {
|
||||
// return null;
|
||||
// }
|
||||
// const isAdminResult = await isAdmin(user_id, conversation_id);
|
||||
// if (!isAdminResult) {
|
||||
// console.error("You are not an admin of the conversation");
|
||||
// return null;
|
||||
// }
|
||||
|
||||
const query = `
|
||||
WITH user_id_query AS (
|
||||
SELECT user_id
|
||||
@@ -425,12 +505,7 @@ async function insertContactById(senderId, conversation_id, read) {
|
||||
|
||||
try {
|
||||
const result = await client.query(query, [senderId, conversation_id, read]);
|
||||
console.log(
|
||||
"Insertcontactbyid: ",
|
||||
result.rows[0],
|
||||
senderId,
|
||||
conversation_id,
|
||||
);
|
||||
console.log("Insertcontactbyid: ", senderId, conversation_id);
|
||||
return result.rows[0] || null;
|
||||
} catch (error) {
|
||||
console.error("Failed to insert contact by IDs:", error);
|
||||
@@ -795,9 +870,14 @@ async function getMembers(conversation_id) {
|
||||
const query = `
|
||||
SELECT
|
||||
a.user_id,
|
||||
a.username
|
||||
a.username,
|
||||
CASE
|
||||
WHEN ga.user_id IS NOT NULL THEN TRUE
|
||||
ELSE FALSE
|
||||
END AS isAdmin
|
||||
FROM Memberships m
|
||||
JOIN Accounts a ON m.user_id = a.user_id
|
||||
LEFT JOIN GroupAdmins ga ON m.user_id = ga.user_id AND m.conversation_id = ga.conversation_id
|
||||
WHERE m.conversation_id = $1;
|
||||
`;
|
||||
|
||||
@@ -814,10 +894,102 @@ async function getMembers(conversation_id) {
|
||||
`Failed to get members for conversation_id ${conversation_id}`,
|
||||
e,
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function isAdmin(user_id, conversation_id) {
|
||||
const query = `
|
||||
SELECT 1 FROM GroupAdmins
|
||||
WHERE user_id = $1
|
||||
AND conversation_id = $2;
|
||||
`;
|
||||
try {
|
||||
const result = await client.query(query, [user_id, conversation_id]);
|
||||
return result.rows.length > 0;
|
||||
} catch (e) {
|
||||
console.error("Failed to check admin status", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getGroupAdmins(conversation_id) {
|
||||
const query = `
|
||||
SELECT user_id, granted_by
|
||||
FROM GroupAdmins
|
||||
WHERE conversation_id = $1;
|
||||
`;
|
||||
|
||||
try {
|
||||
const result = await client.query(query, [conversation_id]);
|
||||
const admins = result.rows.map((row) => ({
|
||||
user_id: row.user_id,
|
||||
granted_by: row.granted_by,
|
||||
}));
|
||||
return admins;
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to get admins for conversation_id ${conversation_id}`,
|
||||
e,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUserFromGroupById(conversation_id, user_id) {
|
||||
const removeUserFromGroupQuery = `
|
||||
DELETE FROM Memberships
|
||||
WHERE conversation_id = $1 AND user_id = $2;
|
||||
`;
|
||||
|
||||
// const removeConversationContactQuery = `
|
||||
// DELETE FROM Contacts
|
||||
// WHERE conversation_id = $1 AND user_id = $2;
|
||||
// `;
|
||||
|
||||
try {
|
||||
// First, remove the user from the Memberships table
|
||||
const removeMembershipResult = await client.query(
|
||||
removeUserFromGroupQuery,
|
||||
[conversation_id, user_id],
|
||||
);
|
||||
|
||||
if (removeMembershipResult.rowCount === 0) {
|
||||
console.log(
|
||||
`No membership found for user_id: ${user_id} in conversation_id: ${conversation_id}`,
|
||||
);
|
||||
return {
|
||||
message: `No membership found for user_id: ${user_id} in conversation_id: ${conversation_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Then, remove the user from the Contacts table
|
||||
// const removeContactResult = await client.query(removeConversationContactQuery, [
|
||||
// conversation_id,
|
||||
// user_id,
|
||||
// ]);
|
||||
//
|
||||
// if (removeContactResult.rowCount === 0) {
|
||||
// console.log(
|
||||
// `No contact found for user_id: ${user_id} in conversation_id: ${conversation_id}`,
|
||||
// );
|
||||
// return {
|
||||
// message: `No contact found for user_id: ${user_id} in conversation_id: ${conversation_id}`,
|
||||
// };
|
||||
// }
|
||||
|
||||
console.log(
|
||||
`Successfully removed user_id: ${user_id} from conversation_id: ${conversation_id}`,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Failed to remove user from group ", e);
|
||||
return {
|
||||
message: `Failed to remove user_id: ${user_id} from conversation_id: ${conversation_id}`,
|
||||
error: e.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
client,
|
||||
insertUser,
|
||||
@@ -832,10 +1004,10 @@ module.exports = {
|
||||
getContacts,
|
||||
updateContactStatus,
|
||||
createGroup,
|
||||
addMemberToGroup,
|
||||
addMemberToGroupByUsername,
|
||||
getConversationsForUser,
|
||||
contactSuggestion,
|
||||
deleteMessage,
|
||||
getMembers,
|
||||
removeUserFromGroupById,
|
||||
};
|
||||
|
||||
@@ -199,6 +199,7 @@ app.get("/api/auth/validate", authorizeUser, (req, res) => {
|
||||
return res.status(200).json({
|
||||
message: "Authorized",
|
||||
username: req.user.username,
|
||||
user_id: req.user.user_id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,9 +298,6 @@ app.get(
|
||||
}
|
||||
try {
|
||||
const suggestions = await contactSuggestion(contact);
|
||||
console.log(
|
||||
`contacts suggestions for contact: ${contact}, suggestions: ${suggestions}`,
|
||||
);
|
||||
return res.status(200).json(suggestions);
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: "Failed to get contact suggestions" });
|
||||
@@ -351,7 +349,13 @@ app.post("/api/chat/groups/create", authorizeUser, async (req, res) => {
|
||||
if (!groupname) {
|
||||
return res.status(400).json({ message: "Groupname not provided" });
|
||||
}
|
||||
const { group_id, contact_user_id } = await createGroup(user_id, groupname);
|
||||
const { group_id, contact_user_id, errorMessage } = await createGroup(
|
||||
user_id,
|
||||
groupname,
|
||||
);
|
||||
if (errorMessage) {
|
||||
return res.status(401).json({ message: errorMessage });
|
||||
}
|
||||
if (!group_id) {
|
||||
return res.status(500).json({ message: "Failed to create group" });
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ const {
|
||||
getConversationsForUser,
|
||||
deleteContact,
|
||||
deleteMessage,
|
||||
removeUserFromGroupById,
|
||||
} = require("../db/db");
|
||||
const { isValidUsername } = require("../utils/filter");
|
||||
const { verifyJwtToken } = require("../auth/jwt");
|
||||
@@ -202,6 +203,33 @@ function initializeSocket(io) {
|
||||
callback({ status: "ok", message: "Successfully deleted contact" });
|
||||
io.to(conversation_id).emit("left group", { conversation_id, user_id });
|
||||
});
|
||||
|
||||
socket.on("remove user from group", async (msg, callback) => {
|
||||
const { conversation_id, user_id } = msg;
|
||||
if (!conversation_id) {
|
||||
return callback({
|
||||
status: "error",
|
||||
message: "No conversation id provided",
|
||||
});
|
||||
}
|
||||
if (!user_id) {
|
||||
return callback({ status: "error", message: "No user id provided" });
|
||||
}
|
||||
|
||||
const result = await removeUserFromGroupById(conversation_id, user_id);
|
||||
|
||||
if (result?.message) {
|
||||
return callback({ status: "error", messsage: result.message });
|
||||
}
|
||||
|
||||
io.to(conversation_id).emit("left group", { conversation_id, user_id });
|
||||
|
||||
return callback({
|
||||
status: "ok",
|
||||
message: "Successfully removed user from group",
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.log("(socket)", socket.id, " disconnected due to: ", reason);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user