This commit is contained in:
Халимов Рустам
2026-03-10 21:11:59 +03:00
parent a3c0d35263
commit b4b4b8e0d3
80 changed files with 23310 additions and 0 deletions

18
.env.example Normal file
View 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
View 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
View 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
View 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 (обычно 515 минут).
---
## 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
View 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
View 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"
}
}

View 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

Binary file not shown.

View 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);
});

View 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);
});

View 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])
}

View 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
View 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
View 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
View 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
View 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);

View 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;
}
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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
View 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;

File diff suppressed because it is too large Load Diff

18
apps/server/tsconfig.json Normal file
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
apps/web/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

Binary file not shown.

Binary file not shown.

View 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
View 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>
);
}

View 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;

File diff suppressed because it is too large Load Diff

View 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);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
)}
</>
);
}

View 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>
);
}

View 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>
);
}

View 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)}
/>
</>
);
}

View 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
);
}

View 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);

File diff suppressed because it is too large Load Diff

View 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>
</>
);
}

File diff suppressed because it is too large Load Diff

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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>;
},
}));

View 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
View 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
View 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
View 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
View 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>
);

View 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>
);
}

View 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>
);
}

View 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 } });
}
},
}));

View 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,
});
},
}));

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

25
apps/web/tsconfig.json Normal file
View 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"]
}

View 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
View 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
View 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 ""

BIN
logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 MiB

79
pack-for-deploy.bat Normal file
View 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

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View 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"
}
}

Binary file not shown.

BIN
sounds/call_sound.mp3 Normal file

Binary file not shown.

74
update-local.bat Normal file
View 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
View 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 ""