Spaces:
Running
Running
Upload 26 files
Browse files- README.md +43 -43
- bun.lock +17 -5
- index.html +17 -18
- package-lock.json +0 -0
- package.json +4 -2
- server.ts +21 -10
- src/App.tsx +6 -0
- src/components/ChatRoom.tsx +362 -145
- src/components/NotesView.tsx +9 -22
- src/components/VoiceMeetingModal.tsx +785 -0
- src/index.css +0 -2
- src/services/api.ts +54 -4
- src/types.ts +23 -1
README.md
CHANGED
|
@@ -1,43 +1,43 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: School Notes Archives
|
| 3 |
-
emoji: 📚
|
| 4 |
-
colorFrom: blue
|
| 5 |
-
colorTo: indigo
|
| 6 |
-
sdk: docker
|
| 7 |
-
app_port: 3000
|
| 8 |
-
pinned: false
|
| 9 |
-
---
|
| 10 |
-
|
| 11 |
-
A private study workspace for organizing notes, uploading documents, and collaborating in subject-based group chats.
|
| 12 |
-
|
| 13 |
-
## Features
|
| 14 |
-
|
| 15 |
-
- Subject-based notes archive
|
| 16 |
-
- Image and `.docx` uploads
|
| 17 |
-
- Searchable notes
|
| 18 |
-
- Group chat rooms
|
| 19 |
-
- Audio file and microphone voice-message attachments (audio up to 2 minutes, video up to 10 seconds)
|
| 20 |
-
- User and admin controls
|
| 21 |
-
- Persistent storage under `/Archives`
|
| 22 |
-
|
| 23 |
-
## Run locally
|
| 24 |
-
|
| 25 |
-
**Prerequisites:** Node.js 22 or later
|
| 26 |
-
|
| 27 |
-
```bash
|
| 28 |
-
npm install
|
| 29 |
-
npm run dev
|
| 30 |
-
```
|
| 31 |
-
|
| 32 |
-
The application runs at `http://localhost:3000`.
|
| 33 |
-
|
| 34 |
-
## Production build
|
| 35 |
-
|
| 36 |
-
```bash
|
| 37 |
-
npm run build
|
| 38 |
-
npm start
|
| 39 |
-
```
|
| 40 |
-
|
| 41 |
-
For Hugging Face Spaces, the Docker Space should attach persistent storage at `/Archives` so uploaded files, call history, and application data survive restarts. The Gemini API key is not required for the core notes archive; configure any required secrets in the Space settings.
|
| 42 |
-
|
| 43 |
-
Chat supports audio uploads, browser microphone recording, and short video uploads through the existing Node/Express API. Audio is limited to 2 minutes and video to 10 seconds. Microphone recording requires a secure browser context, which the HTTPS URL of a deployed Hugging Face Space provides. No Python or FastRTC service is required for this Docker-based app.
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: School Notes Archives
|
| 3 |
+
emoji: 📚
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 3000
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
A private study workspace for organizing notes, uploading documents, and collaborating in subject-based group chats.
|
| 12 |
+
|
| 13 |
+
## Features
|
| 14 |
+
|
| 15 |
+
- Subject-based notes archive
|
| 16 |
+
- Image and `.docx` uploads
|
| 17 |
+
- Searchable notes
|
| 18 |
+
- Group chat rooms
|
| 19 |
+
- Audio file and microphone voice-message attachments (audio up to 2 minutes, video up to 10 seconds)
|
| 20 |
+
- User and admin controls
|
| 21 |
+
- Persistent storage under `/Archives`
|
| 22 |
+
|
| 23 |
+
## Run locally
|
| 24 |
+
|
| 25 |
+
**Prerequisites:** Node.js 22 or later
|
| 26 |
+
|
| 27 |
+
```bash
|
| 28 |
+
npm install
|
| 29 |
+
npm run dev
|
| 30 |
+
```
|
| 31 |
+
|
| 32 |
+
The application runs at `http://localhost:3000`.
|
| 33 |
+
|
| 34 |
+
## Production build
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
npm run build
|
| 38 |
+
npm start
|
| 39 |
+
```
|
| 40 |
+
|
| 41 |
+
For Hugging Face Spaces, the Docker Space should attach persistent storage at `/Archives` so uploaded files, call history, and application data survive restarts. The Gemini API key is not required for the core notes archive; configure any required secrets in the Space settings.
|
| 42 |
+
|
| 43 |
+
Chat supports audio uploads, browser microphone recording, and short video uploads through the existing Node/Express API. Audio is limited to 2 minutes and video to 10 seconds. Microphone recording requires a secure browser context, which the HTTPS URL of a deployed Hugging Face Space provides. No Python or FastRTC service is required for this Docker-based app.
|
bun.lock
CHANGED
|
@@ -7,14 +7,16 @@
|
|
| 7 |
"dependencies": {
|
| 8 |
"@google/genai": "^2.4.0",
|
| 9 |
"@tailwindcss/vite": "^4.1.14",
|
|
|
|
|
|
|
| 10 |
"@vitejs/plugin-react": "^5.0.4",
|
| 11 |
"dotenv": "^17.2.3",
|
| 12 |
"express": "^4.21.2",
|
| 13 |
"lucide-react": "^0.546.0",
|
| 14 |
"motion": "^12.23.24",
|
| 15 |
"multer": "^2.3.0",
|
| 16 |
-
"react": "^
|
| 17 |
-
"react-dom": "^
|
| 18 |
"vite": "^6.2.3",
|
| 19 |
},
|
| 20 |
"devDependencies": {
|
|
@@ -261,10 +263,16 @@
|
|
| 261 |
|
| 262 |
"@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
|
| 263 |
|
|
|
|
|
|
|
| 264 |
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
|
| 265 |
|
| 266 |
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
| 267 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
| 269 |
|
| 270 |
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
|
@@ -319,6 +327,8 @@
|
|
| 319 |
|
| 320 |
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
| 321 |
|
|
|
|
|
|
|
| 322 |
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
| 323 |
|
| 324 |
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
|
@@ -453,6 +463,8 @@
|
|
| 453 |
|
| 454 |
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
| 455 |
|
|
|
|
|
|
|
| 456 |
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
| 457 |
|
| 458 |
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
|
|
@@ -521,9 +533,9 @@
|
|
| 521 |
|
| 522 |
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
| 523 |
|
| 524 |
-
"react": ["react@
|
| 525 |
|
| 526 |
-
"react-dom": ["react-dom@
|
| 527 |
|
| 528 |
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
| 529 |
|
|
@@ -537,7 +549,7 @@
|
|
| 537 |
|
| 538 |
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
| 539 |
|
| 540 |
-
"scheduler": ["scheduler@0.
|
| 541 |
|
| 542 |
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
| 543 |
|
|
|
|
| 7 |
"dependencies": {
|
| 8 |
"@google/genai": "^2.4.0",
|
| 9 |
"@tailwindcss/vite": "^4.1.14",
|
| 10 |
+
"@types/react": "^18.3.12",
|
| 11 |
+
"@types/react-dom": "^18.3.1",
|
| 12 |
"@vitejs/plugin-react": "^5.0.4",
|
| 13 |
"dotenv": "^17.2.3",
|
| 14 |
"express": "^4.21.2",
|
| 15 |
"lucide-react": "^0.546.0",
|
| 16 |
"motion": "^12.23.24",
|
| 17 |
"multer": "^2.3.0",
|
| 18 |
+
"react": "^18.3.1",
|
| 19 |
+
"react-dom": "^18.3.1",
|
| 20 |
"vite": "^6.2.3",
|
| 21 |
},
|
| 22 |
"devDependencies": {
|
|
|
|
| 263 |
|
| 264 |
"@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
|
| 265 |
|
| 266 |
+
"@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="],
|
| 267 |
+
|
| 268 |
"@types/qs": ["@types/qs@6.15.1", "", {}, "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw=="],
|
| 269 |
|
| 270 |
"@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="],
|
| 271 |
|
| 272 |
+
"@types/react": ["@types/react@18.3.31", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw=="],
|
| 273 |
+
|
| 274 |
+
"@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="],
|
| 275 |
+
|
| 276 |
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
|
| 277 |
|
| 278 |
"@types/send": ["@types/send@1.2.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ=="],
|
|
|
|
| 327 |
|
| 328 |
"cookie-signature": ["cookie-signature@1.0.7", "", {}, "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA=="],
|
| 329 |
|
| 330 |
+
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
| 331 |
+
|
| 332 |
"data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
|
| 333 |
|
| 334 |
"debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="],
|
|
|
|
| 463 |
|
| 464 |
"long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
|
| 465 |
|
| 466 |
+
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
| 467 |
+
|
| 468 |
"lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
| 469 |
|
| 470 |
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
|
|
|
|
| 533 |
|
| 534 |
"raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="],
|
| 535 |
|
| 536 |
+
"react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="],
|
| 537 |
|
| 538 |
+
"react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="],
|
| 539 |
|
| 540 |
"react-refresh": ["react-refresh@0.18.0", "", {}, "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw=="],
|
| 541 |
|
|
|
|
| 549 |
|
| 550 |
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
|
| 551 |
|
| 552 |
+
"scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="],
|
| 553 |
|
| 554 |
"semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
| 555 |
|
index.html
CHANGED
|
@@ -1,18 +1,17 @@
|
|
| 1 |
-
<!doctype html>
|
| 2 |
-
<html lang="en">
|
| 3 |
-
<head>
|
| 4 |
-
<meta charset="UTF-8" />
|
| 5 |
-
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
-
<title>School Notes Archives</title>
|
| 7 |
-
<meta name="description" content="Secure academic notes repository with 9 subject folders, study group chat, document search, dark mode, and admin controls." />
|
| 8 |
-
<meta property="og:title" content="School Notes Archives" />
|
| 9 |
-
<meta property="og:description" content="Secure academic notes repository with 9 subject folders, study group chat, document search, dark mode, and admin controls." />
|
| 10 |
-
<meta property="og:type" content="website" />
|
| 11 |
-
<meta name="twitter:card" content="summary_large_image" />
|
| 12 |
-
</head>
|
| 13 |
-
<body>
|
| 14 |
-
<div id="root"></div>
|
| 15 |
-
<script type="module" src="/src/main.tsx"></script>
|
| 16 |
-
</body>
|
| 17 |
-
</html>
|
| 18 |
-
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>School Notes Archives</title>
|
| 7 |
+
<meta name="description" content="Secure academic notes repository with 9 subject folders, study group chat, document search, dark mode, and admin controls." />
|
| 8 |
+
<meta property="og:title" content="School Notes Archives" />
|
| 9 |
+
<meta property="og:description" content="Secure academic notes repository with 9 subject folders, study group chat, document search, dark mode, and admin controls." />
|
| 10 |
+
<meta property="og:type" content="website" />
|
| 11 |
+
<meta name="twitter:card" content="summary_large_image" />
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<div id="root"></div>
|
| 15 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 16 |
+
</body>
|
| 17 |
+
</html>
|
|
|
package-lock.json
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -14,14 +14,16 @@
|
|
| 14 |
"dependencies": {
|
| 15 |
"@google/genai": "^2.4.0",
|
| 16 |
"@tailwindcss/vite": "^4.1.14",
|
|
|
|
|
|
|
| 17 |
"@vitejs/plugin-react": "^5.0.4",
|
| 18 |
"dotenv": "^17.2.3",
|
| 19 |
"express": "^4.21.2",
|
| 20 |
"lucide-react": "^0.546.0",
|
| 21 |
"motion": "^12.23.24",
|
| 22 |
"multer": "^2.3.0",
|
| 23 |
-
"react": "^
|
| 24 |
-
"react-dom": "^
|
| 25 |
"vite": "^6.2.3"
|
| 26 |
},
|
| 27 |
"devDependencies": {
|
|
|
|
| 14 |
"dependencies": {
|
| 15 |
"@google/genai": "^2.4.0",
|
| 16 |
"@tailwindcss/vite": "^4.1.14",
|
| 17 |
+
"@types/react": "^18.3.12",
|
| 18 |
+
"@types/react-dom": "^18.3.1",
|
| 19 |
"@vitejs/plugin-react": "^5.0.4",
|
| 20 |
"dotenv": "^17.2.3",
|
| 21 |
"express": "^4.21.2",
|
| 22 |
"lucide-react": "^0.546.0",
|
| 23 |
"motion": "^12.23.24",
|
| 24 |
"multer": "^2.3.0",
|
| 25 |
+
"react": "^18.3.1",
|
| 26 |
+
"react-dom": "^18.3.1",
|
| 27 |
"vite": "^6.2.3"
|
| 28 |
},
|
| 29 |
"devDependencies": {
|
server.ts
CHANGED
|
@@ -21,6 +21,7 @@ const CHATS_FILE = path.join(ARCHIVES_DIR, 'chats.json');
|
|
| 21 |
const NOTIFICATIONS_FILE = path.join(ARCHIVES_DIR, 'notifications.json');
|
| 22 |
const GROUPS_FILE = path.join(ARCHIVES_DIR, 'groups.json');
|
| 23 |
const CALLS_FILE = path.join(ARCHIVES_DIR, 'calls.json');
|
|
|
|
| 24 |
|
| 25 |
// Ensure directories exist
|
| 26 |
function ensureDirsAndFiles() {
|
|
@@ -135,6 +136,10 @@ function ensureDirsAndFiles() {
|
|
| 135 |
if (!fs.existsSync(CALLS_FILE)) {
|
| 136 |
fs.writeFileSync(CALLS_FILE, JSON.stringify([], null, 2), 'utf-8');
|
| 137 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
|
| 140 |
ensureDirsAndFiles();
|
|
@@ -158,12 +163,17 @@ function writeJson(filePath: string, data: any) {
|
|
| 158 |
}
|
| 159 |
}
|
| 160 |
|
| 161 |
-
//
|
| 162 |
-
|
|
|
|
|
|
|
|
|
|
| 163 |
|
| 164 |
function generateToken(username: string): string {
|
| 165 |
const token = crypto.randomBytes(24).toString('hex');
|
| 166 |
-
|
|
|
|
|
|
|
| 167 |
return token;
|
| 168 |
}
|
| 169 |
|
|
@@ -177,7 +187,7 @@ function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
|
| 177 |
return;
|
| 178 |
}
|
| 179 |
|
| 180 |
-
const username =
|
| 181 |
if (!username) {
|
| 182 |
res.status(401).json({ error: 'Invalid or expired session token' });
|
| 183 |
return;
|
|
@@ -656,9 +666,9 @@ app.post('/api/calls', authMiddleware, (req: Request, res: Response) => {
|
|
| 656 |
|
| 657 |
const calls = readJson<any[]>(CALLS_FILE, []);
|
| 658 |
const today = new Date().toISOString().split('T')[0];
|
| 659 |
-
const
|
| 660 |
-
if (
|
| 661 |
-
res.status(400).json({ error: 'You can start
|
| 662 |
return;
|
| 663 |
}
|
| 664 |
|
|
@@ -761,8 +771,9 @@ app.post('/api/chats/upload-file', authMiddleware, upload.single('file'), (req:
|
|
| 761 |
}
|
| 762 |
|
| 763 |
const ext = path.extname(file.originalname).toLowerCase();
|
| 764 |
-
const
|
| 765 |
-
const isAudio = file.mimetype.startsWith('audio/');
|
|
|
|
| 766 |
const mediaDuration = req.body.mediaDuration ? parseFloat(req.body.mediaDuration) : undefined;
|
| 767 |
|
| 768 |
if (isVideo && mediaDuration !== undefined && (!Number.isFinite(mediaDuration) || mediaDuration > 10)) {
|
|
@@ -781,7 +792,7 @@ app.post('/api/chats/upload-file', authMiddleware, upload.single('file'), (req:
|
|
| 781 |
if (file.mimetype.startsWith('image/')) fileType = 'image';
|
| 782 |
else if (ext === '.docx' || file.mimetype.includes('wordprocessingml')) fileType = 'docx';
|
| 783 |
else if (isVideo) fileType = 'video';
|
| 784 |
-
else if (
|
| 785 |
|
| 786 |
const fileUrl = `/Archives/uploads/${file.filename}`;
|
| 787 |
|
|
|
|
| 21 |
const NOTIFICATIONS_FILE = path.join(ARCHIVES_DIR, 'notifications.json');
|
| 22 |
const GROUPS_FILE = path.join(ARCHIVES_DIR, 'groups.json');
|
| 23 |
const CALLS_FILE = path.join(ARCHIVES_DIR, 'calls.json');
|
| 24 |
+
const SESSIONS_FILE = path.join(ARCHIVES_DIR, 'sessions.json');
|
| 25 |
|
| 26 |
// Ensure directories exist
|
| 27 |
function ensureDirsAndFiles() {
|
|
|
|
| 136 |
if (!fs.existsSync(CALLS_FILE)) {
|
| 137 |
fs.writeFileSync(CALLS_FILE, JSON.stringify([], null, 2), 'utf-8');
|
| 138 |
}
|
| 139 |
+
|
| 140 |
+
if (!fs.existsSync(SESSIONS_FILE)) {
|
| 141 |
+
fs.writeFileSync(SESSIONS_FILE, JSON.stringify({}, null, 2), 'utf-8');
|
| 142 |
+
}
|
| 143 |
}
|
| 144 |
|
| 145 |
ensureDirsAndFiles();
|
|
|
|
| 163 |
}
|
| 164 |
}
|
| 165 |
|
| 166 |
+
// Persistent session tokens
|
| 167 |
+
function getUsernameForToken(token: string): string | undefined {
|
| 168 |
+
const sessions = readJson<Record<string, string>>(SESSIONS_FILE, {});
|
| 169 |
+
return sessions[token];
|
| 170 |
+
}
|
| 171 |
|
| 172 |
function generateToken(username: string): string {
|
| 173 |
const token = crypto.randomBytes(24).toString('hex');
|
| 174 |
+
const sessions = readJson<Record<string, string>>(SESSIONS_FILE, {});
|
| 175 |
+
sessions[token] = username;
|
| 176 |
+
writeJson(SESSIONS_FILE, sessions);
|
| 177 |
return token;
|
| 178 |
}
|
| 179 |
|
|
|
|
| 187 |
return;
|
| 188 |
}
|
| 189 |
|
| 190 |
+
const username = getUsernameForToken(token);
|
| 191 |
if (!username) {
|
| 192 |
res.status(401).json({ error: 'Invalid or expired session token' });
|
| 193 |
return;
|
|
|
|
| 666 |
|
| 667 |
const calls = readJson<any[]>(CALLS_FILE, []);
|
| 668 |
const today = new Date().toISOString().split('T')[0];
|
| 669 |
+
const callsTodayCount = calls.filter((call) => call.caller.toLowerCase() === currentUser.username.toLowerCase() && callDate(call.startedAt) === today).length;
|
| 670 |
+
if (callsTodayCount >= 3) {
|
| 671 |
+
res.status(400).json({ error: 'You can start a maximum of 3 voice calls per day.' });
|
| 672 |
return;
|
| 673 |
}
|
| 674 |
|
|
|
|
| 771 |
}
|
| 772 |
|
| 773 |
const ext = path.extname(file.originalname).toLowerCase();
|
| 774 |
+
const audioExts = ['.webm', '.ogg', '.mp3', '.wav', '.m4a', '.aac', '.opus'];
|
| 775 |
+
const isAudio = file.mimetype.startsWith('audio/') || (audioExts.includes(ext) && !file.mimetype.startsWith('video/'));
|
| 776 |
+
const isVideo = (file.mimetype.startsWith('video/') || ['.mp4', '.mov', '.avi', '.mkv'].includes(ext)) && !isAudio;
|
| 777 |
const mediaDuration = req.body.mediaDuration ? parseFloat(req.body.mediaDuration) : undefined;
|
| 778 |
|
| 779 |
if (isVideo && mediaDuration !== undefined && (!Number.isFinite(mediaDuration) || mediaDuration > 10)) {
|
|
|
|
| 792 |
if (file.mimetype.startsWith('image/')) fileType = 'image';
|
| 793 |
else if (ext === '.docx' || file.mimetype.includes('wordprocessingml')) fileType = 'docx';
|
| 794 |
else if (isVideo) fileType = 'video';
|
| 795 |
+
else if (isAudio) fileType = 'audio';
|
| 796 |
|
| 797 |
const fileUrl = `/Archives/uploads/${file.filename}`;
|
| 798 |
|
src/App.tsx
CHANGED
|
@@ -64,6 +64,12 @@ export default function App() {
|
|
| 64 |
};
|
| 65 |
|
| 66 |
checkAutoLogin();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}, []);
|
| 68 |
|
| 69 |
// Fetch Notes
|
|
|
|
| 64 |
};
|
| 65 |
|
| 66 |
checkAutoLogin();
|
| 67 |
+
|
| 68 |
+
const handleUnauthorized = () => {
|
| 69 |
+
setCurrentUser(null);
|
| 70 |
+
};
|
| 71 |
+
window.addEventListener('auth_unauthorized', handleUnauthorized);
|
| 72 |
+
return () => window.removeEventListener('auth_unauthorized', handleUnauthorized);
|
| 73 |
}, []);
|
| 74 |
|
| 75 |
// Fetch Notes
|
src/components/ChatRoom.tsx
CHANGED
|
@@ -3,8 +3,6 @@ import {
|
|
| 3 |
MessageSquare,
|
| 4 |
Send,
|
| 5 |
Paperclip,
|
| 6 |
-
Mic,
|
| 7 |
-
Square,
|
| 8 |
Volume2,
|
| 9 |
VolumeX,
|
| 10 |
Users,
|
|
@@ -12,12 +10,20 @@ import {
|
|
| 12 |
UserMinus,
|
| 13 |
Video,
|
| 14 |
FileText,
|
|
|
|
| 15 |
AlertCircle,
|
|
|
|
| 16 |
Sparkles,
|
| 17 |
Download,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
} from 'lucide-react';
|
| 19 |
-
import { User, ChatMessage, GroupInfo } from '../types';
|
| 20 |
import { api } from '../services/api';
|
|
|
|
| 21 |
|
| 22 |
interface ChatRoomProps {
|
| 23 |
currentUser: User;
|
|
@@ -32,26 +38,182 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
|
|
| 32 |
const [talkEffectEnabled, setTalkEffectEnabled] = useState(false);
|
| 33 |
const [uploading, setUploading] = useState(false);
|
| 34 |
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
| 35 |
-
const [
|
| 36 |
-
const [mediaDuration, setMediaDuration] = useState<number | null>(null);
|
| 37 |
const [fileError, setFileError] = useState<string | null>(null);
|
| 38 |
const [chatError, setChatError] = useState<string | null>(null);
|
| 39 |
|
| 40 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 41 |
const lastReadChatIdRef = useRef<string | null>(null);
|
| 42 |
-
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
| 43 |
-
const recordedChunksRef = useRef<Blob[]>([]);
|
| 44 |
-
const recordingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
| 45 |
-
const recordingStartedAtRef = useRef<number | null>(null);
|
| 46 |
|
| 47 |
const joinedGroups = currentUser.joinedGroups || ['General Lobby'];
|
| 48 |
const isJoined = joinedGroups.includes(selectedGroup);
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
// Load Groups List
|
| 51 |
useEffect(() => {
|
| 52 |
api.getGroups().then((g) => setGroups(g)).catch(() => {});
|
| 53 |
}, []);
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
// Poll Chats every 2 seconds for live real-time updates
|
| 56 |
useEffect(() => {
|
| 57 |
let isMounted = true;
|
|
@@ -98,97 +260,41 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
|
|
| 98 |
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 99 |
}, [chats]);
|
| 100 |
|
| 101 |
-
|
| 102 |
-
return () => {
|
| 103 |
-
mediaRecorderRef.current?.stream.getTracks().forEach((track) => track.stop());
|
| 104 |
-
if (recordingTimerRef.current) clearTimeout(recordingTimerRef.current);
|
| 105 |
-
};
|
| 106 |
-
}, []);
|
| 107 |
-
|
| 108 |
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 109 |
setFileError(null);
|
| 110 |
-
|
| 111 |
|
| 112 |
if (e.target.files && e.target.files[0]) {
|
| 113 |
const file = e.target.files[0];
|
| 114 |
-
const
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
const
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
URL.revokeObjectURL(
|
| 124 |
-
const
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
| 128 |
setSelectedFile(null);
|
| 129 |
return;
|
| 130 |
}
|
| 131 |
-
setMediaDuration(duration);
|
| 132 |
setSelectedFile(file);
|
| 133 |
};
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
setFileError('Could not read
|
| 137 |
setSelectedFile(null);
|
| 138 |
};
|
| 139 |
-
|
|
|
|
| 140 |
}
|
| 141 |
-
setSelectedFile(file);
|
| 142 |
-
}
|
| 143 |
-
};
|
| 144 |
-
|
| 145 |
-
const handleToggleRecording = async () => {
|
| 146 |
-
if (isRecording) {
|
| 147 |
-
mediaRecorderRef.current?.stop();
|
| 148 |
-
return;
|
| 149 |
-
}
|
| 150 |
-
|
| 151 |
-
if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) {
|
| 152 |
-
setFileError('Audio recording is not supported by this browser.');
|
| 153 |
-
return;
|
| 154 |
-
}
|
| 155 |
-
|
| 156 |
-
try {
|
| 157 |
-
setFileError(null);
|
| 158 |
-
setSelectedFile(null);
|
| 159 |
-
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 160 |
-
const recorder = new MediaRecorder(stream);
|
| 161 |
-
recordedChunksRef.current = [];
|
| 162 |
-
mediaRecorderRef.current = recorder;
|
| 163 |
-
|
| 164 |
-
recorder.ondataavailable = (event) => {
|
| 165 |
-
if (event.data.size > 0) recordedChunksRef.current.push(event.data);
|
| 166 |
-
};
|
| 167 |
-
recorder.onstop = () => {
|
| 168 |
-
const extension = recorder.mimeType.includes('ogg') ? 'ogg' : 'webm';
|
| 169 |
-
const recordedDuration = recordingStartedAtRef.current
|
| 170 |
-
? (Date.now() - recordingStartedAtRef.current) / 1000
|
| 171 |
-
: undefined;
|
| 172 |
-
const audioFile = new File(
|
| 173 |
-
[new Blob(recordedChunksRef.current, { type: recorder.mimeType })],
|
| 174 |
-
`voice-message-${Date.now()}.${extension}`,
|
| 175 |
-
{ type: recorder.mimeType },
|
| 176 |
-
);
|
| 177 |
-
setSelectedFile(audioFile);
|
| 178 |
-
setMediaDuration(recordedDuration ?? null);
|
| 179 |
-
stream.getTracks().forEach((track) => track.stop());
|
| 180 |
-
mediaRecorderRef.current = null;
|
| 181 |
-
recordingStartedAtRef.current = null;
|
| 182 |
-
recordingTimerRef.current = null;
|
| 183 |
-
setIsRecording(false);
|
| 184 |
-
};
|
| 185 |
-
|
| 186 |
-
recorder.start();
|
| 187 |
-
recordingStartedAtRef.current = Date.now();
|
| 188 |
-
setIsRecording(true);
|
| 189 |
-
recordingTimerRef.current = setTimeout(() => recorder.stop(), 120000);
|
| 190 |
-
} catch {
|
| 191 |
-
setFileError('Microphone access was denied or unavailable.');
|
| 192 |
}
|
| 193 |
};
|
| 194 |
|
|
@@ -204,14 +310,14 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
|
|
| 204 |
let attachment = undefined;
|
| 205 |
|
| 206 |
if (selectedFile) {
|
| 207 |
-
attachment = await api.uploadChatFile(selectedFile,
|
| 208 |
}
|
| 209 |
|
| 210 |
await api.sendChatMessage(selectedGroup, messageText, attachment);
|
| 211 |
|
| 212 |
setMessageText('');
|
| 213 |
setSelectedFile(null);
|
| 214 |
-
|
| 215 |
|
| 216 |
// Refresh chats
|
| 217 |
const updated = await api.getChats(selectedGroup);
|
|
@@ -319,7 +425,30 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
|
|
| 319 |
</p>
|
| 320 |
</div>
|
| 321 |
|
| 322 |
-
<div className="flex items-center gap-2">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 323 |
<button
|
| 324 |
onClick={handleToggleJoinGroup}
|
| 325 |
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
|
@@ -341,12 +470,30 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
|
|
| 341 |
</div>
|
| 342 |
</div>
|
| 343 |
|
| 344 |
-
{/*
|
| 345 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
<div className="flex items-center gap-1.5">
|
| 347 |
-
<
|
| 348 |
<span>
|
| 349 |
-
|
| 350 |
</span>
|
| 351 |
</div>
|
| 352 |
{talkEffectEnabled && (
|
|
@@ -397,19 +544,29 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
|
|
| 397 |
/>
|
| 398 |
)}
|
| 399 |
|
| 400 |
-
{msg.attachment.fileType === 'audio' && (
|
| 401 |
-
<audio src={msg.attachment.fileUrl} controls className="max-w-full" />
|
| 402 |
-
)}
|
| 403 |
-
|
| 404 |
{msg.attachment.fileType === 'video' && (
|
| 405 |
<div className="space-y-1">
|
| 406 |
-
<video
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
<p className="text-[10px] opacity-80 flex items-center gap-1">
|
| 408 |
-
<Video className="w-3 h-3" />
|
| 409 |
</p>
|
| 410 |
</div>
|
| 411 |
)}
|
| 412 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
{['docx', 'other'].includes(msg.attachment.fileType) && (
|
| 414 |
<a
|
| 415 |
href={msg.attachment.fileUrl}
|
|
@@ -440,15 +597,16 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
|
|
| 440 |
</div>
|
| 441 |
|
| 442 |
{/* Errors Alert */}
|
| 443 |
-
{(chatError || fileError) && (
|
| 444 |
<div className="px-4 py-2 bg-rose-50 dark:bg-rose-950/80 text-rose-700 dark:text-rose-300 text-xs font-semibold flex items-center justify-between border-t border-rose-200 dark:border-rose-800">
|
| 445 |
<span className="flex items-center gap-1.5">
|
| 446 |
-
<AlertCircle className="w-4 h-4 shrink-0" /> {chatError || fileError}
|
| 447 |
</span>
|
| 448 |
<button
|
| 449 |
onClick={() => {
|
| 450 |
setChatError(null);
|
| 451 |
setFileError(null);
|
|
|
|
| 452 |
}}
|
| 453 |
className="text-xs hover:underline"
|
| 454 |
>
|
|
@@ -463,16 +621,16 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
|
|
| 463 |
<div className="flex items-center gap-2 truncate">
|
| 464 |
<Paperclip className="w-4 h-4 text-indigo-500 shrink-0" />
|
| 465 |
<span className="font-bold text-slate-800 dark:text-slate-200 truncate">{selectedFile.name}</span>
|
| 466 |
-
{
|
| 467 |
-
<span className="
|
| 468 |
-
{
|
| 469 |
</span>
|
| 470 |
)}
|
| 471 |
</div>
|
| 472 |
<button
|
| 473 |
onClick={() => {
|
| 474 |
setSelectedFile(null);
|
| 475 |
-
|
| 476 |
}}
|
| 477 |
className="text-rose-500 hover:underline text-xs"
|
| 478 |
>
|
|
@@ -481,49 +639,108 @@ export const ChatRoom: React.FC<ChatRoomProps> = ({ currentUser, onRefreshUser }
|
|
| 481 |
</div>
|
| 482 |
)}
|
| 483 |
|
| 484 |
-
{/* Input Bar */}
|
| 485 |
-
|
| 486 |
-
<
|
| 487 |
-
<
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
|
|
|
|
|
|
|
| 513 |
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 523 |
)}
|
| 524 |
-
|
| 525 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 526 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 527 |
</div>
|
| 528 |
);
|
| 529 |
};
|
|
|
|
| 3 |
MessageSquare,
|
| 4 |
Send,
|
| 5 |
Paperclip,
|
|
|
|
|
|
|
| 6 |
Volume2,
|
| 7 |
VolumeX,
|
| 8 |
Users,
|
|
|
|
| 10 |
UserMinus,
|
| 11 |
Video,
|
| 12 |
FileText,
|
| 13 |
+
Image as ImageIcon,
|
| 14 |
AlertCircle,
|
| 15 |
+
Clock,
|
| 16 |
Sparkles,
|
| 17 |
Download,
|
| 18 |
+
PhoneCall,
|
| 19 |
+
Shield,
|
| 20 |
+
Radio,
|
| 21 |
+
Mic,
|
| 22 |
+
X,
|
| 23 |
} from 'lucide-react';
|
| 24 |
+
import { User, ChatMessage, GroupInfo, VoiceCall } from '../types';
|
| 25 |
import { api } from '../services/api';
|
| 26 |
+
import { VoiceMeetingModal } from './VoiceMeetingModal';
|
| 27 |
|
| 28 |
interface ChatRoomProps {
|
| 29 |
currentUser: User;
|
|
|
|
| 38 |
const [talkEffectEnabled, setTalkEffectEnabled] = useState(false);
|
| 39 |
const [uploading, setUploading] = useState(false);
|
| 40 |
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
| 41 |
+
const [videoDuration, setVideoDuration] = useState<number | null>(null);
|
|
|
|
| 42 |
const [fileError, setFileError] = useState<string | null>(null);
|
| 43 |
const [chatError, setChatError] = useState<string | null>(null);
|
| 44 |
|
| 45 |
const messagesEndRef = useRef<HTMLDivElement>(null);
|
| 46 |
const lastReadChatIdRef = useRef<string | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
const joinedGroups = currentUser.joinedGroups || ['General Lobby'];
|
| 49 |
const isJoined = joinedGroups.includes(selectedGroup);
|
| 50 |
|
| 51 |
+
// Voice Meetings State
|
| 52 |
+
const [activeCalls, setActiveCalls] = useState<VoiceCall[]>([]);
|
| 53 |
+
const [currentCall, setCurrentCall] = useState<VoiceCall | null>(null);
|
| 54 |
+
const [isMeetingModalOpen, setIsMeetingModalOpen] = useState(false);
|
| 55 |
+
const [callError, setCallError] = useState<string | null>(null);
|
| 56 |
+
|
| 57 |
+
// Voice Recording State
|
| 58 |
+
const [isRecording, setIsRecording] = useState(false);
|
| 59 |
+
const [recordingSeconds, setRecordingSeconds] = useState(0);
|
| 60 |
+
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
| 61 |
+
const audioChunksRef = useRef<Blob[]>([]);
|
| 62 |
+
const recordingTimerRef = useRef<any>(null);
|
| 63 |
+
const mediaStreamRef = useRef<MediaStream | null>(null);
|
| 64 |
+
|
| 65 |
+
const startVoiceRecording = async () => {
|
| 66 |
+
setChatError(null);
|
| 67 |
+
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
| 68 |
+
setChatError('Voice recording is not supported in this browser.');
|
| 69 |
+
return;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
try {
|
| 73 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
| 74 |
+
mediaStreamRef.current = stream;
|
| 75 |
+
|
| 76 |
+
let mimeType = 'audio/webm';
|
| 77 |
+
if (!MediaRecorder.isTypeSupported('audio/webm')) {
|
| 78 |
+
if (MediaRecorder.isTypeSupported('audio/mp4')) mimeType = 'audio/mp4';
|
| 79 |
+
else if (MediaRecorder.isTypeSupported('audio/ogg')) mimeType = 'audio/ogg';
|
| 80 |
+
else mimeType = '';
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
const options = mimeType ? { mimeType } : undefined;
|
| 84 |
+
const recorder = new MediaRecorder(stream, options);
|
| 85 |
+
mediaRecorderRef.current = recorder;
|
| 86 |
+
audioChunksRef.current = [];
|
| 87 |
+
|
| 88 |
+
recorder.ondataavailable = (event) => {
|
| 89 |
+
if (event.data && event.data.size > 0) {
|
| 90 |
+
audioChunksRef.current.push(event.data);
|
| 91 |
+
}
|
| 92 |
+
};
|
| 93 |
+
|
| 94 |
+
recorder.start(100);
|
| 95 |
+
setIsRecording(true);
|
| 96 |
+
setRecordingSeconds(0);
|
| 97 |
+
|
| 98 |
+
if (recordingTimerRef.current) clearInterval(recordingTimerRef.current);
|
| 99 |
+
recordingTimerRef.current = setInterval(() => {
|
| 100 |
+
setRecordingSeconds((prev) => prev + 1);
|
| 101 |
+
}, 1000);
|
| 102 |
+
} catch (err: any) {
|
| 103 |
+
console.warn('Microphone recording access attempt:', err?.name || err?.message || err);
|
| 104 |
+
const isDenied = err?.name === 'NotAllowedError' || err?.name === 'PermissionDeniedError' || err?.message?.includes('Permission denied') || err?.message?.includes('denied');
|
| 105 |
+
if (isDenied) {
|
| 106 |
+
setChatError('Microphone permission was denied by your browser. Please grant microphone access in your browser settings or click the paperclip icon to upload an audio file.');
|
| 107 |
+
} else {
|
| 108 |
+
setChatError('Unable to access microphone. Please check your browser audio settings.');
|
| 109 |
+
}
|
| 110 |
+
}
|
| 111 |
+
};
|
| 112 |
+
|
| 113 |
+
const stopAndSendVoiceRecording = () => {
|
| 114 |
+
if (!mediaRecorderRef.current || !isRecording) return;
|
| 115 |
+
|
| 116 |
+
const recorder = mediaRecorderRef.current;
|
| 117 |
+
recorder.onstop = async () => {
|
| 118 |
+
if (recordingTimerRef.current) clearInterval(recordingTimerRef.current);
|
| 119 |
+
if (mediaStreamRef.current) {
|
| 120 |
+
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
const audioBlob = new Blob(audioChunksRef.current, {
|
| 124 |
+
type: recorder.mimeType || 'audio/webm',
|
| 125 |
+
});
|
| 126 |
+
|
| 127 |
+
if (audioBlob.size < 100) {
|
| 128 |
+
setChatError('Voice message was too short.');
|
| 129 |
+
setIsRecording(false);
|
| 130 |
+
setRecordingSeconds(0);
|
| 131 |
+
return;
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
const mime = recorder.mimeType || 'audio/webm';
|
| 135 |
+
const ext = mime.includes('mp4') ? 'm4a' : mime.includes('ogg') ? 'ogg' : 'webm';
|
| 136 |
+
const audioFile = new File([audioBlob], `voice-note-${Date.now()}.${ext}`, { type: mime });
|
| 137 |
+
|
| 138 |
+
setUploading(true);
|
| 139 |
+
setIsRecording(false);
|
| 140 |
+
setRecordingSeconds(0);
|
| 141 |
+
|
| 142 |
+
try {
|
| 143 |
+
const attachment = await api.uploadChatFile(audioFile);
|
| 144 |
+
await api.sendChatMessage(selectedGroup, '🎤 Voice Note', attachment);
|
| 145 |
+
const updated = await api.getChats(selectedGroup);
|
| 146 |
+
setChats(updated);
|
| 147 |
+
} catch (err: any) {
|
| 148 |
+
setChatError(err.message || 'Failed to send voice message');
|
| 149 |
+
} finally {
|
| 150 |
+
setUploading(false);
|
| 151 |
+
}
|
| 152 |
+
};
|
| 153 |
+
|
| 154 |
+
recorder.stop();
|
| 155 |
+
};
|
| 156 |
+
|
| 157 |
+
const cancelVoiceRecording = () => {
|
| 158 |
+
if (recordingTimerRef.current) clearInterval(recordingTimerRef.current);
|
| 159 |
+
if (mediaRecorderRef.current && mediaRecorderRef.current.state !== 'inactive') {
|
| 160 |
+
mediaRecorderRef.current.stop();
|
| 161 |
+
}
|
| 162 |
+
if (mediaStreamRef.current) {
|
| 163 |
+
mediaStreamRef.current.getTracks().forEach((track) => track.stop());
|
| 164 |
+
}
|
| 165 |
+
setIsRecording(false);
|
| 166 |
+
setRecordingSeconds(0);
|
| 167 |
+
audioChunksRef.current = [];
|
| 168 |
+
};
|
| 169 |
+
|
| 170 |
// Load Groups List
|
| 171 |
useEffect(() => {
|
| 172 |
api.getGroups().then((g) => setGroups(g)).catch(() => {});
|
| 173 |
}, []);
|
| 174 |
|
| 175 |
+
// Poll Active Voice Calls for the group
|
| 176 |
+
const loadCalls = async () => {
|
| 177 |
+
try {
|
| 178 |
+
const calls = await api.getCalls(selectedGroup);
|
| 179 |
+
setActiveCalls(calls);
|
| 180 |
+
} catch (err) {
|
| 181 |
+
console.error('Calls polling error:', err);
|
| 182 |
+
}
|
| 183 |
+
};
|
| 184 |
+
|
| 185 |
+
useEffect(() => {
|
| 186 |
+
loadCalls();
|
| 187 |
+
const interval = setInterval(loadCalls, 2000);
|
| 188 |
+
return () => clearInterval(interval);
|
| 189 |
+
}, [selectedGroup]);
|
| 190 |
+
|
| 191 |
+
// Start Meeting
|
| 192 |
+
const handleStartCall = async (mode: 'public' | 'secret') => {
|
| 193 |
+
setCallError(null);
|
| 194 |
+
try {
|
| 195 |
+
const newCall = await api.createCall(selectedGroup, mode);
|
| 196 |
+
setCurrentCall(newCall);
|
| 197 |
+
setIsMeetingModalOpen(true);
|
| 198 |
+
await loadCalls();
|
| 199 |
+
} catch (err: any) {
|
| 200 |
+
setCallError(err.message || 'Failed to start meeting');
|
| 201 |
+
}
|
| 202 |
+
};
|
| 203 |
+
|
| 204 |
+
// Join Existing Meeting
|
| 205 |
+
const handleJoinCall = async (callId: string) => {
|
| 206 |
+
setCallError(null);
|
| 207 |
+
try {
|
| 208 |
+
const joined = await api.joinCall(callId);
|
| 209 |
+
setCurrentCall(joined);
|
| 210 |
+
setIsMeetingModalOpen(true);
|
| 211 |
+
await loadCalls();
|
| 212 |
+
} catch (err: any) {
|
| 213 |
+
setCallError(err.message || 'Failed to join meeting');
|
| 214 |
+
}
|
| 215 |
+
};
|
| 216 |
+
|
| 217 |
// Poll Chats every 2 seconds for live real-time updates
|
| 218 |
useEffect(() => {
|
| 219 |
let isMounted = true;
|
|
|
|
| 260 |
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
| 261 |
}, [chats]);
|
| 262 |
|
| 263 |
+
// Handle File Selection with Video Duration Inspection
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
| 265 |
setFileError(null);
|
| 266 |
+
setVideoDuration(null);
|
| 267 |
|
| 268 |
if (e.target.files && e.target.files[0]) {
|
| 269 |
const file = e.target.files[0];
|
| 270 |
+
const isVid = file.type.startsWith('video/') || ['.mp4', '.webm', '.mov', '.avi'].some((ext) => file.name.toLowerCase().endsWith(ext));
|
| 271 |
+
|
| 272 |
+
if (isVid) {
|
| 273 |
+
// Load HTML5 Video element in memory to inspect duration
|
| 274 |
+
const videoEl = document.createElement('video');
|
| 275 |
+
videoEl.preload = 'metadata';
|
| 276 |
+
videoEl.src = URL.createObjectURL(file);
|
| 277 |
+
|
| 278 |
+
videoEl.onloadedmetadata = () => {
|
| 279 |
+
URL.revokeObjectURL(videoEl.src);
|
| 280 |
+
const dur = videoEl.duration;
|
| 281 |
+
setVideoDuration(dur);
|
| 282 |
+
|
| 283 |
+
if (dur > 5.5) {
|
| 284 |
+
setFileError(`Video Duration Exceeded! Uploaded video is ${dur.toFixed(1)} seconds. Strict limit is 5 seconds maximum.`);
|
| 285 |
setSelectedFile(null);
|
| 286 |
return;
|
| 287 |
}
|
|
|
|
| 288 |
setSelectedFile(file);
|
| 289 |
};
|
| 290 |
+
|
| 291 |
+
videoEl.onerror = () => {
|
| 292 |
+
setFileError('Could not read video file duration.');
|
| 293 |
setSelectedFile(null);
|
| 294 |
};
|
| 295 |
+
} else {
|
| 296 |
+
setSelectedFile(file);
|
| 297 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 298 |
}
|
| 299 |
};
|
| 300 |
|
|
|
|
| 310 |
let attachment = undefined;
|
| 311 |
|
| 312 |
if (selectedFile) {
|
| 313 |
+
attachment = await api.uploadChatFile(selectedFile, videoDuration || undefined);
|
| 314 |
}
|
| 315 |
|
| 316 |
await api.sendChatMessage(selectedGroup, messageText, attachment);
|
| 317 |
|
| 318 |
setMessageText('');
|
| 319 |
setSelectedFile(null);
|
| 320 |
+
setVideoDuration(null);
|
| 321 |
|
| 322 |
// Refresh chats
|
| 323 |
const updated = await api.getChats(selectedGroup);
|
|
|
|
| 425 |
</p>
|
| 426 |
</div>
|
| 427 |
|
| 428 |
+
<div className="flex items-center gap-2 flex-wrap">
|
| 429 |
+
{/* Start Live Meeting Buttons */}
|
| 430 |
+
{isJoined && (
|
| 431 |
+
<div className="flex items-center gap-1.5">
|
| 432 |
+
<button
|
| 433 |
+
onClick={() => handleStartCall('public')}
|
| 434 |
+
className="flex items-center gap-1 px-3 py-1.5 rounded-xl text-xs font-semibold bg-emerald-600 hover:bg-emerald-500 text-white shadow-md shadow-emerald-600/20 transition-all cursor-pointer"
|
| 435 |
+
title="Start Google Meet-style Live Voice Meeting"
|
| 436 |
+
>
|
| 437 |
+
<PhoneCall className="w-3.5 h-3.5" />
|
| 438 |
+
<span>Start Live Meeting</span>
|
| 439 |
+
</button>
|
| 440 |
+
|
| 441 |
+
<button
|
| 442 |
+
onClick={() => handleStartCall('secret')}
|
| 443 |
+
className="flex items-center gap-1 px-3 py-1.5 rounded-xl text-xs font-semibold bg-amber-600 hover:bg-amber-500 text-white shadow-md shadow-amber-600/20 transition-all cursor-pointer"
|
| 444 |
+
title="Start Secret Voice Call with Invited Members"
|
| 445 |
+
>
|
| 446 |
+
<Shield className="w-3.5 h-3.5" />
|
| 447 |
+
<span className="hidden sm:inline">Secret Call</span>
|
| 448 |
+
</button>
|
| 449 |
+
</div>
|
| 450 |
+
)}
|
| 451 |
+
|
| 452 |
<button
|
| 453 |
onClick={handleToggleJoinGroup}
|
| 454 |
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-semibold transition-all cursor-pointer ${
|
|
|
|
| 470 |
</div>
|
| 471 |
</div>
|
| 472 |
|
| 473 |
+
{/* Active Voice Call Ongoing Banner */}
|
| 474 |
+
{activeCalls.length > 0 && (
|
| 475 |
+
<div className="px-4 py-2.5 bg-gradient-to-r from-emerald-600 to-teal-600 text-white text-xs font-semibold flex items-center justify-between border-b border-emerald-500 shadow-inner">
|
| 476 |
+
<div className="flex items-center gap-2">
|
| 477 |
+
<span className="w-2.5 h-2.5 rounded-full bg-white animate-ping shrink-0" />
|
| 478 |
+
<span>
|
| 479 |
+
<strong>Live Voice Meeting Active!</strong> Host: {activeCalls[0].caller} ({activeCalls[0].activeUsers.length} member{activeCalls[0].activeUsers.length === 1 ? '' : 's'} in call)
|
| 480 |
+
</span>
|
| 481 |
+
</div>
|
| 482 |
+
<button
|
| 483 |
+
onClick={() => handleJoinCall(activeCalls[0].id)}
|
| 484 |
+
className="px-3 py-1 rounded-lg bg-white text-emerald-800 hover:bg-emerald-50 font-bold text-xs shadow transition-all cursor-pointer flex items-center gap-1"
|
| 485 |
+
>
|
| 486 |
+
<PhoneCall className="w-3.5 h-3.5" /> Join Meeting
|
| 487 |
+
</button>
|
| 488 |
+
</div>
|
| 489 |
+
)}
|
| 490 |
+
|
| 491 |
+
{/* Video Limit Info Banner */}
|
| 492 |
+
<div className="px-4 py-2 bg-amber-50 dark:bg-amber-950/40 border-b border-amber-200/60 dark:border-amber-900/40 text-[11px] text-amber-800 dark:text-amber-300 flex items-center justify-between">
|
| 493 |
<div className="flex items-center gap-1.5">
|
| 494 |
+
<Clock className="w-3.5 h-3.5 text-amber-600 shrink-0" />
|
| 495 |
<span>
|
| 496 |
+
<strong>Video Rules:</strong> Max <strong>5 seconds</strong> video per upload • Only <strong>1 video per user per day</strong>.
|
| 497 |
</span>
|
| 498 |
</div>
|
| 499 |
{talkEffectEnabled && (
|
|
|
|
| 544 |
/>
|
| 545 |
)}
|
| 546 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 547 |
{msg.attachment.fileType === 'video' && (
|
| 548 |
<div className="space-y-1">
|
| 549 |
+
<video
|
| 550 |
+
src={msg.attachment.fileUrl}
|
| 551 |
+
controls
|
| 552 |
+
className="max-h-48 rounded-lg w-full bg-black"
|
| 553 |
+
/>
|
| 554 |
<p className="text-[10px] opacity-80 flex items-center gap-1">
|
| 555 |
+
<Video className="w-3 h-3" /> 5s Video limit checked
|
| 556 |
</p>
|
| 557 |
</div>
|
| 558 |
)}
|
| 559 |
|
| 560 |
+
{msg.attachment.fileType === 'audio' && (
|
| 561 |
+
<div className="space-y-1 mt-1">
|
| 562 |
+
<audio
|
| 563 |
+
src={msg.attachment.fileUrl}
|
| 564 |
+
controls
|
| 565 |
+
className="w-full max-w-[280px] h-9 rounded-lg"
|
| 566 |
+
/>
|
| 567 |
+
</div>
|
| 568 |
+
)}
|
| 569 |
+
|
| 570 |
{['docx', 'other'].includes(msg.attachment.fileType) && (
|
| 571 |
<a
|
| 572 |
href={msg.attachment.fileUrl}
|
|
|
|
| 597 |
</div>
|
| 598 |
|
| 599 |
{/* Errors Alert */}
|
| 600 |
+
{(chatError || fileError || callError) && (
|
| 601 |
<div className="px-4 py-2 bg-rose-50 dark:bg-rose-950/80 text-rose-700 dark:text-rose-300 text-xs font-semibold flex items-center justify-between border-t border-rose-200 dark:border-rose-800">
|
| 602 |
<span className="flex items-center gap-1.5">
|
| 603 |
+
<AlertCircle className="w-4 h-4 shrink-0" /> {chatError || fileError || callError}
|
| 604 |
</span>
|
| 605 |
<button
|
| 606 |
onClick={() => {
|
| 607 |
setChatError(null);
|
| 608 |
setFileError(null);
|
| 609 |
+
setCallError(null);
|
| 610 |
}}
|
| 611 |
className="text-xs hover:underline"
|
| 612 |
>
|
|
|
|
| 621 |
<div className="flex items-center gap-2 truncate">
|
| 622 |
<Paperclip className="w-4 h-4 text-indigo-500 shrink-0" />
|
| 623 |
<span className="font-bold text-slate-800 dark:text-slate-200 truncate">{selectedFile.name}</span>
|
| 624 |
+
{videoDuration && (
|
| 625 |
+
<span className="px-1.5 py-0.5 rounded bg-indigo-200 dark:bg-indigo-900 text-indigo-800 dark:text-indigo-200 text-[10px] font-bold">
|
| 626 |
+
{videoDuration.toFixed(1)}s Video
|
| 627 |
</span>
|
| 628 |
)}
|
| 629 |
</div>
|
| 630 |
<button
|
| 631 |
onClick={() => {
|
| 632 |
setSelectedFile(null);
|
| 633 |
+
setVideoDuration(null);
|
| 634 |
}}
|
| 635 |
className="text-rose-500 hover:underline text-xs"
|
| 636 |
>
|
|
|
|
| 639 |
</div>
|
| 640 |
)}
|
| 641 |
|
| 642 |
+
{/* Input Bar & Voice Recorder */}
|
| 643 |
+
{isRecording ? (
|
| 644 |
+
<div className="p-3 border-t border-slate-200 dark:border-slate-800 flex items-center justify-between gap-3 bg-rose-50/60 dark:bg-rose-950/40">
|
| 645 |
+
<div className="flex items-center gap-2.5">
|
| 646 |
+
<span className="relative flex h-3 w-3">
|
| 647 |
+
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-rose-400 opacity-75"></span>
|
| 648 |
+
<span className="relative inline-flex rounded-full h-3 w-3 bg-rose-500"></span>
|
| 649 |
+
</span>
|
| 650 |
+
<Mic className="w-4 h-4 text-rose-600 dark:text-rose-400 animate-pulse" />
|
| 651 |
+
<span className="font-mono text-xs font-extrabold text-rose-600 dark:text-rose-400">
|
| 652 |
+
{Math.floor(recordingSeconds / 60)
|
| 653 |
+
.toString()
|
| 654 |
+
.padStart(2, '0')}
|
| 655 |
+
:
|
| 656 |
+
{(recordingSeconds % 60).toString().padStart(2, '0')}
|
| 657 |
+
</span>
|
| 658 |
+
<span className="text-xs text-slate-600 dark:text-slate-300 font-medium hidden sm:inline">
|
| 659 |
+
Recording voice note...
|
| 660 |
+
</span>
|
| 661 |
+
</div>
|
| 662 |
|
| 663 |
+
<div className="flex items-center gap-2">
|
| 664 |
+
<button
|
| 665 |
+
type="button"
|
| 666 |
+
onClick={cancelVoiceRecording}
|
| 667 |
+
className="p-2 rounded-xl bg-slate-200 hover:bg-slate-300 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-semibold flex items-center gap-1 transition-all cursor-pointer"
|
| 668 |
+
title="Discard Recording"
|
| 669 |
+
>
|
| 670 |
+
<X className="w-4 h-4" />
|
| 671 |
+
<span className="hidden sm:inline">Cancel</span>
|
| 672 |
+
</button>
|
| 673 |
|
| 674 |
+
<button
|
| 675 |
+
type="button"
|
| 676 |
+
onClick={stopAndSendVoiceRecording}
|
| 677 |
+
disabled={uploading}
|
| 678 |
+
className="p-2 px-3.5 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-semibold flex items-center gap-1.5 shadow-md shadow-emerald-600/20 transition-all cursor-pointer disabled:opacity-50"
|
| 679 |
+
title="Send Voice Note"
|
| 680 |
+
>
|
| 681 |
+
{uploading ? (
|
| 682 |
+
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 683 |
+
) : (
|
| 684 |
+
<>
|
| 685 |
+
<Send className="w-3.5 h-3.5" />
|
| 686 |
+
<span>Send Voice Note</span>
|
| 687 |
+
</>
|
| 688 |
+
)}
|
| 689 |
+
</button>
|
| 690 |
+
</div>
|
| 691 |
+
</div>
|
| 692 |
+
) : (
|
| 693 |
+
<form onSubmit={handleSendMessage} className="p-3 border-t border-slate-200 dark:border-slate-800 flex items-center gap-2 bg-white dark:bg-slate-900">
|
| 694 |
+
<label className="p-2.5 rounded-xl bg-slate-100 hover:bg-slate-200 dark:bg-slate-800 dark:hover:bg-slate-700 text-slate-600 dark:text-slate-300 cursor-pointer transition-colors" title="Attach file">
|
| 695 |
+
<Paperclip className="w-4 h-4" />
|
| 696 |
+
<input type="file" onChange={handleFileChange} className="hidden" />
|
| 697 |
+
</label>
|
| 698 |
+
|
| 699 |
+
{isJoined && (
|
| 700 |
+
<button
|
| 701 |
+
type="button"
|
| 702 |
+
onClick={startVoiceRecording}
|
| 703 |
+
className="p-2.5 rounded-xl bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-950/60 dark:hover:bg-indigo-900/80 text-indigo-600 dark:text-indigo-400 transition-colors cursor-pointer"
|
| 704 |
+
title="Record & Send Voice Message"
|
| 705 |
+
>
|
| 706 |
+
<Mic className="w-4 h-4" />
|
| 707 |
+
</button>
|
| 708 |
)}
|
| 709 |
+
|
| 710 |
+
<input
|
| 711 |
+
type="text"
|
| 712 |
+
value={messageText}
|
| 713 |
+
onChange={(e) => setMessageText(e.target.value)}
|
| 714 |
+
placeholder={isJoined ? `Message #${selectedGroup}...` : `Join #${selectedGroup} to participate in chat`}
|
| 715 |
+
disabled={!isJoined}
|
| 716 |
+
className="flex-1 px-4 py-2.5 rounded-xl border border-slate-200 dark:border-slate-700 bg-slate-50 dark:bg-slate-800/80 text-slate-900 dark:text-slate-100 text-xs sm:text-sm placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
|
| 717 |
+
/>
|
| 718 |
+
|
| 719 |
+
<button
|
| 720 |
+
type="submit"
|
| 721 |
+
disabled={uploading || (!messageText.trim() && !selectedFile) || !isJoined}
|
| 722 |
+
className="p-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white font-semibold text-xs shadow-md shadow-indigo-600/20 disabled:opacity-40 transition-all cursor-pointer"
|
| 723 |
+
>
|
| 724 |
+
{uploading ? (
|
| 725 |
+
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
| 726 |
+
) : (
|
| 727 |
+
<Send className="w-4 h-4" />
|
| 728 |
+
)}
|
| 729 |
+
</button>
|
| 730 |
+
</form>
|
| 731 |
+
)}
|
| 732 |
</div>
|
| 733 |
+
|
| 734 |
+
{/* Google Meet Style Voice Meeting Modal */}
|
| 735 |
+
{isMeetingModalOpen && currentCall && (
|
| 736 |
+
<VoiceMeetingModal
|
| 737 |
+
isOpen={isMeetingModalOpen}
|
| 738 |
+
onClose={() => setIsMeetingModalOpen(false)}
|
| 739 |
+
currentUser={currentUser}
|
| 740 |
+
call={currentCall}
|
| 741 |
+
onRefreshCalls={loadCalls}
|
| 742 |
+
/>
|
| 743 |
+
)}
|
| 744 |
</div>
|
| 745 |
);
|
| 746 |
};
|
src/components/NotesView.tsx
CHANGED
|
@@ -48,7 +48,6 @@ export const NotesView: React.FC<NotesViewProps> = ({
|
|
| 48 |
setDeletingId(noteId);
|
| 49 |
try {
|
| 50 |
await onDeleteNote(noteId);
|
| 51 |
-
setSelectedImage((selected) => (selected?.id === noteId ? null : selected));
|
| 52 |
} finally {
|
| 53 |
setDeletingId(null);
|
| 54 |
}
|
|
@@ -62,6 +61,13 @@ export const NotesView: React.FC<NotesViewProps> = ({
|
|
| 62 |
|
| 63 |
return (
|
| 64 |
<div className="space-y-4">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
{/* Grid of Notes */}
|
| 66 |
{notes.length === 0 ? (
|
| 67 |
<div className="p-12 text-center bg-white dark:bg-slate-800/60 rounded-2xl border border-dashed border-slate-300 dark:border-slate-700">
|
|
@@ -181,19 +187,15 @@ export const NotesView: React.FC<NotesViewProps> = ({
|
|
| 181 |
|
| 182 |
{userCanDelete && (
|
| 183 |
<button
|
| 184 |
-
type="button"
|
| 185 |
onClick={() => handleDelete(note.id)}
|
| 186 |
disabled={deletingId === note.id}
|
| 187 |
-
className="
|
| 188 |
title="Delete this note"
|
| 189 |
>
|
| 190 |
{deletingId === note.id ? (
|
| 191 |
<div className="w-4 h-4 border-2 border-rose-500 border-t-transparent rounded-full animate-spin" />
|
| 192 |
) : (
|
| 193 |
-
<>
|
| 194 |
-
<Trash2 className="w-4 h-4" />
|
| 195 |
-
<span>Delete</span>
|
| 196 |
-
</>
|
| 197 |
)}
|
| 198 |
</button>
|
| 199 |
)}
|
|
@@ -240,21 +242,6 @@ export const NotesView: React.FC<NotesViewProps> = ({
|
|
| 240 |
>
|
| 241 |
<Download className="w-4 h-4" /> Download Image
|
| 242 |
</a>
|
| 243 |
-
{canDelete(selectedImage) && (
|
| 244 |
-
<button
|
| 245 |
-
type="button"
|
| 246 |
-
onClick={() => handleDelete(selectedImage.id)}
|
| 247 |
-
disabled={deletingId === selectedImage.id}
|
| 248 |
-
className="px-4 py-2 bg-rose-600 hover:bg-rose-500 disabled:opacity-60 text-white font-semibold text-xs rounded-xl flex items-center gap-1.5 shadow"
|
| 249 |
-
>
|
| 250 |
-
{deletingId === selectedImage.id ? (
|
| 251 |
-
<div className="w-4 h-4 border-2 border-white/50 border-t-white rounded-full animate-spin" />
|
| 252 |
-
) : (
|
| 253 |
-
<Trash2 className="w-4 h-4" />
|
| 254 |
-
)}
|
| 255 |
-
Delete Image
|
| 256 |
-
</button>
|
| 257 |
-
)}
|
| 258 |
</div>
|
| 259 |
</div>
|
| 260 |
</div>
|
|
|
|
| 48 |
setDeletingId(noteId);
|
| 49 |
try {
|
| 50 |
await onDeleteNote(noteId);
|
|
|
|
| 51 |
} finally {
|
| 52 |
setDeletingId(null);
|
| 53 |
}
|
|
|
|
| 61 |
|
| 62 |
return (
|
| 63 |
<div className="space-y-4">
|
| 64 |
+
{/* Header Info */}
|
| 65 |
+
<div className="flex items-center justify-between">
|
| 66 |
+
<h3 className="font-bold text-sm text-slate-800 dark:text-slate-200">
|
| 67 |
+
Archived Documents {searchQuery ? `(Search: "${searchQuery}")` : ''} ({notes.length})
|
| 68 |
+
</h3>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
{/* Grid of Notes */}
|
| 72 |
{notes.length === 0 ? (
|
| 73 |
<div className="p-12 text-center bg-white dark:bg-slate-800/60 rounded-2xl border border-dashed border-slate-300 dark:border-slate-700">
|
|
|
|
| 187 |
|
| 188 |
{userCanDelete && (
|
| 189 |
<button
|
|
|
|
| 190 |
onClick={() => handleDelete(note.id)}
|
| 191 |
disabled={deletingId === note.id}
|
| 192 |
+
className="p-1.5 rounded-lg text-rose-600 dark:text-rose-400 hover:bg-rose-50 dark:hover:bg-rose-950/50 transition-colors"
|
| 193 |
title="Delete this note"
|
| 194 |
>
|
| 195 |
{deletingId === note.id ? (
|
| 196 |
<div className="w-4 h-4 border-2 border-rose-500 border-t-transparent rounded-full animate-spin" />
|
| 197 |
) : (
|
| 198 |
+
<Trash2 className="w-4 h-4" />
|
|
|
|
|
|
|
|
|
|
| 199 |
)}
|
| 200 |
</button>
|
| 201 |
)}
|
|
|
|
| 242 |
>
|
| 243 |
<Download className="w-4 h-4" /> Download Image
|
| 244 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
</div>
|
| 246 |
</div>
|
| 247 |
</div>
|
src/components/VoiceMeetingModal.tsx
ADDED
|
@@ -0,0 +1,785 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React, { useState, useEffect, useRef } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
Mic,
|
| 4 |
+
MicOff,
|
| 5 |
+
Video as VideoIcon,
|
| 6 |
+
VideoOff,
|
| 7 |
+
Monitor,
|
| 8 |
+
PhoneOff,
|
| 9 |
+
Hand,
|
| 10 |
+
MessageSquare,
|
| 11 |
+
Users,
|
| 12 |
+
Shield,
|
| 13 |
+
Clock,
|
| 14 |
+
Sparkles,
|
| 15 |
+
Volume2,
|
| 16 |
+
X,
|
| 17 |
+
Send,
|
| 18 |
+
UserPlus,
|
| 19 |
+
AlertCircle,
|
| 20 |
+
Copy,
|
| 21 |
+
Check,
|
| 22 |
+
} from 'lucide-react';
|
| 23 |
+
import { User, VoiceCall, ChatUser } from '../types';
|
| 24 |
+
import { api } from '../services/api';
|
| 25 |
+
|
| 26 |
+
interface VoiceMeetingModalProps {
|
| 27 |
+
isOpen: boolean;
|
| 28 |
+
onClose: () => void;
|
| 29 |
+
currentUser: User;
|
| 30 |
+
call: VoiceCall;
|
| 31 |
+
onRefreshCalls?: () => void;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
interface InCallMessage {
|
| 35 |
+
id: string;
|
| 36 |
+
sender: string;
|
| 37 |
+
text: string;
|
| 38 |
+
time: string;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export const VoiceMeetingModal: React.FC<VoiceMeetingModalProps> = ({
|
| 42 |
+
isOpen,
|
| 43 |
+
onClose,
|
| 44 |
+
currentUser,
|
| 45 |
+
call,
|
| 46 |
+
onRefreshCalls,
|
| 47 |
+
}) => {
|
| 48 |
+
// Local state for controls
|
| 49 |
+
const [isMuted, setIsMuted] = useState(false);
|
| 50 |
+
const [isVideoOn, setIsVideoOn] = useState(false);
|
| 51 |
+
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
| 52 |
+
const [handRaised, setHandRaised] = useState(false);
|
| 53 |
+
const [raisedHands, setRaisedHands] = useState<Record<string, boolean>>({});
|
| 54 |
+
|
| 55 |
+
// Active side panel: 'chat' | 'people' | null
|
| 56 |
+
const [activePanel, setActivePanel] = useState<'chat' | 'people' | null>(null);
|
| 57 |
+
|
| 58 |
+
// In-call text messages
|
| 59 |
+
const [inCallMessages, setInCallMessages] = useState<InCallMessage[]>([]);
|
| 60 |
+
const [chatInput, setChatInput] = useState('');
|
| 61 |
+
|
| 62 |
+
// Audio level monitoring
|
| 63 |
+
const [isSpeaking, setIsSpeaking] = useState(false);
|
| 64 |
+
const [speakingUsers, setSpeakingUsers] = useState<Record<string, boolean>>({});
|
| 65 |
+
|
| 66 |
+
// Call duration counter
|
| 67 |
+
const [secondsElapsed, setSecondsElapsed] = useState(0);
|
| 68 |
+
|
| 69 |
+
// Invite modal for secret call
|
| 70 |
+
const [showInviteModal, setShowInviteModal] = useState(false);
|
| 71 |
+
const [groupUsers, setGroupUsers] = useState<ChatUser[]>([]);
|
| 72 |
+
const [selectedInvites, setSelectedInvites] = useState<string[]>([]);
|
| 73 |
+
const [copySuccess, setCopySuccess] = useState(false);
|
| 74 |
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
| 75 |
+
|
| 76 |
+
// Media streams refs
|
| 77 |
+
const localVideoRef = useRef<HTMLVideoElement | null>(null);
|
| 78 |
+
const localStreamRef = useRef<MediaStream | null>(null);
|
| 79 |
+
const screenStreamRef = useRef<MediaStream | null>(null);
|
| 80 |
+
const audioContextRef = useRef<AudioContext | null>(null);
|
| 81 |
+
const analyserRef = useRef<AnalyserNode | null>(null);
|
| 82 |
+
|
| 83 |
+
// Active call participants refreshed from server
|
| 84 |
+
const [participants, setParticipants] = useState<string[]>(call.activeUsers || [currentUser.username]);
|
| 85 |
+
|
| 86 |
+
// Duration Timer
|
| 87 |
+
useEffect(() => {
|
| 88 |
+
const startTime = new Date(call.startedAt).getTime();
|
| 89 |
+
const updateTimer = () => {
|
| 90 |
+
const now = Date.now();
|
| 91 |
+
const diff = Math.floor((now - startTime) / 1000);
|
| 92 |
+
setSecondsElapsed(diff > 0 ? diff : 0);
|
| 93 |
+
};
|
| 94 |
+
updateTimer();
|
| 95 |
+
const interval = setInterval(updateTimer, 1000);
|
| 96 |
+
return () => clearInterval(interval);
|
| 97 |
+
}, [call.startedAt]);
|
| 98 |
+
|
| 99 |
+
// Format Duration string (HH:MM:SS or MM:SS)
|
| 100 |
+
const formatDuration = (totalSeconds: number) => {
|
| 101 |
+
const hrs = Math.floor(totalSeconds / 3600);
|
| 102 |
+
const mins = Math.floor((totalSeconds % 3600) / 60);
|
| 103 |
+
const secs = totalSeconds % 60;
|
| 104 |
+
const pad = (n: number) => n.toString().padStart(2, '0');
|
| 105 |
+
if (hrs > 0) {
|
| 106 |
+
return `${pad(hrs)}:${pad(mins)}:${pad(secs)}`;
|
| 107 |
+
}
|
| 108 |
+
return `${pad(mins)}:${pad(secs)}`;
|
| 109 |
+
};
|
| 110 |
+
|
| 111 |
+
// Poll call state & signals
|
| 112 |
+
useEffect(() => {
|
| 113 |
+
let isMounted = true;
|
| 114 |
+
|
| 115 |
+
const pollCallStatus = async () => {
|
| 116 |
+
try {
|
| 117 |
+
const calls = await api.getCalls(call.group);
|
| 118 |
+
if (!isMounted) return;
|
| 119 |
+
const current = calls.find((c) => c.id === call.id);
|
| 120 |
+
if (current) {
|
| 121 |
+
setParticipants(current.activeUsers || []);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
// Fetch signals
|
| 125 |
+
const signals = await api.getCallSignals(call.id);
|
| 126 |
+
if (!isMounted) return;
|
| 127 |
+
|
| 128 |
+
for (const sig of signals) {
|
| 129 |
+
if (sig.type === 'hand_raise') {
|
| 130 |
+
setRaisedHands((prev) => ({ ...prev, [sig.from]: sig.data.raised }));
|
| 131 |
+
} else if (sig.type === 'in_call_chat') {
|
| 132 |
+
setInCallMessages((prev) => [
|
| 133 |
+
...prev,
|
| 134 |
+
{
|
| 135 |
+
id: sig.id || `${Date.now()}-${Math.random()}`,
|
| 136 |
+
sender: sig.from,
|
| 137 |
+
text: sig.data.text,
|
| 138 |
+
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
| 139 |
+
},
|
| 140 |
+
]);
|
| 141 |
+
} else if (sig.type === 'speaking') {
|
| 142 |
+
setSpeakingUsers((prev) => ({ ...prev, [sig.from]: sig.data.speaking }));
|
| 143 |
+
}
|
| 144 |
+
}
|
| 145 |
+
} catch (err) {
|
| 146 |
+
console.error('Call polling error:', err);
|
| 147 |
+
}
|
| 148 |
+
};
|
| 149 |
+
|
| 150 |
+
pollCallStatus();
|
| 151 |
+
const interval = setInterval(pollCallStatus, 1500);
|
| 152 |
+
return () => {
|
| 153 |
+
isMounted = false;
|
| 154 |
+
clearInterval(interval);
|
| 155 |
+
};
|
| 156 |
+
}, [call.id, call.group]);
|
| 157 |
+
|
| 158 |
+
// Initialize Microphone & Web Audio Analyzer for Live Speaking Indicator
|
| 159 |
+
useEffect(() => {
|
| 160 |
+
let animationFrameId: number;
|
| 161 |
+
|
| 162 |
+
async function initAudio() {
|
| 163 |
+
try {
|
| 164 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
| 165 |
+
localStreamRef.current = stream;
|
| 166 |
+
|
| 167 |
+
// AudioContext for level detection
|
| 168 |
+
const AudioCtx = window.AudioContext || (window as any).webkitAudioContext;
|
| 169 |
+
if (AudioCtx) {
|
| 170 |
+
const audioCtx = new AudioCtx();
|
| 171 |
+
audioContextRef.current = audioCtx;
|
| 172 |
+
const source = audioCtx.createMediaStreamSource(stream);
|
| 173 |
+
const analyser = audioCtx.createAnalyser();
|
| 174 |
+
analyser.fftSize = 256;
|
| 175 |
+
source.connect(analyser);
|
| 176 |
+
analyserRef.current = analyser;
|
| 177 |
+
|
| 178 |
+
const dataArray = new Uint8Array(analyser.frequencyBinCount);
|
| 179 |
+
const checkVolume = () => {
|
| 180 |
+
if (!analyserRef.current) return;
|
| 181 |
+
analyserRef.current.getByteFrequencyData(dataArray);
|
| 182 |
+
let sum = 0;
|
| 183 |
+
for (let i = 0; i < dataArray.length; i++) {
|
| 184 |
+
sum += dataArray[i];
|
| 185 |
+
}
|
| 186 |
+
const avg = sum / dataArray.length;
|
| 187 |
+
const speaking = avg > 15 && !isMuted;
|
| 188 |
+
setIsSpeaking(speaking);
|
| 189 |
+
|
| 190 |
+
// Broadcast speaking state periodically
|
| 191 |
+
if (speaking !== speakingUsers[currentUser.username]) {
|
| 192 |
+
setSpeakingUsers((prev) => ({ ...prev, [currentUser.username]: speaking }));
|
| 193 |
+
api.sendCallSignal(call.id, 'ALL', 'speaking', { speaking }).catch(() => {});
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
animationFrameId = requestAnimationFrame(checkVolume);
|
| 197 |
+
};
|
| 198 |
+
checkVolume();
|
| 199 |
+
}
|
| 200 |
+
} catch (err) {
|
| 201 |
+
console.warn('Microphone permission or WebAudio not available:', err);
|
| 202 |
+
}
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
initAudio();
|
| 206 |
+
|
| 207 |
+
return () => {
|
| 208 |
+
if (animationFrameId) cancelAnimationFrame(animationFrameId);
|
| 209 |
+
if (localStreamRef.current) {
|
| 210 |
+
localStreamRef.current.getTracks().forEach((track) => track.stop());
|
| 211 |
+
}
|
| 212 |
+
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
| 213 |
+
audioContextRef.current.close();
|
| 214 |
+
}
|
| 215 |
+
};
|
| 216 |
+
}, [call.id, currentUser.username]);
|
| 217 |
+
|
| 218 |
+
// Handle Camera Toggle
|
| 219 |
+
const toggleVideo = async () => {
|
| 220 |
+
if (isVideoOn) {
|
| 221 |
+
if (localVideoRef.current && localVideoRef.current.srcObject) {
|
| 222 |
+
const stream = localVideoRef.current.srcObject as MediaStream;
|
| 223 |
+
stream.getTracks().forEach((t) => t.stop());
|
| 224 |
+
localVideoRef.current.srcObject = null;
|
| 225 |
+
}
|
| 226 |
+
setIsVideoOn(false);
|
| 227 |
+
} else {
|
| 228 |
+
try {
|
| 229 |
+
const videoStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: false });
|
| 230 |
+
if (localVideoRef.current) {
|
| 231 |
+
localVideoRef.current.srcObject = videoStream;
|
| 232 |
+
}
|
| 233 |
+
setIsVideoOn(true);
|
| 234 |
+
} catch (err) {
|
| 235 |
+
console.warn('Camera permission denied or unavailable:', err);
|
| 236 |
+
setErrorMessage('Camera access was denied or device is unavailable.');
|
| 237 |
+
}
|
| 238 |
+
}
|
| 239 |
+
};
|
| 240 |
+
|
| 241 |
+
// Handle Screen Share Toggle
|
| 242 |
+
const toggleScreenShare = async () => {
|
| 243 |
+
if (isScreenSharing) {
|
| 244 |
+
if (screenStreamRef.current) {
|
| 245 |
+
screenStreamRef.current.getTracks().forEach((t) => t.stop());
|
| 246 |
+
screenStreamRef.current = null;
|
| 247 |
+
}
|
| 248 |
+
setIsScreenSharing(false);
|
| 249 |
+
} else {
|
| 250 |
+
try {
|
| 251 |
+
const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true });
|
| 252 |
+
screenStreamRef.current = screenStream;
|
| 253 |
+
if (localVideoRef.current) {
|
| 254 |
+
localVideoRef.current.srcObject = screenStream;
|
| 255 |
+
}
|
| 256 |
+
setIsScreenSharing(true);
|
| 257 |
+
screenStream.getVideoTracks()[0].onended = () => {
|
| 258 |
+
setIsScreenSharing(false);
|
| 259 |
+
};
|
| 260 |
+
} catch (err) {
|
| 261 |
+
console.warn('Screen share cancelled or failed:', err);
|
| 262 |
+
}
|
| 263 |
+
}
|
| 264 |
+
};
|
| 265 |
+
|
| 266 |
+
// Handle Hand Raise
|
| 267 |
+
const toggleHandRaise = () => {
|
| 268 |
+
const nextState = !handRaised;
|
| 269 |
+
setHandRaised(nextState);
|
| 270 |
+
setRaisedHands((prev) => ({ ...prev, [currentUser.username]: nextState }));
|
| 271 |
+
|
| 272 |
+
// Send hand raise signal to call room
|
| 273 |
+
for (const p of participants) {
|
| 274 |
+
if (p.toLowerCase() !== currentUser.username.toLowerCase()) {
|
| 275 |
+
api.sendCallSignal(call.id, p, 'hand_raise', { raised: nextState }).catch(() => {});
|
| 276 |
+
}
|
| 277 |
+
}
|
| 278 |
+
};
|
| 279 |
+
|
| 280 |
+
// Send In-Call Message
|
| 281 |
+
const handleSendInCallMessage = (e: React.FormEvent) => {
|
| 282 |
+
e.preventDefault();
|
| 283 |
+
if (!chatInput.trim()) return;
|
| 284 |
+
|
| 285 |
+
const newMsg: InCallMessage = {
|
| 286 |
+
id: `${Date.now()}-${Math.random()}`,
|
| 287 |
+
sender: currentUser.username,
|
| 288 |
+
text: chatInput.trim(),
|
| 289 |
+
time: new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
|
| 290 |
+
};
|
| 291 |
+
|
| 292 |
+
setInCallMessages((prev) => [...prev, newMsg]);
|
| 293 |
+
setChatInput('');
|
| 294 |
+
|
| 295 |
+
// Send to other active participants
|
| 296 |
+
for (const p of participants) {
|
| 297 |
+
if (p.toLowerCase() !== currentUser.username.toLowerCase()) {
|
| 298 |
+
api.sendCallSignal(call.id, p, 'in_call_chat', { text: newMsg.text }).catch(() => {});
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
};
|
| 302 |
+
|
| 303 |
+
// Handle Leaving Call
|
| 304 |
+
const handleLeaveCall = async () => {
|
| 305 |
+
try {
|
| 306 |
+
await api.leaveCall(call.id);
|
| 307 |
+
if (onRefreshCalls) onRefreshCalls();
|
| 308 |
+
} catch (err) {
|
| 309 |
+
console.error('Error leaving call:', err);
|
| 310 |
+
} finally {
|
| 311 |
+
onClose();
|
| 312 |
+
}
|
| 313 |
+
};
|
| 314 |
+
|
| 315 |
+
// Load Group Users for Invite Modal
|
| 316 |
+
const handleOpenInviteModal = async () => {
|
| 317 |
+
try {
|
| 318 |
+
const users = await api.getChatUsers(call.group);
|
| 319 |
+
setGroupUsers(users);
|
| 320 |
+
setShowInviteModal(true);
|
| 321 |
+
} catch (err) {
|
| 322 |
+
console.error('Failed to fetch group users for call invite:', err);
|
| 323 |
+
}
|
| 324 |
+
};
|
| 325 |
+
|
| 326 |
+
const handleCopyMeetingInfo = () => {
|
| 327 |
+
const info = `Join Live Study Meeting in ${call.group}! Host: ${call.caller}. Meeting ID: ${call.id}`;
|
| 328 |
+
navigator.clipboard.writeText(info);
|
| 329 |
+
setCopySuccess(true);
|
| 330 |
+
setTimeout(() => setCopySuccess(false), 2000);
|
| 331 |
+
};
|
| 332 |
+
|
| 333 |
+
// Calculate Grid layout CSS based on participant count
|
| 334 |
+
const getGridColsClass = (count: number) => {
|
| 335 |
+
if (count <= 1) return 'grid-cols-1 max-w-2xl';
|
| 336 |
+
if (count === 2) return 'grid-cols-1 sm:grid-cols-2 max-w-4xl';
|
| 337 |
+
if (count <= 4) return 'grid-cols-2 max-w-5xl';
|
| 338 |
+
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 max-w-6xl';
|
| 339 |
+
};
|
| 340 |
+
|
| 341 |
+
return (
|
| 342 |
+
<div className="fixed inset-0 z-50 bg-slate-950 flex flex-col font-sans select-none overflow-hidden">
|
| 343 |
+
{/* Top Meeting Header */}
|
| 344 |
+
<div className="px-4 py-3 bg-slate-900/90 border-b border-slate-800 flex items-center justify-between z-10 shrink-0">
|
| 345 |
+
<div className="flex items-center gap-3 min-w-0">
|
| 346 |
+
<div className="flex items-center gap-2">
|
| 347 |
+
<span className="w-3 h-3 rounded-full bg-emerald-500 animate-pulse" />
|
| 348 |
+
<h2 className="font-bold text-sm sm:text-base text-white truncate">
|
| 349 |
+
{call.group} • Live Study Meeting
|
| 350 |
+
</h2>
|
| 351 |
+
</div>
|
| 352 |
+
|
| 353 |
+
<span
|
| 354 |
+
className={`hidden sm:inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold ${
|
| 355 |
+
call.mode === 'secret'
|
| 356 |
+
? 'bg-amber-950/80 text-amber-300 border border-amber-800/60'
|
| 357 |
+
: 'bg-indigo-950/80 text-indigo-300 border border-indigo-800/60'
|
| 358 |
+
}`}
|
| 359 |
+
>
|
| 360 |
+
<Shield className="w-3.5 h-3.5" />
|
| 361 |
+
{call.mode === 'secret' ? 'Secret Call' : 'Public Room'}
|
| 362 |
+
</span>
|
| 363 |
+
|
| 364 |
+
{/* Meeting Timer */}
|
| 365 |
+
<div className="flex items-center gap-1 px-2.5 py-1 rounded-lg bg-slate-800/80 text-slate-300 text-xs font-mono">
|
| 366 |
+
<Clock className="w-3.5 h-3.5 text-indigo-400" />
|
| 367 |
+
{formatDuration(secondsElapsed)}
|
| 368 |
+
</div>
|
| 369 |
+
</div>
|
| 370 |
+
|
| 371 |
+
<div className="flex items-center gap-2">
|
| 372 |
+
{/* Copy Call Info */}
|
| 373 |
+
<button
|
| 374 |
+
onClick={handleCopyMeetingInfo}
|
| 375 |
+
className="p-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-300 hover:text-white text-xs font-semibold flex items-center gap-1.5 transition-colors cursor-pointer"
|
| 376 |
+
title="Copy Meeting Info"
|
| 377 |
+
>
|
| 378 |
+
{copySuccess ? (
|
| 379 |
+
<>
|
| 380 |
+
<Check className="w-4 h-4 text-emerald-400" />
|
| 381 |
+
<span className="hidden md:inline text-emerald-400">Copied</span>
|
| 382 |
+
</>
|
| 383 |
+
) : (
|
| 384 |
+
<>
|
| 385 |
+
<Copy className="w-4 h-4" />
|
| 386 |
+
<span className="hidden md:inline">Share Info</span>
|
| 387 |
+
</>
|
| 388 |
+
)}
|
| 389 |
+
</button>
|
| 390 |
+
|
| 391 |
+
{/* Invite Button for Secret Call */}
|
| 392 |
+
{call.mode === 'secret' && (
|
| 393 |
+
<button
|
| 394 |
+
onClick={handleOpenInviteModal}
|
| 395 |
+
className="p-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-semibold flex items-center gap-1.5 transition-all shadow-md shadow-indigo-600/20 cursor-pointer"
|
| 396 |
+
>
|
| 397 |
+
<UserPlus className="w-4 h-4" />
|
| 398 |
+
<span className="hidden sm:inline">Invite Members</span>
|
| 399 |
+
</button>
|
| 400 |
+
)}
|
| 401 |
+
|
| 402 |
+
{/* Close / Minimalize Button */}
|
| 403 |
+
<button
|
| 404 |
+
onClick={handleLeaveCall}
|
| 405 |
+
className="p-2 rounded-xl text-slate-400 hover:text-white hover:bg-slate-800 transition-colors cursor-pointer"
|
| 406 |
+
title="Leave Meeting"
|
| 407 |
+
>
|
| 408 |
+
<X className="w-5 h-5" />
|
| 409 |
+
</button>
|
| 410 |
+
</div>
|
| 411 |
+
</div>
|
| 412 |
+
|
| 413 |
+
{/* Main Body Area: Participant Video Cards Grid + Side Panel */}
|
| 414 |
+
<div className="flex-1 flex overflow-hidden relative">
|
| 415 |
+
{/* Video / Cards Grid Area */}
|
| 416 |
+
<div className="flex-1 p-4 sm:p-6 overflow-y-auto flex items-center justify-center">
|
| 417 |
+
<div className={`w-full grid gap-4 ${getGridColsClass(participants.length)}`}>
|
| 418 |
+
{participants.map((username) => {
|
| 419 |
+
const isMe = username.toLowerCase() === currentUser.username.toLowerCase();
|
| 420 |
+
const userSpeaking = isMe ? isSpeaking : Boolean(speakingUsers[username]);
|
| 421 |
+
const userHand = Boolean(raisedHands[username]);
|
| 422 |
+
|
| 423 |
+
return (
|
| 424 |
+
<div
|
| 425 |
+
key={username}
|
| 426 |
+
className={`relative rounded-3xl bg-slate-900 border transition-all overflow-hidden flex flex-col items-center justify-center min-h-[220px] sm:min-h-[260px] p-6 shadow-2xl ${
|
| 427 |
+
userSpeaking
|
| 428 |
+
? 'border-emerald-500 ring-2 ring-emerald-500/40'
|
| 429 |
+
: 'border-slate-800 hover:border-slate-700'
|
| 430 |
+
}`}
|
| 431 |
+
>
|
| 432 |
+
{/* Camera Video View for Local User */}
|
| 433 |
+
{isMe && (isVideoOn || isScreenSharing) ? (
|
| 434 |
+
<video
|
| 435 |
+
key={`video-stream-${username}`}
|
| 436 |
+
ref={localVideoRef}
|
| 437 |
+
autoPlay
|
| 438 |
+
playsInline
|
| 439 |
+
muted
|
| 440 |
+
className="absolute inset-0 w-full h-full object-cover rounded-3xl"
|
| 441 |
+
/>
|
| 442 |
+
) : (
|
| 443 |
+
/* Default Google Meet Avatar Card */
|
| 444 |
+
<div key={`avatar-card-${username}`} className="flex flex-col items-center justify-center relative z-10">
|
| 445 |
+
<div className="relative">
|
| 446 |
+
{/* Audio Speaking Ripple Effect Ring */}
|
| 447 |
+
{userSpeaking && (
|
| 448 |
+
<div className="absolute -inset-3 rounded-full bg-emerald-500/30 animate-ping" />
|
| 449 |
+
)}
|
| 450 |
+
<div
|
| 451 |
+
className={`w-20 h-20 sm:w-24 sm:h-24 rounded-full flex items-center justify-center text-2xl sm:text-3xl font-bold uppercase shadow-xl transition-transform ${
|
| 452 |
+
userSpeaking
|
| 453 |
+
? 'bg-gradient-to-tr from-emerald-500 to-teal-400 text-white scale-105'
|
| 454 |
+
: 'bg-indigo-600 text-white'
|
| 455 |
+
}`}
|
| 456 |
+
>
|
| 457 |
+
{username.substring(0, 2)}
|
| 458 |
+
</div>
|
| 459 |
+
</div>
|
| 460 |
+
|
| 461 |
+
<h3 className="mt-4 font-bold text-sm sm:text-base text-white flex items-center gap-1.5">
|
| 462 |
+
{username}
|
| 463 |
+
{isMe && <span className="text-xs text-indigo-400 font-normal">(You)</span>}
|
| 464 |
+
</h3>
|
| 465 |
+
</div>
|
| 466 |
+
)}
|
| 467 |
+
|
| 468 |
+
{/* Top Badges (Hand Raise / Speaking Indicator) */}
|
| 469 |
+
<div className="absolute top-3 left-3 right-3 flex items-center justify-between z-20 pointer-events-none">
|
| 470 |
+
{userHand && (
|
| 471 |
+
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-bold bg-amber-500 text-slate-950 shadow-lg animate-bounce">
|
| 472 |
+
<Hand className="w-3.5 h-3.5 fill-current" /> Hand Raised
|
| 473 |
+
</span>
|
| 474 |
+
)}
|
| 475 |
+
|
| 476 |
+
{userSpeaking && (
|
| 477 |
+
<span className="flex items-center gap-1 px-2.5 py-1 rounded-full text-[10px] font-bold bg-emerald-500 text-slate-950 shadow-md">
|
| 478 |
+
<Volume2 className="w-3.5 h-3.5" /> Speaking
|
| 479 |
+
</span>
|
| 480 |
+
)}
|
| 481 |
+
</div>
|
| 482 |
+
|
| 483 |
+
{/* Bottom Status Tag */}
|
| 484 |
+
<div className="absolute bottom-3 left-3 right-3 flex items-center justify-between z-20 pointer-events-none">
|
| 485 |
+
<div className="px-3 py-1 rounded-xl bg-slate-950/80 backdrop-blur-md border border-slate-800 text-xs text-white font-medium flex items-center gap-1.5">
|
| 486 |
+
<span className="w-2 h-2 rounded-full bg-emerald-400" />
|
| 487 |
+
{username}
|
| 488 |
+
</div>
|
| 489 |
+
|
| 490 |
+
<div className="p-1.5 rounded-xl bg-slate-950/80 backdrop-blur-md border border-slate-800 text-white">
|
| 491 |
+
{isMe ? (
|
| 492 |
+
isMuted ? (
|
| 493 |
+
<MicOff className="w-4 h-4 text-rose-500" />
|
| 494 |
+
) : (
|
| 495 |
+
<Mic className="w-4 h-4 text-emerald-400" />
|
| 496 |
+
)
|
| 497 |
+
) : (
|
| 498 |
+
<Mic className="w-4 h-4 text-slate-400" />
|
| 499 |
+
)}
|
| 500 |
+
</div>
|
| 501 |
+
</div>
|
| 502 |
+
</div>
|
| 503 |
+
);
|
| 504 |
+
})}
|
| 505 |
+
</div>
|
| 506 |
+
</div>
|
| 507 |
+
|
| 508 |
+
{/* Right Side Panel (In-call Chat or Participant Roster) */}
|
| 509 |
+
{activePanel && (
|
| 510 |
+
<div className="w-full sm:w-80 bg-slate-900 border-l border-slate-800 flex flex-col z-20">
|
| 511 |
+
{/* Panel Header */}
|
| 512 |
+
<div className="p-4 border-b border-slate-800 flex items-center justify-between">
|
| 513 |
+
<h3 className="font-bold text-sm text-white flex items-center gap-2">
|
| 514 |
+
{activePanel === 'chat' ? (
|
| 515 |
+
<>
|
| 516 |
+
<MessageSquare className="w-4 h-4 text-indigo-400" /> In-Call Messages
|
| 517 |
+
</>
|
| 518 |
+
) : (
|
| 519 |
+
<>
|
| 520 |
+
<Users className="w-4 h-4 text-indigo-400" /> Participants ({participants.length})
|
| 521 |
+
</>
|
| 522 |
+
)}
|
| 523 |
+
</h3>
|
| 524 |
+
<button
|
| 525 |
+
onClick={() => setActivePanel(null)}
|
| 526 |
+
className="p-1.5 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800"
|
| 527 |
+
>
|
| 528 |
+
<X className="w-4 h-4" />
|
| 529 |
+
</button>
|
| 530 |
+
</div>
|
| 531 |
+
|
| 532 |
+
{/* Panel Content */}
|
| 533 |
+
<div className="flex-1 overflow-y-auto p-4">
|
| 534 |
+
{activePanel === 'chat' ? (
|
| 535 |
+
<div className="space-y-3">
|
| 536 |
+
{inCallMessages.length === 0 ? (
|
| 537 |
+
<p className="text-center text-xs text-slate-500 py-8">
|
| 538 |
+
Messages sent here can be seen only by people in this call.
|
| 539 |
+
</p>
|
| 540 |
+
) : (
|
| 541 |
+
inCallMessages.map((msg) => (
|
| 542 |
+
<div key={msg.id} className="p-3 rounded-2xl bg-slate-800/80 text-xs space-y-1">
|
| 543 |
+
<div className="flex items-center justify-between text-[10px] text-slate-400 font-semibold">
|
| 544 |
+
<span className="text-indigo-300">{msg.sender}</span>
|
| 545 |
+
<span>{msg.time}</span>
|
| 546 |
+
</div>
|
| 547 |
+
<p className="text-slate-100 whitespace-pre-wrap">{msg.text}</p>
|
| 548 |
+
</div>
|
| 549 |
+
))
|
| 550 |
+
)}
|
| 551 |
+
</div>
|
| 552 |
+
) : (
|
| 553 |
+
/* Participants List */
|
| 554 |
+
<div className="space-y-2">
|
| 555 |
+
{participants.map((p) => {
|
| 556 |
+
const isMe = p.toLowerCase() === currentUser.username.toLowerCase();
|
| 557 |
+
return (
|
| 558 |
+
<div
|
| 559 |
+
key={p}
|
| 560 |
+
className="p-3 rounded-2xl bg-slate-800/60 border border-slate-800 flex items-center justify-between"
|
| 561 |
+
>
|
| 562 |
+
<div className="flex items-center gap-2.5">
|
| 563 |
+
<div className="w-8 h-8 rounded-full bg-indigo-600 text-white font-bold text-xs flex items-center justify-center">
|
| 564 |
+
{p.substring(0, 2).toUpperCase()}
|
| 565 |
+
</div>
|
| 566 |
+
<div>
|
| 567 |
+
<p className="text-xs font-bold text-white flex items-center gap-1">
|
| 568 |
+
{p} {isMe && <span className="text-[10px] text-indigo-400">(You)</span>}
|
| 569 |
+
</p>
|
| 570 |
+
<p className="text-[10px] text-slate-400">Active in Voice</p>
|
| 571 |
+
</div>
|
| 572 |
+
</div>
|
| 573 |
+
|
| 574 |
+
<Mic className="w-4 h-4 text-emerald-400" />
|
| 575 |
+
</div>
|
| 576 |
+
);
|
| 577 |
+
})}
|
| 578 |
+
</div>
|
| 579 |
+
)}
|
| 580 |
+
</div>
|
| 581 |
+
|
| 582 |
+
{/* In-Call Chat Input Form */}
|
| 583 |
+
{activePanel === 'chat' && (
|
| 584 |
+
<form onSubmit={handleSendInCallMessage} className="p-3 border-t border-slate-800 flex items-center gap-2">
|
| 585 |
+
<input
|
| 586 |
+
type="text"
|
| 587 |
+
value={chatInput}
|
| 588 |
+
onChange={(e) => setChatInput(e.target.value)}
|
| 589 |
+
placeholder="Send a message to everyone..."
|
| 590 |
+
className="flex-1 px-3 py-2 rounded-xl bg-slate-800 border border-slate-700 text-xs text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
| 591 |
+
/>
|
| 592 |
+
<button
|
| 593 |
+
type="submit"
|
| 594 |
+
disabled={!chatInput.trim()}
|
| 595 |
+
className="p-2 rounded-xl bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white cursor-pointer"
|
| 596 |
+
>
|
| 597 |
+
<Send className="w-4 h-4" />
|
| 598 |
+
</button>
|
| 599 |
+
</form>
|
| 600 |
+
)}
|
| 601 |
+
</div>
|
| 602 |
+
)}
|
| 603 |
+
</div>
|
| 604 |
+
|
| 605 |
+
{/* Error Toast Alert */}
|
| 606 |
+
{errorMessage && (
|
| 607 |
+
<div className="px-4 py-2 bg-rose-950/90 text-rose-200 border-t border-rose-800 text-xs font-semibold flex items-center justify-between shrink-0">
|
| 608 |
+
<span className="flex items-center gap-1.5">
|
| 609 |
+
<AlertCircle className="w-4 h-4 text-rose-400" /> {errorMessage}
|
| 610 |
+
</span>
|
| 611 |
+
<button onClick={() => setErrorMessage(null)} className="hover:underline">
|
| 612 |
+
Dismiss
|
| 613 |
+
</button>
|
| 614 |
+
</div>
|
| 615 |
+
)}
|
| 616 |
+
|
| 617 |
+
{/* Bottom Google Meet Controls Bar */}
|
| 618 |
+
<div className="p-4 bg-slate-900 border-t border-slate-800 flex items-center justify-between shrink-0">
|
| 619 |
+
{/* Left: Meeting Info */}
|
| 620 |
+
<div className="hidden md:flex items-center gap-2 text-xs text-slate-400">
|
| 621 |
+
<span className="font-semibold text-white">{call.group}</span>
|
| 622 |
+
<span>•</span>
|
| 623 |
+
<span>{participants.length} Active in Call</span>
|
| 624 |
+
</div>
|
| 625 |
+
|
| 626 |
+
{/* Center Toolbar Buttons */}
|
| 627 |
+
<div className="flex items-center gap-2 sm:gap-3 mx-auto md:mx-0">
|
| 628 |
+
{/* Mic Toggle */}
|
| 629 |
+
<button
|
| 630 |
+
onClick={() => setIsMuted(!isMuted)}
|
| 631 |
+
className={`p-3.5 sm:p-4 rounded-2xl transition-all cursor-pointer ${
|
| 632 |
+
isMuted
|
| 633 |
+
? 'bg-rose-600 text-white hover:bg-rose-500 shadow-lg shadow-rose-600/30'
|
| 634 |
+
: 'bg-slate-800 text-white hover:bg-slate-700'
|
| 635 |
+
}`}
|
| 636 |
+
title={isMuted ? 'Unmute Microphone' : 'Mute Microphone'}
|
| 637 |
+
>
|
| 638 |
+
{isMuted ? <MicOff className="w-5 h-5" /> : <Mic className="w-5 h-5 text-emerald-400" />}
|
| 639 |
+
</button>
|
| 640 |
+
|
| 641 |
+
{/* Camera Toggle */}
|
| 642 |
+
<button
|
| 643 |
+
onClick={toggleVideo}
|
| 644 |
+
className={`p-3.5 sm:p-4 rounded-2xl transition-all cursor-pointer ${
|
| 645 |
+
isVideoOn
|
| 646 |
+
? 'bg-indigo-600 text-white hover:bg-indigo-500 shadow-lg shadow-indigo-600/30'
|
| 647 |
+
: 'bg-slate-800 text-white hover:bg-slate-700'
|
| 648 |
+
}`}
|
| 649 |
+
title={isVideoOn ? 'Turn Off Camera' : 'Turn On Camera'}
|
| 650 |
+
>
|
| 651 |
+
{isVideoOn ? <VideoIcon className="w-5 h-5" /> : <VideoOff className="w-5 h-5" />}
|
| 652 |
+
</button>
|
| 653 |
+
|
| 654 |
+
{/* Screen Share Toggle */}
|
| 655 |
+
<button
|
| 656 |
+
onClick={toggleScreenShare}
|
| 657 |
+
className={`p-3.5 sm:p-4 rounded-2xl transition-all cursor-pointer ${
|
| 658 |
+
isScreenSharing
|
| 659 |
+
? 'bg-emerald-600 text-white hover:bg-emerald-500'
|
| 660 |
+
: 'bg-slate-800 text-white hover:bg-slate-700'
|
| 661 |
+
}`}
|
| 662 |
+
title="Share Screen"
|
| 663 |
+
>
|
| 664 |
+
<Monitor className="w-5 h-5" />
|
| 665 |
+
</button>
|
| 666 |
+
|
| 667 |
+
{/* Hand Raise Toggle */}
|
| 668 |
+
<button
|
| 669 |
+
onClick={toggleHandRaise}
|
| 670 |
+
className={`p-3.5 sm:p-4 rounded-2xl transition-all cursor-pointer ${
|
| 671 |
+
handRaised
|
| 672 |
+
? 'bg-amber-500 text-slate-950 hover:bg-amber-400 font-bold'
|
| 673 |
+
: 'bg-slate-800 text-white hover:bg-slate-700'
|
| 674 |
+
}`}
|
| 675 |
+
title="Raise Hand"
|
| 676 |
+
>
|
| 677 |
+
<Hand className="w-5 h-5" />
|
| 678 |
+
</button>
|
| 679 |
+
|
| 680 |
+
{/* End Call / Leave Meeting Button */}
|
| 681 |
+
<button
|
| 682 |
+
onClick={handleLeaveCall}
|
| 683 |
+
className="px-6 py-3.5 sm:py-4 rounded-2xl bg-rose-600 hover:bg-rose-500 text-white font-bold text-xs sm:text-sm flex items-center gap-2 shadow-lg shadow-rose-600/30 transition-all cursor-pointer"
|
| 684 |
+
title="Leave Call"
|
| 685 |
+
>
|
| 686 |
+
<PhoneOff className="w-5 h-5" />
|
| 687 |
+
<span className="hidden sm:inline">Leave Call</span>
|
| 688 |
+
</button>
|
| 689 |
+
</div>
|
| 690 |
+
|
| 691 |
+
{/* Right: Side Panel Toggle Buttons */}
|
| 692 |
+
<div className="flex items-center gap-2">
|
| 693 |
+
{/* Chat Panel Button */}
|
| 694 |
+
<button
|
| 695 |
+
onClick={() => setActivePanel(activePanel === 'chat' ? null : 'chat')}
|
| 696 |
+
className={`p-3 rounded-xl transition-colors cursor-pointer ${
|
| 697 |
+
activePanel === 'chat'
|
| 698 |
+
? 'bg-indigo-600 text-white'
|
| 699 |
+
: 'bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700'
|
| 700 |
+
}`}
|
| 701 |
+
title="In-call Messages"
|
| 702 |
+
>
|
| 703 |
+
<MessageSquare className="w-5 h-5" />
|
| 704 |
+
</button>
|
| 705 |
+
|
| 706 |
+
{/* People / Participants Button */}
|
| 707 |
+
<button
|
| 708 |
+
onClick={() => setActivePanel(activePanel === 'people' ? null : 'people')}
|
| 709 |
+
className={`p-3 rounded-xl transition-colors cursor-pointer ${
|
| 710 |
+
activePanel === 'people'
|
| 711 |
+
? 'bg-indigo-600 text-white'
|
| 712 |
+
: 'bg-slate-800 text-slate-400 hover:text-white hover:bg-slate-700'
|
| 713 |
+
}`}
|
| 714 |
+
title="Participants Roster"
|
| 715 |
+
>
|
| 716 |
+
<Users className="w-5 h-5" />
|
| 717 |
+
</button>
|
| 718 |
+
</div>
|
| 719 |
+
</div>
|
| 720 |
+
|
| 721 |
+
{/* Secret Call Invite Modal */}
|
| 722 |
+
{showInviteModal && (
|
| 723 |
+
<div className="fixed inset-0 z-50 bg-black/70 backdrop-blur-sm flex items-center justify-center p-4">
|
| 724 |
+
<div className="bg-slate-900 border border-slate-800 rounded-3xl p-6 w-full max-w-md space-y-4 text-white">
|
| 725 |
+
<div className="flex items-center justify-between border-b border-slate-800 pb-3">
|
| 726 |
+
<h3 className="font-bold text-base flex items-center gap-2">
|
| 727 |
+
<UserPlus className="w-5 h-5 text-indigo-400" /> Invite Members to Secret Call
|
| 728 |
+
</h3>
|
| 729 |
+
<button
|
| 730 |
+
onClick={() => setShowInviteModal(false)}
|
| 731 |
+
className="text-slate-400 hover:text-white"
|
| 732 |
+
>
|
| 733 |
+
<X className="w-5 h-5" />
|
| 734 |
+
</button>
|
| 735 |
+
</div>
|
| 736 |
+
|
| 737 |
+
<p className="text-xs text-slate-400">
|
| 738 |
+
Secret calls are visible only to invited group members. Select students to invite:
|
| 739 |
+
</p>
|
| 740 |
+
|
| 741 |
+
<div className="max-h-60 overflow-y-auto space-y-2 pr-1">
|
| 742 |
+
{groupUsers.length === 0 ? (
|
| 743 |
+
<p className="text-xs text-slate-500 py-4 text-center">No other members in this group.</p>
|
| 744 |
+
) : (
|
| 745 |
+
groupUsers.map((u) => {
|
| 746 |
+
const isSelected = selectedInvites.includes(u.username);
|
| 747 |
+
return (
|
| 748 |
+
<button
|
| 749 |
+
key={u.username}
|
| 750 |
+
type="button"
|
| 751 |
+
onClick={() => {
|
| 752 |
+
if (isSelected) {
|
| 753 |
+
setSelectedInvites(selectedInvites.filter((x) => x !== u.username));
|
| 754 |
+
} else {
|
| 755 |
+
setSelectedInvites([...selectedInvites, u.username]);
|
| 756 |
+
}
|
| 757 |
+
}}
|
| 758 |
+
className={`w-full text-left p-3 rounded-xl border flex items-center justify-between text-xs transition-colors cursor-pointer ${
|
| 759 |
+
isSelected
|
| 760 |
+
? 'bg-indigo-950 border-indigo-600 text-white'
|
| 761 |
+
: 'bg-slate-800/60 border-slate-800 text-slate-300 hover:bg-slate-800'
|
| 762 |
+
}`}
|
| 763 |
+
>
|
| 764 |
+
<span className="font-semibold">{u.username}</span>
|
| 765 |
+
{isSelected && <Check className="w-4 h-4 text-indigo-400" />}
|
| 766 |
+
</button>
|
| 767 |
+
);
|
| 768 |
+
})
|
| 769 |
+
)}
|
| 770 |
+
</div>
|
| 771 |
+
|
| 772 |
+
<div className="flex items-center justify-end gap-2 pt-2 border-t border-slate-800">
|
| 773 |
+
<button
|
| 774 |
+
onClick={() => setShowInviteModal(false)}
|
| 775 |
+
className="px-4 py-2 rounded-xl bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-300"
|
| 776 |
+
>
|
| 777 |
+
Done
|
| 778 |
+
</button>
|
| 779 |
+
</div>
|
| 780 |
+
</div>
|
| 781 |
+
</div>
|
| 782 |
+
)}
|
| 783 |
+
</div>
|
| 784 |
+
);
|
| 785 |
+
};
|
src/index.css
CHANGED
|
@@ -1,3 +1 @@
|
|
| 1 |
@import "tailwindcss";
|
| 2 |
-
|
| 3 |
-
@custom-variant dark (&:where(.dark, .dark *));
|
|
|
|
| 1 |
@import "tailwindcss";
|
|
|
|
|
|
src/services/api.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
import { User, NoteItem, ChatMessage, AppNotification, GroupInfo } from '../types';
|
| 2 |
|
| 3 |
const TOKEN_KEY = 'school_archive_token';
|
| 4 |
const SAVED_USER_KEY = 'school_archive_saved_user';
|
|
@@ -46,6 +46,10 @@ async function request<T>(endpoint: string, options: RequestInit = {}): Promise<
|
|
| 46 |
const data = await res.json();
|
| 47 |
|
| 48 |
if (!res.ok) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
throw new Error(data.error || 'An error occurred while communicating with the server');
|
| 50 |
}
|
| 51 |
|
|
@@ -169,11 +173,11 @@ export const api = {
|
|
| 169 |
return data.chats;
|
| 170 |
},
|
| 171 |
|
| 172 |
-
async uploadChatFile(file: File,
|
| 173 |
const formData = new FormData();
|
| 174 |
formData.append('file', file);
|
| 175 |
-
if (
|
| 176 |
-
formData.append('
|
| 177 |
}
|
| 178 |
|
| 179 |
const data = await request<{ attachment: any }>('/api/chats/upload-file', {
|
|
@@ -200,4 +204,50 @@ export const api = {
|
|
| 200 |
async markNotificationsRead(): Promise<void> {
|
| 201 |
await request('/api/notifications/read', { method: 'POST' });
|
| 202 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
};
|
|
|
|
| 1 |
+
import { User, NoteItem, ChatMessage, AppNotification, GroupInfo, VoiceCall, ChatUser } from '../types';
|
| 2 |
|
| 3 |
const TOKEN_KEY = 'school_archive_token';
|
| 4 |
const SAVED_USER_KEY = 'school_archive_saved_user';
|
|
|
|
| 46 |
const data = await res.json();
|
| 47 |
|
| 48 |
if (!res.ok) {
|
| 49 |
+
if (res.status === 401) {
|
| 50 |
+
clearStoredToken();
|
| 51 |
+
window.dispatchEvent(new Event('auth_unauthorized'));
|
| 52 |
+
}
|
| 53 |
throw new Error(data.error || 'An error occurred while communicating with the server');
|
| 54 |
}
|
| 55 |
|
|
|
|
| 173 |
return data.chats;
|
| 174 |
},
|
| 175 |
|
| 176 |
+
async uploadChatFile(file: File, videoDuration?: number): Promise<any> {
|
| 177 |
const formData = new FormData();
|
| 178 |
formData.append('file', file);
|
| 179 |
+
if (videoDuration !== undefined) {
|
| 180 |
+
formData.append('videoDuration', videoDuration.toString());
|
| 181 |
}
|
| 182 |
|
| 183 |
const data = await request<{ attachment: any }>('/api/chats/upload-file', {
|
|
|
|
| 204 |
async markNotificationsRead(): Promise<void> {
|
| 205 |
await request('/api/notifications/read', { method: 'POST' });
|
| 206 |
},
|
| 207 |
+
|
| 208 |
+
// VOICE MEETINGS / CALLS
|
| 209 |
+
async getCalls(group?: string): Promise<VoiceCall[]> {
|
| 210 |
+
const query = group ? `?group=${encodeURIComponent(group)}` : '';
|
| 211 |
+
const data = await request<{ calls: VoiceCall[] }>(`/api/calls${query}`);
|
| 212 |
+
return data.calls;
|
| 213 |
+
},
|
| 214 |
+
|
| 215 |
+
async createCall(group: string, mode: 'public' | 'secret', invitedUsers: string[] = []): Promise<VoiceCall> {
|
| 216 |
+
const data = await request<{ call: VoiceCall }>('/api/calls', {
|
| 217 |
+
method: 'POST',
|
| 218 |
+
body: JSON.stringify({ group, mode, invitedUsers }),
|
| 219 |
+
});
|
| 220 |
+
return data.call;
|
| 221 |
+
},
|
| 222 |
+
|
| 223 |
+
async joinCall(callId: string): Promise<VoiceCall> {
|
| 224 |
+
const data = await request<{ call: VoiceCall }>(`/api/calls/${encodeURIComponent(callId)}/join`, {
|
| 225 |
+
method: 'POST',
|
| 226 |
+
});
|
| 227 |
+
return data.call;
|
| 228 |
+
},
|
| 229 |
+
|
| 230 |
+
async leaveCall(callId: string): Promise<void> {
|
| 231 |
+
await request(`/api/calls/${encodeURIComponent(callId)}/leave`, {
|
| 232 |
+
method: 'POST',
|
| 233 |
+
});
|
| 234 |
+
},
|
| 235 |
+
|
| 236 |
+
async sendCallSignal(callId: string, to: string, type: string, data: any): Promise<void> {
|
| 237 |
+
await request(`/api/calls/${encodeURIComponent(callId)}/signals`, {
|
| 238 |
+
method: 'POST',
|
| 239 |
+
body: JSON.stringify({ to, type, data }),
|
| 240 |
+
});
|
| 241 |
+
},
|
| 242 |
+
|
| 243 |
+
async getCallSignals(callId: string): Promise<any[]> {
|
| 244 |
+
const data = await request<{ signals: any[] }>(`/api/calls/${encodeURIComponent(callId)}/signals`);
|
| 245 |
+
return data.signals;
|
| 246 |
+
},
|
| 247 |
+
|
| 248 |
+
async getChatUsers(group: string): Promise<ChatUser[]> {
|
| 249 |
+
const query = group ? `?group=${encodeURIComponent(group)}` : '';
|
| 250 |
+
const data = await request<{ users: ChatUser[] }>(`/api/chat-users${query}`);
|
| 251 |
+
return data.users;
|
| 252 |
+
},
|
| 253 |
};
|
src/types.ts
CHANGED
|
@@ -12,6 +12,7 @@ export interface User {
|
|
| 12 |
permissions: UserPermissions;
|
| 13 |
joinedGroups: string[];
|
| 14 |
createdAt: string;
|
|
|
|
| 15 |
}
|
| 16 |
|
| 17 |
export interface SubjectFolder {
|
|
@@ -54,7 +55,7 @@ export interface ChatMessage {
|
|
| 54 |
fileType: 'image' | 'docx' | 'video' | 'audio' | 'other';
|
| 55 |
mimeType: string;
|
| 56 |
size: number;
|
| 57 |
-
|
| 58 |
};
|
| 59 |
timestamp: string;
|
| 60 |
}
|
|
@@ -77,3 +78,24 @@ export interface GroupInfo {
|
|
| 77 |
description: string;
|
| 78 |
icon: string;
|
| 79 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
permissions: UserPermissions;
|
| 13 |
joinedGroups: string[];
|
| 14 |
createdAt: string;
|
| 15 |
+
password?: string;
|
| 16 |
}
|
| 17 |
|
| 18 |
export interface SubjectFolder {
|
|
|
|
| 55 |
fileType: 'image' | 'docx' | 'video' | 'audio' | 'other';
|
| 56 |
mimeType: string;
|
| 57 |
size: number;
|
| 58 |
+
videoDuration?: number;
|
| 59 |
};
|
| 60 |
timestamp: string;
|
| 61 |
}
|
|
|
|
| 78 |
description: string;
|
| 79 |
icon: string;
|
| 80 |
}
|
| 81 |
+
|
| 82 |
+
export interface CallParticipant {
|
| 83 |
+
username: string;
|
| 84 |
+
active: boolean;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
export interface VoiceCall {
|
| 88 |
+
id: string;
|
| 89 |
+
group: string;
|
| 90 |
+
mode: 'public' | 'secret';
|
| 91 |
+
caller: string;
|
| 92 |
+
startedAt: string;
|
| 93 |
+
invitedUsers: string[];
|
| 94 |
+
activeUsers: string[];
|
| 95 |
+
participantUsers: CallParticipant[];
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
export interface ChatUser {
|
| 99 |
+
username: string;
|
| 100 |
+
active: boolean;
|
| 101 |
+
}
|