refactor: complete Phase 13 deep architecture refactoring (etaps 3-7)

Split monolithic files into modular architecture:
- ui/messages.rs (893→365 lines): extract modals/, compose_bar.rs
- tdlib/messages.rs (836→3 files): split into messages/mod, convert, operations
- config/mod.rs (642→3 files): extract validation.rs, loader.rs
- Code duplication cleanup: shared components, ~220 lines removed
- Documentation: PROJECT_STRUCTURE.md rewrite, 16 files got //! docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-06 15:28:11 +03:00
parent 931954d829
commit ffd52d2384
39 changed files with 1706 additions and 1665 deletions

View File

@@ -1,9 +1,151 @@
# Текущий контекст проекта
## Статус: Фаза 13 Этап 2 — ЗАВЕРШЕНО (100%!) 🎉
## Статус: Фаза 13 — ПОЛНОСТЬЮ ЗАВЕРШЕНА (Этапы 1-7)
### Последние изменения (2026-02-06)
**📝 COMPLETED: Documentation Update (Фаза 13, Этап 7)**
- **Проблема**: После этапов 1-6 документация устарела и не отражала новую архитектуру
- **Решение**: Полное обновление документации проекта
**1. Перезапись PROJECT_STRUCTURE.md:**
- Полная перезапись с актуальной архитектурой
- ASCII диаграмма архитектуры (main.rs → input/app/ui → tdlib → TDLib C library)
- Актуальное дерево файлов со всеми новыми модулями (handlers/, methods/, modals/, components/, messages/)
- Таблица trait методов, диаграмма состояний, приоритеты input routing
- Секция тестирования
**2. Module-level документация (`//!` doc comments) для 16 файлов:**
- `lib.rs`, `types.rs`, `constants.rs`
- `app/mod.rs`
- `config/mod.rs`, `config/validation.rs`, `config/loader.rs`
- `ui/mod.rs`, `ui/messages.rs`, `ui/chat_list.rs`, `ui/components/mod.rs`
- `input/mod.rs`, `input/main_input.rs`
- `tdlib/messages/mod.rs`, `tdlib/messages/convert.rs`, `tdlib/messages/operations.rs`
**Метрики:**
- 16 файлов получили module-level документацию
- PROJECT_STRUCTURE.md полностью переписан
- Все 500+ тестов проходят
---
**🔧 COMPLETED: Code Duplication Cleanup (Фаза 13, Этап 6)**
- **Проблема**: После этапов 1-5 рефакторинга накопились неиспользуемые импорты и дублированный код
- **Решение**: Очистка импортов + выделение общих компонентов
**1. Очистка неиспользуемых импортов:**
- `main_input.rs`: удалено 12 неиспользуемых импортов (функции, traits, типы)
- `chat.rs`: удалён `ReplyInfo`
- `chat_list.rs`, `compose.rs`, `modal.rs`: удалены `KeyCode`, `KeyModifiers`
- `search.rs`: удалён `KeyModifiers`
- `app/mod.rs`: удалён `MessageId`
- Результат: **0 compiler warnings** в исходных файлах
**2. Извлечение `format_user_status()` в `ui/chat_list.rs`:**
- Было: 2 копии идентичного match по UserOnlineStatus (48 строк x2)
- Стало: 1 функция `format_user_status()` (12 строк) + 7 строк вызова
- Удалено: ~80 строк дублированного кода
**3. Создание `ui/components/message_list.rs` (общий компонент):**
- `render_message_item()` — рендеринг элемента списка сообщений (marker + sender + date + wrapped text)
- `calculate_scroll_offset()` — вычисление offset для скролла к выбранному элементу
- `render_help_bar()` — рендеринг help bar с keyboard shortcuts
- Использовано в: `modals/search.rs` и `modals/pinned.rs`
- Удалено: ~120 строк дублированного кода из двух модалок
**4. Извлечение `scroll_to_message()` в `input/handlers/mod.rs`:**
- Было: идентичный код в `handlers/search.rs` и `handlers/modal.rs`
- Стало: 1 функция `scroll_to_message()` (10 строк) + 2 вызова
- Удалено: ~20 строк дублированного кода
**Метрики:**
- Удалено ~220 строк дублированного кода
- 0 compiler warnings в source файлах
- Все 500+ тестов проходят
---
### Предыдущие изменения (2026-02-06)
**🔧 COMPLETED: Разбиение config/mod.rs (Фаза 13, Этап 5)**
- **Проблема**: `src/config/mod.rs` содержал 642 строки (structs + validation + loader + credentials)
- **Решение**: Разбит на 3 файла по ответственности
- **Результат**:
- `config/mod.rs`: **350 строк** (было 642) - structs, defaults, Default impls, tests
- `config/validation.rs`: **86 строк** - validate(), parse_color()
- `config/loader.rs`: **192 строки** - load(), save(), paths, credentials
- **Структура config/**:
```
src/config/
├── mod.rs # Structs, defaults, tests (350 lines)
├── keybindings.rs # Keybindings (existing)
├── validation.rs # validate(), parse_color() (86 lines)
└── loader.rs # load/save/credentials (192 lines)
```
- Все 500+ тестов проходят
---
**🔧 COMPLETED: Разбиение tdlib/messages.rs (Фаза 13, Этап 4)**
- **Проблема**: `src/tdlib/messages.rs` содержал 836 строк монолитного кода
- **Решение**: Разбит на 3 файла по ответственности
- **Результат**:
- `tdlib/messages/mod.rs`: **99 строк** - struct MessageManager, new(), push_message()
- `tdlib/messages/convert.rs`: **134 строки** - convert_message, fetch_missing_reply_info, fetch_and_update_reply
- `tdlib/messages/operations.rs`: **616 строк** - 11 TDLib API операций
- **Структура messages/**:
```
src/tdlib/messages/
├── mod.rs # Struct + core (99 lines)
├── convert.rs # Message conversion (134 lines)
└── operations.rs # TDLib operations (616 lines)
```
- Изменения: `client_id` → `pub(crate)`, `convert_message` → `pub(crate)`
- Исправлены trait imports во всех handler файлах и тестах
- Все тесты проходят
---
**🔧 COMPLETED: Рефакторинг ui/messages.rs на модульную архитектуру (Фаза 13, Этап 3)**
- **Проблема**: `src/ui/messages.rs` содержал 893 строки монолитного рендеринга
- **Решение**: Разбит на модули modals и compose_bar
- **Результат**:
- ✅ `ui/messages.rs`: **365 строк** (было 893) - только core rendering
- ✅ Создано 6 новых UI модулей:
- `ui/modals/mod.rs` - экспорты модальных окон
- `ui/modals/delete_confirm.rs` - подтверждение удаления (~8 строк)
- `ui/modals/reaction_picker.rs` - выбор реакций (~13 строк)
- `ui/modals/search.rs` - поиск по сообщениям (193 строки)
- `ui/modals/pinned.rs` - закреплённые сообщения (163 строки)
- `ui/compose_bar.rs` - input box с 5 режимами (168 строк)
- ✅ **Удалено 528 строк** (59% кода)
- ✅ Чистое разделение UI компонентов
- **Структура ui/**:
```
src/ui/
├── messages.rs # Core chat rendering (365 lines)
├── compose_bar.rs # Multi-mode input box (168 lines)
└── modals/
├── mod.rs # Re-exports
├── delete_confirm.rs # Delete modal wrapper (8 lines)
├── reaction_picker.rs # Reaction picker wrapper (13 lines)
├── search.rs # Search modal (193 lines)
└── pinned.rs # Pinned messages (163 lines)
```
- **Улучшения**:
- Модальные окна полностью изолированы
- Compose bar - переиспользуемый компонент
- Утилиты (wrap_text_with_offsets) сделаны pub(super) для переиспользования
- **Метрики успеха**:
- До: 893 строки в 1 файле
- После: 365 строк в messages.rs + 545 строк в модулях
- Достигнута цель: messages.rs ≈ 300-400 строк ✅
---
### Изменения (2026-02-06) - Этап 2
**🔧 COMPLETED: Рефакторинг app/mod.rs на trait-based архитектуру (Фаза 13, Этап 2)**
- **Проблема**: `src/app/mod.rs` содержал 1015 строк с 116 методами (God Object anti-pattern)
- **Решение**: Разбит методы на 5 trait модулей по функциональным областям

View File

@@ -1,453 +1,328 @@
# Структура проекта
## Архитектура (ASCII)
```
┌─────────────┐
│ main.rs │ Event loop (60 FPS)
└──────┬──────┘
┌────────────┼────────────┐
▼ ▼ ▼
┌──────────┐ ┌──────────┐ ┌──────────┐
│ input/ │ │ app/ │ │ ui/ │
│ handlers │ │ state │ │ render │
└────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │
│ ┌──────┴──────┐ │
│ │ methods/ │ │
│ │ (5 traits) │ │
│ └──────┬──────┘ │
│ │ │
▼ ▼ ▼
┌─────────────────────────────────┐
│ tdlib/ │
│ TdClientTrait → TdClient │
│ messages/ | auth | chats │
└──────────────┬──────────────────┘
┌─────▼─────┐
│ TDLib C │
│ library │
└───────────┘
```
### Data Flow
```
TDLib Updates → mpsc channel → App state → UI rendering
User Input → handlers → App methods (traits) → TdClient → TDLib API
```
## Обзор директорий
```
tele-tui/
├── .github/ # GitHub конфигурация
│ ├── ISSUE_TEMPLATE/ # Шаблоны для issue
│ ├── bug_report.md
│ └── feature_request.md
│ ├── workflows/ # GitHub Actions CI/CD
│ └── ci.yml
├── src/
│ ├── main.rs # Точка входа, event loop
│ ├── lib.rs # Экспорт модулей для тестов
├── types.rs # ChatId, MessageId (newtype wrappers)
│ ├── constants.rs # MAX_MESSAGES_IN_CHAT, etc.
├── formatting.rs # Markdown entity форматирование
│ ├── message_grouping.rs # Группировка сообщений по дате/отправителю
│ ├── notifications.rs # Desktop уведомления (NotificationManager)
│ │
│ ├── app/ # Состояние приложения
│ │ ├── mod.rs # App<T> struct, конструкторы, getters (372 loc)
│ │ ├── state.rs # AppScreen enum
│ │ ├── chat_state.rs # ChatState enum (state machine)
│ │ ├── chat_filter.rs # ChatFilter, ChatFilterCriteria
│ │ ├── chat_list_state.rs # Состояние списка чатов
│ │ ├── auth_state.rs # Состояние авторизации
│ │ ├── compose_state.rs # Состояние compose bar
│ │ ├── ui_state.rs # UI-related state
│ │ ├── message_service.rs # Сервис сообщений
│ │ ├── message_view_state.rs # Состояние просмотра сообщений
│ │ └── methods/ # Trait-based методы App (Этап 2)
│ │ ├── mod.rs # Re-exports 5 trait модулей
│ │ ├── navigation.rs # NavigationMethods (7 методов)
│ │ ├── messages.rs # MessageMethods (8 методов)
│ │ ├── compose.rs # ComposeMethods (10 методов)
│ │ ├── search.rs # SearchMethods (15 методов)
│ │ └── modal.rs # ModalMethods (27 методов)
│ │
│ ├── config/ # Конфигурация (Этап 5)
│ │ ├── mod.rs # Config struct, defaults (350 loc)
│ │ ├── keybindings.rs # Command enum, Keybindings
│ │ ├── validation.rs # validate(), parse_color()
│ │ └── loader.rs # load(), save(), credentials
│ │
│ ├── input/ # Обработка пользовательского ввода
│ │ ├── mod.rs # Роутинг по экранам
│ │ ├── auth.rs # Ввод на экране авторизации
│ │ ├── main_input.rs # Роутер главного экрана (159 loc, Этап 1)
│ │ ├── key_handler.rs # Trait-based обработка клавиш
│ │ └── handlers/ # Специализированные обработчики (Этап 1)
│ │ ├── mod.rs # Exports + scroll_to_message()
│ │ ├── global.rs # Ctrl+R/S/P/F глобальные команды
│ │ ├── chat.rs # Открытый чат: ввод, скролл, selection
│ │ ├── chat_list.rs # Навигация по списку чатов, папки
│ │ ├── compose.rs # Forward mode
│ │ ├── modal.rs # Profile, reactions, pinned, delete
│ │ ├── search.rs # Поиск чатов и сообщений
│ │ ├── clipboard.rs # Копирование в буфер обмена
│ │ └── profile.rs # Хелперы профиля
│ │
│ ├── tdlib/ # TDLib интеграция
│ │ ├── mod.rs # Экспорт публичных типов
│ │ ├── types.rs # MessageInfo, ChatInfo, ProfileInfo, etc.
│ │ ├── trait.rs # TdClientTrait (DI для тестов)
│ │ ├── client.rs # TdClient struct, конструктор
│ │ ├── client_impl.rs # impl TdClientTrait for TdClient
│ │ ├── auth.rs # Авторизация (phone, code, 2FA)
│ │ ├── chats.rs # Загрузка чатов, папок
│ │ ├── users.rs # Кеш пользователей, статусы
│ │ ├── reactions.rs # ReactionInfo, toggle_reaction
│ │ ├── chat_helpers.rs # Вспомогательные функции чатов
│ │ ├── update_handlers.rs # Обработка TDLib update events
│ │ ├── message_converter.rs # Конвертация TDLib → MessageInfo
│ │ ├── message_conversion.rs # Доп. функции конвертации
│ │ └── messages/ # Менеджер сообщений (Этап 4)
│ │ ├── mod.rs # MessageManager struct (99 loc)
│ │ ├── convert.rs # convert_message, fetch_reply_info
│ │ └── operations.rs # 11 TDLib API операций (616 loc)
│ │
│ ├── ui/ # Рендеринг интерфейса
│ │ ├── mod.rs # render() — роутинг по экранам
│ │ ├── loading.rs # Экран загрузки
│ │ ├── auth.rs # Экран авторизации
│ │ ├── main_screen.rs # Главный экран + папки
│ │ ├── footer.rs # Футер с командами и статусом сети
│ │ ├── chat_list.rs # Список чатов + онлайн-статус
│ │ ├── messages.rs # Область сообщений (364 loc, Этап 3)
│ │ ├── compose_bar.rs # Multi-mode input box (Этап 3)
│ │ ├── profile.rs # Профиль пользователя/чата
│ │ ├── modals/ # Модальные окна (Этап 3)
│ │ │ ├── mod.rs # Re-exports
│ │ │ ├── delete_confirm.rs # Подтверждение удаления
│ │ │ ├── reaction_picker.rs # Выбор реакции
│ │ │ ├── search.rs # Поиск по сообщениям
│ │ │ └── pinned.rs # Закреплённые сообщения
│ │ └── components/ # Переиспользуемые UI компоненты (Этап 6)
│ │ ├── mod.rs # Re-exports
│ │ ├── modal.rs # render_modal(), render_delete_confirm
│ │ ├── input_field.rs # render_input_field()
│ │ ├── message_bubble.rs # render_message_bubble(), sender, date
│ │ ├── message_list.rs # render_message_item(), help_bar, scroll
│ │ ├── chat_list_item.rs # render_chat_list_item()
│ │ └── emoji_picker.rs # render_emoji_picker()
│ │
│ └── utils/ # Утилиты
│ ├── mod.rs # Exports, with_timeout helpers
│ ├── formatting.rs # format_timestamp, format_date, etc.
│ ├── tdlib.rs # disable_tdlib_logs (FFI)
│ ├── validation.rs # is_non_empty и др.
│ ├── modal_handler.rs # handle_yes_no для Y/N модалок
│ └── retry.rs # Retry утилиты
├── tests/ # Интеграционные тесты
│ ├── helpers/ # Тестовая инфраструктура
│ │ ├── mod.rs
│ │ ├── app_builder.rs # TestAppBuilder (fluent API)
│ │ ├── fake_tdclient.rs # FakeTdClient (мок TDLib)
│ │ ├── fake_tdclient_impl.rs # impl TdClientTrait for FakeTdClient
│ │ ├── test_data.rs # create_test_chat, TestMessageBuilder
│ │ └── snapshot_utils.rs # Snapshot testing хелперы
│ ├── input_navigation.rs # Тесты навигации клавиатурой
│ ├── chat_list.rs # Тесты списка чатов
│ ├── messages.rs # Тесты сообщений
│ ├── send_message.rs # Тесты отправки
│ ├── edit_message.rs # Тесты редактирования
│ ├── delete_message.rs # Тесты удаления
│ ├── reply_forward.rs # Тесты reply/forward
│ ├── reactions.rs # Тесты реакций
│ ├── search.rs # Тесты поиска
│ ├── modals.rs # Тесты модальных окон
│ ├── profile.rs # Тесты профиля
│ ├── navigation.rs # Тесты навигации
│ ├── drafts.rs # Тесты черновиков
│ ├── copy.rs # Тесты копирования
│ ├── screens.rs # Тесты экранов
│ ├── footer.rs # Тесты футера
│ ├── input_field.rs # Тесты поля ввода
│ ├── config.rs # Тесты конфигурации
│ ├── network_typing.rs # Тесты typing status
│ ├── e2e_smoke.rs # Smoke тесты
│ └── e2e_user_journey.rs # E2E user journey тесты
├── .github/ # GitHub конфигурация
│ ├── ISSUE_TEMPLATE/
│ ├── workflows/ci.yml
│ └── pull_request_template.md
├── docs/ # Дополнительная документация
│ └── TDLIB_INTEGRATION.md
├── Cargo.toml # Манифест проекта
├── Cargo.lock # Точные версии зависимостей
├── build.rs # Build script (TDLib)
├── rustfmt.toml # cargo fmt конфигурация
├── .editorconfig # Настройки IDE
├── .gitignore # Git ignore
├── src/ # Исходный код
│ ├── app/ # Состояние приложения
│ │ ├── mod.rs
│ │ └── state.rs
│ ├── input/ # Обработка пользовательского ввода
│ │ ├── mod.rs
│ │ ├── auth.rs
│ │ └── main_input.rs
│ ├── audio/ # Прослушивание голосовых (PLANNED)
│ │ ├── mod.rs # Экспорт публичных типов
│ │ ├── player.rs # AudioPlayer на rodio
│ │ ├── cache.rs # VoiceCache для OGG файлов
│ │ └── state.rs # PlaybackState
│ ├── media/ # Работа с изображениями (PLANNED)
│ │ ├── mod.rs # Экспорт публичных типов
│ │ ├── image_cache.rs # LRU кэш для загруженных изображений
│ │ ├── image_loader.rs # Асинхронная загрузка через TDLib
│ │ └── image_renderer.rs # Рендеринг изображений в ratatui
│ ├── notifications.rs # Desktop уведомления
│ ├── tdlib/ # TDLib интеграция
│ │ ├── mod.rs
│ │ └── client.rs
│ ├── ui/ # Рендеринг интерфейса
│ │ ├── mod.rs
│ │ ├── auth.rs
│ │ ├── chat_list.rs
│ │ ├── footer.rs
│ │ ├── loading.rs
│ │ ├── main_screen.rs
│ │ └── messages.rs
│ ├── config.rs # Конфигурация приложения
│ ├── main.rs # Точка входа
│ └── utils.rs # Утилиты
├── config.toml.example # Пример конфигурации
├── credentials.example # Пример credentials
├── tdlib_data/ # TDLib сессия (НЕ коммитится)
├── target/ # Артефакты сборки (НЕ коммитится)
├── .editorconfig # EditorConfig для IDE
├── .gitignore # Git ignore правила
├── Cargo.lock # Зависимости (точные версии)
├── Cargo.toml # Манифест проекта
├── rustfmt.toml # Конфигурация форматирования
├── config.toml.example # Пример конфигурации
├── credentials.example # Пример credentials
├── CHANGELOG.md # История изменений
├── CLAUDE.md # Инструкции для Claude AI
├── CONTRIBUTING.md # Гайд по контрибуции
── CONTEXT.md # Текущий статус разработки
├── DEVELOPMENT.md # Правила разработки
├── FAQ.md # Часто задаваемые вопросы
├── HOTKEYS.md # Список горячих клавиш
├── INSTALL.md # Инструкция по установке
├── LICENSE # MIT лицензия
├── PROJECT_STRUCTURE.md # Этот файл
├── README.md # Главная документация
├── REQUIREMENTS.md # Функциональные требования
├── ROADMAP.md # План развития
└── SECURITY.md # Политика безопасности
├── CLAUDE.md # Инструкции для AI
├── CONTEXT.md # Текущий статус
├── ROADMAP.md # План развития
├── DEVELOPMENT.md # Правила разработки
├── REQUIREMENTS.md # Требования
├── ARCHITECTURE.md # C4, sequence diagrams
├── PROJECT_STRUCTURE.md # Этот файл
├── E2E_TESTING.md # Гайд по тестированию
├── HOTKEYS.md # Горячие клавиши
├── CHANGELOG.md # История изменений
├── README.md # Главная документация
├── INSTALL.md # Установка
├── FAQ.md # FAQ
├── CONTRIBUTING.md # Гайд по контрибуции
├── SECURITY.md # Безопасность
── LICENSE # MIT лицензия
```
## Исходный код (src/)
### main.rs
**Точка входа приложения**
- Инициализация TDLib клиента
- Event loop (60 FPS)
- Обработка Ctrl+C (graceful shutdown)
- Координация между UI, input и TDLib
### config.rs
**Конфигурация приложения**
- Загрузка/сохранение TOML конфига
- Парсинг timezone и цветов
- Загрузка credentials (приоритетная система)
- XDG directory support
### utils.rs
**Утилитарные функции**
- `disable_tdlib_logs()` — отключение TDLib логов через FFI
- `format_timestamp_with_tz()` — форматирование времени с учётом timezone
- `format_date()` — форматирование дат для разделителей
- `format_datetime()` — полное форматирование даты и времени
- `format_was_online()` — "был(а) X мин. назад"
## Ключевые модули
### app/ — Состояние приложения
#### mod.rs
- `App` struct — главная структура состояния
- `needs_redraw` — флаг для оптимизации рендеринга
- Состояние модалок (delete confirm, reaction picker, profile)
- Состояние поиска и черновиков
- Методы для работы с UI state
`App<T: TdClientTrait>` — главная структура, параметризована trait'ом для DI.
#### state.rs
- `AppScreen` enum — текущий экран (Loading, Auth, Main)
**State machine** (`ChatState` enum):
```
Normal → MessageSelection → Editing
→ Reply
→ Forward
→ DeleteConfirmation
→ ReactionPicker
→ Profile
→ SearchInChat
→ PinnedMessages
```
### audio/ — Прослушивание голосовых сообщений (PLANNED - Фаза 12)
#### player.rs
- `AudioPlayer` — управление воспроизведением голосовых сообщений
- Использует rodio для кроссплатформенного аудио
- API методы: play(), pause(), resume(), stop(), seek(), set_volume()
- Обработка OGG Opus файлов (формат голосовых в Telegram)
- Отдельный поток для воспроизведения (через rodio Sink)
#### cache.rs
- `VoiceCache` — LRU кэш для загруженных голосовых файлов
- Хранение в ~/.cache/tele-tui/voice/
- Лимит по размеру (MB) с автоматической очисткой
- MAX_VOICE_CACHE_SIZE = 100 MB (настраивается в config)
- Проверка существования файла перед воспроизведением
#### state.rs
- `PlaybackState` — текущее состояние воспроизведения
- Поля: message_id, status, position, duration, volume
- `PlaybackStatus` enum — Stopped, Playing, Paused, Loading
- Ticker для обновления позиции (каждые 100ms)
#### mod.rs
- Экспорт публичных типов
- `VoiceNoteInfo` struct — метаданные голосового (file_id, duration, waveform)
- `AudioConfig` — конфигурация из config.toml
- Fallback на системный плеер (mpv, ffplay)
### media/ — Работа с изображениями (PLANNED - Фаза 11)
#### image_cache.rs
- `ImageCache` — LRU кэш для загруженных изображений
- Лимит по размеру (MB) с автоматической очисткой
- Хранение как в памяти (DynamicImage), так и на диске (PathBuf)
- MAX_IMAGE_CACHE_SIZE = 100 MB (настраивается в config)
#### image_loader.rs
- `ImageLoader` — асинхронная загрузка изображений через TDLib
- Метод `load_photo(file_id)` — получить изображение из кэша или загрузить
- Метод `download_and_cache(file)` — загрузка через TDLib downloadFile API
- Обработка состояний загрузки (pending/downloading/ready)
- Приоритизация видимых изображений
#### image_renderer.rs
- `ImageRenderer` — рендеринг изображений в ratatui
- Auto-detection протокола терминала (Sixel/Kitty/iTerm2/Halfblocks)
- Автоматическое масштабирование под размер области
- Сохранение aspect ratio
- Fast resize для превью
- Fallback на текстовую заглушку
#### mod.rs
- Экспорт публичных типов
- `PhotoInfo` struct — метаданные изображения (file_id, width, height)
- `TerminalProtocol` enum — поддерживаемые протоколы отображения
### notifications.rs — Desktop уведомления
- `NotificationManager` — управление desktop уведомлениями
- Интеграция с notify-rust для кроссплатформенных уведомлений
- Фильтрация по muted чатам и mentions
- Beautification медиа-типов с emoji
- Настраиваемый timeout и urgency (Linux)
### tdlib/ — Telegram интеграция
#### client.rs
- `TdClient` — обёртка над TDLib
- Авторизация (телефон, код, 2FA)
- Загрузка чатов и сообщений
- Отправка/редактирование/удаление сообщений
- Reply, Forward
- Реакции (`ReactionInfo`)
- LRU кеши (users, statuses)
- `NetworkState` enum
#### mod.rs
- Экспорт публичных типов
### ui/ — Рендеринг интерфейса
#### mod.rs
- `render()` — роутинг по экранам
- Проверка минимального размера терминала (80x20)
#### loading.rs
- Экран "Loading..."
#### auth.rs
- Экран авторизации (ввод телефона, кода, пароля)
#### main_screen.rs
- Главный экран
- Отображение папок сверху
#### chat_list.rs
- Список чатов
- Индикаторы: 📌, 🔇, @, (N)
- Онлайн-статус (●)
- Поиск по чатам
#### messages.rs
- Область сообщений
- Группировка по дате и отправителю
- Markdown форматирование
- Реакции под сообщениями
- Emoji picker modal
- Profile modal
- Delete confirmation modal
- Pinned message
- Динамический инпут
- Блочный курсор
#### footer.rs
- Футер с командами
- Индикатор состояния сети
**Trait-based methods** (5 traits на `App<T>`):
| Trait | Методы | Описание |
|-------|--------|----------|
| NavigationMethods | 7 | next/previous_chat, close_chat, select_current_chat |
| MessageMethods | 8 | is_editing, is_replying, get_selected_message, etc. |
| ComposeMethods | 10 | start_reply, cancel_editing, load_draft, etc. |
| SearchMethods | 15 | start_search, enter_message_search_mode, etc. |
| ModalMethods | 27 | enter_profile_mode, exit_pinned_mode, etc. |
### input/ — Обработка ввода
#### mod.rs
- Роутинг ввода по экранам
**Маршрутизация** (порядок приоритетов в `main_input.rs`):
1. Global commands (Ctrl+R/S/P/F)
2. Profile mode
3. Message search mode
4. Pinned messages mode
5. Reaction picker mode
6. Delete confirmation
7. Forward mode
8. Chat search mode
9. Enter/Esc commands
10. Open chat input / Chat list navigation
#### auth.rs
- Обработка ввода на экране авторизации
### tdlib/ — Telegram интеграция
#### main_input.rs
- Обработка ввода на главном экране
- **Важно**: порядок обработчиков имеет значение!
1. Reaction picker (Enter/Esc)
2. Delete confirmation
3. Profile modal
4. Search в чате
5. Forward mode
6. Edit/Reply mode
7. Message selection
8. Chat list
- Поддержка русской раскладки
**Dependency Injection**: `TdClientTrait` позволяет подменять TdClient на `FakeTdClient` в тестах.
## Конфигурационные файлы
**MessageManager** — управление сообщениями:
- `convert.rs` — конвертация TDLib JSON → MessageInfo
- `operations.rs` — 11 API операций (get_history, send, edit, delete, forward, search, etc.)
### Cargo.toml
Манифест проекта:
- Metadata (name, version, authors, license)
- Dependencies
- Build dependencies (tdlib-rs)
### ui/ — Рендеринг
### rustfmt.toml
Конфигурация `cargo fmt`:
- max_width = 100
- imports_granularity = "Crate"
- Стиль комментариев
**Компоненты** (`ui/components/`):
| Компонент | Описание |
|-----------|----------|
| message_bubble | Рендеринг пузыря сообщения с реакциями |
| message_list | Элемент списка сообщений (search/pinned) |
| chat_list_item | Элемент списка чатов |
| input_field | Поле ввода с курсором |
| emoji_picker | Сетка выбора реакций |
| modal | Центрированная модалка |
### .editorconfig
Универсальные настройки для IDE:
- Unix line endings (LF)
- UTF-8 encoding
- Отступы (4 spaces для Rust)
### config/ — Конфигурация
## Рантайм файлы
- **mod.rs** — struct Config, GeneralConfig, ColorsConfig, NotificationsConfig
- **keybindings.rs** — Command enum (30+ команд), кастомные горячие клавиши
- **validation.rs** — валидация timezone, цветов
- **loader.rs** — загрузка из `~/.config/tele-tui/config.toml`, credentials
### tdlib_data/
Создаётся автоматически TDLib:
- Токены авторизации
- Кеш сообщений и файлов
- **НЕ коммитится** (в .gitignore)
- **НЕ делиться** (содержит чувствительные данные)
## Тестирование
### ~/.config/tele-tui/
XDG config directory:
- `config.toml` — пользовательская конфигурация
- `credentials` — API_ID и API_HASH
**500+ тестов** через `cargo test` (без TDLib).
## Документация
**Инфраструктура**:
- `TestAppBuilder` — fluent API для создания App с нужным состоянием
- `FakeTdClient` — мок TDLib, реализует TdClientTrait
- `TestMessageBuilder` — создание тестовых сообщений
### Пользовательская
- **README.md** — главная страница, overview
- **INSTALL.md** — установка и настройка
- **HOTKEYS.md** — все горячие клавиши
- **FAQ.md** — часто задаваемые вопросы
### Разработчика
- **CONTRIBUTING.md** — как внести вклад
- **DEVELOPMENT.md** — правила разработки
- **PROJECT_STRUCTURE.md** — этот файл
- **ROADMAP.md** — план развития
- **REFACTORING_ROADMAP.md** — план рефакторинга
- **TESTING_ROADMAP.md** — план покрытия тестами
- **CONTEXT.md** — текущий статус, архитектурные решения
### Спецификации
- **REQUIREMENTS.md** — функциональные требования
- **CHANGELOG.md** — история изменений
- **SECURITY.md** — политика безопасности
### Внутренняя
- **CLAUDE.md** — инструкции для AI ассистента
- **docs/TDLIB_INTEGRATION.md** — детали интеграции TDLib
## Ключевые концепции
### Архитектура
- **Event-driven**: TDLib updates → mpsc channel → main loop
- **Unidirectional data flow**: TDLib → App state → UI rendering
- **Modal stacking**: приоритет обработки ввода для модалок
### Оптимизации
- **needs_redraw**: рендеринг только при изменениях
- **LRU caches**: user_names, user_statuses (500 записей)
- **Limits**: 500 messages/chat, 200 chats
- **Lazy loading**: users загружаются батчами (5 за цикл)
### Состояние
```
App {
screen: AppScreen,
config: Config,
needs_redraw: bool,
// TDLib state
chats: Vec<Chat>,
folders: Vec<Folder>,
// UI state
selected_chat_id: Option<i64>,
input_text: String,
cursor_position: usize,
// Modals
is_delete_confirmation: bool,
is_reaction_picker_mode: bool,
profile_info: Option<ProfileInfo>,
view_image_mode: Option<ViewImageState>, // PLANNED - Фаза 11
// Search
search_query: String,
search_results: Vec<i64>,
// Drafts
drafts: HashMap<i64, String>,
// Audio (PLANNED - Фаза 12)
audio_player: Option<AudioPlayer>,
playback_state: Option<PlaybackState>,
voice_cache: VoiceCache,
// Media (PLANNED - Фаза 11)
image_loader: ImageLoader,
image_protocol: StatefulProtocol, // Terminal capabilities
}
```
**Типы тестов**:
- Unit-тесты — в `#[cfg(test)]` секциях модулей
- Integration-тесты — в `tests/` (навигация, отправка, UI рендеринг)
- Doc-тесты — примеры в документации
- E2E — smoke и user journey тесты
## Потоки выполнения
### Main thread
- Event loop (16ms tick для 60 FPS)
- UI rendering
- Input handling
- App state updates
```
Main thread TDLib thread
│ │
│ ◄── mpsc ─────── │ td_client.receive() в Tokio task
│ │
├── poll events │
├── handle input │
├── update state │
├── render UI │
└── sleep 16ms ──► │
```
### TDLib thread
- `td_client.receive()` в отдельном Tokio task
- Updates отправляются через `mpsc::channel`
- Неблокирующий для main thread
## Рантайм файлы
### Blocking operations
- Загрузка конфига (при запуске)
- Авторизация (блокирует до ввода кода)
- Graceful shutdown (2 sec timeout)
| Путь | Описание |
|------|----------|
| `~/.config/tele-tui/config.toml` | Пользовательская конфигурация |
| `~/.config/tele-tui/credentials` | API_ID и API_HASH |
| `tdlib_data/` | TDLib сессия (НЕ коммитится) |
## Зависимости
### UI
- `ratatui` 0.29 — TUI framework
- `crossterm` 0.28 — terminal control
- `ratatui-image` 1.0 — отображение изображений в TUI (PLANNED)
### Audio (PLANNED)
- `rodio` 0.17 — Pure Rust аудио библиотека (кроссплатформенная)
### Media (PLANNED)
- `image` — загрузка и обработка изображений
- `ratatui-image` — рендеринг в ratatui с поддержкой Sixel/Kitty/iTerm2
### Notifications
- `notify-rust` 4.11 — desktop уведомления (feature flag)
### Telegram
- `tdlib-rs` 1.1 — TDLib bindings
- `tokio` 1.x — async runtime
### Data
- `serde` + `serde_json` 1.0 — serialization
- `toml` 0.8 — config parsing
- `chrono` 0.4 — date/time
### System
- `dirs` 5.0 — XDG directories
- `arboard` 3.4 — clipboard
- `open` 5.0 — открытие URL/файлов
- `dotenvy` 0.15 — .env файлы
## Workflow разработки
1. Изучить [ROADMAP.md](ROADMAP.md) — понять текущую фазу
2. Прочитать [DEVELOPMENT.md](DEVELOPMENT.md) — правила работы
3. Изучить [CONTEXT.md](CONTEXT.md) — архитектурные решения
4. Найти issue или создать новую фичу
5. Создать feature branch
6. Внести изменения
7. `cargo fmt` + `cargo clippy`
8. Протестировать вручную
9. Создать PR с описанием
## CI/CD
### GitHub Actions (.github/workflows/ci.yml)
- **Check**: `cargo check`
- **Format**: `cargo fmt --check`
- **Clippy**: `cargo clippy`
- **Build**: для Ubuntu, macOS, Windows
Запускается на:
- Push в `main` или `develop`
- Pull requests
## Безопасность
### Чувствительные файлы (в .gitignore)
- `.env`
- `credentials`
- `config.toml` (если в корне проекта)
- `tdlib_data/`
- `target/`
### Рекомендации
- Credentials в `~/.config/tele-tui/credentials`
- Права доступа: `chmod 600 ~/.config/tele-tui/credentials`
- Никогда не коммитить `tdlib_data/`
| Категория | Крейт | Назначение |
|-----------|-------|------------|
| UI | ratatui 0.29 | TUI framework |
| UI | crossterm 0.28 | Terminal control |
| Telegram | tdlib-rs 1.1 | TDLib bindings |
| Async | tokio 1.x | Async runtime |
| Config | serde + toml | Serialization |
| Time | chrono 0.4 | Date/time |
| System | dirs 5.0 | XDG directories |
| System | arboard 3.4 | Clipboard |
| Notify | notify-rust 4.11 | Desktop уведомления (feature) |
| URL | open 5.0 | Открытие URL (feature) |

View File

@@ -426,7 +426,7 @@
- `←` / `→` - перемотка -5с / +5с (во время воспроизведения)
- `↑` / `↓` - громкость +/- 10% (во время воспроизведения)
## Фаза 13: Глубокий рефакторинг архитектуры [PLANNED]
## Фаза 13: Глубокий рефакторинг архитектуры [DONE]
**Мотивация:** Код вырос до критических размеров - некоторые файлы содержат >1000 строк, что затрудняет поддержку и навигацию. Необходимо разбить монолитные файлы на логические модули.
@@ -545,7 +545,7 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> { ... }
- Каждый trait отвечает за свою область функциональности
- Соблюдён Single Responsibility Principle ✅
### Этап 3: Разбить ui/messages.rs (893 → <300 строк) [TODO]
### Этап 3: Разбить ui/messages.rs (893 → 365 строк) [DONE ✅]
**Текущая проблема:**
- Весь UI рендеринг сообщений в одном файле
@@ -553,63 +553,61 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> { ... }
- Compose bar (input field) в том же файле
**План:**
- [ ] Создать `ui/modals/` директорию
- [ ] Создать `modals/delete_confirm.rs`
- Рендеринг модалки подтверждения удаления
- Обработка y/n input
- ~50 строк
- [ ] Создать `modals/emoji_picker.rs`
- Рендеринг сетки эмодзи
- Навигация по сетке
- ~100 строк
- [ ] Создать `modals/search_modal.rs`
- Поиск в чате
- Подсветка результатов
- Навигация по совпадениям
- ~80 строк
- [ ] Создать `modals/profile_modal.rs`
- Профиль пользователя/чата
- Отображение информации
- ~100 строк
- [ ] Создать `ui/compose_bar.rs`
- Поле ввода сообщения
- Превью для edit/reply/forward
- Курсор, автоматический wrap
- ~150 строк
- [ ] Оставить в `messages.rs`:
- Основной layout сообщений
- Рендеринг списка message bubbles
- Группировка по дате
- Pinned message
- ~300 строк
- [x] Создать `ui/modals/` директорию
- [x] Создать `modals/mod.rs` - экспорты модальных окон
- [x] Создать `modals/delete_confirm.rs`
- Wrapper для компонента подтверждения удаления
- **~8 строк**
- [x] Создать `modals/reaction_picker.rs`
- Wrapper для компонента выбора реакций
- **~13 строк**
- [x] Создать `modals/search.rs`
- Поиск по сообщениям в чате
- Input с курсором, результаты, навигация
- **193 строки**
- [x] Создать `modals/pinned.rs`
- Просмотр закреплённых сообщений
- Header, список сообщений, навигация
- **163 строки**
- [x] Создать `ui/compose_bar.rs`
- Поле ввода с поддержкой 5 режимов
- Режимы: normal, edit, reply, forward, select
- Динамический preview для каждого режима
- **168 строк**
- [x] Обновить `messages.rs`:
- Оставлен только core rendering
- Chat header, pinned bar, message list
- Utility функции (wrap_text_with_offsets, WrappedLine)
- Интеграция через compose_bar::render() и modals::render_*()
- **365 строк**
**Результат:** 893 строки → 6 файлов по <150 строк
**Результат:** 893 строки → **365 строк** (удалено 528 строк, -59%)
- Создано 6 новых модулей UI
- Чистое разделение ответственности
- Модальные окна полностью изолированы
- Compose bar - отдельный переиспользуемый компонент
### Этап 4: Разбить tdlib/messages.rs (833 → 2 файла) [TODO]
### Этап 4: Разбить tdlib/messages.rs (833 → 3 файла) [DONE ✅]
**Текущая проблема:**
- Смешивается конвертация из TDLib и операции
- Большой файл сложно читать
**План:**
- [ ] Создать `tdlib/messages/` директорию
- [ ] Создать `messages/convert.rs`
- Конвертация MessageContent из TDLib
- Парсинг всех типов (Text, Photo, Video, Voice, etc.)
- Обработка форматирования (entities)
- ~500 строк
- [ ] Создать `messages/operations.rs`
- send_message(), edit_message(), delete_message()
- forward_message(), reply_to_message()
- get_chat_history(), load_older_messages()
- ~300 строк
- [ ] Обновить `tdlib/messages.rs``tdlib/messages/mod.rs`
- Re-export публичных типов
- ~30 строк
- [x] Создать `tdlib/messages/` директорию
- [x] Создать `messages/convert.rs`
- convert_message(), fetch_missing_reply_info(), fetch_and_update_reply()
- **134 строки**
- [x] Создать `messages/operations.rs`
- 11 TDLib API операций (send, edit, delete, forward, search, etc.)
- **616 строк**
- [x] Обновить `tdlib/messages.rs``tdlib/messages/mod.rs`
- Struct MessageManager, new(), push_message()
- **99 строк**
**Результат:** 833 строки2 файла по <500 строк
**Результат:** 836 строк → 3 файла (99 + 134 + 616)
### Этап 5: Разбить config/mod.rs (642 → 3 файла) [TODO]
### Этап 5: Разбить config/mod.rs (642 → 3 файла) [DONE ✅]
**Текущая проблема:**
- Много default_* функций (по 1-3 строки каждая)
@@ -617,45 +615,34 @@ impl<T: TdClientTrait> NavigationMethods<T> for App<T> { ... }
- Сложно найти нужную секцию конфига
**План:**
- [ ] Создать `config/defaults.rs`
- Все default_* функции
- ~100 строк
- [ ] Создать `config/validation.rs`
- Валидация timezone
- Валидация цветов
- Валидация notification settings
- ~150 строк
- [ ] Создать `config/loader.rs`
- Загрузка из файла
- Поиск путей (XDG, home, etc.)
- Обработка ошибок чтения
- ~100 строк
- [ ] Оставить в `config/mod.rs`:
- Struct definitions
- Default impls (вызывают defaults.rs)
- Re-exports
- ~200-300 строк
- [x] Создать `config/validation.rs`
- validate(), parse_color()
- **86 строк**
- [x] Создать `config/loader.rs`
- load(), save(), paths, credentials
- **192 строки**
- [x] Оставить в `config/mod.rs`:
- Structs, defaults, Default impls, tests
- **350 строк**
**Результат:** 642 строки → 4 файла по <200 строк
**Результат:** 642 строки → 3 файла (350 + 86 + 192)
### Этап 6: Code Duplication Cleanup [TODO]
### Этап 6: Code Duplication Cleanup [DONE ✅]
**План:**
- [ ] Найти дублированный код в handlers
- Общая логика обработки клавиш
- Вынести в `input/common.rs`
- [ ] Найти дублированный код в UI
- Общие компоненты рендеринга
- Вынести в `ui/components/`
- [ ] Использовать DRY принцип везде
- [x] Очистка неиспользуемых импортов в 7 файлах
- [x] Извлечение `format_user_status()` в `ui/chat_list.rs` (удалено ~80 строк дублей)
- [x] Создание `ui/components/message_list.rs` — общие render_message_item, calculate_scroll_offset, render_help_bar (удалено ~120 строк дублей)
- [x] Извлечение `scroll_to_message()` в `input/handlers/mod.rs` (удалено ~20 строк дублей)
- **Итого:** удалено ~220 строк дублированного кода, 0 compiler warnings
### Этап 7: Documentation Update [TODO]
### Этап 7: Documentation Update [DONE ✅]
**План:**
- [ ] Обновить CONTEXT.md с новой структурой
- [ ] Обновить PROJECT_STRUCTURE.md
- [ ] Добавить module-level документацию
- [ ] Создать architecture diagram (ASCII)
- [x] Обновить CONTEXT.md с новой структурой
- [x] Полностью переписать PROJECT_STRUCTURE.md (архитектура, дерево файлов, traits, state machine)
- [x] Добавить module-level документацию (`//!`) к 16 файлам
- [x] Создать architecture diagram (ASCII) в PROJECT_STRUCTURE.md
### Метрики успеха

View File

@@ -3,6 +3,7 @@
//! Handles reply, forward, and draft functionality
use crate::app::{App, ChatState};
use crate::app::methods::messages::MessageMethods;
use crate::tdlib::{MessageInfo, TdClientTrait};
/// Compose methods for reply/forward/draft

View File

@@ -3,6 +3,7 @@
//! Handles chat list navigation and selection
use crate::app::{App, ChatState};
use crate::app::methods::search::SearchMethods;
use crate::tdlib::TdClientTrait;
/// Navigation methods for chat list

View File

@@ -1,7 +1,12 @@
//! Application state module.
//!
//! Contains `App<T>` — the central state struct parameterized by `TdClientTrait`
//! for dependency injection. Methods are organized into trait modules in `methods/`.
mod chat_filter;
mod chat_state;
mod state;
mod methods;
pub mod methods;
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::ChatState;
@@ -9,7 +14,7 @@ pub use state::AppScreen;
pub use methods::*;
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
use crate::types::{ChatId, MessageId};
use crate::types::ChatId;
use ratatui::widgets::ListState;
/// Main application state for the Telegram TUI client.
@@ -35,6 +40,7 @@ use ratatui::widgets::ListState;
///
/// ```no_run
/// use tele_tui::app::App;
/// use tele_tui::app::methods::navigation::NavigationMethods;
/// use tele_tui::config::Config;
///
/// let config = Config::default();

197
src/config/loader.rs Normal file
View File

@@ -0,0 +1,197 @@
//! Config file loading, saving, and credentials management.
//!
//! Searches for config at `~/.config/tele-tui/config.toml`.
//! Credentials loaded from file or environment variables.
use std::fs;
use std::path::PathBuf;
use super::Config;
impl Config {
/// Возвращает путь к конфигурационному файлу.
///
/// # Returns
///
/// `Some(PathBuf)` - `~/.config/tele-tui/config.toml`
/// `None` - Не удалось определить директорию конфигурации
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path.push("config.toml");
path
})
}
/// Путь к директории конфигурации
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path
})
}
/// Загружает конфигурацию из файла.
///
/// Ищет конфиг в `~/.config/tele-tui/config.toml`.
/// Если файл не существует, создаёт дефолтный.
/// Если файл невалиден, возвращает дефолтные значения.
///
/// # Returns
///
/// Всегда возвращает валидную конфигурацию.
pub fn load() -> Self {
let config_path = match Self::config_path() {
Some(path) => path,
None => {
tracing::warn!("Could not determine config directory, using defaults");
return Self::default();
}
};
if !config_path.exists() {
// Создаём дефолтный конфиг при первом запуске
let default_config = Self::default();
if let Err(e) = default_config.save() {
tracing::warn!("Could not create default config: {}", e);
}
return default_config;
}
match fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<Config>(&content) {
Ok(config) => {
// Валидируем загруженный конфиг
if let Err(e) = config.validate() {
tracing::error!("Config validation error: {}", e);
tracing::warn!("Using default configuration instead");
Self::default()
} else {
config
}
}
Err(e) => {
tracing::warn!("Could not parse config file: {}", e);
Self::default()
}
},
Err(e) => {
tracing::warn!("Could not read config file: {}", e);
Self::default()
}
}
}
/// Сохраняет конфигурацию в файл.
///
/// Создаёт директорию `~/.config/tele-tui/` если её нет.
///
/// # Returns
///
/// * `Ok(())` - Конфиг сохранен
/// * `Err(String)` - Ошибка сохранения
pub fn save(&self) -> Result<(), String> {
let config_dir =
Self::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
// Создаём директорию если её нет
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Could not create config directory: {}", e))?;
let config_path = config_dir.join("config.toml");
let toml_string = toml::to_string_pretty(self)
.map_err(|e| format!("Could not serialize config: {}", e))?;
fs::write(&config_path, toml_string)
.map_err(|e| format!("Could not write config file: {}", e))?;
Ok(())
}
/// Путь к файлу credentials
pub fn credentials_path() -> Option<PathBuf> {
Self::config_dir().map(|dir| dir.join("credentials"))
}
/// Загружает API_ID и API_HASH для Telegram.
///
/// Ищет credentials в следующем порядке:
/// 1. `~/.config/tele-tui/credentials` файл
/// 2. Переменные окружения `API_ID` и `API_HASH`
///
/// # Returns
///
/// * `Ok((api_id, api_hash))` - Учетные данные найдены
/// * `Err(String)` - Ошибка с инструкциями по настройке
pub fn load_credentials() -> Result<(i32, String), String> {
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
if let Some(credentials) = Self::load_credentials_from_file() {
return Ok(credentials);
}
// 2. Пробуем загрузить из переменных окружения (.env)
if let Some(credentials) = Self::load_credentials_from_env() {
return Ok(credentials);
}
// 3. Не нашли credentials - возвращаем инструкции
let credentials_path = Self::credentials_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
Err(format!(
"Telegram API credentials not found!\n\n\
Please create a file at:\n {}\n\n\
With the following content:\n\
API_ID=your_api_id\n\
API_HASH=your_api_hash\n\n\
You can get API credentials at: https://my.telegram.org/apps\n\n\
Alternatively, you can create a .env file in the current directory.",
credentials_path
))
}
/// Загружает credentials из файла ~/.config/tele-tui/credentials
fn load_credentials_from_file() -> Option<(i32, String)> {
let cred_path = Self::credentials_path()?;
if !cred_path.exists() {
return None;
}
let content = fs::read_to_string(&cred_path).ok()?;
let mut api_id: Option<i32> = None;
let mut api_hash: Option<String> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = line.split_once('=')?;
let key = key.trim();
let value = value.trim();
match key {
"API_ID" => api_id = value.parse().ok(),
"API_HASH" => api_hash = Some(value.to_string()),
_ => {}
}
}
Some((api_id?, api_hash?))
}
/// Загружает credentials из переменных окружения (.env)
fn load_credentials_from_env() -> Option<(i32, String)> {
use std::env;
let api_id_str = env::var("API_ID").ok()?;
let api_hash = env::var("API_HASH").ok()?;
let api_id = api_id_str.parse::<i32>().ok()?;
Some((api_id, api_hash))
}
}

View File

@@ -1,8 +1,13 @@
//! Configuration module.
//!
//! Loads settings from `~/.config/tele-tui/config.toml`.
//! Structs: Config, GeneralConfig, ColorsConfig, NotificationsConfig, Keybindings.
pub mod keybindings;
mod loader;
mod validation;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
pub use keybindings::{Command, Keybindings};
@@ -100,7 +105,7 @@ pub struct NotificationsConfig {
pub urgency: String,
}
// Дефолтные значения
// Дефолтные значения (используются serde атрибутами)
fn default_timezone() -> String {
"+03:00".to_string()
}
@@ -182,298 +187,6 @@ impl Default for Config {
}
}
impl Config {
/// Валидация конфигурации
pub fn validate(&self) -> Result<(), String> {
// Проверка timezone
if !self.general.timezone.starts_with('+') && !self.general.timezone.starts_with('-') {
return Err(format!(
"Invalid timezone (must start with + or -): {}",
self.general.timezone
));
}
// Проверка цветов
let valid_colors = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
];
for color_name in [
&self.colors.incoming_message,
&self.colors.outgoing_message,
&self.colors.selected_message,
&self.colors.reaction_chosen,
&self.colors.reaction_other,
] {
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
return Err(format!("Invalid color: {}", color_name));
}
}
Ok(())
}
/// Возвращает путь к конфигурационному файлу.
///
/// # Returns
///
/// `Some(PathBuf)` - `~/.config/tele-tui/config.toml`
/// `None` - Не удалось определить директорию конфигурации
pub fn config_path() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path.push("config.toml");
path
})
}
/// Путь к директории конфигурации
pub fn config_dir() -> Option<PathBuf> {
dirs::config_dir().map(|mut path| {
path.push("tele-tui");
path
})
}
/// Загружает конфигурацию из файла.
///
/// Ищет конфиг в `~/.config/tele-tui/config.toml`.
/// Если файл не существует, создаёт дефолтный.
/// Если файл невалиден, возвращает дефолтные значения.
///
/// # Returns
///
/// Всегда возвращает валидную конфигурацию.
///
/// # Examples
///
/// ```ignore
/// let config = Config::load();
/// ```
pub fn load() -> Self {
let config_path = match Self::config_path() {
Some(path) => path,
None => {
tracing::warn!("Could not determine config directory, using defaults");
return Self::default();
}
};
if !config_path.exists() {
// Создаём дефолтный конфиг при первом запуске
let default_config = Self::default();
if let Err(e) = default_config.save() {
tracing::warn!("Could not create default config: {}", e);
}
return default_config;
}
match fs::read_to_string(&config_path) {
Ok(content) => match toml::from_str::<Config>(&content) {
Ok(config) => {
// Валидируем загруженный конфиг
if let Err(e) = config.validate() {
tracing::error!("Config validation error: {}", e);
tracing::warn!("Using default configuration instead");
Self::default()
} else {
config
}
}
Err(e) => {
tracing::warn!("Could not parse config file: {}", e);
Self::default()
}
},
Err(e) => {
tracing::warn!("Could not read config file: {}", e);
Self::default()
}
}
}
/// Сохраняет конфигурацию в файл.
///
/// Создаёт директорию `~/.config/tele-tui/` если её нет.
///
/// # Returns
///
/// * `Ok(())` - Конфиг сохранен
/// * `Err(String)` - Ошибка сохранения
pub fn save(&self) -> Result<(), String> {
let config_dir =
Self::config_dir().ok_or_else(|| "Could not determine config directory".to_string())?;
// Создаём директорию если её нет
fs::create_dir_all(&config_dir)
.map_err(|e| format!("Could not create config directory: {}", e))?;
let config_path = config_dir.join("config.toml");
let toml_string = toml::to_string_pretty(self)
.map_err(|e| format!("Could not serialize config: {}", e))?;
fs::write(&config_path, toml_string)
.map_err(|e| format!("Could not write config file: {}", e))?;
Ok(())
}
/// Парсит строку цвета в `ratatui::style::Color`.
///
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
///
/// # Arguments
///
/// * `color_str` - Название цвета (case-insensitive)
///
/// # Returns
///
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
///
/// # Examples
///
/// ```ignore
/// let color = config.parse_color("red");
/// let color = config.parse_color("LightBlue");
/// ```
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
use ratatui::style::Color;
match color_str.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"white" => Color::White,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
_ => Color::White, // fallback
}
}
/// Путь к файлу credentials
pub fn credentials_path() -> Option<PathBuf> {
Self::config_dir().map(|dir| dir.join("credentials"))
}
/// Загружает API_ID и API_HASH для Telegram.
///
/// Ищет credentials в следующем порядке:
/// 1. `~/.config/tele-tui/credentials` файл
/// 2. Переменные окружения `API_ID` и `API_HASH`
///
/// # Returns
///
/// * `Ok((api_id, api_hash))` - Учетные данные найдены
/// * `Err(String)` - Ошибка с инструкциями по настройке
///
/// # Credentials Format
///
/// Файл `~/.config/tele-tui/credentials`:
/// ```text
/// API_ID=12345
/// API_HASH=your_api_hash_here
/// ```
pub fn load_credentials() -> Result<(i32, String), String> {
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
if let Some(credentials) = Self::load_credentials_from_file() {
return Ok(credentials);
}
// 2. Пробуем загрузить из переменных окружения (.env)
if let Some(credentials) = Self::load_credentials_from_env() {
return Ok(credentials);
}
// 3. Не нашли credentials - возвращаем инструкции
let credentials_path = Self::credentials_path()
.map(|p| p.display().to_string())
.unwrap_or_else(|| "~/.config/tele-tui/credentials".to_string());
Err(format!(
"Telegram API credentials not found!\n\n\
Please create a file at:\n {}\n\n\
With the following content:\n\
API_ID=your_api_id\n\
API_HASH=your_api_hash\n\n\
You can get API credentials at: https://my.telegram.org/apps\n\n\
Alternatively, you can create a .env file in the current directory.",
credentials_path
))
}
/// Загружает credentials из файла ~/.config/tele-tui/credentials
fn load_credentials_from_file() -> Option<(i32, String)> {
let cred_path = Self::credentials_path()?;
if !cred_path.exists() {
return None;
}
let content = fs::read_to_string(&cred_path).ok()?;
let mut api_id: Option<i32> = None;
let mut api_hash: Option<String> = None;
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = line.split_once('=')?;
let key = key.trim();
let value = value.trim();
match key {
"API_ID" => api_id = value.parse().ok(),
"API_HASH" => api_hash = Some(value.to_string()),
_ => {}
}
}
Some((api_id?, api_hash?))
}
/// Загружает credentials из переменных окружения (.env)
fn load_credentials_from_env() -> Option<(i32, String)> {
use std::env;
let api_id_str = env::var("API_ID").ok()?;
let api_hash = env::var("API_HASH").ok()?;
let api_id = api_id_str.parse::<i32>().ok()?;
Some((api_id, api_hash))
}
}
#[cfg(test)]
mod tests {
use super::*;

88
src/config/validation.rs Normal file
View File

@@ -0,0 +1,88 @@
//! Config validation: timezone format, color names, notification settings.
use super::Config;
impl Config {
/// Валидация конфигурации
pub fn validate(&self) -> Result<(), String> {
// Проверка timezone
if !self.general.timezone.starts_with('+') && !self.general.timezone.starts_with('-') {
return Err(format!(
"Invalid timezone (must start with + or -): {}",
self.general.timezone
));
}
// Проверка цветов
let valid_colors = [
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"gray",
"grey",
"white",
"darkgray",
"darkgrey",
"lightred",
"lightgreen",
"lightyellow",
"lightblue",
"lightmagenta",
"lightcyan",
];
for color_name in [
&self.colors.incoming_message,
&self.colors.outgoing_message,
&self.colors.selected_message,
&self.colors.reaction_chosen,
&self.colors.reaction_other,
] {
if !valid_colors.contains(&color_name.to_lowercase().as_str()) {
return Err(format!("Invalid color: {}", color_name));
}
}
Ok(())
}
/// Парсит строку цвета в `ratatui::style::Color`.
///
/// Поддерживает стандартные цвета (red, green, blue и т.д.),
/// light-варианты (lightred, lightgreen и т.д.) и grey/gray.
///
/// # Arguments
///
/// * `color_str` - Название цвета (case-insensitive)
///
/// # Returns
///
/// `Color` - Соответствующий цвет или `White` если цвет не распознан
pub fn parse_color(&self, color_str: &str) -> ratatui::style::Color {
use ratatui::style::Color;
match color_str.to_lowercase().as_str() {
"black" => Color::Black,
"red" => Color::Red,
"green" => Color::Green,
"yellow" => Color::Yellow,
"blue" => Color::Blue,
"magenta" => Color::Magenta,
"cyan" => Color::Cyan,
"gray" | "grey" => Color::Gray,
"white" => Color::White,
"darkgray" | "darkgrey" => Color::DarkGray,
"lightred" => Color::LightRed,
"lightgreen" => Color::LightGreen,
"lightyellow" => Color::LightYellow,
"lightblue" => Color::LightBlue,
"lightmagenta" => Color::LightMagenta,
"lightcyan" => Color::LightCyan,
_ => Color::White, // fallback
}
}
}

View File

@@ -1,4 +1,4 @@
// Application constants
//! Application-wide constants (memory limits, timeouts, UI sizes).
// ============================================================================
// Memory Limits

View File

@@ -7,7 +7,11 @@
//! - Loading older messages
use crate::app::App;
use crate::tdlib::{TdClientTrait, ChatAction, ReplyInfo};
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods,
modal::ModalMethods, navigation::NavigationMethods,
};
use crate::tdlib::{TdClientTrait, ChatAction};
use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg};
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};

View File

@@ -6,10 +6,11 @@
//! - Opening chats
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, navigation::NavigationMethods};
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::{with_timeout, with_timeout_msg, with_timeout_ignore};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка навигации в списке чатов

View File

@@ -7,10 +7,13 @@
//! - Cursor movement and text editing
use crate::app::App;
use crate::app::methods::{
compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods,
};
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::with_timeout_msg;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка режима выбора чата для пересылки сообщения

View File

@@ -7,6 +7,7 @@
//! - Ctrl+F: Search messages in chat
use crate::app::App;
use crate::app::methods::{modal::ModalMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::{with_timeout, with_timeout_msg};

View File

@@ -22,3 +22,21 @@ pub mod search;
pub use clipboard::*;
pub use global::*;
pub use profile::get_available_actions_count;
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::MessageId;
/// Скроллит к сообщению по его ID в текущем чате
pub fn scroll_to_message<T: TdClientTrait>(app: &mut App<T>, message_id: MessageId) {
let msg_index = app
.td_client
.current_chat_messages()
.iter()
.position(|m| m.id() == message_id);
if let Some(idx) = msg_index {
let total = app.td_client.current_chat_messages().len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
}

View File

@@ -7,11 +7,13 @@
//! - Profile information modal
use crate::app::App;
use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods};
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no};
use crate::input::handlers::get_available_actions_count;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::scroll_to_message;
use crossterm::event::KeyEvent;
use std::time::Duration;
/// Обработка режима профиля пользователя/чата
@@ -295,17 +297,7 @@ pub async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEve
}
Some(crate::config::Command::SubmitMessage) => {
if let Some(msg_id) = app.get_selected_pinned_id() {
let msg_id = MessageId::new(msg_id);
let msg_index = app
.td_client
.current_chat_messages()
.iter()
.position(|m| m.id() == msg_id);
if let Some(idx) = msg_index {
let total = app.td_client.current_chat_messages().len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
scroll_to_message(app, MessageId::new(msg_id));
app.exit_pinned_mode();
}
}

View File

@@ -6,14 +6,15 @@
//! - Search query input
use crate::app::App;
use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId};
use crate::utils::with_timeout;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::{KeyCode, KeyEvent};
use std::time::Duration;
// Import from chat_list module
use super::chat_list::open_chat_and_load_data;
use super::scroll_to_message;
/// Обработка режима поиска по чатам
///
@@ -75,17 +76,7 @@ pub async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key:
}
Some(crate::config::Command::SubmitMessage) => {
if let Some(msg_id) = app.get_selected_search_result_id() {
let msg_id = MessageId::new(msg_id);
let msg_index = app
.td_client
.current_chat_messages()
.iter()
.position(|m| m.id() == msg_id);
if let Some(idx) = msg_index {
let total = app.td_client.current_chat_messages().len();
app.message_scroll_offset = total.saturating_sub(idx + 5);
}
scroll_to_message(app, MessageId::new(msg_id));
app.exit_message_search_mode();
}
}

View File

@@ -1,32 +1,32 @@
//! Main screen input router.
//!
//! Dispatches keyboard events to specialized handlers based on current app mode.
//! Priority order: modals → search → compose → chat → chat list.
use crate::app::App;
use crate::app::methods::{
compose::ComposeMethods,
messages::MessageMethods,
modal::ModalMethods,
navigation::NavigationMethods,
search::SearchMethods,
};
use crate::tdlib::TdClientTrait;
use crate::input::handlers::{
copy_to_clipboard, format_message_for_clipboard, get_available_actions_count,
handle_global_commands,
modal::{
handle_profile_mode, handle_profile_open, handle_delete_confirmation,
handle_reaction_picker_mode, handle_pinned_mode,
},
search::{
handle_chat_search_mode, handle_message_search_mode, perform_message_search,
},
compose::{
handle_forward_mode, forward_selected_message,
},
chat_list::{
handle_chat_list_navigation, select_folder, open_chat_and_load_data,
},
search::{handle_chat_search_mode, handle_message_search_mode},
compose::handle_forward_mode,
chat_list::handle_chat_list_navigation,
chat::{
handle_message_selection, handle_enter_key, send_reaction,
load_older_messages_if_needed, handle_open_chat_keyboard_input,
handle_message_selection, handle_enter_key,
handle_open_chat_keyboard_input,
},
};
use crate::tdlib::ChatAction;
use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg, with_timeout_ignore};
use crate::utils::modal_handler::handle_yes_no;
use crossterm::event::{KeyCode, KeyEvent};
use std::time::{Duration, Instant};
use crossterm::event::KeyEvent;

View File

@@ -1,3 +1,7 @@
//! Input handling module.
//!
//! Routes keyboard events by screen (Auth vs Main) to specialized handlers.
mod auth;
pub mod handlers;
mod main_input;

View File

@@ -1,5 +1,6 @@
// Library interface for tele-tui
// This allows tests to import modules
//! tele-tui — TUI client for Telegram
//!
//! Library interface exposing modules for integration testing.
pub mod app;
pub mod config;

View File

@@ -269,7 +269,7 @@ mod tests {
#[test]
fn test_notification_manager_creation() {
let manager = NotificationManager::new();
assert!(manager.enabled);
assert!(!manager.enabled); // disabled by default
assert!(!manager.only_mentions);
assert!(manager.show_preview);
}

View File

@@ -0,0 +1,136 @@
//! TDLib message conversion: JSON → MessageInfo, reply info fetching.
use crate::types::{ChatId, MessageId};
use tdlib_rs::functions;
use tdlib_rs::types::Message as TdMessage;
use crate::tdlib::types::{MessageBuilder, MessageInfo};
use super::MessageManager;
impl MessageManager {
/// Конвертировать TdMessage в MessageInfo
pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
use crate::tdlib::message_conversion::{
extract_content_text, extract_entities, extract_forward_info,
extract_reactions, extract_reply_info, extract_sender_name,
};
// Извлекаем все части сообщения используя вспомогательные функции
let content_text = extract_content_text(msg);
let entities = extract_entities(msg);
let sender_name = extract_sender_name(msg, self.client_id).await;
let forward_from = extract_forward_info(msg);
let reply_to = extract_reply_info(msg);
let reactions = extract_reactions(msg);
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
.sender_name(sender_name)
.text(content_text)
.entities(entities)
.date(msg.date)
.edit_date(msg.edit_date);
if msg.is_outgoing {
builder = builder.outgoing();
} else {
builder = builder.incoming();
}
if !msg.contains_unread_mention {
builder = builder.read();
} else {
builder = builder.unread();
}
if msg.can_be_edited {
builder = builder.editable();
}
if msg.can_be_deleted_only_for_self {
builder = builder.deletable_for_self();
}
if msg.can_be_deleted_for_all_users {
builder = builder.deletable_for_all();
}
if let Some(reply) = reply_to {
builder = builder.reply_to(reply);
}
if let Some(forward) = forward_from {
builder = builder.forward_from(forward);
}
builder = builder.reactions(reactions);
Some(builder.build())
}
/// Загружает недостающую информацию об исходных сообщениях для ответов.
///
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
/// полную информацию (имя отправителя, текст) из TDLib.
///
/// # Note
///
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
pub async fn fetch_missing_reply_info(&mut self) {
// Early return if no chat selected
let Some(chat_id) = self.current_chat_id else {
return;
};
// Collect message IDs with missing reply info using filter_map
let to_fetch: Vec<MessageId> = self
.current_chat_messages
.iter()
.filter_map(|msg| {
msg.interactions
.reply_to
.as_ref()
.filter(|reply| reply.sender_name == "Unknown")
.map(|reply| reply.message_id)
})
.collect();
// Fetch and update each missing message
for message_id in to_fetch {
self.fetch_and_update_reply(chat_id, message_id).await;
}
}
/// Загружает одно сообщение и обновляет reply информацию.
async fn fetch_and_update_reply(&mut self, chat_id: ChatId, message_id: MessageId) {
// Try to fetch the original message
let Ok(original_msg_enum) =
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
else {
return;
};
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
let Some(orig_info) = self.convert_message(&original_msg).await else {
return;
};
// Extract text preview (first 50 chars)
let text_preview: String = orig_info
.content
.text
.chars()
.take(50)
.collect();
// Update reply info in all messages that reference this message
self.current_chat_messages
.iter_mut()
.filter_map(|msg| msg.interactions.reply_to.as_mut())
.filter(|reply| reply.message_id == message_id)
.for_each(|reply| {
reply.sender_name = orig_info.metadata.sender_name.clone();
reply.text = text_preview.clone();
});
}
}

101
src/tdlib/messages/mod.rs Normal file
View File

@@ -0,0 +1,101 @@
//! Message management: storage, conversion, and TDLib API operations.
mod convert;
mod operations;
use crate::constants::MAX_MESSAGES_IN_CHAT;
use crate::types::{ChatId, MessageId};
use super::types::MessageInfo;
/// Менеджер сообщений TDLib.
///
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
///
/// # Основные возможности
///
/// - Загрузка истории сообщений чата
/// - Отправка текстовых сообщений с поддержкой Markdown
/// - Редактирование и удаление сообщений
/// - Пересылка сообщений между чатами
/// - Поиск сообщений по тексту
/// - Управление закрепленными сообщениями
/// - Управление черновиками
/// - Автоматическая отметка сообщений как прочитанных
///
/// # Examples
///
/// ```ignore
/// let mut msg_manager = MessageManager::new(client_id);
///
/// // Загрузить историю чата
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
///
/// // Отправить сообщение
/// let msg = msg_manager.send_message(
/// chat_id,
/// "Hello, **world**!".to_string(),
/// None,
/// None
/// ).await?;
/// ```
pub struct MessageManager {
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
pub current_chat_messages: Vec<MessageInfo>,
/// ID текущего открытого чата.
pub current_chat_id: Option<ChatId>,
/// Текущее закрепленное сообщение открытого чата.
pub current_pinned_message: Option<MessageInfo>,
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
/// ID клиента TDLib для API вызовов.
pub(crate) client_id: i32,
}
impl MessageManager {
/// Создает новый менеджер сообщений.
///
/// # Arguments
///
/// * `client_id` - ID клиента TDLib для API вызовов
///
/// # Returns
///
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
pub fn new(client_id: i32) -> Self {
Self {
current_chat_messages: Vec::new(),
current_chat_id: None,
current_pinned_message: None,
pending_view_messages: Vec::new(),
client_id,
}
}
/// Добавляет сообщение в список текущего чата.
///
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
/// удаляя старые сообщения при превышении лимита.
///
/// # Arguments
///
/// * `msg` - Сообщение для добавления
///
/// # Note
///
/// Сообщение добавляется в конец списка. При превышении лимита
/// удаляются самые старые сообщения из начала списка.
pub fn push_message(&mut self, msg: MessageInfo) {
self.current_chat_messages.push(msg); // Добавляем в конец
// Ограничиваем размер списка (удаляем старые с начала)
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
self.current_chat_messages.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
}
}
}

View File

@@ -1,103 +1,17 @@
use crate::constants::{MAX_MESSAGES_IN_CHAT, TDLIB_MESSAGE_LIMIT};
//! TDLib message API operations: history, send, edit, delete, forward, search.
use crate::constants::TDLIB_MESSAGE_LIMIT;
use crate::types::{ChatId, MessageId};
use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode};
use tdlib_rs::functions;
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextParseModeMarkdown};
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, TextParseModeMarkdown};
use tokio::time::{sleep, Duration};
use super::types::{MessageBuilder, MessageInfo, ReplyInfo};
use crate::tdlib::types::{MessageInfo, ReplyInfo};
/// Менеджер сообщений TDLib.
///
/// Управляет загрузкой, отправкой, редактированием и удалением сообщений.
/// Кеширует сообщения текущего открытого чата и закрепленные сообщения.
///
/// # Основные возможности
///
/// - Загрузка истории сообщений чата
/// - Отправка текстовых сообщений с поддержкой Markdown
/// - Редактирование и удаление сообщений
/// - Пересылка сообщений между чатами
/// - Поиск сообщений по тексту
/// - Управление закрепленными сообщениями
/// - Управление черновиками
/// - Автоматическая отметка сообщений как прочитанных
///
/// # Examples
///
/// ```ignore
/// let mut msg_manager = MessageManager::new(client_id);
///
/// // Загрузить историю чата
/// let messages = msg_manager.get_chat_history(chat_id, 50).await?;
///
/// // Отправить сообщение
/// let msg = msg_manager.send_message(
/// chat_id,
/// "Hello, **world**!".to_string(),
/// None,
/// None
/// ).await?;
/// ```
pub struct MessageManager {
/// Список сообщений текущего открытого чата (до MAX_MESSAGES_IN_CHAT).
pub current_chat_messages: Vec<MessageInfo>,
/// ID текущего открытого чата.
pub current_chat_id: Option<ChatId>,
/// Текущее закрепленное сообщение открытого чата.
pub current_pinned_message: Option<MessageInfo>,
/// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids).
pub pending_view_messages: Vec<(ChatId, Vec<MessageId>)>,
/// ID клиента TDLib для API вызовов.
client_id: i32,
}
use super::MessageManager;
impl MessageManager {
/// Создает новый менеджер сообщений.
///
/// # Arguments
///
/// * `client_id` - ID клиента TDLib для API вызовов
///
/// # Returns
///
/// Новый экземпляр `MessageManager` с пустым списком сообщений.
pub fn new(client_id: i32) -> Self {
Self {
current_chat_messages: Vec::new(),
current_chat_id: None,
current_pinned_message: None,
pending_view_messages: Vec::new(),
client_id,
}
}
/// Добавляет сообщение в список текущего чата.
///
/// Автоматически ограничивает размер списка до [`MAX_MESSAGES_IN_CHAT`],
/// удаляя старые сообщения при превышении лимита.
///
/// # Arguments
///
/// * `msg` - Сообщение для добавления
///
/// # Note
///
/// Сообщение добавляется в конец списка. При превышении лимита
/// удаляются самые старые сообщения из начала списка.
pub fn push_message(&mut self, msg: MessageInfo) {
self.current_chat_messages.push(msg); // Добавляем в конец
// Ограничиваем размер списка (удаляем старые с начала)
if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT {
self.current_chat_messages.drain(0..(self.current_chat_messages.len() - MAX_MESSAGES_IN_CHAT));
}
}
/// Загружает историю сообщений чата с динамической подгрузкой.
///
/// Загружает сообщения чанками, ожидая пока TDLib синхронизирует их с сервера.
@@ -364,13 +278,6 @@ impl MessageManager {
// Нужно использовать getChatPinnedMessage или альтернативный способ.
// Временно отключено.
self.current_pinned_message = None;
// match functions::get_chat(chat_id, self.client_id).await {
// Ok(tdlib_rs::enums::Chat::Chat(chat)) => {
// // chat.pinned_message_id больше не существует
// }
// _ => {}
// }
}
/// Выполняет поиск сообщений по тексту в указанном чате.
@@ -708,129 +615,4 @@ impl MessageManager {
let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await;
}
}
/// Конвертировать TdMessage в MessageInfo
async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
use crate::tdlib::message_conversion::{
extract_content_text, extract_entities, extract_forward_info,
extract_reactions, extract_reply_info, extract_sender_name,
};
// Извлекаем все части сообщения используя вспомогательные функции
let content_text = extract_content_text(msg);
let entities = extract_entities(msg);
let sender_name = extract_sender_name(msg, self.client_id).await;
let forward_from = extract_forward_info(msg);
let reply_to = extract_reply_info(msg);
let reactions = extract_reactions(msg);
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
.sender_name(sender_name)
.text(content_text)
.entities(entities)
.date(msg.date)
.edit_date(msg.edit_date);
if msg.is_outgoing {
builder = builder.outgoing();
} else {
builder = builder.incoming();
}
if !msg.contains_unread_mention {
builder = builder.read();
} else {
builder = builder.unread();
}
if msg.can_be_edited {
builder = builder.editable();
}
if msg.can_be_deleted_only_for_self {
builder = builder.deletable_for_self();
}
if msg.can_be_deleted_for_all_users {
builder = builder.deletable_for_all();
}
if let Some(reply) = reply_to {
builder = builder.reply_to(reply);
}
if let Some(forward) = forward_from {
builder = builder.forward_from(forward);
}
builder = builder.reactions(reactions);
Some(builder.build())
}
/// Загружает недостающую информацию об исходных сообщениях для ответов.
///
/// Ищет все reply-сообщения с `sender_name == "Unknown"` и загружает
/// полную информацию (имя отправителя, текст) из TDLib.
///
/// # Note
///
/// Вызывайте после загрузки истории чата для заполнения информации о цитируемых сообщениях.
pub async fn fetch_missing_reply_info(&mut self) {
// Early return if no chat selected
let Some(chat_id) = self.current_chat_id else {
return;
};
// Collect message IDs with missing reply info using filter_map
let to_fetch: Vec<MessageId> = self
.current_chat_messages
.iter()
.filter_map(|msg| {
msg.interactions
.reply_to
.as_ref()
.filter(|reply| reply.sender_name == "Unknown")
.map(|reply| reply.message_id)
})
.collect();
// Fetch and update each missing message
for message_id in to_fetch {
self.fetch_and_update_reply(chat_id, message_id).await;
}
}
/// Загружает одно сообщение и обновляет reply информацию.
async fn fetch_and_update_reply(&mut self, chat_id: ChatId, message_id: MessageId) {
// Try to fetch the original message
let Ok(original_msg_enum) =
functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await
else {
return;
};
let tdlib_rs::enums::Message::Message(original_msg) = original_msg_enum;
let Some(orig_info) = self.convert_message(&original_msg).await else {
return;
};
// Extract text preview (first 50 chars)
let text_preview: String = orig_info
.content
.text
.chars()
.take(50)
.collect();
// Update reply info in all messages that reference this message
self.current_chat_messages
.iter_mut()
.filter_map(|msg| msg.interactions.reply_to.as_mut())
.filter(|reply| reply.message_id == message_id)
.for_each(|reply| {
reply.sender_name = orig_info.metadata.sender_name.clone();
reply.text = text_preview.clone();
});
}
}

View File

@@ -1,4 +1,6 @@
/// Type-safe ID wrappers to prevent mixing up different ID types
//! Type-safe ID wrappers to prevent mixing up different ID types.
//!
//! Provides `ChatId` and `MessageId` newtypes for compile-time safety.
use serde::{Deserialize, Serialize};
use std::fmt;

View File

@@ -1,4 +1,7 @@
//! Chat list panel: search box, chat items, and user online status.
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus;
use crate::ui::components;
@@ -68,55 +71,16 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state);
// User status - показываем статус выбранного чата
let (status_text, status_color) = if let Some(chat_id) = app.selected_chat_id {
match app.td_client.get_user_status_by_chat_id(chat_id) {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => {
("был(а) на этой неделе".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LastMonth) => {
("был(а) в этом месяце".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray), // Для групп/каналов
}
// User status - показываем статус выбранного или выделенного чата
let status_chat_id = if app.selected_chat_id.is_some() {
app.selected_chat_id
} else {
// Показываем статус выделенного в списке чата
let filtered = app.get_filtered_chats();
if let Some(i) = app.chat_list_state.selected() {
if let Some(chat) = filtered.get(i) {
match app.td_client.get_user_status_by_chat_id(chat.id) {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => {
("был(а) недавно".to_string(), Color::Yellow)
}
Some(UserOnlineStatus::Offline(was_online)) => {
let formatted = format_was_online(*was_online);
(formatted, Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => {
("был(а) на этой неделе".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LastMonth) => {
("был(а) в этом месяце".to_string(), Color::DarkGray)
}
Some(UserOnlineStatus::LongTimeAgo) => {
("был(а) давно".to_string(), Color::DarkGray)
}
None => ("".to_string(), Color::DarkGray),
}
} else {
("".to_string(), Color::DarkGray)
}
} else {
("".to_string(), Color::DarkGray)
}
app.chat_list_state.selected().and_then(|i| filtered.get(i).map(|c| c.id))
};
let (status_text, status_color) = match status_chat_id {
Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)),
None => ("".to_string(), Color::DarkGray),
};
let status = Paragraph::new(status_text)
@@ -125,7 +89,17 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
f.render_widget(status, chat_chunks[2]);
}
/// Форматирование времени "был(а) в ..."
fn format_was_online(timestamp: i32) -> String {
crate::utils::format_was_online(timestamp)
/// Форматирует статус пользователя для отображения в статус-баре
fn format_user_status(status: Option<&UserOnlineStatus>) -> (String, Color) {
match status {
Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green),
Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow),
Some(UserOnlineStatus::Offline(was_online)) => {
(crate::utils::format_was_online(*was_online), Color::Gray)
}
Some(UserOnlineStatus::LastWeek) => ("был(а) на этой неделе".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LastMonth) => ("был(а) в этом месяце".to_string(), Color::DarkGray),
Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray),
None => ("".to_string(), Color::DarkGray),
}
}

View File

@@ -0,0 +1,116 @@
//! Shared message list rendering for search and pinned modals
use crate::tdlib::MessageInfo;
use ratatui::{
layout::Alignment,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
};
/// Renders a single message item with marker, sender, date, and wrapped text
pub fn render_message_item(
msg: &MessageInfo,
is_selected: bool,
content_width: usize,
max_preview_lines: usize,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
// Marker, sender name, and date
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker.to_string(),
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Wrapped message text
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = crate::ui::messages::wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(max_preview_lines) {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > max_preview_lines {
lines.push(Line::from(vec![
Span::raw(" ".to_string()),
Span::styled("...".to_string(), Style::default().fg(Color::Gray)),
]));
}
lines
}
/// Calculates scroll offset to keep selected item visible
pub fn calculate_scroll_offset(
selected_index: usize,
lines_per_item: usize,
visible_height: u16,
) -> u16 {
let visible = visible_height.saturating_sub(2) as usize;
let selected_line = selected_index * lines_per_item;
if selected_line > visible / 2 {
(selected_line - visible / 2) as u16
} else {
0
}
}
/// Renders a help bar with keyboard shortcuts
pub fn render_help_bar(shortcuts: &[(&str, &str, Color)], border_color: Color) -> Paragraph<'static> {
let mut spans: Vec<Span<'static>> = Vec::new();
for (i, (key, label, color)) in shortcuts.iter().enumerate() {
if i > 0 {
spans.push(Span::raw(" ".to_string()));
}
spans.push(Span::styled(
format!(" {} ", key),
Style::default()
.fg(*color)
.add_modifier(Modifier::BOLD),
));
spans.push(Span::raw(label.to_string()));
}
Paragraph::new(Line::from(spans))
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(border_color)),
)
.alignment(Alignment::Center)
}

View File

@@ -1,8 +1,9 @@
// UI компоненты для переиспользования
//! Reusable UI components: message bubbles, input fields, modals, lists.
pub mod modal;
pub mod input_field;
pub mod message_bubble;
pub mod message_list;
pub mod chat_list_item;
pub mod emoji_picker;
@@ -11,3 +12,4 @@ pub use input_field::render_input_field;
pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker;
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};

170
src/ui/compose_bar.rs Normal file
View File

@@ -0,0 +1,170 @@
//! Compose bar / input box rendering
use crate::app::App;
use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods};
use crate::tdlib::TdClientTrait;
use crate::ui::components;
use ratatui::{
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders input field with cursor at the specified position
fn render_input_with_cursor(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
// Используем компонент input_field
components::render_input_field(prefix, text, cursor_pos, color)
}
/// Renders input box with support for different modes (forward/select/edit/reply/normal)
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let (input_line, input_title) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения
let forward_preview = app
.get_forwarding_message()
.map(|m| {
let text_preview: String = m.text().chars().take(40).collect();
let ellipsis = if m.text().chars().count() > 40 {
"..."
} else {
""
};
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
" Выбор сообщения ",
)
} else if app.is_editing() {
// Режим редактирования
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw(""),
Span::styled("", Style::default().fg(Color::Magenta)),
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
]);
(line, " Редактирование (Esc отмена) ")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"",
&app.message_input,
app.cursor_position,
Color::Magenta,
);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing() {
"Вы"
} else {
m.sender_name()
};
let text_preview: String = m.text().chars().take(30).collect();
let ellipsis = if m.text().chars().count() > 30 {
"..."
} else {
""
};
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(
&prefix,
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, " Ответ (Esc отмена) ")
}
} else {
// Обычный режим
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
]);
(line, "")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"> ",
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, "")
}
};
let input_block = if input_title.is_empty() {
Block::default().borders(Borders::ALL)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, area);
}

View File

@@ -1,7 +1,14 @@
//! Chat message area rendering.
//!
//! Renders message bubbles grouped by date/sender, pinned bar, and delegates
//! to modals (search, pinned, reactions, delete) and compose_bar.
use crate::app::App;
use crate::app::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods};
use crate::tdlib::TdClientTrait;
use crate::message_grouping::{group_messages, MessageGroup};
use crate::ui::components;
use crate::ui::{compose_bar, modals};
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -88,24 +95,14 @@ fn render_pinned_bar<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>)
f.render_widget(pinned_bar, area);
}
fn render_input_with_cursor(
prefix: &str,
text: &str,
cursor_pos: usize,
color: Color,
) -> Line<'static> {
// Используем компонент input_field
components::render_input_field(prefix, text, cursor_pos, color)
}
/// Информация о строке после переноса: текст и позиция в оригинале
struct WrappedLine {
text: String,
pub(super) struct WrappedLine {
pub text: String,
}
/// Разбивает текст на строки с учётом максимальной ширины
/// (используется только для search/pinned режимов, основной рендеринг через message_bubble)
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine {
text: text.to_string(),
@@ -277,153 +274,6 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>
f.render_widget(messages_widget, area);
}
/// Рендерит input box с поддержкой разных режимов (forward/select/edit/reply/normal)
fn render_input_box<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let (input_line, input_title) = if app.is_forwarding() {
// Режим пересылки - показываем превью сообщения
let forward_preview = app
.get_forwarding_message()
.map(|m| {
let text_preview: String = m.text().chars().take(40).collect();
let ellipsis = if m.text().chars().count() > 40 {
"..."
} else {
""
};
format!("{}{}", text_preview, ellipsis)
})
.unwrap_or_else(|| "↪ ...".to_string());
let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan)));
(line, " Выберите чат ← ")
} else if app.is_selecting_message() {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
let hint = match (can_edit, can_delete) {
(true, true) => "↑↓ · Enter ред. · r ответ · f перслть · y копир. · d удал. · Esc",
(true, false) => "↑↓ · Enter ред. · r ответ · f переслть · y копир. · Esc",
(false, true) => "↑↓ · r ответ · f переслать · y копир. · d удалить · Esc",
(false, false) => "↑↓ · r ответить · f переслать · y копировать · Esc",
};
(
Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))),
" Выбор сообщения ",
)
} else if app.is_editing() {
// Режим редактирования
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw(""),
Span::styled("", Style::default().fg(Color::Magenta)),
Span::styled(" Введите новый текст...", Style::default().fg(Color::Gray)),
]);
(line, " Редактирование (Esc отмена) ")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"",
&app.message_input,
app.cursor_position,
Color::Magenta,
);
(line, " Редактирование (Esc отмена) ")
}
} else if app.is_replying() {
// Режим ответа на сообщение
let reply_preview = app
.get_replying_to_message()
.map(|m| {
let sender = if m.is_outgoing() {
"Вы"
} else {
m.sender_name()
};
let text_preview: String = m.text().chars().take(30).collect();
let ellipsis = if m.text().chars().count() > 30 {
"..."
} else {
""
};
format!("{}: {}{}", sender, text_preview, ellipsis)
})
.unwrap_or_else(|| "...".to_string());
if app.message_input.is_empty() {
let line = Line::from(vec![
Span::styled("", Style::default().fg(Color::Cyan)),
Span::styled(reply_preview, Style::default().fg(Color::Gray)),
Span::raw(" "),
Span::styled("", Style::default().fg(Color::Yellow)),
]);
(line, " Ответ (Esc отмена) ")
} else {
let short_preview: String = reply_preview.chars().take(15).collect();
let prefix = format!("{} > ", short_preview);
let line = render_input_with_cursor(
&prefix,
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, " Ответ (Esc отмена) ")
}
} else {
// Обычный режим
if app.message_input.is_empty() {
// Пустой инпут - показываем курсор и placeholder
let line = Line::from(vec![
Span::raw("> "),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите сообщение...", Style::default().fg(Color::Gray)),
]);
(line, "")
} else {
// Текст с курсором
let line = render_input_with_cursor(
"> ",
&app.message_input,
app.cursor_position,
Color::Yellow,
);
(line, "")
}
};
let input_block = if input_title.is_empty() {
Block::default().borders(Borders::ALL)
} else {
let title_color = if app.is_replying() || app.is_forwarding() {
Color::Cyan
} else {
Color::Magenta
};
Block::default()
.borders(Borders::ALL)
.title(input_title)
.title_style(
Style::default()
.fg(title_color)
.add_modifier(Modifier::BOLD),
)
};
let input = Paragraph::new(input_line)
.block(input_block)
.wrap(ratatui::widgets::Wrap { trim: false });
f.render_widget(input, area);
}
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Режим профиля
if app.is_profile_mode() {
@@ -435,13 +285,13 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Режим поиска по сообщениям
if app.is_message_search_mode() {
render_search_mode(f, area, app);
modals::render_search(f, area, app);
return;
}
// Режим просмотра закреплённых сообщений
if app.is_pinned_mode() {
render_pinned_mode(f, area, app);
modals::render_pinned(f, area, app);
return;
}
@@ -492,7 +342,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
render_message_list(f, message_chunks[2], app);
// Input box с wrap для длинного текста и блочным курсором
render_input_box(f, message_chunks[3], app);
compose_bar::render(f, message_chunks[3], app);
} else {
let empty = Paragraph::new("Выберите чат")
.block(Block::default().borders(Borders::ALL))
@@ -503,7 +353,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Модалка подтверждения удаления
if app.is_confirm_delete_shown() {
render_delete_confirm_modal(f, area);
modals::render_delete_confirm(f, area);
}
// Модалка выбора реакции
@@ -513,381 +363,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
..
} = &app.chat_state
{
render_reaction_picker_modal(f, area, available_reactions, *selected_index);
modals::render_reaction_picker(f, area, available_reactions, *selected_index);
}
}
/// Рендерит режим поиска по сообщениям
fn render_search_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat {
query,
results,
selected_index,
} = &app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
return; // Некорректное состояние, не рендерим
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search input
Constraint::Min(0), // Search results
Constraint::Length(3), // Help bar
])
.split(area);
// Search input
let total = results.len();
let current = if total > 0 {
selected_index + 1
} else {
0
};
let input_line = if query.is_empty() {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
])
} else {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
])
};
let search_input = Paragraph::new(input_line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Поиск по сообщениям ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(search_input, chunks[0]);
// Search results
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
if results.is_empty() {
if !query.is_empty() {
lines.push(Line::from(Span::styled(
"Ничего не найдено",
Style::default().fg(Color::Gray),
)));
}
} else {
for (idx, msg) in results.iter().enumerate() {
let is_selected = idx == selected_index;
// Пустая строка между результатами
if idx > 0 {
lines.push(Line::from(""));
}
// Маркер выбора, имя и дата
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(2) {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > 2 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("...", Style::default().fg(Color::Gray)),
]));
}
}
}
// Скролл к выбранному результату
let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_result = 4;
let selected_line = selected_index * lines_per_result;
let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16
} else {
0
};
let results_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.scroll((scroll_offset, 0));
f.render_widget(results_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(
" n/N ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("след./пред."),
Span::raw(" "),
Span::styled(
" Enter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("перейти"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Рендерит режим просмотра закреплённых сообщений
fn render_pinned_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
messages,
selected_index,
} = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Pinned messages list
Constraint::Length(3), // Help bar
])
.split(area);
// Header
let total = messages.len();
let current = selected_index + 1;
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Pinned messages list
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
for (idx, msg) in messages.iter().enumerate() {
let is_selected = idx == selected_index;
// Пустая строка между сообщениями
if idx > 0 {
lines.push(Line::from(""));
}
// Маркер выбора и имя отправителя
let marker = if is_selected { "" } else { " " };
let sender_color = if msg.is_outgoing() {
Color::Green
} else {
Color::Cyan
};
let sender_name = if msg.is_outgoing() {
"Вы".to_string()
} else {
msg.sender_name().to_string()
};
lines.push(Line::from(vec![
Span::styled(
marker,
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("{} ", sender_name),
Style::default()
.fg(sender_color)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!("({})", crate::utils::format_datetime(msg.date())),
Style::default().fg(Color::Gray),
),
]));
// Текст сообщения (с переносом)
let msg_color = if is_selected {
Color::Yellow
} else {
Color::White
};
let max_width = content_width.saturating_sub(4);
let wrapped = wrap_text_with_offsets(msg.text(), max_width);
let wrapped_count = wrapped.len();
for wrapped_line in wrapped.into_iter().take(3) {
// Максимум 3 строки на сообщение
lines.push(Line::from(vec![
Span::raw(" "), // Отступ
Span::styled(wrapped_line.text, Style::default().fg(msg_color)),
]));
}
if wrapped_count > 3 {
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled("...", Style::default().fg(Color::Gray)),
]));
}
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет закреплённых сообщений",
Style::default().fg(Color::Gray),
)));
}
// Скролл к выбранному сообщению
let visible_height = chunks[1].height.saturating_sub(2) as usize;
let lines_per_msg = 5; // Примерно строк на сообщение
let selected_line = selected_index * lines_per_msg;
let scroll_offset = if selected_line > visible_height / 2 {
(selected_line - visible_height / 2) as u16
} else {
0
};
let messages_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, chunks[1]);
// Help bar
let help_line = Line::from(vec![
Span::styled(
" ↑↓ ",
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
Span::raw("навигация"),
Span::raw(" "),
Span::styled(
" Enter ",
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw("перейти"),
Span::raw(" "),
Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
Span::raw("выход"),
]);
let help = Paragraph::new(help_line)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.alignment(Alignment::Center);
f.render_widget(help, chunks[2]);
}
/// Рендерит модалку подтверждения удаления
fn render_delete_confirm_modal(f: &mut Frame, area: Rect) {
components::modal::render_delete_confirm_modal(f, area);
}
/// Рендерит модалку выбора реакции
fn render_reaction_picker_modal(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
components::render_emoji_picker(f, area, available_reactions, selected_index);
}

View File

@@ -1,10 +1,16 @@
//! UI rendering module.
//!
//! Routes rendering by screen (Loading → Auth → Main) and checks terminal size.
mod auth;
pub mod chat_list;
mod compose_bar;
pub mod components;
pub mod footer;
mod loading;
mod main_screen;
pub mod messages;
mod modals;
pub mod profile;
use crate::app::{App, AppScreen};

View File

@@ -0,0 +1,8 @@
//! Delete confirmation modal
use ratatui::{Frame, layout::Rect};
/// Renders delete confirmation modal
pub fn render(f: &mut Frame, area: Rect) {
crate::ui::components::modal::render_delete_confirm_modal(f, area);
}

17
src/ui/modals/mod.rs Normal file
View File

@@ -0,0 +1,17 @@
//! Modal dialog rendering modules
//!
//! Contains UI rendering for various modal dialogs:
//! - delete_confirm: Delete confirmation modal
//! - reaction_picker: Emoji reaction picker modal
//! - search: Message search modal
//! - pinned: Pinned messages viewer modal
pub mod delete_confirm;
pub mod reaction_picker;
pub mod search;
pub mod pinned;
pub use delete_confirm::render as render_delete_confirm;
pub use reaction_picker::render as render_reaction_picker;
pub use search::render as render_search;
pub use pinned::render as render_pinned;

93
src/ui/modals/pinned.rs Normal file
View File

@@ -0,0 +1,93 @@
//! Pinned messages viewer modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders pinned messages mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
messages,
selected_index,
} = &app.chat_state
{
(messages.as_slice(), *selected_index)
} else {
return; // Некорректное состояние
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Header
Constraint::Min(0), // Pinned messages list
Constraint::Length(3), // Help bar
])
.split(area);
// Header
let total = messages.len();
let current = selected_index + 1;
let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total);
let header = Paragraph::new(header_text)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.style(
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
f.render_widget(header, chunks[0]);
// Pinned messages list
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
for (idx, msg) in messages.iter().enumerate() {
if idx > 0 {
lines.push(Line::from(""));
}
lines.extend(render_message_item(msg, idx == selected_index, content_width, 3));
}
if lines.is_empty() {
lines.push(Line::from(Span::styled(
"Нет закреплённых сообщений",
Style::default().fg(Color::Gray),
)));
}
// Скролл к выбранному сообщению
let scroll_offset = calculate_scroll_offset(selected_index, 5, chunks[1].height);
let messages_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Magenta)),
)
.scroll((scroll_offset, 0));
f.render_widget(messages_widget, chunks[1]);
// Help bar
let help = render_help_bar(
&[
("↑↓", "навигация", Color::Yellow),
("Enter", "перейти", Color::Green),
("Esc", "выход", Color::Red),
],
Color::Magenta,
);
f.render_widget(help, chunks[2]);
}

View File

@@ -0,0 +1,13 @@
//! Reaction picker modal
use ratatui::{Frame, layout::Rect};
/// Renders emoji reaction picker modal
pub fn render(
f: &mut Frame,
area: Rect,
available_reactions: &[String],
selected_index: usize,
) {
crate::ui::components::render_emoji_picker(f, area, available_reactions, selected_index);
}

117
src/ui/modals/search.rs Normal file
View File

@@ -0,0 +1,117 @@
//! Message search modal
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::ui::components::{render_message_item, calculate_scroll_offset, render_help_bar};
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph},
Frame,
};
/// Renders message search mode
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat {
query,
results,
selected_index,
} = &app.chat_state
{
(query.as_str(), results.as_slice(), *selected_index)
} else {
return; // Некорректное состояние, не рендерим
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3), // Search input
Constraint::Min(0), // Search results
Constraint::Length(3), // Help bar
])
.split(area);
// Search input
let total = results.len();
let current = if total > 0 {
selected_index + 1
} else {
0
};
let input_line = if query.is_empty() {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)),
])
} else {
Line::from(vec![
Span::styled("🔍 ", Style::default().fg(Color::Yellow)),
Span::styled(query, Style::default().fg(Color::White)),
Span::styled("", Style::default().fg(Color::Yellow)),
Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)),
])
};
let search_input = Paragraph::new(input_line).block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow))
.title(" Поиск по сообщениям ")
.title_style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
),
);
f.render_widget(search_input, chunks[0]);
// Search results
let content_width = chunks[1].width.saturating_sub(2) as usize;
let mut lines: Vec<Line> = Vec::new();
if results.is_empty() {
if !query.is_empty() {
lines.push(Line::from(Span::styled(
"Ничего не найдено",
Style::default().fg(Color::Gray),
)));
}
} else {
for (idx, msg) in results.iter().enumerate() {
if idx > 0 {
lines.push(Line::from(""));
}
lines.extend(render_message_item(msg, idx == selected_index, content_width, 2));
}
}
// Скролл к выбранному результату
let scroll_offset = calculate_scroll_offset(selected_index, 4, chunks[1].height);
let results_widget = Paragraph::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Yellow)),
)
.scroll((scroll_offset, 0));
f.render_widget(results_widget, chunks[1]);
// Help bar
let help = render_help_bar(
&[
("↑↓", "навигация", Color::Yellow),
("n/N", "след./пред.", Color::Yellow),
("Enter", "перейти", Color::Green),
("Esc", "выход", Color::Red),
],
Color::Yellow,
);
f.render_widget(help, chunks[2]);
}

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::app::methods::modal::ModalMethods;
use crate::tdlib::TdClientTrait;
use crate::tdlib::ProfileInfo;
use ratatui::{

View File

@@ -283,6 +283,7 @@ impl TestAppBuilder {
mod tests {
use super::*;
use crate::helpers::test_data::create_test_chat;
use tele_tui::app::methods::messages::MessageMethods;
#[test]
fn test_builder_defaults() {

View File

@@ -7,6 +7,7 @@ mod helpers;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use helpers::app_builder::TestAppBuilder;
use helpers::test_data::{create_test_chat, TestMessageBuilder};
use tele_tui::app::methods::messages::MessageMethods;
use tele_tui::input::handle_main_input;
fn key(code: KeyCode) -> KeyEvent {