С моего поста про то что мы делаем свой очередной в стране чат прошел уже месяц. Да, пост вышел раньше чем месяц назад, но писался и публиковался он еще в первых числах марта.

Итоги разработки за месяц

  1. Сделали полный адаптив на мобильные устройства, пока все еще оставляем на вебвью, но немного урезали функционал что бы не нагружать устройство

  2. Добавили RNNoiser для улучшенной звуковой обработки

  3. Добавили функции "Отложенные сообщения"

  4. Переработали Input для сообщений

  5. Улучшили латентность в запросах.

  6. Пересобрали хранение данных

Теперь можем пойти по порядку, начнем с UI части.

Адаптив на мобильные устройства

Начнем пожалуй с того что нам пришлось немного дописать хард стили и написать пару новых компонентов.

Декомпозированное меню
Декомпозированное меню

В нашем подходе мы решили что меню аналогичное PC версии для мобилки - будет перебор. При тестах это маленькие элементы и заметная задержка в рендере.

import { Icon } from '@shared/ui/Icon';
import { UserAvatar } from '@shared/ui/UserAvatar';

export function MobileBottomNav({ 
    currentView, 
    onViewChange,
    user
}) {
    const tabs = [
        { id: 'general-chats', icon: 'groupChat', label: 'Серверы' },
        { id: 'direct-chats', icon: 'chat', label: 'Чаты' },
        { id: 'chat-board', icon: 'board', label: 'Канбан' },
        { id: 'mobile-search', icon: 'search', label: 'Поиск' },
        { id: 'profile', label: 'Профиль', isProfile: true },
    ];

    return (
        <nav className="mobile-bottom-nav">
            {tabs.map(tab => (
                <button
                    key={tab.id}
                    className={`mobile-bottom-nav-tab ${currentView === tab.id ? 'active' : ''}`}
                    onClick={() => onViewChange(tab.id)}
                >
                    <span className="mobile-bottom-nav-icon">
                        {tab.isProfile ? (
                            <UserAvatar
                                avatar={user?.avatar}
                                userId={user?.user_id}
                                username={user?.username}
                                status={user?.status}
                                size={28}
                                showStatus={false}
                                className="mobile-bottom-nav-avatar"
                            />
                        ) : (
                            <Icon name={tab.icon} size={22} />
                        )}
                    </span>
                    <span className="mobile-bottom-nav-label">{tab.label}</span>
                </button>
            ))}
        </nav>
    );
}

Код у нас вышел довольно простой, так скажем максимально минимальный. Пять табов, каждый — просто id, иконка и лейбл. Никакой логики внутри, никаких API-вызовов, никакого стейта. Компонент буквально делает одну вещь: рендерит кнопки и при клике говорит наверх «пользователь хочет вот этот экран». Всё.

Единственное исключение — таб профиля. Вместо стандартной иконки мы рендерим UserAvatar с аватаркой текущего пользователя. Это мелочь, но на практике сильно помогает: человек видит привычное изображение, и это интуитивно считывается как «мой профиль». Плюс визуально разбавляет ряд одинаковых иконок.

Важный момент — компонент ничего не знает о том, что произойдёт после клика. Он не знает, что «Серверы» — это загрузка списка чатов через API, что «Чаты» — это директы, а «Канбан» — это вообще доска задач-чатов. Он просто прокидывает строку 'general-chats' или 'direct-chats' через onViewChange, а дальше разбирается родитель.

Серверы или общие чаты
Серверы или общие чаты

Окно списка чатов и топиков мы тоже переписали. Ну точнее не переписали, а написали новый. На десктопе у нас есть LeftMenu — боковая панель с серверами, топиками, пунктами меню. Тащить её на мобилку «как есть» мы пытались, но элементы очень маленькие и главное — заметный лаг при рендере, потому что компонент тянет за собой всю структуру десктопного лейаута. Поэтому для мобилки появился MobileServersList — отдельный компонент, заточенный под тач-интерфейс.

Визуально он разделён на две зоны. Слева — вертикальная полоска с иконками серверов, как в Discord. Справа — список топиков выбранного сервера. Идея простая: тапнул по серверу слева — справа моментально появляются его топики. Тапнул по топику — проваливаешься в чат.

