Original
This commit is contained in:
18
.env.example
Normal file
18
.env.example
Normal file
@@ -0,0 +1,18 @@
|
||||
# Server
|
||||
PORT=3001
|
||||
JWT_SECRET=your-secure-random-secret-key-here
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:3000
|
||||
|
||||
# Database (PostgreSQL)
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/vortex
|
||||
|
||||
# Message encryption (AES-256-GCM) — 64-char hex key (32 bytes)
|
||||
# Generate with: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
# WARNING: if you lose this key, all encrypted messages become unreadable!
|
||||
ENCRYPTION_KEY=
|
||||
|
||||
# TURN server for voice/video calls (optional, recommended for production)
|
||||
# Install coturn: sudo apt install coturn
|
||||
# TURN_URL=turn:your-domain.com:3478
|
||||
# TURN_SECRET=your-coturn-shared-secret
|
||||
# STUN_URLS=stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302
|
||||
34
.gitignore
vendored
Normal file
34
.gitignore
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
apps/server/dist/
|
||||
apps/web/dist/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
apps/server/.env
|
||||
|
||||
# Uploads (user data)
|
||||
apps/server/uploads/avatars/*
|
||||
!apps/server/uploads/avatars/.gitkeep
|
||||
|
||||
# Deploy artifacts
|
||||
vortex-deploy/
|
||||
vortex-deploy.tar.gz
|
||||
|
||||
# Credentials
|
||||
CREDENTIALS.txt
|
||||
|
||||
# Security audit (personal)
|
||||
SECURITY_AUDIT.md
|
||||
|
||||
# OS files
|
||||
Thumbs.db
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
108
CONFIGURATION.md
Normal file
108
CONFIGURATION.md
Normal file
@@ -0,0 +1,108 @@
|
||||
# Vortex Messenger — Настройка конфигурации
|
||||
|
||||
> Если вы используете `deploy.sh` — всё ниже подставляется автоматически.
|
||||
> Этот файл нужен для ручной настройки или если вы хотите понимать, что где лежит.
|
||||
|
||||
---
|
||||
|
||||
## 1. Файл `update-local.bat` (на вашем ПК)
|
||||
|
||||
Откройте файл и замените IP на ваш:
|
||||
|
||||
```
|
||||
set SERVER_IP=<IP вашего VPS>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Файл `apps/server/.env` (на сервере)
|
||||
|
||||
Скопируйте `.env.example` и заполните:
|
||||
|
||||
```env
|
||||
# Порт HTTP-сервера (обычно менять не нужно)
|
||||
PORT=3001
|
||||
|
||||
# Секрет для подписи JWT-токенов — длинная случайная строка
|
||||
# Сгенерировать: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
JWT_SECRET=<ВАШ_СЛУЧАЙНЫЙ_СЕКРЕТ>
|
||||
|
||||
# Строка подключения к PostgreSQL
|
||||
# Формат: postgresql://ПОЛЬЗОВАТЕЛЬ:ПАРОЛЬ@localhost:5432/ИМЯ_БАЗЫ
|
||||
DATABASE_URL=postgresql://vortex:<ПАРОЛЬ_БД>@localhost:5432/vortex
|
||||
|
||||
# Ключ шифрования сообщений (64 hex-символа = 32 байта)
|
||||
# Сгенерировать: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
# ⚠ ВАЖНО: при утере ключа все зашифрованные сообщения станут нечитаемы!
|
||||
ENCRYPTION_KEY=<64_HEX_СИМВОЛА>
|
||||
|
||||
# Режим работы
|
||||
NODE_ENV=production
|
||||
|
||||
# Разрешённые домены для CORS (ваш домен с https://)
|
||||
CORS_ORIGINS=https://<ВАШ_ДОМЕН>
|
||||
|
||||
# Лимит регистраций с одного IP
|
||||
MAX_REGISTRATIONS_PER_IP=5
|
||||
|
||||
# TURN-сервер для звонков (coturn)
|
||||
TURN_URL=turn:<ВАШ_ДОМЕН>:3478
|
||||
TURN_SECRET=<СЕКРЕТ_TURN_СЕРВЕРА>
|
||||
STUN_URLS=stun:stun.l.google.com:19302,stun:<ВАШ_ДОМЕН>:3478
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Конфигурация coturn (на сервере)
|
||||
|
||||
Файл `/etc/turnserver.conf`:
|
||||
|
||||
```
|
||||
realm=<ВАШ_ДОМЕН>
|
||||
server-name=<ВАШ_ДОМЕН>
|
||||
static-auth-secret=<СЕКРЕТ_TURN_СЕРВЕРА> # тот же что TURN_SECRET в .env
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Nginx (на сервере)
|
||||
|
||||
Файл `/etc/nginx/sites-available/vortex` — замените `server_name`:
|
||||
|
||||
```
|
||||
server_name <ВАШ_ДОМЕН>;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Генерация ключей
|
||||
|
||||
```bash
|
||||
# JWT-секрет (64 символа)
|
||||
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
|
||||
# Ключ шифрования сообщений (64 hex-символа)
|
||||
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
|
||||
# Пароль для базы данных (32 символа)
|
||||
node -e "console.log(require('crypto').randomBytes(24).toString('base64url').slice(0,32))"
|
||||
|
||||
# TURN-секрет (32 символа)
|
||||
node -e "console.log(require('crypto').randomBytes(24).toString('base64url').slice(0,32))"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Сводная таблица
|
||||
|
||||
| Где | Что заменить | Пример значения |
|
||||
|-----|-------------|-----------------|
|
||||
| `update-local.bat` строка 9 | `SERVER_IP` | `203.0.113.50` |
|
||||
| `.env` → `JWT_SECRET` | Случайная строка 64+ символов | см. раздел 5 |
|
||||
| `.env` → `DATABASE_URL` | Пароль пользователя БД | `postgresql://vortex:MyPass123@localhost:5432/vortex` |
|
||||
| `.env` → `ENCRYPTION_KEY` | 64 hex-символа | см. раздел 5 |
|
||||
| `.env` → `CORS_ORIGINS` | Ваш домен с `https://` | `https://chat.example.com` |
|
||||
| `.env` → `TURN_URL` | Ваш домен | `turn:chat.example.com:3478` |
|
||||
| `.env` → `TURN_SECRET` | Случайная строка | см. раздел 5 |
|
||||
| `/etc/turnserver.conf` | `realm`, `server-name`, `static-auth-secret` | Ваш домен + TURN_SECRET |
|
||||
| Nginx → `server_name` | Ваш домен | `chat.example.com` |
|
||||
215
DEPLOYMENT.md
Normal file
215
DEPLOYMENT.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Vortex Messenger — Руководство по развёртыванию
|
||||
|
||||
## Что понадобится
|
||||
|
||||
| Что | Детали |
|
||||
|-----|--------|
|
||||
| **VPS** | Ubuntu 22.04 / 24.04, от 1 ГБ RAM, публичный IP |
|
||||
| **Домен** | С A-записью, направленной на IP вашего VPS |
|
||||
| **Email** | Для получения SSL-сертификата (Let's Encrypt) |
|
||||
| **ПК** | Windows / macOS / Linux с SSH-клиентом |
|
||||
|
||||
---
|
||||
|
||||
## 1. Подготовка сервера
|
||||
|
||||
### 1.1 Покупка VPS
|
||||
Купите VPS у провайдера (Timeweb Cloud, Hetzner, DigitalOcean и т.д.).
|
||||
Запишите **IP** и **root-пароль**.
|
||||
|
||||
### 1.2 Покупка домена
|
||||
Купите домен и в DNS-настройках реестра создайте A-запись:
|
||||
|
||||
```
|
||||
Тип: A
|
||||
Хост: @
|
||||
Значение: <IP вашего VPS>
|
||||
```
|
||||
|
||||
> Может потребоваться до 24 часов для распространения DNS (обычно 5–15 минут).
|
||||
|
||||
---
|
||||
|
||||
## 2. Загрузка проекта на сервер
|
||||
|
||||
С вашего ПК (из папки с проектом):
|
||||
|
||||
```bash
|
||||
# Создать папку на сервере
|
||||
ssh root@<IP> "mkdir -p /var/www/vortex"
|
||||
|
||||
# Скопировать файлы проекта
|
||||
scp -r ./* root@<IP>:/var/www/vortex/
|
||||
```
|
||||
|
||||
> Замените `<IP>` на IP вашего VPS. При первом подключении подтвердите fingerprint (`yes`) и введите root-пароль.
|
||||
|
||||
---
|
||||
|
||||
## 3. Запуск автоматического деплоя
|
||||
|
||||
```bash
|
||||
# Подключиться к серверу
|
||||
ssh root@<IP>
|
||||
|
||||
# Дать права на запуск
|
||||
chmod +x /var/www/vortex/deploy.sh
|
||||
|
||||
# Запустить деплой
|
||||
/var/www/vortex/deploy.sh
|
||||
```
|
||||
|
||||
Скрипт спросит:
|
||||
- **Домен** — ваш домен (например `messenger.example.com`)
|
||||
- **Email** — для SSL-сертификата
|
||||
|
||||
Всё остальное (Node.js, PostgreSQL, Nginx, Certbot, coturn, PM2) установится и настроится автоматически.
|
||||
|
||||
---
|
||||
|
||||
## 4. Что делает скрипт
|
||||
|
||||
| Шаг | Действие |
|
||||
|-----|----------|
|
||||
| 1/10 | Обновление системы, установка базовых утилит |
|
||||
| 2/10 | Установка Node.js 20 и PM2 |
|
||||
| 3/10 | Установка PostgreSQL, создание БД и пользователя |
|
||||
| 4/10 | Установка Nginx |
|
||||
| 5/10 | Установка Certbot для SSL |
|
||||
| 6/10 | Установка coturn (TURN-сервер для звонков) |
|
||||
| 7/10 | Создание `.env` с автогенерированными ключами |
|
||||
| 8/10 | `npm install`, Prisma миграции, сборка бэкенда и фронтенда |
|
||||
| 9/10 | Настройка Nginx (reverse proxy, SSL, блокировка доступа по IP) |
|
||||
| 10/10 | Запуск сервера через PM2 с автозапуском |
|
||||
|
||||
По завершении:
|
||||
- Кредитные данные сохраняются в `/var/www/vortex/CREDENTIALS.txt` (доступ только root)
|
||||
- Скрипт обновления создаётся в `/var/www/vortex/update.sh`
|
||||
|
||||
---
|
||||
|
||||
## 5. Настройка конфигурации
|
||||
|
||||
Подробная инструкция по всем параметрам, которые нужно подставить под себя — в отдельном файле **[CONFIGURATION.md](CONFIGURATION.md)**.
|
||||
|
||||
> При использовании `deploy.sh` всё настраивается автоматически. `CONFIGURATION.md` нужен только для ручной настройки или понимания структуры.
|
||||
|
||||
---
|
||||
|
||||
## 6. После установки
|
||||
|
||||
Откройте в браузере:
|
||||
|
||||
```
|
||||
https://ваш-домен.ru
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Полезные команды на сервере
|
||||
|
||||
```bash
|
||||
# ─── Подключение к серверу ───
|
||||
ssh root@<IP>
|
||||
|
||||
# ─── Логи приложения ───
|
||||
pm2 logs vortex-server # все логи
|
||||
pm2 logs vortex-server --lines 50 # последние 50 строк
|
||||
|
||||
# ─── Управление сервером ───
|
||||
pm2 restart vortex-server # перезапуск
|
||||
pm2 stop vortex-server # остановка
|
||||
pm2 start vortex-server # запуск
|
||||
pm2 monit # мониторинг в реальном времени
|
||||
|
||||
# ─── Статус ───
|
||||
pm2 status # список процессов
|
||||
pm2 info vortex-server # детальная информация
|
||||
|
||||
# ─── Nginx ───
|
||||
nginx -t # проверить конфигурацию
|
||||
systemctl reload nginx # перезагрузить Nginx
|
||||
systemctl status nginx # статус Nginx
|
||||
|
||||
# ─── Логи Nginx ───
|
||||
tail -f /var/log/nginx/access.log # логи входящих запросов
|
||||
tail -f /var/log/nginx/error.log # логи ошибок
|
||||
|
||||
# ─── PostgreSQL ───
|
||||
sudo -u postgres psql -d vortex # подключиться к БД
|
||||
\dt # список таблиц (внутри psql)
|
||||
\q # выход из psql
|
||||
|
||||
# ─── coturn (TURN-сервер) ───
|
||||
systemctl status coturn # статус TURN
|
||||
systemctl restart coturn # перезапуск TURN
|
||||
|
||||
# ─── SSL сертификат ───
|
||||
certbot renew --dry-run # проверка обновления сертификата
|
||||
certbot certificates # список сертификатов
|
||||
|
||||
# ─── Файрвол ───
|
||||
ufw status # статус файрвола
|
||||
ufw allow 22/tcp # SSH
|
||||
ufw allow 80/tcp # HTTP
|
||||
ufw allow 443/tcp # HTTPS
|
||||
ufw allow 3478/tcp # TURN TCP
|
||||
ufw allow 3478/udp # TURN UDP
|
||||
|
||||
# ─── Файлы проекта ───
|
||||
ls /var/www/vortex/ # корень проекта
|
||||
cat /var/www/vortex/apps/server/.env # посмотреть env
|
||||
cat /var/www/vortex/CREDENTIALS.txt # все сгенерированные пароли
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Обновление проекта
|
||||
|
||||
### С Windows (update-local.bat)
|
||||
|
||||
1. Внесите изменения в код на ПК
|
||||
2. Отредактируйте `update-local.bat` — впишите IP вашего сервера в переменную `SERVER_IP`
|
||||
3. Запустите `update-local.bat` — он соберёт архив и загрузит на сервер
|
||||
4. Подключитесь к серверу и запустите:
|
||||
|
||||
```bash
|
||||
ssh root@<IP>
|
||||
/var/www/vortex/update.sh
|
||||
```
|
||||
|
||||
### Вручную
|
||||
|
||||
```bash
|
||||
# На ПК: заархивировать проект (без node_modules, dist, .env)
|
||||
tar -czf vortex-deploy.tar.gz --exclude='node_modules' --exclude='dist' --exclude='.env' --exclude='uploads/avatars/*' -C . .
|
||||
|
||||
# Загрузить на сервер
|
||||
scp vortex-deploy.tar.gz root@<IP>:/root/
|
||||
|
||||
# На сервере: запустить обновление
|
||||
ssh root@<IP>
|
||||
/var/www/vortex/update.sh
|
||||
```
|
||||
|
||||
Скрипт `update.sh` автоматически:
|
||||
- Остановит сервер
|
||||
- Сделает бэкап `.env` и аватаров
|
||||
- Распакует обновление
|
||||
- Восстановит `.env` и аватары
|
||||
- Установит зависимости, применит миграции, пересоберёт
|
||||
- Запустит сервер
|
||||
|
||||
---
|
||||
|
||||
## 9. Устранение проблем
|
||||
|
||||
| Проблема | Решение |
|
||||
|----------|---------|
|
||||
| Сайт не открывается | `pm2 logs vortex-server --lines 30` — проверить ошибки |
|
||||
| 502 Bad Gateway | `pm2 restart vortex-server` — бэкенд упал |
|
||||
| SSL не получен | Проверить DNS: `dig +short ваш-домен.ru` должен показать IP сервера. Повторить: `certbot --nginx -d ваш-домен.ru` |
|
||||
| Звонки не работают | `systemctl status coturn` — проверить TURN-сервер. Порты 3478 TCP/UDP должны быть открыты |
|
||||
| БД недоступна | `systemctl status postgresql` → `systemctl start postgresql` |
|
||||
| Сервер не стартует после ребута | `pm2 startup` и `pm2 save` |
|
||||
| Нет места на диске | `df -h` → очистить логи: `pm2 flush` |
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Vortex Messenger
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
39
apps/server/package.json
Normal file
39
apps/server/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@vortex/server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.3.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.2.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"socket.io": "^4.8.1",
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/express-rate-limit": "^5.1.3",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/mime-types": "^3.0.1",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^22.10.0",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"prisma": "^6.3.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
113
apps/server/prisma/clean-db.ts
Normal file
113
apps/server/prisma/clean-db.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* Полная очистка базы данных от тестовых данных.
|
||||
* Удаляет ВСЕ: пользователей, чаты, сообщения, истории, дружбы.
|
||||
* Таблицы и схема остаются на месте.
|
||||
*
|
||||
* Запуск: npx tsx prisma/clean-db.ts
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function cleanDatabase() {
|
||||
console.log('⚠️ ВНИМАНИЕ: Полная очистка базы данных!\n');
|
||||
|
||||
// Удаляем в правильном порядке (зависимости → родители)
|
||||
const counts: Record<string, number> = {};
|
||||
|
||||
// 1. Зависимые таблицы
|
||||
const r1 = await prisma.hiddenMessage.deleteMany();
|
||||
counts['HiddenMessage'] = r1.count;
|
||||
|
||||
const r2 = await prisma.readReceipt.deleteMany();
|
||||
counts['ReadReceipt'] = r2.count;
|
||||
|
||||
const r3 = await prisma.reaction.deleteMany();
|
||||
counts['Reaction'] = r3.count;
|
||||
|
||||
const r4 = await prisma.pinnedMessage.deleteMany();
|
||||
counts['PinnedMessage'] = r4.count;
|
||||
|
||||
const r5 = await prisma.media.deleteMany();
|
||||
counts['Media'] = r5.count;
|
||||
|
||||
const r6 = await prisma.storyView.deleteMany();
|
||||
counts['StoryView'] = r6.count;
|
||||
|
||||
const r7 = await prisma.story.deleteMany();
|
||||
counts['Story'] = r7.count;
|
||||
|
||||
// 2. Сообщения
|
||||
const r8 = await prisma.message.deleteMany();
|
||||
counts['Message'] = r8.count;
|
||||
|
||||
// 3. Чаты
|
||||
const r9 = await prisma.chatMember.deleteMany();
|
||||
counts['ChatMember'] = r9.count;
|
||||
|
||||
const r10 = await prisma.chat.deleteMany();
|
||||
counts['Chat'] = r10.count;
|
||||
|
||||
// 4. Дружбы
|
||||
const r11 = await prisma.friendship.deleteMany();
|
||||
counts['Friendship'] = r11.count;
|
||||
|
||||
// 5. Пользователи
|
||||
const r12 = await prisma.user.deleteMany();
|
||||
counts['User'] = r12.count;
|
||||
|
||||
// 6. Чистка папки uploads (кроме avatars/.gitkeep)
|
||||
const uploadsDir = path.join(__dirname, '..', 'uploads');
|
||||
let filesDeleted = 0;
|
||||
|
||||
if (fs.existsSync(uploadsDir)) {
|
||||
const entries = fs.readdirSync(uploadsDir);
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(uploadsDir, entry);
|
||||
const stat = fs.statSync(fullPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Для папки avatars — очистить содержимое, но оставить папку
|
||||
if (entry === 'avatars') {
|
||||
const avatarFiles = fs.readdirSync(fullPath);
|
||||
for (const f of avatarFiles) {
|
||||
if (f === '.gitkeep') continue;
|
||||
fs.unlinkSync(path.join(fullPath, f));
|
||||
filesDeleted++;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Файлы в корне uploads
|
||||
if (entry !== '.gitkeep') {
|
||||
fs.unlinkSync(fullPath);
|
||||
filesDeleted++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Вывод результатов
|
||||
console.log('┌──────────────────────────────────────┐');
|
||||
console.log('│ 🧹 База данных очищена! │');
|
||||
console.log('├──────────────────────────────────────┤');
|
||||
for (const [table, count] of Object.entries(counts)) {
|
||||
if (count > 0) {
|
||||
console.log(`│ ${table.padEnd(20)} ${String(count).padStart(6)} удалено │`);
|
||||
}
|
||||
}
|
||||
if (filesDeleted > 0) {
|
||||
console.log(`│ ${'Файлы (uploads)'.padEnd(20)} ${String(filesDeleted).padStart(6)} удалено │`);
|
||||
}
|
||||
console.log('└──────────────────────────────────────┘');
|
||||
console.log('\n✅ Готово. БД чистая, можно начинать с нуля.');
|
||||
}
|
||||
|
||||
cleanDatabase()
|
||||
.catch((e) => {
|
||||
console.error('❌ Ошибка очистки:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
BIN
apps/server/prisma/dev.db
Normal file
BIN
apps/server/prisma/dev.db
Normal file
Binary file not shown.
69
apps/server/prisma/encrypt-existing-files.ts
Normal file
69
apps/server/prisma/encrypt-existing-files.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Encrypt existing unencrypted files in the uploads directory.
|
||||
*
|
||||
* Run once after enabling ENCRYPTION_KEY:
|
||||
* npx ts-node prisma/encrypt-existing-files.ts
|
||||
*
|
||||
* Safe to re-run — skips already-encrypted files (decryption test).
|
||||
*/
|
||||
import '../src/config'; // loads .env & initialises encryption
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { isEncryptionEnabled, encryptFileInPlace, decryptFileToBuffer } from '../src/encrypt';
|
||||
|
||||
const UPLOADS_ROOT = path.join(__dirname, '../uploads');
|
||||
|
||||
function walkDir(dir: string): string[] {
|
||||
const files: string[] = [];
|
||||
if (!fs.existsSync(dir)) return files;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...walkDir(full));
|
||||
} else {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!isEncryptionEnabled()) {
|
||||
console.error('❌ ENCRYPTION_KEY не задан в .env — сначала укажите ключ шифрования.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('🔒 Начало шифрования файлов в uploads/…\n');
|
||||
|
||||
const allFiles = walkDir(UPLOADS_ROOT);
|
||||
console.log(`📁 Найдено ${allFiles.length} файлов`);
|
||||
|
||||
let encrypted = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const filePath of allFiles) {
|
||||
const relPath = path.relative(UPLOADS_ROOT, filePath);
|
||||
try {
|
||||
// Try to decrypt — if it works, file is already encrypted
|
||||
const decrypted = decryptFileToBuffer(filePath);
|
||||
if (decrypted !== null) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// File is not encrypted — encrypt it
|
||||
encryptFileInPlace(filePath);
|
||||
encrypted++;
|
||||
process.stdout.write(` ✔ ${encrypted} зашифровано, ${skipped} пропущено\r`);
|
||||
} catch (e) {
|
||||
console.error(`\n ❌ Ошибка с файлом ${relPath}:`, e);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n\n✅ Готово! Зашифровано ${encrypted} файлов, пропущено ${skipped} (уже зашифрованы).`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('Ошибка:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
67
apps/server/prisma/encrypt-existing.ts
Normal file
67
apps/server/prisma/encrypt-existing.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Migrate existing plain-text messages to encrypted form.
|
||||
*
|
||||
* Run once after enabling ENCRYPTION_KEY:
|
||||
* npx ts-node prisma/encrypt-existing.ts
|
||||
*
|
||||
* Safe to re-run — skips already-encrypted messages ("enc:v1:" prefix).
|
||||
*/
|
||||
import '../src/config'; // loads .env & initialises encryption
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { encryptText, isEncryptionEnabled } from '../src/encrypt';
|
||||
|
||||
// Use raw PrismaClient to bypass the encryption middleware (avoid double-encryption)
|
||||
const rawPrisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
if (!isEncryptionEnabled()) {
|
||||
console.error('❌ ENCRYPTION_KEY не задан в .env — сначала укажите ключ шифрования.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('🔒 Начало шифрования существующих сообщений…\n');
|
||||
|
||||
// We bypass the Prisma middleware by using $queryRawUnsafe for the SELECT,
|
||||
// then use raw UPDATE to avoid double-encryption via middleware.
|
||||
const messages: Array<{ id: string; content: string | null; quote: string | null }> =
|
||||
await rawPrisma.$queryRaw`
|
||||
SELECT id, content, quote FROM "Message"
|
||||
WHERE (content IS NOT NULL AND content != '' AND content NOT LIKE 'enc:v1:%')
|
||||
OR (quote IS NOT NULL AND quote != '' AND quote NOT LIKE 'enc:v1:%')
|
||||
`;
|
||||
|
||||
console.log(`📝 Найдено ${messages.length} незашифрованных сообщений`);
|
||||
|
||||
let encrypted = 0;
|
||||
const BATCH_SIZE = 500;
|
||||
|
||||
for (let i = 0; i < messages.length; i += BATCH_SIZE) {
|
||||
const batch = messages.slice(i, i + BATCH_SIZE);
|
||||
await rawPrisma.$transaction(
|
||||
batch.map((msg) => {
|
||||
const newContent = msg.content && !msg.content.startsWith('enc:v1:')
|
||||
? encryptText(msg.content) : null;
|
||||
const newQuote = msg.quote && !msg.quote.startsWith('enc:v1:')
|
||||
? encryptText(msg.quote) : null;
|
||||
|
||||
return rawPrisma.$executeRaw`
|
||||
UPDATE "Message"
|
||||
SET content = COALESCE(${newContent}::text, content),
|
||||
quote = COALESCE(${newQuote}::text, quote)
|
||||
WHERE id = ${msg.id}
|
||||
`;
|
||||
})
|
||||
);
|
||||
encrypted += batch.length;
|
||||
process.stdout.write(` ✔ ${encrypted}/${messages.length}\r`);
|
||||
}
|
||||
|
||||
console.log(`\n\n✅ Готово! Зашифровано ${encrypted} сообщений.`);
|
||||
console.log('⚠ СОХРАНИТЕ КЛЮЧ ENCRYPTION_KEY В НАДЁЖНОМ МЕСТЕ — без него данные не восстановить!');
|
||||
await rawPrisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('Ошибка миграции:', e);
|
||||
process.exit(1);
|
||||
});
|
||||
195
apps/server/prisma/schema.prisma
Normal file
195
apps/server/prisma/schema.prisma
Normal file
@@ -0,0 +1,195 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
username String @unique
|
||||
displayName String @default("")
|
||||
email String? @unique
|
||||
phone String?
|
||||
password String
|
||||
avatar String?
|
||||
bio String?
|
||||
birthday String?
|
||||
createdAt DateTime @default(now())
|
||||
lastSeen DateTime @default(now())
|
||||
isOnline Boolean @default(false)
|
||||
hideStoryViews Boolean @default(false)
|
||||
registrationIp String?
|
||||
|
||||
messages Message[]
|
||||
chatMembers ChatMember[]
|
||||
reactions Reaction[]
|
||||
readReceipts ReadReceipt[]
|
||||
forwardedMessages Message[] @relation("ForwardedFrom")
|
||||
stories Story[]
|
||||
storyViews StoryView[]
|
||||
friendshipsSent Friendship[] @relation("FriendshipsSent")
|
||||
friendshipsReceived Friendship[] @relation("FriendshipsReceived")
|
||||
}
|
||||
|
||||
model Chat {
|
||||
id String @id @default(uuid())
|
||||
type String @default("personal")
|
||||
name String?
|
||||
avatar String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
members ChatMember[]
|
||||
messages Message[]
|
||||
pinnedMessages PinnedMessage[]
|
||||
}
|
||||
|
||||
model ChatMember {
|
||||
id String @id @default(uuid())
|
||||
chatId String
|
||||
userId String
|
||||
role String @default("member")
|
||||
joinedAt DateTime @default(now())
|
||||
isMuted Boolean @default(false)
|
||||
isArchived Boolean @default(false)
|
||||
isPinned Boolean @default(false)
|
||||
clearedAt DateTime?
|
||||
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([chatId, userId])
|
||||
}
|
||||
|
||||
model Message {
|
||||
id String @id @default(uuid())
|
||||
chatId String
|
||||
senderId String
|
||||
content String?
|
||||
type String @default("text")
|
||||
replyToId String?
|
||||
quote String?
|
||||
forwardedFromId String?
|
||||
isEdited Boolean @default(false)
|
||||
isDeleted Boolean @default(false)
|
||||
scheduledAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
sender User @relation(fields: [senderId], references: [id])
|
||||
replyTo Message? @relation("Reply", fields: [replyToId], references: [id])
|
||||
replies Message[] @relation("Reply")
|
||||
forwardedFrom User? @relation("ForwardedFrom", fields: [forwardedFromId], references: [id])
|
||||
|
||||
media Media[]
|
||||
reactions Reaction[]
|
||||
readBy ReadReceipt[]
|
||||
pinnedIn PinnedMessage[]
|
||||
hiddenBy HiddenMessage[]
|
||||
}
|
||||
|
||||
model Media {
|
||||
id String @id @default(uuid())
|
||||
messageId String
|
||||
type String
|
||||
url String
|
||||
filename String?
|
||||
thumbnail String?
|
||||
size Int?
|
||||
duration Float?
|
||||
width Int?
|
||||
height Int?
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
model Reaction {
|
||||
id String @id @default(uuid())
|
||||
messageId String
|
||||
userId String
|
||||
emoji String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([messageId, userId, emoji])
|
||||
}
|
||||
|
||||
model ReadReceipt {
|
||||
id String @id @default(uuid())
|
||||
messageId String
|
||||
userId String
|
||||
readAt DateTime @default(now())
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([messageId, userId])
|
||||
}
|
||||
|
||||
model PinnedMessage {
|
||||
id String @id @default(uuid())
|
||||
chatId String
|
||||
messageId String
|
||||
pinnedAt DateTime @default(now())
|
||||
|
||||
chat Chat @relation(fields: [chatId], references: [id], onDelete: Cascade)
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([chatId, messageId])
|
||||
}
|
||||
|
||||
model Story {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
type String @default("text")
|
||||
mediaUrl String?
|
||||
content String?
|
||||
bgColor String?
|
||||
createdAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
views StoryView[]
|
||||
}
|
||||
|
||||
model StoryView {
|
||||
id String @id @default(uuid())
|
||||
storyId String
|
||||
userId String
|
||||
viewedAt DateTime @default(now())
|
||||
|
||||
story Story @relation(fields: [storyId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
|
||||
@@unique([storyId, userId])
|
||||
}
|
||||
|
||||
model HiddenMessage {
|
||||
id String @id @default(uuid())
|
||||
messageId String
|
||||
userId String
|
||||
hiddenAt DateTime @default(now())
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([messageId, userId])
|
||||
}
|
||||
|
||||
model Friendship {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
friendId String
|
||||
status String @default("pending") // pending | accepted | declined
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation("FriendshipsSent", fields: [userId], references: [id], onDelete: Cascade)
|
||||
friend User @relation("FriendshipsReceived", fields: [friendId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, friendId])
|
||||
}
|
||||
48
apps/server/prisma/seed.ts
Normal file
48
apps/server/prisma/seed.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
console.log('Заполнение базы данных...\n');
|
||||
|
||||
const password = await bcrypt.hash('demo123', 10);
|
||||
|
||||
const usersData = [
|
||||
{ username: 'evgeniy', displayName: 'Евгений', bio: 'Создатель Vortex' },
|
||||
{ username: 'anastasia', displayName: 'Анастасия', bio: 'Дизайнер интерфейсов' },
|
||||
{ username: 'artem', displayName: 'Артём', bio: 'Frontend разработчик' },
|
||||
{ username: 'polina', displayName: 'Полина', bio: 'Backend разработчик' },
|
||||
{ username: 'daniil', displayName: 'Даниил', bio: 'DevOps инженер' },
|
||||
{ username: 'vladimir', displayName: 'Владимир', bio: 'Product Manager' },
|
||||
];
|
||||
|
||||
const users = await Promise.all(
|
||||
usersData.map((u) =>
|
||||
prisma.user.upsert({
|
||||
where: { username: u.username },
|
||||
update: { displayName: u.displayName, bio: u.bio },
|
||||
create: {
|
||||
username: u.username,
|
||||
displayName: u.displayName,
|
||||
password,
|
||||
bio: u.bio,
|
||||
isOnline: false,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`Создано ${users.length} пользователей`);
|
||||
|
||||
console.log('\n--- Тестовые аккаунты ---');
|
||||
console.log('Пароль для всех: demo123\n');
|
||||
for (const user of users) {
|
||||
console.log(` ${user.username} (${user.displayName})`);
|
||||
}
|
||||
console.log('\nЗаполнение завершено!');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch(console.error)
|
||||
.finally(() => prisma.$disconnect());
|
||||
40
apps/server/src/config.ts
Normal file
40
apps/server/src/config.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import dotenv from 'dotenv';
|
||||
import path from 'path';
|
||||
import { initEncryption } from './encrypt';
|
||||
|
||||
dotenv.config({ path: path.join(__dirname, '../.env') });
|
||||
|
||||
if (!process.env.JWT_SECRET) {
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new Error('JWT_SECRET не задан в .env — нельзя запускать в production без секрета!');
|
||||
}
|
||||
console.error(' ⚠ JWT_SECRET не задан в .env — используется dev-значение. Укажите безопасный секрет в продакшене!');
|
||||
}
|
||||
|
||||
// Initialise message encryption (AES-256-GCM)
|
||||
if (process.env.ENCRYPTION_KEY) {
|
||||
initEncryption(process.env.ENCRYPTION_KEY);
|
||||
console.log(' 🔒 Шифрование сообщений включено (AES-256-GCM)');
|
||||
} else {
|
||||
console.warn(' ⚠ ENCRYPTION_KEY не задан — сообщения хранятся без шифрования. Для продакшена задайте 64-символьный hex-ключ.');
|
||||
}
|
||||
|
||||
export const config = {
|
||||
port: Number(process.env.PORT) || 3001,
|
||||
jwtSecret: process.env.JWT_SECRET || 'vortex-dev-fallback-not-for-production',
|
||||
corsOrigins: process.env.CORS_ORIGINS
|
||||
? process.env.CORS_ORIGINS.split(',').map(s => s.trim())
|
||||
: ['http://localhost:5173', 'http://localhost:3000'],
|
||||
uploadsDir: 'uploads',
|
||||
/** Minimum password length */
|
||||
minPasswordLength: 8,
|
||||
/** Maximum registrations allowed from the same IP (permanent, DB-level) */
|
||||
maxRegistrationsPerIp: Number(process.env.MAX_REGISTRATIONS_PER_IP) || 2,
|
||||
/** TURN server URL for WebRTC calls (e.g. turn:your-domain.com:3478) */
|
||||
turnUrl: process.env.TURN_URL || '',
|
||||
/** Shared secret for TURN server (coturn static-auth-secret) */
|
||||
turnSecret: process.env.TURN_SECRET || '',
|
||||
/** STUN server URLs */
|
||||
stunUrls: (process.env.STUN_URLS || 'stun:stun.l.google.com:19302,stun:stun1.l.google.com:19302')
|
||||
.split(',').map(s => s.trim()).filter(Boolean),
|
||||
};
|
||||
153
apps/server/src/db.ts
Normal file
153
apps/server/src/db.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { encryptText, decryptText, isEncryptionEnabled } from './encrypt';
|
||||
|
||||
const basePrisma = new PrismaClient();
|
||||
|
||||
// ─── Prisma extension: transparent message encryption ───────────────
|
||||
// Encrypts `content` and `quote` before writing to DB,
|
||||
// decrypts them after reading. This way the DB never stores plaintext.
|
||||
|
||||
export const prisma = basePrisma.$extends({
|
||||
query: {
|
||||
message: {
|
||||
async create({ args, query }) {
|
||||
if (args.data.content && typeof args.data.content === 'string') {
|
||||
args.data.content = encryptText(args.data.content);
|
||||
}
|
||||
if (args.data.quote && typeof args.data.quote === 'string') {
|
||||
args.data.quote = encryptText(args.data.quote);
|
||||
}
|
||||
const result = await query(args);
|
||||
decryptMessageFields(result as Record<string, unknown>);
|
||||
return result;
|
||||
},
|
||||
async update({ args, query }) {
|
||||
if (args.data.content && typeof args.data.content === 'string') {
|
||||
args.data.content = encryptText(args.data.content);
|
||||
}
|
||||
if (args.data.quote && typeof args.data.quote === 'string') {
|
||||
args.data.quote = encryptText(args.data.quote);
|
||||
}
|
||||
const result = await query(args);
|
||||
decryptMessageFields(result as Record<string, unknown>);
|
||||
return result;
|
||||
},
|
||||
async upsert({ args, query }) {
|
||||
if (args.create.content && typeof args.create.content === 'string') {
|
||||
args.create.content = encryptText(args.create.content);
|
||||
}
|
||||
if (args.create.quote && typeof args.create.quote === 'string') {
|
||||
args.create.quote = encryptText(args.create.quote);
|
||||
}
|
||||
if (args.update.content && typeof args.update.content === 'string') {
|
||||
(args.update as Record<string, unknown>).content = encryptText(args.update.content as string);
|
||||
}
|
||||
if (args.update.quote && typeof args.update.quote === 'string') {
|
||||
(args.update as Record<string, unknown>).quote = encryptText(args.update.quote as string);
|
||||
}
|
||||
const result = await query(args);
|
||||
decryptMessageFields(result as Record<string, unknown>);
|
||||
return result;
|
||||
},
|
||||
async findUnique({ args, query }) {
|
||||
const result = await query(args);
|
||||
if (result) decryptMessageFields(result as Record<string, unknown>);
|
||||
return result;
|
||||
},
|
||||
async findFirst({ args, query }) {
|
||||
const result = await query(args);
|
||||
if (result) decryptMessageFields(result as Record<string, unknown>);
|
||||
return result;
|
||||
},
|
||||
async findMany({ args, query }) {
|
||||
const results = await query(args);
|
||||
for (const item of results) {
|
||||
decryptMessageFields(item as Record<string, unknown>);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
},
|
||||
// Also decrypt messages nested inside Chat queries
|
||||
chat: {
|
||||
async findMany({ args, query }) {
|
||||
const results = await query(args);
|
||||
for (const chat of results) {
|
||||
decryptChatMessages(chat as Record<string, unknown>);
|
||||
}
|
||||
return results;
|
||||
},
|
||||
async findFirst({ args, query }) {
|
||||
const result = await query(args);
|
||||
if (result) decryptChatMessages(result as Record<string, unknown>);
|
||||
return result;
|
||||
},
|
||||
async findUnique({ args, query }) {
|
||||
const result = await query(args);
|
||||
if (result) decryptChatMessages(result as Record<string, unknown>);
|
||||
return result;
|
||||
},
|
||||
async create({ args, query }) {
|
||||
const result = await query(args);
|
||||
decryptChatMessages(result as Record<string, unknown>);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
// Decrypt message inside PinnedMessage queries
|
||||
pinnedMessage: {
|
||||
async findFirst({ args, query }) {
|
||||
const result = await query(args);
|
||||
if (result) decryptNested(result as Record<string, unknown>);
|
||||
return result;
|
||||
},
|
||||
async findMany({ args, query }) {
|
||||
const results = await query(args);
|
||||
for (const item of results) decryptNested(item as Record<string, unknown>);
|
||||
return results;
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/** Decrypt content/quote on a message-shaped object. */
|
||||
function decryptMessageFields(obj: Record<string, unknown> | null): void {
|
||||
if (!obj || typeof obj !== 'object' || !isEncryptionEnabled()) return;
|
||||
|
||||
if (typeof obj.content === 'string') {
|
||||
obj.content = decryptText(obj.content);
|
||||
}
|
||||
if (typeof obj.quote === 'string') {
|
||||
obj.quote = decryptText(obj.quote);
|
||||
}
|
||||
// Nested replyTo
|
||||
if (obj.replyTo && typeof obj.replyTo === 'object') {
|
||||
decryptMessageFields(obj.replyTo as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt messages nested inside a chat object. */
|
||||
function decryptChatMessages(chat: Record<string, unknown>): void {
|
||||
if (!chat || !isEncryptionEnabled()) return;
|
||||
if (Array.isArray(chat.messages)) {
|
||||
for (const msg of chat.messages) {
|
||||
decryptMessageFields(msg as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
// pinnedMessages[].message
|
||||
if (Array.isArray(chat.pinnedMessages)) {
|
||||
for (const pm of chat.pinnedMessages) {
|
||||
const pmo = pm as Record<string, unknown>;
|
||||
if (pmo.message && typeof pmo.message === 'object') {
|
||||
decryptMessageFields(pmo.message as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Decrypt nested message field on any object (e.g. PinnedMessage.message). */
|
||||
function decryptNested(obj: Record<string, unknown>): void {
|
||||
if (!obj || !isEncryptionEnabled()) return;
|
||||
if (obj.message && typeof obj.message === 'object') {
|
||||
decryptMessageFields(obj.message as Record<string, unknown>);
|
||||
}
|
||||
}
|
||||
|
||||
154
apps/server/src/encrypt.ts
Normal file
154
apps/server/src/encrypt.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
/**
|
||||
* AES-256-GCM encryption for message content and files.
|
||||
*
|
||||
* Text format: "enc:v1:<iv_hex>:<authTag_hex>:<ciphertext_hex>"
|
||||
* File format: [12 bytes IV][16 bytes AuthTag][...ciphertext...]
|
||||
*
|
||||
* The "enc:v1:" prefix allows detecting encrypted vs plain-text content
|
||||
* for backward compatibility with existing unencrypted messages.
|
||||
*/
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 12; // 96 bits — recommended for GCM
|
||||
const AUTH_TAG_LENGTH = 16;
|
||||
const FILE_HEADER_LENGTH = IV_LENGTH + AUTH_TAG_LENGTH; // 28 bytes
|
||||
const PREFIX = 'enc:v1:';
|
||||
|
||||
let encryptionKey: Buffer | null = null;
|
||||
|
||||
/** Initialise encryption with a 64-char hex key (32 bytes). */
|
||||
export function initEncryption(hexKey: string): void {
|
||||
if (!hexKey || hexKey.length !== 64) {
|
||||
throw new Error(
|
||||
'ENCRYPTION_KEY must be a 64-character hex string (32 bytes). ' +
|
||||
'Generate one with: node -e "console.log(require(\'crypto\').randomBytes(32).toString(\'hex\'))"'
|
||||
);
|
||||
}
|
||||
encryptionKey = Buffer.from(hexKey, 'hex');
|
||||
}
|
||||
|
||||
/** Returns true if encryption is enabled (key configured). */
|
||||
export function isEncryptionEnabled(): boolean {
|
||||
return encryptionKey !== null;
|
||||
}
|
||||
|
||||
/** Encrypt a plain-text string. Returns the encrypted string or the original if encryption is disabled. */
|
||||
export function encryptText(plaintext: string): string {
|
||||
if (!encryptionKey || !plaintext) return plaintext;
|
||||
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, encryptionKey, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
return `${PREFIX}${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted.toString('hex')}`;
|
||||
}
|
||||
|
||||
/** Decrypt an encrypted string. Returns the plain text, or the original string if it's not encrypted. */
|
||||
export function decryptText(ciphertext: string): string {
|
||||
if (!ciphertext || !ciphertext.startsWith(PREFIX)) {
|
||||
// Not encrypted (legacy data or null) — return as-is
|
||||
return ciphertext;
|
||||
}
|
||||
if (!encryptionKey) {
|
||||
console.error('Cannot decrypt: ENCRYPTION_KEY not configured');
|
||||
return '[зашифровано]';
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = ciphertext.slice(PREFIX.length);
|
||||
const [ivHex, tagHex, dataHex] = payload.split(':');
|
||||
if (!ivHex || !tagHex || !dataHex) return '[повреждённые данные]';
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(tagHex, 'hex');
|
||||
const encrypted = Buffer.from(dataHex, 'hex');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKey, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
|
||||
return decrypted.toString('utf8');
|
||||
} catch (e) {
|
||||
console.error('Decryption failed:', e);
|
||||
return '[ошибка расшифровки]';
|
||||
}
|
||||
}
|
||||
|
||||
// ─── File encryption ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Encrypt a file in-place on disk.
|
||||
* Replaces the original file with: [IV 12B][AuthTag 16B][ciphertext...]
|
||||
*/
|
||||
export function encryptFileInPlace(filePath: string): void {
|
||||
if (!encryptionKey) return;
|
||||
|
||||
const plainData = fs.readFileSync(filePath);
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, encryptionKey, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plainData), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// Write: IV + AuthTag + Ciphertext
|
||||
const output = Buffer.concat([iv, authTag, encrypted]);
|
||||
fs.writeFileSync(filePath, output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file appears to be encrypted (has valid header size).
|
||||
* This is a heuristic — not 100% reliable on tiny files, but good enough.
|
||||
*/
|
||||
export function isFileEncrypted(filePath: string): boolean {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
// Encrypted files must be at least 28 bytes (header).
|
||||
// We also check if decryption with the key succeeds on the first chunk.
|
||||
if (stat.size < FILE_HEADER_LENGTH) return false;
|
||||
// If encryption is disabled, treat all files as plain
|
||||
if (!encryptionKey) return false;
|
||||
return true; // Assume encrypted if key is configured and file is large enough
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a file and return the plain-text Buffer.
|
||||
* Returns null if decryption fails (file may be unencrypted).
|
||||
*/
|
||||
export function decryptFileToBuffer(filePath: string): Buffer | null {
|
||||
if (!encryptionKey) return null;
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(filePath);
|
||||
if (data.length < FILE_HEADER_LENGTH) return null;
|
||||
|
||||
const iv = data.subarray(0, IV_LENGTH);
|
||||
const authTag = data.subarray(IV_LENGTH, FILE_HEADER_LENGTH);
|
||||
const ciphertext = data.subarray(FILE_HEADER_LENGTH);
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, encryptionKey, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
return decrypted;
|
||||
} catch {
|
||||
// Decryption failed — file is likely not encrypted (legacy)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a URL path like '/uploads/avatars/abc.jpg' to an absolute file path.
|
||||
*/
|
||||
export function resolveUploadPath(urlPath: string, uploadsRoot: string): string | null {
|
||||
if (!urlPath) return null;
|
||||
const filename = urlPath.replace(/^\/uploads\//, '');
|
||||
const filePath = path.resolve(uploadsRoot, filename);
|
||||
// Path containment check
|
||||
if (!filePath.startsWith(uploadsRoot)) return null;
|
||||
return filePath;
|
||||
}
|
||||
188
apps/server/src/index.ts
Normal file
188
apps/server/src/index.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import express from 'express';
|
||||
import { createServer } from 'http';
|
||||
import { Server } from 'socket.io';
|
||||
import cors from 'cors';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import crypto from 'crypto';
|
||||
import mime from 'mime-types';
|
||||
import { config } from './config';
|
||||
import { prisma } from './db';
|
||||
import authRoutes from './routes/auth';
|
||||
import userRoutes from './routes/users';
|
||||
import chatRoutes from './routes/chats';
|
||||
import messageRoutes from './routes/messages';
|
||||
import storyRoutes from './routes/stories';
|
||||
import friendRoutes from './routes/friends';
|
||||
import { setupSocket } from './socket';
|
||||
import { authenticateToken, AuthRequest } from './middleware/auth';
|
||||
import { decryptFileToBuffer, isEncryptionEnabled } from './encrypt';
|
||||
import { UPLOADS_ROOT } from './shared';
|
||||
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
const io = new Server(server, {
|
||||
cors: {
|
||||
origin: config.corsOrigins,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE'],
|
||||
},
|
||||
});
|
||||
|
||||
// Trust first proxy (Nginx) so req.ip returns real client IP from X-Forwarded-For
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use(cors({ origin: config.corsOrigins }));
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
|
||||
// Serve uploads — decrypts encrypted files on the fly
|
||||
app.use('/uploads', (req, res, next) => {
|
||||
// Security headers
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('Content-Security-Policy', "default-src 'none'");
|
||||
res.setHeader('Cache-Control', 'private, max-age=86400');
|
||||
|
||||
// Resolve file path safely
|
||||
const urlPath = decodeURIComponent(req.path);
|
||||
if (urlPath.includes('..')) {
|
||||
res.status(400).end();
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.resolve(UPLOADS_ROOT, urlPath.replace(/^\//, ''));
|
||||
if (!filePath.startsWith(UPLOADS_ROOT) || !fs.existsSync(filePath)) {
|
||||
res.status(404).end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Set Content-Type from extension
|
||||
const contentType = mime.lookup(filePath) || 'application/octet-stream';
|
||||
res.setHeader('Content-Type', contentType);
|
||||
|
||||
// If encryption is enabled, try to decrypt
|
||||
if (isEncryptionEnabled()) {
|
||||
const decrypted = decryptFileToBuffer(filePath);
|
||||
if (decrypted) {
|
||||
res.setHeader('Content-Length', decrypted.length);
|
||||
res.end(decrypted);
|
||||
return;
|
||||
}
|
||||
// Decryption failed — file is likely unencrypted (legacy), fall through to static
|
||||
}
|
||||
|
||||
// Serve unencrypted file as-is
|
||||
next();
|
||||
}, express.static(UPLOADS_ROOT));
|
||||
|
||||
// Rate limiting for auth endpoints (prevent brute-force)
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 20, // max 20 attempts per window
|
||||
message: { error: 'Слишком много попыток, попробуйте позже' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// General API rate limiter (100 req/min per IP)
|
||||
const apiLimiter = rateLimit({
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
max: 100,
|
||||
message: { error: 'Слишком много запросов, попробуйте позже' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
});
|
||||
|
||||
// API маршруты — auth/me uses general limiter (called on every page load)
|
||||
app.use('/api/auth/me', apiLimiter, authRoutes);
|
||||
app.use('/api/auth', authLimiter, authRoutes);
|
||||
app.use('/api/users', apiLimiter, authenticateToken, userRoutes);
|
||||
app.use('/api/chats', apiLimiter, authenticateToken, chatRoutes);
|
||||
app.use('/api/messages', apiLimiter, authenticateToken, messageRoutes);
|
||||
app.use('/api/stories', apiLimiter, authenticateToken, storyRoutes);
|
||||
app.use('/api/friends', apiLimiter, authenticateToken, friendRoutes);
|
||||
|
||||
// Проверка здоровья
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ status: 'ok', name: 'Vortex Server' });
|
||||
});
|
||||
|
||||
// ICE серверы для WebRTC звонков
|
||||
app.get('/api/ice-servers', authenticateToken, (_req: AuthRequest, res) => {
|
||||
const iceServers: Array<{ urls: string | string[]; username?: string; credential?: string }> = [];
|
||||
|
||||
// STUN серверы
|
||||
if (config.stunUrls.length > 0) {
|
||||
iceServers.push({ urls: config.stunUrls });
|
||||
}
|
||||
|
||||
// TURN сервер с временными credentials (coturn --use-auth-secret)
|
||||
if (config.turnUrl && config.turnSecret) {
|
||||
const ttl = 24 * 3600; // 24 часа
|
||||
const timestamp = Math.floor(Date.now() / 1000) + ttl;
|
||||
const username = `${timestamp}:vortex`;
|
||||
const credential = crypto
|
||||
.createHmac('sha1', config.turnSecret)
|
||||
.update(username)
|
||||
.digest('base64');
|
||||
|
||||
iceServers.push({
|
||||
urls: config.turnUrl,
|
||||
username,
|
||||
credential,
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ iceServers });
|
||||
});
|
||||
|
||||
// Socket.io
|
||||
setupSocket(io);
|
||||
|
||||
// При старте сервера сбросить всех в offline
|
||||
prisma.user.updateMany({ data: { isOnline: false, lastSeen: new Date() } })
|
||||
.then(() => console.log(' ✔ Все пользователи сброшены в offline'))
|
||||
.catch((e: unknown) => console.error('Ошибка сброса онлайн-статусов:', e));
|
||||
|
||||
// Cleanup expired stories (every 10 minutes)
|
||||
import { deleteUploadedFile } from './shared';
|
||||
|
||||
async function cleanupExpiredStories() {
|
||||
try {
|
||||
const expired = await prisma.story.findMany({
|
||||
where: { expiresAt: { lte: new Date() } },
|
||||
select: { id: true, mediaUrl: true },
|
||||
});
|
||||
|
||||
if (expired.length === 0) return;
|
||||
|
||||
for (const story of expired) {
|
||||
if (story.mediaUrl) deleteUploadedFile(story.mediaUrl);
|
||||
}
|
||||
|
||||
const ids = expired.map(s => s.id);
|
||||
// Cascade handles StoryView deletion via schema onDelete: Cascade
|
||||
await prisma.story.deleteMany({ where: { id: { in: ids } } });
|
||||
|
||||
console.log(` 🗑 Удалено ${expired.length} истёкших историй`);
|
||||
} catch (e) {
|
||||
console.error('Story cleanup error:', e);
|
||||
}
|
||||
}
|
||||
|
||||
cleanupExpiredStories();
|
||||
setInterval(cleanupExpiredStories, 10 * 60 * 1000);
|
||||
|
||||
server.listen(config.port, () => {
|
||||
console.log(`\n ⚡ Vortex Server запущен на порту ${config.port}\n`);
|
||||
});
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
console.log('\n Завершение работы...');
|
||||
await prisma.$disconnect();
|
||||
server.close(() => {
|
||||
process.exit(0);
|
||||
});
|
||||
};
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
26
apps/server/src/middleware/auth.ts
Normal file
26
apps/server/src/middleware/auth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { config } from '../config';
|
||||
|
||||
export interface AuthRequest extends Request {
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
export function authenticateToken(req: AuthRequest, res: Response, next: NextFunction) {
|
||||
const authHeader = req.headers['authorization'];
|
||||
const token = authHeader && authHeader.split(' ')[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Требуется авторизация' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = jwt.verify(token, config.jwtSecret) as { userId: string };
|
||||
req.userId = decoded.userId;
|
||||
next();
|
||||
} catch {
|
||||
res.status(403).json({ error: 'Недействительный токен' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
169
apps/server/src/routes/auth.ts
Normal file
169
apps/server/src/routes/auth.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Router } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { prisma } from '../db';
|
||||
import { config } from '../config';
|
||||
import { USER_SELECT } from '../shared';
|
||||
import { authenticateToken, AuthRequest } from '../middleware/auth';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── Strict registration rate limiter: 3 registrations per IP per hour ───
|
||||
const registerLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000, // 1 hour
|
||||
max: 3,
|
||||
message: { error: 'Слишком много регистраций с этого IP. Попробуйте через час.' },
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
validate: false,
|
||||
keyGenerator: (req) => req.ip || req.socket.remoteAddress || 'unknown',
|
||||
});
|
||||
|
||||
// In-memory cooldown: track last registration timestamp per IP (prevents rapid-fire even within rate limit)
|
||||
const registrationCooldowns = new Map<string, number>();
|
||||
const REGISTRATION_COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes between registrations from same IP
|
||||
|
||||
// Регистрация
|
||||
router.post('/register', registerLimiter, async (req, res) => {
|
||||
try {
|
||||
const { username, displayName, password, bio } = req.body;
|
||||
|
||||
// ── IP cooldown check ──
|
||||
const clientIp = req.ip || req.socket.remoteAddress || 'unknown';
|
||||
const lastReg = registrationCooldowns.get(clientIp);
|
||||
if (lastReg && Date.now() - lastReg < REGISTRATION_COOLDOWN_MS) {
|
||||
const waitMinutes = Math.ceil((REGISTRATION_COOLDOWN_MS - (Date.now() - lastReg)) / 60000);
|
||||
res.status(429).json({ error: `Подождите ${waitMinutes} мин. перед созданием нового аккаунта` });
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Permanent IP limit (DB-level) ──
|
||||
const accountsFromIp = await prisma.user.count({ where: { registrationIp: clientIp } });
|
||||
if (accountsFromIp >= config.maxRegistrationsPerIp) {
|
||||
res.status(403).json({ error: `Максимум ${config.maxRegistrationsPerIp} аккаунта с одного IP. Лимит исчерпан.` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username и пароль обязательны' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_]{3,20}$/.test(username)) {
|
||||
res.status(400).json({ error: 'Username: 3-20 символов, только латиница, цифры, _' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < config.minPasswordLength) {
|
||||
res.status(400).json({ error: `Пароль должен быть не менее ${config.minPasswordLength} символов` });
|
||||
return;
|
||||
}
|
||||
|
||||
// Password must contain at least one letter and one digit
|
||||
if (!/[a-zA-Zа-яА-Я]/.test(password) || !/\d/.test(password)) {
|
||||
res.status(400).json({ error: 'Пароль должен содержать буквы и цифры' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate optional fields
|
||||
if (displayName !== undefined && (typeof displayName !== 'string' || displayName.length > 50)) {
|
||||
res.status(400).json({ error: 'Имя должно быть не длиннее 50 символов' });
|
||||
return;
|
||||
}
|
||||
if (bio !== undefined && (typeof bio !== 'string' || bio.length > 500)) {
|
||||
res.status(400).json({ error: 'Био должно быть не длиннее 500 символов' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username: username.toLowerCase() } });
|
||||
if (existing) {
|
||||
res.status(400).json({ error: 'Этот username уже занят' });
|
||||
return;
|
||||
}
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 10);
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: username.toLowerCase(),
|
||||
displayName: (displayName || username).slice(0, 50),
|
||||
password: hashedPassword,
|
||||
bio: bio ? bio.slice(0, 500) : null,
|
||||
registrationIp: clientIp,
|
||||
},
|
||||
select: USER_SELECT,
|
||||
});
|
||||
|
||||
const token = jwt.sign({ userId: user.id }, config.jwtSecret, { expiresIn: '30d' });
|
||||
|
||||
// Track registration for cooldown
|
||||
registrationCooldowns.set(clientIp, Date.now());
|
||||
|
||||
res.json({ token, user: { ...user, isOnline: true } });
|
||||
} catch (error) {
|
||||
console.error('Registration error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Вход
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Username и пароль обязательны' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { username: username.toLowerCase() },
|
||||
select: { ...USER_SELECT, password: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(400).json({ error: 'Неверный username или пароль' });
|
||||
return;
|
||||
}
|
||||
|
||||
const validPassword = await bcrypt.compare(password, user.password);
|
||||
if (!validPassword) {
|
||||
res.status(400).json({ error: 'Неверный username или пароль' });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { isOnline: true, lastSeen: new Date() },
|
||||
});
|
||||
|
||||
const token = jwt.sign({ userId: user.id }, config.jwtSecret, { expiresIn: '30d' });
|
||||
|
||||
const { password: _, ...userWithoutPassword } = user;
|
||||
res.json({ token, user: { ...userWithoutPassword, isOnline: true } });
|
||||
} catch (error) {
|
||||
console.error('Login error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Текущий пользователь — uses authenticateToken middleware instead of duplicating JWT parsing
|
||||
router.get('/me', authenticateToken, async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: req.userId },
|
||||
select: USER_SELECT,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'Пользователь не найден' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ user });
|
||||
} catch {
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
608
apps/server/src/routes/chats.ts
Normal file
608
apps/server/src/routes/chats.ts
Normal file
@@ -0,0 +1,608 @@
|
||||
import { Router } from 'express';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { prisma } from '../db';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { USER_SELECT, SENDER_SELECT, uploadGroupAvatar, deleteUploadedFile, encryptUploadedFile } from '../shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Compact user select for chat member lists (no bio/birthday)
|
||||
const CHAT_USER_SELECT = {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
avatar: true,
|
||||
isOnline: true,
|
||||
lastSeen: true,
|
||||
};
|
||||
|
||||
// Получить все чаты пользователя
|
||||
router.get('/', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chats = await prisma.chat.findMany({
|
||||
where: {
|
||||
members: { some: { userId: req.userId } },
|
||||
},
|
||||
include: {
|
||||
members: {
|
||||
include: { user: { select: CHAT_USER_SELECT } },
|
||||
},
|
||||
messages: {
|
||||
where: {
|
||||
isDeleted: false,
|
||||
OR: [
|
||||
{ scheduledAt: null },
|
||||
{ senderId: req.userId! },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
sender: { select: { id: true, username: true, displayName: true } },
|
||||
readBy: { select: { userId: true } },
|
||||
},
|
||||
},
|
||||
pinnedMessages: {
|
||||
orderBy: { pinnedAt: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
message: {
|
||||
include: {
|
||||
sender: { select: SENDER_SELECT },
|
||||
media: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Batch unread counts in a single query to avoid N+1
|
||||
const chatIds = chats.map(c => c.id);
|
||||
let unreadCounts: Array<{ chatId: string; count: bigint }> = [];
|
||||
if (chatIds.length > 0) {
|
||||
unreadCounts = await prisma.$queryRaw<Array<{ chatId: string; count: bigint }>>(
|
||||
Prisma.sql`SELECT m."chatId", COUNT(m.id) as count FROM "Message" m
|
||||
LEFT JOIN "ReadReceipt" rr ON rr."messageId" = m.id AND rr."userId" = ${req.userId}
|
||||
WHERE m."chatId" IN (${Prisma.join(chatIds)})
|
||||
AND m."senderId" != ${req.userId} AND m."isDeleted" = false AND rr.id IS NULL
|
||||
AND m."scheduledAt" IS NULL
|
||||
GROUP BY m."chatId"`
|
||||
).catch(() => [] as Array<{ chatId: string; count: bigint }>);
|
||||
}
|
||||
|
||||
const unreadMap = new Map(unreadCounts.map(r => [r.chatId, Number(r.count)]));
|
||||
|
||||
// Filter last message by clearedAt per user
|
||||
const chatsFiltered = chats.map((chat) => {
|
||||
const member = chat.members.find((m) => m.userId === req.userId);
|
||||
const clearedAt = member?.clearedAt;
|
||||
if (clearedAt && chat.messages.length > 0) {
|
||||
const filtered = chat.messages.filter((msg) => new Date(msg.createdAt) > new Date(clearedAt));
|
||||
return { ...chat, messages: filtered };
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
const sortedChats = chatsFiltered.sort((a, b) => {
|
||||
const aPinned = a.members.find((m) => m.userId === req.userId)?.isPinned || false;
|
||||
const bPinned = b.members.find((m) => m.userId === req.userId)?.isPinned || false;
|
||||
if (aPinned && !bPinned) return -1;
|
||||
if (!aPinned && bPinned) return 1;
|
||||
|
||||
const aDate = a.messages[0]?.createdAt || a.createdAt;
|
||||
const bDate = b.messages[0]?.createdAt || b.createdAt;
|
||||
return new Date(bDate).getTime() - new Date(aDate).getTime();
|
||||
});
|
||||
|
||||
const chatsWithUnread = sortedChats.map((chat) => ({
|
||||
...chat,
|
||||
unreadCount: unreadMap.get(chat.id) || 0,
|
||||
}));
|
||||
|
||||
res.json(chatsWithUnread);
|
||||
} catch (error) {
|
||||
console.error('Get chats error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Создать личный чат
|
||||
router.post('/personal', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { userId } = req.body;
|
||||
if (!userId) {
|
||||
res.status(400).json({ error: 'ID пользователя обязателен' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existingChat = await prisma.chat.findFirst({
|
||||
where: {
|
||||
type: 'personal',
|
||||
AND: [
|
||||
{ members: { some: { userId: req.userId } } },
|
||||
{ members: { some: { userId } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: { select: CHAT_USER_SELECT } } },
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
sender: { select: { id: true, username: true, displayName: true } },
|
||||
readBy: { select: { userId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingChat) {
|
||||
res.json({ ...existingChat, unreadCount: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const chat = await prisma.chat.create({
|
||||
data: {
|
||||
type: 'personal',
|
||||
members: {
|
||||
create: [{ userId: req.userId! }, { userId }],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: { select: CHAT_USER_SELECT } } },
|
||||
messages: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ ...chat, unreadCount: 0 });
|
||||
} catch (error) {
|
||||
console.error('Create chat error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Создать или получить чат "Избранное" (saved messages)
|
||||
router.post('/favorites', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
|
||||
// Check if favorites chat already exists
|
||||
const existing = await prisma.chat.findFirst({
|
||||
where: {
|
||||
type: 'favorites',
|
||||
members: { some: { userId } },
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: { select: CHAT_USER_SELECT } } },
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
sender: { select: { id: true, username: true, displayName: true } },
|
||||
readBy: { select: { userId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
res.json({ ...existing, unreadCount: 0 });
|
||||
return;
|
||||
}
|
||||
|
||||
const chat = await prisma.chat.create({
|
||||
data: {
|
||||
type: 'favorites',
|
||||
name: null,
|
||||
members: {
|
||||
create: [{ userId, role: 'admin' }],
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: { select: CHAT_USER_SELECT } } },
|
||||
messages: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ ...chat, unreadCount: 0 });
|
||||
} catch (error) {
|
||||
console.error('Create favorites chat error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Создать групповой чат
|
||||
router.post('/group', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { name, memberIds } = req.body;
|
||||
if (!name || !memberIds || !Array.isArray(memberIds)) {
|
||||
res.status(400).json({ error: 'Название и участники обязательны' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate group name length
|
||||
if (typeof name !== 'string' || name.trim().length === 0 || name.length > 100) {
|
||||
res.status(400).json({ error: 'Название группы должно быть от 1 до 100 символов' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Limit max members
|
||||
if (memberIds.length > 256) {
|
||||
res.status(400).json({ error: 'Максимум 256 участников в группе' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allMemberIds = [...new Set([req.userId!, ...memberIds])];
|
||||
|
||||
const chat = await prisma.chat.create({
|
||||
data: {
|
||||
type: 'group',
|
||||
name,
|
||||
members: {
|
||||
create: allMemberIds.map((uid) => ({
|
||||
userId: uid,
|
||||
role: uid === req.userId ? 'admin' : 'member',
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: { select: CHAT_USER_SELECT } } },
|
||||
messages: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ ...chat, unreadCount: 0 });
|
||||
} catch (error) {
|
||||
console.error('Create group error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить чат по ID
|
||||
router.get('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chat = await prisma.chat.findFirst({
|
||||
where: {
|
||||
id: String(req.params.id),
|
||||
members: { some: { userId: req.userId } },
|
||||
},
|
||||
include: {
|
||||
members: { include: { user: { select: CHAT_USER_SELECT } } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Чат не найден' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(chat);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить группу (только админ)
|
||||
router.put('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chatId = String(req.params.id);
|
||||
const { name } = req.body;
|
||||
|
||||
const member = await prisma.chatMember.findUnique({
|
||||
where: { chatId_userId: { chatId, userId: req.userId! } },
|
||||
});
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Только администратор может редактировать группу' });
|
||||
return;
|
||||
}
|
||||
|
||||
const chat = await prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { name },
|
||||
include: {
|
||||
members: { include: { user: { select: USER_SELECT } } },
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
sender: { select: { id: true, username: true, displayName: true } },
|
||||
readBy: { select: { userId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json(chat);
|
||||
} catch (error) {
|
||||
console.error('Update group error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузить аватар группы (только админ)
|
||||
router.post('/:id/avatar', uploadGroupAvatar.single('avatar'), encryptUploadedFile, async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chatId = String(req.params.id);
|
||||
|
||||
const member = await prisma.chatMember.findUnique({
|
||||
where: { chatId_userId: { chatId, userId: req.userId! } },
|
||||
});
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Только администратор может менять аватар группы' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Файл не загружен' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete old avatar file
|
||||
const currentChat = await prisma.chat.findUnique({ where: { id: chatId }, select: { avatar: true } });
|
||||
if (currentChat?.avatar) deleteUploadedFile(currentChat.avatar);
|
||||
|
||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||
|
||||
const chat = await prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { avatar: avatarUrl },
|
||||
include: {
|
||||
members: { include: { user: { select: USER_SELECT } } },
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
sender: { select: { id: true, username: true, displayName: true } },
|
||||
readBy: { select: { userId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json(chat);
|
||||
} catch (error) {
|
||||
console.error('Upload group avatar error:', error);
|
||||
res.status(500).json({ error: 'Ошибка загрузки аватара' });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить аватар группы (только админ)
|
||||
router.delete('/:id/avatar', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chatId = String(req.params.id);
|
||||
|
||||
const member = await prisma.chatMember.findUnique({
|
||||
where: { chatId_userId: { chatId, userId: req.userId! } },
|
||||
});
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Только администратор может менять аватар группы' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete file from disk
|
||||
const currentChat = await prisma.chat.findUnique({ where: { id: chatId }, select: { avatar: true } });
|
||||
if (currentChat?.avatar) deleteUploadedFile(currentChat.avatar);
|
||||
|
||||
const chat = await prisma.chat.update({
|
||||
where: { id: chatId },
|
||||
data: { avatar: null },
|
||||
include: {
|
||||
members: { include: { user: { select: USER_SELECT } } },
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
sender: { select: { id: true, username: true, displayName: true } },
|
||||
readBy: { select: { userId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json(chat);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка удаления аватара' });
|
||||
}
|
||||
});
|
||||
|
||||
// Добавить участников в группу (только админ)
|
||||
router.post('/:id/members', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chatId = String(req.params.id);
|
||||
const { userIds } = req.body;
|
||||
|
||||
if (!userIds || !Array.isArray(userIds) || userIds.length === 0) {
|
||||
res.status(400).json({ error: 'Необходимо указать пользователей' });
|
||||
return;
|
||||
}
|
||||
|
||||
const member = await prisma.chatMember.findUnique({
|
||||
where: { chatId_userId: { chatId, userId: req.userId! } },
|
||||
});
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Только администратор может добавлять участников' });
|
||||
return;
|
||||
}
|
||||
|
||||
const chat = await prisma.chat.findUnique({ where: { id: chatId } });
|
||||
if (!chat || chat.type !== 'group') {
|
||||
res.status(400).json({ error: 'Чат не является группой' });
|
||||
return;
|
||||
}
|
||||
|
||||
for (const uid of userIds) {
|
||||
await prisma.chatMember.upsert({
|
||||
where: { chatId_userId: { chatId, userId: uid } },
|
||||
create: { chatId, userId: uid, role: 'member' },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
const updatedChat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
members: { include: { user: { select: USER_SELECT } } },
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
sender: { select: { id: true, username: true, displayName: true } },
|
||||
readBy: { select: { userId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json(updatedChat);
|
||||
} catch (error) {
|
||||
console.error('Add members error:', error);
|
||||
res.status(500).json({ error: 'Ошибка добавления участников' });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить участника из группы (только админ)
|
||||
router.delete('/:id/members/:userId', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chatId = String(req.params.id);
|
||||
const targetUserId = String(req.params.userId);
|
||||
|
||||
const member = await prisma.chatMember.findUnique({
|
||||
where: { chatId_userId: { chatId, userId: req.userId! } },
|
||||
});
|
||||
|
||||
if (!member || member.role !== 'admin') {
|
||||
res.status(403).json({ error: 'Только администратор может удалять участников' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (targetUserId === req.userId) {
|
||||
res.status(400).json({ error: 'Нельзя удалить себя из группы' });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.chatMember.delete({
|
||||
where: { chatId_userId: { chatId, userId: targetUserId } },
|
||||
});
|
||||
|
||||
const updatedChat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: {
|
||||
members: { include: { user: { select: USER_SELECT } } },
|
||||
messages: {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 1,
|
||||
include: {
|
||||
sender: { select: { id: true, username: true, displayName: true } },
|
||||
readBy: { select: { userId: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
res.json(updatedChat);
|
||||
} catch (error) {
|
||||
console.error('Remove member error:', error);
|
||||
res.status(500).json({ error: 'Ошибка удаления участника' });
|
||||
}
|
||||
});
|
||||
|
||||
// Очистить чат для себя
|
||||
router.post('/:id/clear', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chatId = String(req.params.id);
|
||||
|
||||
await prisma.chatMember.update({
|
||||
where: { chatId_userId: { chatId, userId: req.userId! } },
|
||||
data: { clearedAt: new Date() },
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Clear chat error:', error);
|
||||
res.status(500).json({ error: 'Ошибка очистки чата' });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить чат (для текущего пользователя — выйти из чата)
|
||||
router.delete('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chatId = String(req.params.id);
|
||||
const userId = req.userId!;
|
||||
|
||||
const chat = await prisma.chat.findUnique({
|
||||
where: { id: chatId },
|
||||
include: { members: true },
|
||||
});
|
||||
|
||||
if (!chat) {
|
||||
res.status(404).json({ error: 'Чат не найден' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Membership check
|
||||
const isMember = chat.members.some(m => m.userId === userId);
|
||||
if (!isMember) {
|
||||
res.status(403).json({ error: 'Нет доступа к этому чату' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (chat.type === 'personal') {
|
||||
// For personal chats, just remove the member (soft leave) instead of destroying for both
|
||||
await prisma.chatMember.delete({
|
||||
where: { chatId_userId: { chatId, userId } },
|
||||
});
|
||||
// If both members have left, clean up the chat
|
||||
const remaining = await prisma.chatMember.count({ where: { chatId } });
|
||||
if (remaining === 0) {
|
||||
await prisma.chat.delete({ where: { id: chatId } });
|
||||
}
|
||||
} else if (chat.members.length <= 1) {
|
||||
// Last member — delete the group entirely
|
||||
await prisma.chat.delete({ where: { id: chatId } });
|
||||
} else {
|
||||
// For groups, just remove the member
|
||||
await prisma.chatMember.delete({
|
||||
where: { chatId_userId: { chatId, userId } },
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Delete chat error:', error);
|
||||
res.status(500).json({ error: 'Ошибка удаления чата' });
|
||||
}
|
||||
});
|
||||
|
||||
// Закрепить / открепить чат
|
||||
router.post('/:id/pin', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chatId = String(req.params.id);
|
||||
const userId = req.userId!;
|
||||
|
||||
const member = await prisma.chatMember.findUnique({
|
||||
where: { chatId_userId: { chatId, userId } },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
res.status(404).json({ error: 'Чат не найден' });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.chatMember.update({
|
||||
where: { chatId_userId: { chatId, userId } },
|
||||
data: { isPinned: !member.isPinned },
|
||||
});
|
||||
|
||||
res.json({ isPinned: !member.isPinned });
|
||||
} catch (error) {
|
||||
console.error('Pin chat error:', error);
|
||||
res.status(500).json({ error: 'Ошибка закрепления чата' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
258
apps/server/src/routes/friends.ts
Normal file
258
apps/server/src/routes/friends.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../db';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { USER_SELECT } from '../shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// ─── Get accepted friends list ───────────────────────────────────────
|
||||
router.get('/', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
|
||||
const friendships = await prisma.friendship.findMany({
|
||||
where: {
|
||||
status: 'accepted',
|
||||
OR: [{ userId }, { friendId: userId }],
|
||||
},
|
||||
include: {
|
||||
user: { select: USER_SELECT },
|
||||
friend: { select: USER_SELECT },
|
||||
},
|
||||
});
|
||||
|
||||
const friends = friendships.map(f => ({
|
||||
...(f.userId === userId ? f.friend : f.user),
|
||||
friendshipId: f.id,
|
||||
}));
|
||||
|
||||
res.json(friends);
|
||||
} catch (error) {
|
||||
console.error('Get friends error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения друзей' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Get incoming friend requests ────────────────────────────────────
|
||||
router.get('/requests', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
|
||||
const requests = await prisma.friendship.findMany({
|
||||
where: { friendId: userId, status: 'pending' },
|
||||
include: {
|
||||
user: { select: USER_SELECT },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
res.json(requests.map(r => ({ id: r.id, user: r.user, createdAt: r.createdAt })));
|
||||
} catch (error) {
|
||||
console.error('Get friend requests error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения заявок' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Get outgoing friend requests ────────────────────────────────────
|
||||
router.get('/outgoing', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
|
||||
const requests = await prisma.friendship.findMany({
|
||||
where: { userId, status: 'pending' },
|
||||
include: {
|
||||
friend: { select: USER_SELECT },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
res.json(requests.map(r => ({ id: r.id, user: r.friend, createdAt: r.createdAt })));
|
||||
} catch (error) {
|
||||
console.error('Get outgoing requests error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения заявок' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Get friendship status with a user ───────────────────────────────
|
||||
router.get('/status/:userId', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const targetId = String(req.params.userId);
|
||||
|
||||
if (userId === targetId) {
|
||||
res.json({ status: 'self' });
|
||||
return;
|
||||
}
|
||||
|
||||
const friendship = await prisma.friendship.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId, friendId: targetId },
|
||||
{ userId: targetId, friendId: userId },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (!friendship) {
|
||||
res.json({ status: 'none', friendshipId: null });
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine who sent the request to show correct action
|
||||
const direction = friendship.userId === userId ? 'outgoing' : 'incoming';
|
||||
res.json({ status: friendship.status, friendshipId: friendship.id, direction });
|
||||
} catch (error) {
|
||||
console.error('Get friend status error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения статуса' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Send friend request ─────────────────────────────────────────────
|
||||
router.post('/request', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const { friendId } = req.body;
|
||||
|
||||
if (!friendId || typeof friendId !== 'string') {
|
||||
res.status(400).json({ error: 'ID пользователя обязателен' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (userId === friendId) {
|
||||
res.status(400).json({ error: 'Нельзя добавить себя в друзья' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if target user exists
|
||||
const targetUser = await prisma.user.findUnique({ where: { id: friendId } });
|
||||
if (!targetUser) {
|
||||
res.status(404).json({ error: 'Пользователь не найден' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for existing friendship in either direction
|
||||
const existing = await prisma.friendship.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ userId, friendId },
|
||||
{ userId: friendId, friendId: userId },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (existing.status === 'accepted') {
|
||||
res.status(400).json({ error: 'Уже в друзьях' });
|
||||
return;
|
||||
}
|
||||
if (existing.status === 'pending') {
|
||||
// If they already sent us a request, auto-accept
|
||||
if (existing.userId === friendId) {
|
||||
const updated = await prisma.friendship.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: 'accepted' },
|
||||
include: { user: { select: USER_SELECT }, friend: { select: USER_SELECT } },
|
||||
});
|
||||
res.json({ status: 'accepted', friendship: updated });
|
||||
return;
|
||||
}
|
||||
res.status(400).json({ error: 'Заявка уже отправлена' });
|
||||
return;
|
||||
}
|
||||
if (existing.status === 'declined') {
|
||||
// Allow re-sending if previously declined — keep existing direction to avoid @@unique conflict
|
||||
const updated = await prisma.friendship.update({
|
||||
where: { id: existing.id },
|
||||
data: { status: 'pending' },
|
||||
});
|
||||
res.json({ status: 'pending', friendship: updated });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const friendship = await prisma.friendship.create({
|
||||
data: { userId, friendId },
|
||||
include: { user: { select: USER_SELECT }, friend: { select: USER_SELECT } },
|
||||
});
|
||||
|
||||
res.json({ status: 'pending', friendship });
|
||||
} catch (error) {
|
||||
console.error('Send friend request error:', error);
|
||||
res.status(500).json({ error: 'Ошибка отправки заявки' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Accept friend request ───────────────────────────────────────────
|
||||
router.post('/:id/accept', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const friendshipId = String(req.params.id);
|
||||
|
||||
const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId } });
|
||||
|
||||
if (!friendship || friendship.friendId !== userId || friendship.status !== 'pending') {
|
||||
res.status(404).json({ error: 'Заявка не найдена' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await prisma.friendship.update({
|
||||
where: { id: friendshipId },
|
||||
data: { status: 'accepted' },
|
||||
include: { user: { select: USER_SELECT }, friend: { select: USER_SELECT } },
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Accept friend request error:', error);
|
||||
res.status(500).json({ error: 'Ошибка принятия заявки' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Decline friend request ──────────────────────────────────────────
|
||||
router.post('/:id/decline', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const friendshipId = String(req.params.id);
|
||||
|
||||
const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId } });
|
||||
|
||||
if (!friendship || friendship.friendId !== userId || friendship.status !== 'pending') {
|
||||
res.status(404).json({ error: 'Заявка не найдена' });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.friendship.update({
|
||||
where: { id: friendshipId },
|
||||
data: { status: 'declined' },
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Decline friend request error:', error);
|
||||
res.status(500).json({ error: 'Ошибка отклонения заявки' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Remove friend ───────────────────────────────────────────────────
|
||||
router.delete('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const friendshipId = String(req.params.id);
|
||||
|
||||
const friendship = await prisma.friendship.findUnique({ where: { id: friendshipId } });
|
||||
|
||||
if (!friendship || (friendship.userId !== userId && friendship.friendId !== userId)) {
|
||||
res.status(404).json({ error: 'Дружба не найдена' });
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.friendship.delete({ where: { id: friendshipId } });
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('Remove friend error:', error);
|
||||
res.status(500).json({ error: 'Ошибка удаления друга' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
221
apps/server/src/routes/messages.ts
Normal file
221
apps/server/src/routes/messages.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../db';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { SENDER_SELECT, MESSAGE_INCLUDE, uploadFile, deleteUploadedFile, encryptUploadedFile } from '../shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Получить сообщения чата
|
||||
router.get('/chat/:chatId', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chatId = String(req.params.chatId);
|
||||
const { cursor, limit = '50' } = req.query;
|
||||
const take = Math.min(Math.max(1, parseInt(limit as string) || 50), 200);
|
||||
|
||||
const member = await prisma.chatMember.findUnique({
|
||||
where: { chatId_userId: { chatId, userId: req.userId! } },
|
||||
});
|
||||
|
||||
if (!member) {
|
||||
res.status(403).json({ error: 'Нет доступа к этому чату' });
|
||||
return;
|
||||
}
|
||||
|
||||
const createdAtFilter: Record<string, Date> = {};
|
||||
if (cursor) createdAtFilter.lt = new Date(cursor as string);
|
||||
if (member.clearedAt) createdAtFilter.gt = member.clearedAt;
|
||||
|
||||
const messages = await prisma.message.findMany({
|
||||
where: {
|
||||
chatId,
|
||||
isDeleted: false,
|
||||
hiddenBy: { none: { userId: req.userId! } },
|
||||
// Scheduled messages: only visible to the sender until delivered
|
||||
OR: [
|
||||
{ scheduledAt: null },
|
||||
{ senderId: req.userId! },
|
||||
],
|
||||
...(Object.keys(createdAtFilter).length > 0 ? { createdAt: createdAtFilter } : {}),
|
||||
},
|
||||
include: MESSAGE_INCLUDE,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take,
|
||||
});
|
||||
|
||||
res.json(messages.reverse());
|
||||
} catch (error) {
|
||||
console.error('Get messages error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузка файла
|
||||
router.post('/upload', uploadFile.single('file'), encryptUploadedFile, async (req: AuthRequest, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Файл не загружен' });
|
||||
return;
|
||||
}
|
||||
|
||||
const fileUrl = `/uploads/${req.file.filename}`;
|
||||
// multer decodes multipart filenames as latin1 — re-decode as UTF-8
|
||||
const originalName = Buffer.from(req.file.originalname, 'latin1').toString('utf8');
|
||||
|
||||
res.json({
|
||||
url: fileUrl,
|
||||
filename: originalName,
|
||||
size: req.file.size,
|
||||
mimetype: req.file.mimetype,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Upload error:', error);
|
||||
res.status(500).json({ error: 'Ошибка загрузки' });
|
||||
}
|
||||
});
|
||||
|
||||
// Редактировать сообщение
|
||||
router.put('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { content } = req.body;
|
||||
const id = String(req.params.id);
|
||||
|
||||
if (!content || typeof content !== 'string' || content.length > 10000) {
|
||||
res.status(400).json({ error: 'Содержимое обязательно и не должно превышать 10000 символов' });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = await prisma.message.findUnique({ where: { id } });
|
||||
if (!message || message.senderId !== req.userId) {
|
||||
res.status(403).json({ error: 'Нет прав для редактирования' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await prisma.message.update({
|
||||
where: { id },
|
||||
data: { content, isEdited: true },
|
||||
include: MESSAGE_INCLUDE,
|
||||
});
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить сообщение
|
||||
router.delete('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const id = String(req.params.id);
|
||||
|
||||
const message = await prisma.message.findUnique({
|
||||
where: { id },
|
||||
include: { media: true },
|
||||
});
|
||||
if (!message || message.senderId !== req.userId) {
|
||||
res.status(403).json({ error: 'Нет прав для удаления' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete media files from disk
|
||||
if (message.media && message.media.length > 0) {
|
||||
for (const m of message.media) {
|
||||
if (m.url) deleteUploadedFile(m.url);
|
||||
}
|
||||
await prisma.media.deleteMany({ where: { messageId: id } });
|
||||
}
|
||||
|
||||
await prisma.message.update({
|
||||
where: { id },
|
||||
data: { isDeleted: true, content: null },
|
||||
});
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Получить общие медиа/файлы/ссылки чата
|
||||
router.get('/chat/:chatId/shared', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const chatId = String(req.params.chatId);
|
||||
const { type } = req.query; // 'media' | 'files' | 'links'
|
||||
|
||||
// Check membership
|
||||
const member = await prisma.chatMember.findUnique({
|
||||
where: { chatId_userId: { chatId, userId: req.userId! } },
|
||||
});
|
||||
if (!member) {
|
||||
res.status(403).json({ error: 'Нет доступа' });
|
||||
return;
|
||||
}
|
||||
|
||||
const baseWhere: Prisma.MessageWhereInput = {
|
||||
chatId,
|
||||
isDeleted: false,
|
||||
hiddenBy: { none: { userId: req.userId! } },
|
||||
...(member.clearedAt ? { createdAt: { gt: member.clearedAt } } : {}),
|
||||
};
|
||||
|
||||
if (type === 'media') {
|
||||
// Images and videos
|
||||
const messages = await prisma.message.findMany({
|
||||
where: {
|
||||
...baseWhere,
|
||||
media: { some: { type: { in: ['image', 'video'] } } },
|
||||
},
|
||||
include: {
|
||||
media: { where: { type: { in: ['image', 'video'] } } },
|
||||
sender: { select: SENDER_SELECT },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
res.json(messages);
|
||||
} else if (type === 'files') {
|
||||
// Files (documents, archives, audio, etc.)
|
||||
const messages = await prisma.message.findMany({
|
||||
where: {
|
||||
...baseWhere,
|
||||
media: { some: { type: { notIn: ['image', 'video'] } } },
|
||||
},
|
||||
include: {
|
||||
media: { where: { type: { notIn: ['image', 'video'] } } },
|
||||
sender: { select: SENDER_SELECT },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
res.json(messages);
|
||||
} else if (type === 'links') {
|
||||
// Messages containing URLs
|
||||
const messages = await prisma.message.findMany({
|
||||
where: {
|
||||
...baseWhere,
|
||||
content: { contains: 'http' },
|
||||
},
|
||||
include: {
|
||||
sender: { select: SENDER_SELECT },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
// Filter to only messages with actual URLs
|
||||
const withLinks = messages
|
||||
.filter((m) => m.content && /https?:\/\/[^\s]+/i.test(m.content))
|
||||
.map((m) => {
|
||||
const links = m.content!.match(/https?:\/\/[^\s]+/gi) || [];
|
||||
return { ...m, links };
|
||||
});
|
||||
res.json(withLinks);
|
||||
} else {
|
||||
res.status(400).json({ error: 'Invalid type. Use: media, files, or links' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Shared media error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
244
apps/server/src/routes/stories.ts
Normal file
244
apps/server/src/routes/stories.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../db';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { deleteUploadedFile } from '../shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Get all active stories (grouped by user)
|
||||
router.get('/', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const now = new Date();
|
||||
|
||||
// Get accepted friends
|
||||
const friendships = await prisma.friendship.findMany({
|
||||
where: {
|
||||
status: 'accepted',
|
||||
OR: [{ userId }, { friendId: userId }],
|
||||
},
|
||||
select: { userId: true, friendId: true },
|
||||
});
|
||||
|
||||
const friendIds = friendships.map(f =>
|
||||
f.userId === userId ? f.friendId : f.userId,
|
||||
);
|
||||
// Include own userId to see own stories
|
||||
friendIds.push(userId);
|
||||
|
||||
const stories = await prisma.story.findMany({
|
||||
where: {
|
||||
userId: { in: friendIds },
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true, displayName: true, avatar: true },
|
||||
},
|
||||
views: {
|
||||
select: { userId: true },
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
});
|
||||
|
||||
// Group by user
|
||||
interface StoryItem {
|
||||
id: string;
|
||||
type: string;
|
||||
mediaUrl: string | null;
|
||||
content: string | null;
|
||||
bgColor: string | null;
|
||||
createdAt: Date;
|
||||
expiresAt: Date;
|
||||
viewCount: number;
|
||||
viewed: boolean;
|
||||
}
|
||||
interface StoryGroupResult {
|
||||
user: typeof stories[number]['user'];
|
||||
stories: StoryItem[];
|
||||
hasUnviewed: boolean;
|
||||
}
|
||||
const grouped: Record<string, StoryGroupResult> = {};
|
||||
for (const story of stories) {
|
||||
if (!grouped[story.userId]) {
|
||||
grouped[story.userId] = {
|
||||
user: story.user,
|
||||
stories: [],
|
||||
hasUnviewed: false,
|
||||
};
|
||||
}
|
||||
const viewed = story.views.some(v => v.userId === userId);
|
||||
grouped[story.userId].stories.push({
|
||||
id: story.id,
|
||||
type: story.type,
|
||||
mediaUrl: story.mediaUrl,
|
||||
content: story.content,
|
||||
bgColor: story.bgColor,
|
||||
createdAt: story.createdAt,
|
||||
expiresAt: story.expiresAt,
|
||||
viewCount: story.views.length,
|
||||
viewed,
|
||||
});
|
||||
if (!viewed && story.userId !== userId) {
|
||||
grouped[story.userId].hasUnviewed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Own stories first, then unviewed, then viewed
|
||||
const result = Object.values(grouped).sort((a, b) => {
|
||||
if (a.user.id === userId) return -1;
|
||||
if (b.user.id === userId) return 1;
|
||||
if (a.hasUnviewed && !b.hasUnviewed) return -1;
|
||||
if (!a.hasUnviewed && b.hasUnviewed) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
} catch (error) {
|
||||
console.error('Get stories error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения историй' });
|
||||
}
|
||||
});
|
||||
|
||||
// Create a story
|
||||
router.post('/', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const { type, mediaUrl, content, bgColor } = req.body;
|
||||
|
||||
// Validate mediaUrl to prevent path traversal
|
||||
if (mediaUrl) {
|
||||
if (typeof mediaUrl !== 'string' || !mediaUrl.startsWith('/uploads/') || mediaUrl.includes('..')) {
|
||||
res.status(400).json({ error: 'Недопустимый URL медиафайла' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const story = await prisma.story.create({
|
||||
data: {
|
||||
userId,
|
||||
type: type || 'text',
|
||||
mediaUrl,
|
||||
content,
|
||||
bgColor: bgColor || '#6366f1',
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true, displayName: true, avatar: true },
|
||||
},
|
||||
views: true,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(story);
|
||||
} catch (error) {
|
||||
console.error('Create story error:', error);
|
||||
res.status(500).json({ error: 'Ошибка создания истории' });
|
||||
}
|
||||
});
|
||||
|
||||
// View a story
|
||||
router.post('/:storyId/view', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const storyId = req.params.storyId as string;
|
||||
|
||||
// Verify story exists and viewer is the owner or a friend
|
||||
const story = await prisma.story.findUnique({ where: { id: storyId }, select: { userId: true } });
|
||||
if (!story) {
|
||||
res.status(404).json({ error: 'История не найдена' });
|
||||
return;
|
||||
}
|
||||
if (story.userId !== userId) {
|
||||
const friendship = await prisma.friendship.findFirst({
|
||||
where: {
|
||||
status: 'accepted',
|
||||
OR: [
|
||||
{ userId, friendId: story.userId },
|
||||
{ userId: story.userId, friendId: userId },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (!friendship) {
|
||||
res.status(403).json({ error: 'Нет доступа' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.storyView.upsert({
|
||||
where: { storyId_userId: { storyId, userId } },
|
||||
create: { storyId, userId },
|
||||
update: {},
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('View story error:', error);
|
||||
res.status(500).json({ error: 'Ошибка просмотра истории' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get story viewers
|
||||
router.get('/:storyId/viewers', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const storyId = req.params.storyId as string;
|
||||
|
||||
const story = await prisma.story.findUnique({ where: { id: storyId }, select: { userId: true } });
|
||||
if (!story || story.userId !== userId) {
|
||||
res.status(403).json({ error: 'Только автор может просматривать аудиторию' });
|
||||
return;
|
||||
}
|
||||
|
||||
const views = await prisma.storyView.findMany({
|
||||
where: {
|
||||
storyId,
|
||||
user: { hideStoryViews: false },
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true, displayName: true, avatar: true },
|
||||
},
|
||||
},
|
||||
orderBy: { viewedAt: 'desc' },
|
||||
});
|
||||
|
||||
res.json(views.map(v => ({
|
||||
userId: v.userId,
|
||||
username: v.user.username,
|
||||
displayName: v.user.displayName,
|
||||
avatar: v.user.avatar,
|
||||
viewedAt: v.viewedAt,
|
||||
})));
|
||||
} catch (error) {
|
||||
console.error('Get story viewers error:', error);
|
||||
res.status(500).json({ error: 'Ошибка получения просмотров' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete own story
|
||||
router.delete('/:storyId', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const userId = req.userId!;
|
||||
const storyId = req.params.storyId as string;
|
||||
|
||||
const story = await prisma.story.findUnique({ where: { id: storyId } });
|
||||
if (!story || story.userId !== userId) {
|
||||
res.status(403).json({ error: 'Нет прав' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete media file if present
|
||||
if (story.mediaUrl) deleteUploadedFile(story.mediaUrl);
|
||||
|
||||
await prisma.story.delete({ where: { id: storyId } });
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
console.error('Delete story error:', error);
|
||||
res.status(500).json({ error: 'Ошибка удаления истории' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
233
apps/server/src/routes/users.ts
Normal file
233
apps/server/src/routes/users.ts
Normal file
@@ -0,0 +1,233 @@
|
||||
import { Router } from 'express';
|
||||
import { prisma } from '../db';
|
||||
import { AuthRequest } from '../middleware/auth';
|
||||
import { USER_SELECT, SENDER_SELECT, uploadUserAvatar, deleteUploadedFile, encryptUploadedFile } from '../shared';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Поиск пользователей
|
||||
router.get('/search', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { q } = req.query;
|
||||
if (!q || typeof q !== 'string' || q.trim().length < 3) {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ username: { contains: q } },
|
||||
{ displayName: { contains: q } },
|
||||
],
|
||||
NOT: { id: req.userId },
|
||||
},
|
||||
select: USER_SELECT,
|
||||
take: 20,
|
||||
});
|
||||
|
||||
res.json(users);
|
||||
} catch (error) {
|
||||
console.error('Search users error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Профиль пользователя
|
||||
router.get('/:id', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: String(req.params.id) },
|
||||
select: USER_SELECT,
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'Пользователь не найден' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Загрузить аватар
|
||||
router.post('/avatar', uploadUserAvatar.single('avatar'), encryptUploadedFile, async (req: AuthRequest, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
res.status(400).json({ error: 'Файл не загружен' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete old avatar file if exists
|
||||
const currentUser = await prisma.user.findUnique({ where: { id: req.userId }, select: { avatar: true } });
|
||||
if (currentUser?.avatar) deleteUploadedFile(currentUser.avatar);
|
||||
|
||||
const avatarUrl = `/uploads/avatars/${req.file.filename}`;
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: { avatar: avatarUrl },
|
||||
select: USER_SELECT,
|
||||
});
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка загрузки аватара' });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить аватар
|
||||
router.delete('/avatar', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
// Delete file from disk
|
||||
const currentUser = await prisma.user.findUnique({ where: { id: req.userId }, select: { avatar: true } });
|
||||
if (currentUser?.avatar) deleteUploadedFile(currentUser.avatar);
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: { avatar: null },
|
||||
select: USER_SELECT,
|
||||
});
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка удаления аватара' });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить профиль (username НЕ меняется!)
|
||||
router.put('/profile', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { displayName, bio, birthday } = req.body;
|
||||
|
||||
// Validate field lengths
|
||||
if (displayName !== undefined && (typeof displayName !== 'string' || displayName.length === 0 || displayName.length > 50)) {
|
||||
res.status(400).json({ error: 'Имя должно быть от 1 до 50 символов' });
|
||||
return;
|
||||
}
|
||||
if (bio !== undefined && bio !== null && (typeof bio !== 'string' || bio.length > 500)) {
|
||||
res.status(400).json({ error: 'Био должно быть не длиннее 500 символов' });
|
||||
return;
|
||||
}
|
||||
if (birthday !== undefined && birthday !== null) {
|
||||
if (typeof birthday !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(birthday) || isNaN(Date.parse(birthday))) {
|
||||
res.status(400).json({ error: 'Некорректный формат даты рождения (YYYY-MM-DD)' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updateData: Record<string, string | null> = {};
|
||||
if (displayName !== undefined) updateData.displayName = displayName;
|
||||
if (bio !== undefined) updateData.bio = bio;
|
||||
if (birthday !== undefined) updateData.birthday = birthday;
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: updateData,
|
||||
select: USER_SELECT,
|
||||
});
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Поиск сообщений
|
||||
router.get('/messages/search', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { q, chatId } = req.query;
|
||||
if (!q || typeof q !== 'string') {
|
||||
res.json([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const where: Record<string, unknown> = {
|
||||
content: { contains: q },
|
||||
isDeleted: false,
|
||||
};
|
||||
|
||||
if (chatId) {
|
||||
where.chatId = chatId;
|
||||
const member = await prisma.chatMember.findUnique({
|
||||
where: { chatId_userId: { chatId: chatId as string, userId: req.userId! } },
|
||||
});
|
||||
if (member?.clearedAt) {
|
||||
where.createdAt = { gt: member.clearedAt };
|
||||
}
|
||||
} else {
|
||||
where.chat = {
|
||||
members: { some: { userId: req.userId } },
|
||||
};
|
||||
}
|
||||
|
||||
const messages = await prisma.message.findMany({
|
||||
where,
|
||||
include: {
|
||||
sender: { select: SENDER_SELECT },
|
||||
chat: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
type: true,
|
||||
members: {
|
||||
include: {
|
||||
user: { select: { id: true, username: true, displayName: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 50,
|
||||
});
|
||||
|
||||
// For global search (no chatId filter), filter out messages before clearedAt per chat
|
||||
let filtered = messages;
|
||||
if (!chatId) {
|
||||
const memberships = await prisma.chatMember.findMany({
|
||||
where: { userId: req.userId! },
|
||||
select: { chatId: true, clearedAt: true },
|
||||
});
|
||||
const clearedMap = new Map<string, Date>();
|
||||
for (const m of memberships) {
|
||||
if (m.clearedAt) clearedMap.set(m.chatId, m.clearedAt);
|
||||
}
|
||||
if (clearedMap.size > 0) {
|
||||
filtered = messages.filter((msg) => {
|
||||
const cleared = clearedMap.get(msg.chatId);
|
||||
if (!cleared) return true;
|
||||
return new Date(msg.createdAt) > new Date(cleared);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.json(filtered);
|
||||
} catch (error) {
|
||||
console.error('Search messages error:', error);
|
||||
res.status(500).json({ error: 'Ошибка сервера' });
|
||||
}
|
||||
});
|
||||
|
||||
// Обновить настройки приватности
|
||||
router.put('/settings', async (req: AuthRequest, res) => {
|
||||
try {
|
||||
const { hideStoryViews } = req.body;
|
||||
|
||||
const updateData: Record<string, boolean> = {};
|
||||
if (typeof hideStoryViews === 'boolean') updateData.hideStoryViews = hideStoryViews;
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: req.userId },
|
||||
data: updateData,
|
||||
select: USER_SELECT,
|
||||
});
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: 'Ошибка сохранения настроек' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
181
apps/server/src/shared.ts
Normal file
181
apps/server/src/shared.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import multer from 'multer';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { encryptFileInPlace, isEncryptionEnabled } from './encrypt';
|
||||
|
||||
// ─── Prisma select objects ────────────────────────────────────────────
|
||||
|
||||
/** Standard user fields to include in API responses (excludes password) */
|
||||
export const USER_SELECT = {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
avatar: true,
|
||||
bio: true,
|
||||
birthday: true,
|
||||
isOnline: true,
|
||||
lastSeen: true,
|
||||
createdAt: true,
|
||||
hideStoryViews: true,
|
||||
} as const;
|
||||
|
||||
/** Compact user fields for message sender / forwarded-from */
|
||||
export const SENDER_SELECT = {
|
||||
id: true,
|
||||
username: true,
|
||||
displayName: true,
|
||||
avatar: true,
|
||||
} as const;
|
||||
|
||||
/** Full message include for API responses */
|
||||
export const MESSAGE_INCLUDE = {
|
||||
sender: { select: SENDER_SELECT },
|
||||
forwardedFrom: { select: SENDER_SELECT },
|
||||
replyTo: {
|
||||
include: { sender: { select: { id: true, username: true, displayName: true } } },
|
||||
},
|
||||
media: true,
|
||||
reactions: {
|
||||
include: { user: { select: { id: true, username: true, displayName: true } } },
|
||||
},
|
||||
readBy: { select: { userId: true } },
|
||||
} as const;
|
||||
|
||||
// ─── File system helpers ──────────────────────────────────────────────
|
||||
|
||||
const uploadsRoot = path.join(__dirname, '../uploads');
|
||||
|
||||
/** Ensure a directory exists (recursive). */
|
||||
export function ensureDir(dirPath: string): void {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
/** Safely delete a file from the uploads directory given its URL path (e.g. '/uploads/avatars/abc.jpg'). */
|
||||
export function deleteUploadedFile(urlPath: string): void {
|
||||
if (!urlPath) return;
|
||||
try {
|
||||
const filename = urlPath.replace(/^\/uploads\//, '');
|
||||
const filePath = path.resolve(uploadsRoot, filename);
|
||||
|
||||
// Path containment check — prevent directory traversal
|
||||
if (!filePath.startsWith(uploadsRoot)) {
|
||||
console.error('Path traversal attempt blocked:', urlPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to delete file:', urlPath, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Multer configurations ───────────────────────────────────────────
|
||||
|
||||
const avatarsDir = path.join(uploadsRoot, 'avatars');
|
||||
ensureDir(avatarsDir);
|
||||
ensureDir(uploadsRoot);
|
||||
|
||||
/** Allowed image extensions for avatars. */
|
||||
const ALLOWED_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif']);
|
||||
|
||||
function createAvatarStorage(prefix = '') {
|
||||
return multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, avatarsDir),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `${prefix}${uuidv4()}${ext}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Multer middleware for user avatar uploads (max 5MB, images only). */
|
||||
export const uploadUserAvatar = multer({
|
||||
storage: createAvatarStorage(''),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (file.mimetype.startsWith('image/') && ALLOWED_IMAGE_EXTENSIONS.has(ext)) cb(null, true);
|
||||
else cb(new Error('Только изображения (jpg, png, gif, webp, avif)'));
|
||||
},
|
||||
});
|
||||
|
||||
/** Multer middleware for group avatar uploads (max 5MB, images only). */
|
||||
export const uploadGroupAvatar = multer({
|
||||
storage: createAvatarStorage('group-'),
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (file.mimetype.startsWith('image/') && ALLOWED_IMAGE_EXTENSIONS.has(ext)) cb(null, true);
|
||||
else cb(new Error('Только изображения (jpg, png, gif, webp, avif)'));
|
||||
},
|
||||
});
|
||||
|
||||
/** Blocked file extensions that could be served as executable content. */
|
||||
const BLOCKED_EXTENSIONS = new Set([
|
||||
'.html', '.htm', '.svg', '.xml', '.xhtml',
|
||||
'.php', '.jsp', '.asp', '.aspx', '.cgi',
|
||||
'.exe', '.bat', '.cmd', '.com', '.msi', '.scr', '.pif',
|
||||
'.sh', '.bash', '.ps1', '.psm1', '.vbs', '.vbe', '.js', '.jse', '.wsf', '.wsh',
|
||||
'.dll', '.sys', '.drv',
|
||||
'.hta', '.cpl', '.inf', '.reg',
|
||||
]);
|
||||
|
||||
/** Multer middleware for general file uploads (max 50MB). */
|
||||
export const uploadFile = multer({
|
||||
storage: multer.diskStorage({
|
||||
destination: (_req, _file, cb) => cb(null, uploadsRoot),
|
||||
filename: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
cb(null, `${uuidv4()}${ext}`);
|
||||
},
|
||||
}),
|
||||
limits: { fileSize: 50 * 1024 * 1024 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (BLOCKED_EXTENSIONS.has(ext)) {
|
||||
cb(new Error('Этот тип файла не разрешён'));
|
||||
} else {
|
||||
cb(null, true);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// ─── Post-upload file encryption middleware ───────────────────────────
|
||||
|
||||
/**
|
||||
* Express middleware that encrypts an uploaded file in-place after multer
|
||||
* has written it to disk. Use after any multer middleware.
|
||||
*/
|
||||
export function encryptUploadedFile(req: Request, _res: Response, next: NextFunction): void {
|
||||
if (!isEncryptionEnabled()) return next();
|
||||
|
||||
try {
|
||||
// Single file upload (req.file)
|
||||
if (req.file) {
|
||||
encryptFileInPlace(req.file.path);
|
||||
}
|
||||
// Multiple files (req.files) — handle both array and field-keyed forms
|
||||
if (req.files) {
|
||||
const files = Array.isArray(req.files)
|
||||
? req.files
|
||||
: Object.values(req.files).flat();
|
||||
for (const file of files) {
|
||||
encryptFileInPlace(file.path);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('File encryption error:', e);
|
||||
// Don't block the request — file is already saved, just unencrypted
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
/** Absolute path to the uploads root directory. */
|
||||
export const UPLOADS_ROOT = uploadsRoot;
|
||||
1119
apps/server/src/socket/index.ts
Normal file
1119
apps/server/src/socket/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
18
apps/server/tsconfig.json
Normal file
18
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
17
apps/web/index.html
Normal file
17
apps/web/index.html
Normal file
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vortex.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#09090b" />
|
||||
<title>Vortex Messenger</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
apps/web/package.json
Normal file
34
apps/web/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "@vortex/web",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emoji-mart/data": "^1.2.1",
|
||||
"@emoji-mart/react": "^1.1.1",
|
||||
"@tanstack/react-query": "^5.62.0",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"emoji-mart": "^5.6.0",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"socket.io-client": "^4.8.1",
|
||||
"zustand": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"typescript": "^5.7.3",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
BIN
apps/web/public/logo.jpg
Normal file
BIN
apps/web/public/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
BIN
apps/web/public/logo.png
Normal file
BIN
apps/web/public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.7 MiB |
BIN
apps/web/public/sounds/abonent_nedostupen.mp3
Normal file
BIN
apps/web/public/sounds/abonent_nedostupen.mp3
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/call_sound.mp3
Normal file
BIN
apps/web/public/sounds/call_sound.mp3
Normal file
Binary file not shown.
10
apps/web/public/vortex.svg
Normal file
10
apps/web/public/vortex.svg
Normal file
@@ -0,0 +1,10 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="8" fill="url(#g1)" />
|
||||
<path d="M16 6C11 6 8 9 9 14C10 19 14 21 16 26C18 21 22 19 23 14C24 9 21 6 16 6Z" fill="white" fill-opacity="0.9" />
|
||||
<defs>
|
||||
<linearGradient id="g1" x1="0" y1="0" x2="32" y2="32" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6366f1" />
|
||||
<stop offset="1" stop-color="#8b5cf6" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 493 B |
50
apps/web/src/App.tsx
Normal file
50
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AnimatePresence } from 'framer-motion';
|
||||
import { useAuthStore } from './stores/authStore';
|
||||
import AuthPage from './pages/AuthPage';
|
||||
import ChatPage from './pages/ChatPage';
|
||||
|
||||
export default function App() {
|
||||
const { token, user, checkAuth, isLoading } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center bg-surface">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<VortexLoader />
|
||||
<p className="text-zinc-500 text-sm">Загрузка...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AnimatePresence mode="wait">
|
||||
{token && user ? (
|
||||
<ChatPage key="chat" />
|
||||
) : (
|
||||
<AuthPage key="auth" />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
|
||||
function VortexLoader() {
|
||||
return (
|
||||
<div className="relative w-12 h-12">
|
||||
<div className="absolute inset-0 rounded-full border-2 border-transparent border-t-vortex-500 animate-spin" />
|
||||
<div
|
||||
className="absolute inset-1 rounded-full border-2 border-transparent border-t-vortex-400 animate-spin"
|
||||
style={{ animationDuration: '0.8s', animationDirection: 'reverse' }}
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-2 rounded-full border-2 border-transparent border-t-vortex-300 animate-spin"
|
||||
style={{ animationDuration: '0.6s' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
60
apps/web/src/components/Avatar.tsx
Normal file
60
apps/web/src/components/Avatar.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { memo } from 'react';
|
||||
import { getInitials, generateAvatarColor } from '../lib/utils';
|
||||
|
||||
interface AvatarProps {
|
||||
src?: string | null;
|
||||
name: string;
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||
className?: string;
|
||||
online?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
xs: 'w-6 h-6 text-[10px]',
|
||||
sm: 'w-8 h-8 text-xs',
|
||||
md: 'w-10 h-10 text-sm',
|
||||
lg: 'w-12 h-12 text-base',
|
||||
xl: 'w-20 h-20 text-xl',
|
||||
} as const;
|
||||
|
||||
const onlineDotSize = {
|
||||
xs: 'w-1.5 h-1.5 border',
|
||||
sm: 'w-2 h-2 border',
|
||||
md: 'w-2.5 h-2.5 border-2',
|
||||
lg: 'w-3 h-3 border-2',
|
||||
xl: 'w-4 h-4 border-2',
|
||||
} as const;
|
||||
|
||||
function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarProps) {
|
||||
const sizeClass = sizeClasses[size];
|
||||
const initials = getInitials(name || '?');
|
||||
const gradientClass = generateAvatarColor(name || '');
|
||||
|
||||
return (
|
||||
<div className={`relative shrink-0 ${className}`}>
|
||||
{src ? (
|
||||
<img
|
||||
src={src}
|
||||
alt={name}
|
||||
className={`${sizeClass} rounded-full object-cover`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`${sizeClass} rounded-full bg-gradient-to-br ${gradientClass} flex items-center justify-center text-white font-medium`}
|
||||
>
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
{online !== undefined && (
|
||||
<div
|
||||
className={`absolute bottom-0 right-0 ${onlineDotSize[size]} rounded-full border-surface ${
|
||||
online ? 'bg-emerald-500' : 'bg-zinc-500'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const Avatar = memo(AvatarInner);
|
||||
export default Avatar;
|
||||
1766
apps/web/src/components/CallModal.tsx
Normal file
1766
apps/web/src/components/CallModal.tsx
Normal file
File diff suppressed because it is too large
Load Diff
214
apps/web/src/components/ChatListItem.tsx
Normal file
214
apps/web/src/components/ChatListItem.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import { useState, useRef, useEffect, memo } from 'react';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { ru, enUS } from 'date-fns/locale';
|
||||
import { Check, CheckCheck, Image, FileText, Mic, Video, Pin, Trash2, Bookmark } from 'lucide-react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { stripMarkdown } from '../lib/utils';
|
||||
import { api } from '../lib/api';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import Avatar from './Avatar';
|
||||
import type { Chat } from '../lib/types';
|
||||
|
||||
interface ChatListItemProps {
|
||||
chat: Chat;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { setActiveChat, loadMessages, typingUsers, drafts, loadChats } = useChatStore();
|
||||
const { t, lang } = useLang();
|
||||
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const ctxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const myMember = chat.members.find((m) => m.user.id === user?.id);
|
||||
const isPinned = myMember?.isPinned ?? false;
|
||||
|
||||
const draft = drafts[chat.id] || '';
|
||||
|
||||
const otherMember = chat.members.find((m) => m.user.id !== user?.id);
|
||||
const isFavorites = chat.type === 'favorites';
|
||||
const chatName = isFavorites
|
||||
? t('favorites')
|
||||
: chat.type === 'personal'
|
||||
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
|
||||
: chat.name || t('group');
|
||||
|
||||
const chatAvatar = isFavorites
|
||||
? null
|
||||
: chat.type === 'personal'
|
||||
? otherMember?.user.avatar
|
||||
: chat.avatar;
|
||||
|
||||
const isOnline = chat.type === 'personal' && otherMember?.user.isOnline;
|
||||
|
||||
// Check if someone is typing in this chat
|
||||
const typingInChat = typingUsers.filter((t) => t.chatId === chat.id && t.userId !== user?.id);
|
||||
const isTyping = typingInChat.length > 0;
|
||||
|
||||
const lastMessage = chat.messages?.[0];
|
||||
const lastMessageText = lastMessage
|
||||
? lastMessage.isDeleted
|
||||
? t('messageDeleted')
|
||||
: lastMessage.type === 'voice'
|
||||
? t('voice')
|
||||
: lastMessage.type === 'file' || lastMessage.type === 'image' || lastMessage.type === 'video'
|
||||
? lastMessage.media?.[0]?.type === 'image'
|
||||
? t('photo')
|
||||
: lastMessage.media?.[0]?.type === 'video'
|
||||
? t('video')
|
||||
: t('file')
|
||||
: lastMessage.content || ''
|
||||
: '';
|
||||
|
||||
const previewText = stripMarkdown(lastMessageText);
|
||||
|
||||
const isMine = lastMessage?.senderId === user?.id;
|
||||
|
||||
// Галочки прочтения
|
||||
const isRead = lastMessage?.readBy?.some((r) => r.userId !== user?.id);
|
||||
|
||||
const timeStr = lastMessage
|
||||
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
|
||||
: '';
|
||||
|
||||
const handleClick = () => {
|
||||
setActiveChat(chat.id);
|
||||
loadMessages(chat.id);
|
||||
};
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu({ x: e.clientX, y: e.clientY });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctxMenu) return;
|
||||
const close = (e: MouseEvent) => {
|
||||
if (ctxRef.current && !ctxRef.current.contains(e.target as Node)) setCtxMenu(null);
|
||||
};
|
||||
document.addEventListener('mousedown', close);
|
||||
return () => document.removeEventListener('mousedown', close);
|
||||
}, [ctxMenu]);
|
||||
|
||||
const handlePin = async () => {
|
||||
setCtxMenu(null);
|
||||
try {
|
||||
await api.togglePinChat(chat.id);
|
||||
loadChats();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
setCtxMenu(null);
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setShowDeleteConfirm(false);
|
||||
try {
|
||||
await api.deleteChat(chat.id);
|
||||
useChatStore.getState().removeChat(chat.id);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const initials = chatName
|
||||
.split(' ')
|
||||
.map((w: string) => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
className={`w-full flex items-center gap-3 px-3 py-3 transition-colors text-left ${
|
||||
isActive ? 'bg-accent/15 border-r-2 border-accent' : 'hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{/* Аватар */}
|
||||
<div className="relative flex-shrink-0">
|
||||
{isFavorites ? (
|
||||
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-lg">
|
||||
<Bookmark size={22} className="text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar src={chatAvatar} name={chatName} size="lg" online={isOnline ? true : undefined} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Инфо */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
{isPinned && <Pin size={12} className="text-vortex-400 flex-shrink-0 rotate-45" />}
|
||||
<span className="text-sm font-medium text-white truncate">{chatName}</span>
|
||||
</div>
|
||||
{timeStr && <span className="text-xs text-zinc-500 flex-shrink-0 ml-2">{timeStr}</span>}
|
||||
</div>
|
||||
<div className="flex items-center justify-between mt-0.5">
|
||||
<div className="flex items-center gap-1 min-w-0 flex-1">
|
||||
{isMine && lastMessage && !lastMessage.isDeleted && (
|
||||
<span className="flex-shrink-0">
|
||||
{isRead ? (
|
||||
<CheckCheck size={14} className="text-vortex-400" />
|
||||
) : (
|
||||
<Check size={14} className="text-zinc-500" />
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
<p className={`text-xs truncate ${isTyping ? 'text-vortex-400 font-medium' : draft ? 'text-red-400' : 'text-zinc-400'}`}>
|
||||
{isTyping ? t('typing') : draft ? <><span className="font-medium">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
|
||||
</p>
|
||||
</div>
|
||||
{chat.unreadCount > 0 && !isActive && (
|
||||
<span className="ml-2 flex-shrink-0 min-w-[20px] h-5 px-1.5 rounded-full bg-accent flex items-center justify-center text-[11px] text-white font-medium">
|
||||
{chat.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Context Menu */}
|
||||
{ctxMenu && (
|
||||
<div
|
||||
ref={ctxRef}
|
||||
className="fixed z-[9999] min-w-[180px] py-1 rounded-xl bg-surface-secondary border border-border shadow-xl animate-in fade-in zoom-in-95 duration-100"
|
||||
style={{ top: ctxMenu.y, left: ctxMenu.x }}
|
||||
>
|
||||
<button
|
||||
onClick={handlePin}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Pin size={16} className={isPinned ? 'rotate-45' : ''} />
|
||||
{isPinned ? t('unpinChat') : t('pinChat')}
|
||||
</button>
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t('deleteChat')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
open={showDeleteConfirm}
|
||||
message={t('deleteChatConfirm')}
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ChatListItem);
|
||||
877
apps/web/src/components/ChatView.tsx
Normal file
877
apps/web/src/components/ChatView.tsx
Normal file
@@ -0,0 +1,877 @@
|
||||
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Phone,
|
||||
Video,
|
||||
MoreVertical,
|
||||
Search,
|
||||
X,
|
||||
ArrowDown,
|
||||
Trash2,
|
||||
UserPlus,
|
||||
Bell,
|
||||
BellOff,
|
||||
Settings,
|
||||
Eraser,
|
||||
Pin,
|
||||
Forward,
|
||||
Bookmark,
|
||||
} from 'lucide-react';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { api } from '../lib/api';
|
||||
import { getSocket } from '../lib/socket';
|
||||
import { isChatMuted, toggleMuteChat } from '../lib/sounds';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { formatLastSeen } from '../lib/utils';
|
||||
import type { UserBasic, Message } from '../lib/types';
|
||||
import MessageBubble from './MessageBubble';
|
||||
import MessageInput from './MessageInput';
|
||||
import TypingIndicator from './TypingIndicator';
|
||||
import UserProfile from './UserProfile';
|
||||
import GroupSettings from './GroupSettings';
|
||||
import ForwardModal from './ForwardModal';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
import Avatar from './Avatar';
|
||||
import { useThemeStore } from '../stores/themeStore';
|
||||
|
||||
export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCall?: (targetUser: UserBasic, type: 'voice' | 'video') => void; onStartGroupCall?: (chatId: string, chatName: string, type: 'voice' | 'video') => void }) {
|
||||
const { user } = useAuthStore();
|
||||
const { t, lang } = useLang();
|
||||
const { chatTheme } = useThemeStore();
|
||||
const {
|
||||
activeChat,
|
||||
chats,
|
||||
messages,
|
||||
typingUsers,
|
||||
pinnedMessages,
|
||||
isLoadingMessages,
|
||||
setActiveChat,
|
||||
} = useChatStore();
|
||||
|
||||
const [showTopMenu, setShowTopMenu] = useState(false);
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<Message[]>([]);
|
||||
const [profileUserId, setProfileUserId] = useState<string | null>(null);
|
||||
const [showGroupSettings, setShowGroupSettings] = useState(false);
|
||||
const [showScrollDown, setShowScrollDown] = useState(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set());
|
||||
const [showForwardModal, setShowForwardModal] = useState(false);
|
||||
const [showDeleteMenu, setShowDeleteMenu] = useState(false);
|
||||
const [confirmAction, setConfirmAction] = useState<{ message: string; action: () => void } | null>(null);
|
||||
const [scrollReady, setScrollReady] = useState(false);
|
||||
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const topMenuRef = useRef<HTMLDivElement>(null);
|
||||
const deleteMenuRef = useRef<HTMLDivElement>(null);
|
||||
const chatViewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const chat = chats.find((c) => c.id === activeChat);
|
||||
const chatMessages = activeChat ? messages[activeChat] || [] : [];
|
||||
const pinnedMsg = activeChat ? pinnedMessages[activeChat] : null;
|
||||
|
||||
// Количество непрочитанных сообщений (для бейджика)
|
||||
const unreadCount = chatMessages.filter(
|
||||
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||||
).length;
|
||||
|
||||
const otherMember = chat?.members.find((m) => m.user.id !== user?.id);
|
||||
const isFavorites = chat?.type === 'favorites';
|
||||
const chatName = isFavorites
|
||||
? t('favorites')
|
||||
: chat?.type === 'personal'
|
||||
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
|
||||
: chat?.name || t('group');
|
||||
const chatAvatar = isFavorites
|
||||
? null
|
||||
: chat?.type === 'personal'
|
||||
? otherMember?.user.avatar
|
||||
: chat?.avatar;
|
||||
const isOnline = chat?.type === 'personal' && otherMember?.user.isOnline;
|
||||
|
||||
const typingInChat = typingUsers.filter((t) => t.chatId === activeChat && t.userId !== user?.id);
|
||||
|
||||
// Load muted state
|
||||
useEffect(() => {
|
||||
if (activeChat) {
|
||||
setMuted(isChatMuted(activeChat));
|
||||
setScrollReady(false);
|
||||
setActiveGroupCallParticipants([]);
|
||||
}
|
||||
}, [activeChat]);
|
||||
|
||||
// Listen for active group calls
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
if (!socket) return;
|
||||
const handler = (data: { chatId: string; participants: string[] }) => {
|
||||
if (data.chatId === activeChat) {
|
||||
setActiveGroupCallParticipants(data.participants.filter(p => p !== user?.id));
|
||||
}
|
||||
};
|
||||
socket.on('group_call_active', handler);
|
||||
// Request current status when opening a group chat
|
||||
if (activeChat && chat?.type === 'group') {
|
||||
socket.emit('group_call_status', { chatId: activeChat });
|
||||
}
|
||||
return () => { socket.off('group_call_active', handler); };
|
||||
}, [activeChat, user?.id, chat?.type]);
|
||||
|
||||
// Close top menu on click outside
|
||||
useEffect(() => {
|
||||
if (!showTopMenu) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (topMenuRef.current && !topMenuRef.current.contains(e.target as Node)) {
|
||||
setShowTopMenu(false);
|
||||
}
|
||||
};
|
||||
// Use setTimeout to avoid the same click that opened the menu from closing it
|
||||
const timer = setTimeout(() => document.addEventListener('click', handleClick), 0);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [showTopMenu]);
|
||||
|
||||
// Close delete menu on click outside
|
||||
useEffect(() => {
|
||||
if (!showDeleteMenu) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (deleteMenuRef.current && !deleteMenuRef.current.contains(e.target as Node)) {
|
||||
setShowDeleteMenu(false);
|
||||
}
|
||||
};
|
||||
const timer = setTimeout(() => document.addEventListener('click', handleClick), 0);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('click', handleClick);
|
||||
};
|
||||
}, [showDeleteMenu]);
|
||||
|
||||
// Прокрутка вниз
|
||||
const scrollToBottom = useCallback((smooth = true) => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: smooth ? 'smooth' : 'instant', block: 'end' });
|
||||
}, []);
|
||||
|
||||
// Первичная прокрутка при открытии чата или после загрузки (layout effect — до отрисовки)
|
||||
useLayoutEffect(() => {
|
||||
if (!isLoadingMessages && messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
||||
setScrollReady(true);
|
||||
}
|
||||
}, [activeChat, isLoadingMessages]);
|
||||
|
||||
// Scroll on new message arrivals
|
||||
useEffect(() => {
|
||||
if (chatMessages.length > 0) {
|
||||
const lastMsg = chatMessages[chatMessages.length - 1];
|
||||
if (lastMsg.senderId === user?.id) {
|
||||
setTimeout(() => scrollToBottom(true), 50);
|
||||
} else {
|
||||
// Если пользователь внизу — прокрутить
|
||||
const container = messagesContainerRef.current;
|
||||
if (container) {
|
||||
const isNearBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight < 250;
|
||||
if (isNearBottom) setTimeout(() => scrollToBottom(true), 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [chatMessages.length, user?.id, scrollToBottom]);
|
||||
|
||||
// Read receipts — debounced via ref to avoid excessive emits
|
||||
const sentReadIdsRef = useRef<Set<string>>(new Set());
|
||||
useEffect(() => {
|
||||
if (!activeChat || !user?.id) return;
|
||||
// Reset tracked IDs when switching chats
|
||||
sentReadIdsRef.current.clear();
|
||||
}, [activeChat, user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeChat || !user?.id) return;
|
||||
const unread = chatMessages.filter(
|
||||
(m) => m.senderId !== user.id && !m.readBy?.some((r) => r.userId === user.id) && !sentReadIdsRef.current.has(m.id)
|
||||
);
|
||||
if (unread.length > 0) {
|
||||
const ids = unread.map((m) => m.id);
|
||||
ids.forEach((id) => sentReadIdsRef.current.add(id));
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
socket.emit('read_messages', {
|
||||
chatId: activeChat,
|
||||
messageIds: ids,
|
||||
});
|
||||
}
|
||||
// Update local store immediately for current user
|
||||
useChatStore.getState().markRead(activeChat, user.id, ids);
|
||||
}
|
||||
}, [chatMessages.length, activeChat, user?.id]);
|
||||
|
||||
// Scroll detection
|
||||
const handleScroll = () => {
|
||||
const container = messagesContainerRef.current;
|
||||
if (!container) return;
|
||||
const isNearBottom =
|
||||
container.scrollHeight - container.scrollTop - container.clientHeight < 200;
|
||||
setShowScrollDown(!isNearBottom);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (!chatViewRef.current) return;
|
||||
const { left, top } = chatViewRef.current.getBoundingClientRect();
|
||||
chatViewRef.current.style.setProperty('--mouse-x', `${e.clientX - left}px`);
|
||||
chatViewRef.current.style.setProperty('--mouse-y', `${e.clientY - top}px`);
|
||||
};
|
||||
|
||||
// Поиск сообщений
|
||||
useEffect(() => {
|
||||
if (!searchText.trim() || !activeChat) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
const results = await api.searchMessages(searchText, activeChat);
|
||||
setSearchResults(results);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchText, activeChat]);
|
||||
|
||||
const openSearch = () => {
|
||||
setShowSearch(true);
|
||||
setShowTopMenu(false);
|
||||
setTimeout(() => searchInputRef.current?.focus(), 100);
|
||||
};
|
||||
|
||||
if (!activeChat || !chat) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-surface-secondary/50 rounded-[2rem] overflow-hidden border border-white/5 shadow-2xl relative z-0 backdrop-blur-3xl group">
|
||||
{/* Slowly pulsing purple background as requested */}
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden transition-opacity duration-[10000ms]">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[60vw] h-[60vw] max-w-[800px] max-h-[800px] bg-vortex-600/10 rounded-full blur-[120px] animate-[pulse_8s_ease-in-out_infinite]" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[40vw] h-[40vw] max-w-[500px] max-h-[500px] bg-purple-600/15 rounded-full blur-[100px] animate-[pulse_12s_ease-in-out_infinite_reverse]" />
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4wMSkvPjwvc3ZnPg==')] [mask-image:radial-gradient(ellipse_at_center,black_40%,transparent_100%)] opacity-20 pointer-events-none" />
|
||||
|
||||
<div className="text-center relative z-10 w-full max-w-sm px-6">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ duration: 0.6, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="w-28 h-28 mx-auto mb-8 rounded-[2rem] bg-gradient-to-br from-vortex-500/20 to-purple-600/20 flex items-center justify-center shadow-[0_0_60px_-15px_var(--color-accent)] ring-1 ring-white/10 backdrop-blur-2xl relative"
|
||||
>
|
||||
<div className="absolute inset-0 rounded-[2rem] bg-gradient-to-br from-white/[0.05] to-transparent pointer-events-none" />
|
||||
<img src="/logo.png" alt="Vortex" className="w-16 h-16 rounded-2xl object-cover shadow-2xl transform hover:scale-105 transition-transform" />
|
||||
</motion.div>
|
||||
<motion.h2
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.1, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="text-3xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-vortex-400 via-fuchsia-400 to-indigo-400 mb-4 drop-shadow-lg tracking-tight"
|
||||
>
|
||||
Vortex Messenger
|
||||
</motion.h2>
|
||||
<motion.p
|
||||
initial={{ y: 20, opacity: 0 }}
|
||||
animate={{ y: 0, opacity: 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.2, ease: [0.16, 1, 0.3, 1] }}
|
||||
className="text-sm font-medium text-zinc-300 bg-white/5 backdrop-blur-lg py-2.5 px-6 rounded-full inline-flex border border-white/10 shadow-lg"
|
||||
>
|
||||
{t('selectChat')}
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const initials = chatName
|
||||
.split(' ')
|
||||
.map((w: string) => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const handleToggleSelect = (msgId: string) => {
|
||||
const newMap = new Set(selectedMessages);
|
||||
if (newMap.has(msgId)) {
|
||||
newMap.delete(msgId);
|
||||
if (newMap.size === 0) setSelectionMode(false);
|
||||
} else {
|
||||
newMap.add(msgId);
|
||||
}
|
||||
setSelectedMessages(newMap);
|
||||
};
|
||||
|
||||
const handleStartSelection = (msgId: string) => {
|
||||
setSelectionMode(true);
|
||||
setSelectedMessages(new Set([msgId]));
|
||||
};
|
||||
|
||||
const handleForward = (targetChatId: string) => {
|
||||
const socket = getSocket();
|
||||
if (!socket || !activeChat) return;
|
||||
|
||||
const messagesToForward = Array.from(selectedMessages)
|
||||
.map(id => chatMessages.find(m => m.id === id))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => new Date(a!.createdAt).getTime() - new Date(b!.createdAt).getTime());
|
||||
|
||||
messagesToForward.forEach(msg => {
|
||||
socket.emit('send_message', {
|
||||
chatId: targetChatId,
|
||||
content: msg?.content,
|
||||
type: msg?.type,
|
||||
forwardedFromId: msg?.sender.id,
|
||||
mediaUrl: msg?.media?.[0]?.url,
|
||||
mediaType: msg?.media?.[0]?.type,
|
||||
fileName: msg?.media?.[0]?.filename,
|
||||
fileSize: msg?.media?.[0]?.size ?? undefined,
|
||||
});
|
||||
});
|
||||
|
||||
setSelectionMode(false);
|
||||
setSelectedMessages(new Set());
|
||||
setShowForwardModal(false);
|
||||
setActiveChat(targetChatId);
|
||||
};
|
||||
|
||||
const handleBulkDelete = (deleteForAll: boolean) => {
|
||||
const socket = getSocket();
|
||||
if (!socket || !activeChat) return;
|
||||
|
||||
const ids = Array.from(selectedMessages);
|
||||
socket.emit('delete_messages', {
|
||||
messageIds: ids,
|
||||
chatId: activeChat,
|
||||
deleteForAll,
|
||||
});
|
||||
|
||||
// Optimistic local removal
|
||||
if (!deleteForAll) {
|
||||
useChatStore.getState().hideMessages(ids, activeChat);
|
||||
}
|
||||
|
||||
setSelectionMode(false);
|
||||
setSelectedMessages(new Set());
|
||||
setShowDeleteMenu(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={chatViewRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
className={`flex-1 flex flex-col h-full rounded-3xl overflow-hidden shadow-[0_0_120px_-20px_rgba(0,0,0,0.5)] border border-border/50 relative z-0 chat-theme-${chatTheme} transition-colors duration-500`}
|
||||
>
|
||||
{/* Шапка чата */}
|
||||
{selectionMode ? (
|
||||
<div className="h-[76px] flex items-center justify-between px-6 border-b border-border/40 bg-surface-secondary/80 backdrop-blur-xl z-20 flex-shrink-0 animate-in slide-in-from-top-2">
|
||||
<div className="flex items-center gap-4 text-white">
|
||||
<button onClick={() => { setSelectionMode(false); setSelectedMessages(new Set()); }} className="p-2 -ml-2 rounded-full hover:bg-white/10 transition">
|
||||
<X size={20} className="text-zinc-300" />
|
||||
</button>
|
||||
<span className="font-medium text-[15px]">{selectedMessages.size} {t('selected') || 'выбрано'}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Кнопка удаления с выпадающим меню */}
|
||||
<div className="relative" ref={deleteMenuRef}>
|
||||
<button
|
||||
disabled={selectedMessages.size === 0}
|
||||
onClick={() => setShowDeleteMenu(!showDeleteMenu)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-red-500/90 text-white font-medium rounded-xl hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
{t('delete')}
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showDeleteMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-surface-secondary/95 backdrop-blur-2xl shadow-2xl z-50 py-1.5 ring-1 ring-border/50 overflow-hidden"
|
||||
>
|
||||
<button
|
||||
onClick={() => handleBulkDelete(false)}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Trash2 size={16} className="text-zinc-400" />
|
||||
{t('deleteForMe')}
|
||||
</button>
|
||||
<div className="border-t border-border/30 mx-3" />
|
||||
<button
|
||||
onClick={() => handleBulkDelete(true)}
|
||||
className="flex items-center gap-3 w-full px-4 py-3 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} className="text-red-400" />
|
||||
{t('deleteForAll')}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<button
|
||||
disabled={selectedMessages.size === 0}
|
||||
onClick={() => setShowForwardModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-white text-black font-medium rounded-xl hover:bg-zinc-200 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<Forward size={18} />
|
||||
{t('forward')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[76px] flex items-center justify-between px-6 border-b border-border/40 bg-surface-secondary/80 backdrop-blur-xl z-20 flex-shrink-0">
|
||||
<button
|
||||
className="flex items-center gap-3 min-w-0 flex-1 group transition-all"
|
||||
onClick={() => {
|
||||
if (chat.type === 'personal' && otherMember) {
|
||||
setProfileUserId(otherMember.user.id);
|
||||
} else if (chat.type === 'group') {
|
||||
setShowGroupSettings(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative flex-shrink-0 transform transition-transform duration-300 group-hover:scale-105">
|
||||
{isFavorites ? (
|
||||
<div className="w-11 h-11 rounded-full bg-gradient-to-br from-amber-400 to-orange-500 flex items-center justify-center shadow-lg ring-2 ring-transparent group-hover:ring-accent/30 transition-all duration-300">
|
||||
<Bookmark size={20} className="text-white" />
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
src={chatAvatar}
|
||||
name={chatName}
|
||||
size="md"
|
||||
online={isOnline ? true : undefined}
|
||||
className="ring-2 ring-transparent group-hover:ring-accent/30 transition-all duration-300 rounded-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<h3 className="text-base font-semibold text-white truncate drop-shadow-sm group-hover:text-accent/90 transition-colors">{chatName}</h3>
|
||||
<p className="text-xs text-zinc-400 truncate">
|
||||
{isFavorites
|
||||
? t('favoritesDescription')
|
||||
: typingInChat.length > 0
|
||||
? <span className="text-accent font-medium">{t('typing')}</span>
|
||||
: isOnline
|
||||
? <span className="text-emerald-400">{t('online')}</span>
|
||||
: chat.type === 'personal' && otherMember?.user.lastSeen
|
||||
? `${t('lastSeenAt')} ${formatLastSeen(otherMember.user.lastSeen, lang)}`
|
||||
: chat.type === 'group'
|
||||
? `${chat.members.length} ${t('members')}`
|
||||
: ''}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-1.5 ml-4">
|
||||
{/* Поиск */}
|
||||
<AnimatePresence>
|
||||
{showSearch && (
|
||||
<motion.div
|
||||
initial={{ width: 0, opacity: 0 }}
|
||||
animate={{ width: 200, opacity: 1 }}
|
||||
exit={{ width: 0, opacity: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
placeholder={t('searchMessages')}
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
className="w-full px-3 py-1.5 rounded-lg bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (showSearch) {
|
||||
setShowSearch(false);
|
||||
setSearchText('');
|
||||
setSearchResults([]);
|
||||
} else {
|
||||
openSearch();
|
||||
}
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
>
|
||||
{showSearch ? <X size={18} /> : <Search size={18} />}
|
||||
</button>
|
||||
|
||||
{!isFavorites && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (chat.type === 'personal' && otherMember) {
|
||||
onStartCall?.(otherMember.user, 'voice');
|
||||
} else if (chat.type === 'group') {
|
||||
onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice');
|
||||
}
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white" title={t('call')}>
|
||||
<Phone size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (chat.type === 'personal' && otherMember) {
|
||||
onStartCall?.(otherMember.user, 'video');
|
||||
} else if (chat.type === 'group') {
|
||||
onStartGroupCall?.(chat.id, chat.name || 'Group', 'video');
|
||||
}
|
||||
}}
|
||||
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white" title={t('videoCall')}>
|
||||
<Video size={18} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Меню */}
|
||||
<div className="relative" ref={topMenuRef}>
|
||||
<button
|
||||
onClick={() => setShowTopMenu(!showTopMenu)}
|
||||
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{showTopMenu && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="absolute right-0 top-full mt-2 w-56 rounded-2xl glass-strong shadow-2xl z-50 py-1.5 ring-1 ring-border/50 backdrop-blur-2xl"
|
||||
>
|
||||
<button
|
||||
onClick={openSearch}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Search size={16} />
|
||||
{t('searchMessages')}
|
||||
</button>
|
||||
{chat.type === 'personal' && otherMember && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTopMenu(false);
|
||||
setProfileUserId(otherMember.user.id);
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
{t('userProfile')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (activeChat) {
|
||||
const nowMuted = toggleMuteChat(activeChat);
|
||||
setMuted(nowMuted);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
{muted ? <Bell size={16} /> : <BellOff size={16} />}
|
||||
{muted ? t('enableSound') : t('disableSound')}
|
||||
</button>
|
||||
{chat.type === 'group' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTopMenu(false);
|
||||
setShowGroupSettings(true);
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Settings size={16} />
|
||||
{t('groupSettings')}
|
||||
</button>
|
||||
)}
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTopMenu(false);
|
||||
if (activeChat) {
|
||||
setConfirmAction({
|
||||
message: t('clearChatConfirm'),
|
||||
action: async () => {
|
||||
try {
|
||||
await api.clearChat(activeChat);
|
||||
useChatStore.getState().clearMessages(activeChat);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Eraser size={16} />
|
||||
{t('clearChat')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowTopMenu(false);
|
||||
if (activeChat) {
|
||||
setConfirmAction({
|
||||
message: t('deleteChatConfirm'),
|
||||
action: async () => {
|
||||
try {
|
||||
await api.deleteChat(activeChat);
|
||||
useChatStore.getState().removeChat(activeChat);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t('deleteChat')}
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Результаты поиска */}
|
||||
<AnimatePresence>
|
||||
{showSearch && searchResults.length > 0 && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="absolute top-14 left-0 right-0 z-20 max-h-60 overflow-y-auto glass-strong border-b border-border"
|
||||
>
|
||||
{searchResults.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
className="px-4 py-2 hover:bg-surface-hover cursor-pointer border-b border-border/50 last:border-0"
|
||||
onClick={() => {
|
||||
// Scroll to message
|
||||
const el = document.getElementById(`msg-${msg.id}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('bg-vortex-500/20');
|
||||
setTimeout(() => el.classList.remove('bg-vortex-500/20'), 2000);
|
||||
}
|
||||
setShowSearch(false);
|
||||
setSearchText('');
|
||||
setSearchResults([]);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="text-xs font-medium text-vortex-400">
|
||||
{msg.sender?.displayName || msg.sender?.username}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-600">
|
||||
{new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru' : 'en')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-300 truncate">{msg.content}</p>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Закреплённое сообщение */}
|
||||
{/* Active group call banner */}
|
||||
{chat?.type === 'group' && activeGroupCallParticipants.length > 0 && (
|
||||
<button
|
||||
onClick={() => onStartGroupCall?.(chat.id, chat.name || 'Group', 'voice')}
|
||||
className="flex items-center gap-3 px-4 py-2.5 border-b border-border bg-emerald-500/10 hover:bg-emerald-500/20 transition-colors text-left w-full flex-shrink-0"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center">
|
||||
<Phone size={14} className="text-emerald-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-emerald-400">{t('activeCall')}</p>
|
||||
<p className="text-sm text-zinc-300">{activeGroupCallParticipants.length} {t('participants')}</p>
|
||||
</div>
|
||||
<span className="text-xs text-emerald-400 font-medium px-3 py-1 rounded-full bg-emerald-500/20">{t('joinCall')}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{pinnedMsg && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const el = document.getElementById(`msg-${pinnedMsg.id}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
el.classList.add('bg-vortex-500/20');
|
||||
setTimeout(() => el.classList.remove('bg-vortex-500/20'), 2000);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-3 px-4 py-2 border-b border-border bg-surface-secondary/60 hover:bg-surface-hover transition-colors text-left w-full flex-shrink-0"
|
||||
>
|
||||
<Pin size={16} className="text-vortex-400 flex-shrink-0 rotate-45" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-xs font-medium text-vortex-400">{t('pinnedMessage')}</p>
|
||||
<p className="text-sm text-zinc-300 truncate">
|
||||
{pinnedMsg.content || (pinnedMsg.media?.length > 0 ? t('media') : '...')}
|
||||
</p>
|
||||
</div>
|
||||
<X
|
||||
size={16}
|
||||
className="text-zinc-500 hover:text-white flex-shrink-0 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const socket = getSocket();
|
||||
if (socket && activeChat) {
|
||||
socket.emit('unpin_message', { messageId: pinnedMsg.id, chatId: activeChat });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Сообщения */}
|
||||
<div
|
||||
ref={messagesContainerRef}
|
||||
onScroll={handleScroll}
|
||||
className={`flex-1 overflow-y-auto px-6 pt-6 pb-2 relative z-10 ${!scrollReady && !isLoadingMessages && chatMessages.length > 0 ? 'invisible' : ''}`}
|
||||
>
|
||||
{isLoadingMessages ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-6 border-2 border-vortex-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : chatMessages.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-sm text-zinc-500">{t('noMessages')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-w-3xl mx-auto">
|
||||
{chatMessages.map((msg, i) => {
|
||||
const prevMsg = i > 0 ? chatMessages[i - 1] : null;
|
||||
const showAvatar = !prevMsg || prevMsg.senderId !== msg.senderId;
|
||||
const showDate =
|
||||
!prevMsg ||
|
||||
new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString();
|
||||
|
||||
return (
|
||||
<div key={msg.id} id={`msg-${msg.id}`} className="transition-colors duration-500">
|
||||
{showDate && (
|
||||
<div className="flex justify-center my-4">
|
||||
<span className="px-3 py-1 rounded-full text-xs text-zinc-400 glass">
|
||||
{new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<MessageBubble
|
||||
message={msg}
|
||||
isMine={msg.senderId === user?.id}
|
||||
showAvatar={showAvatar}
|
||||
onViewProfile={(userId) => setProfileUserId(userId)}
|
||||
selectionMode={selectionMode}
|
||||
isSelected={selectedMessages.has(msg.id)}
|
||||
onToggleSelect={handleToggleSelect}
|
||||
onStartSelectionMode={handleStartSelection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} className="h-4" /> {/* Empty spacer for the bottom scroll boundary */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка прокрутки вниз */}
|
||||
<AnimatePresence>
|
||||
{showScrollDown && (
|
||||
<motion.button
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0, opacity: 0 }}
|
||||
onClick={() => scrollToBottom()}
|
||||
className="absolute bottom-24 right-6 w-11 h-11 rounded-full bg-surface-tertiary/90 backdrop-blur-md border border-border shadow-2xl flex items-center justify-center text-zinc-400 hover:text-white hover:bg-surface-hover hover:scale-105 transition-all z-10"
|
||||
>
|
||||
<ArrowDown size={20} />
|
||||
{unreadCount > 0 && (
|
||||
<motion.span
|
||||
initial={{ scale: 0 }}
|
||||
animate={{ scale: 1 }}
|
||||
className="absolute -top-1.5 -right-1.5 min-w-[20px] h-5 px-1.5 rounded-full bg-accent text-white text-[11px] font-bold flex items-center justify-center shadow-lg border-2 border-surface-secondary"
|
||||
>
|
||||
{unreadCount > 99 ? '99+' : unreadCount}
|
||||
</motion.span>
|
||||
)}
|
||||
</motion.button>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Typing индикатор */}
|
||||
{typingInChat.length > 0 && (
|
||||
<div className="px-4 pb-1">
|
||||
<TypingIndicator />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Ввод сообщения */}
|
||||
<MessageInput chatId={activeChat} />
|
||||
|
||||
{/* Профиль пользователя */}
|
||||
<AnimatePresence>
|
||||
{profileUserId && (
|
||||
<UserProfile
|
||||
userId={profileUserId}
|
||||
chatId={activeChat || undefined}
|
||||
onClose={() => setProfileUserId(null)}
|
||||
isSelf={profileUserId === user?.id}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Настройки группы */}
|
||||
<AnimatePresence>
|
||||
{showGroupSettings && chat && chat.type === 'group' && (
|
||||
<GroupSettings
|
||||
chat={chat}
|
||||
onClose={() => setShowGroupSettings(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<AnimatePresence>
|
||||
{showForwardModal && (
|
||||
<ForwardModal
|
||||
onClose={() => setShowForwardModal(false)}
|
||||
onForward={handleForward}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!confirmAction}
|
||||
message={confirmAction?.message || ''}
|
||||
onConfirm={() => {
|
||||
confirmAction?.action();
|
||||
setConfirmAction(null);
|
||||
}}
|
||||
onCancel={() => setConfirmAction(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
apps/web/src/components/ConfirmModal.tsx
Normal file
81
apps/web/src/components/ConfirmModal.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useLang } from '../lib/i18n';
|
||||
|
||||
interface ConfirmModalProps {
|
||||
open: boolean;
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmText?: string;
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export default function ConfirmModal({
|
||||
open,
|
||||
title,
|
||||
message,
|
||||
confirmText,
|
||||
cancelText,
|
||||
danger = true,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: ConfirmModalProps) {
|
||||
const { t } = useLang();
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
transition={{ type: 'spring', duration: 0.35, bounce: 0.2 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={title}
|
||||
className="w-full max-w-[360px] mx-4 rounded-2xl bg-surface-secondary border border-border/50 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div className="p-5 flex flex-col items-center text-center">
|
||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3 ${danger ? 'bg-red-500/15' : 'bg-accent/15'}`}>
|
||||
<AlertTriangle size={24} className={danger ? 'text-red-400' : 'text-accent'} />
|
||||
</div>
|
||||
{title && (
|
||||
<h3 className="text-white text-base font-semibold mb-1">{title}</h3>
|
||||
)}
|
||||
<p className="text-zinc-400 text-sm leading-relaxed">{message}</p>
|
||||
</div>
|
||||
<div className="flex border-t border-border/40">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="flex-1 py-3 text-sm font-medium text-zinc-400 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
{cancelText || t('cancel')}
|
||||
</button>
|
||||
<div className="w-px bg-border/40" />
|
||||
<button
|
||||
onClick={onConfirm}
|
||||
className={`flex-1 py-3 text-sm font-medium transition-colors ${
|
||||
danger
|
||||
? 'text-red-400 hover:bg-red-500/10 hover:text-red-300'
|
||||
: 'text-accent hover:bg-accent/10'
|
||||
}`}
|
||||
>
|
||||
{confirmText || t('confirm')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
337
apps/web/src/components/DatePicker.tsx
Normal file
337
apps/web/src/components/DatePicker.tsx
Normal file
@@ -0,0 +1,337 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { ChevronLeft, ChevronRight, Calendar } from 'lucide-react';
|
||||
import { useLang } from '../lib/i18n';
|
||||
|
||||
interface DatePickerProps {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
}
|
||||
|
||||
type View = 'days' | 'months' | 'years';
|
||||
|
||||
export default function DatePicker({ value, onChange }: DatePickerProps) {
|
||||
const { t, lang } = useLang();
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number; openUp: boolean } | null>(null);
|
||||
|
||||
const today = new Date();
|
||||
const parsed = value ? new Date(value) : null;
|
||||
const [viewYear, setViewYear] = useState(parsed?.getFullYear() || today.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(parsed?.getMonth() || today.getMonth());
|
||||
const [view, setView] = useState<View>('days');
|
||||
const [yearRangeStart, setYearRangeStart] = useState(() => {
|
||||
const y = parsed?.getFullYear() || today.getFullYear();
|
||||
return y - (y % 24);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handle = (e: MouseEvent) => {
|
||||
const target = e.target as Node;
|
||||
if (ref.current && !ref.current.contains(target) && dropdownRef.current && !dropdownRef.current.contains(target)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
setTimeout(() => document.addEventListener('click', handle), 0);
|
||||
return () => document.removeEventListener('click', handle);
|
||||
}, [open]);
|
||||
|
||||
// Reset view to days when reopened & compute position for portal
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setView('days');
|
||||
if (parsed) {
|
||||
setViewYear(parsed.getFullYear());
|
||||
setViewMonth(parsed.getMonth());
|
||||
setYearRangeStart(parsed.getFullYear() - (parsed.getFullYear() % 24));
|
||||
}
|
||||
// Compute dropdown position
|
||||
if (ref.current) {
|
||||
const rect = ref.current.getBoundingClientRect();
|
||||
const dropdownHeight = 370;
|
||||
const spaceBelow = window.innerHeight - rect.bottom;
|
||||
const openUp = spaceBelow < dropdownHeight;
|
||||
setPos({
|
||||
top: openUp ? rect.top : rect.bottom + 8,
|
||||
left: rect.left,
|
||||
openUp,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
setPos(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const months = t('months');
|
||||
const weekDays = t('weekDays');
|
||||
const shortMonths = useMemo(() => months.map(m => m.slice(0, 3)), [months]);
|
||||
|
||||
const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate();
|
||||
const firstDayRaw = new Date(viewYear, viewMonth, 1).getDay();
|
||||
const firstDay = firstDayRaw === 0 ? 6 : firstDayRaw - 1;
|
||||
|
||||
const prevMonth = () => {
|
||||
if (viewMonth === 0) { setViewMonth(11); setViewYear(viewYear - 1); }
|
||||
else setViewMonth(viewMonth - 1);
|
||||
};
|
||||
const nextMonth = () => {
|
||||
if (viewMonth === 11) { setViewMonth(0); setViewYear(viewYear + 1); }
|
||||
else setViewMonth(viewMonth + 1);
|
||||
};
|
||||
|
||||
const selectDay = (day: number) => {
|
||||
const m = String(viewMonth + 1).padStart(2, '0');
|
||||
const d = String(day).padStart(2, '0');
|
||||
onChange(`${viewYear}-${m}-${d}`);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const isSelected = (day: number) => {
|
||||
if (!parsed) return false;
|
||||
return parsed.getFullYear() === viewYear && parsed.getMonth() === viewMonth && parsed.getDate() === day;
|
||||
};
|
||||
|
||||
const isToday = (day: number) => {
|
||||
return today.getFullYear() === viewYear && today.getMonth() === viewMonth && today.getDate() === day;
|
||||
};
|
||||
|
||||
const displayValue = parsed
|
||||
? parsed.toLocaleDateString(useLang.getState().lang === 'ru' ? 'ru-RU' : 'en-US', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
: '';
|
||||
|
||||
const cells: (number | null)[] = [];
|
||||
for (let i = 0; i < firstDay; i++) cells.push(null);
|
||||
for (let d = 1; d <= daysInMonth; d++) cells.push(d);
|
||||
|
||||
// 24 years per page
|
||||
const yearCells = useMemo(() => {
|
||||
const arr: number[] = [];
|
||||
for (let i = 0; i < 24; i++) arr.push(yearRangeStart + i);
|
||||
return arr;
|
||||
}, [yearRangeStart]);
|
||||
|
||||
const handleHeaderClick = () => {
|
||||
if (view === 'days') {
|
||||
setView('months');
|
||||
} else if (view === 'months') {
|
||||
setYearRangeStart(viewYear - (viewYear % 24));
|
||||
setView('years');
|
||||
}
|
||||
};
|
||||
|
||||
const selectMonth = (monthIdx: number) => {
|
||||
setViewMonth(monthIdx);
|
||||
setView('days');
|
||||
};
|
||||
|
||||
const selectYear = (year: number) => {
|
||||
setViewYear(year);
|
||||
setView('months');
|
||||
};
|
||||
|
||||
const headerLabel = view === 'days'
|
||||
? `${months[viewMonth]} ${viewYear}`
|
||||
: view === 'months'
|
||||
? `${viewYear}`
|
||||
: `${yearRangeStart} — ${yearRangeStart + 23}`;
|
||||
|
||||
const handlePrev = () => {
|
||||
if (view === 'days') prevMonth();
|
||||
else if (view === 'months') setViewYear(y => y - 1);
|
||||
else setYearRangeStart(s => s - 24);
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (view === 'days') nextMonth();
|
||||
else if (view === 'months') setViewYear(y => y + 1);
|
||||
else setYearRangeStart(s => s + 24);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(!open)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-lg bg-surface-tertiary text-sm text-white border border-border hover:border-accent transition-colors text-left"
|
||||
>
|
||||
<Calendar size={14} className="text-zinc-500 flex-shrink-0" />
|
||||
<span className={displayValue ? 'text-white' : 'text-zinc-500'}>
|
||||
{displayValue || (lang === 'ru' ? 'дд.мм.гггг' : 'mm/dd/yyyy')}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{open && createPortal(
|
||||
<AnimatePresence>
|
||||
{pos && (
|
||||
<motion.div
|
||||
ref={dropdownRef}
|
||||
initial={{ opacity: 0, y: pos.openUp ? 8 : -8, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: pos.openUp ? 8 : -8, scale: 0.95 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="fixed w-72 glass-strong rounded-xl shadow-2xl z-[9999] overflow-hidden border border-border"
|
||||
style={{
|
||||
left: pos.left,
|
||||
...(pos.openUp
|
||||
? { bottom: window.innerHeight - pos.top + 8 }
|
||||
: { top: pos.top }),
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<button type="button" onClick={handlePrev} className="p-1 rounded-lg hover:bg-surface-hover text-zinc-400 hover:text-white transition-colors">
|
||||
<ChevronLeft size={18} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleHeaderClick}
|
||||
className={`text-sm font-medium text-white transition-colors ${view !== 'years' ? 'hover:text-accent cursor-pointer' : 'cursor-default'}`}
|
||||
>
|
||||
{headerLabel}
|
||||
</button>
|
||||
<button type="button" onClick={handleNext} className="p-1 rounded-lg hover:bg-surface-hover text-zinc-400 hover:text-white transition-colors">
|
||||
<ChevronRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{/* ===== DAYS VIEW ===== */}
|
||||
{view === 'days' && (
|
||||
<motion.div
|
||||
key="days"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
>
|
||||
<div className="grid grid-cols-7 px-3 pt-2">
|
||||
{weekDays.map((d) => (
|
||||
<div key={d} className="text-center text-[11px] text-zinc-500 font-medium py-1">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-7 px-3 pb-2">
|
||||
{cells.map((day, i) => (
|
||||
<div key={i} className="flex items-center justify-center">
|
||||
{day ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => selectDay(day)}
|
||||
className={`w-8 h-8 rounded-full text-sm flex items-center justify-center transition-all ${
|
||||
isSelected(day)
|
||||
? 'bg-accent text-white font-semibold shadow-lg shadow-accent/30'
|
||||
: isToday(day)
|
||||
? 'text-vortex-400 font-semibold ring-1 ring-vortex-500/50'
|
||||
: 'text-zinc-300 hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
) : (
|
||||
<span className="w-8 h-8" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ===== MONTHS VIEW ===== */}
|
||||
{view === 'months' && (
|
||||
<motion.div
|
||||
key="months"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid grid-cols-3 gap-1 p-3"
|
||||
>
|
||||
{shortMonths.map((m, idx) => {
|
||||
const isCurrentMonth = viewYear === today.getFullYear() && idx === today.getMonth();
|
||||
const isSelectedMonth = parsed && viewYear === parsed.getFullYear() && idx === parsed.getMonth();
|
||||
return (
|
||||
<button
|
||||
key={idx}
|
||||
type="button"
|
||||
onClick={() => selectMonth(idx)}
|
||||
className={`py-2.5 rounded-lg text-sm font-medium transition-all ${
|
||||
isSelectedMonth
|
||||
? 'bg-accent text-white shadow-lg shadow-accent/30'
|
||||
: isCurrentMonth
|
||||
? 'text-vortex-400 ring-1 ring-vortex-500/50'
|
||||
: 'text-zinc-300 hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{m}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* ===== YEARS VIEW ===== */}
|
||||
{view === 'years' && (
|
||||
<motion.div
|
||||
key="years"
|
||||
initial={{ opacity: 0, scale: 0.96 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.96 }}
|
||||
transition={{ duration: 0.12 }}
|
||||
className="grid grid-cols-4 gap-1 p-3"
|
||||
>
|
||||
{yearCells.map((yr) => {
|
||||
const isCurrentYear = yr === today.getFullYear();
|
||||
const isSelectedYear = parsed && yr === parsed.getFullYear();
|
||||
return (
|
||||
<button
|
||||
key={yr}
|
||||
type="button"
|
||||
onClick={() => selectYear(yr)}
|
||||
className={`py-2 rounded-lg text-sm font-medium transition-all ${
|
||||
isSelectedYear
|
||||
? 'bg-accent text-white shadow-lg shadow-accent/30'
|
||||
: isCurrentYear
|
||||
? 'text-vortex-400 ring-1 ring-vortex-500/50'
|
||||
: 'text-zinc-300 hover:bg-surface-hover'
|
||||
}`}
|
||||
>
|
||||
{yr}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-t border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { onChange(''); setOpen(false); }}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||
>
|
||||
{t('clear')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const m = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(today.getDate()).padStart(2, '0');
|
||||
onChange(`${today.getFullYear()}-${m}-${d}`);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="text-xs text-vortex-400 hover:text-vortex-300 transition-colors"
|
||||
>
|
||||
{t('today')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
211
apps/web/src/components/EmojiPicker.tsx
Normal file
211
apps/web/src/components/EmojiPicker.tsx
Normal file
@@ -0,0 +1,211 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Picker from '@emoji-mart/react';
|
||||
import data from '@emoji-mart/data';
|
||||
import { Search, TrendingUp, Loader2 } from 'lucide-react';
|
||||
import { useLang } from '../lib/i18n';
|
||||
|
||||
interface TenorGif {
|
||||
id: string;
|
||||
media_formats?: {
|
||||
gif?: { url: string };
|
||||
tinygif?: { url: string };
|
||||
};
|
||||
content_description?: string;
|
||||
}
|
||||
|
||||
interface EmojiPickerProps {
|
||||
onSelect: (emoji: string) => void;
|
||||
onSelectGif?: (url: string, preview: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const getTenorKey = () => localStorage.getItem('vortex_tenor_key') || '';
|
||||
|
||||
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
|
||||
const { lang, t } = useLang();
|
||||
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
|
||||
const [gifQuery, setGifQuery] = useState('');
|
||||
const [gifs, setGifs] = useState<TenorGif[]>([]);
|
||||
const [gifLoading, setGifLoading] = useState(false);
|
||||
const [trendingGifs, setTrendingGifs] = useState<TenorGif[]>([]);
|
||||
const gifSearchRef = useRef<HTMLInputElement>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
// Load trending GIFs
|
||||
useEffect(() => {
|
||||
if (tab === 'gif' && getTenorKey() && trendingGifs.length === 0) {
|
||||
setGifLoading(true);
|
||||
fetch(`https://tenor.googleapis.com/v2/featured?key=${getTenorKey()}&limit=30&media_filter=gif,tinygif`)
|
||||
.then(r => r.json())
|
||||
.then(d => { setTrendingGifs(d.results || []); setGifLoading(false); })
|
||||
.catch(() => setGifLoading(false));
|
||||
}
|
||||
}, [tab]);
|
||||
|
||||
const searchGifs = useCallback((q: string) => {
|
||||
if (!getTenorKey() || !q.trim()) { setGifs([]); return; }
|
||||
setGifLoading(true);
|
||||
fetch(`https://tenor.googleapis.com/v2/search?key=${getTenorKey()}&q=${encodeURIComponent(q)}&limit=30&media_filter=gif,tinygif`)
|
||||
.then(r => r.json())
|
||||
.then(d => { setGifs(d.results || []); setGifLoading(false); })
|
||||
.catch(() => setGifLoading(false));
|
||||
}, []);
|
||||
|
||||
const handleGifSearch = (q: string) => {
|
||||
setGifQuery(q);
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
debounceRef.current = setTimeout(() => searchGifs(q), 400);
|
||||
};
|
||||
|
||||
const pickGif = (gif: TenorGif) => {
|
||||
const url = gif.media_formats?.gif?.url || gif.media_formats?.tinygif?.url || '';
|
||||
const preview = gif.media_formats?.tinygif?.url || url;
|
||||
if (onSelectGif && url) {
|
||||
onSelectGif(url, preview);
|
||||
}
|
||||
};
|
||||
|
||||
const displayGifs = gifQuery.trim() ? gifs : trendingGifs;
|
||||
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const update = () => {
|
||||
const el = anchorRef.current?.parentElement;
|
||||
if (!el) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const w = tab === 'gif' ? 360 : 352;
|
||||
let left = rect.right - w;
|
||||
if (left < 8) left = 8;
|
||||
setPos({ top: rect.top - 8, left });
|
||||
};
|
||||
update();
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, [tab]);
|
||||
|
||||
const pickerWidth = tab === 'gif' ? 360 : 352;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div ref={anchorRef} className="hidden" />
|
||||
{createPortal(
|
||||
<>
|
||||
<div className="fixed inset-0 z-[9990]" onClick={onClose} />
|
||||
<div
|
||||
className="fixed z-[9991] rounded-2xl shadow-2xl border border-white/10"
|
||||
style={{
|
||||
width: pickerWidth,
|
||||
bottom: pos ? `${window.innerHeight - pos.top}px` : undefined,
|
||||
left: pos ? pos.left : undefined,
|
||||
background: 'rgb(17, 17, 19)',
|
||||
visibility: pos ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-white/10">
|
||||
<button
|
||||
onClick={() => setTab('emoji')}
|
||||
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'emoji' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
EMOJI
|
||||
</button>
|
||||
{(getTenorKey() || onSelectGif) && (
|
||||
<button
|
||||
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
|
||||
className={`flex-1 py-2.5 text-xs font-semibold tracking-wide transition-colors ${tab === 'gif' ? 'text-white border-b-2 border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
|
||||
>
|
||||
GIF
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emoji tab */}
|
||||
{tab === 'emoji' && (
|
||||
<Picker
|
||||
data={data}
|
||||
onEmojiSelect={(e: { native: string }) => onSelect(e.native)}
|
||||
theme="dark"
|
||||
locale={lang === 'ru' ? 'ru' : 'en'}
|
||||
set="native"
|
||||
previewPosition="none"
|
||||
skinTonePosition="search"
|
||||
perLine={9}
|
||||
emojiSize={28}
|
||||
emojiButtonSize={36}
|
||||
maxFrequentRows={2}
|
||||
navPosition="bottom"
|
||||
dynamicWidth={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* GIF tab */}
|
||||
{tab === 'gif' && (
|
||||
<div className="flex flex-col h-[calc(100%-41px)]">
|
||||
{!getTenorKey() ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-6 text-center">
|
||||
<p className="text-sm text-zinc-400 mb-2">{t('tenorKeyRequired')}</p>
|
||||
<p className="text-xs text-zinc-500 mb-3">{t('openConsoleRun')}</p>
|
||||
<code className="text-xs bg-black/30 px-3 py-1.5 rounded-lg text-vortex-400">
|
||||
localStorage.setItem('vortex_tenor_key', 'YOUR_KEY')
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="p-2">
|
||||
<div className="relative">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
ref={gifSearchRef}
|
||||
value={gifQuery}
|
||||
onChange={(e) => handleGifSearch(e.target.value)}
|
||||
placeholder={t('searchGifs')}
|
||||
className="w-full pl-8 pr-3 py-2 rounded-lg bg-surface-tertiary/80 text-sm text-white placeholder-zinc-500 border border-border/30 focus:border-accent/50 outline-none transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!gifQuery.trim() && !gifLoading && (
|
||||
<div className="flex items-center gap-1.5 px-3 pb-1">
|
||||
<TrendingUp size={12} className="text-zinc-500" />
|
||||
<span className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold">{t('trending')}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-y-auto p-1.5">
|
||||
{gifLoading ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 size={24} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
) : displayGifs.length === 0 ? (
|
||||
<p className="text-center text-xs text-zinc-500 py-10">{gifQuery ? t('nothingFound') : ''}</p>
|
||||
) : (
|
||||
<div className="columns-2 gap-1.5">
|
||||
{displayGifs.map((gif) => (
|
||||
<button
|
||||
key={gif.id}
|
||||
onClick={() => { pickGif(gif); onClose(); }}
|
||||
className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
|
||||
>
|
||||
<img
|
||||
src={gif.media_formats?.tinygif?.url || gif.media_formats?.gif?.url}
|
||||
alt={gif.content_description || 'GIF'}
|
||||
className="w-full h-auto rounded-lg"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
97
apps/web/src/components/ForwardModal.tsx
Normal file
97
apps/web/src/components/ForwardModal.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Search } from 'lucide-react';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import Avatar from './Avatar';
|
||||
|
||||
interface ForwardModalProps {
|
||||
onClose: () => void;
|
||||
onForward: (chatId: string) => void;
|
||||
}
|
||||
|
||||
export default function ForwardModal({ onClose, onForward }: ForwardModalProps) {
|
||||
const { chats } = useChatStore();
|
||||
const { user } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const filteredChats = chats.filter((chat) => {
|
||||
const otherMember = chat.members.find((m) => m.userId !== user?.id);
|
||||
const chatName = chat.type === 'personal'
|
||||
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
|
||||
: chat.name || t('group');
|
||||
return chatName.toLowerCase().includes(search.toLowerCase());
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
onClick={onClose}
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('forward')}
|
||||
className="relative w-full max-w-md bg-surface-secondary/90 glass-strong rounded-3xl overflow-hidden shadow-2xl border border-border"
|
||||
>
|
||||
<div className="p-4 flex items-center justify-between border-b border-white/5">
|
||||
<h2 className="text-lg font-semibold text-white">{t('forwardMessage')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
|
||||
>
|
||||
<X size={20} className="text-zinc-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
<div className="relative mb-4">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchChats') || 'Поиск чатов'}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="w-full bg-black/20 border border-white/10 rounded-xl py-2.5 pl-10 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:border-vortex-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="max-h-80 overflow-y-auto space-y-1 pr-2 custom-scrollbar">
|
||||
{filteredChats.map((chat) => {
|
||||
const otherMember = chat.members.find((m) => m.userId !== user?.id);
|
||||
const chatName = chat.type === 'personal'
|
||||
? otherMember?.user.displayName || otherMember?.user.username || t('chat')
|
||||
: chat.name || t('group');
|
||||
const chatAvatar = chat.type === 'personal'
|
||||
? otherMember?.user.avatar
|
||||
: chat.avatar;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={chat.id}
|
||||
onClick={() => onForward(chat.id)}
|
||||
className="w-full flex items-center gap-3 p-2 rounded-xl hover:bg-white/5 transition-colors text-left"
|
||||
>
|
||||
<Avatar src={chatAvatar} name={chatName} size="md" />
|
||||
<span className="text-white font-medium flex-1 truncate">{chatName}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filteredChats.length === 0 && (
|
||||
<p className="text-center text-zinc-500 py-4 text-sm">{t('nothingFound')}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
909
apps/web/src/components/GroupCallModal.tsx
Normal file
909
apps/web/src/components/GroupCallModal.tsx
Normal file
@@ -0,0 +1,909 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Phone, PhoneOff, Video, VideoOff, Mic, MicOff, Monitor, MonitorOff, Minimize2, Volume2, ShieldCheck, ShieldOff, ChevronUp } from 'lucide-react';
|
||||
import { getSocket } from '../lib/socket';
|
||||
import { api } from '../lib/api';
|
||||
import { useLang } from '../lib/i18n';
|
||||
|
||||
interface ParticipantInfo {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
interface PeerState {
|
||||
pc: RTCPeerConnection;
|
||||
remoteStream: MediaStream;
|
||||
hasVideo: boolean;
|
||||
}
|
||||
|
||||
interface GroupCallModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
chatId: string;
|
||||
chatName: string;
|
||||
callType: 'voice' | 'video';
|
||||
}
|
||||
|
||||
// ICE config reuse
|
||||
let cachedIceConfig: RTCConfiguration | null = null;
|
||||
let iceCacheFetchedAt = 0;
|
||||
const ICE_CACHE_TTL = 3600_000;
|
||||
const FALLBACK_ICE: RTCConfiguration = {
|
||||
iceServers: [
|
||||
{ urls: 'stun:stun.l.google.com:19302' },
|
||||
{ urls: 'stun:stun1.l.google.com:19302' },
|
||||
],
|
||||
};
|
||||
|
||||
async function getIceServers(): Promise<RTCConfiguration> {
|
||||
if (cachedIceConfig && Date.now() - iceCacheFetchedAt < ICE_CACHE_TTL) return cachedIceConfig;
|
||||
try {
|
||||
const data = await api.getIceServers();
|
||||
if (data.iceServers?.length > 0) {
|
||||
cachedIceConfig = { iceServers: data.iceServers };
|
||||
iceCacheFetchedAt = Date.now();
|
||||
return cachedIceConfig;
|
||||
}
|
||||
} catch { /* fallback */ }
|
||||
return FALLBACK_ICE;
|
||||
}
|
||||
|
||||
export default function GroupCallModal({ isOpen, onClose, chatId, chatName, callType: initialCallType }: GroupCallModalProps) {
|
||||
const { t } = useLang();
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isVideoOff, setIsVideoOff] = useState(initialCallType === 'voice');
|
||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||
const [isMinimized, setIsMinimized] = useState(false);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [participants, setParticipants] = useState<Map<string, ParticipantInfo>>(new Map());
|
||||
const [remoteVolume, setRemoteVolume] = useState(1);
|
||||
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
|
||||
const [noiseSuppression, setNoiseSuppression] = useState(false);
|
||||
const [joined, setJoined] = useState(false);
|
||||
const [microphones, setMicrophones] = useState<MediaDeviceInfo[]>([]);
|
||||
const [activeMicId, setActiveMicId] = useState<string>('');
|
||||
const [showMicMenu, setShowMicMenu] = useState(false);
|
||||
|
||||
const localStreamRef = useRef<MediaStream | null>(null);
|
||||
const screenStreamRef = useRef<MediaStream | null>(null);
|
||||
const peersRef = useRef<Map<string, PeerState>>(new Map());
|
||||
const localVideoRef = useRef<HTMLVideoElement>(null);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const joinedRef = useRef(false);
|
||||
const remoteAudioRefs = useRef<Map<string, HTMLAudioElement>>(new Map());
|
||||
// Force re-render when peer video state changes
|
||||
const [, forceUpdate] = useState(0);
|
||||
// Noise gate refs
|
||||
const noiseGateCtxRef = useRef<AudioContext | null>(null);
|
||||
const noiseGateGainRef = useRef<GainNode | null>(null);
|
||||
const noiseGateRafRef = useRef<number>(0);
|
||||
const noiseGateTrackRef = useRef<MediaStreamTrack | null>(null);
|
||||
|
||||
const cleanup = useCallback(() => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
localStreamRef.current = null;
|
||||
}
|
||||
if (screenStreamRef.current) {
|
||||
screenStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
}
|
||||
for (const [, peer] of peersRef.current) {
|
||||
peer.pc.close();
|
||||
}
|
||||
peersRef.current.clear();
|
||||
remoteAudioRefs.current.clear();
|
||||
// Clean up noise gate
|
||||
if (noiseGateRafRef.current) cancelAnimationFrame(noiseGateRafRef.current);
|
||||
if (noiseGateTrackRef.current) { noiseGateTrackRef.current.stop(); noiseGateTrackRef.current = null; }
|
||||
if (noiseGateCtxRef.current) { noiseGateCtxRef.current.close().catch(() => {}); noiseGateCtxRef.current = null; }
|
||||
noiseGateGainRef.current = null;
|
||||
setParticipants(new Map());
|
||||
setDuration(0);
|
||||
setIsMuted(false);
|
||||
setIsVideoOff(initialCallType === 'voice');
|
||||
setIsScreenSharing(false);
|
||||
setIsMinimized(false);
|
||||
setJoined(false);
|
||||
joinedRef.current = false;
|
||||
setNoiseSuppression(false);
|
||||
setShowMicMenu(false);
|
||||
}, [initialCallType]);
|
||||
|
||||
const createPeerConnection = useCallback(async (targetUserId: string, initiator: boolean) => {
|
||||
const iceConfig = await getIceServers();
|
||||
const pc = new RTCPeerConnection(iceConfig);
|
||||
const remoteStream = new MediaStream();
|
||||
|
||||
const peerState: PeerState = { pc, remoteStream, hasVideo: false };
|
||||
peersRef.current.set(targetUserId, peerState);
|
||||
|
||||
// Add local tracks
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getTracks().forEach(track => pc.addTrack(track, localStreamRef.current!));
|
||||
}
|
||||
|
||||
pc.onicecandidate = (e) => {
|
||||
if (e.candidate) {
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_ice_candidate', { chatId, targetUserId, candidate: e.candidate });
|
||||
}
|
||||
};
|
||||
|
||||
pc.ontrack = (e) => {
|
||||
if (!remoteStream.getTracks().includes(e.track)) {
|
||||
remoteStream.addTrack(e.track);
|
||||
}
|
||||
const hasVid = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
|
||||
peerState.hasVideo = hasVid;
|
||||
|
||||
e.track.onunmute = () => {
|
||||
peerState.hasVideo = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
|
||||
forceUpdate(n => n + 1);
|
||||
};
|
||||
e.track.onmute = () => {
|
||||
peerState.hasVideo = remoteStream.getVideoTracks().some(t => t.readyState === 'live' && t.enabled && !t.muted);
|
||||
forceUpdate(n => n + 1);
|
||||
};
|
||||
|
||||
// Play audio through audio element
|
||||
const audioEl = remoteAudioRefs.current.get(targetUserId);
|
||||
if (audioEl && audioEl.srcObject !== remoteStream) {
|
||||
audioEl.srcObject = remoteStream;
|
||||
audioEl.volume = remoteVolume;
|
||||
audioEl.play().catch(() => {});
|
||||
}
|
||||
|
||||
forceUpdate(n => n + 1);
|
||||
};
|
||||
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (pc.connectionState === 'failed' || pc.connectionState === 'disconnected') {
|
||||
console.warn(`[GroupCall] Peer ${targetUserId} connection ${pc.connectionState}`);
|
||||
}
|
||||
};
|
||||
|
||||
if (initiator) {
|
||||
const offer = await pc.createOffer();
|
||||
await pc.setLocalDescription(offer);
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_call_offer', { chatId, targetUserId, offer: pc.localDescription });
|
||||
}
|
||||
|
||||
return peerState;
|
||||
}, [chatId, remoteVolume]);
|
||||
|
||||
const joinCall = useCallback(async () => {
|
||||
if (joinedRef.current) return;
|
||||
joinedRef.current = true;
|
||||
|
||||
try {
|
||||
const wantVideo = initialCallType === 'video';
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true },
|
||||
video: wantVideo,
|
||||
}).catch(async () => {
|
||||
// Fallback to audio only
|
||||
return navigator.mediaDevices.getUserMedia({ audio: { noiseSuppression: true, echoCancellation: true } });
|
||||
});
|
||||
|
||||
localStreamRef.current = stream;
|
||||
if (!stream.getVideoTracks().length) setIsVideoOff(true);
|
||||
|
||||
const audioSettings = stream.getAudioTracks()[0]?.getSettings();
|
||||
if (audioSettings?.deviceId) setActiveMicId(audioSettings.deviceId);
|
||||
refreshMicrophones();
|
||||
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_call_join', { chatId, callType: initialCallType });
|
||||
setJoined(true);
|
||||
|
||||
timerRef.current = setInterval(() => setDuration(d => d + 1), 1000);
|
||||
} catch (err: any) {
|
||||
console.error('Error joining group call:', err);
|
||||
if (err?.name === 'NotAllowedError' || err?.name === 'NotFoundError') {
|
||||
alert('Разрешите доступ к микрофону в настройках браузера для совершения звонков');
|
||||
}
|
||||
joinedRef.current = false;
|
||||
}
|
||||
}, [chatId, initialCallType, t]);
|
||||
|
||||
const leaveCall = useCallback(() => {
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_call_leave', { chatId });
|
||||
cleanup();
|
||||
onClose();
|
||||
}, [chatId, cleanup, onClose]);
|
||||
|
||||
// Toggle mic
|
||||
const toggleMic = useCallback(() => {
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getAudioTracks().forEach(t => { t.enabled = !t.enabled; });
|
||||
setIsMuted(m => !m);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Toggle video
|
||||
const toggleVideo = useCallback(async () => {
|
||||
if (!isVideoOff) {
|
||||
// Turn off video
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = false; });
|
||||
}
|
||||
setIsVideoOff(true);
|
||||
} else {
|
||||
// Turn on video
|
||||
if (localStreamRef.current?.getVideoTracks().some(t => t.readyState === 'live')) {
|
||||
localStreamRef.current.getVideoTracks().forEach(t => { t.enabled = true; });
|
||||
setIsVideoOff(false);
|
||||
} else {
|
||||
try {
|
||||
const camStream = await navigator.mediaDevices.getUserMedia({ video: true });
|
||||
const videoTrack = camStream.getVideoTracks()[0];
|
||||
if (videoTrack && localStreamRef.current) {
|
||||
localStreamRef.current.addTrack(videoTrack);
|
||||
// Add to all peer connections
|
||||
for (const [, peer] of peersRef.current) {
|
||||
peer.pc.addTrack(videoTrack, localStreamRef.current);
|
||||
const offer = await peer.pc.createOffer();
|
||||
await peer.pc.setLocalDescription(offer);
|
||||
}
|
||||
setIsVideoOff(false);
|
||||
}
|
||||
} catch { console.warn('Camera unavailable'); }
|
||||
}
|
||||
}
|
||||
}, [isVideoOff]);
|
||||
|
||||
// Toggle screen share
|
||||
const toggleScreenShare = useCallback(async () => {
|
||||
if (isScreenSharing) {
|
||||
if (screenStreamRef.current) {
|
||||
screenStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
}
|
||||
// Replace screen track with null on all peers
|
||||
for (const [targetUserId, peer] of peersRef.current) {
|
||||
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'video');
|
||||
if (sender) {
|
||||
await sender.replaceTrack(null);
|
||||
const transceiver = peer.pc.getTransceivers().find(t => t.sender === sender);
|
||||
if (transceiver) transceiver.direction = 'recvonly';
|
||||
const offer = await peer.pc.createOffer();
|
||||
await peer.pc.setLocalDescription(offer);
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
|
||||
}
|
||||
}
|
||||
setIsScreenSharing(false);
|
||||
} else {
|
||||
try {
|
||||
const screenStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { width: { ideal: 1920 }, height: { ideal: 1080 }, frameRate: { ideal: 30 } },
|
||||
audio: false,
|
||||
});
|
||||
screenStreamRef.current = screenStream;
|
||||
const screenTrack = screenStream.getVideoTracks()[0];
|
||||
|
||||
for (const [targetUserId, peer] of peersRef.current) {
|
||||
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'video');
|
||||
if (sender) {
|
||||
await sender.replaceTrack(screenTrack);
|
||||
const transceiver = peer.pc.getTransceivers().find(t => t.sender === sender);
|
||||
if (transceiver && (transceiver.direction === 'recvonly' || transceiver.direction === 'inactive')) {
|
||||
transceiver.direction = 'sendrecv';
|
||||
const offer = await peer.pc.createOffer();
|
||||
await peer.pc.setLocalDescription(offer);
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
|
||||
}
|
||||
} else {
|
||||
peer.pc.addTrack(screenTrack, localStreamRef.current || screenStream);
|
||||
const offer = await peer.pc.createOffer();
|
||||
await peer.pc.setLocalDescription(offer);
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_call_renegotiate', { chatId, targetUserId, offer: peer.pc.localDescription });
|
||||
}
|
||||
}
|
||||
|
||||
screenTrack.onended = () => {
|
||||
setIsScreenSharing(false);
|
||||
if (screenStreamRef.current) {
|
||||
screenStreamRef.current.getTracks().forEach(t => t.stop());
|
||||
screenStreamRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
setIsScreenSharing(true);
|
||||
} catch { console.error('Screen share failed'); }
|
||||
}
|
||||
}, [isScreenSharing, chatId]);
|
||||
|
||||
// Noise gate with look-ahead: analyse undelayed signal, gate delayed signal
|
||||
const applyNoiseGate = useCallback(async () => {
|
||||
if (!localStreamRef.current) return;
|
||||
const rawTrack = localStreamRef.current.getAudioTracks()[0];
|
||||
if (!rawTrack) return;
|
||||
try {
|
||||
const ctx = new AudioContext();
|
||||
const source = ctx.createMediaStreamSource(new MediaStream([rawTrack]));
|
||||
|
||||
// Look-ahead delay: signal is delayed so gate can open before audio arrives
|
||||
const delayNode = ctx.createDelay(0.2);
|
||||
delayNode.delayTime.value = 0.05; // 50ms look-ahead
|
||||
|
||||
// Analysis path: bandpass on voice fundamentals (undelayed, sees audio early)
|
||||
const analysisHP = ctx.createBiquadFilter();
|
||||
analysisHP.type = 'highpass'; analysisHP.frequency.value = 100; analysisHP.Q.value = 0.7;
|
||||
const analysisLP = ctx.createBiquadFilter();
|
||||
analysisLP.type = 'lowpass'; analysisLP.frequency.value = 4000; analysisLP.Q.value = 0.7;
|
||||
|
||||
const analyser = ctx.createAnalyser();
|
||||
analyser.fftSize = 2048;
|
||||
analyser.smoothingTimeConstant = 0.3;
|
||||
|
||||
const gainNode = ctx.createGain();
|
||||
gainNode.gain.value = 0;
|
||||
const dest = ctx.createMediaStreamDestination();
|
||||
|
||||
// Signal path: source → delay → gain → output (full quality, just gated)
|
||||
source.connect(delayNode);
|
||||
delayNode.connect(gainNode);
|
||||
gainNode.connect(dest);
|
||||
|
||||
// Analysis path (no delay): source → bandpass → analyser
|
||||
source.connect(analysisHP);
|
||||
analysisHP.connect(analysisLP);
|
||||
analysisLP.connect(analyser);
|
||||
|
||||
const dataArray = new Float32Array(analyser.fftSize);
|
||||
const OPEN_THRESHOLD = -38;
|
||||
const CLOSE_THRESHOLD = -48;
|
||||
const CONFIRM_MS = 22;
|
||||
const HOLD_TIME = 150;
|
||||
const ATTACK = 0.002;
|
||||
const RELEASE = 0.02;
|
||||
|
||||
// State machine: 0=closed, 1=pending, 2=open
|
||||
let state = 0;
|
||||
let pendingSince = 0;
|
||||
let holdUntil = 0;
|
||||
|
||||
const check = () => {
|
||||
analyser.getFloatTimeDomainData(dataArray);
|
||||
let sum = 0;
|
||||
for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i];
|
||||
const rms = Math.sqrt(sum / dataArray.length);
|
||||
const db = rms > 0 ? 20 * Math.log10(rms) : -100;
|
||||
const now = performance.now();
|
||||
|
||||
if (state === 0) {
|
||||
if (db > OPEN_THRESHOLD) {
|
||||
state = 1;
|
||||
pendingSince = now;
|
||||
}
|
||||
} else if (state === 1) {
|
||||
if (db <= CLOSE_THRESHOLD) {
|
||||
state = 0;
|
||||
} else if (now - pendingSince >= CONFIRM_MS) {
|
||||
state = 2;
|
||||
holdUntil = now + HOLD_TIME;
|
||||
gainNode.gain.setTargetAtTime(1, ctx.currentTime, ATTACK);
|
||||
}
|
||||
} else {
|
||||
if (db > CLOSE_THRESHOLD) {
|
||||
holdUntil = now + HOLD_TIME;
|
||||
}
|
||||
if (now >= holdUntil) {
|
||||
state = 0;
|
||||
gainNode.gain.setTargetAtTime(0, ctx.currentTime, RELEASE);
|
||||
}
|
||||
}
|
||||
noiseGateRafRef.current = requestAnimationFrame(check);
|
||||
};
|
||||
check();
|
||||
|
||||
const gatedTrack = dest.stream.getAudioTracks()[0];
|
||||
gatedTrack.enabled = !isMuted;
|
||||
|
||||
// Replace on all peers
|
||||
for (const [, peer] of peersRef.current) {
|
||||
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'audio');
|
||||
if (sender) await sender.replaceTrack(gatedTrack);
|
||||
}
|
||||
|
||||
noiseGateCtxRef.current = ctx;
|
||||
noiseGateGainRef.current = gainNode;
|
||||
noiseGateTrackRef.current = gatedTrack;
|
||||
setNoiseSuppression(true);
|
||||
} catch (err) {
|
||||
console.error('Failed to apply noise gate:', err);
|
||||
}
|
||||
}, [isMuted]);
|
||||
|
||||
const removeNoiseGate = useCallback(async () => {
|
||||
// Restore raw mic track to all peers
|
||||
if (localStreamRef.current) {
|
||||
const rawTrack = localStreamRef.current.getAudioTracks()[0];
|
||||
if (rawTrack) {
|
||||
for (const [, peer] of peersRef.current) {
|
||||
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'audio');
|
||||
if (sender) await sender.replaceTrack(rawTrack);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (noiseGateRafRef.current) { cancelAnimationFrame(noiseGateRafRef.current); noiseGateRafRef.current = 0; }
|
||||
if (noiseGateTrackRef.current) { noiseGateTrackRef.current.stop(); noiseGateTrackRef.current = null; }
|
||||
if (noiseGateCtxRef.current) { noiseGateCtxRef.current.close().catch(() => {}); noiseGateCtxRef.current = null; }
|
||||
noiseGateGainRef.current = null;
|
||||
setNoiseSuppression(false);
|
||||
}, []);
|
||||
|
||||
const toggleNoiseSuppression = useCallback(async () => {
|
||||
if (noiseSuppression) {
|
||||
await removeNoiseGate();
|
||||
} else {
|
||||
await applyNoiseGate();
|
||||
}
|
||||
}, [noiseSuppression, applyNoiseGate, removeNoiseGate]);
|
||||
|
||||
// Enumerate microphones
|
||||
const refreshMicrophones = useCallback(async () => {
|
||||
try {
|
||||
const devices = await navigator.mediaDevices.enumerateDevices();
|
||||
const audioInputs = devices.filter(d => d.kind === 'audioinput');
|
||||
setMicrophones(audioInputs);
|
||||
return audioInputs;
|
||||
} catch { return []; }
|
||||
}, []);
|
||||
|
||||
// Switch microphone
|
||||
const switchMicrophone = useCallback(async (deviceId: string) => {
|
||||
setShowMicMenu(false);
|
||||
try {
|
||||
const newStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: { deviceId: { exact: deviceId }, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
||||
});
|
||||
const newTrack = newStream.getAudioTracks()[0];
|
||||
if (!newTrack) return;
|
||||
newTrack.enabled = !isMuted;
|
||||
|
||||
const wasGated = noiseSuppression;
|
||||
if (wasGated) await removeNoiseGate();
|
||||
|
||||
if (localStreamRef.current) {
|
||||
localStreamRef.current.getAudioTracks().forEach(t => {
|
||||
localStreamRef.current!.removeTrack(t);
|
||||
t.stop();
|
||||
});
|
||||
localStreamRef.current.addTrack(newTrack);
|
||||
}
|
||||
for (const [, peer] of peersRef.current) {
|
||||
const sender = peer.pc.getSenders().find(s => s.track?.kind === 'audio');
|
||||
if (sender) await sender.replaceTrack(newTrack);
|
||||
}
|
||||
setActiveMicId(deviceId);
|
||||
if (wasGated) await applyNoiseGate();
|
||||
} catch (err) {
|
||||
console.error('Switch mic failed:', err);
|
||||
}
|
||||
}, [isMuted, noiseSuppression, removeNoiseGate, applyNoiseGate]);
|
||||
|
||||
// Volume
|
||||
const handleVolumeChange = useCallback((vol: number) => {
|
||||
const v = Math.max(0, Math.min(1, vol));
|
||||
setRemoteVolume(v);
|
||||
for (const [, el] of remoteAudioRefs.current) {
|
||||
el.volume = v;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Socket event handlers
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
if (!socket || !isOpen) return;
|
||||
|
||||
const onParticipants = async (data: { chatId: string; participants: ParticipantInfo[] }) => {
|
||||
if (data.chatId !== chatId) return;
|
||||
const newMap = new Map<string, ParticipantInfo>();
|
||||
for (const p of data.participants) {
|
||||
newMap.set(p.id, p);
|
||||
// Create peer connection to each existing participant (we are the initiator)
|
||||
if (!peersRef.current.has(p.id)) {
|
||||
await createPeerConnection(p.id, true);
|
||||
}
|
||||
}
|
||||
setParticipants(prev => {
|
||||
const merged = new Map(prev);
|
||||
for (const [k, v] of newMap) merged.set(k, v);
|
||||
return merged;
|
||||
});
|
||||
};
|
||||
|
||||
const onUserJoined = (data: { chatId: string; userId: string; userInfo: ParticipantInfo }) => {
|
||||
if (data.chatId !== chatId) return;
|
||||
setParticipants(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(data.userId, data.userInfo);
|
||||
return next;
|
||||
});
|
||||
// The new joiner will send us an offer — we wait for it
|
||||
};
|
||||
|
||||
const onUserLeft = (data: { chatId: string; userId: string }) => {
|
||||
if (data.chatId !== chatId) return;
|
||||
const peer = peersRef.current.get(data.userId);
|
||||
if (peer) {
|
||||
peer.pc.close();
|
||||
peersRef.current.delete(data.userId);
|
||||
}
|
||||
remoteAudioRefs.current.delete(data.userId);
|
||||
setParticipants(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(data.userId);
|
||||
return next;
|
||||
});
|
||||
forceUpdate(n => n + 1);
|
||||
};
|
||||
|
||||
const onOffer = async (data: { chatId: string; from: string; offer: RTCSessionDescriptionInit }) => {
|
||||
if (data.chatId !== chatId) return;
|
||||
let peerState = peersRef.current.get(data.from);
|
||||
if (!peerState) {
|
||||
peerState = await createPeerConnection(data.from, false);
|
||||
}
|
||||
await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.offer));
|
||||
const answer = await peerState.pc.createAnswer();
|
||||
await peerState.pc.setLocalDescription(answer);
|
||||
socket.emit('group_call_answer', { chatId, targetUserId: data.from, answer: peerState.pc.localDescription });
|
||||
};
|
||||
|
||||
const onAnswer = async (data: { chatId: string; from: string; answer: RTCSessionDescriptionInit }) => {
|
||||
if (data.chatId !== chatId) return;
|
||||
const peerState = peersRef.current.get(data.from);
|
||||
if (peerState) {
|
||||
await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.answer));
|
||||
}
|
||||
};
|
||||
|
||||
const onIceCandidate = (data: { chatId: string; from: string; candidate: RTCIceCandidateInit }) => {
|
||||
if (data.chatId !== chatId) return;
|
||||
const peerState = peersRef.current.get(data.from);
|
||||
if (peerState?.pc.remoteDescription) {
|
||||
peerState.pc.addIceCandidate(new RTCIceCandidate(data.candidate)).catch(console.error);
|
||||
}
|
||||
};
|
||||
|
||||
const onRenegotiate = async (data: { chatId: string; from: string; offer: RTCSessionDescriptionInit }) => {
|
||||
if (data.chatId !== chatId) return;
|
||||
const peerState = peersRef.current.get(data.from);
|
||||
if (!peerState) return;
|
||||
await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.offer));
|
||||
const answer = await peerState.pc.createAnswer();
|
||||
await peerState.pc.setLocalDescription(answer);
|
||||
socket.emit('group_call_renegotiate_answer', { chatId, targetUserId: data.from, answer: peerState.pc.localDescription });
|
||||
};
|
||||
|
||||
const onRenegotiateAnswer = async (data: { chatId: string; from: string; answer: RTCSessionDescriptionInit }) => {
|
||||
if (data.chatId !== chatId) return;
|
||||
const peerState = peersRef.current.get(data.from);
|
||||
if (peerState) {
|
||||
await peerState.pc.setRemoteDescription(new RTCSessionDescription(data.answer));
|
||||
}
|
||||
};
|
||||
|
||||
socket.on('group_call_participants', onParticipants);
|
||||
socket.on('group_call_user_joined', onUserJoined);
|
||||
socket.on('group_call_user_left', onUserLeft);
|
||||
socket.on('group_call_offer', onOffer);
|
||||
socket.on('group_call_answer', onAnswer);
|
||||
socket.on('group_ice_candidate', onIceCandidate);
|
||||
socket.on('group_call_renegotiate', onRenegotiate);
|
||||
socket.on('group_call_renegotiate_answer', onRenegotiateAnswer);
|
||||
|
||||
return () => {
|
||||
socket.off('group_call_participants', onParticipants);
|
||||
socket.off('group_call_user_joined', onUserJoined);
|
||||
socket.off('group_call_user_left', onUserLeft);
|
||||
socket.off('group_call_offer', onOffer);
|
||||
socket.off('group_call_answer', onAnswer);
|
||||
socket.off('group_ice_candidate', onIceCandidate);
|
||||
socket.off('group_call_renegotiate', onRenegotiate);
|
||||
socket.off('group_call_renegotiate_answer', onRenegotiateAnswer);
|
||||
};
|
||||
}, [isOpen, chatId, createPeerConnection]);
|
||||
|
||||
// Auto-join on open
|
||||
useEffect(() => {
|
||||
if (isOpen && !joined) {
|
||||
joinCall();
|
||||
}
|
||||
}, [isOpen, joined, joinCall]);
|
||||
|
||||
// Sync local video
|
||||
useEffect(() => {
|
||||
if (!localVideoRef.current) return;
|
||||
const desired = isScreenSharing && screenStreamRef.current ? screenStreamRef.current : localStreamRef.current;
|
||||
if (desired && localVideoRef.current.srcObject !== desired) {
|
||||
localVideoRef.current.srcObject = desired;
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (joinedRef.current) {
|
||||
const socket = getSocket();
|
||||
socket?.emit('group_call_leave', { chatId });
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
}, [chatId, cleanup]);
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = sec % 60;
|
||||
return `${m.toString().padStart(2, '0')}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const participantList = Array.from(participants.values());
|
||||
const hasLocalVideo = !!(localStreamRef.current?.getVideoTracks().some(t => t.enabled) || isScreenSharing);
|
||||
|
||||
// Grid columns based on participant count
|
||||
const totalStreams = participantList.length + 1; // +1 for self
|
||||
const gridCols = totalStreams <= 1 ? 1 : totalStreams <= 4 ? 2 : 3;
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{/* Hidden audio elements for each remote participant */}
|
||||
{participantList.map(p => (
|
||||
<audio
|
||||
key={`audio-${p.id}`}
|
||||
ref={el => { if (el) remoteAudioRefs.current.set(p.id, el); }}
|
||||
autoPlay
|
||||
playsInline
|
||||
/>
|
||||
))}
|
||||
|
||||
{isMinimized && joined ? (
|
||||
<motion.div
|
||||
key="group-call-minimized"
|
||||
initial={{ opacity: 0, y: 50, scale: 0.8 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 50, scale: 0.8 }}
|
||||
className="fixed bottom-6 right-6 z-[100] flex items-center gap-3 px-4 py-3 rounded-2xl glass-strong shadow-2xl shadow-black/50 border border-white/10 cursor-pointer select-none"
|
||||
onClick={() => setIsMinimized(false)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-full bg-emerald-500/30 animate-call-wave" />
|
||||
<div className="relative w-10 h-10 rounded-full bg-gradient-to-br from-emerald-500 to-teal-600 flex items-center justify-center text-white font-bold text-sm">
|
||||
{participantList.length + 1}
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-white font-medium truncate max-w-[120px]">{chatName}</p>
|
||||
<p className="text-xs text-zinc-400 font-mono">{formatDuration(duration)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2" onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={toggleMic}
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
||||
>
|
||||
{isMuted ? <MicOff size={14} /> : <Mic size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={leaveCall}
|
||||
className="w-8 h-8 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white transition-colors"
|
||||
>
|
||||
<PhoneOff size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="group-call-overlay"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] flex items-center justify-center bg-surface/90 backdrop-blur-xl overflow-hidden"
|
||||
onClick={() => setShowVolumeSlider(false)}
|
||||
>
|
||||
<div className="absolute inset-0 pointer-events-none opacity-40">
|
||||
<div className="absolute top-[10%] left-[20%] w-[50vh] h-[50vh] bg-emerald-500/30 rounded-full blur-[120px] animate-float" />
|
||||
<div className="absolute bottom-[10%] right-[20%] w-[50vh] h-[50vh] bg-vortex-500/20 rounded-full blur-[120px] animate-float-delayed" />
|
||||
</div>
|
||||
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="relative w-full max-w-5xl mx-4 rounded-[2.5rem] glass-strong shadow-2xl shadow-black/50 overflow-hidden border border-white/5"
|
||||
>
|
||||
{/* Volume slider popup */}
|
||||
{showVolumeSlider && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[200]" onClick={() => setShowVolumeSlider(false)} />
|
||||
<div className="fixed z-[201] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[250px] rounded-xl bg-zinc-800/95 backdrop-blur-md border border-zinc-600 shadow-2xl p-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="text-xs text-zinc-400 uppercase tracking-wider mb-3">{t('volume')}</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Volume2 size={16} className="text-zinc-400 shrink-0" />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={remoteVolume}
|
||||
onChange={e => handleVolumeChange(parseFloat(e.target.value))}
|
||||
className="w-full h-1.5 rounded-full appearance-none bg-zinc-600 accent-vortex-500 cursor-pointer"
|
||||
/>
|
||||
<span className="text-xs text-zinc-300 w-8 text-right">{Math.round(remoteVolume * 100)}%</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{/* Mic selector popup */}
|
||||
{showMicMenu && microphones.length > 0 && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[200]" onClick={() => setShowMicMenu(false)} />
|
||||
<div className="fixed z-[201] left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[320px] rounded-xl bg-zinc-800/95 backdrop-blur-md border border-zinc-600 shadow-2xl py-2">
|
||||
<div className="px-3 py-1.5 text-xs text-zinc-400 uppercase tracking-wider border-b border-zinc-700 mb-1">{t('selectMicrophone')}</div>
|
||||
{microphones.map((mic, i) => (
|
||||
<button
|
||||
key={mic.deviceId}
|
||||
onClick={() => switchMicrophone(mic.deviceId)}
|
||||
className={`w-full text-left px-4 py-2.5 text-sm transition-colors ${activeMicId === mic.deviceId
|
||||
? 'text-vortex-400 bg-vortex-500/20 font-medium'
|
||||
: 'text-zinc-200 hover:bg-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{mic.label || `${t('microphone')} ${i + 1}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-8 py-4 border-b border-white/5">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-white">{chatName}</h3>
|
||||
<p className="text-xs text-zinc-400">{participantList.length + 1} {t('participants') || 'участников'} · {formatDuration(duration)}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMinimized(true)}
|
||||
className="w-8 h-8 rounded-full bg-white/10 flex items-center justify-center text-white/70 hover:text-white transition-colors"
|
||||
title={t('minimize')}
|
||||
>
|
||||
<Minimize2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Participant grid */}
|
||||
<div className="p-4" style={{ minHeight: '300px', maxHeight: '60vh', overflowY: 'auto' }}>
|
||||
<div
|
||||
className="grid gap-3"
|
||||
style={{ gridTemplateColumns: `repeat(${gridCols}, 1fr)` }}
|
||||
>
|
||||
{/* Self */}
|
||||
<div className="relative bg-zinc-900 rounded-2xl overflow-hidden aspect-video flex items-center justify-center border border-white/5">
|
||||
{hasLocalVideo ? (
|
||||
<video ref={localVideoRef} autoPlay playsInline muted className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">
|
||||
{t('you')?.charAt(0).toUpperCase() || 'Я'}
|
||||
</div>
|
||||
{isMuted && <MicOff size={14} className="text-red-400" />}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white">
|
||||
{t('you')} {isMuted ? '🔇' : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remote participants */}
|
||||
{participantList.map(p => {
|
||||
const peer = peersRef.current.get(p.id);
|
||||
const hasVid = peer?.hasVideo;
|
||||
const initials = (p.displayName || p.username).split(' ').map(w => w[0]).join('').slice(0, 2).toUpperCase();
|
||||
|
||||
return (
|
||||
<div key={p.id} className="relative bg-zinc-900 rounded-2xl overflow-hidden aspect-video flex items-center justify-center border border-white/5 cursor-pointer" title={t('rightClickVolume')} onContextMenu={(e) => { e.preventDefault(); setShowVolumeSlider(true); }}>
|
||||
{hasVid ? (
|
||||
<video
|
||||
autoPlay
|
||||
playsInline
|
||||
muted
|
||||
ref={el => {
|
||||
if (el && peer?.remoteStream && el.srcObject !== peer.remoteStream) {
|
||||
el.srcObject = peer.remoteStream;
|
||||
}
|
||||
}}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center">
|
||||
{p.avatar ? (
|
||||
<img src={p.avatar} alt="" className="w-16 h-16 rounded-full object-cover mb-2" />
|
||||
) : (
|
||||
<div className="w-16 h-16 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-xl mb-2">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-2 left-2 px-2 py-0.5 rounded-full bg-black/60 text-xs text-white truncate max-w-[80%]">
|
||||
{p.displayName || p.username}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="px-8 pb-8 pt-4 flex items-center justify-center gap-4 flex-wrap">
|
||||
{/* Mic with dropdown */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={toggleMic}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isMuted ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
||||
title={isMuted ? t('unmute') : t('mute')}
|
||||
>
|
||||
{isMuted ? <MicOff size={18} /> : <Mic size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={async (e) => { e.stopPropagation(); await refreshMicrophones(); setShowMicMenu(!showMicMenu); setShowVolumeSlider(false); }}
|
||||
className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-zinc-700 hover:bg-zinc-600 flex items-center justify-center text-white/70 hover:text-white transition-colors border border-zinc-600"
|
||||
>
|
||||
<ChevronUp size={10} />
|
||||
</button>
|
||||
</div>
|
||||
{/* Camera — only for video calls */}
|
||||
{initialCallType === 'video' && (
|
||||
<button
|
||||
onClick={toggleVideo}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isVideoOff ? 'bg-red-500/20 text-red-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
||||
>
|
||||
{isVideoOff ? <VideoOff size={18} /> : <Video size={18} />}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleScreenShare}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${isScreenSharing ? 'bg-vortex-500/30 text-vortex-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
||||
title={isScreenSharing ? t('stopScreenShare') : t('screenShare')}
|
||||
>
|
||||
{isScreenSharing ? <MonitorOff size={18} /> : <Monitor size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); setShowVolumeSlider(!showVolumeSlider); }}
|
||||
className="w-11 h-11 rounded-full flex items-center justify-center transition-colors bg-white/10 text-white hover:bg-white/20"
|
||||
title={t('volume')}
|
||||
>
|
||||
<Volume2 size={18} />
|
||||
</button>
|
||||
<button
|
||||
onClick={toggleNoiseSuppression}
|
||||
className={`w-11 h-11 rounded-full flex items-center justify-center transition-colors ${noiseSuppression ? 'bg-emerald-500/20 text-emerald-400' : 'bg-white/10 text-white hover:bg-white/20'}`}
|
||||
title={noiseSuppression ? t('noiseSuppressionOn') : t('noiseSuppressionOff')}
|
||||
>
|
||||
{noiseSuppression ? <ShieldCheck size={18} /> : <ShieldOff size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={leaveCall}
|
||||
className="w-14 h-14 rounded-full bg-red-500 hover:bg-red-600 flex items-center justify-center text-white shadow-xl shadow-red-500/30 transition-all hover:scale-105 ml-2"
|
||||
>
|
||||
<PhoneOff size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
423
apps/web/src/components/GroupSettings.tsx
Normal file
423
apps/web/src/components/GroupSettings.tsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
Camera,
|
||||
Edit3,
|
||||
Check,
|
||||
Loader2,
|
||||
UserPlus,
|
||||
Trash2,
|
||||
Search,
|
||||
Crown,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { Chat, UserPresence } from '../lib/types';
|
||||
import ConfirmModal from './ConfirmModal';
|
||||
|
||||
interface GroupSettingsProps {
|
||||
chat: Chat;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function GroupSettings({ chat, onClose }: GroupSettingsProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { updateChat } = useChatStore();
|
||||
const { t } = useLang();
|
||||
|
||||
const currentMember = chat.members.find((m) => m.user.id === user?.id);
|
||||
const [removeTargetId, setRemoveTargetId] = useState<string | null>(null);
|
||||
const isAdmin = currentMember?.role === 'admin';
|
||||
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
const [groupName, setGroupName] = useState(chat.name || '');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [avatarUploading, setAvatarUploading] = useState(false);
|
||||
const [showAddMember, setShowAddMember] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState<UserPresence[]>([]);
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Keep local state in sync with chat prop
|
||||
useEffect(() => {
|
||||
setGroupName(chat.name || '');
|
||||
}, [chat.name]);
|
||||
|
||||
// Search users to add
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
setIsSearching(true);
|
||||
const results = await api.searchUsers(searchQuery);
|
||||
// Filter out users already in the group
|
||||
const memberIds = new Set(chat.members.map((m) => m.user.id));
|
||||
setSearchResults(results.filter((u) => !memberIds.has(u.id)));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchQuery, chat.members]);
|
||||
|
||||
const handleSaveName = async () => {
|
||||
if (!groupName.trim()) return;
|
||||
try {
|
||||
setIsSaving(true);
|
||||
const updatedChat = await api.updateGroup(chat.id, { name: groupName.trim() });
|
||||
updateChat(updatedChat);
|
||||
setIsEditingName(false);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
try {
|
||||
setAvatarUploading(true);
|
||||
const updatedChat = await api.uploadGroupAvatar(chat.id, file);
|
||||
updateChat(updatedChat);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
e.target.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAvatar = async () => {
|
||||
try {
|
||||
setAvatarUploading(true);
|
||||
const updatedChat = await api.removeGroupAvatar(chat.id);
|
||||
updateChat(updatedChat);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setAvatarUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddMember = async (userId: string) => {
|
||||
try {
|
||||
const updatedChat = await api.addGroupMembers(chat.id, [userId]);
|
||||
updateChat(updatedChat);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (userId: string) => {
|
||||
setRemoveTargetId(userId);
|
||||
};
|
||||
|
||||
const confirmRemoveMember = async () => {
|
||||
if (!removeTargetId) return;
|
||||
try {
|
||||
const updatedChat = await api.removeGroupMember(chat.id, removeTargetId);
|
||||
updateChat(updatedChat);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
setRemoveTargetId(null);
|
||||
};
|
||||
|
||||
const initials = (chat.name || 'G')
|
||||
.split(' ')
|
||||
.map((w: string) => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 z-50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50, scale: 0.95 }}
|
||||
animate={{ opacity: 1, x: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, x: 50, scale: 0.95 }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="fixed right-3 top-3 bottom-3 w-[380px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border/40">
|
||||
<h2 className="text-lg font-semibold text-white">{t('groupSettings')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-xl text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Avatar */}
|
||||
<div className="flex flex-col items-center py-8 px-6">
|
||||
<div className="relative group">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-40 h-40 bg-vortex-500/20 rounded-full blur-[40px] pointer-events-none" />
|
||||
<div className="relative z-10 p-1.5 rounded-full bg-gradient-to-br from-white/10 to-transparent backdrop-blur-md border border-white/10 shadow-2xl">
|
||||
{chat.avatar ? (
|
||||
<img
|
||||
src={chat.avatar}
|
||||
alt=""
|
||||
className="w-32 h-32 rounded-full object-cover shadow-inner"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-bold text-4xl shadow-inner">
|
||||
{initials}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={avatarUploading}
|
||||
className="absolute inset-0 rounded-full bg-black/50 opacity-0 group-hover:opacity-100 flex items-center justify-center transition-opacity"
|
||||
>
|
||||
{avatarUploading ? (
|
||||
<Loader2 size={24} className="text-white animate-spin" />
|
||||
) : (
|
||||
<Camera size={24} className="text-white" />
|
||||
)}
|
||||
</button>
|
||||
{chat.avatar && (
|
||||
<button
|
||||
onClick={handleRemoveAvatar}
|
||||
disabled={avatarUploading}
|
||||
className="absolute -top-1 -right-1 w-7 h-7 bg-red-500 hover:bg-red-600 rounded-full flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity shadow-lg"
|
||||
>
|
||||
<X size={14} className="text-white" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleAvatarUpload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group name */}
|
||||
{isEditingName ? (
|
||||
<div className="mt-4 flex items-center gap-2 w-full max-w-[260px]">
|
||||
<input
|
||||
type="text"
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
className="flex-1 text-lg font-bold text-center text-white bg-transparent border-b border-vortex-500 outline-none px-2 py-1"
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') handleSaveName();
|
||||
if (e.key === 'Escape') {
|
||||
setIsEditingName(false);
|
||||
setGroupName(chat.name || '');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={handleSaveName}
|
||||
disabled={isSaving || !groupName.trim()}
|
||||
className="p-1.5 rounded-lg text-emerald-400 hover:bg-emerald-500/10 transition-colors"
|
||||
>
|
||||
{isSaving ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setIsEditingName(false);
|
||||
setGroupName(chat.name || '');
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<h3 className="text-xl font-bold text-white">{chat.name}</h3>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => setIsEditingName(true)}
|
||||
className="p-1 rounded-lg text-zinc-500 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-zinc-400 mt-1 flex items-center gap-1">
|
||||
<Users size={14} />
|
||||
{chat.members.length} {t('members')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Members */}
|
||||
<div className="px-4 pb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-sm font-medium text-zinc-400 uppercase tracking-wider">
|
||||
{t('membersCount')}
|
||||
</h4>
|
||||
{isAdmin && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowAddMember(!showAddMember);
|
||||
if (!showAddMember) {
|
||||
setTimeout(() => searchInputRef.current?.focus(), 100);
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 text-xs text-vortex-400 hover:text-vortex-300 transition-colors"
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
{t('addMember')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add member search */}
|
||||
<AnimatePresence>
|
||||
{showAddMember && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
className="overflow-hidden mb-3"
|
||||
>
|
||||
<div className="relative mb-2">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
ref={searchInputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={t('findUser')}
|
||||
className="w-full pl-8 pr-3 py-2 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
|
||||
/>
|
||||
</div>
|
||||
{isSearching && (
|
||||
<div className="flex justify-center py-2">
|
||||
<Loader2 size={16} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{searchResults.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleAddMember(u.id)}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
{(u.displayName || u.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 text-left min-w-0">
|
||||
<p className="text-sm text-white truncate">{u.displayName || u.username}</p>
|
||||
<p className="text-xs text-zinc-500">@{u.username}</p>
|
||||
</div>
|
||||
<UserPlus size={14} className="text-vortex-400 flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
{searchQuery.trim() && !isSearching && searchResults.length === 0 && (
|
||||
<p className="text-xs text-zinc-500 text-center py-2">{t('usersNotFound')}</p>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Member list */}
|
||||
<div className="space-y-1">
|
||||
{chat.members
|
||||
.sort((a, b) => {
|
||||
if (a.role === 'admin' && b.role !== 'admin') return -1;
|
||||
if (b.role === 'admin' && a.role !== 'admin') return 1;
|
||||
return 0;
|
||||
})
|
||||
.map((member) => (
|
||||
<div
|
||||
key={member.user.id}
|
||||
className="flex items-center gap-3 px-3 py-2.5 rounded-xl hover:bg-surface-hover/50 transition-colors group"
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
{member.user.avatar ? (
|
||||
<img src={member.user.avatar} alt="" className="w-9 h-9 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
|
||||
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
{member.user.isOnline && (
|
||||
<span className="absolute bottom-0 right-0 w-2.5 h-2.5 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{member.user.displayName || member.user.username}
|
||||
{member.user.id === user?.id && (
|
||||
<span className="text-zinc-500 ml-1 text-xs">({t('you') || 'вы'})</span>
|
||||
)}
|
||||
</p>
|
||||
{member.role === 'admin' && (
|
||||
<span className="flex items-center gap-0.5 px-1.5 py-0.5 rounded-md bg-amber-500/10 text-amber-400 text-[10px] font-medium flex-shrink-0">
|
||||
<Crown size={10} />
|
||||
{t('adminBadge')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-zinc-500">@{member.user.username}</p>
|
||||
</div>
|
||||
{isAdmin && member.user.id !== user?.id && member.role !== 'admin' && (
|
||||
<button
|
||||
onClick={() => handleRemoveMember(member.user.id)}
|
||||
className="p-1.5 rounded-lg text-zinc-500 hover:text-red-400 hover:bg-red-500/10 opacity-0 group-hover:opacity-100 transition-all flex-shrink-0"
|
||||
title={t('removeMember')}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<ConfirmModal
|
||||
open={!!removeTargetId}
|
||||
message={t('confirmRemoveMember')}
|
||||
onConfirm={confirmRemoveMember}
|
||||
onCancel={() => setRemoveTargetId(null)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
118
apps/web/src/components/ImageLightbox.tsx
Normal file
118
apps/web/src/components/ImageLightbox.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Download, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
interface ImageLightboxProps {
|
||||
url?: string;
|
||||
images?: { url: string; type?: string }[];
|
||||
initialIndex?: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ImageLightbox({ url, images, initialIndex = 0, onClose }: ImageLightboxProps) {
|
||||
const gallery = images && images.length > 0;
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
const currentUrl = gallery ? images![index].url : url!;
|
||||
const currentType = gallery ? images![index].type : undefined;
|
||||
const total = gallery ? images!.length : 1;
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (gallery) setIndex((i) => (i > 0 ? i - 1 : total - 1));
|
||||
}, [gallery, total]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (gallery) setIndex((i) => (i < total - 1 ? i + 1 : 0));
|
||||
}, [gallery, total]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft') goPrev();
|
||||
if (e.key === 'ArrowRight') goNext();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onClose, goPrev, goNext]);
|
||||
|
||||
return createPortal(
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[9999] bg-black/90 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Top bar */}
|
||||
<div className="absolute top-4 right-4 flex items-center gap-2 z-10">
|
||||
{gallery && total > 1 && (
|
||||
<span className="text-sm text-white/70 mr-2">{index + 1} / {total}</span>
|
||||
)}
|
||||
<a
|
||||
href={currentUrl}
|
||||
download
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<Download size={20} />
|
||||
</a>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Left arrow */}
|
||||
{gallery && total > 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<ChevronLeft size={28} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Right arrow */}
|
||||
{gallery && total > 1 && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 z-10 p-2 rounded-full bg-white/10 hover:bg-white/20 text-white transition-colors"
|
||||
>
|
||||
<ChevronRight size={28} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={currentUrl}
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.8, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="max-w-[90vw] max-h-[90vh] flex items-center justify-center"
|
||||
>
|
||||
{currentType === 'video' ? (
|
||||
<video
|
||||
src={currentUrl}
|
||||
controls
|
||||
autoPlay
|
||||
className="max-w-[90vw] max-h-[90vh] rounded-lg shadow-2xl"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src={currentUrl}
|
||||
alt=""
|
||||
className="max-w-[90vw] max-h-[90vh] object-contain rounded-lg shadow-2xl"
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</motion.div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
829
apps/web/src/components/MessageBubble.tsx
Normal file
829
apps/web/src/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,829 @@
|
||||
import { useState, useRef, useEffect, memo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Check,
|
||||
CheckCheck,
|
||||
Play,
|
||||
Pause,
|
||||
Download,
|
||||
FileText,
|
||||
Copy,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Reply,
|
||||
Smile,
|
||||
MoreHorizontal,
|
||||
X,
|
||||
Volume2,
|
||||
Pin,
|
||||
Clock,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { getSocket } from '../lib/socket';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { extractWaveform } from '../lib/utils';
|
||||
import type { Message, MediaItem, Reaction, ChatMember } from '../lib/types';
|
||||
import ImageLightbox from './ImageLightbox';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: Message;
|
||||
isMine: boolean;
|
||||
showAvatar: boolean;
|
||||
onViewProfile?: (userId: string) => void;
|
||||
selectionMode?: boolean;
|
||||
isSelected?: boolean;
|
||||
onToggleSelect?: (id: string) => void;
|
||||
onStartSelectionMode?: (id: string) => void;
|
||||
}
|
||||
|
||||
function MessageBubble({
|
||||
message,
|
||||
isMine,
|
||||
showAvatar,
|
||||
onViewProfile,
|
||||
selectionMode,
|
||||
isSelected,
|
||||
onToggleSelect,
|
||||
onStartSelectionMode
|
||||
}: MessageBubbleProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { setReplyTo, setEditingMessage, pinnedMessages, chats } = useChatStore();
|
||||
const { t, lang } = useLang();
|
||||
const [showContext, setShowContext] = useState(false);
|
||||
const [contextPos, setContextPos] = useState({ x: 0, y: 0 });
|
||||
const [deleteMenuMode, setDeleteMenuMode] = useState(false);
|
||||
const [lightboxUrl, setLightboxUrl] = useState<string | null>(null);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [audioProgress, setAudioProgress] = useState(0);
|
||||
const [audioDuration, setAudioDuration] = useState(0);
|
||||
const [waveformBars, setWaveformBars] = useState<number[] | null>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const bubbleRef = useRef<HTMLDivElement>(null);
|
||||
const [quotedText, setQuotedText] = useState<string | null>(null);
|
||||
|
||||
// Прочитано
|
||||
const isRead = message.readBy?.some((r) => r.userId !== user?.id);
|
||||
|
||||
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
|
||||
const handleContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation(); // Avoid triggering window listener instantly for other menus
|
||||
if (selectionMode) {
|
||||
onToggleSelect?.(message.id);
|
||||
return;
|
||||
}
|
||||
const rect = bubbleRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
// Check if text is selected inside this bubble
|
||||
const selection = window.getSelection();
|
||||
const text = selection?.toString().trim();
|
||||
if (text && bubbleRef.current?.contains(selection?.anchorNode || null)) {
|
||||
setQuotedText(text);
|
||||
} else {
|
||||
setQuotedText(null);
|
||||
}
|
||||
|
||||
const menuWidth = 208;
|
||||
const menuHeight = 350; // estimate
|
||||
let x = e.clientX;
|
||||
let y = e.clientY;
|
||||
|
||||
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
|
||||
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
|
||||
|
||||
setContextPos({ x, y });
|
||||
setShowContext(true);
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (message.content) {
|
||||
navigator.clipboard.writeText(message.content);
|
||||
}
|
||||
setShowContext(false);
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
setReplyTo({ ...message, quote: quotedText });
|
||||
setShowContext(false);
|
||||
setQuotedText(null);
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
setEditingMessage(message);
|
||||
setShowContext(false);
|
||||
};
|
||||
|
||||
const handleDeleteForAll = () => {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
socket.emit('delete_messages', {
|
||||
messageIds: [message.id],
|
||||
chatId: message.chatId,
|
||||
deleteForAll: true,
|
||||
});
|
||||
}
|
||||
setShowContext(false);
|
||||
setDeleteMenuMode(false);
|
||||
};
|
||||
|
||||
const handleDeleteForMe = () => {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
socket.emit('delete_messages', {
|
||||
messageIds: [message.id],
|
||||
chatId: message.chatId,
|
||||
deleteForAll: false,
|
||||
});
|
||||
}
|
||||
// Optimistic hide
|
||||
useChatStore.getState().hideMessages([message.id], message.chatId);
|
||||
setShowContext(false);
|
||||
setDeleteMenuMode(false);
|
||||
};
|
||||
|
||||
// Имя собеседника для кнопки «Удалить также для ...»
|
||||
const chatForDelete = chats.find(c => c.id === message.chatId);
|
||||
const otherMemberName = chatForDelete?.type === 'personal'
|
||||
? chatForDelete.members.find(m => m.user.id !== user?.id)?.user.displayName
|
||||
|| chatForDelete.members.find(m => m.user.id !== user?.id)?.user.username
|
||||
|| ''
|
||||
: '';
|
||||
|
||||
const isPinned = pinnedMessages[message.chatId]?.id === message.id;
|
||||
|
||||
const handlePin = () => {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
if (isPinned) {
|
||||
socket.emit('unpin_message', { messageId: message.id, chatId: message.chatId });
|
||||
} else {
|
||||
socket.emit('pin_message', { messageId: message.id, chatId: message.chatId });
|
||||
}
|
||||
}
|
||||
setShowContext(false);
|
||||
};
|
||||
|
||||
const handleReaction = (emoji: string) => {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
const existingReaction = message.reactions?.find(
|
||||
(r) => r.userId === user?.id && r.emoji === emoji
|
||||
);
|
||||
if (existingReaction) {
|
||||
socket.emit('remove_reaction', { messageId: message.id, chatId: message.chatId, emoji });
|
||||
} else {
|
||||
socket.emit('add_reaction', { messageId: message.id, chatId: message.chatId, emoji });
|
||||
}
|
||||
}
|
||||
setShowContext(false);
|
||||
};
|
||||
|
||||
// Аудио плеер
|
||||
const toggleAudio = () => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
if (isPlaying) {
|
||||
audio.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
// Ensure audio is loaded before playing
|
||||
if (audio.readyState < 2) {
|
||||
audio.load();
|
||||
}
|
||||
audio.play().then(() => {
|
||||
setIsPlaying(true);
|
||||
}).catch((err) => {
|
||||
console.error('Audio play error:', err);
|
||||
// Try reloading and playing again
|
||||
audio.load();
|
||||
audio.play().then(() => setIsPlaying(true)).catch(console.error);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio) return;
|
||||
|
||||
const onTimeUpdate = () => {
|
||||
if (audio.duration) {
|
||||
setAudioProgress((audio.currentTime / audio.duration) * 100);
|
||||
}
|
||||
};
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
setAudioDuration(audio.duration);
|
||||
};
|
||||
|
||||
const onEnded = () => {
|
||||
setIsPlaying(false);
|
||||
setAudioProgress(0);
|
||||
};
|
||||
|
||||
audio.addEventListener('timeupdate', onTimeUpdate);
|
||||
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.addEventListener('ended', onEnded);
|
||||
|
||||
return () => {
|
||||
audio.removeEventListener('timeupdate', onTimeUpdate);
|
||||
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
audio.removeEventListener('ended', onEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Extract real waveform from voice audio
|
||||
useEffect(() => {
|
||||
const voiceUrl = message.media?.find((m) => m.type === 'voice')?.url;
|
||||
if (!voiceUrl) return;
|
||||
extractWaveform(voiceUrl, 28).then(setWaveformBars);
|
||||
}, [message.media]);
|
||||
|
||||
const formatDuration = (sec: number) => {
|
||||
if (!sec || isNaN(sec) || !isFinite(sec)) return '0:00';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.floor(sec % 60);
|
||||
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Close context menu logic
|
||||
const contextMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!showContext) return;
|
||||
const hideMenu = (e: MouseEvent) => {
|
||||
// Don't close if clicking inside the context menu
|
||||
if (contextMenuRef.current?.contains(e.target as Node)) {
|
||||
return;
|
||||
}
|
||||
setShowContext(false);
|
||||
setDeleteMenuMode(false);
|
||||
};
|
||||
window.addEventListener('click', hideMenu, true);
|
||||
window.addEventListener('contextmenu', hideMenu, true);
|
||||
return () => {
|
||||
window.removeEventListener('click', hideMenu, true);
|
||||
window.removeEventListener('contextmenu', hideMenu, true);
|
||||
};
|
||||
}, [showContext]);
|
||||
|
||||
// Deleted message — auto-hide after 5 seconds
|
||||
const [deletedVisible, setDeletedVisible] = useState(true);
|
||||
useEffect(() => {
|
||||
if (message.isDeleted) {
|
||||
const timer = setTimeout(() => setDeletedVisible(false), 5000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [message.isDeleted]);
|
||||
|
||||
if (message.isDeleted) {
|
||||
if (!deletedVisible) return null;
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 1, height: 'auto' }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className={`flex ${isMine ? 'justify-end' : 'justify-start'} mb-1`}
|
||||
>
|
||||
<div className="px-4 py-2 rounded-2xl text-sm italic text-zinc-600 bg-surface-tertiary/50">
|
||||
{t('messageDeleted')}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
const media = message.media || [];
|
||||
const hasImage = media.some((m) => m.type === 'image');
|
||||
const hasVoice = message.type === 'voice' || media.some((m) => m.type === 'voice');
|
||||
const hasAudio = !hasVoice && (message.type === 'audio' || media.some((m) => m.type === 'audio'));
|
||||
const hasFile = media.some((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video' && m.type !== 'audio');
|
||||
const hasVideo = media.some((m) => m.type === 'video');
|
||||
|
||||
// Группировка реакций
|
||||
const reactionGroups: Record<string, { count: number; users: string[]; isMine: boolean }> = {};
|
||||
(message.reactions || []).forEach((r) => {
|
||||
if (!reactionGroups[r.emoji]) {
|
||||
reactionGroups[r.emoji] = { count: 0, users: [], isMine: false };
|
||||
}
|
||||
reactionGroups[r.emoji].count++;
|
||||
reactionGroups[r.emoji].users.push(r.user?.displayName || r.user?.username || '');
|
||||
if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
|
||||
});
|
||||
|
||||
const senderName = message.sender?.displayName || message.sender?.username || '';
|
||||
const senderAvatar = message.sender?.avatar;
|
||||
|
||||
// Simple Markdown formatter
|
||||
const renderFormattedText = (text: string) => {
|
||||
if (!text) return text;
|
||||
// Split by *, _, ~, ` blocks and @mentions while keeping the delimiters
|
||||
const parts = text.split(/(\*\*[\s\S]*?\*\*|\*[\s\S]*?\*|_[\s\S]*?_|~[\s\S]*?~|`[\s\S]*?`|@\w+)/g);
|
||||
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith('**') && part.endsWith('**')) return <strong key={i} className="font-bold">{part.slice(2, -2)}</strong>;
|
||||
if (part.startsWith('_') && part.endsWith('_')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
|
||||
if (part.startsWith('*') && part.endsWith('*')) return <em key={i} className="italic">{part.slice(1, -1)}</em>;
|
||||
if (part.startsWith('~') && part.endsWith('~')) return <del key={i} className="line-through opacity-80">{part.slice(1, -1)}</del>;
|
||||
if (part.startsWith('`') && part.endsWith('`')) {
|
||||
return <code key={i} className="font-mono text-[13px] bg-black/20 px-1 py-0.5 rounded-[0.35rem]">{part.slice(1, -1)}</code>;
|
||||
}
|
||||
if (part.startsWith('@') && part.length > 1) {
|
||||
const mentionUsername = part.slice(1);
|
||||
return (
|
||||
<span
|
||||
key={i}
|
||||
className="font-semibold text-sky-300 cursor-pointer hover:underline"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
// Find userId by username from chat members in store
|
||||
const chat = chats.find(c => c.id === message.chatId);
|
||||
const members = chat?.members || [];
|
||||
const found = members.find((m) => m.user?.username === mentionUsername);
|
||||
if (found) {
|
||||
onViewProfile?.(found.user.id);
|
||||
}
|
||||
}}
|
||||
>{part}</span>
|
||||
);
|
||||
}
|
||||
return <span key={i}>{part}</span>;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={bubbleRef}
|
||||
className={`flex ${isMine ? 'justify-end' : 'justify-start'} group mb-0.5 relative transition-colors duration-200 ${selectionMode ? 'px-4 -mx-4 cursor-pointer hover:bg-white/5 rounded-xl' : ''
|
||||
} ${isSelected ? 'bg-vortex-500/10 hover:bg-vortex-500/20' : ''}`}
|
||||
onClick={() => {
|
||||
if (selectionMode) onToggleSelect?.(message.id);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Selection Checkbox */}
|
||||
{selectionMode && (
|
||||
<div className="absolute left-1 top-1/2 -translate-y-1/2 w-5 h-5 rounded-full border border-white/30 flex items-center justify-center transition-colors">
|
||||
{isSelected && <div className="w-5 h-5 rounded-full bg-vortex-500 flex items-center justify-center">
|
||||
<Check size={12} className="text-white" />
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Аватар (чужие) */}
|
||||
{!isMine && (
|
||||
<div className="w-8 flex-shrink-0 mr-2 self-end">
|
||||
{showAvatar ? (
|
||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||
{senderAvatar ? (
|
||||
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
|
||||
{senderName[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`max-w-[65%] ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||
{/* Имя отправителя (для групп) */}
|
||||
{!isMine && showAvatar && (
|
||||
<button
|
||||
className="text-xs font-medium text-vortex-400 ml-3 mb-0.5 hover:underline"
|
||||
onClick={() => onViewProfile?.(message.senderId)}
|
||||
>
|
||||
{senderName}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Reply */}
|
||||
{message.replyTo && (
|
||||
<div className={`mx-3 mb-1 px-3 py-1.5 rounded-lg border-l-2 border-vortex-500 bg-vortex-500/10 max-w-full`}>
|
||||
<p className="text-xs font-medium text-vortex-400 truncate">
|
||||
{message.replyTo.sender?.displayName || message.replyTo.sender?.username}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-400 truncate">{message.quote || message.replyTo.content || t('media')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Пузырь */}
|
||||
<div
|
||||
onContextMenu={handleContextMenu}
|
||||
onDoubleClick={handleReply}
|
||||
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
||||
className={`cursor-pointer rounded-[1.25rem] overflow-hidden transition-all duration-300 ${
|
||||
hasImage && !message.content
|
||||
? 'p-0 shadow-none border-none'
|
||||
: isMine
|
||||
? 'bubble-sent text-white shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
|
||||
: 'bubble-received text-zinc-100 shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105'
|
||||
}`}
|
||||
>
|
||||
{/* Рендер пересланного сообщения */}
|
||||
{message.forwardedFrom && (
|
||||
<div className="mb-2 text-xs opacity-90 border-l-[3px] border-white/30 pl-2">
|
||||
<span className="font-medium">{t('forwardedFrom')}: </span>
|
||||
{message.forwardedFrom.displayName || message.forwardedFrom.username}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Изображения */}
|
||||
{hasImage && (
|
||||
<div className={`${message.content ? 'mb-2 -mx-3 -mt-2' : ''} ${!message.content ? 'rounded-[1.25rem]' : ''} bg-black/40 overflow-hidden`}>
|
||||
{media
|
||||
.filter((m) => m.type === 'image')
|
||||
.map((m) => (
|
||||
<img
|
||||
key={m.id}
|
||||
src={m.url}
|
||||
alt=""
|
||||
className="max-w-full max-h-80 object-cover cursor-pointer hover:brightness-90 transition-all"
|
||||
onClick={() => setLightboxUrl(m.url)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Видео */}
|
||||
{hasVideo &&
|
||||
media
|
||||
.filter((m) => m.type === 'video')
|
||||
.map((m) => (
|
||||
<div key={m.id} className={`${message.content ? 'mb-2 -mx-3 -mt-2' : ''}`}>
|
||||
<video
|
||||
src={m.url}
|
||||
controls
|
||||
className="max-w-full max-h-80 rounded-lg"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Голосовое */}
|
||||
{hasVoice && (
|
||||
<div className="flex items-center gap-3 min-w-[200px]">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={media.find((m) => m.type === 'voice')?.url}
|
||||
preload="auto"
|
||||
onError={(e) => console.error('Audio load error:', e)}
|
||||
/>
|
||||
<button
|
||||
onClick={toggleAudio}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-vortex-500/20 hover:bg-vortex-500/30'
|
||||
} transition-colors`}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} className={isMine ? 'text-white' : 'text-vortex-400'} />
|
||||
) : (
|
||||
<Play size={16} className={`${isMine ? 'text-white' : 'text-vortex-400'} ml-0.5`} />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Waveform visualization */}
|
||||
<div
|
||||
className="flex items-end gap-[2px] h-6 cursor-pointer"
|
||||
onClick={(e) => {
|
||||
const audio = audioRef.current;
|
||||
if (!audio || !audio.duration) return;
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
const pct = (e.clientX - rect.left) / rect.width;
|
||||
audio.currentTime = pct * audio.duration;
|
||||
setAudioProgress(pct * 100);
|
||||
if (!isPlaying) toggleAudio();
|
||||
}}
|
||||
>
|
||||
{(waveformBars || Array(28).fill(0.5)).map((val, i) => {
|
||||
const barHeight = Math.max(10, val * 100);
|
||||
const progress = audioProgress / 100;
|
||||
const barProgress = i / 28;
|
||||
const isActive = barProgress < progress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
|
||||
? isMine ? 'bg-white/80' : 'bg-vortex-400'
|
||||
: isMine ? 'bg-white/20' : 'bg-white/10'
|
||||
}`}
|
||||
style={{ height: `${barHeight}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
|
||||
{isPlaying
|
||||
? formatDuration(audioRef.current?.currentTime || 0)
|
||||
: formatDuration(audioDuration || message.media?.find((m) => m.type === 'voice')?.duration || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Аудио (mp3 файлы) */}
|
||||
{hasAudio && (() => {
|
||||
const audioMedia = media.find((m) => m.type === 'audio');
|
||||
return (
|
||||
<div className="min-w-[220px]">
|
||||
{audioMedia?.filename && (
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Volume2 size={14} className={isMine ? 'text-white/60' : 'text-vortex-400'} />
|
||||
<span className={`text-xs truncate ${isMine ? 'text-white/70' : 'text-zinc-400'}`}>{audioMedia.filename}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={audioMedia?.url}
|
||||
preload="auto"
|
||||
onError={(e) => console.error('Audio load error:', e)}
|
||||
/>
|
||||
<button
|
||||
onClick={toggleAudio}
|
||||
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white/20 hover:bg-white/30' : 'bg-vortex-500/20 hover:bg-vortex-500/30'
|
||||
} transition-colors`}
|
||||
>
|
||||
{isPlaying ? (
|
||||
<Pause size={16} className={isMine ? 'text-white' : 'text-vortex-400'} />
|
||||
) : (
|
||||
<Play size={16} className={`${isMine ? 'text-white' : 'text-vortex-400'} ml-0.5`} />
|
||||
)}
|
||||
</button>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-[2px] h-6">
|
||||
{Array.from({ length: 28 }).map((_, i) => {
|
||||
const barHeight = [40, 65, 35, 80, 50, 90, 45, 70, 55, 85, 30, 75, 60, 95, 40, 80, 50, 70, 35, 90, 55, 65, 45, 85, 60, 75, 50, 40][i] || 50;
|
||||
const progress = audioProgress / 100;
|
||||
const barProgress = i / 28;
|
||||
const isActive = barProgress < progress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex-1 rounded-full transition-colors duration-150 ${isActive
|
||||
? isMine ? 'bg-white/80' : 'bg-vortex-400'
|
||||
: isMine ? 'bg-white/20' : 'bg-white/10'
|
||||
}`}
|
||||
style={{ height: `${barHeight}%` }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<span className={`text-xs mt-0.5 block ${isMine ? 'text-white/60' : 'text-zinc-500'}`}>
|
||||
{isPlaying
|
||||
? formatDuration(audioRef.current?.currentTime || 0)
|
||||
: formatDuration(audioDuration || 0)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Файлы */}
|
||||
{hasFile &&
|
||||
media
|
||||
.filter((m) => m.type !== 'image' && m.type !== 'voice' && m.type !== 'video')
|
||||
.map((m) => (
|
||||
<a
|
||||
key={m.id}
|
||||
href={m.url}
|
||||
download={m.filename || 'file'}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-3 p-2 rounded-xl ${isMine ? 'bg-white/10 hover:bg-white/15' : 'bg-surface-tertiary hover:bg-surface-hover'
|
||||
} transition-colors mb-1`}
|
||||
>
|
||||
<div className={`w-10 h-10 rounded-lg flex items-center justify-center ${isMine ? 'bg-white/20' : 'bg-vortex-500/20'
|
||||
}`}>
|
||||
<FileText size={20} className={isMine ? 'text-white' : 'text-vortex-400'} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm truncate">{m.filename || t('fileLabel')}</p>
|
||||
<p className={`text-xs ${isMine ? 'text-white/50' : 'text-zinc-500'}`}>
|
||||
{m.size ? `${(m.size / 1024).toFixed(1)} ${t('kb')}` : t('download')}
|
||||
</p>
|
||||
</div>
|
||||
<Download size={16} className={isMine ? 'text-white/50' : 'text-zinc-500'} />
|
||||
</a>
|
||||
))}
|
||||
|
||||
{/* Текст */}
|
||||
{message.content && (
|
||||
<div className="flex items-end gap-2">
|
||||
<p className="text-sm whitespace-pre-wrap break-words flex-1 leading-relaxed">
|
||||
{renderFormattedText(message.content)}
|
||||
</p>
|
||||
<span className={`text-[10px] flex-shrink-0 flex items-center gap-0.5 self-end ${isMine ? 'text-white/50' : 'text-zinc-500'
|
||||
}`}>
|
||||
{message.isEdited && <span>{t('edited')}</span>}
|
||||
{message.scheduledAt && <Clock size={11} className="text-amber-400 mr-0.5" />}
|
||||
{timeStr}
|
||||
{isMine && !message.scheduledAt && (
|
||||
isRead ? (
|
||||
<CheckCheck size={13} className="text-sky-300 ml-0.5" />
|
||||
) : (
|
||||
<Check size={13} className="ml-0.5" />
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Время для медиа без текста */}
|
||||
{!message.content && (hasImage || hasVideo) && (
|
||||
<div className={`flex justify-end px-3 py-1 ${hasImage ? '-mt-8 relative z-10' : ''}`}>
|
||||
<span className="text-[10px] text-white/70 bg-black/40 px-2 py-0.5 rounded-full flex items-center gap-1 backdrop-blur-sm">
|
||||
{timeStr}
|
||||
{isMine && (
|
||||
isRead ? (
|
||||
<CheckCheck size={13} className="text-sky-300" />
|
||||
) : (
|
||||
<Check size={13} />
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Реакции */}
|
||||
{Object.keys(reactionGroups).length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1 mx-1">
|
||||
{Object.entries(reactionGroups).map(([emoji, data]) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => handleReaction(emoji)}
|
||||
className={`flex items-center gap-1 px-2 py-0.5 rounded-full text-xs transition-colors ${data.isMine
|
||||
? 'bg-vortex-500/30 border border-vortex-500/50'
|
||||
: 'bg-surface-tertiary border border-border hover:border-zinc-600'
|
||||
}`}
|
||||
title={data.users.join(', ')}
|
||||
>
|
||||
<span>{emoji}</span>
|
||||
<span className="text-zinc-400">{data.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Аватар (свои) */}
|
||||
{isMine && (
|
||||
<div className="w-8 flex-shrink-0 ml-2 self-end">
|
||||
{showAvatar ? (
|
||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||
{senderAvatar ? (
|
||||
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
|
||||
{senderName[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Контекстное меню */}
|
||||
{typeof document !== 'undefined' && createPortal(
|
||||
<AnimatePresence>
|
||||
{showContext && (
|
||||
<motion.div
|
||||
ref={contextMenuRef}
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.9 }}
|
||||
className="fixed z-[9999] w-52 rounded-[1.25rem] glass-strong shadow-2xl py-1.5 overflow-hidden border border-white/10"
|
||||
style={{ left: contextPos.x, top: contextPos.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{deleteMenuMode ? (
|
||||
<>
|
||||
{/* Delete submenu */}
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 border-b border-border">
|
||||
<button
|
||||
onClick={() => setDeleteMenuMode(false)}
|
||||
className="p-1 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
|
||||
</button>
|
||||
<span className="text-sm font-medium text-zinc-300">{t('delete')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDeleteForMe}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Trash2 size={16} className="text-zinc-400" />
|
||||
{t('deleteForMe')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteForAll}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 hover:text-red-300 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{chatForDelete?.type === 'personal' && otherMemberName
|
||||
? `${t('deleteAlsoFor')} ${otherMemberName}`
|
||||
: t('deleteForAll')}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Быстрые реакции */}
|
||||
<div className="flex items-center gap-1 px-3 py-2 border-b border-border">
|
||||
{['👍', '❤️', '😂', '😮', '😢', '🔥'].map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
onClick={() => handleReaction(emoji)}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg hover:bg-surface-hover transition-colors text-lg"
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleReply}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Reply size={16} />
|
||||
{quotedText ? t('replyWithQuote') : t('reply')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowContext(false);
|
||||
onStartSelectionMode?.(message.id);
|
||||
}}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<CheckCheck size={16} />
|
||||
{t('select')}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handlePin}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Pin size={16} />
|
||||
{isPinned ? t('unpinMessage') : t('pinMessage')}
|
||||
</button>
|
||||
|
||||
{message.content && (
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Copy size={16} />
|
||||
{t('copy')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isMine && message.content && (
|
||||
<button
|
||||
onClick={handleEdit}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||
>
|
||||
<Pencil size={16} />
|
||||
{t('edit')}
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border my-1" />
|
||||
<button
|
||||
onClick={() => setDeleteMenuMode(true)}
|
||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
{t('delete')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Lightbox */}
|
||||
<AnimatePresence>
|
||||
{lightboxUrl && (
|
||||
<ImageLightbox url={lightboxUrl} onClose={() => setLightboxUrl(null)} />
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(MessageBubble);
|
||||
1154
apps/web/src/components/MessageInput.tsx
Normal file
1154
apps/web/src/components/MessageInput.tsx
Normal file
File diff suppressed because it is too large
Load Diff
374
apps/web/src/components/NewChatModal.tsx
Normal file
374
apps/web/src/components/NewChatModal.tsx
Normal file
@@ -0,0 +1,374 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight } from 'lucide-react';
|
||||
import { api } from '../lib/api';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import type { UserPresence, FriendWithId } from '../lib/types';
|
||||
|
||||
interface NewChatModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type Mode = 'personal' | 'group-select' | 'group-name';
|
||||
|
||||
export default function NewChatModal({ onClose }: NewChatModalProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
const { addChat, setActiveChat, loadMessages } = useChatStore();
|
||||
const [mode, setMode] = useState<Mode>('personal');
|
||||
const [query, setQuery] = useState('');
|
||||
const [users, setUsers] = useState<UserPresence[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedUsers, setSelectedUsers] = useState<UserPresence[]>([]);
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [friends, setFriends] = useState<FriendWithId[]>([]);
|
||||
|
||||
// Load friends on mount
|
||||
useEffect(() => {
|
||||
api.getFriends().then(setFriends).catch(() => {});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!query.trim() || query.trim().length < 3) {
|
||||
setUsers([]);
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const results = await api.searchUsers(query);
|
||||
setUsers(results.filter((u) => u.id !== user?.id));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, 300);
|
||||
return () => clearTimeout(timer);
|
||||
}, [query, user?.id]);
|
||||
|
||||
const handleSelectUser = async (selectedUser: UserPresence) => {
|
||||
if (mode === 'personal') {
|
||||
try {
|
||||
const chat = await api.createPersonalChat(selectedUser.id);
|
||||
addChat(chat);
|
||||
setActiveChat(chat.id);
|
||||
loadMessages(chat.id);
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
console.error(e);
|
||||
}
|
||||
} else {
|
||||
// Toggle selection
|
||||
setSelectedUsers((prev) => {
|
||||
const exists = prev.find((u) => u.id === selectedUser.id);
|
||||
if (exists) return prev.filter((u) => u.id !== selectedUser.id);
|
||||
return [...prev, selectedUser];
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateGroup = async () => {
|
||||
if (!groupName.trim() || selectedUsers.length === 0) return;
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const chat = await api.createGroupChat(
|
||||
groupName.trim(),
|
||||
selectedUsers.map((u) => u.id)
|
||||
);
|
||||
addChat(chat);
|
||||
setActiveChat(chat.id);
|
||||
loadMessages(chat.id);
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isSelected = (userId: string) => selectedUsers.some((u) => u.id === userId);
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 z-50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div className="w-full max-w-md rounded-2xl glass-strong shadow-2xl overflow-hidden" role="dialog" aria-modal="true" aria-label={t('newChat')}>
|
||||
{/* Шапка */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border">
|
||||
<div className="flex items-center gap-2">
|
||||
{mode !== 'personal' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (mode === 'group-name') setMode('group-select');
|
||||
else {
|
||||
setMode('personal');
|
||||
setSelectedUsers([]);
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold text-white">
|
||||
{mode === 'personal'
|
||||
? t('newChatTitle')
|
||||
: mode === 'group-select'
|
||||
? t('selectMembers')
|
||||
: t('newGroup')}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'group-name' ? (
|
||||
/* Шаг 2: Назвать группу */
|
||||
<div className="p-4 space-y-4">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('groupNamePlaceholder')}
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
className="w-full px-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
<div>
|
||||
<p className="text-xs text-zinc-500 mb-2">
|
||||
{t('membersCount')} ({selectedUsers.length}):
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedUsers.map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-vortex-500/20 border border-vortex-500/30"
|
||||
>
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white text-[9px] font-semibold">
|
||||
{(u.displayName || u.username)?.[0]?.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<span className="text-xs text-white">{u.displayName || u.username}</span>
|
||||
<button
|
||||
onClick={() => setSelectedUsers((prev) => prev.filter((p) => p.id !== u.id))}
|
||||
className="text-zinc-500 hover:text-zinc-300"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreateGroup}
|
||||
disabled={!groupName.trim() || isCreating}
|
||||
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
{isCreating ? (
|
||||
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Users size={16} />
|
||||
{t('createGroup')}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Переключатель режима + Поиск */}
|
||||
<div className="p-4 space-y-3">
|
||||
{mode === 'personal' && (
|
||||
<button
|
||||
onClick={() => setMode('group-select')}
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl bg-surface-tertiary hover:bg-surface-hover transition-colors border border-border"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center">
|
||||
<Users size={18} className="text-white" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-white">{t('createGroup')}</p>
|
||||
<p className="text-xs text-zinc-500">{t('upTo200')}</p>
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Выбранные (в режиме группы) */}
|
||||
{mode === 'group-select' && selectedUsers.length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedUsers.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => setSelectedUsers((prev) => prev.filter((p) => p.id !== u.id))}
|
||||
className="flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-vortex-500/20 border border-vortex-500/30 text-xs text-white hover:bg-vortex-500/30 transition-colors"
|
||||
>
|
||||
{(u.displayName || u.username)}
|
||||
<X size={11} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={
|
||||
mode === 'personal'
|
||||
? t('findUser')
|
||||
: t('addMembers')
|
||||
}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Результаты */}
|
||||
<div className="max-h-72 overflow-y-auto px-2 pb-4">
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-5 h-5 border-2 border-vortex-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : query.trim().length >= 3 && users.length > 0 ? (
|
||||
users.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleSelectUser(u)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors ${
|
||||
isSelected(u.id)
|
||||
? 'bg-vortex-500/15 border border-vortex-500/30'
|
||||
: 'hover:bg-surface-hover border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-semibold text-sm">
|
||||
{(u.displayName || u.username)?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
{u.isOnline && (
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{u.displayName || u.username}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 truncate">@{u.username}</p>
|
||||
</div>
|
||||
{mode === 'group-select' && (
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
isSelected(u.id)
|
||||
? 'bg-vortex-500 border-vortex-500'
|
||||
: 'border-zinc-600'
|
||||
}`}>
|
||||
{isSelected(u.id) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
) : query.trim().length >= 3 && users.length === 0 ? (
|
||||
<div className="text-center py-8 text-zinc-500">
|
||||
<p className="text-sm">{t('usersNotFound')}</p>
|
||||
</div>
|
||||
) : query.trim().length > 0 && query.trim().length < 3 ? (
|
||||
<div className="text-center py-6 text-zinc-500">
|
||||
<p className="text-sm">{t('minCharsHint')}</p>
|
||||
</div>
|
||||
) : friends.length > 0 ? (
|
||||
<>
|
||||
<p className="text-xs text-zinc-500 uppercase tracking-wider px-2 mb-2 font-semibold">{t('friends')}</p>
|
||||
{friends.map((u) => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleSelectUser(u)}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors ${
|
||||
isSelected(u.id)
|
||||
? 'bg-vortex-500/15 border border-vortex-500/30'
|
||||
: 'hover:bg-surface-hover border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="relative flex-shrink-0">
|
||||
{u.avatar ? (
|
||||
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
|
||||
) : (
|
||||
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-vortex-500 to-purple-600 flex items-center justify-center text-white font-semibold text-sm">
|
||||
{(u.displayName || u.username)?.[0]?.toUpperCase() || '?'}
|
||||
</div>
|
||||
)}
|
||||
{u.isOnline && (
|
||||
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left flex-1">
|
||||
<p className="text-sm font-medium text-white truncate">
|
||||
{u.displayName || u.username}
|
||||
</p>
|
||||
<p className="text-xs text-zinc-500 truncate">@{u.username}</p>
|
||||
</div>
|
||||
{mode === 'group-select' && (
|
||||
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
|
||||
isSelected(u.id)
|
||||
? 'bg-vortex-500 border-vortex-500'
|
||||
: 'border-zinc-600'
|
||||
}`}>
|
||||
{isSelected(u.id) && <Check size={12} className="text-white" />}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2 py-8 text-zinc-500">
|
||||
<MessageSquare size={32} className="opacity-30" />
|
||||
<p className="text-sm">{t('enterNameOrUsername')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Кнопка "Далее" для группы */}
|
||||
{mode === 'group-select' && selectedUsers.length > 0 && (
|
||||
<div className="p-4 border-t border-border">
|
||||
<button
|
||||
onClick={() => setMode('group-name')}
|
||||
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
{t('next')}
|
||||
<ArrowRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
1002
apps/web/src/components/SideMenu.tsx
Normal file
1002
apps/web/src/components/SideMenu.tsx
Normal file
File diff suppressed because it is too large
Load Diff
212
apps/web/src/components/Sidebar.tsx
Normal file
212
apps/web/src/components/Sidebar.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Menu,
|
||||
MessageSquare,
|
||||
X,
|
||||
User as UserIcon,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { api } from '../lib/api';
|
||||
import { getInitials, generateAvatarColor } from '../lib/utils';
|
||||
import Avatar from './Avatar';
|
||||
import { StoryGroup } from '../lib/types';
|
||||
import ChatListItem from './ChatListItem';
|
||||
import NewChatModal from './NewChatModal';
|
||||
import UserProfile from './UserProfile';
|
||||
import SideMenu from './SideMenu';
|
||||
import StoryViewer, { CreateStoryModal } from './StoryViewer';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
export default function Sidebar() {
|
||||
const { user, logout } = useAuthStore();
|
||||
const { chats, activeChat, searchQuery, setSearchQuery, clearStore } = useChatStore();
|
||||
const { t } = useLang();
|
||||
const [showNewChat, setShowNewChat] = useState(false);
|
||||
const [showProfile, setShowProfile] = useState(false);
|
||||
const [showSideMenu, setShowSideMenu] = useState(false);
|
||||
const [storyGroups, setStoryGroups] = useState<StoryGroup[]>([]);
|
||||
const [storyViewerIndex, setStoryViewerIndex] = useState<number | null>(null);
|
||||
const [showCreateStory, setShowCreateStory] = useState(false);
|
||||
|
||||
const loadStories = () => {
|
||||
api.getStories().then(setStoryGroups).catch(console.error);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadStories();
|
||||
const interval = setInterval(loadStories, 30000); // refresh every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const filteredChats = chats.filter((chat) => {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
if (chat.name?.toLowerCase().includes(q)) return true;
|
||||
return chat.members.some(
|
||||
(m) =>
|
||||
m.user.id !== user?.id &&
|
||||
(m.user.username.toLowerCase().includes(q) ||
|
||||
m.user.displayName.toLowerCase().includes(q))
|
||||
);
|
||||
}).sort((a, b) => {
|
||||
// Favorites chat always on top
|
||||
if (a.type === 'favorites') return -1;
|
||||
if (b.type === 'favorites') return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
clearStore();
|
||||
logout();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-[340px] h-full flex flex-col bg-surface-secondary rounded-3xl overflow-hidden border border-border/50 shadow-2xl relative z-10">
|
||||
{/* Шапка */}
|
||||
<div className="h-[76px] px-4 flex items-center gap-3 border-b border-border/40 bg-surface-secondary flex-shrink-0">
|
||||
<button
|
||||
onClick={() => setShowSideMenu(true)}
|
||||
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
title={t('menu')}
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<img src="/logo.png" alt="Vortex" className="w-8 h-8 rounded-lg object-cover" />
|
||||
<h1 className="text-lg font-bold gradient-text truncate">Vortex</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewChat(true)}
|
||||
className="p-2 rounded-lg hover:bg-surface-hover transition-colors text-zinc-400 hover:text-white"
|
||||
title={t('newChat')}
|
||||
>
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Поиск */}
|
||||
<div className="p-4 bg-surface-secondary/50">
|
||||
<div className="relative group">
|
||||
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-accent transition-colors" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('searchChats')}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-11 pr-10 py-3 rounded-2xl bg-surface-tertiary/80 text-[15px] font-medium text-white placeholder-zinc-500 border border-border/30 hover:border-border/60 focus:border-accent transition-all outline-none shadow-inner"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full bg-surface-hover text-zinc-400 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Story circles */}
|
||||
{(storyGroups.length > 0 || true) && (
|
||||
<div className="flex items-center gap-3 px-4 py-2 overflow-x-auto scrollbar-hide border-b border-border/20 flex-shrink-0">
|
||||
{/* Add story circle */}
|
||||
<button
|
||||
onClick={() => setShowCreateStory(true)}
|
||||
className="flex flex-col items-center gap-1 flex-shrink-0 group"
|
||||
>
|
||||
<div className="w-14 h-14 rounded-full border-2 border-dashed border-zinc-600 flex items-center justify-center group-hover:border-vortex-400 transition-colors">
|
||||
<Plus size={20} className="text-zinc-400 group-hover:text-vortex-400 transition-colors" />
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-500 truncate w-14 text-center">{t('newStory')}</span>
|
||||
</button>
|
||||
|
||||
{storyGroups.map((group, idx) => {
|
||||
const avatarUrl = group.user.avatar ? `${API_URL}${group.user.avatar}` : null;
|
||||
const isMine = group.user.id === user?.id;
|
||||
return (
|
||||
<button
|
||||
key={group.user.id}
|
||||
onClick={() => setStoryViewerIndex(idx)}
|
||||
className="flex flex-col items-center gap-1 flex-shrink-0 group"
|
||||
>
|
||||
<div className={`w-14 h-14 rounded-full p-[2.5px] transition-transform group-hover:scale-105 ${
|
||||
group.hasUnviewed
|
||||
? 'bg-gradient-to-tr from-vortex-400 via-purple-500 to-pink-500 shadow-lg shadow-vortex-500/25'
|
||||
: isMine
|
||||
? 'bg-gradient-to-tr from-zinc-500 to-zinc-600'
|
||||
: 'bg-zinc-700'
|
||||
}`}>
|
||||
<div className="w-full h-full rounded-full overflow-hidden border-[2.5px] border-surface-secondary">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={group.user.displayName || group.user.username}
|
||||
size="lg"
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-zinc-400 truncate w-14 text-center">
|
||||
{isMine ? t('myStory') : (group.user.displayName || group.user.username).split(' ')[0]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список чатов */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{filteredChats.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 px-6">
|
||||
<MessageSquare size={40} className="opacity-30" />
|
||||
<p className="text-sm text-center">
|
||||
{searchQuery ? t('nothingFound') : t('noChats')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
filteredChats.map((chat) => (
|
||||
<ChatListItem key={chat.id} chat={chat} isActive={chat.id === activeChat} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Модалки */}
|
||||
<AnimatePresence>
|
||||
{showNewChat && <NewChatModal onClose={() => setShowNewChat(false)} />}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{showProfile && <UserProfile userId={user!.id} onClose={() => setShowProfile(false)} isSelf />}
|
||||
</AnimatePresence>
|
||||
<SideMenu
|
||||
isOpen={showSideMenu}
|
||||
onClose={() => setShowSideMenu(false)}
|
||||
/>
|
||||
<AnimatePresence>
|
||||
{storyViewerIndex !== null && storyGroups.length > 0 && (
|
||||
<StoryViewer
|
||||
stories={storyGroups}
|
||||
initialUserIndex={storyViewerIndex}
|
||||
onClose={() => { setStoryViewerIndex(null); loadStories(); }}
|
||||
onRefresh={loadStories}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{showCreateStory && (
|
||||
<CreateStoryModal
|
||||
onClose={() => setShowCreateStory(false)}
|
||||
onCreated={loadStories}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
512
apps/web/src/components/StoryViewer.tsx
Normal file
512
apps/web/src/components/StoryViewer.tsx
Normal file
@@ -0,0 +1,512 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp } from 'lucide-react';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { api } from '../lib/api';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { getInitials, generateAvatarColor } from '../lib/utils';
|
||||
import Avatar from './Avatar';
|
||||
import { StoryGroup } from '../lib/types';
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||
|
||||
const STORY_BG_COLORS = [
|
||||
'#6366f1', '#8b5cf6', '#ec4899', '#f43f5e', '#ef4444',
|
||||
'#f97316', '#eab308', '#22c55e', '#14b8a6', '#0ea5e9',
|
||||
'#3b82f6', '#1e1e2e',
|
||||
];
|
||||
|
||||
interface StoryViewerProps {
|
||||
stories: StoryGroup[];
|
||||
initialUserIndex: number;
|
||||
onClose: () => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export default function StoryViewer({ stories, initialUserIndex, onClose, onRefresh }: StoryViewerProps) {
|
||||
const { user } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
const [userIndex, setUserIndex] = useState(initialUserIndex);
|
||||
const [storyIndex, setStoryIndex] = useState(0);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [paused, setPaused] = useState(false);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
|
||||
const viewedRef = useRef<Set<string>>(new Set()); // track viewed in this session
|
||||
const [viewOverrides, setViewOverrides] = useState<Record<string, { viewCount: number; viewed: boolean }>>({});
|
||||
|
||||
const STORY_DURATION = 5000; // 5 seconds per story
|
||||
const TICK = 50;
|
||||
|
||||
const [showViewers, setShowViewers] = useState(false);
|
||||
const [viewers, setViewers] = useState<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>([]);
|
||||
const [viewersLoading, setViewersLoading] = useState(false);
|
||||
|
||||
const currentUser = stories[userIndex];
|
||||
const rawStory = currentUser?.stories?.[storyIndex];
|
||||
// Merge prop data with local overrides to avoid mutating props
|
||||
const currentStory = rawStory ? { ...rawStory, ...viewOverrides[rawStory.id] } : null;
|
||||
|
||||
// Reset when viewer opens with different user
|
||||
useEffect(() => {
|
||||
setUserIndex(initialUserIndex);
|
||||
setStoryIndex(0);
|
||||
setProgress(0);
|
||||
viewedRef.current.clear();
|
||||
setViewOverrides({});
|
||||
}, [initialUserIndex]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
if (!currentUser) return;
|
||||
if (storyIndex < currentUser.stories.length - 1) {
|
||||
setStoryIndex(s => s + 1);
|
||||
setProgress(0);
|
||||
} else if (userIndex < stories.length - 1) {
|
||||
setUserIndex(u => u + 1);
|
||||
setStoryIndex(0);
|
||||
setProgress(0);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [storyIndex, userIndex, currentUser, stories.length, onClose]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
if (storyIndex > 0) {
|
||||
setStoryIndex(s => s - 1);
|
||||
setProgress(0);
|
||||
} else if (userIndex > 0) {
|
||||
setUserIndex(u => u - 1);
|
||||
const prevUser = stories[userIndex - 1];
|
||||
setStoryIndex(prevUser.stories.length - 1);
|
||||
setProgress(0);
|
||||
}
|
||||
}, [storyIndex, userIndex, stories]);
|
||||
|
||||
const canGoPrev = storyIndex > 0 || userIndex > 0;
|
||||
const canGoNext = (currentUser && storyIndex < currentUser.stories.length - 1) || userIndex < stories.length - 1;
|
||||
|
||||
// Mark viewed
|
||||
useEffect(() => {
|
||||
if (!currentStory || !currentStory.id) return;
|
||||
if (currentUser.user.id === user?.id) return;
|
||||
if (currentStory.viewed || viewedRef.current.has(currentStory.id)) return;
|
||||
viewedRef.current.add(currentStory.id);
|
||||
const storyId = currentStory.id;
|
||||
const viewCount = currentStory.viewCount || 0;
|
||||
api.viewStory(storyId).then(() => {
|
||||
setViewOverrides(prev => ({
|
||||
...prev,
|
||||
[storyId]: {
|
||||
viewCount: viewCount + 1,
|
||||
viewed: true,
|
||||
},
|
||||
}));
|
||||
}).catch(console.error);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentStory?.id, currentUser?.user?.id, user?.id]);
|
||||
|
||||
// Progress timer - use a key to force restart
|
||||
useEffect(() => {
|
||||
if (paused || !currentStory) return;
|
||||
setProgress(0);
|
||||
const step = (TICK / STORY_DURATION) * 100;
|
||||
timerRef.current = setInterval(() => {
|
||||
setProgress(prev => {
|
||||
if (prev >= 100) {
|
||||
goNext();
|
||||
return 0;
|
||||
}
|
||||
return prev + step;
|
||||
});
|
||||
}, TICK);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
};
|
||||
}, [storyIndex, userIndex, paused, goNext]);
|
||||
|
||||
// Keyboard
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowRight') goNext();
|
||||
if (e.key === 'ArrowLeft') goPrev();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [goNext, goPrev, onClose]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!currentStory) return;
|
||||
try {
|
||||
await api.deleteStory(currentStory.id);
|
||||
onRefresh();
|
||||
goNext();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentUser || !currentStory) return null;
|
||||
|
||||
const timeAgo = (date: string) => {
|
||||
const diff = (Date.now() - new Date(date).getTime()) / 1000;
|
||||
if (diff < 60) return `${Math.floor(diff)}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
return `${Math.floor(diff / 3600)}h`;
|
||||
};
|
||||
|
||||
const avatarUrl = currentUser.user.avatar
|
||||
? `${API_URL}${currentUser.user.avatar}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-black/95 flex items-center justify-center"
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
{/* Story container */}
|
||||
<div
|
||||
className="relative w-full max-w-[420px] h-full max-h-[85vh] rounded-2xl overflow-hidden select-none"
|
||||
onMouseDown={() => setPaused(true)}
|
||||
onMouseUp={() => setPaused(false)}
|
||||
onMouseLeave={() => setPaused(false)}
|
||||
onTouchStart={() => setPaused(true)}
|
||||
onTouchEnd={() => setPaused(false)}
|
||||
>
|
||||
{/* Story content */}
|
||||
{currentStory.type === 'image' && currentStory.mediaUrl ? (
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<img
|
||||
src={currentStory.mediaUrl.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
|
||||
alt="story"
|
||||
className="w-full h-full object-contain"
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="w-full h-full flex items-center justify-center p-8"
|
||||
style={{ background: currentStory.bgColor || '#6366f1' }}
|
||||
>
|
||||
<p className="text-white text-2xl font-bold text-center leading-relaxed drop-shadow-lg"
|
||||
style={{ maxWidth: '90%', wordBreak: 'break-word' }}>
|
||||
{currentStory.content}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Progress bars */}
|
||||
<div className="absolute top-0 left-0 right-0 flex gap-1 p-2 z-10">
|
||||
{currentUser.stories.map((_, i) => (
|
||||
<div key={i} className="flex-1 h-[3px] bg-white/30 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-white rounded-full transition-none"
|
||||
style={{
|
||||
width: i < storyIndex ? '100%' : i === storyIndex ? `${progress}%` : '0%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Header */}
|
||||
<div className="absolute top-4 left-0 right-0 flex items-center gap-3 px-4 pt-2 z-10">
|
||||
<Avatar
|
||||
src={avatarUrl}
|
||||
name={currentUser.user.displayName || currentUser.user.username}
|
||||
size="sm"
|
||||
className="ring-2 ring-white/20 rounded-full"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm font-semibold truncate drop-shadow">
|
||||
{currentUser.user.id === user?.id ? t('myStory') : currentUser.user.displayName || currentUser.user.username}
|
||||
</p>
|
||||
<p className="text-white/60 text-xs drop-shadow">{timeAgo(currentStory.createdAt)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{currentUser.user.id === user?.id && (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (showViewers) {
|
||||
setShowViewers(false);
|
||||
setPaused(false);
|
||||
} else {
|
||||
setPaused(true);
|
||||
setShowViewers(true);
|
||||
setViewersLoading(true);
|
||||
api.getStoryViewers(currentStory.id).then(v => {
|
||||
setViewers(v);
|
||||
setViewersLoading(false);
|
||||
}).catch(() => setViewersLoading(false));
|
||||
}
|
||||
}}
|
||||
className="text-white/60 hover:text-white text-xs flex items-center gap-1 transition-colors p-1"
|
||||
>
|
||||
<Eye size={12} /> {currentStory.viewCount}
|
||||
<ChevronUp size={10} className={`transition-transform ${showViewers ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
<button onClick={handleDelete} className="text-white/60 hover:text-red-400 transition-colors p-1">
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button onClick={onClose} className="text-white/60 hover:text-white transition-colors p-1">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Left/Right click zones */}
|
||||
<div className="absolute inset-0 flex z-[5]">
|
||||
<div className="w-1/3 h-full cursor-pointer" onClick={goPrev} />
|
||||
<div className="w-1/3 h-full" />
|
||||
<div className="w-1/3 h-full cursor-pointer" onClick={goNext} />
|
||||
</div>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
{canGoPrev && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goPrev(); }}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center text-white/70 hover:bg-white/20 hover:text-white transition-all"
|
||||
>
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
)}
|
||||
{canGoNext && (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); goNext(); }}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 rounded-full bg-white/10 backdrop-blur-sm flex items-center justify-center text-white/70 hover:bg-white/20 hover:text-white transition-all"
|
||||
>
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Viewers panel */}
|
||||
<AnimatePresence>
|
||||
{showViewers && currentUser.user.id === user?.id && (
|
||||
<motion.div
|
||||
initial={{ y: '100%' }}
|
||||
animate={{ y: 0 }}
|
||||
exit={{ y: '100%' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||
className="absolute bottom-0 left-0 right-0 z-20 bg-black/90 backdrop-blur-xl rounded-t-2xl border-t border-white/10 max-h-[50%] overflow-y-auto"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h4 className="text-white text-sm font-semibold flex items-center gap-2">
|
||||
<Eye size={14} /> {t('storyViewers')} ({currentStory.viewCount})
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => { setShowViewers(false); setPaused(false); }}
|
||||
className="text-white/60 hover:text-white transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
{viewersLoading ? (
|
||||
<div className="text-white/40 text-sm text-center py-4">{t('sending')}</div>
|
||||
) : viewers.length === 0 ? (
|
||||
<div className="text-white/40 text-sm text-center py-4">{t('noViewers')}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{viewers.map((v) => (
|
||||
<div key={v.userId} className="flex items-center gap-3 py-1.5">
|
||||
<Avatar
|
||||
src={v.avatar ? `${API_URL}${v.avatar}` : null}
|
||||
name={v.displayName || v.username}
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white text-sm truncate">{v.displayName || v.username}</p>
|
||||
<p className="text-white/40 text-xs">@{v.username}</p>
|
||||
</div>
|
||||
<span className="text-white/30 text-xs">{timeAgo(v.viewedAt)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Story creation modal
|
||||
interface CreateStoryModalProps {
|
||||
onClose: () => void;
|
||||
onCreated: () => void;
|
||||
}
|
||||
|
||||
export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) {
|
||||
const { t } = useLang();
|
||||
const [mode, setMode] = useState<'text' | 'image'>('text');
|
||||
const [text, setText] = useState('');
|
||||
const [bgColor, setBgColor] = useState('#6366f1');
|
||||
const [imageFile, setImageFile] = useState<File | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImageFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImagePreview(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
setMode('image');
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (mode === 'text' && !text.trim()) return;
|
||||
if (mode === 'image' && !imageFile) return;
|
||||
setIsUploading(true);
|
||||
|
||||
try {
|
||||
let mediaUrl: string | undefined;
|
||||
if (imageFile) {
|
||||
const result = await api.uploadFile(imageFile);
|
||||
mediaUrl = result.url;
|
||||
}
|
||||
|
||||
await api.createStory({
|
||||
type: mode,
|
||||
content: mode === 'text' ? text.trim() : undefined,
|
||||
bgColor: mode === 'text' ? bgColor : undefined,
|
||||
mediaUrl,
|
||||
});
|
||||
|
||||
onCreated();
|
||||
onClose();
|
||||
} catch (e) {
|
||||
console.error('Create story error:', e);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-black/80 flex items-center justify-center"
|
||||
onClick={e => { if (e.target === e.currentTarget) onClose(); }}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 0.9, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
exit={{ scale: 0.9, opacity: 0 }}
|
||||
className="w-full max-w-[400px] rounded-2xl glass-strong border border-white/10 overflow-hidden"
|
||||
>
|
||||
<div className="p-4 border-b border-white/10 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">{t('newStory')}</h3>
|
||||
<button onClick={onClose} className="text-zinc-400 hover:text-white">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mode tabs */}
|
||||
<div className="flex border-b border-white/10">
|
||||
<button
|
||||
onClick={() => setMode('text')}
|
||||
className={`flex-1 py-2.5 text-sm font-medium transition-colors ${mode === 'text' ? 'text-vortex-400 border-b-2 border-vortex-400' : 'text-zinc-400'}`}
|
||||
>
|
||||
{t('textStory')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setMode('image')}
|
||||
className={`flex-1 py-2.5 text-sm font-medium transition-colors ${mode === 'image' ? 'text-vortex-400 border-b-2 border-vortex-400' : 'text-zinc-400'}`}
|
||||
>
|
||||
{t('imageStory')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
|
||||
<div className="p-4">
|
||||
{mode === 'text' ? (
|
||||
<>
|
||||
{/* Preview */}
|
||||
<div
|
||||
className="w-full h-48 rounded-xl flex items-center justify-center p-4 mb-4 transition-colors"
|
||||
style={{ background: bgColor }}
|
||||
>
|
||||
<p className="text-white text-lg font-bold text-center break-words max-w-full">
|
||||
{text || t('typeYourStory')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
placeholder={t('typeYourStory')}
|
||||
maxLength={200}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-zinc-200 resize-none h-20 mb-3 focus:outline-none focus:border-vortex-500/50"
|
||||
/>
|
||||
|
||||
{/* Color picker */}
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{STORY_BG_COLORS.map(c => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setBgColor(c)}
|
||||
className={`w-7 h-7 rounded-full transition-transform ${bgColor === c ? 'scale-125 ring-2 ring-white/50' : 'hover:scale-110'}`}
|
||||
style={{ background: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{imagePreview ? (
|
||||
<div className="relative w-full h-48 rounded-xl mb-4 overflow-hidden">
|
||||
<img src={imagePreview} className="w-full h-full object-cover" alt="preview" />
|
||||
<button
|
||||
onClick={() => { setImageFile(null); setImagePreview(null); }}
|
||||
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 flex items-center justify-center text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="w-full h-48 rounded-xl border-2 border-dashed border-white/20 flex items-center justify-center mb-4 text-zinc-400 hover:text-white hover:border-white/40 transition-colors"
|
||||
>
|
||||
<Plus size={32} />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={isUploading || (mode === 'text' && !text.trim()) || (mode === 'image' && !imageFile)}
|
||||
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isUploading ? '...' : t('publishStory')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/components/TypingIndicator.tsx
Normal file
25
apps/web/src/components/TypingIndicator.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { useLang } from '../lib/i18n';
|
||||
|
||||
export default function TypingIndicator() {
|
||||
const { t } = useLang();
|
||||
return (
|
||||
<div className="flex items-center gap-1 py-1">
|
||||
<span className="text-xs text-vortex-400 font-medium">{t('typingText')}</span>
|
||||
<div className="flex gap-0.5">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.span
|
||||
key={i}
|
||||
className="w-1 h-1 rounded-full bg-vortex-400"
|
||||
animate={{ opacity: [0.3, 1, 0.3] }}
|
||||
transition={{
|
||||
duration: 1,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.2,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
502
apps/web/src/components/UserProfile.tsx
Normal file
502
apps/web/src/components/UserProfile.tsx
Normal file
@@ -0,0 +1,502 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileText, Link as LinkIcon, Download, ExternalLink, Play, UserPlus, UserMinus, UserCheck, Clock } from 'lucide-react';
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { User, Message, FriendshipStatus } from '../lib/types';
|
||||
import ImageLightbox from './ImageLightbox';
|
||||
import { getSocket } from '../lib/socket';
|
||||
|
||||
interface UserProfileProps {
|
||||
userId: string;
|
||||
chatId?: string;
|
||||
onClose: () => void;
|
||||
isSelf?: boolean;
|
||||
}
|
||||
|
||||
type MediaTab = 'media' | 'files' | 'links';
|
||||
|
||||
export default function UserProfile({ userId, chatId, onClose, isSelf }: UserProfileProps) {
|
||||
const { user: authUser } = useAuthStore();
|
||||
const { t, lang } = useLang();
|
||||
const [profile, setProfile] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [activeTab, setActiveTab] = useState<MediaTab>('media');
|
||||
|
||||
// Shared media state
|
||||
const [sharedMedia, setSharedMedia] = useState<Message[]>([]);
|
||||
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
|
||||
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
|
||||
const [tabLoading, setTabLoading] = useState(false);
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<MediaTab>>(new Set());
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
// Friend state
|
||||
const [friendStatus, setFriendStatus] = useState<FriendshipStatus | null>(null);
|
||||
const [friendLoading, setFriendLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
if (!isSelf) {
|
||||
api.getFriendshipStatus(userId).then(setFriendStatus).catch(() => {});
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// Load shared media/files/links when tab changes
|
||||
const loadTabData = useCallback(async (tab: MediaTab) => {
|
||||
if (!chatId || loadedTabs.has(tab)) return;
|
||||
setTabLoading(true);
|
||||
try {
|
||||
const data = await api.getSharedMedia(chatId, tab);
|
||||
if (tab === 'media') setSharedMedia(data);
|
||||
else if (tab === 'files') setSharedFiles(data);
|
||||
else setSharedLinks(data);
|
||||
setLoadedTabs(prev => new Set(prev).add(tab));
|
||||
} catch (e) {
|
||||
console.error('Failed to load shared', tab, e);
|
||||
} finally {
|
||||
setTabLoading(false);
|
||||
}
|
||||
}, [chatId, loadedTabs]);
|
||||
|
||||
useEffect(() => {
|
||||
loadTabData(activeTab);
|
||||
}, [activeTab, loadTabData]);
|
||||
|
||||
const loadProfile = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
if (isSelf && authUser) {
|
||||
setProfile(authUser);
|
||||
} else {
|
||||
const data = await api.getUser(userId);
|
||||
setProfile(data);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendFriendRequest = async () => {
|
||||
try {
|
||||
setFriendLoading(true);
|
||||
const result = await api.sendFriendRequest(userId);
|
||||
if (result.status === 'accepted') {
|
||||
setFriendStatus({ status: 'accepted', friendshipId: null });
|
||||
} else {
|
||||
setFriendStatus({ status: 'pending', friendshipId: null, direction: 'outgoing' });
|
||||
}
|
||||
// Notify via socket
|
||||
const socket = getSocket();
|
||||
if (socket) socket.emit('friend_request', { friendId: userId });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setFriendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcceptFriend = async () => {
|
||||
if (!friendStatus?.friendshipId) return;
|
||||
try {
|
||||
setFriendLoading(true);
|
||||
await api.acceptFriendRequest(friendStatus.friendshipId);
|
||||
setFriendStatus({ status: 'accepted', friendshipId: friendStatus.friendshipId });
|
||||
const socket = getSocket();
|
||||
if (socket) socket.emit('friend_accepted', { friendId: userId });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setFriendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveFriend = async () => {
|
||||
if (!friendStatus?.friendshipId) return;
|
||||
try {
|
||||
setFriendLoading(true);
|
||||
await api.removeFriend(friendStatus.friendshipId);
|
||||
setFriendStatus({ status: 'none', friendshipId: null });
|
||||
const socket = getSocket();
|
||||
if (socket) socket.emit('friend_removed', { friendId: userId });
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setFriendLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initials = (profile?.displayName || profile?.username || '??')
|
||||
.split(' ')
|
||||
.map((w: string) => w[0])
|
||||
.join('')
|
||||
.slice(0, 2)
|
||||
.toUpperCase();
|
||||
|
||||
const tabs: { key: MediaTab; label: string; icon: React.ElementType }[] = [
|
||||
{ key: 'media', label: t('mediaTab'), icon: ImageIcon },
|
||||
{ key: 'files', label: t('filesTab'), icon: FileText },
|
||||
{ key: 'links', label: t('linksTab'), icon: LinkIcon },
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 bg-black/60 z-50"
|
||||
onClick={onClose}
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: 50, filter: 'blur(20px)' }}
|
||||
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
|
||||
exit={{ opacity: 0, x: 50, filter: 'blur(20px)' }}
|
||||
transition={{ type: 'spring', damping: 25, stiffness: 300, mass: 0.8 }}
|
||||
className="fixed right-3 top-3 bottom-3 w-[360px] max-w-[calc(100%-24px)] bg-surface-secondary/80 backdrop-blur-2xl shadow-[0_0_120px_rgba(0,0,0,0.6)] border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden"
|
||||
>
|
||||
{/* Шапка */}
|
||||
<div className="flex items-center justify-between p-5 border-b border-white/5 bg-white/5 relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-vortex-500/20 to-purple-500/10 pointer-events-none" />
|
||||
<h2 className="text-xl font-bold tracking-tight text-white drop-shadow-sm relative z-10">
|
||||
{isSelf ? t('myProfile') : t('profileTitle')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-full bg-black/20 text-zinc-400 hover:text-white hover:bg-white/10 transition-all border border-white/5 relative z-10"
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="w-8 h-8 border-2 border-vortex-500 border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
) : profile ? (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* Аватар */}
|
||||
<div className="flex flex-col items-center pt-8 pb-4 px-6 relative overflow-visible">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[240px] h-[240px] bg-vortex-500/10 rounded-full blur-[80px] pointer-events-none" />
|
||||
|
||||
<div className="relative group">
|
||||
{/* Spinning gradient glow ring */}
|
||||
<div className="absolute -inset-1 bg-gradient-to-r from-accent via-purple-500 to-accent rounded-full opacity-50 blur group-hover:opacity-75 transition duration-500 animate-[spin_4s_linear_infinite]" />
|
||||
|
||||
<div className="relative">
|
||||
{profile.avatar ? (
|
||||
<img
|
||||
src={profile.avatar}
|
||||
alt=""
|
||||
className="w-32 h-32 rounded-full object-cover ring-4 ring-surface bg-surface"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-surface to-surface-secondary flex items-center justify-center text-white font-bold text-4xl ring-4 ring-surface relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-accent/20 to-purple-500/20" />
|
||||
<span className="relative z-10 text-transparent bg-clip-text bg-gradient-to-br from-white to-zinc-400 drop-shadow-md">{initials}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{profile.isOnline && (
|
||||
<div className="absolute bottom-3 right-3 flex items-center justify-center">
|
||||
<div className="absolute w-7 h-7 bg-emerald-500 rounded-full animate-ping opacity-60" />
|
||||
<div className="w-7 h-7 bg-emerald-500 rounded-full border-[5px] border-surface-secondary shadow-[0_0_15px_rgba(16,185,129,0.8)]" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Имя */}
|
||||
<h3 className="mt-5 text-[28px] font-bold text-transparent bg-clip-text bg-gradient-to-b from-white to-white/70 tracking-tight text-center px-4">
|
||||
{profile.displayName || profile.username}
|
||||
</h3>
|
||||
|
||||
{/* Username (неизменяемый) */}
|
||||
<div className="flex items-center gap-1.5 mt-2.5 bg-vortex-500/10 hover:bg-vortex-500/20 transition-colors px-4 py-1.5 rounded-full border border-vortex-500/20 backdrop-blur-sm cursor-default">
|
||||
<AtSign size={14} className="text-vortex-400" />
|
||||
<span className="text-sm font-semibold text-vortex-100">{profile.username}</span>
|
||||
</div>
|
||||
|
||||
{/* Онлайн статус */}
|
||||
<p className="text-xs font-semibold uppercase tracking-widest mt-4">
|
||||
{profile.isOnline ? (
|
||||
<span className="text-emerald-400 drop-shadow-[0_0_8px_rgba(52,211,153,0.8)] flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-emerald-400 rounded-full animate-pulse" />
|
||||
{t('online')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-zinc-500 flex items-center gap-1.5">
|
||||
<span className="w-1.5 h-1.5 bg-zinc-500 rounded-full" />
|
||||
{t('wasRecently')}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Friend button (for other users only) */}
|
||||
{!isSelf && friendStatus && (
|
||||
<div className="mt-4">
|
||||
{friendStatus.status === 'none' && (
|
||||
<button
|
||||
onClick={handleSendFriendRequest}
|
||||
disabled={friendLoading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-vortex-500/20 border border-vortex-500/30 text-vortex-300 hover:bg-vortex-500/30 transition-all text-sm font-medium"
|
||||
>
|
||||
{friendLoading ? <Loader2 size={16} className="animate-spin" /> : <UserPlus size={16} />}
|
||||
{t('addFriend')}
|
||||
</button>
|
||||
)}
|
||||
{friendStatus.status === 'pending' && friendStatus.direction === 'outgoing' && (
|
||||
<div className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-yellow-500/10 border border-yellow-500/20 text-yellow-400 text-sm font-medium">
|
||||
<Clock size={16} />
|
||||
{t('requestSent')}
|
||||
</div>
|
||||
)}
|
||||
{friendStatus.status === 'pending' && friendStatus.direction === 'incoming' && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleAcceptFriend}
|
||||
disabled={friendLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-green-500/20 border border-green-500/30 text-green-400 hover:bg-green-500/30 transition-all text-sm font-medium"
|
||||
>
|
||||
{friendLoading ? <Loader2 size={16} className="animate-spin" /> : <UserCheck size={16} />}
|
||||
{t('accept')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRemoveFriend}
|
||||
disabled={friendLoading}
|
||||
className="flex items-center gap-2 px-4 py-2.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 hover:bg-red-500/20 transition-all text-sm font-medium"
|
||||
>
|
||||
{t('decline')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{friendStatus.status === 'accepted' && (
|
||||
<button
|
||||
onClick={handleRemoveFriend}
|
||||
disabled={friendLoading}
|
||||
className="flex items-center gap-2 px-5 py-2.5 rounded-full bg-red-500/10 border border-red-500/20 text-red-400 hover:bg-red-500/20 transition-all text-sm font-medium"
|
||||
>
|
||||
{friendLoading ? <Loader2 size={16} className="animate-spin" /> : <UserMinus size={16} />}
|
||||
{t('removeFriend')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Информация */}
|
||||
<div className="px-5 space-y-3 pb-8 relative z-10">
|
||||
{/* О себе */}
|
||||
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-6 h-6 rounded-full bg-vortex-500/20 flex items-center justify-center border border-vortex-500/30">
|
||||
<Edit3 size={12} className="text-vortex-400" />
|
||||
</div>
|
||||
<label className="text-xs font-semibold text-vortex-200/50 uppercase tracking-widest">
|
||||
{t('aboutMe')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-200 leading-relaxed pl-1">
|
||||
{profile.bio || (
|
||||
<span className="text-white/30 italic">{t('notSpecified')}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Дата рождения */}
|
||||
{profile.birthday && (
|
||||
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-6 h-6 rounded-full bg-orange-500/20 flex items-center justify-center border border-orange-500/30">
|
||||
<Calendar size={12} className="text-orange-400" />
|
||||
</div>
|
||||
<label className="text-xs font-semibold text-orange-200/50 uppercase tracking-widest">
|
||||
{t('birthday')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-200 pl-1">
|
||||
{profile.birthday ? (
|
||||
new Date(profile.birthday).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})
|
||||
) : (
|
||||
<span className="text-white/30 italic">{t('notSpecified')}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Дата регистрации */}
|
||||
<div className="bg-black/20 backdrop-blur-xl border border-white/5 rounded-2xl p-4 transition-all hover:bg-black/30 hover:border-white/10 group">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-6 h-6 rounded-full bg-emerald-500/20 flex items-center justify-center border border-emerald-500/30">
|
||||
<Check size={12} className="text-emerald-400" />
|
||||
</div>
|
||||
<label className="text-xs font-semibold text-emerald-200/50 uppercase tracking-widest">
|
||||
{t('onVortexSince')}
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-200 pl-1">
|
||||
{new Date(profile.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Медиа / Файлы / Ссылки */}
|
||||
<div className="border-t border-white/5 bg-black/10 mt-2 backdrop-blur-md">
|
||||
<div className="flex px-2 pt-2 gap-1 overflow-x-auto no-scrollbar">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={`flex-1 flex items-center justify-center gap-2 py-3 px-1 text-xs font-bold transition-all rounded-t-xl min-w-[100px] ${activeTab === tab.key
|
||||
? 'bg-white/10 text-white shadow-[inset_0_2px_10px_rgba(255,255,255,0.05)] border-t border-x border-white/10'
|
||||
: 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<tab.icon size={14} className={activeTab === tab.key ? 'text-vortex-400' : 'opacity-70'} />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="min-h-[160px] bg-white/[0.02] border-t border-white/5 relative">
|
||||
{/* Subtle top glow for active tab content */}
|
||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-vortex-500/50 to-transparent" />
|
||||
{tabLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 size={20} className="animate-spin text-zinc-500" />
|
||||
</div>
|
||||
) : activeTab === 'media' ? (
|
||||
sharedMedia.length > 0 ? (
|
||||
<div className="grid grid-cols-3 gap-0.5 p-1">
|
||||
{(() => {
|
||||
const allMedia = sharedMedia.flatMap((msg) => (msg.media || []));
|
||||
return allMedia.map((m, idx) => (
|
||||
<div
|
||||
key={m.id}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
className="relative aspect-square bg-zinc-900 overflow-hidden group cursor-pointer"
|
||||
>
|
||||
{m.type === 'video' ? (
|
||||
<>
|
||||
<img
|
||||
src={m.thumbnail || m.url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
|
||||
<Play size={24} className="text-white fill-white" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<img
|
||||
src={m.url}
|
||||
alt=""
|
||||
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
));
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedPhotos')}</p>
|
||||
</div>
|
||||
)
|
||||
) : activeTab === 'files' ? (
|
||||
sharedFiles.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{sharedFiles.flatMap((msg) =>
|
||||
(msg.media || []).map((m) => (
|
||||
<a
|
||||
key={m.id}
|
||||
href={m.url}
|
||||
download={m.filename || 'file'}
|
||||
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors group/file"
|
||||
>
|
||||
<div className="w-10 h-10 rounded-xl bg-vortex-500/20 flex items-center justify-center flex-shrink-0 border border-vortex-500/30 group-hover/file:scale-105 transition-transform">
|
||||
<FileText size={18} className="text-vortex-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-white truncate">{m.filename || 'file'}</p>
|
||||
<p className="text-xs text-zinc-500">
|
||||
{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}
|
||||
{msg.sender ? ` · ${msg.sender.displayName || msg.sender.username}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Download size={16} className="text-zinc-500 flex-shrink-0" />
|
||||
</a>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedFiles')}</p>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
sharedLinks.length > 0 ? (
|
||||
<div className="divide-y divide-border">
|
||||
{sharedLinks.map((msg) => (
|
||||
<div key={msg.id} className="px-4 py-3 hover:bg-white/5 transition-colors">
|
||||
<p className="text-xs text-zinc-500 mb-1.5 font-medium">
|
||||
{msg.sender?.displayName || msg.sender?.username} · {new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US')}
|
||||
</p>
|
||||
{(msg.links || []).map((link: string, i: number) => (
|
||||
<a
|
||||
key={i}
|
||||
href={link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-vortex-400 hover:text-vortex-300 transition-colors truncate"
|
||||
>
|
||||
<ExternalLink size={14} className="flex-shrink-0" />
|
||||
<span className="truncate">{link}</span>
|
||||
</a>
|
||||
))}
|
||||
{msg.content && (
|
||||
<p className="text-xs text-zinc-400 mt-1 line-clamp-2">{msg.content}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<p className="text-xs text-zinc-600 italic">{t('sharedLinks')}</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-zinc-500">
|
||||
{t('profileNotFound')}
|
||||
</div>
|
||||
)}
|
||||
</motion.div>
|
||||
|
||||
{/* Media lightbox gallery */}
|
||||
<AnimatePresence>
|
||||
{lightboxIndex !== null && (
|
||||
<ImageLightbox
|
||||
images={sharedMedia.flatMap((msg) => (msg.media || []).map((m) => ({ url: m.url, type: m.type })))}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
308
apps/web/src/index.css
Normal file
308
apps/web/src/index.css
Normal file
@@ -0,0 +1,308 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
|
||||
--color-vortex-50: #eef2ff;
|
||||
--color-vortex-100: #e0e7ff;
|
||||
--color-vortex-200: #c7d2fe;
|
||||
--color-vortex-300: #a5b4fc;
|
||||
--color-vortex-400: #818cf8;
|
||||
--color-vortex-500: #6366f1;
|
||||
--color-vortex-600: #4f46e5;
|
||||
--color-vortex-700: #4338ca;
|
||||
--color-vortex-800: #3730a3;
|
||||
--color-vortex-900: #312e81;
|
||||
--color-vortex-950: #1e1b4b;
|
||||
|
||||
--color-surface: #09090b;
|
||||
--color-surface-secondary: #111113;
|
||||
--color-surface-tertiary: #1a1a1e;
|
||||
--color-surface-hover: #222226;
|
||||
--color-border: rgba(255, 255, 255, 0.08);
|
||||
--color-border-light: rgba(255, 255, 255, 0.12);
|
||||
|
||||
--color-accent: #6366f1;
|
||||
--color-accent-hover: #818cf8;
|
||||
--color-accent-light: rgba(99, 102, 241, 0.15);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.1) transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
background-color: #09090b;
|
||||
color: #fafafa;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@keyframes slideIn {
|
||||
from { opacity: 0; transform: translateY(8px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes pulse-soft {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slideIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.glass-strong {
|
||||
background: rgba(17, 17, 19, 0.85);
|
||||
backdrop-filter: blur(40px);
|
||||
-webkit-backdrop-filter: blur(40px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.gradient-text {
|
||||
background: linear-gradient(135deg, #6366f1, #8b5cf6, #a855f7);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.bubble-sent {
|
||||
background: #3b82f6;
|
||||
}
|
||||
|
||||
.bubble-received {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.bubble-sent ::selection {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* ==================== */
|
||||
/* Animation Keyframes */
|
||||
/* ==================== */
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-20px); }
|
||||
}
|
||||
|
||||
@keyframes call-wave {
|
||||
0% { transform: scale(1); opacity: 0.6; }
|
||||
100% { transform: scale(2.5); opacity: 0; }
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.animate-float-delayed {
|
||||
animation: float 6s ease-in-out 3s infinite;
|
||||
}
|
||||
|
||||
.animate-call-wave {
|
||||
animation: call-wave 2s ease-out infinite;
|
||||
}
|
||||
|
||||
.animate-call-wave-delayed {
|
||||
animation: call-wave 2s ease-out 1s infinite;
|
||||
}
|
||||
|
||||
/* ==================== */
|
||||
/* Chat Themes */
|
||||
/* ==================== */
|
||||
|
||||
.chat-theme-midnight {
|
||||
background-color: #0f0f13;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3z' fill='%23ffffff' fill-opacity='0.02' fill-rule='evenodd'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.chat-theme-ocean {
|
||||
background-color: #0b172a;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%233b82f6' fill-opacity='0.05' fill-rule='evenodd'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.chat-theme-forest {
|
||||
background-color: #0f1c15;
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='20' height='20' viewBox='0 0 20 20' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%2310b981' fill-opacity='0.04' fill-rule='evenodd'%3E%3Ccircle cx='3' cy='3' r='3'/%3E%3Ccircle cx='13' cy='13' r='3'/%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
.chat-theme-sunset {
|
||||
background: linear-gradient(#1f111a, #150a0f);
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-sunset::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background-image: radial-gradient(circle at 50% 150%, rgba(236, 72, 153, 0.1), transparent 60%);
|
||||
}
|
||||
|
||||
.chat-theme-classic {
|
||||
background-color: #121215;
|
||||
}
|
||||
|
||||
.chat-theme-neon {
|
||||
background-color: #0b0f19;
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-neon::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 400px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(139, 92, 246, 0.15), transparent 80%);
|
||||
transition: background 0.15s ease-out;
|
||||
}
|
||||
|
||||
.chat-theme-aurora {
|
||||
background: linear-gradient(135deg, #022c22, #064e3b);
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-aurora::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 600px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(16, 185, 129, 0.2), transparent 70%);
|
||||
transition: background 0.15s ease-out;
|
||||
}
|
||||
|
||||
.chat-theme-cyber {
|
||||
background-color: #000;
|
||||
background-image:
|
||||
linear-gradient(rgba(245, 158, 11, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(245, 158, 11, 0.03) 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-cyber::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 300px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(245, 158, 11, 0.15), transparent);
|
||||
transition: background 0.15s ease-out;
|
||||
}
|
||||
|
||||
.chat-theme-glass {
|
||||
background-color: #0d1117;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
.chat-theme-glass::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 50vw;
|
||||
height: 50vh;
|
||||
left: var(--mouse-x, 50%);
|
||||
top: var(--mouse-y, 50%);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.2), transparent 60%);
|
||||
filter: blur(80px);
|
||||
transition: left 0.5s cubic-bezier(0.2, 0.8, 0.2, 1), top 0.5s cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.chat-theme-void {
|
||||
background-color: #000;
|
||||
position: relative;
|
||||
}
|
||||
.chat-theme-void::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0; right: 0; bottom: 0; left: 0;
|
||||
pointer-events: none;
|
||||
background: radial-gradient(circle 120px at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(255, 255, 255, 0.05), transparent);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.chat-bg {
|
||||
background-color: #09090b;
|
||||
background-image:
|
||||
radial-gradient(ellipse at 20% 50%, rgba(99, 102, 241, 0.06) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 80% 20%, rgba(139, 92, 246, 0.05) 0%, transparent 50%),
|
||||
radial-gradient(ellipse at 60% 80%, rgba(168, 85, 247, 0.04) 0%, transparent 50%),
|
||||
url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%236366f1' fill-opacity='0.03'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(99, 102, 241, 0.3);
|
||||
text-shadow: 0 0 8px rgba(99, 102, 241, 0.4);
|
||||
}
|
||||
|
||||
input:focus, textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Focus-visible ring for keyboard navigation accessibility */
|
||||
:focus-visible {
|
||||
outline: 2px solid rgba(99, 102, 241, 0.6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
button:focus:not(:focus-visible),
|
||||
a:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* emoji-mart dark theme overrides */
|
||||
em-emoji-picker {
|
||||
--em-rgb-background: 17, 17, 19;
|
||||
--em-rgb-input: 26, 26, 30;
|
||||
--em-rgb-color: 240, 240, 240;
|
||||
--border-radius: 0 0 16px 16px;
|
||||
border: none !important;
|
||||
}
|
||||
297
apps/web/src/lib/api.ts
Normal file
297
apps/web/src/lib/api.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import type { User, UserBasic, UserPresence, Chat, Message, MediaItem, StoryGroup, FriendRequest, FriendWithId, FriendshipStatus } from './types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
|
||||
class ApiClient {
|
||||
private token: string | null = null;
|
||||
|
||||
setToken(token: string | null) {
|
||||
this.token = token;
|
||||
}
|
||||
|
||||
private async request<T>(endpoint: string, options: RequestInit & { timeout?: number } = {}): Promise<T> {
|
||||
const { timeout = 30_000, ...fetchOptions } = options;
|
||||
const controller = new AbortController();
|
||||
const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : undefined;
|
||||
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
...fetchOptions.headers,
|
||||
};
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${API_BASE}${endpoint}`, {
|
||||
...fetchOptions,
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
throw new Error('Время ожидания запроса истекло');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ error: '\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u0435\u0440\u0432\u0435\u0440\u0430' }));
|
||||
throw new Error(error.error || '\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u043f\u0440\u043e\u0441\u0430');
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// \u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u044f
|
||||
async login(username: string, password: string) {
|
||||
return this.request<{ token: string; user: User }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
}
|
||||
|
||||
async register(username: string, displayName: string, password: string, bio?: string) {
|
||||
return this.request<{ token: string; user: User }>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, displayName, password, bio }),
|
||||
});
|
||||
}
|
||||
|
||||
async getMe() {
|
||||
return this.request<{ user: User }>('/auth/me');
|
||||
}
|
||||
|
||||
// \u041f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0438
|
||||
async searchUsers(query: string) {
|
||||
return this.request<UserPresence[]>(`/users/search?q=${encodeURIComponent(query)}`);
|
||||
}
|
||||
|
||||
async getUser(id: string) {
|
||||
return this.request<User>(`/users/${id}`);
|
||||
}
|
||||
|
||||
async updateProfile(data: { displayName?: string; bio?: string; birthday?: string }) {
|
||||
return this.request<User>('/users/profile', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async uploadAvatar(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 120_000);
|
||||
const response = await fetch(`${API_BASE}/users/avatar`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) throw new Error('Ошибка загрузки аватара');
|
||||
return response.json() as Promise<User>;
|
||||
}
|
||||
|
||||
async removeAvatar() {
|
||||
return this.request<User>('/users/avatar', { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async searchMessages(query: string, chatId?: string) {
|
||||
const params = new URLSearchParams({ q: query });
|
||||
if (chatId) params.append('chatId', chatId);
|
||||
return this.request<Message[]>(`/users/messages/search?${params}`);
|
||||
}
|
||||
|
||||
// \u0427\u0430\u0442\u044b
|
||||
async getChats() {
|
||||
return this.request<Chat[]>('/chats');
|
||||
}
|
||||
|
||||
async createPersonalChat(userId: string) {
|
||||
return this.request<Chat>('/chats/personal', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userId }),
|
||||
});
|
||||
}
|
||||
|
||||
async createGroupChat(name: string, memberIds: string[]) {
|
||||
return this.request<Chat>('/chats/group', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ name, memberIds }),
|
||||
});
|
||||
}
|
||||
|
||||
// \u0421\u043e\u043e\u0431\u0449\u0435\u043d\u0438\u044f
|
||||
async getMessages(chatId: string, cursor?: string) {
|
||||
const params = cursor ? `?cursor=${cursor}` : '';
|
||||
return this.request<Message[]>(`/messages/chat/${chatId}${params}`);
|
||||
}
|
||||
|
||||
async uploadFile(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 120_000);
|
||||
const response = await fetch(`${API_BASE}/messages/upload`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) throw new Error('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0444\u0430\u0439\u043b\u0430');
|
||||
return response.json() as Promise<{ url: string; filename: string; size: number }>;
|
||||
}
|
||||
|
||||
// \u0413\u0440\u0443\u043f\u043f\u044b
|
||||
async updateGroup(chatId: string, data: { name?: string }) {
|
||||
return this.request<Chat>(`/chats/${chatId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async uploadGroupAvatar(chatId: string, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 120_000);
|
||||
const response = await fetch(`${API_BASE}/chats/${chatId}/avatar`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timer);
|
||||
|
||||
if (!response.ok) throw new Error('\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438 \u0430\u0432\u0430\u0442\u0430\u0440\u0430');
|
||||
return response.json() as Promise<Chat>;
|
||||
}
|
||||
|
||||
async removeGroupAvatar(chatId: string) {
|
||||
return this.request<Chat>(`/chats/${chatId}/avatar`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async addGroupMembers(chatId: string, userIds: string[]) {
|
||||
return this.request<Chat>(`/chats/${chatId}/members`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ userIds }),
|
||||
});
|
||||
}
|
||||
|
||||
async removeGroupMember(chatId: string, userId: string) {
|
||||
return this.request<Chat>(`/chats/${chatId}/members/${userId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
|
||||
async clearChat(chatId: string) {
|
||||
return this.request<{ message: string }>(`/chats/${chatId}/clear`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async deleteChat(chatId: string) {
|
||||
return this.request<{ message: string }>(`/chats/${chatId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async togglePinChat(chatId: string) {
|
||||
return this.request<{ isPinned: boolean }>(`/chats/${chatId}/pin`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async getSharedMedia(chatId: string, type: 'media' | 'files' | 'links') {
|
||||
return this.request<Message[]>(`/messages/chat/${chatId}/shared?type=${type}`);
|
||||
}
|
||||
|
||||
// ICE серверы для WebRTC
|
||||
async getIceServers() {
|
||||
return this.request<{ iceServers: RTCIceServer[] }>('/ice-servers');
|
||||
}
|
||||
|
||||
// Stories
|
||||
async getStories() {
|
||||
return this.request<StoryGroup[]>('/stories');
|
||||
}
|
||||
|
||||
async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) {
|
||||
return this.request<{ id: string }>('/stories', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
async viewStory(storyId: string) {
|
||||
return this.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async deleteStory(storyId: string) {
|
||||
return this.request<{ message: string }>(`/stories/${storyId}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async getStoryViewers(storyId: string) {
|
||||
return this.request<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>(`/stories/${storyId}/viewers`);
|
||||
}
|
||||
|
||||
// Favorites chat
|
||||
async getOrCreateFavorites() {
|
||||
return this.request<Chat>('/chats/favorites', { method: 'POST' });
|
||||
}
|
||||
|
||||
// User settings
|
||||
async updateSettings(data: { hideStoryViews?: boolean }) {
|
||||
return this.request<User>('/users/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
// Friends
|
||||
async getFriends() {
|
||||
return this.request<FriendWithId[]>('/friends');
|
||||
}
|
||||
|
||||
async getFriendRequests() {
|
||||
return this.request<FriendRequest[]>('/friends/requests');
|
||||
}
|
||||
|
||||
async getOutgoingRequests() {
|
||||
return this.request<FriendRequest[]>('/friends/outgoing');
|
||||
}
|
||||
|
||||
async getFriendshipStatus(userId: string) {
|
||||
return this.request<FriendshipStatus>(`/friends/status/${userId}`);
|
||||
}
|
||||
|
||||
async sendFriendRequest(friendId: string) {
|
||||
return this.request<{ status: string }>('/friends/request', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ friendId }),
|
||||
});
|
||||
}
|
||||
|
||||
async acceptFriendRequest(friendshipId: string) {
|
||||
return this.request<{ id: string }>(`/friends/${friendshipId}/accept`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async declineFriendRequest(friendshipId: string) {
|
||||
return this.request<{ success: boolean }>(`/friends/${friendshipId}/decline`, { method: 'POST' });
|
||||
}
|
||||
|
||||
async removeFriend(friendshipId: string) {
|
||||
return this.request<{ success: boolean }>(`/friends/${friendshipId}`, { method: 'DELETE' });
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient();
|
||||
59
apps/web/src/lib/hooks.ts
Normal file
59
apps/web/src/lib/hooks.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
|
||||
/**
|
||||
* Debounced value — updates value after the specified delay.
|
||||
*/
|
||||
export function useDebouncedValue<T>(value: T, delay: number): T {
|
||||
const [debounced, setDebounced] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(value), delay);
|
||||
return () => clearTimeout(timer);
|
||||
}, [value, delay]);
|
||||
|
||||
return debounced;
|
||||
}
|
||||
|
||||
/**
|
||||
* Debounced callback — returns a function that delays execution.
|
||||
*/
|
||||
export function useDebouncedCallback<T extends (...args: unknown[]) => unknown>(
|
||||
callback: T,
|
||||
delay: number,
|
||||
): (...args: Parameters<T>) => void {
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useCallback((...args: Parameters<T>) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
timerRef.current = setTimeout(() => callbackRef.current(...args), delay);
|
||||
}, [delay]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an AbortController that auto-aborts on unmount or when reset is called.
|
||||
*/
|
||||
export function useAbortController() {
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const getSignal = useCallback(() => {
|
||||
if (controllerRef.current) controllerRef.current.abort();
|
||||
controllerRef.current = new AbortController();
|
||||
return controllerRef.current.signal;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
controllerRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return getSignal;
|
||||
}
|
||||
513
apps/web/src/lib/i18n.ts
Normal file
513
apps/web/src/lib/i18n.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
export type Lang = 'ru' | 'en';
|
||||
|
||||
const LANG_KEY = 'vortex_language';
|
||||
|
||||
const translations = {
|
||||
ru: {
|
||||
// Side menu
|
||||
myProfile: 'Мой профиль',
|
||||
settings: 'Настройки',
|
||||
aboutApp: 'О приложении',
|
||||
logout: 'Выйти из аккаунта',
|
||||
// Profile
|
||||
name: 'Имя',
|
||||
username: 'Имя пользователя',
|
||||
aboutMe: 'О себе',
|
||||
birthday: 'Дата рождения',
|
||||
enterName: 'Введите имя',
|
||||
tellAboutYourself: 'Расскажите о себе',
|
||||
removePhoto: 'Удалить фото',
|
||||
// Settings
|
||||
language: 'Язык / Language',
|
||||
interfaceLang: 'Язык интерфейса',
|
||||
theme: 'Оформление',
|
||||
russian: 'Русский',
|
||||
english: 'English',
|
||||
about: 'О приложении',
|
||||
version: 'Версия',
|
||||
modernMessenger: 'Современный мессенджер с фокусом',
|
||||
onPrivacy: 'на приватность и удобство',
|
||||
modernMessengerShort: 'Современный мессенджер',
|
||||
// Chat
|
||||
chat: 'Чат',
|
||||
group: 'Группа',
|
||||
searchChats: 'Поиск чатов...',
|
||||
noChats: 'Нет чатов — создайте первый!',
|
||||
nothingFound: 'Ничего не найдено',
|
||||
selectChat: 'Выберите чат для начала общения',
|
||||
noMessages: 'Нет сообщений — напишите первое!',
|
||||
typing: 'печатает...',
|
||||
online: 'в сети',
|
||||
wasRecently: 'был(а) недавно',
|
||||
members: 'участников',
|
||||
messagePlaceholder: 'Сообщение...',
|
||||
message: 'Сообщение...',
|
||||
addCaption: 'Добавьте подпись...',
|
||||
searchMessages: 'Поиск сообщений...',
|
||||
searchMessagesBtn: 'Поиск сообщений',
|
||||
userProfile: 'Профиль пользователя',
|
||||
enableSound: 'Включить звук',
|
||||
disableSound: 'Выключить звук',
|
||||
deleteChat: 'Удалить чат',
|
||||
// Messages
|
||||
messageDeleted: 'Сообщение удалено',
|
||||
reply: 'Ответить',
|
||||
copy: 'Копировать',
|
||||
edit: 'Редактировать',
|
||||
delete: 'Удалить',
|
||||
deleteForMe: 'Удалить у меня',
|
||||
deleteForAll: 'Удалить у всех',
|
||||
deleteAlsoFor: 'Удалить также для',
|
||||
editing: 'Редактирование',
|
||||
replyTo: 'Ответ',
|
||||
media: 'Медиа',
|
||||
download: 'Скачать',
|
||||
edited: 'ред.',
|
||||
selected: 'выбрано',
|
||||
fileLabel: 'Файл',
|
||||
kb: 'КБ',
|
||||
voice: '🎤 Голосовое',
|
||||
photo: '🖼 Фото',
|
||||
video: '🎬 Видео',
|
||||
file: 'Файл',
|
||||
// Call
|
||||
call: 'Звонок',
|
||||
videoCall: 'Видеозвонок',
|
||||
incomingCall: 'Входящий звонок',
|
||||
incomingVideoCall: 'Входящий видеозвонок',
|
||||
calling: 'Вызов...',
|
||||
accept: 'Принять',
|
||||
decline: 'Отклонить',
|
||||
endCall: 'Завершить',
|
||||
callEnded: 'Звонок завершён',
|
||||
callDeclined: 'Звонок отклонён',
|
||||
// Photo/video
|
||||
photoVideo: 'Фото / видео',
|
||||
fileBtn: 'Файл',
|
||||
sendError: 'Ошибка отправки',
|
||||
// New chat
|
||||
newChat: 'Новый чат',
|
||||
menu: 'Меню',
|
||||
// Date picker
|
||||
months: ['Январь', 'Февраль', 'Март', 'Апрель', 'Май', 'Июнь', 'Июль', 'Август', 'Сентябрь', 'Октябрь', 'Ноябрь', 'Декабрь'],
|
||||
weekDays: ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс'],
|
||||
clear: 'Очистить',
|
||||
today: 'Сегодня',
|
||||
// New chat
|
||||
newChatTitle: 'Новый чат',
|
||||
selectMembers: 'Выберите участников',
|
||||
newGroup: 'Новая группа',
|
||||
groupNamePlaceholder: 'Название группы...',
|
||||
membersCount: 'Участники',
|
||||
createGroup: 'Создать группу',
|
||||
upTo200: 'До 200 участников',
|
||||
findUser: 'Найти пользователя по имени или @username...',
|
||||
addMembers: 'Добавить участников...',
|
||||
usersNotFound: 'Пользователи не найдены',
|
||||
enterNameOrUsername: 'Введите имя или @username',
|
||||
next: 'Далее',
|
||||
pinMessage: 'Закрепить',
|
||||
unpinMessage: 'Открепить',
|
||||
pinnedMessage: 'Закреплённое сообщение',
|
||||
forwardMessage: 'Переслать сообщение',
|
||||
forward: 'Переслать',
|
||||
forwardedFrom: 'Переслано от',
|
||||
replyWithQuote: 'Ответить с цитатой',
|
||||
select: 'Выбрать',
|
||||
sending: 'Отправка...',
|
||||
scheduleMessage: 'Запланировать',
|
||||
scheduleSend: 'Отправить по расписанию',
|
||||
scheduled: 'Запланировано',
|
||||
myStory: 'Моя история',
|
||||
newStory: 'Новая история',
|
||||
textStory: 'Текст',
|
||||
imageStory: 'Фото',
|
||||
typeYourStory: 'Напишите историю...',
|
||||
publishStory: 'Опубликовать',
|
||||
stories: 'Истории',
|
||||
clearChat: 'Очистить чат',
|
||||
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
|
||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
||||
pinChat: 'Закрепить чат',
|
||||
unpinChat: 'Открепить чат',
|
||||
chatCleared: 'Чат очищен',
|
||||
groupSettings: 'Настройки группы',
|
||||
editGroupName: 'Изменить название',
|
||||
addMember: 'Добавить участника',
|
||||
removeMember: 'Удалить из группы',
|
||||
leaveGroup: 'Покинуть группу',
|
||||
adminBadge: 'Админ',
|
||||
memberBadge: 'Участник',
|
||||
screenShare: 'Демонстрация экрана',
|
||||
stopScreenShare: 'Остановить демонстрацию',
|
||||
switchCamera: 'Выбрать камеру',
|
||||
minimize: 'Свернуть',
|
||||
volume: 'Громкость',
|
||||
mute: 'Выключить микрофон',
|
||||
unmute: 'Включить микрофон',
|
||||
noiseSuppressionOn: 'Шумоподавление включено',
|
||||
noiseSuppressionOff: 'Шумоподавление выключено',
|
||||
selectMicrophone: 'Выбрать микрофон',
|
||||
microphone: 'Микрофон',
|
||||
rightClickVolume: 'ПКМ для регулировки громкости',
|
||||
participants: 'участников',
|
||||
joinCall: 'Присоединиться к звонку',
|
||||
activeCall: 'Активный звонок',
|
||||
groupNameRequired: 'Введите название группы',
|
||||
confirmRemoveMember: 'Удалить этого участника из группы?',
|
||||
yes: 'Да',
|
||||
no: 'Нет',
|
||||
you: 'вы',
|
||||
// User profile
|
||||
mediaTab: 'Медиа',
|
||||
filesTab: 'Файлы',
|
||||
linksTab: 'Ссылки',
|
||||
profileTitle: 'Профиль',
|
||||
removeAvatar: 'Удалить аватар',
|
||||
namePlaceholder: 'Имя',
|
||||
notSpecified: 'Не указано',
|
||||
onVortexSince: 'На Vortex с',
|
||||
cancel: 'Отмена',
|
||||
confirm: 'Подтвердить',
|
||||
save: 'Сохранить',
|
||||
sharedPhotos: 'Общие фото и видео будут здесь',
|
||||
sharedFiles: 'Общие файлы будут здесь',
|
||||
sharedLinks: 'Общие ссылки будут здесь',
|
||||
profileNotFound: 'Профиль не найден',
|
||||
// Typing
|
||||
typingText: 'печатает',
|
||||
// Emoji
|
||||
emojiFrequent: 'Часто',
|
||||
emojiGestures: 'Жесты',
|
||||
emojiEmotions: 'Эмоции',
|
||||
emojiObjects: 'Предметы',
|
||||
emojiFood: 'Еда',
|
||||
emojiNature: 'Природа',
|
||||
// Auth
|
||||
login: 'Вход',
|
||||
register: 'Регистрация',
|
||||
latinOnly: '(латиница, нельзя изменить)',
|
||||
displayNameLabel: 'Отображаемое имя',
|
||||
displayNamePlaceholder: 'Ваше имя (любой язык)',
|
||||
password: 'Пароль',
|
||||
passwordPlaceholder: 'Введите пароль',
|
||||
bioPlaceholder: 'Расскажите о себе (необязательно)',
|
||||
loginBtn: 'Войти',
|
||||
createAccount: 'Создать аккаунт',
|
||||
testAccounts: 'Тестовые аккаунты: evgeniy, anastasia, artem, polina',
|
||||
passwordForAll: 'Пароль для всех:',
|
||||
// File/upload
|
||||
fileTooLarge: 'Файл слишком большой (макс. 50МБ)',
|
||||
dropFileHere: 'Отпустите файл здесь',
|
||||
uploading: 'Загрузка...',
|
||||
changePhoto: 'Сменить фото',
|
||||
remove: 'Удалить',
|
||||
// Formatting
|
||||
formatBold: 'Жирный',
|
||||
formatBoldHint: '**текст**',
|
||||
formatItalic: 'Курсив',
|
||||
formatItalicHint: '_текст_',
|
||||
formatStrike: 'Зачёркнут',
|
||||
formatStrikeHint: '~текст~',
|
||||
formatMono: 'Моноширинный',
|
||||
formatMonoHint: '`текст`',
|
||||
// Draft
|
||||
draft: 'Черновик:',
|
||||
// Date
|
||||
datePlaceholder: 'дд.мм.гггг',
|
||||
// GIF / Emoji
|
||||
tenorKeyRequired: 'Для GIF нужен Tenor API ключ',
|
||||
openConsoleRun: 'Откройте консоль и выполните:',
|
||||
searchGifs: 'Поиск GIF...',
|
||||
trending: 'Популярные',
|
||||
// Misc
|
||||
error: 'Ошибка',
|
||||
// Friends
|
||||
friends: 'Друзья',
|
||||
friendRequests: 'Заявки в друзья',
|
||||
friendsList: 'Список друзей',
|
||||
noFriends: 'Пока нет друзей',
|
||||
addFriend: 'Добавить в друзья',
|
||||
removeFriend: 'Удалить из друзей',
|
||||
requestSent: 'Заявка отправлена',
|
||||
searchFriends: 'Поиск по @username (мин. 3 символа)',
|
||||
noSearchResults: 'Пользователи не найдены',
|
||||
minCharsHint: 'Введите минимум 3 символа после @',
|
||||
// Story viewers
|
||||
storyViewers: 'Кто просмотрел',
|
||||
noViewers: 'Пока никто не посмотрел',
|
||||
// Favorites
|
||||
favorites: 'Избранное',
|
||||
favoritesDescription: 'Сохраняйте важные сообщения здесь',
|
||||
// Privacy
|
||||
privacy: 'Приватность',
|
||||
hideStoryViews: 'Скрывать просмотры историй',
|
||||
hideStoryViewsDesc: 'Другие не увидят, что вы смотрели их историю',
|
||||
// Scheduled
|
||||
scheduledFor: 'Запланировано на',
|
||||
messageScheduled: 'Сообщение запланировано',
|
||||
scheduledDelivered: 'Сообщение отправлено для',
|
||||
scheduledDeliveredAt: 'в',
|
||||
scheduleIn1h: 'Через 1 час',
|
||||
scheduleIn3h: 'Через 3 часа',
|
||||
scheduleTomorrow: 'Завтра в 9:00',
|
||||
scheduleCustom: 'Выбрать дату и время',
|
||||
scheduleTime: 'Время',
|
||||
scheduleDate: 'Дата',
|
||||
// Last seen
|
||||
lastSeenAt: 'был(а)',
|
||||
justNow: 'только что',
|
||||
},
|
||||
en: {
|
||||
myProfile: 'My Profile',
|
||||
settings: 'Settings',
|
||||
aboutApp: 'About',
|
||||
logout: 'Log Out',
|
||||
name: 'Name',
|
||||
username: 'Username',
|
||||
aboutMe: 'About me',
|
||||
birthday: 'Birthday',
|
||||
enterName: 'Enter name',
|
||||
tellAboutYourself: 'Tell about yourself',
|
||||
removePhoto: 'Remove photo',
|
||||
language: 'Language',
|
||||
interfaceLang: 'Interface language',
|
||||
theme: 'Theme',
|
||||
russian: 'Русский',
|
||||
english: 'English',
|
||||
about: 'About',
|
||||
version: 'Version',
|
||||
modernMessenger: 'A modern messenger focused',
|
||||
onPrivacy: 'on privacy and convenience',
|
||||
modernMessengerShort: 'Modern messenger',
|
||||
searchChats: 'Search chats...',
|
||||
chat: 'Chat',
|
||||
group: 'Group',
|
||||
noChats: 'No chats — create one!',
|
||||
nothingFound: 'Nothing found',
|
||||
selectChat: 'Select a chat to start messaging',
|
||||
noMessages: 'No messages — write the first one!',
|
||||
typing: 'typing...',
|
||||
online: 'online',
|
||||
wasRecently: 'was recently',
|
||||
members: 'members',
|
||||
messagePlaceholder: 'Message...',
|
||||
message: 'Message...',
|
||||
addCaption: 'Add a caption...',
|
||||
searchMessages: 'Search messages...',
|
||||
searchMessagesBtn: 'Search messages',
|
||||
userProfile: 'User profile',
|
||||
enableSound: 'Enable sound',
|
||||
disableSound: 'Mute',
|
||||
deleteChat: 'Delete chat',
|
||||
messageDeleted: 'Message deleted',
|
||||
reply: 'Reply',
|
||||
copy: 'Copy',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
deleteForMe: 'Delete for me',
|
||||
deleteForAll: 'Delete for everyone',
|
||||
deleteAlsoFor: 'Delete also for',
|
||||
editing: 'Editing',
|
||||
replyTo: 'Reply',
|
||||
media: 'Media',
|
||||
download: 'Download',
|
||||
edited: 'edit.',
|
||||
selected: 'selected',
|
||||
fileLabel: 'File',
|
||||
kb: 'KB',
|
||||
voice: '🎤 Voice',
|
||||
photo: '🖼 Photo',
|
||||
video: '🎬 Video',
|
||||
file: 'File',
|
||||
call: 'Call',
|
||||
videoCall: 'Video call',
|
||||
incomingCall: 'Incoming call',
|
||||
incomingVideoCall: 'Incoming video call',
|
||||
calling: 'Calling...',
|
||||
accept: 'Accept',
|
||||
decline: 'Decline',
|
||||
endCall: 'End call',
|
||||
callEnded: 'Call ended',
|
||||
callDeclined: 'Call declined',
|
||||
photoVideo: 'Photo / video',
|
||||
fileBtn: 'File',
|
||||
sendError: 'Send error',
|
||||
newChat: 'New chat',
|
||||
menu: 'Menu',
|
||||
months: ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'],
|
||||
weekDays: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],
|
||||
clear: 'Clear',
|
||||
today: 'Today',
|
||||
newChatTitle: 'New Chat',
|
||||
selectMembers: 'Select members',
|
||||
newGroup: 'New Group',
|
||||
groupNamePlaceholder: 'Group name...',
|
||||
membersCount: 'Members',
|
||||
createGroup: 'Create Group',
|
||||
upTo200: 'Up to 200 members',
|
||||
findUser: 'Find user by name or @username...',
|
||||
addMembers: 'Add members...',
|
||||
usersNotFound: 'Users not found',
|
||||
enterNameOrUsername: 'Enter name or @username',
|
||||
next: 'Next',
|
||||
pinMessage: 'Pin',
|
||||
unpinMessage: 'Unpin',
|
||||
pinnedMessage: 'Pinned message',
|
||||
forwardMessage: 'Forward message',
|
||||
forward: 'Forward',
|
||||
forwardedFrom: 'Forwarded from',
|
||||
replyWithQuote: 'Reply with quote',
|
||||
select: 'Select',
|
||||
sending: 'Sending...',
|
||||
scheduleMessage: 'Schedule',
|
||||
scheduleSend: 'Send scheduled',
|
||||
scheduled: 'Scheduled',
|
||||
myStory: 'My story',
|
||||
newStory: 'New story',
|
||||
textStory: 'Text',
|
||||
imageStory: 'Photo',
|
||||
typeYourStory: 'Write your story...',
|
||||
publishStory: 'Publish',
|
||||
stories: 'Stories',
|
||||
clearChat: 'Clear chat',
|
||||
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
|
||||
deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
|
||||
pinChat: 'Pin chat',
|
||||
unpinChat: 'Unpin chat',
|
||||
chatCleared: 'Chat cleared',
|
||||
groupSettings: 'Group settings',
|
||||
editGroupName: 'Edit name',
|
||||
addMember: 'Add member',
|
||||
removeMember: 'Remove from group',
|
||||
leaveGroup: 'Leave group',
|
||||
adminBadge: 'Admin',
|
||||
memberBadge: 'Member',
|
||||
screenShare: 'Screen share',
|
||||
stopScreenShare: 'Stop sharing',
|
||||
switchCamera: 'Switch camera',
|
||||
minimize: 'Minimize',
|
||||
volume: 'Volume',
|
||||
mute: 'Mute',
|
||||
unmute: 'Unmute',
|
||||
noiseSuppressionOn: 'Noise suppression on',
|
||||
noiseSuppressionOff: 'Noise suppression off',
|
||||
selectMicrophone: 'Select microphone',
|
||||
microphone: 'Microphone',
|
||||
rightClickVolume: 'Right-click to adjust volume',
|
||||
participants: 'participants',
|
||||
joinCall: 'Join call',
|
||||
activeCall: 'Active call',
|
||||
groupNameRequired: 'Enter a group name',
|
||||
confirmRemoveMember: 'Remove this member from the group?',
|
||||
yes: 'Yes',
|
||||
no: 'No',
|
||||
you: 'you',
|
||||
mediaTab: 'Media',
|
||||
filesTab: 'Files',
|
||||
linksTab: 'Links',
|
||||
profileTitle: 'Profile',
|
||||
removeAvatar: 'Remove avatar',
|
||||
namePlaceholder: 'Name',
|
||||
notSpecified: 'Not specified',
|
||||
onVortexSince: 'On Vortex since',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
save: 'Save',
|
||||
sharedPhotos: 'Shared photos and videos will appear here',
|
||||
sharedFiles: 'Shared files will appear here',
|
||||
sharedLinks: 'Shared links will appear here',
|
||||
profileNotFound: 'Profile not found',
|
||||
typingText: 'typing',
|
||||
emojiFrequent: 'Frequent',
|
||||
emojiGestures: 'Gestures',
|
||||
emojiEmotions: 'Emotions',
|
||||
emojiObjects: 'Objects',
|
||||
emojiFood: 'Food',
|
||||
emojiNature: 'Nature',
|
||||
login: 'Login',
|
||||
register: 'Register',
|
||||
latinOnly: '(latin only, cannot be changed)',
|
||||
displayNameLabel: 'Display name',
|
||||
displayNamePlaceholder: 'Your name (any language)',
|
||||
password: 'Password',
|
||||
passwordPlaceholder: 'Enter password',
|
||||
bioPlaceholder: 'Tell about yourself (optional)',
|
||||
loginBtn: 'Login',
|
||||
createAccount: 'Create account',
|
||||
testAccounts: 'Test accounts: evgeniy, anastasia, artem, polina',
|
||||
passwordForAll: 'Password for all:',
|
||||
fileTooLarge: 'File too large (max 50MB)',
|
||||
dropFileHere: 'Drop file here',
|
||||
uploading: 'Uploading...',
|
||||
changePhoto: 'Change photo',
|
||||
remove: 'Remove',
|
||||
formatBold: 'Bold',
|
||||
formatBoldHint: '**text**',
|
||||
formatItalic: 'Italic',
|
||||
formatItalicHint: '_text_',
|
||||
formatStrike: 'Strikethrough',
|
||||
formatStrikeHint: '~text~',
|
||||
formatMono: 'Monospace',
|
||||
formatMonoHint: '`text`',
|
||||
draft: 'Draft:',
|
||||
datePlaceholder: 'dd.mm.yyyy',
|
||||
tenorKeyRequired: 'Tenor API key required for GIFs',
|
||||
openConsoleRun: 'Open console and run:',
|
||||
searchGifs: 'Search GIFs...',
|
||||
trending: 'Trending',
|
||||
error: 'Error',
|
||||
friends: 'Friends',
|
||||
friendRequests: 'Friend requests',
|
||||
friendsList: 'Friends list',
|
||||
noFriends: 'No friends yet',
|
||||
addFriend: 'Add friend',
|
||||
removeFriend: 'Remove friend',
|
||||
requestSent: 'Request sent',
|
||||
searchFriends: 'Search by @username (min. 3 chars)',
|
||||
noSearchResults: 'No users found',
|
||||
minCharsHint: 'Enter at least 3 characters after @',
|
||||
storyViewers: 'Who viewed',
|
||||
noViewers: 'No one viewed yet',
|
||||
favorites: 'Favorites',
|
||||
favoritesDescription: 'Save important messages here',
|
||||
privacy: 'Privacy',
|
||||
hideStoryViews: 'Hide story views',
|
||||
hideStoryViewsDesc: 'Others won\'t see that you viewed their story',
|
||||
scheduledFor: 'Scheduled for',
|
||||
messageScheduled: 'Message scheduled',
|
||||
scheduledDelivered: 'Message sent to',
|
||||
scheduledDeliveredAt: 'at',
|
||||
scheduleIn1h: 'In 1 hour',
|
||||
scheduleIn3h: 'In 3 hours',
|
||||
scheduleTomorrow: 'Tomorrow at 9:00',
|
||||
scheduleCustom: 'Choose date & time',
|
||||
scheduleTime: 'Time',
|
||||
scheduleDate: 'Date',
|
||||
lastSeenAt: 'was',
|
||||
justNow: 'just now',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type TranslationKey = keyof typeof translations.ru;
|
||||
type TranslationValue<K extends TranslationKey> = (typeof translations.ru)[K];
|
||||
|
||||
interface LangState {
|
||||
lang: Lang;
|
||||
setLang: (lang: Lang) => void;
|
||||
t: <K extends TranslationKey>(key: K) => TranslationValue<K>;
|
||||
}
|
||||
|
||||
export const useLang = create<LangState>((set, get) => ({
|
||||
lang: (localStorage.getItem(LANG_KEY) as Lang) || 'ru',
|
||||
setLang: (lang) => {
|
||||
localStorage.setItem(LANG_KEY, lang);
|
||||
set({ lang });
|
||||
},
|
||||
t: (key) => {
|
||||
const { lang } = get();
|
||||
return (translations[lang][key] ?? translations.ru[key] ?? key) as TranslationValue<typeof key>;
|
||||
},
|
||||
}));
|
||||
42
apps/web/src/lib/socket.ts
Normal file
42
apps/web/src/lib/socket.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
let socket: Socket | null = null;
|
||||
|
||||
export function connectSocket(token: string): Socket {
|
||||
if (socket?.connected) {
|
||||
return socket;
|
||||
}
|
||||
|
||||
// Clean up old socket instance if it exists but is disconnected
|
||||
if (socket) {
|
||||
socket.removeAllListeners();
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
|
||||
socket = io(window.location.origin, {
|
||||
auth: { token },
|
||||
transports: ['websocket', 'polling'],
|
||||
});
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('Socket подключён');
|
||||
});
|
||||
|
||||
socket.on('connect_error', (err) => {
|
||||
console.error('Ошибка подключения Socket:', err.message);
|
||||
});
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function getSocket(): Socket | null {
|
||||
return socket;
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
}
|
||||
120
apps/web/src/lib/sounds.ts
Normal file
120
apps/web/src/lib/sounds.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// Notification sound using Web Audio API — generates a pleasant chime
|
||||
let audioContext: AudioContext | null = null;
|
||||
|
||||
function getAudioContext(): AudioContext {
|
||||
if (!audioContext) {
|
||||
audioContext = new AudioContext();
|
||||
}
|
||||
return audioContext;
|
||||
}
|
||||
|
||||
export function playNotificationSound() {
|
||||
try {
|
||||
const ctx = getAudioContext();
|
||||
if (ctx.state === 'suspended') {
|
||||
ctx.resume();
|
||||
}
|
||||
|
||||
const now = ctx.currentTime;
|
||||
|
||||
// Soft, warm notification — lower frequencies, triangle waves, gentle volume
|
||||
// First note — warm mellow tone
|
||||
const osc1 = ctx.createOscillator();
|
||||
const gain1 = ctx.createGain();
|
||||
osc1.type = 'triangle';
|
||||
osc1.frequency.setValueAtTime(523.25, now); // C5
|
||||
gain1.gain.setValueAtTime(0.08, now);
|
||||
gain1.gain.exponentialRampToValueAtTime(0.001, now + 0.3);
|
||||
osc1.connect(gain1);
|
||||
gain1.connect(ctx.destination);
|
||||
osc1.start(now);
|
||||
osc1.stop(now + 0.3);
|
||||
|
||||
// Second note — gentle higher tone
|
||||
const osc2 = ctx.createOscillator();
|
||||
const gain2 = ctx.createGain();
|
||||
osc2.type = 'triangle';
|
||||
osc2.frequency.setValueAtTime(659.25, now + 0.08); // E5
|
||||
gain2.gain.setValueAtTime(0, now);
|
||||
gain2.gain.setValueAtTime(0.06, now + 0.08);
|
||||
gain2.gain.exponentialRampToValueAtTime(0.001, now + 0.35);
|
||||
osc2.connect(gain2);
|
||||
gain2.connect(ctx.destination);
|
||||
osc2.start(now + 0.08);
|
||||
osc2.stop(now + 0.35);
|
||||
} catch (e) {
|
||||
// Audio context not supported — silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// Muted chats stored in localStorage
|
||||
const MUTED_KEY = 'vortex_muted_chats';
|
||||
|
||||
export function getMutedChats(): Set<string> {
|
||||
try {
|
||||
const stored = localStorage.getItem(MUTED_KEY);
|
||||
return stored ? new Set(JSON.parse(stored)) : new Set();
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function toggleMuteChat(chatId: string): boolean {
|
||||
const muted = getMutedChats();
|
||||
if (muted.has(chatId)) {
|
||||
muted.delete(chatId);
|
||||
} else {
|
||||
muted.add(chatId);
|
||||
}
|
||||
localStorage.setItem(MUTED_KEY, JSON.stringify([...muted]));
|
||||
return muted.has(chatId);
|
||||
}
|
||||
|
||||
export function isChatMuted(chatId: string): boolean {
|
||||
return getMutedChats().has(chatId);
|
||||
}
|
||||
|
||||
// Call ringtone
|
||||
let callAudio: HTMLAudioElement | null = null;
|
||||
|
||||
export function playCallRingtone() {
|
||||
try {
|
||||
if (callAudio) {
|
||||
callAudio.pause();
|
||||
callAudio.currentTime = 0;
|
||||
}
|
||||
callAudio = new Audio('/sounds/call_sound.mp3');
|
||||
callAudio.loop = true;
|
||||
callAudio.volume = 0.5;
|
||||
callAudio.play().catch(() => {});
|
||||
} catch (e) {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
export function stopCallRingtone() {
|
||||
try {
|
||||
if (callAudio) {
|
||||
callAudio.pause();
|
||||
callAudio.currentTime = 0;
|
||||
callAudio = null;
|
||||
}
|
||||
} catch (e) {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
// "Абонент недоступен" sound
|
||||
export function playUnavailableSound(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
try {
|
||||
const audio = new Audio('/sounds/abonent_nedostupen.mp3');
|
||||
audio.volume = 0.7;
|
||||
audio.onended = () => resolve();
|
||||
audio.onerror = () => resolve();
|
||||
audio.play().catch(() => resolve());
|
||||
} catch (e) {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
175
apps/web/src/lib/types.ts
Normal file
175
apps/web/src/lib/types.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
// ─── User types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface UserBasic {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar: string | null;
|
||||
}
|
||||
|
||||
export interface UserPresence extends UserBasic {
|
||||
isOnline: boolean;
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface User extends UserPresence {
|
||||
bio: string | null;
|
||||
birthday: string | null;
|
||||
createdAt: string;
|
||||
hideStoryViews?: boolean;
|
||||
}
|
||||
|
||||
// ─── Chat types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChatMember {
|
||||
id: string;
|
||||
userId: string;
|
||||
role: string;
|
||||
isPinned?: boolean;
|
||||
isMuted?: boolean;
|
||||
isArchived?: boolean;
|
||||
clearedAt?: string | null;
|
||||
user: UserPresence;
|
||||
}
|
||||
|
||||
export interface MediaItem {
|
||||
id: string;
|
||||
type: string;
|
||||
url: string;
|
||||
filename: string | null;
|
||||
thumbnail: string | null;
|
||||
size: number | null;
|
||||
duration: number | null;
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
}
|
||||
|
||||
export interface Reaction {
|
||||
id: string;
|
||||
emoji: string;
|
||||
userId: string;
|
||||
user: { id: string; username: string; displayName: string };
|
||||
}
|
||||
|
||||
export interface MessageSender {
|
||||
id: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar?: string | null;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
chatId: string;
|
||||
senderId: string;
|
||||
content: string | null;
|
||||
type: string;
|
||||
replyToId: string | null;
|
||||
quote?: string | null;
|
||||
forwardedFromId?: string | null;
|
||||
isEdited: boolean;
|
||||
isDeleted: boolean;
|
||||
scheduledAt?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt?: string;
|
||||
sender: MessageSender;
|
||||
replyTo?: {
|
||||
id: string;
|
||||
content: string | null;
|
||||
quote?: string | null;
|
||||
sender: { id: string; username: string; displayName: string };
|
||||
} | null;
|
||||
forwardedFrom?: UserBasic | null;
|
||||
media: MediaItem[];
|
||||
reactions: Reaction[];
|
||||
readBy: Array<{ userId: string }>;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string | null;
|
||||
avatar: string | null;
|
||||
createdAt: string;
|
||||
members: ChatMember[];
|
||||
messages: Message[];
|
||||
unreadCount: number;
|
||||
pinnedMessages?: Array<{
|
||||
id: string;
|
||||
message: Message;
|
||||
}>;
|
||||
}
|
||||
|
||||
// ─── Socket event types ────────────────────────────────────────────────
|
||||
|
||||
export interface TypingUser {
|
||||
chatId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface CallInfo {
|
||||
from: string;
|
||||
offer: RTCSessionDescriptionInit;
|
||||
callType: 'voice' | 'video';
|
||||
chatId: string;
|
||||
callerInfo?: UserBasic | null;
|
||||
}
|
||||
|
||||
// ─── Story types ───────────────────────────────────────────────────────
|
||||
|
||||
export interface Story {
|
||||
id: string;
|
||||
type: string;
|
||||
mediaUrl: string | null;
|
||||
content: string | null;
|
||||
bgColor: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
viewCount: number;
|
||||
viewed: boolean;
|
||||
}
|
||||
|
||||
export interface StoryViewer {
|
||||
userId: string;
|
||||
username: string;
|
||||
displayName: string;
|
||||
avatar: string | null;
|
||||
viewedAt: string;
|
||||
}
|
||||
|
||||
export interface StoryGroup {
|
||||
user: UserBasic;
|
||||
stories: Story[];
|
||||
hasUnviewed: boolean;
|
||||
}
|
||||
|
||||
// ─── Utility types ─────────────────────────────────────────────────
|
||||
|
||||
// ─── Friend types ──────────────────────────────────────────────────
|
||||
|
||||
export interface FriendRequest {
|
||||
id: string;
|
||||
user: User;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface FriendWithId extends UserPresence {
|
||||
friendshipId: string;
|
||||
}
|
||||
|
||||
export interface FriendshipStatus {
|
||||
status: 'none' | 'pending' | 'accepted' | 'declined' | 'self';
|
||||
friendshipId?: string | null;
|
||||
direction?: 'incoming' | 'outgoing';
|
||||
}
|
||||
|
||||
// ─── Utility types ─────────────────────────────────────────────────────
|
||||
|
||||
/** Audio file extensions recognized by the app. */
|
||||
export const AUDIO_EXTENSIONS = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma'] as const;
|
||||
|
||||
/** Max file size for uploads (50MB). */
|
||||
export const MAX_FILE_SIZE = 50 * 1024 * 1024;
|
||||
|
||||
/** Max avatar size (5MB). */
|
||||
export const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
|
||||
139
apps/web/src/lib/utils.ts
Normal file
139
apps/web/src/lib/utils.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { clsx, type ClassValue } from 'clsx';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return clsx(inputs);
|
||||
}
|
||||
|
||||
export function formatTime(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
return d.toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (days === 0) return lang === 'ru' ? 'Сегодня' : 'Today';
|
||||
if (days === 1) return lang === 'ru' ? 'Вчера' : 'Yesterday';
|
||||
if (days < 7) {
|
||||
const weekDaysRu = ['Воскресенье', 'Понедельник', 'Вторник', 'Среда', 'Четверг', 'Пятница', 'Суббота'];
|
||||
const weekDaysEn = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
||||
return (lang === 'ru' ? weekDaysRu : weekDaysEn)[d.getDay()];
|
||||
}
|
||||
|
||||
return d.toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: days > 365 ? 'numeric' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export function formatLastSeen(date: string | Date, lang: string = 'ru'): string {
|
||||
const d = new Date(date);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - d.getTime();
|
||||
const minutes = Math.floor(diff / (1000 * 60));
|
||||
|
||||
if (minutes < 1) return lang === 'ru' ? 'только что' : 'just now';
|
||||
if (minutes < 60) return lang === 'ru' ? `${minutes} мин. назад` : `${minutes}m ago`;
|
||||
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return lang === 'ru' ? `${hours} ч. назад` : `${hours}h ago`;
|
||||
|
||||
const at = lang === 'ru' ? ' в ' : ' at ';
|
||||
return formatDate(date, lang) + at + formatTime(date, lang);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips markdown syntax (**bold**, *italic*, _italic_, ~strikethrough~, `code`)
|
||||
* and returns plain text for use in previews.
|
||||
*/
|
||||
export function stripMarkdown(text: string): string {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/\*\*([\s\S]*?)\*\*/g, '$1')
|
||||
.replace(/\*([\s\S]*?)\*/g, '$1')
|
||||
.replace(/_([\s\S]*?)_/g, '$1')
|
||||
.replace(/~([\s\S]*?)~/g, '$1')
|
||||
.replace(/`([\s\S]*?)`/g, '$1');
|
||||
}
|
||||
|
||||
export function getInitials(name: string): string {
|
||||
return name
|
||||
.split(' ')
|
||||
.map((part) => part[0])
|
||||
.join('')
|
||||
.toUpperCase()
|
||||
.slice(0, 2);
|
||||
}
|
||||
|
||||
export function generateAvatarColor(name: string): string {
|
||||
const colors = [
|
||||
'from-violet-500 to-purple-600',
|
||||
'from-blue-500 to-indigo-600',
|
||||
'from-emerald-500 to-teal-600',
|
||||
'from-rose-500 to-pink-600',
|
||||
'from-amber-500 to-orange-600',
|
||||
'from-cyan-500 to-blue-600',
|
||||
'from-fuchsia-500 to-purple-600',
|
||||
'from-lime-500 to-green-600',
|
||||
];
|
||||
|
||||
let hash = 0;
|
||||
for (let i = 0; i < name.length; i++) {
|
||||
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
|
||||
return colors[Math.abs(hash) % colors.length];
|
||||
}
|
||||
|
||||
// Waveform cache so we don't decode the same audio twice
|
||||
const waveformCache = new Map<string, number[]>();
|
||||
|
||||
/**
|
||||
* Decodes an audio file from a URL and extracts normalized waveform peak values.
|
||||
* Returns an array of `bars` values in [0, 1].
|
||||
*/
|
||||
export async function extractWaveform(url: string, bars: number = 28): Promise<number[]> {
|
||||
const cached = waveformCache.get(url);
|
||||
if (cached) return cached;
|
||||
|
||||
let audioCtx: AudioContext | undefined;
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
audioCtx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)();
|
||||
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
|
||||
await audioCtx.close();
|
||||
audioCtx = undefined;
|
||||
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
const samplesPerBar = Math.floor(channelData.length / bars);
|
||||
const peaks: number[] = [];
|
||||
|
||||
for (let i = 0; i < bars; i++) {
|
||||
let peak = 0;
|
||||
const start = i * samplesPerBar;
|
||||
// Sample a subset for performance
|
||||
const step = Math.max(1, Math.floor(samplesPerBar / 200));
|
||||
for (let j = 0; j < samplesPerBar; j += step) {
|
||||
const abs = Math.abs(channelData[start + j] || 0);
|
||||
if (abs > peak) peak = abs;
|
||||
}
|
||||
peaks.push(peak);
|
||||
}
|
||||
|
||||
// Normalize to [0, 1]
|
||||
const max = Math.max(...peaks, 0.01);
|
||||
const normalized = peaks.map(p => p / max);
|
||||
waveformCache.set(url, normalized);
|
||||
return normalized;
|
||||
} catch {
|
||||
// Close leaked AudioContext if any
|
||||
if (audioCtx) audioCtx.close().catch(() => {});
|
||||
// On error, return uniform bars
|
||||
return Array(bars).fill(0.5);
|
||||
}
|
||||
}
|
||||
20
apps/web/src/main.tsx
Normal file
20
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
);
|
||||
226
apps/web/src/pages/AuthPage.tsx
Normal file
226
apps/web/src/pages/AuthPage.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import { useState, FormEvent } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { Eye, EyeOff, ArrowRight, UserPlus, LogIn } from 'lucide-react';
|
||||
|
||||
export default function AuthPage() {
|
||||
const [isLogin, setIsLogin] = useState(true);
|
||||
const [username, setUsername] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [bio, setBio] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const { login, register } = useAuthStore();
|
||||
const { t } = useLang();
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
if (isLogin) {
|
||||
await login(username, password);
|
||||
} else {
|
||||
await register(username, displayName || username, password, bio);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : 'Ошибка');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-full flex items-center justify-center relative overflow-hidden bg-surface"
|
||||
>
|
||||
{/* Анимированный фон */}
|
||||
<div className="absolute inset-0 pointer-events-none">
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] opacity-20">
|
||||
<div className="absolute inset-0 rounded-full bg-gradient-to-r from-vortex-600/30 to-purple-600/30 blur-[120px] animate-pulse" />
|
||||
</div>
|
||||
<div className="absolute top-20 left-20 w-72 h-72 bg-vortex-500/10 rounded-full blur-[100px]" />
|
||||
<div className="absolute bottom-20 right-20 w-96 h-96 bg-purple-500/10 rounded-full blur-[100px]" />
|
||||
</div>
|
||||
|
||||
{/* Карточка авторизации */}
|
||||
<motion.div
|
||||
initial={{ scale: 0.95, y: 20 }}
|
||||
animate={{ scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.4, ease: 'easeOut' }}
|
||||
className="relative z-10 w-full max-w-md mx-4"
|
||||
>
|
||||
<div className="glass-strong rounded-3xl p-8 shadow-2xl shadow-vortex-500/5">
|
||||
{/* Логотип */}
|
||||
<div className="flex flex-col items-center mb-8">
|
||||
<motion.div
|
||||
initial={{ rotate: -180, scale: 0 }}
|
||||
animate={{ rotate: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
|
||||
>
|
||||
<img
|
||||
src="/logo.png"
|
||||
alt="Vortex"
|
||||
className="w-20 h-20 rounded-2xl shadow-lg shadow-vortex-500/30 object-cover"
|
||||
/>
|
||||
</motion.div>
|
||||
<h1 className="text-2xl font-bold gradient-text mt-4">Vortex</h1>
|
||||
<p className="text-zinc-500 text-sm mt-1">{t('modernMessengerShort')}</p>
|
||||
</div>
|
||||
|
||||
{/* Переключатель Вход/Регистрация */}
|
||||
<div className="flex rounded-xl bg-white/5 p-1 mb-6">
|
||||
<button
|
||||
onClick={() => { setIsLogin(true); setError(''); setPassword(''); }}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center gap-2 ${
|
||||
isLogin
|
||||
? 'bg-gradient-to-r from-vortex-500 to-purple-600 text-white shadow-lg shadow-vortex-500/25'
|
||||
: 'text-zinc-400 hover:text-zinc-200'
|
||||
}`}
|
||||
aria-pressed={isLogin}
|
||||
>
|
||||
<LogIn size={16} />
|
||||
{t('login')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setIsLogin(false); setError(''); setPassword(''); }}
|
||||
className={`flex-1 py-2.5 px-4 rounded-lg text-sm font-medium transition-all duration-200 flex items-center justify-center gap-2 ${
|
||||
!isLogin
|
||||
? 'bg-gradient-to-r from-vortex-500 to-purple-600 text-white shadow-lg shadow-vortex-500/25'
|
||||
: 'text-zinc-400 hover:text-zinc-200'
|
||||
}`}
|
||||
aria-pressed={!isLogin}
|
||||
>
|
||||
<UserPlus size={16} />
|
||||
{t('register')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Ошибка */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="mb-4 p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Форма */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
|
||||
Username {!isLogin && <span className="text-zinc-600">{t('latinOnly')}</span>}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value.replace(/[^a-zA-Z0-9_]/g, ''))}
|
||||
placeholder="username"
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all"
|
||||
required
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isLogin && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">
|
||||
{t('displayNameLabel')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder={t('displayNamePlaceholder')}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">{t('password')}</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all pr-12"
|
||||
required
|
||||
autoComplete={isLogin ? 'current-password' : 'new-password'}
|
||||
minLength={6}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AnimatePresence>
|
||||
{!isLogin && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: 'auto' }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<label className="block text-sm font-medium text-zinc-400 mb-1.5">{t('aboutMe')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={bio}
|
||||
onChange={(e) => setBio(e.target.value)}
|
||||
placeholder={t('bioPlaceholder')}
|
||||
className="w-full px-4 py-3 rounded-xl bg-white/5 border border-white/10 text-white placeholder-zinc-600 focus:border-vortex-500/50 focus:ring-1 focus:ring-vortex-500/25 transition-all"
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.button
|
||||
whileHover={{ scale: 1.01 }}
|
||||
whileTap={{ scale: 0.99 }}
|
||||
disabled={isSubmitting}
|
||||
type="submit"
|
||||
className="w-full py-3 px-4 rounded-xl bg-gradient-to-r from-vortex-500 to-purple-600 text-white font-medium shadow-lg shadow-vortex-500/25 hover:shadow-vortex-500/40 transition-all duration-200 flex items-center justify-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<div className="w-5 h-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
{isLogin ? t('loginBtn') : t('createAccount')}
|
||||
<ArrowRight size={18} />
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
304
apps/web/src/pages/ChatPage.tsx
Normal file
304
apps/web/src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { useChatStore } from '../stores/chatStore';
|
||||
import { useAuthStore } from '../stores/authStore';
|
||||
import { getSocket, disconnectSocket } from '../lib/socket';
|
||||
import { api } from '../lib/api';
|
||||
import { playNotificationSound, isChatMuted } from '../lib/sounds';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import type { Message, UserBasic, CallInfo } from '../lib/types';
|
||||
import { Send, Check } from 'lucide-react';
|
||||
import Sidebar from '../components/Sidebar';
|
||||
import ChatView from '../components/ChatView';
|
||||
import CallModal from '../components/CallModal';
|
||||
import GroupCallModal from '../components/GroupCallModal';
|
||||
|
||||
export default function ChatPage() {
|
||||
const {
|
||||
loadChats,
|
||||
addMessage,
|
||||
updateMessage,
|
||||
removeMessage,
|
||||
removeMessages,
|
||||
hideMessages,
|
||||
addReaction,
|
||||
removeReaction,
|
||||
markRead,
|
||||
addTypingUser,
|
||||
removeTypingUser,
|
||||
updateUserOnlineStatus,
|
||||
setPinnedMessage,
|
||||
removePinnedMessage,
|
||||
clearStore,
|
||||
} = useChatStore();
|
||||
const { user } = useAuthStore();
|
||||
const initialized = useRef(false);
|
||||
|
||||
// Call state
|
||||
const [callOpen, setCallOpen] = useState(false);
|
||||
const [callTarget, setCallTarget] = useState<UserBasic | null>(null);
|
||||
const [callType, setCallType] = useState<'voice' | 'video'>('voice');
|
||||
const [incomingCall, setIncomingCall] = useState<CallInfo | null>(null);
|
||||
const [callSessionId, setCallSessionId] = useState(0);
|
||||
const [deliveryNotification, setDeliveryNotification] = useState<string | null>(null);
|
||||
const deliveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Group call state
|
||||
const [groupCallOpen, setGroupCallOpen] = useState(false);
|
||||
const [groupCallChatId, setGroupCallChatId] = useState('');
|
||||
const [groupCallChatName, setGroupCallChatName] = useState('');
|
||||
const [groupCallType, setGroupCallType] = useState<'voice' | 'video'>('voice');
|
||||
const [groupCallSessionId, setGroupCallSessionId] = useState(0);
|
||||
|
||||
const { t } = useLang();
|
||||
|
||||
useEffect(() => {
|
||||
if (initialized.current) return;
|
||||
initialized.current = true;
|
||||
loadChats();
|
||||
}, [loadChats]);
|
||||
|
||||
// Обработка закрытия вкладки — отправить disconnect
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const socket = getSocket();
|
||||
if (!socket) return;
|
||||
|
||||
socket.on('new_message', async (message: Message) => {
|
||||
// If this chat isn't in our store yet (e.g. someone just created it and sent a message),
|
||||
// fetch chats so the new chat appears in the sidebar immediately
|
||||
const { chats } = useChatStore.getState();
|
||||
if (!chats.some(c => c.id === message.chatId)) {
|
||||
try {
|
||||
const allChats = await api.getChats();
|
||||
const newChat = allChats.find(c => c.id === message.chatId);
|
||||
if (newChat) {
|
||||
// Reset unreadCount to 0 because addMessage below will increment it by 1
|
||||
useChatStore.getState().addChat({ ...newChat, unreadCount: 0 });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch new chat:', e);
|
||||
}
|
||||
}
|
||||
addMessage(message);
|
||||
// Play notification sound for messages from others
|
||||
if (message.senderId !== user?.id && !isChatMuted(message.chatId)) {
|
||||
playNotificationSound();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('scheduled_delivered', async (message: Message & { _recipientName?: string; _deliveredAt?: string }) => {
|
||||
// If chat unknown, fetch it first
|
||||
const { chats } = useChatStore.getState();
|
||||
if (!chats.some(c => c.id === message.chatId)) {
|
||||
try {
|
||||
const allChats = await api.getChats();
|
||||
const newChat = allChats.find(c => c.id === message.chatId);
|
||||
if (newChat) useChatStore.getState().addChat(newChat);
|
||||
} catch (_) { /* ignore */ }
|
||||
}
|
||||
// A scheduled message was delivered: update it in store (remove scheduledAt)
|
||||
updateMessage({ ...message, scheduledAt: null });
|
||||
|
||||
// Show delivery notification to the sender
|
||||
if (message.senderId === user?.id && message._recipientName) {
|
||||
const time = message._deliveredAt
|
||||
? new Date(message._deliveredAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
const notifText = `${useLang.getState().t('scheduledDelivered')} ${message._recipientName} ${useLang.getState().t('scheduledDeliveredAt')} ${time}`;
|
||||
setDeliveryNotification(notifText);
|
||||
if (deliveryTimerRef.current) clearTimeout(deliveryTimerRef.current);
|
||||
deliveryTimerRef.current = setTimeout(() => setDeliveryNotification(null), 5000);
|
||||
}
|
||||
|
||||
// Notify others with sound
|
||||
if (message.senderId !== user?.id && !isChatMuted(message.chatId)) {
|
||||
playNotificationSound();
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('message_edited', (message: Message) => {
|
||||
updateMessage(message);
|
||||
});
|
||||
|
||||
socket.on('message_deleted', (data: { messageId: string; chatId: string }) => {
|
||||
removeMessage(data.messageId, data.chatId);
|
||||
});
|
||||
|
||||
socket.on('messages_deleted', (data: { messageIds: string[]; chatId: string }) => {
|
||||
removeMessages(data.messageIds, data.chatId);
|
||||
});
|
||||
|
||||
socket.on('messages_hidden', (data: { messageIds: string[]; chatId: string }) => {
|
||||
hideMessages(data.messageIds, data.chatId);
|
||||
});
|
||||
|
||||
socket.on('reaction_added', (data: { messageId: string; chatId: string; userId: string; username: string; emoji: string }) => {
|
||||
addReaction(data.messageId, data.chatId, data.userId, data.username, data.emoji);
|
||||
});
|
||||
|
||||
socket.on('reaction_removed', (data: { messageId: string; chatId: string; userId: string; emoji: string }) => {
|
||||
removeReaction(data.messageId, data.chatId, data.userId, data.emoji);
|
||||
});
|
||||
|
||||
socket.on('messages_read', (data: { chatId: string; userId: string; messageIds: string[] }) => {
|
||||
markRead(data.chatId, data.userId, data.messageIds);
|
||||
});
|
||||
|
||||
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
|
||||
if (data.userId !== user?.id) {
|
||||
addTypingUser(data.chatId, data.userId);
|
||||
setTimeout(() => removeTypingUser(data.chatId, data.userId), 3000);
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('user_stopped_typing', (data: { chatId: string; userId: string }) => {
|
||||
removeTypingUser(data.chatId, data.userId);
|
||||
});
|
||||
|
||||
socket.on('user_online', (data: { userId: string }) => {
|
||||
updateUserOnlineStatus(data.userId, true);
|
||||
});
|
||||
|
||||
socket.on('user_offline', (data: { userId: string; lastSeen?: string }) => {
|
||||
updateUserOnlineStatus(data.userId, false, data.lastSeen);
|
||||
});
|
||||
|
||||
socket.on('message_pinned', (data: { chatId: string; message: Message }) => {
|
||||
setPinnedMessage(data.chatId, data.message);
|
||||
});
|
||||
|
||||
socket.on('message_unpinned', (data: { chatId: string; messageId: string; newPinnedMessage: Message | null }) => {
|
||||
removePinnedMessage(data.chatId, data.messageId, data.newPinnedMessage);
|
||||
});
|
||||
|
||||
socket.on('call_incoming', async (data: CallInfo) => {
|
||||
// Use callerInfo from server if available, otherwise look up from chats
|
||||
let callerInfo: UserBasic | null = data.callerInfo || null;
|
||||
if (!callerInfo) {
|
||||
const { chats } = useChatStore.getState();
|
||||
for (const chat of chats) {
|
||||
const member = chat.members.find((m) => m.user.id === data.from);
|
||||
if (member) {
|
||||
callerInfo = member.user;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
setCallTarget(null); // Clear any previous outgoing target
|
||||
setIncomingCall({
|
||||
from: data.from,
|
||||
offer: data.offer,
|
||||
callType: data.callType,
|
||||
chatId: data.chatId,
|
||||
callerInfo,
|
||||
});
|
||||
setCallType(data.callType);
|
||||
setCallSessionId(id => id + 1);
|
||||
setCallOpen(true);
|
||||
});
|
||||
|
||||
return () => {
|
||||
socket.off('new_message');
|
||||
socket.off('scheduled_delivered');
|
||||
socket.off('message_edited');
|
||||
socket.off('message_deleted');
|
||||
socket.off('messages_deleted');
|
||||
socket.off('messages_hidden');
|
||||
socket.off('reaction_added');
|
||||
socket.off('reaction_removed');
|
||||
socket.off('messages_read');
|
||||
socket.off('user_typing');
|
||||
socket.off('user_stopped_typing');
|
||||
socket.off('user_online');
|
||||
socket.off('user_offline');
|
||||
socket.off('message_pinned');
|
||||
socket.off('message_unpinned');
|
||||
socket.off('call_incoming');
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
|
||||
setCallTarget(targetUser);
|
||||
setCallType(type);
|
||||
setIncomingCall(null);
|
||||
setCallSessionId(id => id + 1);
|
||||
setCallOpen(true);
|
||||
};
|
||||
|
||||
const handleStartGroupCall = (chatId: string, chatName: string, type: 'voice' | 'video') => {
|
||||
setGroupCallChatId(chatId);
|
||||
setGroupCallChatName(chatName);
|
||||
setGroupCallType(type);
|
||||
setGroupCallSessionId(id => id + 1);
|
||||
setGroupCallOpen(true);
|
||||
};
|
||||
|
||||
const handleCloseCall = () => {
|
||||
setCallOpen(false);
|
||||
setCallTarget(null);
|
||||
setIncomingCall(null);
|
||||
};
|
||||
|
||||
const handleCloseGroupCall = () => {
|
||||
setGroupCallOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="h-full flex bg-surface p-3 gap-3 overflow-hidden"
|
||||
>
|
||||
<Sidebar />
|
||||
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
||||
<CallModal
|
||||
key={callSessionId}
|
||||
isOpen={callOpen}
|
||||
onClose={handleCloseCall}
|
||||
targetUser={callTarget}
|
||||
callType={callType}
|
||||
incoming={incomingCall}
|
||||
/>
|
||||
<GroupCallModal
|
||||
key={`gc-${groupCallSessionId}`}
|
||||
isOpen={groupCallOpen}
|
||||
onClose={handleCloseGroupCall}
|
||||
chatId={groupCallChatId}
|
||||
chatName={groupCallChatName}
|
||||
callType={groupCallType}
|
||||
/>
|
||||
|
||||
{/* Scheduled message delivery notification */}
|
||||
<AnimatePresence>
|
||||
{deliveryNotification && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: -20, scale: 0.95 }}
|
||||
className="fixed top-6 left-1/2 -translate-x-1/2 z-[9999] px-5 py-3 rounded-2xl bg-surface-secondary shadow-2xl border border-border flex items-center gap-3"
|
||||
>
|
||||
<div className="w-8 h-8 rounded-full bg-emerald-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<Send size={14} className="text-emerald-400" />
|
||||
</div>
|
||||
<span className="text-sm text-zinc-200">{deliveryNotification}</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
100
apps/web/src/stores/authStore.ts
Normal file
100
apps/web/src/stores/authStore.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../lib/api';
|
||||
import { connectSocket, disconnectSocket } from '../lib/socket';
|
||||
import type { User } from '../lib/types';
|
||||
|
||||
interface AuthState {
|
||||
token: string | null;
|
||||
user: User | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
login: (username: string, password: string) => Promise<void>;
|
||||
register: (username: string, displayName: string, password: string, bio?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
checkAuth: () => Promise<void>;
|
||||
updateUser: (data: Partial<User>) => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
token: localStorage.getItem('vortex_token'),
|
||||
user: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
|
||||
login: async (username, password) => {
|
||||
try {
|
||||
set({ error: null, isLoading: true });
|
||||
const { token, user } = await api.login(username, password);
|
||||
localStorage.setItem('vortex_token', token);
|
||||
api.setToken(token);
|
||||
connectSocket(token);
|
||||
set({ token, user, isLoading: false });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
set({ error: msg, isLoading: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (username, displayName, password, bio) => {
|
||||
try {
|
||||
set({ error: null, isLoading: true });
|
||||
const { token, user } = await api.register(username, displayName, password, bio);
|
||||
localStorage.setItem('vortex_token', token);
|
||||
api.setToken(token);
|
||||
connectSocket(token);
|
||||
set({ token, user, isLoading: false });
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
set({ error: msg, isLoading: false });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem('vortex_token');
|
||||
api.setToken(null);
|
||||
disconnectSocket();
|
||||
set({ token: null, user: null });
|
||||
},
|
||||
|
||||
checkAuth: async () => {
|
||||
const token = get().token;
|
||||
if (!token) {
|
||||
set({ isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
// Retry up to 3 times in case server is still starting
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
api.setToken(token);
|
||||
const { user } = await api.getMe();
|
||||
connectSocket(token);
|
||||
set({ user, isLoading: false });
|
||||
return;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
// Only retry on network/server errors, not on auth errors (401/403)
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
if (msg.includes('Требуется авторизация') || msg.includes('Недействительный токен')) {
|
||||
break;
|
||||
}
|
||||
if (attempt < 2) {
|
||||
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)));
|
||||
}
|
||||
}
|
||||
}
|
||||
console.warn('checkAuth failed:', lastError);
|
||||
localStorage.removeItem('vortex_token');
|
||||
set({ token: null, user: null, isLoading: false });
|
||||
},
|
||||
|
||||
updateUser: (data) => {
|
||||
const { user } = get();
|
||||
if (user) {
|
||||
set({ user: { ...user, ...data } });
|
||||
}
|
||||
},
|
||||
}));
|
||||
440
apps/web/src/stores/chatStore.ts
Normal file
440
apps/web/src/stores/chatStore.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
import { create } from 'zustand';
|
||||
import { api } from '../lib/api';
|
||||
import { useAuthStore } from './authStore';
|
||||
import type { Chat, ChatMember, Message, TypingUser } from '../lib/types';
|
||||
|
||||
interface ChatState {
|
||||
chats: Chat[];
|
||||
activeChat: string | null;
|
||||
messages: Record<string, Message[]>;
|
||||
pinnedMessages: Record<string, Message>;
|
||||
typingUsers: TypingUser[];
|
||||
replyTo: Message | null;
|
||||
editingMessage: Message | null;
|
||||
isLoadingChats: boolean;
|
||||
isLoadingMessages: boolean;
|
||||
searchQuery: string;
|
||||
drafts: Record<string, string>;
|
||||
|
||||
setActiveChat: (chatId: string | null) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setDraft: (chatId: string, text: string) => void;
|
||||
getDraft: (chatId: string) => string;
|
||||
loadChats: () => Promise<void>;
|
||||
loadMessages: (chatId: string) => Promise<void>;
|
||||
addMessage: (message: Message) => void;
|
||||
updateMessage: (message: Message) => void;
|
||||
removeMessage: (messageId: string, chatId: string) => void;
|
||||
removeMessages: (messageIds: string[], chatId: string) => void;
|
||||
hideMessages: (messageIds: string[], chatId: string) => void;
|
||||
addReaction: (messageId: string, chatId: string, userId: string, username: string, emoji: string) => void;
|
||||
removeReaction: (messageId: string, chatId: string, userId: string, emoji: string) => void;
|
||||
markRead: (chatId: string, userId: string, messageIds: string[]) => void;
|
||||
addTypingUser: (chatId: string, userId: string) => void;
|
||||
removeTypingUser: (chatId: string, userId: string) => void;
|
||||
updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void;
|
||||
setReplyTo: (message: Message | null) => void;
|
||||
setEditingMessage: (message: Message | null) => void;
|
||||
addChat: (chat: Chat) => void;
|
||||
updateChat: (chat: Chat) => void;
|
||||
removeChat: (chatId: string) => void;
|
||||
clearMessages: (chatId: string) => void;
|
||||
setPinnedMessage: (chatId: string, message: Message) => void;
|
||||
removePinnedMessage: (chatId: string, messageId: string, newPinned: Message | null) => void;
|
||||
clearStore: () => void;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set, get) => ({
|
||||
chats: [],
|
||||
activeChat: null,
|
||||
messages: {},
|
||||
pinnedMessages: {},
|
||||
typingUsers: [],
|
||||
replyTo: null,
|
||||
editingMessage: null,
|
||||
isLoadingChats: false,
|
||||
isLoadingMessages: false,
|
||||
searchQuery: '',
|
||||
drafts: JSON.parse(localStorage.getItem('vortex_drafts') || '{}'),
|
||||
|
||||
setActiveChat: (chatId) => set((state) => ({
|
||||
activeChat: chatId,
|
||||
replyTo: null,
|
||||
editingMessage: null,
|
||||
chats: chatId
|
||||
? state.chats.map((c) => c.id === chatId ? { ...c, unreadCount: 0 } : c)
|
||||
: state.chats,
|
||||
})),
|
||||
setSearchQuery: (query) => set({ searchQuery: query }),
|
||||
|
||||
setDraft: (chatId, text) => {
|
||||
set((state) => {
|
||||
const drafts = { ...state.drafts };
|
||||
if (text.trim()) {
|
||||
drafts[chatId] = text;
|
||||
} else {
|
||||
delete drafts[chatId];
|
||||
}
|
||||
localStorage.setItem('vortex_drafts', JSON.stringify(drafts));
|
||||
return { drafts };
|
||||
});
|
||||
},
|
||||
|
||||
getDraft: (chatId) => {
|
||||
return get().drafts[chatId] || '';
|
||||
},
|
||||
|
||||
loadChats: async () => {
|
||||
try {
|
||||
set({ isLoadingChats: true });
|
||||
const chats = await api.getChats();
|
||||
// Auto-create favorites chat if not present
|
||||
if (!chats.some((c: any) => c.type === 'favorites')) {
|
||||
try {
|
||||
const favChat = await api.getOrCreateFavorites();
|
||||
chats.unshift(favChat);
|
||||
} catch {}
|
||||
}
|
||||
// Extract pinned messages from chats
|
||||
const pinnedMessages: Record<string, Message> = {};
|
||||
for (const chat of chats) {
|
||||
if (chat.pinnedMessages && chat.pinnedMessages.length > 0) {
|
||||
pinnedMessages[chat.id] = chat.pinnedMessages[0].message;
|
||||
}
|
||||
}
|
||||
set({ chats, pinnedMessages, isLoadingChats: false });
|
||||
} catch (error) {
|
||||
console.error('Load chats error:', error);
|
||||
set({ isLoadingChats: false });
|
||||
}
|
||||
},
|
||||
|
||||
loadMessages: async (chatId) => {
|
||||
try {
|
||||
set({ isLoadingMessages: true });
|
||||
const fetched = await api.getMessages(chatId);
|
||||
set((state) => {
|
||||
// Merge fetched messages with any that arrived via socket during the fetch
|
||||
const existing = state.messages[chatId] || [];
|
||||
const fetchedIds = new Set(fetched.map(m => m.id));
|
||||
const socketOnly = existing.filter(m => !fetchedIds.has(m.id));
|
||||
const merged = [...fetched, ...socketOnly].sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
return {
|
||||
messages: { ...state.messages, [chatId]: merged },
|
||||
isLoadingMessages: false,
|
||||
};
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Load messages error:', error);
|
||||
set({ isLoadingMessages: false });
|
||||
}
|
||||
},
|
||||
|
||||
addMessage: (message) => {
|
||||
const userId = useAuthStore.getState().user?.id;
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[message.chatId] || [];
|
||||
if (chatMessages.some((m) => m.id === message.id)) return state;
|
||||
|
||||
const updatedMessages = {
|
||||
...state.messages,
|
||||
[message.chatId]: [...chatMessages, message],
|
||||
};
|
||||
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === message.chatId) {
|
||||
return {
|
||||
...chat,
|
||||
messages: [message],
|
||||
unreadCount: chat.id === state.activeChat ? chat.unreadCount : chat.unreadCount + 1,
|
||||
};
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
updatedChats.sort((a, b) => {
|
||||
const aPin = a.members?.find((m) => m.user?.id === userId)?.isPinned ? 1 : 0;
|
||||
const bPin = b.members?.find((m) => m.user?.id === userId)?.isPinned ? 1 : 0;
|
||||
if (aPin !== bPin) return bPin - aPin;
|
||||
const aTime = a.messages[0]?.createdAt || a.createdAt;
|
||||
const bTime = b.messages[0]?.createdAt || b.createdAt;
|
||||
return new Date(bTime).getTime() - new Date(aTime).getTime();
|
||||
});
|
||||
|
||||
return { messages: updatedMessages, chats: updatedChats };
|
||||
});
|
||||
},
|
||||
|
||||
updateMessage: (message) => {
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[message.chatId] || [];
|
||||
return {
|
||||
messages: {
|
||||
...state.messages,
|
||||
[message.chatId]: chatMessages.map((m) => (m.id === message.id ? message : m)),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeMessage: (messageId, chatId) => {
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
const updatedMessages = chatMessages.map((m) =>
|
||||
m.id === messageId ? { ...m, isDeleted: true, content: null } : m
|
||||
);
|
||||
|
||||
// Find the latest non-deleted message to show in sidebar
|
||||
const latestVisible = updatedMessages
|
||||
.filter(m => !m.isDeleted)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
|
||||
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === chatId) {
|
||||
// If the deleted message was the last message shown, replace with previous one
|
||||
const currentLast = chat.messages?.[0];
|
||||
if (currentLast?.id === messageId) {
|
||||
return {
|
||||
...chat,
|
||||
messages: latestVisible ? [latestVisible] : [{ ...currentLast, isDeleted: true, content: null }],
|
||||
};
|
||||
}
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
return {
|
||||
messages: {
|
||||
...state.messages,
|
||||
[chatId]: updatedMessages,
|
||||
},
|
||||
chats: updatedChats,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeMessages: (messageIds, chatId) => {
|
||||
const idsSet = new Set(messageIds);
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
const updatedMessages = chatMessages.map((m) =>
|
||||
idsSet.has(m.id) ? { ...m, isDeleted: true, content: null } : m
|
||||
);
|
||||
|
||||
const latestVisible = updatedMessages
|
||||
.filter(m => !m.isDeleted)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
|
||||
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === chatId) {
|
||||
const currentLast = chat.messages?.[0];
|
||||
if (currentLast && idsSet.has(currentLast.id)) {
|
||||
return {
|
||||
...chat,
|
||||
messages: latestVisible ? [latestVisible] : [{ ...currentLast, isDeleted: true, content: null }],
|
||||
};
|
||||
}
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
return {
|
||||
messages: { ...state.messages, [chatId]: updatedMessages },
|
||||
chats: updatedChats,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
hideMessages: (messageIds, chatId) => {
|
||||
const idsSet = new Set(messageIds);
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
const updatedMessages = chatMessages.filter((m) => !idsSet.has(m.id));
|
||||
|
||||
const latestVisible = updatedMessages
|
||||
.filter(m => !m.isDeleted)
|
||||
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0];
|
||||
|
||||
const updatedChats = state.chats.map((chat) => {
|
||||
if (chat.id === chatId) {
|
||||
const currentLast = chat.messages?.[0];
|
||||
if (currentLast && idsSet.has(currentLast.id)) {
|
||||
return {
|
||||
...chat,
|
||||
messages: latestVisible ? [latestVisible] : [],
|
||||
};
|
||||
}
|
||||
}
|
||||
return chat;
|
||||
});
|
||||
|
||||
return {
|
||||
messages: { ...state.messages, [chatId]: updatedMessages },
|
||||
chats: updatedChats,
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addReaction: (messageId, chatId, userId, username, emoji) => {
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
return {
|
||||
messages: {
|
||||
...state.messages,
|
||||
[chatId]: chatMessages.map((m) => {
|
||||
if (m.id === messageId) {
|
||||
const exists = m.reactions.some((r) => r.userId === userId && r.emoji === emoji);
|
||||
if (exists) return m;
|
||||
return {
|
||||
...m,
|
||||
reactions: [
|
||||
...m.reactions,
|
||||
{ id: `${messageId}-${userId}-${emoji}`, emoji, userId, user: { id: userId, username, displayName: username } },
|
||||
],
|
||||
};
|
||||
}
|
||||
return m;
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
removeReaction: (messageId, chatId, userId, emoji) => {
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
return {
|
||||
messages: {
|
||||
...state.messages,
|
||||
[chatId]: chatMessages.map((m) => {
|
||||
if (m.id === messageId) {
|
||||
return {
|
||||
...m,
|
||||
reactions: m.reactions.filter((r) => !(r.userId === userId && r.emoji === emoji)),
|
||||
};
|
||||
}
|
||||
return m;
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
markRead: (chatId, userId, messageIds) => {
|
||||
const currentUserId = useAuthStore.getState().user?.id;
|
||||
set((state) => {
|
||||
const chatMessages = state.messages[chatId] || [];
|
||||
return {
|
||||
messages: {
|
||||
...state.messages,
|
||||
[chatId]: chatMessages.map((m) => {
|
||||
if (messageIds.includes(m.id)) {
|
||||
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
|
||||
if (alreadyRead) return m;
|
||||
return { ...m, readBy: [...(m.readBy || []), { userId }] };
|
||||
}
|
||||
return m;
|
||||
}),
|
||||
},
|
||||
chats: state.chats.map((chat) => {
|
||||
if (chat.id === chatId && userId === currentUserId) {
|
||||
return { ...chat, unreadCount: 0 };
|
||||
}
|
||||
return chat;
|
||||
}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
addTypingUser: (chatId, userId) => {
|
||||
set((state) => {
|
||||
const exists = state.typingUsers.some((t) => t.chatId === chatId && t.userId === userId);
|
||||
if (exists) return state;
|
||||
return { typingUsers: [...state.typingUsers, { chatId, userId }] };
|
||||
});
|
||||
},
|
||||
|
||||
removeTypingUser: (chatId, userId) => {
|
||||
set((state) => ({
|
||||
typingUsers: state.typingUsers.filter((t) => !(t.chatId === chatId && t.userId === userId)),
|
||||
}));
|
||||
},
|
||||
|
||||
updateUserOnlineStatus: (userId, isOnline, lastSeen) => {
|
||||
set((state) => ({
|
||||
chats: state.chats.map((chat) => ({
|
||||
...chat,
|
||||
members: chat.members.map((m) =>
|
||||
m.user.id === userId
|
||||
? { ...m, user: { ...m.user, isOnline, lastSeen: lastSeen || m.user.lastSeen } }
|
||||
: m
|
||||
),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
setReplyTo: (message) => set({ replyTo: message, editingMessage: null }),
|
||||
setEditingMessage: (message) => set({ editingMessage: message, replyTo: null }),
|
||||
|
||||
addChat: (chat) => {
|
||||
set((state) => {
|
||||
if (state.chats.some((c) => c.id === chat.id)) return state;
|
||||
return { chats: [chat, ...state.chats] };
|
||||
});
|
||||
},
|
||||
|
||||
updateChat: (chat) => {
|
||||
set((state) => ({
|
||||
chats: state.chats.map((c) => (c.id === chat.id ? { ...c, ...chat } : c)),
|
||||
}));
|
||||
},
|
||||
|
||||
removeChat: (chatId) => {
|
||||
set((state) => ({
|
||||
chats: state.chats.filter((c) => c.id !== chatId),
|
||||
activeChat: state.activeChat === chatId ? null : state.activeChat,
|
||||
messages: (() => { const m = { ...state.messages }; delete m[chatId]; return m; })(),
|
||||
}));
|
||||
},
|
||||
|
||||
clearMessages: (chatId) => {
|
||||
set((state) => ({
|
||||
messages: { ...state.messages, [chatId]: [] },
|
||||
chats: state.chats.map((c) =>
|
||||
c.id === chatId ? { ...c, messages: [] } : c
|
||||
),
|
||||
}));
|
||||
},
|
||||
|
||||
setPinnedMessage: (chatId, message) => {
|
||||
set((state) => ({
|
||||
pinnedMessages: { ...state.pinnedMessages, [chatId]: message },
|
||||
}));
|
||||
},
|
||||
|
||||
removePinnedMessage: (chatId, _messageId, newPinned) => {
|
||||
set((state) => {
|
||||
const updated = { ...state.pinnedMessages };
|
||||
if (newPinned) {
|
||||
updated[chatId] = newPinned;
|
||||
} else {
|
||||
delete updated[chatId];
|
||||
}
|
||||
return { pinnedMessages: updated };
|
||||
});
|
||||
},
|
||||
|
||||
clearStore: () => {
|
||||
set({
|
||||
chats: [],
|
||||
activeChat: null,
|
||||
messages: {},
|
||||
pinnedMessages: {},
|
||||
typingUsers: [],
|
||||
replyTo: null,
|
||||
editingMessage: null,
|
||||
});
|
||||
},
|
||||
}));
|
||||
21
apps/web/src/stores/themeStore.ts
Normal file
21
apps/web/src/stores/themeStore.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
|
||||
export type ChatTheme = 'midnight' | 'ocean' | 'forest' | 'sunset' | 'classic' | 'neon' | 'aurora' | 'cyber' | 'glass' | 'void';
|
||||
|
||||
interface ThemeState {
|
||||
chatTheme: ChatTheme;
|
||||
setChatTheme: (theme: ChatTheme) => void;
|
||||
}
|
||||
|
||||
export const useThemeStore = create<ThemeState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
chatTheme: 'midnight',
|
||||
setChatTheme: (theme) => set({ chatTheme: theme }),
|
||||
}),
|
||||
{
|
||||
name: 'vortex-theme-storage',
|
||||
}
|
||||
)
|
||||
);
|
||||
1
apps/web/src/vite-env.d.ts
vendored
Normal file
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
apps/web/tsconfig.json
Normal file
25
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
1
apps/web/tsconfig.tsbuildinfo
Normal file
1
apps/web/tsconfig.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/callmodal.tsx","./src/components/chatlistitem.tsx","./src/components/chatview.tsx","./src/components/confirmmodal.tsx","./src/components/datepicker.tsx","./src/components/emojipicker.tsx","./src/components/forwardmodal.tsx","./src/components/groupsettings.tsx","./src/components/imagelightbox.tsx","./src/components/messagebubble.tsx","./src/components/messageinput.tsx","./src/components/newchatmodal.tsx","./src/components/sidemenu.tsx","./src/components/sidebar.tsx","./src/components/storyviewer.tsx","./src/components/typingindicator.tsx","./src/components/userprofile.tsx","./src/lib/api.ts","./src/lib/i18n.ts","./src/lib/socket.ts","./src/lib/sounds.ts","./src/lib/utils.ts","./src/pages/authpage.tsx","./src/pages/chatpage.tsx","./src/stores/authstore.ts","./src/stores/chatstore.ts","./src/stores/themestore.ts"],"version":"5.9.3"}
|
||||
25
apps/web/vite.config.ts
Normal file
25
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/uploads': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/socket.io': {
|
||||
target: 'http://localhost:3001',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
440
deploy.sh
Normal file
440
deploy.sh
Normal file
@@ -0,0 +1,440 @@
|
||||
#!/bin/bash
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
# Vortex Messenger — полный деплой на чистый VPS
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
#
|
||||
# Требования:
|
||||
# - Чистый VPS с Ubuntu 22.04 или 24.04
|
||||
# - Домен с A-записью, направленной на IP сервера
|
||||
#
|
||||
# Инструкция:
|
||||
# 1. Загрузите весь проект на сервер:
|
||||
# scp -r ./* root@<IP>:/var/www/vortex/
|
||||
# 2. Зайдите на сервер: ssh root@<IP>
|
||||
# 3. Запустите:
|
||||
# chmod +x /var/www/vortex/deploy.sh
|
||||
# /var/www/vortex/deploy.sh
|
||||
#
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
||||
set -e
|
||||
|
||||
# ─── Цвета ───
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_step() { echo -e "\n${CYAN}━━━ $1 ━━━${NC}\n"; }
|
||||
print_ok() { echo -e "${GREEN}✅ $1${NC}"; }
|
||||
print_warn() { echo -e "${YELLOW}⚠️ $1${NC}"; }
|
||||
print_error(){ echo -e "${RED}❌ $1${NC}"; }
|
||||
|
||||
# ─── Проверки ───
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
print_error "Запустите с правами root: sudo ./deploy.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
APP_DIR="/var/www/vortex"
|
||||
|
||||
if [ ! -f "$APP_DIR/package.json" ]; then
|
||||
print_error "Файлы проекта не найдены в $APP_DIR!"
|
||||
echo ""
|
||||
echo "Сначала загрузите проект на сервер:"
|
||||
echo " scp -r ./* root@<IP>:$APP_DIR/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ─── Ввод параметров ───
|
||||
echo -e "${CYAN}"
|
||||
echo "╔═══════════════════════════════════════════════════╗"
|
||||
echo "║ 🌀 Vortex Messenger — Деплой ║"
|
||||
echo "╚═══════════════════════════════════════════════════╝"
|
||||
echo -e "${NC}"
|
||||
|
||||
read -p "Введите домен (например messenger.ru): " DOMAIN
|
||||
if [ -z "$DOMAIN" ]; then
|
||||
print_error "Домен не указан!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
read -p "Введите email для SSL-сертификата: " SSL_EMAIL
|
||||
if [ -z "$SSL_EMAIL" ]; then
|
||||
print_error "Email не указан!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Генерация безопасных ключей
|
||||
DB_PASSWORD=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
JWT_SECRET=$(openssl rand -base64 48 | tr -dc 'a-zA-Z0-9' | head -c 64)
|
||||
ENCRYPTION_KEY=$(openssl rand -hex 32)
|
||||
TURN_SECRET=$(openssl rand -base64 24 | tr -dc 'a-zA-Z0-9' | head -c 32)
|
||||
|
||||
echo ""
|
||||
print_ok "Параметры приняты. Начинаю установку..."
|
||||
echo ""
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Шаг 1: Обновление системы
|
||||
# ═══════════════════════════════════════
|
||||
print_step "1/10 — Обновление системы"
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt update && apt upgrade -y
|
||||
apt install -y curl wget gnupg2 software-properties-common git unzip build-essential
|
||||
print_ok "Система обновлена"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Шаг 2: Node.js 20
|
||||
# ═══════════════════════════════════════
|
||||
print_step "2/10 — Установка Node.js 20"
|
||||
if ! command -v node &>/dev/null || [[ $(node -v | cut -d. -f1 | tr -d v) -lt 20 ]]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
apt install -y nodejs
|
||||
fi
|
||||
npm install -g pm2
|
||||
print_ok "Node.js $(node -v), npm $(npm -v), PM2 установлен"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Шаг 3: PostgreSQL
|
||||
# ═══════════════════════════════════════
|
||||
print_step "3/10 — Установка PostgreSQL"
|
||||
apt install -y postgresql postgresql-contrib
|
||||
systemctl enable postgresql
|
||||
systemctl start postgresql
|
||||
|
||||
sudo -u postgres psql -c "DO \$\$ BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'vortex') THEN
|
||||
CREATE ROLE vortex WITH LOGIN PASSWORD '${DB_PASSWORD}';
|
||||
END IF;
|
||||
END \$\$;"
|
||||
sudo -u postgres psql -c "SELECT 1 FROM pg_database WHERE datname = 'vortex'" | grep -q 1 || \
|
||||
sudo -u postgres createdb -O vortex vortex
|
||||
|
||||
print_ok "PostgreSQL готов (БД: vortex, пользователь: vortex)"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Шаг 4: Nginx
|
||||
# ═══════════════════════════════════════
|
||||
print_step "4/10 — Установка Nginx"
|
||||
apt install -y nginx
|
||||
systemctl enable nginx
|
||||
print_ok "Nginx установлен"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Шаг 5: Certbot (SSL)
|
||||
# ═══════════════════════════════════════
|
||||
print_step "5/10 — Установка Certbot"
|
||||
apt install -y certbot python3-certbot-nginx
|
||||
print_ok "Certbot установлен"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Шаг 6: coturn (TURN-сервер для звонков)
|
||||
# ═══════════════════════════════════════
|
||||
print_step "6/10 — Установка coturn (TURN)"
|
||||
apt install -y coturn
|
||||
|
||||
cat > /etc/turnserver.conf << EOF
|
||||
listening-port=3478
|
||||
tls-listening-port=5349
|
||||
realm=${DOMAIN}
|
||||
server-name=${DOMAIN}
|
||||
lt-cred-mech
|
||||
use-auth-secret
|
||||
static-auth-secret=${TURN_SECRET}
|
||||
fingerprint
|
||||
no-cli
|
||||
no-tlsv1
|
||||
no-tlsv1_1
|
||||
EOF
|
||||
|
||||
sed -i 's/#TURNSERVER_ENABLED=1/TURNSERVER_ENABLED=1/' /etc/default/coturn 2>/dev/null || true
|
||||
systemctl enable coturn
|
||||
systemctl restart coturn
|
||||
print_ok "coturn настроен"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Шаг 7: Настройка окружения (.env)
|
||||
# ═══════════════════════════════════════
|
||||
print_step "7/10 — Создание .env"
|
||||
|
||||
mkdir -p ${APP_DIR}/apps/server/uploads/avatars
|
||||
|
||||
cat > ${APP_DIR}/apps/server/.env << EOF
|
||||
DATABASE_URL=postgresql://vortex:${DB_PASSWORD}@localhost:5432/vortex
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
PORT=3001
|
||||
NODE_ENV=production
|
||||
CORS_ORIGINS=https://${DOMAIN}
|
||||
MAX_REGISTRATIONS_PER_IP=5
|
||||
TURN_URL=turn:${DOMAIN}:3478
|
||||
TURN_SECRET=${TURN_SECRET}
|
||||
STUN_URLS=stun:stun.l.google.com:19302,stun:${DOMAIN}:3478
|
||||
EOF
|
||||
|
||||
print_ok "Файл .env создан"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Шаг 8: Установка зависимостей и сборка
|
||||
# ═══════════════════════════════════════
|
||||
print_step "8/10 — Установка зависимостей и сборка"
|
||||
|
||||
cd ${APP_DIR}
|
||||
|
||||
# Установка зависимостей (npm workspaces)
|
||||
npm install --legacy-peer-deps
|
||||
print_ok "Зависимости установлены"
|
||||
|
||||
# --- Бэкенд ---
|
||||
cd ${APP_DIR}/apps/server
|
||||
npx prisma generate
|
||||
npx prisma db push --accept-data-loss
|
||||
print_ok "БД синхронизирована"
|
||||
|
||||
npx tsc
|
||||
print_ok "Бэкенд скомпилирован (dist/)"
|
||||
|
||||
# --- Фронтенд ---
|
||||
cd ${APP_DIR}/apps/web
|
||||
npx vite build
|
||||
print_ok "Фронтенд собран (dist/)"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Шаг 9: Nginx конфигурация
|
||||
# ═══════════════════════════════════════
|
||||
print_step "9/10 — Настройка Nginx"
|
||||
|
||||
cat > /etc/nginx/sites-available/vortex << EOF
|
||||
server {
|
||||
listen 80;
|
||||
server_name ${DOMAIN};
|
||||
|
||||
root ${APP_DIR}/apps/web/dist;
|
||||
index index.html;
|
||||
client_max_body_size 50M;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript;
|
||||
gzip_min_length 1000;
|
||||
|
||||
location / {
|
||||
try_files \$uri \$uri/ /index.html;
|
||||
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||
client_max_body_size 50M;
|
||||
}
|
||||
|
||||
location /socket.io/ {
|
||||
proxy_pass http://127.0.0.1:3001;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade \$http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
proxy_read_timeout 86400;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
proxy_pass http://127.0.0.1:3001/uploads/;
|
||||
proxy_set_header Host \$host;
|
||||
proxy_set_header X-Real-IP \$remote_addr;
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
# Блокировка доступа по голому IP
|
||||
cat > /etc/nginx/sites-available/block-ip << EOF
|
||||
server {
|
||||
listen 80 default_server;
|
||||
server_name _;
|
||||
return 444;
|
||||
}
|
||||
EOF
|
||||
|
||||
ln -sf /etc/nginx/sites-available/vortex /etc/nginx/sites-enabled/
|
||||
ln -sf /etc/nginx/sites-available/block-ip /etc/nginx/sites-enabled/
|
||||
rm -f /etc/nginx/sites-enabled/default
|
||||
|
||||
nginx -t && systemctl reload nginx
|
||||
print_ok "Nginx настроен"
|
||||
|
||||
# --- SSL ---
|
||||
print_step "Получение SSL-сертификата"
|
||||
certbot --nginx -d ${DOMAIN} --non-interactive --agree-tos -m ${SSL_EMAIL} 2>&1 || {
|
||||
print_warn "SSL не удалось получить автоматически."
|
||||
print_warn "Проверьте что DNS A-запись ${DOMAIN} указывает на IP этого сервера."
|
||||
print_warn "После настройки DNS выполните вручную:"
|
||||
echo " certbot --nginx -d ${DOMAIN}"
|
||||
}
|
||||
|
||||
# Обновить block-ip для HTTPS тоже
|
||||
if [ -f "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" ]; then
|
||||
cat > /etc/nginx/sites-available/block-ip << EOF
|
||||
server {
|
||||
listen 80 default_server;
|
||||
listen 443 ssl default_server;
|
||||
server_name _;
|
||||
ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
|
||||
return 444;
|
||||
}
|
||||
EOF
|
||||
nginx -t && systemctl reload nginx
|
||||
print_ok "Доступ по IP заблокирован (HTTP и HTTPS)"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Шаг 10: Запуск через PM2
|
||||
# ═══════════════════════════════════════
|
||||
print_step "10/10 — Запуск сервера"
|
||||
|
||||
cd ${APP_DIR}/apps/server
|
||||
|
||||
pm2 delete vortex-server 2>/dev/null || true
|
||||
pm2 start dist/index.js --name vortex-server --cwd ${APP_DIR}/apps/server
|
||||
pm2 save
|
||||
pm2 startup systemd -u root --hp /root 2>/dev/null || pm2 startup
|
||||
|
||||
print_ok "Сервер запущен"
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Файрвол
|
||||
# ═══════════════════════════════════════
|
||||
if command -v ufw &>/dev/null; then
|
||||
ufw allow 22/tcp
|
||||
ufw allow 80/tcp
|
||||
ufw allow 443/tcp
|
||||
ufw allow 3478/tcp
|
||||
ufw allow 3478/udp
|
||||
ufw --force enable
|
||||
print_ok "Файрвол настроен"
|
||||
fi
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Скрипт обновления (для будущих апдейтов)
|
||||
# ═══════════════════════════════════════
|
||||
cat > ${APP_DIR}/update.sh << 'UPDATESCRIPT'
|
||||
#!/bin/bash
|
||||
set -e
|
||||
APP_DIR="/var/www/vortex"
|
||||
ARCHIVE="/root/vortex-deploy.tar.gz"
|
||||
ENV_BACKUP="/root/vortex-env-backup"
|
||||
|
||||
echo ""
|
||||
echo "VORTEX - Update server"
|
||||
echo ""
|
||||
|
||||
if [ ! -f "$ARCHIVE" ]; then
|
||||
echo "Архив не найден: $ARCHIVE"; exit 1
|
||||
fi
|
||||
|
||||
echo "1/7 Остановка сервера"
|
||||
pm2 stop vortex-server 2>/dev/null || true
|
||||
|
||||
echo "2/7 Бэкап .env"
|
||||
if [ -f "$APP_DIR/apps/server/.env" ]; then
|
||||
cp "$APP_DIR/apps/server/.env" "$ENV_BACKUP"
|
||||
fi
|
||||
|
||||
echo "3/7 Бэкап аватаров"
|
||||
if [ -d "$APP_DIR/apps/server/uploads/avatars" ]; then
|
||||
cp -r "$APP_DIR/apps/server/uploads/avatars" /root/vortex-avatars-backup 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "4/7 Распаковка обновления"
|
||||
tar -xzf "$ARCHIVE" -C "$APP_DIR"
|
||||
if [ -f "$ENV_BACKUP" ]; then
|
||||
cp "$ENV_BACKUP" "$APP_DIR/apps/server/.env"
|
||||
fi
|
||||
if [ -d "/root/vortex-avatars-backup" ]; then
|
||||
mkdir -p "$APP_DIR/apps/server/uploads/avatars"
|
||||
cp -r /root/vortex-avatars-backup/* "$APP_DIR/apps/server/uploads/avatars/" 2>/dev/null || true
|
||||
rm -rf /root/vortex-avatars-backup
|
||||
fi
|
||||
|
||||
echo "5/7 Установка зависимостей"
|
||||
cd "$APP_DIR"
|
||||
npm install --legacy-peer-deps
|
||||
|
||||
echo "6/7 Сборка сервера"
|
||||
cd "$APP_DIR/apps/server"
|
||||
npx prisma generate
|
||||
npx prisma db push --accept-data-loss
|
||||
npx tsc
|
||||
|
||||
echo "7/7 Запуск сервера"
|
||||
pm2 restart vortex-server
|
||||
pm2 save
|
||||
rm -f "$ARCHIVE"
|
||||
echo ""
|
||||
echo "Готово! Проверка: pm2 logs vortex-server --lines 10"
|
||||
UPDATESCRIPT
|
||||
chmod +x ${APP_DIR}/update.sh
|
||||
|
||||
# ═══════════════════════════════════════
|
||||
# Итог
|
||||
# ═══════════════════════════════════════
|
||||
SERVER_IP=$(curl -s4 ifconfig.me 2>/dev/null || hostname -I | awk '{print $1}')
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}╔═══════════════════════════════════════════════════╗${NC}"
|
||||
echo -e "${GREEN}║ 🎉 Vortex Messenger развёрнут! ║${NC}"
|
||||
echo -e "${GREEN}╠═══════════════════════════════════════════════════╣${NC}"
|
||||
echo -e "${GREEN}║${NC} ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}║${NC} 🌐 Сайт: https://${DOMAIN}${NC}"
|
||||
echo -e "${GREEN}║${NC} 📡 IP: ${SERVER_IP}${NC}"
|
||||
echo -e "${GREEN}║${NC} ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}║${NC} 📂 Проект: ${APP_DIR}${NC}"
|
||||
echo -e "${GREEN}║${NC} 🔧 Env: ${APP_DIR}/apps/server/.env${NC}"
|
||||
echo -e "${GREEN}║${NC} 🔄 Апдейт: ${APP_DIR}/update.sh${NC}"
|
||||
echo -e "${GREEN}║${NC} ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}╠═══════════════════════════════════════════════════╣${NC}"
|
||||
echo -e "${GREEN}║${NC} Команды: ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}║${NC} pm2 logs vortex-server — логи ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}║${NC} pm2 restart vortex-server — перезапуск ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}║${NC} pm2 monit — мониторинг ${GREEN}║${NC}"
|
||||
echo -e "${GREEN}╚═══════════════════════════════════════════════════╝${NC}"
|
||||
|
||||
# Сохранить credentials
|
||||
cat > ${APP_DIR}/CREDENTIALS.txt << EOF
|
||||
=== Vortex Messenger — Credentials ===
|
||||
Дата: $(date)
|
||||
Домен: ${DOMAIN}
|
||||
IP: ${SERVER_IP}
|
||||
|
||||
--- База данных ---
|
||||
Host: localhost:5432
|
||||
Database: vortex
|
||||
User: vortex
|
||||
Password: ${DB_PASSWORD}
|
||||
|
||||
--- Приложение ---
|
||||
JWT_SECRET=${JWT_SECRET}
|
||||
ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
|
||||
--- TURN сервер ---
|
||||
TURN_URL=turn:${DOMAIN}:3478
|
||||
TURN_SECRET=${TURN_SECRET}
|
||||
EOF
|
||||
|
||||
chmod 600 ${APP_DIR}/CREDENTIALS.txt
|
||||
echo ""
|
||||
print_ok "Credentials сохранены в ${APP_DIR}/CREDENTIALS.txt"
|
||||
echo ""
|
||||
79
pack-for-deploy.bat
Normal file
79
pack-for-deploy.bat
Normal file
@@ -0,0 +1,79 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo.
|
||||
echo ╔═══════════════════════════════════════════════════╗
|
||||
echo ║ 🌀 Vortex Messenger — Упаковка для деплоя ║
|
||||
echo ╚═══════════════════════════════════════════════════╝
|
||||
echo.
|
||||
|
||||
cd /d "%~dp0"
|
||||
|
||||
echo 📦 Создание архива для загрузки на VPS...
|
||||
|
||||
:: Создаём временную папку
|
||||
if exist "vortex-deploy" rmdir /s /q "vortex-deploy"
|
||||
mkdir "vortex-deploy"
|
||||
|
||||
:: Копируем нужные файлы (без node_modules, .git, и т.д.)
|
||||
echo Копирование package.json...
|
||||
copy package.json vortex-deploy\ >nul
|
||||
|
||||
echo Копирование deploy.sh...
|
||||
copy deploy.sh vortex-deploy\ >nul
|
||||
|
||||
echo Копирование apps\server...
|
||||
xcopy apps\server vortex-deploy\apps\server\ /E /I /Q /EXCLUDE:exclude-list.tmp >nul 2>nul
|
||||
:: Удаляем то что не нужно
|
||||
if exist "vortex-deploy\apps\server\node_modules" rmdir /s /q "vortex-deploy\apps\server\node_modules"
|
||||
if exist "vortex-deploy\apps\server\dist" rmdir /s /q "vortex-deploy\apps\server\dist"
|
||||
|
||||
echo Копирование apps\web...
|
||||
xcopy apps\web vortex-deploy\apps\web\ /E /I /Q >nul 2>nul
|
||||
:: Удаляем то что не нужно
|
||||
if exist "vortex-deploy\apps\web\node_modules" rmdir /s /q "vortex-deploy\apps\web\node_modules"
|
||||
if exist "vortex-deploy\apps\web\dist" rmdir /s /q "vortex-deploy\apps\web\dist"
|
||||
|
||||
echo Копирование sounds...
|
||||
if exist sounds xcopy sounds vortex-deploy\sounds\ /E /I /Q >nul 2>nul
|
||||
|
||||
:: Удаляем загруженные файлы (только тестовые)
|
||||
if exist "vortex-deploy\apps\server\uploads" (
|
||||
rmdir /s /q "vortex-deploy\apps\server\uploads"
|
||||
mkdir "vortex-deploy\apps\server\uploads\avatars"
|
||||
)
|
||||
|
||||
:: Удаляем .env (секреты генерируются на сервере)
|
||||
if exist "vortex-deploy\apps\server\.env" del "vortex-deploy\apps\server\.env"
|
||||
|
||||
echo.
|
||||
echo 📦 Сжатие в архив...
|
||||
|
||||
:: Проверяем наличие tar (есть в Windows 10+)
|
||||
where tar >nul 2>nul
|
||||
if %ERRORLEVEL% equ 0 (
|
||||
tar -czf vortex-deploy.tar.gz -C vortex-deploy .
|
||||
echo.
|
||||
echo ✅ Архив создан: vortex-deploy.tar.gz
|
||||
) else (
|
||||
echo ⚠ tar не найден. Установите 7-Zip или заархивируйте папку vortex-deploy вручную.
|
||||
echo Папка готова: vortex-deploy\
|
||||
)
|
||||
|
||||
:: Очистка
|
||||
rmdir /s /q "vortex-deploy" 2>nul
|
||||
|
||||
echo.
|
||||
echo ═══════════════════════════════════════════════════════
|
||||
echo Следующий шаг — загрузите архив на VPS:
|
||||
echo.
|
||||
echo scp vortex-deploy.tar.gz root@ВАШ_IP:/root/
|
||||
echo.
|
||||
echo Затем на сервере:
|
||||
echo cd /root
|
||||
echo mkdir -p /var/www/vortex
|
||||
echo tar -xzf vortex-deploy.tar.gz -C /var/www/vortex
|
||||
echo chmod +x /var/www/vortex/deploy.sh
|
||||
echo sudo /var/www/vortex/deploy.sh
|
||||
echo ═══════════════════════════════════════════════════════
|
||||
echo.
|
||||
pause
|
||||
5418
package-lock.json
generated
Normal file
5418
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "vortex-messenger",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently -k -n server,web -c \"#6366f1\",\"#8b5cf6\" \"npm run dev -w apps/server\" \"npm run dev -w apps/web\"",
|
||||
"build": "npm run build -w apps/server && npm run build -w apps/web",
|
||||
"db:push": "npm run db:push -w apps/server",
|
||||
"db:seed": "npm run db:seed -w apps/server",
|
||||
"setup": "npm install && npm run db:push && npm run db:seed",
|
||||
"start": "npm run dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^9.1.0"
|
||||
}
|
||||
}
|
||||
BIN
sounds/abonent_nedostupen.mp3
Normal file
BIN
sounds/abonent_nedostupen.mp3
Normal file
Binary file not shown.
BIN
sounds/call_sound.mp3
Normal file
BIN
sounds/call_sound.mp3
Normal file
Binary file not shown.
74
update-local.bat
Normal file
74
update-local.bat
Normal file
@@ -0,0 +1,74 @@
|
||||
@echo off
|
||||
chcp 65001 >nul
|
||||
echo.
|
||||
echo ══════════════════════════════════════════════
|
||||
echo VORTEX — Подготовка обновления (Windows)
|
||||
echo ══════════════════════════════════════════════
|
||||
echo.
|
||||
|
||||
set SERVER_IP=109.196.102.13
|
||||
|
||||
:: 1. Очистка старого архива
|
||||
echo [1/4] Очистка...
|
||||
if exist vortex-deploy rd /s /q vortex-deploy
|
||||
if exist vortex-deploy.tar.gz del vortex-deploy.tar.gz
|
||||
|
||||
:: 2. Копирование файлов
|
||||
echo [2/4] Копирование файлов проекта...
|
||||
mkdir vortex-deploy
|
||||
copy package.json vortex-deploy\ >nul
|
||||
copy deploy.sh vortex-deploy\ >nul
|
||||
xcopy apps vortex-deploy\apps /E /I /Q >nul
|
||||
|
||||
:: 3. Удаление лишнего
|
||||
echo [3/4] Очистка от node_modules, dist, .env...
|
||||
if exist vortex-deploy\apps\server\node_modules rd /s /q vortex-deploy\apps\server\node_modules
|
||||
if exist vortex-deploy\apps\server\dist rd /s /q vortex-deploy\apps\server\dist
|
||||
if exist vortex-deploy\apps\server\.env del vortex-deploy\apps\server\.env
|
||||
if exist vortex-deploy\apps\web\node_modules rd /s /q vortex-deploy\apps\web\node_modules
|
||||
if exist vortex-deploy\apps\web\dist rd /s /q vortex-deploy\apps\web\dist
|
||||
if exist vortex-deploy\apps\server\uploads\avatars rd /s /q vortex-deploy\apps\server\uploads\avatars
|
||||
mkdir vortex-deploy\apps\server\uploads\avatars >nul 2>&1
|
||||
|
||||
:: 4. Создание архива
|
||||
echo [4/4] Создание архива...
|
||||
tar -czf vortex-deploy.tar.gz -C vortex-deploy .
|
||||
|
||||
:: Проверка
|
||||
if not exist vortex-deploy.tar.gz (
|
||||
echo.
|
||||
echo ✗ ОШИБКА: Архив не создан!
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
for %%A in (vortex-deploy.tar.gz) do set SIZE=%%~zA
|
||||
set /a SIZE_KB=%SIZE%/1024
|
||||
echo.
|
||||
echo ✓ Архив создан: vortex-deploy.tar.gz (%SIZE_KB% KB)
|
||||
echo.
|
||||
|
||||
:: 5. Загрузка на сервер
|
||||
echo Загрузка на сервер %SERVER_IP%...
|
||||
echo (Введите пароль root когда попросит)
|
||||
echo.
|
||||
scp vortex-deploy.tar.gz root@%SERVER_IP%:/root/
|
||||
|
||||
if %errorlevel% equ 0 (
|
||||
echo.
|
||||
echo ══════════════════════════════════════════════
|
||||
echo ✓ ГОТОВО! Архив загружен на сервер.
|
||||
echo.
|
||||
echo Теперь зайдите на сервер:
|
||||
echo ssh root@%SERVER_IP%
|
||||
echo.
|
||||
echo И запустите:
|
||||
echo /var/www/vortex/update.sh
|
||||
echo ══════════════════════════════════════════════
|
||||
) else (
|
||||
echo.
|
||||
echo ✗ Ошибка загрузки. Проверьте пароль и попробуйте снова.
|
||||
)
|
||||
|
||||
echo.
|
||||
pause
|
||||
112
update-server.sh
Normal file
112
update-server.sh
Normal file
@@ -0,0 +1,112 @@
|
||||
#!/bin/bash
|
||||
# ══════════════════════════════════════════════
|
||||
# VORTEX — Обновление на сервере
|
||||
# ══════════════════════════════════════════════
|
||||
|
||||
set -e
|
||||
|
||||
APP_DIR="/var/www/vortex"
|
||||
ARCHIVE="/root/vortex-deploy.tar.gz"
|
||||
ENV_BACKUP="/root/vortex-env-backup"
|
||||
|
||||
GREEN='\033[0;32m'
|
||||
RED='\033[0;31m'
|
||||
CYAN='\033[0;36m'
|
||||
NC='\033[0m'
|
||||
|
||||
print_step() { echo -e "\n${CYAN}━━━ $1 ━━━${NC}\n"; }
|
||||
print_ok() { echo -e "${GREEN} ✓ $1${NC}"; }
|
||||
print_err() { echo -e "${RED} ✗ $1${NC}"; }
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════"
|
||||
echo " VORTEX — Обновление сервера"
|
||||
echo "══════════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Проверка архива
|
||||
if [ ! -f "$ARCHIVE" ]; then
|
||||
print_err "Архив $ARCHIVE не найден!"
|
||||
echo " Сначала запустите update-local.bat на вашем ПК"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. Остановка сервера
|
||||
print_step "1/7 — Остановка сервера"
|
||||
pm2 stop vortex-server 2>/dev/null || true
|
||||
print_ok "Сервер остановлен"
|
||||
|
||||
# 2. Бэкап .env
|
||||
print_step "2/7 — Сохранение .env"
|
||||
if [ -f "$APP_DIR/apps/server/.env" ]; then
|
||||
cp "$APP_DIR/apps/server/.env" "$ENV_BACKUP"
|
||||
print_ok ".env сохранён в $ENV_BACKUP"
|
||||
else
|
||||
print_err ".env не найден — пропускаем"
|
||||
fi
|
||||
|
||||
# 3. Бэкап uploads
|
||||
print_step "3/7 — Сохранение загруженных файлов"
|
||||
if [ -d "$APP_DIR/apps/server/uploads/avatars" ]; then
|
||||
cp -r "$APP_DIR/apps/server/uploads/avatars" /root/vortex-avatars-backup 2>/dev/null || true
|
||||
print_ok "Аватарки сохранены"
|
||||
fi
|
||||
|
||||
# 4. Распаковка
|
||||
print_step "4/7 — Распаковка обновления"
|
||||
tar -xzf "$ARCHIVE" -C "$APP_DIR"
|
||||
print_ok "Файлы обновлены"
|
||||
|
||||
# Восстановление .env
|
||||
if [ -f "$ENV_BACKUP" ]; then
|
||||
cp "$ENV_BACKUP" "$APP_DIR/apps/server/.env"
|
||||
print_ok ".env восстановлен"
|
||||
fi
|
||||
|
||||
# Восстановление аватарок
|
||||
if [ -d "/root/vortex-avatars-backup" ]; then
|
||||
mkdir -p "$APP_DIR/apps/server/uploads/avatars"
|
||||
cp -r /root/vortex-avatars-backup/* "$APP_DIR/apps/server/uploads/avatars/" 2>/dev/null || true
|
||||
rm -rf /root/vortex-avatars-backup
|
||||
print_ok "Аватарки восстановлены"
|
||||
fi
|
||||
|
||||
# 5. Установка зависимостей
|
||||
print_step "5/7 — Установка зависимостей"
|
||||
cd "$APP_DIR"
|
||||
npm install --production=false --legacy-peer-deps
|
||||
print_ok "Зависимости установлены"
|
||||
|
||||
# 6. Сборка
|
||||
print_step "6/7 — Сборка проекта"
|
||||
|
||||
cd "$APP_DIR/apps/server"
|
||||
npx prisma generate
|
||||
print_ok "Prisma Client сгенерирован"
|
||||
|
||||
npx prisma db push --accept-data-loss
|
||||
print_ok "База данных синхронизирована"
|
||||
|
||||
npx tsc
|
||||
print_ok "Сервер скомпилирован"
|
||||
|
||||
cd "$APP_DIR/apps/web"
|
||||
npx vite build
|
||||
print_ok "Фронтенд собран"
|
||||
|
||||
# 7. Запуск
|
||||
print_step "7/7 — Запуск сервера"
|
||||
pm2 restart vortex-server
|
||||
pm2 save
|
||||
print_ok "Сервер запущен"
|
||||
|
||||
# Очистка
|
||||
rm -f "$ARCHIVE"
|
||||
|
||||
echo ""
|
||||
echo "══════════════════════════════════════════════"
|
||||
echo -e " ${GREEN}✓ ОБНОВЛЕНИЕ ЗАВЕРШЕНО!${NC}"
|
||||
echo ""
|
||||
echo " Проверьте: pm2 logs vortex-server --lines 10"
|
||||
echo "══════════════════════════════════════════════"
|
||||
echo ""
|
||||
Reference in New Issue
Block a user