jabogram/doc/DESIGN.md
2026-02-27 00:32:28 +03:00

1850 lines
72 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 участников** в групповом чате