При этом компонент переиспользует тот же хук useLeftMenu, что и десктопный LeftMenu. Список серверов, создание нового сервера, обработка WebSocket-событий — всё это общее. Мы не стали дублировать бизнес-логику, поменяли только представление.

Переработанный Input
Переработанный Input

Мы переработали input теперь он стал похож на mattermost формат. При тестировании заметили что некоторые объекты редактирования текста съезжают на вторую строку, что на наше мнение негативно сказывается на самом UI. Какими функциями обладает окно редактирования:

  1. Жирный и курсив — базовое форматирование текста

  2. Блок кода — для вставки фрагментов кода с подсветкой синтаксиса

  3. PlantUML-диаграмма — для построения диаграмм прямо в сообщении через текстовую разметку

  4. Цитата — для выделения цитируемого текста

  5. Ссылка — для вставки гиперссылок

  6. Список — для оформления маркированных списков

  7. Опрос — для создания голосований прямо внутри чата

Нижнее меню которое открывается на длинный тап
Нижнее меню которое открывается на длинный тап

Добавили нижнее меню которое может открываться на длинный тап. По сути мы переиспользовали уже существующий компонент, но сделали его через условие isMobile и со своими стилями.

Меню выбора эмодзи реакции на сообщение
Меню выбора эмодзи реакции на сообщение

Для удобства - сделали отдельный адаптив для создания реакции на сообщение.

Правое меню
Правое меню

Правое меню переработали только в CSS стилях и добавили логику isMobile которая вызывает определенный формат. Так же добавили новый компонент "Отложенные", переходя в него - открывается меню все запланированных к отправке сообщений. Почему отдельно? Удобно посмотреть весь планировщик сразу, чем смотреть в чате отдельно запланированное сообщение.

Меню пользователя
Меню пользователя

Тут мы уже решили сделать отдельный компонент именно для мобилок.

    return (
        <div className="mobile-profile-page">
            <div className="mobile-profile-page-header">
                <div className="mobile-profile-user-card">
                    <div className="mobile-profile-avatar">
                        {avatarUrl ? (
                            <img src={avatarUrl} alt={user.username} className="mobile-profile-avatar-img" />
                        ) : (
                            <div className="mobile-profile-avatar-placeholder">{initials}</div>
                        )}
                        <span className={`status-icon ${user.status || 'offline'} mobile-profile-status`}></span>
                    </div>
                    <div className="mobile-profile-info">
                        <div className="mobile-profile-username">{user.username}</div>
                        <div className="mobile-profile-handle">{user.handle || `@${user.username}`}</div>
                    </div>
                </div>
            </div>

            <div className="mobile-profile-page-content">
                <div className="mobile-profile-section">
                    <div className="mobile-profile-item" onClick={onOpenProfile}>
                        <span className="mobile-profile-item-icon"><Icon name="user" size={20} /></span>
                        <span className="mobile-profile-item-text">Редактировать профиль</span>
                        <span className="mobile-profile-item-arrow"><Icon name="chevronRight" size={16} /></span>
                    </div>
                    <div className="mobile-profile-item" onClick={onOpenSettings}>
                        <span className="mobile-profile-item-icon"><Icon name="settings" size={20} /></span>
                        <span className="mobile-profile-item-text">Настройки</span>
                        <span className="mobile-profile-item-arrow"><Icon name="chevronRight" size={16} /></span>
                    </div>
                    <div className="mobile-profile-item" onClick={onOpenEmojiPacks}>
                        <span className="mobile-profile-item-icon"><Icon name="smile" size={20} /></span>
                        <span className="mobile-profile-item-text">Эмодзи-паки</span>
                        <span className="mobile-profile-item-arrow"><Icon name="chevronRight" size={16} /></span>
                    </div>
                    <div className="mobile-profile-item" onClick={onOpenAudioSettings}>
                        <span className="mobile-profile-item-icon"><Icon name="mic" size={20} /></span>
                        <span className="mobile-profile-item-text">Настройки звука</span>
                        <span className="mobile-profile-item-arrow"><Icon name="chevronRight" size={16} /></span>
                    </div>
                </div>

                <div className="mobile-profile-section">
                    <div className="mobile-profile-item" onClick={() => setShowStatusList(!showStatusList)}>
                        <span className="mobile-profile-item-icon">
                            <span className={`status-icon ${user.status || 'offline'}`}></span>
                        </span>
                        <span className="mobile-profile-item-text">Изменить статус</span>
                        <span className="mobile-profile-item-arrow">
                            <Icon name={showStatusList ? 'chevronDown' : 'chevronRight'} size={16} />
                        </span>
                    </div>
                    {showStatusList && (
                        <div className="mobile-profile-status-list">
                            {statuses.map(status => (
                                <div
                                    key={status.value}
                                    className={`mobile-profile-status-option ${user.status === status.value ? 'active' : ''}`}
                                    onClick={() => handleStatusSelect(status.value)}
                                >
                                    <span className={`status-icon ${status.value}`}></span>
                                    <span>{status.label}</span>
                                </div>
                            ))}
                        </div>
                    )}
                </div>

                <div className="mobile-profile-section">
                    <div 
                        className="mobile-profile-item danger"
                        onClick={() => {
                            if (confirm('Вы уверены, что хотите выйти?')) {
                                onLogout();
                            }
                        }}
                    >
                        <span className="mobile-profile-item-icon"><Icon name="logout" size={20} /></span>
                        <span className="mobile-profile-item-text">Выйти</span>
                    </div>
                </div>
            </div>
        </div>
    );

