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:
144
CONTEXT.md
144
CONTEXT.md
@@ -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 модулей по функциональным областям
|
||||
|
||||
@@ -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/
|
||||
├── 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/ # Шаблоны для issue
|
||||
│ │ ├── bug_report.md
|
||||
│ │ └── feature_request.md
|
||||
│ ├── workflows/ # GitHub Actions CI/CD
|
||||
│ │ └── ci.yml
|
||||
│ ├── ISSUE_TEMPLATE/
|
||||
│ ├── workflows/ci.yml
|
||||
│ └── pull_request_template.md
|
||||
│
|
||||
├── docs/ # Дополнительная документация
|
||||
│ └── TDLIB_INTEGRATION.md
|
||||
│
|
||||
├── 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 # Утилиты
|
||||
│
|
||||
├── tdlib_data/ # TDLib сессия (НЕ коммитится)
|
||||
├── target/ # Артефакты сборки (НЕ коммитится)
|
||||
│
|
||||
├── .editorconfig # EditorConfig для IDE
|
||||
├── .gitignore # Git ignore правила
|
||||
├── Cargo.lock # Зависимости (точные версии)
|
||||
├── Cargo.toml # Манифест проекта
|
||||
├── rustfmt.toml # Конфигурация форматирования
|
||||
├── Cargo.lock # Точные версии зависимостей
|
||||
├── build.rs # Build script (TDLib)
|
||||
├── rustfmt.toml # cargo fmt конфигурация
|
||||
├── .editorconfig # Настройки IDE
|
||||
├── .gitignore # Git ignore
|
||||
│
|
||||
├── 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 # Функциональные требования
|
||||
├── CLAUDE.md # Инструкции для AI
|
||||
├── CONTEXT.md # Текущий статус
|
||||
├── ROADMAP.md # План развития
|
||||
└── SECURITY.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) |
|
||||
|
||||
149
ROADMAP.md
149
ROADMAP.md
@@ -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
|
||||
|
||||
### Метрики успеха
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
197
src/config/loader.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -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
88
src/config/validation.rs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// Application constants
|
||||
//! Application-wide constants (memory limits, timeouts, UI sizes).
|
||||
|
||||
// ============================================================================
|
||||
// Memory Limits
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// Обработка навигации в списке чатов
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// Обработка режима выбора чата для пересылки сообщения
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
136
src/tdlib/messages/convert.rs
Normal file
136
src/tdlib/messages/convert.rs
Normal 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
101
src/tdlib/messages/mod.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
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),
|
||||
}
|
||||
} else {
|
||||
("".to_string(), Color::DarkGray)
|
||||
}
|
||||
} else {
|
||||
("".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),
|
||||
}
|
||||
}
|
||||
|
||||
116
src/ui/components/message_list.rs
Normal file
116
src/ui/components/message_list.rs
Normal 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)
|
||||
}
|
||||
@@ -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
170
src/ui/compose_bar.rs
Normal 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
8
src/ui/modals/delete_confirm.rs
Normal file
8
src/ui/modals/delete_confirm.rs
Normal 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
17
src/ui/modals/mod.rs
Normal 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
93
src/ui/modals/pinned.rs
Normal 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]);
|
||||
}
|
||||
13
src/ui/modals/reaction_picker.rs
Normal file
13
src/ui/modals/reaction_picker.rs
Normal 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
117
src/ui/modals/search.rs
Normal 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]);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::app::App;
|
||||
use crate::app::methods::modal::ModalMethods;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crate::tdlib::ProfileInfo;
|
||||
use ratatui::{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user