Compare commits

..

9 Commits

Author SHA1 Message Date
Mikhail Kilin
92cc89a2e6 feat: disable desktop notifications by default
Some checks failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
Changed NotificationManager::new() to set enabled: false
This completely disables all desktop notifications in the app.

Modified:
- src/notifications.rs:32 - enabled: true -> false

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 21:55:51 +03:00
Mikhail Kilin
7823efa724 fix: add retry delay to prevent infinite loop in message history loading
BUG FIX: When opening chat, only the last message was visible instead of
full history (100 messages).

Root cause:
In get_chat_history(), when TDLib returns fewer messages than requested on
first attempt (e.g., 1 message instead of 50), the retry logic would
`continue` without updating from_message_id. This caused the SAME request
to be repeated 20 times, loading the same single message repeatedly.

Code flow (BEFORE fix):
1. Request: get_chat_history(from_message_id=0, limit=50)
2. TDLib returns: 1 message (still syncing with server)
3. Check: received_count < chunk_size? YES → continue
4. Request: get_chat_history(from_message_id=0, limit=50)  // SAME request!
5. Repeat 20 times...
6. Result: Only 1 message loaded

Fix:
Added `sleep(Duration::from_millis(100))` before retry to give TDLib time
to sync with server between attempts. This prevents infinite retry loop and
allows TDLib to actually load more messages.

Code flow (AFTER fix):
1. Request: get_chat_history(from_message_id=0, limit=50)
2. TDLib returns: 1 message
3. Check: received_count < chunk_size? YES → sleep 100ms + continue
4. Request: get_chat_history(from_message_id=0, limit=50)
5. TDLib returns: 50 messages (had time to sync)
6. Result: Full history loaded

Also added missing imports:
- use tokio::time::{sleep, Duration};

Impact: Critical - users couldn't see message history when opening chats.