Каждый пункт просто вызывает колбэк наверх — сам компонент ничего не открывает, а лишь говорит родителю «покажи вот это».

Единственная логика, которая живёт внутри — это переключение статуса. Тапаешь «Изменить статус» — раскрывается список из четырёх вариантов: в сети, нет на месте, не беспокоить, оффлайн. Выбираешь — список схлопывается, статус обновляется через onStatusChange. Никаких модалок, никаких лишних экранов.

В самом низу — кнопка выхода с confirm-диалогом, чтобы случайно не разлогинить себя на ходу. Красный цвет, отдельная секция — стандартный паттерн для деструктивных действий.

Компонент полностью презентационный, если не считать одного useState для раскрытия списка статусов. Вся тяжёлая работа — на стороне родителя, который передаёт нужные колбэки.

Меню настройки звука
Меню настройки звука

Настройки звука тоже переработались. Теперь у нас есть 2 формата звуковой настройки или драйвера, не знаю как правильно это написать.

RNNoise — нейросетевой подход. Работает через WebAssembly: скомпилированная модель загружается в AudioWorklet, обрабатывает звук фреймами по 480 сэмплов и на каждом фрейме выдаёт VAD-коэффициент — насколько вероятно, что сейчас говорит человек. Поверх этого работает noise gate: если VAD ниже порога — звук плавно приглушается, если выше — открывается. Атака быстрая, затухание мягкое, чтобы не обрезать хвосты слов. По умолчанию стоит именно этот режим, и при его выборе браузерные фильтры вроде googHighpassFilter и googTypingNoiseDetection отключаются — нейросеть берёт всё на себя.

Spectral — классический спектральный метод. Работает через FFT: звук разбивается на частотные полосы, для каждой оценивается уровень шума по алгоритму Minimum Statistics, и на основе этого вычисляются коэффициенты усиления. Дополнительно есть детекция основного тона голоса — если в спектре обнаружена гармоническая структура, характерная для речи, эти частоты сохраняются сильнее. Проще, легче, но менее точно в сложных условиях.

Оба алгоритма встраиваются в одну и ту же аудиоцепочку: сигнал с микрофона проходит через high-pass фильтр на 80 Гц, затем через выбранный шумоподавитель, затем через компрессор — и уже обработанный поток идёт в LiveKit. Если RNNoise не удалось загрузить (нет WASM-файла, старый браузер), система автоматически откатывается на Spectral без ошибок для пользователя.

По моим ощущениям нейронка выигрывает. Качество более чистое, нет лишних шумов. Звуки клавы, мыши, вентиляторов, музыки, кино и прочего - отсекаются довольно хорошо. Некоторые высокие шумы глушатся без эффекта на голос. Spectral по моему мнению - обычная настройка. Да, приглушает некоторые частоты, не так бросаются при диалоге, но качество голоса немного страдает.

