72 KiB
Jabogram: Дизайн-документ
Версия: 1.0 Дата: 2026-02-10
Содержание
- Введение
- Глоссарий
- Архитектура системы
- Структура проекта
- Сервер
- Протокол XMPP: используемые XEP
- Формат сообщений
- Клиентское приложение — общая библиотека
- Клиентское приложение — iOS
- Клиентское приложение — Android
- Модели данных
- Сценарии взаимодействия
- Безопасность
- Развёртывание
- Тестирование
- Ограничения и допущения 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)
{
"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:
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
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 — основная конфигурация
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
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:
#!/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:
#!/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 | In-Band Registration | Регистрация нового аккаунта прямо через XMPP-соединение |
| XEP-0030 | Service Discovery | Обнаружение возможностей сервера (поддержка upload, MUC и т.д.) |
| XEP-0045 | Multi-User Chat | Групповые чаты |
| XEP-0066 | Out of Band Data | Передача URL голосового файла в теле сообщения |
| XEP-0084 | User Avatar | Аватары пользователей |
| XEP-0085 | Chat State Notifications | Индикатор «печатает…» |
| XEP-0184 | Message Delivery Receipts | Подтверждение доставки (✓✓) |
| XEP-0191 | Blocking Command | Блокировка пользователей |
| XEP-0198 | Stream Management | Надёжная доставка stanza, возобновление сессии после обрыва |
| XEP-0280 | Message Carbons | Синхронизация сообщений между устройствами одного пользователя |
| XEP-0313 | Message Archive Management | Серверный архив; синхронизация истории при подключении |
| XEP-0333 | Chat Markers | Статус «прочитано» (displayed marker) |
| XEP-0352 | Client State Indication | Клиент сообщает серверу, что ушёл в фон (экономия трафика) |
| XEP-0357 | Push Notifications | Серверные push-уведомления при офлайн-сообщениях |
| XEP-0363 | HTTP File Upload | Загрузка голосовых файлов на сервер |
7. Формат сообщений
7.1 Текстовое сообщение (1-on-1)
<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)
<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)
<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)
<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):
Запрос:
<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>
Ответ сервера:
<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-сообщения со ссылкой:
<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 точек |
Клиент-получатель:
- Видит
<voice-message>→ рисует UI голосового сообщения с waveform - По нажатию Play → скачивает файл по URL из
<x xmlns="jabber:x:oob"><url>→ воспроизводит - Кеширует скачанный файл локально
Если клиент не поддерживает голосовые сообщения — показывает <body> как обычную ссылку (graceful degradation).
7.6 Сообщение в групповой чат (MUC)
<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. Основные обязанности:
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;
}
Стратегия реконнекта:
- При потере соединения — попытка resume (XEP-0198)
- Если resume не удался — полный reconnect
- Экспоненциальный backoff: 1s → 2s → 4s → 8s → 16s → 30s (max)
- При переходе приложения из background в foreground — немедленная попытка reconnect
8.2 Сервис сообщений (services/xmpp/MessageService.ts)
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)
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/)
// 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)
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)
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. Три основные таблицы:
-- Контакты
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
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
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
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
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
function useXMPP() {
// Автоматическое подключение при наличии credentials
// Подписка на события (сообщения, presence, roster updates)
// Реконнект при восстановлении сети
// CSI: active при foreground, inactive при background
}
useAudioRecorder
function useAudioRecorder() {
return {
isRecording: boolean;
duration: number;
amplitude: number;
start: () => Promise<void>;
stop: () => Promise<RecordedAudio>;
cancel: () => Promise<void>;
};
}
useAudioPlayer
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
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 Точка входа
// 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 — требуемые разрешения
<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)
- Включить Push Notifications capability в Xcode
- Настроить
Jabogram.entitlements:<key>aps-environment</key> <string>production</string> - Зарегистрировать APNs device token через Firebase Cloud Messaging:
@react-native-firebase/messagingполучает FCM token- FCM-токен регистрируется на XMPP-сервере через XEP-0357
- При получении push → показать локальное уведомление, если приложение в фоне
9.4 Хранение credentials
- iOS Keychain через
react-native-keychain - Хранить JID и пароль
- Автоматический вход при запуске, если есть сохранённые credentials
9.5 Аудио-сессия
Настройка AVAudioSession при записи:
- Category:
playAndRecord - Mode:
default - При воспроизведении: через динамик (не ушной, если телефон не у уха)
10. Клиентское приложение — Android
10.1 Точка входа
Аналогично iOS:
// 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 — разрешения
<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)
- Файл
google-services.jsonвandroid/app/(не в git, добавить в.gitignore) @react-native-firebase/messagingдля получения FCM-токена- Регистрация токена на XMPP-сервере (XEP-0357)
- Notification channels:
// В 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)
/** Статус доставки сообщения */
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-записью
Порядок развёртывания:
-
Подготовить сервер:
apt update && apt install -y docker.io docker-compose-plugin -
Получить TLS-сертификат:
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/ -
Создать
.env:POSTGRES_PASSWORD=<secure random password> -
Запустить:
cd server && docker compose up -d -
Проверить:
docker compose exec ejabberd ejabberdctl status
14.2 Сборка клиентских приложений
iOS:
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:
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:
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
- Один сервер — без федерации (S2S отключён), все пользователи на одном домене
- Нет E2E-шифрования — только transport encryption (TLS). OMEMO — в будущих версиях
- Голос только как файлы — нет VoIP / WebRTC
- Нет произвольных файлов — только голосовые сообщения (audio/mp4)
- Нет web-клиента — только iOS и Android
- Нет редактирования/удаления сообщений — в будущих версиях (XEP-0308, XEP-0424)
- Аватар — только из галереи, без камеры в v1
- Поиск пользователей — только по точному JID, нет глобального каталога
- Push-уведомления — через Firebase (FCM для обеих платформ, APNs через FCM)
- Максимум 200 участников в групповом чате