1850 lines
72 KiB
Markdown
1850 lines
72 KiB
Markdown
# Jabogram: Дизайн-документ
|
||
|
||
> Версия: 1.0
|
||
> Дата: 2026-02-10
|
||
|
||
---
|
||
|
||
## Содержание
|
||
|
||
1. [Введение](#1-введение)
|
||
2. [Глоссарий](#2-глоссарий)
|
||
3. [Архитектура системы](#3-архитектура-системы)
|
||
4. [Структура проекта](#4-структура-проекта)
|
||
5. [Сервер](#5-сервер)
|
||
6. [Протокол XMPP: используемые XEP](#6-протокол-xmpp-используемые-xep)
|
||
7. [Формат сообщений](#7-формат-сообщений)
|
||
8. [Клиентское приложение — общая библиотека](#8-клиентское-приложение--общая-библиотека)
|
||
9. [Клиентское приложение — iOS](#9-клиентское-приложение--ios)
|
||
10. [Клиентское приложение — Android](#10-клиентское-приложение--android)
|
||
11. [Модели данных](#11-модели-данных)
|
||
12. [Сценарии взаимодействия](#12-сценарии-взаимодействия)
|
||
13. [Безопасность](#13-безопасность)
|
||
14. [Развёртывание](#14-развёртывание)
|
||
15. [Тестирование](#15-тестирование)
|
||
16. [Ограничения и допущения v1](#16-ограничения-и-допущения-v1)
|
||
|
||
---
|
||
|
||
## 1. Введение
|
||
|
||
### 1.1 Цель
|
||
|
||
Jabogram — мессенджер для обмена **текстовыми** и **голосовыми** сообщениями (голос — только предзаписанные аудиофайлы, без реалтайм-стриминга). Протокол — XMPP.
|
||
|
||
### 1.2 Основные возможности (scope v1)
|
||
|
||
| Функция | Описание |
|
||
|---|---|
|
||
| Регистрация / вход | XMPP In-Band Registration (XEP-0077) + SASL-аутентификация |
|
||
| Личные чаты (1-on-1) | Текст и голосовые сообщения |
|
||
| Групповые чаты | Multi-User Chat (XEP-0045) — текст и голосовые сообщения |
|
||
| Голосовые сообщения | Запись аудио → загрузка файла → отправка ссылки через XMPP |
|
||
| Статусы сообщений | Отправлено / доставлено / прочитано |
|
||
| Индикатор набора | «Пользователь печатает…» |
|
||
| Список контактов | XMPP Roster + поиск по JID |
|
||
| Присутствие | Online / Away / Offline |
|
||
| История сообщений | MAM (XEP-0313) + локальный кеш SQLite |
|
||
| Push-уведомления | APNs (iOS), FCM (Android) через XEP-0357 |
|
||
| Профиль пользователя | Никнейм + аватар (XEP-0084) |
|
||
|
||
### 1.3 Вне scope v1
|
||
|
||
- Голосовые/видеозвонки (WebRTC)
|
||
- End-to-end шифрование (OMEMO, XEP-0384)
|
||
- Передача произвольных файлов (изображения, документы)
|
||
- Web-клиент
|
||
- Стикеры / реакции
|
||
|
||
---
|
||
|
||
## 2. Глоссарий
|
||
|
||
| Термин | Определение |
|
||
|---|---|
|
||
| **JID** | Jabber ID — адрес пользователя в XMPP, формат `user@domain/resource` |
|
||
| **Bare JID** | JID без resource-части: `user@domain` |
|
||
| **Full JID** | JID с resource: `user@domain/phone` |
|
||
| **Roster** | Список контактов пользователя, хранится на сервере |
|
||
| **Stanza** | XML-сообщение протокола XMPP (`<message>`, `<presence>`, `<iq>`) |
|
||
| **MAM** | Message Archive Management — серверный архив сообщений |
|
||
| **MUC** | Multi-User Chat — групповой чат XMPP |
|
||
| **XEP** | XMPP Extension Protocol — расширение протокола |
|
||
| **OOB** | Out of Band Data — передача URL файла внутри stanza |
|
||
| **BOSH** | Bidirectional-streams Over Synchronous HTTP (не используется, только WebSocket) |
|
||
|
||
---
|
||
|
||
## 3. Архитектура системы
|
||
|
||
### 3.1 Общая схема
|
||
|
||
```
|
||
┌──────────────┐ WSS (5443) ┌──────────────────────┐
|
||
│ iOS-клиент │◄─────────────────────────► │ │
|
||
│ (React │ │ ejabberd │
|
||
│ Native) │ │ XMPP-сервер │
|
||
└──────────────┘ │ │
|
||
│ ┌────────────────┐ │
|
||
┌──────────────┐ WSS (5443) │ │ mod_mam │ │ ┌────────────┐
|
||
│ Android- │◄─────────────────────────► │ │ mod_muc │ │◄──►│ PostgreSQL │
|
||
│ клиент │ │ │ mod_http_upload│ │ └────────────┘
|
||
│ (React │ │ │ mod_push │ │
|
||
│ Native) │ │ │ mod_roster │ │ ┌────────────┐
|
||
└──────────────┘ │ │ ... │ │───►│ File │
|
||
│ └────────────────┘ │ │ Storage │
|
||
└──────────┬───────────┘ └────────────┘
|
||
│
|
||
┌──────────▼───────────┐
|
||
│ Push Gateway │
|
||
│ (APNs / FCM) │
|
||
└──────────────────────┘
|
||
```
|
||
|
||
### 3.2 Технологический стек
|
||
|
||
| Компонент | Технология |
|
||
|---|---|
|
||
| XMPP-сервер | **ejabberd** (latest stable) |
|
||
| БД сервера | **PostgreSQL 16+** |
|
||
| Файловое хранилище | Локальный диск / S3-совместимое (MinIO) |
|
||
| Reverse proxy | **Nginx** (TLS-терминация, проксирование WebSocket) |
|
||
| Контейнеризация | **Docker + docker-compose** |
|
||
| Клиент: фреймворк | **React Native** (latest stable, New Architecture) |
|
||
| Клиент: XMPP-библиотека | **stanza.js** |
|
||
| Клиент: навигация | **React Navigation 7+** |
|
||
| Клиент: состояние | **Zustand** |
|
||
| Клиент: локальная БД | **op-sqlite** (SQLite для React Native) |
|
||
| Клиент: аудио | **expo-av** или **react-native-audio-recorder-player** |
|
||
| Клиент: push (iOS) | **@react-native-firebase/messaging** + APNs |
|
||
| Клиент: push (Android) | **@react-native-firebase/messaging** + FCM |
|
||
| Язык клиента | **TypeScript** (strict mode) |
|
||
|
||
---
|
||
|
||
## 4. Структура проекта
|
||
|
||
Монорепозиторий с npm/yarn workspaces.
|
||
|
||
```
|
||
jabogram/
|
||
├── README.md
|
||
├── package.json # workspace root
|
||
├── doc/
|
||
│ └── DESIGN.md # этот документ
|
||
│
|
||
├── server/
|
||
│ ├── docker-compose.yml # ejabberd + postgres + nginx + minio
|
||
│ ├── ejabberd/
|
||
│ │ ├── Dockerfile # кастомный образ (если нужны плагины)
|
||
│ │ ├── ejabberd.yml # основной конфиг
|
||
│ │ └── certs/ # TLS-сертификаты (не в git)
|
||
│ ├── nginx/
|
||
│ │ └── nginx.conf # reverse proxy конфиг
|
||
│ ├── postgres/
|
||
│ │ └── init.sql # начальная схема (если нужна)
|
||
│ └── scripts/
|
||
│ ├── deploy.sh # скрипт развёртывания
|
||
│ ├── backup.sh # бекап БД
|
||
│ └── create-user.sh # создание пользователя через ejabberdctl
|
||
│
|
||
├── react-native-lib/
|
||
│ ├── package.json
|
||
│ ├── tsconfig.json
|
||
│ ├── src/
|
||
│ │ ├── index.ts # public API библиотеки
|
||
│ │ ├── services/
|
||
│ │ │ ├── xmpp/
|
||
│ │ │ │ ├── XMPPClient.ts # подключение, авторизация, реконнект
|
||
│ │ │ │ ├── MessageService.ts # отправка/приём сообщений
|
||
│ │ │ │ ├── RosterService.ts # управление контактами
|
||
│ │ │ │ ├── PresenceService.ts # статусы присутствия
|
||
│ │ │ │ ├── MUCService.ts # групповые чаты
|
||
│ │ │ │ ├── MAMService.ts # архив сообщений
|
||
│ │ │ │ ├── FileUploadService.ts # XEP-0363 HTTP File Upload
|
||
│ │ │ │ └── types.ts # XMPP-специфичные типы
|
||
│ │ │ ├── audio/
|
||
│ │ │ │ ├── AudioRecorder.ts # запись голосового сообщения
|
||
│ │ │ │ └── AudioPlayer.ts # воспроизведение
|
||
│ │ │ ├── push/
|
||
│ │ │ │ └── PushService.ts # регистрация push-токена
|
||
│ │ │ └── storage/
|
||
│ │ │ ├── Database.ts # инициализация SQLite
|
||
│ │ │ ├── MessageRepository.ts # CRUD сообщений
|
||
│ │ │ ├── ChatRepository.ts # CRUD чатов
|
||
│ │ │ └── ContactRepository.ts # CRUD контактов
|
||
│ │ ├── stores/
|
||
│ │ │ ├── useAuthStore.ts
|
||
│ │ │ ├── useChatsStore.ts
|
||
│ │ │ ├── useContactsStore.ts
|
||
│ │ │ ├── usePresenceStore.ts
|
||
│ │ │ └── useSettingsStore.ts
|
||
│ │ ├── screens/
|
||
│ │ │ ├── auth/
|
||
│ │ │ │ ├── LoginScreen.tsx
|
||
│ │ │ │ └── RegisterScreen.tsx
|
||
│ │ │ ├── chats/
|
||
│ │ │ │ ├── ChatListScreen.tsx
|
||
│ │ │ │ └── ChatScreen.tsx
|
||
│ │ │ ├── contacts/
|
||
│ │ │ │ ├── ContactsScreen.tsx
|
||
│ │ │ │ └── AddContactScreen.tsx
|
||
│ │ │ ├── groups/
|
||
│ │ │ │ ├── CreateGroupScreen.tsx
|
||
│ │ │ │ └── GroupInfoScreen.tsx
|
||
│ │ │ └── profile/
|
||
│ │ │ └── ProfileScreen.tsx
|
||
│ │ ├── components/
|
||
│ │ │ ├── MessageBubble.tsx
|
||
│ │ │ ├── VoiceMessageBubble.tsx
|
||
│ │ │ ├── ChatInput.tsx
|
||
│ │ │ ├── VoiceRecordButton.tsx
|
||
│ │ │ ├── VoiceRecordOverlay.tsx
|
||
│ │ │ ├── ChatListItem.tsx
|
||
│ │ │ ├── ContactListItem.tsx
|
||
│ │ │ ├── Avatar.tsx
|
||
│ │ │ ├── PresenceDot.tsx
|
||
│ │ │ ├── TypingIndicator.tsx
|
||
│ │ │ └── MessageStatus.tsx
|
||
│ │ ├── navigation/
|
||
│ │ │ └── AppNavigator.tsx
|
||
│ │ ├── hooks/
|
||
│ │ │ ├── useXMPP.ts
|
||
│ │ │ ├── useAudioRecorder.ts
|
||
│ │ │ ├── useAudioPlayer.ts
|
||
│ │ │ └── useMessages.ts
|
||
│ │ ├── types/
|
||
│ │ │ └── index.ts
|
||
│ │ └── utils/
|
||
│ │ ├── jid.ts # парсинг/форматирование JID
|
||
│ │ ├── datetime.ts # форматирование дат
|
||
│ │ └── audio.ts # утилиты для аудио
|
||
│ └── __tests__/
|
||
│ └── ...
|
||
│
|
||
├── react-native-ios/
|
||
│ ├── package.json # зависимость на react-native-lib
|
||
│ ├── index.js # точка входа (импорт App из lib)
|
||
│ ├── app.json
|
||
│ ├── metro.config.js # пути к workspace-пакетам
|
||
│ ├── babel.config.js
|
||
│ ├── Gemfile # CocoaPods
|
||
│ └── ios/
|
||
│ ├── Jabogram/
|
||
│ │ ├── AppDelegate.mm
|
||
│ │ ├── Info.plist # permissions, URL schemes, background modes
|
||
│ │ └── Jabogram.entitlements # push notifications entitlement
|
||
│ ├── Jabogram.xcodeproj/
|
||
│ └── Podfile
|
||
│
|
||
└── react-native-android/
|
||
├── package.json # зависимость на react-native-lib
|
||
├── index.js # точка входа (импорт App из lib)
|
||
├── app.json
|
||
├── metro.config.js
|
||
├── babel.config.js
|
||
└── android/
|
||
├── app/
|
||
│ ├── src/main/
|
||
│ │ ├── AndroidManifest.xml # permissions, services
|
||
│ │ ├── java/.../
|
||
│ │ │ └── MainActivity.kt
|
||
│ │ └── res/ # иконки, цвета, строки
|
||
│ └── build.gradle.kts
|
||
├── build.gradle.kts
|
||
├── settings.gradle.kts
|
||
└── google-services.json # FCM (не в git)
|
||
```
|
||
|
||
### 4.1 Workspace-конфигурация (корневой package.json)
|
||
|
||
```jsonc
|
||
{
|
||
"name": "jabogram",
|
||
"private": true,
|
||
"workspaces": [
|
||
"react-native-lib",
|
||
"react-native-ios",
|
||
"react-native-android"
|
||
]
|
||
}
|
||
```
|
||
|
||
### 4.2 Metro-конфигурация (react-native-ios и react-native-android)
|
||
|
||
Обе платформенные директории должны иметь `metro.config.js`, который умеет резолвить модули из `react-native-lib`:
|
||
|
||
```js
|
||
const path = require('path');
|
||
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config');
|
||
|
||
const libPath = path.resolve(__dirname, '../react-native-lib');
|
||
|
||
const config = {
|
||
watchFolders: [libPath],
|
||
resolver: {
|
||
nodeModulesPaths: [
|
||
path.resolve(__dirname, 'node_modules'),
|
||
path.resolve(libPath, 'node_modules'),
|
||
],
|
||
},
|
||
};
|
||
|
||
module.exports = mergeConfig(getDefaultConfig(__dirname), config);
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Сервер
|
||
|
||
### 5.1 Выбор: ejabberd
|
||
|
||
Почему ejabberd:
|
||
- Erlang/OTP — высокая производительность и отказоустойчивость
|
||
- Поддержка всех необходимых XEP «из коробки»
|
||
- Встроенный `mod_http_upload` для загрузки файлов
|
||
- Встроенный `mod_push` для push-уведомлений
|
||
- REST API для администрирования
|
||
- Официальный Docker-образ
|
||
- Поддержка PostgreSQL в качестве бэкенда
|
||
|
||
### 5.2 docker-compose.yml
|
||
|
||
```yaml
|
||
version: "3.8"
|
||
|
||
services:
|
||
ejabberd:
|
||
image: ejabberd/ecs:latest
|
||
container_name: jabogram-ejabberd
|
||
restart: unless-stopped
|
||
ports:
|
||
- "5222:5222" # XMPP client-to-server (STARTTLS)
|
||
- "5443:5443" # XMPP over WebSocket (WSS) + HTTP Upload
|
||
- "5280:5280" # Admin web UI + API (только localhost)
|
||
volumes:
|
||
- ./ejabberd/ejabberd.yml:/home/ejabberd/conf/ejabberd.yml:ro
|
||
- ./ejabberd/certs:/home/ejabberd/certs:ro
|
||
- ejabberd-database:/home/ejabberd/database
|
||
- ejabberd-uploads:/home/ejabberd/uploads
|
||
depends_on:
|
||
postgres:
|
||
condition: service_healthy
|
||
environment:
|
||
- ERLANG_NODE_ARG=ejabberd@localhost
|
||
- CTL_ON_CREATE=register admin jabogram.example.com adminpassword
|
||
|
||
postgres:
|
||
image: postgres:16-alpine
|
||
container_name: jabogram-postgres
|
||
restart: unless-stopped
|
||
environment:
|
||
POSTGRES_DB: ejabberd
|
||
POSTGRES_USER: ejabberd
|
||
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}" # из .env
|
||
volumes:
|
||
- postgres-data:/var/lib/postgresql/data
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U ejabberd"]
|
||
interval: 5s
|
||
timeout: 3s
|
||
retries: 5
|
||
|
||
nginx:
|
||
image: nginx:alpine
|
||
container_name: jabogram-nginx
|
||
restart: unless-stopped
|
||
ports:
|
||
- "443:443"
|
||
volumes:
|
||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||
- ./ejabberd/certs:/etc/nginx/certs:ro
|
||
depends_on:
|
||
- ejabberd
|
||
|
||
volumes:
|
||
postgres-data:
|
||
ejabberd-database:
|
||
ejabberd-uploads:
|
||
```
|
||
|
||
### 5.3 ejabberd.yml — основная конфигурация
|
||
|
||
```yaml
|
||
hosts:
|
||
- jabogram.example.com
|
||
|
||
loglevel: info
|
||
|
||
certfiles:
|
||
- /home/ejabberd/certs/*.pem
|
||
|
||
## ─── Listeners ────────────────────────────────────────────
|
||
|
||
listen:
|
||
# XMPP client-to-server
|
||
- port: 5222
|
||
ip: "::"
|
||
module: ejabberd_c2s
|
||
max_stanza_size: 262144
|
||
shaper: c2s_shaper
|
||
access: c2s
|
||
starttls_required: true
|
||
|
||
# XMPP over WebSocket + HTTP Upload + BOSH (fallback)
|
||
- port: 5443
|
||
ip: "::"
|
||
module: ejabberd_http
|
||
tls: true
|
||
request_handlers:
|
||
/ws: ejabberd_http_ws # WebSocket endpoint
|
||
/upload: mod_http_upload # HTTP File Upload
|
||
/api: mod_http_api # REST API
|
||
|
||
# Admin interface (привязан к localhost)
|
||
- port: 5280
|
||
ip: "127.0.0.1"
|
||
module: ejabberd_http
|
||
request_handlers:
|
||
/admin: ejabberd_web_admin
|
||
/api: mod_http_api
|
||
|
||
## ─── Authentication ───────────────────────────────────────
|
||
|
||
auth_method: sql
|
||
auth_password_format: scram
|
||
|
||
## ─── Database ─────────────────────────────────────────────
|
||
|
||
default_db: sql
|
||
new_sql_schema: true
|
||
|
||
sql_type: pgsql
|
||
sql_server: postgres
|
||
sql_port: 5432
|
||
sql_database: ejabberd
|
||
sql_username: ejabberd
|
||
sql_password: "${POSTGRES_PASSWORD}"
|
||
sql_pool_size: 10
|
||
|
||
## ─── Modules ──────────────────────────────────────────────
|
||
|
||
modules:
|
||
## Roster (список контактов)
|
||
mod_roster:
|
||
db_type: sql
|
||
versioning: true
|
||
|
||
## Presence (онлайн-статус)
|
||
mod_presence: {}
|
||
|
||
## Одиночные чаты — карбоны (синхронизация между устройствами)
|
||
mod_carboncopy: {}
|
||
|
||
## Статусы доставки сообщений
|
||
mod_receipt: {}
|
||
|
||
## Индикатор набора текста
|
||
mod_chatstate: {}
|
||
|
||
## Архив сообщений (история)
|
||
mod_mam:
|
||
db_type: sql
|
||
default: always # архивировать все сообщения
|
||
request_activates_archiving: false
|
||
assume_mam_usage: true
|
||
compress_xml: true
|
||
|
||
## Stream Management (надёжная доставка, возобновление сессии)
|
||
mod_stream_mgmt:
|
||
resend_on_timeout: if_offline
|
||
|
||
## Групповые чаты
|
||
mod_muc:
|
||
db_type: sql
|
||
access_create: muc_create
|
||
default_room_options:
|
||
persistent: true
|
||
mam: true
|
||
allow_change_subj: true
|
||
max_users: 200
|
||
|
||
## HTTP File Upload (для голосовых сообщений)
|
||
mod_http_upload:
|
||
put_url: "https://jabogram.example.com:5443/upload"
|
||
get_url: "https://jabogram.example.com:5443/upload"
|
||
docroot: /home/ejabberd/uploads
|
||
max_size: 10485760 # 10 MB
|
||
file_mode: "0640"
|
||
dir_mode: "2750"
|
||
thumbnail: false
|
||
custom_headers:
|
||
"Access-Control-Allow-Origin": "*"
|
||
"Access-Control-Allow-Headers": "Content-Type"
|
||
|
||
## In-Band Registration (регистрация через XMPP)
|
||
mod_register:
|
||
access: register
|
||
ip_access: trusted_network
|
||
welcome_message:
|
||
subject: "Welcome to Jabogram!"
|
||
body: "Welcome!"
|
||
|
||
## Push-уведомления (XEP-0357)
|
||
mod_push: {}
|
||
mod_push_keepalive: {}
|
||
|
||
## vCard (профиль пользователя)
|
||
mod_vcard:
|
||
db_type: sql
|
||
|
||
## Аватар пользователя
|
||
mod_avatar: {}
|
||
|
||
## Пинг (keepalive)
|
||
mod_ping:
|
||
send_pings: true
|
||
ping_interval: 60
|
||
ping_ack_timeout: 30
|
||
timeout_action: kill
|
||
|
||
## Блокировка пользователей
|
||
mod_blocking:
|
||
db_type: sql
|
||
|
||
## Service Discovery
|
||
mod_disco: {}
|
||
|
||
## Время последней активности
|
||
mod_last:
|
||
db_type: sql
|
||
|
||
## Приватное хранилище (для клиентских настроек)
|
||
mod_private:
|
||
db_type: sql
|
||
|
||
## Версия сервера
|
||
mod_version: {}
|
||
|
||
## ─── ACL / Access ─────────────────────────────────────────
|
||
|
||
acl:
|
||
admin:
|
||
user: admin@jabogram.example.com
|
||
local:
|
||
user_regexp: ""
|
||
|
||
access_rules:
|
||
c2s:
|
||
allow: all
|
||
register:
|
||
allow: all
|
||
muc_create:
|
||
allow: local
|
||
|
||
shaper_rules:
|
||
c2s_shaper:
|
||
none: admin
|
||
normal: all
|
||
|
||
shaper:
|
||
normal:
|
||
rate: 10000
|
||
burst_size: 50000
|
||
```
|
||
|
||
### 5.4 nginx.conf
|
||
|
||
```nginx
|
||
events {
|
||
worker_connections 1024;
|
||
}
|
||
|
||
http {
|
||
upstream ejabberd_ws {
|
||
server ejabberd:5443;
|
||
}
|
||
|
||
server {
|
||
listen 443 ssl;
|
||
server_name jabogram.example.com;
|
||
|
||
ssl_certificate /etc/nginx/certs/fullchain.pem;
|
||
ssl_certificate_key /etc/nginx/certs/privkey.pem;
|
||
ssl_protocols TLSv1.2 TLSv1.3;
|
||
|
||
# WebSocket proxy
|
||
location /ws {
|
||
proxy_pass https://ejabberd_ws/ws;
|
||
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_read_timeout 600s;
|
||
proxy_send_timeout 600s;
|
||
}
|
||
|
||
# HTTP File Upload
|
||
location /upload {
|
||
proxy_pass https://ejabberd_ws/upload;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
client_max_body_size 10m;
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
### 5.5 Скрипты управления
|
||
|
||
**scripts/deploy.sh:**
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
set -euo pipefail
|
||
cd "$(dirname "$0")/.."
|
||
docker compose up -d --build
|
||
docker compose exec ejabberd ejabberdctl status
|
||
```
|
||
|
||
**scripts/create-user.sh:**
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# Использование: ./create-user.sh username password
|
||
set -euo pipefail
|
||
USER="$1"
|
||
PASS="$2"
|
||
DOMAIN="jabogram.example.com"
|
||
docker compose exec ejabberd ejabberdctl register "$USER" "$DOMAIN" "$PASS"
|
||
echo "Created: ${USER}@${DOMAIN}"
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Протокол XMPP: используемые XEP
|
||
|
||
| XEP | Название | Назначение в Jabogram |
|
||
|---|---|---|
|
||
| [XEP-0077](https://xmpp.org/extensions/xep-0077.html) | In-Band Registration | Регистрация нового аккаунта прямо через XMPP-соединение |
|
||
| [XEP-0030](https://xmpp.org/extensions/xep-0030.html) | Service Discovery | Обнаружение возможностей сервера (поддержка upload, MUC и т.д.) |
|
||
| [XEP-0045](https://xmpp.org/extensions/xep-0045.html) | Multi-User Chat | Групповые чаты |
|
||
| [XEP-0066](https://xmpp.org/extensions/xep-0066.html) | Out of Band Data | Передача URL голосового файла в теле сообщения |
|
||
| [XEP-0084](https://xmpp.org/extensions/xep-0084.html) | User Avatar | Аватары пользователей |
|
||
| [XEP-0085](https://xmpp.org/extensions/xep-0085.html) | Chat State Notifications | Индикатор «печатает…» |
|
||
| [XEP-0184](https://xmpp.org/extensions/xep-0184.html) | Message Delivery Receipts | Подтверждение доставки (✓✓) |
|
||
| [XEP-0191](https://xmpp.org/extensions/xep-0191.html) | Blocking Command | Блокировка пользователей |
|
||
| [XEP-0198](https://xmpp.org/extensions/xep-0198.html) | Stream Management | Надёжная доставка stanza, возобновление сессии после обрыва |
|
||
| [XEP-0280](https://xmpp.org/extensions/xep-0280.html) | Message Carbons | Синхронизация сообщений между устройствами одного пользователя |
|
||
| [XEP-0313](https://xmpp.org/extensions/xep-0313.html) | Message Archive Management | Серверный архив; синхронизация истории при подключении |
|
||
| [XEP-0333](https://xmpp.org/extensions/xep-0333.html) | Chat Markers | Статус «прочитано» (displayed marker) |
|
||
| [XEP-0352](https://xmpp.org/extensions/xep-0352.html) | Client State Indication | Клиент сообщает серверу, что ушёл в фон (экономия трафика) |
|
||
| [XEP-0357](https://xmpp.org/extensions/xep-0357.html) | Push Notifications | Серверные push-уведомления при офлайн-сообщениях |
|
||
| [XEP-0363](https://xmpp.org/extensions/xep-0363.html) | HTTP File Upload | Загрузка голосовых файлов на сервер |
|
||
|
||
---
|
||
|
||
## 7. Формат сообщений
|
||
|
||
### 7.1 Текстовое сообщение (1-on-1)
|
||
|
||
```xml
|
||
<message
|
||
from="alice@jabogram.example.com/phone"
|
||
to="bob@jabogram.example.com"
|
||
type="chat"
|
||
id="msg-uuid-001">
|
||
<body>Привет, как дела?</body>
|
||
<request xmlns="urn:xmpp:receipts"/>
|
||
<markable xmlns="urn:xmpp:chat-markers:0"/>
|
||
</message>
|
||
```
|
||
|
||
### 7.2 Подтверждение доставки (XEP-0184)
|
||
|
||
```xml
|
||
<message
|
||
from="bob@jabogram.example.com/phone"
|
||
to="alice@jabogram.example.com"
|
||
type="chat"
|
||
id="receipt-001">
|
||
<received xmlns="urn:xmpp:receipts" id="msg-uuid-001"/>
|
||
</message>
|
||
```
|
||
|
||
### 7.3 Маркер «прочитано» (XEP-0333)
|
||
|
||
```xml
|
||
<message
|
||
from="bob@jabogram.example.com/phone"
|
||
to="alice@jabogram.example.com"
|
||
type="chat"
|
||
id="marker-001">
|
||
<displayed xmlns="urn:xmpp:chat-markers:0" id="msg-uuid-001"/>
|
||
</message>
|
||
```
|
||
|
||
### 7.4 Индикатор набора текста (XEP-0085)
|
||
|
||
```xml
|
||
<message
|
||
from="alice@jabogram.example.com/phone"
|
||
to="bob@jabogram.example.com"
|
||
type="chat">
|
||
<composing xmlns="http://jabber.org/protocol/chatstates"/>
|
||
</message>
|
||
```
|
||
|
||
### 7.5 Голосовое сообщение
|
||
|
||
Голосовое сообщение отправляется в два этапа:
|
||
|
||
**Этап 1 — получение upload-слота (XEP-0363):**
|
||
|
||
Запрос:
|
||
```xml
|
||
<iq from="alice@jabogram.example.com/phone"
|
||
to="jabogram.example.com"
|
||
type="get"
|
||
id="upload-001">
|
||
<request xmlns="urn:xmpp:http:upload:0"
|
||
filename="voice-1707580800.m4a"
|
||
size="48320"
|
||
content-type="audio/mp4"/>
|
||
</iq>
|
||
```
|
||
|
||
Ответ сервера:
|
||
```xml
|
||
<iq from="jabogram.example.com"
|
||
to="alice@jabogram.example.com/phone"
|
||
type="result"
|
||
id="upload-001">
|
||
<slot xmlns="urn:xmpp:http:upload:0">
|
||
<put url="https://jabogram.example.com:5443/upload/abcdef123/voice-1707580800.m4a">
|
||
<header name="Authorization">Basic ...</header>
|
||
<header name="Content-Type">audio/mp4</header>
|
||
</put>
|
||
<get url="https://jabogram.example.com:5443/upload/abcdef123/voice-1707580800.m4a"/>
|
||
</slot>
|
||
</iq>
|
||
```
|
||
|
||
**Этап 2 — загрузка файла по HTTP PUT:**
|
||
|
||
```
|
||
PUT /upload/abcdef123/voice-1707580800.m4a HTTP/1.1
|
||
Host: jabogram.example.com:5443
|
||
Content-Type: audio/mp4
|
||
Content-Length: 48320
|
||
|
||
<binary audio data>
|
||
```
|
||
|
||
**Этап 3 — отправка XMPP-сообщения со ссылкой:**
|
||
|
||
```xml
|
||
<message
|
||
from="alice@jabogram.example.com/phone"
|
||
to="bob@jabogram.example.com"
|
||
type="chat"
|
||
id="msg-uuid-002">
|
||
<body>https://jabogram.example.com:5443/upload/abcdef123/voice-1707580800.m4a</body>
|
||
<x xmlns="jabber:x:oob">
|
||
<url>https://jabogram.example.com:5443/upload/abcdef123/voice-1707580800.m4a</url>
|
||
<desc>voice-message</desc>
|
||
</x>
|
||
<voice-message xmlns="urn:jabogram:voice:0"
|
||
duration="5"
|
||
mime-type="audio/mp4"
|
||
size="48320"
|
||
waveform="0.1,0.3,0.7,0.9,0.5,0.2,0.4,0.8,0.6,0.3"/>
|
||
<request xmlns="urn:xmpp:receipts"/>
|
||
<markable xmlns="urn:xmpp:chat-markers:0"/>
|
||
</message>
|
||
```
|
||
|
||
Пояснение полей `<voice-message>`:
|
||
|
||
| Атрибут | Тип | Описание |
|
||
|---|---|---|
|
||
| `duration` | int | Длительность в секундах |
|
||
| `mime-type` | string | MIME-тип аудио (`audio/mp4`) |
|
||
| `size` | int | Размер файла в байтах |
|
||
| `waveform` | string | CSV нормализованных float-значений (0..1) для визуализации волны, ~10-50 точек |
|
||
|
||
Клиент-получатель:
|
||
1. Видит `<voice-message>` → рисует UI голосового сообщения с waveform
|
||
2. По нажатию Play → скачивает файл по URL из `<x xmlns="jabber:x:oob"><url>` → воспроизводит
|
||
3. Кеширует скачанный файл локально
|
||
|
||
Если клиент не поддерживает голосовые сообщения — показывает `<body>` как обычную ссылку (graceful degradation).
|
||
|
||
### 7.6 Сообщение в групповой чат (MUC)
|
||
|
||
```xml
|
||
<message
|
||
from="alice@jabogram.example.com/phone"
|
||
to="developers@conference.jabogram.example.com"
|
||
type="groupchat"
|
||
id="msg-uuid-003">
|
||
<body>Всем привет!</body>
|
||
</message>
|
||
```
|
||
|
||
---
|
||
|
||
## 8. Клиентское приложение — общая библиотека
|
||
|
||
### 8.1 XMPP-клиент (services/xmpp/XMPPClient.ts)
|
||
|
||
Обёртка над stanza.js. Основные обязанности:
|
||
|
||
```typescript
|
||
interface XMPPClientConfig {
|
||
domain: string; // jabogram.example.com
|
||
wsURL: string; // wss://jabogram.example.com/ws
|
||
resource: string; // "ios" | "android"
|
||
}
|
||
|
||
class XMPPClient {
|
||
// Жизненный цикл
|
||
connect(jid: string, password: string): Promise<void>;
|
||
disconnect(): Promise<void>;
|
||
reconnect(): Promise<void>;
|
||
|
||
// Stream Management (XEP-0198)
|
||
enableStreamManagement(): void;
|
||
resumeSession(): Promise<boolean>;
|
||
|
||
// Client State Indication (XEP-0352)
|
||
setActive(): void;
|
||
setInactive(): void;
|
||
|
||
// Низкоуровневый доступ
|
||
sendStanza(stanza: Element): void;
|
||
onStanza(handler: (stanza: Element) => void): void;
|
||
|
||
// События
|
||
on(event: 'connected' | 'disconnected' | 'error' | 'stanza', cb): void;
|
||
}
|
||
```
|
||
|
||
**Стратегия реконнекта:**
|
||
1. При потере соединения — попытка resume (XEP-0198)
|
||
2. Если resume не удался — полный reconnect
|
||
3. Экспоненциальный backoff: 1s → 2s → 4s → 8s → 16s → 30s (max)
|
||
4. При переходе приложения из background в foreground — немедленная попытка reconnect
|
||
|
||
### 8.2 Сервис сообщений (services/xmpp/MessageService.ts)
|
||
|
||
```typescript
|
||
class MessageService {
|
||
sendTextMessage(to: string, body: string, chatType: 'chat' | 'groupchat'): Message;
|
||
sendVoiceMessage(to: string, audioFile: AudioFile, chatType: 'chat' | 'groupchat'): Promise<Message>;
|
||
sendDeliveryReceipt(to: string, messageId: string): void;
|
||
sendDisplayedMarker(to: string, messageId: string): void;
|
||
sendChatState(to: string, state: ChatState): void;
|
||
|
||
onMessage(handler: (message: Message) => void): void;
|
||
onReceipt(handler: (messageId: string) => void): void;
|
||
onDisplayed(handler: (messageId: string) => void): void;
|
||
onChatState(handler: (jid: string, state: ChatState) => void): void;
|
||
}
|
||
|
||
type ChatState = 'active' | 'composing' | 'paused' | 'inactive' | 'gone';
|
||
```
|
||
|
||
### 8.3 Сервис загрузки файлов (services/xmpp/FileUploadService.ts)
|
||
|
||
```typescript
|
||
class FileUploadService {
|
||
/**
|
||
* 1. Запрашивает upload slot (XEP-0363)
|
||
* 2. Загружает файл по HTTP PUT
|
||
* 3. Возвращает GET URL для вставки в сообщение
|
||
*/
|
||
async uploadFile(file: {
|
||
uri: string;
|
||
name: string;
|
||
size: number;
|
||
mimeType: string;
|
||
}): Promise<{ getUrl: string }>;
|
||
}
|
||
```
|
||
|
||
### 8.4 Сервис аудио (services/audio/)
|
||
|
||
```typescript
|
||
// AudioRecorder.ts
|
||
class AudioRecorder {
|
||
/** Начать запись. Формат: AAC (.m4a) */
|
||
async start(): Promise<void>;
|
||
|
||
/** Остановить запись и вернуть файл */
|
||
async stop(): Promise<RecordedAudio>;
|
||
|
||
/** Отменить запись */
|
||
async cancel(): Promise<void>;
|
||
|
||
/** Текущая амплитуда (для анимации) */
|
||
onAmplitude(handler: (amplitude: number) => void): void;
|
||
|
||
/** Текущая длительность записи */
|
||
onDuration(handler: (seconds: number) => void): void;
|
||
}
|
||
|
||
interface RecordedAudio {
|
||
uri: string; // локальный путь к файлу
|
||
duration: number; // секунды
|
||
size: number; // байты
|
||
mimeType: 'audio/mp4';
|
||
waveform: number[]; // нормализованные амплитуды для визуализации
|
||
}
|
||
|
||
// AudioPlayer.ts
|
||
class AudioPlayer {
|
||
async play(uri: string): Promise<void>;
|
||
async pause(): void;
|
||
async stop(): void;
|
||
async seekTo(seconds: number): void;
|
||
|
||
onProgress(handler: (current: number, total: number) => void): void;
|
||
onComplete(handler: () => void): void;
|
||
}
|
||
```
|
||
|
||
**Формат аудио:** AAC в контейнере M4A.
|
||
- Нативная поддержка на iOS и Android без дополнительных кодеков
|
||
- Хорошее сжатие (голос 5 сек ≈ 30-50 КБ при 32 kbps)
|
||
- Параметры записи: mono, 32 kbps, sample rate 22050 Hz
|
||
|
||
### 8.5 Сервис MAM (services/xmpp/MAMService.ts)
|
||
|
||
```typescript
|
||
class MAMService {
|
||
/**
|
||
* Запрос архива сообщений.
|
||
* При первом подключении — загрузка последних N сообщений для каждого чата.
|
||
* При повторном подключении — загрузка сообщений с момента последнего известного.
|
||
*/
|
||
async fetchHistory(params: {
|
||
with?: string; // JID собеседника (опционально)
|
||
before?: string; // ID сообщения для пагинации «назад»
|
||
after?: string; // ID для пагинации «вперёд»
|
||
max?: number; // лимит (по умолчанию 50)
|
||
}): Promise<ArchivedMessage[]>;
|
||
|
||
/**
|
||
* Запрос сообщений, пришедших пока клиент был офлайн.
|
||
* Вызывается сразу после успешного connect.
|
||
*/
|
||
async syncSinceLastMessage(lastKnownId: string): Promise<ArchivedMessage[]>;
|
||
}
|
||
```
|
||
|
||
### 8.6 Сервис Roster (services/xmpp/RosterService.ts)
|
||
|
||
```typescript
|
||
class RosterService {
|
||
/** Получить текущий список контактов */
|
||
async getRoster(): Promise<Contact[]>;
|
||
|
||
/** Добавить контакт (отправить subscription request) */
|
||
async addContact(jid: string, name?: string): Promise<void>;
|
||
|
||
/** Удалить контакт */
|
||
async removeContact(jid: string): Promise<void>;
|
||
|
||
/** Принять входящий запрос на подписку */
|
||
async acceptSubscription(jid: string): Promise<void>;
|
||
|
||
/** Отклонить входящий запрос */
|
||
async denySubscription(jid: string): Promise<void>;
|
||
|
||
/** Обновление ростера (push от сервера) */
|
||
onRosterUpdate(handler: (contacts: Contact[]) => void): void;
|
||
|
||
/** Входящий subscription request */
|
||
onSubscriptionRequest(handler: (jid: string) => void): void;
|
||
}
|
||
```
|
||
|
||
### 8.7 Локальная база данных (services/storage/)
|
||
|
||
SQLite через op-sqlite. Три основные таблицы:
|
||
|
||
```sql
|
||
-- Контакты
|
||
CREATE TABLE contacts (
|
||
jid TEXT PRIMARY KEY,
|
||
name TEXT,
|
||
avatar_url TEXT,
|
||
subscription TEXT DEFAULT 'none', -- none | to | from | both
|
||
updated_at INTEGER NOT NULL
|
||
);
|
||
|
||
-- Чаты (список бесед)
|
||
CREATE TABLE chats (
|
||
id TEXT PRIMARY KEY, -- bare JID собеседника или room JID
|
||
type TEXT NOT NULL, -- 'chat' | 'groupchat'
|
||
title TEXT, -- имя контакта или название группы
|
||
last_message_id TEXT,
|
||
last_message_text TEXT,
|
||
last_message_at INTEGER,
|
||
unread_count INTEGER DEFAULT 0,
|
||
pinned INTEGER DEFAULT 0,
|
||
updated_at INTEGER NOT NULL
|
||
);
|
||
|
||
-- Сообщения
|
||
CREATE TABLE messages (
|
||
id TEXT PRIMARY KEY, -- XMPP stanza id
|
||
chat_id TEXT NOT NULL, -- ссылка на chats.id
|
||
from_jid TEXT NOT NULL,
|
||
to_jid TEXT NOT NULL,
|
||
type TEXT NOT NULL, -- 'text' | 'voice'
|
||
body TEXT,
|
||
-- Голосовое сообщение
|
||
voice_url TEXT,
|
||
voice_duration INTEGER,
|
||
voice_mime_type TEXT,
|
||
voice_size INTEGER,
|
||
voice_waveform TEXT, -- JSON array
|
||
voice_local_uri TEXT, -- кешированный локальный путь
|
||
-- Статус
|
||
status TEXT DEFAULT 'sent', -- 'sending' | 'sent' | 'delivered' | 'displayed' | 'error'
|
||
-- Timestamps
|
||
created_at INTEGER NOT NULL,
|
||
server_timestamp INTEGER, -- timestamp из MAM
|
||
FOREIGN KEY (chat_id) REFERENCES chats(id)
|
||
);
|
||
|
||
CREATE INDEX idx_messages_chat_id ON messages(chat_id, created_at);
|
||
CREATE INDEX idx_messages_status ON messages(status);
|
||
```
|
||
|
||
### 8.8 Zustand stores
|
||
|
||
#### useAuthStore
|
||
|
||
```typescript
|
||
interface AuthState {
|
||
jid: string | null;
|
||
password: string | null; // хранится в Keychain/Keystore, не в памяти
|
||
isAuthenticated: boolean;
|
||
isConnecting: boolean;
|
||
connectionError: string | null;
|
||
|
||
login(jid: string, password: string): Promise<void>;
|
||
register(username: string, password: string): Promise<void>;
|
||
logout(): Promise<void>;
|
||
}
|
||
```
|
||
|
||
#### useChatsStore
|
||
|
||
```typescript
|
||
interface ChatsState {
|
||
chats: Chat[];
|
||
currentChatId: string | null;
|
||
messages: Record<string, Message[]>; // chatId → messages
|
||
|
||
loadChats(): Promise<void>;
|
||
loadMessages(chatId: string, before?: string): Promise<void>;
|
||
addMessage(message: Message): void;
|
||
updateMessageStatus(messageId: string, status: MessageStatus): void;
|
||
incrementUnread(chatId: string): void;
|
||
resetUnread(chatId: string): void;
|
||
setCurrentChat(chatId: string | null): void;
|
||
}
|
||
```
|
||
|
||
#### useContactsStore
|
||
|
||
```typescript
|
||
interface ContactsState {
|
||
contacts: Contact[];
|
||
pendingRequests: string[]; // входящие запросы на добавление
|
||
|
||
loadContacts(): Promise<void>;
|
||
addContact(jid: string, name?: string): Promise<void>;
|
||
removeContact(jid: string): Promise<void>;
|
||
acceptRequest(jid: string): Promise<void>;
|
||
denyRequest(jid: string): Promise<void>;
|
||
}
|
||
```
|
||
|
||
#### usePresenceStore
|
||
|
||
```typescript
|
||
interface PresenceState {
|
||
presences: Record<string, PresenceInfo>; // bare JID → presence
|
||
|
||
setPresence(jid: string, info: PresenceInfo): void;
|
||
getPresence(jid: string): PresenceInfo;
|
||
}
|
||
|
||
interface PresenceInfo {
|
||
status: 'online' | 'away' | 'dnd' | 'offline';
|
||
statusText?: string;
|
||
lastSeen?: number; // timestamp
|
||
}
|
||
```
|
||
|
||
### 8.9 Экраны
|
||
|
||
#### LoginScreen / RegisterScreen
|
||
- Поля: JID (или username при регистрации), пароль
|
||
- Адрес сервера — конфигурируемый (по умолчанию `jabogram.example.com`)
|
||
- Валидация ввода
|
||
- Индикация состояния подключения
|
||
- Сохранение credentials в Keychain (iOS) / Keystore (Android)
|
||
|
||
#### ChatListScreen
|
||
- Список бесед, отсортированный по дате последнего сообщения
|
||
- Каждый элемент: аватар, имя, превью последнего сообщения, время, счётчик непрочитанных
|
||
- Для голосовых: превью = "🎤 Голосовое сообщение"
|
||
- Индикатор онлайн-статуса контакта
|
||
- Pull-to-refresh для синхронизации
|
||
- FAB-кнопка для создания нового чата
|
||
|
||
#### ChatScreen
|
||
- Список сообщений (FlatList, inverted)
|
||
- Пагинация: подгрузка старых сообщений при скролле вверх (MAM)
|
||
- Текстовые сообщения — пузыри (свои справа, чужие слева)
|
||
- Голосовые сообщения — специальный виджет с кнопкой Play, waveform, длительностью
|
||
- Индикатор «печатает…» над полем ввода
|
||
- Статусы сообщений (✓ отправлено, ✓✓ доставлено, синие ✓✓ прочитано)
|
||
- Поле ввода текста + кнопка отправки
|
||
- Кнопка микрофона: нажать и удерживать → запись; отпустить → отправка; свайп влево → отмена
|
||
- При записи: оверлей с таймером, визуализацией амплитуды, подсказкой «свайп для отмены»
|
||
|
||
#### ContactsScreen
|
||
- Список контактов с онлайн-статусами
|
||
- Секция «Входящие запросы» (если есть) — принять / отклонить
|
||
- Поиск по имени / JID
|
||
- Кнопка добавления нового контакта
|
||
|
||
#### AddContactScreen
|
||
- Поле ввода JID
|
||
- Отправка subscription request
|
||
|
||
#### CreateGroupScreen
|
||
- Название группы
|
||
- Выбор участников из контактов
|
||
- Создание MUC-комнаты
|
||
|
||
#### ProfileScreen
|
||
- Аватар (выбор из галереи, обрезка)
|
||
- Никнейм
|
||
- Статус-текст
|
||
- Кнопка «Выйти»
|
||
|
||
### 8.10 Навигация
|
||
|
||
```
|
||
AppNavigator (Stack)
|
||
├── AuthStack (когда !isAuthenticated)
|
||
│ ├── LoginScreen
|
||
│ └── RegisterScreen
|
||
│
|
||
└── MainStack (когда isAuthenticated)
|
||
├── MainTabs (Bottom Tab)
|
||
│ ├── ChatsTab
|
||
│ │ └── ChatListScreen
|
||
│ ├── ContactsTab
|
||
│ │ └── ContactsScreen
|
||
│ └── ProfileTab
|
||
│ └── ProfileScreen
|
||
│
|
||
├── ChatScreen (modal push)
|
||
├── AddContactScreen (modal push)
|
||
├── CreateGroupScreen (modal push)
|
||
└── GroupInfoScreen (modal push)
|
||
```
|
||
|
||
### 8.11 Компоненты: детальное описание ключевых
|
||
|
||
#### ChatInput
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────┐
|
||
│ ┌──────────────────────────────────┐ ┌──────┐ │
|
||
│ │ Введите сообщение... │ │ 🎤 │ │
|
||
│ └──────────────────────────────────┘ └──────┘ │
|
||
└──────────────────────────────────────────────────┘
|
||
```
|
||
|
||
Логика переключения:
|
||
- Если текст пуст → показывает кнопку 🎤 (микрофон)
|
||
- Если текст не пуст → кнопка заменяется на ➤ (отправить)
|
||
- Отправка chat state `composing` при наборе, `paused` через 3 сек неактивности
|
||
|
||
#### VoiceRecordOverlay
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────┐
|
||
│ │
|
||
│ ◉ 0:05 ← свайп для отмены │
|
||
│ ▂▃▅▇▅▃▂▃▅▇▅▃ (визуализация амплитуды) │
|
||
│ │
|
||
└──────────────────────────────────────────────────┘
|
||
```
|
||
|
||
Появляется при long press на 🎤. Отпускание пальца → отправка. Свайп влево → отмена.
|
||
|
||
#### VoiceMessageBubble
|
||
|
||
```
|
||
┌───────────────────────────────┐
|
||
│ ▶ ▂▃▅▇▅▃▂▃▅▇▅▃▂ 0:05 │
|
||
│ 12:34 ✓✓ │
|
||
└───────────────────────────────┘
|
||
```
|
||
|
||
- Кнопка play/pause
|
||
- Waveform (из данных `voice_waveform`)
|
||
- Длительность / текущая позиция при проигрывании
|
||
- Прогресс-бар поверх waveform
|
||
- Время отправки и статус доставки
|
||
|
||
### 8.12 Хуки
|
||
|
||
#### useXMPP
|
||
|
||
```typescript
|
||
function useXMPP() {
|
||
// Автоматическое подключение при наличии credentials
|
||
// Подписка на события (сообщения, presence, roster updates)
|
||
// Реконнект при восстановлении сети
|
||
// CSI: active при foreground, inactive при background
|
||
}
|
||
```
|
||
|
||
#### useAudioRecorder
|
||
|
||
```typescript
|
||
function useAudioRecorder() {
|
||
return {
|
||
isRecording: boolean;
|
||
duration: number;
|
||
amplitude: number;
|
||
start: () => Promise<void>;
|
||
stop: () => Promise<RecordedAudio>;
|
||
cancel: () => Promise<void>;
|
||
};
|
||
}
|
||
```
|
||
|
||
#### useAudioPlayer
|
||
|
||
```typescript
|
||
function useAudioPlayer(messageId: string) {
|
||
return {
|
||
isPlaying: boolean;
|
||
progress: number; // 0..1
|
||
currentTime: number;
|
||
duration: number;
|
||
play: (uri: string) => Promise<void>;
|
||
pause: () => void;
|
||
seekTo: (seconds: number) => void;
|
||
};
|
||
}
|
||
```
|
||
|
||
#### useMessages
|
||
|
||
```typescript
|
||
function useMessages(chatId: string) {
|
||
return {
|
||
messages: Message[];
|
||
isLoading: boolean;
|
||
hasMore: boolean;
|
||
loadMore: () => Promise<void>; // подгрузка старых (MAM)
|
||
sendText: (body: string) => void;
|
||
sendVoice: (audio: RecordedAudio) => Promise<void>;
|
||
};
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Клиентское приложение — iOS
|
||
|
||
### 9.1 Точка входа
|
||
|
||
```javascript
|
||
// react-native-ios/index.js
|
||
import { AppRegistry } from 'react-native';
|
||
import { App } from 'react-native-lib';
|
||
import { name as appName } from './app.json';
|
||
|
||
AppRegistry.registerComponent(appName, () => App);
|
||
```
|
||
|
||
### 9.2 Info.plist — требуемые разрешения
|
||
|
||
```xml
|
||
<key>NSMicrophoneUsageDescription</key>
|
||
<string>Jabogram needs microphone access to record voice messages</string>
|
||
|
||
<key>NSPhotoLibraryUsageDescription</key>
|
||
<string>Jabogram needs photo library access to set your avatar</string>
|
||
|
||
<key>NSCameraUsageDescription</key>
|
||
<string>Jabogram needs camera access to take a photo for your avatar</string>
|
||
|
||
<key>UIBackgroundModes</key>
|
||
<array>
|
||
<string>remote-notification</string>
|
||
<string>fetch</string>
|
||
</array>
|
||
```
|
||
|
||
### 9.3 Push-уведомления (APNs)
|
||
|
||
1. Включить Push Notifications capability в Xcode
|
||
2. Настроить `Jabogram.entitlements`:
|
||
```xml
|
||
<key>aps-environment</key>
|
||
<string>production</string>
|
||
```
|
||
3. Зарегистрировать APNs device token через Firebase Cloud Messaging:
|
||
- `@react-native-firebase/messaging` получает FCM token
|
||
- FCM-токен регистрируется на XMPP-сервере через XEP-0357
|
||
4. При получении push → показать локальное уведомление, если приложение в фоне
|
||
|
||
### 9.4 Хранение credentials
|
||
|
||
- **iOS Keychain** через `react-native-keychain`
|
||
- Хранить JID и пароль
|
||
- Автоматический вход при запуске, если есть сохранённые credentials
|
||
|
||
### 9.5 Аудио-сессия
|
||
|
||
Настройка AVAudioSession при записи:
|
||
- Category: `playAndRecord`
|
||
- Mode: `default`
|
||
- При воспроизведении: через динамик (не ушной, если телефон не у уха)
|
||
|
||
---
|
||
|
||
## 10. Клиентское приложение — Android
|
||
|
||
### 10.1 Точка входа
|
||
|
||
Аналогично iOS:
|
||
|
||
```javascript
|
||
// react-native-android/index.js
|
||
import { AppRegistry } from 'react-native';
|
||
import { App } from 'react-native-lib';
|
||
import { name as appName } from './app.json';
|
||
|
||
AppRegistry.registerComponent(appName, () => App);
|
||
```
|
||
|
||
### 10.2 AndroidManifest.xml — разрешения
|
||
|
||
```xml
|
||
<uses-permission android:name="android.permission.INTERNET" />
|
||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||
<uses-permission android:name="android.permission.VIBRATE" />
|
||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||
```
|
||
|
||
### 10.3 Push-уведомления (FCM)
|
||
|
||
1. Файл `google-services.json` в `android/app/` (не в git, добавить в `.gitignore`)
|
||
2. `@react-native-firebase/messaging` для получения FCM-токена
|
||
3. Регистрация токена на XMPP-сервере (XEP-0357)
|
||
4. Notification channels:
|
||
```kotlin
|
||
// В MainActivity или Application
|
||
val channel = NotificationChannel(
|
||
"messages",
|
||
"Messages",
|
||
NotificationManager.IMPORTANCE_HIGH
|
||
)
|
||
```
|
||
|
||
### 10.4 Хранение credentials
|
||
|
||
- **Android Keystore** через `react-native-keychain`
|
||
- Тот же API, что и на iOS — библиотека абстрагирует платформу
|
||
|
||
### 10.5 Специфика аудиозаписи
|
||
|
||
- Runtime permission `RECORD_AUDIO` — запрашивать перед первой записью
|
||
- Android 13+: дополнительно `POST_NOTIFICATIONS`
|
||
- Формат записи: AAC (.m4a) — поддерживается нативно через MediaRecorder
|
||
|
||
---
|
||
|
||
## 11. Модели данных
|
||
|
||
### 11.1 TypeScript-типы (react-native-lib/src/types/index.ts)
|
||
|
||
```typescript
|
||
/** Статус доставки сообщения */
|
||
export type MessageStatus = 'sending' | 'sent' | 'delivered' | 'displayed' | 'error';
|
||
|
||
/** Тип сообщения */
|
||
export type MessageType = 'text' | 'voice';
|
||
|
||
/** Тип чата */
|
||
export type ChatType = 'chat' | 'groupchat';
|
||
|
||
/** Статус присутствия */
|
||
export type PresenceStatus = 'online' | 'away' | 'dnd' | 'offline';
|
||
|
||
/** Статус подписки ростера */
|
||
export type SubscriptionStatus = 'none' | 'to' | 'from' | 'both';
|
||
|
||
/** Сообщение */
|
||
export interface Message {
|
||
id: string;
|
||
chatId: string;
|
||
fromJid: string;
|
||
toJid: string;
|
||
type: MessageType;
|
||
body?: string;
|
||
|
||
// Голосовое сообщение
|
||
voice?: {
|
||
url: string;
|
||
duration: number; // секунды
|
||
mimeType: string;
|
||
size: number; // байты
|
||
waveform: number[]; // нормализованные значения 0..1
|
||
localUri?: string; // путь к кешированному файлу
|
||
};
|
||
|
||
status: MessageStatus;
|
||
createdAt: number; // unix timestamp ms
|
||
serverTimestamp?: number; // timestamp из MAM
|
||
}
|
||
|
||
/** Чат / беседа */
|
||
export interface Chat {
|
||
id: string; // bare JID или room JID
|
||
type: ChatType;
|
||
title: string;
|
||
avatarUrl?: string;
|
||
lastMessage?: {
|
||
id: string;
|
||
text: string;
|
||
timestamp: number;
|
||
};
|
||
unreadCount: number;
|
||
pinned: boolean;
|
||
}
|
||
|
||
/** Контакт */
|
||
export interface Contact {
|
||
jid: string; // bare JID
|
||
name?: string;
|
||
avatarUrl?: string;
|
||
subscription: SubscriptionStatus;
|
||
}
|
||
|
||
/** Информация о присутствии */
|
||
export interface PresenceInfo {
|
||
status: PresenceStatus;
|
||
statusText?: string;
|
||
lastSeen?: number; // unix timestamp
|
||
}
|
||
|
||
/** Записанное аудио */
|
||
export interface RecordedAudio {
|
||
uri: string;
|
||
duration: number;
|
||
size: number;
|
||
mimeType: 'audio/mp4';
|
||
waveform: number[];
|
||
}
|
||
|
||
/** Данные аутентификации */
|
||
export interface AuthCredentials {
|
||
jid: string;
|
||
password: string;
|
||
server: string;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 12. Сценарии взаимодействия
|
||
|
||
### 12.1 Регистрация нового пользователя
|
||
|
||
```
|
||
Клиент Сервер (ejabberd)
|
||
│ │
|
||
│──── WSS connect ─────────────────────►│
|
||
│◄─── stream features (SASL, IBR) ──────│
|
||
│ │
|
||
│──── IQ: register (XEP-0077) ─────────►│
|
||
│ username="alice", password="***" │
|
||
│◄─── IQ result: success ───────────────│
|
||
│ │
|
||
│──── SASL auth (SCRAM-SHA-256) ───────►│
|
||
│◄─── success ──────────────────────────│
|
||
│ │
|
||
│──── bind resource "ios" ─────────────►│
|
||
│◄─── bound: alice@server/ios ──────────│
|
||
│ │
|
||
│──── enable stream mgmt (XEP-0198) ──►│
|
||
│◄─── enabled ──────────────────────────│
|
||
│ │
|
||
│──── enable carbons (XEP-0280) ──────►│
|
||
│◄─── result ───────────────────────────│
|
||
│ │
|
||
│──── initial presence ────────────────►│
|
||
│ │
|
||
```
|
||
|
||
### 12.2 Вход существующего пользователя
|
||
|
||
```
|
||
Клиент Сервер
|
||
│ │
|
||
│──── WSS connect ─────────────────────►│
|
||
│◄─── stream features ─────────────────│
|
||
│──── SASL auth ───────────────────────►│
|
||
│◄─── success ──────────────────────────│
|
||
│──── bind ────────────────────────────►│
|
||
│◄─── bound ────────────────────────────│
|
||
│──── stream mgmt ─────────────────────►│
|
||
│──── enable carbons ──────────────────►│
|
||
│──── initial presence ────────────────►│
|
||
│ │
|
||
│──── MAM: sync since last known ──────►│ // XEP-0313
|
||
│◄─── archived messages ───────────────│
|
||
│ │
|
||
│──── get roster ──────────────────────►│
|
||
│◄─── roster items ────────────────────│
|
||
│◄─── presence updates ───────────────│
|
||
│ │
|
||
```
|
||
|
||
### 12.3 Отправка текстового сообщения
|
||
|
||
```
|
||
Alice (клиент) Сервер Bob (клиент)
|
||
│ │ │
|
||
│ 1. UI: ввод текста │ │
|
||
│ 2. chatstate=composing │ │
|
||
│────────────────────────►│─────────────────────►│ 3. «Alice печатает…»
|
||
│ │ │
|
||
│ 4. Нажатие «отправить» │ │
|
||
│ 5. Сохранить в SQLite │ │
|
||
│ status='sending' │ │
|
||
│ │ │
|
||
│ 6. <message> + receipt │ │
|
||
│────────────────────────►│ │
|
||
│ │ 7. route message │
|
||
│ │─────────────────────►│ 8. Получено
|
||
│ │ │ 9. Сохранить в SQLite
|
||
│ │ │ 10. Показать в UI
|
||
│ │ │
|
||
│ │◄─────────────────────│ 11. <received> (XEP-0184)
|
||
│◄────────────────────────│ │
|
||
│ 12. status='delivered' │ │
|
||
│ │ │
|
||
│ │◄─────────────────────│ 13. <displayed> (XEP-0333)
|
||
│◄────────────────────────│ │ (при открытии чата)
|
||
│ 14. status='displayed' │ │
|
||
│ │ │
|
||
```
|
||
|
||
### 12.4 Отправка голосового сообщения
|
||
|
||
```
|
||
Alice (клиент) Сервер Bob (клиент)
|
||
│ │ │
|
||
│ 1. Long press 🎤 │ │
|
||
│ 2. Начало записи │ │
|
||
│ ...запись... │ │
|
||
│ 3. Отпускание пальца │ │
|
||
│ 4. stop() → RecordedAudio │
|
||
│ 5. Вычисление waveform │ │
|
||
│ │ │
|
||
│ 6. IQ: request upload │ │
|
||
│ slot (XEP-0363) │ │
|
||
│────────────────────────►│ │
|
||
│◄────────────────────────│ 7. PUT url + GET url│
|
||
│ │ │
|
||
│ 8. HTTP PUT (файл) │ │
|
||
│────────────────────────►│ 9. Сохранить файл │
|
||
│◄──── 201 Created ──────│ │
|
||
│ │ │
|
||
│ 10. <message> с OOB │ │
|
||
│ + <voice-message> │ │
|
||
│ + receipt request │ │
|
||
│────────────────────────►│ │
|
||
│ │─────────────────────►│ 11. Получить сообщение
|
||
│ │ │ 12. Распознать voice-message
|
||
│ │ │ 13. Показать VoiceMessageBubble
|
||
│ │ │ 14. По нажатию Play:
|
||
│ │ │ GET файл → воспроизвести
|
||
│ │ │
|
||
```
|
||
|
||
### 12.5 Получение офлайн-сообщения (push)
|
||
|
||
```
|
||
Bob (офлайн) Сервер Push Gateway Телефон Bob
|
||
│ │ │ │
|
||
│ │◄── message from Alice │ │
|
||
│ │ │ │
|
||
│ │ mod_push: │ │
|
||
│ │ Bob offline → │ │
|
||
│ │ trigger push │ │
|
||
│ │─────────────────────►│ │
|
||
│ │ │ APNs / FCM │
|
||
│ │ │───────────────────►│
|
||
│ │ │ │ notification
|
||
│ │ │ │
|
||
│ Bob открывает │ │ │
|
||
│ приложение │ │ │
|
||
│────── connect ─────────►│ │ │
|
||
│────── MAM sync ────────►│ │ │
|
||
│◄── пропущенные msg ────│ │ │
|
||
│ │ │ │
|
||
```
|
||
|
||
### 12.6 Создание группового чата
|
||
|
||
```
|
||
Alice (клиент) Сервер (MUC)
|
||
│ │
|
||
│ 1. IQ: create room │
|
||
│ room@conference.jabogram.example.com
|
||
│────────────────────────────────────── ►│
|
||
│◄── room created ─────────────────────│
|
||
│ │
|
||
│ 2. IQ: configure room │
|
||
│ (название, persistent, etc.) │
|
||
│──────────────────────────────────────►│
|
||
│◄── configured ───────────────────────│
|
||
│ │
|
||
│ 3. Invite участников │
|
||
│──────────────────────────────────────►│
|
||
│ ──────────────│──► Bob gets invite
|
||
│ ──────────────│──► Carol gets invite
|
||
│ │
|
||
```
|
||
|
||
### 12.7 Синхронизация истории при подключении
|
||
|
||
```
|
||
Клиент Сервер
|
||
│ │
|
||
│ 1. Подключение + auth │
|
||
│──────────────────────────────────────►│
|
||
│ │
|
||
│ 2. Прочитать lastKnownMessageId │
|
||
│ из локального SQLite │
|
||
│ │
|
||
│ 3. MAM query: after=lastKnownId │
|
||
│──────────────────────────────────────►│
|
||
│◄── archived messages (порциями) ─────│
|
||
│ │
|
||
│ 4. Для каждого нового сообщения: │
|
||
│ - сохранить в SQLite │
|
||
│ - обновить chat list │
|
||
│ - обновить unread counter │
|
||
│ │
|
||
│ 5. Если lastKnownId отсутствует │
|
||
│ (первый вход): загрузить │
|
||
│ последние 50 сообщений │
|
||
│ │
|
||
```
|
||
|
||
---
|
||
|
||
## 13. Безопасность
|
||
|
||
### 13.1 Транспортный уровень
|
||
|
||
| Требование | Реализация |
|
||
|---|---|
|
||
| Шифрование соединения | TLS 1.2+ обязателен. STARTTLS для XMPP C2S (порт 5222), WSS для WebSocket (порт 5443) |
|
||
| Сертификаты | Let's Encrypt или корпоративный CA |
|
||
| Certificate pinning | Опционально для production (через react-native-ssl-pinning) |
|
||
|
||
### 13.2 Аутентификация
|
||
|
||
| Требование | Реализация |
|
||
|---|---|
|
||
| SASL-механизм | SCRAM-SHA-256 (предпочтительно), SCRAM-SHA-1 (fallback). PLAIN запрещён |
|
||
| Хранение пароля на сервере | scram-формат (hash + salt + iterations). Никогда plain |
|
||
| Хранение пароля на клиенте | iOS Keychain / Android Keystore через react-native-keychain |
|
||
| Регистрация | XEP-0077 с rate limiting (ip_access в ejabberd) |
|
||
|
||
### 13.3 Загрузка файлов
|
||
|
||
| Требование | Реализация |
|
||
|---|---|
|
||
| Авторизация upload | Только аутентифицированные пользователи получают upload slot |
|
||
| Ограничение размера | max_size: 10 MB |
|
||
| Допустимые типы | Только audio/mp4, audio/ogg (проверка на клиенте и Content-Type при upload) |
|
||
| URL upload-слотов | Случайные, непредсказуемые пути |
|
||
|
||
### 13.4 Защита от злоупотреблений
|
||
|
||
- Rate limiting на уровне ejabberd (shaper)
|
||
- Rate limiting на уровне nginx
|
||
- mod_blocking для блокировки пользователей
|
||
- Ограничение частоты регистраций с одного IP
|
||
|
||
---
|
||
|
||
## 14. Развёртывание
|
||
|
||
### 14.1 Серверная инфраструктура
|
||
|
||
**Минимальные требования:**
|
||
- VPS: 2 vCPU, 4 GB RAM, 40 GB SSD
|
||
- Ubuntu 22.04+ или Debian 12+
|
||
- Docker Engine 24+
|
||
- Домен с DNS A-записью
|
||
|
||
**Порядок развёртывания:**
|
||
|
||
1. Подготовить сервер:
|
||
```bash
|
||
apt update && apt install -y docker.io docker-compose-plugin
|
||
```
|
||
|
||
2. Получить TLS-сертификат:
|
||
```bash
|
||
apt install -y certbot
|
||
certbot certonly --standalone -d jabogram.example.com
|
||
cp /etc/letsencrypt/live/jabogram.example.com/fullchain.pem server/ejabberd/certs/
|
||
cp /etc/letsencrypt/live/jabogram.example.com/privkey.pem server/ejabberd/certs/
|
||
```
|
||
|
||
3. Создать `.env`:
|
||
```
|
||
POSTGRES_PASSWORD=<secure random password>
|
||
```
|
||
|
||
4. Запустить:
|
||
```bash
|
||
cd server && docker compose up -d
|
||
```
|
||
|
||
5. Проверить:
|
||
```bash
|
||
docker compose exec ejabberd ejabberdctl status
|
||
```
|
||
|
||
### 14.2 Сборка клиентских приложений
|
||
|
||
**iOS:**
|
||
```bash
|
||
cd react-native-ios
|
||
npm install
|
||
cd ios && pod install && cd ..
|
||
npx react-native run-ios --mode Release
|
||
# или: xcodebuild -workspace ios/Jabogram.xcworkspace -scheme Jabogram -configuration Release
|
||
```
|
||
|
||
**Android:**
|
||
```bash
|
||
cd react-native-android
|
||
npm install
|
||
cd android && ./gradlew assembleRelease
|
||
# APK: android/app/build/outputs/apk/release/app-release.apk
|
||
```
|
||
|
||
### 14.3 Переменные окружения / конфигурация клиента
|
||
|
||
Файл `react-native-lib/src/config.ts`:
|
||
|
||
```typescript
|
||
export const config = {
|
||
/** XMPP домен */
|
||
domain: 'jabogram.example.com',
|
||
|
||
/** WebSocket URL */
|
||
wsUrl: 'wss://jabogram.example.com/ws',
|
||
|
||
/** Максимальная длительность голосового сообщения (секунды) */
|
||
maxVoiceDuration: 300,
|
||
|
||
/** Максимальный размер файла (байты) */
|
||
maxFileSize: 10 * 1024 * 1024,
|
||
|
||
/** MAM: размер страницы при загрузке истории */
|
||
mamPageSize: 50,
|
||
|
||
/** Аудио: битрейт AAC */
|
||
audioBitrate: 32000,
|
||
|
||
/** Аудио: sample rate */
|
||
audioSampleRate: 22050,
|
||
|
||
/** Количество точек waveform */
|
||
waveformPoints: 40,
|
||
};
|
||
```
|
||
|
||
---
|
||
|
||
## 15. Тестирование
|
||
|
||
### 15.1 Unit-тесты (react-native-lib)
|
||
|
||
| Модуль | Что тестировать |
|
||
|---|---|
|
||
| `jid.ts` | Парсинг/форматирование JID |
|
||
| `MessageService` | Формирование XML stanza (text, voice, receipts, markers) |
|
||
| `FileUploadService` | Парсинг upload slot response |
|
||
| `MAMService` | Парсинг archived messages |
|
||
| `AudioRecorder` | Вычисление waveform из амплитуд |
|
||
| Zustand stores | Все мутации состояния |
|
||
| `MessageRepository` | CRUD операции с SQLite |
|
||
| Компоненты | Snapshot-тесты ключевых компонентов |
|
||
|
||
Инструменты: **Jest** + **React Native Testing Library**
|
||
|
||
### 15.2 Интеграционные тесты
|
||
|
||
| Сценарий | Описание |
|
||
|---|---|
|
||
| Регистрация → вход | Клиент регистрирует аккаунт, отключается, подключается заново |
|
||
| Обмен текстом | Два клиента обмениваются сообщениями, проверка доставки |
|
||
| Голосовое сообщение | Upload → отправка → скачивание → проверка файла |
|
||
| Офлайн-доставка | Отправка сообщения офлайн-пользователю → подключение → MAM sync |
|
||
| Групповой чат | Создание, приглашение, обмен сообщениями |
|
||
|
||
Инструменты: **Detox** (E2E для React Native) + тестовый ejabberd в Docker
|
||
|
||
### 15.3 Серверные тесты
|
||
|
||
- Проверка конфигурации ejabberd при старте
|
||
- Healthcheck PostgreSQL
|
||
- Проверка доступности WebSocket endpoint
|
||
- Проверка работы HTTP File Upload
|
||
|
||
---
|
||
|
||
## 16. Ограничения и допущения v1
|
||
|
||
1. **Один сервер** — без федерации (S2S отключён), все пользователи на одном домене
|
||
2. **Нет E2E-шифрования** — только transport encryption (TLS). OMEMO — в будущих версиях
|
||
3. **Голос только как файлы** — нет VoIP / WebRTC
|
||
4. **Нет произвольных файлов** — только голосовые сообщения (audio/mp4)
|
||
5. **Нет web-клиента** — только iOS и Android
|
||
6. **Нет редактирования/удаления сообщений** — в будущих версиях (XEP-0308, XEP-0424)
|
||
7. **Аватар** — только из галереи, без камеры в v1
|
||
8. **Поиск пользователей** — только по точному JID, нет глобального каталога
|
||
9. **Push-уведомления** — через Firebase (FCM для обеих платформ, APNs через FCM)
|
||
10. **Максимум 200 участников** в групповом чате
|