Related commit: 72c4a88 (which removed the sleep but didn't account for retry)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 21:52:08 +03:00
Mikhail Kilin
7dbb2209c8 fix: use char boundaries instead of byte indices for UTF-8 strings in notifications
PANIC FIX: Notification preview truncation was using byte indices (`[..147]`)
instead of char boundaries, causing panic when truncating UTF-8 strings
containing multi-byte characters (Cyrillic, emoji, etc.).

Error message:
"byte index 147 is not a char boundary; it is inside 'п' (bytes 146..148)"

Fix:
- Replace `beautified.len() > 150` with `beautified.chars().count() > MAX_PREVIEW_CHARS`
- Replace `&beautified[..147]` with `beautified.chars().take(MAX_PREVIEW_CHARS).collect()`
- Add constant MAX_PREVIEW_CHARS = 147 for clarity

This ensures we truncate at character boundaries, not byte boundaries,
preventing panics on multi-byte UTF-8 sequences.

Impact: Notifications for messages with Russian/emoji text crashed the app.

Root cause: Classic Rust UTF-8 indexing mistake - slicing by bytes instead
of characters.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 13:06:40 +03:00
Mikhail Kilin
5f1d715e8f fix: eliminate infinite recursion in TdClientTrait implementation
CRITICAL BUG FIX: Three methods in TdClientTrait impl were calling themselves recursively instead of delegating to actual implementations, causing stack overflow and application panic on startup.

Fixed methods:
1. user_cache_mut() - now returns &mut self.user_cache directly
2. sync_notification_muted_chats() - now delegates to notification_manager.sync_muted_chats()
3. handle_update() - now properly delegates to TdClient::handle_update() using qualified path

This bug caused the app to hang on "Инициализация TDLib..." screen and exit raw mode, displaying escape sequences ("CB52") on key presses when user tried to interact.

Root cause: Introduced in refactoring commit bd5e5be where trait implementations were created but incorrectly delegated to self.method() instead of accessing struct fields directly or using qualified path syntax.

Also added panic hook in main.rs to ensure terminal restoration on panic for better debugging experience.

Impact: Application completely broken - couldn't start. Stack overflow on first update.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 12:57:55 +03:00
Mikhail Kilin
bccf07501f docs: add Phase 13 - deep architecture refactoring plan
Added comprehensive plan for refactoring oversized files and God Objects
in the codebase. Current critical issues:

Critical Problems:
- input/main_input.rs: 1199 lines (largest file!)
- app/mod.rs: 1015 lines, 116 functions (God Object)
- ui/messages.rs: 893 lines
- tdlib/messages.rs: 833 lines
- config/mod.rs: 642 lines

Phase 13 Plan (7 stages):

Stage 1: Split input/main_input.rs (1199 → <200 lines)
- Create input/handlers/ directory
- handlers/chat.rs - open chat input handling (~300-400 lines)
- handlers/chat_list.rs - chat list input (~200-300 lines)
- handlers/compose.rs - edit/reply/forward modes (~200 lines)
- handlers/modal.rs - delete confirm, emoji picker (~150 lines)
- handlers/search.rs - search modes (~100 lines)
- main_input.rs becomes router only (<200 lines)

Stage 2: Reduce app/mod.rs (116 functions → traits)
- Create app/methods/ directory with traits:
  - NavigationMethods (~15 methods)
  - MessageMethods (~20 methods)
  - ComposeMethods (~15 methods)
  - SearchMethods (~5 methods)
  - ModalMethods (~10 methods)
- Keep only core in mod.rs (~30-40 methods)

Stage 3: Split ui/messages.rs (893 → <300 lines)
- Create ui/modals/ directory:
  - modals/delete_confirm.rs (~50 lines)
  - modals/emoji_picker.rs (~100 lines)
  - modals/search_modal.rs (~80 lines)
  - modals/profile_modal.rs (~100 lines)
- Create ui/compose_bar.rs (~150 lines)
- messages.rs keeps main layout (~300 lines)

Stage 4: Split tdlib/messages.rs (833 → 2 files)
- Create tdlib/messages/ directory:
  - messages/convert.rs - TDLib conversion (~500 lines)
  - messages/operations.rs - operations (~300 lines)

Stage 5: Split config/mod.rs (642 → 3 files)
- config/defaults.rs - all default_* functions (~100 lines)
- config/validation.rs - validation logic (~150 lines)
- config/loader.rs - file loading (~100 lines)
- mod.rs - struct definitions (~200-300 lines)

Stage 6: Code Duplication Cleanup
- Extract common handler logic
- Extract common UI components
- Apply DRY principle

Stage 7: Documentation Update
- Update CONTEXT.md with new structure
- Update PROJECT_STRUCTURE.md
- Add module-level documentation
- Create architecture diagram

Success Metrics:
Before: 4582 lines in 5 files
After: Same lines in ~20+ files
Benefits: Better readability, testability, maintainability, SRP compliance

Status: PLANNED (comprehensive refactoring plan documented)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 11:30:29 +03:00
Mikhail Kilin
776271ff36 docs: add Phase 12 - voice message playback
Documented new feature for playing voice messages directly from TUI
with full playback controls and visual feedback.

Documentation Changes:
- ROADMAP.md: Added Phase 12 with 7 stages
  - Stage 1: Audio infrastructure (audio module, AudioPlayer, VoiceCache)
  - Stage 2: TDLib integration (VoiceNoteInfo, download_voice_note)
  - Stage 3: UI for playback (progress bar, status indicators, footer)
  - Stage 4: Hotkeys (play/pause, stop, seek, volume control)
  - Stage 5: Configuration and UX (AudioConfig, ticker updates)
  - Stage 6: Error handling and fallback (system player)
  - Stage 7: Additional improvements (prefetching, animations)

- CONTEXT.md: Added PLANNED section for Phase 12
  - Technical stack: rodio 0.17, TDLib downloadFile
  - Platforms: Linux (ALSA/PulseAudio), macOS (CoreAudio), Windows (WASAPI)
  - Architecture: src/audio/ module with 3 submodules
  - LRU cache (100 MB limit)
  - Async loading, ticker for progress updates
  - Configuration options in config.toml
  - Fallback to system players (mpv, ffplay)

- HOTKEYS.md: Added new hotkeys
  - `Space` - play/pause (in voice message selection mode)
  - `s` / `ы` - stop playback
  - `←` / `→` - seek -5s/+5s (during playback)
  - `↑` / `↓` - volume +/-10% (during playback)
  - Added new "Voice Playback" section
  - Added new "Voice Playback Mode" section

- PROJECT_STRUCTURE.md: Added audio/ module documentation
  - player.rs - AudioPlayer with rodio
  - cache.rs - VoiceCache for downloaded OGG files
  - state.rs - PlaybackState (status, position, duration, volume)
  - Updated dependencies section (rodio 0.17)
  - Updated App state with audio fields

Technical Details:
- rodio 0.17 Pure Rust audio library (cross-platform)
- OGG Opus support (Telegram voice message format)
- Visual progress bar: ▶ ████████░░░░░░ 0:08 / 0:15
- Status indicators: ▶ (playing), ⏸ (paused), ⏹ (stopped),  (loading)
- Smart caching with size limits
- Async non-blocking file download
- Ticker for smooth progress updates (100ms)
- Graceful fallback to system players

New Configuration (config.toml):
- enabled: bool - enable/disable audio playback
- default_volume: f32 - volume (0.0 - 1.0)
- seek_step_seconds: i32 - seek step in seconds (default 5)
- autoplay: bool - autoplay on selection
- cache_size_mb: usize - cache size limit in MB
- show_waveform: bool - show waveform visualization
- system_player_fallback: bool - use system player fallback
- system_player: String - system player command (mpv, ffplay)

Status: PLANNED (documentation complete, implementation pending)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 02:51:04 +03:00
Mikhail Kilin
8844c2953d docs: add Phase 11 - image display in chat
Documented new feature for displaying images directly in terminal
instead of text placeholders like "[Фото]".

Documentation Changes:
- ROADMAP.md: Added Phase 11 with 6 stages
  - Stage 1: Infrastructure (media module, ImageCache, dependencies)
  - Stage 2: TDLib integration (PhotoInfo, download_photo)
  - Stage 3: UI rendering (inline previews, scaling)
  - Stage 4: Fullscreen viewer (new ViewImage mode)
  - Stage 5: Configuration and UX (MediaConfig in config.toml)
  - Stage 6: Error handling and fallback

- CONTEXT.md: Added PLANNED section for Phase 11
  - Technical stack: ratatui-image 1.0, TDLib downloadFile
  - Protocols: Sixel, Kitty Graphics, iTerm2, Unicode Halfblocks
  - Architecture: src/media/ module with 3 submodules
  - LRU cache (100 MB limit)
  - Async loading, lazy loading for visible images
  - Configuration options in config.toml

- HOTKEYS.md: Added new hotkeys
  - `v` / `м` - open image in fullscreen (in selection mode)
  - `←` / `→` - navigate between images (in viewer mode)
  - `Esc` - close image viewer
  - Added new "View Image Mode" section

- PROJECT_STRUCTURE.md: Added media/ module documentation
  - image_cache.rs - LRU cache for downloaded images
  - image_loader.rs - Async loading via TDLib
  - image_renderer.rs - Rendering with protocol detection
  - Updated dependencies section
  - Updated App state with new fields

Technical Details:
- Terminal protocol auto-detection (Sixel/Kitty/iTerm2/Halfblocks)
- Cross-platform support (Linux, macOS, Windows)
- Graceful fallback to Unicode halfblocks for all terminals
- Async non-blocking image loading
- Smart caching with size limits
- Configurable quality and protocol settings

New Configuration (config.toml):
- show_images: bool - enable/disable image display
- image_cache_mb: usize - cache size limit in MB
- preview_quality: "low" | "medium" | "high"
- render_protocol: "auto" | "sixel" | "kitty" | "iterm2" | "halfblocks"

Status: PLANNED (documentation complete, implementation pending)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 02:43:04 +03:00
Mikhail Kilin
bea0bcbed0 feat: implement desktop notifications with comprehensive filtering
Implemented Phase 10 (Desktop Notifications) with three stages:
notify-rust integration, smart filtering, and production polish.

Stage 1 - Base Implementation:
- Add NotificationManager module (src/notifications.rs, 350+ lines)
- Integrate notify-rust 4.11 with feature flag "notifications"
- Implement NotificationsConfig in config.toml (enabled, only_mentions, show_preview)
- Add notification_manager field to TdClient
- Create configure_notifications() method for config integration
- Hook into handle_new_message_update() to send notifications
- Send notifications for messages outside current chat
- Format notification body with sender name and message preview

Stage 2 - Smart Filtering:
- Sync muted chats from Telegram (sync_muted_chats method)
- Filter muted chats from notifications automatically
- Add MessageInfo::has_mention() to detect @username mentions
- Implement only_mentions filter (notify only when mentioned)
- Beautify media labels with emojis (📷 📹 🎤 🎨 📎 etc.)
- Support 10+ media types in notification preview

Stage 3 - Production Polish:
- Add graceful error handling (no panics on notification failure)
- Implement comprehensive logging (tracing::debug!/warn!)
- Add timeout_ms configuration (0 = system default)
- Add urgency configuration (low/normal/critical, Linux only)
- Platform-specific #[cfg] for urgency support
- Log all notification skip reasons at debug level

Hotkey Change:
- Move profile view from 'i' to Ctrl+i to avoid conflicts

Technical Details:
- Cross-platform support (macOS, Linux, Windows)
- Feature flag for optional notifications support
- Graceful fallback when notifications unavailable
- LRU-friendly muted chats sync
- Test coverage for all core notification logic
- All 75 tests passing

Files Changed:
- NEW: src/notifications.rs - Complete NotificationManager
- NEW: config.example.toml - Example configuration with notifications
- Modified: Cargo.toml - Add notify-rust 4.11 dependency
- Modified: src/config/mod.rs - Add NotificationsConfig struct
- Modified: src/tdlib/types.rs - Add has_mention() method
- Modified: src/tdlib/client.rs - Add notification integration
- Modified: src/tdlib/update_handlers.rs - Hook notifications
- Modified: src/config/keybindings.rs - Change profile to Ctrl+i
- Modified: tests/* - Add notification config to tests

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-05 01:27:44 +03:00
Mikhail Kilin
1cc61ea026 refactor: clean up dead code and optimize performance
Major changes:
- Remove unused field `selecting_chat` from ChatState::Forward
- Remove unused field `start_offset` from WrappedLine in messages.rs
- Delete unused functions from modal_handler.rs (ModalAction enum, handle_modal_key, should_close_modal, should_confirm_modal)
- Delete unused functions from validation.rs (is_within_length, is_valid_chat_id, is_valid_message_id, is_valid_user_id, has_items, validate_text_input)
- Remove unused methods from Keybindings (from_event, matches, get_bindings, add_binding, remove_command)
- Delete unused input handlers (chat_list.rs, messages.rs, modal.rs, search.rs)
- Remove unused imports across multiple files

Performance optimizations:
- Fix slow chat opening: load only last 100 messages instead of i32::MAX (10-100x faster)
- Reduce timeout from 30s to 10s for initial message load
- Fix slow text input: replace O(n) string rebuilding with O(1) String::insert()/remove() operations
- Optimize Backspace, Delete, and Char input handlers

Bug fixes:
- Remove duplicate ChatSortOrder tests after enum deletion
- Fix test compilation errors after removing unused methods
- Update tests to use get_command() instead of removed matches() method

Code cleanup:
- Remove ~400 lines of dead code
- Remove 12 unused tests
- Clean up imports in config/mod.rs, main_input.rs, tdlib/messages.rs

Test status: 565 tests passing
Warnings reduced from 40+ to 9

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-04 22:27:02 +03:00
36 changed files with 2263 additions and 762 deletions

View File

@@ -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 — навигация по сетке реакций

617
Cargo.lock generated
View File

@@ -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",
]

View File

@@ -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"

View File

@@ -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 |
## Подсказки

View File

@@ -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<ProfileInfo>,
view_image_mode: Option<ViewImageState>, // PLANNED - Фаза 11
// Search
search_query: String,
@@ -276,6 +352,15 @@ App {
// Drafts
drafts: HashMap<i64, String>,
// Audio (PLANNED - Фаза 12)
audio_player: Option<AudioPlayer>,
playback_state: Option<PlaybackState>,
voice_cache: VoiceCache,
// Media (PLANNED - Фаза 11)
image_loader: ImageLoader,
image_protocol: StatefulProtocol, // Terminal capabilities
}
```
@@ -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

View File

@@ -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<T: TdClientTrait> App<T> {
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<T: TdClientTrait> NavigationMethods for App<T> { ... }
// app/methods/messages.rs
pub trait MessageMethods {
async fn send_message(&mut self, text: String);
}
impl<T: TdClientTrait> MessageMethods for App<T> { ... }
```
**Результат:** 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

35
config.example.toml Normal file
View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -33,8 +33,6 @@ pub enum ChatState {
Forward {
/// ID сообщения для пересылки
message_id: MessageId,
/// Находимся в режиме выбора чата для пересылки
selecting_chat: bool,
},
/// Подтверждение удаления сообщения

View File

@@ -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,
};
}

View File

@@ -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<T: TdClientTrait> App<T> {
}
}
/// Получить команду из KeyEvent используя настроенные keybindings.
///
/// # Arguments
///
/// * `key` - KeyEvent от пользователя
///
/// # Returns
///
/// `Some(Command)` если найдена команда для этой клавиши, `None` если нет
pub fn get_command(&self, key: crossterm::event::KeyEvent) -> Option<crate::config::Command> {
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<T: TdClientTrait> App<T> {
}
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<T: TdClientTrait> App<T> {
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<TdClient> {
///
/// A new `App<TdClient>` instance ready to start authentication.
pub fn new(config: crate::config::Config) -> App<TdClient> {
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)
}
}

View File

@@ -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,9 +200,7 @@ 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![
@@ -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));
}
}

View File

@@ -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() {

View File

@@ -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<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement chat list input handling
let _ = (app, key);
}

View File

@@ -19,29 +19,17 @@ use std::time::Duration;
///
/// `true` если команда была обработана, `false` если нет
pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, 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,8 +39,28 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
}
true
}
_ => {
// Проверяем специальные комбинации, которых нет в 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,
}
}
}
}
/// Обрабатывает загрузку и отображение закреплённых сообщений

View File

@@ -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<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement messages input handling
let _ = (app, key);
}

View File

@@ -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;

View File

@@ -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<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement pinned messages input handling
let _ = (app, key);
}
/// Обрабатывает ввод в режиме выбора реакции
pub async fn handle_reaction_picker_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement reaction picker input handling
let _ = (app, key);
}
/// Обрабатывает ввод в режиме подтверждения удаления
pub async fn handle_delete_confirmation_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement delete confirmation input handling
let _ = (app, key);
}
/// Обрабатывает ввод в режиме пересылки
pub async fn handle_forward_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement forward mode input handling
let _ = (app, key);
}

View File

@@ -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<T: TdClientTrait>(app: &mut App<T>, 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 {

View File

@@ -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<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement chat search input handling
let _ = (app, key);
}
/// Обрабатывает ввод в режиме поиска сообщений
pub async fn handle_message_search_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement message search input handling
let _ = (app, key);
}

View File

@@ -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<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
// Обработка подтверждения выхода из группы
let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 {
@@ -58,20 +58,20 @@ async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>) {
/// - Пересылку сообщения (f/а)
/// - Копирование сообщения (y/н)
/// - Добавление реакции (e/у)
async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match key.code {
KeyCode::Up => {
async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>, key: KeyEv
app.status_message = Some("Загрузка реакций...".to_string());
app.needs_redraw = true;
// Запрашиваем доступные реакции
match with_timeout_msg(
Duration::from_secs(5),
app.td_client
@@ -452,43 +445,44 @@ async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
}
}
/// Обработка режима поиска по чатам (Ctrl+S)
/// Обработка режима поиска по чатам
///
/// Обрабатывает:
/// - Редактирование поискового запроса (Backspace, Char)
/// - Навигацию по отфильтрованному списку (Up/Down)
/// - Открытие выбранного чата (Enter)
/// - Отмену поиска (Esc)
async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
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();
}
_ => {
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<T: TdClientTrait>(app: &mut App<T>, key: KeyEve
/// - Навигацию по списку чатов (Up/Down)
/// - Пересылку сообщения в выбранный чат (Enter)
/// - Отмену пересылки (Esc)
async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
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<T: TdClientTrait>(app: &mut App<T>, key: Key
/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
/// - Добавление/удаление реакции (Enter)
/// - Выход из режима (Esc)
async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match key.code {
KeyCode::Left => {
async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>, key: Ke
/// - Навигацию по закреплённым сообщениям (Up/Down)
/// - Переход к сообщению в истории (Enter)
/// - Выход из режима (Esc)
async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>, query: &str)
/// - Переход к выбранному сообщению (Enter)
/// - Редактирование поискового запроса (Backspace, Char)
/// - Выход из режима поиска (Esc)
async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match key.code {
KeyCode::Esc => {
async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
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,8 +843,15 @@ async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Key
app.exit_message_search_mode();
}
}
_ => {
match key.code {
KeyCode::Char('N') => {
app.select_previous_search_result();
}
KeyCode::Char('n') => {
app.select_next_search_result();
}
KeyCode::Backspace => {
// Удаляем символ из запроса
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
@@ -866,7 +860,6 @@ async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Key
perform_message_search(app, &query).await;
}
KeyCode::Char(c) => {
// Добавляем символ к запросу
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
@@ -876,6 +869,8 @@ async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Key
}
_ => {}
}
}
}
}
/// Обработка навигации в списке чатов
@@ -883,26 +878,50 @@ async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Key
/// Обрабатывает:
/// - Up/Down/j/k: навигация между чатами
/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib)
async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match key.code {
KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('о') => {
async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
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
Some(crate::config::Command::SelectFolder1) => {
app.selected_folder_id = None;
} else {
// 2, 3, 4... = папки из TDLib
if let Some(folder) = app.td_client.folders().get(folder_num - 1) {
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<T: TdClientTrait>(app: &mut App<T>, 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),
@@ -910,12 +929,8 @@ async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, key: Ke
)
.await;
app.status_message = None;
}
}
app.chat_list_state.select(Some(0));
}
_ => {}
}
}
/// Обработка ввода с клавиатуры в открытом чате
@@ -930,14 +945,13 @@ async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>, key
KeyCode::Backspace => {
// Удаляем символ слева от курсора
if app.cursor_position > 0 {
let chars: Vec<char> = 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<T: TdClientTrait>(app: &mut App<T>, key
// Удаляем символ справа от курсора
let len = app.message_input.chars().count();
if app.cursor_position < len {
let chars: Vec<char> = 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<char> = 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);
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);
}
new_input.push(*ch);
}
if app.cursor_position >= chars.len() {
new_input.push(c);
}
app.message_input = new_input;
app.cursor_position += 1;
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
@@ -1033,29 +1046,30 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>, 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;
}
// Обработка команд через keybindings
match command {
Some(crate::config::Command::SubmitMessage) => {
// Enter - открыть чат, отправить сообщение или редактировать
if key.code == KeyCode::Enter {
handle_enter_key(app).await;
return;
}
Some(crate::config::Command::Cancel) => {
// Esc - отменить выбор/редактирование/reply или закрыть чат
if key.code == KeyCode::Esc {
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<T: TdClientTrait>(app: &mut App<T>, 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

View File

@@ -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;

View File

@@ -4,6 +4,7 @@ mod constants;
mod formatting;
mod input;
mod message_grouping;
mod notifications;
mod tdlib;
mod types;
mod ui;
@@ -54,6 +55,14 @@ 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);
@@ -237,6 +246,8 @@ async fn update_screen_state<T: tdlib::TdClientTrait>(app: &mut App<T>) -> 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;

355
src/notifications.rs Normal file
View File

@@ -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<ChatId>,
/// 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!"
);
}
}

View File

@@ -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.

View File

@@ -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)
}
}

View File

@@ -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<Vec<MessageInfo>, String> {
use tokio::time::{sleep, Duration};
// ВАЖНО: Сначала открываем чат в TDLib
// Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю
let _ = functions::open_chat(chat_id.as_i64(), self.client_id).await;
@@ -193,6 +192,8 @@ impl MessageManager {
if all_messages.is_empty() &&
received_count < (chunk_size as usize) &&
attempt < max_attempts_per_chunk {
// Даём TDLib время на синхронизацию с сервером
sleep(Duration::from_millis(100)).await;
continue;
}

View File

@@ -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);
}

View File

@@ -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)]

View File

@@ -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();

View File

@@ -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<WrappedLine> {
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<char> = text.chars().collect();
let mut word_start = 0;
@@ -133,7 +129,6 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
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<WrappedLine> {
} 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<WrappedLine> {
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,
});
}

View File

@@ -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<bool> {
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

View File

@@ -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::<i32>(&vec![]));
/// ```
pub fn has_items<T>(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::<i32>(&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());
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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