diff --git a/CONTEXT.md b/CONTEXT.md index 0a5292b..bfacd58 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,6 +4,155 @@ ### Последние изменения (2026-02-04) +**🔔 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) + +**🎤 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) + +**✅ ЗАВЕРШЕНО: Интеграция 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) + **🐛 FIX: Зависание при открытии чатов с большой историей** - **Проблема**: При использовании `i32::MAX` как лимита загрузки истории, приложение зависало в чатах с тысячами сообщений (например, на итерации #96 было загружено 4750+ сообщений и загрузка продолжалась) - **Решение**: Заменён лимит с `i32::MAX` на разумные 300 сообщений при открытии чата @@ -25,7 +174,7 @@ - Сериализация/десериализация для загрузки из конфига - Метод `get_command()` для определения команды по KeyEvent - **Тесты**: 4 unit теста (все проходят) -- **Статус**: Готово к интеграции (требуется замена HotkeysConfig) +- **Статус**: ✅ Интегрировано в Config и main_input.rs **🎯 NEW: KeyHandler trait для обработки клавиш** - **Модуль**: `src/input/key_handler.rs` (380+ строк) @@ -81,7 +230,7 @@ - Builder pattern для удобного конструирования - Эффективность (работает с references, без клонирования) - **Тесты**: 6 unit тестов (все проходят) -- **Статус**: Готово к интеграции (TODO: заменить дублирующуюся логику в App/UI) +- **Статус**: ✅ Интегрировано в App и ChatListState ### Что сделано @@ -194,7 +343,7 @@ - `1-9` — переключение папок (в списке чатов) - `Ctrl+F` — поиск по сообщениям в открытом чате - `n` / `N` — навигация по результатам поиска (следующий/предыдущий) -- `i` — открыть профиль пользователя/чата +- `Ctrl+i` / `Ctrl+ш` — открыть профиль пользователя/чата - `y` / `н` в режиме выбора — скопировать сообщение в буфер обмена - `e` / `у` в режиме выбора — добавить реакцию (открывает emoji picker) - `←` / `→` / `↑` / `↓` в emoji picker — навигация по сетке реакций diff --git a/Cargo.lock b/Cargo.lock index 81aeaf3..7ed3479 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,126 @@ dependencies = [ "x11rb", ] +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.3", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.3", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-signal" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.3", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -128,6 +248,28 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "bumpalo" version = "3.19.1" @@ -227,7 +369,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -315,6 +457,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "console" version = "0.15.11" @@ -687,6 +838,33 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -709,6 +887,27 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -818,6 +1017,19 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-sink" version = "0.3.31" @@ -863,7 +1075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.3", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1102,7 +1314,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core", + "windows-core 0.62.2", ] [[package]] @@ -1477,6 +1689,18 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "mac-notification-sys" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2", + "objc2-foundation", + "time", +] + [[package]] name = "matchers" version = "0.2.0" @@ -1492,6 +1716,15 @@ version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "mime" version = "0.3.17" @@ -1547,6 +1780,20 @@ dependencies = [ "tempfile", ] +[[package]] +name = "notify-rust" +version = "4.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1629,6 +1876,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags", + "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -1717,6 +1966,22 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -1737,7 +2002,7 @@ dependencies = [ "libc", "redox_syscall", "smallvec", - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -1780,6 +2045,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.32" @@ -1827,6 +2103,20 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.3", + "windows-sys 0.61.2", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -1842,6 +2132,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit 0.23.10+spec-1.0.0", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -1866,6 +2165,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quick-xml" +version = "0.37.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.44" @@ -2243,6 +2551,17 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serde_spanned" version = "0.6.9" @@ -2484,6 +2803,18 @@ dependencies = [ "libc", ] +[[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml", + "thiserror 2.0.18", + "windows", + "windows-version", +] + [[package]] name = "tdlib-rs" version = "1.2.0" @@ -2530,6 +2861,7 @@ dependencies = [ "dirs 5.0.1", "dotenvy", "insta", + "notify-rust", "open", "ratatui", "serde", @@ -2761,8 +3093,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", "serde_spanned", - "toml_datetime", - "toml_edit", + "toml_datetime 0.6.11", + "toml_edit 0.22.27", ] [[package]] @@ -2774,6 +3106,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -2783,11 +3124,32 @@ dependencies = [ "indexmap 2.13.0", "serde", "serde_spanned", - "toml_datetime", + "toml_datetime 0.6.11", "toml_write", "winnow", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.13.0", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +dependencies = [ + "winnow", +] + [[package]] name = "toml_write" version = "0.1.2" @@ -2912,6 +3274,17 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "uds_windows" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9" +dependencies = [ + "memoffset", + "tempfile", + "winapi", +] + [[package]] name = "unicode-ident" version = "1.0.22" @@ -2971,6 +3344,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" +[[package]] +name = "uuid" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +dependencies = [ + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -3129,6 +3513,41 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[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-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -3137,9 +3556,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", ] [[package]] @@ -3164,21 +3594,46 @@ dependencies = [ "syn", ] +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + [[package]] name = "windows-registry" version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link", - "windows-result", - "windows-strings", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3187,7 +3642,16 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link", + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", ] [[package]] @@ -3196,7 +3660,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3241,7 +3705,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link", + "windows-link 0.2.1", ] [[package]] @@ -3281,7 +3745,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link", + "windows-link 0.2.1", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -3292,6 +3756,24 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -3500,6 +3982,67 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zbus" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.3", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + [[package]] name = "zerocopy" version = "0.8.34" @@ -3684,3 +4227,43 @@ checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" dependencies = [ "zune-core", ] + +[[package]] +name = "zvariant" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index b196ea2..7408e4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,9 +10,10 @@ keywords = ["telegram", "tui", "terminal", "cli"] categories = ["command-line-utilities"] [features] -default = ["clipboard", "url-open"] +default = ["clipboard", "url-open", "notifications"] clipboard = ["dep:arboard"] url-open = ["dep:open"] +notifications = ["dep:notify-rust"] [dependencies] ratatui = "0.29" @@ -26,6 +27,7 @@ dotenvy = "0.15" chrono = "0.4" open = { version = "5.0", optional = true } arboard = { version = "3.4", optional = true } +notify-rust = { version = "4.11", optional = true } toml = "0.8" dirs = "5.0" thiserror = "1.0" diff --git a/HOTKEYS.md b/HOTKEYS.md index df67c01..1f640d3 100644 --- a/HOTKEYS.md +++ b/HOTKEYS.md @@ -41,7 +41,43 @@ | `d` / `Delete` | `в` | Удалить сообщение | | `y` | `н` | Копировать текст в буфер обмена | | `e` | `у` | Добавить реакцию (Emoji picker) | -| `i` | | Открыть профиль чата/пользователя | +| `v` | `м` | Открыть изображение в полном размере | +| `Ctrl+i` | `Ctrl+ш` | Открыть профиль чата/пользователя | + +## Просмотр изображений + +### Режим просмотра изображения + +| Клавиша | Действие | +|---------|----------| +| `v` / `м` | Открыть изображение (в режиме выбора) | +| `←` | Предыдущее изображение в чате | +| `→` | Следующее изображение в чате | +| `Esc` | Закрыть просмотр изображения | + +**Примечание**: Изображения отображаются inline в чате автоматически. Используйте `v` для просмотра в полном размере. + +## Прослушивание голосовых сообщений + +### Управление воспроизведением + +| Клавиша | Русская раскладка | Действие | +|---------|-------------------|----------| +| `Space` | | Воспроизвести/Пауза (в режиме выбора голосового) | +| `s` | `ы` | Остановить воспроизведение | + +### Во время воспроизведения + +| Клавиша | Действие | +|---------|----------| +| `Space` | Пауза / Возобновить | +| `s` / `ы` | Остановить | +| `←` | Перемотка назад (по умолчанию -5 сек) | +| `→` | Перемотка вперед (по умолчанию +5 сек) | +| `↑` | Увеличить громкость (+10%) | +| `↓` | Уменьшить громкость (-10%) | + +**Примечание**: Голосовые сообщения показывают progress bar во время воспроизведения: `▶ ████████░░░░░░ 0:08 / 0:15` ## Модалки подтверждения @@ -103,6 +139,8 @@ - Удалить: `d` / `в` / `Delete` - Копировать: `y` / `н` - Реакция: `e` / `у` +- Просмотр изображения: `v` / `м` (если выбрано сообщение с фото) +- Воспроизведение голосового: `Space` (если выбрано голосовое сообщение) - Отменить: `Esc` ### Режим редактирования @@ -120,6 +158,16 @@ - Переслать: `Enter` - Отменить: `Esc` +### Режим просмотра изображения +- Навигация: `←/→` (предыдущее/следующее изображение) +- Закрыть: `Esc` + +### Режим воспроизведения голосового +- Пауза/Возобновить: `Space` +- Остановить: `s` / `ы` +- Перемотка: `←/→` (-5с / +5с) +- Громкость: `↑/↓` (+/- 10%) + ## Поддержка русской раскладки Все основные vim-клавиши поддерживают русскую раскладку: @@ -135,6 +183,8 @@ | `d` | `в` | Delete | | `y` | `н` | Copy (Yank) | | `e` | `у` | Emoji reaction | +| `v` | `м` | View image | +| `s` | `ы` | Stop audio | ## Подсказки diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md index c469bbf..8aaf2a3 100644 --- a/PROJECT_STRUCTURE.md +++ b/PROJECT_STRUCTURE.md @@ -23,6 +23,17 @@ tele-tui/ │ │ ├── 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 @@ -102,6 +113,70 @@ tele-tui/ #### state.rs - `AppScreen` enum — текущий экран (Loading, Auth, Main) +### 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 @@ -269,6 +344,7 @@ App { is_delete_confirmation: bool, is_reaction_picker_mode: bool, profile_info: Option, + view_image_mode: Option, // PLANNED - Фаза 11 // Search search_query: String, @@ -276,6 +352,15 @@ App { // 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 } ``` @@ -302,6 +387,17 @@ App { ### 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 diff --git a/ROADMAP.md b/ROADMAP.md index c1e6d0d..5809a50 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,7 +129,7 @@ - Индикатор черновика в списке чатов - Восстановление текста при возврате в чат - [x] Профиль пользователя/чата - - `i` — открыть информацию о чате/собеседнике + - `Ctrl+i` — открыть информацию о чате/собеседнике - Для личных чатов: имя, username, телефон, био - Для групп: название, описание, количество участников - [x] Копирование сообщений @@ -143,3 +143,535 @@ - `~/.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) + - Предупреждение для больших файлов + +### Технические детали +- **Поддерживаемые протоколы:** + - Sixel (xterm, WezTerm, mintty) + - Kitty Graphics Protocol (Kitty terminal) + - iTerm2 Inline Images (iTerm2 на macOS) + - Unicode Halfblocks (fallback для всех) +- **Поддерживаемые форматы:** + - JPEG, PNG, GIF, WebP, BMP +- **Новые хоткеи:** + - `v` / `м` - открыть изображение в полном размере (режим выбора) + - `←` / `→` - навигация между изображениями (в режиме просмотра) + - `Esc` - закрыть полноэкранный просмотр + +## Фаза 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 + +### Этап 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) +- [ ] Кэширование + - Сохранение путей к загруженным файлам + - Не перезагружать уже скачанные голосовые + - Проверка существования файла перед воспроизведением + +### Этап 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 + - Использование символов ▁▂▃▄▅▆▇█ для визуализации + +### Этап 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 { ... } +``` + +**Результат:** 116 функций → 6 trait impl блоков + +### Этап 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 файлах +``` + +**После рефакторинга:** +``` +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 diff --git a/config.example.toml b/config.example.toml new file mode 100644 index 0000000..806a875 --- /dev/null +++ b/config.example.toml @@ -0,0 +1,35 @@ +# Telegram TUI Configuration Example +# Copy this file to ~/.config/tele-tui/config.toml + +[general] +# Timezone offset (e.g., "+03:00", "-05:00") +timezone = "+03:00" + +[colors] +# Colors: red, green, blue, yellow, cyan, magenta, white, black, gray +# Also available: lightred, lightgreen, lightblue, lightyellow, lightcyan, lightmagenta +incoming_message = "white" +outgoing_message = "green" +selected_message = "yellow" +reaction_chosen = "yellow" +reaction_other = "gray" + +[notifications] +# Enable desktop notifications for new messages +enabled = true + +# Only notify when you are mentioned (@username) +only_mentions = false + +# Show message preview text in notifications +show_preview = true + +# Notification timeout in milliseconds (0 = system default) +timeout_ms = 5000 + +# Notification urgency level: "low", "normal", "critical" +# Note: Only works on Linux (libnotify), ignored on macOS/Windows +urgency = "normal" + +# Note: Notifications respect Telegram's mute settings +# Muted chats won't trigger notifications diff --git a/src/app/chat_filter.rs b/src/app/chat_filter.rs index f3ba670..094cad4 100644 --- a/src/app/chat_filter.rs +++ b/src/app/chat_filter.rs @@ -227,54 +227,6 @@ impl ChatFilter { } } -/// Сортировка чатов -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChatSortOrder { - /// По времени последнего сообщения (новые сверху) - ByLastMessage, - - /// По названию (алфавит) - ByTitle, - - /// По количеству непрочитанных (больше сверху) - ByUnreadCount, - - /// Закреплённые сверху, остальные по последнему сообщению - PinnedFirst, -} - -impl ChatSortOrder { - /// Сортирует чаты согласно порядку - /// - /// # Note - /// - /// Модифицирует переданный slice in-place - pub fn sort(&self, chats: &mut [&ChatInfo]) { - match self { - ChatSortOrder::ByLastMessage => { - chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date)); - } - ChatSortOrder::ByTitle => { - chats.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase())); - } - ChatSortOrder::ByUnreadCount => { - chats.sort_by(|a, b| b.unread_count.cmp(&a.unread_count)); - } - ChatSortOrder::PinnedFirst => { - chats.sort_by(|a, b| { - // Сначала по pinned статусу - match (a.is_pinned, b.is_pinned) { - (true, false) => std::cmp::Ordering::Less, - (false, true) => std::cmp::Ordering::Greater, - // Если оба pinned или оба не pinned - по времени - _ => b.last_message_date.cmp(&a.last_message_date), - } - }); - } - } - } -} - #[cfg(test)] mod tests { use super::*; @@ -379,32 +331,4 @@ mod tests { assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2 } - #[test] - fn test_sort_by_title() { - let chat1 = create_test_chat(1, "Charlie", None, vec![0], 0, 0, false, false); - let chat2 = create_test_chat(2, "Alice", None, vec![0], 0, 0, false, false); - let chat3 = create_test_chat(3, "Bob", None, vec![0], 0, 0, false, false); - - let mut chats = vec![&chat1, &chat2, &chat3]; - ChatSortOrder::ByTitle.sort(&mut chats); - - assert_eq!(chats[0].title, "Alice"); - assert_eq!(chats[1].title, "Bob"); - assert_eq!(chats[2].title, "Charlie"); - } - - #[test] - fn test_sort_pinned_first() { - let chat1 = create_test_chat(1, "Chat 1", None, vec![0], 0, 0, false, false); - let chat2 = create_test_chat(2, "Chat 2", None, vec![0], 0, 0, true, false); - let chat3 = create_test_chat(3, "Chat 3", None, vec![0], 0, 0, true, false); - - let mut chats = vec![&chat1, &chat2, &chat3]; - ChatSortOrder::PinnedFirst.sort(&mut chats); - - // Pinned chats first - assert!(chats[0].is_pinned); - assert!(chats[1].is_pinned); - assert!(!chats[2].is_pinned); - } } diff --git a/src/app/chat_state.rs b/src/app/chat_state.rs index 55b1bbf..f6cb3c8 100644 --- a/src/app/chat_state.rs +++ b/src/app/chat_state.rs @@ -33,8 +33,6 @@ pub enum ChatState { Forward { /// ID сообщения для пересылки message_id: MessageId, - /// Находимся в режиме выбора чата для пересылки - selecting_chat: bool, }, /// Подтверждение удаления сообщения diff --git a/src/app/message_view_state.rs b/src/app/message_view_state.rs index 99cbf4c..fafdd26 100644 --- a/src/app/message_view_state.rs +++ b/src/app/message_view_state.rs @@ -185,7 +185,6 @@ impl MessageViewState { pub fn start_forward(&mut self, message_id: MessageId) { self.chat_state = ChatState::Forward { message_id, - selecting_chat: true, }; } diff --git a/src/app/mod.rs b/src/app/mod.rs index 130a6b7..83417d2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1,6 +1,8 @@ +mod chat_filter; mod chat_state; mod state; +pub use chat_filter::{ChatFilter, ChatFilterCriteria}; pub use chat_state::ChatState; pub use state::AppScreen; @@ -119,6 +121,19 @@ impl App { } } + /// Получить команду из KeyEvent используя настроенные keybindings. + /// + /// # Arguments + /// + /// * `key` - KeyEvent от пользователя + /// + /// # Returns + /// + /// `Some(Command)` если найдена команда для этой клавиши, `None` если нет + pub fn get_command(&self, key: crossterm::event::KeyEvent) -> Option { + self.config.keybindings.get_command(&key) + } + pub fn next_chat(&mut self) { let filtered = self.get_filtered_chats(); if filtered.is_empty() { @@ -297,31 +312,15 @@ impl App { } pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> { - let folder_filtered: Vec<&ChatInfo> = match self.selected_folder_id { - None => self.chats.iter().collect(), // All - показываем все - Some(folder_id) => self - .chats - .iter() - .filter(|c| c.folder_ids.contains(&folder_id)) - .collect(), - }; + // Используем ChatFilter для централизованной фильтрации + let mut criteria = ChatFilterCriteria::new() + .with_folder(self.selected_folder_id); - if self.search_query.is_empty() { - folder_filtered - } else { - let query = self.search_query.to_lowercase(); - folder_filtered - .into_iter() - .filter(|c| { - // Поиск по названию чата - c.title.to_lowercase().contains(&query) || - // Поиск по username (@...) - c.username.as_ref() - .map(|u| u.to_lowercase().contains(&query)) - .unwrap_or(false) - }) - .collect() + 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) { @@ -412,7 +411,6 @@ impl App { if let Some(msg) = self.get_selected_message() { self.chat_state = ChatState::Forward { message_id: msg.id(), - selecting_chat: true, }; // Сбрасываем выбор чата на первый self.chat_list_state.select(Some(0)); @@ -1009,6 +1007,9 @@ impl App { /// /// A new `App` instance ready to start authentication. pub fn new(config: crate::config::Config) -> App { - App::with_client(config, TdClient::new()) + let mut client = TdClient::new(); + // Configure notifications from config + client.configure_notifications(&config.notifications); + App::with_client(config, client) } } diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index cb71b5a..e52fe87 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -92,13 +92,6 @@ impl KeyBinding { } } - pub fn from_event(event: KeyEvent) -> Self { - Self { - key: event.code, - modifiers: event.modifiers, - } - } - pub fn matches(&self, event: &KeyEvent) -> bool { self.key == event.code && self.modifiers == event.modifiers } @@ -163,9 +156,7 @@ impl Keybindings { ]); // Chat list - bindings.insert(Command::OpenChat, vec![ - KeyBinding::new(KeyCode::Enter), - ]); + // Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key() for i in 1..=9 { let cmd = match i { 1 => Command::SelectFolder1, @@ -185,9 +176,9 @@ impl Keybindings { } // Message actions - bindings.insert(Command::EditMessage, vec![ - KeyBinding::new(KeyCode::Up), - ]); + // Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input + // в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не + // конфликтовать с Command::MoveUp в списке чатов. bindings.insert(Command::DeleteMessage, vec![ KeyBinding::new(KeyCode::Delete), KeyBinding::new(KeyCode::Char('d')), @@ -209,10 +200,8 @@ impl Keybindings { KeyBinding::new(KeyCode::Char('e')), KeyBinding::new(KeyCode::Char('у')), // RU ]); - bindings.insert(Command::SelectMessage, vec![ - KeyBinding::new(KeyCode::Enter), - ]); - + // Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key() + // Input bindings.insert(Command::SubmitMessage, vec![ KeyBinding::new(KeyCode::Enter), @@ -241,8 +230,8 @@ impl Keybindings { // Profile bindings.insert(Command::OpenProfile, vec![ - KeyBinding::new(KeyCode::Char('i')), - KeyBinding::new(KeyCode::Char('ш')), // RU + KeyBinding::with_ctrl(KeyCode::Char('i')), + KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU ]); Self { bindings } @@ -257,32 +246,6 @@ impl Keybindings { } None } - - /// Проверяет соответствует ли событие команде - pub fn matches(&self, event: &KeyEvent, command: Command) -> bool { - self.bindings - .get(&command) - .map(|bindings| bindings.iter().any(|binding| binding.matches(event))) - .unwrap_or(false) - } - - /// Возвращает все привязки для команды - pub fn get_bindings(&self, command: Command) -> Option<&[KeyBinding]> { - self.bindings.get(&command).map(|v| v.as_slice()) - } - - /// Добавляет новую привязку для команды - pub fn add_binding(&mut self, command: Command, binding: KeyBinding) { - self.bindings - .entry(command) - .or_insert_with(Vec::new) - .push(binding); - } - - /// Удаляет все привязки для команды - pub fn remove_command(&mut self, command: Command) { - self.bindings.remove(&command); - } } impl Default for Keybindings { @@ -434,9 +397,9 @@ mod tests { let kb = Keybindings::default(); // Проверяем навигацию - assert!(kb.matches(&KeyEvent::from(KeyCode::Up), Command::MoveUp)); - assert!(kb.matches(&KeyEvent::from(KeyCode::Char('k')), Command::MoveUp)); - assert!(kb.matches(&KeyEvent::from(KeyCode::Char('р')), Command::MoveUp)); + assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Up)), Some(Command::MoveUp)); + assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('k'))), Some(Command::MoveUp)); + assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveUp)); } #[test] @@ -459,14 +422,4 @@ mod tests { assert_eq!(kb.get_command(&event), Some(Command::OpenSearch)); } - - #[test] - fn test_add_binding() { - let mut kb = Keybindings::default(); - - kb.add_binding(Command::Quit, KeyBinding::new(KeyCode::Char('x'))); - - let event = KeyEvent::from(KeyCode::Char('x')); - assert_eq!(kb.get_command(&event), Some(Command::Quit)); - } } diff --git a/src/config/mod.rs b/src/config/mod.rs index d6dc022..92bd190 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,11 +1,10 @@ pub mod keybindings; -use crossterm::event::KeyCode; use serde::{Deserialize, Serialize}; use std::fs; use std::path::PathBuf; -pub use keybindings::{Command, KeyBinding, Keybindings}; +pub use keybindings::{Command, Keybindings}; /// Главная конфигурация приложения. /// @@ -35,6 +34,10 @@ pub struct Config { /// Горячие клавиши. #[serde(default)] pub keybindings: Keybindings, + + /// Настройки desktop notifications. + #[serde(default)] + pub notifications: NotificationsConfig, } /// Общие настройки приложения. @@ -72,6 +75,31 @@ pub struct ColorsConfig { pub reaction_other: String, } +/// Настройки desktop notifications. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NotificationsConfig { + /// Включить/выключить уведомления + #[serde(default = "default_notifications_enabled")] + pub enabled: bool, + + /// Уведомлять только при @упоминаниях + #[serde(default)] + pub only_mentions: bool, + + /// Показывать превью текста сообщения + #[serde(default = "default_show_preview")] + pub show_preview: bool, + + /// Продолжительность показа уведомления (миллисекунды) + /// 0 = системное значение по умолчанию + #[serde(default = "default_notification_timeout")] + pub timeout_ms: i32, + + /// Уровень важности: "low", "normal", "critical" + #[serde(default = "default_notification_urgency")] + pub urgency: String, +} + // Дефолтные значения fn default_timezone() -> String { "+03:00".to_string() @@ -97,6 +125,22 @@ fn default_reaction_other_color() -> String { "gray".to_string() } +fn default_notifications_enabled() -> bool { + true +} + +fn default_show_preview() -> bool { + true +} + +fn default_notification_timeout() -> i32 { + 5000 // 5 seconds +} + +fn default_notification_urgency() -> String { + "normal".to_string() +} + impl Default for GeneralConfig { fn default() -> Self { Self { timezone: default_timezone() } @@ -115,6 +159,17 @@ impl Default for ColorsConfig { } } +impl Default for NotificationsConfig { + fn default() -> Self { + Self { + enabled: default_notifications_enabled(), + only_mentions: false, + show_preview: default_show_preview(), + timeout_ms: default_notification_timeout(), + urgency: default_notification_urgency(), + } + } +} impl Default for Config { fn default() -> Self { @@ -122,6 +177,7 @@ impl Default for Config { general: GeneralConfig::default(), colors: ColorsConfig::default(), keybindings: Keybindings::default(), + notifications: NotificationsConfig::default(), } } } @@ -347,8 +403,6 @@ impl Config { /// API_HASH=your_api_hash_here /// ``` pub fn load_credentials() -> Result<(i32, String), String> { - use std::env; - // 1. Пробуем загрузить из ~/.config/tele-tui/credentials if let Some(credentials) = Self::load_credentials_from_file() { return Ok(credentials); @@ -423,7 +477,7 @@ impl Config { #[cfg(test)] mod tests { use super::*; - use crossterm::event::{KeyEvent, KeyModifiers}; + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; #[test] fn test_config_default_includes_keybindings() { diff --git a/src/input/handlers/chat_list.rs b/src/input/handlers/chat_list.rs deleted file mode 100644 index 5a816bd..0000000 --- a/src/input/handlers/chat_list.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Chat list navigation input handling - -use crate::app::App; -use crate::tdlib::TdClientTrait; -use crossterm::event::KeyEvent; - -/// Обрабатывает ввод в списке чатов -pub async fn handle_chat_list_input(app: &mut App, key: KeyEvent) { - // TODO: Implement chat list input handling - let _ = (app, key); -} diff --git a/src/input/handlers/global.rs b/src/input/handlers/global.rs index 067ebb5..0bc9a99 100644 --- a/src/input/handlers/global.rs +++ b/src/input/handlers/global.rs @@ -19,29 +19,17 @@ use std::time::Duration; /// /// `true` если команда была обработана, `false` если нет pub async fn handle_global_commands(app: &mut App, key: KeyEvent) -> bool { - let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let command = app.get_command(key); - match key.code { - 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; - app.status_message = None; - true - } - KeyCode::Char('s') if has_ctrl => { + match command { + Some(crate::config::Command::OpenSearch) => { // Ctrl+S - начать поиск (только если чат не открыт) if app.selected_chat_id.is_none() { app.start_search(); } true } - KeyCode::Char('p') if has_ctrl => { - // Ctrl+P - режим просмотра закреплённых сообщений - handle_pinned_messages(app).await; - true - } - KeyCode::Char('f') if has_ctrl => { + Some(crate::config::Command::OpenSearchInChat) => { // Ctrl+F - поиск по сообщениям в открытом чате if app.selected_chat_id.is_some() && !app.is_pinned_mode() @@ -51,7 +39,27 @@ pub async fn handle_global_commands(app: &mut App, key: Key } true } - _ => false, + _ => { + // Проверяем специальные комбинации, которых нет в Command enum + let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + match key.code { + 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; + // Синхронизируем muted чаты после обновления + app.td_client.sync_notification_muted_chats(); + app.status_message = None; + true + } + KeyCode::Char('p') if has_ctrl => { + // Ctrl+P - режим просмотра закреплённых сообщений + handle_pinned_messages(app).await; + true + } + _ => false, + } + } } } diff --git a/src/input/handlers/messages.rs b/src/input/handlers/messages.rs deleted file mode 100644 index f735295..0000000 --- a/src/input/handlers/messages.rs +++ /dev/null @@ -1,11 +0,0 @@ -//! Message input handling when chat is open - -use crate::app::App; -use crate::tdlib::TdClientTrait; -use crossterm::event::KeyEvent; - -/// Обрабатывает ввод когда открыт чат -pub async fn handle_messages_input(app: &mut App, key: KeyEvent) { - // TODO: Implement messages input handling - let _ = (app, key); -} diff --git a/src/input/handlers/mod.rs b/src/input/handlers/mod.rs index d729986..3b1a0b0 100644 --- a/src/input/handlers/mod.rs +++ b/src/input/handlers/mod.rs @@ -1,26 +1,14 @@ -//! Input handlers organized by screen/mode +//! Input handlers organized by functionality //! //! This module contains handlers for different input contexts: //! - global: Global commands (Ctrl+R, Ctrl+S, etc.) -//! - profile: Profile mode input -//! - search: Search modes (chat search, message search) -//! - modal: Modal modes (pinned, reactions, delete, forward) -//! - messages: Message input when chat is open -//! - chat_list: Chat list navigation //! - clipboard: Clipboard operations +//! - profile: Profile helper functions -pub mod chat_list; pub mod clipboard; pub mod global; -pub mod messages; -pub mod modal; pub mod profile; -pub mod search; -// pub use chat_list::*; // Пока не используется pub use clipboard::*; pub use global::*; -// pub use messages::*; // Пока не используется -// pub use modal::*; // Пока не используется -pub use profile::get_available_actions_count; // Используется в main_input -// pub use search::*; // Пока не используется +pub use profile::get_available_actions_count; diff --git a/src/input/handlers/modal.rs b/src/input/handlers/modal.rs deleted file mode 100644 index 8c55fdb..0000000 --- a/src/input/handlers/modal.rs +++ /dev/null @@ -1,35 +0,0 @@ -//! Modal mode input handling -//! -//! Handles input for modal states: -//! - Pinned messages view -//! - Reaction picker -//! - Delete confirmation -//! - Forward mode - -use crate::app::App; -use crate::tdlib::TdClientTrait; -use crossterm::event::KeyEvent; - -/// Обрабатывает ввод в режиме закреплённых сообщений -pub async fn handle_pinned_input(app: &mut App, key: KeyEvent) { - // TODO: Implement pinned messages input handling - let _ = (app, key); -} - -/// Обрабатывает ввод в режиме выбора реакции -pub async fn handle_reaction_picker_input(app: &mut App, key: KeyEvent) { - // TODO: Implement reaction picker input handling - let _ = (app, key); -} - -/// Обрабатывает ввод в режиме подтверждения удаления -pub async fn handle_delete_confirmation_input(app: &mut App, key: KeyEvent) { - // TODO: Implement delete confirmation input handling - let _ = (app, key); -} - -/// Обрабатывает ввод в режиме пересылки -pub async fn handle_forward_input(app: &mut App, key: KeyEvent) { - // TODO: Implement forward mode input handling - let _ = (app, key); -} diff --git a/src/input/handlers/profile.rs b/src/input/handlers/profile.rs index 5a8cfd3..92f80d7 100644 --- a/src/input/handlers/profile.rs +++ b/src/input/handlers/profile.rs @@ -1,15 +1,4 @@ -//! Profile mode input handling - -use crate::app::App; -use crate::tdlib::TdClientTrait; -use crossterm::event::KeyEvent; - -/// Обрабатывает ввод в режиме профиля -pub async fn handle_profile_input(app: &mut App, key: KeyEvent) { - // TODO: Implement profile input handling - // Временно делегируем обратно в main_input - let _ = (app, key); -} +//! Profile mode helper functions /// Возвращает количество доступных действий в профиле pub fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize { diff --git a/src/input/handlers/search.rs b/src/input/handlers/search.rs deleted file mode 100644 index 038eb81..0000000 --- a/src/input/handlers/search.rs +++ /dev/null @@ -1,17 +0,0 @@ -//! Search mode input handling (chat search and message search) - -use crate::app::App; -use crate::tdlib::TdClientTrait; -use crossterm::event::KeyEvent; - -/// Обрабатывает ввод в режиме поиска чатов -pub async fn handle_chat_search_input(app: &mut App, key: KeyEvent) { - // TODO: Implement chat search input handling - let _ = (app, key); -} - -/// Обрабатывает ввод в режиме поиска сообщений -pub async fn handle_message_search_input(app: &mut App, key: KeyEvent) { - // TODO: Implement message search input handling - let _ = (app, key); -} diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 3dbb0ab..c178639 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -8,7 +8,7 @@ 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, KeyModifiers}; +use crossterm::event::{KeyCode, KeyEvent}; use std::time::{Duration, Instant}; /// Обработка режима профиля пользователя/чата @@ -18,7 +18,7 @@ use std::time::{Duration, Instant}; /// - Навигацию по действиям профиля (Up/Down) /// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу /// - Выход из режима профиля (Esc) -async fn handle_profile_mode(app: &mut App, key: KeyEvent) { +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 { @@ -58,20 +58,20 @@ async fn handle_profile_mode(app: &mut App, key: KeyEvent) } // Обычная навигация по профилю - match key.code { - KeyCode::Esc => { + match command { + Some(crate::config::Command::Cancel) => { app.exit_profile_mode(); } - KeyCode::Up => { + Some(crate::config::Command::MoveUp) => { app.select_previous_profile_action(); } - KeyCode::Down => { + 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); } } - KeyCode::Enter => { + Some(crate::config::Command::SubmitMessage) => { // Выполнить выбранное действие let Some(profile) = app.get_profile_info() else { return; @@ -170,17 +170,15 @@ async fn handle_profile_open(app: &mut App) { /// - Пересылку сообщения (f/а) /// - Копирование сообщения (y/н) /// - Добавление реакции (e/у) -async fn handle_message_selection(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Up => { +async fn handle_message_selection(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::MoveUp) => { app.select_previous_message(); } - KeyCode::Down => { + Some(crate::config::Command::MoveDown) => { app.select_next_message(); - // Если вышли из режима выбора (индекс стал None), ничего не делаем } - KeyCode::Char('d') | KeyCode::Char('в') | KeyCode::Delete => { - // Показать модалку подтверждения удаления + Some(crate::config::Command::DeleteMessage) => { let Some(msg) = app.get_selected_message() else { return; }; @@ -192,16 +190,13 @@ async fn handle_message_selection(app: &mut App, key: KeyEv }; } } - KeyCode::Char('r') | KeyCode::Char('к') => { - // Начать режим ответа на выбранное сообщение + Some(crate::config::Command::ReplyMessage) => { app.start_reply_to_selected(); } - KeyCode::Char('f') | KeyCode::Char('а') => { - // Начать режим пересылки + Some(crate::config::Command::ForwardMessage) => { app.start_forward_selected(); } - KeyCode::Char('y') | KeyCode::Char('н') => { - // Копировать сообщение + Some(crate::config::Command::CopyMessage) => { let Some(msg) = app.get_selected_message() else { return; }; @@ -215,8 +210,7 @@ async fn handle_message_selection(app: &mut App, key: KeyEv } } } - KeyCode::Char('e') | KeyCode::Char('у') => { - // Открыть emoji picker для добавления реакции + Some(crate::config::Command::ReactMessage) => { let Some(msg) = app.get_selected_message() else { return; }; @@ -226,7 +220,6 @@ async fn handle_message_selection(app: &mut App, key: KeyEv app.status_message = Some("Загрузка реакций...".to_string()); app.needs_redraw = true; - // Запрашиваем доступные реакции match with_timeout_msg( Duration::from_secs(5), app.td_client @@ -452,42 +445,43 @@ async fn handle_enter_key(app: &mut App) { } } -/// Обработка режима поиска по чатам (Ctrl+S) +/// Обработка режима поиска по чатам /// /// Обрабатывает: /// - Редактирование поискового запроса (Backspace, Char) /// - Навигацию по отфильтрованному списку (Up/Down) /// - Открытие выбранного чата (Enter) /// - Отмену поиска (Esc) -async fn handle_chat_search_mode(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Esc => { +async fn handle_chat_search_mode(app: &mut App, key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::Cancel) => { app.cancel_search(); } - KeyCode::Enter => { - // Выбрать чат из отфильтрованного списка + 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; } } - KeyCode::Backspace => { - app.search_query.pop(); - // Сбрасываем выделение при изменении запроса - app.chat_list_state.select(Some(0)); - } - KeyCode::Down => { + Some(crate::config::Command::MoveDown) => { app.next_filtered_chat(); } - KeyCode::Up => { + Some(crate::config::Command::MoveUp) => { app.previous_filtered_chat(); } - KeyCode::Char(c) => { - app.search_query.push(c); - // Сбрасываем выделение при изменении запроса - app.chat_list_state.select(Some(0)); + _ => { + 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)); + } + _ => {} + } } - _ => {} } } @@ -497,19 +491,19 @@ async fn handle_chat_search_mode(app: &mut App, key: KeyEve /// - Навигацию по списку чатов (Up/Down) /// - Пересылку сообщения в выбранный чат (Enter) /// - Отмену пересылки (Esc) -async fn handle_forward_mode(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Esc => { +async fn handle_forward_mode(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::Cancel) => { app.cancel_forward(); } - KeyCode::Enter => { + Some(crate::config::Command::SubmitMessage) => { forward_selected_message(app).await; app.cancel_forward(); } - KeyCode::Down => { + Some(crate::config::Command::MoveDown) => { app.next_chat(); } - KeyCode::Up => { + Some(crate::config::Command::MoveUp) => { app.previous_chat(); } _ => {} @@ -710,18 +704,17 @@ async fn handle_delete_confirmation(app: &mut App, key: Key /// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6) /// - Добавление/удаление реакции (Enter) /// - Выход из режима (Esc) -async fn handle_reaction_picker_mode(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Left => { +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; } - KeyCode::Right => { + Some(crate::config::Command::MoveRight) => { app.select_next_reaction(); app.needs_redraw = true; } - KeyCode::Up => { - // Переход на ряд выше (8 эмодзи в ряду) + Some(crate::config::Command::MoveUp) => { if let crate::app::ChatState::ReactionPicker { selected_index, .. @@ -733,8 +726,7 @@ async fn handle_reaction_picker_mode(app: &mut App, key: Ke } } } - KeyCode::Down => { - // Переход на ряд ниже (8 эмодзи в ряду) + Some(crate::config::Command::MoveDown) => { if let crate::app::ChatState::ReactionPicker { selected_index, available_reactions, @@ -748,11 +740,10 @@ async fn handle_reaction_picker_mode(app: &mut App, key: Ke } } } - KeyCode::Enter => { - // Добавить/убрать реакцию + Some(crate::config::Command::SubmitMessage) => { send_reaction(app).await; } - KeyCode::Esc => { + Some(crate::config::Command::Cancel) => { app.exit_reaction_picker_mode(); app.needs_redraw = true; } @@ -766,22 +757,20 @@ async fn handle_reaction_picker_mode(app: &mut App, key: Ke /// - Навигацию по закреплённым сообщениям (Up/Down) /// - Переход к сообщению в истории (Enter) /// - Выход из режима (Esc) -async fn handle_pinned_mode(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Esc => { +async fn handle_pinned_mode(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::Cancel) => { app.exit_pinned_mode(); } - KeyCode::Up => { + Some(crate::config::Command::MoveUp) => { app.select_previous_pinned(); } - KeyCode::Down => { + Some(crate::config::Command::MoveDown) => { app.select_next_pinned(); } - KeyCode::Enter => { - // Перейти к сообщению в истории + 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() @@ -789,7 +778,6 @@ async fn handle_pinned_mode(app: &mut App, key: KeyEvent) { .position(|m| m.id() == msg_id); if let Some(idx) = msg_index { - // Вычисляем scroll offset чтобы показать сообщение let total = app.td_client.current_chat_messages().len(); app.message_scroll_offset = total.saturating_sub(idx + 5); } @@ -828,19 +816,18 @@ async fn perform_message_search(app: &mut App, query: &str) /// - Переход к выбранному сообщению (Enter) /// - Редактирование поискового запроса (Backspace, Char) /// - Выход из режима поиска (Esc) -async fn handle_message_search_mode(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::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(); } - KeyCode::Up | KeyCode::Char('N') => { + Some(crate::config::Command::MoveUp) => { app.select_previous_search_result(); } - KeyCode::Down | KeyCode::Char('n') => { + Some(crate::config::Command::MoveDown) => { app.select_next_search_result(); } - KeyCode::Enter => { - // Перейти к выбранному сообщению + 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 @@ -856,25 +843,33 @@ async fn handle_message_search_mode(app: &mut App, key: Key app.exit_message_search_mode(); } } - 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; + _ => { + 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; + } + _ => {} + } } - 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; - } - _ => {} } } @@ -883,41 +878,61 @@ async fn handle_message_search_mode(app: &mut App, key: Key /// Обрабатывает: /// - Up/Down/j/k: навигация между чатами /// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib) -async fn handle_chat_list_navigation(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('о') => { +async fn handle_chat_list_navigation(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::MoveDown) => { app.next_chat(); } - KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('р') => { + Some(crate::config::Command::MoveUp) => { app.previous_chat(); } - // Цифры 1-9 - переключение папок - KeyCode::Char(c) if c >= '1' && c <= '9' => { - let folder_num = (c as usize) - ('1' as usize); // 0-based - if folder_num == 0 { - // 1 = All - app.selected_folder_id = None; - } else { - // 2, 3, 4... = папки из TDLib - if let Some(folder) = app.td_client.folders().get(folder_num - 1) { - 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; - } - } + 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)); + } +} + /// Обработка ввода с клавиатуры в открытом чате /// /// Обрабатывает: @@ -930,14 +945,13 @@ async fn handle_open_chat_keyboard_input(app: &mut App, key 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; + // Находим 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; } } @@ -945,30 +959,29 @@ async fn handle_open_chat_keyboard_input(app: &mut App, key // Удаляем символ справа от курсора 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; + // Находим 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) => { // Вставляем символ в позицию курсора - 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 >= 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); } - if app.cursor_position >= chars.len() { - new_input.push(c); - } - app.message_input = new_input; app.cursor_position += 1; // Отправляем typing status с throttling (не чаще 1 раза в 5 сек) @@ -1033,29 +1046,30 @@ pub async fn handle(app: &mut App, key: KeyEvent) { return; } - let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + // Получаем команду из keybindings + let command = app.get_command(key); // Режим профиля if app.is_profile_mode() { - handle_profile_mode(app, key).await; + handle_profile_mode(app, key, command).await; return; } // Режим поиска по сообщениям if app.is_message_search_mode() { - handle_message_search_mode(app, key).await; + handle_message_search_mode(app, key, command).await; return; } // Режим просмотра закреплённых сообщений if app.is_pinned_mode() { - handle_pinned_mode(app, key).await; + handle_pinned_mode(app, key, command).await; return; } // Обработка ввода в режиме выбора реакции if app.is_reaction_picker_mode() { - handle_reaction_picker_mode(app, key).await; + handle_reaction_picker_mode(app, key, command).await; return; } @@ -1067,46 +1081,50 @@ pub async fn handle(app: &mut App, key: KeyEvent) { // Режим выбора чата для пересылки if app.is_forwarding() { - handle_forward_mode(app, key).await; + handle_forward_mode(app, key, command).await; return; } // Режим поиска if app.is_searching { - handle_chat_search_mode(app, key).await; + handle_chat_search_mode(app, key, command).await; return; } - // Enter - открыть чат, отправить сообщение или редактировать - if key.code == KeyCode::Enter { - handle_enter_key(app).await; - return; - } - - // Esc - отменить выбор/редактирование/reply или закрыть чат - if key.code == KeyCode::Esc { - handle_escape_key(app).await; - return; + // Обработка команд через keybindings + 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; + return; + } + Some(crate::config::Command::OpenProfile) => { + // Открыть профиль (обычно 'i') + if app.selected_chat_id.is_some() { + handle_profile_open(app).await; + return; + } + } + _ => {} } // Режим открытого чата if app.selected_chat_id.is_some() { // Режим выбора сообщения для редактирования/удаления if app.is_selecting_message() { - handle_message_selection(app, key).await; - return; - } - - // Ctrl+U для профиля - if key.code == KeyCode::Char('u') && has_ctrl { - handle_profile_open(app).await; + handle_message_selection(app, key, command).await; return; } handle_open_chat_keyboard_input(app, key).await; } else { // В режиме списка чатов - навигация стрелками и переключение папок - handle_chat_list_navigation(app, key).await; + handle_chat_list_navigation(app, key, command).await; } } @@ -1124,10 +1142,11 @@ async fn open_chat_and_load_data(app: &mut App, chat_id: i6 app.status_message = Some("Загрузка сообщений...".to_string()); app.message_scroll_offset = 0; - // Загружаем все доступные сообщения (без лимита) + // Загружаем последние 100 сообщений для быстрого открытия чата + // Остальные сообщения будут подгружаться при скролле вверх match with_timeout_msg( - Duration::from_secs(30), - app.td_client.get_chat_history(ChatId::new(chat_id), i32::MAX), + Duration::from_secs(10), + app.td_client.get_chat_history(ChatId::new(chat_id), 100), "Таймаут загрузки сообщений", ) .await diff --git a/src/lib.rs b/src/lib.rs index 670db97..fa72b40 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,6 +7,7 @@ pub mod constants; pub mod formatting; pub mod input; pub mod message_grouping; +pub mod notifications; pub mod tdlib; pub mod types; pub mod ui; diff --git a/src/main.rs b/src/main.rs index 9b6e837..86cb4dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod constants; mod formatting; mod input; mod message_grouping; +mod notifications; mod tdlib; mod types; mod ui; @@ -54,14 +55,22 @@ async fn main() -> Result<(), io::Error> { let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; + // Ensure terminal restoration on panic + let panic_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + let _ = disable_raw_mode(); + let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture); + panic_hook(info); + })); + // Create app state let mut app = App::new(config); - + // Запускаем инициализацию 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(); - + tokio::spawn(async move { let _ = tdlib_rs::functions::set_tdlib_parameters( false, // use_test_dc @@ -82,7 +91,7 @@ async fn main() -> Result<(), io::Error> { ) .await; }); - + let res = run_app(&mut terminal, &mut app).await; // Restore terminal @@ -237,6 +246,8 @@ async fn update_screen_state(app: &mut App) -> bool if app.chat_list_state.selected().is_none() && !app.chats.is_empty() { app.chat_list_state.select(Some(0)); } + // Синхронизируем muted чаты для notifications + app.td_client.sync_notification_muted_chats(); // Убираем статус загрузки когда чаты появились if app.is_loading { app.is_loading = false; diff --git a/src/notifications.rs b/src/notifications.rs new file mode 100644 index 0000000..e0dc06d --- /dev/null +++ b/src/notifications.rs @@ -0,0 +1,355 @@ +//! Desktop notifications module +//! +//! Provides cross-platform desktop notifications for new messages. + +use crate::tdlib::{ChatInfo, MessageInfo}; +use crate::types::ChatId; +use std::collections::HashSet; + +#[cfg(feature = "notifications")] +use notify_rust::{Notification, Timeout}; + +/// Manages desktop notifications +pub struct NotificationManager { + /// Whether notifications are enabled + enabled: bool, + /// Set of muted chat IDs (don't notify for these chats) + muted_chats: HashSet, + /// Only notify for mentions (@username) + only_mentions: bool, + /// Show message preview text + show_preview: bool, + /// Notification timeout in milliseconds (0 = system default) + timeout_ms: i32, + /// Notification urgency level + urgency: String, +} + +impl NotificationManager { + /// Creates a new notification manager with default settings + pub fn new() -> Self { + Self { + enabled: false, + muted_chats: HashSet::new(), + only_mentions: false, + show_preview: true, + timeout_ms: 5000, + urgency: "normal".to_string(), + } + } + + /// Creates a notification manager with custom settings + pub fn with_config( + enabled: bool, + only_mentions: bool, + show_preview: bool, + ) -> Self { + Self { + enabled, + muted_chats: HashSet::new(), + only_mentions, + show_preview, + timeout_ms: 5000, + urgency: "normal".to_string(), + } + } + + /// Sets whether notifications are enabled + pub fn set_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + + /// Sets whether to only notify for mentions + pub fn set_only_mentions(&mut self, only_mentions: bool) { + self.only_mentions = only_mentions; + } + + /// Sets notification timeout in milliseconds + pub fn set_timeout(&mut self, timeout_ms: i32) { + self.timeout_ms = timeout_ms; + } + + /// Sets notification urgency level + pub fn set_urgency(&mut self, urgency: String) { + self.urgency = urgency; + } + + /// Adds a chat to the muted list + pub fn mute_chat(&mut self, chat_id: ChatId) { + self.muted_chats.insert(chat_id); + } + + /// Removes a chat from the muted list + pub fn unmute_chat(&mut self, chat_id: ChatId) { + self.muted_chats.remove(&chat_id); + } + + /// Checks if a chat should be muted based on Telegram mute status + pub fn sync_muted_chats(&mut self, chats: &[ChatInfo]) { + self.muted_chats.clear(); + for chat in chats { + if chat.is_muted { + self.muted_chats.insert(chat.id); + } + } + } + + /// Sends a notification for a new message + /// + /// # Arguments + /// + /// * `chat` - Chat information + /// * `message` - Message information + /// * `sender_name` - Name of the message sender + /// + /// Returns `Ok(())` if notification was sent or skipped, `Err` if failed + pub fn notify_new_message( + &self, + chat: &ChatInfo, + message: &MessageInfo, + sender_name: &str, + ) -> Result<(), String> { + // Check if notifications are enabled + if !self.enabled { + tracing::debug!("Notifications disabled, skipping"); + return Ok(()); + } + + // Don't notify for outgoing messages + if message.is_outgoing() { + tracing::debug!("Outgoing message, skipping notification"); + return Ok(()); + } + + // Check if chat is muted + if self.muted_chats.contains(&chat.id) { + tracing::debug!("Chat {} is muted, skipping notification", chat.title); + return Ok(()); + } + + // Check if we only notify for mentions + if self.only_mentions && !message.has_mention() { + tracing::debug!("only_mentions=true but no mention found, skipping"); + return Ok(()); + } + + // Format the notification + let title = &chat.title; + let body = self.format_message_body(sender_name, message); + + tracing::debug!("Sending notification for chat: {}", title); + + // Send the notification + self.send_notification(title, &body)?; + + Ok(()) + } + + /// Formats the message body for notification + fn format_message_body(&self, sender_name: &str, message: &MessageInfo) -> String { + // For groups, include sender name. For private chats, sender name is in title + let prefix = if !sender_name.is_empty() && sender_name != message.sender_name() { + format!("{}: ", sender_name) + } else { + String::new() + }; + + let content = if self.show_preview { + let text = message.text(); + + // Check if message is empty (media, sticker, etc.) + if text.is_empty() { + "Новое сообщение".to_string() + } else { + // Beautify media labels with emojis + let beautified = Self::beautify_media_labels(text); + + // Limit preview length (use char count, not byte count for UTF-8 safety) + const MAX_PREVIEW_CHARS: usize = 147; + let char_count = beautified.chars().count(); + if char_count > MAX_PREVIEW_CHARS { + let truncated: String = beautified.chars().take(MAX_PREVIEW_CHARS).collect(); + format!("{}...", truncated) + } else { + beautified + } + } + } else { + "Новое сообщение".to_string() + }; + + format!("{}{}", prefix, content) + } + + /// Replaces text media labels with emoji-enhanced versions + fn beautify_media_labels(text: &str) -> String { + text.replace("[Фото]", "📷 Фото") + .replace("[Видео]", "🎥 Видео") + .replace("[GIF]", "🎞️ GIF") + .replace("[Голосовое]", "🎤 Голосовое") + .replace("[Стикер:", "🎨 Стикер:") + .replace("[Файл:", "📎 Файл:") + .replace("[Аудио:", "🎵 Аудио:") + .replace("[Аудио]", "🎵 Аудио") + .replace("[Видеосообщение]", "📹 Видеосообщение") + .replace("[Локация]", "📍 Локация") + .replace("[Контакт:", "👤 Контакт:") + .replace("[Опрос:", "📊 Опрос:") + .replace("[Место встречи:", "📍 Место встречи:") + .replace("[Неподдерживаемый тип сообщения]", "📨 Сообщение") + } + + /// Sends a desktop notification + /// + /// Returns `Ok(())` if notification was sent successfully or skipped. + /// Logs errors but doesn't fail - notifications are not critical for app functionality. + #[cfg(feature = "notifications")] + fn send_notification(&self, title: &str, body: &str) -> Result<(), String> { + // Don't send if notifications are disabled + if !self.enabled { + return Ok(()); + } + + // Determine timeout + let timeout = if self.timeout_ms <= 0 { + Timeout::Default + } else { + Timeout::Milliseconds(self.timeout_ms as u32) + }; + + // Build notification + let mut notification = Notification::new(); + notification + .summary(title) + .body(body) + .icon("telegram") + .appname("tele-tui") + .timeout(timeout); + + // Set urgency if supported + #[cfg(all(unix, not(target_os = "macos")))] + { + use notify_rust::Urgency; + let urgency_level = match self.urgency.to_lowercase().as_str() { + "low" => Urgency::Low, + "critical" => Urgency::Critical, + _ => Urgency::Normal, + }; + notification.urgency(urgency_level); + } + + match notification.show() { + Ok(_) => Ok(()), + Err(e) => { + // Log error but don't fail - notifications are optional + tracing::warn!("Failed to send desktop notification: {}", e); + // Return Ok to not break the app flow + Ok(()) + } + } + } + + /// Fallback when notifications feature is disabled + #[cfg(not(feature = "notifications"))] + fn send_notification(&self, _title: &str, _body: &str) -> Result<(), String> { + Ok(()) + } +} + +impl Default for NotificationManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_notification_manager_creation() { + let manager = NotificationManager::new(); + assert!(manager.enabled); + assert!(!manager.only_mentions); + assert!(manager.show_preview); + } + + #[test] + fn test_mute_unmute() { + let mut manager = NotificationManager::new(); + let chat_id = ChatId::new(123); + + manager.mute_chat(chat_id); + assert!(manager.muted_chats.contains(&chat_id)); + + manager.unmute_chat(chat_id); + assert!(!manager.muted_chats.contains(&chat_id)); + } + + #[test] + fn test_disabled_notifications() { + let mut manager = NotificationManager::new(); + manager.set_enabled(false); + + // Should return Ok without sending notification + let result = manager.send_notification("Test", "Body"); + assert!(result.is_ok()); + } + + #[test] + fn test_only_mentions_setting() { + let mut manager = NotificationManager::new(); + assert!(!manager.only_mentions); + + manager.set_only_mentions(true); + assert!(manager.only_mentions); + + manager.set_only_mentions(false); + assert!(!manager.only_mentions); + } + + #[test] + fn test_beautify_media_labels() { + // Test photo + assert_eq!( + NotificationManager::beautify_media_labels("[Фото]"), + "📷 Фото" + ); + + // Test video + assert_eq!( + NotificationManager::beautify_media_labels("[Видео]"), + "🎥 Видео" + ); + + // Test sticker with emoji + assert_eq!( + NotificationManager::beautify_media_labels("[Стикер: 😊]"), + "🎨 Стикер: 😊]" + ); + + // Test audio with title + assert_eq!( + NotificationManager::beautify_media_labels("[Аудио: Artist - Song]"), + "🎵 Аудио: Artist - Song]" + ); + + // Test file + assert_eq!( + NotificationManager::beautify_media_labels("[Файл: document.pdf]"), + "📎 Файл: document.pdf]" + ); + + // Test regular text (no changes) + assert_eq!( + NotificationManager::beautify_media_labels("Hello, world!"), + "Hello, world!" + ); + + // Test mixed content + assert_eq!( + NotificationManager::beautify_media_labels("[Фото] Check this out!"), + "📷 Фото Check this out!" + ); + } +} diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 0452993..bac4e33 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -15,6 +15,7 @@ use super::messages::MessageManager; use super::reactions::ReactionManager; use super::types::{ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus}; use super::users::UserCache; +use crate::notifications::NotificationManager; /// TDLib client wrapper for Telegram integration. /// @@ -52,6 +53,7 @@ pub struct TdClient { pub message_manager: MessageManager, pub user_cache: UserCache, pub reaction_manager: ReactionManager, + pub notification_manager: NotificationManager, // Состояние сети pub network_state: NetworkState, @@ -93,10 +95,27 @@ impl TdClient { message_manager: MessageManager::new(client_id), user_cache: UserCache::new(client_id), reaction_manager: ReactionManager::new(client_id), + notification_manager: NotificationManager::new(), network_state: NetworkState::Connecting, } } + /// 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_timeout(config.timeout_ms); + self.notification_manager.set_urgency(config.urgency.clone()); + // Note: show_preview is used when formatting notification body + } + + /// Synchronizes muted chats from Telegram to notification manager. + /// + /// 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); + } + // Делегирование к auth /// Sends phone number for authentication. diff --git a/src/tdlib/client_impl.rs b/src/tdlib/client_impl.rs index 76ea884..2590206 100644 --- a/src/tdlib/client_impl.rs +++ b/src/tdlib/client_impl.rs @@ -260,11 +260,17 @@ impl TdClientTrait for TdClient { } fn user_cache_mut(&mut self) -> &mut UserCache { - self.user_cache_mut() + &mut self.user_cache + } + + // ============ Notification methods ============ + fn sync_notification_muted_chats(&mut self) { + self.notification_manager.sync_muted_chats(&self.chat_manager.chats); } // ============ Update handling ============ fn handle_update(&mut self, update: Update) { - self.handle_update(update) + // Delegate to the real implementation + TdClient::handle_update(self, update) } } diff --git a/src/tdlib/messages.rs b/src/tdlib/messages.rs index b490788..6e4b18b 100644 --- a/src/tdlib/messages.rs +++ b/src/tdlib/messages.rs @@ -1,10 +1,11 @@ use crate::constants::{MAX_MESSAGES_IN_CHAT, TDLIB_MESSAGE_LIMIT}; use crate::types::{ChatId, MessageId}; -use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, MessageContent, MessageSender, 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 tokio::time::{sleep, Duration}; -use super::types::{ForwardInfo, MessageBuilder, MessageInfo, ReactionInfo, ReplyInfo}; +use super::types::{MessageBuilder, MessageInfo, ReplyInfo}; /// Менеджер сообщений TDLib. /// @@ -123,8 +124,6 @@ impl MessageManager { chat_id: ChatId, limit: i32, ) -> Result, String> { - use tokio::time::{sleep, Duration}; - // ВАЖНО: Сначала открываем чат в TDLib // Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю let _ = functions::open_chat(chat_id.as_i64(), self.client_id).await; @@ -190,9 +189,11 @@ impl MessageManager { // Если это первая загрузка и получили мало сообщений - продолжаем попытки // TDLib может подгружать данные с сервера постепенно - if all_messages.is_empty() && - received_count < (chunk_size as usize) && + if all_messages.is_empty() && + received_count < (chunk_size as usize) && attempt < max_attempts_per_chunk { + // Даём TDLib время на синхронизацию с сервером + sleep(Duration::from_millis(100)).await; continue; } diff --git a/src/tdlib/trait.rs b/src/tdlib/trait.rs index 8072760..70d1cfb 100644 --- a/src/tdlib/trait.rs +++ b/src/tdlib/trait.rs @@ -120,6 +120,9 @@ pub trait TdClientTrait: Send { fn set_main_chat_list_position(&mut self, position: i32); fn user_cache_mut(&mut self) -> &mut UserCache; + // ============ Notification methods ============ + fn sync_notification_muted_chats(&mut self); + // ============ Update handling ============ fn handle_update(&mut self, update: Update); } diff --git a/src/tdlib/types.rs b/src/tdlib/types.rs index 6c8fbb6..0829d65 100644 --- a/src/tdlib/types.rs +++ b/src/tdlib/types.rs @@ -1,3 +1,4 @@ +use tdlib_rs::enums::TextEntityType; use tdlib_rs::types::TextEntity; use crate::types::{ChatId, MessageId}; @@ -192,6 +193,16 @@ impl MessageInfo { self.state.can_be_deleted_for_all_users } + /// 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(_) + ) + }) + } + pub fn reply_to(&self) -> Option<&ReplyInfo> { self.interactions.reply_to.as_ref() } @@ -475,6 +486,39 @@ mod tests { assert!(message.can_be_edited()); assert!(message.can_be_deleted_for_all_users()); } + + #[test] + fn test_message_has_mention() { + // Message without mentions + let message = MessageBuilder::new(MessageId::new(1)) + .text("Hello world") + .build(); + assert!(!message.has_mention()); + + // Message with @mention + let message_with_mention = MessageBuilder::new(MessageId::new(2)) + .text("Hello @user") + .entities(vec![TextEntity { + offset: 6, + length: 5, + r#type: TextEntityType::Mention, + }]) + .build(); + assert!(message_with_mention.has_mention()); + + // Message with MentionName + let message_with_mention_name = MessageBuilder::new(MessageId::new(3)) + .text("Hello John") + .entities(vec![TextEntity { + offset: 6, + length: 4, + r#type: TextEntityType::MentionName( + tdlib_rs::types::TextEntityTypeMentionName { user_id: 123 }, + ), + }]) + .build(); + assert!(message_with_mention_name.has_mention()); + } } #[derive(Debug, Clone)] diff --git a/src/tdlib/update_handlers.rs b/src/tdlib/update_handlers.rs index abdc74b..cd933e5 100644 --- a/src/tdlib/update_handlers.rs +++ b/src/tdlib/update_handlers.rs @@ -19,12 +19,29 @@ use super::types::ReactionInfo; /// Обрабатывает Update::NewMessage - добавление нового сообщения pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessage) { - // Добавляем новое сообщение если это текущий открытый чат let chat_id = ChatId::new(new_msg.message.chat_id); + + // Если сообщение НЕ для текущего открытого чата - отправляем уведомление 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); + + // 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, + ); + } return; } + // Добавляем новое сообщение если это текущий открытый чат + 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(); diff --git a/src/ui/messages.rs b/src/ui/messages.rs index d2d8895..611d630 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -101,8 +101,6 @@ fn render_input_with_cursor( /// Информация о строке после переноса: текст и позиция в оригинале struct WrappedLine { text: String, - /// Начальная позиция в символах от начала оригинального текста - start_offset: usize, } /// Разбивает текст на строки с учётом максимальной ширины @@ -111,14 +109,12 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if max_width == 0 { return vec![WrappedLine { text: text.to_string(), - start_offset: 0, }]; } let mut result = Vec::new(); let mut current_line = String::new(); let mut current_width = 0; - let mut line_start_offset = 0; let chars: Vec = text.chars().collect(); let mut word_start = 0; @@ -133,7 +129,6 @@ 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; } else if current_width + 1 + word_width <= max_width { current_line.push(' '); current_line.push_str(&word); @@ -141,11 +136,9 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { } else { result.push(WrappedLine { text: current_line, - start_offset: line_start_offset, }); current_line = word; current_width = word_width; - line_start_offset = word_start; } in_word = false; } @@ -161,31 +154,26 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec { if current_width == 0 { current_line = word; - line_start_offset = word_start; } else if current_width + 1 + word_width <= max_width { current_line.push(' '); current_line.push_str(&word); } else { result.push(WrappedLine { text: current_line, - start_offset: line_start_offset, }); current_line = word; - line_start_offset = word_start; } } if !current_line.is_empty() { result.push(WrappedLine { text: current_line, - start_offset: line_start_offset, }); } if result.is_empty() { result.push(WrappedLine { text: String::new(), - start_offset: 0, }); } diff --git a/src/utils/modal_handler.rs b/src/utils/modal_handler.rs index 842985f..9da8e45 100644 --- a/src/utils/modal_handler.rs +++ b/src/utils/modal_handler.rs @@ -4,82 +4,6 @@ use crossterm::event::KeyCode; -/// Результат обработки клавиши в модальном окне. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ModalAction { - /// Закрыть модалку (Escape была нажата) - Close, - /// Подтвердить действие (Enter была нажата) - Confirm, - /// Продолжить обработку ввода (другая клавиша) - Continue, -} - -/// Обрабатывает стандартные клавиши для модальных окон. -/// -/// Проверяет клавиши Escape (закрыть) и Enter (подтвердить). -/// Если нажата другая клавиша, возвращает `Continue`. -/// -/// # Arguments -/// -/// * `key_code` - код нажатой клавиши -/// -/// # Returns -/// -/// * `ModalAction::Close` - если нажата Escape -/// * `ModalAction::Confirm` - если нажата Enter -/// * `ModalAction::Continue` - для других клавиш -/// -/// # Examples -/// -/// ``` -/// use crossterm::event::KeyCode; -/// use tele_tui::utils::modal_handler::{handle_modal_key, ModalAction}; -/// -/// assert_eq!(handle_modal_key(KeyCode::Esc), ModalAction::Close); -/// assert_eq!(handle_modal_key(KeyCode::Enter), ModalAction::Confirm); -/// assert_eq!(handle_modal_key(KeyCode::Char('a')), ModalAction::Continue); -/// ``` -pub fn handle_modal_key(key_code: KeyCode) -> ModalAction { - match key_code { - KeyCode::Esc => ModalAction::Close, - KeyCode::Enter => ModalAction::Confirm, - _ => ModalAction::Continue, - } -} - -/// Проверяет, нужно ли закрыть модалку (нажата Escape). -/// -/// # Examples -/// -/// ``` -/// use crossterm::event::KeyCode; -/// use tele_tui::utils::modal_handler::should_close_modal; -/// -/// assert!(should_close_modal(KeyCode::Esc)); -/// assert!(!should_close_modal(KeyCode::Enter)); -/// assert!(!should_close_modal(KeyCode::Char('q'))); -/// ``` -pub fn should_close_modal(key_code: KeyCode) -> bool { - matches!(key_code, KeyCode::Esc) -} - -/// Проверяет, нужно ли подтвердить действие в модалке (нажата Enter). -/// -/// # Examples -/// -/// ``` -/// use crossterm::event::KeyCode; -/// use tele_tui::utils::modal_handler::should_confirm_modal; -/// -/// assert!(should_confirm_modal(KeyCode::Enter)); -/// assert!(!should_confirm_modal(KeyCode::Esc)); -/// assert!(!should_confirm_modal(KeyCode::Char('y'))); -/// ``` -pub fn should_confirm_modal(key_code: KeyCode) -> bool { - matches!(key_code, KeyCode::Enter) -} - /// Обрабатывает клавиши для подтверждения Yes/No. /// /// Поддерживает: @@ -138,28 +62,6 @@ pub fn handle_yes_no(key_code: KeyCode) -> Option { mod tests { use super::*; - #[test] - fn test_handle_modal_key() { - assert_eq!(handle_modal_key(KeyCode::Esc), ModalAction::Close); - assert_eq!(handle_modal_key(KeyCode::Enter), ModalAction::Confirm); - assert_eq!(handle_modal_key(KeyCode::Char('a')), ModalAction::Continue); - assert_eq!(handle_modal_key(KeyCode::Up), ModalAction::Continue); - } - - #[test] - fn test_should_close_modal() { - assert!(should_close_modal(KeyCode::Esc)); - assert!(!should_close_modal(KeyCode::Enter)); - assert!(!should_close_modal(KeyCode::Char('q'))); - } - - #[test] - fn test_should_confirm_modal() { - assert!(should_confirm_modal(KeyCode::Enter)); - assert!(!should_confirm_modal(KeyCode::Esc)); - assert!(!should_confirm_modal(KeyCode::Char('y'))); - } - #[test] fn test_handle_yes_no() { // Yes variants diff --git a/src/utils/validation.rs b/src/utils/validation.rs index 8a0e964..2dfac4f 100644 --- a/src/utils/validation.rs +++ b/src/utils/validation.rs @@ -2,8 +2,6 @@ //! //! Переиспользуемые валидаторы для проверки пользовательского ввода. -use crate::types::{ChatId, MessageId, UserId}; - /// Проверяет, что строка не пустая (после trim). /// /// # Examples @@ -20,112 +18,6 @@ pub fn is_non_empty(text: &str) -> bool { !text.trim().is_empty() } -/// Проверяет, что текст не превышает максимальную длину. -/// -/// # Arguments -/// -/// * `text` - текст для проверки -/// * `max_length` - максимальная длина в символах -/// -/// # Examples -/// -/// ``` -/// use tele_tui::utils::validation::is_within_length; -/// -/// assert!(is_within_length("hello", 10)); -/// assert!(!is_within_length("very long text here", 5)); -/// ``` -pub fn is_within_length(text: &str, max_length: usize) -> bool { - text.chars().count() <= max_length -} - -/// Проверяет валидность ID чата (не нулевой). -/// -/// # Examples -/// -/// ``` -/// use tele_tui::types::ChatId; -/// use tele_tui::utils::validation::is_valid_chat_id; -/// -/// assert!(is_valid_chat_id(ChatId::new(123))); -/// assert!(!is_valid_chat_id(ChatId::new(0))); -/// assert!(!is_valid_chat_id(ChatId::new(-1))); -/// ``` -pub fn is_valid_chat_id(chat_id: ChatId) -> bool { - chat_id.as_i64() > 0 -} - -/// Проверяет валидность ID сообщения (не нулевой). -/// -/// # Examples -/// -/// ``` -/// use tele_tui::types::MessageId; -/// use tele_tui::utils::validation::is_valid_message_id; -/// -/// assert!(is_valid_message_id(MessageId::new(456))); -/// assert!(!is_valid_message_id(MessageId::new(0))); -/// ``` -pub fn is_valid_message_id(message_id: MessageId) -> bool { - message_id.as_i64() > 0 -} - -/// Проверяет валидность ID пользователя (не нулевой). -/// -/// # Examples -/// -/// ``` -/// use tele_tui::types::UserId; -/// use tele_tui::utils::validation::is_valid_user_id; -/// -/// assert!(is_valid_user_id(UserId::new(789))); -/// assert!(!is_valid_user_id(UserId::new(0))); -/// ``` -pub fn is_valid_user_id(user_id: UserId) -> bool { - user_id.as_i64() > 0 -} - -/// Проверяет, что вектор не пустой. -/// -/// # Examples -/// -/// ``` -/// use tele_tui::utils::validation::has_items; -/// -/// assert!(has_items(&vec![1, 2, 3])); -/// assert!(!has_items::(&vec![])); -/// ``` -pub fn has_items(items: &[T]) -> bool { - !items.is_empty() -} - -/// Комбинированная валидация текстового ввода: -/// - Не пустой (после trim) -/// - В пределах максимальной длины -/// -/// # Examples -/// -/// ``` -/// use tele_tui::utils::validation::validate_text_input; -/// -/// assert!(validate_text_input("hello", 100).is_ok()); -/// assert!(validate_text_input("", 100).is_err()); -/// assert!(validate_text_input(" ", 100).is_err()); -/// assert!(validate_text_input("very long text", 5).is_err()); -/// ``` -pub fn validate_text_input(text: &str, max_length: usize) -> Result<(), String> { - if !is_non_empty(text) { - return Err("Text cannot be empty".to_string()); - } - if !is_within_length(text, max_length) { - return Err(format!( - "Text exceeds maximum length of {} characters", - max_length - )); - } - Ok(()) -} - #[cfg(test)] mod tests { use super::*; @@ -138,54 +30,4 @@ mod tests { assert!(!is_non_empty(" ")); assert!(!is_non_empty("\t\n")); } - - #[test] - fn test_is_within_length() { - assert!(is_within_length("hello", 10)); - assert!(is_within_length("hello", 5)); - assert!(!is_within_length("hello", 4)); - assert!(is_within_length("", 0)); - } - - #[test] - fn test_is_valid_chat_id() { - assert!(is_valid_chat_id(ChatId::new(123))); - assert!(is_valid_chat_id(ChatId::new(999999))); - assert!(!is_valid_chat_id(ChatId::new(0))); - assert!(!is_valid_chat_id(ChatId::new(-1))); - } - - #[test] - fn test_is_valid_message_id() { - assert!(is_valid_message_id(MessageId::new(456))); - assert!(!is_valid_message_id(MessageId::new(0))); - assert!(!is_valid_message_id(MessageId::new(-1))); - } - - #[test] - fn test_is_valid_user_id() { - assert!(is_valid_user_id(UserId::new(789))); - assert!(!is_valid_user_id(UserId::new(0))); - } - - #[test] - fn test_has_items() { - assert!(has_items(&vec![1, 2, 3])); - assert!(has_items(&vec!["a"])); - assert!(!has_items::(&vec![])); - } - - #[test] - fn test_validate_text_input() { - // Valid - assert!(validate_text_input("hello", 100).is_ok()); - assert!(validate_text_input("test message", 20).is_ok()); - - // Empty - assert!(validate_text_input("", 100).is_err()); - assert!(validate_text_input(" ", 100).is_err()); - - // Too long - assert!(validate_text_input("very long text", 5).is_err()); - } } diff --git a/tests/config.rs b/tests/config.rs index 27235e3..d8053c6 100644 --- a/tests/config.rs +++ b/tests/config.rs @@ -1,6 +1,6 @@ // Integration tests for config flow -use tele_tui::config::{Config, ColorsConfig, GeneralConfig, Keybindings}; +use tele_tui::config::{Config, ColorsConfig, GeneralConfig, Keybindings, NotificationsConfig}; /// Test: Дефолтные значения конфигурации #[test] @@ -33,6 +33,7 @@ fn test_config_custom_values() { reaction_other: "white".to_string(), }, keybindings: Keybindings::default(), + notifications: NotificationsConfig::default(), }; assert_eq!(config.general.timezone, "+05:00"); @@ -116,6 +117,7 @@ fn test_config_toml_serialization() { reaction_other: "white".to_string(), }, keybindings: Keybindings::default(), + notifications: NotificationsConfig::default(), }; // Сериализуем в TOML diff --git a/tests/helpers/app_builder.rs b/tests/helpers/app_builder.rs index ec0449d..0c8c569 100644 --- a/tests/helpers/app_builder.rs +++ b/tests/helpers/app_builder.rs @@ -175,7 +175,6 @@ impl TestAppBuilder { pub fn forward_mode(mut self, message_id: i64) -> Self { self.chat_state = Some(ChatState::Forward { message_id: MessageId::new(message_id), - selecting_chat: true, }); self } diff --git a/tests/helpers/fake_tdclient_impl.rs b/tests/helpers/fake_tdclient_impl.rs index 83d8b56..05a3f44 100644 --- a/tests/helpers/fake_tdclient_impl.rs +++ b/tests/helpers/fake_tdclient_impl.rs @@ -299,6 +299,11 @@ impl TdClientTrait for FakeTdClient { panic!("user_cache_mut not supported for FakeTdClient") } + // ============ Notification methods ============ + fn sync_notification_muted_chats(&mut self) { + // Not implemented for fake client (notifications are not tested) + } + // ============ Update handling ============ fn handle_update(&mut self, _update: Update) { // Not implemented for fake client