diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index f3369cd..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: CI - -on: - push: - branches: [ main, develop ] - pull_request: - branches: [ main, develop ] - -env: - CARGO_TERM_COLOR: always - -jobs: - check: - name: Check - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - run: cargo check --all-features - - fmt: - name: Format - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: rustfmt - - run: cargo fmt --all -- --check - - clippy: - name: Clippy - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - with: - components: clippy - - run: cargo clippy --all-features -- -D warnings - - build: - name: Build - strategy: - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v4 - - uses: dtolnay/rust-toolchain@stable - - run: cargo build --release --all-features diff --git a/.woodpecker/check.yml b/.woodpecker/check.yml new file mode 100644 index 0000000..8b0a786 --- /dev/null +++ b/.woodpecker/check.yml @@ -0,0 +1,26 @@ +when: + - event: pull_request + +steps: + - name: fmt + image: rust:latest + commands: + - rustup component add rustfmt + - cargo fmt -- --check + + - name: clippy + image: rust:latest + environment: + CARGO_HOME: /tmp/cargo + commands: + - apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1 + - rustup component add clippy + - cargo clippy -- -D warnings + + - name: test + image: rust:latest + environment: + CARGO_HOME: /tmp/cargo + commands: + - apt-get update -qq && apt-get install -y -qq pkg-config libssl-dev libdbus-1-dev zlib1g-dev > /dev/null 2>&1 + - cargo test diff --git a/CONTEXT.md b/CONTEXT.md index bfacd58..60865de 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,2102 +1,286 @@ # Текущий контекст проекта -## Статус: Фаза 9 — ЗАВЕРШЕНО + Тестирование (100%!) 🎉 +## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS) -### Последние изменения (2026-02-04) +### Photo Albums (Media Groups) — DONE -**🔔 NEW: Desktop уведомления (Notifications) — Стадия 1/3 завершена** -- **Реализовано**: - - ✅ NotificationManager с базовой функциональностью (`src/notifications.rs`, 230+ строк) - - ✅ Интеграция с TdClient (поле `notification_manager`) - - ✅ Конфигурация в `config.toml` (enabled, only_mentions, show_preview) - - ✅ Отправка уведомлений для новых сообщений вне текущего чата - - ✅ Зависимость notify-rust 4.11 (с feature flag "notifications") - - ✅ Форматирование body уведомления (текст, заглушки для медиа) -- **Текущие ограничения**: - - ⚠️ Только текстовые сообщения (нет доступа к MessageContentType) - - ⚠️ Muted чаты пока не фильтруются (sync_muted_chats не вызывается) - - ⚠️ Фильтр only_mentions не реализован (нет метода has_mention()) -- **TODO - Стадия 2** (улучшения): - - [x] Синхронизация muted чатов из Telegram (вызов sync_muted_chats при загрузке) ✅ - - [x] Фильтрация по упоминаниям (@username) если only_mentions=true ✅ - - [x] Поддержка типов медиа (фото, видео, стикеры) в body ✅ -- **Стадия 3** (полировка) - ВЫПОЛНЕНО ✅: - - [x] Обработка ошибок notify-rust с graceful fallback - - [x] Логирование через tracing::warn! и tracing::debug! - - [x] Дополнительные настройки: timeout_ms и urgency - - [x] Платформенная поддержка urgency (только Linux) -- **TODO - Стадия 3** (опционально): - - [ ] Ручное тестирование на Linux/macOS/Windows - - [ ] Обработка ошибок notify-rust (fallback если уведомления не работают) - - [ ] Настройки продолжительности показа (timeout) - - [ ] Иконка приложения для уведомлений -- **Изменения**: - - `Cargo.toml`: добавлен notify-rust 4.11, feature "notifications" - - `src/notifications.rs`: новый модуль (230 строк) - - `src/lib.rs`: экспорт модуля notifications - - `src/main.rs`: добавлен `mod notifications;` - - `src/config/mod.rs`: добавлена NotificationsConfig - - `config.example.toml`: добавлена секция [notifications] - - `src/tdlib/client.rs`: поле notification_manager, метод configure_notifications() - - `src/tdlib/update_handlers.rs`: интеграция в handle_new_message_update() - - `src/app/mod.rs`: вызов configure_notifications() при инициализации -- **Тесты**: Компиляция успешна (cargo build --lib ✅, cargo build ✅) +Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото. -**📸 PLANNED: Показ изображений в чате (Фаза 11)** -- **Описание**: Отображение изображений прямо в терминале вместо текстовых заглушек "[Фото]" -- **Технологии**: - - ratatui-image 1.0 - поддержка изображений в TUI - - Протоколы: Sixel, Kitty Graphics, iTerm2 Inline Images, Unicode Halfblocks - - TDLib downloadFile API для загрузки фото - - LRU кэш для загруженных изображений (лимит 100 MB) -- **Архитектура**: - - `src/media/` - новый модуль (image_cache, image_loader, image_renderer) - - `PhotoInfo` в `MessageInfo` для хранения метаданных изображения - - Асинхронная загрузка в фоне (не блокирует UI) - - Lazy loading - загрузка только видимых изображений -- **UX фичи**: - - Превью в списке сообщений (миниатюры 20x10 символов) - - Индикатор загрузки с progress bar - - Полноэкранный просмотр: `v` в режиме выбора - - Навигация между изображениями: `←` / `→` - - Auto-detection возможностей терминала - - Fallback на Unicode halfblocks для любых терминалов -- **Конфигурация** (config.toml): - - show_images: bool - включить/отключить - - image_cache_mb: usize - размер кэша - - preview_quality: "low" | "medium" | "high" - - render_protocol: "auto" | "sixel" | "kitty" | "iterm2" | "halfblocks" -- **План реализации**: - - Этап 1: Инфраструктура (модуль media, ImageCache, зависимости) - - Этап 2: Интеграция с TDLib (PhotoInfo, download_photo) - - Этап 3: Рендеринг в UI (превью, масштабирование) - - Этап 4: Полноэкранный просмотр (новый режим ViewImage) - - Этап 5: Конфигурация и оптимизация - - Этап 6: Обработка ошибок и fallback -- **Ожидаемый результат**: - - Фото показываются inline в чате с автоматическим масштабированием - - Поддержка всех популярных терминалов (Kitty, WezTerm, iTerm2, и любых других) - - Производительность: кэширование, асинхронность, lazy loading -- **Статус**: PLANNED (документация готова в ROADMAP.md) +**Проблема**: TDLib отправляет альбомы как отдельные `Message` с общим `media_album_id: i64`. Ранее проект это поле игнорировал — каждое фото отображалось как отдельный пузырь. -**🎤 PLANNED: Прослушивание голосовых сообщений (Фаза 12)** -- **Описание**: Воспроизведение голосовых сообщений прямо из TUI с визуальным feedback -- **Технологии**: - - rodio 0.17 - Pure Rust аудио библиотека (кроссплатформенная) - - TDLib downloadFile API для загрузки OGG файлов - - Поддержка платформ: Linux (ALSA/PulseAudio), macOS (CoreAudio), Windows (WASAPI) - - Fallback на системный плеер (mpv, ffplay) если rodio не работает -- **Архитектура**: - - `src/audio/` - новый модуль (player, cache, state) - - `AudioPlayer` - управление воспроизведением (play, pause, stop, seek, volume) - - `VoiceCache` - LRU кэш загруженных файлов (лимит 100 MB) - - `PlaybackState` - текущее состояние (status, position, duration, volume) - - Асинхронная загрузка в фоне (не блокирует UI) -- **UX фичи**: - - Progress bar в сообщении (▶ ████████░░░░░░ 0:08 / 0:15) - - Статусы: ▶ (playing), ⏸ (paused), ⏹ (stopped), ⏳ (loading) - - Хоткеи: Space (play/pause), s (stop), ←/→ (seek ±5s), ↑/↓ (volume) - - Waveform визуализация (опционально, из Telegram API) - - Автоматическая остановка при закрытии чата - - Индикатор загрузки с процентами -- **Конфигурация** (config.toml): - - enabled: bool - включить/отключить аудио - - default_volume: f32 - громкость (0.0 - 1.0) - - seek_step_seconds: i32 - шаг перемотки (5 сек) - - autoplay: bool - автовоспроизведение - - cache_size_mb: usize - размер кэша - - show_waveform: bool - показывать waveform - - system_player_fallback: bool - использовать системный плеер - - system_player: String - команда плеера (mpv, ffplay) -- **План реализации**: - - Этап 1: Инфраструктура аудио (модуль audio, AudioPlayer, VoiceCache) - - Этап 2: Интеграция с TDLib (VoiceNoteInfo, download_voice_note) - - Этап 3: UI для воспроизведения (progress bar, индикаторы, footer) - - Этап 4: Хоткеи для управления (play/pause, stop, seek, volume) - - Этап 5: Конфигурация и UX (AudioConfig, ticker для обновления) - - Этап 6: Обработка ошибок и fallback (системный плеер) - - Этап 7: Дополнительные улучшения (префетчинг, анимация) -- **Ожидаемый результат**: - - Голосовые воспроизводятся с визуальным индикатором прогресса - - Полный контроль: play, pause, stop, seek, volume - - Кэширование загруженных файлов - - Graceful fallback на системный плеер - - Кроссплатформенность (Linux, macOS, Windows) -- **Статус**: PLANNED (документация готова в ROADMAP.md) +**Решение:** -**🐛 FIX: HashMap keybindings коллизии - дубликаты клавиш** -- **Проблема #1**: `KeyCode::Enter` был привязан к 3 командам (OpenChat, SelectMessage, SubmitMessage) -- **Проблема #2**: `KeyCode::Up` был привязан к 2 командам (MoveUp, EditMessage) -- **Симптомы**: - - `Enter` возвращал `SelectMessage` вместо `SubmitMessage` → чат не открывался - - `Up` возвращал `EditMessage` вместо `MoveUp` → навигация в списке чатов не работала -- **Причина**: HashMap перезаписывает значения при повторной вставке (last-insert-wins) -- **Решение**: - - Удалены привязки `OpenChat` и `SelectMessage` для Enter (обрабатываются в `handle_enter_key`) - - Удалена привязка `EditMessage` для Up (обрабатывается напрямую в `handle_open_chat_keyboard_input`) - - Это контекстно-зависимая логика, которую нельзя корректно выразить через простой HashMap -- **Изменения**: `src/config/keybindings.rs:166-168, 186-189, 210-212` -- **Тесты**: Все 571 тест проходят (75 unit + 496 integration) +1. **Data Model** — `media_album_id: i64` в `MessageMetadata`, `MessageBuilder`, getter `MessageInfo::media_album_id()`. Оба конвертера (async + sync) передают поле из TDLib. -**✅ ЗАВЕРШЕНО: Интеграция ChatFilter в App** -- **Цель**: Заменить дублирующуюся логику фильтрации в `App::get_filtered_chats()` -- **Решение**: - - Добавлен экспорт `ChatFilter`, `ChatFilterCriteria`, `ChatSortOrder` в `src/app/mod.rs` - - Метод `get_filtered_chats()` переписан с использованием ChatFilter API - - Удалена дублирующая логика (27 строк → 11 строк) - - Используется builder pattern для создания критериев -- **Преимущества**: - - Единый источник правды для фильтрации чатов - - Централизованная логика в ChatFilter модуле - - Type-safe критерии через builder pattern - - Reference-based фильтрация (без клонирования) -- **Изменения**: `src/app/mod.rs:0-5, 313-323` -- **Тесты**: Все 577 тестов проходят (81 unit + 496 integration) +2. **Message Grouping** — новый вариант `MessageGroup::Album(Vec)`. Сообщения с одинаковым `media_album_id != 0` группируются; одиночное сообщение с album_id остаётся `Message`. -**🐛 FIX: Зависание при открытии чатов с большой историей** -- **Проблема**: При использовании `i32::MAX` как лимита загрузки истории, приложение зависало в чатах с тысячами сообщений (например, на итерации #96 было загружено 4750+ сообщений и загрузка продолжалась) -- **Решение**: Заменён лимит с `i32::MAX` на разумные 300 сообщений при открытии чата -- **Обоснование**: 300 сообщений достаточно для заполнения экрана с запасом (при высоте экрана 37 строк отображается ~230 сообщений) -- **Pagination**: При скролле вверх автоматически подгружается ещё история через `load_older_messages` -- **Тесты**: Все 104 теста проходят успешно, включая новые тесты для chunked loading +3. **Album Grid Constants** — `ALBUM_PHOTO_WIDTH: 16`, `ALBUM_PHOTO_HEIGHT: 8`, `ALBUM_PHOTO_GAP: 1`, `ALBUM_GRID_MAX_COLS: 3` (3×16 + 2×1 = 50 = `INLINE_IMAGE_MAX_WIDTH`). -**⚙️ NEW: Система настраиваемых горячих клавиш** -- **Модуль**: `src/config/keybindings.rs` (420+ строк) -- **Архитектура**: - - Enum `Command` с 40+ командами (навигация, чат, сообщения, input, profile) - - Struct `KeyBinding` с поддержкой модификаторов (Ctrl, Shift, Alt, Super, Hyper, Meta) - - Struct `Keybindings` для управления привязками команд к клавишам - - HashMap> для множественных bindings -- **Возможности**: - - Type-safe команды через enum (невозможно опечататься в названии) - - Множественные привязки для одной команды (например, EN/RU раскладки) - - Поддержка модификаторов (Ctrl+S, Shift+Enter и т.д.) - - Сериализация/десериализация для загрузки из конфига - - Метод `get_command()` для определения команды по KeyEvent -- **Тесты**: 4 unit теста (все проходят) -- **Статус**: ✅ Интегрировано в Config и main_input.rs +4. **`render_album_bubble()`** — сетка фото (до 3 в ряд), `DeferredImageRender` с `x_offset` для каждого фото, общая подпись и timestamp, индикация выбора, статусы загрузки. -**🎯 NEW: KeyHandler trait для обработки клавиш** -- **Модуль**: `src/input/key_handler.rs` (380+ строк) -- **Архитектура**: - - Enum `KeyResult` (Handled, HandledNeedsRedraw, NotHandled, Quit) - результат обработки - - Trait `KeyHandler` - единый интерфейс для обработчиков клавиш - - Method `handle_key()` - обработка с Command enum - - Method `priority()` - приоритет обработчика для цепочки -- **Реализации**: - - `GlobalKeyHandler` - глобальные команды (Quit, OpenSearch, Cancel) - - `ChatListKeyHandler` - навигация по чатам (Up/Down, OpenChat, папки 1-9) - - `MessageViewKeyHandler` - просмотр сообщений (scroll, PageUp/Down, SearchInChat, Profile) - - `MessageSelectionKeyHandler` - действия с сообщением (Delete, Reply, Forward, Copy, React) - - `KeyHandlerChain` - цепочка обработчиков с автосортировкой по приоритету -- **Преимущества**: - - Разделение ответственности - каждый экран = свой handler - - Избавление от огромных match блоков - - Простое добавление новых режимов - - Type-safe через enum Command - - Композиция через KeyHandlerChain -- **Тесты**: 3 unit теста (все проходят) -- **Статус**: Готово к интеграции (TODO: методы в App, интеграция в main_input.rs) +5. **Integration** — `Album` arm в `render_message_list`, `x_offset` в second pass. Без feature `images` — fallback через отдельные bubble. -**🔍 NEW: Централизованная фильтрация чатов** -- **Модуль**: `src/app/chat_filter.rs` (470+ строк) -- **Архитектура**: - - Struct `ChatFilterCriteria` - критерии фильтрации с builder pattern - - Struct `ChatFilter` - централизованная логика фильтрации - - Enum `ChatSortOrder` - порядки сортировки -- **Возможности фильтрации**: - - По папке (folder_id) - - По поисковому запросу (название или @username, case-insensitive) - - Только закреплённые (pinned_only) - - Только непрочитанные (unread_only) - - Только с упоминаниями (mentions_only) - - Скрывать muted чаты (hide_muted) - - Скрывать архивные (hide_archived) -- **Методы**: - - `filter()` - основной метод фильтрации (без клонирования) - - `by_folder()` / `by_search()` - упрощённые варианты - - `count()` - подсчёт чатов - - `count_unread()` - подсчёт непрочитанных - - `count_unread_mentions()` - подсчёт упоминаний -- **Сортировка**: - - ByLastMessage - по времени последнего сообщения - - ByTitle - по алфавиту - - ByUnreadCount - по количеству непрочитанных - - PinnedFirst - закреплённые сверху -- **Преимущества**: - - Единый источник правды для фильтрации - - Убирает дублирование логики (App, UI, обработчики) - - Type-safe критерии через struct - - Builder pattern для удобного конструирования - - Эффективность (работает с references, без клонирования) -- **Тесты**: 6 unit тестов (все проходят) -- **Статус**: ✅ Интегрировано в App и ChatListState +**Модифицированные файлы:** +- `src/tdlib/types.rs` — `media_album_id` в `MessageMetadata`, `MessageBuilder`, getter +- `src/tdlib/messages/convert.rs` — передача `media_album_id` в builder +- `src/tdlib/message_converter.rs` — передача `media_album_id` в builder +- `src/message_grouping.rs` — `Album` variant + album detection + 4 новых теста +- `src/constants.rs` — album grid constants +- `src/ui/components/message_bubble.rs` — `x_offset` в `DeferredImageRender`, `render_album_bubble()` +- `src/ui/components/mod.rs` — export `render_album_bubble` +- `src/ui/messages.rs` — `Album` arm + `x_offset` в second pass -### Что сделано +6. **Навигация j/k по альбомам** — альбом обрабатывается как одно сообщение. `select_previous_message()` / `select_next_message()` перескакивают через все сообщения альбома. `start_message_selection()` встаёт на первый элемент альбома если последнее сообщение — часть альбома. -#### TDLib интеграция -- Подключена библиотека `tdlib-rs` v1.2.0 с автоматической загрузкой TDLib -- Реализована авторизация через телефон + код + 2FA пароль -- Сессия сохраняется автоматически в папке `tdlib_data/` -- Отключены логи TDLib через FFI вызов `td_execute` до создания клиента -- Updates обрабатываются в отдельном потоке через `mpsc` канал (неблокирующе) -- **Graceful shutdown**: корректное закрытие TDLib при выходе (Ctrl+C) +7. **Тесты** — 4 unit-теста в `message_grouping.rs`, 5 snapshot-тестов в `tests/messages.rs`, 3 теста навигации в `tests/input_navigation.rs`. -#### Функциональность -- Загрузка списка чатов (до 50 штук) -- **Фильтрация чатов**: показываются только чаты из ChatList::Main (без архива) -- **Фильтрация удалённых аккаунтов**: "Deleted Account" не отображаются в списке -- Отображение названия чата, счётчика непрочитанных и **@username** -- **Иконка 📌** для закреплённых чатов -- **Иконка 🔇** для замьюченных чатов -- **Индикатор @** для чатов с непрочитанными упоминаниями -- **Онлайн-статус**: зелёная точка ● для онлайн пользователей -- **Загрузка истории сообщений**: динамическая чанковая подгрузка (по 50 сообщений) - - Retry логика: до 20 попыток на чанк, ждет пока TDLib синхронизирует с сервера - - Лимит 300 сообщений при открытии чата (достаточно для заполнения экрана) - - Автоматическая подгрузка старых сообщений при скролле вверх (pagination) - - FIX: Убран i32::MAX лимит, который вызывал зависание в чатах с тысячами сообщений -- **Группировка сообщений по дате** (разделители "Сегодня", "Вчера", дата) — по центру -- **Группировка сообщений по отправителю** (заголовок с именем) -- **Выравнивание сообщений**: исходящие справа (зелёные), входящие слева -- **Перенос длинных сообщений**: автоматический wrap на несколько строк -- **Отображение времени и галочек**: `текст (HH:MM ✓✓)` для исходящих, `(HH:MM) текст` для входящих -- **Галочки прочтения** (✓ отправлено, ✓✓ прочитано) — обновляются в реальном времени -- **Отметка сообщений как прочитанных**: при открытии чата счётчик непрочитанных сбрасывается -- **Отправка текстовых сообщений** -- **Редактирование сообщений**: ↑ при пустом инпуте → выбор → Enter → редактирование -- **Удаление сообщений**: в режиме выбора нажать `d` / `в` / `Delete` → модалка подтверждения -- **Reply на сообщения**: в режиме выбора нажать `r` / `к` → режим ответа с превью -- **Forward сообщений**: в режиме выбора нажать `f` / `а` → выбор чата для пересылки -- **Отображение пересланных сообщений**: индикатор "↪ Переслано от" с именем отправителя -- **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений -- **Новые сообщения в реальном времени** при открытом чате -- **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username -- **Typing indicator** ("печатает..."): отображение статуса набора текста собеседником и отправка своего статуса -- **Закреплённые сообщения**: отображение pinned message вверху чата с переходом к нему -- **Поиск по сообщениям в чате** (Ctrl+F): поиск текста внутри открытого чата с навигацией по результатам -- **Черновики**: автосохранение набранного текста при переключении между чатами -- **Профиль пользователя/чата** (`i`): просмотр информации о собеседнике или группе -- **Копирование сообщений** (`y`/`н`): копирование текста сообщения в системный буфер обмена -- **Реакции на сообщения**: - - Отображение реакций под сообщениями - - Логика отображения: 1 человек = только emoji, 2+ = emoji + счётчик - - Свои реакции в рамках [👍], чужие без рамок 👍 - - Emoji picker с сеткой доступных реакций (8 в ряду) - - Добавление/удаление реакций (toggle) - - Обновление реакций в реальном времени через Update::MessageInteractionInfo -- **Конфигурационный файл** (`~/.config/tele-tui/config.toml`): - - Автоматическое создание дефолтного конфига при первом запуске - - **Настройка timezone**: формат "+03:00" или "-05:00" - - **Настройка цветов**: incoming_message, outgoing_message, selected_message, reaction_chosen, reaction_other - - **Credentials файл** (`~/.config/tele-tui/credentials`): API_ID и API_HASH - - Приоритет загрузки: ~/.config/tele-tui/credentials → .env → сообщение об ошибке с инструкциями -- **Кеширование имён пользователей**: имена загружаются асинхронно и обновляются в UI -- **Папки Telegram**: загрузка и переключение между папками (1-9) -- **Медиа-заглушки**: [Фото], [Видео], [Голосовое], [Стикер], [GIF] и др. -- **Markdown форматирование в сообщениях**: - - **Жирный** (bold) - - *Курсив* (italic) - - __Подчёркнутый__ (underline) - - ~~Зачёркнутый~~ (strikethrough) - - `Код` (inline code, Pre, PreCode) — cyan на тёмном фоне - - Спойлеры — скрытый текст (серый на сером) - - Ссылки (URL, TextUrl, Email, Phone) — синий с подчёркиванием - - @Упоминания — синий с подчёркиванием +**Дополнительно модифицированные файлы:** +- `src/app/methods/messages.rs` — навигация перескакивает альбомы +- `tests/helpers/test_data.rs` — `TestMessageBuilder::media_album_id()` +- `tests/messages.rs` — 5 snapshot-тестов для альбомов +- `tests/input_navigation.rs` — 3 теста навигации по альбомам -#### Состояние сети -- **Индикатор в футере**: показывает текущее состояние подключения - - `⚠ Нет сети` — красный, ожидание сети - - `⏳ Прокси...` — cyan, подключение к прокси - - `⏳ Подключение...` — cyan, подключение к серверам - - `⏳ Обновление...` — cyan, синхронизация данных +**Что НЕ меняется:** image modal (v), auto-download, одиночные фото. -#### Оптимизации -- **60 FPS ready**: poll таймаут 16ms, рендеринг только при изменениях (`needs_redraw` флаг) -- **Оптимизация памяти**: - - Очистка сообщений при закрытии чата - - Лимит кэша пользователей (500) - - Периодическая очистка неактивных записей -- **Минимальное разрешение**: предупреждение если терминал меньше 80x20 +--- -#### Динамический инпут -- **Автоматическое расширение**: поле ввода увеличивается при длинном тексте (до 10 строк) -- **Перенос текста**: длинные сообщения переносятся на новые строки -- **Блочный курсор**: vim-style курсор █ с возможностью перемещения по тексту +### Оптимизация: Ленивая загрузка сообщений при открытии чата (DONE) -#### Управление -- `↑/↓` стрелки — навигация по списку чатов -- `Enter` — открыть чат / отправить сообщение -- `Esc` — закрыть открытый чат / отменить поиск -- `Ctrl+S` — поиск по чатам (фильтрация по названию и username) -- `Ctrl+R` — обновить список чатов -- `Ctrl+C` — выход (graceful shutdown) -- `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых) -- `↑` при пустом инпуте — выбор сообщения для редактирования -- `Enter` в режиме выбора — начать редактирование -- `r` / `к` в режиме выбора — ответить на сообщение (reply) -- `f` / `а` в режиме выбора — переслать сообщение (forward) -- `d` / `в` / `Delete` в режиме выбора — удалить сообщение (с подтверждением) -- `y` / `н` / `Enter` — подтвердить удаление в модалке -- `n` / `т` / `Esc` — отменить удаление в модалке -- `Esc` — отменить выбор/редактирование/reply -- `1-9` — переключение папок (в списке чатов) -- `Ctrl+F` — поиск по сообщениям в открытом чате -- `n` / `N` — навигация по результатам поиска (следующий/предыдущий) -- `Ctrl+i` / `Ctrl+ш` — открыть профиль пользователя/чата -- `y` / `н` в режиме выбора — скопировать сообщение в буфер обмена -- `e` / `у` в режиме выбора — добавить реакцию (открывает emoji picker) -- `←` / `→` / `↑` / `↓` в emoji picker — навигация по сетке реакций -- `Enter` в emoji picker — добавить/удалить реакцию -- `Esc` в emoji picker — закрыть picker -- **Редактирование текста в инпуте:** - - `←` / `→` — перемещение курсора - - `Home` — курсор в начало - - `End` — курсор в конец - - `Backspace` — удалить символ слева - - `Delete` — удалить символ справа +Чат открывается мгновенно (< 1 сек) вместо 5-30 сек для больших чатов. -### Структура проекта +**Проблема**: `open_chat_and_load_data()` блокировал UI до полной загрузки ВСЕХ сообщений (`get_chat_history(chat_id, i32::MAX)`). Для чата с 500+ сообщениями это 10+ запросов к TDLib. + +**Решение**: +- Загрузка только 50 последних сообщений (один запрос) → чат виден сразу +- Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop через `pending_chat_init` +- Старые сообщения подгружаются при скролле вверх (существующий `load_older_messages_if_needed`) + +**Модифицированные файлы:** +- `src/app/mod.rs` — поле `pending_chat_init: Option` +- `src/input/handlers/chat_list.rs` — `open_chat_and_load_data()`: 50 сообщений + `pending_chat_init` +- `src/main.rs` — обработка `pending_chat_init` в main loop (reply info, pinned, photos) +- `src/app/methods/navigation.rs` — сброс `pending_chat_init` в `close_chat()` + +--- + +### Bugfix: Авто-загрузка фото в чате (DONE) + +Фото не отображались — отсутствовал код загрузки файлов после открытия чата. + +**Проблема**: `extract_media_info()` создавал `PhotoInfo` с `PhotoDownloadState::NotDownloaded`, но никакой код не инициировал `download_file()`. Фото оставались в состоянии "📷 [Фото]" без inline превью. + +**Исправление:** +- **Авто-загрузка при открытии чата**: после загрузки истории сообщений скачиваются фото из последних 30 сообщений (если `auto_download_images = true` и `show_images = true`). Каждый файл — с таймаутом 5 сек. +- **Загрузка по `v`**: вместо "Фото не загружено" — скачивание + открытие модалки. Также повторная попытка при `Error`. +- Обновление `PhotoDownloadState` в сообщении после успешной/неуспешной загрузки. + +**Модифицированные файлы:** +- `src/input/handlers/chat_list.rs` — авто-загрузка фото в `open_chat_and_load_data()` +- `src/input/handlers/chat.rs` — `handle_view_image()`: download on NotDownloaded + retry on Error + +--- + +### Этап 2+3: Account Switcher Modal + Переключение аккаунтов (DONE) + +Реализована модалка переключения аккаунтов и механизм переключения: + +- **Модалка `Ctrl+A`**: глобальный оверлей поверх любого экрана (Loading/Auth/Main) +- **Навигация**: `j/k` по списку, `Enter` выбор, `a` добавление, `Esc` закрытие +- **Переключение**: close TDLib → `recreate_client(new_db_path)` → auth flow +- **Добавление аккаунта**: ввод имени в модалке → валидация → `add_account()` → переключение +- **Footer индикатор**: `[account_name]` если не "default" +- **`AccountSwitcherState`**: enum `SelectAccount` / `AddAccount` — глобальный оверлей в `App` +- **`recreate_client()`**: новый метод в `TdClientTrait` — close old → new TdClient → spawn set_tdlib_parameters + +**Новые файлы:** +- `src/ui/modals/account_switcher.rs` — UI рендеринг (SelectAccount + AddAccount) +- `tests/account_switcher.rs` — 12 тестов + +**Модифицированные файлы:** +- `src/app/mod.rs` — `AccountSwitcherState` enum, 3 поля (`account_switcher`, `current_account_name`, `pending_account_switch`), 8 методов +- `src/accounts/manager.rs` — `add_account()` (validate + save + ensure_dir) +- `src/accounts/mod.rs` — re-export `add_account` +- `src/tdlib/trait.rs` — `recreate_client(&mut self, db_path)` в TdClientTrait +- `src/tdlib/client.rs` — реализация `recreate_client` (close → new → set_tdlib_parameters) +- `src/tdlib/client_impl.rs` — trait impl делегирование +- `tests/helpers/fake_tdclient_impl.rs` — no-op `recreate_client` +- `src/input/main_input.rs` — account_switcher роутинг (highest priority) +- `src/input/handlers/global.rs` — `Ctrl+A` → open_account_switcher +- `src/input/handlers/modal.rs` — `handle_account_switcher()` (SelectAccount + AddAccount input) +- `src/ui/modals/mod.rs` — `pub mod account_switcher;` +- `src/ui/mod.rs` — overlay поверх любого экрана +- `src/ui/footer.rs` — `[account_name]` индикатор +- `src/main.rs` — `pending_account_switch` check в run_app, `Ctrl+A` из любого экрана + +### Этап 1: Инфраструктура профилей аккаунтов (DONE) + +Реализована инфраструктура для мультиаккаунта: + +- **Модуль `accounts/`**: `profile.rs` (типы + валидация) + `manager.rs` (загрузка/сохранение/миграция) +- **`accounts.toml`**: конфиг списка аккаунтов в `~/.config/tele-tui/accounts.toml` +- **XDG data dir**: БД TDLib хранится в `~/.local/share/tele-tui/accounts/{name}/tdlib_data/` +- **Автомиграция**: `./tdlib_data/` → XDG path при первом запуске +- **CLI флаг `--account `**: выбор аккаунта при запуске +- **Параметризация `db_path`**: `TdClient::new(db_path)`, `App::new(config, db_path)` + +--- + +## Предыдущий статус: Multiline Message Display (DONE) + +### Multiline в сообщениях + +- **Multiline в сообщениях**: `\n` корректно отображается в пузырях сообщений (split по `\n` + word wrap) +- **Маркер выделения**: ▶ показывается только на первой строке multiline-сообщения +- Перенос строки в инпуте отключён (Shift+Enter/Alt+Enter/Ctrl+J не вставляют `\n`) + +**Файлы изменены:** +- `ui/components/message_bubble.rs` — `wrap_text_with_offsets()` split по `\n` + `wrap_paragraph()` + selection marker fix + +--- + +### Vim Normal/Insert Mode (DONE) + +Реализован Vim-like режим работы с двумя состояниями: + +- **Normal mode** (по умолчанию при открытии чата): навигация j/k, команды d/r/f/y, автоматический вход в MessageSelection +- **Insert mode** (нажать `i`/`ш`): набор текста, Esc возвращает в Normal +- Автопереключение в Insert при Reply (`r`) и Edit (`Enter`) +- Визуальные индикаторы: `[NORMAL]`/`[INSERT]` в footer, зелёная/серая рамка compose bar +- В Insert mode блокируются все команды кроме текстового ввода и Esc + +**Файлы изменены:** +- `app/chat_state.rs` — enum `InputMode` +- `app/mod.rs` — поле `input_mode` в `App` +- `config/keybindings.rs` — `Command::EnterInsertMode` + keybinding `i`/`ш` +- `app/methods/navigation.rs` — `close_chat()` сбрасывает input_mode +- `input/main_input.rs` — главный роутер Insert/Normal +- `input/handlers/chat.rs` — EnterInsertMode, auto-Insert при Reply/Edit +- `input/handlers/chat_list.rs` — auto-MessageSelection при открытии чата +- `ui/footer.rs` — mode indicator +- `ui/compose_bar.rs` — visual mode differentiation +- `tests/` — обновлены для нового поведения + +--- + +## Предыдущий статус: Фаза 12 — Прослушивание голосовых сообщений (DONE) + +### Завершённые фазы (краткий итог) + +| Фаза | Описание | Статус | +|------|----------|--------| +| 1 | Базовая инфраструктура (ratatui + crossterm, vim-навигация) | DONE | +| 2 | TDLib интеграция (авторизация, чаты, сообщения) | DONE | +| 3 | Улучшение UX (отправка, поиск, скролл, realtime) | DONE | +| 4 | Папки и фильтрация (загрузка папок, переключение 1-9) | DONE | +| 5 | Расширенный функционал (онлайн-статус, медиа-заглушки, muted) | DONE | +| 6 | Полировка (60 FPS, память, graceful shutdown, динамический инпут) | DONE | +| 7 | Рефакторинг памяти (единый источник данных, LRU-кэш) | DONE | +| 8 | Дополнительные фичи (markdown, edit/delete, reply/forward, блочный курсор) | DONE | +| 9 | Расширенные возможности (typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг) | DONE | +| 10 | Desktop уведомления (notify-rust, muted фильтр, mentions, медиа) | DONE (83%) | +| 11 | Inline просмотр фото (ratatui-image, кэш, загрузка) | DONE | +| 12 | Прослушивание голосовых сообщений (ffplay, play/pause, seek, ticker, cache, config) | DONE | +| 13 | Глубокий рефакторинг архитектуры (7 этапов) | DONE | + +### Фаза 11: Inline фото + оптимизации (подробности) + +Feature-gated (`images`), 2-tier архитектура: + +**Базовая реализация:** +1. **Типы**: `MediaInfo`, `PhotoInfo`, `PhotoDownloadState`, `ImageModalState`, `ImagesConfig` +2. **Зависимости**: `ratatui-image 8.1`, `image 0.25` (feature-gated) +3. **Media модуль**: `ImageCache` (LRU), dual `ImageRenderer` (inline + modal) +4. **UX**: Always-show inline preview (фикс. ширина 50 chars) + полноэкранная модалка на `v`/`м` +5. **Метаданные**: `extract_media_info()` из TDLib MessagePhoto; auto-download visible photos + +**Оптимизации производительности:** +1. **Dual protocol strategy**: + - `inline_image_renderer`: Halfblocks → быстро (Unicode блоки), для навигации + - `modal_image_renderer`: iTerm2/Sixel → медленно (high quality), для просмотра +2. **Frame throttling**: inline images 15 FPS (66ms), текст 60 FPS +3. **Lazy loading**: загрузка только видимых изображений (не все сразу) +4. **LRU кэш**: max 100 протоколов, eviction старых +5. **Loading indicator**: "⏳ Загрузка..." в модалке при первом открытии +6. **Navigation hotkeys**: `←`/`→` между фото, `Esc`/`q` закрыть модалку + +**UI рендеринг**: +- `message_bubble.rs`: photo status (Downloading/Error/placeholder), inline preview +- `messages.rs`: второй проход с `render_images()` + throttling + только видимые +- `modals/image_viewer.rs`: fullscreen modal с aspect ratio + loading state + +### Фаза 13: Рефакторинг (подробности) + +Разбиты 5 монолитных файлов (4582 строк) на модульную архитектуру: + +- **input/main_input.rs** (1199→164): чистый роутер + 5 handler модулей в `handlers/` +- **app/mod.rs** (1015→371): 5 trait модулей в `methods/` (Navigation, Message, Compose, Search, Modal) +- **ui/messages.rs** (893→365): модули `modals/` (search, pinned, delete, reactions) + `compose_bar.rs` +- **tdlib/messages.rs** (836→3 файла): `messages/` (mod, convert, operations) +- **config/mod.rs** (642→3 файла): validation.rs, loader.rs +- **Очистка дублей**: ~220 строк удалено (shared components, format_user_status, scroll_to_message) +- **Документация**: PROJECT_STRUCTURE.md переписан, 16 файлов получили `//!` docs + +### Фаза 12: Голосовые сообщения (подробности) + +**Реализовано:** +- **AudioPlayer** на ffplay (subprocess): play, pause (SIGSTOP), resume (SIGCONT), stop +- **VoiceCache**: LRU кэш OGG файлов в `~/.cache/tele-tui/voice/` (max 100 MB) +- **Типы**: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus` +- **TDLib интеграция**: `download_voice_note()`, конвертация `MessageVoiceNote` +- **Хоткеи**: Space (play/pause), ←/→ (seek ±5s via ffplay restart с `-ss`) +- **Автостоп**: при навигации на другое сообщение воспроизведение останавливается +- **Ticker**: `last_playback_tick` в App + обновление position в event loop (1 FPS redraw) +- **VoiceCache**: проверка кэша перед загрузкой, кэширование после download +- **AudioConfig**: `[audio]` секция в config.toml (cache_size_mb, auto_download_voice) +- **UI**: progress bar (━●─) + waveform (▁▂▃▄▅▆▇█) + иконки статуса в `message_bubble.rs` +- **Race condition fixes**: `starting` flag + pid ownership guard в потоках AudioPlayer +- **Seek**: `resume_from()` перезапускает ffplay с `-ss` offset; MoveLeft/MoveRight как alias для SeekBackward/SeekForward +- **Resume with rewind**: пауза→продолжение откатывает на 1 секунду назад +- **Graceful shutdown**: `stop_playback()` + Drop impl для AudioPlayer + +### Ключевая архитектура ``` -src/ -├── main.rs # Точка входа, event loop, TDLib инициализация, graceful shutdown -├── lib.rs # Библиотечный интерфейс (для тестов) -├── types.rs # Типобезопасные обёртки (ChatId, MessageId, UserId) -├── config.rs # Конфигурация (TOML), загрузка credentials -├── error.rs # TeletuiError enum, Result type alias -├── constants.rs # Константы проекта (MAX_MESSAGES_IN_CHAT, POLL_TIMEOUT_MS, etc.) -├── formatting.rs # Markdown форматирование (CharStyle, format_text_with_entities) -├── app/ -│ ├── mod.rs # App структура и состояние (needs_redraw флаг) -│ ├── state.rs # AppScreen enum -│ └── chat_state.rs # ChatState enum (Normal, MessageSelection, Editing, etc.) -├── ui/ -│ ├── mod.rs # Роутинг UI по экранам, проверка минимального размера -│ ├── loading.rs # Экран загрузки -│ ├── auth.rs # Экран авторизации -│ ├── main_screen.rs # Главный экран с папками -│ ├── chat_list.rs # Список чатов (pin, mute, online, mentions) -│ ├── messages.rs # Область сообщений (wrap, группировка, динамический инпут) -│ ├── footer.rs # Подвал с командами и статусом сети -│ ├── profile.rs # Экран профиля пользователя/чата -│ └── components/ # Переиспользуемые UI компоненты -│ ├── mod.rs -│ ├── modal.rs -│ ├── input_field.rs -│ ├── message_bubble.rs -│ ├── chat_list_item.rs -│ └── emoji_picker.rs -├── input/ -│ ├── mod.rs # Роутинг ввода -│ ├── auth.rs # Обработка ввода на экране авторизации -│ └── main_input.rs # Обработка ввода на главном экране -├── utils.rs # Утилиты (disable_tdlib_logs, format_timestamp_with_tz, format_date, get_day) -└── tdlib/ - ├── mod.rs # Модуль экспорта (TdClient, UserOnlineStatus, NetworkState) - ├── client.rs # TdClient: авторизация, chats, messages, users, reactions - ├── auth.rs # AuthManager + AuthState enum - ├── chats.rs # ChatManager для операций с чатами - ├── messages.rs # MessageManager для сообщений - ├── users.rs # UserCache с LRU кэшем - ├── reactions.rs # ReactionManager - └── types.rs # Общие типы данных (ChatInfo, MessageInfo, MessageBuilder, etc.) - -tests/ -├── helpers/ -│ ├── mod.rs # Экспорт тестовых утилит -│ ├── app_builder.rs # TestAppBuilder для создания тестовых App -│ ├── fake_tdclient.rs # FakeTdClient (mock TDLib клиент, для будущих интеграционных тестов) -│ ├── snapshot_utils.rs # Утилиты для snapshot тестов (render_to_buffer, buffer_to_string) -│ └── test_data.rs # Builders для тестовых данных (TestChatBuilder, TestMessageBuilder) -├── chat_list.rs # Snapshot тесты для списка чатов (9 тестов) -└── messages.rs # Snapshot тесты для сообщений (19 тестов) +main.rs → event loop (16ms poll) +├── input/ → роутер + handlers/ (chat, chat_list, compose, modal, search) +├── app/ → App + methods/ (5 traits, 67 методов) +├── ui/ → рендеринг (messages, chat_list, modals/, compose_bar, components/) +├── audio/ → player.rs (ffplay), cache.rs (VoiceCache) +├── media/ → [feature=images] cache.rs, image_renderer.rs +└── tdlib/ → TDLib wrapper (client, auth, chats, messages/, users, reactions, types) ``` ### Тестирование -**Статус**: ПОЛНОСТЬЮ ЗАВЕРШЕНО! (100%) — Все тесты готовы! 🎉🎊🚀 - -**Стратегия**: Комбо подход — 70% snapshot tests, 25% integration tests, 5% e2e smoke tests + performance benchmarks - -**Инфраструктура (Фаза 0)**: ✅ Завершена -- Добавлены зависимости: `insta = "1.34"`, `tokio-test = "0.4"`, `criterion = "0.5"` -- Создан `src/lib.rs` для экспорта модулей в тесты -- Созданы test helpers: - - `TestAppBuilder` — fluent builder для создания тестовых App - - `TestChatBuilder` / `TestMessageBuilder` — builders для тестовых данных - - `FakeTdClient` — in-memory mock TDLib клиента - - `render_to_buffer` / `buffer_to_string` — утилиты для snapshot тестов - -**Snapshot Tests (Фаза 1)**: ✅ 57/57 (100%) -- ✅ **1.1 Chat List** (10/10): пустой список, множественные чаты, unread, pinned, muted, mentions, selected, long title, search mode, online status -- ✅ **1.2 Messages** (19/19): empty chat, incoming/outgoing, date separators, sender grouping, read receipts, edited, long message wrap, markdown, media, reply, forwarded, reactions, selected -- ✅ **1.3 Modals** (8/8): delete confirmation, emoji picker, profile, pinned message, search, forward -- ✅ **1.4 Input Field** (7/7): empty, text, long text, editing/reply/search modes -- ✅ **1.5 Footer** (6/6): chat list, open chat, network states, search mode -- ✅ **1.6 Screens** (7/7): loading, auth, main, terminal size warning - -**Integration Tests (Фаза 2)**: ✅ 93/93 (100%!) -- ✅ **2.1 Send Message Flow** (6/6): отправка текста, множественные, форматирование, разные чаты, входящие, reply -- ✅ **2.2 Edit Message Flow** (6/6): изменение текста, edit_date, can_be_edited, только свои, множественные, форматирование -- ✅ **2.3 Delete Message Flow** (6/6): удаление из списка, множественные, can_be_deleted, только свои, разные чаты, revoke -- ✅ **2.4 Reply & Forward Flow** (8/8): reply с превью, связь с оригиналом, forward с sender, разные чаты, комбо -- ✅ **2.5 Reactions Flow** (10/10): добавление, toggle, множественные, разные юзеры, подсчёт, chosen, realtime, доступные, на forwarded, очистка -- ✅ **2.6 Search Flow** (8/8): по названию, username, сообщениям, навигация, case-insensitive, пробелы, пустой, очистка -- ✅ **2.7 Drafts Flow** (7/7): сохранение, восстановление, удаление, независимые, индикатор, пустой, закрытие чата -- ✅ **2.8 Navigation Flow** (7/7): списку чатов, открытие, закрытие, скролл, папки, wrap, пустой список -- ✅ **2.9 Profile Flow** (6/6): личный чат, имя+username, телефон, группа, участники, закрытие -- ✅ **2.10 Network & Typing Flow** (9/9): typing indicator, action, статус, timeout, network states (5) -- ✅ **2.11 Copy Flow** (9/9): форматирование plain, forward, reply, оба контекста, длинные, markdown, clipboard init, clipboard test, кроссплатформенность -- ✅ **2.12 Config Flow** (11/11): дефолты, кастомные, валидные цвета, light цвета, невалидные (fallback), case-insensitive, TOML сериализация, частичный TOML, timezone форматы, credentials из env, credentials ошибка - -**E2E Tests (Фаза 3)**: ✅ 12/12 (100%!) -- ✅ **3.1 Smoke Tests** (4/4): базовые структуры, минимальный размер терминала, константы, graceful shutdown -- ✅ **3.2 User Journey** (8/8): app launch, open chat, send message, receive message, multi-step conversation, switch chats, edit/reply flows, network changes - -**Utils Tests (Фаза 4.1)**: ✅ 18/18 (100%!) -- ✅ `format_timestamp_with_tz`: 5 тестов (positive offset, negative offset, zero offset, midnight wrap, invalid fallback) -- ✅ `get_day`: 2 теста (основной, группировка) -- ✅ `format_datetime`: 1 тест -- ✅ `parse_timezone_offset`: 1 тест -- ✅ `format_date`: 4 теста (today, yesterday, old, epoch) -- ✅ `format_was_online`: 5 тестов (just now, minutes ago, hours ago, days ago, very old) - -**Performance Benchmarks (Фаза 4.2)**: ✅ 8/8 (100%!) -- ✅ `group_messages.rs`: benchmark группировки сообщений (100, 500) -- ✅ `formatting.rs`: benchmark форматирования (timestamp, date, get_day) -- ✅ `format_markdown.rs`: benchmark markdown (simple, entities, long text) - -**ИТОГО**: 188 тестов + 8 benchmarks = 196 тестов (100%)! 🎉🎊🚀 -- Фаза 0: Инфраструктура ✅ -- Фаза 1: UI Snapshot Tests ✅ (57 тестов) -- Фаза 2: Integration Tests ✅ (93 теста) -- Фаза 3: E2E Tests ✅ (12 тестов) -- Фаза 4.1: Utils Tests ✅ (18 тестов) -- Фаза 4.2: Performance Benchmarks ✅ (8 benchmarks) - -Подробный план и roadmap: см. [TESTING_ROADMAP.md](TESTING_ROADMAP.md) +500+ тестов (0 failures): +- Snapshot tests: 57 (UI компоненты) +- Integration tests: 93 (user flows) +- E2E tests: 12 (smoke + journey) +- Utils tests: 18 +- Performance benchmarks: 8 ### Ключевые решения -1. **Неблокирующий receive**: TDLib updates приходят в отдельном потоке и передаются в main loop через `mpsc::channel`. Это позволяет UI оставаться отзывчивым. +1. **Неблокирующий receive**: TDLib updates через `mpsc::channel` в отдельном потоке +2. **Trait-based App**: методы разбиты на traits — требуют `use` import на call site +3. **FakeTdClient**: mock для тестов без TDLib (реализует TdClientTrait) +4. **Оптимизация рендеринга**: `needs_redraw` флаг, рендеринг только при изменениях +5. **Конфиг**: TOML `~/.config/tele-tui/config.toml`, credentials с приоритетом (XDG → .env) +6. **Feature-gated images**: `images` feature flag для ratatui-image + image deps +7. **Dual renderer**: inline (Halfblocks, 15 FPS) + modal (iTerm2/Sixel, high quality) для баланса скорости/качества +8. **Audio via ffplay**: subprocess с SIGSTOP/SIGCONT для pause/resume, автостоп при навигации -2. **FFI для логов**: Используем прямой вызов `td_execute` для отключения логов синхронно, до создания клиента, чтобы избежать вывода в терминал. - -3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`. - -4. **Кеширование имён**: При получении `Update::User` сохраняем имя (first_name + last_name) и username в HashMap. Имена подгружаются асинхронно через очередь `pending_user_ids`. Кэш ограничен 500 записями. - -5. **Группировка сообщений**: Сообщения группируются по дате (разделители по центру) и по отправителю (заголовки). Исходящие выравниваются вправо, входящие влево. - -6. **Отметка прочтения**: При открытии чата вызывается `view_messages` для всех сообщений. Новые входящие сообщения автоматически отмечаются как прочитанные. `Update::ChatReadOutbox` обновляет статус галочек. - -7. **Graceful shutdown**: При Ctrl+C устанавливается флаг остановки, закрывается TDLib клиент, ожидается завершение polling задачи с таймаутом 2 сек. - -8. **Оптимизация рендеринга**: Флаг `needs_redraw` позволяет пропускать перерисовку когда ничего не изменилось. Триггеры: TDLib updates, пользовательский ввод, изменение размера терминала. - -9. **Перенос текста**: Длинные сообщения автоматически разбиваются на строки с учётом ширины терминала. Для исходящих — time_mark на последней строке, для входящих — время на первой строке с отступом для остальных. - -10. **Конфигурационный файл**: TOML конфиг создаётся автоматически при первом запуске в `~/.config/tele-tui/config.toml`. Поддерживает настройку timezone (применяется к отображению времени через `format_timestamp_with_tz`) и цветовой схемы (парсится в `ratatui::style::Color`). Credentials загружаются с приоритетом: XDG config dir → .env → ошибка с инструкциями. - -11. **Реакции**: Хранятся в `Vec` для каждого сообщения. Обновляются в реальном времени через `Update::MessageInteractionInfo`. Emoji picker использует сетку 8x6 с навигацией стрелками. Приоритет обработки ввода: reaction picker → delete confirmation → остальные модалки (важно для корректной работы Enter/Esc). - -### Зависимости (Cargo.toml) +### Зависимости (основные) ```toml -ratatui = "0.29" -crossterm = "0.28" -tdlib-rs = { version = "1.1", features = ["download-tdlib"] } -tokio = { version = "1", features = ["full"] } -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -dotenvy = "0.15" -chrono = "0.4" -clipboard = "0.5" -toml = "0.8" -dirs = "5.0" +ratatui = "0.29" # TUI фреймворк +crossterm = "0.28" # Терминальный backend +tdlib-rs = "1.1" # Telegram TDLib binding +tokio = "1" # Async runtime +notify-rust = "4.11" # Desktop уведомления (feature flag) +ratatui-image = "8.1" # Inline images (feature flag) +image = "0.25" # Image decoding (feature flag) ``` -### API Credentials - -Приоритет загрузки (от высшего к низшему): - -1. **Файл credentials** (`~/.config/tele-tui/credentials`): -``` -API_ID=your_api_id -API_HASH=your_api_hash -``` - -2. **Переменные окружения** (`.env` файл в текущей директории): -``` -API_ID=your_api_id -API_HASH=your_api_hash -``` - -3. Если ничего не найдено — показывается сообщение об ошибке с инструкциями. - -### Конфигурационный файл - -Создаётся автоматически при первом запуске в `~/.config/tele-tui/config.toml`: - -```toml -[general] -# Часовой пояс в формате "+03:00" или "-05:00" -# Применяется к отображению времени сообщений -timezone = "+03:00" - -[colors] -# Цветовая схема (поддерживаемые цвета: black, red, green, yellow, blue, magenta, cyan, gray, white, darkgray, lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan) - -# Цвет входящих сообщений -incoming_message = "white" - -# Цвет исходящих сообщений -outgoing_message = "green" - -# Цвет выбранного сообщения -selected_message = "yellow" - -# Цвет своих реакций (в рамках [👍]) -reaction_chosen = "yellow" - -# Цвет чужих реакций -reaction_other = "gray" -``` - -## Последние обновления (2026-02-03) - -### Рефакторинг — Упрощение main_input.rs ✅ ПОЛНОСТЬЮ ЗАВЕРШЕНО (2026-02-03) - -**Цель**: Упростить функцию `handle()` в `main_input.rs` путём извлечения обработчиков режимов в отдельные функции. - -**Phase 1** — Базовые режимы (не выполнялась в текущей сессии, была ранее) - -**Phase 2** — Обработка клавиатуры (~163 строки): - -1. ✅ **`handle_open_chat_keyboard_input()`** (~129 строк) - - Backspace/Delete для редактирования текста - - Char для ввода символов + typing status (throttling 5 сек) - - Навигация курсора (Left/Right/Home/End) - - Скролл сообщений (Up/Down) с подгрузкой старых - -2. ✅ **`handle_chat_list_navigation()`** (~34 строки) - - Навигация по чатам: Up/Down/j/k - - Переключение папок: цифры 1-9 (1=All, 2-9=папки) - -**Phase 3** — Все оставшиеся режимы и действия (~783 строки): - -3. ✅ **`handle_profile_mode()`** (~120 строк) - - Режим профиля пользователя/чата - - Модалка подтверждения выхода из группы (двухшаговая) - - Открытие в браузере, копирование ID - -4. ✅ **`handle_message_search_mode()`** (~73 строки) - - Поиск по сообщениям в открытом чате (Ctrl+F) - - Навигация по результатам, переход к сообщению - -5. ✅ **`handle_pinned_mode()`** (~42 строки) - - Режим просмотра закреплённых сообщений - - Навигация и переход к сообщению в истории - -6. ✅ **`handle_reaction_picker_mode()`** (~90 строк) - - Emoji picker для добавления реакций - - Навигация по сетке 8x6, toggle реакции - -7. ✅ **`handle_delete_confirmation()`** (~60 строк) - - Модалка подтверждения удаления сообщения - - Обработка yes/no, удаление для себя/всех - -8. ✅ **`handle_forward_mode()`** (~52 строки) - - Выбор чата для пересылки сообщения - - Навигация по списку чатов, отправка - -9. ✅ **`handle_chat_search_mode()`** (~43 строки) - - Поиск по чатам (Ctrl+S) - - Фильтрация списка, открытие чата - -10. ✅ **`handle_enter_key()`** (~145 строк) - - Открытие чата из списка - - Отправка/редактирование сообщений - - Начало редактирования из режима выбора - -11. ✅ **`handle_escape_key()`** (~35 строк) - - Обработка Esc: отмена действий или закрытие чата - - Сохранение черновика при закрытии - -12. ✅ **`handle_message_selection()`** (~95 строк) - - Режим выбора сообщения в открытом чате - - Действия: reply, forward, delete, copy, react - -13. ✅ **`handle_profile_open()`** (~28 строк) - - Ctrl+U для открытия профиля чата/пользователя - -**Итоговый результат**: -- ✅ Функция `handle()` сократилась с **891 до 82 строк** (91% сокращение! 🎉) -- ✅ Извлечено **13 специализированных функций** (~946 строк кода) -- ✅ Каждая функция имеет чёткую ответственность и подробную документацию -- ✅ Код стал **линейным и простым для понимания** -- ✅ Функция handle() теперь читается как оглавление - всё понятно с первого взгляда -- ✅ Все 196 тестов (188 tests + 8 benchmarks) проходят успешно - -**Также**: -- ✅ Обновлён `tdlib-rs` с версии 1.1 на 1.2.0 - -**Файлы изменены**: -- `src/input/main_input.rs` — извлечено 13 функций-обработчиков, handle() сократилась с 891 до 82 строк -- `Cargo.toml` — обновлена версия tdlib-rs -- `CONTEXT.md` — обновлён контекст проекта - -**Phase 4** — Упрощение вложенности (применены паттерны): - -- ✅ **Early returns** - замена if-else на ранние выходы -- ✅ **Let-else guards** - замена `if let Some` на `let Some(...) else { return }` -- ✅ **Вспомогательные функции** - извлечение сложной логики - - `edit_message()` - редактирование сообщения (~50 строк) - - `send_new_message()` - отправка нового сообщения (~55 строк) - - `perform_message_search()` - поиск по сообщениям (~20 строк) - -**Упрощённые функции**: -- `handle_profile_mode()` - упрощён блок Enter с let-else -- `handle_profile_open()` - применён early return guard -- `handle_enter_key()` - разделена на части, сокращена с ~130 до ~40 строк -- `handle_message_search_mode()` - извлечена логика поиска -- `handle_escape_key()` - преобразован в early returns -- `handle_message_selection()` - применены let-else guards - -**Результат Phase 4**: -- ✅ Глубина вложенности: **6+ уровней → 2-3 уровня** -- ✅ Код стал **максимально линейным и читаемым** -- ✅ Применены современные Rust паттерны (let-else, guards) -- ✅ Извлечено 3 дополнительных вспомогательных функции - -**Коммиты**: -- `f4c24dd` — Phase 2: extract keyboard and navigation handlers (2 функции) -- `45d03b5` — Phase 3: complete main_input.rs simplification (11 функций) -- `67fd750` — Phase 4: reduce nesting with early returns and guard clauses -- `9d9232f` — Phase 4: complete nesting simplification with let-else guards - ---- - -## Последние обновления (2026-02-02) - -### Исправление критической ошибки — Stack Overflow при работе с сообщениями ✅ (2026-02-02) - -**Проблема**: -- Stack overflow при запуске приложения, отправке и редактировании сообщений -- Ошибка: `thread 'main' has overflowed its stack fatal runtime error: stack overflow, aborting` - -**Причина**: -Бесконечная рекурсия в trait реализации из-за несоответствия сигнатур методов между trait и inherent impl: -- Trait методы: `&mut self` -- TdClient inherent методы: `&self` -- При вызове `self.method()` внутри trait impl, Rust не мог вызвать inherent метод (несовместимость типов) и вызывал trait метод → бесконечная рекурсия - -**Исправлено 6 методов**: - -1. **`send_message`** - прямой вызов `self.message_manager.send_message()` вместо `self.send_message()` -2. **`edit_message`** - прямой вызов `self.message_manager.edit_message()` -3. **`delete_messages`** - прямой вызов `self.message_manager.delete_messages()` -4. **`forward_messages`** - прямой вызов `self.message_manager.forward_messages()` -5. **`current_chat_messages`** - прямой доступ `self.message_manager.current_chat_messages.to_vec()` -6. **`current_pinned_message`** - прямой доступ `self.message_manager.current_pinned_message.clone()` - -**Результат**: -- ✅ Компиляция успешна -- ✅ Все 196+ тестов проходят -- ✅ Приложение запускается без ошибок -- ✅ Отправка сообщений работает -- ✅ Редактирование сообщений работает -- ✅ Удаление и пересылка сообщений работают - -**Файлы изменены**: -- `src/tdlib/client_impl.rs` - исправлены 6 методов trait реализации - ---- - -### Рефакторинг — Dependency Injection для TdClient ЗАВЕРШЁН ✅ (2026-02-02) - -**Статус**: ВСЕ 8 ЭТАПОВ ЗАВЕРШЕНЫ! 🎉 - -**Цель**: Реализовать trait-based DI для TdClient, чтобы тесты использовали FakeTdClient вместо реального TDLib клиента. - -**План (8 этапов) - ВСЕ ГОТОВО**: -1. ✅ Создать trait TdClientTrait -2. ✅ Реализовать trait для TdClient -3. ✅ Реализовать trait для FakeTdClient -4. ✅ Сделать App generic: `App` -5. ✅ Обновить все input handlers (generic) -6. ✅ Обновить все UI модули (generic) -7. ✅ Обновить TestAppBuilder и тесты -8. ✅ Убрать timeout'ы (100ms), запустить тесты - -**Что сделано (ВСЕ ЭТАПЫ)**: - -**Этапы 1-2: Trait и impl для TdClient** -- ✅ Создан `src/tdlib/trait.rs` (130 строк): - - Trait `TdClientTrait` с 40+ методами - - Все async методы с `#[async_trait]` - - Auth, Chat, Message, User, Reaction методы - - Getters/Setters для state - -- ✅ Создан `src/tdlib/client_impl.rs` (270 строк): - - `impl TdClientTrait for TdClient` - - Все методы делегируют к существующим - - Полное покрытие API - -**Этап 3: FakeTdClient trait impl** -- ✅ Создан `tests/helpers/fake_tdclient_impl.rs` (~300 строк): - - `impl TdClientTrait for FakeTdClient` - - Делегирование к методам FakeTdClient - - Обработка Arc> vs &references design limitation - - Некоторые методы возвращают пустые значения (для UI-only полей) - -**Этап 4: Generic App** -- ✅ Обновлён `src/app/mod.rs`: - - `pub struct App` - - `impl App` - generic impl со всеми методами - - `impl App` - convenience `new(config)` для продакшена - - `with_client(config, td_client)` - generic конструктор - -**Этап 5: Generic input handlers** -- ✅ Обновлены ВСЕ input handlers: - - `src/input/main_input.rs` - `handle(app: &mut App)` - - `src/input/auth.rs` - generic - - `src/input/handlers/global.rs` - `handle_global_commands()` + `handle_pinned_messages()` - - `src/input/handlers/profile.rs` - generic - - `src/input/handlers/chat_list.rs` - generic - - `src/input/handlers/modal.rs` - все 4 функции generic - - `src/input/handlers/search.rs` - обе функции generic - - `src/input/handlers/messages.rs` - generic - -**Этап 6: Generic UI modules** -- ✅ Обновлены ВСЕ UI модули: - - `src/ui/mod.rs` - `render()` - - `src/ui/loading.rs` - generic - - `src/ui/auth.rs` - generic - - `src/ui/main_screen.rs` - generic - - `src/ui/chat_list.rs` - generic - - `src/ui/footer.rs` - generic - - `src/ui/messages.rs` - generic - - `src/ui/profile.rs` - generic - -**Этап 7: Тесты и TestAppBuilder** -- ✅ Обновлён `tests/helpers/app_builder.rs`: - - `build() -> App` вместо `App` - - Использует `FakeTdClient::new()` + builder pattern - - Чистая работа без обращения к internal fields - - Все тесты билдера обновлены -- ✅ Обновлён `src/main.rs`: - - `run_app()` - generic - - `main()` использует `App::new(config)` - работает как раньше - -**Этап 8: Удалены timeout'ы** -- ✅ Удалены 3 timeout wrapper'а из `src/input/main_input.rs`: - - Typing status send (line ~869) - убран `tokio::time::timeout(100ms)` - - Draft save (line ~685) - убран `tokio::time::timeout(100ms)` - - Draft clear (line ~691) - убран `tokio::time::timeout(100ms)` -- Причина удаления: timeout'ы были добавлены "чтобы не блокировать UI в тестах", но теперь тесты используют FakeTdClient который возвращается мгновенно - -**Файлы созданы**: -- `src/tdlib/trait.rs` - trait definition -- `src/tdlib/client_impl.rs` - impl for TdClient -- `tests/helpers/fake_tdclient_impl.rs` - impl for FakeTdClient - -**Файлы изменены (основные)**: -- `src/tdlib/mod.rs` - экспорты FolderInfo, UserCache, TdClientTrait -- `src/app/mod.rs` - generic App -- `src/main.rs` - generic run_app() -- `src/input/*.rs` - все handlers generic -- `src/ui/*.rs` - все UI функции generic -- `tests/helpers/app_builder.rs` - build() -> App -- `tests/helpers/mod.rs` - добавлен fake_tdclient_impl модуль -- `Cargo.toml` - добавлен async-trait - -**Результат**: -- ✅ Чистая архитектура с trait-based DI -- ✅ App работает с любым T: TdClientTrait -- ✅ Тесты используют FakeTdClient (быстро, без логов) -- ✅ Продакшн использует TdClient (реальный TDLib) -- ✅ Убраны timeout'ы из продакшн кода -- ✅ Priority 6 ЗАВЕРШЁН на 100%! 🎉 - ---- - -## Последние обновления (2026-02-02 ранее) - -### Рефакторинг — UI компоненты message_bubble.rs ЗАВЕРШЁН ✅ (2026-02-02) - -**Что сделано**: -- ✅ Создан полноценный модуль `src/ui/components/message_bubble.rs` (437 строк): - - `render_date_separator()` — рендеринг разделителей дат с центрированием - - `render_sender_header()` — рендеринг заголовков отправителей (входящие/исходящие) - - `render_message_bubble()` — рендеринг сообщений (forward, reply, текст с entities, реакции) - - Функция `wrap_text_with_offsets()` для переноса длинных текстов - -- ✅ Упрощён `src/ui/messages.rs`: - - Удалено **~300 строк** ручной группировки и рендеринга - - Используется `message_grouping::group_messages()` для логической группировки - - Используются компоненты для рендеринга каждого типа `MessageGroup` - - Код стал чище и понятнее - -- ✅ Обновлены модули: - - `src/ui/components/mod.rs` — добавлены экспорты новых функций - - `src/main.rs` — добавлен `mod message_grouping;` - -**Результат**: -- ✅ Все **196 тестов** (188 tests + 8 benchmarks) прошли успешно -- ✅ Ничего не сломалось - тесты защитили от регрессии -- ✅ **P3.7 — UI компоненты**: 5/5 (100%) ЗАВЕРШЕНО! -- ✅ Код стал модульным и переиспользуемым -- ✅ Упрощена поддержка и тестирование - -**Преимущества**: -- 📦 Разделение ответственности — логика (grouping) отделена от представления (rendering) -- 🔄 Переиспользуемые компоненты для рендеринга сообщений -- 🧪 Проще тестировать отдельные части -- 📖 Улучшенная читаемость кода -- 🛡️ Тесты подтвердили корректность рефакторинга - -**Файлы изменены**: -- `src/ui/components/message_bubble.rs` — создан (437 строк) -- `src/ui/components/mod.rs` — добавлены экспорты -- `src/ui/messages.rs` — упрощён (~300 строк удалено, используются компоненты) -- `src/main.rs` — добавлен `mod message_grouping;` -- `REFACTORING_ROADMAP.md` — обновлён статус P3.7 -- `CONTEXT.md` — добавлена запись об изменениях - ---- - -## Последние обновления (2026-02-02 СЕЙЧАС) - -### Интеграция validation utils — Завершение рефакторинга #1 ✅ (2026-02-02) - -**Проблема**: -- Модуль `validation.rs` был создан, но НИ РАЗУ не использовался в реальном коде -- Экспорт был закомментирован в `utils/mod.rs` -- 4 места с проверкой `.is_empty()` должны были использовать `is_non_empty()` -- Оставался 1 прямой вызов `tokio::time::timeout` в main.rs - -**Что исправлено**: - -1. ✅ **Раскомментирован экспорт validation** (src/utils/mod.rs:11) - ```rust - pub use validation::*; // Теперь экспортируется! - ``` - -2. ✅ **Интегрирован is_non_empty() в 4 местах**: - - `src/input/auth.rs:18` — валидация phone_input перед отправкой - - `src/input/auth.rs:50` — валидация code_input перед отправкой - - `src/input/auth.rs:82` — валидация password_input перед отправкой - - `src/input/main_input.rs:484` — валидация message_input перед отправкой/редактированием - -3. ✅ **Заменён последний прямой timeout** (src/main.rs:180) - ```rust - // Было: - let _ = tokio::time::timeout(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await; - - // Стало: - with_timeout_ignore(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await; - ``` - -**Итог**: -- ✅ **Категория #1 (Дублирование кода) ПОЛНОСТЬЮ ЗАВЕРШЕНА!** - - retry utils: 100% покрытие (0 прямых timeout вызовов) - - modal_handler: интегрирован в 2 диалогах - - validation: интегрирован в 4 местах -- ✅ Все утилиты созданы, протестированы И применены в реальном коде -- ✅ Дублирование устранено на ~15-20% кодовой базы - -**Файлы изменены**: -- `src/utils/mod.rs` — раскомментирован экспорт validation -- `src/input/auth.rs` — 3 замены на is_non_empty() -- `src/input/main_input.rs` — 1 замена на is_non_empty() -- `src/main.rs` — замена timeout на with_timeout_ignore -- `REFACTORING_OPPORTUNITIES.md` — обновлён статус категории #1 -- `CONTEXT.md` — добавлена запись об изменениях - ---- - -## Последние обновления (2026-02-02 ранее) - -### Исправление интеграционных тестов — Проблема с TDLib в тестах ✅ (2026-02-02) - -**Проблема**: -- 5 интеграционных тестов зависали более 60 секунд: - - `test_russian_keyboard_navigation` - - `test_backspace_with_cursor` - - `test_cursor_navigation_in_input` - - `test_esc_closes_chat` - - `test_home_end_in_input` - - `test_insert_char_at_cursor_position` -- Причина: тесты создавали настоящий `TdClient`, который вызывал `tdlib_rs::create_client()` -- TDLib не был инициализирован параметрами и блокировал async вызовы -- Verbose логи от TDLib загромождали вывод тестов - -**Что исправлено**: - -1. ✅ **Русская раскладка навигации** (src/input/main_input.rs:945): - - Исправлена ошибка: использовалась 'ц' вместо 'р' для движения вверх - - Правильно: `KeyCode::Char('р')` (русская k) для Up - -2. ✅ **Timeout для send_chat_action при вводе** (src/input/main_input.rs:867-870): - ```rust - let _ = tokio::time::timeout( - Duration::from_millis(100), - app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing) - ).await; - ``` - -3. ✅ **Timeout для set_draft_message при закрытии чата** (src/input/main_input.rs:683-692): - ```rust - let _ = tokio::time::timeout( - Duration::from_millis(100), - app.td_client.set_draft_message(chat_id, draft_text) - ).await; - ``` - -4. ✅ **Timeout для send_chat_action Cancel при отправке** (src/input/main_input.rs:592-594): - ```rust - let _ = tokio::time::timeout( - Duration::from_millis(100), - app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel) - ).await; - ``` - -**Результат**: -- ✅ Все 6 тестов проходят успешно за **0.11 секунды** (вместо 60+ секунд зависания) -- ✅ Тесты стабильны и не блокируются -- ⚠️ Логи TDLib всё ещё выводятся (можно игнорировать или перенаправить stderr) - -**Техническое решение**: -- Выбран **Вариант 3** (добавление timeout'ов) как временное прагматичное решение -- Timeout'ы защищают от зависания UI даже в продакшене (не критичные операции) -- Альтернатива (Dependency Injection через trait) задокументирована в `REFACTORING_ROADMAP.md` → Priority 6 - -**Добавлено в roadmap**: -- ✅ Создан **Priority 6: Улучшение тестируемости** - - P6.1 — Dependency Injection для TdClient - - Документированы 3 варианта решения с плюсами/минусами - - Оценка трудозатрат: 2-3 дня для trait-based DI - - Текущее состояние: Вариант 3 применён временно - -**Все тесты проходят**: 196 passed (188 tests + 8 benchmarks) ✅ - -**Файлы изменены**: -- `src/input/main_input.rs` — добавлены 3 timeout обёртки -- `REFACTORING_ROADMAP.md` — добавлен Priority 6 с детальным анализом -- `CONTEXT.md` — обновлён контекст проекта - ---- - -## Последние обновления (2026-02-01) - -### Рефакторинг — Подготовка к разделению больших файлов (#2) ⏳ (2026-02-01) - -**Что сделано**: -- ✅ Создана модульная структура `src/input/handlers/` (подготовка): - - `clipboard.rs` (~100 строк) - извлечены операции с буфером обмена - - `global.rs` (~90 строк) - извлечены глобальные команды (Ctrl+R/S/P/F) - - Заглушки: `profile.rs`, `search.rs`, `modal.rs`, `messages.rs`, `chat_list.rs` -- ⏳ `main_input.rs` остаётся монолитным (1139 строк) - - Попытка полной миграции привела к поломке навигации - откачено - - Handlers остаются как подготовка к постепенной миграции - -**Статус Большие файлы (#2.1)**: ⏳ Подготовка (2/7) -- ✅ Структура handlers создана -- ✅ clipboard.rs извлечён (не используется, подготовка) -- ✅ global.rs извлечён (не используется, подготовка) -- ⏳ Требуется постепенная миграция с тщательным тестированием - -**Урок**: Критичная логика ввода требует осторожного рефакторинга с проверкой функциональности после каждого шага. - -**Все тесты проходят**: 563 passed; 0 failed ✅ - ---- - -### Рефакторинг — Быстрые победы (Вариант 1) ✅ (2026-02-01) - -**Что сделано**: -- ✅ Создан `src/utils/modal_handler.rs` (120+ строк): - - 4 функции для обработки модальных окон - - `ModalAction` enum для type-safe обработки - - Поддержка английской и русской раскладки - - 4 unit теста (все проходят) -- ✅ Создан `src/utils/validation.rs` (180+ строк): - - 7 функций валидации: `is_non_empty()`, `is_within_length()`, `is_valid_chat_id()`, и др. - - Покрывает все основные паттерны валидации - - 7 unit тестов (все проходят) -- ✅ Частичная инкапсуляция App: - - Поле `config` сделано приватным (readonly через `app.config()`) - - Добавлено 30+ методов-геттеров и сеттеров - - Остальные поля оставлены pub для совместимости - -**Статус Дублирование кода (#1)**: ✅ ПОЛНОСТЬЮ ЗАВЕРШЕНО И ИНТЕГРИРОВАНО! (3/3) -- ✅ retry utils — 100% покрытие (0 прямых timeout вызовов, использовано в 8+ местах) -- ✅ modal_handler — интегрирован в 2 диалогах (leave group, delete message) -- ✅ validation — интегрирован в 4 местах (auth.rs x3, main_input.rs x1) - -**Статус Инкапсуляция (#5)**: ✅ Частично выполнено (1/4) -- ✅ Config инкапсулирован -- ⏳ Полная инкапсуляция требует массового рефакторинга 170+ мест - -**Все тесты проходят**: 563 passed; 0 failed ✅ - ---- - -### Тестирование — Фаза 4 ЗАВЕРШЕНА! ✅ (2026-02-01) - -**Что сделано**: -- ✅ Добавлено 9 новых unit тестов в `src/utils/formatting.rs`: - - 4 теста для `format_date()` (today, yesterday, old, epoch) - - 5 тестов для `format_was_online()` (just now, minutes/hours/days ago, very old) -- ✅ Создано 3 performance benchmark файла в `benches/`: - - `group_messages.rs` — benchmark группировки сообщений (100, 500) - - `formatting.rs` — benchmark форматирования времени и даты - - `format_markdown.rs` — benchmark markdown форматирования -- ✅ Добавлена зависимость `criterion = "0.5"` в Cargo.toml -- ✅ Все тесты проходят: **188 тестов + 8 benchmarks** - -**Статус Utils Tests**: 18/18 (100%) ✅ -**Статус Performance Benchmarks**: 8/8 (100%) ✅ - -**🎉🎊 ВСЕ ТЕСТЫ ПОЛНОСТЬЮ ЗАВЕРШЕНЫ! 🎊🎉** - -Общий прогресс тестирования: **196/196 (100%)** -- Фаза 0-3: ✅ Завершены -- Фаза 4.1 (Utils): ✅ Завершена -- Фаза 4.2 (Performance): ✅ Завершена - ---- - -### P3.8 — Извлечение форматирования ✅ ЗАВЕРШЕНО! - -**Что сделано**: -- ✅ Создан `src/formatting.rs` с логикой markdown форматирования (262 строки) -- ✅ Перенесены функции из `messages.rs`: - - `CharStyle` — структура для стилей символов (bold, italic, code, spoiler, url, mention) - - `format_text_with_entities()` — преобразование текста с entities в стилизованные Span - - `styles_equal()` — сравнение стилей - - `adjust_entities_for_substring()` — корректировка entities при переносе текста -- ✅ Добавлено 5 unit тестов для форматирования -- ✅ Обновлены `src/lib.rs` и `src/main.rs` для экспорта модуля -- ✅ `src/ui/messages.rs` сокращён на ~143 строки -- ✅ Все lib тесты проходят (17 passed) -- ✅ Бинарник компилируется успешно - -**Преимущества**: -- 📦 Логика форматирования изолирована в отдельном модуле -- ✅ Можно тестировать независимо -- 🔄 Легко переиспользовать в других компонентах UI -- 📖 Улучшена читаемость кода - -**🎉 Статус Priority 3: ЗАВЕРШЁН 100% (4/4 задачи)! 🎉** -- ✅ P3.7 — UI компоненты -- ✅ P3.8 — Форматирование -- ✅ P3.9 — Группировка сообщений -- ✅ P3.10 — Hotkey mapping - -**P3.10 — Hotkey mapping** ✅ ЗАВЕРШЕНО! - -**Что сделано**: -- ✅ Создан `HotkeysConfig` с 10 настраиваемыми горячими клавишами -- ✅ Реализован метод `matches(key: KeyCode, action: &str)` для проверки hotkeys -- ✅ Исправлен баг с UTF-8 (chars().count() вместо len() для поддержки кириллицы) -- ✅ Добавлены 9 unit тестов (все проходят) -- ✅ Hotkeys добавлены в Config::default() с дефолтными значениями - -**Дефолтные горячие клавиши**: -```toml -[hotkeys] -up = "k,ц" -down = "j,о" -reply = "r,к" -forward = "f,а" -delete = "d,в" -edit = "e,у" -copy = "y,н" -view_profile = "i,ш" -reaction = "1234567890" -quit = "q,й" -``` - -**P3.9 — Группировка сообщений** ✅ ЗАВЕРШЕНО! - -**Что сделано**: -- ✅ Перенесён код группировки из `ui/messages.rs` в отдельный модуль `src/message_grouping.rs` (274 строки) -- ✅ Создана публичная функция `group_messages(messages: &[MessageInfo]) -> Vec` -- ✅ Группировка по дате и отправителю с оптимизацией -- ✅ Добавлены 7 unit тестов -- ✅ Добавлен doctest пример в rustdoc - -**P4.12 — Rustdoc (частично)** ⏳ 30% - -**Что сделано**: -- ✅ Добавлена документация для TdClient (60+ строк rustdoc) -- ✅ Добавлена документация для App struct -- ✅ Добавлены doctests примеры использования -- ✅ Исправлены все doctests для компиляции - -**Статус тестов**: 464 теста, все проходят ✅ - ---- - -### 🎉🎊 PRIORITY 2 ЗАВЕРШЁН НА 100%! 🎊🎉 - -**P2.7 — MessageBuilder pattern** ✅ ФИНАЛЬНАЯ ЗАДАЧА ЗАВЕРШЕНА! - -**Что сделано**: -- ✅ Создан MessageBuilder с fluent API (323 строки кода) -- ✅ Реализовано 16 методов для удобного создания сообщений -- ✅ Обновлён convert_message() для использования builder -- ✅ Добавлены 6 unit тестов - -**Пример использования**: -```rust -let message = MessageBuilder::new(MessageId::new(123)) - .sender_name("Alice") - .text("Hello!") - .outgoing() - .read() - .build(); -``` - -**🏆 ИТОГИ PRIORITY 2 (100% - 5/5 задач):** -- ✅ P2.5 — Error enum -- ✅ P2.3 — Config validation -- ✅ P2.4 — Newtype для ID -- ✅ P2.6 — MessageInfo реструктуризация -- ✅ P2.7 — MessageBuilder pattern ← ФИНАЛ! - -**Преимущества Priority 2**: -- 🛡️ Type safety повсюду -- 📦 Логическая структура данных -- 🔧 Удобные API для работы с кодом -- 📚 Самодокументирующийся код - ---- - -**P2.6 — Реструктуризация MessageInfo** ✅ ЗАВЕРШЕНО! - -**Что сделано**: -- ✅ Сгруппированы 16 плоских полей в 4 логические структуры -- ✅ Создано 4 новых типа: MessageMetadata, MessageContent, MessageState, MessageInteractions -- ✅ Добавлен конструктор MessageInfo::new() и getter методы -- ✅ Обновлены 14 файлов с ~200+ обращениями к полям -- ✅ Все тестовые файлы обновлены - -**Преимущества**: -- 📦 Логическая группировка данных -- 🔍 Проще понимать структуру сообщения -- ➕ Легче добавлять новые поля -- 📚 Улучшенная читаемость кода - -**Статус Priority 2**: 80% (4/5 задач) ✅ -- ✅ Error enum -- ✅ Config validation -- ✅ Newtype для ID -- ✅ MessageInfo реструктуризация ← ТОЛЬКО ЧТО! -- ⏳ MessageBuilder pattern (последняя!) - ---- - -**P2.4 — Newtype pattern для ID** ✅ ЗАВЕРШЕНО! - -**Что сделано**: -- ✅ Создан `src/types.rs` с типобезопасными обёртками для идентификаторов -- ✅ Реализованы три типа: `ChatId(i64)`, `MessageId(i64)`, `UserId(i64)` -- ✅ Добавлены методы: `new()`, `as_i64()`, `From`, `Display`, `Hash`, `Eq`, `Serialize/Deserialize` -- ✅ Обновлены 15+ модулей для использования новых типов -- ✅ Исправлены 53 ошибки компиляции связанные с type conversions -- ✅ Компилятор теперь предотвращает смешивание разных типов ID на этапе компиляции - -**Модули обновлены**: -- `tdlib/types.rs` — ChatInfo, MessageInfo, ReplyInfo, ProfileInfo -- `tdlib/chats.rs` — все методы с chat_id параметрами -- `tdlib/messages.rs` — MessageManager, pending_view_messages -- `tdlib/users.rs` — LruCache, UserCache mappings -- `tdlib/reactions.rs` — reaction methods -- `tdlib/client.rs` — все публичные методы и Update handlers -- `app/mod.rs` — selected_chat_id -- `app/chat_state.rs` — все варианты ChatState -- `input/main_input.rs` — обработка ввода с преобразованием типов -- Test helpers — TestAppBuilder, TestChatBuilder, TestMessageBuilder - -**Преимущества**: -- 🛡️ Type safety на уровне компиляции — невозможно перепутать ChatId, MessageId, UserId -- 🔍 Улучшенная читаемость кода — явные типы вместо i64 -- 🐛 Меньше ошибок — компилятор ловит проблемы до запуска -- 📚 Лучшая документация — типы самодокументируются - -**Статус Priority 2**: 60% (3/5 задач) ✅ -- ✅ Error enum -- ✅ Config validation -- ✅ Newtype для ID -- ⏳ MessageInfo реструктуризация -- ⏳ MessageBuilder pattern - ---- - -### Тестирование — ЗАВЕРШЕНО! 🎉🎊🚀 (2026-01-30) - -**Добавлено**: -- 📝 93 integration теста (12 файлов): send_message, edit_message, delete_message, reply_forward, reactions, search, drafts, navigation, profile, network_typing, **copy**, **config** -- 🎯 Phase 2.1-2.10 (73 теста) ✅ -- 🎯 **Phase 2.11 Copy Flow** (9 тестов) ✅ — НОВОЕ! - - Форматирование сообщений (plain, forward, reply, комбо, длинные, markdown) - - Clipboard тесты (инициализация, реальное копирование, кроссплатформенность) -- 🎯 **Phase 2.12 Config Flow** (11 тестов) ✅ — НОВОЕ! - - Config дефолты и кастомные значения - - Парсинг цветов (валидные, light, невалидные с fallback, case-insensitive) - - TOML сериализация/десериализация - - Timezone форматы - - Credentials загрузка (из env, проверка ошибок) -- 📚 Обновлена документация тестирования (TESTING_PROGRESS.md, TESTING_ROADMAP.md, CONTEXT.md) - -**Покрытие**: 148/151 тестов (98%) — БОЛЬШЕ ЧЕМ ПЛАНИРОВАЛОСЬ! 🎉 -- ✅ Phase 0: Инфраструктура (100%) -- ✅ Phase 1: UI Snapshot Tests (100%) - 55 тестов -- ✅ Phase 2: Integration Tests (100%!) - 93 тестов (вместо запланированных 84!) - - Copy Flow: 9 тестов (вместо 3) - - Config Flow: 11 тестов (вместо 8) - -**Все тесты проходят**: `cargo test` → 148+ passed ✅ - -**Статус**: ВСЕ ОСНОВНЫЕ ТЕСТЫ ЗАВЕРШЕНЫ! Опциональные тесты (E2E smoke, utils, performance) можно сделать позже. - -Подробности: [TESTING_PROGRESS.md](TESTING_PROGRESS.md) - -### Рефакторинг — Приоритет 1 ЗАВЕРШЁН! 🏗️✨ (2026-01-30) - -**Статус**: Priority 1 (3/3 задач) ✅ ЗАВЕРШЕНО! - -**Завершено**: -- ✅ **P1.3 — Константы** (ранее) - - Вынесены магические числа в `src/constants.rs` - - Улучшена читаемость и maintainability - -- ✅ **P1.2 — Разделение TdClient** (2026-01-30) - - Разделён монолитный TdClient (2036 строк, 87KB) на 7 модулей: - - `auth.rs` — AuthManager + AuthState enum (6.8KB) - - `chats.rs` — ChatManager для операций с чатами (8.1KB) - - `messages.rs` — MessageManager для сообщений (18.5KB) - - `users.rs` — UserCache с LRU кэшем (6.2KB) - - `reactions.rs` — ReactionManager (4.2KB) - - `types.rs` — Общие типы данных (10.8KB) - - `mod.rs` — Экспорты модулей - - Размер client.rs сократился на **50%** (87KB → 42.5KB) - - Исправлено 130+ ошибок компиляции из-за изменений в tdlib-rs API - - Все 330 тестов проходят ✅ - -- ✅ **P1.1 — ChatState enum** (2026-01-30) - - Схлопнуты 14 boolean полей в type-safe enum `ChatState` - - Невозможно иметь несколько состояний одновременно - - Данные состояния хранятся вместе с ним - - Варианты: Normal, MessageSelection, Editing, Reply, Forward, DeleteConfirmation, ReactionPicker, Profile, SearchInChat, PinnedMessages - - Обновлены все методы App для делегирования к ChatState - - Все 330 тестов проходят ✅ - -**Преимущества**: -- Код стал более модульным и maintainable -- Улучшена type-safety -- Проще добавлять новые фичи -- Лучше читаемость - -**Priority 2 (100% завершено - 5/5)** ✅ ПОЛНОСТЬЮ ЗАВЕРШЁН! 🎉: -- ✅ **P2.5 — Error enum** (завершено 2026-01-31) - - Создан `src/error.rs` с типобезопасным enum `TeletuiError` - - Добавлены варианты: TdLib, Config, Network, Auth, Chat, Message, User, InvalidTimezone, InvalidColor, Clipboard, Io, Toml, Json, Other - - Type alias `Result` для упрощения сигнатур - - Использован `thiserror` для автоматического Display - - Заменены все `Result` на `Result` в 7 модулях - - Все 350 тестов проходят ✅ - -- ✅ **P2.3 — Config validation** (завершено 2026-01-31) - - Добавлен метод `Config::validate()` для проверки конфигурации - - Валидация timezone: проверка что начинается с + или - - - Валидация цветов: проверка что цвет из списка допустимых (black, red, green, yellow, blue, magenta, cyan, gray, white, darkgray, lightred, lightgreen, lightyellow, lightblue, lightmagenta, lightcyan) - - При загрузке невалидного конфига автоматически используется дефолтный - - Все 350 тестов проходят ✅ - -- ✅ **P2.4 — Newtype pattern для ID** (завершено 2026-01-31) - - Создан `src/types.rs` с типобезопасными обёртками: `ChatId`, `MessageId`, `UserId` - - Реализованы методы: `new()`, `as_i64()`, `From`, `Display`, `Hash`, `Eq`, `Serialize/Deserialize` - - Обновлены 15+ модулей для использования новых типов: - - `tdlib/types.rs`: ChatInfo, MessageInfo, ReplyInfo, ProfileInfo - - `tdlib/chats.rs`, `tdlib/messages.rs`, `tdlib/users.rs`, `tdlib/reactions.rs` - - `tdlib/client.rs`: все методы и Update handlers - - `app/mod.rs`, `app/chat_state.rs` - - `input/main_input.rs` - - Test helpers (app_builder, test_data) - - Компилятор теперь предотвращает смешивание разных типов ID - - Все тесты компилируются успешно ✅ - -- ✅ **P2.6 — Реструктуризация MessageInfo** (завершено 2026-01-31) - - Сгруппированы 16 плоских полей MessageInfo в 4 логические структуры - - Новые структуры: - - `MessageMetadata`: id, sender_name, date, edit_date - - `MessageContent`: text, entities - - `MessageState`: is_outgoing, is_read, can_be_edited, can_be_deleted_* - - `MessageInteractions`: reply_to, forward_from, reactions - - Добавлен конструктор `MessageInfo::new()` для удобного создания - - Добавлены getter методы для удобного доступа (id(), text(), sender_name() и др.) - - Обновлены 14 файлов (~200+ обращений к полям): - - `ui/messages.rs`: рендеринг сообщений (100+ изменений) - - `app/mod.rs`, `input/main_input.rs`: логика приложения - - `tdlib/client.rs`: обработка updates - - Все тестовые файлы - - Логическая группировка данных улучшает maintainability ✅ - -- ✅ **P2.7 — MessageBuilder pattern** (завершено 2026-01-31) - - Создан `MessageBuilder` с fluent API для удобного создания сообщений - - Реализованы методы: - - Базовые: `sender_name()`, `text()`, `entities()`, `date()`, `edit_date()` - - Флаги: `outgoing()`, `incoming()`, `read()`, `unread()`, `edited()` - - Права: `editable()`, `deletable_for_self()`, `deletable_for_all()` - - Дополнительно: `reply_to()`, `forward_from()`, `reactions()`, `add_reaction()` - - Финализация: `build()` → MessageInfo - - Обновлён `convert_message()` для использования builder - - Добавлены 6 unit тестов демонстрирующих fluent API - - Преимущества: читабельность, гибкость, самодокументирование ✅ - -**🎉 Priority 2 ЗАВЕРШЁН НА 100%! 🎉** - -**Следующие шаги**: Priority 3 (UI компоненты, форматирование, группировка сообщений) - -Подробности: [REFACTORING_ROADMAP.md](REFACTORING_ROADMAP.md) - -## Что НЕ сделано / TODO - -Все пункты Фазы 9 завершены! Можно переходить к следующей фазе разработки или продолжить написание тестов. - -## Технический долг - -См. [REFACTORING_ROADMAP.md](REFACTORING_ROADMAP.md) для детального плана рефакторинга. - -**Завершено** (Priority 1): -1. ~~**ChatState enum**~~ ✅ — схлопнуты boolean состояния в type-safe enum -2. ~~**Разделение TdClient**~~ ✅ — разделён на 7 модулей -3. ~~**Константы**~~ ✅ — вынесены в отдельный модуль - -**Завершено** (Priority 1): ✅ 3/3 (100%) -1. ~~**ChatState enum**~~ ✅ -2. ~~**Разделение TdClient**~~ ✅ -3. ~~**Константы**~~ ✅ - -**Завершено** (Priority 2): ✅ 5/5 (100%) 🎉 -1. ~~**Error enum**~~ ✅ — типобезопасная обработка ошибок (2026-01-31) -2. ~~**Config validation**~~ ✅ — валидация конфигурации при загрузке (2026-01-31) -3. ~~**Newtype pattern для ID**~~ ✅ — типобезопасные обёртки ChatId, MessageId, UserId (2026-01-31) -4. ~~**MessageInfo реструктуризация**~~ ✅ — группировка полей в логические структуры (2026-01-31) -5. ~~**MessageBuilder pattern**~~ ✅ — fluent API для создания сообщений (2026-01-31) - -**Завершено** (Priority 3): ✅ 1/4 (25%) -1. ~~**P3.7 — UI компоненты**~~ ✅ — выделение переиспользуемых компонентов (2026-01-31) -2. ~~**P3.8 — Форматирование**~~ ✅ — вынесено markdown форматирование в src/formatting.rs (2026-01-31) - -**В работе** (Priority 3-5): -1. **P3.9 — Группировка сообщений** — вынести логику группировки в отдельный модуль -2. **P3.10 — Hotkey mapping** — добавить настройку хоткеев в конфиг -3. **Юнит-тесты** — добавить для utils и других модулей - -## Недавние исправления - -### 31 января 2026 (вечер) — Критические баги с сообщениями, редактированием и reply -1. **Исправлена проблема с отображением новых сообщений** ✅ - - **Проблема**: Новые сообщения (как отправленные, так и входящие) не появлялись в UI - - **Причина**: Сообщения добавлялись в начало массива (`insert(0)`), но UI показывал конец массива - - **Решение**: Изменён порядок хранения — сообщения теперь добавляются в конец (`push()`) - - **Результат**: Сообщения отображаются корректно в реальном времени - -2. **Исправлено редактирование сообщений** ✅ - - **Проблема**: Ошибка "Message not found" при попытке редактировать - - **Причина**: Метод `get_selected_message()` конвертировал индекс в обратном порядке (старая логика) - - **Решение**: - - Убрана конвертация индекса в `get_selected_message()` - - Исправлена логика выбора: `start_message_selection()` начинает с индекса `len-1` (последнее сообщение) - - Обновлена логика навигации: `select_previous_message()` уменьшает индекс, `select_next_message()` увеличивает - - **Результат**: Редактирование работает без ошибок - -3. **Исправлен reply на сообщения** ✅ - - **Проблема 1**: Reply не отправлялся (нажатие Enter ничего не делало) - - **Причина**: Неправильная структура условий — reply попадал в блок с `selected_message_id`, но не в блок отправки - - **Решение**: Изменена структура условий — проверка `is_editing()` вынесена наружу - - **Проблема 2**: Reply отправлялся, но не показывалось превью исходного сообщения - - **Причина**: Параметр `_reply_info` в `send_message()` не использовался - - **Решение**: Убрано подчёркивание и добавлена логика сохранения `reply_info` в `MessageInfo` после `convert_message()` - - **Результат**: Reply работает корректно с превью исходного сообщения - -4. **Удалены отладочные логи** ✅ - - Удалены временные `eprintln!` из `src/tdlib/client.rs` и `src/input/main_input.rs` - -### 31 января 2026 (утро) — Баги в тестах и работе приложения -1. **Исправлены ошибки компиляции тестов** ✅ - - Исправлены синтаксические ошибки в `tests/delete_message.rs` и `tests/reply_forward.rs` - - Исправлены проблемы с доступом к полям (field vs method) - - Исправлены несоответствия типов (MessageId vs i64) - -2. **Исправлена проблема с загрузкой истории сообщений** ✅ - - Добавлен вызов `open_chat()` перед `get_chat_history()` в `src/tdlib/messages.rs` - - Реализована логика повторных попыток (retry) с задержками для синхронизации TDLib - - Исправлен race condition с установкой `current_chat_id` (теперь устанавливается после загрузки сообщений) - - **Результат**: История загружается корректно с первого раза (проверено: 51 сообщение) - -3. **Уточнена документация по редактированию сообщений** ℹ️ - - **Проблема**: Пользователь нажимал 'r' (reply) вместо Enter при попытке редактировать - - **Правильный процесс**: ↑ (выбор) → Enter (начать редактирование) → изменить текст → Enter (сохранить) - - **Ошибочный процесс**: ↑ (выбор) → 'r' (начинается режим Reply!) → текст отправляется как ответ - - Добавлены инструкции в документацию для избежания путаницы - -### 31 января 2026 (поздний вечер) — E2E интеграционные тесты ✅ -1. **Созданы E2E Smoke тесты** ✅ - - **Файл**: `tests/e2e_smoke.rs` - - **Тесты**: - - Проверка базовых структур приложения (NetworkState enum) - - Проверка минимального размера терминала (80x20) - - Проверка базовых констант (MAX_MESSAGES_IN_CHAT, MAX_CHATS, MAX_USER_CACHE_SIZE) - - Проверка graceful shutdown флага (AtomicBool) - - **Результат**: 4/4 теста, покрывают базовую функциональность без краша - -2. **Созданы User Journey интеграционные тесты** ✅ - - **Файл**: `tests/e2e_user_journey.rs` - - **Многошаговые сценарии** (8 тестов): - - Тест 1: App Launch → Auth → Chat List (загрузка списка чатов) - - Тест 2: Open Chat → Load History → Send Message (основной flow) - - Тест 3: Receive Incoming Message (симуляция входящих сообщений через update channel) - - Тест 4: Multi-step conversation (полноценная беседа туда-обратно) - - Тест 5: Switch between chats (переключение между чатами) - - Тест 6: Edit message during conversation (редактирование с проверкой edit_date) - - Тест 7: Reply to message (ответ на конкретное сообщение с reply_info) - - Тест 8: Network state changes (симуляция потери и восстановления сети) - - **Результат**: 8/8 тестов, полное покрытие пользовательских сценариев - -3. **Расширен FakeTdClient для E2E тестов** ✅ - - Добавлены геттеры для тестовых проверок: - - `get_network_state()` — получить текущее состояние сети - - `get_current_chat_id()` — получить ID открытого чата - - `set_update_channel()` — установить канал для получения update событий - - Исправлена `simulate_network_change()` — добавлен clone для state - - Все методы поддерживают async/await и работают с Arc> - -4. **Обновлены TESTING_ROADMAP.md и CONTEXT.md** ✅ - - Отмечена Фаза 3 как завершённая (100%) - - Общий прогресс тестирования: **160/163 теста (98%)** - - Остались только опциональные тесты Utils + Performance (Фаза 4) - -**Следующие шаги**: Фаза 4 (опциональная) — Utils тесты и Performance бенчмарки - -### 31 января 2026 (поздняя ночь) — Массовое исправление всех интеграционных тестов ✅ -1. **Проблема**: После расширения FakeTdClient для async все старые интеграционные тесты перестали компилироваться - -2. **Решение**: Автоматизированное исправление всех тестовых файлов - - Создан bash скрипт для массовой замены геттеров - - Использованы специализированные агенты для исправления каждого типа тестов - - Обновлены 10 тестовых файлов: send_message, edit_message, delete_message, reply_forward, reactions, network_typing, navigation, drafts, search, profile - -3. **Изменения API**: - - Все тесты конвертированы в async с tokio::test - - client теперь immutable (использует Arc> внутри) - - Все методы теперь async и требуют await - - ChatId вместо i64 для идентификаторов чатов - - Все геттеры переименованы с префиксом get_ - -4. **Результат**: - - ✅ **463 ТЕСТА ПРОШЛИ!** - - 0 ошибок компиляции - - 0 упавших тестов - - 100% success rate - - Все фазы тестирования работают (Фаза 0, 1, 2, 3) - -**Статистика по файлам**: -- E2E тесты: 27 passed (smoke 4 + user_journey 23) -- Integration тесты: 260+ passed -- Snapshot тесты: 176+ passed -- **ВСЕГО: 463 ТЕСТА** - -### 1 февраля 2026 (раннее утро) — Завершение snapshot тестов ✅ -1. **Добавлен последний snapshot тест** ✅ - - **Файл**: `tests/chat_list.rs` - - **Тест**: `snapshot_chat_with_online_status` - тест для отображения онлайн-статуса (зеленая точка ●) - - Использует прямое манипулирование `app.td_client.user_cache` для установки онлайн-статуса - - Snapshot показывает "● онлайн" в нижней панели для выбранного чата - -2. **Фаза 1 ЗАВЕРШЕНА НА 100%!** 🎉 - - 1.1 Chat List: 10/10 (100%) ✅ - - 1.2 Messages: 19/19 (100%) ✅ - - 1.3 Modals: 8/8 (100%) ✅ - - 1.4 Input Field: 7/7 (100%) ✅ - - 1.5 Footer: 6/6 (100%) ✅ - - 1.6 Screens: 7/7 (100%) ✅ - - **Всего: 57/57 snapshot тестов** - -3. **Обновлена статистика**: - - **464 ТЕСТА ПРОШЛИ** (было 463) - - Все обязательные фазы: ✅ 100% - - **Все обязательные тесты: 164/164 (100%!)** - -**Осталось только опциональные тесты**: -- Фаза 4.1: Utils тесты (5 штук) - низкий приоритет -- Фаза 4.2: Performance бенчмарки (3 штуки) - низкий приоритет - -### 31 января 2026 (поздняя ночь) — Рефакторинг Priority 3: Message Grouping ✅ -1. **Создан модуль message_grouping.rs** ✅ - - **Файл**: `src/message_grouping.rs` (255 строк) - - **Реализовано**: - - Enum `MessageGroup` с тремя вариантами: - - `DateSeparator(i32)` — разделитель даты - - `SenderHeader { is_outgoing: bool, sender_name: String }` — заголовок отправителя - - `Message(MessageInfo)` — само сообщение - - Функция `group_messages()` для группировки сообщений по дате и отправителю - - Полная документация с rustdoc комментариями - - 5 unit тестов (все проходят): - - test_group_messages_by_date - - test_group_messages_by_sender - - test_group_outgoing_vs_incoming - - test_empty_messages - - test_single_message - -2. **Обновлены файлы проекта** ✅ - - Модуль добавлен в `src/lib.rs` - - Обновлен `REFACTORING_ROADMAP.md`: - - P3.9 отмечено как завершённое ✅ - - P3.7 отмечено как частично завершённое (4/5 компонентов) - - P3.8 отмечено как завершённое ✅ - - Priority 3: 3/4 задач (75%) - - **Общий прогресс рефакторинга: 11/17 задач (65%)** - -3. **Разблокированы зависимости** ✅ - - P3.9 ✅ (Message Grouping) завершено - - P3.8 ✅ (Formatting Module) уже было завершено ранее - - Теперь можно реализовать `message_bubble.rs` (был заблокирован P3.8 и P3.9) - -4. **Результаты тестирования**: - - ✅ Все 464 теста прошли успешно - - ✅ Новые 5 unit тестов для message_grouping прошли - - ✅ Doctest для group_messages() прошёл - - ✅ Нет ошибок компиляции - -**Следующие шаги рефакторинга**: -- P3.10: Hotkey Mapping (осталась последняя задача Priority 3) -- Интеграция message_grouping в messages.rs -- Реализация message_bubble.rs (теперь разблокировано!) - -### 31 января 2026 (поздняя ночь) — Рефакторинг Priority 3: Hotkey Mapping ✅ -1. **Создана структура HotkeysConfig** ✅ - - **Файл**: `src/config.rs` (расширен на ~230 строк) - - **Реализовано**: - - Структура `HotkeysConfig` с 10 полями hotkeys - - Навигация: up, down, left, right (vim + русские + стрелки) - - Действия: reply, forward, delete, copy, react, profile (англ + русские) - - Метод `matches(key: KeyCode, action: &str) -> bool` - - Приватный метод `key_matches()` для проверки соответствия - - Поддержка специальных клавиш (Up, Down, Delete, Enter, Esc, и др.) - - Дефолтные значения для всех hotkeys - - Default impl для HotkeysConfig - -2. **Добавлены unit тесты** ✅ - - 9 unit тестов для HotkeysConfig: - - test_hotkeys_matches_char_keys - - test_hotkeys_matches_arrow_keys - - test_hotkeys_matches_vim_keys - - test_hotkeys_matches_russian_vim_keys - - test_hotkeys_matches_special_delete_key - - test_hotkeys_does_not_match_wrong_keys - - test_hotkeys_does_not_match_wrong_actions - - test_hotkeys_unknown_action - - test_config_default_includes_hotkeys - -3. **Обновлены файлы проекта** ✅ - - Добавлен import `crossterm::event::KeyCode` в config.rs - - Поле `hotkeys` добавлено в структуру `Config` - - `Config::default()` включает `hotkeys: HotkeysConfig::default()` - - Обновлен `REFACTORING_ROADMAP.md`: - - P3.10 отмечено как завершённое ✅ - - **Priority 3: 4/4 задач (100%) 🎉🎉** - - **Общий прогресс рефакторинга: 12/17 задач (71%)** - -4. **Поддержка конфигурации** ✅ - - Пользователи теперь могут настроить hotkeys в `~/.config/tele-tui/config.toml`: - ```toml - [hotkeys] - up = ["k", "р", "Up"] - down = ["j", "о", "Down"] - reply = ["r", "к"] - forward = ["f", "а"] - delete = ["d", "в", "Delete"] - copy = ["y", "н"] - react = ["e", "у"] - profile = ["i", "ш"] - ``` - -5. **Результаты**: - - ✅ Код компилируется успешно - - ✅ Все тесты проходят - - ✅ Готово к интеграции в input handlers - -**🎉 Priority 3 ЗАВЕРШЁН НА 100%! 🎉** - -**Следующие шаги рефакторинга**: -- Priority 4: Качество кода (unit тесты, rustdoc, config validation, async/await) -- Priority 5: Опциональные улучшения (feature flags, LRU cache, tracing) -- Интеграция message_grouping в messages.rs -- Реализация message_bubble.rs - -### 31 января 2026 (поздняя ночь) — Рефакторинг Priority 4: Rustdoc (частично) ✅ -1. **Добавлена документация для публичных API** ✅ - - **Файлы**: `src/tdlib/client.rs`, `src/app/mod.rs` - - **Реализовано**: - - TdClient: полная документация структуры + примеры использования - - TdClient методы: - * Авторизация: send_phone_number(), send_code(), send_password() - * Чаты: load_chats(), load_folder_chats(), leave_chat(), get_profile_info() - * Все методы имеют описания параметров, возвращаемых значений и ошибок - - App: документация структуры с объяснением state machine - - App методы: new() с примером использования - - **Прогресс**: +60 строк doc-комментариев (210 → 270) - -2. **Обновлена документация проекта** ✅ - - Обновлен REFACTORING_ROADMAP.md (P4.12 отмечено как частично завершённое) - -**Текущий статус рефакторинга**: -- ✅ Priority 1: 3/3 (100%) -- ✅ Priority 2: 5/5 (100%) -- ✅ Priority 3: 4/4 (100%) 🎉 -- ✅ Priority 4: 4/4 (100%) 🎉 -- ✅ Priority 5: 3/3 (100%) 🎉🎉🎉 - -**🎊🎉 РЕФАКТОРИНГ ПОЛНОСТЬЮ ЗАВЕРШЁН: 20/20 задач (100%)! 🎉🎊** - -**Последние изменения (1 февраля 2026)**: -- ✅ **P5.15 — Feature flags для зависимостей** (2026-02-01) - - Добавлены опциональные features `clipboard` и `url-open` в Cargo.toml - - Зависимости `arboard` и `open` теперь опциональные - - Условная компиляция в коде с graceful degradation - - Преимущества: уменьшение размера бинарника, модульность - -- ✅ **P5.16 — LRU cache обобщение** (2026-02-01) - - Обобщена структура `LruCache` в src/tdlib/users.rs - - Type-safe: `K: Eq + Hash + Clone + Copy`, `V: Clone` - - Обновлены типы в UserCache: `LruCache`, `LruCache` - - Переиспользуемая реализация без дополнительных зависимостей - -- ✅ **P5.17 — Tracing вместо eprintln!** (2026-02-01) - - Добавлены зависимости `tracing` и `tracing-subscriber` в Cargo.toml - - Инициализирован subscriber в main.rs с env-filter - - Заменены все `eprintln!` на tracing макросы (`warn!`, `error!`) - - Настраиваемые уровни логов через переменную окружения `RUST_LOG` - -**Достижения рефакторинга**: -✅ Все 5 приоритетов завершены на 100% -✅ 20/20 задач выполнено -✅ Type safety повсюду (newtypes, enums) -✅ Модульная архитектура (client разделён на 7 модулей) -✅ Переиспользуемые компоненты (UI, formatting, grouping) -✅ Качество кода (rustdoc, тесты, валидация) -✅ Опциональные улучшения (feature flags, generic cache, tracing) - -## Дополнительный рефакторинг больших файлов (2026-02-03) - -После завершения основного рефакторинга (20/20 задач), продолжена работа по разделению больших монолитных файлов и функций. - -### Phase 2-4: Рефакторинг main_input.rs ✅ - -**Phase 2** (коммит f4c24dd): -- Извлечены обработчики клавиатуры и навигации (2 функции) -- handle() сокращена с 891 до ~734 строк - -**Phase 3** (коммиты 45d03b5, 7e372bf): -- Извлечены ВСЕ оставшиеся обработчики режимов (11 функций) -- handle() сокращена до 82 строк (91% ✂️) -- Итого: 13 извлечённых функций - -**Phase 4** (коммиты 67fd750, 9d9232f, 6150fe3): -- Применены паттерны упрощения вложенности (early returns, let-else guards) -- Разделён handle_enter_key() на 3 части (130 → 40 строк, 67% ✂️) -- Вложенность сокращена с 6+ до 2-3 уровней - -### Phase 5: Рефакторинг ui/messages.rs ✅ ЗАВЕРШЁН! - -**Коммит 315395f** - Начало Phase 5: -- Извлечены: render_chat_header(), render_pinned_bar() (~80 строк) -- render() сокращена на ~65 строк - -**Коммит 2dbbf1c** - Завершение Phase 5: -- Извлечены: render_message_list() (~100 строк), render_input_box() (~145 строк) -- render() сокращена с **~390 до ~92 строк (76% ✂️)** -- Итого: **4 извлечённые функции** для модульного рендеринга - -**Результат Phase 5:** -``` -render() теперь (~92 строки): - ├─ Early returns (profile/search/pinned modes) ~15 строк - ├─ Layout setup (вычисление размеров) ~35 строк - ├─ Делегирование в 4 функции: - │ ├─ render_chat_header() - заголовок с typing status - │ ├─ render_pinned_bar() - панель закреплённого сообщения - │ ├─ render_message_list() - список + автоскролл - │ └─ render_input_box() - input с режимами (forward/select/edit/reply) - └─ Modal overlays (delete/reaction picker) ~15 строк -``` - -**Достижения дополнительного рефакторинга:** -- ✅ main_input.rs: handle() сокращена на 91% (891 → 82 строки) -- ✅ ui/messages.rs: render() сокращена на 76% (390 → 92 строки) -- ✅ Применены современные Rust паттерны (let-else guards, early returns) -- ✅ Код стал модульным и читаемым -- ✅ Каждая функция имеет чёткую ответственность - -### Phase 6: Рефакторинг tdlib/client.rs ✅ ЗАВЕРШЁН! (2026-02-04) - -**Этап 1** (коммит 0acf864) - Извлечение Update Handlers: -- Создан модуль `src/tdlib/update_handlers.rs` (302 строки) -- **Извлечено 8 handler функций** (~350 строк): - - handle_new_message_update() — добавление новых сообщений (44 строки) - - handle_chat_action_update() — статус набора текста (32 строки) - - handle_chat_position_update() — управление позициями чатов (36 строк) - - handle_user_update() — обработка информации о пользователях (40 строк) - - handle_message_interaction_info_update() — обновление реакций (44 строки) - - handle_message_send_succeeded_update() — успешная отправка (35 строк) - - handle_chat_draft_message_update() — черновики сообщений (15 строк) - - handle_auth_state() — изменение состояния авторизации (10 строк) -- handle_update() обновлен для делегирования в update_handlers -- **Результат: client.rs 1259 → 983 строки (22% ✂️)** - -**Этап 2** (коммит 88ff4dd) - Извлечение Message Converter: -- Создан модуль `src/tdlib/message_converter.rs` (250 строк) -- **Извлечено 6 conversion функций** (~240 строк): - - convert_message() — основная конвертация TDLib → MessageInfo (150+ строк) - - extract_reply_info() — извлечение reply информации (30 строк) - - extract_forward_info() — извлечение forward информации (25 строк) - - extract_reactions() — извлечение реакций (20 строк) - - get_origin_sender_name() — получение имени отправителя (15 строк) - - update_reply_info_from_loaded_messages() — обновление reply из кэша (30 строк) -- Исправлены ошибки компиляции с неверными именами полей -- Обновлены вызовы в update_handlers.rs -- **Результат: client.rs 983 → 754 строки (23% ✂️)** - -**Этап 3** (коммит b081886) - Извлечение Chat Helpers: -- Создан модуль `src/tdlib/chat_helpers.rs` (149 строк) -- **Извлечено 3 helper функции** (~140 строк): - - find_chat_mut() — поиск чата по ID (15 строк) - - update_chat() — обновление чата через closure (15 строк, используется 9+ раз) - - add_or_update_chat() — добавление/обновление чата в списке (110+ строк) -- Использован sed для замены вызовов методов по всей кодовой базе -- **Результат: client.rs 754 → 599 строк (21% ✂️)** - -**Итоговый результат Phase 6:** -- ✅ Файл client.rs сократился с **1259 до 599 строк (52% ✂️)** 🎉 -- ✅ Создано **3 новых модуля** с чёткой ответственностью: - - update_handlers.rs — обработка всех типов TDLib Update - - message_converter.rs — конвертация TDLib Message → MessageInfo - - chat_helpers.rs — утилиты для работы с чатами -- ✅ Все **590+ тестов** проходят успешно -- ✅ Код стал **модульным и лучше организованным** -- ✅ TdClient теперь ближе к **facade pattern** (делегирует в специализированные модули) - -**Достижения дополнительного рефакторинга (итого):** -- ✅ main_input.rs: handle() сокращена на 91% (891 → 82 строки) -- ✅ ui/messages.rs: render() сокращена на 76% (390 → 92 строки) -- ✅ tdlib/client.rs: файл сокращён на 52% (1259 → 599 строк) 🎉 -- ✅ Применены современные Rust паттерны (let-else guards, early returns) -- ✅ Код стал модульным и читаемым -- ✅ Каждая функция имеет чёткую ответственность -- ✅ **2 из 4 больших файлов рефакторены (50%)** - -### Phase 7: Рефакторинг tdlib/messages.rs ✅ ЗАВЕРШЁН! (2026-02-04) - -**Проблема**: Огромная функция `convert_message()` на 150 строк в MessageManager - -**Решение**: Создан модуль `src/tdlib/message_conversion.rs` (158 строк) -- **Извлечено 6 вспомогательных функций**: - - `extract_content_text()` — извлечение текста из различных типов сообщений (~80 строк) - - `extract_entities()` — извлечение форматирования (~10 строк) - - `extract_sender_name()` — получение имени отправителя с API вызовом (~15 строк) - - `extract_forward_info()` — информация о пересылке (~12 строк) - - `extract_reply_info()` — информация об ответе (~15 строк) - - `extract_reactions()` — реакции на сообщение (~26 строк) -- Метод `convert_message()` сократился с **150 до 57 строк** (62% сокращение! 🎉) -- Файл `messages.rs` сократился с **850 до 757 строк** (11% сокращение) - -**Результат Phase 7:** -- ✅ Файл `messages.rs`: **850 → 757 строк** -- ✅ Метод `convert_message()`: **150 → 57 строк** (62% ✂️) -- ✅ Создан переиспользуемый модуль `message_conversion.rs` -- ✅ Все **629 тестов** проходят успешно - -**🎉🎉 КАТЕГОРИЯ "БОЛЬШИЕ ФАЙЛЫ/ФУНКЦИИ" ЗАВЕРШЕНА НА 100%! 🎉🎉** - -**Достижения дополнительного рефакторинга (итого):** -- ✅ main_input.rs: handle() сокращена на 91% (891 → 82 строки) -- ✅ ui/messages.rs: render() сокращена на 76% (390 → 92 строки) -- ✅ tdlib/client.rs: файл сокращён на 52% (1259 → 599 строк) -- ✅ tdlib/messages.rs: convert_message() сокращена на 62% (150 → 57 строк) -- ✅ Применены современные Rust паттерны (let-else guards, early returns) -- ✅ Код стал модульным и читаемым -- ✅ Каждая функция имеет чёткую ответственность -- ✅ **ВСЕ 4 БОЛЬШИХ ФАЙЛА ОТРЕФАКТОРЕНЫ (100%!)** 🎉🎉🎉 - -### 🎊 РЕФАКТОРИНГ ПОЛНОСТЬЮ ЗАВЕРШЁН (2026-02-04) 🎊 - -**Итоговые достижения**: - -**Основной рефакторинг (21/21 задач - 100%)**: -- ✅ Priority 1 (3/3): ChatState enum, разделение TdClient, константы -- ✅ Priority 2 (5/5): Error enum, Config validation, Newtype ID, MessageInfo реструктуризация, MessageBuilder -- ✅ Priority 3 (4/4): UI компоненты, форматирование, группировка сообщений, hotkey mapping -- ✅ Priority 4 (4/4): Unit tests, Rustdoc документация, Config validation, Async/await консистентность -- ✅ Priority 5 (3/3): Feature flags, LRU cache обобщение, Tracing -- ✅ Priority 6 (1/1): Dependency Injection для TdClient (trait-based) - -**Дополнительный рефакторинг больших файлов (Phases 2-7)**: -- ✅ main_input.rs: handle() сокращена на **91%** (891 → 82 строки) -- ✅ ui/messages.rs: render() сокращена на **76%** (390 → 92 строки) -- ✅ tdlib/client.rs: файл сокращён на **52%** (1259 → 599 строк) -- ✅ tdlib/messages.rs: convert_message() сокращена на **62%** (150 → 57 строк) - -**Преимущества после рефакторинга**: -- 🛡️ Type safety повсюду (ChatState enum, newtype IDs, Error enum) -- 📦 Модульная архитектура (TdClient разделён на 7 модулей) -- 🎨 Переиспользуемые UI компоненты -- 📚 Полная документация (rustdoc + примеры) -- ⚡ Быстрые тесты (trait-based DI с FakeTdClient) -- 🔧 Настраиваемость (hotkeys, feature flags) -- 📊 Структурированное логирование (tracing) -- ✅ 343 теста проходят успешно - -**Ветка `refactoring` слита в `main`** (2026-02-04) - -### Phase 8: Дополнительный рефакторинг (категории 6, 8) ✅ ЗАВЕРШЁН! (2026-02-04) - -**Цель**: Создать отсутствующие абстракции и централизовать дублирующуюся функциональность - -#### Категория 6: Отсутствующие абстракции (3/3 завершены) - -**6.1. KeyHandler trait** (src/input/key_handler.rs - 380+ строк): -- ✅ Trait `KeyHandler` с методами `handle_key()` и `priority()` -- ✅ Enum `KeyResult` для результатов обработки (Handled, HandledNeedsRedraw, NotHandled, Quit) -- ✅ 4 реализации: - - `GlobalKeyHandler` — глобальные хоткеи (Quit, Search, Help) - - `ChatListKeyHandler` — навигация по чатам - - `MessageViewKeyHandler` — просмотр сообщений - - `MessageSelectionKeyHandler` — выбор сообщений для операций -- ✅ `KeyHandlerChain` для композиции с приоритетами -- ✅ 3 unit теста (все проходят) - -**6.3. Keybindings система** (src/config/keybindings.rs - 420+ строк): -- ✅ Enum `Command` с 40+ командами (MoveUp, OpenChat, EditMessage, и т.д.) -- ✅ Struct `KeyBinding` для связки клавиш с модификаторами -- ✅ Struct `Keybindings` с HashMap для привязок -- ✅ Custom serde для KeyCode и KeyModifiers (поддержка TOML) -- ✅ Поддержка множественных привязок (EN/RU раскладки) -- ✅ 4 unit теста (все проходят) - -#### Категория 8: Централизация функциональности (2/2 завершены) - -**8.1. ChatFilter** (src/app/chat_filter.rs - 470+ строк): -- ✅ Struct `ChatFilterCriteria` с builder pattern: - - Фильтрация: по папке, поиску, pinned, unread, mentions, muted, archived - - Композиция критериев через методы-builders -- ✅ Struct `ChatFilter` с методами: - - `filter()` — основная фильтрация по критериям - - `by_folder()` / `by_search()` — упрощённые варианты - - `count()` / `count_unread()` / `count_unread_mentions()` — подсчёт -- ✅ Enum `ChatSortOrder` (ByLastMessage, ByTitle, ByUnreadCount, PinnedFirst) -- ✅ Reference-based фильтрация (без клонирования) -- ✅ 6 unit тестов (все проходят) - -**8.2. MessageService** (src/app/message_service.rs - 508+ строк): -- ✅ Struct `MessageGroup` — группировка по дате -- ✅ Struct `SenderGroup` — группировка по отправителю -- ✅ Struct `MessageSearchResult` — результаты поиска с контекстом -- ✅ Struct `MessageService` с 13 методами бизнес-логики: - - `group_by_date()` — группировка с метками "Сегодня", "Вчера", дата - - `group_by_sender()` — объединение последовательных сообщений от отправителя - - `search()` — полнотекстовый поиск (case-insensitive) с snippet - - `find_next()` / `find_previous()` — навигация по результатам - - `filter_by_sender()` / `filter_unread()` — фильтрация сообщений - - `find_by_id()` / `find_index_by_id()` — поиск по ID - - `get_last_n()` — получение последних N сообщений - - `get_in_date_range()` — фильтрация по диапазону дат - - `count_by_sender_type()` — статистика (incoming/outgoing) - - `create_index()` — создание HashMap индекса для быстрого доступа -- ✅ 7 unit тестов (все проходят) - -**Результаты Phase 8:** -- ✅ Создано **3 новых модуля** с чёткими абстракциями -- ✅ **1778+ строк** структурированного кода -- ✅ **20 unit тестов** (все проходят) -- ✅ Разделение ответственности: TDLib → Service → UI -- ✅ Builder pattern для фильтров -- ✅ Trait-based расширяемая архитектура -- ✅ Type-safe command система -- ⏳ TODO: интеграция в существующий код App/UI - -**Итоговые метрики всего рефакторинга:** -- ✅ **26/26 категорий** завершены (100%) -- ✅ **640+ тестов** проходят успешно -- ✅ Код сокращён и модуляризирован -- ✅ Type safety и безопасность -- ✅ Архитектура готова к масштабированию - -### Phase 9: Интеграция новых модулей (категории 6, 8) ✅ ЗАВЕРШЕНА! (2026-02-04) - -**Цель**: Интегрировать созданные в Phase 8 модули (KeyHandler, Keybindings, ChatFilter, MessageService) в существующий код App/UI - -**Результат**: Все модули успешно интегрированы! Централизованная архитектура для команд, фильтрации чатов и операций с сообщениями. - -#### 9.1. Интеграция Keybindings в Config ✅ ЗАВЕРШЕНО! (2026-02-04) - -**Проблема**: В Phase 8 была создана новая система `Keybindings` + `Command` enum, но Config всё ещё использовал старую систему `HotkeysConfig`. - -**Решение**: -- ✅ Заменено поле `hotkeys: HotkeysConfig` на `keybindings: Keybindings` в структуре Config -- ✅ Удалена вся старая система `HotkeysConfig` (~200 строк кода) -- ✅ Удалён метод `matches()` и все вспомогательные функции -- ✅ Обновлён `Config::default()` для использования `Keybindings::default()` -- ✅ Обновлены все тесты в `tests/config.rs`: - - Заменён импорт `HotkeysConfig` на `Keybindings` - - Заменены все использования `hotkeys` на `keybindings` - - Обновлён тест `test_config_default_includes_keybindings()` - -**Результаты**: -- ✅ Код компилируется успешно -- ✅ Все **666 тестов** проходят -- ✅ Config теперь использует type-safe систему Keybindings -- ✅ Готово к дальнейшей интеграции в input handlers - -**Преимущества новой системы**: -- 🛡️ Type-safe команды через `Command` enum вместо строк -- 🔑 Метод `get_command(&KeyEvent) -> Option` для определения команды -- 🌐 Поддержка модификаторов (Ctrl, Shift) из коробки -- 📝 Сериализация/десериализация через serde -- 🔧 Легко добавлять новые команды и привязки - -**Phase 9 завершена!** Все модули интегрированы. - -#### 9.5. Интеграция MessageService в message operations ✅ ЗАВЕРШЕНО! (2026-02-04) - -**Цель**: Заменить ручной поиск сообщений на использование централизованного MessageService модуля. - -**Решение**: -- ✅ MessageService уже импортирован в `src/app/mod.rs` (строка 15) -- ✅ Заменён ручной поиск на `MessageService::find_by_id()` в двух методах: - - `get_replying_to_message()` — поиск сообщения, на которое отвечаем - - `get_forwarding_message()` — поиск сообщения для пересылки -- ✅ Удалены дублирующие `.iter().find(|m| m.id() == id)` конструкции - -**Изменения**: -```rust -// Было: ручной поиск через итератор -self.td_client - .current_chat_messages() - .iter() - .find(|m| m.id() == id) - .cloned() - -// Стало: централизованный поиск через MessageService -MessageService::find_by_id(&self.td_client.current_chat_messages(), id).cloned() -``` - -**Результаты**: -- ✅ Код компилируется успешно -- ✅ Все **631 тест** проходят успешно -- ✅ Централизованная логика поиска сообщений -- ✅ Reference-based поиск (без клонирования при поиске) -- ✅ Готова инфраструктура для использования других методов MessageService - -**Преимущества**: -- 🏗️ Единая точка логики работы с сообщениями -- 🔧 Легко расширять функциональность (search, filter, group_by_date, и т.д.) -- 📝 DRY принцип — меньше дублирования кода -- 🧪 Методы MessageService покрыты unit тестами -- ♻️ Переиспользование в других частях кода - -**Доступные методы MessageService для будущей интеграции**: -- `search()` — полнотекстовый поиск по сообщениям -- `find_index_by_id()` — поиск индекса сообщения -- `group_by_date()` — группировка по дате -- `group_by_sender()` — группировка по отправителю -- `filter_unread()` / `filter_by_sender()` — фильтрация -- `get_last_n()` — получение последних N сообщений -- `count_by_sender_type()` — статистика -- `create_index()` — создание HashMap индекса - -#### 9.4. Интеграция ChatFilter в chat list filtering ✅ ЗАВЕРШЕНО! (2026-02-04) - -**Цель**: Заменить ручную фильтрацию чатов на использование централизованного ChatFilter модуля. - -**Решение**: -- ✅ Добавлен импорт `ChatFilter` и `ChatFilterCriteria` в `src/app/chat_list_state.rs` -- ✅ Метод `get_filtered_chats()` переписан с использованием ChatFilter API -- ✅ Удалена дублирующая логика фильтрации по папкам и поиску -- ✅ Используется builder pattern для создания критериев фильтрации - -**Изменения**: -```rust -// Было: ручная фильтрация в два этапа -let folder_filtered: Vec<&ChatInfo> = match self.selected_folder_id { - None => self.chats.iter().collect(), - Some(folder_id) => self.chats.iter().filter(...).collect(), -}; -if self.search_query.is_empty() { ... } - -// Стало: централизованная фильтрация через ChatFilter -let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id); -if !self.search_query.is_empty() { - criteria = criteria.with_search(self.search_query.clone()); -} -ChatFilter::filter(&self.chats, &criteria) -``` - -**Результаты**: -- ✅ Код компилируется успешно -- ✅ Все **631 тест** проходят успешно -- ✅ Централизованная логика фильтрации (единый источник правды) -- ✅ Сокращён код в ChatListState (меньше дублирования) -- ✅ Легко расширять критерии фильтрации в будущем - -**Преимущества**: -- 🏗️ Единая точка логики фильтрации (ChatFilter модуль) -- 🔧 Builder pattern для композиции критериев -- 📝 Легко добавлять новые типы фильтров (pinned, unread, mentions) -- 🧪 Reference-based фильтрация (без клонирования) -- ♻️ Переиспользование в других частях кода - -#### 9.2. Интеграция Command enum в main_input.rs ✅ ЗАВЕРШЕНО! (2026-02-04) - -**Цель**: Использовать type-safe `Command` enum вместо прямых проверок `KeyCode` в обработчиках ввода. - -**Решение**: -- ✅ Добавлен импорт `use crate::config::Command;` в main_input.rs -- ✅ В начале `handle()` получаем команду: `let command = app.config.keybindings.get_command(&key);` -- ✅ Сделано поле `config` публичным в `App` struct для доступа к keybindings -- ✅ Обновлены обработчики режимов с добавлением параметра `command: Option`: - - `handle_profile_mode()` — навигация по профилю (MoveUp/Down, Cancel) - - `handle_message_selection()` — выбор сообщений (DeleteMessage, ReplyMessage, ForwardMessage, CopyMessage, ReactMessage) - - `handle_chat_list_navigation()` — навигация по чатам (MoveUp/Down, SelectFolder1-9) -- ✅ Создана вспомогательная функция `select_folder()` для выбора папки по индексу -- ✅ Исправлены русские клавиши в keybindings.rs ('р' для MoveUp, 'л' для MoveLeft) -- ✅ Обновлён тест `test_default_bindings()` для соответствия новым привязкам - -**Результаты**: -- ✅ Код компилируется успешно -- ✅ Все **631 тест** проходят успешно -- ✅ Type-safe обработка команд через Command enum -- ✅ Fallback на старую логику KeyCode сохранён для совместимости -- ✅ Fallback для стрелок Up/Down в handle_chat_list_navigation (исправлен test_arrow_navigation_in_chat_list) -- ✅ Русская раскладка работает корректно - -**Преимущества**: -- 🛡️ Type-safe команды вместо строковых проверок -- 🔧 Единая точка конфигурации клавиш (keybindings) -- 📝 Легко добавлять новые команды -- 🌐 Поддержка модификаторов (Ctrl, Shift) -- ♻️ Переиспользование логики через Command enum - -**Примечание**: KeyHandler trait не интегрирован, так как async обработчики несовместимы с синхронным trait. Вместо этого используется прямая интеграция Command enum, что проще и естественнее для async кода. - ---- - -## Известные проблемы - -1. При первом запуске нужно пройти авторизацию +Полная структура проекта: см. [PROJECT_STRUCTURE.md](PROJECT_STRUCTURE.md) +Подробный план: см. [ROADMAP.md](ROADMAP.md) diff --git a/Cargo.lock b/Cargo.lock index 7ed3479..6c7b974 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -28,6 +28,24 @@ dependencies = [ "memchr", ] +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -55,6 +73,12 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +[[package]] +name = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + [[package]] name = "arbitrary" version = "1.4.2" @@ -84,6 +108,32 @@ dependencies = [ "x11rb", ] +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -227,18 +277,86 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47c8fbc0f831f4519fe8b810b6a7a91410ec83031b8233f730a0480029f6a23f" +dependencies = [ + "arrayvec", +] + [[package]] name = "base64" version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + [[package]] name = "bitflags" version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +[[package]] +name = "bitstream-io" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d4bd9d1db2c6bdf285e223a7fa369d5ce98ec767dec949c6ca62863ce61757" +dependencies = [ + "core2", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -270,6 +388,12 @@ dependencies = [ "piper", ] +[[package]] +name = "built" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" + [[package]] name = "bumpalo" version = "3.19.1" @@ -443,6 +567,12 @@ dependencies = [ "error-code", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "compact_str" version = "0.8.1" @@ -500,6 +630,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +dependencies = [ + "memchr", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -865,6 +1004,26 @@ dependencies = [ "syn", ] +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -908,6 +1067,21 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.74.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "rayon-core", + "smallvec", + "zune-inflate", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1103,6 +1277,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "gif" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "h2" version = "0.4.13" @@ -1407,6 +1591,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icy_sixel" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc0a9c4770bc47b0a933256a496cfb8b6531f753ea9bccb19c6dff0ff7273fc" + [[package]] name = "ident_case" version = "1.0.1" @@ -1442,12 +1632,38 @@ checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", "moxcms", "num-traits", "png", + "qoi", + "ravif", + "rayon", + "rgb", "tiff", + "zune-core 0.5.1", + "zune-jpeg 0.5.12", ] +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" + [[package]] name = "indexmap" version = "1.9.3" @@ -1514,6 +1730,17 @@ dependencies = [ "syn", ] +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1578,6 +1805,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.17" @@ -1610,12 +1846,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + [[package]] name = "libc" version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +[[package]] +name = "libfuzzer-sys" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5037190e1f70cbeef565bd267599242926f724d3b8a9f510fd7e0b540cfa4404" +dependencies = [ + "arbitrary", + "cc", +] + [[package]] name = "libredox" version = "0.1.12" @@ -1659,6 +1911,15 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + [[package]] name = "lru" version = "0.12.5" @@ -1710,6 +1971,16 @@ dependencies = [ "regex-automata", ] +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + [[package]] name = "memchr" version = "2.7.6" @@ -1780,6 +2051,27 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + [[package]] name = "notify-rust" version = "4.12.0" @@ -1803,12 +2095,53 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1976,6 +2309,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parking" version = "2.2.1" @@ -2011,6 +2350,12 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + [[package]] name = "pathdiff" version = "0.2.3" @@ -2132,6 +2477,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + [[package]] name = "proc-macro-crate" version = "3.4.0" @@ -2150,6 +2504,25 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "pxfm" version = "0.1.27" @@ -2159,6 +2532,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quick-error" version = "2.0.1" @@ -2189,6 +2571,65 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "ratatui" version = "0.29.0" @@ -2210,6 +2651,72 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "ratatui-image" +version = "8.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ecc67e9f7d0ac69e0f712f58b1a9d5a04d8daeeb3628f4d6b67580abb88b7cb" +dependencies = [ + "base64-simd", + "icy_sixel", + "image", + "rand 0.8.5", + "ratatui", + "rustix 0.38.44", + "thiserror 1.0.69", + "windows 0.58.0", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.2", + "rand_chacha 0.9.0", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef69c1990ceef18a116855938e74793a5f7496ee907562bd0857b6ac734ab285" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + [[package]] name = "rayon" version = "1.11.0" @@ -2352,6 +2859,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rgb" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" + [[package]] name = "ring" version = "0.17.14" @@ -2677,6 +3190,15 @@ version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + [[package]] name = "similar" version = "2.7.0" @@ -2811,7 +3333,7 @@ checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" dependencies = [ "quick-xml", "thiserror 2.0.18", - "windows", + "windows 0.61.3", "windows-version", ] @@ -2855,15 +3377,18 @@ version = "0.1.0" dependencies = [ "arboard", "async-trait", + "base64", "chrono", "criterion", "crossterm", "dirs 5.0.1", "dotenvy", + "image", "insta", "notify-rust", "open", "ratatui", + "ratatui-image", "serde", "serde_json", "tdlib-rs", @@ -2948,7 +3473,7 @@ dependencies = [ "half", "quick-error", "weezl", - "zune-jpeg", + "zune-jpeg 0.4.21", ] [[package]] @@ -3355,6 +3880,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3373,6 +3909,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "walkdir" version = "2.5.0" @@ -3513,6 +4055,16 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6" +dependencies = [ + "windows-core 0.58.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows" version = "0.61.3" @@ -3535,14 +4087,27 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99" +dependencies = [ + "windows-implement 0.58.0", + "windows-interface 0.58.0", + "windows-result 0.2.0", + "windows-strings 0.1.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-core" version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -3554,8 +4119,8 @@ version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement", - "windows-interface", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.2.1", "windows-result 0.4.1", "windows-strings 0.5.1", @@ -3572,6 +4137,17 @@ dependencies = [ "windows-threading", ] +[[package]] +name = "windows-implement" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -3583,6 +4159,17 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-interface" +version = "0.58.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-interface" version = "0.59.3" @@ -3627,6 +4214,15 @@ dependencies = [ "windows-strings 0.5.1", ] +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -3645,6 +4241,16 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result 0.2.0", + "windows-targets 0.52.6", +] + [[package]] name = "windows-strings" version = "0.4.2" @@ -3959,6 +4565,12 @@ dependencies = [ "lzma-sys", ] +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + [[package]] name = "yoke" version = "0.8.1" @@ -4219,13 +4831,37 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zune-jpeg" version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ - "zune-core", + "zune-core 0.4.12", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +dependencies = [ + "zune-core 0.5.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 7408e4f..8610cf7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,10 +10,11 @@ keywords = ["telegram", "tui", "terminal", "cli"] categories = ["command-line-utilities"] [features] -default = ["clipboard", "url-open", "notifications"] +default = ["clipboard", "url-open", "notifications", "images"] clipboard = ["dep:arboard"] url-open = ["dep:open"] notifications = ["dep:notify-rust"] +images = ["dep:ratatui-image", "dep:image"] [dependencies] ratatui = "0.29" @@ -28,11 +29,14 @@ chrono = "0.4" open = { version = "5.0", optional = true } arboard = { version = "3.4", optional = true } notify-rust = { version = "4.11", optional = true } +ratatui-image = { version = "8.1", optional = true, features = ["image-defaults"] } +image = { version = "0.25", optional = true } toml = "0.8" dirs = "5.0" thiserror = "1.0" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +base64 = "0.22.1" [dev-dependencies] insta = "1.34" diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index 8aaf2a3..ec1fb6d 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -1,453 +1,328 @@ # Структура проекта +## Архитектура (ASCII) + +``` + ┌─────────────┐ + │ main.rs │ Event loop (60 FPS) + └──────┬──────┘ + │ + ┌────────────┼────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ input/ │ │ app/ │ │ ui/ │ + │ handlers │ │ state │ │ render │ + └────┬─────┘ └────┬─────┘ └────┬─────┘ + │ │ │ + │ ┌──────┴──────┐ │ + │ │ methods/ │ │ + │ │ (5 traits) │ │ + │ └──────┬──────┘ │ + │ │ │ + ▼ ▼ ▼ + ┌─────────────────────────────────┐ + │ tdlib/ │ + │ TdClientTrait → TdClient │ + │ messages/ | auth | chats │ + └──────────────┬──────────────────┘ + │ + ┌─────▼─────┐ + │ TDLib C │ + │ library │ + └───────────┘ +``` + +### Data Flow +``` +TDLib Updates → mpsc channel → App state → UI rendering +User Input → handlers → App methods (traits) → TdClient → TDLib API +``` + ## Обзор директорий ``` tele-tui/ -├── .github/ # GitHub конфигурация -│ ├── ISSUE_TEMPLATE/ # Шаблоны для issue -│ │ ├── bug_report.md -│ │ └── feature_request.md -│ ├── workflows/ # GitHub Actions CI/CD -│ │ └── ci.yml +├── src/ +│ ├── main.rs # Точка входа, event loop +│ ├── lib.rs # Экспорт модулей для тестов +│ ├── types.rs # ChatId, MessageId (newtype wrappers) +│ ├── constants.rs # MAX_MESSAGES_IN_CHAT, etc. +│ ├── formatting.rs # Markdown entity форматирование +│ ├── message_grouping.rs # Группировка сообщений по дате/отправителю +│ ├── notifications.rs # Desktop уведомления (NotificationManager) +│ │ +│ ├── app/ # Состояние приложения +│ │ ├── mod.rs # App struct, конструкторы, getters (372 loc) +│ │ ├── state.rs # AppScreen enum +│ │ ├── chat_state.rs # ChatState enum (state machine) +│ │ ├── chat_filter.rs # ChatFilter, ChatFilterCriteria +│ │ ├── chat_list_state.rs # Состояние списка чатов +│ │ ├── auth_state.rs # Состояние авторизации +│ │ ├── compose_state.rs # Состояние compose bar +│ │ ├── ui_state.rs # UI-related state +│ │ ├── message_service.rs # Сервис сообщений +│ │ ├── message_view_state.rs # Состояние просмотра сообщений +│ │ └── methods/ # Trait-based методы App (Этап 2) +│ │ ├── mod.rs # Re-exports 5 trait модулей +│ │ ├── navigation.rs # NavigationMethods (7 методов) +│ │ ├── messages.rs # MessageMethods (8 методов) +│ │ ├── compose.rs # ComposeMethods (10 методов) +│ │ ├── search.rs # SearchMethods (15 методов) +│ │ └── modal.rs # ModalMethods (27 методов) +│ │ +│ ├── config/ # Конфигурация (Этап 5) +│ │ ├── mod.rs # Config struct, defaults (350 loc) +│ │ ├── keybindings.rs # Command enum, Keybindings +│ │ ├── validation.rs # validate(), parse_color() +│ │ └── loader.rs # load(), save(), credentials +│ │ +│ ├── input/ # Обработка пользовательского ввода +│ │ ├── mod.rs # Роутинг по экранам +│ │ ├── auth.rs # Ввод на экране авторизации +│ │ ├── main_input.rs # Роутер главного экрана (159 loc, Этап 1) +│ │ ├── key_handler.rs # Trait-based обработка клавиш +│ │ └── handlers/ # Специализированные обработчики (Этап 1) +│ │ ├── mod.rs # Exports + scroll_to_message() +│ │ ├── global.rs # Ctrl+R/S/P/F глобальные команды +│ │ ├── chat.rs # Открытый чат: ввод, скролл, selection +│ │ ├── chat_list.rs # Навигация по списку чатов, папки +│ │ ├── compose.rs # Forward mode +│ │ ├── modal.rs # Profile, reactions, pinned, delete +│ │ ├── search.rs # Поиск чатов и сообщений +│ │ ├── clipboard.rs # Копирование в буфер обмена +│ │ └── profile.rs # Хелперы профиля +│ │ +│ ├── tdlib/ # TDLib интеграция +│ │ ├── mod.rs # Экспорт публичных типов +│ │ ├── types.rs # MessageInfo, ChatInfo, ProfileInfo, etc. +│ │ ├── trait.rs # TdClientTrait (DI для тестов) +│ │ ├── client.rs # TdClient struct, конструктор +│ │ ├── client_impl.rs # impl TdClientTrait for TdClient +│ │ ├── auth.rs # Авторизация (phone, code, 2FA) +│ │ ├── chats.rs # Загрузка чатов, папок +│ │ ├── users.rs # Кеш пользователей, статусы +│ │ ├── reactions.rs # ReactionInfo, toggle_reaction +│ │ ├── chat_helpers.rs # Вспомогательные функции чатов +│ │ ├── update_handlers.rs # Обработка TDLib update events +│ │ ├── message_converter.rs # Конвертация TDLib → MessageInfo +│ │ ├── message_conversion.rs # Доп. функции конвертации +│ │ └── messages/ # Менеджер сообщений (Этап 4) +│ │ ├── mod.rs # MessageManager struct (99 loc) +│ │ ├── convert.rs # convert_message, fetch_reply_info +│ │ └── operations.rs # 11 TDLib API операций (616 loc) +│ │ +│ ├── ui/ # Рендеринг интерфейса +│ │ ├── mod.rs # render() — роутинг по экранам +│ │ ├── loading.rs # Экран загрузки +│ │ ├── auth.rs # Экран авторизации +│ │ ├── main_screen.rs # Главный экран + папки +│ │ ├── footer.rs # Футер с командами и статусом сети +│ │ ├── chat_list.rs # Список чатов + онлайн-статус +│ │ ├── messages.rs # Область сообщений (364 loc, Этап 3) +│ │ ├── compose_bar.rs # Multi-mode input box (Этап 3) +│ │ ├── profile.rs # Профиль пользователя/чата +│ │ ├── modals/ # Модальные окна (Этап 3) +│ │ │ ├── mod.rs # Re-exports +│ │ │ ├── delete_confirm.rs # Подтверждение удаления +│ │ │ ├── reaction_picker.rs # Выбор реакции +│ │ │ ├── search.rs # Поиск по сообщениям +│ │ │ └── pinned.rs # Закреплённые сообщения +│ │ └── components/ # Переиспользуемые UI компоненты (Этап 6) +│ │ ├── mod.rs # Re-exports +│ │ ├── modal.rs # render_modal(), render_delete_confirm +│ │ ├── input_field.rs # render_input_field() +│ │ ├── message_bubble.rs # render_message_bubble(), sender, date +│ │ ├── message_list.rs # render_message_item(), help_bar, scroll +│ │ ├── chat_list_item.rs # render_chat_list_item() +│ │ └── emoji_picker.rs # render_emoji_picker() +│ │ +│ └── utils/ # Утилиты +│ ├── mod.rs # Exports, with_timeout helpers +│ ├── formatting.rs # format_timestamp, format_date, etc. +│ ├── tdlib.rs # disable_tdlib_logs (FFI) +│ ├── validation.rs # is_non_empty и др. +│ ├── modal_handler.rs # handle_yes_no для Y/N модалок +│ └── retry.rs # Retry утилиты +│ +├── tests/ # Интеграционные тесты +│ ├── helpers/ # Тестовая инфраструктура +│ │ ├── mod.rs +│ │ ├── app_builder.rs # TestAppBuilder (fluent API) +│ │ ├── fake_tdclient.rs # FakeTdClient (мок TDLib) +│ │ ├── fake_tdclient_impl.rs # impl TdClientTrait for FakeTdClient +│ │ ├── test_data.rs # create_test_chat, TestMessageBuilder +│ │ └── snapshot_utils.rs # Snapshot testing хелперы +│ ├── input_navigation.rs # Тесты навигации клавиатурой +│ ├── chat_list.rs # Тесты списка чатов +│ ├── messages.rs # Тесты сообщений +│ ├── send_message.rs # Тесты отправки +│ ├── edit_message.rs # Тесты редактирования +│ ├── delete_message.rs # Тесты удаления +│ ├── reply_forward.rs # Тесты reply/forward +│ ├── reactions.rs # Тесты реакций +│ ├── search.rs # Тесты поиска +│ ├── modals.rs # Тесты модальных окон +│ ├── profile.rs # Тесты профиля +│ ├── navigation.rs # Тесты навигации +│ ├── drafts.rs # Тесты черновиков +│ ├── copy.rs # Тесты копирования +│ ├── screens.rs # Тесты экранов +│ ├── footer.rs # Тесты футера +│ ├── input_field.rs # Тесты поля ввода +│ ├── config.rs # Тесты конфигурации +│ ├── network_typing.rs # Тесты typing status +│ ├── e2e_smoke.rs # Smoke тесты +│ └── e2e_user_journey.rs # E2E user journey тесты +│ +├── .github/ # GitHub конфигурация +│ ├── ISSUE_TEMPLATE/ +│ ├── workflows/ci.yml │ └── pull_request_template.md │ -├── docs/ # Дополнительная документация -│ └── TDLIB_INTEGRATION.md +├── Cargo.toml # Манифест проекта +├── Cargo.lock # Точные версии зависимостей +├── build.rs # Build script (TDLib) +├── rustfmt.toml # cargo fmt конфигурация +├── .editorconfig # Настройки IDE +├── .gitignore # Git ignore │ -├── src/ # Исходный код -│ ├── app/ # Состояние приложения -│ │ ├── mod.rs -│ │ └── state.rs -│ ├── input/ # Обработка пользовательского ввода -│ │ ├── mod.rs -│ │ ├── auth.rs -│ │ └── main_input.rs -│ ├── audio/ # Прослушивание голосовых (PLANNED) -│ │ ├── mod.rs # Экспорт публичных типов -│ │ ├── player.rs # AudioPlayer на rodio -│ │ ├── cache.rs # VoiceCache для OGG файлов -│ │ └── state.rs # PlaybackState -│ ├── media/ # Работа с изображениями (PLANNED) -│ │ ├── mod.rs # Экспорт публичных типов -│ │ ├── image_cache.rs # LRU кэш для загруженных изображений -│ │ ├── image_loader.rs # Асинхронная загрузка через TDLib -│ │ └── image_renderer.rs # Рендеринг изображений в ratatui -│ ├── notifications.rs # Desktop уведомления -│ ├── tdlib/ # TDLib интеграция -│ │ ├── mod.rs -│ │ └── client.rs -│ ├── ui/ # Рендеринг интерфейса -│ │ ├── mod.rs -│ │ ├── auth.rs -│ │ ├── chat_list.rs -│ │ ├── footer.rs -│ │ ├── loading.rs -│ │ ├── main_screen.rs -│ │ └── messages.rs -│ ├── config.rs # Конфигурация приложения -│ ├── main.rs # Точка входа -│ └── utils.rs # Утилиты +├── config.toml.example # Пример конфигурации +├── credentials.example # Пример credentials │ -├── tdlib_data/ # TDLib сессия (НЕ коммитится) -├── target/ # Артефакты сборки (НЕ коммитится) -│ -├── .editorconfig # EditorConfig для IDE -├── .gitignore # Git ignore правила -├── Cargo.lock # Зависимости (точные версии) -├── Cargo.toml # Манифест проекта -├── rustfmt.toml # Конфигурация форматирования -│ -├── config.toml.example # Пример конфигурации -├── credentials.example # Пример credentials -│ -├── CHANGELOG.md # История изменений -├── CLAUDE.md # Инструкции для Claude AI -├── CONTRIBUTING.md # Гайд по контрибуции -├── CONTEXT.md # Текущий статус разработки -├── DEVELOPMENT.md # Правила разработки -├── FAQ.md # Часто задаваемые вопросы -├── HOTKEYS.md # Список горячих клавиш -├── INSTALL.md # Инструкция по установке -├── LICENSE # MIT лицензия -├── PROJECT_STRUCTURE.md # Этот файл -├── README.md # Главная документация -├── REQUIREMENTS.md # Функциональные требования -├── ROADMAP.md # План развития -└── SECURITY.md # Политика безопасности +├── CLAUDE.md # Инструкции для AI +├── CONTEXT.md # Текущий статус +├── ROADMAP.md # План развития +├── DEVELOPMENT.md # Правила разработки +├── REQUIREMENTS.md # Требования +├── ARCHITECTURE.md # C4, sequence diagrams +├── PROJECT_STRUCTURE.md # Этот файл +├── E2E_TESTING.md # Гайд по тестированию +├── HOTKEYS.md # Горячие клавиши +├── CHANGELOG.md # История изменений +├── README.md # Главная документация +├── INSTALL.md # Установка +├── FAQ.md # FAQ +├── CONTRIBUTING.md # Гайд по контрибуции +├── SECURITY.md # Безопасность +└── LICENSE # MIT лицензия ``` -## Исходный код (src/) - -### main.rs -**Точка входа приложения** -- Инициализация TDLib клиента -- Event loop (60 FPS) -- Обработка Ctrl+C (graceful shutdown) -- Координация между UI, input и TDLib - -### config.rs -**Конфигурация приложения** -- Загрузка/сохранение TOML конфига -- Парсинг timezone и цветов -- Загрузка credentials (приоритетная система) -- XDG directory support - -### utils.rs -**Утилитарные функции** -- `disable_tdlib_logs()` — отключение TDLib логов через FFI -- `format_timestamp_with_tz()` — форматирование времени с учётом timezone -- `format_date()` — форматирование дат для разделителей -- `format_datetime()` — полное форматирование даты и времени -- `format_was_online()` — "был(а) X мин. назад" +## Ключевые модули ### app/ — Состояние приложения -#### mod.rs -- `App` struct — главная структура состояния -- `needs_redraw` — флаг для оптимизации рендеринга -- Состояние модалок (delete confirm, reaction picker, profile) -- Состояние поиска и черновиков -- Методы для работы с UI state +`App` — главная структура, параметризована 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`): +| 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, - folders: Vec, - - // UI state - selected_chat_id: Option, - input_text: String, - cursor_position: usize, - - // Modals - is_delete_confirmation: bool, - is_reaction_picker_mode: bool, - profile_info: Option, - view_image_mode: Option, // PLANNED - Фаза 11 - - // Search - search_query: String, - search_results: Vec, - - // Drafts - drafts: HashMap, - - // Audio (PLANNED - Фаза 12) - audio_player: Option, - playback_state: Option, - 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) | diff --git a/ROADMAP.md b/ROADMAP.md index 5809a50..7a252a5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,677 +1,170 @@ # Roadmap -## Фаза 1: Базовая инфраструктура [DONE] +## Завершённые фазы -- [x] Настройка проекта (Cargo.toml) -- [x] TUI фреймворк (ratatui + crossterm) -- [x] Базовый layout (папки, список чатов, область сообщений) -- [x] Vim-style навигация (hjkl, стрелки) -- [x] Русская раскладка (ролд) +| Фаза | Описание | Ключевые результаты | +|------|----------|---------------------| +| 1 | Базовая инфраструктура | ratatui + crossterm, vim-навигация, русская раскладка | +| 2 | TDLib интеграция | tdlib-rs, авторизация, загрузка чатов и сообщений | +| 3 | Улучшение UX | Отправка, поиск, скролл, realtime обновления | +| 4 | Папки и фильтрация | Загрузка папок из Telegram, переключение 1-9 | +| 5 | Расширенный функционал | Онлайн-статус, галочки прочтения, медиа-заглушки, muted | +| 6 | Полировка | 60 FPS, оптимизация памяти, graceful shutdown, динамический инпут | +| 7 | Рефакторинг памяти | Единый источник данных, LRU-кэш (500 users), lazy loading | +| 8 | Дополнительные фичи | Markdown, edit/delete, reply/forward, блочный курсор | +| 9 | Расширенные возможности | Typing, pinned, поиск в чате, черновики, профиль, копирование, реакции, конфиг | +| 10 | Desktop уведомления (83%) | notify-rust, muted фильтр, mentions, медиа. TODO: кастомные звуки | +| 11 | Inline просмотр фото | Dual renderer (Halfblocks + iTerm2/Sixel), throttling 15 FPS, modal viewer, lazy loading, auto-download | +| 12 | Голосовые сообщения | ffplay player, pause/resume with seek, VoiceCache, AudioConfig, progress bar + waveform UI | +| 13 | Глубокий рефакторинг | 5 файлов (4582->модули), 5 traits, shared components, docs | -## Фаза 2: TDLib интеграция [DONE] +--- -- [x] Подключение tdlib-rs -- [x] Авторизация (телефон + код + 2FA) -- [x] Сохранение сессии -- [x] Загрузка списка чатов -- [x] Загрузка истории сообщений -- [x] Отключение логов TDLib +## Фаза 11: Inline просмотр фото в чате [DONE] -## Фаза 3: Улучшение UX [DONE] +**UX**: Always-show inline preview (50 chars, Halfblocks) -> `v`/`м` открывает fullscreen modal (iTerm2/Sixel) -> `←`/`→` навигация между фото. -- [x] Отправка сообщений -- [x] Фильтрация чатов (только Main, без архива) -- [x] Поиск по чатам (Ctrl+S) -- [x] Скролл истории сообщений -- [x] Загрузка имён пользователей (вместо User_ID) -- [x] Отметка сообщений как прочитанные -- [x] Реальное время: новые сообщения +### Реализовано: +- [x] **Dual renderer архитектура**: + - `inline_image_renderer`: Halfblocks (быстро, Unicode блоки) для навигации + - `modal_image_renderer`: iTerm2/Sixel (медленно, высокое качество) для просмотра +- [x] **Performance optimizations**: + - Frame throttling: inline 15 FPS, текст 60 FPS + - Lazy loading: только видимые изображения + - LRU cache: max 100 протоколов + - Skip partial rendering (no flickering) +- [x] **UX улучшения**: + - Always-show inline preview (фикс. ширина 50 chars) + - Fullscreen modal на `v`/`м` с aspect ratio + - Loading indicator в модалке + - Navigation hotkeys: `←`/`→` между фото, `Esc`/`q` закрыть +- [x] **Типы и API**: + - `MediaInfo`, `PhotoInfo`, `PhotoDownloadState`, `ImageModalState` + - `ImagesConfig` в config.toml + - Feature flag `images` для зависимостей +- [x] **Media модуль**: + - `cache.rs`: ImageCache (LRU) + - `image_renderer.rs`: new() + new_fast() +- [x] **UI модули**: + - `modals/image_viewer.rs`: fullscreen modal + - `messages.rs`: throttled second-pass rendering +- [x] **Авто-загрузка фото** (bugfix): + - Auto-download последних 30 фото при открытии чата (`open_chat_and_load_data`) + - Download on demand по `v` (вместо "Фото не загружено") + - Retry при ошибке загрузки + - Конфиг: `auto_download_images` + `show_images` в `[images]` -## Фаза 4: Папки и фильтрация [DONE] +--- -- [x] Загрузка папок из Telegram -- [x] Переключение между папками (1-9) -- [x] Фильтрация чатов по папке +## Фаза 12: Прослушивание голосовых сообщений [DONE] -## Фаза 5: Расширенный функционал [DONE] +### Этап 1: Инфраструктура аудио [DONE] +- [x] Модуль `src/audio/` + - `player.rs` — AudioPlayer на ffplay (subprocess) + - `cache.rs` — VoiceCache (LRU, configurable size, `~/.cache/tele-tui/voice/`) +- [x] AudioPlayer API: play(), play_from(ss), pause() (SIGSTOP), resume(), resume_from(ss), stop() +- [x] Race condition fix: `starting` flag + pid ownership guard в потоках +- [x] Drop impl для AudioPlayer (убивает ffplay при выходе) -- [x] Отображение онлайн-статуса (зелёная точка ●) -- [x] Статус доставки/прочтения (✓, ✓✓) -- [x] Поддержка медиа-заглушек (фото, видео, голосовые, стикеры и др.) -- [x] Mentions (@) — индикатор непрочитанных упоминаний -- [x] Muted чаты (иконка 🔇) +### Этап 2: Интеграция с TDLib [DONE] +- [x] Типы: `VoiceInfo`, `VoiceDownloadState`, `PlaybackState`, `PlaybackStatus` +- [x] Конвертация `MessageVoiceNote` в `message_conversion.rs` +- [x] `download_voice_note()` в TdClientTrait + client_impl + fake +- [x] Методы `has_voice()`, `voice_info()`, `voice_info_mut()` на `MessageInfo` -## Фаза 6: Полировка [DONE] +### Этап 3: UI для воспроизведения [DONE] +- [x] Progress bar (━●─) с позицией и длительностью +- [x] Waveform визуализация (▁▂▃▄▅▆▇█) из base64-encoded TDLib данных +- [x] Иконки статуса: ▶ Playing, ⏸ Paused, ⏹ Stopped +- [x] Throttled redraw: обновление UI только при смене секунды (не 60 FPS) -- [x] Оптимизация использования памяти (базовая) - - Очистка сообщений при закрытии чата - - Лимит кэша пользователей (500) - - Периодическая очистка неактивных записей -- [x] Оптимизация 60 FPS - - Poll таймаут 16ms - - Флаг `needs_redraw` — рендеринг только при изменениях - - Обработка Event::Resize для перерисовки при изменении размера -- [x] Минимальное разрешение (80x20) - - Предупреждение если терминал слишком мал -- [x] Обработка ошибок сети - - NetworkState enum (WaitingForNetwork, Connecting, etc.) - - Индикатор в футере с цветовой индикацией -- [x] Graceful shutdown - - AtomicBool флаг для остановки polling - - Корректное закрытие TDLib клиента - - Таймаут ожидания завершения задач -- [x] Динамический инпут - - Автоматическое расширение до 10 строк - - Wrap для длинного текста -- [x] Перенос длинных сообщений - - Автоматический wrap на несколько строк - - Правильное выравнивание для исходящих/входящих +### Этап 4: Хоткеи [DONE] +- [x] Space — play/pause toggle (запуск + пауза/возобновление с откатом 1s) +- [x] ←/→ — seek ±5 сек (через `resume_from()` — перезапуск ffplay с `-ss`) +- [x] Seek работает и при воспроизведении, и на паузе (на паузе двигает позицию, при resume стартует с неё) +- [x] MoveLeft/MoveRight как alias для SeekBackward/SeekForward (HashMap non-deterministic order fix) +- [x] Автоматическая остановка при навигации на другое сообщение +- [x] Остановка ffplay при выходе из приложения (Ctrl+C) -## Фаза 7: Глубокий рефакторинг памяти [DONE] - -- [x] Удалить дублирование current_messages между App и TdClient -- [x] Использовать единый источник данных для сообщений -- [x] Реализовать LRU-кэш для user_names/user_statuses вместо простого лимита -- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл) -- [x] Лимиты памяти: - - MAX_MESSAGES_IN_CHAT = 500 - - MAX_CHATS = 200 - - MAX_CHAT_USER_IDS = 500 - - MAX_USER_CACHE_SIZE = 500 (LRU) - -## Фаза 8: Дополнительные фичи [DONE] - -- [x] Markdown форматирование в сообщениях - - Bold, Italic, Underline, Strikethrough - - Code (inline, Pre, PreCode) - - Spoiler (скрытый текст) - - URLs, упоминания (@) -- [x] Редактирование сообщений - - ↑ при пустом инпуте → выбор сообщения - - Enter для начала редактирования - - Подсветка выбранного сообщения (▶) - - Esc для отмены -- [x] Удаление сообщений - - d / в / Delete в режиме выбора - - Модалка подтверждения (y/n) - - Удаление для всех если возможно -- [x] Индикатор редактирования (✎) - - Отображается рядом с временем для отредактированных сообщений -- [x] Блочный курсор в поле ввода - - Vim-style курсор █ - - Перемещение ←/→, Home/End - - Редактирование в любой позиции -- [x] Reply на сообщения - - `r` / `к` в режиме выбора → режим ответа - - Превью сообщения в поле ввода - - Esc для отмены -- [x] Forward сообщений - - `f` / `а` в режиме выбора → режим пересылки - - Превью сообщения в поле ввода - - Выбор чата стрелками, Enter для пересылки - - Esc для отмены - - Отображение "↪ Переслано от" для пересланных сообщений - -## Фаза 9: Расширенные возможности [DONE] - -- [x] Typing indicator ("печатает...") - - Показывать когда собеседник печатает - - Отправлять свой статус печати при наборе текста -- [x] Закреплённые сообщения (Pinned) - - Отображать pinned message вверху открытого чата - - Клик/хоткей для перехода к закреплённому сообщению -- [x] Поиск по сообщениям в чате - - `Ctrl+F` — поиск текста внутри открытого чата - - Навигация по результатам (n/N или стрелки) - - Подсветка найденных совпадений -- [x] Черновики - - Сохранять набранный текст при переключении между чатами - - Индикатор черновика в списке чатов - - Восстановление текста при возврате в чат -- [x] Профиль пользователя/чата - - `Ctrl+i` — открыть информацию о чате/собеседнике - - Для личных чатов: имя, username, телефон, био - - Для групп: название, описание, количество участников -- [x] Копирование сообщений - - `y` / `н` в режиме выбора — скопировать текст в системный буфер обмена - - Использовать clipboard crate для кроссплатформенности -- [x] Реакции - - Отображение реакций под сообщениями - - `e` в режиме выбора — добавить реакцию (emoji picker) - - Список доступных реакций чата -- [x] Конфигурационный файл - - `~/.config/tele-tui/config.toml` - - Настройки: цветовая схема, часовой пояс, хоткеи - - Загрузка конфига при старте - -## Фаза 10: Desktop уведомления [DONE - 83%] - -### Стадия 1: Базовая реализация [DONE] -- [x] NotificationManager модуль - - notify-rust интеграция (версия 4.11) - - Feature flag "notifications" в Cargo.toml - - Базовая структура с настройками -- [x] Конфигурация уведомлений - - NotificationsConfig в config.toml - - enabled: bool - вкл/выкл уведомлений - - only_mentions: bool - только упоминания - - show_preview: bool - показывать превью текста -- [x] Интеграция с TdClient - - Поле notification_manager в TdClient - - Метод configure_notifications() - - Обработка в handle_new_message_update() -- [x] Базовая отправка уведомлений - - Уведомления для сообщений не из текущего чата - - Форматирование title (имя чата) и body (текст/медиа-заглушка) - - Sender name из MessageInfo - -### Стадия 2: Улучшения [IN PROGRESS] -- [x] Синхронизация muted чатов - - Загрузка списка muted чатов из Telegram - - Вызов sync_muted_chats() при инициализации и обновлении (Ctrl+R) - - Muted чаты автоматически фильтруются из уведомлений -- [x] Фильтрация по упоминаниям - - Метод MessageInfo::has_mention() проверяет TextEntityType::Mention и MentionName - - NotificationManager применяет фильтр only_mentions из конфига - - Работает для @username и inline mentions -- [x] Поддержка типов медиа - - Метод beautify_media_labels() заменяет текстовые заглушки на emoji - - Поддержка: 📷 Фото, 🎥 Видео, 🎞️ GIF, 🎤 Голосовое, 🎨 Стикер - - Также: 📎 Файл, 🎵 Аудио, 📹 Видеосообщение, 📍 Локация, 👤 Контакт, 📊 Опрос -- [ ] Кастомизация звуков - - Настройка звуков уведомлений в config.toml - - Разные звуки для разных типов сообщений - -### Стадия 3: Полировка [DONE] -- [x] Обработка ошибок - - Graceful fallback если уведомления недоступны (возвращает Ok без паники) - - Логирование ошибок через tracing::warn! - - Детальное логирование причин пропуска уведомлений (debug level) -- [x] Дополнительные настройки - - timeout_ms - продолжительность показа (0 = системное значение) - - urgency - уровень важности: "low", "normal", "critical" (только Linux) - - Красивые эмодзи для типов медиа -- [ ] Опциональные улучшения (не критично) - - Кросс-платформенное тестирование (требует ручного тестирования) - - icon - кастомная иконка приложения - - Actions в уведомлениях (кнопки "Ответить", "Прочитано") - -## Фаза 11: Показ изображений в чате [PLANNED] - -### Этап 1: Инфраструктура [TODO] -- [ ] Модуль src/media/ - - image_cache.rs - LRU кэш для загруженных изображений - - image_loader.rs - Асинхронная загрузка через TDLib - - image_renderer.rs - Рендеринг в ratatui -- [ ] Зависимости - - ratatui-image 1.0 - поддержка изображений в TUI - - Определение протокола терминала (Sixel/Kitty/iTerm2/Halfblocks) -- [ ] ImageCache с лимитами - - LRU кэш с максимальным размером в МБ - - Автоматическая очистка старых изображений - - MAX_IMAGE_CACHE_SIZE = 100 MB (по умолчанию) - -### Этап 2: Интеграция с TDLib [TODO] -- [ ] Обработка MessageContentPhoto - - Добавить PhotoInfo в MessageInfo - - Извлечение file_id, width, height из Photo - - Выбор оптимального размера изображения (до 800px) -- [ ] Загрузка файлов - - Метод TdClient::download_photo(file_id) - - Асинхронная загрузка через downloadFile API - - Обработка состояний загрузки (pending/downloading/ready) -- [ ] Кэширование - - Сохранение путей к загруженным файлам - - Повторное использование уже загруженных изображений - -### Этап 3: Рендеринг в UI [TODO] -- [ ] Модификация render_messages() - - Определение возможностей терминала при старте - - Рендеринг изображений через ratatui-image - - Автоматическое масштабирование под размер области - - Сохранение aspect ratio -- [ ] Превью в списке сообщений - - Миниатюры размером 20x10 символов - - Lazy loading (загрузка только видимых) - - Placeholder пока изображение грузится -- [ ] Индикатор загрузки - - Текстовая заглушка "[Загрузка фото...]" - - Progress bar для больших файлов - - Процент загрузки - -### Этап 4: Полноэкранный просмотр [TODO] -- [ ] Новый режим: ViewImage - - `v` / `м` в режиме выбора - открыть изображение - - Показ на весь экран терминала - - `Esc` для закрытия -- [ ] Информация об изображении - - Размер файла - - Разрешение (width x height) - - Формат (JPEG/PNG/GIF) -- [ ] Навигация - - `←` / `→` - предыдущее/следующее изображение в чате - - Автоматическая загрузка соседних изображений - -### Этап 5: Конфигурация и UX [TODO] -- [ ] MediaConfig в config.toml - - show_images: bool - включить/отключить показ изображений - - image_cache_mb: usize - размер кэша в МБ - - preview_quality: "low" | "medium" | "high" - - render_protocol: "auto" | "sixel" | "kitty" | "iterm2" | "halfblocks" -- [ ] Поддержка различных терминалов - - Auto-detection протокола при старте - - Fallback на Unicode halfblocks для любого терминала - - Опция отключения изображений если терминал не поддерживает -- [ ] Оптимизация производительности - - Асинхронная загрузка (не блокирует UI) - - Приоритизация видимых изображений - - Fast resize для превью - - Кэширование отмасштабированных версий - -### Этап 6: Обработка ошибок [TODO] -- [ ] Graceful fallback - - Текстовая заглушка "[Фото]" если загрузка не удалась - - Повторная попытка по запросу пользователя - - Логирование проблем через tracing -- [ ] Ограничения - - Таймаут загрузки (30 сек) - - Максимальный размер файла для автозагрузки (10 MB) - - Предупреждение для больших файлов +### Этап 5: Конфигурация и кэш [DONE] +- [x] `AudioConfig` в config.toml (`cache_size_mb`, `auto_download_voice`) +- [x] `DEFAULT_AUDIO_CACHE_SIZE_MB` константа (100 MB) +- [x] Ticker для progress bar в event loop (delta-based position tracking) +- [x] VoiceCache интеграция: проверка кэша перед загрузкой, кэширование после download ### Технические детали -- **Поддерживаемые протоколы:** - - Sixel (xterm, WezTerm, mintty) - - Kitty Graphics Protocol (Kitty terminal) - - iTerm2 Inline Images (iTerm2 на macOS) - - Unicode Halfblocks (fallback для всех) -- **Поддерживаемые форматы:** - - JPEG, PNG, GIF, WebP, BMP -- **Новые хоткеи:** - - `v` / `м` - открыть изображение в полном размере (режим выбора) - - `←` / `→` - навигация между изображениями (в режиме просмотра) - - `Esc` - закрыть полноэкранный просмотр +- **Аудио:** ffplay (subprocess), resume/seek через перезапуск с `-ss` offset +- **Race conditions:** `starting` flag предотвращает false `is_stopped()` при старте ffplay; pid ownership guard в потоках предотвращает затирание pid нового процесса старым +- **Keybinding conflict:** Left/Right привязаны к MoveLeft/MoveRight и SeekBackward/SeekForward; HashMap iteration order не гарантирован → оба варианта обрабатываются как seek в режиме выбора сообщения +- **Платформы:** macOS, Linux (везде где есть ffmpeg) +- **Хоткеи:** Space (play/pause), ←/→ (seek ±5s) -## Фаза 12: Прослушивание голосовых сообщений [PLANNED] +--- -### Этап 1: Инфраструктура аудио [TODO] -- [ ] Модуль src/audio/ - - player.rs - AudioPlayer на rodio - - cache.rs - VoiceCache для загруженных файлов - - state.rs - PlaybackState (статус, позиция, громкость) -- [ ] Зависимости - - rodio 0.17 - Pure Rust аудио библиотека - - Feature flag "audio" в Cargo.toml -- [ ] AudioPlayer API - - play() - воспроизведение файла - - pause() / resume() - пауза/возобновление - - stop() - остановка - - seek() - перемотка - - set_volume() - регулировка громкости - - get_position() - текущая позиция -- [ ] VoiceCache - - Кэш загруженных OGG файлов в ~/.cache/tele-tui/voice/ - - LRU политика очистки - - MAX_VOICE_CACHE_SIZE = 100 MB +## Фаза 14: Мультиаккаунт -### Этап 2: Интеграция с TDLib [TODO] -- [ ] Обработка MessageContentVoiceNote - - Добавить VoiceNoteInfo в MessageInfo - - Извлечение file_id, duration, mime_type, waveform - - Метка формата (OGG Opus обычно) -- [ ] Загрузка файлов - - Метод TdClient::download_voice_note(file_id) - - Асинхронная загрузка через downloadFile API - - Обработка состояний (pending/downloading/ready) -- [ ] Кэширование - - Сохранение путей к загруженным файлам - - Не перезагружать уже скачанные голосовые - - Проверка существования файла перед воспроизведением +**Цель**: поддержка нескольких Telegram-аккаунтов с мгновенным переключением внутри приложения. -### Этап 3: UI для воспроизведения [TODO] -- [ ] Индикатор в сообщении - - Иконка 🎤 и длительность голосового - - Progress bar во время воспроизведения - - Статус: ▶ (playing), ⏸ (paused), ⏹ (stopped), ⏳ (loading) - - Текущее время / общая длительность (0:08 / 0:15) -- [ ] Модификация render_messages() - - render_voice_note() для голосовых сообщений - - render_progress_bar() для индикатора воспроизведения - - Hint "[Space] Воспроизвести" если не играет -- [ ] Footer с управлением - - Отображение доступных команд при воспроизведении - - "[Space] Play/Pause [s] Stop [←/→] Seek [↑/↓] Volume" -- [ ] Waveform визуализация (опционально) - - Конвертация waveform данных из Telegram в ASCII bars - - Использование символов ▁▂▃▄▅▆▇█ для визуализации +### UI: Индикатор в footer + хоткеи -### Этап 4: Хоткеи для управления [TODO] -- [ ] Новые команды - - PlayVoice - Space в режиме выбора голосового - - PauseVoice - Space во время воспроизведения - - StopVoice - s / ы - - SeekBackward - ← (перемотка назад на 5 сек) - - SeekForward - → (перемотка вперед на 5 сек) - - VolumeUp - ↑ (увеличить на 10%) - - VolumeDown - ↓ (уменьшить на 10%) -- [ ] Контекстная обработка - - Space работает как play/pause в зависимости от состояния - - ← / → для seek только во время воспроизведения - - ↑ / ↓ для громкости только во время воспроизведения -- [ ] Поддержка русской раскладки - - s / ы - stop - - Остальные клавиши универсальны (Space, стрелки) - -### Этап 5: Конфигурация и UX [TODO] -- [ ] AudioConfig в config.toml - - enabled: bool - включить/отключить аудио - - default_volume: f32 - громкость по умолчанию (0.0 - 1.0) - - seek_step_seconds: i32 - шаг перемотки в секундах - - autoplay: bool - автовоспроизведение при выборе - - cache_size_mb: usize - размер кэша голосовых - - show_waveform: bool - показывать waveform визуализацию - - system_player_fallback: bool - использовать системный плеер - - system_player: String - команда системного плеера (mpv, ffplay) -- [ ] Асинхронная загрузка - - Не блокировать UI во время загрузки файла - - Индикатор загрузки с процентами - - Возможность отмены загрузки -- [ ] Обновление UI - - Ticker для обновления progress bar (каждые 100ms) - - Плавное обновление позиции воспроизведения - - Автоматическая остановка при достижении конца - -### Этап 6: Обработка ошибок [TODO] -- [ ] Graceful fallback на системный плеер - - Если rodio не работает - использовать mpv/ffplay - - Логирование ошибок через tracing - - Предупреждение пользователю если аудио недоступно -- [ ] Обработка ошибок загрузки - - Таймаут загрузки (30 сек) - - Повторная попытка по запросу - - Сообщение об ошибке в UI -- [ ] Ограничения - - Максимальный размер файла для кэша - - Автоматическая очистка старых файлов - - Предупреждение для очень длинных голосовых (>5 мин) - -### Этап 7: Дополнительные улучшения [TODO] -- [ ] Управление воспроизведением - - Автоматическая остановка при закрытии чата - - Сохранение позиции при паузе - - Автопереход к следующему голосовому (опционально) -- [ ] Оптимизация - - Lazy loading (загрузка только при воспроизведении) - - Префетчинг следующего голосового (опционально) - - Минимальная задержка при нажатии Play -- [ ] Визуальные улучшения - - Анимация progress bar - - Цветовая индикация статуса (зеленый - playing, желтый - paused) - - Иконки в зависимости от статуса - -### Технические детали -- **Аудио библиотека:** - - rodio 0.17 (Pure Rust, кроссплатформенная) - - Поддержка OGG Opus (формат голосовых в Telegram) - - Контроль воспроизведения через Sink API -- **Платформы:** - - Linux (ALSA, PulseAudio) - - macOS (CoreAudio) - - Windows (WASAPI) -- **Fallback:** - - mpv --no-video (универсальный плеер) - - ffplay -nodisp (из ffmpeg) -- **Новые хоткеи:** - - `Space` - воспроизвести/пауза (в режиме выбора голосового) - - `s` / `ы` - остановить воспроизведение - - `←` / `→` - перемотка -5с / +5с (во время воспроизведения) - - `↑` / `↓` - громкость +/- 10% (во время воспроизведения) - -## Фаза 13: Глубокий рефакторинг архитектуры [PLANNED] - -**Мотивация:** Код вырос до критических размеров - некоторые файлы содержат >1000 строк, что затрудняет поддержку и навигацию. Необходимо разбить монолитные файлы на логические модули. - -**Проблемы:** -- `src/input/main_input.rs` - 1199 строк (самый большой файл!) -- `src/app/mod.rs` - 1015 строк, 116 функций (God Object) -- `src/ui/messages.rs` - 893 строки -- `src/tdlib/messages.rs` - 833 строки -- `src/config/mod.rs` - 642 строки - -### Этап 1: Разбить input/main_input.rs (1199 → <200 строк) [TODO] - -**Текущая проблема:** -- Весь input handling в одном файле -- Функции по 300-400 строк -- Невозможно быстро найти нужный handler - -**План:** -- [ ] Создать `src/input/handlers/` директорию -- [ ] Создать `handlers/chat.rs` - обработка ввода в открытом чате - - Переместить `handle_open_chat_keyboard_input()` - - Обработка скролла, выбора сообщений - - ~300-400 строк -- [ ] Создать `handlers/chat_list.rs` - обработка в списке чатов - - Переместить `handle_chat_list_keyboard_input()` - - Навигация по чатам, папки - - ~200-300 строк -- [ ] Создать `handlers/compose.rs` - режимы edit/reply/forward - - Обработка ввода в режимах редактирования - - Input field управление (курсор, backspace, delete) - - ~200 строк -- [ ] Создать `handlers/modal.rs` - модалки - - Delete confirmation - - Emoji picker - - Profile modal - - ~150 строк -- [ ] Создать `handlers/search.rs` - поиск - - Search mode в чате - - Search mode в списке чатов - - ~100 строк -- [ ] Обновить `main_input.rs` - только роутинг - - Определение текущего режима - - Делегация в нужный handler - - <200 строк - -**Результат:** 1199 строк → 6 файлов по <400 строк - -### Этап 2: Уменьшить app/mod.rs (116 функций → traits) [TODO] - -**Текущая проблема:** -- God Object с 116 функциями -- Сложно найти нужный метод -- Нарушение Single Responsibility Principle - -**План:** -- [ ] Создать `app/methods/` директорию -- [ ] Создать trait `NavigationMethods` - - `next_chat()`, `previous_chat()` - - `scroll_up()`, `scroll_down()` - - `select_chat()`, `open_chat()` - - ~15 методов -- [ ] Создать trait `MessageMethods` - - `send_message()`, `edit_message()`, `delete_message()` - - `reply_to_message()`, `forward_message()` - - `select_message()`, `deselect_message()` - - ~20 методов -- [ ] Создать trait `ComposeMethods` - - `enter_edit_mode()`, `enter_reply_mode()`, `enter_forward_mode()` - - `handle_input_char()`, `move_cursor_left()`, `move_cursor_right()` - - ~15 методов -- [ ] Создать trait `SearchMethods` - - `start_search()`, `search_next()`, `search_previous()` - - `clear_search()` - - ~5 методов -- [ ] Создать trait `ModalMethods` - - `show_delete_confirmation()`, `show_emoji_picker()` - - `show_profile()`, `close_modal()` - - ~10 методов -- [ ] Оставить в `app/mod.rs` только: - - Struct definition - - Constructor (new, with_client) - - Getters/setters для полей - - ~30-40 методов - -**Структура:** -```rust -// app/mod.rs - только core -impl App { - pub fn new() -> Self { ... } - pub fn config(&self) -> &Config { ... } -} - -// app/methods/navigation.rs -pub trait NavigationMethods { - fn next_chat(&mut self); - fn previous_chat(&mut self); -} -impl NavigationMethods for App { ... } - -// app/methods/messages.rs -pub trait MessageMethods { - async fn send_message(&mut self, text: String); -} -impl MessageMethods for App { ... } +``` +┌──────────────┬───────────────────────────┐ +│ Saved Msgs │ Привет! │ +│ Иван Петров │ Как дела? │ +│ Работа чат │ │ +├──────────────┴───────────────────────────┤ +│ [NORMAL] Михаил ⟨1/2⟩ Work(3) │ Ctrl+A │ +└──────────────────────────────────────────┘ ``` -**Результат:** 116 функций → 6 trait impl блоков +- **Footer**: текущий аккаунт + номер `⟨1/2⟩` + бейджи непрочитанных с других аккаунтов +- **Быстрое переключение**: `Ctrl+1`..`Ctrl+9` — мгновенный switch без модалки +- **Модалка управления** (`Ctrl+A`): список аккаунтов, добавление/удаление, выбор активного -### Этап 3: Разбить ui/messages.rs (893 → <300 строк) [TODO] +### Модалка переключения аккаунтов -**Текущая проблема:** -- Весь UI рендеринг сообщений в одном файле -- Модалки смешаны с основным рендерингом -- 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 строк - -**Результат:** 893 строки → 6 файлов по <150 строк - -### Этап 4: Разбить tdlib/messages.rs (833 → 2 файла) [TODO] - -**Текущая проблема:** -- Смешивается конвертация из 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 строк - -**Результат:** 833 строки → 2 файла по <500 строк - -### Этап 5: Разбить config/mod.rs (642 → 3 файла) [TODO] - -**Текущая проблема:** -- Много default_* функций (по 1-3 строки каждая) -- Validation logic смешана с определениями -- Сложно найти нужную секцию конфига - -**План:** -- [ ] Создать `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 строк - -**Результат:** 642 строки → 4 файла по <200 строк - -### Этап 6: Code Duplication Cleanup [TODO] - -**План:** -- [ ] Найти дублированный код в handlers - - Общая логика обработки клавиш - - Вынести в `input/common.rs` -- [ ] Найти дублированный код в UI - - Общие компоненты рендеринга - - Вынести в `ui/components/` -- [ ] Использовать DRY принцип везде - -### Этап 7: Documentation Update [TODO] - -**План:** -- [ ] Обновить CONTEXT.md с новой структурой -- [ ] Обновить PROJECT_STRUCTURE.md -- [ ] Добавить module-level документацию -- [ ] Создать architecture diagram (ASCII) - -### Метрики успеха - -**До рефакторинга:** ``` -input/main_input.rs: 1199 строк -app/mod.rs: 1015 строк (116 функций) -ui/messages.rs: 893 строки -tdlib/messages.rs: 833 строки -config/mod.rs: 642 строки -ИТОГО: 4582 строки в 5 файлах +┌──────────────────────────────────┐ +│ Аккаунты │ +│ │ +│ 1. Михаил (+7 900 ...) ● │ ← активный +│ 2. Work (+7 911 ...) (3) │ ← 3 непрочитанных +│ 3. + Добавить аккаунт │ +│ │ +│ [j/k навигация, Enter выбор] │ +│ [d — удалить аккаунт] │ +└──────────────────────────────────┘ ``` -**После рефакторинга:** -``` -input/handlers/*.rs: ~6 файлов по <400 строк -app/methods/*.rs: ~6 traits с impl блоками -ui/modals/*.rs: ~4 файла по <150 строк -tdlib/messages/*.rs: 2 файла по <500 строк -config/*.rs: 4 файла по <200 строк -ИТОГО: те же строки, но в ~20+ файлах -``` +### Техническая реализация: все клиенты одновременно -**Преимущества:** -- ✅ Легче найти нужный код -- ✅ Легче тестировать модули -- ✅ Меньше конфликтов при работе в команде -- ✅ Лучше читаемость и поддерживаемость -- ✅ Соблюдение Single Responsibility Principle +- **Несколько TdClient**: каждый аккаунт — отдельный `TdClient` со своим `database_directory` + - Аккаунт 1: `~/.local/share/tele-tui/accounts/1/tdlib_data/` + - Аккаунт 2: `~/.local/share/tele-tui/accounts/2/tdlib_data/` +- **Все клиенты активны**: polling updates со всех аккаунтов одновременно (уведомления, непрочитанные) +- **Мгновенное переключение**: swap активного `App.td_client` — чаты и сообщения уже загружены +- **Общий конфиг**: `~/.config/tele-tui/config.toml` (один для всех аккаунтов) +- **Профили аккаунтов**: `~/.config/tele-tui/accounts.toml` — список аккаунтов (имя, путь к БД) + +### Этапы + +- [x] **Этап 1: Инфраструктура профилей** (DONE) + - Структура `AccountProfile` (name, display_name, db_path) + - `accounts.toml` — хранение списка аккаунтов + - Миграция `tdlib_data/` → `accounts/default/tdlib_data/` (обратная совместимость) + - CLI: `--account ` для запуска конкретного аккаунта + +- [x] **Этап 2+3: Account Switcher Modal + Переключение** (DONE) + - Подход: single-client reinit (close TDLib → new TdClient → auth) + - Модалка `Ctrl+A` — глобальный оверлей с навигацией j/k + - Footer индикатор `[account_name]` если не "default" + - `AccountSwitcherState` enum (SelectAccount / AddAccount) + - `recreate_client()` метод в TdClientTrait (close → new → set_tdlib_parameters) + - `add_account()` — создание нового аккаунта из модалки + - `pending_account_switch` флаг → обработка в main loop + +- [ ] **Этап 4: Расширенные возможности мультиаккаунта** + - Удаление аккаунта из модалки + - Хоткеи `Ctrl+1`..`Ctrl+9` — быстрое переключение + - Бейджи непрочитанных с других аккаунтов (требует множественных TdClient) + - Параллельный polling updates со всех аккаунтов diff --git a/benches/format_markdown.rs b/benches/format_markdown.rs index d26041a..e722f17 100644 --- a/benches/format_markdown.rs +++ b/benches/format_markdown.rs @@ -1,6 +1,6 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use tele_tui::formatting::format_text_with_entities; use tdlib_rs::enums::{TextEntity, TextEntityType}; +use tele_tui::formatting::format_text_with_entities; fn create_text_with_entities() -> (String, Vec) { let text = "This is bold and italic text with code and a link and mention".to_string(); @@ -41,9 +41,7 @@ fn benchmark_format_simple_text(c: &mut Criterion) { let entities = vec![]; c.bench_function("format_simple_text", |b| { - b.iter(|| { - format_text_with_entities(black_box(&text), black_box(&entities)) - }); + b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities))); }); } @@ -51,9 +49,7 @@ fn benchmark_format_markdown_text(c: &mut Criterion) { let (text, entities) = create_text_with_entities(); c.bench_function("format_markdown_text", |b| { - b.iter(|| { - format_text_with_entities(black_box(&text), black_box(&entities)) - }); + b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities))); }); } @@ -77,9 +73,7 @@ fn benchmark_format_long_text(c: &mut Criterion) { } c.bench_function("format_long_text_with_100_entities", |b| { - b.iter(|| { - format_text_with_entities(black_box(&text), black_box(&entities)) - }); + b.iter(|| format_text_with_entities(black_box(&text), black_box(&entities))); }); } diff --git a/benches/formatting.rs b/benches/formatting.rs index 029acca..bb84842 100644 --- a/benches/formatting.rs +++ b/benches/formatting.rs @@ -1,5 +1,5 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion}; -use tele_tui::utils::formatting::{format_timestamp_with_tz, format_date, get_day}; +use tele_tui::utils::formatting::{format_date, format_timestamp_with_tz, get_day}; fn benchmark_format_timestamp(c: &mut Criterion) { c.bench_function("format_timestamp_50_times", |b| { @@ -34,10 +34,5 @@ fn benchmark_get_day(c: &mut Criterion) { }); } -criterion_group!( - benches, - benchmark_format_timestamp, - benchmark_format_date, - benchmark_get_day -); +criterion_group!(benches, benchmark_format_timestamp, benchmark_format_date, benchmark_get_day); criterion_main!(benches); diff --git a/benches/group_messages.rs b/benches/group_messages.rs index 3925f5c..d4c604c 100644 --- a/benches/group_messages.rs +++ b/benches/group_messages.rs @@ -8,7 +8,10 @@ fn create_test_messages(count: usize) -> Vec { .map(|i| { let builder = MessageBuilder::new(MessageId::new(i as i64)) .sender_name(&format!("User{}", i % 10)) - .text(&format!("Test message number {} with some longer text to make it more realistic", i)) + .text(&format!( + "Test message number {} with some longer text to make it more realistic", + i + )) .date(1640000000 + (i as i32 * 60)); if i % 2 == 0 { @@ -24,9 +27,7 @@ fn benchmark_group_100_messages(c: &mut Criterion) { let messages = create_test_messages(100); c.bench_function("group_100_messages", |b| { - b.iter(|| { - group_messages(black_box(&messages)) - }); + b.iter(|| group_messages(black_box(&messages))); }); } @@ -34,9 +35,7 @@ fn benchmark_group_500_messages(c: &mut Criterion) { let messages = create_test_messages(500); c.bench_function("group_500_messages", |b| { - b.iter(|| { - group_messages(black_box(&messages)) - }); + b.iter(|| group_messages(black_box(&messages))); }); } diff --git a/rustfmt.toml b/rustfmt.toml index 1283a72..3f1638c 100644 --- a/rustfmt.toml +++ b/rustfmt.toml @@ -6,15 +6,6 @@ max_width = 100 tab_spaces = 4 newline_style = "Unix" -# Imports -imports_granularity = "Crate" -group_imports = "StdExternalCrate" - -# Comments -wrap_comments = true -comment_width = 80 -normalize_comments = true - # Formatting use_small_heuristics = "Default" fn_call_width = 80 diff --git a/src/accounts/manager.rs b/src/accounts/manager.rs new file mode 100644 index 0000000..93e01cc --- /dev/null +++ b/src/accounts/manager.rs @@ -0,0 +1,202 @@ +//! Account manager: loading, saving, migration, and resolution. +//! +//! Handles `accounts.toml` lifecycle and legacy `./tdlib_data/` migration +//! to XDG data directory. + +use std::fs; +use std::path::PathBuf; + +use super::profile::{account_db_path, validate_account_name, AccountsConfig}; + +/// Returns the path to `accounts.toml` in the config directory. +/// +/// `~/.config/tele-tui/accounts.toml` +pub fn accounts_config_path() -> Option { + dirs::config_dir().map(|mut path| { + path.push("tele-tui"); + path.push("accounts.toml"); + path + }) +} + +/// Loads `accounts.toml` or creates it with default values. +/// +/// On first run, also attempts to migrate legacy `./tdlib_data/` directory +/// to the XDG data location. +pub fn load_or_create() -> AccountsConfig { + let config_path = match accounts_config_path() { + Some(path) => path, + None => { + tracing::warn!("Could not determine config directory for accounts, using defaults"); + return AccountsConfig::default_single(); + } + }; + + if config_path.exists() { + // Load existing config + match fs::read_to_string(&config_path) { + Ok(content) => match toml::from_str::(&content) { + Ok(config) => return config, + Err(e) => { + tracing::warn!("Could not parse accounts.toml: {}", e); + return AccountsConfig::default_single(); + } + }, + Err(e) => { + tracing::warn!("Could not read accounts.toml: {}", e); + return AccountsConfig::default_single(); + } + } + } + + // First run: migrate legacy data if present, then create default config + migrate_legacy(); + + let config = AccountsConfig::default_single(); + if let Err(e) = save(&config) { + tracing::warn!("Could not save initial accounts.toml: {}", e); + } + config +} + +/// Saves `AccountsConfig` to `accounts.toml`. +pub fn save(config: &AccountsConfig) -> Result<(), String> { + let config_path = + accounts_config_path().ok_or_else(|| "Could not determine config directory".to_string())?; + + // Ensure parent directory exists + if let Some(parent) = config_path.parent() { + fs::create_dir_all(parent) + .map_err(|e| format!("Could not create config directory: {}", e))?; + } + + let toml_string = toml::to_string_pretty(config) + .map_err(|e| format!("Could not serialize accounts config: {}", e))?; + + fs::write(&config_path, toml_string) + .map_err(|e| format!("Could not write accounts.toml: {}", e))?; + + Ok(()) +} + +/// Migrates legacy `./tdlib_data/` from CWD to XDG data dir. +/// +/// If `./tdlib_data/` exists in the current working directory, moves it to +/// `~/.local/share/tele-tui/accounts/default/tdlib_data/`. +fn migrate_legacy() { + let legacy_path = PathBuf::from("tdlib_data"); + if !legacy_path.exists() || !legacy_path.is_dir() { + return; + } + + let target = account_db_path("default"); + + // Don't overwrite if target already exists + if target.exists() { + tracing::info!( + "Legacy ./tdlib_data/ found but target already exists at {}, skipping migration", + target.display() + ); + return; + } + + // Create parent directories + if let Some(parent) = target.parent() { + if let Err(e) = fs::create_dir_all(parent) { + tracing::error!("Could not create target directory for migration: {}", e); + return; + } + } + + // Move (rename) the directory + match fs::rename(&legacy_path, &target) { + Ok(()) => { + tracing::info!("Migrated ./tdlib_data/ -> {}", target.display()); + } + Err(e) => { + tracing::error!("Could not migrate ./tdlib_data/ to {}: {}", target.display(), e); + } + } +} + +/// Resolves which account to use from CLI arg or default. +/// +/// # Arguments +/// +/// * `config` - The loaded accounts configuration +/// * `account_arg` - Optional account name from `--account` CLI flag +/// +/// # Returns +/// +/// The resolved account name and its db_path. +/// +/// # Errors +/// +/// Returns an error if the specified account is not found or the name is invalid. +pub fn resolve_account( + config: &AccountsConfig, + account_arg: Option<&str>, +) -> Result<(String, PathBuf), String> { + let account_name = account_arg.unwrap_or(&config.default_account); + + // Validate name + validate_account_name(account_name)?; + + // Find account in config + let _account = config.find_account(account_name).ok_or_else(|| { + let available: Vec<&str> = config.accounts.iter().map(|a| a.name.as_str()).collect(); + format!( + "Account '{}' not found. Available accounts: {}", + account_name, + available.join(", ") + ) + })?; + + let db_path = account_db_path(account_name); + Ok((account_name.to_string(), db_path)) +} + +/// Adds a new account to `accounts.toml` and creates its data directory. +/// +/// Validates the name, checks for duplicates, adds the profile to config, +/// saves the config, and creates the data directory. +/// +/// # Returns +/// +/// The db_path for the new account. +/// +/// # Errors +/// +/// Returns an error if the name is invalid, already exists, or I/O fails. +pub fn add_account(name: &str, display_name: &str) -> Result { + validate_account_name(name)?; + + let mut config = load_or_create(); + + // Check for duplicate + if config.find_account(name).is_some() { + return Err(format!("Account '{}' already exists", name)); + } + + // Add new profile + config.accounts.push(super::profile::AccountProfile { + name: name.to_string(), + display_name: display_name.to_string(), + }); + + // Save config + save(&config)?; + + // Create data directory + ensure_account_dir(name) +} + +/// Ensures the account data directory exists. +/// +/// Creates `~/.local/share/tele-tui/accounts/{name}/tdlib_data/` if needed. +pub fn ensure_account_dir(account_name: &str) -> Result { + let db_path = account_db_path(account_name); + fs::create_dir_all(&db_path) + .map_err(|e| format!("Could not create account directory: {}", e))?; + Ok(db_path) +} diff --git a/src/accounts/mod.rs b/src/accounts/mod.rs new file mode 100644 index 0000000..63a79dc --- /dev/null +++ b/src/accounts/mod.rs @@ -0,0 +1,13 @@ +//! Account profiles module for multi-account support. +//! +//! Manages account profiles stored in `~/.config/tele-tui/accounts.toml`. +//! Each account has its own TDLib database directory under +//! `~/.local/share/tele-tui/accounts/{name}/tdlib_data/`. + +pub mod manager; +pub mod profile; + +#[allow(unused_imports)] +pub use manager::{add_account, ensure_account_dir, load_or_create, resolve_account, save}; +#[allow(unused_imports)] +pub use profile::{account_db_path, validate_account_name, AccountProfile, AccountsConfig}; diff --git a/src/accounts/profile.rs b/src/accounts/profile.rs new file mode 100644 index 0000000..568a9c3 --- /dev/null +++ b/src/accounts/profile.rs @@ -0,0 +1,147 @@ +//! Account profile data structures and validation. +//! +//! Defines `AccountProfile` and `AccountsConfig` for multi-account support. +//! Account names are validated to contain only alphanumeric characters, hyphens, and underscores. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Configuration for all accounts, stored in `~/.config/tele-tui/accounts.toml`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountsConfig { + /// Name of the default account to use when no `--account` flag is provided. + pub default_account: String, + + /// List of configured accounts. + pub accounts: Vec, +} + +/// A single account profile. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountProfile { + /// Unique identifier (used in directory names and CLI flag). + pub name: String, + + /// Human-readable display name. + pub display_name: String, +} + +impl AccountsConfig { + /// Creates a default config with a single "default" account. + pub fn default_single() -> Self { + Self { + default_account: "default".to_string(), + accounts: vec![AccountProfile { + name: "default".to_string(), + display_name: "Default".to_string(), + }], + } + } + + /// Finds an account by name. + pub fn find_account(&self, name: &str) -> Option<&AccountProfile> { + self.accounts.iter().find(|a| a.name == name) + } +} + +impl AccountProfile { + /// Computes the TDLib database directory path for this account. + /// + /// Returns `~/.local/share/tele-tui/accounts/{name}/tdlib_data` + /// (or platform equivalent via `dirs::data_dir()`). + pub fn db_path(&self) -> PathBuf { + account_db_path(&self.name) + } +} + +/// Computes the TDLib database directory path for a given account name. +/// +/// Returns `{data_dir}/tele-tui/accounts/{name}/tdlib_data`. +pub fn account_db_path(account_name: &str) -> PathBuf { + let mut path = dirs::data_dir().unwrap_or_else(|| PathBuf::from(".")); + path.push("tele-tui"); + path.push("accounts"); + path.push(account_name); + path.push("tdlib_data"); + path +} + +/// Validates an account name. +/// +/// Valid names contain only lowercase alphanumeric characters, hyphens, and underscores. +/// Must be 1-32 characters long. +/// +/// # Errors +/// +/// Returns a descriptive error message if the name is invalid. +pub fn validate_account_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("Account name cannot be empty".to_string()); + } + if name.len() > 32 { + return Err("Account name cannot be longer than 32 characters".to_string()); + } + if !name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + return Err( + "Account name can only contain lowercase letters, digits, hyphens, and underscores" + .to_string(), + ); + } + if name.starts_with('-') || name.starts_with('_') { + return Err("Account name cannot start with a hyphen or underscore".to_string()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_validate_account_name_valid() { + assert!(validate_account_name("default").is_ok()); + assert!(validate_account_name("work").is_ok()); + assert!(validate_account_name("my-account").is_ok()); + assert!(validate_account_name("account_2").is_ok()); + assert!(validate_account_name("a").is_ok()); + } + + #[test] + fn test_validate_account_name_invalid() { + assert!(validate_account_name("").is_err()); + assert!(validate_account_name("My Account").is_err()); + assert!(validate_account_name("UPPER").is_err()); + assert!(validate_account_name("with spaces").is_err()); + assert!(validate_account_name("-starts-with-dash").is_err()); + assert!(validate_account_name("_starts-with-underscore").is_err()); + assert!(validate_account_name(&"a".repeat(33)).is_err()); + } + + #[test] + fn test_default_single_config() { + let config = AccountsConfig::default_single(); + assert_eq!(config.default_account, "default"); + assert_eq!(config.accounts.len(), 1); + assert_eq!(config.accounts[0].name, "default"); + } + + #[test] + fn test_find_account() { + let config = AccountsConfig::default_single(); + assert!(config.find_account("default").is_some()); + assert!(config.find_account("nonexistent").is_none()); + } + + #[test] + fn test_db_path_contains_account_name() { + let path = account_db_path("work"); + let path_str = path.to_string_lossy(); + assert!(path_str.contains("tele-tui")); + assert!(path_str.contains("accounts")); + assert!(path_str.contains("work")); + assert!(path_str.ends_with("tdlib_data")); + } +} diff --git a/src/app/chat_filter.rs b/src/app/chat_filter.rs index 094cad4..32615fa 100644 --- a/src/app/chat_filter.rs +++ b/src/app/chat_filter.rs @@ -6,10 +6,10 @@ /// - По статусу (archived, muted, и т.д.) /// /// Используется как в App, так и в UI слое для консистентной фильтрации. - use crate::tdlib::ChatInfo; /// Критерии фильтрации чатов +#[allow(dead_code)] #[derive(Debug, Clone, Default)] pub struct ChatFilterCriteria { /// Фильтр по папке (folder_id) @@ -34,6 +34,7 @@ pub struct ChatFilterCriteria { pub hide_archived: bool, } +#[allow(dead_code)] impl ChatFilterCriteria { /// Создаёт критерии с дефолтными значениями pub fn new() -> Self { @@ -42,18 +43,12 @@ impl ChatFilterCriteria { /// Фильтр только по папке pub fn by_folder(folder_id: Option) -> Self { - Self { - folder_id, - ..Default::default() - } + Self { folder_id, ..Default::default() } } /// Фильтр только по поисковому запросу pub fn by_search(query: String) -> Self { - Self { - search_query: Some(query), - ..Default::default() - } + Self { search_query: Some(query), ..Default::default() } } /// Builder: установить папку @@ -154,8 +149,10 @@ impl ChatFilterCriteria { } /// Централизованный фильтр чатов +#[allow(dead_code)] pub struct ChatFilter; +#[allow(dead_code)] impl ChatFilter { /// Фильтрует список чатов по критериям /// @@ -176,10 +173,7 @@ impl ChatFilter { /// /// let filtered = ChatFilter::filter(&all_chats, &criteria); /// ``` - pub fn filter<'a>( - chats: &'a [ChatInfo], - criteria: &ChatFilterCriteria, - ) -> Vec<&'a ChatInfo> { + pub fn filter<'a>(chats: &'a [ChatInfo], criteria: &ChatFilterCriteria) -> Vec<&'a ChatInfo> { chats.iter().filter(|chat| criteria.matches(chat)).collect() } @@ -309,8 +303,7 @@ mod tests { let filtered = ChatFilter::filter(&chats, &criteria); assert_eq!(filtered.len(), 2); // Chat 1 and Chat 3 have unread - let criteria = ChatFilterCriteria::new() - .pinned_only(true); + let criteria = ChatFilterCriteria::new().pinned_only(true); let filtered = ChatFilter::filter(&chats, &criteria); assert_eq!(filtered.len(), 1); // Only Chat 1 is pinned @@ -330,5 +323,4 @@ mod tests { assert_eq!(ChatFilter::count_unread(&chats, &criteria), 15); // 5 + 10 assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2 } - } diff --git a/src/app/chat_state.rs b/src/app/chat_state.rs index f6cb3c8..467d37e 100644 --- a/src/app/chat_state.rs +++ b/src/app/chat_state.rs @@ -3,10 +3,21 @@ use crate::tdlib::{MessageInfo, ProfileInfo}; use crate::types::MessageId; +/// Vim-like input mode for chat view +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum InputMode { + /// Normal mode — navigation and commands (default) + #[default] + Normal, + /// Insert mode — text input only + Insert, +} + /// Состояния чата - взаимоисключающие режимы работы с чатом -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub enum ChatState { /// Обычный режим - просмотр сообщений, набор текста + #[default] Normal, /// Выбор сообщения для действия (edit/delete/reply/forward/reaction) @@ -80,12 +91,6 @@ pub enum ChatState { }, } -impl Default for ChatState { - fn default() -> Self { - ChatState::Normal - } -} - impl ChatState { /// Проверка: находимся в режиме выбора сообщения pub fn is_message_selection(&self) -> bool { diff --git a/src/app/methods/compose.rs b/src/app/methods/compose.rs new file mode 100644 index 0000000..9461a34 --- /dev/null +++ b/src/app/methods/compose.rs @@ -0,0 +1,117 @@ +//! Compose methods for App +//! +//! Handles reply, forward, and draft functionality + +use crate::app::methods::messages::MessageMethods; +use crate::app::{App, ChatState}; +use crate::tdlib::{MessageInfo, TdClientTrait}; + +/// Compose methods for reply/forward/draft +pub trait ComposeMethods { + /// Start replying to the selected message + /// Returns true if reply mode started, false if no message selected + fn start_reply_to_selected(&mut self) -> bool; + + /// Cancel reply mode + fn cancel_reply(&mut self); + + /// Check if currently in reply mode + fn is_replying(&self) -> bool; + + /// Get the message being replied to + fn get_replying_to_message(&self) -> Option; + + /// Start forwarding the selected message + /// Returns true if forward mode started, false if no message selected + fn start_forward_selected(&mut self) -> bool; + + /// Cancel forward mode + fn cancel_forward(&mut self); + + /// Check if currently in forward mode (selecting target chat) + fn is_forwarding(&self) -> bool; + + /// Get the message being forwarded + fn get_forwarding_message(&self) -> Option; + + /// Get draft for the currently selected chat + fn get_current_draft(&self) -> Option; + + /// Load draft into message_input (called when opening chat) + fn load_draft(&mut self); +} + +impl ComposeMethods for App { + fn start_reply_to_selected(&mut self) -> bool { + if let Some(msg) = self.get_selected_message() { + self.chat_state = ChatState::Reply { message_id: msg.id() }; + return true; + } + false + } + + fn cancel_reply(&mut self) { + self.chat_state = ChatState::Normal; + } + + fn is_replying(&self) -> bool { + self.chat_state.is_reply() + } + + fn get_replying_to_message(&self) -> Option { + self.chat_state.selected_message_id().and_then(|id| { + self.td_client + .current_chat_messages() + .iter() + .find(|m| m.id() == id) + .cloned() + }) + } + + fn start_forward_selected(&mut self) -> bool { + if let Some(msg) = self.get_selected_message() { + self.chat_state = ChatState::Forward { message_id: msg.id() }; + // Сбрасываем выбор чата на первый + self.chat_list_state.select(Some(0)); + return true; + } + false + } + + fn cancel_forward(&mut self) { + self.chat_state = ChatState::Normal; + } + + fn is_forwarding(&self) -> bool { + self.chat_state.is_forward() + } + + fn get_forwarding_message(&self) -> Option { + if !self.chat_state.is_forward() { + return None; + } + self.chat_state.selected_message_id().and_then(|id| { + self.td_client + .current_chat_messages() + .iter() + .find(|m| m.id() == id) + .cloned() + }) + } + + fn get_current_draft(&self) -> Option { + self.selected_chat_id.and_then(|chat_id| { + self.chats + .iter() + .find(|c| c.id == chat_id) + .and_then(|c| c.draft_text.clone()) + }) + } + + fn load_draft(&mut self) { + if let Some(draft) = self.get_current_draft() { + self.message_input = draft; + self.cursor_position = self.message_input.chars().count(); + } + } +} diff --git a/src/app/methods/messages.rs b/src/app/methods/messages.rs new file mode 100644 index 0000000..bba3fe3 --- /dev/null +++ b/src/app/methods/messages.rs @@ -0,0 +1,179 @@ +//! Message methods for App +//! +//! Handles message selection, editing, and operations + +use crate::app::{App, ChatState}; +use crate::tdlib::{MessageInfo, TdClientTrait}; + +/// Message operation methods +pub trait MessageMethods { + /// Start message selection mode (triggered by Up arrow in empty input) + fn start_message_selection(&mut self); + + /// Select previous message (up in history = older) + fn select_previous_message(&mut self); + + /// Select next message (down in history = newer) + fn select_next_message(&mut self); + + /// Get currently selected message + fn get_selected_message(&self) -> Option; + + /// Start editing the selected message + /// Returns true if editing started, false if message cannot be edited + fn start_editing_selected(&mut self) -> bool; + + /// Cancel message editing and clear input + fn cancel_editing(&mut self); + + /// Check if currently in editing mode + fn is_editing(&self) -> bool; + + /// Check if currently in message selection mode + fn is_selecting_message(&self) -> bool; +} + +impl MessageMethods for App { + fn start_message_selection(&mut self) { + let messages = self.td_client.current_chat_messages(); + let total = messages.len(); + if total == 0 { + return; + } + // Начинаем с последнего сообщения (индекс len-1 = самое новое внизу) + // Если оно часть альбома — перемещаемся к первому элементу альбома + let mut idx = total - 1; + let album_id = messages[idx].media_album_id(); + if album_id != 0 { + while idx > 0 && messages[idx - 1].media_album_id() == album_id { + idx -= 1; + } + } + self.chat_state = ChatState::MessageSelection { selected_index: idx }; + } + + fn select_previous_message(&mut self) { + if let ChatState::MessageSelection { selected_index } = &mut self.chat_state { + if *selected_index > 0 { + let messages = self.td_client.current_chat_messages(); + let current_album_id = messages[*selected_index].media_album_id(); + + // Перескакиваем через все сообщения текущего альбома назад + let mut new_index = *selected_index - 1; + if current_album_id != 0 { + while new_index > 0 && messages[new_index].media_album_id() == current_album_id + { + new_index -= 1; + } + } + + // Если попали в середину другого альбома — перемещаемся к его первому элементу + let target_album_id = messages[new_index].media_album_id(); + if target_album_id != 0 { + while new_index > 0 + && messages[new_index - 1].media_album_id() == target_album_id + { + new_index -= 1; + } + } + + *selected_index = new_index; + self.stop_playback(); + } + } + } + + fn select_next_message(&mut self) { + let total = self.td_client.current_chat_messages().len(); + if total == 0 { + return; + } + if let ChatState::MessageSelection { selected_index } = &mut self.chat_state { + if *selected_index < total - 1 { + let messages = self.td_client.current_chat_messages(); + let current_album_id = messages[*selected_index].media_album_id(); + + // Перескакиваем через все сообщения текущего альбома вперёд + let mut new_index = *selected_index + 1; + if current_album_id != 0 { + while new_index < total - 1 + && messages[new_index].media_album_id() == current_album_id + { + new_index += 1; + } + // Если мы ещё на последнем элементе альбома — нужно шагнуть на следующее + if messages[new_index].media_album_id() == current_album_id + && new_index < total - 1 + { + new_index += 1; + } + } + + if new_index >= total { + self.chat_state = ChatState::Normal; + } else { + *selected_index = new_index; + } + self.stop_playback(); + } else { + // Дошли до самого нового сообщения - выходим из режима выбора + self.chat_state = ChatState::Normal; + self.stop_playback(); + } + } + } + + fn get_selected_message(&self) -> Option { + self.chat_state + .selected_message_index() + .and_then(|idx| self.td_client.current_chat_messages().get(idx).cloned()) + } + + fn start_editing_selected(&mut self) -> bool { + // Получаем selected_index из текущего состояния + let selected_idx = match &self.chat_state { + ChatState::MessageSelection { selected_index } => Some(*selected_index), + _ => None, + }; + + if selected_idx.is_none() { + return false; + } + + // Сначала извлекаем данные из сообщения + let msg_data = self.get_selected_message().and_then(|msg| { + // Проверяем: + // 1. Можно редактировать + // 2. Это исходящее сообщение + // 3. ID не временный (временные ID в TDLib отрицательные) + if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 { + Some((msg.id(), msg.text().to_string(), selected_idx.unwrap())) + } else { + None + } + }); + + // Затем присваиваем + if let Some((id, content, idx)) = msg_data { + self.cursor_position = content.chars().count(); + self.message_input = content; + self.chat_state = ChatState::Editing { message_id: id, selected_index: idx }; + return true; + } + false + } + + fn cancel_editing(&mut self) { + self.chat_state = ChatState::Normal; + self.message_input.clear(); + self.cursor_position = 0; + } + + fn is_editing(&self) -> bool { + self.chat_state.is_editing() + } + + fn is_selecting_message(&self) -> bool { + self.chat_state.is_message_selection() + } +} diff --git a/src/app/methods/mod.rs b/src/app/methods/mod.rs new file mode 100644 index 0000000..c1f4762 --- /dev/null +++ b/src/app/methods/mod.rs @@ -0,0 +1,25 @@ +//! App methods organized by functionality +//! +//! This module contains traits that organize App methods into logical groups: +//! - navigation: Chat list navigation +//! - messages: Message operations and selection +//! - compose: Reply/Forward/Draft functionality +//! - search: Search in chats and messages +//! - modal: Modal dialogs (Profile, Pinned, Reactions, Delete) + +pub mod compose; +pub mod messages; +pub mod modal; +pub mod navigation; +pub mod search; + +#[allow(unused_imports)] +pub use compose::ComposeMethods; +#[allow(unused_imports)] +pub use messages::MessageMethods; +#[allow(unused_imports)] +pub use modal::ModalMethods; +#[allow(unused_imports)] +pub use navigation::NavigationMethods; +#[allow(unused_imports)] +pub use search::SearchMethods; diff --git a/src/app/methods/modal.rs b/src/app/methods/modal.rs new file mode 100644 index 0000000..2c3c102 --- /dev/null +++ b/src/app/methods/modal.rs @@ -0,0 +1,266 @@ +//! Modal methods for App +//! +//! Handles modal dialogs: Profile, Pinned Messages, Reactions, Delete Confirmation + +use crate::app::{App, ChatState}; +use crate::tdlib::{MessageInfo, ProfileInfo, TdClientTrait}; +use crate::types::MessageId; + +/// Modal dialog methods +pub trait ModalMethods { + // === Delete Confirmation === + + /// Check if delete confirmation modal is shown + fn is_confirm_delete_shown(&self) -> bool; + + // === Pinned Messages === + + /// Check if in pinned messages mode + fn is_pinned_mode(&self) -> bool; + + /// Enter pinned messages mode + fn enter_pinned_mode(&mut self, messages: Vec); + + /// Exit pinned messages mode + fn exit_pinned_mode(&mut self); + + /// Select previous pinned message (up = older) + fn select_previous_pinned(&mut self); + + /// Select next pinned message (down = newer) + fn select_next_pinned(&mut self); + + /// Get currently selected pinned message + fn get_selected_pinned(&self) -> Option<&MessageInfo>; + + /// Get ID of selected pinned message for navigation + fn get_selected_pinned_id(&self) -> Option; + + // === Profile === + + /// Check if in profile mode + fn is_profile_mode(&self) -> bool; + + /// Enter profile mode + fn enter_profile_mode(&mut self, info: ProfileInfo); + + /// Exit profile mode + fn exit_profile_mode(&mut self); + + /// Select previous profile action + fn select_previous_profile_action(&mut self); + + /// Select next profile action + fn select_next_profile_action(&mut self, max_actions: usize); + + /// Show first leave group confirmation + fn show_leave_group_confirmation(&mut self); + + /// Show second leave group confirmation + fn show_leave_group_final_confirmation(&mut self); + + /// Cancel leave group confirmation + fn cancel_leave_group(&mut self); + + /// Get current leave group confirmation step (0, 1, or 2) + fn get_leave_group_confirmation_step(&self) -> u8; + + /// Get profile info + fn get_profile_info(&self) -> Option<&ProfileInfo>; + + /// Get selected profile action index + fn get_selected_profile_action(&self) -> Option; + + // === Reactions === + + /// Check if in reaction picker mode + fn is_reaction_picker_mode(&self) -> bool; + + /// Enter reaction picker mode + fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec); + + /// Exit reaction picker mode + fn exit_reaction_picker_mode(&mut self); + + /// Select previous reaction + fn select_previous_reaction(&mut self); + + /// Select next reaction + fn select_next_reaction(&mut self); + + /// Get currently selected reaction emoji + fn get_selected_reaction(&self) -> Option<&String>; + + /// Get message ID for which reaction is being selected + fn get_selected_message_for_reaction(&self) -> Option; +} + +impl ModalMethods for App { + fn is_confirm_delete_shown(&self) -> bool { + self.chat_state.is_delete_confirmation() + } + + fn is_pinned_mode(&self) -> bool { + self.chat_state.is_pinned_mode() + } + + fn enter_pinned_mode(&mut self, messages: Vec) { + if !messages.is_empty() { + self.chat_state = ChatState::PinnedMessages { messages, selected_index: 0 }; + } + } + + fn exit_pinned_mode(&mut self) { + self.chat_state = ChatState::Normal; + } + + fn select_previous_pinned(&mut self) { + if let ChatState::PinnedMessages { selected_index, messages } = &mut self.chat_state { + if *selected_index + 1 < messages.len() { + *selected_index += 1; + } + } + } + + fn select_next_pinned(&mut self) { + if let ChatState::PinnedMessages { selected_index, .. } = &mut self.chat_state { + if *selected_index > 0 { + *selected_index -= 1; + } + } + } + + fn get_selected_pinned(&self) -> Option<&MessageInfo> { + if let ChatState::PinnedMessages { messages, selected_index } = &self.chat_state { + messages.get(*selected_index) + } else { + None + } + } + + fn get_selected_pinned_id(&self) -> Option { + self.get_selected_pinned().map(|m| m.id().as_i64()) + } + + fn is_profile_mode(&self) -> bool { + self.chat_state.is_profile() + } + + fn enter_profile_mode(&mut self, info: ProfileInfo) { + self.chat_state = ChatState::Profile { + info, + selected_action: 0, + leave_group_confirmation_step: 0, + }; + } + + fn exit_profile_mode(&mut self) { + self.chat_state = ChatState::Normal; + } + + fn select_previous_profile_action(&mut self) { + if let ChatState::Profile { selected_action, .. } = &mut self.chat_state { + if *selected_action > 0 { + *selected_action -= 1; + } + } + } + + fn select_next_profile_action(&mut self, max_actions: usize) { + if let ChatState::Profile { selected_action, .. } = &mut self.chat_state { + if *selected_action < max_actions.saturating_sub(1) { + *selected_action += 1; + } + } + } + + fn show_leave_group_confirmation(&mut self) { + if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state { + *leave_group_confirmation_step = 1; + } + } + + fn show_leave_group_final_confirmation(&mut self) { + if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state { + *leave_group_confirmation_step = 2; + } + } + + fn cancel_leave_group(&mut self) { + if let ChatState::Profile { leave_group_confirmation_step, .. } = &mut self.chat_state { + *leave_group_confirmation_step = 0; + } + } + + fn get_leave_group_confirmation_step(&self) -> u8 { + if let ChatState::Profile { leave_group_confirmation_step, .. } = &self.chat_state { + *leave_group_confirmation_step + } else { + 0 + } + } + + fn get_profile_info(&self) -> Option<&ProfileInfo> { + if let ChatState::Profile { info, .. } = &self.chat_state { + Some(info) + } else { + None + } + } + + fn get_selected_profile_action(&self) -> Option { + if let ChatState::Profile { selected_action, .. } = &self.chat_state { + Some(*selected_action) + } else { + None + } + } + + fn is_reaction_picker_mode(&self) -> bool { + self.chat_state.is_reaction_picker() + } + + fn enter_reaction_picker_mode(&mut self, message_id: i64, available_reactions: Vec) { + self.chat_state = ChatState::ReactionPicker { + message_id: MessageId::new(message_id), + available_reactions, + selected_index: 0, + }; + } + + fn exit_reaction_picker_mode(&mut self) { + self.chat_state = ChatState::Normal; + } + + fn select_previous_reaction(&mut self) { + if let ChatState::ReactionPicker { selected_index, .. } = &mut self.chat_state { + if *selected_index > 0 { + *selected_index -= 1; + } + } + } + + fn select_next_reaction(&mut self) { + if let ChatState::ReactionPicker { selected_index, available_reactions, .. } = + &mut self.chat_state + { + if *selected_index + 1 < available_reactions.len() { + *selected_index += 1; + } + } + } + + fn get_selected_reaction(&self) -> Option<&String> { + if let ChatState::ReactionPicker { available_reactions, selected_index, .. } = + &self.chat_state + { + available_reactions.get(*selected_index) + } else { + None + } + } + + fn get_selected_message_for_reaction(&self) -> Option { + self.chat_state.selected_message_id().map(|id| id.as_i64()) + } +} diff --git a/src/app/methods/navigation.rs b/src/app/methods/navigation.rs new file mode 100644 index 0000000..a9ad35d --- /dev/null +++ b/src/app/methods/navigation.rs @@ -0,0 +1,146 @@ +//! Navigation methods for App +//! +//! Handles chat list navigation and selection + +use crate::app::methods::search::SearchMethods; +use crate::app::{App, ChatState, InputMode}; +use crate::tdlib::TdClientTrait; + +/// Navigation methods for chat list +pub trait NavigationMethods { + /// Move to next chat in the list (wraps around) + fn next_chat(&mut self); + + /// Move to previous chat in the list (wraps around) + fn previous_chat(&mut self); + + /// Select currently highlighted chat + fn select_current_chat(&mut self); + + /// Close currently open chat and reset state + fn close_chat(&mut self); + + /// Move to next filtered chat (considering search query) + fn next_filtered_chat(&mut self); + + /// Move to previous filtered chat (considering search query) + fn previous_filtered_chat(&mut self); + + /// Select currently highlighted filtered chat + fn select_filtered_chat(&mut self); +} + +impl NavigationMethods for App { + fn next_chat(&mut self) { + let filtered = self.get_filtered_chats(); + if filtered.is_empty() { + return; + } + let i = match self.chat_list_state.selected() { + Some(i) => { + if i >= filtered.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.chat_list_state.select(Some(i)); + } + + fn previous_chat(&mut self) { + let filtered = self.get_filtered_chats(); + if filtered.is_empty() { + return; + } + let i = match self.chat_list_state.selected() { + Some(i) => { + if i == 0 { + filtered.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.chat_list_state.select(Some(i)); + } + + fn select_current_chat(&mut self) { + let filtered = self.get_filtered_chats(); + if let Some(i) = self.chat_list_state.selected() { + if let Some(chat) = filtered.get(i) { + self.selected_chat_id = Some(chat.id); + } + } + } + + fn close_chat(&mut self) { + self.selected_chat_id = None; + self.message_input.clear(); + self.cursor_position = 0; + self.message_scroll_offset = 0; + self.last_typing_sent = None; + self.pending_chat_init = None; + // Останавливаем фоновую загрузку фото (drop receiver) + #[cfg(feature = "images")] + { + self.photo_download_rx = None; + } + // Сбрасываем состояние чата в нормальный режим + self.chat_state = ChatState::Normal; + self.input_mode = InputMode::Normal; + // Очищаем данные в TdClient + self.td_client.set_current_chat_id(None); + self.td_client.clear_current_chat_messages(); + self.td_client.set_typing_status(None); + self.td_client.set_current_pinned_message(None); + } + + fn next_filtered_chat(&mut self) { + let filtered = self.get_filtered_chats(); + if filtered.is_empty() { + return; + } + let i = match self.chat_list_state.selected() { + Some(i) => { + if i >= filtered.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.chat_list_state.select(Some(i)); + } + + fn previous_filtered_chat(&mut self) { + let filtered = self.get_filtered_chats(); + if filtered.is_empty() { + return; + } + let i = match self.chat_list_state.selected() { + Some(i) => { + if i == 0 { + filtered.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.chat_list_state.select(Some(i)); + } + + fn select_filtered_chat(&mut self) { + let filtered = self.get_filtered_chats(); + if let Some(i) = self.chat_list_state.selected() { + if let Some(chat) = filtered.get(i) { + self.selected_chat_id = Some(chat.id); + self.cancel_search(); + } + } + } +} diff --git a/src/app/methods/search.rs b/src/app/methods/search.rs new file mode 100644 index 0000000..11cc8fd --- /dev/null +++ b/src/app/methods/search.rs @@ -0,0 +1,165 @@ +//! Search methods for App +//! +//! Handles chat list search and message search within chat + +use crate::app::{App, ChatFilter, ChatFilterCriteria, ChatState}; +use crate::tdlib::{ChatInfo, MessageInfo, TdClientTrait}; + +/// Search methods for chats and messages +pub trait SearchMethods { + // === Chat Search === + + /// Start search mode in chat list + fn start_search(&mut self); + + /// Cancel search mode and reset query + fn cancel_search(&mut self); + + /// Get filtered chats based on search query and selected folder + fn get_filtered_chats(&self) -> Vec<&ChatInfo>; + + // === Message Search === + + /// Check if message search mode is active + fn is_message_search_mode(&self) -> bool; + + /// Enter message search mode within chat + fn enter_message_search_mode(&mut self); + + /// Exit message search mode + fn exit_message_search_mode(&mut self); + + /// Set search results + fn set_search_results(&mut self, results: Vec); + + /// Select previous search result (up) + fn select_previous_search_result(&mut self); + + /// Select next search result (down) + fn select_next_search_result(&mut self); + + /// Get currently selected search result + fn get_selected_search_result(&self) -> Option<&MessageInfo>; + + /// Get ID of selected search result for navigation + fn get_selected_search_result_id(&self) -> Option; + + /// Get current search query + fn get_search_query(&self) -> Option<&str>; + + /// Update search query + fn update_search_query(&mut self, new_query: String); + + /// Get index of selected search result + #[allow(dead_code)] + fn get_search_selected_index(&self) -> Option; + + /// Get all search results + #[allow(dead_code)] + fn get_search_results(&self) -> Option<&[MessageInfo]>; +} + +impl SearchMethods for App { + fn start_search(&mut self) { + self.is_searching = true; + self.search_query.clear(); + } + + fn cancel_search(&mut self) { + self.is_searching = false; + self.search_query.clear(); + self.chat_list_state.select(Some(0)); + } + + fn get_filtered_chats(&self) -> Vec<&ChatInfo> { + // Используем ChatFilter для централизованной фильтрации + let mut criteria = ChatFilterCriteria::new().with_folder(self.selected_folder_id); + + if !self.search_query.is_empty() { + criteria = criteria.with_search(self.search_query.clone()); + } + + ChatFilter::filter(&self.chats, &criteria) + } + + fn is_message_search_mode(&self) -> bool { + self.chat_state.is_search_in_chat() + } + + fn enter_message_search_mode(&mut self) { + self.chat_state = ChatState::SearchInChat { + query: String::new(), + results: Vec::new(), + selected_index: 0, + }; + } + + fn exit_message_search_mode(&mut self) { + self.chat_state = ChatState::Normal; + } + + fn set_search_results(&mut self, results: Vec) { + if let ChatState::SearchInChat { results: r, selected_index, .. } = &mut self.chat_state { + *r = results; + *selected_index = 0; + } + } + + fn select_previous_search_result(&mut self) { + if let ChatState::SearchInChat { selected_index, .. } = &mut self.chat_state { + if *selected_index > 0 { + *selected_index -= 1; + } + } + } + + fn select_next_search_result(&mut self) { + if let ChatState::SearchInChat { selected_index, results, .. } = &mut self.chat_state { + if *selected_index + 1 < results.len() { + *selected_index += 1; + } + } + } + + fn get_selected_search_result(&self) -> Option<&MessageInfo> { + if let ChatState::SearchInChat { results, selected_index, .. } = &self.chat_state { + results.get(*selected_index) + } else { + None + } + } + + fn get_selected_search_result_id(&self) -> Option { + self.get_selected_search_result().map(|m| m.id().as_i64()) + } + + fn get_search_query(&self) -> Option<&str> { + if let ChatState::SearchInChat { query, .. } = &self.chat_state { + Some(query.as_str()) + } else { + None + } + } + + fn update_search_query(&mut self, new_query: String) { + if let ChatState::SearchInChat { query, .. } = &mut self.chat_state { + *query = new_query; + } + } + + fn get_search_selected_index(&self) -> Option { + if let ChatState::SearchInChat { selected_index, .. } = &self.chat_state { + Some(*selected_index) + } else { + None + } + } + + fn get_search_results(&self) -> Option<&[MessageInfo]> { + if let ChatState::SearchInChat { results, .. } = &self.chat_state { + Some(results.as_slice()) + } else { + None + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 83417d2..64cde32 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,14 +1,41 @@ +//! Application state module. +//! +//! Contains `App` — the central state struct parameterized by `TdClientTrait` +//! for dependency injection. Methods are organized into trait modules in `methods/`. + mod chat_filter; mod chat_state; +pub mod methods; mod state; pub use chat_filter::{ChatFilter, ChatFilterCriteria}; -pub use chat_state::ChatState; +pub use chat_state::{ChatState, InputMode}; +#[allow(unused_imports)] +pub use methods::*; pub use state::AppScreen; +use crate::accounts::AccountProfile; use crate::tdlib::{ChatInfo, TdClient, TdClientTrait}; -use crate::types::{ChatId, MessageId}; +use crate::types::ChatId; use ratatui::widgets::ListState; +use std::path::PathBuf; + +/// State of the account switcher modal overlay. +#[derive(Debug, Clone)] +pub enum AccountSwitcherState { + /// List of accounts with navigation. + SelectAccount { + accounts: Vec, + selected_index: usize, + current_account: String, + }, + /// Input for new account name. + AddAccount { + name_input: String, + cursor_position: usize, + error: Option, + }, +} /// Main application state for the Telegram TUI client. /// @@ -33,10 +60,11 @@ 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(); -/// let mut app = App::new(config); +/// let mut app = App::new(config, std::path::PathBuf::from("tdlib_data")); /// /// // Navigate through chats /// app.next_chat(); @@ -52,6 +80,8 @@ pub struct App { pub td_client: T, /// Состояние чата - type-safe state machine (новое!) pub chat_state: ChatState, + /// Vim-like input mode: Normal (navigation) / Insert (text input) + pub input_mode: InputMode, // Auth state (приватные, доступ через геттеры) phone_input: String, code_input: String, @@ -77,8 +107,47 @@ pub struct App { // Typing indicator /// Время последней отправки typing status (для throttling) pub last_typing_sent: Option, + // Image support + #[allow(dead_code)] + #[cfg(feature = "images")] + pub image_cache: Option, + /// Renderer для inline preview в чате (Halfblocks - быстро) + #[cfg(feature = "images")] + pub inline_image_renderer: Option, + /// Renderer для modal просмотра (iTerm2/Sixel - высокое качество) + #[cfg(feature = "images")] + pub modal_image_renderer: Option, + /// Состояние модального окна просмотра изображения + #[cfg(feature = "images")] + pub image_modal: Option, + /// Время последнего рендеринга изображений (для throttling до 15 FPS) + #[cfg(feature = "images")] + pub last_image_render_time: Option, + // Account switcher + /// Account switcher modal state (global overlay) + pub account_switcher: Option, + /// Name of the currently active account + pub current_account_name: String, + /// Pending account switch: (account_name, db_path) + pub pending_account_switch: Option<(String, PathBuf)>, + /// Pending background chat init (reply info, pinned) after fast open + pub pending_chat_init: Option, + /// Receiver for background photo downloads (file_id, result path) + #[cfg(feature = "images")] + pub photo_download_rx: + Option)>>, + // Voice playback + /// Аудиопроигрыватель для голосовых сообщений (rodio) + pub audio_player: Option, + /// Кэш голосовых файлов (LRU, max 100 MB) + pub voice_cache: Option, + /// Состояние текущего воспроизведения + pub playback_state: Option, + /// Время последнего тика для обновления позиции воспроизведения + pub last_playback_tick: Option, } +#[allow(dead_code)] impl App { /// Creates a new App instance with the given configuration and client. /// @@ -96,11 +165,21 @@ impl App { let mut state = ListState::default(); state.select(Some(0)); + let audio_cache_size_mb = config.audio.cache_size_mb; + + #[cfg(feature = "images")] + let image_cache = Some(crate::media::cache::ImageCache::new(config.images.cache_size_mb)); + #[cfg(feature = "images")] + let inline_image_renderer = crate::media::image_renderer::ImageRenderer::new_fast(); + #[cfg(feature = "images")] + let modal_image_renderer = crate::media::image_renderer::ImageRenderer::new(); + App { config, screen: AppScreen::Loading, td_client, chat_state: ChatState::Normal, + input_mode: InputMode::Normal, phone_input: String::new(), code_input: String::new(), password_input: String::new(), @@ -118,6 +197,28 @@ impl App { search_query: String::new(), needs_redraw: true, last_typing_sent: None, + // Account switcher + account_switcher: None, + current_account_name: "default".to_string(), + pending_account_switch: None, + pending_chat_init: None, + #[cfg(feature = "images")] + photo_download_rx: None, + #[cfg(feature = "images")] + image_cache, + #[cfg(feature = "images")] + inline_image_renderer, + #[cfg(feature = "images")] + modal_image_renderer, + #[cfg(feature = "images")] + image_modal: None, + #[cfg(feature = "images")] + last_image_render_time: None, + // Voice playback + audio_player: crate::audio::AudioPlayer::new().ok(), + voice_cache: crate::audio::VoiceCache::new(audio_cache_size_mb).ok(), + playback_state: None, + last_playback_tick: None, } } @@ -134,674 +235,141 @@ impl App { self.config.keybindings.get_command(&key) } - pub fn next_chat(&mut self) { - let filtered = self.get_filtered_chats(); - if filtered.is_empty() { - return; - } - let i = match self.chat_list_state.selected() { - Some(i) => { - if i >= filtered.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.chat_list_state.select(Some(i)); - } - - pub fn previous_chat(&mut self) { - let filtered = self.get_filtered_chats(); - if filtered.is_empty() { - return; - } - let i = match self.chat_list_state.selected() { - Some(i) => { - if i == 0 { - filtered.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - self.chat_list_state.select(Some(i)); - } - - pub fn select_current_chat(&mut self) { - let filtered = self.get_filtered_chats(); - if let Some(i) = self.chat_list_state.selected() { - if let Some(chat) = filtered.get(i) { - self.selected_chat_id = Some(chat.id); - } - } - } - - pub fn close_chat(&mut self) { - self.selected_chat_id = None; - self.message_input.clear(); - self.cursor_position = 0; - self.message_scroll_offset = 0; - self.last_typing_sent = None; - // Сбрасываем состояние чата в нормальный режим - self.chat_state = ChatState::Normal; - // Очищаем данные в TdClient - self.td_client.set_current_chat_id(None); - self.td_client.clear_current_chat_messages(); - self.td_client.set_typing_status(None); - self.td_client.set_current_pinned_message(None); - } - - /// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте) - pub fn start_message_selection(&mut self) { - let total = self.td_client.current_chat_messages().len(); - if total == 0 { - return; - } - // Начинаем с последнего сообщения (индекс len-1 = самое новое внизу) - self.chat_state = ChatState::MessageSelection { selected_index: total - 1 }; - } - - /// Выбрать предыдущее сообщение (вверх по списку = к старым = уменьшить индекс) - pub fn select_previous_message(&mut self) { - if let ChatState::MessageSelection { selected_index } = &mut self.chat_state { - if *selected_index > 0 { - *selected_index -= 1; - } - } - } - - /// Выбрать следующее сообщение (вниз по списку = к новым = увеличить индекс) - pub fn select_next_message(&mut self) { - let total = self.td_client.current_chat_messages().len(); - if total == 0 { - return; - } - if let ChatState::MessageSelection { selected_index } = &mut self.chat_state { - if *selected_index < total - 1 { - *selected_index += 1; - } else { - // Дошли до самого нового сообщения - выходим из режима выбора - self.chat_state = ChatState::Normal; - } - } - } - - /// Получить выбранное сообщение - pub fn get_selected_message(&self) -> Option { - self.chat_state.selected_message_index().and_then(|idx| { - self.td_client.current_chat_messages().get(idx).cloned() - }) - } - - /// Начать редактирование выбранного сообщения - pub fn start_editing_selected(&mut self) -> bool { - // Получаем selected_index из текущего состояния - let selected_idx = match &self.chat_state { - ChatState::MessageSelection { selected_index } => Some(*selected_index), - _ => None, - }; - - if selected_idx.is_none() { - return false; - } - - // Сначала извлекаем данные из сообщения - let msg_data = self.get_selected_message().and_then(|msg| { - // Проверяем: - // 1. Можно редактировать - // 2. Это исходящее сообщение - // 3. ID не временный (временные ID в TDLib отрицательные) - if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 { - Some((msg.id(), msg.text().to_string(), selected_idx.unwrap())) - } else { - None - } - }); - - // Затем присваиваем - if let Some((id, content, idx)) = msg_data { - self.cursor_position = content.chars().count(); - self.message_input = content; - self.chat_state = ChatState::Editing { - message_id: id, - selected_index: idx, - }; - return true; - } - false - } - - /// Отменить редактирование - pub fn cancel_editing(&mut self) { - self.chat_state = ChatState::Normal; - self.message_input.clear(); - self.cursor_position = 0; - } - - /// Проверить, находимся ли в режиме редактирования - pub fn is_editing(&self) -> bool { - self.chat_state.is_editing() - } - - /// Проверить, находимся ли в режиме выбора сообщения - pub fn is_selecting_message(&self) -> bool { - self.chat_state.is_message_selection() - } - + /// Get the selected chat ID as i64 pub fn get_selected_chat_id(&self) -> Option { self.selected_chat_id.map(|id| id.as_i64()) } + /// Останавливает воспроизведение голосового и сбрасывает состояние + pub fn stop_playback(&mut self) { + if let Some(ref player) = self.audio_player { + player.stop(); + } + self.playback_state = None; + self.last_playback_tick = None; + self.status_message = None; + } + + /// Opens the account switcher modal, loading accounts from config. + pub fn open_account_switcher(&mut self) { + let config = crate::accounts::load_or_create(); + self.account_switcher = Some(AccountSwitcherState::SelectAccount { + accounts: config.accounts, + selected_index: 0, + current_account: self.current_account_name.clone(), + }); + } + + /// Closes the account switcher modal. + pub fn close_account_switcher(&mut self) { + self.account_switcher = None; + } + + /// Navigate to previous item in account switcher list. + pub fn account_switcher_select_prev(&mut self) { + if let Some(AccountSwitcherState::SelectAccount { selected_index, .. }) = + &mut self.account_switcher + { + *selected_index = selected_index.saturating_sub(1); + } + } + + /// Navigate to next item in account switcher list. + pub fn account_switcher_select_next(&mut self) { + if let Some(AccountSwitcherState::SelectAccount { accounts, selected_index, .. }) = + &mut self.account_switcher + { + // +1 for the "Add account" item at the end + let max_index = accounts.len(); + if *selected_index < max_index { + *selected_index += 1; + } + } + } + + /// Confirm selection in account switcher. + /// If on an account: sets pending_account_switch. + /// If on "+ Add": transitions to AddAccount state. + pub fn account_switcher_confirm(&mut self) { + let state = self.account_switcher.take(); + match state { + Some(AccountSwitcherState::SelectAccount { + accounts, + selected_index, + current_account, + }) => { + if selected_index < accounts.len() { + // Selected an existing account + let account = &accounts[selected_index]; + if account.name == current_account { + // Already on this account, just close + self.account_switcher = None; + return; + } + let db_path = account.db_path(); + self.pending_account_switch = Some((account.name.clone(), db_path)); + self.account_switcher = None; + } else { + // Selected "+ Add account" + self.account_switcher = Some(AccountSwitcherState::AddAccount { + name_input: String::new(), + cursor_position: 0, + error: None, + }); + } + } + other => { + self.account_switcher = other; + } + } + } + + /// Switch to AddAccount state from SelectAccount. + pub fn account_switcher_start_add(&mut self) { + self.account_switcher = Some(AccountSwitcherState::AddAccount { + name_input: String::new(), + cursor_position: 0, + error: None, + }); + } + + /// Confirm adding a new account. Validates, saves, and sets pending switch. + pub fn account_switcher_confirm_add(&mut self) { + let state = self.account_switcher.take(); + match state { + Some(AccountSwitcherState::AddAccount { name_input, .. }) => { + match crate::accounts::manager::add_account(&name_input, &name_input) { + Ok(db_path) => { + self.pending_account_switch = Some((name_input, db_path)); + self.account_switcher = None; + } + Err(e) => { + let cursor_pos = name_input.chars().count(); + self.account_switcher = Some(AccountSwitcherState::AddAccount { + name_input, + cursor_position: cursor_pos, + error: Some(e), + }); + } + } + } + other => { + self.account_switcher = other; + } + } + } + + /// Go back from AddAccount to SelectAccount. + pub fn account_switcher_back(&mut self) { + self.open_account_switcher(); + } + + /// Get the selected chat info pub fn get_selected_chat(&self) -> Option<&ChatInfo> { self.selected_chat_id .and_then(|id| self.chats.iter().find(|c| c.id == id)) } - pub fn start_search(&mut self) { - self.is_searching = true; - self.search_query.clear(); - } - - pub fn cancel_search(&mut self) { - self.is_searching = false; - self.search_query.clear(); - self.chat_list_state.select(Some(0)); - } - - pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> { - // Используем ChatFilter для централизованной фильтрации - let mut criteria = ChatFilterCriteria::new() - .with_folder(self.selected_folder_id); - - if !self.search_query.is_empty() { - criteria = criteria.with_search(self.search_query.clone()); - } - - ChatFilter::filter(&self.chats, &criteria) - } - - pub fn next_filtered_chat(&mut self) { - let filtered = self.get_filtered_chats(); - if filtered.is_empty() { - return; - } - let i = match self.chat_list_state.selected() { - Some(i) => { - if i >= filtered.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.chat_list_state.select(Some(i)); - } - - pub fn previous_filtered_chat(&mut self) { - let filtered = self.get_filtered_chats(); - if filtered.is_empty() { - return; - } - let i = match self.chat_list_state.selected() { - Some(i) => { - if i == 0 { - filtered.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - self.chat_list_state.select(Some(i)); - } - - pub fn select_filtered_chat(&mut self) { - let filtered = self.get_filtered_chats(); - if let Some(i) = self.chat_list_state.selected() { - if let Some(chat) = filtered.get(i) { - self.selected_chat_id = Some(chat.id); - self.cancel_search(); - } - } - } - - /// Проверить, показывается ли модалка подтверждения удаления - pub fn is_confirm_delete_shown(&self) -> bool { - self.chat_state.is_delete_confirmation() - } - - /// Начать режим ответа на выбранное сообщение - pub fn start_reply_to_selected(&mut self) -> bool { - if let Some(msg) = self.get_selected_message() { - self.chat_state = ChatState::Reply { - message_id: msg.id(), - }; - return true; - } - false - } - - /// Отменить режим ответа - pub fn cancel_reply(&mut self) { - self.chat_state = ChatState::Normal; - } - - /// Проверить, находимся ли в режиме ответа - pub fn is_replying(&self) -> bool { - self.chat_state.is_reply() - } - - /// Получить сообщение, на которое отвечаем - pub fn get_replying_to_message(&self) -> Option { - self.chat_state.selected_message_id().and_then(|id| { - self.td_client - .current_chat_messages() - .iter() - .find(|m| m.id() == id) - .cloned() - }) - } - - /// Начать режим пересылки выбранного сообщения - pub fn start_forward_selected(&mut self) -> bool { - if let Some(msg) = self.get_selected_message() { - self.chat_state = ChatState::Forward { - message_id: msg.id(), - }; - // Сбрасываем выбор чата на первый - self.chat_list_state.select(Some(0)); - return true; - } - false - } - - /// Отменить режим пересылки - pub fn cancel_forward(&mut self) { - self.chat_state = ChatState::Normal; - } - - /// Проверить, находимся ли в режиме выбора чата для пересылки - pub fn is_forwarding(&self) -> bool { - self.chat_state.is_forward() - } - - /// Получить сообщение для пересылки - pub fn get_forwarding_message(&self) -> Option { - if !self.chat_state.is_forward() { - return None; - } - self.chat_state.selected_message_id().and_then(|id| { - self.td_client - .current_chat_messages() - .iter() - .find(|m| m.id() == id) - .cloned() - }) - } - - // === Pinned messages mode === - - /// Проверка режима pinned - pub fn is_pinned_mode(&self) -> bool { - self.chat_state.is_pinned_mode() - } - - /// Войти в режим pinned (вызывается после загрузки pinned сообщений) - pub fn enter_pinned_mode(&mut self, messages: Vec) { - if !messages.is_empty() { - self.chat_state = ChatState::PinnedMessages { - messages, - selected_index: 0, - }; - } - } - - /// Выйти из режима pinned - pub fn exit_pinned_mode(&mut self) { - self.chat_state = ChatState::Normal; - } - - /// Выбрать предыдущий pinned (вверх = более старый) - pub fn select_previous_pinned(&mut self) { - if let ChatState::PinnedMessages { - selected_index, - messages, - } = &mut self.chat_state - { - if *selected_index + 1 < messages.len() { - *selected_index += 1; - } - } - } - - /// Выбрать следующий pinned (вниз = более новый) - pub fn select_next_pinned(&mut self) { - if let ChatState::PinnedMessages { selected_index, .. } = &mut self.chat_state { - if *selected_index > 0 { - *selected_index -= 1; - } - } - } - - /// Получить текущее выбранное pinned сообщение - pub fn get_selected_pinned(&self) -> Option<&crate::tdlib::MessageInfo> { - if let ChatState::PinnedMessages { - messages, - selected_index, - } = &self.chat_state - { - messages.get(*selected_index) - } else { - None - } - } - - /// Получить ID текущего pinned для перехода в историю - pub fn get_selected_pinned_id(&self) -> Option { - self.get_selected_pinned().map(|m| m.id().as_i64()) - } - - // === Message Search Mode === - - /// Проверить, активен ли режим поиска по сообщениям - pub fn is_message_search_mode(&self) -> bool { - self.chat_state.is_search_in_chat() - } - - /// Войти в режим поиска по сообщениям - pub fn enter_message_search_mode(&mut self) { - self.chat_state = ChatState::SearchInChat { - query: String::new(), - results: Vec::new(), - selected_index: 0, - }; - } - - /// Выйти из режима поиска - pub fn exit_message_search_mode(&mut self) { - self.chat_state = ChatState::Normal; - } - - /// Установить результаты поиска - pub fn set_search_results(&mut self, results: Vec) { - if let ChatState::SearchInChat { results: r, selected_index, .. } = &mut self.chat_state { - *r = results; - *selected_index = 0; - } - } - - /// Выбрать предыдущий результат (вверх) - pub fn select_previous_search_result(&mut self) { - if let ChatState::SearchInChat { selected_index, .. } = &mut self.chat_state { - if *selected_index > 0 { - *selected_index -= 1; - } - } - } - - /// Выбрать следующий результат (вниз) - pub fn select_next_search_result(&mut self) { - if let ChatState::SearchInChat { - selected_index, - results, - .. - } = &mut self.chat_state - { - if *selected_index + 1 < results.len() { - *selected_index += 1; - } - } - } - - /// Получить текущий выбранный результат - pub fn get_selected_search_result(&self) -> Option<&crate::tdlib::MessageInfo> { - if let ChatState::SearchInChat { - results, - selected_index, - .. - } = &self.chat_state - { - results.get(*selected_index) - } else { - None - } - } - - /// Получить ID выбранного результата для перехода - pub fn get_selected_search_result_id(&self) -> Option { - self.get_selected_search_result().map(|m| m.id().as_i64()) - } - - /// Получить поисковый запрос из режима поиска - pub fn get_search_query(&self) -> Option<&str> { - if let ChatState::SearchInChat { query, .. } = &self.chat_state { - Some(query.as_str()) - } else { - None - } - } - - /// Обновить поисковый запрос - pub fn update_search_query(&mut self, new_query: String) { - if let ChatState::SearchInChat { query, .. } = &mut self.chat_state { - *query = new_query; - } - } - - /// Получить индекс выбранного результата поиска - pub fn get_search_selected_index(&self) -> Option { - if let ChatState::SearchInChat { selected_index, .. } = &self.chat_state { - Some(*selected_index) - } else { - None - } - } - - /// Получить результаты поиска - pub fn get_search_results(&self) -> Option<&[crate::tdlib::MessageInfo]> { - if let ChatState::SearchInChat { results, .. } = &self.chat_state { - Some(results.as_slice()) - } else { - None - } - } - - // === Draft Management === - - /// Получить черновик для текущего чата - pub fn get_current_draft(&self) -> Option { - self.selected_chat_id.and_then(|chat_id| { - self.chats - .iter() - .find(|c| c.id == chat_id) - .and_then(|c| c.draft_text.clone()) - }) - } - - /// Загрузить черновик в message_input (вызывается при открытии чата) - pub fn load_draft(&mut self) { - if let Some(draft) = self.get_current_draft() { - self.message_input = draft; - self.cursor_position = self.message_input.chars().count(); - } - } - - // === Profile Mode === - - /// Проверить, активен ли режим профиля - pub fn is_profile_mode(&self) -> bool { - self.chat_state.is_profile() - } - - /// Войти в режим профиля - pub fn enter_profile_mode(&mut self, info: crate::tdlib::ProfileInfo) { - self.chat_state = ChatState::Profile { - info, - selected_action: 0, - leave_group_confirmation_step: 0, - }; - } - - /// Выйти из режима профиля - pub fn exit_profile_mode(&mut self) { - self.chat_state = ChatState::Normal; - } - - /// Выбрать предыдущее действие - pub fn select_previous_profile_action(&mut self) { - if let ChatState::Profile { - selected_action, .. - } = &mut self.chat_state - { - if *selected_action > 0 { - *selected_action -= 1; - } - } - } - - /// Выбрать следующее действие - pub fn select_next_profile_action(&mut self, max_actions: usize) { - if let ChatState::Profile { - selected_action, .. - } = &mut self.chat_state - { - if *selected_action < max_actions.saturating_sub(1) { - *selected_action += 1; - } - } - } - - /// Показать первое подтверждение выхода из группы - pub fn show_leave_group_confirmation(&mut self) { - if let ChatState::Profile { - leave_group_confirmation_step, - .. - } = &mut self.chat_state - { - *leave_group_confirmation_step = 1; - } - } - - /// Показать второе подтверждение выхода из группы - pub fn show_leave_group_final_confirmation(&mut self) { - if let ChatState::Profile { - leave_group_confirmation_step, - .. - } = &mut self.chat_state - { - *leave_group_confirmation_step = 2; - } - } - - /// Отменить подтверждение выхода из группы - pub fn cancel_leave_group(&mut self) { - if let ChatState::Profile { - leave_group_confirmation_step, - .. - } = &mut self.chat_state - { - *leave_group_confirmation_step = 0; - } - } - - /// Получить текущий шаг подтверждения - pub fn get_leave_group_confirmation_step(&self) -> u8 { - if let ChatState::Profile { - leave_group_confirmation_step, - .. - } = &self.chat_state - { - *leave_group_confirmation_step - } else { - 0 - } - } - - /// Получить информацию профиля - pub fn get_profile_info(&self) -> Option<&crate::tdlib::ProfileInfo> { - if let ChatState::Profile { info, .. } = &self.chat_state { - Some(info) - } else { - None - } - } - - /// Получить индекс выбранного действия в профиле - pub fn get_selected_profile_action(&self) -> Option { - if let ChatState::Profile { - selected_action, .. - } = &self.chat_state - { - Some(*selected_action) - } else { - None - } - } - - // ========== Reaction Picker ========== - - pub fn is_reaction_picker_mode(&self) -> bool { - self.chat_state.is_reaction_picker() - } - - pub fn enter_reaction_picker_mode( - &mut self, - message_id: i64, - available_reactions: Vec, - ) { - self.chat_state = ChatState::ReactionPicker { - message_id: MessageId::new(message_id), - available_reactions, - selected_index: 0, - }; - } - - pub fn exit_reaction_picker_mode(&mut self) { - self.chat_state = ChatState::Normal; - } - - pub fn select_previous_reaction(&mut self) { - if let ChatState::ReactionPicker { selected_index, .. } = &mut self.chat_state { - if *selected_index > 0 { - *selected_index -= 1; - } - } - } - - pub fn select_next_reaction(&mut self) { - if let ChatState::ReactionPicker { - selected_index, - available_reactions, - .. - } = &mut self.chat_state - { - if *selected_index + 1 < available_reactions.len() { - *selected_index += 1; - } - } - } - - pub fn get_selected_reaction(&self) -> Option<&String> { - if let ChatState::ReactionPicker { - available_reactions, - selected_index, - .. - } = &self.chat_state - { - available_reactions.get(*selected_index) - } else { - None - } - } - - pub fn get_selected_message_for_reaction(&self) -> Option { - self.chat_state.selected_message_id().map(|id| id.as_i64()) - } - // ========== Getter/Setter методы для инкапсуляции ========== // Config @@ -997,19 +565,17 @@ impl App { /// Creates a new App instance with the given configuration and a real TDLib client. /// /// This is a convenience method for production use that automatically creates - /// a new TdClient instance. + /// a new TdClient instance with the specified database path. /// /// # Arguments /// /// * `config` - Application configuration loaded from config.toml + /// * `db_path` - Path to the TDLib database directory for this account /// /// # Returns /// /// A new `App` instance ready to start authentication. - pub fn new(config: crate::config::Config) -> App { - let mut client = TdClient::new(); - // Configure notifications from config - client.configure_notifications(&config.notifications); - App::with_client(config, client) + pub fn new(config: crate::config::Config, db_path: std::path::PathBuf) -> App { + App::with_client(config, TdClient::new(db_path)) } } diff --git a/src/audio/cache.rs b/src/audio/cache.rs new file mode 100644 index 0000000..ab846aa --- /dev/null +++ b/src/audio/cache.rs @@ -0,0 +1,155 @@ +//! Voice message cache management. +//! +//! Caches downloaded OGG voice files in ~/.cache/tele-tui/voice/ +//! with LRU eviction when cache size exceeds limit. + +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Cache for voice message files +pub struct VoiceCache { + cache_dir: PathBuf, + /// file_id -> (path, size_bytes, access_count) + files: HashMap, + access_counter: usize, + max_size_bytes: u64, +} + +impl VoiceCache { + /// Creates a new VoiceCache with the given max size in MB + pub fn new(max_size_mb: u64) -> Result { + let cache_dir = dirs::cache_dir() + .ok_or("Failed to get cache directory")? + .join("tele-tui") + .join("voice"); + + fs::create_dir_all(&cache_dir) + .map_err(|e| format!("Failed to create cache directory: {}", e))?; + + Ok(Self { + cache_dir, + files: HashMap::new(), + access_counter: 0, + max_size_bytes: max_size_mb * 1024 * 1024, + }) + } + + /// Gets the path for a cached voice file, if it exists + pub fn get(&mut self, file_id: &str) -> Option { + if let Some((path, _, access)) = self.files.get_mut(file_id) { + // Update access count for LRU + self.access_counter += 1; + *access = self.access_counter; + Some(path.clone()) + } else { + None + } + } + + /// Stores a voice file in the cache + pub fn store(&mut self, file_id: &str, source_path: &Path) -> Result { + // Copy file to cache + let filename = format!("{}.ogg", file_id.replace('/', "_")); + let dest_path = self.cache_dir.join(&filename); + + fs::copy(source_path, &dest_path) + .map_err(|e| format!("Failed to copy voice file to cache: {}", e))?; + + // Get file size + let size = fs::metadata(&dest_path) + .map_err(|e| format!("Failed to get file size: {}", e))? + .len(); + + // Store in cache + self.access_counter += 1; + self.files + .insert(file_id.to_string(), (dest_path.clone(), size, self.access_counter)); + + // Check if we need to evict + self.evict_if_needed()?; + + Ok(dest_path) + } + + /// Returns the total size of all cached files + pub fn total_size(&self) -> u64 { + self.files.values().map(|(_, size, _)| size).sum() + } + + /// Evicts oldest files until cache is under max size + fn evict_if_needed(&mut self) -> Result<(), String> { + while self.total_size() > self.max_size_bytes && !self.files.is_empty() { + // Find least recently accessed file + let oldest_id = self + .files + .iter() + .min_by_key(|(_, (_, _, access))| access) + .map(|(id, _)| id.clone()); + + if let Some(id) = oldest_id { + self.evict(&id)?; + } + } + Ok(()) + } + + /// Evicts a specific file from cache + fn evict(&mut self, file_id: &str) -> Result<(), String> { + if let Some((path, _, _)) = self.files.remove(file_id) { + fs::remove_file(&path).map_err(|e| format!("Failed to remove cached file: {}", e))?; + } + Ok(()) + } + + /// Clears all cached files + #[allow(dead_code)] + pub fn clear(&mut self) -> Result<(), String> { + for (path, _, _) in self.files.values() { + let _ = fs::remove_file(path); // Ignore errors + } + self.files.clear(); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_voice_cache_creation() { + let cache = VoiceCache::new(100); + assert!(cache.is_ok()); + } + + #[test] + fn test_cache_get_nonexistent() { + let mut cache = VoiceCache::new(100).unwrap(); + assert!(cache.get("nonexistent").is_none()); + } + + #[test] + fn test_cache_store_and_get() { + let mut cache = VoiceCache::new(100).unwrap(); + + // Create temporary file + let temp_dir = std::env::temp_dir(); + let temp_file = temp_dir.join("test_voice.ogg"); + let mut file = fs::File::create(&temp_file).unwrap(); + file.write_all(b"test audio data").unwrap(); + + // Store in cache + let result = cache.store("test123", &temp_file); + assert!(result.is_ok()); + + // Get from cache + let cached_path = cache.get("test123"); + assert!(cached_path.is_some()); + assert!(cached_path.unwrap().exists()); + + // Cleanup + fs::remove_file(&temp_file).unwrap(); + } +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs new file mode 100644 index 0000000..b0890ad --- /dev/null +++ b/src/audio/mod.rs @@ -0,0 +1,11 @@ +//! Audio playback module for voice messages. +//! +//! Provides: +//! - AudioPlayer: rodio-based playback with play/pause/stop/volume controls +//! - VoiceCache: LRU cache for downloaded OGG voice files + +pub mod cache; +pub mod player; + +pub use cache::VoiceCache; +pub use player::AudioPlayer; diff --git a/src/audio/player.rs b/src/audio/player.rs new file mode 100644 index 0000000..6fceb9b --- /dev/null +++ b/src/audio/player.rs @@ -0,0 +1,198 @@ +//! Audio player for voice messages. +//! +//! Uses ffplay (from FFmpeg) for reliable Opus/OGG playback. +//! Pause/resume implemented via SIGSTOP/SIGCONT signals. + +use std::path::Path; +use std::process::Command; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +/// Audio player state and controls +pub struct AudioPlayer { + /// PID of current playback process (if any) + current_pid: Arc>>, + /// Whether the process is currently paused (SIGSTOP) + paused: Arc>, + /// Path to the currently playing file (for restart with seek) + current_path: Arc>>, + /// True between play_from() call and ffplay actually starting (race window) + starting: Arc>, +} + +impl AudioPlayer { + /// Creates a new AudioPlayer + pub fn new() -> Result { + Command::new("which") + .arg("ffplay") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + .map_err(|_| "ffplay not found (install ffmpeg)".to_string())?; + + Ok(Self { + current_pid: Arc::new(Mutex::new(None)), + paused: Arc::new(Mutex::new(false)), + current_path: Arc::new(Mutex::new(None)), + starting: Arc::new(Mutex::new(false)), + }) + } + + /// Plays an audio file from the given path + pub fn play>(&self, path: P) -> Result<(), String> { + self.play_from(path, 0.0) + } + + /// Plays an audio file starting from the given position (seconds) + pub fn play_from>(&self, path: P, start_secs: f32) -> Result<(), String> { + self.stop(); + + let path_owned = path.as_ref().to_path_buf(); + *self.current_path.lock().unwrap() = Some(path_owned.clone()); + *self.starting.lock().unwrap() = true; + let current_pid = self.current_pid.clone(); + let paused = self.paused.clone(); + let starting = self.starting.clone(); + + std::thread::spawn(move || { + let mut cmd = Command::new("ffplay"); + cmd.arg("-nodisp") + .arg("-autoexit") + .arg("-loglevel") + .arg("quiet"); + + if start_secs > 0.0 { + cmd.arg("-ss").arg(format!("{:.1}", start_secs)); + } + + if let Ok(mut child) = cmd + .arg(&path_owned) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + let pid = child.id(); + *current_pid.lock().unwrap() = Some(pid); + *paused.lock().unwrap() = false; + *starting.lock().unwrap() = false; + + let _ = child.wait(); + + // Обнуляем только если это наш pid (новый play мог уже заменить его) + let mut pid_guard = current_pid.lock().unwrap(); + if *pid_guard == Some(pid) { + *pid_guard = None; + *paused.lock().unwrap() = false; + } + } else { + *starting.lock().unwrap() = false; + } + }); + + Ok(()) + } + + /// Pauses playback via SIGSTOP + pub fn pause(&self) { + if let Some(pid) = *self.current_pid.lock().unwrap() { + let _ = Command::new("kill") + .arg("-STOP") + .arg(pid.to_string()) + .output(); + *self.paused.lock().unwrap() = true; + } + } + + /// Resumes playback via SIGCONT (from the same position) + pub fn resume(&self) { + if let Some(pid) = *self.current_pid.lock().unwrap() { + let _ = Command::new("kill") + .arg("-CONT") + .arg(pid.to_string()) + .output(); + *self.paused.lock().unwrap() = false; + } + } + + /// Resumes playback from a specific position (restarts ffplay with -ss) + pub fn resume_from(&self, position_secs: f32) -> Result<(), String> { + let path = self.current_path.lock().unwrap().clone(); + if let Some(path) = path { + self.play_from(&path, position_secs) + } else { + Err("No file to resume".to_string()) + } + } + + /// Stops playback (kills the process) + pub fn stop(&self) { + *self.starting.lock().unwrap() = false; + if let Some(pid) = self.current_pid.lock().unwrap().take() { + // Resume first if paused, then kill + let _ = Command::new("kill") + .arg("-CONT") + .arg(pid.to_string()) + .output(); + let _ = Command::new("kill").arg(pid.to_string()).output(); + } + *self.paused.lock().unwrap() = false; + } + + /// Returns true if a process is active (playing or paused) + #[allow(dead_code)] + pub fn is_playing(&self) -> bool { + self.current_pid.lock().unwrap().is_some() && !*self.paused.lock().unwrap() + } + + /// Returns true if paused + #[allow(dead_code)] + pub fn is_paused(&self) -> bool { + self.current_pid.lock().unwrap().is_some() && *self.paused.lock().unwrap() + } + + /// Returns true if no active process and not starting a new one + pub fn is_stopped(&self) -> bool { + self.current_pid.lock().unwrap().is_none() && !*self.starting.lock().unwrap() + } + + #[allow(dead_code)] + pub fn set_volume(&self, _volume: f32) {} + #[allow(dead_code)] + pub fn adjust_volume(&self, _delta: f32) {} + + pub fn volume(&self) -> f32 { + 1.0 + } + + #[allow(dead_code)] + pub fn seek(&self, _delta: Duration) -> Result<(), String> { + Err("Seeking not supported".to_string()) + } +} + +impl Drop for AudioPlayer { + fn drop(&mut self) { + self.stop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_audio_player_creation() { + if let Ok(player) = AudioPlayer::new() { + assert!(player.is_stopped()); + assert!(!player.is_playing()); + assert!(!player.is_paused()); + } + } + + #[test] + fn test_volume() { + if let Ok(player) = AudioPlayer::new() { + assert_eq!(player.volume(), 1.0); + } + } +} diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index e52fe87..af4eebe 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -4,7 +4,6 @@ /// - Загрузку из конфигурационного файла /// - Множественные binding для одной команды (EN/RU раскладки) /// - Type-safe команды через enum - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -48,6 +47,14 @@ pub enum Command { ReactMessage, SelectMessage, + // Media + ViewImage, // v - просмотр фото + + // Voice playback + TogglePlayback, // Space - play/pause + SeekForward, // → - seek +5s + SeekBackward, // ← - seek -5s + // Input SubmitMessage, Cancel, @@ -57,6 +64,9 @@ pub enum Command { MoveToStart, MoveToEnd, + // Vim mode + EnterInsertMode, + // Profile OpenProfile, } @@ -72,24 +82,21 @@ pub struct KeyBinding { impl KeyBinding { pub fn new(key: KeyCode) -> Self { - Self { - key, - modifiers: KeyModifiers::NONE, - } + Self { key, modifiers: KeyModifiers::NONE } } pub fn with_ctrl(key: KeyCode) -> Self { - Self { - key, - modifiers: KeyModifiers::CONTROL, - } + Self { key, modifiers: KeyModifiers::CONTROL } } + #[allow(dead_code)] pub fn with_shift(key: KeyCode) -> Self { - Self { - key, - modifiers: KeyModifiers::SHIFT, - } + Self { key, modifiers: KeyModifiers::SHIFT } + } + + #[allow(dead_code)] + pub fn with_alt(key: KeyCode) -> Self { + Self { key, modifiers: KeyModifiers::ALT } } pub fn matches(&self, event: &KeyEvent) -> bool { @@ -105,55 +112,81 @@ pub struct Keybindings { } impl Keybindings { - /// Создаёт дефолтную конфигурацию - pub fn default() -> Self { + /// Ищет команду по клавише + pub fn get_command(&self, event: &KeyEvent) -> Option { + for (command, bindings) in &self.bindings { + if bindings.iter().any(|binding| binding.matches(event)) { + return Some(*command); + } + } + None + } +} + +impl Default for Keybindings { + fn default() -> Self { let mut bindings = HashMap::new(); // Navigation - bindings.insert(Command::MoveUp, vec![ - KeyBinding::new(KeyCode::Up), - KeyBinding::new(KeyCode::Char('k')), - KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН) - ]); - bindings.insert(Command::MoveDown, vec![ - KeyBinding::new(KeyCode::Down), - KeyBinding::new(KeyCode::Char('j')), - KeyBinding::new(KeyCode::Char('о')), // RU - ]); - bindings.insert(Command::MoveLeft, vec![ - KeyBinding::new(KeyCode::Left), - KeyBinding::new(KeyCode::Char('h')), - KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН) - ]); - bindings.insert(Command::MoveRight, vec![ - KeyBinding::new(KeyCode::Right), - KeyBinding::new(KeyCode::Char('l')), - KeyBinding::new(KeyCode::Char('д')), // RU - ]); - bindings.insert(Command::PageUp, vec![ - KeyBinding::new(KeyCode::PageUp), - KeyBinding::with_ctrl(KeyCode::Char('u')), - ]); - bindings.insert(Command::PageDown, vec![ - KeyBinding::new(KeyCode::PageDown), - KeyBinding::with_ctrl(KeyCode::Char('d')), - ]); + bindings.insert( + Command::MoveUp, + vec![ + KeyBinding::new(KeyCode::Up), + KeyBinding::new(KeyCode::Char('k')), + KeyBinding::new(KeyCode::Char('р')), // RU (custom mapping, not standard ЙЦУКЕН) + ], + ); + bindings.insert( + Command::MoveDown, + vec![ + KeyBinding::new(KeyCode::Down), + KeyBinding::new(KeyCode::Char('j')), + KeyBinding::new(KeyCode::Char('о')), // RU + ], + ); + bindings.insert( + Command::MoveLeft, + vec![ + KeyBinding::new(KeyCode::Left), + KeyBinding::new(KeyCode::Char('h')), + KeyBinding::new(KeyCode::Char('л')), // RU (custom mapping, not standard ЙЦУКЕН) + ], + ); + bindings.insert( + Command::MoveRight, + vec![ + KeyBinding::new(KeyCode::Right), + KeyBinding::new(KeyCode::Char('l')), + KeyBinding::new(KeyCode::Char('д')), // RU + ], + ); + bindings.insert( + Command::PageUp, + vec![ + KeyBinding::new(KeyCode::PageUp), + KeyBinding::with_ctrl(KeyCode::Char('u')), + ], + ); + bindings.insert( + Command::PageDown, + vec![ + KeyBinding::new(KeyCode::PageDown), + KeyBinding::with_ctrl(KeyCode::Char('d')), + ], + ); // Global - bindings.insert(Command::Quit, vec![ - KeyBinding::new(KeyCode::Char('q')), - KeyBinding::new(KeyCode::Char('й')), // RU - KeyBinding::with_ctrl(KeyCode::Char('c')), - ]); - bindings.insert(Command::OpenSearch, vec![ - KeyBinding::with_ctrl(KeyCode::Char('s')), - ]); - bindings.insert(Command::OpenSearchInChat, vec![ - KeyBinding::with_ctrl(KeyCode::Char('f')), - ]); - bindings.insert(Command::Help, vec![ - KeyBinding::new(KeyCode::Char('?')), - ]); + bindings.insert( + Command::Quit, + vec![ + KeyBinding::new(KeyCode::Char('q')), + KeyBinding::new(KeyCode::Char('й')), // RU + KeyBinding::with_ctrl(KeyCode::Char('c')), + ], + ); + bindings.insert(Command::OpenSearch, vec![KeyBinding::with_ctrl(KeyCode::Char('s'))]); + bindings.insert(Command::OpenSearchInChat, vec![KeyBinding::with_ctrl(KeyCode::Char('f'))]); + bindings.insert(Command::Help, vec![KeyBinding::new(KeyCode::Char('?'))]); // Chat list // Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key() @@ -170,88 +203,117 @@ impl Keybindings { 9 => Command::SelectFolder9, _ => unreachable!(), }; - bindings.insert(cmd, vec![ - KeyBinding::new(KeyCode::Char(char::from_digit(i, 10).unwrap())), - ]); + bindings.insert( + cmd, + vec![KeyBinding::new(KeyCode::Char( + char::from_digit(i, 10).unwrap(), + ))], + ); } // Message actions // Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input // в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не // конфликтовать с Command::MoveUp в списке чатов. - bindings.insert(Command::DeleteMessage, vec![ - KeyBinding::new(KeyCode::Delete), - KeyBinding::new(KeyCode::Char('d')), - KeyBinding::new(KeyCode::Char('в')), // RU - ]); - bindings.insert(Command::ReplyMessage, vec![ - KeyBinding::new(KeyCode::Char('r')), - KeyBinding::new(KeyCode::Char('к')), // RU - ]); - bindings.insert(Command::ForwardMessage, vec![ - KeyBinding::new(KeyCode::Char('f')), - KeyBinding::new(KeyCode::Char('а')), // RU - ]); - bindings.insert(Command::CopyMessage, vec![ - KeyBinding::new(KeyCode::Char('y')), - KeyBinding::new(KeyCode::Char('н')), // RU - ]); - bindings.insert(Command::ReactMessage, vec![ - KeyBinding::new(KeyCode::Char('e')), - KeyBinding::new(KeyCode::Char('у')), // RU - ]); + bindings.insert( + Command::DeleteMessage, + vec![ + KeyBinding::new(KeyCode::Delete), + KeyBinding::new(KeyCode::Char('d')), + KeyBinding::new(KeyCode::Char('в')), // RU + ], + ); + bindings.insert( + Command::ReplyMessage, + vec![ + KeyBinding::new(KeyCode::Char('r')), + KeyBinding::new(KeyCode::Char('к')), // RU + ], + ); + bindings.insert( + Command::ForwardMessage, + vec![ + KeyBinding::new(KeyCode::Char('f')), + KeyBinding::new(KeyCode::Char('а')), // RU + ], + ); + bindings.insert( + Command::CopyMessage, + vec![ + KeyBinding::new(KeyCode::Char('y')), + KeyBinding::new(KeyCode::Char('н')), // RU + ], + ); + bindings.insert( + Command::ReactMessage, + vec![ + KeyBinding::new(KeyCode::Char('e')), + KeyBinding::new(KeyCode::Char('у')), // RU + ], + ); // Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key() - + + // Media + bindings.insert( + Command::ViewImage, + vec![ + KeyBinding::new(KeyCode::Char('v')), + KeyBinding::new(KeyCode::Char('м')), // RU + ], + ); + + // Voice playback + bindings.insert(Command::TogglePlayback, vec![KeyBinding::new(KeyCode::Char(' '))]); + bindings.insert(Command::SeekForward, vec![KeyBinding::new(KeyCode::Right)]); + bindings.insert(Command::SeekBackward, vec![KeyBinding::new(KeyCode::Left)]); + // Input - bindings.insert(Command::SubmitMessage, vec![ - KeyBinding::new(KeyCode::Enter), - ]); - bindings.insert(Command::Cancel, vec![ - KeyBinding::new(KeyCode::Esc), - ]); - bindings.insert(Command::NewLine, vec![ - KeyBinding::with_shift(KeyCode::Enter), - ]); - bindings.insert(Command::DeleteChar, vec![ - KeyBinding::new(KeyCode::Backspace), - ]); - bindings.insert(Command::DeleteWord, vec![ - KeyBinding::with_ctrl(KeyCode::Backspace), - KeyBinding::with_ctrl(KeyCode::Char('w')), - ]); - bindings.insert(Command::MoveToStart, vec![ - KeyBinding::new(KeyCode::Home), - KeyBinding::with_ctrl(KeyCode::Char('a')), - ]); - bindings.insert(Command::MoveToEnd, vec![ - KeyBinding::new(KeyCode::End), - KeyBinding::with_ctrl(KeyCode::Char('e')), - ]); + bindings.insert(Command::SubmitMessage, vec![KeyBinding::new(KeyCode::Enter)]); + bindings.insert(Command::Cancel, vec![KeyBinding::new(KeyCode::Esc)]); + bindings.insert(Command::NewLine, vec![]); + bindings.insert(Command::DeleteChar, vec![KeyBinding::new(KeyCode::Backspace)]); + bindings.insert( + Command::DeleteWord, + vec![ + KeyBinding::with_ctrl(KeyCode::Backspace), + KeyBinding::with_ctrl(KeyCode::Char('w')), + ], + ); + bindings.insert( + Command::MoveToStart, + vec![ + KeyBinding::new(KeyCode::Home), + KeyBinding::with_ctrl(KeyCode::Char('a')), + ], + ); + bindings.insert( + Command::MoveToEnd, + vec![ + KeyBinding::new(KeyCode::End), + KeyBinding::with_ctrl(KeyCode::Char('e')), + ], + ); + + // Vim mode + bindings.insert( + Command::EnterInsertMode, + vec![ + KeyBinding::new(KeyCode::Char('i')), + KeyBinding::new(KeyCode::Char('ш')), // RU + ], + ); // Profile - bindings.insert(Command::OpenProfile, vec![ - KeyBinding::with_ctrl(KeyCode::Char('i')), - KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU - ]); + bindings.insert( + Command::OpenProfile, + vec![ + KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I + KeyBinding::with_ctrl(KeyCode::Char('г')), // RU + ], + ); Self { bindings } } - - /// Ищет команду по клавише - pub fn get_command(&self, event: &KeyEvent) -> Option { - for (command, bindings) in &self.bindings { - if bindings.iter().any(|binding| binding.matches(event)) { - return Some(*command); - } - } - None - } -} - -impl Default for Keybindings { - fn default() -> Self { - Self::default() - } } /// Сериализация KeyModifiers @@ -356,14 +418,15 @@ mod key_code_serde { let s = String::deserialize(deserializer)?; if s.starts_with("Char('") && s.ends_with("')") { - let c = s.chars().nth(6).ok_or_else(|| { - serde::de::Error::custom("Invalid Char format") - })?; + let c = s + .chars() + .nth(6) + .ok_or_else(|| serde::de::Error::custom("Invalid Char format"))?; return Ok(KeyCode::Char(c)); } - if s.starts_with("F") { - let n = s[1..].parse().map_err(serde::de::Error::custom)?; + if let Some(suffix) = s.strip_prefix("F") { + let n = suffix.parse().map_err(serde::de::Error::custom)?; return Ok(KeyCode::F(n)); } diff --git a/src/config/loader.rs b/src/config/loader.rs new file mode 100644 index 0000000..5935089 --- /dev/null +++ b/src/config/loader.rs @@ -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 { + dirs::config_dir().map(|mut path| { + path.push("tele-tui"); + path.push("config.toml"); + path + }) + } + + /// Путь к директории конфигурации + pub fn config_dir() -> Option { + 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::(&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 { + 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 = None; + let mut api_hash: Option = 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::().ok()?; + + Some((api_id, api_hash)) + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 92bd190..abd9015 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -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}; @@ -21,7 +26,7 @@ pub use keybindings::{Command, Keybindings}; /// println!("Timezone: {}", config.general.timezone); /// println!("Incoming color: {}", config.colors.incoming_message); /// ``` -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct Config { /// Общие настройки (timezone и т.д.). #[serde(default)] @@ -38,6 +43,14 @@ pub struct Config { /// Настройки desktop notifications. #[serde(default)] pub notifications: NotificationsConfig, + + /// Настройки отображения изображений. + #[serde(default)] + pub images: ImagesConfig, + + /// Настройки аудио (голосовые сообщения). + #[serde(default)] + pub audio: AudioConfig, } /// Общие настройки приложения. @@ -100,7 +113,59 @@ pub struct NotificationsConfig { pub urgency: String, } -// Дефолтные значения +/// Настройки отображения изображений. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ImagesConfig { + /// Показывать превью изображений в чате + #[serde(default = "default_show_images")] + pub show_images: bool, + + /// Размер кэша изображений (в МБ) + #[serde(default = "default_image_cache_size_mb")] + pub cache_size_mb: u64, + + /// Максимальная ширина inline превью (в символах) + #[serde(default = "default_inline_image_max_width")] + pub inline_image_max_width: usize, + + /// Автоматически загружать изображения при открытии чата + #[serde(default = "default_auto_download_images")] + pub auto_download_images: bool, +} + +impl Default for ImagesConfig { + fn default() -> Self { + Self { + show_images: default_show_images(), + cache_size_mb: default_image_cache_size_mb(), + inline_image_max_width: default_inline_image_max_width(), + auto_download_images: default_auto_download_images(), + } + } +} + +/// Настройки аудио (голосовые сообщения). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AudioConfig { + /// Размер кэша голосовых файлов (в МБ) + #[serde(default = "default_audio_cache_size_mb")] + pub cache_size_mb: u64, + + /// Автоматически загружать голосовые при открытии чата + #[serde(default = "default_auto_download_voice")] + pub auto_download_voice: bool, +} + +impl Default for AudioConfig { + fn default() -> Self { + Self { + cache_size_mb: default_audio_cache_size_mb(), + auto_download_voice: default_auto_download_voice(), + } + } +} + +// Дефолтные значения (используются serde атрибутами) fn default_timezone() -> String { "+03:00".to_string() } @@ -126,7 +191,7 @@ fn default_reaction_other_color() -> String { } fn default_notifications_enabled() -> bool { - true + false } fn default_show_preview() -> bool { @@ -141,6 +206,30 @@ fn default_notification_urgency() -> String { "normal".to_string() } +fn default_show_images() -> bool { + true +} + +fn default_image_cache_size_mb() -> u64 { + crate::constants::DEFAULT_IMAGE_CACHE_SIZE_MB +} + +fn default_inline_image_max_width() -> usize { + crate::constants::INLINE_IMAGE_MAX_WIDTH +} + +fn default_auto_download_images() -> bool { + true +} + +fn default_audio_cache_size_mb() -> u64 { + crate::constants::DEFAULT_AUDIO_CACHE_SIZE_MB +} + +fn default_auto_download_voice() -> bool { + false +} + impl Default for GeneralConfig { fn default() -> Self { Self { timezone: default_timezone() } @@ -171,309 +260,6 @@ impl Default for NotificationsConfig { } } -impl Default for Config { - fn default() -> Self { - Self { - general: GeneralConfig::default(), - colors: ColorsConfig::default(), - keybindings: Keybindings::default(), - notifications: NotificationsConfig::default(), - } - } -} - -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 { - dirs::config_dir().map(|mut path| { - path.push("tele-tui"); - path.push("config.toml"); - path - }) - } - - /// Путь к директории конфигурации - pub fn config_dir() -> Option { - 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::(&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 { - 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 = None; - let mut api_hash: Option = 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::().ok()?; - - Some((api_id, api_hash)) - } -} - #[cfg(test)] mod tests { use super::*; @@ -485,10 +271,22 @@ mod tests { let keybindings = &config.keybindings; // Test that keybindings exist for common commands - assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) == Some(Command::ReplyMessage)); - assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE)) == Some(Command::ReplyMessage)); - assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)) == Some(Command::ForwardMessage)); - assert!(keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE)) == Some(Command::ForwardMessage)); + assert!( + keybindings.get_command(&KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)) + == Some(Command::ReplyMessage) + ); + assert!( + keybindings.get_command(&KeyEvent::new(KeyCode::Char('к'), KeyModifiers::NONE)) + == Some(Command::ReplyMessage) + ); + assert!( + keybindings.get_command(&KeyEvent::new(KeyCode::Char('f'), KeyModifiers::NONE)) + == Some(Command::ForwardMessage) + ); + assert!( + keybindings.get_command(&KeyEvent::new(KeyCode::Char('а'), KeyModifiers::NONE)) + == Some(Command::ForwardMessage) + ); } #[test] @@ -556,10 +354,24 @@ mod tests { #[test] fn test_config_validate_valid_all_standard_colors() { let colors = [ - "black", "red", "green", "yellow", "blue", "magenta", - "cyan", "gray", "grey", "white", "darkgray", "darkgrey", - "lightred", "lightgreen", "lightyellow", "lightblue", - "lightmagenta", "lightcyan" + "black", + "red", + "green", + "yellow", + "blue", + "magenta", + "cyan", + "gray", + "grey", + "white", + "darkgray", + "darkgrey", + "lightred", + "lightgreen", + "lightyellow", + "lightblue", + "lightmagenta", + "lightcyan", ]; for color in colors { @@ -570,11 +382,7 @@ mod tests { config.colors.reaction_chosen = color.to_string(); config.colors.reaction_other = color.to_string(); - assert!( - config.validate().is_ok(), - "Color '{}' should be valid", - color - ); + assert!(config.validate().is_ok(), "Color '{}' should be valid", color); } } diff --git a/src/config/validation.rs b/src/config/validation.rs new file mode 100644 index 0000000..a9bb132 --- /dev/null +++ b/src/config/validation.rs @@ -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 + } + } +} diff --git a/src/constants.rs b/src/constants.rs index 032c191..b8c8559 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,4 +1,4 @@ -// Application constants +//! Application-wide constants (memory limits, timeouts, UI sizes). // ============================================================================ // Memory Limits @@ -35,3 +35,50 @@ pub const LAZY_LOAD_USERS_PER_TICK: usize = 5; /// Лимит количества сообщений для загрузки через TDLib за раз pub const TDLIB_MESSAGE_LIMIT: i32 = 50; + +// ============================================================================ +// Images +// ============================================================================ + +/// Максимальная ширина превью изображения (в символах) +pub const MAX_IMAGE_WIDTH: u16 = 30; + +/// Максимальная высота превью изображения (в строках) +pub const MAX_IMAGE_HEIGHT: u16 = 15; + +/// Минимальная высота превью изображения (в строках) +pub const MIN_IMAGE_HEIGHT: u16 = 3; + +/// Таймаут скачивания файла (в секундах) +#[allow(dead_code)] +pub const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 30; + +/// Размер кэша изображений по умолчанию (в МБ) +pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500; + +/// Максимальная ширина inline превью изображений (в символах) +#[cfg(feature = "images")] +pub const INLINE_IMAGE_MAX_WIDTH: usize = 50; + +/// Ширина одного фото в альбоме (в символах) +#[cfg(feature = "images")] +pub const ALBUM_PHOTO_WIDTH: u16 = 16; + +/// Высота одного фото в альбоме (в строках) +#[cfg(feature = "images")] +pub const ALBUM_PHOTO_HEIGHT: u16 = 8; + +/// Отступ между фото в альбоме (в символах) +#[cfg(feature = "images")] +pub const ALBUM_PHOTO_GAP: u16 = 1; + +/// Максимальное количество фото в одном ряду альбома +#[cfg(feature = "images")] +pub const ALBUM_GRID_MAX_COLS: usize = 3; + +// ============================================================================ +// Audio +// ============================================================================ + +/// Размер кэша голосовых сообщений по умолчанию (в МБ) +pub const DEFAULT_AUDIO_CACHE_SIZE_MB: u64 = 100; diff --git a/src/formatting.rs b/src/formatting.rs index 1fe6d4e..4786211 100644 --- a/src/formatting.rs +++ b/src/formatting.rs @@ -126,23 +126,25 @@ pub fn format_text_with_entities( let start = entity.offset as usize; let end = (entity.offset + entity.length) as usize; - for i in start..end.min(chars.len()) { + for item in char_styles + .iter_mut() + .take(end.min(chars.len())) + .skip(start) + { match &entity.r#type { - TextEntityType::Bold => char_styles[i].bold = true, - TextEntityType::Italic => char_styles[i].italic = true, - TextEntityType::Underline => char_styles[i].underline = true, - TextEntityType::Strikethrough => char_styles[i].strikethrough = true, + TextEntityType::Bold => item.bold = true, + TextEntityType::Italic => item.italic = true, + TextEntityType::Underline => item.underline = true, + TextEntityType::Strikethrough => item.strikethrough = true, TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => { - char_styles[i].code = true + item.code = true } - TextEntityType::Spoiler => char_styles[i].spoiler = true, + TextEntityType::Spoiler => item.spoiler = true, TextEntityType::Url | TextEntityType::TextUrl(_) | TextEntityType::EmailAddress - | TextEntityType::PhoneNumber => char_styles[i].url = true, - TextEntityType::Mention | TextEntityType::MentionName(_) => { - char_styles[i].mention = true - } + | TextEntityType::PhoneNumber => item.url = true, + TextEntityType::Mention | TextEntityType::MentionName(_) => item.mention = true, _ => {} } } @@ -277,11 +279,7 @@ mod tests { #[test] fn test_format_text_with_bold() { let text = "Hello"; - let entities = vec![TextEntity { - offset: 0, - length: 5, - r#type: TextEntityType::Bold, - }]; + let entities = vec![TextEntity { offset: 0, length: 5, r#type: TextEntityType::Bold }]; let spans = format_text_with_entities(text, &entities, Color::White); assert_eq!(spans.len(), 1); diff --git a/src/input/auth.rs b/src/input/auth.rs index 4a43a2a..8670b86 100644 --- a/src/input/auth.rs +++ b/src/input/auth.rs @@ -20,7 +20,8 @@ pub async fn handle(app: &mut App, key_code: KeyCode) { app.status_message = Some("Отправка номера...".to_string()); match with_timeout_msg( Duration::from_secs(10), - app.td_client.send_phone_number(app.phone_input().to_string()), + app.td_client + .send_phone_number(app.phone_input().to_string()), "Таймаут отправки номера", ) .await @@ -84,7 +85,8 @@ pub async fn handle(app: &mut App, key_code: KeyCode) { app.status_message = Some("Проверка пароля...".to_string()); match with_timeout_msg( Duration::from_secs(10), - app.td_client.send_password(app.password_input().to_string()), + app.td_client + .send_password(app.password_input().to_string()), "Таймаут проверки пароля", ) .await diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs new file mode 100644 index 0000000..5c687c3 --- /dev/null +++ b/src/input/handlers/chat.rs @@ -0,0 +1,857 @@ +//! Chat input handlers +//! +//! Handles keyboard input when a chat is open, including: +//! - Message scrolling and navigation +//! - Message selection and actions +//! - Editing and sending messages +//! - Loading older messages + +use super::chat_list::open_chat_and_load_data; +use crate::app::methods::{ + compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods, + navigation::NavigationMethods, +}; +use crate::app::App; +use crate::app::InputMode; +use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard}; +use crate::tdlib::{ChatAction, TdClientTrait}; +use crate::types::{ChatId, MessageId}; +use crate::utils::{is_non_empty, with_timeout, with_timeout_msg}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::{Duration, Instant}; + +/// Обработка режима выбора сообщения для действий +/// +/// Обрабатывает: +/// - Навигацию по сообщениям (Up/Down) +/// - Удаление сообщения (d/в/Delete) +/// - Ответ на сообщение (r/к) +/// - Пересылку сообщения (f/а) +/// - Копирование сообщения (y/н) +/// - Добавление реакции (e/у) +pub async fn handle_message_selection( + app: &mut App, + _key: KeyEvent, + command: Option, +) { + match command { + Some(crate::config::Command::MoveUp) => { + app.select_previous_message(); + } + Some(crate::config::Command::MoveDown) => { + app.select_next_message(); + } + Some(crate::config::Command::DeleteMessage) => { + let Some(msg) = app.get_selected_message() else { + return; + }; + let can_delete = + msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users(); + if can_delete { + app.chat_state = crate::app::ChatState::DeleteConfirmation { message_id: msg.id() }; + } + } + Some(crate::config::Command::EnterInsertMode) => { + app.input_mode = InputMode::Insert; + app.chat_state = crate::app::ChatState::Normal; + } + Some(crate::config::Command::ReplyMessage) => { + app.start_reply_to_selected(); + app.input_mode = InputMode::Insert; + } + Some(crate::config::Command::ForwardMessage) => { + app.start_forward_selected(); + } + Some(crate::config::Command::CopyMessage) => { + let Some(msg) = app.get_selected_message() else { + return; + }; + let text = format_message_for_clipboard(&msg); + match copy_to_clipboard(&text) { + Ok(_) => { + app.status_message = Some("Сообщение скопировано".to_string()); + } + Err(e) => { + app.error_message = Some(format!("Ошибка копирования: {}", e)); + } + } + } + Some(crate::config::Command::ViewImage) => { + handle_view_or_play_media(app).await; + } + Some(crate::config::Command::TogglePlayback) => { + handle_toggle_voice_playback(app).await; + } + Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => { + handle_voice_seek(app, 5.0); + } + Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => { + handle_voice_seek(app, -5.0); + } + Some(crate::config::Command::ReactMessage) => { + let Some(msg) = app.get_selected_message() else { + return; + }; + let chat_id = app.selected_chat_id.unwrap(); + let message_id = msg.id(); + + app.status_message = Some("Загрузка реакций...".to_string()); + app.needs_redraw = true; + + match with_timeout_msg( + Duration::from_secs(5), + app.td_client + .get_message_available_reactions(chat_id, message_id), + "Таймаут загрузки реакций", + ) + .await + { + Ok(reactions) => { + let reactions: Vec = reactions; + if reactions.is_empty() { + app.error_message = + Some("Реакции недоступны для этого сообщения".to_string()); + app.status_message = None; + app.needs_redraw = true; + } else { + app.enter_reaction_picker_mode(message_id.as_i64(), reactions); + app.status_message = None; + app.needs_redraw = true; + } + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + app.needs_redraw = true; + } + } + } + _ => {} + } +} + +/// Редактирование существующего сообщения +pub async fn edit_message( + app: &mut App, + chat_id: i64, + msg_id: MessageId, + text: String, +) { + // Проверяем, что сообщение есть в локальном кэше + let msg_exists = app + .td_client + .current_chat_messages() + .iter() + .any(|m| m.id() == msg_id); + + if !msg_exists { + app.error_message = + Some(format!("Сообщение {} не найдено в кэше чата {}", msg_id.as_i64(), chat_id)); + app.chat_state = crate::app::ChatState::Normal; + app.message_input.clear(); + app.cursor_position = 0; + return; + } + + match with_timeout_msg( + Duration::from_secs(5), + app.td_client + .edit_message(ChatId::new(chat_id), msg_id, text), + "Таймаут редактирования", + ) + .await + { + Ok(mut edited_msg) => { + // Сохраняем reply_to из старого сообщения (если есть) + let messages = app.td_client.current_chat_messages_mut(); + if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) { + let old_reply_to = messages[pos].interactions.reply_to.clone(); + // Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый + if let Some(old_reply) = old_reply_to { + if edited_msg + .interactions + .reply_to + .as_ref() + .is_none_or(|r| r.sender_name == "Unknown") + { + edited_msg.interactions.reply_to = Some(old_reply); + } + } + // Заменяем сообщение + messages[pos] = edited_msg; + } + // Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования + app.message_input.clear(); + app.cursor_position = 0; + app.chat_state = crate::app::ChatState::Normal; + app.needs_redraw = true; + } + Err(e) => { + app.error_message = Some(e); + } + } +} + +/// Отправка нового сообщения (с опциональным reply) +pub async fn send_new_message(app: &mut App, chat_id: i64, text: String) { + let reply_to_id = if app.is_replying() { + app.chat_state.selected_message_id() + } else { + None + }; + + // Создаём ReplyInfo ДО отправки, пока сообщение точно доступно + let reply_info = app + .get_replying_to_message() + .map(|m| crate::tdlib::ReplyInfo { + message_id: m.id(), + sender_name: m.sender_name().to_string(), + text: m.text().to_string(), + }); + + app.message_input.clear(); + app.cursor_position = 0; + // Сбрасываем режим reply если он был активен + if app.is_replying() { + app.chat_state = crate::app::ChatState::Normal; + } + app.last_typing_sent = None; + + // Отменяем typing status + app.td_client + .send_chat_action(ChatId::new(chat_id), ChatAction::Cancel) + .await; + + match with_timeout_msg( + Duration::from_secs(5), + app.td_client + .send_message(ChatId::new(chat_id), text, reply_to_id, reply_info), + "Таймаут отправки", + ) + .await + { + Ok(sent_msg) => { + // Добавляем отправленное сообщение в список (с лимитом) + app.td_client.push_message(sent_msg); + // Сбрасываем скролл чтобы видеть новое сообщение + app.message_scroll_offset = 0; + } + Err(e) => { + app.error_message = Some(e); + } + } +} + +/// Обработка клавиши Enter +/// +/// Обрабатывает три сценария: +/// 1. В режиме выбора сообщения: начать редактирование +/// 2. В открытом чате: отправить новое или редактировать существующее сообщение +/// 3. В списке чатов: открыть выбранный чат +pub async fn handle_enter_key(app: &mut App) { + // Сценарий 1: Открытие чата из списка + if app.selected_chat_id.is_none() { + let prev_selected = app.selected_chat_id; + app.select_current_chat(); + + if app.selected_chat_id != prev_selected { + if let Some(chat_id) = app.get_selected_chat_id() { + open_chat_and_load_data(app, chat_id).await; + } + } + return; + } + + // Сценарий 2: Режим выбора сообщения - начать редактирование + if app.is_selecting_message() { + if app.start_editing_selected() { + app.input_mode = InputMode::Insert; + } else { + // Нельзя редактировать это сообщение + app.chat_state = crate::app::ChatState::Normal; + } + return; + } + + // Сценарий 3: Отправка или редактирование сообщения + if !is_non_empty(&app.message_input) { + return; + } + + let Some(chat_id) = app.get_selected_chat_id() else { + return; + }; + + let text = app.message_input.clone(); + + if app.is_editing() { + // Редактирование существующего сообщения + if let Some(msg_id) = app.chat_state.selected_message_id() { + edit_message(app, chat_id, msg_id, text).await; + } + } else { + // Отправка нового сообщения + send_new_message(app, chat_id, text).await; + } +} + +/// Отправляет реакцию на выбранное сообщение +pub async fn send_reaction(app: &mut App) { + // Get selected reaction emoji + let Some(emoji) = app.get_selected_reaction().cloned() else { + return; + }; + + // Get selected message ID + let Some(message_id) = app.get_selected_message_for_reaction() else { + return; + }; + + // Get chat ID + let Some(chat_id) = app.selected_chat_id else { + return; + }; + + let message_id = MessageId::new(message_id); + app.status_message = Some("Отправка реакции...".to_string()); + app.needs_redraw = true; + + // Send reaction with timeout + let result = with_timeout_msg( + Duration::from_secs(5), + app.td_client + .toggle_reaction(chat_id, message_id, emoji.clone()), + "Таймаут отправки реакции", + ) + .await; + + // Handle result + match result { + Ok(_) => { + app.status_message = Some(format!("Реакция {} добавлена", emoji)); + app.exit_reaction_picker_mode(); + app.needs_redraw = true; + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + app.needs_redraw = true; + } + } +} + +/// Подгружает старые сообщения если скролл близко к верху +pub async fn load_older_messages_if_needed(app: &mut App) { + // Check if there are messages to load from + if app.td_client.current_chat_messages().is_empty() { + return; + } + + // Get the oldest message ID + let oldest_msg_id = app + .td_client + .current_chat_messages() + .first() + .map(|m| m.id()) + .unwrap_or(MessageId::new(0)); + + // Get current chat ID + let Some(chat_id) = app.get_selected_chat_id() else { + return; + }; + + // Check if scroll is near the top + let message_count = app.td_client.current_chat_messages().len(); + if app.message_scroll_offset <= message_count.saturating_sub(10) { + return; + } + + // Load older messages with timeout + let Ok(older) = with_timeout( + Duration::from_secs(3), + app.td_client + .load_older_messages(ChatId::new(chat_id), oldest_msg_id), + ) + .await + else { + return; + }; + + // Add older messages to the beginning if any were loaded + if !older.is_empty() { + let msgs = app.td_client.current_chat_messages_mut(); + msgs.splice(0..0, older); + } +} + +/// Обработка ввода клавиатуры в открытом чате +/// +/// Обрабатывает: +/// - Backspace/Delete: удаление символов относительно курсора +/// - Char: вставка символов в позицию курсора + typing status +/// - Left/Right/Home/End: навигация курсора +/// - Up/Down: скролл сообщений или начало режима выбора +pub async fn handle_open_chat_keyboard_input(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Backspace => { + // Удаляем символ слева от курсора + if app.cursor_position > 0 { + let chars: Vec = app.message_input.chars().collect(); + let mut new_input = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i != app.cursor_position - 1 { + new_input.push(*ch); + } + } + app.message_input = new_input; + app.cursor_position -= 1; + } + } + KeyCode::Delete => { + // Удаляем символ справа от курсора + let len = app.message_input.chars().count(); + if app.cursor_position < len { + let chars: Vec = app.message_input.chars().collect(); + let mut new_input = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i != app.cursor_position { + new_input.push(*ch); + } + } + app.message_input = new_input; + } + } + KeyCode::Char(c) => { + // Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift) + // Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля + if key.modifiers.contains(KeyModifiers::CONTROL) + || key.modifiers.contains(KeyModifiers::ALT) + { + return; + } + + // Вставляем символ в позицию курсора + let chars: Vec = app.message_input.chars().collect(); + let mut new_input = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i == app.cursor_position { + new_input.push(c); + } + new_input.push(*ch); + } + if app.cursor_position >= chars.len() { + new_input.push(c); + } + app.message_input = new_input; + app.cursor_position += 1; + + // Отправляем typing status с throttling (не чаще 1 раза в 5 сек) + let should_send_typing = app + .last_typing_sent + .map(|t| t.elapsed().as_secs() >= 5) + .unwrap_or(true); + if should_send_typing { + if let Some(chat_id) = app.get_selected_chat_id() { + app.td_client + .send_chat_action(ChatId::new(chat_id), ChatAction::Typing) + .await; + app.last_typing_sent = Some(Instant::now()); + } + } + } + KeyCode::Left => { + // Курсор влево + if app.cursor_position > 0 { + app.cursor_position -= 1; + } + } + KeyCode::Right => { + // Курсор вправо + let len = app.message_input.chars().count(); + if app.cursor_position < len { + app.cursor_position += 1; + } + } + KeyCode::Home => { + // Курсор в начало + app.cursor_position = 0; + } + KeyCode::End => { + // Курсор в конец + app.cursor_position = app.message_input.chars().count(); + } + // Стрелки вверх/вниз - скролл сообщений (в Insert mode) + KeyCode::Down => { + if app.message_scroll_offset > 0 { + app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3); + } + } + KeyCode::Up => { + // В Insert mode — только скролл + app.message_scroll_offset += 3; + load_older_messages_if_needed(app).await; + } + _ => {} + } +} + +/// Обработка команды ViewImage — только фото +async fn handle_view_or_play_media(app: &mut App) { + let Some(msg) = app.get_selected_message() else { + return; + }; + + if msg.has_photo() { + #[cfg(feature = "images")] + handle_view_image(app).await; + #[cfg(not(feature = "images"))] + { + app.status_message = Some("Просмотр изображений отключён".to_string()); + } + } else { + app.status_message = Some("Сообщение не содержит фото".to_string()); + } +} + +/// Space: play/pause toggle для голосовых сообщений +async fn handle_toggle_voice_playback(app: &mut App) { + use crate::tdlib::PlaybackStatus; + + // Если уже есть активное воспроизведение — toggle pause/resume + if let Some(ref mut playback) = app.playback_state { + if let Some(ref player) = app.audio_player { + match playback.status { + PlaybackStatus::Playing => { + player.pause(); + playback.status = PlaybackStatus::Paused; + app.last_playback_tick = None; + app.status_message = Some("⏸ Пауза".to_string()); + } + PlaybackStatus::Paused => { + // Откатываем на 1 секунду для контекста + let resume_pos = (playback.position - 1.0).max(0.0); + // Перезапускаем ffplay с нужной позиции (-ss) + if player.resume_from(resume_pos).is_ok() { + playback.position = resume_pos; + } else { + // Fallback: простой SIGCONT без перемотки + player.resume(); + } + playback.status = PlaybackStatus::Playing; + app.last_playback_tick = Some(Instant::now()); + app.status_message = Some("▶ Воспроизведение".to_string()); + } + _ => {} + } + app.needs_redraw = true; + } + return; + } + + // Нет активного воспроизведения — пробуем запустить текущее голосовое + let Some(msg) = app.get_selected_message() else { + return; + }; + if msg.has_voice() { + handle_play_voice(app).await; + } +} + +/// Seek голосового сообщения на delta секунд +fn handle_voice_seek(app: &mut App, delta: f32) { + use crate::tdlib::PlaybackStatus; + + let Some(ref mut playback) = app.playback_state else { + return; + }; + let Some(ref player) = app.audio_player else { + return; + }; + + let was_playing = matches!(playback.status, PlaybackStatus::Playing); + let was_paused = matches!(playback.status, PlaybackStatus::Paused); + + if was_playing || was_paused { + let new_position = (playback.position + delta).clamp(0.0, playback.duration); + + if was_playing { + // Перезапускаем ffplay с новой позиции + if player.resume_from(new_position).is_ok() { + playback.position = new_position; + app.last_playback_tick = Some(std::time::Instant::now()); + } + } else { + // На паузе — только двигаем позицию, воспроизведение начнётся при resume + player.stop(); + playback.position = new_position; + } + + let arrow = if delta > 0.0 { "→" } else { "←" }; + app.status_message = Some(format!("{} {:.0}s", arrow, new_position)); + app.needs_redraw = true; + } +} + +/// Обработка команды ViewImage — открыть модальное окно с фото +#[cfg(feature = "images")] +async fn handle_view_image(app: &mut App) { + use crate::tdlib::{ImageModalState, PhotoDownloadState}; + + if !app.config().images.show_images { + return; + } + + let Some(msg) = app.get_selected_message() else { + return; + }; + + if !msg.has_photo() { + app.status_message = Some("Сообщение не содержит фото".to_string()); + return; + } + + let photo = msg.photo_info().unwrap(); + let msg_id = msg.id(); + let file_id = photo.file_id; + let photo_width = photo.width; + let photo_height = photo.height; + let download_state = photo.download_state.clone(); + + match download_state { + PhotoDownloadState::Downloaded(path) => { + // Открываем модальное окно + app.image_modal = Some(ImageModalState { + message_id: msg_id, + photo_path: path, + photo_width, + photo_height, + }); + app.needs_redraw = true; + } + PhotoDownloadState::Downloading => { + app.status_message = Some("Загрузка фото...".to_string()); + } + PhotoDownloadState::NotDownloaded => { + // Скачиваем фото и открываем + app.status_message = Some("Загрузка фото...".to_string()); + app.needs_redraw = true; + match app.td_client.download_file(file_id).await { + Ok(path) => { + // Обновляем состояние загрузки в сообщении + for msg in app.td_client.current_chat_messages_mut() { + if let Some(photo) = msg.photo_info_mut() { + if photo.file_id == file_id { + photo.download_state = PhotoDownloadState::Downloaded(path.clone()); + break; + } + } + } + // Открываем модалку + app.image_modal = Some(ImageModalState { + message_id: msg_id, + photo_path: path, + photo_width, + photo_height, + }); + app.status_message = None; + } + Err(e) => { + for msg in app.td_client.current_chat_messages_mut() { + if let Some(photo) = msg.photo_info_mut() { + if photo.file_id == file_id { + photo.download_state = PhotoDownloadState::Error(e.clone()); + break; + } + } + } + app.error_message = Some(format!("Ошибка загрузки фото: {}", e)); + app.status_message = None; + } + } + } + PhotoDownloadState::Error(_) => { + // Повторная попытка загрузки + app.status_message = Some("Повторная загрузка фото...".to_string()); + app.needs_redraw = true; + match app.td_client.download_file(file_id).await { + Ok(path) => { + for msg in app.td_client.current_chat_messages_mut() { + if let Some(photo) = msg.photo_info_mut() { + if photo.file_id == file_id { + photo.download_state = PhotoDownloadState::Downloaded(path.clone()); + break; + } + } + } + app.image_modal = Some(ImageModalState { + message_id: msg_id, + photo_path: path, + photo_width, + photo_height, + }); + app.status_message = None; + } + Err(e) => { + app.error_message = Some(format!("Ошибка загрузки фото: {}", e)); + app.status_message = None; + } + } + } + } +} + +/// Вспомогательная функция для воспроизведения из конкретного пути +async fn handle_play_voice_from_path( + app: &mut App, + path: &str, + voice: &crate::tdlib::VoiceInfo, + msg: &crate::tdlib::MessageInfo, +) { + use crate::tdlib::{PlaybackState, PlaybackStatus}; + + if let Some(ref player) = app.audio_player { + match player.play(path) { + Ok(_) => { + app.playback_state = Some(PlaybackState { + message_id: msg.id(), + status: PlaybackStatus::Playing, + position: 0.0, + duration: voice.duration as f32, + volume: player.volume(), + }); + app.last_playback_tick = Some(Instant::now()); + app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration)); + app.needs_redraw = true; + } + Err(e) => { + app.error_message = Some(format!("Ошибка воспроизведения: {}", e)); + } + } + } else { + app.error_message = Some("Аудиоплеер не инициализирован".to_string()); + } +} + +/// Воспроизведение голосового сообщения +async fn handle_play_voice(app: &mut App) { + use crate::tdlib::VoiceDownloadState; + + let Some(msg) = app.get_selected_message() else { + return; + }; + + if !msg.has_voice() { + return; + } + + let voice = msg.voice_info().unwrap(); + let file_id = voice.file_id; + + match &voice.download_state { + VoiceDownloadState::Downloaded(path) => { + // TDLib может вернуть путь без расширения — ищем файл с .oga + use std::path::Path; + let audio_path = if Path::new(path).exists() { + path.clone() + } else { + // Пробуем добавить .oga + let with_oga = format!("{}.oga", path); + if Path::new(&with_oga).exists() { + with_oga + } else { + // Пробуем найти файл с похожим именем в той же папке + if let Some(parent) = Path::new(path).parent() { + if let Some(stem) = Path::new(path).file_name() { + if let Ok(entries) = std::fs::read_dir(parent) { + for entry in entries.flatten() { + let entry_name = entry.file_name(); + if entry_name + .to_string_lossy() + .starts_with(&stem.to_string_lossy().to_string()) + { + let found_path = entry.path().to_string_lossy().to_string(); + // Кэшируем найденный файл + if let Some(ref mut cache) = app.voice_cache { + let _ = cache.store( + &file_id.to_string(), + Path::new(&found_path), + ); + } + return handle_play_voice_from_path( + app, + &found_path, + voice, + &msg, + ) + .await; + } + } + } + } + } + app.error_message = Some(format!("Файл не найден: {}", path)); + return; + } + }; + + // Кэшируем файл если ещё не в кэше + if let Some(ref mut cache) = app.voice_cache { + let _ = cache.store(&file_id.to_string(), Path::new(&audio_path)); + } + + handle_play_voice_from_path(app, &audio_path, voice, &msg).await; + } + VoiceDownloadState::Downloading => { + app.status_message = Some("Загрузка голосового...".to_string()); + } + VoiceDownloadState::NotDownloaded => { + // Проверяем кэш перед загрузкой + let cache_key = file_id.to_string(); + if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) { + let path_str = cached_path.to_string_lossy().to_string(); + handle_play_voice_from_path(app, &path_str, voice, &msg).await; + return; + } + + // Начинаем загрузку + app.status_message = Some("Загрузка голосового...".to_string()); + match app.td_client.download_voice_note(file_id).await { + Ok(path) => { + // Кэшируем загруженный файл + if let Some(ref mut cache) = app.voice_cache { + let _ = cache.store(&cache_key, std::path::Path::new(&path)); + } + + handle_play_voice_from_path(app, &path, voice, &msg).await; + } + Err(e) => { + app.error_message = Some(format!("Ошибка загрузки: {}", e)); + } + } + } + VoiceDownloadState::Error(e) => { + app.error_message = Some(format!("Ошибка загрузки: {}", e)); + } + } +} + +// TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика +/* +#[cfg(feature = "images")] +fn collapse_photo(app: &mut App, msg_id: crate::types::MessageId) { + // Закомментировано - будет реализовано в Этапе 4 +} + +#[cfg(feature = "images")] +fn expand_photo(app: &mut App, msg_id: crate::types::MessageId, path: &str) { + // Закомментировано - будет реализовано в Этапе 4 +} +*/ + +// TODO (Этап 4): Функция _download_and_expand будет переписана +/* +#[cfg(feature = "images")] +async fn _download_and_expand(app: &mut App, msg_id: crate::types::MessageId, file_id: i32) { + // Закомментировано - будет реализовано в Этапе 4 +} +*/ diff --git a/src/input/handlers/chat_list.rs b/src/input/handlers/chat_list.rs new file mode 100644 index 0000000..6a747c3 --- /dev/null +++ b/src/input/handlers/chat_list.rs @@ -0,0 +1,140 @@ +//! Chat list input handlers +//! +//! Handles keyboard input for the chat list view, including: +//! - Navigation between chats +//! - Folder selection +//! - Opening chats + +use crate::app::methods::{ + compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods, +}; +use crate::app::App; +use crate::app::InputMode; +use crate::tdlib::TdClientTrait; +use crate::types::{ChatId, MessageId}; +use crate::utils::{with_timeout, with_timeout_msg}; +use crossterm::event::KeyEvent; +use std::time::Duration; + +/// Обработка навигации в списке чатов +/// +/// Обрабатывает: +/// - Up/Down/j/k: навигация между чатами +/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib) +pub async fn handle_chat_list_navigation( + app: &mut App, + _key: KeyEvent, + command: Option, +) { + match command { + Some(crate::config::Command::MoveDown) => { + app.next_chat(); + } + Some(crate::config::Command::MoveUp) => { + app.previous_chat(); + } + Some(crate::config::Command::SelectFolder1) => { + app.selected_folder_id = None; + app.chat_list_state.select(Some(0)); + } + Some(crate::config::Command::SelectFolder2) => { + select_folder(app, 0).await; + } + Some(crate::config::Command::SelectFolder3) => { + select_folder(app, 1).await; + } + Some(crate::config::Command::SelectFolder4) => { + select_folder(app, 2).await; + } + Some(crate::config::Command::SelectFolder5) => { + select_folder(app, 3).await; + } + Some(crate::config::Command::SelectFolder6) => { + select_folder(app, 4).await; + } + Some(crate::config::Command::SelectFolder7) => { + select_folder(app, 5).await; + } + Some(crate::config::Command::SelectFolder8) => { + select_folder(app, 6).await; + } + Some(crate::config::Command::SelectFolder9) => { + select_folder(app, 7).await; + } + _ => {} + } +} + +/// Выбирает папку по индексу и загружает её чаты +pub async fn select_folder(app: &mut App, folder_idx: usize) { + if let Some(folder) = app.td_client.folders().get(folder_idx) { + let folder_id = folder.id; + app.selected_folder_id = Some(folder_id); + app.status_message = Some("Загрузка чатов папки...".to_string()); + let _ = + with_timeout(Duration::from_secs(5), app.td_client.load_folder_chats(folder_id, 50)) + .await; + app.status_message = None; + app.chat_list_state.select(Some(0)); + } +} + +/// Открывает чат и загружает последние сообщения (быстро). +/// +/// Загружает только 50 последних сообщений для мгновенного отображения. +/// Фоновые задачи (reply info, pinned, photos) откладываются в `pending_chat_init` +/// и выполняются на следующем тике main loop. +/// +/// При ошибке устанавливает error_message и очищает status_message. +pub async fn open_chat_and_load_data(app: &mut App, chat_id: i64) { + app.status_message = Some("Загрузка сообщений...".to_string()); + app.message_scroll_offset = 0; + + // Загружаем только 50 последних сообщений (один запрос к TDLib) + match with_timeout_msg( + Duration::from_secs(10), + app.td_client.get_chat_history(ChatId::new(chat_id), 50), + "Таймаут загрузки сообщений", + ) + .await + { + Ok(messages) => { + // Собираем ID всех входящих сообщений для отметки как прочитанные + let incoming_message_ids: Vec = messages + .iter() + .filter(|msg| !msg.is_outgoing()) + .map(|msg| msg.id()) + .collect(); + + // Сохраняем загруженные сообщения + app.td_client.set_current_chat_messages(messages); + + // Добавляем входящие сообщения в очередь для отметки как прочитанные + if !incoming_message_ids.is_empty() { + app.td_client + .pending_view_messages_mut() + .push((ChatId::new(chat_id), incoming_message_ids)); + } + + // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории + // Это предотвращает race condition с Update::NewMessage + app.td_client + .set_current_chat_id(Some(ChatId::new(chat_id))); + + // Загружаем черновик (локальная операция, мгновенно) + app.load_draft(); + + // Показываем чат СРАЗУ + app.status_message = None; + app.input_mode = InputMode::Normal; + app.start_message_selection(); + + // Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop + app.pending_chat_init = Some(ChatId::new(chat_id)); + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + } + } +} diff --git a/src/input/handlers/compose.rs b/src/input/handlers/compose.rs new file mode 100644 index 0000000..86af305 --- /dev/null +++ b/src/input/handlers/compose.rs @@ -0,0 +1,85 @@ +//! Compose input handlers +//! +//! Handles text input and message composition, including: +//! - Forward mode +//! - Reply mode +//! - Edit mode +//! - Cursor movement and text editing + +use crate::app::methods::{ + compose::ComposeMethods, navigation::NavigationMethods, search::SearchMethods, +}; +use crate::app::App; +use crate::tdlib::TdClientTrait; +use crate::types::ChatId; +use crate::utils::with_timeout_msg; +use crossterm::event::KeyEvent; +use std::time::Duration; + +/// Обработка режима выбора чата для пересылки сообщения +/// +/// Обрабатывает: +/// - Навигацию по списку чатов (Up/Down) +/// - Пересылку сообщения в выбранный чат (Enter) +/// - Отмену пересылки (Esc) +pub async fn handle_forward_mode( + app: &mut App, + _key: KeyEvent, + command: Option, +) { + match command { + Some(crate::config::Command::Cancel) => { + app.cancel_forward(); + } + Some(crate::config::Command::SubmitMessage) => { + forward_selected_message(app).await; + app.cancel_forward(); + } + Some(crate::config::Command::MoveDown) => { + app.next_chat(); + } + Some(crate::config::Command::MoveUp) => { + app.previous_chat(); + } + _ => {} + } +} + +/// Пересылает выбранное сообщение в выбранный чат +pub async fn forward_selected_message(app: &mut App) { + // Get all required IDs with early returns + let filtered = app.get_filtered_chats(); + let Some(i) = app.chat_list_state.selected() else { + return; + }; + let Some(chat) = filtered.get(i) else { + return; + }; + let to_chat_id = chat.id; + + let Some(msg_id) = app.chat_state.selected_message_id() else { + return; + }; + let Some(from_chat_id) = app.get_selected_chat_id() else { + return; + }; + + // Forward the message with timeout + let result = with_timeout_msg( + Duration::from_secs(5), + app.td_client + .forward_messages(to_chat_id, ChatId::new(from_chat_id), vec![msg_id]), + "Таймаут пересылки", + ) + .await; + + // Handle result + match result { + Ok(_) => { + app.status_message = Some("Сообщение переслано".to_string()); + } + Err(e) => { + app.error_message = Some(e); + } + } +} diff --git a/src/input/handlers/global.rs b/src/input/handlers/global.rs index 0bc9a99..23d935d 100644 --- a/src/input/handlers/global.rs +++ b/src/input/handlers/global.rs @@ -6,6 +6,7 @@ //! - Ctrl+P: View pinned messages //! - Ctrl+F: Search messages in chat +use crate::app::methods::{modal::ModalMethods, search::SearchMethods}; use crate::app::App; use crate::tdlib::TdClientTrait; use crate::types::ChatId; @@ -46,7 +47,8 @@ pub async fn handle_global_commands(app: &mut App, key: Key KeyCode::Char('r') if has_ctrl => { // Ctrl+R - обновить список чатов app.status_message = Some("Обновление чатов...".to_string()); - let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; + let _ = + with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; // Синхронизируем muted чаты после обновления app.td_client.sync_notification_muted_chats(); app.status_message = None; @@ -57,6 +59,11 @@ pub async fn handle_global_commands(app: &mut App, key: Key handle_pinned_messages(app).await; true } + KeyCode::Char('a') if has_ctrl => { + // Ctrl+A - переключение аккаунтов + app.open_account_switcher(); + true + } _ => false, } } diff --git a/src/input/handlers/mod.rs b/src/input/handlers/mod.rs index 3b1a0b0..998a4ac 100644 --- a/src/input/handlers/mod.rs +++ b/src/input/handlers/mod.rs @@ -4,11 +4,39 @@ //! - global: Global commands (Ctrl+R, Ctrl+S, etc.) //! - clipboard: Clipboard operations //! - profile: Profile helper functions +//! - chat: Keyboard input handling for open chat view +//! - chat_list: Navigation and interaction in the chat list +//! - compose: Text input, editing, and message composition +//! - modal: Modal dialogs (delete confirmation, emoji picker, etc.) +//! - search: Search functionality (chat search, message search) +pub mod chat; +pub mod chat_list; pub mod clipboard; +pub mod compose; pub mod global; +pub mod modal; pub mod profile; +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(app: &mut App, 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); + } +} diff --git a/src/input/handlers/modal.rs b/src/input/handlers/modal.rs new file mode 100644 index 0000000..6820250 --- /dev/null +++ b/src/input/handlers/modal.rs @@ -0,0 +1,404 @@ +//! Modal dialog handlers +//! +//! Handles keyboard input for modal dialogs, including: +//! - Account switcher (global overlay) +//! - Delete confirmation +//! - Reaction picker (emoji selector) +//! - Pinned messages view +//! - Profile information modal + +use super::scroll_to_message; +use crate::app::methods::{modal::ModalMethods, navigation::NavigationMethods}; +use crate::app::{AccountSwitcherState, App}; +use crate::input::handlers::get_available_actions_count; +use crate::tdlib::TdClientTrait; +use crate::types::{ChatId, MessageId}; +use crate::utils::{modal_handler::handle_yes_no, with_timeout_msg}; +use crossterm::event::{KeyCode, KeyEvent}; +use std::time::Duration; + +/// Обработка ввода в модалке переключения аккаунтов +/// +/// **SelectAccount mode:** +/// - j/k (MoveUp/MoveDown) — навигация по списку +/// - Enter — выбор аккаунта или переход к добавлению +/// - a/ф — быстрое добавление аккаунта +/// - Esc — закрыть модалку +/// +/// **AddAccount mode:** +/// - Char input → ввод имени +/// - Backspace → удалить символ +/// - Enter → создать аккаунт +/// - Esc → назад к списку +pub async fn handle_account_switcher( + app: &mut App, + key: KeyEvent, + command: Option, +) { + let Some(state) = &app.account_switcher else { + return; + }; + + match state { + AccountSwitcherState::SelectAccount { .. } => { + match command { + Some(crate::config::Command::MoveUp) => { + app.account_switcher_select_prev(); + } + Some(crate::config::Command::MoveDown) => { + app.account_switcher_select_next(); + } + Some(crate::config::Command::SubmitMessage) => { + app.account_switcher_confirm(); + } + Some(crate::config::Command::Cancel) => { + app.close_account_switcher(); + } + _ => { + // Raw key check for 'a'/'ф' shortcut + match key.code { + KeyCode::Char('a') | KeyCode::Char('ф') => { + app.account_switcher_start_add(); + } + _ => {} + } + } + } + } + AccountSwitcherState::AddAccount { .. } => match key.code { + KeyCode::Esc => { + app.account_switcher_back(); + } + KeyCode::Enter => { + app.account_switcher_confirm_add(); + } + KeyCode::Backspace => { + if let Some(AccountSwitcherState::AddAccount { + name_input, + cursor_position, + error, + }) = &mut app.account_switcher + { + if *cursor_position > 0 { + let mut chars: Vec = name_input.chars().collect(); + chars.remove(*cursor_position - 1); + *name_input = chars.into_iter().collect(); + *cursor_position -= 1; + *error = None; + } + } + } + KeyCode::Char(c) => { + if let Some(AccountSwitcherState::AddAccount { + name_input, + cursor_position, + error, + }) = &mut app.account_switcher + { + let mut chars: Vec = name_input.chars().collect(); + chars.insert(*cursor_position, c); + *name_input = chars.into_iter().collect(); + *cursor_position += 1; + *error = None; + } + } + _ => {} + }, + } +} + +/// Обработка режима профиля пользователя/чата +/// +/// Обрабатывает: +/// - Модалку подтверждения выхода из группы (двухшаговая) +/// - Навигацию по действиям профиля (Up/Down) +/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу +/// - Выход из режима профиля (Esc) +pub async fn handle_profile_mode( + app: &mut App, + key: KeyEvent, + command: Option, +) { + // Обработка подтверждения выхода из группы + let confirmation_step = app.get_leave_group_confirmation_step(); + if confirmation_step > 0 { + match handle_yes_no(key.code) { + Some(true) => { + // Подтверждение + if confirmation_step == 1 { + // Первое подтверждение - показываем второе + app.show_leave_group_final_confirmation(); + } else if confirmation_step == 2 { + // Второе подтверждение - выходим из группы + if let Some(chat_id) = app.selected_chat_id { + let leave_result = app.td_client.leave_chat(chat_id).await; + match leave_result { + Ok(_) => { + app.status_message = Some("Вы вышли из группы".to_string()); + app.exit_profile_mode(); + app.close_chat(); + } + Err(e) => { + app.error_message = Some(e); + app.cancel_leave_group(); + } + } + } + } + } + Some(false) => { + // Отмена + app.cancel_leave_group(); + } + None => { + // Другая клавиша - игнорируем + } + } + return; + } + + // Обычная навигация по профилю + match command { + Some(crate::config::Command::Cancel) => { + app.exit_profile_mode(); + } + Some(crate::config::Command::MoveUp) => { + app.select_previous_profile_action(); + } + Some(crate::config::Command::MoveDown) => { + if let Some(profile) = app.get_profile_info() { + let max_actions = get_available_actions_count(profile); + app.select_next_profile_action(max_actions); + } + } + Some(crate::config::Command::SubmitMessage) => { + // Выполнить выбранное действие + let Some(profile) = app.get_profile_info() else { + return; + }; + + let actions = get_available_actions_count(profile); + let action_index = app.get_selected_profile_action().unwrap_or(0); + + // Guard: проверяем, что индекс действия валидный + if action_index >= actions { + return; + } + + // Определяем какое действие выбрано + let mut current_idx = 0; + + // Действие: Открыть в браузере + if let Some(username) = &profile.username { + if action_index == current_idx { + let url = format!("https://t.me/{}", username.trim_start_matches('@')); + #[cfg(feature = "url-open")] + { + match open::that(&url) { + Ok(_) => { + app.status_message = Some(format!("Открыто: {}", url)); + } + Err(e) => { + app.error_message = + Some(format!("Ошибка открытия браузера: {}", e)); + } + } + } + #[cfg(not(feature = "url-open"))] + { + app.error_message = Some( + "Открытие URL недоступно (требуется feature 'url-open')".to_string(), + ); + } + return; + } + current_idx += 1; + } + + // Действие: Скопировать ID + if action_index == current_idx { + app.status_message = Some(format!("ID скопирован: {}", profile.chat_id)); + return; + } + current_idx += 1; + + // Действие: Покинуть группу + if profile.is_group && action_index == current_idx { + app.show_leave_group_confirmation(); + } + } + _ => {} + } +} + +/// Обработка Ctrl+U для открытия профиля чата/пользователя +/// +/// Загружает информацию о профиле и переключает в режим просмотра профиля +pub async fn handle_profile_open(app: &mut App) { + let Some(chat_id) = app.selected_chat_id else { + return; + }; + + app.status_message = Some("Загрузка профиля...".to_string()); + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.get_profile_info(chat_id), + "Таймаут загрузки профиля", + ) + .await + { + Ok(profile) => { + app.enter_profile_mode(profile); + app.status_message = None; + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + } + } +} + +/// Обработка модалки подтверждения удаления сообщения +/// +/// Обрабатывает: +/// - Подтверждение удаления (Y/y/Д/д) +/// - Отмена удаления (N/n/Т/т) +/// - Удаление для себя или для всех (зависит от can_be_deleted_for_all_users) +pub async fn handle_delete_confirmation(app: &mut App, key: KeyEvent) { + match handle_yes_no(key.code) { + Some(true) => { + // Подтверждение удаления + if let Some(msg_id) = app.chat_state.selected_message_id() { + if let Some(chat_id) = app.get_selected_chat_id() { + // Находим сообщение для проверки can_be_deleted_for_all_users + let can_delete_for_all = app + .td_client + .current_chat_messages() + .iter() + .find(|m| m.id() == msg_id) + .map(|m| m.can_be_deleted_for_all_users()) + .unwrap_or(false); + + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.delete_messages( + ChatId::new(chat_id), + vec![msg_id], + can_delete_for_all, + ), + "Таймаут удаления", + ) + .await + { + Ok(_) => { + // Удаляем из локального списка + app.td_client + .current_chat_messages_mut() + .retain(|m| m.id() != msg_id); + // Сбрасываем состояние + app.chat_state = crate::app::ChatState::Normal; + } + Err(e) => { + app.error_message = Some(e); + } + } + } + } + // Закрываем модалку + app.chat_state = crate::app::ChatState::Normal; + } + Some(false) => { + // Отмена удаления + app.chat_state = crate::app::ChatState::Normal; + } + None => { + // Другая клавиша - игнорируем + } + } +} + +/// Обработка режима выбора реакции (emoji picker) +/// +/// Обрабатывает: +/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6) +/// - Добавление/удаление реакции (Enter) +/// - Выход из режима (Esc) +pub async fn handle_reaction_picker_mode( + app: &mut App, + _key: KeyEvent, + command: Option, +) { + match command { + Some(crate::config::Command::MoveLeft) => { + app.select_previous_reaction(); + app.needs_redraw = true; + } + Some(crate::config::Command::MoveRight) => { + app.select_next_reaction(); + app.needs_redraw = true; + } + Some(crate::config::Command::MoveUp) => { + if let crate::app::ChatState::ReactionPicker { selected_index, .. } = + &mut app.chat_state + { + if *selected_index >= 8 { + *selected_index = selected_index.saturating_sub(8); + app.needs_redraw = true; + } + } + } + Some(crate::config::Command::MoveDown) => { + if let crate::app::ChatState::ReactionPicker { + selected_index, + available_reactions, + .. + } = &mut app.chat_state + { + let new_index = *selected_index + 8; + if new_index < available_reactions.len() { + *selected_index = new_index; + app.needs_redraw = true; + } + } + } + Some(crate::config::Command::SubmitMessage) => { + super::chat::send_reaction(app).await; + } + Some(crate::config::Command::Cancel) => { + app.exit_reaction_picker_mode(); + app.needs_redraw = true; + } + _ => {} + } +} + +/// Обработка режима просмотра закреплённых сообщений +/// +/// Обрабатывает: +/// - Навигацию по закреплённым сообщениям (Up/Down) +/// - Переход к сообщению в истории (Enter) +/// - Выход из режима (Esc) +pub async fn handle_pinned_mode( + app: &mut App, + _key: KeyEvent, + command: Option, +) { + match command { + Some(crate::config::Command::Cancel) => { + app.exit_pinned_mode(); + } + Some(crate::config::Command::MoveUp) => { + app.select_previous_pinned(); + } + Some(crate::config::Command::MoveDown) => { + app.select_next_pinned(); + } + Some(crate::config::Command::SubmitMessage) => { + if let Some(msg_id) = app.get_selected_pinned_id() { + scroll_to_message(app, MessageId::new(msg_id)); + app.exit_pinned_mode(); + } + } + _ => {} + } +} diff --git a/src/input/handlers/search.rs b/src/input/handlers/search.rs new file mode 100644 index 0000000..1bb151c --- /dev/null +++ b/src/input/handlers/search.rs @@ -0,0 +1,136 @@ +//! Search input handlers +//! +//! Handles keyboard input for search functionality, including: +//! - Chat list search mode +//! - Message search mode +//! - Search query input + +use crate::app::methods::{navigation::NavigationMethods, search::SearchMethods}; +use crate::app::App; +use crate::tdlib::TdClientTrait; +use crate::types::{ChatId, MessageId}; +use crate::utils::with_timeout; +use crossterm::event::{KeyCode, KeyEvent}; +use std::time::Duration; + +use super::chat_list::open_chat_and_load_data; +use super::scroll_to_message; + +/// Обработка режима поиска по чатам +/// +/// Обрабатывает: +/// - Редактирование поискового запроса (Backspace, Char) +/// - Навигацию по отфильтрованному списку (Up/Down) +/// - Открытие выбранного чата (Enter) +/// - Отмену поиска (Esc) +pub async fn handle_chat_search_mode( + app: &mut App, + key: KeyEvent, + command: Option, +) { + match command { + Some(crate::config::Command::Cancel) => { + app.cancel_search(); + } + Some(crate::config::Command::SubmitMessage) => { + app.select_filtered_chat(); + if let Some(chat_id) = app.get_selected_chat_id() { + open_chat_and_load_data(app, chat_id).await; + } + } + Some(crate::config::Command::MoveDown) => { + app.next_filtered_chat(); + } + Some(crate::config::Command::MoveUp) => { + app.previous_filtered_chat(); + } + _ => match key.code { + KeyCode::Backspace => { + app.search_query.pop(); + app.chat_list_state.select(Some(0)); + } + KeyCode::Char(c) => { + app.search_query.push(c); + app.chat_list_state.select(Some(0)); + } + _ => {} + }, + } +} + +/// Обработка режима поиска по сообщениям в открытом чате +/// +/// Обрабатывает: +/// - Навигацию по результатам поиска (Up/Down/N/n) +/// - Переход к выбранному сообщению (Enter) +/// - Редактирование поискового запроса (Backspace, Char) +/// - Выход из режима поиска (Esc) +pub async fn handle_message_search_mode( + app: &mut App, + key: KeyEvent, + command: Option, +) { + match command { + Some(crate::config::Command::Cancel) => { + app.exit_message_search_mode(); + } + Some(crate::config::Command::MoveUp) => { + app.select_previous_search_result(); + } + Some(crate::config::Command::MoveDown) => { + app.select_next_search_result(); + } + Some(crate::config::Command::SubmitMessage) => { + if let Some(msg_id) = app.get_selected_search_result_id() { + scroll_to_message(app, MessageId::new(msg_id)); + app.exit_message_search_mode(); + } + } + _ => match key.code { + KeyCode::Char('N') => { + app.select_previous_search_result(); + } + KeyCode::Char('n') => { + app.select_next_search_result(); + } + KeyCode::Backspace => { + let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else { + return; + }; + query.pop(); + app.update_search_query(query.clone()); + perform_message_search(app, &query).await; + } + KeyCode::Char(c) => { + let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else { + return; + }; + query.push(c); + app.update_search_query(query.clone()); + perform_message_search(app, &query).await; + } + _ => {} + }, + } +} + +/// Выполняет поиск по сообщениям с обновлением результатов +pub async fn perform_message_search(app: &mut App, query: &str) { + let Some(chat_id) = app.get_selected_chat_id() else { + return; + }; + + if query.is_empty() { + app.set_search_results(Vec::new()); + return; + } + + if let Ok(results) = with_timeout( + Duration::from_secs(3), + app.td_client.search_messages(ChatId::new(chat_id), query), + ) + .await + { + app.set_search_results(results); + } +} diff --git a/src/input/main_input.rs b/src/input/main_input.rs index c178639..62e3d3e 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -1,280 +1,37 @@ -use crate::app::App; -use crate::tdlib::TdClientTrait; -use crate::input::handlers::{ - copy_to_clipboard, format_message_for_clipboard, get_available_actions_count, - handle_global_commands, +//! 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::methods::{ + compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods, + navigation::NavigationMethods, search::SearchMethods, }; -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 crate::app::App; +use crate::app::InputMode; +use crate::input::handlers::{ + chat::{handle_enter_key, handle_message_selection, handle_open_chat_keyboard_input}, + chat_list::handle_chat_list_navigation, + compose::handle_forward_mode, + handle_global_commands, + modal::{ + handle_account_switcher, handle_delete_confirmation, handle_pinned_mode, + handle_profile_mode, handle_profile_open, handle_reaction_picker_mode, + }, + search::{handle_chat_search_mode, handle_message_search_mode}, +}; +use crate::tdlib::TdClientTrait; +use crossterm::event::KeyEvent; -/// Обработка режима профиля пользователя/чата -/// -/// Обрабатывает: -/// - Модалку подтверждения выхода из группы (двухшаговая) -/// - Навигацию по действиям профиля (Up/Down) -/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу -/// - Выход из режима профиля (Esc) -async fn handle_profile_mode(app: &mut App, key: KeyEvent, command: Option) { - // Обработка подтверждения выхода из группы - let confirmation_step = app.get_leave_group_confirmation_step(); - if confirmation_step > 0 { - match handle_yes_no(key.code) { - Some(true) => { - // Подтверждение - if confirmation_step == 1 { - // Первое подтверждение - показываем второе - app.show_leave_group_final_confirmation(); - } else if confirmation_step == 2 { - // Второе подтверждение - выходим из группы - if let Some(chat_id) = app.selected_chat_id { - let leave_result = app.td_client.leave_chat(chat_id).await; - match leave_result { - Ok(_) => { - app.status_message = Some("Вы вышли из группы".to_string()); - app.exit_profile_mode(); - app.close_chat(); - } - Err(e) => { - app.error_message = Some(e); - app.cancel_leave_group(); - } - } - } - } - } - Some(false) => { - // Отмена - app.cancel_leave_group(); - } - None => { - // Другая клавиша - игнорируем - } - } - return; - } - - // Обычная навигация по профилю - match command { - Some(crate::config::Command::Cancel) => { - app.exit_profile_mode(); - } - Some(crate::config::Command::MoveUp) => { - app.select_previous_profile_action(); - } - Some(crate::config::Command::MoveDown) => { - if let Some(profile) = app.get_profile_info() { - let max_actions = get_available_actions_count(profile); - app.select_next_profile_action(max_actions); - } - } - Some(crate::config::Command::SubmitMessage) => { - // Выполнить выбранное действие - let Some(profile) = app.get_profile_info() else { - return; - }; - - let actions = get_available_actions_count(profile); - let action_index = app.get_selected_profile_action().unwrap_or(0); - - // Guard: проверяем, что индекс действия валидный - if action_index >= actions { - return; - } - - // Определяем какое действие выбрано - let mut current_idx = 0; - - // Действие: Открыть в браузере - if let Some(username) = &profile.username { - if action_index == current_idx { - let url = format!( - "https://t.me/{}", - username.trim_start_matches('@') - ); - #[cfg(feature = "url-open")] - { - match open::that(&url) { - Ok(_) => { - app.status_message = Some(format!("Открыто: {}", url)); - } - Err(e) => { - app.error_message = - Some(format!("Ошибка открытия браузера: {}", e)); - } - } - } - #[cfg(not(feature = "url-open"))] - { - app.error_message = Some( - "Открытие URL недоступно (требуется feature 'url-open')".to_string() - ); - } - return; - } - current_idx += 1; - } - - // Действие: Скопировать ID - if action_index == current_idx { - app.status_message = Some(format!("ID скопирован: {}", profile.chat_id)); - return; - } - current_idx += 1; - - // Действие: Покинуть группу - if profile.is_group && action_index == current_idx { - app.show_leave_group_confirmation(); - } - } - _ => {} - } -} - -/// Обработка Ctrl+U для открытия профиля чата/пользователя -/// -/// Загружает информацию о профиле и переключает в режим просмотра профиля -async fn handle_profile_open(app: &mut App) { - let Some(chat_id) = app.selected_chat_id else { - return; - }; - - app.status_message = Some("Загрузка профиля...".to_string()); - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.get_profile_info(chat_id), - "Таймаут загрузки профиля", - ) - .await - { - Ok(profile) => { - app.enter_profile_mode(profile); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } -} - -/// Обработка режима выбора сообщения для действий -/// -/// Обрабатывает: -/// - Навигацию по сообщениям (Up/Down) -/// - Удаление сообщения (d/в/Delete) -/// - Ответ на сообщение (r/к) -/// - Пересылку сообщения (f/а) -/// - Копирование сообщения (y/н) -/// - Добавление реакции (e/у) -async fn handle_message_selection(app: &mut App, _key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::MoveUp) => { - app.select_previous_message(); - } - Some(crate::config::Command::MoveDown) => { - app.select_next_message(); - } - Some(crate::config::Command::DeleteMessage) => { - let Some(msg) = app.get_selected_message() else { - return; - }; - let can_delete = - msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users(); - if can_delete { - app.chat_state = crate::app::ChatState::DeleteConfirmation { - message_id: msg.id(), - }; - } - } - Some(crate::config::Command::ReplyMessage) => { - app.start_reply_to_selected(); - } - Some(crate::config::Command::ForwardMessage) => { - app.start_forward_selected(); - } - Some(crate::config::Command::CopyMessage) => { - let Some(msg) = app.get_selected_message() else { - return; - }; - let text = format_message_for_clipboard(&msg); - match copy_to_clipboard(&text) { - Ok(_) => { - app.status_message = Some("Сообщение скопировано".to_string()); - } - Err(e) => { - app.error_message = Some(format!("Ошибка копирования: {}", e)); - } - } - } - Some(crate::config::Command::ReactMessage) => { - let Some(msg) = app.get_selected_message() else { - return; - }; - let chat_id = app.selected_chat_id.unwrap(); - let message_id = msg.id(); - - app.status_message = Some("Загрузка реакций...".to_string()); - app.needs_redraw = true; - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client - .get_message_available_reactions(chat_id, message_id), - "Таймаут загрузки реакций", - ) - .await - { - Ok(reactions) => { - let reactions: Vec = reactions; - if reactions.is_empty() { - app.error_message = - Some("Реакции недоступны для этого сообщения".to_string()); - app.status_message = None; - app.needs_redraw = true; - } else { - app.enter_reaction_picker_mode(message_id.as_i64(), reactions); - app.status_message = None; - app.needs_redraw = true; - } - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - app.needs_redraw = true; - } - } - } - _ => {} - } -} - -/// Обработка клавиши Esc -/// -/// Обрабатывает отмену текущего действия или закрытие чата: -/// - В режиме выбора сообщения: отменить выбор -/// - В режиме редактирования: отменить редактирование -/// - В режиме ответа: отменить ответ -/// - В открытом чате: сохранить черновик и закрыть чат -async fn handle_escape_key(app: &mut App) { - // Early return для режима выбора сообщения - if app.is_selecting_message() { - app.chat_state = crate::app::ChatState::Normal; - return; - } - - // Early return для режима редактирования - if app.is_editing() { - app.cancel_editing(); - return; - } - - // Early return для режима ответа - if app.is_replying() { - app.cancel_reply(); +/// Обработка клавиши Esc в Normal mode +/// +/// Закрывает чат с сохранением черновика +async fn handle_escape_normal(app: &mut App) { + // Закрываем модальное окно изображения если открыто + #[cfg(feature = "images")] + if app.image_modal.is_some() { + app.image_modal = None; + app.needs_redraw = true; return; } @@ -284,827 +41,189 @@ async fn handle_escape_key(app: &mut App) { }; // Сохраняем черновик если есть текст в инпуте - if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() { + if !app.message_input.is_empty() { let draft_text = app.message_input.clone(); let _ = app.td_client.set_draft_message(chat_id, draft_text).await; - } else if app.message_input.is_empty() { + } else { // Очищаем черновик если инпут пустой - let _ = app.td_client.set_draft_message(chat_id, String::new()).await; + let _ = app + .td_client + .set_draft_message(chat_id, String::new()) + .await; } app.close_chat(); } -/// Редактирование существующего сообщения -async fn edit_message(app: &mut App, chat_id: i64, msg_id: MessageId, text: String) { - // Проверяем, что сообщение есть в локальном кэше - let msg_exists = app.td_client.current_chat_messages() - .iter() - .any(|m| m.id() == msg_id); - - if !msg_exists { - app.error_message = Some(format!( - "Сообщение {} не найдено в кэше чата {}", - msg_id.as_i64(), chat_id - )); - app.chat_state = crate::app::ChatState::Normal; - app.message_input.clear(); - app.cursor_position = 0; - return; - } - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.edit_message(ChatId::new(chat_id), msg_id, text), - "Таймаут редактирования", - ) - .await - { - Ok(mut edited_msg) => { - // Сохраняем reply_to из старого сообщения (если есть) - let messages = app.td_client.current_chat_messages_mut(); - if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) { - let old_reply_to = messages[pos].interactions.reply_to.clone(); - // Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый - if let Some(old_reply) = old_reply_to { - if edited_msg.interactions.reply_to.as_ref() - .map_or(true, |r| r.sender_name == "Unknown") { - edited_msg.interactions.reply_to = Some(old_reply); - } - } - // Заменяем сообщение - messages[pos] = edited_msg; - } - // Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования - app.message_input.clear(); - app.cursor_position = 0; - app.chat_state = crate::app::ChatState::Normal; - app.needs_redraw = true; - } - Err(e) => { - app.error_message = Some(e); - } - } -} - -/// Отправка нового сообщения (с опциональным reply) -async fn send_new_message(app: &mut App, chat_id: i64, text: String) { - let reply_to_id = if app.is_replying() { - app.chat_state.selected_message_id() - } else { - None - }; - - // Создаём ReplyInfo ДО отправки, пока сообщение точно доступно - let reply_info = app.get_replying_to_message().map(|m| { - crate::tdlib::ReplyInfo { - message_id: m.id(), - sender_name: m.sender_name().to_string(), - text: m.text().to_string(), - } - }); - - app.message_input.clear(); - app.cursor_position = 0; - // Сбрасываем режим reply если он был активен - if app.is_replying() { - app.chat_state = crate::app::ChatState::Normal; - } - app.last_typing_sent = None; - - // Отменяем typing status - app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel).await; - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info), - "Таймаут отправки", - ) - .await - { - Ok(sent_msg) => { - // Добавляем отправленное сообщение в список (с лимитом) - app.td_client.push_message(sent_msg); - // Сбрасываем скролл чтобы видеть новое сообщение - app.message_scroll_offset = 0; - } - Err(e) => { - app.error_message = Some(e); - } - } -} - -/// Обработка клавиши Enter -/// -/// Обрабатывает три сценария: -/// 1. В режиме выбора сообщения: начать редактирование -/// 2. В открытом чате: отправить новое или редактировать существующее сообщение -/// 3. В списке чатов: открыть выбранный чат -async fn handle_enter_key(app: &mut App) { - // Сценарий 1: Открытие чата из списка - if app.selected_chat_id.is_none() { - let prev_selected = app.selected_chat_id; - app.select_current_chat(); - - if app.selected_chat_id != prev_selected { - if let Some(chat_id) = app.get_selected_chat_id() { - open_chat_and_load_data(app, chat_id).await; - } - } - return; - } - - // Сценарий 2: Режим выбора сообщения - начать редактирование - if app.is_selecting_message() { - if !app.start_editing_selected() { - // Нельзя редактировать это сообщение - app.chat_state = crate::app::ChatState::Normal; - } - return; - } - - // Сценарий 3: Отправка или редактирование сообщения - if !is_non_empty(&app.message_input) { - return; - } - - let Some(chat_id) = app.get_selected_chat_id() else { - return; - }; - - let text = app.message_input.clone(); - - if app.is_editing() { - // Редактирование существующего сообщения - if let Some(msg_id) = app.chat_state.selected_message_id() { - edit_message(app, chat_id, msg_id, text).await; - } - } else { - // Отправка нового сообщения - send_new_message(app, chat_id, text).await; - } -} - -/// Обработка режима поиска по чатам -/// -/// Обрабатывает: -/// - Редактирование поискового запроса (Backspace, Char) -/// - Навигацию по отфильтрованному списку (Up/Down) -/// - Открытие выбранного чата (Enter) -/// - Отмену поиска (Esc) -async fn handle_chat_search_mode(app: &mut App, key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::Cancel) => { - app.cancel_search(); - } - Some(crate::config::Command::SubmitMessage) => { - app.select_filtered_chat(); - if let Some(chat_id) = app.get_selected_chat_id() { - open_chat_and_load_data(app, chat_id).await; - } - } - Some(crate::config::Command::MoveDown) => { - app.next_filtered_chat(); - } - Some(crate::config::Command::MoveUp) => { - app.previous_filtered_chat(); - } - _ => { - match key.code { - KeyCode::Backspace => { - app.search_query.pop(); - app.chat_list_state.select(Some(0)); - } - KeyCode::Char(c) => { - app.search_query.push(c); - app.chat_list_state.select(Some(0)); - } - _ => {} - } - } - } -} - -/// Обработка режима выбора чата для пересылки сообщения -/// -/// Обрабатывает: -/// - Навигацию по списку чатов (Up/Down) -/// - Пересылку сообщения в выбранный чат (Enter) -/// - Отмену пересылки (Esc) -async fn handle_forward_mode(app: &mut App, _key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::Cancel) => { - app.cancel_forward(); - } - Some(crate::config::Command::SubmitMessage) => { - forward_selected_message(app).await; - app.cancel_forward(); - } - Some(crate::config::Command::MoveDown) => { - app.next_chat(); - } - Some(crate::config::Command::MoveUp) => { - app.previous_chat(); - } - _ => {} - } -} - -/// Пересылает выбранное сообщение в выбранный чат -async fn forward_selected_message(app: &mut App) { - // Get all required IDs with early returns - let filtered = app.get_filtered_chats(); - let Some(i) = app.chat_list_state.selected() else { - return; - }; - let Some(chat) = filtered.get(i) else { - return; - }; - let to_chat_id = chat.id; - - let Some(msg_id) = app.chat_state.selected_message_id() else { - return; - }; - let Some(from_chat_id) = app.get_selected_chat_id() else { - return; - }; - - // Forward the message with timeout - let result = with_timeout_msg( - Duration::from_secs(5), - app.td_client.forward_messages( - to_chat_id, - ChatId::new(from_chat_id), - vec![msg_id], - ), - "Таймаут пересылки", - ) - .await; - - // Handle result - match result { - Ok(_) => { - app.status_message = Some("Сообщение переслано".to_string()); - } - Err(e) => { - app.error_message = Some(e); - } - } -} - -/// Отправляет реакцию на выбранное сообщение -async fn send_reaction(app: &mut App) { - // Get selected reaction emoji - let Some(emoji) = app.get_selected_reaction().cloned() else { - return; - }; - - // Get selected message ID - let Some(message_id) = app.get_selected_message_for_reaction() else { - return; - }; - - // Get chat ID - let Some(chat_id) = app.selected_chat_id else { - return; - }; - - let message_id = MessageId::new(message_id); - app.status_message = Some("Отправка реакции...".to_string()); - app.needs_redraw = true; - - // Send reaction with timeout - let result = with_timeout_msg( - Duration::from_secs(5), - app.td_client.toggle_reaction(chat_id, message_id, emoji.clone()), - "Таймаут отправки реакции", - ) - .await; - - // Handle result - match result { - Ok(_) => { - app.status_message = Some(format!("Реакция {} добавлена", emoji)); - app.exit_reaction_picker_mode(); - app.needs_redraw = true; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - app.needs_redraw = true; - } - } -} - -/// Подгружает старые сообщения если скролл близко к верху -async fn load_older_messages_if_needed(app: &mut App) { - // Check if there are messages to load from - if app.td_client.current_chat_messages().is_empty() { - return; - } - - // Get the oldest message ID - let oldest_msg_id = app - .td_client - .current_chat_messages() - .first() - .map(|m| m.id()) - .unwrap_or(MessageId::new(0)); - - // Get current chat ID - let Some(chat_id) = app.get_selected_chat_id() else { - return; - }; - - // Check if scroll is near the top - let message_count = app.td_client.current_chat_messages().len(); - if app.message_scroll_offset <= message_count.saturating_sub(10) { - return; - } - - // Load older messages with timeout - let Ok(older) = with_timeout( - Duration::from_secs(3), - app.td_client.load_older_messages(ChatId::new(chat_id), oldest_msg_id), - ) - .await - else { - return; - }; - - // Add older messages to the beginning if any were loaded - if !older.is_empty() { - let msgs = app.td_client.current_chat_messages_mut(); - msgs.splice(0..0, older); - } -} - -/// Обработка модалки подтверждения удаления сообщения +/// Обработка клавиши Esc в Insert mode /// -/// Обрабатывает: -/// - Подтверждение удаления (Y/y/Д/д) -/// - Отмена удаления (N/n/Т/т) -/// - Удаление для себя или для всех (зависит от can_be_deleted_for_all_users) -async fn handle_delete_confirmation(app: &mut App, key: KeyEvent) { - match handle_yes_no(key.code) { - Some(true) => { - // Подтверждение удаления - if let Some(msg_id) = app.chat_state.selected_message_id() { - if let Some(chat_id) = app.get_selected_chat_id() { - // Находим сообщение для проверки can_be_deleted_for_all_users - let can_delete_for_all = app - .td_client - .current_chat_messages() - .iter() - .find(|m| m.id() == msg_id) - .map(|m| m.can_be_deleted_for_all_users()) - .unwrap_or(false); - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.delete_messages( - ChatId::new(chat_id), - vec![msg_id], - can_delete_for_all, - ), - "Таймаут удаления", - ) - .await - { - Ok(_) => { - // Удаляем из локального списка - app.td_client - .current_chat_messages_mut() - .retain(|m| m.id() != msg_id); - // Сбрасываем состояние - app.chat_state = crate::app::ChatState::Normal; - } - Err(e) => { - app.error_message = Some(e); - } - } - } - } - // Закрываем модалку - app.chat_state = crate::app::ChatState::Normal; - } - Some(false) => { - // Отмена удаления - app.chat_state = crate::app::ChatState::Normal; - } - None => { - // Другая клавиша - игнорируем - } - } -} - -/// Обработка режима выбора реакции (emoji picker) -/// -/// Обрабатывает: -/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6) -/// - Добавление/удаление реакции (Enter) -/// - Выход из режима (Esc) -async fn handle_reaction_picker_mode(app: &mut App, _key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::MoveLeft) => { - app.select_previous_reaction(); - app.needs_redraw = true; - } - Some(crate::config::Command::MoveRight) => { - app.select_next_reaction(); - app.needs_redraw = true; - } - Some(crate::config::Command::MoveUp) => { - if let crate::app::ChatState::ReactionPicker { - selected_index, - .. - } = &mut app.chat_state - { - if *selected_index >= 8 { - *selected_index = selected_index.saturating_sub(8); - app.needs_redraw = true; - } - } - } - Some(crate::config::Command::MoveDown) => { - if let crate::app::ChatState::ReactionPicker { - selected_index, - available_reactions, - .. - } = &mut app.chat_state - { - let new_index = *selected_index + 8; - if new_index < available_reactions.len() { - *selected_index = new_index; - app.needs_redraw = true; - } - } - } - Some(crate::config::Command::SubmitMessage) => { - send_reaction(app).await; - } - Some(crate::config::Command::Cancel) => { - app.exit_reaction_picker_mode(); - app.needs_redraw = true; - } - _ => {} - } -} - -/// Обработка режима просмотра закреплённых сообщений -/// -/// Обрабатывает: -/// - Навигацию по закреплённым сообщениям (Up/Down) -/// - Переход к сообщению в истории (Enter) -/// - Выход из режима (Esc) -async fn handle_pinned_mode(app: &mut App, _key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::Cancel) => { - app.exit_pinned_mode(); - } - Some(crate::config::Command::MoveUp) => { - app.select_previous_pinned(); - } - Some(crate::config::Command::MoveDown) => { - app.select_next_pinned(); - } - 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); - } - app.exit_pinned_mode(); - } - } - _ => {} - } -} - -/// Выполняет поиск по сообщениям с обновлением результатов -async fn perform_message_search(app: &mut App, query: &str) { - let Some(chat_id) = app.get_selected_chat_id() else { - return; - }; - - if query.is_empty() { - app.set_search_results(Vec::new()); - return; - } - - if let Ok(results) = with_timeout( - Duration::from_secs(3), - app.td_client.search_messages(ChatId::new(chat_id), query), - ) - .await - { - app.set_search_results(results); - } -} - -/// Обработка режима поиска по сообщениям в открытом чате -/// -/// Обрабатывает: -/// - Навигацию по результатам поиска (Up/Down/N/n) -/// - Переход к выбранному сообщению (Enter) -/// - Редактирование поискового запроса (Backspace, Char) -/// - Выход из режима поиска (Esc) -async fn handle_message_search_mode(app: &mut App, key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::Cancel) => { - app.exit_message_search_mode(); - } - Some(crate::config::Command::MoveUp) => { - app.select_previous_search_result(); - } - Some(crate::config::Command::MoveDown) => { - app.select_next_search_result(); - } - 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); - } - app.exit_message_search_mode(); - } - } - _ => { - match key.code { - KeyCode::Char('N') => { - app.select_previous_search_result(); - } - KeyCode::Char('n') => { - app.select_next_search_result(); - } - KeyCode::Backspace => { - let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else { - return; - }; - query.pop(); - app.update_search_query(query.clone()); - perform_message_search(app, &query).await; - } - KeyCode::Char(c) => { - let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else { - return; - }; - query.push(c); - app.update_search_query(query.clone()); - perform_message_search(app, &query).await; - } - _ => {} - } - } - } -} - -/// Обработка навигации в списке чатов -/// -/// Обрабатывает: -/// - Up/Down/j/k: навигация между чатами -/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib) -async fn handle_chat_list_navigation(app: &mut App, _key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::MoveDown) => { - app.next_chat(); - } - Some(crate::config::Command::MoveUp) => { - app.previous_chat(); - } - Some(crate::config::Command::SelectFolder1) => { - app.selected_folder_id = None; - app.chat_list_state.select(Some(0)); - } - Some(crate::config::Command::SelectFolder2) => { - select_folder(app, 0).await; - } - Some(crate::config::Command::SelectFolder3) => { - select_folder(app, 1).await; - } - Some(crate::config::Command::SelectFolder4) => { - select_folder(app, 2).await; - } - Some(crate::config::Command::SelectFolder5) => { - select_folder(app, 3).await; - } - Some(crate::config::Command::SelectFolder6) => { - select_folder(app, 4).await; - } - Some(crate::config::Command::SelectFolder7) => { - select_folder(app, 5).await; - } - Some(crate::config::Command::SelectFolder8) => { - select_folder(app, 6).await; - } - Some(crate::config::Command::SelectFolder9) => { - select_folder(app, 7).await; - } - _ => {} - } -} - -async fn select_folder(app: &mut App, folder_idx: usize) { - if let Some(folder) = app.td_client.folders().get(folder_idx) { - let folder_id = folder.id; - app.selected_folder_id = Some(folder_id); - app.status_message = Some("Загрузка чатов папки...".to_string()); - let _ = with_timeout( - Duration::from_secs(5), - app.td_client.load_folder_chats(folder_id, 50), - ) - .await; - app.status_message = None; - app.chat_list_state.select(Some(0)); - } -} - -/// Обработка ввода с клавиатуры в открытом чате -/// -/// Обрабатывает: -/// - Backspace/Delete: удаление символов относительно курсора -/// - Char: вставка символов в позицию курсора + typing status -/// - Left/Right/Home/End: навигация курсора -/// - Up/Down: скролл сообщений или начало режима выбора -async fn handle_open_chat_keyboard_input(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Backspace => { - // Удаляем символ слева от курсора - if app.cursor_position > 0 { - // Находим byte offset для позиции курсора - let byte_pos = app.message_input - .char_indices() - .nth(app.cursor_position - 1) - .map(|(pos, _)| pos) - .unwrap_or(0); - app.message_input.remove(byte_pos); - app.cursor_position -= 1; - } - } - KeyCode::Delete => { - // Удаляем символ справа от курсора - let len = app.message_input.chars().count(); - if app.cursor_position < len { - // Находим byte offset для текущей позиции курсора - let byte_pos = app.message_input - .char_indices() - .nth(app.cursor_position) - .map(|(pos, _)| pos) - .unwrap_or(app.message_input.len()); - app.message_input.remove(byte_pos); - } - } - KeyCode::Char(c) => { - // Вставляем символ в позицию курсора - if app.cursor_position >= app.message_input.chars().count() { - // Вставка в конец строки - самый быстрый случай - app.message_input.push(c); - } else { - // Находим byte offset для позиции курсора - let byte_pos = app.message_input - .char_indices() - .nth(app.cursor_position) - .map(|(pos, _)| pos) - .unwrap_or(app.message_input.len()); - app.message_input.insert(byte_pos, c); - } - app.cursor_position += 1; - - // Отправляем typing status с throttling (не чаще 1 раза в 5 сек) - let should_send_typing = app - .last_typing_sent - .map(|t| t.elapsed().as_secs() >= 5) - .unwrap_or(true); - if should_send_typing { - if let Some(chat_id) = app.get_selected_chat_id() { - app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await; - app.last_typing_sent = Some(Instant::now()); - } - } - } - KeyCode::Left => { - // Курсор влево - if app.cursor_position > 0 { - app.cursor_position -= 1; - } - } - KeyCode::Right => { - // Курсор вправо - let len = app.message_input.chars().count(); - if app.cursor_position < len { - app.cursor_position += 1; - } - } - KeyCode::Home => { - // Курсор в начало - app.cursor_position = 0; - } - KeyCode::End => { - // Курсор в конец - app.cursor_position = app.message_input.chars().count(); - } - // Стрелки вверх/вниз - скролл сообщений или начало выбора - KeyCode::Down => { - // Скролл вниз (к новым сообщениям) - if app.message_scroll_offset > 0 { - app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3); - } - } - KeyCode::Up => { - // Если инпут пустой и не в режиме редактирования — начать выбор сообщения - if app.message_input.is_empty() && !app.is_editing() { - app.start_message_selection(); - } else { - // Скролл вверх (к старым сообщениям) - app.message_scroll_offset += 3; - - // Подгружаем старые сообщения если нужно - load_older_messages_if_needed(app).await; - } - } - _ => {} +/// Отменяет Reply/Editing и возвращает в Normal + MessageSelection +fn handle_escape_insert(app: &mut App) { + if app.is_editing() { + app.cancel_editing(); } + if app.is_replying() { + app.cancel_reply(); + } + app.input_mode = InputMode::Normal; + app.start_message_selection(); } +/// Главный обработчик ввода - роутер для всех режимов приложения pub async fn handle(app: &mut App, key: KeyEvent) { - // Глобальные команды (работают всегда) + let command = app.get_command(key); + + // 0. Account switcher (глобальный оверлей — highest priority) + if app.account_switcher.is_some() { + handle_account_switcher(app, key, command).await; + return; + } + + // 1. Insert mode + чат открыт → только текст, Enter, Esc + // (Ctrl+C обрабатывается в main.rs до вызова router) + if app.selected_chat_id.is_some() && app.input_mode == InputMode::Insert { + // Модальные окна всё равно обрабатываем (image modal, delete confirmation etc.) + #[cfg(feature = "images")] + if app.image_modal.is_some() { + handle_image_modal_mode(app, key).await; + return; + } + if app.is_confirm_delete_shown() { + handle_delete_confirmation(app, key).await; + return; + } + if app.is_reaction_picker_mode() { + handle_reaction_picker_mode(app, key, command).await; + return; + } + if app.is_profile_mode() { + handle_profile_mode(app, key, command).await; + return; + } + if app.is_message_search_mode() { + handle_message_search_mode(app, key, command).await; + return; + } + if app.is_pinned_mode() { + handle_pinned_mode(app, key, command).await; + return; + } + if app.is_forwarding() { + handle_forward_mode(app, key, command).await; + return; + } + + match command { + Some(crate::config::Command::Cancel) => { + handle_escape_insert(app); + return; + } + Some(crate::config::Command::SubmitMessage) => { + handle_enter_key(app).await; + return; + } + Some(crate::config::Command::DeleteWord) => { + // Ctrl+W → удалить слово + if app.cursor_position > 0 { + let chars: Vec = app.message_input.chars().collect(); + let mut new_pos = app.cursor_position; + // Пропускаем пробелы + while new_pos > 0 && chars[new_pos - 1] == ' ' { + new_pos -= 1; + } + // Пропускаем слово + while new_pos > 0 && chars[new_pos - 1] != ' ' { + new_pos -= 1; + } + let new_input: String = chars[..new_pos] + .iter() + .chain(chars[app.cursor_position..].iter()) + .collect(); + app.message_input = new_input; + app.cursor_position = new_pos; + } + return; + } + Some(crate::config::Command::MoveToStart) => { + app.cursor_position = 0; + return; + } + Some(crate::config::Command::MoveToEnd) => { + app.cursor_position = app.message_input.chars().count(); + return; + } + _ => {} + } + // Весь остальной ввод → текст + handle_open_chat_keyboard_input(app, key).await; + return; + } + + // 3. Глобальные команды (Ctrl+R, Ctrl+S, Ctrl+P, Ctrl+F) if handle_global_commands(app, key).await { return; } - // Получаем команду из keybindings - let command = app.get_command(key); + // 4. Модальное окно просмотра изображения + #[cfg(feature = "images")] + if app.image_modal.is_some() { + handle_image_modal_mode(app, key).await; + return; + } - // Режим профиля + // 5. Режим профиля if app.is_profile_mode() { handle_profile_mode(app, key, command).await; return; } - // Режим поиска по сообщениям + // 6. Режим поиска по сообщениям if app.is_message_search_mode() { handle_message_search_mode(app, key, command).await; return; } - // Режим просмотра закреплённых сообщений + // 7. Режим просмотра закреплённых сообщений if app.is_pinned_mode() { handle_pinned_mode(app, key, command).await; return; } - // Обработка ввода в режиме выбора реакции + // 8. Обработка ввода в режиме выбора реакции if app.is_reaction_picker_mode() { handle_reaction_picker_mode(app, key, command).await; return; } - // Модалка подтверждения удаления + // 9. Модалка подтверждения удаления if app.is_confirm_delete_shown() { handle_delete_confirmation(app, key).await; return; } - // Режим выбора чата для пересылки + // 10. Режим выбора чата для пересылки if app.is_forwarding() { handle_forward_mode(app, key, command).await; return; } - // Режим поиска + // 11. Режим поиска чатов if app.is_searching { handle_chat_search_mode(app, key, command).await; return; } - // Обработка команд через keybindings + // 12. Normal mode commands (Enter, Esc, Profile) match command { Some(crate::config::Command::SubmitMessage) => { - // Enter - открыть чат, отправить сообщение или редактировать handle_enter_key(app).await; return; } Some(crate::config::Command::Cancel) => { - // Esc - отменить выбор/редактирование/reply или закрыть чат - handle_escape_key(app).await; + handle_escape_normal(app).await; return; } Some(crate::config::Command::OpenProfile) => { - // Открыть профиль (обычно 'i') if app.selected_chat_id.is_some() { handle_profile_open(app).await; return; @@ -1113,87 +232,96 @@ pub async fn handle(app: &mut App, key: KeyEvent) { _ => {} } - // Режим открытого чата + // 13. Normal mode в чате → MessageSelection if app.selected_chat_id.is_some() { - // Режим выбора сообщения для редактирования/удаления - if app.is_selecting_message() { - handle_message_selection(app, key, command).await; - return; + // Auto-enter MessageSelection if not already in it + if !app.is_selecting_message() { + app.start_message_selection(); } - - handle_open_chat_keyboard_input(app, key).await; + handle_message_selection(app, key, command).await; } else { - // В режиме списка чатов - навигация стрелками и переключение папок + // 14. Список чатов handle_chat_list_navigation(app, key, command).await; } } -/// Открывает чат и загружает все необходимые данные. +/// Обработка модального окна просмотра изображения /// -/// Выполняет: -/// - Загрузку истории сообщений (с timeout) -/// - Установку current_chat_id (после загрузки, чтобы избежать race condition) -/// - Загрузку reply info (с timeout) -/// - Загрузку закреплённого сообщения (с timeout) -/// - Загрузку черновика -/// -/// При ошибке устанавливает error_message и очищает status_message. -async fn open_chat_and_load_data(app: &mut App, chat_id: i64) { - app.status_message = Some("Загрузка сообщений...".to_string()); - app.message_scroll_offset = 0; +/// Hotkeys: +/// - Esc/q: закрыть модальное окно +/// - ←: предыдущее фото в чате +/// - →: следующее фото в чате +#[cfg(feature = "images")] +async fn handle_image_modal_mode(app: &mut App, key: KeyEvent) { + use crossterm::event::KeyCode; - // Загружаем последние 100 сообщений для быстрого открытия чата - // Остальные сообщения будут подгружаться при скролле вверх - match with_timeout_msg( - Duration::from_secs(10), - app.td_client.get_chat_history(ChatId::new(chat_id), 100), - "Таймаут загрузки сообщений", - ) - .await - { - Ok(messages) => { - // Собираем ID всех входящих сообщений для отметки как прочитанные - let incoming_message_ids: Vec = messages - .iter() - .filter(|msg| !msg.is_outgoing()) - .map(|msg| msg.id()) - .collect(); - - // Сохраняем загруженные сообщения - app.td_client.set_current_chat_messages(messages); - - // Добавляем входящие сообщения в очередь для отметки как прочитанные - if !incoming_message_ids.is_empty() { - app.td_client - .pending_view_messages_mut() - .push((ChatId::new(chat_id), incoming_message_ids)); - } - - // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории - // Это предотвращает race condition с Update::NewMessage - app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); - - // Загружаем недостающие reply info (игнорируем ошибки) - with_timeout_ignore( - Duration::from_secs(5), - app.td_client.fetch_missing_reply_info(), - ) - .await; - - // Загружаем последнее закреплённое сообщение (игнорируем ошибки) - with_timeout_ignore( - Duration::from_secs(2), - app.td_client.load_current_pinned_message(ChatId::new(chat_id)), - ) - .await; - - // Загружаем черновик - app.load_draft(); - app.status_message = None; + match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('й') => { + // Закрываем модальное окно + app.image_modal = None; + app.needs_redraw = true; } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; + KeyCode::Left | KeyCode::Char('h') | KeyCode::Char('р') => { + // Предыдущее фото в чате + navigate_to_adjacent_photo(app, Direction::Previous).await; } + KeyCode::Right | KeyCode::Char('l') | KeyCode::Char('д') => { + // Следующее фото в чате + navigate_to_adjacent_photo(app, Direction::Next).await; + } + _ => {} } } + +#[cfg(feature = "images")] +enum Direction { + Previous, + Next, +} + +/// Переключение на соседнее фото в чате +#[cfg(feature = "images")] +async fn navigate_to_adjacent_photo(app: &mut App, direction: Direction) { + use crate::tdlib::PhotoDownloadState; + + let Some(current_modal) = &app.image_modal else { + return; + }; + + let current_msg_id = current_modal.message_id; + let messages = app.td_client.current_chat_messages(); + + // Находим текущее сообщение + let Some(current_idx) = messages.iter().position(|m| m.id() == current_msg_id) else { + return; + }; + + // Ищем следующее/предыдущее сообщение с фото + let search_range: Box> = match direction { + Direction::Previous => Box::new((0..current_idx).rev()), + Direction::Next => Box::new((current_idx + 1)..messages.len()), + }; + + for idx in search_range { + if let Some(photo) = messages[idx].photo_info() { + if let PhotoDownloadState::Downloaded(path) = &photo.download_state { + // Нашли фото - открываем его + app.image_modal = Some(crate::tdlib::ImageModalState { + message_id: messages[idx].id(), + photo_path: path.clone(), + photo_width: photo.width, + photo_height: photo.height, + }); + app.needs_redraw = true; + return; + } + } + } + + // Если не нашли фото - показываем сообщение + let msg = match direction { + Direction::Previous => "Нет предыдущих фото", + Direction::Next => "Нет следующих фото", + }; + app.status_message = Some(msg.to_string()); +} diff --git a/src/input/mod.rs b/src/input/mod.rs index 297485f..a13a0dc 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -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; diff --git a/src/lib.rs b/src/lib.rs index fa72b40..4ca43b4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,16 @@ -// 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 accounts; pub mod app; +pub mod audio; pub mod config; pub mod constants; pub mod formatting; pub mod input; +#[cfg(feature = "images")] +pub mod media; pub mod message_grouping; pub mod notifications; pub mod tdlib; diff --git a/src/main.rs b/src/main.rs index 86cb4dc..38eb922 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,8 +1,12 @@ +mod accounts; mod app; +mod audio; mod config; mod constants; mod formatting; mod input; +#[cfg(feature = "images")] +mod media; mod message_grouping; mod notifications; mod tdlib; @@ -28,6 +32,19 @@ use input::{handle_auth_input, handle_main_input}; use tdlib::AuthState; use utils::{disable_tdlib_logs, with_timeout_ignore}; +/// Parses `--account ` from CLI arguments. +fn parse_account_arg() -> Option { + let args: Vec = std::env::args().collect(); + let mut i = 1; + while i < args.len() { + if args[i] == "--account" && i + 1 < args.len() { + return Some(args[i + 1].clone()); + } + i += 1; + } + None +} + #[tokio::main] async fn main() -> Result<(), io::Error> { // Загружаем переменные окружения из .env @@ -38,13 +55,32 @@ async fn main() -> Result<(), io::Error> { tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")) + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), ) .init(); // Загружаем конфигурацию (создаёт дефолтный если отсутствует) let config = config::Config::load(); + // Загружаем/создаём accounts.toml + миграция legacy ./tdlib_data/ + let accounts_config = accounts::load_or_create(); + + // Резолвим аккаунт из CLI или default + let account_arg = parse_account_arg(); + let (account_name, db_path) = + accounts::resolve_account(&accounts_config, account_arg.as_deref()).unwrap_or_else(|e| { + eprintln!("Error: {}", e); + std::process::exit(1); + }); + + // Создаём директорию аккаунта если её нет + let db_path = accounts::ensure_account_dir( + account_arg + .as_deref() + .unwrap_or(&accounts_config.default_account), + ) + .unwrap_or(db_path); + // Отключаем логи TDLib ДО создания клиента disable_tdlib_logs(); @@ -63,24 +99,26 @@ async fn main() -> Result<(), io::Error> { panic_hook(info); })); - // Create app state - let mut app = App::new(config); + // Create app state with account-specific db_path + let mut app = App::new(config, db_path); + app.current_account_name = account_name; // Запускаем инициализацию TDLib в фоне (только для реального клиента) let client_id = app.td_client.client_id(); let api_id = app.td_client.api_id; let api_hash = app.td_client.api_hash.clone(); + let db_path_str = app.td_client.db_path.to_string_lossy().to_string(); tokio::spawn(async move { let _ = tdlib_rs::functions::set_tdlib_parameters( - false, // use_test_dc - "tdlib_data".to_string(), // database_directory - "".to_string(), // files_directory - "".to_string(), // database_encryption_key - true, // use_file_database - true, // use_chat_info_database - true, // use_message_database - false, // use_secret_chats + false, // use_test_dc + db_path_str, // database_directory + "".to_string(), // files_directory + "".to_string(), // database_encryption_key + true, // use_file_database + true, // use_chat_info_database + true, // use_message_database + false, // use_secret_chats api_id, api_hash, "en".to_string(), // system_language_code @@ -121,7 +159,7 @@ async fn run_app( let polling_handle = tokio::spawn(async move { while !should_stop_clone.load(Ordering::Relaxed) { // receive() с таймаутом 0.1 сек чтобы периодически проверять флаг - let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await; + let result = tokio::task::spawn_blocking(tdlib_rs::receive).await; if let Ok(Some((update, _client_id))) = result { if update_tx.send(update).is_err() { break; // Канал закрыт, выходим @@ -143,6 +181,34 @@ async fn run_app( app.needs_redraw = true; } + // Обрабатываем результаты фоновой загрузки фото + #[cfg(feature = "images")] + { + use crate::tdlib::PhotoDownloadState; + + let mut got_photos = false; + if let Some(ref mut rx) = app.photo_download_rx { + while let Ok((file_id, result)) = rx.try_recv() { + let new_state = match result { + Ok(path) => PhotoDownloadState::Downloaded(path), + Err(_) => PhotoDownloadState::Error("Ошибка загрузки".to_string()), + }; + for msg in app.td_client.current_chat_messages_mut() { + if let Some(photo) = msg.photo_info_mut() { + if photo.file_id == file_id { + photo.download_state = new_state; + got_photos = true; + break; + } + } + } + } + } + if got_photos { + app.needs_redraw = true; + } + } + // Очищаем устаревший typing status if app.td_client.clear_stale_typing_status() { app.needs_redraw = true; @@ -164,6 +230,42 @@ async fn run_app( app.needs_redraw = true; } + // Обновляем позицию воспроизведения голосового сообщения + { + let mut stop_playback = false; + if let Some(ref mut playback) = app.playback_state { + use crate::tdlib::PlaybackStatus; + match playback.status { + PlaybackStatus::Playing => { + let prev_second = playback.position as u32; + if let Some(last_tick) = app.last_playback_tick { + let delta = last_tick.elapsed().as_secs_f32(); + playback.position += delta; + } + app.last_playback_tick = Some(std::time::Instant::now()); + + // Проверяем завершение воспроизведения + if playback.position >= playback.duration + || app.audio_player.as_ref().is_some_and(|p| p.is_stopped()) + { + stop_playback = true; + } + // Перерисовка только при смене секунды (не 60 FPS) + if playback.position as u32 != prev_second || stop_playback { + app.needs_redraw = true; + } + } + _ => { + app.last_playback_tick = None; + } + } + } + if stop_playback { + app.stop_playback(); + app.last_playback_tick = None; + } + } + // Рендерим только если есть изменения if app.needs_redraw { terminal.draw(|f| ui::render(f, app))?; @@ -182,21 +284,39 @@ async fn run_app( // Graceful shutdown should_stop.store(true, Ordering::Relaxed); + // Останавливаем воспроизведение голосового (убиваем ffplay) + app.stop_playback(); + // Закрываем TDLib клиент let _ = tdlib_rs::functions::close(app.td_client.client_id()).await; // Ждём завершения polling задачи (с таймаутом) - with_timeout_ignore(Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), polling_handle).await; + with_timeout_ignore( + Duration::from_secs(SHUTDOWN_TIMEOUT_SECS), + polling_handle, + ) + .await; return Ok(()); } - match app.screen { - AppScreen::Loading => { - // В состоянии загрузки игнорируем ввод + // Ctrl+A opens account switcher from any screen + if key.code == KeyCode::Char('a') + && key.modifiers.contains(KeyModifiers::CONTROL) + && app.account_switcher.is_none() + { + app.open_account_switcher(); + } else if app.account_switcher.is_some() { + // Route to main input handler when account switcher is open + handle_main_input(app, key).await; + } else { + match app.screen { + AppScreen::Loading => { + // В состоянии загрузки игнорируем ввод + } + AppScreen::Auth => handle_auth_input(app, key.code).await, + AppScreen::Main => handle_main_input(app, key).await, } - AppScreen::Auth => handle_auth_input(app, key.code).await, - AppScreen::Main => handle_main_input(app, key).await, } // Любой ввод требует перерисовки @@ -209,6 +329,118 @@ async fn run_app( _ => {} } } + + // Process pending chat initialization (reply info, pinned, photos) + if let Some(chat_id) = app.pending_chat_init.take() { + // Загружаем недостающие reply info (игнорируем ошибки) + with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()) + .await; + + // Загружаем последнее закреплённое сообщение (игнорируем ошибки) + with_timeout_ignore( + Duration::from_secs(2), + app.td_client.load_current_pinned_message(chat_id), + ) + .await; + + // Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно) + #[cfg(feature = "images")] + { + use crate::tdlib::PhotoDownloadState; + + if app.config().images.auto_download_images && app.config().images.show_images { + let photo_file_ids: Vec = app + .td_client + .current_chat_messages() + .iter() + .rev() + .take(5) + .filter_map(|msg| { + msg.photo_info().and_then(|p| { + matches!(p.download_state, PhotoDownloadState::NotDownloaded) + .then_some(p.file_id) + }) + }) + .collect(); + + if !photo_file_ids.is_empty() { + let client_id = app.td_client.client_id(); + let (tx, rx) = + tokio::sync::mpsc::unbounded_channel::<(i32, Result)>(); + app.photo_download_rx = Some(rx); + + for file_id in photo_file_ids { + let tx = tx.clone(); + tokio::spawn(async move { + let result = tokio::time::timeout(Duration::from_secs(5), async { + match tdlib_rs::functions::download_file( + file_id, 1, 0, 0, true, client_id, + ) + .await + { + Ok(tdlib_rs::enums::File::File(file)) + if file.local.is_downloading_completed + && !file.local.path.is_empty() => + { + Ok(file.local.path) + } + Ok(_) => Err("Файл не скачан".to_string()), + Err(e) => Err(format!("{:?}", e)), + } + }) + .await; + + let result = match result { + Ok(r) => r, + Err(_) => Err("Таймаут загрузки".to_string()), + }; + let _ = tx.send((file_id, result)); + }); + } + } + } + } + + app.needs_redraw = true; + } + + // Check pending account switch + if let Some((account_name, new_db_path)) = app.pending_account_switch.take() { + // 1. Stop playback + app.stop_playback(); + + // 2. Recreate client (closes old, creates new, inits TDLib params) + if let Err(e) = app.td_client.recreate_client(new_db_path).await { + app.error_message = Some(format!("Ошибка переключения: {}", e)); + continue; + } + + // 3. Reset app state + app.current_account_name = account_name.clone(); + app.screen = AppScreen::Loading; + + // 4. Persist selected account as default for next launch + let mut accounts_config = accounts::load_or_create(); + accounts_config.default_account = account_name; + if let Err(e) = accounts::save(&accounts_config) { + tracing::warn!("Could not save default account: {}", e); + } + app.chats.clear(); + app.selected_chat_id = None; + app.chat_state = Default::default(); + app.input_mode = Default::default(); + app.status_message = Some("Переключение аккаунта...".to_string()); + app.error_message = None; + app.is_searching = false; + app.search_query.clear(); + app.message_input.clear(); + app.cursor_position = 0; + app.message_scroll_offset = 0; + app.pending_chat_init = None; + app.account_switcher = None; + + app.needs_redraw = true; + } } } diff --git a/src/media/cache.rs b/src/media/cache.rs new file mode 100644 index 0000000..a67c9e4 --- /dev/null +++ b/src/media/cache.rs @@ -0,0 +1,112 @@ +//! Image cache with LRU eviction. +//! +//! Stores downloaded images in `~/.cache/tele-tui/images/` with size-based eviction. + +use std::fs; +use std::path::PathBuf; + +/// Кэш изображений с LRU eviction по mtime +#[allow(dead_code)] +pub struct ImageCache { + cache_dir: PathBuf, + max_size_bytes: u64, +} + +#[allow(dead_code)] +impl ImageCache { + /// Создаёт новый кэш с указанным лимитом в МБ + pub fn new(cache_size_mb: u64) -> Self { + let cache_dir = dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from("/tmp")) + .join("tele-tui") + .join("images"); + + // Создаём директорию кэша если не существует + let _ = fs::create_dir_all(&cache_dir); + + Self { + cache_dir, + max_size_bytes: cache_size_mb * 1024 * 1024, + } + } + + /// Проверяет, есть ли файл в кэше + pub fn get_cached(&self, file_id: i32) -> Option { + let path = self.cache_dir.join(format!("{}.jpg", file_id)); + if path.exists() { + // Обновляем mtime для LRU + let _ = filetime::set_file_mtime(&path, filetime::FileTime::now()); + Some(path) + } else { + None + } + } + + /// Кэширует файл, копируя из source_path + pub fn cache_file(&self, file_id: i32, source_path: &str) -> Result { + let dest = self.cache_dir.join(format!("{}.jpg", file_id)); + + fs::copy(source_path, &dest).map_err(|e| format!("Ошибка кэширования: {}", e))?; + + // Evict если превышен лимит + self.evict_if_needed(); + + Ok(dest) + } + + /// Удаляет старые файлы если кэш превышает лимит + fn evict_if_needed(&self) { + let entries = match fs::read_dir(&self.cache_dir) { + Ok(entries) => entries, + Err(_) => return, + }; + + let mut files: Vec<(PathBuf, u64, std::time::SystemTime)> = entries + .filter_map(|e| e.ok()) + .filter_map(|e| { + let meta = e.metadata().ok()?; + let mtime = meta.modified().ok()?; + Some((e.path(), meta.len(), mtime)) + }) + .collect(); + + let total_size: u64 = files.iter().map(|(_, size, _)| size).sum(); + + if total_size <= self.max_size_bytes { + return; + } + + // Сортируем по mtime (старые первые) + files.sort_by_key(|(_, _, mtime)| *mtime); + + let mut current_size = total_size; + for (path, size, _) in &files { + if current_size <= self.max_size_bytes { + break; + } + let _ = fs::remove_file(path); + current_size -= size; + } + } +} + +/// Обёртка для установки mtime без внешней зависимости +#[allow(dead_code)] +mod filetime { + use std::path::Path; + + pub struct FileTime; + + impl FileTime { + pub fn now() -> Self { + FileTime + } + } + + pub fn set_file_mtime(_path: &Path, _time: FileTime) -> Result<(), std::io::Error> { + // На macOS/Linux можно использовать utime, но для простоты + // достаточно прочитать файл (обновит atime) — LRU по mtime не критичен + // для нашего use case. Файл будет перезаписан при повторном скачивании. + Ok(()) + } +} diff --git a/src/media/image_renderer.rs b/src/media/image_renderer.rs new file mode 100644 index 0000000..2c10e09 --- /dev/null +++ b/src/media/image_renderer.rs @@ -0,0 +1,125 @@ +//! Terminal image renderer using ratatui-image. +//! +//! Detects terminal protocol (iTerm2, Sixel, Halfblocks) and renders images +//! as StatefulProtocol widgets. +//! +//! Implements LRU-like caching for protocols to avoid unlimited memory growth. + +use crate::types::MessageId; +use ratatui_image::picker::{Picker, ProtocolType}; +use ratatui_image::protocol::StatefulProtocol; +use std::collections::HashMap; + +/// Максимальное количество кэшированных протоколов (LRU) +const MAX_CACHED_PROTOCOLS: usize = 100; + +/// Рендерер изображений для терминала с LRU кэшем +pub struct ImageRenderer { + picker: Picker, + /// Протоколы рендеринга для каждого сообщения (message_id -> protocol) + protocols: HashMap, + /// Порядок доступа для LRU (message_id -> порядковый номер) + access_order: HashMap, + /// Счётчик для отслеживания порядка доступа + access_counter: usize, +} + +impl ImageRenderer { + /// Создаёт ImageRenderer с автодетектом протокола (высокое качество для modal) + pub fn new() -> Option { + let picker = Picker::from_query_stdio().ok()?; + + Some(Self { + picker, + protocols: HashMap::new(), + access_order: HashMap::new(), + access_counter: 0, + }) + } + + /// Создаёт ImageRenderer с принудительным Halfblocks (быстро, для inline preview) + pub fn new_fast() -> Option { + let mut picker = Picker::from_fontsize((8, 12)); + picker.set_protocol_type(ProtocolType::Halfblocks); + + Some(Self { + picker, + protocols: HashMap::new(), + access_order: HashMap::new(), + access_counter: 0, + }) + } + + /// Загружает изображение из файла и создаёт протокол рендеринга. + /// + /// Если протокол уже существует, не загружает повторно (кэширование). + /// Использует LRU eviction при превышении лимита. + pub fn load_image(&mut self, msg_id: MessageId, path: &str) -> Result<(), String> { + let msg_id_i64 = msg_id.as_i64(); + + // Оптимизация: если протокол уже есть, обновляем access time и возвращаем + if self.protocols.contains_key(&msg_id_i64) { + self.access_counter += 1; + self.access_order.insert(msg_id_i64, self.access_counter); + return Ok(()); + } + + // Evict старые протоколы если превышен лимит + if self.protocols.len() >= MAX_CACHED_PROTOCOLS { + self.evict_oldest_protocol(); + } + + let img = image::ImageReader::open(path) + .map_err(|e| format!("Ошибка открытия: {}", e))? + .decode() + .map_err(|e| format!("Ошибка декодирования: {}", e))?; + + let protocol = self.picker.new_resize_protocol(img); + self.protocols.insert(msg_id_i64, protocol); + + // Обновляем access order + self.access_counter += 1; + self.access_order.insert(msg_id_i64, self.access_counter); + + Ok(()) + } + + /// Удаляет самый старый протокол (LRU eviction) + fn evict_oldest_protocol(&mut self) { + if let Some((&oldest_id, _)) = self.access_order.iter().min_by_key(|(_, &order)| order) { + self.protocols.remove(&oldest_id); + self.access_order.remove(&oldest_id); + } + } + + /// Получает мутабельную ссылку на протокол для рендеринга. + /// + /// Обновляет access time для LRU. + pub fn get_protocol(&mut self, msg_id: &MessageId) -> Option<&mut StatefulProtocol> { + let msg_id_i64 = msg_id.as_i64(); + + if self.protocols.contains_key(&msg_id_i64) { + // Обновляем access time + self.access_counter += 1; + self.access_order.insert(msg_id_i64, self.access_counter); + } + + self.protocols.get_mut(&msg_id_i64) + } + + /// Удаляет протокол для сообщения + #[allow(dead_code)] + pub fn remove(&mut self, msg_id: &MessageId) { + let msg_id_i64 = msg_id.as_i64(); + self.protocols.remove(&msg_id_i64); + self.access_order.remove(&msg_id_i64); + } + + /// Очищает все протоколы + #[allow(dead_code)] + pub fn clear(&mut self) { + self.protocols.clear(); + self.access_order.clear(); + self.access_counter = 0; + } +} diff --git a/src/media/mod.rs b/src/media/mod.rs new file mode 100644 index 0000000..46e0bc0 --- /dev/null +++ b/src/media/mod.rs @@ -0,0 +1,9 @@ +//! Media handling module (feature-gated under "images"). +//! +//! Provides image caching and terminal image rendering via ratatui-image. + +#[cfg(feature = "images")] +pub mod cache; + +#[cfg(feature = "images")] +pub mod image_renderer; diff --git a/src/message_grouping.rs b/src/message_grouping.rs index 5ccb0c2..206ca83 100644 --- a/src/message_grouping.rs +++ b/src/message_grouping.rs @@ -12,9 +12,14 @@ pub enum MessageGroup { /// Разделитель даты (день в формате timestamp) DateSeparator(i32), /// Заголовок отправителя (is_outgoing, sender_name) - SenderHeader { is_outgoing: bool, sender_name: String }, + SenderHeader { + is_outgoing: bool, + sender_name: String, + }, /// Сообщение - Message(MessageInfo), + Message(Box), + /// Альбом (группа фото с одинаковым media_album_id) + Album(Vec), } /// Группирует сообщения по дате и отправителю @@ -51,6 +56,10 @@ pub enum MessageGroup { /// // Рендерим сообщение /// println!("{}", msg.text()); /// } +/// MessageGroup::Album(messages) => { +/// // Рендерим альбом (группу фото) +/// println!("Album with {} photos", messages.len()); +/// } /// } /// } /// ``` @@ -58,12 +67,28 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec { let mut result = Vec::new(); let mut last_day: Option = None; let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name) + let mut album_acc: Vec = Vec::new(); + + /// Сбрасывает аккумулятор альбома в результат + fn flush_album(acc: &mut Vec, result: &mut Vec) { + if acc.is_empty() { + return; + } + if acc.len() >= 2 { + result.push(MessageGroup::Album(std::mem::take(acc))); + } else { + // Одно сообщение — не альбом + result.push(MessageGroup::Message(Box::new(acc.remove(0)))); + } + } for msg in messages { // Проверяем, нужно ли добавить разделитель даты let msg_day = get_day(msg.date()); if last_day != Some(msg_day) { + // Flush аккумулятор перед разделителем даты + flush_album(&mut album_acc, &mut result); // Добавляем разделитель даты result.push(MessageGroup::DateSeparator(msg.date())); last_day = Some(msg_day); @@ -82,17 +107,42 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec { let show_sender_header = last_sender.as_ref() != Some(¤t_sender); if show_sender_header { - result.push(MessageGroup::SenderHeader { - is_outgoing: msg.is_outgoing(), - sender_name, - }); + // Flush аккумулятор перед сменой отправителя + flush_album(&mut album_acc, &mut result); + result.push(MessageGroup::SenderHeader { is_outgoing: msg.is_outgoing(), sender_name }); last_sender = Some(current_sender); } - // Добавляем само сообщение - result.push(MessageGroup::Message(msg.clone())); + // Проверяем, является ли сообщение частью альбома + let album_id = msg.media_album_id(); + if album_id != 0 { + // Проверяем, совпадает ли album_id с текущим аккумулятором + if let Some(first) = album_acc.first() { + if first.media_album_id() == album_id { + // Тот же альбом — добавляем + album_acc.push(msg.clone()); + continue; + } else { + // Другой альбом — flush старый, начинаем новый + flush_album(&mut album_acc, &mut result); + album_acc.push(msg.clone()); + continue; + } + } else { + // Аккумулятор пуст — начинаем новый альбом + album_acc.push(msg.clone()); + continue; + } + } + + // Обычное сообщение (не альбом) — flush аккумулятор + flush_album(&mut album_acc, &mut result); + result.push(MessageGroup::Message(Box::new(msg.clone()))); } + // Flush оставшийся аккумулятор + flush_album(&mut album_acc, &mut result); + result } @@ -246,4 +296,152 @@ mod tests { assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. })); assert!(matches!(grouped[2], MessageGroup::Message(_))); } + + #[test] + fn test_album_grouping_two_photos() { + let msg1 = MessageBuilder::new(MessageId::new(1)) + .sender_name("Alice") + .text("Photo 1") + .date(1609459200) + .incoming() + .media_album_id(12345) + .build(); + + let msg2 = MessageBuilder::new(MessageId::new(2)) + .sender_name("Alice") + .text("Photo 2") + .date(1609459201) + .incoming() + .media_album_id(12345) + .build(); + + let messages = vec![msg1, msg2]; + let grouped = group_messages(&messages); + + // DateSep, SenderHeader, Album + assert_eq!(grouped.len(), 3); + assert!(matches!(grouped[0], MessageGroup::DateSeparator(_))); + assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. })); + if let MessageGroup::Album(album) = &grouped[2] { + assert_eq!(album.len(), 2); + assert_eq!(album[0].id(), MessageId::new(1)); + assert_eq!(album[1].id(), MessageId::new(2)); + } else { + panic!("Expected Album, got {:?}", grouped[2]); + } + } + + #[test] + fn test_album_single_photo_not_album() { + // Одно сообщение с album_id → не альбом, обычное сообщение + let msg = MessageBuilder::new(MessageId::new(1)) + .sender_name("Alice") + .text("Single photo") + .date(1609459200) + .incoming() + .media_album_id(12345) + .build(); + + let messages = vec![msg]; + let grouped = group_messages(&messages); + + // DateSep, SenderHeader, Message (не Album) + assert_eq!(grouped.len(), 3); + assert!(matches!(grouped[2], MessageGroup::Message(_))); + } + + #[test] + fn test_album_with_regular_messages() { + let msg1 = MessageBuilder::new(MessageId::new(1)) + .sender_name("Alice") + .text("Text message") + .date(1609459200) + .incoming() + .build(); + + let msg2 = MessageBuilder::new(MessageId::new(2)) + .sender_name("Alice") + .text("Photo 1") + .date(1609459201) + .incoming() + .media_album_id(100) + .build(); + + let msg3 = MessageBuilder::new(MessageId::new(3)) + .sender_name("Alice") + .text("Photo 2") + .date(1609459202) + .incoming() + .media_album_id(100) + .build(); + + let msg4 = MessageBuilder::new(MessageId::new(4)) + .sender_name("Alice") + .text("After album") + .date(1609459203) + .incoming() + .build(); + + let messages = vec![msg1, msg2, msg3, msg4]; + let grouped = group_messages(&messages); + + // DateSep, SenderHeader, Message, Album, Message + assert_eq!(grouped.len(), 5); + assert!(matches!(grouped[2], MessageGroup::Message(_))); + assert!(matches!(grouped[3], MessageGroup::Album(_))); + assert!(matches!(grouped[4], MessageGroup::Message(_))); + } + + #[test] + fn test_two_different_albums() { + let msg1 = MessageBuilder::new(MessageId::new(1)) + .sender_name("Alice") + .text("Album 1 - Photo 1") + .date(1609459200) + .incoming() + .media_album_id(100) + .build(); + + let msg2 = MessageBuilder::new(MessageId::new(2)) + .sender_name("Alice") + .text("Album 1 - Photo 2") + .date(1609459201) + .incoming() + .media_album_id(100) + .build(); + + let msg3 = MessageBuilder::new(MessageId::new(3)) + .sender_name("Alice") + .text("Album 2 - Photo 1") + .date(1609459202) + .incoming() + .media_album_id(200) + .build(); + + let msg4 = MessageBuilder::new(MessageId::new(4)) + .sender_name("Alice") + .text("Album 2 - Photo 2") + .date(1609459203) + .incoming() + .media_album_id(200) + .build(); + + let messages = vec![msg1, msg2, msg3, msg4]; + let grouped = group_messages(&messages); + + // DateSep, SenderHeader, Album(2), Album(2) + assert_eq!(grouped.len(), 4); + if let MessageGroup::Album(a1) = &grouped[2] { + assert_eq!(a1.len(), 2); + assert_eq!(a1[0].media_album_id(), 100); + } else { + panic!("Expected first Album"); + } + if let MessageGroup::Album(a2) = &grouped[3] { + assert_eq!(a2.len(), 2); + assert_eq!(a2[0].media_album_id(), 200); + } else { + panic!("Expected second Album"); + } + } } diff --git a/src/notifications.rs b/src/notifications.rs index e0dc06d..5d76078 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -10,6 +10,7 @@ use std::collections::HashSet; use notify_rust::{Notification, Timeout}; /// Manages desktop notifications +#[allow(dead_code)] pub struct NotificationManager { /// Whether notifications are enabled enabled: bool, @@ -25,6 +26,7 @@ pub struct NotificationManager { urgency: String, } +#[allow(dead_code)] impl NotificationManager { /// Creates a new notification manager with default settings pub fn new() -> Self { @@ -39,11 +41,7 @@ impl NotificationManager { } /// Creates a notification manager with custom settings - pub fn with_config( - enabled: bool, - only_mentions: bool, - show_preview: bool, - ) -> Self { + pub fn with_config(enabled: bool, only_mentions: bool, show_preview: bool) -> Self { Self { enabled, muted_chats: HashSet::new(), @@ -269,7 +267,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); } @@ -311,22 +309,13 @@ mod tests { #[test] fn test_beautify_media_labels() { // Test photo - assert_eq!( - NotificationManager::beautify_media_labels("[Фото]"), - "📷 Фото" - ); + assert_eq!(NotificationManager::beautify_media_labels("[Фото]"), "📷 Фото"); // Test video - assert_eq!( - NotificationManager::beautify_media_labels("[Видео]"), - "🎥 Видео" - ); + assert_eq!(NotificationManager::beautify_media_labels("[Видео]"), "🎥 Видео"); // Test sticker with emoji - assert_eq!( - NotificationManager::beautify_media_labels("[Стикер: 😊]"), - "🎨 Стикер: 😊]" - ); + assert_eq!(NotificationManager::beautify_media_labels("[Стикер: 😊]"), "🎨 Стикер: 😊]"); // Test audio with title assert_eq!( @@ -341,10 +330,7 @@ mod tests { ); // Test regular text (no changes) - assert_eq!( - NotificationManager::beautify_media_labels("Hello, world!"), - "Hello, world!" - ); + assert_eq!(NotificationManager::beautify_media_labels("Hello, world!"), "Hello, world!"); // Test mixed content assert_eq!( diff --git a/src/tdlib/auth.rs b/src/tdlib/auth.rs index eeef949..ded0e5b 100644 --- a/src/tdlib/auth.rs +++ b/src/tdlib/auth.rs @@ -5,6 +5,7 @@ use tdlib_rs::functions; /// /// Отслеживает текущий этап аутентификации пользователя, /// от инициализации TDLib до полной авторизации. +#[allow(dead_code)] #[derive(Debug, Clone, PartialEq)] pub enum AuthState { /// Ожидание параметров TDLib (начальное состояние). @@ -72,6 +73,7 @@ pub struct AuthManager { client_id: i32, } +#[allow(dead_code)] impl AuthManager { /// Создает новый менеджер авторизации. /// @@ -83,10 +85,7 @@ impl AuthManager { /// /// Новый экземпляр `AuthManager` в состоянии `WaitTdlibParameters`. pub fn new(client_id: i32) -> Self { - Self { - state: AuthState::WaitTdlibParameters, - client_id, - } + Self { state: AuthState::WaitTdlibParameters, client_id } } /// Проверяет, завершена ли авторизация. diff --git a/src/tdlib/chat_helpers.rs b/src/tdlib/chat_helpers.rs index 2895316..b022ee7 100644 --- a/src/tdlib/chat_helpers.rs +++ b/src/tdlib/chat_helpers.rs @@ -3,7 +3,7 @@ //! This module contains utility functions for managing chats, //! including finding, updating, and adding/removing chats. -use crate::constants::{MAX_CHAT_USER_IDS, MAX_CHATS}; +use crate::constants::{MAX_CHATS, MAX_CHAT_USER_IDS}; use crate::types::{ChatId, MessageId, UserId}; use tdlib_rs::enums::{Chat as TdChat, ChatList, ChatType}; @@ -33,7 +33,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) { // Пропускаем удалённые аккаунты if td_chat.title == "Deleted Account" || td_chat.title.is_empty() { // Удаляем из списка если уже был добавлен - client.chats_mut().retain(|c| c.id != ChatId::new(td_chat.id)); + client + .chats_mut() + .retain(|c| c.id != ChatId::new(td_chat.id)); return; } @@ -70,7 +72,9 @@ pub fn add_or_update_chat(client: &mut TdClient, td_chat_enum: &TdChat) { let user_id = UserId::new(private.user_id); client.user_cache.chat_user_ids.insert(chat_id, user_id); // Проверяем, есть ли уже username в кэше (peek не обновляет LRU) - client.user_cache.user_usernames + client + .user_cache + .user_usernames .peek(&user_id) .map(|u| format!("@{}", u)) } diff --git a/src/tdlib/chats.rs b/src/tdlib/chats.rs index 2ab2f91..ddc40fc 100644 --- a/src/tdlib/chats.rs +++ b/src/tdlib/chats.rs @@ -197,10 +197,7 @@ impl ChatManager { ChatType::Secret(_) => "Секретный чат", }; - let is_group = matches!( - &chat.r#type, - ChatType::Supergroup(_) | ChatType::BasicGroup(_) - ); + let is_group = matches!(&chat.r#type, ChatType::Supergroup(_) | ChatType::BasicGroup(_)); // Для личных чатов получаем информацию о пользователе let (bio, phone_number, username, online_status) = if let ChatType::Private(private_chat) = @@ -208,13 +205,15 @@ impl ChatManager { { match functions::get_user(private_chat.user_id, self.client_id).await { Ok(tdlib_rs::enums::User::User(user)) => { - let bio_opt = if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) = - functions::get_user_full_info(private_chat.user_id, self.client_id).await - { - full_info.bio.map(|b| b.text) - } else { - None - }; + let bio_opt = + if let Ok(tdlib_rs::enums::UserFullInfo::UserFullInfo(full_info)) = + functions::get_user_full_info(private_chat.user_id, self.client_id) + .await + { + full_info.bio.map(|b| b.text) + } else { + None + }; let online_status_str = match user.status { tdlib_rs::enums::UserStatus::Online(_) => Some("В сети".to_string()), @@ -234,10 +233,7 @@ impl ChatManager { _ => None, }; - let username_opt = user - .usernames - .as_ref() - .map(|u| u.editable_username.clone()); + let username_opt = user.usernames.as_ref().map(|u| u.editable_username.clone()); (bio_opt, Some(user.phone_number.clone()), username_opt, online_status_str) } @@ -257,7 +253,10 @@ impl ChatManager { } else { None }; - let link = full_info.invite_link.as_ref().map(|l| l.invite_link.clone()); + let link = full_info + .invite_link + .as_ref() + .map(|l| l.invite_link.clone()); (Some(full_info.member_count), desc, link) } _ => (None, None, None), @@ -324,7 +323,8 @@ impl ChatManager { /// ).await; /// ``` pub async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) { - let _ = functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await; + let _ = + functions::send_chat_action(chat_id.as_i64(), 0, Some(action), self.client_id).await; } /// Очищает устаревший typing-статус. @@ -371,6 +371,7 @@ impl ChatManager { /// println!("Status: {}", typing_text); /// } /// ``` + #[allow(dead_code)] pub fn get_typing_text(&self) -> Option { self.typing_status .as_ref() diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index bac4e33..5ab10b6 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -1,19 +1,17 @@ use crate::types::{ChatId, MessageId, UserId}; use std::env; -use tdlib_rs::enums::{ - ChatList, ConnectionState, Update, UserStatus, - Chat as TdChat -}; -use tdlib_rs::types::Message as TdMessage; +use std::path::PathBuf; +use tdlib_rs::enums::{Chat as TdChat, ChatList, ConnectionState, Update, UserStatus}; use tdlib_rs::functions; - - +use tdlib_rs::types::Message as TdMessage; use super::auth::{AuthManager, AuthState}; use super::chats::ChatManager; use super::messages::MessageManager; use super::reactions::ReactionManager; -use super::types::{ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus}; +use super::types::{ + ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus, +}; use super::users::UserCache; use crate::notifications::NotificationManager; @@ -32,7 +30,7 @@ use crate::notifications::NotificationManager; /// ```ignore /// use tele_tui::tdlib::TdClient; /// -/// let mut client = TdClient::new(); +/// let mut client = TdClient::new(std::path::PathBuf::from("tdlib_data")); /// /// // Start authorization /// client.send_phone_number("+1234567890".to_string()).await?; @@ -45,6 +43,7 @@ use crate::notifications::NotificationManager; pub struct TdClient { pub api_id: i32, pub api_hash: String, + pub db_path: PathBuf, client_id: i32, // Менеджеры (делегируем им функциональность) @@ -59,6 +58,7 @@ pub struct TdClient { pub network_state: NetworkState, } +#[allow(dead_code)] impl TdClient { /// Creates a new TDLib client instance. /// @@ -71,24 +71,24 @@ impl TdClient { /// # Returns /// /// A new `TdClient` instance ready for authentication. - pub fn new() -> Self { + pub fn new(db_path: PathBuf) -> Self { // Пробуем загрузить credentials из Config (файл или env) - let (api_id, api_hash) = crate::config::Config::load_credentials() - .unwrap_or_else(|_| { - // Fallback на прямое чтение из env (старое поведение) - let api_id = env::var("API_ID") - .unwrap_or_else(|_| "0".to_string()) - .parse() - .unwrap_or(0); - let api_hash = env::var("API_HASH").unwrap_or_default(); - (api_id, api_hash) - }); + let (api_id, api_hash) = crate::config::Config::load_credentials().unwrap_or_else(|_| { + // Fallback на прямое чтение из env (старое поведение) + let api_id = env::var("API_ID") + .unwrap_or_else(|_| "0".to_string()) + .parse() + .unwrap_or(0); + let api_hash = env::var("API_HASH").unwrap_or_default(); + (api_id, api_hash) + }); let client_id = tdlib_rs::create_client(); Self { api_id, api_hash, + db_path, client_id, auth: AuthManager::new(client_id), chat_manager: ChatManager::new(client_id), @@ -103,9 +103,11 @@ impl TdClient { /// Configures notification manager from app config pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) { self.notification_manager.set_enabled(config.enabled); - self.notification_manager.set_only_mentions(config.only_mentions); + self.notification_manager + .set_only_mentions(config.only_mentions); self.notification_manager.set_timeout(config.timeout_ms); - self.notification_manager.set_urgency(config.urgency.clone()); + self.notification_manager + .set_urgency(config.urgency.clone()); // Note: show_preview is used when formatting notification body } @@ -113,7 +115,8 @@ impl TdClient { /// /// Should be called after chats are loaded to ensure muted chats don't trigger notifications. pub fn sync_notification_muted_chats(&mut self) { - self.notification_manager.sync_muted_chats(&self.chat_manager.chats); + self.notification_manager + .sync_muted_chats(&self.chat_manager.chats); } // Делегирование к auth @@ -254,12 +257,17 @@ impl TdClient { .await } - pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result, String> { + pub async fn get_pinned_messages( + &mut self, + chat_id: ChatId, + ) -> Result, String> { self.message_manager.get_pinned_messages(chat_id).await } pub async fn load_current_pinned_message(&mut self, chat_id: ChatId) { - self.message_manager.load_current_pinned_message(chat_id).await + self.message_manager + .load_current_pinned_message(chat_id) + .await } pub async fn search_messages( @@ -362,6 +370,22 @@ impl TdClient { .await } + // Делегирование файловых операций + + /// Скачивает файл по file_id и возвращает локальный путь. + pub async fn download_file(&self, file_id: i32) -> Result { + match functions::download_file(file_id, 1, 0, 0, true, self.client_id).await { + Ok(tdlib_rs::enums::File::File(file)) => { + if file.local.is_downloading_completed && !file.local.path.is_empty() { + Ok(file.local.path) + } else { + Err("Файл не скачан".to_string()) + } + } + Err(e) => Err(format!("Ошибка скачивания файла: {:?}", e)), + } + } + // Вспомогательные методы pub fn client_id(&self) -> i32 { self.client_id @@ -423,7 +447,10 @@ impl TdClient { self.chat_manager.typing_status.as_ref() } - pub fn set_typing_status(&mut self, status: Option<(crate::types::UserId, String, std::time::Instant)>) { + pub fn set_typing_status( + &mut self, + status: Option<(crate::types::UserId, String, std::time::Instant)>, + ) { self.chat_manager.typing_status = status; } @@ -431,7 +458,9 @@ impl TdClient { &self.message_manager.pending_view_messages } - pub fn pending_view_messages_mut(&mut self) -> &mut Vec<(crate::types::ChatId, Vec)> { + pub fn pending_view_messages_mut( + &mut self, + ) -> &mut Vec<(crate::types::ChatId, Vec)> { &mut self.message_manager.pending_view_messages } @@ -462,19 +491,6 @@ impl TdClient { // ==================== Helper методы для упрощения обработки updates ==================== - /// Находит мутабельную ссылку на чат по ID. - /// - /// Упрощает повторяющийся паттерн `self.chats_mut().iter_mut().find(...)`. - /// - /// # Arguments - /// - /// * `chat_id` - ID чата для поиска - /// - /// # Returns - /// - /// * `Some(&mut ChatInfo)` - если чат найден - /// * `None` - если чат не найден - /// Обрабатываем одно обновление от TDLib pub fn handle_update(&mut self, update: Update) { match update { @@ -500,7 +516,11 @@ impl TdClient { }); // Обновляем позиции если они пришли - for pos in update.positions.iter().filter(|p| matches!(p.list, ChatList::Main)) { + for pos in update + .positions + .iter() + .filter(|p| matches!(p.list, ChatList::Main)) + { crate::tdlib::chat_helpers::update_chat(self, chat_id, |chat| { chat.order = pos.order; chat.is_pinned = pos.is_pinned; @@ -511,27 +531,43 @@ impl TdClient { self.chats_mut().sort_by(|a, b| b.order.cmp(&a.order)); } Update::ChatReadInbox(update) => { - crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { - chat.unread_count = update.unread_count; - }); + crate::tdlib::chat_helpers::update_chat( + self, + ChatId::new(update.chat_id), + |chat| { + chat.unread_count = update.unread_count; + }, + ); } Update::ChatUnreadMentionCount(update) => { - crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { - chat.unread_mention_count = update.unread_mention_count; - }); + crate::tdlib::chat_helpers::update_chat( + self, + ChatId::new(update.chat_id), + |chat| { + chat.unread_mention_count = update.unread_mention_count; + }, + ); } Update::ChatNotificationSettings(update) => { - crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { - // mute_for > 0 означает что чат замьючен - chat.is_muted = update.notification_settings.mute_for > 0; - }); + crate::tdlib::chat_helpers::update_chat( + self, + ChatId::new(update.chat_id), + |chat| { + // mute_for > 0 означает что чат замьючен + chat.is_muted = update.notification_settings.mute_for > 0; + }, + ); } Update::ChatReadOutbox(update) => { // Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения let last_read_msg_id = MessageId::new(update.last_read_outbox_message_id); - crate::tdlib::chat_helpers::update_chat(self, ChatId::new(update.chat_id), |chat| { - chat.last_read_outbox_message_id = last_read_msg_id; - }); + crate::tdlib::chat_helpers::update_chat( + self, + ChatId::new(update.chat_id), + |chat| { + chat.last_read_outbox_message_id = last_read_msg_id; + }, + ); // Если это текущий открытый чат — обновляем is_read у сообщений if Some(ChatId::new(update.chat_id)) == self.current_chat_id() { for msg in self.current_chat_messages_mut().iter_mut() { @@ -569,7 +605,9 @@ impl TdClient { UserStatus::LastMonth(_) => UserOnlineStatus::LastMonth, UserStatus::Empty => UserOnlineStatus::LongTimeAgo, }; - self.user_cache.user_statuses.insert(UserId::new(update.user_id), status); + self.user_cache + .user_statuses + .insert(UserId::new(update.user_id), status); } Update::ConnectionState(update) => { // Обновляем состояние сетевого соединения @@ -597,17 +635,62 @@ impl TdClient { } } - - // Helper functions - pub fn extract_message_text_static(message: &TdMessage) -> (String, Vec) { + pub fn extract_message_text_static( + message: &TdMessage, + ) -> (String, Vec) { use tdlib_rs::enums::MessageContent; match &message.content { - MessageContent::MessageText(text) => (text.text.text.clone(), text.text.entities.clone()), + MessageContent::MessageText(text) => { + (text.text.text.clone(), text.text.entities.clone()) + } _ => (String::new(), Vec::new()), } } + /// Recreates the TDLib client with a new database path. + /// + /// Closes the old client, creates a new one, and spawns TDLib parameter initialization. + pub async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> { + // 1. Close old client + let _ = functions::close(self.client_id).await; + + // 2. Create new client + let new_client = TdClient::new(db_path); + + // 3. Spawn set_tdlib_parameters for new client + let new_client_id = new_client.client_id; + let api_id = new_client.api_id; + let api_hash = new_client.api_hash.clone(); + let db_path_str = new_client.db_path.to_string_lossy().to_string(); + + tokio::spawn(async move { + let _ = functions::set_tdlib_parameters( + false, + db_path_str, + "".to_string(), + "".to_string(), + true, + true, + true, + false, + api_id, + api_hash, + "en".to_string(), + "Desktop".to_string(), + "".to_string(), + env!("CARGO_PKG_VERSION").to_string(), + new_client_id, + ) + .await; + }); + + // 4. Replace self + *self = new_client; + + Ok(()) + } + pub fn extract_content_text(content: &tdlib_rs::enums::MessageContent) -> String { use tdlib_rs::enums::MessageContent; match content { diff --git a/src/tdlib/client_impl.rs b/src/tdlib/client_impl.rs index 2590206..8318199 100644 --- a/src/tdlib/client_impl.rs +++ b/src/tdlib/client_impl.rs @@ -4,9 +4,13 @@ use super::client::TdClient; use super::r#trait::TdClientTrait; -use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus}; +use super::{ + AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, + UserOnlineStatus, +}; use crate::types::{ChatId, MessageId, UserId}; use async_trait::async_trait; +use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; #[async_trait] @@ -51,11 +55,19 @@ impl TdClientTrait for TdClient { } // ============ Message methods ============ - async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result, String> { + async fn get_chat_history( + &mut self, + chat_id: ChatId, + limit: i32, + ) -> Result, String> { self.get_chat_history(chat_id, limit).await } - async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result, String> { + async fn load_older_messages( + &mut self, + chat_id: ChatId, + from_message_id: MessageId, + ) -> Result, String> { self.load_older_messages(chat_id, from_message_id).await } @@ -67,7 +79,11 @@ impl TdClientTrait for TdClient { self.load_current_pinned_message(chat_id).await } - async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result, String> { + async fn search_messages( + &self, + chat_id: ChatId, + query: &str, + ) -> Result, String> { self.search_messages(chat_id, query).await } @@ -147,7 +163,8 @@ impl TdClientTrait for TdClient { chat_id: ChatId, message_id: MessageId, ) -> Result, String> { - self.get_message_available_reactions(chat_id, message_id).await + self.get_message_available_reactions(chat_id, message_id) + .await } async fn toggle_reaction( @@ -159,6 +176,16 @@ impl TdClientTrait for TdClient { self.toggle_reaction(chat_id, message_id, reaction).await } + // ============ File methods ============ + async fn download_file(&self, file_id: i32) -> Result { + self.download_file(file_id).await + } + + async fn download_voice_note(&self, file_id: i32) -> Result { + // Voice notes use the same download mechanism as photos + self.download_file(file_id).await + } + fn client_id(&self) -> i32 { self.client_id() } @@ -265,7 +292,13 @@ impl TdClientTrait for TdClient { // ============ Notification methods ============ fn sync_notification_muted_chats(&mut self) { - self.notification_manager.sync_muted_chats(&self.chat_manager.chats); + self.notification_manager + .sync_muted_chats(&self.chat_manager.chats); + } + + // ============ Account switching ============ + async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String> { + TdClient::recreate_client(self, db_path).await } // ============ Update handling ============ diff --git a/src/tdlib/message_conversion.rs b/src/tdlib/message_conversion.rs index f92fcd1..cf529c4 100644 --- a/src/tdlib/message_conversion.rs +++ b/src/tdlib/message_conversion.rs @@ -7,7 +7,10 @@ use crate::types::MessageId; use tdlib_rs::enums::{MessageContent, MessageSender}; use tdlib_rs::types::Message as TdMessage; -use super::types::{ForwardInfo, ReactionInfo, ReplyInfo}; +use super::types::{ + ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo, + VoiceDownloadState, VoiceInfo, +}; /// Извлекает текст контента из TDLib Message /// @@ -19,9 +22,9 @@ pub fn extract_content_text(msg: &TdMessage) -> String { MessageContent::MessagePhoto(p) => { let caption_text = p.caption.text.clone(); if caption_text.is_empty() { - "[Фото]".to_string() + "📷 [Фото]".to_string() } else { - caption_text + format!("📷 {}", caption_text) } } MessageContent::MessageVideo(v) => { @@ -52,11 +55,12 @@ pub fn extract_content_text(msg: &TdMessage) -> String { } } MessageContent::MessageVoiceNote(v) => { + let duration = v.voice_note.duration; let caption_text = v.caption.text.clone(); if caption_text.is_empty() { - "[Голосовое]".to_string() + format!("🎤 [Голосовое {:.0}s]", duration) } else { - caption_text + format!("🎤 {} ({:.0}s)", caption_text, duration) } } MessageContent::MessageAudio(a) => { @@ -94,9 +98,9 @@ pub async fn extract_sender_name(msg: &TdMessage, client_id: i32) -> String { match &msg.sender_id { MessageSender::User(user) => { match tdlib_rs::functions::get_user(user.user_id, client_id).await { - Ok(tdlib_rs::enums::User::User(u)) => { - format!("{} {}", u.first_name, u.last_name).trim().to_string() - } + Ok(tdlib_rs::enums::User::User(u)) => format!("{} {}", u.first_name, u.last_name) + .trim() + .to_string(), _ => format!("User {}", user.user_id), } } @@ -132,6 +136,57 @@ pub fn extract_reply_info(msg: &TdMessage) -> Option { }) } +/// Извлекает информацию о медиа-контенте из TDLib Message +/// +/// Для MessagePhoto: получает лучший размер фото, извлекает file_id, width, height. +/// Возвращает None для не-медийных типов сообщений. +pub fn extract_media_info(msg: &TdMessage) -> Option { + match &msg.content { + MessageContent::MessagePhoto(p) => { + // Берём лучший (последний = самый большой) размер фото + let best_size = p.photo.sizes.last()?; + let file_id = best_size.photo.id; + let width = best_size.width; + let height = best_size.height; + + // Проверяем, скачан ли файл + let download_state = if !best_size.photo.local.path.is_empty() + && best_size.photo.local.is_downloading_completed + { + PhotoDownloadState::Downloaded(best_size.photo.local.path.clone()) + } else { + PhotoDownloadState::NotDownloaded + }; + + Some(MediaInfo::Photo(PhotoInfo { file_id, width, height, download_state })) + } + MessageContent::MessageVoiceNote(v) => { + let file_id = v.voice_note.voice.id; + let duration = v.voice_note.duration; + let mime_type = v.voice_note.mime_type.clone(); + let waveform = v.voice_note.waveform.clone(); + + // Проверяем, скачан ли файл + let download_state = if !v.voice_note.voice.local.path.is_empty() + && v.voice_note.voice.local.is_downloading_completed + { + VoiceDownloadState::Downloaded(v.voice_note.voice.local.path.clone()) + } else { + VoiceDownloadState::NotDownloaded + }; + + Some(MediaInfo::Voice(VoiceInfo { + file_id, + duration, + mime_type, + waveform, + download_state, + })) + } + _ => None, + } +} + /// Извлекает реакции из TDLib Message pub fn extract_reactions(msg: &TdMessage) -> Vec { msg.interaction_info diff --git a/src/tdlib/message_converter.rs b/src/tdlib/message_converter.rs index 466b6e2..5cdf92a 100644 --- a/src/tdlib/message_converter.rs +++ b/src/tdlib/message_converter.rs @@ -11,11 +11,7 @@ use super::client::TdClient; use super::types::{ForwardInfo, MessageInfo, ReactionInfo, ReplyInfo}; /// Конвертирует TDLib сообщение в MessageInfo -pub fn convert_message( - client: &mut TdClient, - message: &TdMessage, - chat_id: ChatId, -) -> MessageInfo { +pub fn convert_message(client: &mut TdClient, message: &TdMessage, chat_id: ChatId) -> MessageInfo { let sender_name = match &message.sender_id { tdlib_rs::enums::MessageSender::User(user) => { // Пробуем получить имя из кеша (get обновляет LRU порядок) @@ -76,7 +72,8 @@ pub fn convert_message( .text(content) .entities(entities) .date(message.date) - .edit_date(message.edit_date); + .edit_date(message.edit_date) + .media_album_id(message.media_album_id); // Применяем флаги if message.is_outgoing { @@ -119,7 +116,7 @@ pub fn extract_reply_info(client: &TdClient, message: &TdMessage) -> Option Option Option None, } @@ -218,12 +206,7 @@ pub fn update_reply_info_from_loaded_messages(client: &mut TdClient) { let msg_data: std::collections::HashMap = client .current_chat_messages() .iter() - .map(|m| { - ( - m.id().as_i64(), - (m.sender_name().to_string(), m.text().to_string()), - ) - }) + .map(|m| (m.id().as_i64(), (m.sender_name().to_string(), m.text().to_string()))) .collect(); // Обновляем reply_to для сообщений с неполными данными diff --git a/src/tdlib/messages/convert.rs b/src/tdlib/messages/convert.rs new file mode 100644 index 0000000..0e4a4e6 --- /dev/null +++ b/src/tdlib/messages/convert.rs @@ -0,0 +1,137 @@ +//! 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 { + use crate::tdlib::message_conversion::{ + extract_content_text, extract_entities, extract_forward_info, extract_media_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 media = extract_media_info(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) + .media_album_id(msg.media_album_id); + + 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); + + if let Some(media) = media { + builder = builder.media(media); + } + + 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 = 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(); + }); + } +} diff --git a/src/tdlib/messages/mod.rs b/src/tdlib/messages/mod.rs new file mode 100644 index 0000000..f08419d --- /dev/null +++ b/src/tdlib/messages/mod.rs @@ -0,0 +1,102 @@ +//! 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, + + /// ID текущего открытого чата. + pub current_chat_id: Option, + + /// Текущее закрепленное сообщение открытого чата. + pub current_pinned_message: Option, + + /// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids). + pub pending_view_messages: Vec<(ChatId, Vec)>, + + /// 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)); + } + } +} diff --git a/src/tdlib/messages.rs b/src/tdlib/messages/operations.rs similarity index 67% rename from src/tdlib/messages.rs rename to src/tdlib/messages/operations.rs index 6e4b18b..651446f 100644 --- a/src/tdlib/messages.rs +++ b/src/tdlib/messages/operations.rs @@ -1,103 +1,21 @@ -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::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, - - /// ID текущего открытого чата. - pub current_chat_id: Option, - - /// Текущее закрепленное сообщение открытого чата. - pub current_pinned_message: Option, - - /// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids). - pub pending_view_messages: Vec<(ChatId, Vec)>, - - /// 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 синхронизирует их с сервера. @@ -172,7 +90,7 @@ impl MessageManager { }; let received_count = messages_obj.messages.len(); - + // Если получили пустой результат if messages_obj.messages.is_empty() { consecutive_empty_results += 1; @@ -183,15 +101,16 @@ impl MessageManager { // Пробуем еще раз continue; } - + // Получили сообщения - сбрасываем счетчик consecutive_empty_results = 0; - + // Если это первая загрузка и получили мало сообщений - продолжаем попытки // TDLib может подгружать данные с сервера постепенно - if all_messages.is_empty() && - received_count < (chunk_size as usize) && - attempt < max_attempts_per_chunk { + if all_messages.is_empty() + && received_count < (chunk_size as usize) + && attempt < max_attempts_per_chunk + { // Даём TDLib время на синхронизацию с сервером sleep(Duration::from_millis(100)).await; continue; @@ -212,7 +131,7 @@ impl MessageManager { if !chunk_messages.is_empty() { // Для следующей итерации: ID самого старого сообщения из текущего чанка from_message_id = chunk_messages[0].id().as_i64(); - + // ВАЖНО: Вставляем чанк В НАЧАЛО списка! // Первый чанк содержит НОВЫЕ сообщения (например 51-100) // Второй чанк содержит СТАРЫЕ сообщения (например 1-50) @@ -224,7 +143,7 @@ impl MessageManager { // Последующие чанки - вставляем в начало all_messages.splice(0..0, chunk_messages); } - + chunk_loaded = true; } @@ -241,7 +160,7 @@ impl MessageManager { break; } } - + Ok(all_messages) } @@ -287,11 +206,9 @@ impl MessageManager { match result { Ok(tdlib_rs::enums::Messages::Messages(messages_obj)) => { let mut messages = Vec::new(); - for msg_opt in messages_obj.messages.iter().rev() { - if let Some(msg) = msg_opt { - if let Some(info) = self.convert_message(msg).await { - messages.push(info); - } + for msg in messages_obj.messages.iter().rev().flatten() { + if let Some(info) = self.convert_message(msg).await { + messages.push(info); } } Ok(messages) @@ -319,17 +236,20 @@ impl MessageManager { /// let pinned = msg_manager.get_pinned_messages(chat_id).await?; /// println!("Found {} pinned messages", pinned.len()); /// ``` - pub async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result, String> { + pub async fn get_pinned_messages( + &mut self, + chat_id: ChatId, + ) -> Result, String> { let result = functions::search_chat_messages( chat_id.as_i64(), String::new(), None, - 0, // from_message_id - 0, // offset - 100, // limit + 0, // from_message_id + 0, // offset + 100, // limit Some(SearchMessagesFilter::Pinned), - 0, // message_thread_id - 0, // saved_messages_topic_id + 0, // message_thread_id + 0, // saved_messages_topic_id self.client_id, ) .await; @@ -364,13 +284,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 больше не существует - // } - // _ => {} - // } } /// Выполняет поиск сообщений по тексту в указанном чате. @@ -403,8 +316,8 @@ impl MessageManager { 0, // offset 100, // limit None, - 0, // message_thread_id - 0, // saved_messages_topic_id + 0, // message_thread_id + 0, // saved_messages_topic_id self.client_id, ) .await; @@ -474,15 +387,9 @@ impl MessageManager { .await { Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => { - FormattedText { - text: ft.text, - entities: ft.entities, - } + FormattedText { text: ft.text, entities: ft.entities } } - Err(_) => FormattedText { - text: text.clone(), - entities: vec![], - }, + Err(_) => FormattedText { text: text.clone(), entities: vec![] }, }; let content = InputMessageContent::InputMessageText(InputMessageText { @@ -515,7 +422,7 @@ impl MessageManager { .convert_message(&msg) .await .ok_or_else(|| "Не удалось конвертировать сообщение".to_string())?; - + // Добавляем reply_info если был передан if let Some(reply) = reply_info { msg_info.interactions.reply_to = Some(reply); @@ -553,15 +460,9 @@ impl MessageManager { .await { Ok(tdlib_rs::enums::FormattedText::FormattedText(ft)) => { - FormattedText { - text: ft.text, - entities: ft.entities, - } + FormattedText { text: ft.text, entities: ft.entities } } - Err(_) => FormattedText { - text: text.clone(), - entities: vec![], - }, + Err(_) => FormattedText { text: text.clone(), entities: vec![] }, }; let content = InputMessageContent::InputMessageText(InputMessageText { @@ -570,8 +471,13 @@ impl MessageManager { clear_draft: true, }); - let result = - functions::edit_message_text(chat_id.as_i64(), message_id.as_i64(), content, self.client_id).await; + let result = functions::edit_message_text( + chat_id.as_i64(), + message_id.as_i64(), + content, + self.client_id, + ) + .await; match result { Ok(tdlib_rs::enums::Message::Message(msg)) => self @@ -602,7 +508,8 @@ impl MessageManager { ) -> Result<(), String> { let message_ids_i64: Vec = message_ids.into_iter().map(|id| id.as_i64()).collect(); let result = - functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id).await; + functions::delete_messages(chat_id.as_i64(), message_ids_i64, revoke, self.client_id) + .await; match result { Ok(_) => Ok(()), Err(e) => Err(format!("Ошибка удаления: {:?}", e)), @@ -670,17 +577,15 @@ impl MessageManager { reply_to: None, date: 0, input_message_text: InputMessageContent::InputMessageText(InputMessageText { - text: FormattedText { - text: text.clone(), - entities: vec![], - }, + text: FormattedText { text: text.clone(), entities: vec![] }, link_preview_options: None, clear_draft: false, }), }) }; - let result = functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await; + let result = + functions::set_chat_draft_message(chat_id.as_i64(), 0, draft, self.client_id).await; match result { Ok(_) => Ok(()), @@ -705,132 +610,8 @@ impl MessageManager { for (chat_id, message_ids) in batch { let ids: Vec = message_ids.iter().map(|id| id.as_i64()).collect(); - let _ = functions::view_messages(chat_id.as_i64(), ids, None, true, self.client_id).await; + 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 { - 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 = 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(); - }); - } } diff --git a/src/tdlib/mod.rs b/src/tdlib/mod.rs index 219967d..842ebc9 100644 --- a/src/tdlib/mod.rs +++ b/src/tdlib/mod.rs @@ -4,8 +4,8 @@ mod chat_helpers; // Chat management helpers pub mod chats; pub mod client; mod client_impl; // Private module for trait implementation -mod message_converter; // Message conversion utilities (for client.rs) mod message_conversion; // Message conversion utilities (for messages.rs) +mod message_converter; // Message conversion utilities (for client.rs) pub mod messages; pub mod reactions; pub mod r#trait; @@ -17,9 +17,15 @@ pub mod users; pub use auth::AuthState; pub use client::TdClient; pub use r#trait::TdClientTrait; +#[allow(unused_imports)] pub use types::{ - ChatInfo, FolderInfo, MessageBuilder, MessageInfo, NetworkState, ProfileInfo, ReplyInfo, UserOnlineStatus, + ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState, + PhotoInfo, PlaybackState, PlaybackStatus, ProfileInfo, ReplyInfo, UserOnlineStatus, + VoiceDownloadState, VoiceInfo, }; + +#[cfg(feature = "images")] +pub use types::ImageModalState; pub use users::UserCache; // Re-export ChatAction для удобства diff --git a/src/tdlib/reactions.rs b/src/tdlib/reactions.rs index 5aa285a..9682aa4 100644 --- a/src/tdlib/reactions.rs +++ b/src/tdlib/reactions.rs @@ -69,7 +69,8 @@ impl ReactionManager { message_id: MessageId, ) -> Result, String> { // Получаем сообщение - let msg_result = functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await; + let msg_result = + functions::get_message(chat_id.as_i64(), message_id.as_i64(), self.client_id).await; let _msg = match msg_result { Ok(m) => m, Err(e) => return Err(format!("Ошибка получения сообщения: {:?}", e)), diff --git a/src/tdlib/trait.rs b/src/tdlib/trait.rs index 70d1cfb..bc35045 100644 --- a/src/tdlib/trait.rs +++ b/src/tdlib/trait.rs @@ -5,6 +5,7 @@ use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus}; use crate::types::{ChatId, MessageId, UserId}; use async_trait::async_trait; +use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; use super::ChatInfo; @@ -13,6 +14,7 @@ use super::ChatInfo; /// /// This trait defines the interface for both real and fake TDLib clients, /// enabling dependency injection and easier testing. +#[allow(dead_code)] #[async_trait] pub trait TdClientTrait: Send { // ============ Auth methods ============ @@ -31,11 +33,23 @@ pub trait TdClientTrait: Send { fn clear_stale_typing_status(&mut self) -> bool; // ============ Message methods ============ - async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result, String>; - async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result, String>; + async fn get_chat_history( + &mut self, + chat_id: ChatId, + limit: i32, + ) -> Result, String>; + async fn load_older_messages( + &mut self, + chat_id: ChatId, + from_message_id: MessageId, + ) -> Result, String>; async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result, String>; async fn load_current_pinned_message(&mut self, chat_id: ChatId); - async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result, String>; + async fn search_messages( + &self, + chat_id: ChatId, + query: &str, + ) -> Result, String>; async fn send_message( &mut self, @@ -90,6 +104,10 @@ pub trait TdClientTrait: Send { reaction: String, ) -> Result<(), String>; + // ============ File methods ============ + async fn download_file(&self, file_id: i32) -> Result; + async fn download_voice_note(&self, file_id: i32) -> Result; + // ============ Getters (immutable) ============ fn client_id(&self) -> i32; async fn get_me(&self) -> Result; @@ -123,6 +141,13 @@ pub trait TdClientTrait: Send { // ============ Notification methods ============ fn sync_notification_muted_chats(&mut self); + // ============ Account switching ============ + /// Recreates the client with a new database path (for account switching). + /// + /// For real TdClient: closes old client, creates new one, inits TDLib parameters. + /// For FakeTdClient: no-op. + async fn recreate_client(&mut self, db_path: PathBuf) -> Result<(), String>; + // ============ Update handling ============ fn handle_update(&mut self, update: Update); } diff --git a/src/tdlib/types.rs b/src/tdlib/types.rs index 0829d65..d1237b3 100644 --- a/src/tdlib/types.rs +++ b/src/tdlib/types.rs @@ -54,6 +54,54 @@ pub struct ReactionInfo { pub is_chosen: bool, } +/// Информация о медиа-контенте сообщения +#[derive(Debug, Clone)] +pub enum MediaInfo { + Photo(PhotoInfo), + Voice(VoiceInfo), +} + +/// Информация о фотографии в сообщении +#[derive(Debug, Clone)] +pub struct PhotoInfo { + pub file_id: i32, + pub width: i32, + pub height: i32, + pub download_state: PhotoDownloadState, +} + +/// Состояние загрузки фотографии +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub enum PhotoDownloadState { + NotDownloaded, + Downloading, + Downloaded(String), + Error(String), +} + +/// Информация о голосовом сообщении +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct VoiceInfo { + pub file_id: i32, + pub duration: i32, // seconds + pub mime_type: String, + /// Waveform данные для визуализации (base64-encoded строка амплитуд) + pub waveform: String, + pub download_state: VoiceDownloadState, +} + +/// Состояние загрузки голосового сообщения +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub enum VoiceDownloadState { + NotDownloaded, + Downloading, + Downloaded(String), // path to cached OGG file + Error(String), +} + /// Метаданные сообщения (ID, отправитель, время) #[derive(Debug, Clone)] pub struct MessageMetadata { @@ -62,14 +110,18 @@ pub struct MessageMetadata { pub date: i32, /// Дата редактирования (0 если не редактировалось) pub edit_date: i32, + /// ID медиа-альбома (0 если не часть альбома) + pub media_album_id: i64, } /// Контент сообщения (текст и форматирование) -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone)] pub struct MessageContent { pub text: String, /// Сущности форматирования (bold, italic, code и т.д.) pub entities: Vec, + /// Медиа-контент (фото, видео и т.д.) + pub media: Option, } /// Состояние и права доступа к сообщению @@ -106,6 +158,7 @@ pub struct MessageInfo { impl MessageInfo { /// Создать новое сообщение + #[allow(clippy::too_many_arguments)] pub fn new( id: MessageId, sender_name: String, @@ -128,11 +181,9 @@ impl MessageInfo { sender_name, date, edit_date, + media_album_id: 0, }, - content: MessageContent { - text: content, - entities, - }, + content: MessageContent { text: content, entities, media: None }, state: MessageState { is_outgoing, is_read, @@ -140,11 +191,7 @@ impl MessageInfo { can_be_deleted_only_for_self, can_be_deleted_for_all_users, }, - interactions: MessageInteractions { - reply_to, - forward_from, - reactions, - }, + interactions: MessageInteractions { reply_to, forward_from, reactions }, } } @@ -165,6 +212,10 @@ impl MessageInfo { self.metadata.edit_date > 0 } + pub fn media_album_id(&self) -> i64 { + self.metadata.media_album_id + } + pub fn text(&self) -> &str { &self.content.text } @@ -196,13 +247,53 @@ impl MessageInfo { /// Checks if the message contains a mention (@username or user mention) pub fn has_mention(&self) -> bool { self.content.entities.iter().any(|entity| { - matches!( - entity.r#type, - TextEntityType::Mention | TextEntityType::MentionName(_) - ) + matches!(entity.r#type, TextEntityType::Mention | TextEntityType::MentionName(_)) }) } + /// Проверяет, содержит ли сообщение фото + pub fn has_photo(&self) -> bool { + matches!(self.content.media, Some(MediaInfo::Photo(_))) + } + + /// Возвращает ссылку на PhotoInfo (если есть) + pub fn photo_info(&self) -> Option<&PhotoInfo> { + match &self.content.media { + Some(MediaInfo::Photo(info)) => Some(info), + _ => None, + } + } + + /// Возвращает мутабельную ссылку на PhotoInfo (если есть) + pub fn photo_info_mut(&mut self) -> Option<&mut PhotoInfo> { + match &mut self.content.media { + Some(MediaInfo::Photo(info)) => Some(info), + _ => None, + } + } + + /// Проверяет, содержит ли сообщение голосовое + pub fn has_voice(&self) -> bool { + matches!(self.content.media, Some(MediaInfo::Voice(_))) + } + + /// Возвращает ссылку на VoiceInfo (если есть) + pub fn voice_info(&self) -> Option<&VoiceInfo> { + match &self.content.media { + Some(MediaInfo::Voice(info)) => Some(info), + _ => None, + } + } + + /// Возвращает мутабельную ссылку на VoiceInfo (если есть) + #[allow(dead_code)] + pub fn voice_info_mut(&mut self) -> Option<&mut VoiceInfo> { + match &mut self.content.media { + Some(MediaInfo::Voice(info)) => Some(info), + _ => None, + } + } + pub fn reply_to(&self) -> Option<&ReplyInfo> { self.interactions.reply_to.as_ref() } @@ -217,13 +308,13 @@ impl MessageInfo { } /// Builder для удобного создания MessageInfo с fluent API -/// +/// /// # Примеры -/// +/// /// ``` /// use tele_tui::tdlib::MessageBuilder; /// use tele_tui::types::MessageId; -/// +/// /// let message = MessageBuilder::new(MessageId::new(123)) /// .sender_name("Alice") /// .text("Hello, world!") @@ -246,6 +337,8 @@ pub struct MessageBuilder { reply_to: Option, forward_from: Option, reactions: Vec, + media: Option, + media_album_id: i64, } impl MessageBuilder { @@ -266,6 +359,8 @@ impl MessageBuilder { reply_to: None, forward_from: None, reactions: Vec::new(), + media: None, + media_album_id: 0, } } @@ -363,9 +458,21 @@ impl MessageBuilder { self } + /// Установить медиа-контент + pub fn media(mut self, media: MediaInfo) -> Self { + self.media = Some(media); + self + } + + /// Установить ID медиа-альбома + pub fn media_album_id(mut self, id: i64) -> Self { + self.media_album_id = id; + self + } + /// Построить MessageInfo из данных builder'а pub fn build(self) -> MessageInfo { - MessageInfo::new( + let mut msg = MessageInfo::new( self.id, self.sender_name, self.is_outgoing, @@ -380,11 +487,13 @@ impl MessageBuilder { self.reply_to, self.forward_from, self.reactions, - ) + ); + msg.content.media = self.media; + msg.metadata.media_album_id = self.media_album_id; + msg } } - #[cfg(test)] mod tests { use super::*; @@ -452,9 +561,7 @@ mod tests { #[test] fn test_message_builder_with_reactions() { let reaction = ReactionInfo { - emoji: "👍".to_string(), - count: 5, - is_chosen: true, + emoji: "👍".to_string(), count: 5, is_chosen: true }; let message = MessageBuilder::new(MessageId::new(300)) @@ -512,9 +619,9 @@ mod tests { .entities(vec![TextEntity { offset: 6, length: 4, - r#type: TextEntityType::MentionName( - tdlib_rs::types::TextEntityTypeMentionName { user_id: 123 }, - ), + r#type: TextEntityType::MentionName(tdlib_rs::types::TextEntityTypeMentionName { + user_id: 123, + }), }]) .build(); assert!(message_with_mention_name.has_mention()); @@ -574,3 +681,44 @@ pub enum UserOnlineStatus { /// Оффлайн с указанием времени (unix timestamp) Offline(i32), } + +/// Состояние модального окна для просмотра изображения +#[cfg(feature = "images")] +#[derive(Debug, Clone)] +pub struct ImageModalState { + /// ID сообщения с фото + pub message_id: MessageId, + /// Путь к файлу изображения + pub photo_path: String, + /// Ширина оригинального изображения + pub photo_width: i32, + /// Высота оригинального изображения + pub photo_height: i32, +} + +/// Состояние воспроизведения голосового сообщения +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub struct PlaybackState { + /// ID сообщения, которое воспроизводится + pub message_id: MessageId, + /// Статус воспроизведения + pub status: PlaybackStatus, + /// Текущая позиция (секунды) + pub position: f32, + /// Общая длительность (секунды) + pub duration: f32, + /// Громкость (0.0 - 1.0) + pub volume: f32, +} + +/// Статус воспроизведения +#[allow(dead_code)] +#[derive(Debug, Clone, PartialEq)] +pub enum PlaybackStatus { + Playing, + Paused, + Stopped, + Loading, + Error(String), +} diff --git a/src/tdlib/update_handlers.rs b/src/tdlib/update_handlers.rs index cd933e5..192859a 100644 --- a/src/tdlib/update_handlers.rs +++ b/src/tdlib/update_handlers.rs @@ -5,12 +5,10 @@ use crate::types::{ChatId, MessageId, UserId}; use std::time::Instant; -use tdlib_rs::enums::{ - AuthorizationState, ChatAction, ChatList, MessageSender, -}; +use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, MessageSender}; use tdlib_rs::types::{ - UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, - UpdateMessageInteractionInfo, UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser, + UpdateChatAction, UpdateChatDraftMessage, UpdateChatPosition, UpdateMessageInteractionInfo, + UpdateMessageSendSucceeded, UpdateNewMessage, UpdateUser, }; use super::auth::AuthState; @@ -25,24 +23,24 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag if Some(chat_id) != client.current_chat_id() { // Find and clone chat info to avoid borrow checker issues if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() { - let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id); + let msg_info = + crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id); // Get sender name (from message or user cache) let sender_name = msg_info.sender_name(); // Send notification - let _ = client.notification_manager.notify_new_message( - &chat, - &msg_info, - sender_name, - ); + let _ = client + .notification_manager + .notify_new_message(&chat, &msg_info, sender_name); } return; } // Добавляем новое сообщение если это текущий открытый чат - let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id); + let msg_info = + crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id); let msg_id = msg_info.id(); let is_incoming = !msg_info.is_outgoing(); @@ -74,7 +72,9 @@ pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessag client.push_message(msg_info.clone()); // Если это входящее сообщение — добавляем в очередь для отметки как прочитанное if is_incoming { - client.pending_view_messages_mut().push((chat_id, vec![msg_id])); + client + .pending_view_messages_mut() + .push((chat_id, vec![msg_id])); } } } @@ -105,7 +105,7 @@ pub fn handle_chat_action_update(client: &mut TdClient, update: UpdateChatAction ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()), ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()), ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()), - ChatAction::Cancel | _ => None, // Отмена или неизвестное действие + _ => None, // Отмена или неизвестное действие }; match action_text { @@ -181,14 +181,21 @@ pub fn handle_user_update(client: &mut TdClient, update: UpdateUser) { } else { format!("{} {}", user.first_name, user.last_name) }; - client.user_cache.user_names.insert(UserId::new(user.id), display_name); + client + .user_cache + .user_names + .insert(UserId::new(user.id), display_name); // Сохраняем username если есть (с упрощённым извлечением через and_then) - if let Some(username) = user.usernames + if let Some(username) = user + .usernames .as_ref() .and_then(|u| u.active_usernames.first()) { - client.user_cache.user_usernames.insert(UserId::new(user.id), username.to_string()); + client + .user_cache + .user_usernames + .insert(UserId::new(user.id), username.to_string()); // Обновляем username в чатах, связанных с этим пользователем for (&chat_id, &user_id) in &client.user_cache.chat_user_ids.clone() { if user_id == UserId::new(user.id) { @@ -273,7 +280,8 @@ pub fn handle_message_send_succeeded_update( }; // Конвертируем новое сообщение - let mut new_msg = crate::tdlib::message_converter::convert_message(client, &update.message, chat_id); + let mut new_msg = + crate::tdlib::message_converter::convert_message(client, &update.message, chat_id); // Сохраняем reply_info из старого сообщения (если было) let old_reply = client.current_chat_messages()[idx] diff --git a/src/tdlib/users.rs b/src/tdlib/users.rs index 641a36b..264f346 100644 --- a/src/tdlib/users.rs +++ b/src/tdlib/users.rs @@ -175,7 +175,9 @@ impl UserCache { } // Сохраняем имя - let display_name = format!("{} {}", user.first_name, user.last_name).trim().to_string(); + let display_name = format!("{} {}", user.first_name, user.last_name) + .trim() + .to_string(); self.user_names.insert(UserId::new(user_id), display_name); // Обновляем статус @@ -211,6 +213,7 @@ impl UserCache { /// # Returns /// /// Имя пользователя (first_name + last_name) или "User {id}" если не найден. + #[allow(dead_code)] pub async fn get_user_name(&self, user_id: UserId) -> String { // Сначала пытаемся получить из кэша if let Some(name) = self.user_names.peek(&user_id) { @@ -220,7 +223,9 @@ impl UserCache { // Загружаем пользователя match functions::get_user(user_id.as_i64(), self.client_id).await { Ok(User::User(user)) => { - let name = format!("{} {}", user.first_name, user.last_name).trim().to_string(); + let name = format!("{} {}", user.first_name, user.last_name) + .trim() + .to_string(); name } _ => format!("User {}", user_id.as_i64()), @@ -257,8 +262,7 @@ impl UserCache { } Err(_) => { // Если не удалось загрузить, сохраняем placeholder - self.user_names - .insert(user_id, format!("User {}", user_id)); + self.user_names.insert(user_id, format!("User {}", user_id)); } } } diff --git a/src/types.rs b/src/types.rs index ae0dffc..4926792 100644 --- a/src/types.rs +++ b/src/types.rs @@ -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; @@ -134,7 +136,7 @@ mod tests { // let chat_id = ChatId::new(1); // let message_id = MessageId::new(1); // if chat_id == message_id { } // ERROR: mismatched types - + // Runtime values can be the same, but types are different let chat_id = ChatId::new(1); let message_id = MessageId::new(1); diff --git a/src/ui/auth.rs b/src/ui/auth.rs index ac45d61..eecc2ab 100644 --- a/src/ui/auth.rs +++ b/src/ui/auth.rs @@ -1,6 +1,6 @@ use crate::app::App; -use crate::tdlib::TdClientTrait; use crate::tdlib::AuthState; +use crate::tdlib::TdClientTrait; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, diff --git a/src/ui/chat_list.rs b/src/ui/chat_list.rs index 181ffe5..2e5cc64 100644 --- a/src/ui/chat_list.rs +++ b/src/ui/chat_list.rs @@ -1,3 +1,6 @@ +//! Chat list panel: search box, chat items, and user online status. + +use crate::app::methods::{compose::ComposeMethods, search::SearchMethods}; use crate::app::App; use crate::tdlib::TdClientTrait; use crate::tdlib::UserOnlineStatus; @@ -68,55 +71,18 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state); - // User status - показываем статус выбранного чата - let (status_text, status_color) = if let Some(chat_id) = app.selected_chat_id { - match app.td_client.get_user_status_by_chat_id(chat_id) { - Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green), - Some(UserOnlineStatus::Recently) => ("был(а) недавно".to_string(), Color::Yellow), - Some(UserOnlineStatus::Offline(was_online)) => { - let formatted = format_was_online(*was_online); - (formatted, Color::Gray) - } - Some(UserOnlineStatus::LastWeek) => { - ("был(а) на этой неделе".to_string(), Color::DarkGray) - } - Some(UserOnlineStatus::LastMonth) => { - ("был(а) в этом месяце".to_string(), Color::DarkGray) - } - Some(UserOnlineStatus::LongTimeAgo) => ("был(а) давно".to_string(), Color::DarkGray), - None => ("".to_string(), Color::DarkGray), // Для групп/каналов - } + // User status - показываем статус выбранного или выделенного чата + let status_chat_id = if app.selected_chat_id.is_some() { + app.selected_chat_id } else { - // Показываем статус выделенного в списке чата let filtered = app.get_filtered_chats(); - if let Some(i) = app.chat_list_state.selected() { - if let Some(chat) = filtered.get(i) { - match app.td_client.get_user_status_by_chat_id(chat.id) { - Some(UserOnlineStatus::Online) => ("● онлайн".to_string(), Color::Green), - Some(UserOnlineStatus::Recently) => { - ("был(а) недавно".to_string(), Color::Yellow) - } - Some(UserOnlineStatus::Offline(was_online)) => { - let formatted = format_was_online(*was_online); - (formatted, Color::Gray) - } - Some(UserOnlineStatus::LastWeek) => { - ("был(а) на этой неделе".to_string(), Color::DarkGray) - } - Some(UserOnlineStatus::LastMonth) => { - ("был(а) в этом месяце".to_string(), Color::DarkGray) - } - Some(UserOnlineStatus::LongTimeAgo) => { - ("был(а) давно".to_string(), Color::DarkGray) - } - None => ("".to_string(), Color::DarkGray), - } - } else { - ("".to_string(), Color::DarkGray) - } - } else { - ("".to_string(), Color::DarkGray) - } + app.chat_list_state + .selected() + .and_then(|i| filtered.get(i).map(|c| c.id)) + }; + let (status_text, status_color) = match status_chat_id { + Some(chat_id) => format_user_status(app.td_client.get_user_status_by_chat_id(chat_id)), + None => ("".to_string(), Color::DarkGray), }; let status = Paragraph::new(status_text) @@ -125,7 +91,17 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { 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), + } } diff --git a/src/ui/components/emoji_picker.rs b/src/ui/components/emoji_picker.rs index e0a384c..94ac19c 100644 --- a/src/ui/components/emoji_picker.rs +++ b/src/ui/components/emoji_picker.rs @@ -21,7 +21,7 @@ pub fn render_emoji_picker( ) { // Размеры модалки (зависят от количества реакций) let emojis_per_row = 8; - let rows = (available_reactions.len() + emojis_per_row - 1) / emojis_per_row; + let rows = available_reactions.len().div_ceil(emojis_per_row); let modal_width = 50u16; let modal_height = (rows + 4) as u16; // +4 для заголовка, отступов и подсказки @@ -29,12 +29,7 @@ pub fn render_emoji_picker( let x = area.x + (area.width.saturating_sub(modal_width)) / 2; let y = area.y + (area.height.saturating_sub(modal_height)) / 2; - let modal_area = Rect::new( - x, - y, - modal_width.min(area.width), - modal_height.min(area.height), - ); + let modal_area = Rect::new(x, y, modal_width.min(area.width), modal_height.min(area.height)); // Очищаем область под модалкой f.render_widget(Clear, modal_area); @@ -87,10 +82,7 @@ pub fn render_emoji_picker( .add_modifier(Modifier::BOLD), ), Span::raw("Добавить "), - Span::styled( - " [Esc] ", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ), + Span::styled(" [Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), Span::raw("Отмена"), ])); diff --git a/src/ui/components/input_field.rs b/src/ui/components/input_field.rs index ddca359..66a259a 100644 --- a/src/ui/components/input_field.rs +++ b/src/ui/components/input_field.rs @@ -34,10 +34,7 @@ pub fn render_input_field( // Символ под курсором (или █ если курсор в конце) if safe_cursor_pos < chars.len() { let cursor_char = chars[safe_cursor_pos].to_string(); - spans.push(Span::styled( - cursor_char, - Style::default().fg(Color::Black).bg(color), - )); + spans.push(Span::styled(cursor_char, Style::default().fg(Color::Black).bg(color))); } else { // Курсор в конце - показываем блок spans.push(Span::styled("█", Style::default().fg(color))); diff --git a/src/ui/components/message_bubble.rs b/src/ui/components/message_bubble.rs index 49f1f94..db26cf5 100644 --- a/src/ui/components/message_bubble.rs +++ b/src/ui/components/message_bubble.rs @@ -7,7 +7,9 @@ use crate::config::Config; use crate::formatting; -use crate::tdlib::MessageInfo; +#[cfg(feature = "images")] +use crate::tdlib::PhotoDownloadState; +use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus}; use crate::types::MessageId; use crate::utils::{format_date, format_timestamp_with_tz}; use ratatui::{ @@ -22,19 +24,34 @@ struct WrappedLine { start_offset: usize, } -/// Разбивает текст на строки с учётом максимальной ширины +/// Разбивает текст на строки с учётом максимальной ширины и `\n` fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { + let mut all_lines = Vec::new(); + let mut char_offset = 0; + + for segment in text.split('\n') { + let wrapped = wrap_paragraph(segment, max_width, char_offset); + all_lines.extend(wrapped); + char_offset += segment.chars().count() + 1; // +1 за '\n' + } + + if all_lines.is_empty() { + all_lines.push(WrappedLine { text: String::new(), start_offset: 0 }); + } + + all_lines +} + +/// Разбивает один абзац (без `\n`) на строки по ширине +fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec { if max_width == 0 { - return vec![WrappedLine { - text: text.to_string(), - start_offset: 0, - }]; + return vec![WrappedLine { text: text.to_string(), start_offset: base_offset }]; } let mut result = Vec::new(); let mut current_line = String::new(); let mut current_width = 0; - let mut line_start_offset = 0; + let mut line_start_offset = base_offset; let chars: Vec = text.chars().collect(); let mut word_start = 0; @@ -49,7 +66,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if current_width == 0 { current_line = word; current_width = word_width; - line_start_offset = word_start; + line_start_offset = base_offset + word_start; } else if current_width + 1 + word_width <= max_width { current_line.push(' '); current_line.push_str(&word); @@ -61,7 +78,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { }); current_line = word; current_width = word_width; - line_start_offset = word_start; + line_start_offset = base_offset + word_start; } in_word = false; } @@ -77,7 +94,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if current_width == 0 { current_line = word; - line_start_offset = word_start; + line_start_offset = base_offset + word_start; } else if current_width + 1 + word_width <= max_width { current_line.push(' '); current_line.push_str(&word); @@ -87,7 +104,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { start_offset: line_start_offset, }); current_line = word; - line_start_offset = word_start; + line_start_offset = base_offset + word_start; } } @@ -99,10 +116,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { } if result.is_empty() { - result.push(WrappedLine { - text: String::new(), - start_offset: 0, - }); + result.push(WrappedLine { text: String::new(), start_offset: base_offset }); } result @@ -115,7 +129,11 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { /// * `date` - timestamp сообщения /// * `content_width` - ширина области для центрирования /// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху) -pub fn render_date_separator(date: i32, content_width: usize, is_first: bool) -> Vec> { +pub fn render_date_separator( + date: i32, + content_width: usize, + is_first: bool, +) -> Vec> { let mut lines = Vec::new(); if !is_first { @@ -198,6 +216,7 @@ pub fn render_message_bubble( config: &Config, content_width: usize, selected_msg_id: Option, + playback_state: Option<&PlaybackState>, ) -> Vec> { let mut lines = Vec::new(); let is_selected = selected_msg_id == Some(msg.id()); @@ -252,10 +271,8 @@ pub fn render_message_bubble( Span::styled(reply_line, Style::default().fg(Color::Cyan)), ])); } else { - lines.push(Line::from(vec![Span::styled( - reply_line, - Style::default().fg(Color::Cyan), - )])); + lines + .push(Line::from(vec![Span::styled(reply_line, Style::default().fg(Color::Cyan))])); } } @@ -277,22 +294,35 @@ pub fn render_message_bubble( let is_last_line = i == total_wrapped - 1; let line_len = wrapped.text.chars().count(); - let line_entities = - formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len); - let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color); + let line_entities = formatting::adjust_entities_for_substring( + msg.entities(), + wrapped.start_offset, + line_len, + ); + let formatted_spans = + formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color); if is_last_line { let full_len = line_len + time_mark_len + marker_len; let padding = content_width.saturating_sub(full_len + 1); let mut line_spans = vec![Span::raw(" ".repeat(padding))]; - if is_selected { + if is_selected && i == 0 { + // Одна строка — маркер на ней line_spans.push(Span::styled( selection_marker, - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), )); + } else if is_selected { + // Последняя строка multi-line — пробелы вместо маркера + line_spans.push(Span::raw(" ".repeat(marker_len))); } line_spans.extend(formatted_spans); - line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray))); + line_spans.push(Span::styled( + format!(" {}", time_mark), + Style::default().fg(Color::Gray), + )); lines.push(Line::from(line_spans)); } else { let padding = content_width.saturating_sub(line_len + marker_len + 1); @@ -300,8 +330,13 @@ pub fn render_message_bubble( if i == 0 && is_selected { line_spans.push(Span::styled( selection_marker, - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), )); + } else if is_selected { + // Средние строки multi-line — пробелы вместо маркера + line_spans.push(Span::raw(" ".repeat(marker_len))); } line_spans.extend(formatted_spans); lines.push(Line::from(line_spans)); @@ -319,19 +354,26 @@ pub fn render_message_bubble( for (i, wrapped) in wrapped_lines.into_iter().enumerate() { let line_len = wrapped.text.chars().count(); - let line_entities = - formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len); - let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color); + let line_entities = formatting::adjust_entities_for_substring( + msg.entities(), + wrapped.start_offset, + line_len, + ); + let formatted_spans = + formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color); if i == 0 { let mut line_spans = vec![]; if is_selected { line_spans.push(Span::styled( selection_marker, - Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), )); } - line_spans.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray))); + line_spans + .push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray))); line_spans.push(Span::raw(" ")); line_spans.extend(formatted_spans); lines.push(Line::from(line_spans)); @@ -359,12 +401,10 @@ pub fn render_message_bubble( } else { format!("[{}]", reaction.emoji) } + } else if reaction.count > 1 { + format!("{} {}", reaction.emoji, reaction.count) } else { - if reaction.count > 1 { - format!("{} {}", reaction.emoji, reaction.count) - } else { - reaction.emoji.clone() - } + reaction.emoji.clone() }; let style = if reaction.is_chosen { @@ -392,5 +432,338 @@ pub fn render_message_bubble( } } + // Отображаем индикатор воспроизведения голосового + if msg.has_voice() { + if let Some(voice) = msg.voice_info() { + let is_this_playing = playback_state + .map(|ps| ps.message_id == msg.id()) + .unwrap_or(false); + + let status_line = if is_this_playing { + let ps = playback_state.unwrap(); + let icon = match ps.status { + PlaybackStatus::Playing => "▶", + PlaybackStatus::Paused => "⏸", + PlaybackStatus::Loading => "⏳", + _ => "⏹", + }; + let bar = render_progress_bar(ps.position, ps.duration, 20); + format!("{} {} {:.0}s/{:.0}s", icon, bar, ps.position, ps.duration) + } else { + let waveform = render_waveform(&voice.waveform, 20); + format!(" {} {:.0}s", waveform, voice.duration) + }; + + let status_len = status_line.chars().count(); + if msg.is_outgoing() { + let padding = content_width.saturating_sub(status_len + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(status_line, Style::default().fg(Color::Cyan)), + ])); + } else { + lines.push(Line::from(Span::styled(status_line, Style::default().fg(Color::Cyan)))); + } + } + } + + // Отображаем статус фото (если есть) + #[cfg(feature = "images")] + if let Some(photo) = msg.photo_info() { + match &photo.download_state { + PhotoDownloadState::Downloading => { + let status = "📷 ⏳ Загрузка..."; + if msg.is_outgoing() { + let padding = content_width.saturating_sub(status.chars().count() + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(status, Style::default().fg(Color::Yellow)), + ])); + } else { + lines + .push(Line::from(Span::styled(status, Style::default().fg(Color::Yellow)))); + } + } + PhotoDownloadState::Error(e) => { + let status = format!("📷 [Ошибка: {}]", e); + if msg.is_outgoing() { + let padding = content_width.saturating_sub(status.chars().count() + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(status, Style::default().fg(Color::Red)), + ])); + } else { + lines.push(Line::from(Span::styled(status, Style::default().fg(Color::Red)))); + } + } + PhotoDownloadState::Downloaded(_) => { + // Всегда показываем inline превью для загруженных фото + let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH); + let img_height = calculate_image_height(photo.width, photo.height, inline_width); + for _ in 0..img_height { + lines.push(Line::from("")); + } + } + PhotoDownloadState::NotDownloaded => { + // Для незагруженных фото ничего не рендерим, + // текст сообщения уже содержит 📷 prefix + } + } + } + lines } + +/// Информация для отложенного рендеринга изображения поверх placeholder +#[cfg(feature = "images")] +pub struct DeferredImageRender { + pub message_id: MessageId, + /// Путь к файлу изображения + pub photo_path: String, + /// Смещение в строках от начала всего списка сообщений + pub line_offset: usize, + /// Горизонтальное смещение от левого края контента (для сетки альбомов) + pub x_offset: u16, + pub width: u16, + pub height: u16, +} + +/// Рендерит bubble для альбома (группы фото с общим media_album_id) +/// +/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp. +#[cfg(feature = "images")] +pub fn render_album_bubble( + messages: &[MessageInfo], + config: &Config, + content_width: usize, + selected_msg_id: Option, +) -> (Vec>, Vec) { + use crate::constants::{ + ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH, + }; + + let mut lines: Vec> = Vec::new(); + let mut deferred: Vec = Vec::new(); + + let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id())); + let is_outgoing = messages.first().is_some_and(|m| m.is_outgoing()); + + // Selection marker + let selection_marker = if is_selected { "▶ " } else { "" }; + + // Фильтруем фото + let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect(); + let photo_count = photos.len(); + + if photo_count == 0 { + // Нет фото — рендерим как обычные сообщения + for msg in messages { + lines.extend(render_message_bubble(msg, config, content_width, selected_msg_id, None)); + } + return (lines, deferred); + } + + // Grid layout + let cols = photo_count.min(ALBUM_GRID_MAX_COLS); + let rows = photo_count.div_ceil(cols); + + // Добавляем маркер выбора на первую строку + if is_selected { + lines.push(Line::from(vec![Span::styled( + selection_marker, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )])); + } + + let grid_start_line = lines.len(); + + // Генерируем placeholder-строки для сетки + for row in 0..rows { + for line_in_row in 0..ALBUM_PHOTO_HEIGHT { + let mut spans = Vec::new(); + + // Для исходящих — добавляем отступ справа + if is_outgoing { + let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH + + (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP; + let padding = content_width.saturating_sub(grid_width as usize + 1); + spans.push(Span::raw(" ".repeat(padding))); + } + + // Для каждого столбца в этом ряду + for col in 0..cols { + let photo_idx = row * cols + col; + if photo_idx >= photo_count { + break; + } + + let msg = photos[photo_idx]; + if let Some(photo) = msg.photo_info() { + match &photo.download_state { + PhotoDownloadState::Downloaded(path) => { + if line_in_row == 0 { + // Регистрируем deferred render для этого фото + let x_off = if is_outgoing { + let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH + + (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP; + let padding = content_width + .saturating_sub(grid_width as usize + 1) + as u16; + padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP) + } else { + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP) + }; + + deferred.push(DeferredImageRender { + message_id: msg.id(), + photo_path: path.clone(), + line_offset: grid_start_line + + row * ALBUM_PHOTO_HEIGHT as usize, + x_offset: x_off, + width: ALBUM_PHOTO_WIDTH, + height: ALBUM_PHOTO_HEIGHT, + }); + } + // Пустая строка — placeholder для изображения + } + PhotoDownloadState::Downloading => { + if line_in_row == ALBUM_PHOTO_HEIGHT / 2 { + spans.push(Span::styled( + "⏳ Загрузка...", + Style::default().fg(Color::Yellow), + )); + } + } + PhotoDownloadState::Error(e) => { + if line_in_row == ALBUM_PHOTO_HEIGHT / 2 { + let err_text: String = e.chars().take(14).collect(); + spans.push(Span::styled( + format!("❌ {}", err_text), + Style::default().fg(Color::Red), + )); + } + } + PhotoDownloadState::NotDownloaded => { + if line_in_row == ALBUM_PHOTO_HEIGHT / 2 { + spans.push(Span::styled("📷", Style::default().fg(Color::Gray))); + } + } + } + } + } + + lines.push(Line::from(spans)); + } + } + + // Caption: собираем непустые тексты (без "📷 [Фото]" prefix) + let captions: Vec<&str> = messages + .iter() + .map(|m| m.text()) + .filter(|t| !t.is_empty() && !t.starts_with("📷")) + .collect(); + + let msg_color = if is_selected { + config.parse_color(&config.colors.selected_message) + } else if is_outgoing { + config.parse_color(&config.colors.outgoing_message) + } else { + config.parse_color(&config.colors.incoming_message) + }; + + // Timestamp из последнего сообщения + let last_msg = messages.last().unwrap(); + let time = format_timestamp_with_tz(last_msg.date(), &config.general.timezone); + + if !captions.is_empty() { + let caption_text = captions.join(" "); + let time_suffix = format!(" ({})", time); + + if is_outgoing { + let total_len = caption_text.chars().count() + time_suffix.chars().count(); + let padding = content_width.saturating_sub(total_len + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(caption_text, Style::default().fg(msg_color)), + Span::styled(time_suffix, Style::default().fg(Color::Gray)), + ])); + } else { + lines.push(Line::from(vec![ + Span::styled(format!(" ({})", time), Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::styled(caption_text, Style::default().fg(msg_color)), + ])); + } + } else { + // Без подписи — только timestamp + let time_text = format!("({})", time); + if is_outgoing { + let padding = content_width.saturating_sub(time_text.chars().count() + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(time_text, Style::default().fg(Color::Gray)), + ])); + } else { + lines.push(Line::from(vec![Span::styled( + format!(" {}", time_text), + Style::default().fg(Color::Gray), + )])); + } + } + + (lines, deferred) +} + +/// Вычисляет высоту изображения (в строках) с учётом пропорций +#[cfg(feature = "images")] +pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 { + use crate::constants::{MAX_IMAGE_HEIGHT, MAX_IMAGE_WIDTH, MIN_IMAGE_HEIGHT}; + + let display_width = (content_width as u16).min(MAX_IMAGE_WIDTH); + let aspect = img_height as f64 / img_width as f64; + // Терминальные символы ~2:1 по высоте, компенсируем + let raw_height = (display_width as f64 * aspect * 0.5) as u16; + raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT) +} + +/// Рендерит progress bar для воспроизведения +fn render_progress_bar(position: f32, duration: f32, width: usize) -> String { + if duration <= 0.0 { + return "─".repeat(width); + } + let ratio = (position / duration).clamp(0.0, 1.0); + let filled = (ratio * width as f32) as usize; + let empty = width.saturating_sub(filled + 1); + format!("{}●{}", "━".repeat(filled), "─".repeat(empty)) +} + +/// Рендерит waveform из base64-encoded данных TDLib +fn render_waveform(waveform_b64: &str, width: usize) -> String { + const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█']; + + if waveform_b64.is_empty() { + return "▁".repeat(width); + } + + // Декодируем waveform (каждый байт = амплитуда 0-255) + use base64::Engine; + let bytes = base64::engine::general_purpose::STANDARD + .decode(waveform_b64) + .unwrap_or_default(); + + if bytes.is_empty() { + return "▁".repeat(width); + } + + // Сэмплируем до нужной ширины + let mut result = String::with_capacity(width * 4); + for i in 0..width { + let byte_idx = i * bytes.len() / width; + let amplitude = bytes.get(byte_idx).copied().unwrap_or(0); + let bar_idx = (amplitude as usize * (BARS.len() - 1)) / 255; + result.push(BARS[bar_idx]); + } + result +} diff --git a/src/ui/components/message_list.rs b/src/ui/components/message_list.rs new file mode 100644 index 0000000..e5b5156 --- /dev/null +++ b/src/ui/components/message_list.rs @@ -0,0 +1,117 @@ +//! 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> { + 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> = 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) +} diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index 8a9fff0..d904d3c 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -1,13 +1,17 @@ -// UI компоненты для переиспользования +//! Reusable UI components: message bubbles, input fields, modals, lists. -pub mod modal; -pub mod input_field; -pub mod message_bubble; pub mod chat_list_item; pub mod emoji_picker; +pub mod input_field; +pub mod message_bubble; +pub mod message_list; +pub mod modal; // Экспорт основных функций -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 input_field::render_input_field; +#[cfg(feature = "images")] +pub use message_bubble::{calculate_image_height, render_album_bubble, DeferredImageRender}; pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header}; +pub use message_list::{calculate_scroll_offset, render_help_bar, render_message_item}; diff --git a/src/ui/components/modal.rs b/src/ui/components/modal.rs index 8c15102..73b7ca6 100644 --- a/src/ui/components/modal.rs +++ b/src/ui/components/modal.rs @@ -74,10 +74,7 @@ pub fn render_delete_confirm_modal(f: &mut Frame, area: Rect) { ), Span::raw("Да"), Span::raw(" "), - Span::styled( - " [n/Esc] ", - Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), - ), + Span::styled(" [n/Esc] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), Span::raw("Нет"), ]), ]; diff --git a/src/ui/compose_bar.rs b/src/ui/compose_bar.rs new file mode 100644 index 0000000..134cf90 --- /dev/null +++ b/src/ui/compose_bar.rs @@ -0,0 +1,194 @@ +//! Compose bar / input box rendering + +use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods}; +use crate::app::App; +use crate::app::InputMode; +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> { + 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(f: &mut Frame, area: Rect, app: &App) { + let (input_line, input_title): (Line, &str) = 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() { + 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.input_mode == InputMode::Normal { + // Normal mode — dim, no cursor + if app.message_input.is_empty() { + let line = Line::from(vec![Span::styled( + "> Press i to type...", + Style::default().fg(Color::DarkGray), + )]); + (line, "") + } else { + let draft_preview: String = app.message_input.chars().take(60).collect(); + let ellipsis = if app.message_input.chars().count() > 60 { + "..." + } else { + "" + }; + let line = Line::from(Span::styled( + format!("> {}{}", draft_preview, ellipsis), + Style::default().fg(Color::DarkGray), + )); + (line, "") + } + } else { + // Insert mode — active, with cursor + if app.message_input.is_empty() { + 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() { + let border_style = if app.input_mode == InputMode::Insert { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::DarkGray) + }; + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + } 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); +} diff --git a/src/ui/footer.rs b/src/ui/footer.rs index 34ee9f1..135c399 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -1,6 +1,7 @@ use crate::app::App; -use crate::tdlib::TdClientTrait; +use crate::app::InputMode; use crate::tdlib::NetworkState; +use crate::tdlib::TdClientTrait; use ratatui::{ layout::Rect, style::{Color, Style}, @@ -18,18 +19,32 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { NetworkState::Updating => "⏳ Обновление... | ", }; + // Account indicator (shown if not "default") + let account_indicator = if app.current_account_name != "default" { + format!("[{}] ", app.current_account_name) + } else { + String::new() + }; + let status = if let Some(msg) = &app.status_message { - format!(" {}{} ", network_indicator, msg) + format!(" {}{}{} ", account_indicator, network_indicator, msg) } else if let Some(err) = &app.error_message { - format!(" {}Error: {} ", network_indicator, err) + format!(" {}{}Error: {} ", account_indicator, network_indicator, err) } else if app.is_searching { - format!(" {}↑/↓: Navigate | Enter: Select | Esc: Cancel ", network_indicator) + format!( + " {}{}↑/↓: Navigate | Enter: Select | Esc: Cancel ", + account_indicator, network_indicator + ) } else if app.selected_chat_id.is_some() { - format!(" {}↑/↓: Scroll | Ctrl+U: Profile | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ", network_indicator) + let mode_str = match app.input_mode { + InputMode::Normal => "[NORMAL] j/k: Nav | i: Insert | d/r/f/y: Actions | Esc: Close", + InputMode::Insert => "[INSERT] Type message | Esc: Normal mode", + }; + format!(" {}{}{} | Ctrl+C: Quit ", account_indicator, network_indicator, mode_str) } else { format!( - " {}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", - network_indicator + " {}{}↑/↓: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ", + account_indicator, network_indicator ) }; diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 611d630..c48fda5 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -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::methods::{messages::MessageMethods, modal::ModalMethods, search::SearchMethods}; use crate::app::App; -use crate::tdlib::TdClientTrait; use crate::message_grouping::{group_messages, MessageGroup}; +use crate::tdlib::TdClientTrait; use crate::ui::components; +use crate::ui::{compose_bar, modals}; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, @@ -11,7 +18,12 @@ use ratatui::{ }; /// Рендерит заголовок чата с typing status -fn render_chat_header(f: &mut Frame, area: Rect, app: &App, chat: &crate::tdlib::ChatInfo) { +fn render_chat_header( + f: &mut Frame, + area: Rect, + app: &App, + chat: &crate::tdlib::ChatInfo, +) { let typing_action = app .td_client .typing_status() @@ -27,10 +39,7 @@ fn render_chat_header(f: &mut Frame, area: Rect, app: &App, .add_modifier(Modifier::BOLD), )]; if let Some(username) = &chat.username { - spans.push(Span::styled( - format!(" {}", username), - Style::default().fg(Color::Gray), - )); + spans.push(Span::styled(format!(" {}", username), Style::default().fg(Color::Gray))); } spans.push(Span::styled( format!(" {}", action), @@ -83,33 +92,20 @@ fn render_pinned_bar(f: &mut Frame, area: Rect, app: &App) Span::raw(" ".repeat(padding)), Span::styled(pinned_hint, Style::default().fg(Color::Gray)), ]); - let pinned_bar = - Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40))); + let pinned_bar = Paragraph::new(pinned_line).style(Style::default().bg(Color::Rgb(40, 20, 40))); 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 { +pub(super) fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if max_width == 0 { - return vec![WrappedLine { - text: text.to_string(), - }]; + return vec![WrappedLine { text: text.to_string() }]; } let mut result = Vec::new(); @@ -134,9 +130,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { current_line.push_str(&word); current_width += 1 + word_width; } else { - result.push(WrappedLine { - text: current_line, - }); + result.push(WrappedLine { text: current_line }); current_line = word; current_width = word_width; } @@ -158,29 +152,23 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { current_line.push(' '); current_line.push_str(&word); } else { - result.push(WrappedLine { - text: current_line, - }); + result.push(WrappedLine { text: current_line }); current_line = word; } } if !current_line.is_empty() { - result.push(WrappedLine { - text: current_line, - }); + result.push(WrappedLine { text: current_line }); } if result.is_empty() { - result.push(WrappedLine { - text: String::new(), - }); + result.push(WrappedLine { text: String::new() }); } result } /// Рендерит список сообщений с группировкой по дате/отправителю и автоскроллом -fn render_message_list(f: &mut Frame, area: Rect, app: &App) { +fn render_message_list(f: &mut Frame, area: Rect, app: &mut App) { let content_width = area.width.saturating_sub(2) as usize; // Messages с группировкой по дате и отправителю @@ -191,6 +179,13 @@ fn render_message_list(f: &mut Frame, area: Rect, app: &App // Номер строки, где начинается выбранное сообщение (для автоскролла) let mut selected_msg_line: Option = None; + // ОПТИМИЗАЦИЯ: Убрали массовый preloading всех изображений. + // Теперь загружаем только видимые изображения во втором проходе (см. ниже). + + // Собираем информацию о развёрнутых изображениях (для второго прохода) + #[cfg(feature = "images")] + let mut deferred_images: Vec = Vec::new(); + // Используем message_grouping для группировки сообщений let grouped = group_messages(&app.td_client.current_chat_messages()); let mut is_first_date = true; @@ -204,10 +199,7 @@ fn render_message_list(f: &mut Frame, area: Rect, app: &App is_first_date = false; is_first_sender = true; // Сбрасываем счётчик заголовков после даты } - MessageGroup::SenderHeader { - is_outgoing, - sender_name, - } => { + MessageGroup::SenderHeader { is_outgoing, sender_name } => { // Рендерим заголовок отправителя lines.extend(components::render_sender_header( is_outgoing, @@ -225,12 +217,85 @@ fn render_message_list(f: &mut Frame, area: Rect, app: &App } // Рендерим сообщение - lines.extend(components::render_message_bubble( + let bubble_lines = components::render_message_bubble( &msg, app.config(), content_width, selected_msg_id, - )); + app.playback_state.as_ref(), + ); + + // Собираем deferred image renders для всех загруженных фото + #[cfg(feature = "images")] + if let Some(photo) = msg.photo_info() { + if let crate::tdlib::PhotoDownloadState::Downloaded(path) = + &photo.download_state + { + let inline_width = + content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH); + let img_height = components::calculate_image_height( + photo.width, + photo.height, + inline_width, + ); + let img_width = inline_width as u16; + let bubble_len = bubble_lines.len(); + let placeholder_start = lines.len() + bubble_len - img_height as usize; + + deferred_images.push(components::DeferredImageRender { + message_id: msg.id(), + photo_path: path.clone(), + line_offset: placeholder_start, + x_offset: 0, + width: img_width, + height: img_height, + }); + } + } + + lines.extend(bubble_lines); + } + MessageGroup::Album(album_messages) => { + #[cfg(feature = "images")] + { + let is_selected = album_messages + .iter() + .any(|m| selected_msg_id == Some(m.id())); + if is_selected { + selected_msg_line = Some(lines.len()); + } + + let (bubble_lines, album_deferred) = components::render_album_bubble( + &album_messages, + app.config(), + content_width, + selected_msg_id, + ); + + for mut d in album_deferred { + d.line_offset += lines.len(); + deferred_images.push(d); + } + + lines.extend(bubble_lines); + } + #[cfg(not(feature = "images"))] + { + // Fallback: рендерим каждое сообщение отдельно + for msg in &album_messages { + let is_selected = selected_msg_id == Some(msg.id()); + if is_selected { + selected_msg_line = Some(lines.len()); + } + lines.extend(components::render_message_bubble( + msg, + app.config(), + content_width, + selected_msg_id, + app.playback_state.as_ref(), + )); + } + } } } } @@ -244,11 +309,7 @@ fn render_message_list(f: &mut Frame, area: Rect, app: &App let total_lines = lines.len(); // Базовый скролл (показываем последние сообщения) - let base_scroll = if total_lines > visible_height { - total_lines - visible_height - } else { - 0 - }; + let base_scroll = total_lines.saturating_sub(visible_height); // Если выбрано сообщение, автоскроллим к нему let scroll_offset = if app.is_selecting_message() { @@ -275,156 +336,67 @@ fn render_message_list(f: &mut Frame, area: Rect, app: &App .block(Block::default().borders(Borders::ALL)) .scroll((scroll_offset, 0)); f.render_widget(messages_widget, area); + + // Второй проход: рендерим изображения поверх placeholder-ов + #[cfg(feature = "images")] + { + use ratatui_image::StatefulImage; + + // THROTTLING: Рендерим изображения максимум 15 FPS (каждые 66ms) + let should_render_images = app + .last_image_render_time + .map(|t| t.elapsed() > std::time::Duration::from_millis(66)) + .unwrap_or(true); + + if !deferred_images.is_empty() && should_render_images { + let content_x = area.x + 1; + let content_y = area.y + 1; + + for d in &deferred_images { + let y_in_content = d.line_offset as i32 - scroll_offset as i32; + + // Пропускаем изображения, которые полностью за пределами видимости + if y_in_content < 0 || y_in_content as usize >= visible_height { + continue; + } + + let img_y = content_y + y_in_content as u16; + let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y); + + // ВАЖНО: Не рендерим частично видимые изображения (убирает сжатие и мигание) + if d.height > remaining_height { + continue; + } + + // Рендерим с ПОЛНОЙ высотой (не сжимаем) + let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height); + + // ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу) + // Используем inline_renderer с Halfblocks для скорости + if let Some(renderer) = &mut app.inline_image_renderer { + // Загружаем только если видимо (early return если уже в кеше) + let _ = renderer.load_image(d.message_id, &d.photo_path); + + if let Some(protocol) = renderer.get_protocol(&d.message_id) { + f.render_stateful_widget(StatefulImage::default(), img_rect, protocol); + } + } + } + + // Обновляем время последнего рендеринга (для throttling) + app.last_image_render_time = Some(std::time::Instant::now()); + } + } } -/// Рендерит input box с поддержкой разных режимов (forward/select/edit/reply/normal) -fn render_input_box(f: &mut Frame, area: Rect, app: &App) { - 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()); +pub fn render(f: &mut Frame, area: Rect, app: &mut App) { + // Модальное окно просмотра изображения (приоритет выше всех) + #[cfg(feature = "images")] + if let Some(modal_state) = app.image_modal.clone() { + modals::render_image_viewer(f, app, &modal_state); + return; + } - 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(f: &mut Frame, area: Rect, app: &App) { // Режим профиля if app.is_profile_mode() { if let Some(profile) = app.get_profile_info() { @@ -435,27 +407,27 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { // Режим поиска по сообщениям 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; } - if let Some(chat) = app.get_selected_chat() { + if let Some(chat) = app.get_selected_chat().cloned() { // Вычисляем динамическую высоту инпута на основе длины текста let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> " - let input_text_len = app.message_input.chars().count() + 2; // +2 для "> " - let input_lines = if input_width > 0 { - ((input_text_len as f32 / input_width as f32).ceil() as u16).max(1) + let input_lines: u16 = if input_width > 0 { + let len = app.message_input.chars().count() + 2; // +2 для "> " + ((len as f32 / input_width as f32).ceil() as u16).max(1) } else { 1 }; // Минимум 3 строки (1 контент + 2 рамки), максимум 10 - let input_height = (input_lines + 2).min(10).max(3); + let input_height = (input_lines + 2).clamp(3, 10); // Проверяем, есть ли закреплённое сообщение let has_pinned = app.td_client.current_pinned_message().is_some(); @@ -483,7 +455,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { }; // Chat header с typing status - render_chat_header(f, message_chunks[0], app, chat); + render_chat_header(f, message_chunks[0], app, &chat); // Pinned bar (если есть закреплённое сообщение) render_pinned_bar(f, message_chunks[1], app); @@ -492,7 +464,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { 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,391 +475,13 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { // Модалка подтверждения удаления if app.is_confirm_delete_shown() { - render_delete_confirm_modal(f, area); + modals::render_delete_confirm(f, area); } // Модалка выбора реакции - if let crate::app::ChatState::ReactionPicker { - available_reactions, - selected_index, - .. - } = &app.chat_state + if let crate::app::ChatState::ReactionPicker { available_reactions, selected_index, .. } = + &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(f: &mut Frame, area: Rect, app: &App) { - // Извлекаем данные из 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 = 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(f: &mut Frame, area: Rect, app: &App) { - // Извлекаем данные из 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 = 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); -} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 0b8266c..05e2d0f 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -1,10 +1,16 @@ +//! UI rendering module. +//! +//! Routes rendering by screen (Loading → Auth → Main) and checks terminal size. + mod auth; pub mod chat_list; pub mod components; +mod compose_bar; pub mod footer; mod loading; mod main_screen; pub mod messages; +mod modals; pub mod profile; use crate::app::{App, AppScreen}; @@ -33,6 +39,11 @@ pub fn render(f: &mut Frame, app: &mut App) { AppScreen::Auth => auth::render(f, app), AppScreen::Main => main_screen::render(f, app), } + + // Global overlay: account switcher (renders on top of ANY screen) + if app.account_switcher.is_some() { + modals::render_account_switcher(f, area, app); + } } fn render_size_warning(f: &mut Frame, width: u16, height: u16) { diff --git a/src/ui/modals/account_switcher.rs b/src/ui/modals/account_switcher.rs new file mode 100644 index 0000000..76b25c3 --- /dev/null +++ b/src/ui/modals/account_switcher.rs @@ -0,0 +1,190 @@ +//! Account switcher modal +//! +//! Renders a centered popup with account list (SelectAccount) or +//! new account name input (AddAccount). + +use crate::app::{AccountSwitcherState, App}; +use crate::tdlib::TdClientTrait; +use ratatui::{ + layout::Rect, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph}, + Frame, +}; + +/// Renders the account switcher modal overlay. +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let Some(state) = &app.account_switcher else { + return; + }; + + match state { + AccountSwitcherState::SelectAccount { accounts, selected_index, current_account } => { + render_select_account(f, area, accounts, *selected_index, current_account); + } + AccountSwitcherState::AddAccount { name_input, cursor_position, error } => { + render_add_account(f, area, name_input, *cursor_position, error.as_deref()); + } + } +} + +fn render_select_account( + f: &mut Frame, + area: Rect, + accounts: &[crate::accounts::AccountProfile], + selected_index: usize, + current_account: &str, +) { + let mut lines: Vec = Vec::new(); + lines.push(Line::from("")); + + for (idx, account) in accounts.iter().enumerate() { + let is_selected = idx == selected_index; + let is_current = account.name == current_account; + + let marker = if is_current { "● " } else { " " }; + let suffix = if is_current { " (текущий)" } else { "" }; + let display = format!("{}{} ({}){}", marker, account.name, account.display_name, suffix); + + let style = if is_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else if is_current { + Style::default().fg(Color::Green) + } else { + Style::default().fg(Color::White) + }; + + lines.push(Line::from(Span::styled(format!(" {}", display), style))); + } + + // Separator + lines.push(Line::from(Span::styled( + " ──────────────────────", + Style::default().fg(Color::DarkGray), + ))); + + // Add account item + let add_selected = selected_index == accounts.len(); + let add_style = if add_selected { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan) + }; + lines.push(Line::from(Span::styled(" + Добавить аккаунт", add_style))); + + lines.push(Line::from("")); + + // Help bar + lines.push(Line::from(vec![ + Span::styled(" j/k ", Style::default().fg(Color::Yellow)), + Span::styled("Nav", Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled(" Enter ", Style::default().fg(Color::Green)), + Span::styled("Select", Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled(" a ", Style::default().fg(Color::Cyan)), + Span::styled("Add", Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled(" Esc ", Style::default().fg(Color::Red)), + Span::styled("Close", Style::default().fg(Color::DarkGray)), + ])); + + // Calculate dynamic height: header(3) + accounts + separator(1) + add(1) + empty(1) + help(1) + footer(1) + let content_height = (accounts.len() as u16) + 7; + let height = content_height.min(area.height.saturating_sub(4)); + let width = 40u16.min(area.width.saturating_sub(4)); + + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + let modal_area = Rect::new(x, y, width, height); + + f.render_widget(Clear, modal_area); + + let modal = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" АККАУНТЫ ") + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + ); + + f.render_widget(modal, modal_area); +} + +fn render_add_account( + f: &mut Frame, + area: Rect, + name_input: &str, + _cursor_position: usize, + error: Option<&str>, +) { + let mut lines: Vec = Vec::new(); + lines.push(Line::from("")); + + // Input field + let input_display = if name_input.is_empty() { + Span::styled("_", Style::default().fg(Color::DarkGray)) + } else { + Span::styled(format!("{}_", name_input), Style::default().fg(Color::White)) + }; + lines.push(Line::from(vec![ + Span::styled(" Имя: ", Style::default().fg(Color::Cyan)), + input_display, + ])); + + // Hint + lines.push(Line::from(Span::styled( + " (a-z, 0-9, -, _)", + Style::default().fg(Color::DarkGray), + ))); + + lines.push(Line::from("")); + + // Error + if let Some(err) = error { + lines.push(Line::from(Span::styled(format!(" {}", err), Style::default().fg(Color::Red)))); + lines.push(Line::from("")); + } + + // Help bar + lines.push(Line::from(vec![ + Span::styled(" Enter ", Style::default().fg(Color::Green)), + Span::styled("Create", Style::default().fg(Color::DarkGray)), + Span::raw(" "), + Span::styled(" Esc ", Style::default().fg(Color::Red)), + Span::styled("Back", Style::default().fg(Color::DarkGray)), + ])); + + let height = if error.is_some() { 10 } else { 8 }; + let height = (height as u16).min(area.height.saturating_sub(4)); + let width = 40u16.min(area.width.saturating_sub(4)); + + let x = area.x + (area.width.saturating_sub(width)) / 2; + let y = area.y + (area.height.saturating_sub(height)) / 2; + let modal_area = Rect::new(x, y, width, height); + + f.render_widget(Clear, modal_area); + + let modal = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + .title(" НОВЫЙ АККАУНТ ") + .title_style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + ); + + f.render_widget(modal, modal_area); +} diff --git a/src/ui/modals/delete_confirm.rs b/src/ui/modals/delete_confirm.rs new file mode 100644 index 0000000..d27804c --- /dev/null +++ b/src/ui/modals/delete_confirm.rs @@ -0,0 +1,8 @@ +//! Delete confirmation modal + +use ratatui::{layout::Rect, Frame}; + +/// Renders delete confirmation modal +pub fn render(f: &mut Frame, area: Rect) { + crate::ui::components::modal::render_delete_confirm_modal(f, area); +} diff --git a/src/ui/modals/image_viewer.rs b/src/ui/modals/image_viewer.rs new file mode 100644 index 0000000..afbd5fc --- /dev/null +++ b/src/ui/modals/image_viewer.rs @@ -0,0 +1,178 @@ +//! Модальное окно для полноэкранного просмотра изображений. +//! +//! Поддерживает: +//! - Автоматическое масштабирование с сохранением aspect ratio +//! - Максимизация по ширине/высоте терминала +//! - Затемнение фона +//! - Hotkeys: Esc/q для закрытия, ←/→ для навигации между фото + +use crate::app::App; +use crate::tdlib::r#trait::TdClientTrait; +use crate::tdlib::ImageModalState; +use ratatui::{ + layout::{Alignment, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Clear, Paragraph}, + Frame, +}; +use ratatui_image::StatefulImage; + +/// Рендерит модальное окно с полноэкранным изображением +pub fn render(f: &mut Frame, app: &mut App, modal_state: &ImageModalState) { + let area = f.area(); + + // Затемняем весь фон + f.render_widget(Clear, area); + f.render_widget(Block::default().style(Style::default().bg(Color::Black)), area); + + // Резервируем место для подсказок (2 строки внизу) + let image_area_height = area.height.saturating_sub(2); + + // Вычисляем размер изображения с сохранением aspect ratio + let (img_width, img_height) = calculate_modal_size( + modal_state.photo_width, + modal_state.photo_height, + area.width, + image_area_height, + ); + + // Центрируем изображение + let img_x = (area.width.saturating_sub(img_width)) / 2; + let img_y = (image_area_height.saturating_sub(img_height)) / 2; + let img_rect = Rect::new(img_x, img_y, img_width, img_height); + + // Рендерим изображение (используем modal_renderer для высокого качества) + if let Some(renderer) = &mut app.modal_image_renderer { + // Проверяем есть ли протокол уже в кеше + if let Some(protocol) = renderer.get_protocol(&modal_state.message_id) { + // Протокол готов - рендерим изображение (iTerm2/Sixel - высокое качество) + f.render_stateful_widget(StatefulImage::default(), img_rect, protocol); + } else { + // Протокола нет - показываем индикатор загрузки + let loading_text = vec![ + Line::from(""), + Line::from(Span::styled( + "⏳ Загрузка изображения...", + Style::default().fg(Color::Gray), + )), + Line::from(""), + Line::from(Span::styled( + "(декодирование в высоком качестве)", + Style::default().fg(Color::DarkGray), + )), + ]; + let loading = Paragraph::new(loading_text) + .alignment(Alignment::Center) + .block(Block::default()); + f.render_widget(loading, img_rect); + + // Загружаем изображение (может занять время для iTerm2/Sixel) + let _ = renderer.load_image(modal_state.message_id, &modal_state.photo_path); + + // Триггерим перерисовку для показа загруженного изображения + app.needs_redraw = true; + } + } + + // Подсказки внизу + let hint = "[Esc/q] Закрыть [←/→] Пред/След фото"; + let hint_y = area.height.saturating_sub(1); + let hint_rect = Rect::new(0, hint_y, area.width, 1); + f.render_widget( + Paragraph::new(Span::styled(hint, Style::default().fg(Color::Gray))) + .alignment(Alignment::Center), + hint_rect, + ); + + // Информация о размере (опционально) + let info = format!( + "{}x{} | {:.1}%", + modal_state.photo_width, + modal_state.photo_height, + (img_width as f64 / modal_state.photo_width as f64) * 100.0 + ); + let info_y = area.height.saturating_sub(2); + let info_rect = Rect::new(0, info_y, area.width, 1); + f.render_widget( + Paragraph::new(Span::styled(info, Style::default().fg(Color::DarkGray))) + .alignment(Alignment::Center), + info_rect, + ); +} + +/// Вычисляет размер изображения для модалки с сохранением aspect ratio. +/// +/// # Логика масштабирования: +/// - Если изображение меньше терминала → показываем как есть +/// - Если ширина больше → масштабируем по ширине +/// - Если высота больше → масштабируем по высоте +/// - Сохраняем aspect ratio +fn calculate_modal_size( + img_width: i32, + img_height: i32, + term_width: u16, + term_height: u16, +) -> (u16, u16) { + let aspect_ratio = img_width as f64 / img_height as f64; + + // Если изображение помещается целиком + if img_width <= term_width as i32 && img_height <= term_height as i32 { + return (img_width as u16, img_height as u16); + } + + // Начинаем с максимального размера терминала + let mut width = term_width as f64; + let mut height = term_height as f64; + + // Подгоняем по aspect ratio + let term_aspect = width / height; + + if term_aspect > aspect_ratio { + // Терминал шире → ограничены по высоте + width = height * aspect_ratio; + } else { + // Терминал выше → ограничены по ширине + height = width / aspect_ratio; + } + + (width as u16, height as u16) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_calculate_modal_size_fits() { + // Изображение помещается целиком + let (w, h) = calculate_modal_size(50, 30, 100, 50); + assert_eq!(w, 50); + assert_eq!(h, 30); + } + + #[test] + fn test_calculate_modal_size_scale_width() { + // Ограничены по ширине (изображение шире терминала) + let (w, h) = calculate_modal_size(200, 100, 100, 100); + assert_eq!(w, 100); + assert_eq!(h, 50); // aspect ratio 2:1 + } + + #[test] + fn test_calculate_modal_size_scale_height() { + // Ограничены по высоте (изображение выше терминала) + let (w, h) = calculate_modal_size(100, 200, 100, 100); + assert_eq!(w, 50); // aspect ratio 1:2 + assert_eq!(h, 100); + } + + #[test] + fn test_calculate_modal_size_aspect_ratio() { + // Проверка сохранения aspect ratio + let (w, h) = calculate_modal_size(1920, 1080, 100, 100); + let aspect = w as f64 / h as f64; + let expected_aspect = 1920.0 / 1080.0; + assert!((aspect - expected_aspect).abs() < 0.01); + } +} diff --git a/src/ui/modals/mod.rs b/src/ui/modals/mod.rs new file mode 100644 index 0000000..81ae8d4 --- /dev/null +++ b/src/ui/modals/mod.rs @@ -0,0 +1,27 @@ +//! Modal dialog rendering modules +//! +//! Contains UI rendering for various modal dialogs: +//! - account_switcher: Account switcher modal (global overlay) +//! - delete_confirm: Delete confirmation modal +//! - reaction_picker: Emoji reaction picker modal +//! - search: Message search modal +//! - pinned: Pinned messages viewer modal +//! - image_viewer: Full-screen image viewer modal (images feature) + +pub mod account_switcher; +pub mod delete_confirm; +pub mod pinned; +pub mod reaction_picker; +pub mod search; + +#[cfg(feature = "images")] +pub mod image_viewer; + +pub use account_switcher::render as render_account_switcher; +pub use delete_confirm::render as render_delete_confirm; +pub use pinned::render as render_pinned; +pub use reaction_picker::render as render_reaction_picker; +pub use search::render as render_search; + +#[cfg(feature = "images")] +pub use image_viewer::render as render_image_viewer; diff --git a/src/ui/modals/pinned.rs b/src/ui/modals/pinned.rs new file mode 100644 index 0000000..6caac5e --- /dev/null +++ b/src/ui/modals/pinned.rs @@ -0,0 +1,91 @@ +//! Pinned messages viewer modal + +use crate::app::App; +use crate::tdlib::TdClientTrait; +use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item}; +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(f: &mut Frame, area: Rect, app: &App) { + // Извлекаем данные из 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 = 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]); +} diff --git a/src/ui/modals/reaction_picker.rs b/src/ui/modals/reaction_picker.rs new file mode 100644 index 0000000..eb2782c --- /dev/null +++ b/src/ui/modals/reaction_picker.rs @@ -0,0 +1,8 @@ +//! Reaction picker modal + +use ratatui::{layout::Rect, Frame}; + +/// 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); +} diff --git a/src/ui/modals/search.rs b/src/ui/modals/search.rs new file mode 100644 index 0000000..e82bc4e --- /dev/null +++ b/src/ui/modals/search.rs @@ -0,0 +1,110 @@ +//! Message search modal + +use crate::app::App; +use crate::tdlib::TdClientTrait; +use crate::ui::components::{calculate_scroll_offset, render_help_bar, render_message_item}; +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(f: &mut Frame, area: Rect, app: &App) { + // Извлекаем данные из 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 = 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]); +} diff --git a/src/ui/profile.rs b/src/ui/profile.rs index a30543e..f9ea91f 100644 --- a/src/ui/profile.rs +++ b/src/ui/profile.rs @@ -1,6 +1,7 @@ +use crate::app::methods::modal::ModalMethods; use crate::app::App; -use crate::tdlib::TdClientTrait; use crate::tdlib::ProfileInfo; +use crate::tdlib::TdClientTrait; use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style}, diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 877935e..131bc8e 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -6,6 +6,6 @@ pub mod validation; pub use formatting::*; // pub use modal_handler::*; // Используется через явный import -pub use retry::{with_timeout, with_timeout_msg, with_timeout_ignore}; +pub use retry::{with_timeout, with_timeout_ignore, with_timeout_msg}; pub use tdlib::*; pub use validation::*; diff --git a/src/utils/retry.rs b/src/utils/retry.rs index 5a139be..19e77a5 100644 --- a/src/utils/retry.rs +++ b/src/utils/retry.rs @@ -105,10 +105,9 @@ mod tests { #[tokio::test] async fn test_with_timeout_success() { - let result = with_timeout(Duration::from_secs(1), async { - Ok::<_, String>("success".to_string()) - }) - .await; + let result = + with_timeout(Duration::from_secs(1), async { Ok::<_, String>("success".to_string()) }) + .await; assert!(result.is_ok()); assert_eq!(result.unwrap(), "success"); diff --git a/tests/account_switcher.rs b/tests/account_switcher.rs new file mode 100644 index 0000000..26449db --- /dev/null +++ b/tests/account_switcher.rs @@ -0,0 +1,193 @@ +// Integration tests for account switcher modal + +mod helpers; + +use helpers::app_builder::TestAppBuilder; +use helpers::test_data::create_test_chat; +use tele_tui::app::AccountSwitcherState; + +// ============ Open/Close Tests ============ + +#[test] +fn test_open_account_switcher() { + let mut app = TestAppBuilder::new().build(); + assert!(app.account_switcher.is_none()); + + app.open_account_switcher(); + + assert!(app.account_switcher.is_some()); + match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { accounts, selected_index, current_account }) => { + assert!(!accounts.is_empty()); + assert_eq!(*selected_index, 0); + assert_eq!(current_account, "default"); + } + _ => panic!("Expected SelectAccount state"), + } +} + +#[test] +fn test_close_account_switcher() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + assert!(app.account_switcher.is_some()); + + app.close_account_switcher(); + assert!(app.account_switcher.is_none()); +} + +// ============ Navigation Tests ============ + +#[test] +fn test_account_switcher_navigate_down() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + let num_accounts = match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { accounts, .. }) => accounts.len(), + _ => panic!("Expected SelectAccount state"), + }; + + // Navigate down past all accounts to "Add account" item + for _ in 0..num_accounts { + app.account_switcher_select_next(); + } + + match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { selected_index, accounts, .. }) => { + // Should be at the "Add account" item (index == accounts.len()) + assert_eq!(*selected_index, accounts.len()); + } + _ => panic!("Expected SelectAccount state"), + } +} + +#[test] +fn test_account_switcher_navigate_up() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + // Navigate down first + app.account_switcher_select_next(); + // Navigate back up + app.account_switcher_select_prev(); + + match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { selected_index, .. }) => { + assert_eq!(*selected_index, 0); + } + _ => panic!("Expected SelectAccount state"), + } +} + +#[test] +fn test_account_switcher_navigate_up_at_top() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + // Already at 0, navigate up should stay at 0 + app.account_switcher_select_prev(); + + match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { selected_index, .. }) => { + assert_eq!(*selected_index, 0); + } + _ => panic!("Expected SelectAccount state"), + } +} + +// ============ Confirm Tests ============ + +#[test] +fn test_confirm_current_account_closes_modal() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + // Confirm on the current account (default) should just close + app.account_switcher_confirm(); + + assert!(app.account_switcher.is_none()); + assert!(app.pending_account_switch.is_none()); +} + +#[test] +fn test_confirm_add_account_transitions_to_add_state() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + let num_accounts = match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { accounts, .. }) => accounts.len(), + _ => panic!("Expected SelectAccount state"), + }; + + // Navigate past all accounts to "+ Add account" + for _ in 0..num_accounts { + app.account_switcher_select_next(); + } + + // Confirm should transition to AddAccount + app.account_switcher_confirm(); + + match &app.account_switcher { + Some(AccountSwitcherState::AddAccount { name_input, cursor_position, error }) => { + assert!(name_input.is_empty()); + assert_eq!(*cursor_position, 0); + assert!(error.is_none()); + } + _ => panic!("Expected AddAccount state"), + } +} + +// ============ Add Account State Tests ============ + +#[test] +fn test_start_add_from_select() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + + // Use quick shortcut + app.account_switcher_start_add(); + + match &app.account_switcher { + Some(AccountSwitcherState::AddAccount { .. }) => {} + _ => panic!("Expected AddAccount state"), + } +} + +#[test] +fn test_back_from_add_to_select() { + let mut app = TestAppBuilder::new().build(); + app.open_account_switcher(); + app.account_switcher_start_add(); + + // Go back + app.account_switcher_back(); + + match &app.account_switcher { + Some(AccountSwitcherState::SelectAccount { .. }) => {} + _ => panic!("Expected SelectAccount state after back"), + } +} + +// ============ Footer Tests ============ + +#[test] +fn test_default_account_name() { + let app = TestAppBuilder::new().build(); + assert_eq!(app.current_account_name, "default"); +} + +#[test] +fn test_custom_account_name() { + let mut app = TestAppBuilder::new().build(); + app.current_account_name = "work".to_string(); + assert_eq!(app.current_account_name, "work"); +} + +// ============ Pending Switch Tests ============ + +#[test] +fn test_pending_switch_initially_none() { + let app = TestAppBuilder::new().build(); + assert!(app.pending_account_switch.is_none()); +} diff --git a/tests/accounts.rs b/tests/accounts.rs new file mode 100644 index 0000000..cf43876 --- /dev/null +++ b/tests/accounts.rs @@ -0,0 +1,180 @@ +// Integration tests for accounts module + +use tele_tui::accounts::{account_db_path, validate_account_name, AccountProfile, AccountsConfig}; + +#[test] +fn test_default_single_config() { + let config = AccountsConfig::default_single(); + assert_eq!(config.default_account, "default"); + assert_eq!(config.accounts.len(), 1); + assert_eq!(config.accounts[0].name, "default"); + assert_eq!(config.accounts[0].display_name, "Default"); +} + +#[test] +fn test_find_account_exists() { + let config = AccountsConfig::default_single(); + let account = config.find_account("default"); + assert!(account.is_some()); + assert_eq!(account.unwrap().name, "default"); +} + +#[test] +fn test_find_account_not_found() { + let config = AccountsConfig::default_single(); + assert!(config.find_account("work").is_none()); + assert!(config.find_account("").is_none()); +} + +#[test] +fn test_db_path_structure() { + let path = account_db_path("default"); + let path_str = path.to_string_lossy(); + + assert!(path_str.contains("tele-tui")); + assert!(path_str.contains("accounts")); + assert!(path_str.contains("default")); + assert!(path_str.ends_with("tdlib_data")); +} + +#[test] +fn test_db_path_per_account() { + let path_default = account_db_path("default"); + let path_work = account_db_path("work"); + + assert_ne!(path_default, path_work); + assert!(path_default.to_string_lossy().contains("default")); + assert!(path_work.to_string_lossy().contains("work")); +} + +#[test] +fn test_account_profile_db_path() { + let profile = AccountProfile { + name: "test-account".to_string(), + display_name: "Test".to_string(), + }; + let path = profile.db_path(); + assert!(path.to_string_lossy().contains("test-account")); + assert!(path.to_string_lossy().ends_with("tdlib_data")); +} + +#[test] +fn test_validate_account_name_valid() { + assert!(validate_account_name("default").is_ok()); + assert!(validate_account_name("work").is_ok()); + assert!(validate_account_name("my-account").is_ok()); + assert!(validate_account_name("account123").is_ok()); + assert!(validate_account_name("test_account").is_ok()); + assert!(validate_account_name("a").is_ok()); +} + +#[test] +fn test_validate_account_name_empty() { + let err = validate_account_name("").unwrap_err(); + assert!(err.contains("empty")); +} + +#[test] +fn test_validate_account_name_too_long() { + let long_name = "a".repeat(33); + let err = validate_account_name(&long_name).unwrap_err(); + assert!(err.contains("32")); +} + +#[test] +fn test_validate_account_name_uppercase() { + assert!(validate_account_name("MyAccount").is_err()); + assert!(validate_account_name("WORK").is_err()); +} + +#[test] +fn test_validate_account_name_spaces() { + assert!(validate_account_name("my account").is_err()); +} + +#[test] +fn test_validate_account_name_starts_with_dash() { + assert!(validate_account_name("-bad").is_err()); +} + +#[test] +fn test_validate_account_name_starts_with_underscore() { + assert!(validate_account_name("_bad").is_err()); +} + +#[test] +fn test_validate_account_name_special_chars() { + assert!(validate_account_name("foo@bar").is_err()); + assert!(validate_account_name("foo.bar").is_err()); + assert!(validate_account_name("foo/bar").is_err()); +} + +#[test] +fn test_resolve_account_default() { + let config = AccountsConfig::default_single(); + let result = tele_tui::accounts::resolve_account(&config, None); + assert!(result.is_ok()); + let (name, path) = result.unwrap(); + assert_eq!(name, "default"); + assert!(path.to_string_lossy().contains("default")); +} + +#[test] +fn test_resolve_account_explicit() { + let config = AccountsConfig::default_single(); + let result = tele_tui::accounts::resolve_account(&config, Some("default")); + assert!(result.is_ok()); + let (name, _) = result.unwrap(); + assert_eq!(name, "default"); +} + +#[test] +fn test_resolve_account_not_found() { + let config = AccountsConfig::default_single(); + let result = tele_tui::accounts::resolve_account(&config, Some("work")); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(err.contains("work")); + assert!(err.contains("not found")); +} + +#[test] +fn test_resolve_account_invalid_name() { + let config = AccountsConfig::default_single(); + let result = tele_tui::accounts::resolve_account(&config, Some("BAD NAME")); + assert!(result.is_err()); +} + +#[test] +fn test_accounts_config_serde_roundtrip() { + let config = AccountsConfig::default_single(); + let toml_str = toml::to_string_pretty(&config).unwrap(); + let parsed: AccountsConfig = toml::from_str(&toml_str).unwrap(); + + assert_eq!(parsed.default_account, config.default_account); + assert_eq!(parsed.accounts.len(), config.accounts.len()); + assert_eq!(parsed.accounts[0].name, config.accounts[0].name); +} + +#[test] +fn test_accounts_config_multi_account_serde() { + let config = AccountsConfig { + default_account: "default".to_string(), + accounts: vec![ + AccountProfile { + name: "default".to_string(), + display_name: "Default".to_string(), + }, + AccountProfile { + name: "work".to_string(), + display_name: "Work".to_string(), + }, + ], + }; + + let toml_str = toml::to_string_pretty(&config).unwrap(); + let parsed: AccountsConfig = toml::from_str(&toml_str).unwrap(); + + assert_eq!(parsed.accounts.len(), 2); + assert!(parsed.find_account("work").is_some()); +} diff --git a/tests/chat_list.rs b/tests/chat_list.rs index ff5c158..9695123 100644 --- a/tests/chat_list.rs +++ b/tests/chat_list.rs @@ -65,16 +65,14 @@ fn test_incoming_message_shows_unread_badge() { .last_message("Как дела?") .build(); - let mut app = TestAppBuilder::new() - .with_chat(chat) - .build(); + let mut app = TestAppBuilder::new().with_chat(chat).build(); // Рендерим UI - должно быть без "(1)" let buffer_before = render_to_buffer(80, 24, |f| { tele_tui::ui::chat_list::render(f, f.area(), &mut app); }); let output_before = buffer_to_string(&buffer_before); - + // Проверяем что нет "(1)" в первой строке чата assert!(!output_before.contains("(1)"), "Before: should not contain (1)"); @@ -87,9 +85,13 @@ fn test_incoming_message_shows_unread_badge() { tele_tui::ui::chat_list::render(f, f.area(), &mut app); }); let output_after = buffer_to_string(&buffer_after); - + // Проверяем что появилось "(1)" в первой строке чата - assert!(output_after.contains("(1)"), "After: should contain (1)\nActual output:\n{}", output_after); + assert!( + output_after.contains("(1)"), + "After: should contain (1)\nActual output:\n{}", + output_after + ); } #[tokio::test] @@ -127,39 +129,44 @@ async fn test_opening_chat_clears_unread_badge() { tele_tui::ui::chat_list::render(f, f.area(), &mut app); }); let output_before = buffer_to_string(&buffer_before); - + // Проверяем что есть "(3)" в списке чатов - assert!(output_before.contains("(3)"), "Before opening: should contain (3)\nActual output:\n{}", output_before); + assert!( + output_before.contains("(3)"), + "Before opening: should contain (3)\nActual output:\n{}", + output_before + ); // Симулируем открытие чата - загружаем историю let chat_id = ChatId::new(999); let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap(); - + // Собираем ID входящих сообщений (как в реальном коде) let incoming_message_ids: Vec = loaded_messages .iter() .filter(|msg| !msg.is_outgoing()) .map(|msg| msg.id()) .collect(); - + // Проверяем что нашли 3 входящих сообщения assert_eq!(incoming_message_ids.len(), 3, "Should have 3 incoming messages"); // Добавляем в очередь для отметки как прочитанные (напрямую через Mutex) - app.td_client.pending_view_messages + app.td_client + .pending_view_messages .lock() .unwrap() .push((chat_id, incoming_message_ids)); - + // Обрабатываем очередь (как в main loop) app.td_client.process_pending_view_messages().await; - + // В FakeTdClient это должно записаться в viewed_messages let viewed = app.td_client.get_viewed_messages(); assert_eq!(viewed.len(), 1, "Should have one batch of viewed messages"); assert_eq!(viewed[0].0, 999, "Should be for chat 999"); assert_eq!(viewed[0].1.len(), 3, "Should have viewed 3 messages"); - + // В реальном приложении TDLib отправит Update::ChatReadInbox // который обновит unread_count в чате. Симулируем это: app.chats[0].unread_count = 0; @@ -169,9 +176,13 @@ async fn test_opening_chat_clears_unread_badge() { tele_tui::ui::chat_list::render(f, f.area(), &mut app); }); let output_after = buffer_to_string(&buffer_after); - + // Проверяем что "(3)" больше нет - assert!(!output_after.contains("(3)"), "After opening: should not contain (3)\nActual output:\n{}", output_after); + assert!( + !output_after.contains("(3)"), + "After opening: should not contain (3)\nActual output:\n{}", + output_after + ); } #[tokio::test] @@ -202,7 +213,7 @@ async fn test_opening_chat_loads_many_messages() { // Открываем чат - загружаем историю (запрашиваем 100 сообщений) let chat_id = ChatId::new(888); let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap(); - + // Проверяем что загрузились ВСЕ 50 сообщений, а не только последние 2-3 assert_eq!( loaded_messages.len(), @@ -244,7 +255,7 @@ async fn test_chat_history_chunked_loading() { // Тест 1: Загружаем 100 сообщений (больше чем 50, меньше чем 120) let chat_id = ChatId::new(999); let loaded_messages = app.td_client.get_chat_history(chat_id, 100).await.unwrap(); - + assert_eq!( loaded_messages.len(), 100, @@ -254,13 +265,13 @@ async fn test_chat_history_chunked_loading() { // Проверяем что сообщения в правильном порядке (от старых к новым) assert_eq!(loaded_messages[0].text(), "Message 1"); - assert_eq!(loaded_messages[49].text(), "Message 50"); // Граница первого чанка - assert_eq!(loaded_messages[50].text(), "Message 51"); // Начало второго чанка + assert_eq!(loaded_messages[49].text(), "Message 50"); // Граница первого чанка + assert_eq!(loaded_messages[50].text(), "Message 51"); // Начало второго чанка assert_eq!(loaded_messages[99].text(), "Message 100"); // Тест 2: Загружаем все 120 сообщений let all_messages = app.td_client.get_chat_history(chat_id, 120).await.unwrap(); - + assert_eq!( all_messages.len(), 120, @@ -273,7 +284,7 @@ async fn test_chat_history_chunked_loading() { // Тест 3: Запрашиваем 200 сообщений, но есть только 120 let limited_messages = app.td_client.get_chat_history(chat_id, 200).await.unwrap(); - + assert_eq!( limited_messages.len(), 120, @@ -307,8 +318,12 @@ async fn test_chat_history_loads_all_without_limit() { // Загружаем без лимита (i32::MAX) let chat_id = ChatId::new(1001); - let all = app.td_client.get_chat_history(chat_id, i32::MAX).await.unwrap(); - + let all = app + .td_client + .get_chat_history(chat_id, i32::MAX) + .await + .unwrap(); + assert_eq!(all.len(), 200, "Should load all 200 messages without limit"); assert_eq!(all[0].text(), "Msg 1", "First message should be oldest"); assert_eq!(all[199].text(), "Msg 200", "Last message should be newest"); @@ -338,25 +353,29 @@ async fn test_load_older_messages_pagination() { .build(); let chat_id = ChatId::new(1002); - + // Шаг 1: Загружаем только последние 30 сообщений // get_chat_history загружает от конца, поэтому получим сообщения 1-30 let initial_batch = app.td_client.get_chat_history(chat_id, 30).await.unwrap(); assert_eq!(initial_batch.len(), 30, "Should load 30 messages initially"); assert_eq!(initial_batch[0].text(), "Msg 1", "First message should be Msg 1"); assert_eq!(initial_batch[29].text(), "Msg 30", "Last should be Msg 30"); - + // Шаг 2: Загружаем все 150 сообщений для проверки load_older let all_messages = app.td_client.get_chat_history(chat_id, 150).await.unwrap(); assert_eq!(all_messages.len(), 150); - + // Имитируем ситуацию: у нас есть сообщения 101-150, хотим загрузить 51-100 // Берем ID сообщения 101 (первое в нашем "окне") let msg_101_id = all_messages[100].id(); // index 100 = Msg 101 - + // Загружаем сообщения старше 101 - let older_batch = app.td_client.load_older_messages(chat_id, msg_101_id).await.unwrap(); - + let older_batch = app + .td_client + .load_older_messages(chat_id, msg_101_id) + .await + .unwrap(); + // Должны получить сообщения 1-100 (все что старше 101) assert_eq!(older_batch.len(), 100, "Should load 100 older messages"); assert_eq!(older_batch[0].text(), "Msg 1", "Oldest should be Msg 1"); @@ -473,7 +492,7 @@ fn snapshot_chat_search_mode() { fn snapshot_chat_with_online_status() { use tele_tui::tdlib::UserOnlineStatus; use tele_tui::types::ChatId; - + let chat = TestChatBuilder::new("Alice", 123) .last_message("Hey there!") .build(); @@ -493,4 +512,3 @@ fn snapshot_chat_with_online_status() { let output = buffer_to_string(&buffer); assert_snapshot!("chat_with_online_status", output); } - diff --git a/tests/config.rs b/tests/config.rs index d8053c6..7039c4e 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,6 +1,9 @@ // Integration tests for config flow -use tele_tui::config::{Config, ColorsConfig, GeneralConfig, Keybindings, NotificationsConfig}; +use tele_tui::config::{ + AudioConfig, ColorsConfig, Config, GeneralConfig, ImagesConfig, Keybindings, + NotificationsConfig, +}; /// Test: Дефолтные значения конфигурации #[test] @@ -22,9 +25,7 @@ fn test_config_default_values() { #[test] fn test_config_custom_values() { let config = Config { - general: GeneralConfig { - timezone: "+05:00".to_string(), - }, + general: GeneralConfig { timezone: "+05:00".to_string() }, colors: ColorsConfig { incoming_message: "cyan".to_string(), outgoing_message: "blue".to_string(), @@ -34,6 +35,8 @@ fn test_config_custom_values() { }, keybindings: Keybindings::default(), notifications: NotificationsConfig::default(), + images: ImagesConfig::default(), + audio: AudioConfig::default(), }; assert_eq!(config.general.timezone, "+05:00"); @@ -106,9 +109,7 @@ fn test_parse_color_case_insensitive() { #[test] fn test_config_toml_serialization() { let original_config = Config { - general: GeneralConfig { - timezone: "-05:00".to_string(), - }, + general: GeneralConfig { timezone: "-05:00".to_string() }, colors: ColorsConfig { incoming_message: "cyan".to_string(), outgoing_message: "blue".to_string(), @@ -118,6 +119,8 @@ fn test_config_toml_serialization() { }, keybindings: Keybindings::default(), notifications: NotificationsConfig::default(), + images: ImagesConfig::default(), + audio: AudioConfig::default(), }; // Сериализуем в TOML @@ -160,25 +163,19 @@ mod timezone_tests { #[test] fn test_timezone_formats() { let positive = Config { - general: GeneralConfig { - timezone: "+03:00".to_string(), - }, + general: GeneralConfig { timezone: "+03:00".to_string() }, ..Default::default() }; assert_eq!(positive.general.timezone, "+03:00"); let negative = Config { - general: GeneralConfig { - timezone: "-05:00".to_string(), - }, + general: GeneralConfig { timezone: "-05:00".to_string() }, ..Default::default() }; assert_eq!(negative.general.timezone, "-05:00"); let zero = Config { - general: GeneralConfig { - timezone: "+00:00".to_string(), - }, + general: GeneralConfig { timezone: "+00:00".to_string() }, ..Default::default() }; assert_eq!(zero.general.timezone, "+00:00"); diff --git a/tests/delete_message.rs b/tests/delete_message.rs index 1ee2649..49cefbf 100644 --- a/tests/delete_message.rs +++ b/tests/delete_message.rs @@ -12,13 +12,19 @@ async fn test_delete_message_removes_from_list() { let client = FakeTdClient::new(); // Отправляем сообщение - let msg = client.send_message(ChatId::new(123), "Delete me".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "Delete me".to_string(), None, None) + .await + .unwrap(); // Проверяем что сообщение есть assert_eq!(client.get_messages(123).len(), 1); // Удаляем сообщение - client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap(); + client + .delete_messages(ChatId::new(123), vec![msg.id()], false) + .await + .unwrap(); // Проверяем что удаление записалось assert_eq!(client.get_deleted_messages().len(), 1); @@ -34,15 +40,30 @@ async fn test_delete_multiple_messages() { let client = FakeTdClient::new(); // Отправляем 3 сообщения - let msg1 = client.send_message(ChatId::new(123), "Message 1".to_string(), None, None).await.unwrap(); - let msg2 = client.send_message(ChatId::new(123), "Message 2".to_string(), None, None).await.unwrap(); - let msg3 = client.send_message(ChatId::new(123), "Message 3".to_string(), None, None).await.unwrap(); + let msg1 = client + .send_message(ChatId::new(123), "Message 1".to_string(), None, None) + .await + .unwrap(); + let msg2 = client + .send_message(ChatId::new(123), "Message 2".to_string(), None, None) + .await + .unwrap(); + let msg3 = client + .send_message(ChatId::new(123), "Message 3".to_string(), None, None) + .await + .unwrap(); assert_eq!(client.get_messages(123).len(), 3); // Удаляем первое и третье - client.delete_messages(ChatId::new(123), vec![msg1.id()], false).await.unwrap(); - client.delete_messages(ChatId::new(123), vec![msg3.id()], false).await.unwrap(); + client + .delete_messages(ChatId::new(123), vec![msg1.id()], false) + .await + .unwrap(); + client + .delete_messages(ChatId::new(123), vec![msg3.id()], false) + .await + .unwrap(); // Проверяем историю удалений assert_eq!(client.get_deleted_messages().len(), 2); @@ -89,12 +110,18 @@ async fn test_delete_nonexistent_message() { let client = FakeTdClient::new(); // Отправляем одно сообщение - let msg = client.send_message(ChatId::new(123), "Exists".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "Exists".to_string(), None, None) + .await + .unwrap(); assert_eq!(client.get_messages(123).len(), 1); // Пытаемся удалить несуществующее - client.delete_messages(ChatId::new(123), vec![MessageId::new(999)], false).await.unwrap(); + client + .delete_messages(ChatId::new(123), vec![MessageId::new(999)], false) + .await + .unwrap(); // Удаление записалось в историю assert_eq!(client.get_deleted_messages().len(), 1); @@ -112,7 +139,10 @@ async fn test_delete_nonexistent_message() { async fn test_delete_with_confirmation_flow() { let client = FakeTdClient::new(); - let msg = client.send_message(ChatId::new(123), "To delete".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "To delete".to_string(), None, None) + .await + .unwrap(); // Шаг 1: Пользователь нажал 'd' -> показывается модалка (в App) // В FakeTdClient просто проверяем что сообщение ещё есть @@ -120,7 +150,10 @@ async fn test_delete_with_confirmation_flow() { assert_eq!(client.get_deleted_messages().len(), 0); // Шаг 2: Пользователь подтвердил 'y' -> удаляем - client.delete_messages(ChatId::new(123), vec![msg.id()], false).await.unwrap(); + client + .delete_messages(ChatId::new(123), vec![msg.id()], false) + .await + .unwrap(); // Проверяем что удалено assert_eq!(client.get_messages(123).len(), 0); @@ -132,7 +165,10 @@ async fn test_delete_with_confirmation_flow() { async fn test_cancel_delete_keeps_message() { let client = FakeTdClient::new(); - let msg = client.send_message(ChatId::new(123), "Keep me".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "Keep me".to_string(), None, None) + .await + .unwrap(); // Шаг 1: Пользователь нажал 'd' -> показалась модалка assert_eq!(client.get_messages(123).len(), 1); diff --git a/tests/drafts.rs b/tests/drafts.rs index 8ab8c64..69f0c27 100644 --- a/tests/drafts.rs +++ b/tests/drafts.rs @@ -3,8 +3,8 @@ mod helpers; use helpers::test_data::{create_test_chat, TestChatBuilder}; -use tele_tui::types::{ChatId, MessageId}; use std::collections::HashMap; +use tele_tui::types::{ChatId, MessageId}; /// Простая структура для хранения черновиков (как в реальном App) struct DraftManager { diff --git a/tests/e2e_user_journey.rs b/tests/e2e_user_journey.rs index fd7b582..e086223 100644 --- a/tests/e2e_user_journey.rs +++ b/tests/e2e_user_journey.rs @@ -23,10 +23,7 @@ async fn test_user_journey_app_launch_to_chat_list() { let chat2 = TestChatBuilder::new("Work Group", 102).build(); let chat3 = TestChatBuilder::new("Boss", 103).build(); - let client = client - .with_chat(chat1) - .with_chat(chat2) - .with_chat(chat3); + let client = client.with_chat(chat1).with_chat(chat2).with_chat(chat3); // 4. Симулируем загрузку чатов через load_chats let loaded_chats = client.load_chats(50).await.unwrap(); @@ -58,9 +55,7 @@ async fn test_user_journey_open_chat_send_message() { .outgoing() .build(); - let client = client - .with_message(123, msg1) - .with_message(123, msg2); + let client = client.with_message(123, msg1).with_message(123, msg2); // 3. Открываем чат client.open_chat(ChatId::new(123)).await.unwrap(); @@ -77,12 +72,10 @@ async fn test_user_journey_open_chat_send_message() { assert_eq!(history[1].text(), "I'm good, thanks!"); // 7. Отправляем новое сообщение - let _new_msg = client.send_message( - ChatId::new(123), - "What's for dinner?".to_string(), - None, - None - ).await.unwrap(); + let _new_msg = client + .send_message(ChatId::new(123), "What's for dinner?".to_string(), None, None) + .await + .unwrap(); // 8. Проверяем что сообщение отправлено assert_eq!(client.get_sent_messages().len(), 1); @@ -153,34 +146,43 @@ async fn test_user_journey_multi_step_conversation() { client.set_update_channel(tx); // 4. Входящее сообщение от Alice - client.simulate_incoming_message(ChatId::new(789), "How's the project going?".to_string(), "Alice"); + client.simulate_incoming_message( + ChatId::new(789), + "How's the project going?".to_string(), + "Alice", + ); // Проверяем update let update = rx.try_recv().ok(); assert!(matches!(update, Some(TdUpdate::NewMessage { .. }))); // 5. Отвечаем - client.send_message( - ChatId::new(789), - "Almost done! Just need to finish tests.".to_string(), - None, - None - ).await.unwrap(); + client + .send_message( + ChatId::new(789), + "Almost done! Just need to finish tests.".to_string(), + None, + None, + ) + .await + .unwrap(); // 6. Проверяем историю после первого обмена let history1 = client.get_chat_history(ChatId::new(789), 50).await.unwrap(); assert_eq!(history1.len(), 2); // 7. Еще одно входящее сообщение - client.simulate_incoming_message(ChatId::new(789), "Great! Let me know if you need help.".to_string(), "Alice"); + client.simulate_incoming_message( + ChatId::new(789), + "Great! Let me know if you need help.".to_string(), + "Alice", + ); // 8. Снова отвечаем - client.send_message( - ChatId::new(789), - "Will do, thanks!".to_string(), - None, - None - ).await.unwrap(); + client + .send_message(ChatId::new(789), "Will do, thanks!".to_string(), None, None) + .await + .unwrap(); // 9. Финальная проверка истории let final_history = client.get_chat_history(ChatId::new(789), 50).await.unwrap(); @@ -219,24 +221,20 @@ async fn test_user_journey_switch_chats() { assert_eq!(client.get_current_chat_id(), Some(111)); // 3. Отправляем сообщение в первом чате - client.send_message( - ChatId::new(111), - "Message in chat 1".to_string(), - None, - None - ).await.unwrap(); + client + .send_message(ChatId::new(111), "Message in chat 1".to_string(), None, None) + .await + .unwrap(); // 4. Переключаемся на второй чат client.open_chat(ChatId::new(222)).await.unwrap(); assert_eq!(client.get_current_chat_id(), Some(222)); // 5. Отправляем сообщение во втором чате - client.send_message( - ChatId::new(222), - "Message in chat 2".to_string(), - None, - None - ).await.unwrap(); + client + .send_message(ChatId::new(222), "Message in chat 2".to_string(), None, None) + .await + .unwrap(); // 6. Переключаемся на третий чат client.open_chat(ChatId::new(333)).await.unwrap(); @@ -270,12 +268,10 @@ async fn test_user_journey_edit_during_conversation() { client.open_chat(ChatId::new(555)).await.unwrap(); // 2. Отправляем сообщение с опечаткой - let msg = client.send_message( - ChatId::new(555), - "I'll be there at 5pm tomorow".to_string(), - None, - None - ).await.unwrap(); + let msg = client + .send_message(ChatId::new(555), "I'll be there at 5pm tomorow".to_string(), None, None) + .await + .unwrap(); // 3. Проверяем что сообщение отправлено let history = client.get_chat_history(ChatId::new(555), 50).await.unwrap(); @@ -283,17 +279,19 @@ async fn test_user_journey_edit_during_conversation() { assert_eq!(history[0].text(), "I'll be there at 5pm tomorow"); // 4. Исправляем опечатку - client.edit_message( - ChatId::new(555), - msg.id(), - "I'll be there at 5pm tomorrow".to_string() - ).await.unwrap(); + client + .edit_message(ChatId::new(555), msg.id(), "I'll be there at 5pm tomorrow".to_string()) + .await + .unwrap(); // 5. Проверяем что сообщение отредактировано let edited_history = client.get_chat_history(ChatId::new(555), 50).await.unwrap(); assert_eq!(edited_history.len(), 1); assert_eq!(edited_history[0].text(), "I'll be there at 5pm tomorrow"); - assert!(edited_history[0].metadata.edit_date > 0, "Должна быть установлена дата редактирования"); + assert!( + edited_history[0].metadata.edit_date > 0, + "Должна быть установлена дата редактирования" + ); // 6. Проверяем историю редактирований assert_eq!(client.get_edited_messages().len(), 1); @@ -315,7 +313,11 @@ async fn test_user_journey_reply_in_conversation() { client.set_update_channel(tx); // 3. Входящее сообщение с вопросом - client.simulate_incoming_message(ChatId::new(666), "Can you send me the report?".to_string(), "Charlie"); + client.simulate_incoming_message( + ChatId::new(666), + "Can you send me the report?".to_string(), + "Charlie", + ); let update = rx.try_recv().ok(); assert!(matches!(update, Some(TdUpdate::NewMessage { .. }))); @@ -324,12 +326,10 @@ async fn test_user_journey_reply_in_conversation() { let question_msg_id = history[0].id(); // 4. Отправляем другое сообщение (не связанное) - client.send_message( - ChatId::new(666), - "Working on it now".to_string(), - None, - None - ).await.unwrap(); + client + .send_message(ChatId::new(666), "Working on it now".to_string(), None, None) + .await + .unwrap(); // 5. Отвечаем на конкретный вопрос (reply) let reply_info = Some(tele_tui::tdlib::ReplyInfo { @@ -338,12 +338,15 @@ async fn test_user_journey_reply_in_conversation() { text: "Can you send me the report?".to_string(), }); - client.send_message( - ChatId::new(666), - "Sure, sending now!".to_string(), - Some(question_msg_id), - reply_info - ).await.unwrap(); + client + .send_message( + ChatId::new(666), + "Sure, sending now!".to_string(), + Some(question_msg_id), + reply_info, + ) + .await + .unwrap(); // 6. Проверяем что reply сохранён let final_history = client.get_chat_history(ChatId::new(666), 50).await.unwrap(); @@ -376,12 +379,10 @@ async fn test_user_journey_network_state_changes() { // 4. Открываем чат и отправляем сообщение client.open_chat(ChatId::new(888)).await.unwrap(); - client.send_message( - ChatId::new(888), - "Test message".to_string(), - None, - None - ).await.unwrap(); + client + .send_message(ChatId::new(888), "Test message".to_string(), None, None) + .await + .unwrap(); // Очищаем канал от update NewMessage let _ = rx.try_recv(); @@ -391,8 +392,14 @@ async fn test_user_journey_network_state_changes() { // Проверяем update let update = rx.try_recv().ok(); - assert!(matches!(update, Some(TdUpdate::ConnectionState { state: NetworkState::WaitingForNetwork })), - "Expected ConnectionState update, got: {:?}", update); + assert!( + matches!( + update, + Some(TdUpdate::ConnectionState { state: NetworkState::WaitingForNetwork }) + ), + "Expected ConnectionState update, got: {:?}", + update + ); // 6. Проверяем что состояние изменилось assert_eq!(client.get_network_state(), NetworkState::WaitingForNetwork); @@ -405,12 +412,10 @@ async fn test_user_journey_network_state_changes() { assert_eq!(client.get_network_state(), NetworkState::Ready); // 8. Отправляем сообщение после восстановления - client.send_message( - ChatId::new(888), - "Connection restored!".to_string(), - None, - None - ).await.unwrap(); + client + .send_message(ChatId::new(888), "Connection restored!".to_string(), None, None) + .await + .unwrap(); // 9. Проверяем что оба сообщения в истории let history = client.get_chat_history(ChatId::new(888), 50).await.unwrap(); diff --git a/tests/edit_message.rs b/tests/edit_message.rs index 66881b8..ecb77af 100644 --- a/tests/edit_message.rs +++ b/tests/edit_message.rs @@ -12,10 +12,16 @@ async fn test_edit_message_changes_text() { let client = FakeTdClient::new(); // Отправляем сообщение - let msg = client.send_message(ChatId::new(123), "Original text".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "Original text".to_string(), None, None) + .await + .unwrap(); // Редактируем сообщение - client.edit_message(ChatId::new(123), msg.id(), "Edited text".to_string()).await.unwrap(); + client + .edit_message(ChatId::new(123), msg.id(), "Edited text".to_string()) + .await + .unwrap(); // Проверяем что редактирование записалось assert_eq!(client.get_edited_messages().len(), 1); @@ -34,7 +40,10 @@ async fn test_edit_message_sets_edit_date() { let client = FakeTdClient::new(); // Отправляем сообщение - let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "Original".to_string(), None, None) + .await + .unwrap(); // Получаем дату до редактирования let messages_before = client.get_messages(123); @@ -42,7 +51,10 @@ async fn test_edit_message_sets_edit_date() { assert_eq!(messages_before[0].metadata.edit_date, 0); // Не редактировалось // Редактируем сообщение - client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap(); + client + .edit_message(ChatId::new(123), msg.id(), "Edited".to_string()) + .await + .unwrap(); // Проверяем что edit_date установлена let messages_after = client.get_messages(123); @@ -78,16 +90,28 @@ async fn test_can_only_edit_own_messages() { async fn test_multiple_edits_of_same_message() { let client = FakeTdClient::new(); - let msg = client.send_message(ChatId::new(123), "Version 1".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "Version 1".to_string(), None, None) + .await + .unwrap(); // Первое редактирование - client.edit_message(ChatId::new(123), msg.id(), "Version 2".to_string()).await.unwrap(); + client + .edit_message(ChatId::new(123), msg.id(), "Version 2".to_string()) + .await + .unwrap(); // Второе редактирование - client.edit_message(ChatId::new(123), msg.id(), "Version 3".to_string()).await.unwrap(); + client + .edit_message(ChatId::new(123), msg.id(), "Version 3".to_string()) + .await + .unwrap(); // Третье редактирование - client.edit_message(ChatId::new(123), msg.id(), "Final version".to_string()).await.unwrap(); + client + .edit_message(ChatId::new(123), msg.id(), "Final version".to_string()) + .await + .unwrap(); // Проверяем что все 3 редактирования записаны assert_eq!(client.get_edited_messages().len(), 3); @@ -107,7 +131,9 @@ async fn test_edit_nonexistent_message() { let client = FakeTdClient::new(); // Пытаемся отредактировать несуществующее сообщение - let result = client.edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string()).await; + let result = client + .edit_message(ChatId::new(123), MessageId::new(999), "New text".to_string()) + .await; // Должна вернуться ошибка assert!(result.is_err()); @@ -124,7 +150,10 @@ async fn test_edit_nonexistent_message() { async fn test_edit_history_tracking() { let client = FakeTdClient::new(); - let msg = client.send_message(ChatId::new(123), "Original".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "Original".to_string(), None, None) + .await + .unwrap(); // Симулируем начало редактирования -> изменение -> отмена // Отменять на уровне FakeTdClient нельзя, но можно проверить что original сохранён @@ -134,14 +163,20 @@ async fn test_edit_history_tracking() { let original = messages_before[0].text().to_string(); // Редактируем - client.edit_message(ChatId::new(123), msg.id(), "Edited".to_string()).await.unwrap(); + client + .edit_message(ChatId::new(123), msg.id(), "Edited".to_string()) + .await + .unwrap(); // Проверяем что изменилось let messages_edited = client.get_messages(123); assert_eq!(messages_edited[0].text(), "Edited"); // Можем "отменить" редактирование вернув original - client.edit_message(ChatId::new(123), msg.id(), original).await.unwrap(); + client + .edit_message(ChatId::new(123), msg.id(), original) + .await + .unwrap(); // Проверяем что вернулось let messages_restored = client.get_messages(123); diff --git a/tests/helpers/app_builder.rs b/tests/helpers/app_builder.rs index 0c8c569..5301d8c 100644 --- a/tests/helpers/app_builder.rs +++ b/tests/helpers/app_builder.rs @@ -1,15 +1,16 @@ // Test App builder +use super::FakeTdClient; use ratatui::widgets::ListState; use std::collections::HashMap; -use super::FakeTdClient; -use tele_tui::app::{App, AppScreen, ChatState}; +use tele_tui::app::{App, AppScreen, ChatState, InputMode}; use tele_tui::config::Config; use tele_tui::tdlib::AuthState; use tele_tui::tdlib::{ChatInfo, MessageInfo}; use tele_tui::types::{ChatId, MessageId}; /// Builder для создания тестового App с FakeTdClient\n///\n/// Использует trait-based DI для подмены TdClient на FakeTdClient в тестах. +#[allow(dead_code)] pub struct TestAppBuilder { config: Config, screen: AppScreen, @@ -19,6 +20,7 @@ pub struct TestAppBuilder { is_searching: bool, search_query: String, chat_state: Option, + input_mode: Option, messages: HashMap>, status_message: Option, auth_state: Option, @@ -33,6 +35,7 @@ impl Default for TestAppBuilder { } } +#[allow(dead_code)] impl TestAppBuilder { pub fn new() -> Self { Self { @@ -44,6 +47,7 @@ impl TestAppBuilder { is_searching: false, search_query: String::new(), chat_state: None, + input_mode: None, messages: HashMap::new(), status_message: None, auth_state: None, @@ -133,7 +137,8 @@ impl TestAppBuilder { /// Подтверждение удаления pub fn delete_confirmation(mut self, message_id: i64) -> Self { - self.chat_state = Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) }); + self.chat_state = + Some(ChatState::DeleteConfirmation { message_id: MessageId::new(message_id) }); self } @@ -171,11 +176,15 @@ impl TestAppBuilder { self } + /// Установить Insert mode + pub fn insert_mode(mut self) -> Self { + self.input_mode = Some(InputMode::Insert); + self + } + /// Режим пересылки сообщения pub fn forward_mode(mut self, message_id: i64) -> Self { - self.chat_state = Some(ChatState::Forward { - message_id: MessageId::new(message_id), - }); + self.chat_state = Some(ChatState::Forward { message_id: MessageId::new(message_id) }); self } @@ -216,17 +225,17 @@ impl TestAppBuilder { pub fn build(self) -> App { // Создаём FakeTdClient с чатами и сообщениями let mut fake_client = FakeTdClient::new(); - + // Добавляем чаты for chat in &self.chats { fake_client = fake_client.with_chat(chat.clone()); } - + // Добавляем сообщения for (chat_id, messages) in self.messages { fake_client = fake_client.with_messages(chat_id, messages); } - + // Устанавливаем текущий чат если нужно if let Some(chat_id) = self.selected_chat_id { *fake_client.current_chat_id.lock().unwrap() = Some(chat_id); @@ -236,7 +245,7 @@ impl TestAppBuilder { if let Some(auth_state) = self.auth_state { fake_client = fake_client.with_auth_state(auth_state); } - + // Создаём App с FakeTdClient let mut app = App::with_client(self.config, fake_client); @@ -246,12 +255,17 @@ impl TestAppBuilder { app.message_input = self.message_input; app.is_searching = self.is_searching; app.search_query = self.search_query; - + // Применяем chat_state если он установлен if let Some(chat_state) = self.chat_state { app.chat_state = chat_state; } + // Применяем input_mode если он установлен + if let Some(input_mode) = self.input_mode { + app.input_mode = input_mode; + } + // Применяем status_message if let Some(status) = self.status_message { app.status_message = Some(status); @@ -283,6 +297,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() { diff --git a/tests/helpers/fake_tdclient.rs b/tests/helpers/fake_tdclient.rs index 19558a3..c598546 100644 --- a/tests/helpers/fake_tdclient.rs +++ b/tests/helpers/fake_tdclient.rs @@ -2,25 +2,53 @@ use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo}; use tele_tui::tdlib::types::{FolderInfo, ReactionInfo}; +use tele_tui::tdlib::{AuthState, ChatInfo, MessageInfo, NetworkState, ProfileInfo, ReplyInfo}; use tele_tui::types::{ChatId, MessageId, UserId}; use tokio::sync::mpsc; /// Update события от TDLib (упрощённая версия) #[derive(Debug, Clone)] +#[allow(dead_code)] pub enum TdUpdate { - NewMessage { chat_id: ChatId, message: MessageInfo }, - MessageContent { chat_id: ChatId, message_id: MessageId, new_text: String }, - DeleteMessages { chat_id: ChatId, message_ids: Vec }, - ChatAction { chat_id: ChatId, user_id: UserId, action: String }, - MessageInteractionInfo { chat_id: ChatId, message_id: MessageId, reactions: Vec }, - ConnectionState { state: NetworkState }, - ChatReadOutbox { chat_id: ChatId, last_read_outbox_message_id: MessageId }, - ChatDraftMessage { chat_id: ChatId, draft_text: Option }, + NewMessage { + chat_id: ChatId, + message: MessageInfo, + }, + MessageContent { + chat_id: ChatId, + message_id: MessageId, + new_text: String, + }, + DeleteMessages { + chat_id: ChatId, + message_ids: Vec, + }, + ChatAction { + chat_id: ChatId, + user_id: UserId, + action: String, + }, + MessageInteractionInfo { + chat_id: ChatId, + message_id: MessageId, + reactions: Vec, + }, + ConnectionState { + state: NetworkState, + }, + ChatReadOutbox { + chat_id: ChatId, + last_read_outbox_message_id: MessageId, + }, + ChatDraftMessage { + chat_id: ChatId, + draft_text: Option, + }, } /// Упрощённый mock TDLib клиента для тестов +#[allow(dead_code)] pub struct FakeTdClient { // Данные pub chats: Arc>>, @@ -30,14 +58,14 @@ pub struct FakeTdClient { pub profiles: Arc>>, pub drafts: Arc>>, pub available_reactions: Arc>>, - + // Состояние pub network_state: Arc>, pub typing_chat_id: Arc>>, pub current_chat_id: Arc>>, pub current_pinned_message: Arc>>, pub auth_state: Arc>, - + // История действий (для проверки в тестах) pub sent_messages: Arc>>, pub edited_messages: Arc>>, @@ -45,18 +73,22 @@ pub struct FakeTdClient { pub forwarded_messages: Arc>>, pub searched_queries: Arc>>, pub viewed_messages: Arc)>>>, // (chat_id, message_ids) - pub chat_actions: Arc>>, // (chat_id, action) + pub chat_actions: Arc>>, // (chat_id, action) pub pending_view_messages: Arc)>>>, // Очередь для отметки как прочитанные - + // Update channel для симуляции событий pub update_tx: Arc>>>, - + + // Скачанные файлы (file_id -> local_path) + pub downloaded_files: Arc>>, + // Настройки поведения pub simulate_delays: bool, pub fail_next_operation: Arc>, } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct SentMessage { pub chat_id: i64, pub text: String, @@ -65,6 +97,7 @@ pub struct SentMessage { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct EditedMessage { pub chat_id: i64, pub message_id: MessageId, @@ -72,6 +105,7 @@ pub struct EditedMessage { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct DeletedMessages { pub chat_id: i64, pub message_ids: Vec, @@ -79,6 +113,7 @@ pub struct DeletedMessages { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct ForwardedMessages { pub from_chat_id: i64, pub to_chat_id: i64, @@ -86,6 +121,7 @@ pub struct ForwardedMessages { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct SearchQuery { pub chat_id: i64, pub query: String, @@ -121,6 +157,7 @@ impl Clone for FakeTdClient { viewed_messages: Arc::clone(&self.viewed_messages), chat_actions: Arc::clone(&self.chat_actions), pending_view_messages: Arc::clone(&self.pending_view_messages), + downloaded_files: Arc::clone(&self.downloaded_files), update_tx: Arc::clone(&self.update_tx), simulate_delays: self.simulate_delays, fail_next_operation: Arc::clone(&self.fail_next_operation), @@ -128,6 +165,7 @@ impl Clone for FakeTdClient { } } +#[allow(dead_code)] impl FakeTdClient { pub fn new() -> Self { Self { @@ -138,8 +176,14 @@ impl FakeTdClient { profiles: Arc::new(Mutex::new(HashMap::new())), drafts: Arc::new(Mutex::new(HashMap::new())), available_reactions: Arc::new(Mutex::new(vec![ - "👍".to_string(), "❤️".to_string(), "😂".to_string(), "😮".to_string(), - "😢".to_string(), "🙏".to_string(), "👏".to_string(), "🔥".to_string(), + "👍".to_string(), + "❤️".to_string(), + "😂".to_string(), + "😮".to_string(), + "😢".to_string(), + "🙏".to_string(), + "👏".to_string(), + "🔥".to_string(), ])), network_state: Arc::new(Mutex::new(NetworkState::Ready)), typing_chat_id: Arc::new(Mutex::new(None)), @@ -154,19 +198,20 @@ impl FakeTdClient { viewed_messages: Arc::new(Mutex::new(vec![])), chat_actions: Arc::new(Mutex::new(vec![])), pending_view_messages: Arc::new(Mutex::new(vec![])), + downloaded_files: Arc::new(Mutex::new(HashMap::new())), update_tx: Arc::new(Mutex::new(None)), simulate_delays: false, fail_next_operation: Arc::new(Mutex::new(false)), } } - + /// Создать update channel для получения событий pub fn with_update_channel(self) -> (Self, mpsc::UnboundedReceiver) { let (tx, rx) = mpsc::unbounded_channel(); *self.update_tx.lock().unwrap() = Some(tx); (self, rx) } - + /// Включить симуляцию задержек (как в реальном TDLib) pub fn with_delays(mut self) -> Self { self.simulate_delays = true; @@ -174,7 +219,7 @@ impl FakeTdClient { } // ==================== Builder Methods ==================== - + /// Добавить чат pub fn with_chat(self, chat: ChatInfo) -> Self { self.chats.lock().unwrap().push(chat); @@ -200,16 +245,16 @@ impl FakeTdClient { /// Добавить несколько сообщений в чат pub fn with_messages(self, chat_id: i64, messages: Vec) -> Self { - self.messages - .lock() - .unwrap() - .insert(chat_id, messages); + self.messages.lock().unwrap().insert(chat_id, messages); self } /// Добавить папку pub fn with_folder(self, id: i32, name: &str) -> Self { - self.folders.lock().unwrap().push(FolderInfo { id, name: name.to_string() }); + self.folders + .lock() + .unwrap() + .push(FolderInfo { id, name: name.to_string() }); self } @@ -236,7 +281,16 @@ impl FakeTdClient { *self.auth_state.lock().unwrap() = state; self } - + + /// Добавить скачанный файл (для mock download_file) + pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self { + self.downloaded_files + .lock() + .unwrap() + .insert(file_id, path.to_string()); + self + } + /// Установить доступные реакции pub fn with_available_reactions(self, reactions: Vec) -> Self { *self.available_reactions.lock().unwrap() = reactions; @@ -244,60 +298,76 @@ impl FakeTdClient { } // ==================== Async TDLib Operations ==================== - + /// Загрузить список чатов pub async fn load_chats(&self, limit: usize) -> Result, String> { if self.should_fail() { return Err("Failed to load chats".to_string()); } - + if self.simulate_delays { tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; } - - let chats = self.chats.lock().unwrap().iter().take(limit).cloned().collect(); + + let chats = self + .chats + .lock() + .unwrap() + .iter() + .take(limit) + .cloned() + .collect(); Ok(chats) } - + /// Открыть чат pub async fn open_chat(&self, chat_id: ChatId) -> Result<(), String> { if self.should_fail() { return Err("Failed to open chat".to_string()); } - + *self.current_chat_id.lock().unwrap() = Some(chat_id.as_i64()); Ok(()) } - + /// Получить историю чата - pub async fn get_chat_history(&self, chat_id: ChatId, limit: i32) -> Result, String> { + pub async fn get_chat_history( + &self, + chat_id: ChatId, + limit: i32, + ) -> Result, String> { if self.should_fail() { return Err("Failed to load history".to_string()); } - + if self.simulate_delays { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } - - let messages = self.messages + + let messages = self + .messages .lock() .unwrap() .get(&chat_id.as_i64()) .map(|msgs| msgs.iter().take(limit as usize).cloned().collect()) .unwrap_or_default(); - + Ok(messages) } - + /// Загрузить старые сообщения - pub async fn load_older_messages(&self, chat_id: ChatId, from_message_id: MessageId) -> Result, String> { + pub async fn load_older_messages( + &self, + chat_id: ChatId, + from_message_id: MessageId, + ) -> Result, String> { if self.should_fail() { return Err("Failed to load older messages".to_string()); } - + let messages = self.messages.lock().unwrap(); let chat_messages = messages.get(&chat_id.as_i64()).ok_or("Chat not found")?; - + // Найти индекс сообщения и вернуть предыдущие if let Some(idx) = chat_messages.iter().position(|m| m.id() == from_message_id) { let older: Vec<_> = chat_messages.iter().take(idx).cloned().collect(); @@ -318,24 +388,24 @@ impl FakeTdClient { if self.should_fail() { return Err("Failed to send message".to_string()); } - + if self.simulate_delays { tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; } - + let message_id = MessageId::new((self.sent_messages.lock().unwrap().len() as i64) + 1000); - + self.sent_messages.lock().unwrap().push(SentMessage { chat_id: chat_id.as_i64(), text: text.clone(), reply_to, reply_info: reply_info.clone(), }); - + let message = MessageInfo::new( message_id, "You".to_string(), - true, // is_outgoing + true, // is_outgoing text.clone(), vec![], // entities chrono::Utc::now().timestamp() as i32, @@ -345,10 +415,10 @@ impl FakeTdClient { true, // can_be_deleted_only_for_self true, // can_be_deleted_for_all_users reply_info, - None, // forward_from + None, // forward_from vec![], // reactions ); - + // Добавляем в историю self.messages .lock() @@ -356,16 +426,13 @@ impl FakeTdClient { .entry(chat_id.as_i64()) .or_insert_with(Vec::new) .push(message.clone()); - + // Отправляем Update::NewMessage - self.send_update(TdUpdate::NewMessage { - chat_id, - message: message.clone(), - }); - + self.send_update(TdUpdate::NewMessage { chat_id, message: message.clone() }); + Ok(message) } - + /// Редактировать сообщение pub async fn edit_message( &self, @@ -376,41 +443,37 @@ impl FakeTdClient { if self.should_fail() { return Err("Failed to edit message".to_string()); } - + if self.simulate_delays { tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; } - + self.edited_messages.lock().unwrap().push(EditedMessage { chat_id: chat_id.as_i64(), message_id, new_text: new_text.clone(), }); - + // Обновляем сообщение let mut messages = self.messages.lock().unwrap(); if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { msg.content.text = new_text.clone(); msg.metadata.edit_date = msg.metadata.date + 60; - + let updated = msg.clone(); drop(messages); // Освобождаем lock перед отправкой update - + // Отправляем Update - self.send_update(TdUpdate::MessageContent { - chat_id, - message_id, - new_text, - }); - + self.send_update(TdUpdate::MessageContent { chat_id, message_id, new_text }); + return Ok(updated); } } - + Err("Message not found".to_string()) } - + /// Удалить сообщения pub async fn delete_messages( &self, @@ -421,33 +484,30 @@ impl FakeTdClient { if self.should_fail() { return Err("Failed to delete messages".to_string()); } - + if self.simulate_delays { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; } - + self.deleted_messages.lock().unwrap().push(DeletedMessages { chat_id: chat_id.as_i64(), message_ids: message_ids.clone(), revoke, }); - + // Удаляем из истории let mut messages = self.messages.lock().unwrap(); if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { chat_msgs.retain(|m| !message_ids.contains(&m.id())); } drop(messages); - + // Отправляем Update - self.send_update(TdUpdate::DeleteMessages { - chat_id, - message_ids, - }); - + self.send_update(TdUpdate::DeleteMessages { chat_id, message_ids }); + Ok(()) } - + /// Переслать сообщения pub async fn forward_messages( &self, @@ -458,26 +518,33 @@ impl FakeTdClient { if self.should_fail() { return Err("Failed to forward messages".to_string()); } - + if self.simulate_delays { tokio::time::sleep(tokio::time::Duration::from_millis(150)).await; } - - self.forwarded_messages.lock().unwrap().push(ForwardedMessages { - from_chat_id: from_chat_id.as_i64(), - to_chat_id: to_chat_id.as_i64(), - message_ids, - }); - + + self.forwarded_messages + .lock() + .unwrap() + .push(ForwardedMessages { + from_chat_id: from_chat_id.as_i64(), + to_chat_id: to_chat_id.as_i64(), + message_ids, + }); + Ok(()) } /// Поиск сообщений в чате - pub async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result, String> { + pub async fn search_messages( + &self, + chat_id: ChatId, + query: &str, + ) -> Result, String> { if self.should_fail() { return Err("Failed to search messages".to_string()); } - + let messages = self.messages.lock().unwrap(); let results: Vec<_> = messages .get(&chat_id.as_i64()) @@ -488,43 +555,49 @@ impl FakeTdClient { .collect() }) .unwrap_or_default(); - + self.searched_queries.lock().unwrap().push(SearchQuery { chat_id: chat_id.as_i64(), query: query.to_string(), results_count: results.len(), }); - + Ok(results) } - + /// Установить черновик pub async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> { if text.is_empty() { self.drafts.lock().unwrap().remove(&chat_id.as_i64()); } else { - self.drafts.lock().unwrap().insert(chat_id.as_i64(), text.clone()); + self.drafts + .lock() + .unwrap() + .insert(chat_id.as_i64(), text.clone()); } - + self.send_update(TdUpdate::ChatDraftMessage { chat_id, draft_text: if text.is_empty() { None } else { Some(text) }, }); - + Ok(()) } - + /// Отправить действие в чате (typing, etc.) pub async fn send_chat_action(&self, chat_id: ChatId, action: String) { - self.chat_actions.lock().unwrap().push((chat_id.as_i64(), action.clone())); - + self.chat_actions + .lock() + .unwrap() + .push((chat_id.as_i64(), action.clone())); + if action == "Typing" { *self.typing_chat_id.lock().unwrap() = Some(chat_id.as_i64()); } else if action == "Cancel" { *self.typing_chat_id.lock().unwrap() = None; } } - + /// Получить доступные реакции для сообщения pub async fn get_message_available_reactions( &self, @@ -534,10 +607,10 @@ impl FakeTdClient { if self.should_fail() { return Err("Failed to get available reactions".to_string()); } - + Ok(self.available_reactions.lock().unwrap().clone()) } - + /// Установить/удалить реакцию pub async fn toggle_reaction( &self, @@ -548,15 +621,18 @@ impl FakeTdClient { if self.should_fail() { return Err("Failed to toggle reaction".to_string()); } - + // Обновляем реакции на сообщении let mut messages = self.messages.lock().unwrap(); if let Some(chat_msgs) = messages.get_mut(&chat_id.as_i64()) { if let Some(msg) = chat_msgs.iter_mut().find(|m| m.id() == message_id) { let reactions = &mut msg.interactions.reactions; - + // Toggle logic - if let Some(pos) = reactions.iter().position(|r| r.emoji == emoji && r.is_chosen) { + if let Some(pos) = reactions + .iter() + .position(|r| r.emoji == emoji && r.is_chosen) + { // Удаляем свою реакцию reactions.remove(pos); } else if let Some(reaction) = reactions.iter_mut().find(|r| r.emoji == emoji) { @@ -571,10 +647,10 @@ impl FakeTdClient { is_chosen: true, }); } - + let updated_reactions = reactions.clone(); drop(messages); - + // Отправляем Update self.send_update(TdUpdate::MessageInteractionInfo { chat_id, @@ -583,16 +659,30 @@ impl FakeTdClient { }); } } - + Ok(()) } - + + /// Скачать файл (mock) + pub async fn download_file(&self, file_id: i32) -> Result { + if self.should_fail() { + return Err("Failed to download file".to_string()); + } + + self.downloaded_files + .lock() + .unwrap() + .get(&file_id) + .cloned() + .ok_or_else(|| format!("File {} not found", file_id)) + } + /// Получить информацию о профиле pub async fn get_profile_info(&self, chat_id: ChatId) -> Result { if self.should_fail() { return Err("Failed to get profile info".to_string()); } - + self.profiles .lock() .unwrap() @@ -600,7 +690,7 @@ impl FakeTdClient { .cloned() .ok_or_else(|| "Profile not found".to_string()) } - + /// Отметить сообщения как просмотренные pub async fn view_messages(&self, chat_id: ChatId, message_ids: Vec) { self.viewed_messages @@ -608,25 +698,25 @@ impl FakeTdClient { .unwrap() .push((chat_id.as_i64(), message_ids.iter().map(|id| id.as_i64()).collect())); } - + /// Загрузить чаты папки pub async fn load_folder_chats(&self, _folder_id: i32, _limit: usize) -> Result<(), String> { if self.should_fail() { return Err("Failed to load folder chats".to_string()); } - + Ok(()) } - + // ==================== Helper Methods ==================== - + /// Отправить update в канал (если он установлен) fn send_update(&self, update: TdUpdate) { if let Some(tx) = self.update_tx.lock().unwrap().as_ref() { let _ = tx.send(update); } } - + /// Проверить нужно ли симулировать ошибку fn should_fail(&self) -> bool { let mut fail = self.fail_next_operation.lock().unwrap(); @@ -637,16 +727,16 @@ impl FakeTdClient { false } } - + /// Симулировать ошибку в следующей операции pub fn fail_next(&self) { *self.fail_next_operation.lock().unwrap() = true; } - + /// Симулировать входящее сообщение pub fn simulate_incoming_message(&self, chat_id: ChatId, text: String, sender_name: &str) { let message_id = MessageId::new(9000 + chrono::Utc::now().timestamp()); - + let message = MessageInfo::new( message_id, sender_name.to_string(), @@ -663,7 +753,7 @@ impl FakeTdClient { None, vec![], ); - + // Добавляем в историю self.messages .lock() @@ -671,26 +761,22 @@ impl FakeTdClient { .entry(chat_id.as_i64()) .or_insert_with(Vec::new) .push(message.clone()); - + // Отправляем Update self.send_update(TdUpdate::NewMessage { chat_id, message }); } - + /// Симулировать typing от собеседника pub fn simulate_typing(&self, chat_id: ChatId, user_id: UserId) { - self.send_update(TdUpdate::ChatAction { - chat_id, - user_id, - action: "Typing".to_string(), - }); + self.send_update(TdUpdate::ChatAction { chat_id, user_id, action: "Typing".to_string() }); } - + /// Симулировать изменение состояния сети pub fn simulate_network_change(&self, state: NetworkState) { *self.network_state.lock().unwrap() = state.clone(); self.send_update(TdUpdate::ConnectionState { state }); } - + /// Симулировать прочтение сообщений pub fn simulate_read_outbox(&self, chat_id: ChatId, last_read_message_id: MessageId) { self.send_update(TdUpdate::ChatReadOutbox { @@ -698,9 +784,9 @@ impl FakeTdClient { last_read_outbox_message_id: last_read_message_id, }); } - + // ==================== Getters for Test Assertions ==================== - + /// Получить все чаты pub fn get_chats(&self) -> Vec { self.chats.lock().unwrap().clone() @@ -720,57 +806,57 @@ impl FakeTdClient { .cloned() .unwrap_or_default() } - + /// Получить отправленные сообщения pub fn get_sent_messages(&self) -> Vec { self.sent_messages.lock().unwrap().clone() } - + /// Получить отредактированные сообщения pub fn get_edited_messages(&self) -> Vec { self.edited_messages.lock().unwrap().clone() } - + /// Получить удалённые сообщения pub fn get_deleted_messages(&self) -> Vec { self.deleted_messages.lock().unwrap().clone() } - + /// Получить пересланные сообщения pub fn get_forwarded_messages(&self) -> Vec { self.forwarded_messages.lock().unwrap().clone() } - + /// Получить поисковые запросы pub fn get_search_queries(&self) -> Vec { self.searched_queries.lock().unwrap().clone() } - + /// Получить просмотренные сообщения pub fn get_viewed_messages(&self) -> Vec<(i64, Vec)> { self.viewed_messages.lock().unwrap().clone() } - + /// Получить действия в чатах pub fn get_chat_actions(&self) -> Vec<(i64, String)> { self.chat_actions.lock().unwrap().clone() } - + /// Получить текущее состояние сети pub fn get_network_state(&self) -> NetworkState { self.network_state.lock().unwrap().clone() } - + /// Получить ID текущего открытого чата pub fn get_current_chat_id(&self) -> Option { *self.current_chat_id.lock().unwrap() } - + /// Установить update channel для получения событий pub fn set_update_channel(&self, tx: mpsc::UnboundedSender) { *self.update_tx.lock().unwrap() = Some(tx); } - + /// Очистить всю историю действий pub fn clear_all_history(&self) { self.sent_messages.lock().unwrap().clear(); @@ -810,10 +896,12 @@ mod tests { async fn test_send_message() { let client = FakeTdClient::new(); let chat_id = ChatId::new(123); - - let result = client.send_message(chat_id, "Hello".to_string(), None, None).await; + + let result = client + .send_message(chat_id, "Hello".to_string(), None, None) + .await; assert!(result.is_ok()); - + let sent = client.get_sent_messages(); assert_eq!(sent.len(), 1); assert_eq!(sent[0].text, "Hello"); @@ -824,12 +912,17 @@ mod tests { async fn test_edit_message() { let client = FakeTdClient::new(); let chat_id = ChatId::new(123); - - let msg = client.send_message(chat_id, "Hello".to_string(), None, None).await.unwrap(); + + let msg = client + .send_message(chat_id, "Hello".to_string(), None, None) + .await + .unwrap(); let msg_id = msg.id(); - - let _ = client.edit_message(chat_id, msg_id, "Hello World".to_string()).await; - + + let _ = client + .edit_message(chat_id, msg_id, "Hello World".to_string()) + .await; + let edited = client.get_edited_messages(); assert_eq!(edited.len(), 1); assert_eq!(client.get_messages(123)[0].text(), "Hello World"); @@ -840,25 +933,30 @@ mod tests { async fn test_delete_message() { let client = FakeTdClient::new(); let chat_id = ChatId::new(123); - - let msg = client.send_message(chat_id, "Hello".to_string(), None, None).await.unwrap(); + + let msg = client + .send_message(chat_id, "Hello".to_string(), None, None) + .await + .unwrap(); let msg_id = msg.id(); - + let _ = client.delete_messages(chat_id, vec![msg_id], false).await; - + let deleted = client.get_deleted_messages(); assert_eq!(deleted.len(), 1); assert_eq!(client.get_messages(123).len(), 0); } - + #[tokio::test] async fn test_update_channel() { let (client, mut rx) = FakeTdClient::new().with_update_channel(); let chat_id = ChatId::new(123); - + // Отправляем сообщение - let _ = client.send_message(chat_id, "Test".to_string(), None, None).await; - + let _ = client + .send_message(chat_id, "Test".to_string(), None, None) + .await; + // Проверяем что получили Update if let Some(update) = rx.recv().await { match update { @@ -871,39 +969,43 @@ mod tests { panic!("No update received"); } } - + #[tokio::test] async fn test_simulate_incoming_message() { let (client, mut rx) = FakeTdClient::new().with_update_channel(); let chat_id = ChatId::new(123); - + client.simulate_incoming_message(chat_id, "Hello from Bob".to_string(), "Bob"); - + // Проверяем Update if let Some(TdUpdate::NewMessage { message, .. }) = rx.recv().await { assert_eq!(message.text(), "Hello from Bob"); assert_eq!(message.sender_name(), "Bob"); assert!(!message.is_outgoing()); } - + // Проверяем что сообщение добавилось assert_eq!(client.get_messages(123).len(), 1); } - + #[tokio::test] async fn test_fail_next_operation() { let client = FakeTdClient::new(); let chat_id = ChatId::new(123); - + // Устанавливаем флаг ошибки client.fail_next(); - + // Следующая операция должна упасть - let result = client.send_message(chat_id, "Test".to_string(), None, None).await; + let result = client + .send_message(chat_id, "Test".to_string(), None, None) + .await; assert!(result.is_err()); - + // Но следующая должна пройти - let result2 = client.send_message(chat_id, "Test2".to_string(), None, None).await; + let result2 = client + .send_message(chat_id, "Test2".to_string(), None, None) + .await; assert!(result2.is_ok()); } } diff --git a/tests/helpers/fake_tdclient_impl.rs b/tests/helpers/fake_tdclient_impl.rs index 05a3f44..8104bc6 100644 --- a/tests/helpers/fake_tdclient_impl.rs +++ b/tests/helpers/fake_tdclient_impl.rs @@ -2,9 +2,13 @@ use super::fake_tdclient::FakeTdClient; use async_trait::async_trait; +use std::path::PathBuf; use tdlib_rs::enums::{ChatAction, Update}; -use tele_tui::tdlib::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus}; use tele_tui::tdlib::TdClientTrait; +use tele_tui::tdlib::{ + AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, + UserOnlineStatus, +}; use tele_tui::types::{ChatId, MessageId, UserId}; #[async_trait] @@ -54,11 +58,19 @@ impl TdClientTrait for FakeTdClient { } // ============ Message methods ============ - async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result, String> { + async fn get_chat_history( + &mut self, + chat_id: ChatId, + limit: i32, + ) -> Result, String> { FakeTdClient::get_chat_history(self, chat_id, limit).await } - async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result, String> { + async fn load_older_messages( + &mut self, + chat_id: ChatId, + from_message_id: MessageId, + ) -> Result, String> { FakeTdClient::load_older_messages(self, chat_id, from_message_id).await } @@ -71,7 +83,11 @@ impl TdClientTrait for FakeTdClient { // Not implemented for fake } - async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result, String> { + async fn search_messages( + &self, + chat_id: ChatId, + query: &str, + ) -> Result, String> { FakeTdClient::search_messages(self, chat_id, query).await } @@ -129,7 +145,10 @@ impl TdClientTrait for FakeTdClient { let mut pending = self.pending_view_messages.lock().unwrap(); for (chat_id, message_ids) in pending.drain(..) { let ids: Vec = message_ids.iter().map(|id| id.as_i64()).collect(); - self.viewed_messages.lock().unwrap().push((chat_id.as_i64(), ids)); + self.viewed_messages + .lock() + .unwrap() + .push((chat_id.as_i64(), ids)); } } @@ -161,6 +180,16 @@ impl TdClientTrait for FakeTdClient { FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await } + // ============ File methods ============ + async fn download_file(&self, file_id: i32) -> Result { + FakeTdClient::download_file(self, file_id).await + } + + async fn download_voice_note(&self, file_id: i32) -> Result { + // Fake implementation: return a fake path + Ok(format!("/tmp/fake_voice_{}.ogg", file_id)) + } + // ============ Getters (immutable) ============ fn client_id(&self) -> i32 { 0 // Fake client ID @@ -178,13 +207,17 @@ impl TdClientTrait for FakeTdClient { static AUTH_STATE_WAIT_PHONE: OnceLock = OnceLock::new(); static AUTH_STATE_WAIT_CODE: OnceLock = OnceLock::new(); static AUTH_STATE_WAIT_PASSWORD: OnceLock = OnceLock::new(); - + let current = self.auth_state.lock().unwrap(); match *current { AuthState::Ready => &AUTH_STATE_READY, - AuthState::WaitPhoneNumber => AUTH_STATE_WAIT_PHONE.get_or_init(|| AuthState::WaitPhoneNumber), + AuthState::WaitPhoneNumber => { + AUTH_STATE_WAIT_PHONE.get_or_init(|| AuthState::WaitPhoneNumber) + } AuthState::WaitCode => AUTH_STATE_WAIT_CODE.get_or_init(|| AuthState::WaitCode), - AuthState::WaitPassword => AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword), + AuthState::WaitPassword => { + AUTH_STATE_WAIT_PASSWORD.get_or_init(|| AuthState::WaitPassword) + } _ => &AUTH_STATE_READY, } } @@ -304,6 +337,12 @@ impl TdClientTrait for FakeTdClient { // Not implemented for fake client (notifications are not tested) } + // ============ Account switching ============ + async fn recreate_client(&mut self, _db_path: PathBuf) -> Result<(), String> { + // No-op for fake client + Ok(()) + } + // ============ Update handling ============ fn handle_update(&mut self, _update: Update) { // Not implemented for fake client diff --git a/tests/helpers/test_data.rs b/tests/helpers/test_data.rs index 02a7ac4..9d655da 100644 --- a/tests/helpers/test_data.rs +++ b/tests/helpers/test_data.rs @@ -1,10 +1,11 @@ // Test data builders and fixtures -use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo}; use tele_tui::tdlib::types::{ForwardInfo, ReactionInfo}; +use tele_tui::tdlib::{ChatInfo, MessageInfo, ProfileInfo, ReplyInfo}; use tele_tui::types::{ChatId, MessageId}; /// Builder для создания тестового чата +#[allow(dead_code)] pub struct TestChatBuilder { id: i64, title: String, @@ -21,6 +22,7 @@ pub struct TestChatBuilder { draft_text: Option, } +#[allow(dead_code)] impl TestChatBuilder { pub fn new(title: &str, id: i64) -> Self { Self { @@ -100,6 +102,7 @@ impl TestChatBuilder { } /// Builder для создания тестового сообщения +#[allow(dead_code)] pub struct TestMessageBuilder { id: i64, sender_name: String, @@ -115,8 +118,10 @@ pub struct TestMessageBuilder { reply_to: Option, forward_from: Option, reactions: Vec, + media_album_id: i64, } +#[allow(dead_code)] impl TestMessageBuilder { pub fn new(content: &str, id: i64) -> Self { Self { @@ -134,6 +139,7 @@ impl TestMessageBuilder { reply_to: None, forward_from: None, reactions: vec![], + media_album_id: 0, } } @@ -175,9 +181,7 @@ impl TestMessageBuilder { } pub fn forwarded_from(mut self, sender: &str) -> Self { - self.forward_from = Some(ForwardInfo { - sender_name: sender.to_string(), - }); + self.forward_from = Some(ForwardInfo { sender_name: sender.to_string() }); self } @@ -187,8 +191,13 @@ impl TestMessageBuilder { self } + pub fn media_album_id(mut self, id: i64) -> Self { + self.media_album_id = id; + self + } + pub fn build(self) -> MessageInfo { - MessageInfo::new( + let mut msg = MessageInfo::new( MessageId::new(self.id), self.sender_name, self.is_outgoing, @@ -203,7 +212,9 @@ impl TestMessageBuilder { self.reply_to, self.forward_from, self.reactions, - ) + ); + msg.metadata.media_album_id = self.media_album_id; + msg } } diff --git a/tests/input_field.rs b/tests/input_field.rs index 5570945..c9212c7 100644 --- a/tests/input_field.rs +++ b/tests/input_field.rs @@ -11,13 +11,13 @@ use insta::assert_snapshot; fn snapshot_empty_input() { let chat = create_test_chat("Mom", 123); - let app = TestAppBuilder::new() + let mut app = TestAppBuilder::new() .with_chat(chat) .selected_chat(123) .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -28,14 +28,15 @@ fn snapshot_empty_input() { fn snapshot_input_with_text() { let chat = create_test_chat("Mom", 123); - let app = TestAppBuilder::new() + let mut app = TestAppBuilder::new() .with_chat(chat) .selected_chat(123) + .insert_mode() .message_input("Hello, how are you?") .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -49,14 +50,15 @@ fn snapshot_input_long_text_2_lines() { // Text that wraps to 2 lines let long_text = "This is a longer message that will wrap to multiple lines in the input field for testing purposes."; - let app = TestAppBuilder::new() + let mut app = TestAppBuilder::new() .with_chat(chat) .selected_chat(123) + .insert_mode() .message_input(long_text) .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -70,14 +72,15 @@ fn snapshot_input_long_text_max_lines() { // Very long text that reaches maximum 10 lines let very_long_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo."; - let app = TestAppBuilder::new() + let mut app = TestAppBuilder::new() .with_chat(chat) .selected_chat(123) + .insert_mode() .message_input(very_long_text) .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -91,16 +94,17 @@ fn snapshot_input_editing_mode() { .outgoing() .build(); - let app = TestAppBuilder::new() + let mut app = TestAppBuilder::new() .with_chat(chat) .with_message(123, message) .selected_chat(123) + .insert_mode() .editing_message(1, 0) .message_input("Edited text here") .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -114,16 +118,17 @@ fn snapshot_input_reply_mode() { .sender("Mom") .build(); - let app = TestAppBuilder::new() + let mut app = TestAppBuilder::new() .with_chat(chat) .with_message(123, original_msg) .selected_chat(123) + .insert_mode() .replying_to(1) .message_input("I think it's great!") .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); diff --git a/tests/input_navigation.rs b/tests/input_navigation.rs index 3357c74..d0e9190 100644 --- a/tests/input_navigation.rs +++ b/tests/input_navigation.rs @@ -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 { @@ -144,6 +145,7 @@ async fn test_cursor_navigation_in_input() { let mut app = TestAppBuilder::new() .with_chats(vec![create_test_chat("Chat 1", 101)]) .selected_chat(101) + .insert_mode() .build(); // Вводим текст "Hello" @@ -181,6 +183,7 @@ async fn test_home_end_in_input() { let mut app = TestAppBuilder::new() .with_chats(vec![create_test_chat("Chat 1", 101)]) .selected_chat(101) + .insert_mode() .build(); // Вводим текст @@ -205,6 +208,7 @@ async fn test_backspace_with_cursor() { let mut app = TestAppBuilder::new() .with_chats(vec![create_test_chat("Chat 1", 101)]) .selected_chat(101) + .insert_mode() .build(); // Вводим "Hello" @@ -237,6 +241,7 @@ async fn test_insert_char_at_cursor_position() { let mut app = TestAppBuilder::new() .with_chats(vec![create_test_chat("Chat 1", 101)]) .selected_chat(101) + .insert_mode() .build(); // Вводим "Hllo" @@ -258,31 +263,168 @@ async fn test_insert_char_at_cursor_position() { assert_eq!(app.cursor_position, 2); } -/// Test: Навигация вверх по сообщениям из пустого инпута +/// Test: Normal mode автоматически входит в MessageSelection #[tokio::test] -async fn test_up_arrow_selects_last_message_when_input_empty() { +async fn test_normal_mode_auto_enters_message_selection() { let messages = vec![ TestMessageBuilder::new("Msg 1", 1).outgoing().build(), TestMessageBuilder::new("Msg 2", 2).outgoing().build(), TestMessageBuilder::new("Msg 3", 3).outgoing().build(), ]; - + let mut app = TestAppBuilder::new() .with_chats(vec![create_test_chat("Chat 1", 101)]) .selected_chat(101) .with_messages(101, messages) .build(); - // Инпут пустой + // Инпут пустой, Normal mode assert_eq!(app.message_input, ""); - // Up - должен начать выбор сообщения (последнего) + // Любая клавиша в Normal mode — auto-enters MessageSelection handle_main_input(&mut app, key(KeyCode::Up)).await; // Проверяем что вошли в режим выбора сообщения assert!(app.is_selecting_message()); } +/// Test: j/k перескакивают через альбом как одно сообщение +#[tokio::test] +async fn test_album_navigation_skips_grouped_messages() { + let messages = vec![ + TestMessageBuilder::new("Before album", 1) + .sender("Alice") + .build(), + TestMessageBuilder::new("Photo 1", 2) + .sender("Alice") + .media_album_id(100) + .build(), + TestMessageBuilder::new("Photo 2", 3) + .sender("Alice") + .media_album_id(100) + .build(), + TestMessageBuilder::new("Photo 3", 4) + .sender("Alice") + .media_album_id(100) + .build(), + TestMessageBuilder::new("After album", 5) + .sender("Alice") + .build(), + ]; + + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat 1", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .build(); + + // Входим в режим выбора — начинаем с последнего (index=4, "After album") + app.start_message_selection(); + assert!(app.is_selecting_message()); + + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "After album"); + + // k (up) — перескакиваем альбом, попадаем на первый элемент альбома (index=1) + app.select_previous_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "Photo 1"); + assert_eq!(msg.media_album_id(), 100); + + // k (up) — перескакиваем на сообщение до альбома (index=0) + app.select_previous_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "Before album"); + + // j (down) — перескакиваем на первый элемент альбома (index=1) + app.select_next_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "Photo 1"); + + // j (down) — перескакиваем альбом, попадаем на "After album" (index=4) + app.select_next_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "After album"); +} + +/// Test: Начало выбора, когда последнее сообщение — часть альбома +#[tokio::test] +async fn test_album_navigation_start_at_album_end() { + let messages = vec![ + TestMessageBuilder::new("Regular", 1) + .sender("Alice") + .build(), + TestMessageBuilder::new("Album Photo 1", 2) + .sender("Alice") + .media_album_id(200) + .build(), + TestMessageBuilder::new("Album Photo 2", 3) + .sender("Alice") + .media_album_id(200) + .build(), + ]; + + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat 1", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .build(); + + // Входим в режим выбора — должны оказаться на первом элементе альбома (index=1) + app.start_message_selection(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "Album Photo 1"); + + // k (up) — на обычное сообщение + app.select_previous_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "Regular"); +} + +/// Test: Два альбома подряд — навигация между ними +#[tokio::test] +async fn test_album_navigation_two_albums() { + let messages = vec![ + TestMessageBuilder::new("A1-P1", 1) + .sender("Alice") + .media_album_id(100) + .build(), + TestMessageBuilder::new("A1-P2", 2) + .sender("Alice") + .media_album_id(100) + .build(), + TestMessageBuilder::new("A2-P1", 3) + .sender("Alice") + .media_album_id(200) + .build(), + TestMessageBuilder::new("A2-P2", 4) + .sender("Alice") + .media_album_id(200) + .build(), + ]; + + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat 1", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .build(); + + // Начинаем — последний альбом (index=2, первый элемент album 200) + app.start_message_selection(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "A2-P1"); + + // k — перескакиваем на первый альбом (index=0) + app.select_previous_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "A1-P1"); + + // j — перескакиваем на второй альбом (index=2) + app.select_next_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "A2-P1"); +} + /// Test: Циклическая навигация по списку чатов (переход с конца в начало) #[tokio::test] async fn test_circular_navigation_optional() { diff --git a/tests/messages.rs b/tests/messages.rs index 5b018fa..3c89e99 100644 --- a/tests/messages.rs +++ b/tests/messages.rs @@ -18,7 +18,7 @@ fn snapshot_empty_chat() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -39,7 +39,7 @@ fn snapshot_single_incoming_message() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -58,7 +58,7 @@ fn snapshot_single_outgoing_message() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -80,7 +80,7 @@ fn snapshot_date_separator_old_date() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -111,7 +111,7 @@ fn snapshot_sender_grouping() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -130,7 +130,7 @@ fn snapshot_outgoing_sent() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -160,7 +160,7 @@ fn snapshot_outgoing_read() { } let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -179,7 +179,7 @@ fn snapshot_edited_message() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -199,7 +199,7 @@ fn snapshot_long_message_wrap() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -218,7 +218,7 @@ fn snapshot_markdown_bold_italic_code() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -238,7 +238,7 @@ fn snapshot_markdown_link_mention() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -257,7 +257,7 @@ fn snapshot_markdown_spoiler() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -276,7 +276,7 @@ fn snapshot_media_placeholder() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -297,7 +297,7 @@ fn snapshot_reply_message() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -318,7 +318,7 @@ fn snapshot_forwarded_message() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -339,7 +339,7 @@ fn snapshot_single_reaction() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -361,7 +361,7 @@ fn snapshot_multiple_reactions() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -381,9 +381,124 @@ fn snapshot_selected_message() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); assert_snapshot!("selected_message", output); } + +#[test] +fn snapshot_album_incoming() { + let chat = create_test_chat("Mom", 123); + let msg1 = TestMessageBuilder::new("📷 [Фото]", 1) + .sender("Alice") + .media_album_id(12345) + .build(); + let msg2 = TestMessageBuilder::new("Caption for album", 2) + .sender("Alice") + .media_album_id(12345) + .build(); + let msg3 = TestMessageBuilder::new("📷 [Фото]", 3) + .sender("Alice") + .media_album_id(12345) + .build(); + + let mut app = TestAppBuilder::new() + .with_chat(chat) + .with_messages(123, vec![msg1, msg2, msg3]) + .selected_chat(123) + .build(); + + let buffer = render_to_buffer(80, 24, |f| { + tele_tui::ui::messages::render(f, f.area(), &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("album_incoming", output); +} + +#[test] +fn snapshot_album_outgoing() { + let chat = create_test_chat("Mom", 123); + let msg1 = TestMessageBuilder::new("📷 [Фото]", 1) + .outgoing() + .media_album_id(99999) + .build(); + let msg2 = TestMessageBuilder::new("My vacation photos", 2) + .outgoing() + .media_album_id(99999) + .build(); + + let mut app = TestAppBuilder::new() + .with_chat(chat) + .with_messages(123, vec![msg1, msg2]) + .selected_chat(123) + .build(); + + let buffer = render_to_buffer(80, 24, |f| { + tele_tui::ui::messages::render(f, f.area(), &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("album_outgoing", output); +} + +#[test] +fn snapshot_album_with_regular_messages() { + let chat = create_test_chat("Group Chat", 123); + let msg1 = TestMessageBuilder::new("Regular message before", 1) + .sender("Alice") + .build(); + let msg2 = TestMessageBuilder::new("📷 [Фото]", 2) + .sender("Alice") + .media_album_id(555) + .build(); + let msg3 = TestMessageBuilder::new("Album caption", 3) + .sender("Alice") + .media_album_id(555) + .build(); + let msg4 = TestMessageBuilder::new("Regular message after", 4) + .sender("Alice") + .build(); + + let mut app = TestAppBuilder::new() + .with_chat(chat) + .with_messages(123, vec![msg1, msg2, msg3, msg4]) + .selected_chat(123) + .build(); + + let buffer = render_to_buffer(80, 24, |f| { + tele_tui::ui::messages::render(f, f.area(), &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("album_with_regular_messages", output); +} + +#[test] +fn snapshot_album_selected() { + let chat = create_test_chat("Mom", 123); + let msg1 = TestMessageBuilder::new("📷 [Фото]", 1) + .sender("Alice") + .media_album_id(777) + .build(); + let msg2 = TestMessageBuilder::new("📷 [Фото]", 2) + .sender("Alice") + .media_album_id(777) + .build(); + + let mut app = TestAppBuilder::new() + .with_chat(chat) + .with_messages(123, vec![msg1, msg2]) + .selected_chat(123) + .selecting_message(1) // Выбираем одно из сообщений альбома + .build(); + + let buffer = render_to_buffer(80, 24, |f| { + tele_tui::ui::messages::render(f, f.area(), &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("album_selected", output); +} diff --git a/tests/modals.rs b/tests/modals.rs index e4cf7e7..99421a3 100644 --- a/tests/modals.rs +++ b/tests/modals.rs @@ -3,19 +3,19 @@ mod helpers; use helpers::app_builder::TestAppBuilder; -use tele_tui::tdlib::TdClientTrait; use helpers::snapshot_utils::{buffer_to_string, render_to_buffer}; use helpers::test_data::{ create_test_chat, create_test_profile, TestChatBuilder, TestMessageBuilder, }; use insta::assert_snapshot; +use tele_tui::tdlib::TdClientTrait; #[test] fn snapshot_delete_confirmation_modal() { let chat = create_test_chat("Mom", 123); let message = TestMessageBuilder::new("Delete me", 1).outgoing().build(); - let app = TestAppBuilder::new() + let mut app = TestAppBuilder::new() .with_chat(chat) .with_message(123, message) .selected_chat(123) @@ -23,7 +23,7 @@ fn snapshot_delete_confirmation_modal() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -35,9 +35,18 @@ fn snapshot_emoji_picker_default() { let chat = create_test_chat("Mom", 123); let message = TestMessageBuilder::new("React to this", 1).build(); - let reactions = vec!["👍".to_string(), "👎".to_string(), "❤️".to_string(), "🔥".to_string(), "😊".to_string(), "😢".to_string(), "😮".to_string(), "🎉".to_string()]; + let reactions = vec![ + "👍".to_string(), + "👎".to_string(), + "❤️".to_string(), + "🔥".to_string(), + "😊".to_string(), + "😢".to_string(), + "😮".to_string(), + "🎉".to_string(), + ]; - let app = TestAppBuilder::new() + let mut app = TestAppBuilder::new() .with_chat(chat) .with_message(123, message) .selected_chat(123) @@ -45,7 +54,7 @@ fn snapshot_emoji_picker_default() { .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -57,7 +66,16 @@ fn snapshot_emoji_picker_with_selection() { let chat = create_test_chat("Mom", 123); let message = TestMessageBuilder::new("React to this", 1).build(); - let reactions = vec!["👍".to_string(), "👎".to_string(), "❤️".to_string(), "🔥".to_string(), "😊".to_string(), "😢".to_string(), "😮".to_string(), "🎉".to_string()]; + let reactions = vec![ + "👍".to_string(), + "👎".to_string(), + "❤️".to_string(), + "🔥".to_string(), + "😊".to_string(), + "😢".to_string(), + "😮".to_string(), + "🎉".to_string(), + ]; let mut app = TestAppBuilder::new() .with_chat(chat) @@ -72,7 +90,7 @@ fn snapshot_emoji_picker_with_selection() { } let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -84,14 +102,14 @@ fn snapshot_profile_personal_chat() { let chat = create_test_chat("Alice", 123); let profile = create_test_profile("Alice", 123); - let app = TestAppBuilder::new() + let mut app = TestAppBuilder::new() .with_chat(chat) .selected_chat(123) .profile_mode(profile) .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -108,14 +126,14 @@ fn snapshot_profile_group_chat() { profile.member_count = Some(25); profile.description = Some("Work discussion group".to_string()); - let app = TestAppBuilder::new() + let mut app = TestAppBuilder::new() .with_chat(chat) .selected_chat(456) .profile_mode(profile) .build(); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -138,7 +156,7 @@ fn snapshot_pinned_message() { app.td_client.set_current_pinned_message(Some(pinned_msg)); let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); @@ -160,13 +178,15 @@ fn snapshot_search_in_chat() { .build(); // Устанавливаем результаты поиска - if let tele_tui::app::ChatState::SearchInChat { results, selected_index, .. } = &mut app.chat_state { + if let tele_tui::app::ChatState::SearchInChat { results, selected_index, .. } = + &mut app.chat_state + { *results = vec![msg1, msg2]; *selected_index = 0; } let buffer = render_to_buffer(80, 24, |f| { - tele_tui::ui::messages::render(f, f.area(), &app); + tele_tui::ui::messages::render(f, f.area(), &mut app); }); let output = buffer_to_string(&buffer); diff --git a/tests/navigation.rs b/tests/navigation.rs index 58172e9..d58807d 100644 --- a/tests/navigation.rs +++ b/tests/navigation.rs @@ -74,7 +74,7 @@ async fn test_enter_opens_chat() { #[tokio::test] async fn test_esc_closes_chat() { // Состояние: открыт чат 123 - let selected_chat_id = Some(123); + let _selected_chat_id = Some(123); // Пользователь нажал Esc let selected_chat_id: Option = None; @@ -97,7 +97,7 @@ async fn test_scroll_messages_in_chat() { let client = client.with_messages(123, messages); - let msgs = client.get_messages(123); + let _msgs = client.get_messages(123); // Скролл начинается снизу (последнее сообщение видно) let mut scroll_offset: usize = 0; diff --git a/tests/network_typing.rs b/tests/network_typing.rs index 1bf0096..af3cb7c 100644 --- a/tests/network_typing.rs +++ b/tests/network_typing.rs @@ -97,7 +97,9 @@ async fn test_typing_indicator_on() { // Alice начала печатать в чате 123 // Симулируем через send_chat_action - client.send_chat_action(ChatId::new(123), "Typing".to_string()).await; + client + .send_chat_action(ChatId::new(123), "Typing".to_string()) + .await; assert_eq!(*client.typing_chat_id.lock().unwrap(), Some(123)); @@ -110,11 +112,15 @@ async fn test_typing_indicator_off() { let client = FakeTdClient::new(); // Изначально Alice печатала - client.send_chat_action(ChatId::new(123), "Typing".to_string()).await; + client + .send_chat_action(ChatId::new(123), "Typing".to_string()) + .await; assert_eq!(*client.typing_chat_id.lock().unwrap(), Some(123)); // Alice перестала печатать - client.send_chat_action(ChatId::new(123), "Cancel".to_string()).await; + client + .send_chat_action(ChatId::new(123), "Cancel".to_string()) + .await; assert_eq!(*client.typing_chat_id.lock().unwrap(), None); @@ -124,7 +130,7 @@ async fn test_typing_indicator_off() { /// Test: Отправка своего typing status #[tokio::test] async fn test_send_own_typing_status() { - let client = FakeTdClient::new(); + let _client = FakeTdClient::new(); // Пользователь начал печатать в чате 456 // В реальном App вызывается client.send_chat_action(chat_id, ChatAction::Typing) diff --git a/tests/reactions.rs b/tests/reactions.rs index 391967b..8d1e12c 100644 --- a/tests/reactions.rs +++ b/tests/reactions.rs @@ -12,10 +12,16 @@ async fn test_add_reaction_to_message() { let client = FakeTdClient::new(); // Отправляем сообщение - let msg = client.send_message(ChatId::new(123), "React to this!".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "React to this!".to_string(), None, None) + .await + .unwrap(); // Добавляем реакцию - client.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string()).await.unwrap(); + client + .toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string()) + .await + .unwrap(); // Проверяем что реакция записалась let messages = client.get_messages(123); @@ -46,7 +52,10 @@ async fn test_toggle_reaction_removes_it() { let msg_id = messages_before[0].id(); // Toggle - удаляем свою реакцию - client.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()).await.unwrap(); + client + .toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()) + .await + .unwrap(); let messages_after = client.get_messages(123); assert_eq!(messages_after[0].reactions().len(), 0); @@ -57,13 +66,28 @@ async fn test_toggle_reaction_removes_it() { async fn test_multiple_reactions_on_one_message() { let client = FakeTdClient::new(); - let msg = client.send_message(ChatId::new(123), "Many reactions".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "Many reactions".to_string(), None, None) + .await + .unwrap(); // Добавляем несколько разных реакций - client.toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string()).await.unwrap(); - client.toggle_reaction(ChatId::new(123), msg.id(), "❤️".to_string()).await.unwrap(); - client.toggle_reaction(ChatId::new(123), msg.id(), "😂".to_string()).await.unwrap(); - client.toggle_reaction(ChatId::new(123), msg.id(), "🔥".to_string()).await.unwrap(); + client + .toggle_reaction(ChatId::new(123), msg.id(), "👍".to_string()) + .await + .unwrap(); + client + .toggle_reaction(ChatId::new(123), msg.id(), "❤️".to_string()) + .await + .unwrap(); + client + .toggle_reaction(ChatId::new(123), msg.id(), "😂".to_string()) + .await + .unwrap(); + client + .toggle_reaction(ChatId::new(123), msg.id(), "🔥".to_string()) + .await + .unwrap(); // Проверяем что все 4 реакции записались let messages = client.get_messages(123); @@ -151,7 +175,10 @@ async fn test_reaction_counter_increases() { let msg_id = messages_before[0].id(); // Мы добавляем свою реакцию - счётчик должен увеличиться - client.toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()).await.unwrap(); + client + .toggle_reaction(ChatId::new(123), msg_id, "👍".to_string()) + .await + .unwrap(); let messages = client.get_messages(123); assert_eq!(messages[0].reactions()[0].count, 2); @@ -177,7 +204,10 @@ async fn test_update_reaction_we_add_ours() { let msg_id = messages_before[0].id(); // Добавляем нашу реакцию - client.toggle_reaction(ChatId::new(123), msg_id, "🔥".to_string()).await.unwrap(); + client + .toggle_reaction(ChatId::new(123), msg_id, "🔥".to_string()) + .await + .unwrap(); let messages = client.get_messages(123); let reaction = &messages[0].reactions()[0]; diff --git a/tests/reply_forward.rs b/tests/reply_forward.rs index ac439b3..c989e2f 100644 --- a/tests/reply_forward.rs +++ b/tests/reply_forward.rs @@ -4,8 +4,8 @@ mod helpers; use helpers::fake_tdclient::FakeTdClient; use helpers::test_data::TestMessageBuilder; -use tele_tui::tdlib::ReplyInfo; use tele_tui::tdlib::types::ForwardInfo; +use tele_tui::tdlib::ReplyInfo; use tele_tui::types::{ChatId, MessageId}; /// Test: Reply создаёт сообщение с reply_to @@ -28,7 +28,15 @@ async fn test_reply_creates_message_with_reply_to() { }; // Отвечаем на него - let reply_msg = client.send_message(ChatId::new(123), "Answer!".to_string(), Some(MessageId::new(100)), Some(reply_info)).await.unwrap(); + let reply_msg = client + .send_message( + ChatId::new(123), + "Answer!".to_string(), + Some(MessageId::new(100)), + Some(reply_info), + ) + .await + .unwrap(); // Проверяем что ответ отправлен с reply_to assert_eq!(client.get_sent_messages().len(), 1); @@ -79,7 +87,10 @@ async fn test_cancel_reply_sends_without_reply_to() { // Пользователь начал reply (r), потом отменил (Esc), затем отправил // Это эмулируется отправкой без reply_to - client.send_message(ChatId::new(123), "Regular message".to_string(), None, None).await.unwrap(); + client + .send_message(ChatId::new(123), "Regular message".to_string(), None, None) + .await + .unwrap(); // Проверяем что отправилось без reply_to assert_eq!(client.get_sent_messages()[0].reply_to, None); @@ -175,7 +186,15 @@ async fn test_reply_to_forwarded_message() { }; // Отвечаем на пересланное сообщение - let reply_msg = client.send_message(ChatId::new(123), "Thanks for sharing!".to_string(), Some(MessageId::new(100)), Some(reply_info)).await.unwrap(); + let reply_msg = client + .send_message( + ChatId::new(123), + "Thanks for sharing!".to_string(), + Some(MessageId::new(100)), + Some(reply_info), + ) + .await + .unwrap(); // Проверяем что reply содержит reply_to assert_eq!(client.get_sent_messages()[0].reply_to, Some(MessageId::new(100))); diff --git a/tests/send_message.rs b/tests/send_message.rs index 4703ac4..9f3c2ae 100644 --- a/tests/send_message.rs +++ b/tests/send_message.rs @@ -14,7 +14,10 @@ async fn test_send_text_message() { let client = client.with_chat(chat); // Отправляем сообщение - let msg = client.send_message(ChatId::new(123), "Hello, Mom!".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "Hello, Mom!".to_string(), None, None) + .await + .unwrap(); // Проверяем что сообщение было отправлено assert_eq!(client.get_sent_messages().len(), 1); @@ -36,13 +39,22 @@ async fn test_send_multiple_messages_updates_list() { let client = FakeTdClient::new(); // Отправляем первое сообщение - let msg1 = client.send_message(ChatId::new(123), "Message 1".to_string(), None, None).await.unwrap(); + let msg1 = client + .send_message(ChatId::new(123), "Message 1".to_string(), None, None) + .await + .unwrap(); // Отправляем второе сообщение - let msg2 = client.send_message(ChatId::new(123), "Message 2".to_string(), None, None).await.unwrap(); + let msg2 = client + .send_message(ChatId::new(123), "Message 2".to_string(), None, None) + .await + .unwrap(); // Отправляем третье сообщение - let msg3 = client.send_message(ChatId::new(123), "Message 3".to_string(), None, None).await.unwrap(); + let msg3 = client + .send_message(ChatId::new(123), "Message 3".to_string(), None, None) + .await + .unwrap(); // Проверяем что все 3 сообщения отслеживаются assert_eq!(client.get_sent_messages().len(), 3); @@ -66,7 +78,10 @@ async fn test_send_empty_message_technical() { let client = FakeTdClient::new(); // FakeTdClient технически может отправить пустое сообщение - let msg = client.send_message(ChatId::new(123), "".to_string(), None, None).await.unwrap(); + let msg = client + .send_message(ChatId::new(123), "".to_string(), None, None) + .await + .unwrap(); // Проверяем что оно отправилось (в реальном App это должно фильтроваться) assert_eq!(client.get_sent_messages().len(), 1); @@ -85,7 +100,10 @@ async fn test_send_message_with_markdown() { let client = FakeTdClient::new(); let text = "**Bold** *italic* `code`"; - client.send_message(ChatId::new(123), text.to_string(), None, None).await.unwrap(); + client + .send_message(ChatId::new(123), text.to_string(), None, None) + .await + .unwrap(); // Проверяем что текст сохранился как есть (парсинг markdown - отдельная логика) let messages = client.get_messages(123); @@ -99,13 +117,22 @@ async fn test_send_messages_to_different_chats() { let client = FakeTdClient::new(); // Отправляем в чат 123 - client.send_message(ChatId::new(123), "Hello Mom".to_string(), None, None).await.unwrap(); + client + .send_message(ChatId::new(123), "Hello Mom".to_string(), None, None) + .await + .unwrap(); // Отправляем в чат 456 - client.send_message(ChatId::new(456), "Hello Boss".to_string(), None, None).await.unwrap(); + client + .send_message(ChatId::new(456), "Hello Boss".to_string(), None, None) + .await + .unwrap(); // Отправляем ещё одно в чат 123 - client.send_message(ChatId::new(123), "How are you?".to_string(), None, None).await.unwrap(); + client + .send_message(ChatId::new(123), "How are you?".to_string(), None, None) + .await + .unwrap(); // Проверяем общее количество отправленных assert_eq!(client.get_sent_messages().len(), 3); @@ -128,7 +155,10 @@ async fn test_receive_incoming_message() { let client = FakeTdClient::new(); // Добавляем существующее сообщение - client.send_message(ChatId::new(123), "My outgoing".to_string(), None, None).await.unwrap(); + client + .send_message(ChatId::new(123), "My outgoing".to_string(), None, None) + .await + .unwrap(); // Симулируем входящее сообщение от собеседника let incoming_msg = TestMessageBuilder::new("Hey there!", 2000) diff --git a/tests/snapshots/input_field__empty_input.snap b/tests/snapshots/input_field__empty_input.snap index c988f85..6e3e581 100644 --- a/tests/snapshots/input_field__empty_input.snap +++ b/tests/snapshots/input_field__empty_input.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__album_incoming.snap b/tests/snapshots/messages__album_incoming.snap new file mode 100644 index 0000000..4e9c3b5 --- /dev/null +++ b/tests/snapshots/messages__album_incoming.snap @@ -0,0 +1,28 @@ +--- +source: tests/messages.rs +expression: output +--- +┌──────────────────────────────────────────────────────────────────────────────┐ +│👤 Mom │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ──────── 02.01.2022 ──────── │ +│ │ +│Alice ──────────────── │ +│ (14:33) 📷 [Фото] │ +│ (14:33) Caption for album │ +│ (14:33) 📷 [Фото] │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│> Press i to type... │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__album_outgoing.snap b/tests/snapshots/messages__album_outgoing.snap new file mode 100644 index 0000000..0aa2f09 --- /dev/null +++ b/tests/snapshots/messages__album_outgoing.snap @@ -0,0 +1,28 @@ +--- +source: tests/messages.rs +expression: output +--- +┌──────────────────────────────────────────────────────────────────────────────┐ +│👤 Mom │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ──────── 02.01.2022 ──────── │ +│ │ +│ Вы ──────────────── │ +│ 📷 [Фото] (14:33 ✓✓)│ +│ My vacation photos (14:33 ✓✓) │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│> Press i to type... │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__album_selected.snap b/tests/snapshots/messages__album_selected.snap new file mode 100644 index 0000000..c8dd19e --- /dev/null +++ b/tests/snapshots/messages__album_selected.snap @@ -0,0 +1,28 @@ +--- +source: tests/messages.rs +expression: output +--- +┌──────────────────────────────────────────────────────────────────────────────┐ +│👤 Mom │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ──────── 02.01.2022 ──────── │ +│ │ +│Alice ──────────────── │ +│ (14:33) 📷 [Фото] │ +│▶ (14:33) 📷 [Фото] │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌ Выбор сообщения ─────────────────────────────────────────────────────────────┐ +│↑↓ · r ответ · f переслать · y копир. · d удалить · Esc │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__album_with_regular_messages.snap b/tests/snapshots/messages__album_with_regular_messages.snap new file mode 100644 index 0000000..264475b --- /dev/null +++ b/tests/snapshots/messages__album_with_regular_messages.snap @@ -0,0 +1,28 @@ +--- +source: tests/messages.rs +expression: output +--- +┌──────────────────────────────────────────────────────────────────────────────┐ +│👤 Group Chat │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ──────── 02.01.2022 ──────── │ +│ │ +│Alice ──────────────── │ +│ (14:33) Regular message before │ +│ (14:33) 📷 [Фото] │ +│ (14:33) Album caption │ +│ (14:33) Regular message after │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│> Press i to type... │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__date_separator_old_date.snap b/tests/snapshots/messages__date_separator_old_date.snap index c208a55..7236593 100644 --- a/tests/snapshots/messages__date_separator_old_date.snap +++ b/tests/snapshots/messages__date_separator_old_date.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__edited_message.snap b/tests/snapshots/messages__edited_message.snap index ae43e84..c98497c 100644 --- a/tests/snapshots/messages__edited_message.snap +++ b/tests/snapshots/messages__edited_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__empty_chat.snap b/tests/snapshots/messages__empty_chat.snap index 1215be2..6390b52 100644 --- a/tests/snapshots/messages__empty_chat.snap +++ b/tests/snapshots/messages__empty_chat.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__forwarded_message.snap b/tests/snapshots/messages__forwarded_message.snap index 810dff7..918bc8f 100644 --- a/tests/snapshots/messages__forwarded_message.snap +++ b/tests/snapshots/messages__forwarded_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__long_message_wrap.snap b/tests/snapshots/messages__long_message_wrap.snap index b03e458..2beffe5 100644 --- a/tests/snapshots/messages__long_message_wrap.snap +++ b/tests/snapshots/messages__long_message_wrap.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__markdown_bold_italic_code.snap b/tests/snapshots/messages__markdown_bold_italic_code.snap index 67b927b..cfe7134 100644 --- a/tests/snapshots/messages__markdown_bold_italic_code.snap +++ b/tests/snapshots/messages__markdown_bold_italic_code.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__markdown_link_mention.snap b/tests/snapshots/messages__markdown_link_mention.snap index a6211be..aacbe63 100644 --- a/tests/snapshots/messages__markdown_link_mention.snap +++ b/tests/snapshots/messages__markdown_link_mention.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__markdown_spoiler.snap b/tests/snapshots/messages__markdown_spoiler.snap index 8b8bac4..9458598 100644 --- a/tests/snapshots/messages__markdown_spoiler.snap +++ b/tests/snapshots/messages__markdown_spoiler.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__media_placeholder.snap b/tests/snapshots/messages__media_placeholder.snap index aa6291a..210f9fd 100644 --- a/tests/snapshots/messages__media_placeholder.snap +++ b/tests/snapshots/messages__media_placeholder.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__multiple_reactions.snap b/tests/snapshots/messages__multiple_reactions.snap index c8a2cf5..a8a8808 100644 --- a/tests/snapshots/messages__multiple_reactions.snap +++ b/tests/snapshots/messages__multiple_reactions.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__outgoing_read.snap b/tests/snapshots/messages__outgoing_read.snap index 37da376..1b3077a 100644 --- a/tests/snapshots/messages__outgoing_read.snap +++ b/tests/snapshots/messages__outgoing_read.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__outgoing_sent.snap b/tests/snapshots/messages__outgoing_sent.snap index c8586c1..8b001c0 100644 --- a/tests/snapshots/messages__outgoing_sent.snap +++ b/tests/snapshots/messages__outgoing_sent.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__reply_message.snap b/tests/snapshots/messages__reply_message.snap index f4307c4..c0e65e8 100644 --- a/tests/snapshots/messages__reply_message.snap +++ b/tests/snapshots/messages__reply_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__sender_grouping.snap b/tests/snapshots/messages__sender_grouping.snap index 345c13d..c2d894e 100644 --- a/tests/snapshots/messages__sender_grouping.snap +++ b/tests/snapshots/messages__sender_grouping.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__single_incoming_message.snap b/tests/snapshots/messages__single_incoming_message.snap index 4eb04b1..9d23183 100644 --- a/tests/snapshots/messages__single_incoming_message.snap +++ b/tests/snapshots/messages__single_incoming_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__single_outgoing_message.snap b/tests/snapshots/messages__single_outgoing_message.snap index 1221f7b..2736447 100644 --- a/tests/snapshots/messages__single_outgoing_message.snap +++ b/tests/snapshots/messages__single_outgoing_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__single_reaction.snap b/tests/snapshots/messages__single_reaction.snap index b7f88e6..4c185b6 100644 --- a/tests/snapshots/messages__single_reaction.snap +++ b/tests/snapshots/messages__single_reaction.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/modals__delete_confirmation_modal.snap b/tests/snapshots/modals__delete_confirmation_modal.snap index c2ac787..17ec0e2 100644 --- a/tests/snapshots/modals__delete_confirmation_modal.snap +++ b/tests/snapshots/modals__delete_confirmation_modal.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/modals__emoji_picker_default.snap b/tests/snapshots/modals__emoji_picker_default.snap index 13a3e23..0a9e3de 100644 --- a/tests/snapshots/modals__emoji_picker_default.snap +++ b/tests/snapshots/modals__emoji_picker_default.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/modals__emoji_picker_with_selection.snap b/tests/snapshots/modals__emoji_picker_with_selection.snap index 13a3e23..0a9e3de 100644 --- a/tests/snapshots/modals__emoji_picker_with_selection.snap +++ b/tests/snapshots/modals__emoji_picker_with_selection.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/modals__pinned_message.snap b/tests/snapshots/modals__pinned_message.snap index ee14a2c..6c5b1aa 100644 --- a/tests/snapshots/modals__pinned_message.snap +++ b/tests/snapshots/modals__pinned_message.snap @@ -24,5 +24,5 @@ expression: output │ │ └──────────────────────────────────────────────────────────────────────────────┘ ┌──────────────────────────────────────────────────────────────────────────────┐ -│> █ Введите сообщение... │ +│> Press i to type... │ └──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/vim_mode.rs b/tests/vim_mode.rs new file mode 100644 index 0000000..3c45233 --- /dev/null +++ b/tests/vim_mode.rs @@ -0,0 +1,609 @@ +//! Tests for Vim Normal/Insert mode feature +//! +//! Covers: +//! - Mode transitions (i→Insert, Esc→Normal, auto-Insert on Reply/Edit) +//! - Command blocking in Insert mode (vim keys type text) +//! - Insert mode input handling (NewLine, DeleteWord, MoveToStart, MoveToEnd) +//! - Close chat resets mode +//! - Edge cases (Esc cancels Reply/Editing from Insert) + +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::compose::ComposeMethods; +use tele_tui::app::methods::messages::MessageMethods; +use tele_tui::app::InputMode; +use tele_tui::input::handle_main_input; + +fn key(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::empty()) +} + +fn ctrl_key(c: char) -> KeyEvent { + KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL) +} + +// ============================================================ +// Mode Transitions +// ============================================================ + +/// `i` в Normal mode → переход в Insert mode +#[tokio::test] +async fn test_i_enters_insert_mode() { + let messages = vec![TestMessageBuilder::new("Hello", 1).outgoing().build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + assert_eq!(app.input_mode, InputMode::Normal); + + handle_main_input(&mut app, key(KeyCode::Char('i'))).await; + + assert_eq!(app.input_mode, InputMode::Insert); + // Выходим из MessageSelection + assert!(!app.is_selecting_message()); +} + +/// `ш` (русская i) в Normal mode → переход в Insert mode +#[tokio::test] +async fn test_russian_i_enters_insert_mode() { + let messages = vec![TestMessageBuilder::new("Hello", 1).outgoing().build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('ш'))).await; + + assert_eq!(app.input_mode, InputMode::Insert); +} + +/// Esc в Insert mode → Normal mode + MessageSelection +#[tokio::test] +async fn test_esc_exits_insert_mode() { + let messages = vec![TestMessageBuilder::new("Hello", 1).outgoing().build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .insert_mode() + .build(); + + assert_eq!(app.input_mode, InputMode::Insert); + + handle_main_input(&mut app, key(KeyCode::Esc)).await; + + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.is_selecting_message()); +} + +/// Esc в Normal mode → закрывает чат +#[tokio::test] +async fn test_esc_in_normal_closes_chat() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .build(); + + assert!(app.selected_chat_id.is_some()); + + handle_main_input(&mut app, key(KeyCode::Esc)).await; + + assert!(app.selected_chat_id.is_none()); + assert_eq!(app.input_mode, InputMode::Normal); +} + +/// close_chat() сбрасывает input_mode +#[tokio::test] +async fn test_close_chat_resets_input_mode() { + use tele_tui::app::methods::navigation::NavigationMethods; + + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + assert_eq!(app.input_mode, InputMode::Insert); + + app.close_chat(); + + assert_eq!(app.input_mode, InputMode::Normal); +} + +/// Auto-Insert при Reply (`r` в MessageSelection) +#[tokio::test] +async fn test_reply_auto_enters_insert_mode() { + let messages = vec![TestMessageBuilder::new("Hello from friend", 1) + .sender("Friend") + .build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + assert_eq!(app.input_mode, InputMode::Normal); + + // `r` → reply + handle_main_input(&mut app, key(KeyCode::Char('r'))).await; + + assert_eq!(app.input_mode, InputMode::Insert); + assert!(app.is_replying()); +} + +/// Auto-Insert при Edit (Enter в MessageSelection) +#[tokio::test] +async fn test_edit_auto_enters_insert_mode() { + let messages = vec![TestMessageBuilder::new("My message", 1).outgoing().build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + assert_eq!(app.input_mode, InputMode::Normal); + + // Enter → edit selected message + handle_main_input(&mut app, key(KeyCode::Enter)).await; + + assert_eq!(app.input_mode, InputMode::Insert); + assert!(app.is_editing()); +} + +/// При открытии чата → Normal mode (selected_chat задан builder'ом, как после open) +#[test] +fn test_open_chat_defaults_to_normal_mode() { + // Проверяем что при настройке чата (аналог состояния после open_chat_and_load_data) + // режим = Normal, и start_message_selection() корректно входит в MessageSelection + let messages = vec![ + TestMessageBuilder::new("Msg 1", 1).build(), + TestMessageBuilder::new("Msg 2", 2).build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .build(); + + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.selected_chat_id.is_some()); + + // open_chat_and_load_data вызывает start_message_selection() + app.start_message_selection(); + assert!(app.is_selecting_message()); +} + +/// После отправки сообщения — остаёмся в Insert +#[tokio::test] +async fn test_send_message_stays_in_insert() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .message_input("Hello!") + .build(); + app.cursor_position = 6; + + assert_eq!(app.input_mode, InputMode::Insert); + + // Enter → отправить + handle_main_input(&mut app, key(KeyCode::Enter)).await; + + // Остаёмся в Insert + assert_eq!(app.input_mode, InputMode::Insert); + // Инпут очищен + assert_eq!(app.message_input, ""); +} + +// ============================================================ +// Command Blocking in Insert Mode +// ============================================================ + +/// `j` в Insert mode → набирает символ, НЕ навигация +#[tokio::test] +async fn test_j_types_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('j'))).await; + + assert_eq!(app.message_input, "j"); +} + +/// `k` в Insert mode → набирает символ +#[tokio::test] +async fn test_k_types_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('k'))).await; + + assert_eq!(app.message_input, "k"); +} + +/// `d` в Insert mode → набирает "d", НЕ удаляет сообщение +#[tokio::test] +async fn test_d_types_in_insert_mode() { + let messages = vec![TestMessageBuilder::new("Hello", 1).outgoing().build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('d'))).await; + + assert_eq!(app.message_input, "d"); + // НЕ вошли в delete confirmation + assert!(!app.chat_state.is_delete_confirmation()); +} + +/// `r` в Insert mode → набирает "r", НЕ reply +#[tokio::test] +async fn test_r_types_in_insert_mode() { + let messages = vec![TestMessageBuilder::new("Hello", 1).build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('r'))).await; + + assert_eq!(app.message_input, "r"); + assert!(!app.is_replying()); +} + +/// `f` в Insert mode → набирает "f", НЕ forward +#[tokio::test] +async fn test_f_types_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('f'))).await; + + assert_eq!(app.message_input, "f"); + assert!(!app.is_forwarding()); +} + +/// `q` в Insert mode → набирает "q", НЕ quit +#[tokio::test] +async fn test_q_types_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('q'))).await; + + assert_eq!(app.message_input, "q"); +} + +/// Ctrl+S в Insert mode → НЕ открывает поиск +#[tokio::test] +async fn test_ctrl_s_blocked_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, ctrl_key('s')).await; + + assert!(!app.is_searching); +} + +/// Ctrl+F в Insert mode → НЕ открывает поиск по сообщениям +#[tokio::test] +async fn test_ctrl_f_blocked_in_insert_mode() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .build(); + + handle_main_input(&mut app, ctrl_key('f')).await; + + assert!(!app.chat_state.is_search_in_chat()); +} + +// ============================================================ +// Normal Mode — commands work +// ============================================================ + +/// `j` в Normal mode → навигация вниз (MoveDown) в MessageSelection +#[tokio::test] +async fn test_j_navigates_in_normal_mode() { + let messages = vec![ + TestMessageBuilder::new("Msg 1", 1).build(), + TestMessageBuilder::new("Msg 2", 2).build(), + TestMessageBuilder::new("Msg 3", 3).build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(1) + .build(); + + assert_eq!(app.input_mode, InputMode::Normal); + assert_eq!(app.chat_state.selected_message_index(), Some(1)); + + handle_main_input(&mut app, key(KeyCode::Char('j'))).await; + + // j = MoveDown = select_next_message + assert_eq!(app.chat_state.selected_message_index(), Some(2)); + // Текст НЕ добавился + assert_eq!(app.message_input, ""); +} + +/// `k` в Normal mode → навигация вверх в MessageSelection +#[tokio::test] +async fn test_k_navigates_in_normal_mode() { + let messages = vec![ + TestMessageBuilder::new("Msg 1", 1).build(), + TestMessageBuilder::new("Msg 2", 2).build(), + TestMessageBuilder::new("Msg 3", 3).build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(2) + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('k'))).await; + + assert_eq!(app.chat_state.selected_message_index(), Some(1)); + assert_eq!(app.message_input, ""); +} + +/// `d` в Normal mode → показывает подтверждение удаления +#[tokio::test] +async fn test_d_deletes_in_normal_mode() { + let messages = vec![TestMessageBuilder::new("My message", 1).outgoing().build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + handle_main_input(&mut app, key(KeyCode::Char('d'))).await; + + assert!(app.chat_state.is_delete_confirmation()); +} + +// ============================================================ +// Insert Mode Input Handling +// ============================================================ + +/// Ctrl+W → удаляет слово в Insert mode +#[tokio::test] +async fn test_ctrl_w_deletes_word_in_insert() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .message_input("Hello World") + .build(); + app.cursor_position = 11; // конец "Hello World" + + handle_main_input(&mut app, ctrl_key('w')).await; + + assert_eq!(app.message_input, "Hello "); + assert_eq!(app.cursor_position, 6); +} + +/// Ctrl+W → удаляет слово + пробелы перед ним +#[tokio::test] +async fn test_ctrl_w_deletes_word_with_spaces() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .message_input("one two three") + .build(); + app.cursor_position = 14; // конец + + handle_main_input(&mut app, ctrl_key('w')).await; + + // "one two " → удалили "three", осталось "one two " + assert_eq!(app.message_input, "one two "); + assert_eq!(app.cursor_position, 9); +} + +/// Ctrl+A → курсор в начало в Insert mode +#[tokio::test] +async fn test_ctrl_a_moves_to_start_in_insert() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .message_input("Hello World") + .build(); + app.cursor_position = 11; + + handle_main_input(&mut app, ctrl_key('a')).await; + + assert_eq!(app.cursor_position, 0); +} + +/// Ctrl+E → курсор в конец в Insert mode +#[tokio::test] +async fn test_ctrl_e_moves_to_end_in_insert() { + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .insert_mode() + .message_input("Hello World") + .build(); + app.cursor_position = 0; + + handle_main_input(&mut app, ctrl_key('e')).await; + + assert_eq!(app.cursor_position, 11); +} + +// ============================================================ +// Edge Cases — Esc from Insert cancels Reply/Editing +// ============================================================ + +/// Esc из Insert при активном Reply → отменяет reply + Normal + MessageSelection +#[tokio::test] +async fn test_esc_from_insert_cancels_reply() { + let messages = vec![TestMessageBuilder::new("Hello", 1).sender("Friend").build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .insert_mode() + .replying_to(1) + .build(); + + assert!(app.is_replying()); + assert_eq!(app.input_mode, InputMode::Insert); + + handle_main_input(&mut app, key(KeyCode::Esc)).await; + + assert!(!app.is_replying()); + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.is_selecting_message()); +} + +/// Esc из Insert при активном Editing → отменяет editing + Normal + MessageSelection +#[tokio::test] +async fn test_esc_from_insert_cancels_editing() { + let messages = vec![TestMessageBuilder::new("My message", 1).outgoing().build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .insert_mode() + .editing_message(1, 0) + .message_input("Edited text") + .build(); + + assert!(app.is_editing()); + assert_eq!(app.input_mode, InputMode::Insert); + + handle_main_input(&mut app, key(KeyCode::Esc)).await; + + assert!(!app.is_editing()); + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.is_selecting_message()); + // Инпут очищен (cancel_editing) + assert_eq!(app.message_input, ""); +} + +/// Normal mode auto-enters MessageSelection при первом нажатии +/// Используем `k` (MoveUp), т.к. `j` (MoveDown) на последнем сообщении выходит из selection +#[tokio::test] +async fn test_normal_mode_auto_enters_selection_on_any_key() { + let messages = vec![ + TestMessageBuilder::new("Msg 1", 1).build(), + TestMessageBuilder::new("Msg 2", 2).build(), + ]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .build(); + + // ChatState::Normal, InputMode::Normal — не в MessageSelection + assert!(!app.is_selecting_message()); + + // `k` (MoveUp) в Normal mode → auto-enter MessageSelection + move up + handle_main_input(&mut app, key(KeyCode::Char('k'))).await; + + assert!(app.is_selecting_message()); + // Начали с последнего (index 1), MoveUp → index 0 + assert_eq!(app.chat_state.selected_message_index(), Some(0)); +} + +/// Полный цикл: Normal → i → набор текста → Esc → Normal +#[tokio::test] +async fn test_full_mode_cycle() { + let messages = vec![TestMessageBuilder::new("Msg", 1).build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + // 1. Normal mode + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.is_selecting_message()); + + // 2. i → Insert + handle_main_input(&mut app, key(KeyCode::Char('i'))).await; + assert_eq!(app.input_mode, InputMode::Insert); + assert!(!app.is_selecting_message()); + + // 3. Набираем текст + handle_main_input(&mut app, key(KeyCode::Char('H'))).await; + handle_main_input(&mut app, key(KeyCode::Char('i'))).await; + assert_eq!(app.message_input, "Hi"); + + // 4. Esc → Normal + MessageSelection + handle_main_input(&mut app, key(KeyCode::Esc)).await; + assert_eq!(app.input_mode, InputMode::Normal); + assert!(app.is_selecting_message()); + // Текст сохранён (черновик) + assert_eq!(app.message_input, "Hi"); +} + +/// Полный цикл: Normal → r (reply) → набор → Enter (отправка) → остаёмся в Insert +#[tokio::test] +async fn test_reply_send_stays_insert() { + let messages = vec![TestMessageBuilder::new("Question?", 1) + .sender("Friend") + .build()]; + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .selecting_message(0) + .build(); + + // 1. r → auto-Insert + Reply + handle_main_input(&mut app, key(KeyCode::Char('r'))).await; + assert_eq!(app.input_mode, InputMode::Insert); + assert!(app.is_replying()); + + // 2. Набираем ответ + for c in "Yes!".chars() { + handle_main_input(&mut app, key(KeyCode::Char(c))).await; + } + assert_eq!(app.message_input, "Yes!"); + + // 3. Enter → отправить + handle_main_input(&mut app, key(KeyCode::Enter)).await; + + // Остаёмся в Insert после отправки + assert_eq!(app.input_mode, InputMode::Insert); + assert_eq!(app.message_input, ""); +}