Вот, краткие изменения UI части...

Пересборка хранения данных (Пока что в messages сервисе)

В прошлом посте, один из пользователей задавая вопрос про objectId натолкнул меня на мысль более глубокого изучения влияние данных на размер одного документа. За счет этого получилось выявить очень узкое место, а именно то что ID пользователей, реакции и многое другое мы сохраняли уникальными значениями внутри одного сообщения. Что могло бы привести к тому что один документ мог начать весить и 2мб и 5мб и лимит в 16мб. Я разнес данные на соответствующие коллекции, и теперь запросам стало проще отрабатывать.

Коллекция

Размер документа

Краткое описание

messages

~400 B

Основное сообщение: текст, отправитель, чат, топик, тред, тип, статус, флаги редактирования/удаления. Форварды крупнее за счёт вложенного объекта forwarded_from

messages_archive

~400 B

Архивная копия старых сообщений, структура идентична messages. Используется для fallback-пагинации когда основная коллекция уже очищена

message_reads

~130 B

Запись прочтения конкретного сообщения конкретным пользователем. Существует только для директов — обеспечивает галочки «прочитано» на каждом сообщении

user_read_cursors

~140 B

Курсор «прочитал до сюда» — одна запись на комбинацию пользователь+чат+топик. Используется для подсчёта unread_count и отрисовки разделителя «Новые сообщения»

message_reactions

~120 B

Одна реакция одного пользователя на одно сообщение. Поддерживает стандартные Unicode-эмодзи и кастомные серверные эмодзи формата :code:

pinned_messages

~150 B

Запись о закреплении сообщения в чате или топике. Хранит кто закрепил, когда и позицию в списке закреплённых

polls

0.5–3 KB

Данные опроса: вопрос, варианты, голоса, список проголосовавших. Растёт линейно с количеством голосов — самая тяжёлая коллекция

scheduled_messages

~350 B

Отложенное сообщение со временем отправки и статусом (pending/sent/cancelled/failed). Фоновый планировщик проверяет каждые 15 секунд и перенаправляет их в основные сообщения.

Новая структура хранения сообщений:

{
  "_id": {
    "$oid": "69c7c6470a130f4721dc4a03"
  },
  "message_id": null,
  "chat_id": "699704e89516d3af5affb5a0",
  "sender_id": "69937300905954b8b3fe030f",
  "content": "fsdfgsdgsdg",
  "message_type": "text",
  "topic_id": "69970596e1519d365ffe92b8",
  "thread_id": null,
  "reply_to_id": null,
  "created_at": {
    "$date": "2026-03-28T12:15:03.739Z"
  },
  "is_edited": false,
  "is_deleted": false,
  "status": "sent"
}

Структура реакций:

{
  "_id": {
    "$oid": "69ca8e7e7cc1e4e5176291af"
  },
  "message_id": "69ca4bf77cc1e4e5176291a3",
  "chat_id": "6994ddc65c3ec16e1a68a652",
  "emoji": ":galaxy/(=_=):",
  "user_id": "6994dadb192b04c5bf8617f4",
  "created_at": {
    "$date": "2026-03-30T14:53:50.500Z"
  }
}

Структура курсора прочитанных сообщений:

{
  "_id": {
    "$oid": "69c98b376784fb7d45ca1558"
  },
  "chat_id": "6994ddc65c3ec16e1a68a652",
  "topic_id": null,
  "user_id": "6994dadb192b04c5bf8617f4",
  "last_read_message_id": "69ca56267cc1e4e5176291ad",
  "updated_at": {
    "$date": "2026-03-30T10:53:26.446Z"
  }
}

Структура прочитанных сообщений для личных чатов:

{
  "_id": {
    "$oid": "69c98b404a46b814d5b9e24f"
  },
  "message_id": "69c98b404a46b814d5b9e24e",
  "chat_id": "6994ddc65c3ec16e1a68a652",
  "user_id": "6994dadb192b04c5bf8617f4",
  "read_at": {
    "$date": "2026-03-29T20:27:44.769Z"
  }
}

В базе у нас две коллекции для трекинга прочтений, и они решают разные задачи.

user_read_cursors — основная. Хранит одну запись на комбинацию «пользователь + чат + топик»: поле last_read_message_id указывает на последнее прочитанное сообщение. По сути это закладка — «я прочитал до сюда». Работает для всех типов чатов: директов, серверов, топиков. Когда пользователь открывает чат или доскролливает до конца, вызывается upsertcursor, который двигает эту закладку вперёд на самое свежее сообщение. Назад она никогда не откатывается.

message_reads — дополнительная, живёт только для директов. Хранит per-message записи: кто и когда прочитал конкретное сообщение. Нужна для одной вещи — галочек «прочитано» на каждом сообщении, как в Telegram. В серверных чатах и топиках этой коллекции нет — там возможны десятки или сотни участников, и показывать галочки на каждом сообщении бессмысленно и дорого по объёму.

Над чем еще поработали:

Убрали лишние вызовы методов на фронте, добавили больше WS событий, увеличили TTL между запросов со 120 секунд - на 600 секунд. Зачем нам нужен этот TTL на запросы? Это дополнительная поддержка в случае если у нас WS отвалился или Redis умер - данные заберем на прямую из сервиса. При этом мы маппим данные, то что получили сопоставляем с тем что есть в Cache сервисе фронта. Если есть новые данные - дополняем кеш и дорисовываем новые элементы (топики, треды, сообщения, чаты и прочее). Такой подход не заставляет UI полностью перерисовываться.

Убрали 1 из задуманных фичей. Ранее я говорил что мы делали модуль который упростил бы жизнь пользователей. Задача модуля была отслеживать входящие сообщения и понимать что они адресованы именно нам и что в них есть какое-либо напоминание. Например "Поставь на завтра встречу" или "Завтра надо сходить в команду <> и запросить данные" и т.п. При получении подобных сообщений фронт должен был бы создавать в ToDo листе задачи и подсвечивать их нам. Но тут мы столкнулись с проблемой определения назначений. Как понять что в общем чате или в треде - сообщение адресовано нам? Можно по тегу, а если тега нету? Можно сделать только при условии наличия тега или сообщение пришло нам в ЛС. Но ведь тогда ломается вся задумка.

Другой вариант был бы - прикрутить LLM на бэке, но это лишние ресурсы. А так как мессенджер в первую очередь для общения внутри компании и команд - у них может не быть нужных ресурсов, плюс эти ресурсы в текущей ситуации довольно дорогие. Поэтому мы убрали реализацию из фронта, сохранили его в отдельной ветке, может быть вернемся когда продумаем все намного лучше.

Тестирование

UI - мы уже собрали, да WebView, но работает очень хорошо. Тестировали на 2ух устройствах:
- Samsung Galaxy s24 ultra
- Samsung Galaxy A07
На бюджетном аппарате тоже вполне хорошая работоспособность.

Бэк - я правлю, сейчас получилось сократить на Docker Desktop задержи ответов с 350мс на некоторые запросы до 70мс. Проводим Unit тесты что бы проверить все сервисы. Недели через 2 начнем думать как нам разворачиваться. Есть сервер у знакомого, но я там не до конца разобрался как развернуть на линуксе все))

Плановый период выхода на открытое тестирование - 20-ые числа апреля

Что в планах?

В планах добавить сервис друзей, сейчас его нету, по причине того что делалось под компании, а там поиск по почте или нику. В формате "Для личного" - будет сервис друзей, с блокировками и прочими прелестями.

Добавить Бот API. Данные API будет давать доступ к стандартному набору функций, по примеру маттермоста.

Добавить интеграцию с Git и Confluence (Для совместных обсуждений документа в Real-Time, а не обсуждения в комментариях)

Добавить сохраненные сообщения

Добавить архив чатов (это вообще самая не приоритетная функция)

Добавить SSO авторизацию, отправку кодов авторизации на почту и 2FA. (Очень приоритетные функции, изучаю в целом структуру как это работает)

Строчка благодарности

Спасибо за прочтение данной статьи, если есть вопросы, предложения, замечания - пишите их в комментарии) Если нужно записать демонстрацию работоспособности на PC или мобилке - напишите, сделаю и выложу сюда)