Merge pull request 'refactor' (#19) from refactor into main
Some checks failed
CI / Check (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / Build (windows-latest) (push) Has been cancelled

Reviewed-on: #19
This commit is contained in:
2026-02-05 18:58:39 +00:00
36 changed files with 2263 additions and 762 deletions

View File

@@ -4,6 +4,155 @@
### Последние изменения (2026-02-04) ### Последние изменения (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: Зависание при открытии чатов с большой историей** **🐛 FIX: Зависание при открытии чатов с большой историей**
- **Проблема**: При использовании `i32::MAX` как лимита загрузки истории, приложение зависало в чатах с тысячами сообщений (например, на итерации #96 было загружено 4750+ сообщений и загрузка продолжалась) - **Проблема**: При использовании `i32::MAX` как лимита загрузки истории, приложение зависало в чатах с тысячами сообщений (например, на итерации #96 было загружено 4750+ сообщений и загрузка продолжалась)
- **Решение**: Заменён лимит с `i32::MAX` на разумные 300 сообщений при открытии чата - **Решение**: Заменён лимит с `i32::MAX` на разумные 300 сообщений при открытии чата
@@ -25,7 +174,7 @@
- Сериализация/десериализация для загрузки из конфига - Сериализация/десериализация для загрузки из конфига
- Метод `get_command()` для определения команды по KeyEvent - Метод `get_command()` для определения команды по KeyEvent
- **Тесты**: 4 unit теста (все проходят) - **Тесты**: 4 unit теста (все проходят)
- **Статус**: Готово к интеграции (требуется замена HotkeysConfig) - **Статус**: ✅ Интегрировано в Config и main_input.rs
**🎯 NEW: KeyHandler trait для обработки клавиш** **🎯 NEW: KeyHandler trait для обработки клавиш**
- **Модуль**: `src/input/key_handler.rs` (380+ строк) - **Модуль**: `src/input/key_handler.rs` (380+ строк)
@@ -81,7 +230,7 @@
- Builder pattern для удобного конструирования - Builder pattern для удобного конструирования
- Эффективность (работает с references, без клонирования) - Эффективность (работает с references, без клонирования)
- **Тесты**: 6 unit тестов (все проходят) - **Тесты**: 6 unit тестов (все проходят)
- **Статус**: Готово к интеграции (TODO: заменить дублирующуюся логику в App/UI) - **Статус**: ✅ Интегрировано в App и ChatListState
### Что сделано ### Что сделано
@@ -194,7 +343,7 @@
- `1-9` — переключение папок (в списке чатов) - `1-9` — переключение папок (в списке чатов)
- `Ctrl+F` — поиск по сообщениям в открытом чате - `Ctrl+F` — поиск по сообщениям в открытом чате
- `n` / `N` — навигация по результатам поиска (следующий/предыдущий) - `n` / `N` — навигация по результатам поиска (следующий/предыдущий)
- `i` — открыть профиль пользователя/чата - `Ctrl+i` / `Ctrl+ш` — открыть профиль пользователя/чата
- `y` / `н` в режиме выбора — скопировать сообщение в буфер обмена - `y` / `н` в режиме выбора — скопировать сообщение в буфер обмена
- `e` / `у` в режиме выбора — добавить реакцию (открывает emoji picker) - `e` / `у` в режиме выбора — добавить реакцию (открывает emoji picker)
- `←` / `→` / `↑` / `↓` в emoji picker — навигация по сетке реакций - `←` / `→` / `↑` / `↓` в emoji picker — навигация по сетке реакций

617
Cargo.lock generated
View File

@@ -84,6 +84,126 @@ dependencies = [
"x11rb", "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]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.89" version = "0.1.89"
@@ -128,6 +248,28 @@ dependencies = [
"generic-array", "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]] [[package]]
name = "bumpalo" name = "bumpalo"
version = "3.19.1" version = "3.19.1"
@@ -227,7 +369,7 @@ dependencies = [
"num-traits", "num-traits",
"serde", "serde",
"wasm-bindgen", "wasm-bindgen",
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -315,6 +457,15 @@ dependencies = [
"static_assertions", "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]] [[package]]
name = "console" name = "console"
version = "0.15.11" version = "0.15.11"
@@ -687,6 +838,33 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "equivalent" name = "equivalent"
version = "1.0.2" version = "1.0.2"
@@ -709,6 +887,27 @@ version = "3.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" 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]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.3.0" version = "2.3.0"
@@ -818,6 +1017,19 @@ version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" 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]] [[package]]
name = "futures-sink" name = "futures-sink"
version = "0.3.31" version = "0.3.31"
@@ -863,7 +1075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
dependencies = [ dependencies = [
"rustix 1.1.3", "rustix 1.1.3",
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -1102,7 +1314,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core", "windows-core 0.62.2",
] ]
[[package]] [[package]]
@@ -1477,6 +1689,18 @@ dependencies = [
"pkg-config", "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]] [[package]]
name = "matchers" name = "matchers"
version = "0.2.0" version = "0.2.0"
@@ -1492,6 +1716,15 @@ version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "memoffset"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
dependencies = [
"autocfg",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.17" version = "0.3.17"
@@ -1547,6 +1780,20 @@ dependencies = [
"tempfile", "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]] [[package]]
name = "nu-ansi-term" name = "nu-ansi-term"
version = "0.50.3" version = "0.50.3"
@@ -1629,6 +1876,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [ dependencies = [
"bitflags", "bitflags",
"block2",
"libc",
"objc2", "objc2",
"objc2-core-foundation", "objc2-core-foundation",
] ]
@@ -1717,6 +1966,22 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" 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]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -1737,7 +2002,7 @@ dependencies = [
"libc", "libc",
"redox_syscall", "redox_syscall",
"smallvec", "smallvec",
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -1780,6 +2045,17 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" 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]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
@@ -1827,6 +2103,20 @@ dependencies = [
"miniz_oxide", "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]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@@ -1842,6 +2132,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 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]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@@ -1866,6 +2165,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" 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]] [[package]]
name = "quote" name = "quote"
version = "1.0.44" version = "1.0.44"
@@ -2243,6 +2551,17 @@ dependencies = [
"zmij", "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]] [[package]]
name = "serde_spanned" name = "serde_spanned"
version = "0.6.9" version = "0.6.9"
@@ -2484,6 +2803,18 @@ dependencies = [
"libc", "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]] [[package]]
name = "tdlib-rs" name = "tdlib-rs"
version = "1.2.0" version = "1.2.0"
@@ -2530,6 +2861,7 @@ dependencies = [
"dirs 5.0.1", "dirs 5.0.1",
"dotenvy", "dotenvy",
"insta", "insta",
"notify-rust",
"open", "open",
"ratatui", "ratatui",
"serde", "serde",
@@ -2761,8 +3093,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
dependencies = [ dependencies = [
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime 0.6.11",
"toml_edit", "toml_edit 0.22.27",
] ]
[[package]] [[package]]
@@ -2774,6 +3106,15 @@ dependencies = [
"serde", "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]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.22.27" version = "0.22.27"
@@ -2783,11 +3124,32 @@ dependencies = [
"indexmap 2.13.0", "indexmap 2.13.0",
"serde", "serde",
"serde_spanned", "serde_spanned",
"toml_datetime", "toml_datetime 0.6.11",
"toml_write", "toml_write",
"winnow", "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]] [[package]]
name = "toml_write" name = "toml_write"
version = "0.1.2" version = "0.1.2"
@@ -2912,6 +3274,17 @@ version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" 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]] [[package]]
name = "unicode-ident" name = "unicode-ident"
version = "1.0.22" version = "1.0.22"
@@ -2971,6 +3344,17 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" 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]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"
@@ -3129,6 +3513,41 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 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]] [[package]]
name = "windows-core" name = "windows-core"
version = "0.62.2" version = "0.62.2"
@@ -3137,9 +3556,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [ dependencies = [
"windows-implement", "windows-implement",
"windows-interface", "windows-interface",
"windows-link", "windows-link 0.2.1",
"windows-result", "windows-result 0.4.1",
"windows-strings", "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]] [[package]]
@@ -3164,21 +3594,46 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "windows-link"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
[[package]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 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]] [[package]]
name = "windows-registry" name = "windows-registry"
version = "0.6.1" version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
"windows-result", "windows-result 0.4.1",
"windows-strings", "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]] [[package]]
@@ -3187,7 +3642,16 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [ 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]] [[package]]
@@ -3196,7 +3660,7 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -3241,7 +3705,7 @@ version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
] ]
[[package]] [[package]]
@@ -3281,7 +3745,7 @@ version = "0.53.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
dependencies = [ dependencies = [
"windows-link", "windows-link 0.2.1",
"windows_aarch64_gnullvm 0.53.1", "windows_aarch64_gnullvm 0.53.1",
"windows_aarch64_msvc 0.53.1", "windows_aarch64_msvc 0.53.1",
"windows_i686_gnu 0.53.1", "windows_i686_gnu 0.53.1",
@@ -3292,6 +3756,24 @@ dependencies = [
"windows_x86_64_msvc 0.53.1", "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]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.48.5" version = "0.48.5"
@@ -3500,6 +3982,67 @@ dependencies = [
"synstructure", "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]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.34" version = "0.8.34"
@@ -3684,3 +4227,43 @@ checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
dependencies = [ dependencies = [
"zune-core", "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"] categories = ["command-line-utilities"]
[features] [features]
default = ["clipboard", "url-open"] default = ["clipboard", "url-open", "notifications"]
clipboard = ["dep:arboard"] clipboard = ["dep:arboard"]
url-open = ["dep:open"] url-open = ["dep:open"]
notifications = ["dep:notify-rust"]
[dependencies] [dependencies]
ratatui = "0.29" ratatui = "0.29"
@@ -26,6 +27,7 @@ dotenvy = "0.15"
chrono = "0.4" chrono = "0.4"
open = { version = "5.0", optional = true } open = { version = "5.0", optional = true }
arboard = { version = "3.4", optional = true } arboard = { version = "3.4", optional = true }
notify-rust = { version = "4.11", optional = true }
toml = "0.8" toml = "0.8"
dirs = "5.0" dirs = "5.0"
thiserror = "1.0" thiserror = "1.0"

View File

@@ -41,7 +41,43 @@
| `d` / `Delete` | `в` | Удалить сообщение | | `d` / `Delete` | `в` | Удалить сообщение |
| `y` | `н` | Копировать текст в буфер обмена | | `y` | `н` | Копировать текст в буфер обмена |
| `e` | `у` | Добавить реакцию (Emoji picker) | | `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` - Удалить: `d` / `в` / `Delete`
- Копировать: `y` / `н` - Копировать: `y` / `н`
- Реакция: `e` / `у` - Реакция: `e` / `у`
- Просмотр изображения: `v` / `м` (если выбрано сообщение с фото)
- Воспроизведение голосового: `Space` (если выбрано голосовое сообщение)
- Отменить: `Esc` - Отменить: `Esc`
### Режим редактирования ### Режим редактирования
@@ -120,6 +158,16 @@
- Переслать: `Enter` - Переслать: `Enter`
- Отменить: `Esc` - Отменить: `Esc`
### Режим просмотра изображения
- Навигация: `←/→` (предыдущее/следующее изображение)
- Закрыть: `Esc`
### Режим воспроизведения голосового
- Пауза/Возобновить: `Space`
- Остановить: `s` / `ы`
- Перемотка: `←/→` (-5с / +5с)
- Громкость: `↑/↓` (+/- 10%)
## Поддержка русской раскладки ## Поддержка русской раскладки
Все основные vim-клавиши поддерживают русскую раскладку: Все основные vim-клавиши поддерживают русскую раскладку:
@@ -135,6 +183,8 @@
| `d` | `в` | Delete | | `d` | `в` | Delete |
| `y` | `н` | Copy (Yank) | | `y` | `н` | Copy (Yank) |
| `e` | `у` | Emoji reaction | | `e` | `у` | Emoji reaction |
| `v` | `м` | View image |
| `s` | `ы` | Stop audio |
## Подсказки ## Подсказки

View File

@@ -23,6 +23,17 @@ tele-tui/
│ │ ├── mod.rs │ │ ├── mod.rs
│ │ ├── auth.rs │ │ ├── auth.rs
│ │ └── main_input.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 интеграция │ ├── tdlib/ # TDLib интеграция
│ │ ├── mod.rs │ │ ├── mod.rs
│ │ └── client.rs │ │ └── client.rs
@@ -102,6 +113,70 @@ tele-tui/
#### state.rs #### state.rs
- `AppScreen` enum — текущий экран (Loading, Auth, Main) - `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 интеграция ### tdlib/ — Telegram интеграция
#### client.rs #### client.rs
@@ -269,6 +344,7 @@ App {
is_delete_confirmation: bool, is_delete_confirmation: bool,
is_reaction_picker_mode: bool, is_reaction_picker_mode: bool,
profile_info: Option<ProfileInfo>, profile_info: Option<ProfileInfo>,
view_image_mode: Option<ViewImageState>, // PLANNED - Фаза 11
// Search // Search
search_query: String, search_query: String,
@@ -276,6 +352,15 @@ App {
// Drafts // Drafts
drafts: HashMap<i64, String>, 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 ### UI
- `ratatui` 0.29 — TUI framework - `ratatui` 0.29 — TUI framework
- `crossterm` 0.28 — terminal control - `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 ### Telegram
- `tdlib-rs` 1.1 — TDLib bindings - `tdlib-rs` 1.1 — TDLib bindings

View File

@@ -129,7 +129,7 @@
- Индикатор черновика в списке чатов - Индикатор черновика в списке чатов
- Восстановление текста при возврате в чат - Восстановление текста при возврате в чат
- [x] Профиль пользователя/чата - [x] Профиль пользователя/чата
- `i` — открыть информацию о чате/собеседнике - `Ctrl+i` — открыть информацию о чате/собеседнике
- Для личных чатов: имя, username, телефон, био - Для личных чатов: имя, username, телефон, био
- Для групп: название, описание, количество участников - Для групп: название, описание, количество участников
- [x] Копирование сообщений - [x] Копирование сообщений
@@ -143,3 +143,535 @@
- `~/.config/tele-tui/config.toml` - `~/.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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -379,32 +331,4 @@ mod tests {
assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2 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 { Forward {
/// ID сообщения для пересылки /// ID сообщения для пересылки
message_id: MessageId, message_id: MessageId,
/// Находимся в режиме выбора чата для пересылки
selecting_chat: bool,
}, },
/// Подтверждение удаления сообщения /// Подтверждение удаления сообщения

View File

@@ -185,7 +185,6 @@ impl MessageViewState {
pub fn start_forward(&mut self, message_id: MessageId) { pub fn start_forward(&mut self, message_id: MessageId) {
self.chat_state = ChatState::Forward { self.chat_state = ChatState::Forward {
message_id, message_id,
selecting_chat: true,
}; };
} }

View File

@@ -1,6 +1,8 @@
mod chat_filter;
mod chat_state; mod chat_state;
mod state; mod state;
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
pub use chat_state::ChatState; pub use chat_state::ChatState;
pub use state::AppScreen; 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) { pub fn next_chat(&mut self) {
let filtered = self.get_filtered_chats(); let filtered = self.get_filtered_chats();
if filtered.is_empty() { if filtered.is_empty() {
@@ -297,31 +312,15 @@ impl<T: TdClientTrait> App<T> {
} }
pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> { pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
let folder_filtered: Vec<&ChatInfo> = match self.selected_folder_id { // Используем ChatFilter для централизованной фильтрации
None => self.chats.iter().collect(), // All - показываем все let mut criteria = ChatFilterCriteria::new()
Some(folder_id) => self .with_folder(self.selected_folder_id);
.chats
.iter()
.filter(|c| c.folder_ids.contains(&folder_id))
.collect(),
};
if self.search_query.is_empty() { if !self.search_query.is_empty() {
folder_filtered criteria = criteria.with_search(self.search_query.clone());
} 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()
} }
ChatFilter::filter(&self.chats, &criteria)
} }
pub fn next_filtered_chat(&mut self) { pub fn next_filtered_chat(&mut self) {
@@ -412,7 +411,6 @@ impl<T: TdClientTrait> App<T> {
if let Some(msg) = self.get_selected_message() { if let Some(msg) = self.get_selected_message() {
self.chat_state = ChatState::Forward { self.chat_state = ChatState::Forward {
message_id: msg.id(), message_id: msg.id(),
selecting_chat: true,
}; };
// Сбрасываем выбор чата на первый // Сбрасываем выбор чата на первый
self.chat_list_state.select(Some(0)); self.chat_list_state.select(Some(0));
@@ -1009,6 +1007,9 @@ impl App<TdClient> {
/// ///
/// A new `App<TdClient>` instance ready to start authentication. /// A new `App<TdClient>` instance ready to start authentication.
pub fn new(config: crate::config::Config) -> App<TdClient> { 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 { pub fn matches(&self, event: &KeyEvent) -> bool {
self.key == event.code && self.modifiers == event.modifiers self.key == event.code && self.modifiers == event.modifiers
} }
@@ -163,9 +156,7 @@ impl Keybindings {
]); ]);
// Chat list // Chat list
bindings.insert(Command::OpenChat, vec![ // Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key()
KeyBinding::new(KeyCode::Enter),
]);
for i in 1..=9 { for i in 1..=9 {
let cmd = match i { let cmd = match i {
1 => Command::SelectFolder1, 1 => Command::SelectFolder1,
@@ -185,9 +176,9 @@ impl Keybindings {
} }
// Message actions // Message actions
bindings.insert(Command::EditMessage, vec![ // Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input
KeyBinding::new(KeyCode::Up), // в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не
]); // конфликтовать с Command::MoveUp в списке чатов.
bindings.insert(Command::DeleteMessage, vec![ bindings.insert(Command::DeleteMessage, vec![
KeyBinding::new(KeyCode::Delete), KeyBinding::new(KeyCode::Delete),
KeyBinding::new(KeyCode::Char('d')), KeyBinding::new(KeyCode::Char('d')),
@@ -209,9 +200,7 @@ impl Keybindings {
KeyBinding::new(KeyCode::Char('e')), KeyBinding::new(KeyCode::Char('e')),
KeyBinding::new(KeyCode::Char('у')), // RU KeyBinding::new(KeyCode::Char('у')), // RU
]); ]);
bindings.insert(Command::SelectMessage, vec![ // Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key()
KeyBinding::new(KeyCode::Enter),
]);
// Input // Input
bindings.insert(Command::SubmitMessage, vec![ bindings.insert(Command::SubmitMessage, vec![
@@ -241,8 +230,8 @@ impl Keybindings {
// Profile // Profile
bindings.insert(Command::OpenProfile, vec![ bindings.insert(Command::OpenProfile, vec![
KeyBinding::new(KeyCode::Char('i')), KeyBinding::with_ctrl(KeyCode::Char('i')),
KeyBinding::new(KeyCode::Char('ш')), // RU KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU
]); ]);
Self { bindings } Self { bindings }
@@ -257,32 +246,6 @@ impl Keybindings {
} }
None 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 { impl Default for Keybindings {
@@ -434,9 +397,9 @@ mod tests {
let kb = Keybindings::default(); let kb = Keybindings::default();
// Проверяем навигацию // Проверяем навигацию
assert!(kb.matches(&KeyEvent::from(KeyCode::Up), Command::MoveUp)); assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Up)), Some(Command::MoveUp));
assert!(kb.matches(&KeyEvent::from(KeyCode::Char('k')), Command::MoveUp)); assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('k'))), Some(Command::MoveUp));
assert!(kb.matches(&KeyEvent::from(KeyCode::Char('р')), Command::MoveUp)); assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveUp));
} }
#[test] #[test]
@@ -459,14 +422,4 @@ mod tests {
assert_eq!(kb.get_command(&event), Some(Command::OpenSearch)); 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; pub mod keybindings;
use crossterm::event::KeyCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
pub use keybindings::{Command, KeyBinding, Keybindings}; pub use keybindings::{Command, Keybindings};
/// Главная конфигурация приложения. /// Главная конфигурация приложения.
/// ///
@@ -35,6 +34,10 @@ pub struct Config {
/// Горячие клавиши. /// Горячие клавиши.
#[serde(default)] #[serde(default)]
pub keybindings: Keybindings, pub keybindings: Keybindings,
/// Настройки desktop notifications.
#[serde(default)]
pub notifications: NotificationsConfig,
} }
/// Общие настройки приложения. /// Общие настройки приложения.
@@ -72,6 +75,31 @@ pub struct ColorsConfig {
pub reaction_other: String, 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 { fn default_timezone() -> String {
"+03:00".to_string() "+03:00".to_string()
@@ -97,6 +125,22 @@ fn default_reaction_other_color() -> String {
"gray".to_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 { impl Default for GeneralConfig {
fn default() -> Self { fn default() -> Self {
Self { timezone: default_timezone() } 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 { impl Default for Config {
fn default() -> Self { fn default() -> Self {
@@ -122,6 +177,7 @@ impl Default for Config {
general: GeneralConfig::default(), general: GeneralConfig::default(),
colors: ColorsConfig::default(), colors: ColorsConfig::default(),
keybindings: Keybindings::default(), keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(),
} }
} }
} }
@@ -347,8 +403,6 @@ impl Config {
/// API_HASH=your_api_hash_here /// API_HASH=your_api_hash_here
/// ``` /// ```
pub fn load_credentials() -> Result<(i32, String), String> { pub fn load_credentials() -> Result<(i32, String), String> {
use std::env;
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials // 1. Пробуем загрузить из ~/.config/tele-tui/credentials
if let Some(credentials) = Self::load_credentials_from_file() { if let Some(credentials) = Self::load_credentials_from_file() {
return Ok(credentials); return Ok(credentials);
@@ -423,7 +477,7 @@ impl Config {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crossterm::event::{KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
#[test] #[test]
fn test_config_default_includes_keybindings() { 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` если нет /// `true` если команда была обработана, `false` если нет
pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) -> bool { 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 { match command {
KeyCode::Char('r') if has_ctrl => { Some(crate::config::Command::OpenSearch) => {
// 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 => {
// Ctrl+S - начать поиск (только если чат не открыт) // Ctrl+S - начать поиск (только если чат не открыт)
if app.selected_chat_id.is_none() { if app.selected_chat_id.is_none() {
app.start_search(); app.start_search();
} }
true true
} }
KeyCode::Char('p') if has_ctrl => { Some(crate::config::Command::OpenSearchInChat) => {
// Ctrl+P - режим просмотра закреплённых сообщений
handle_pinned_messages(app).await;
true
}
KeyCode::Char('f') if has_ctrl => {
// Ctrl+F - поиск по сообщениям в открытом чате // Ctrl+F - поиск по сообщениям в открытом чате
if app.selected_chat_id.is_some() if app.selected_chat_id.is_some()
&& !app.is_pinned_mode() && !app.is_pinned_mode()
@@ -51,7 +39,27 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
} }
true true
} }
_ => false, _ => {
// Проверяем специальные комбинации, которых нет в Command enum
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('r') if has_ctrl => {
// Ctrl+R - обновить список чатов
app.status_message = Some("Обновление чатов...".to_string());
let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
// Синхронизируем muted чаты после обновления
app.td_client.sync_notification_muted_chats();
app.status_message = None;
true
}
KeyCode::Char('p') if has_ctrl => {
// Ctrl+P - режим просмотра закреплённых сообщений
handle_pinned_messages(app).await;
true
}
_ => false,
}
}
} }
} }

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: //! This module contains handlers for different input contexts:
//! - global: Global commands (Ctrl+R, Ctrl+S, etc.) //! - 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 //! - clipboard: Clipboard operations
//! - profile: Profile helper functions
pub mod chat_list;
pub mod clipboard; pub mod clipboard;
pub mod global; pub mod global;
pub mod messages;
pub mod modal;
pub mod profile; pub mod profile;
pub mod search;
// pub use chat_list::*; // Пока не используется
pub use clipboard::*; pub use clipboard::*;
pub use global::*; pub use global::*;
// pub use messages::*; // Пока не используется pub use profile::get_available_actions_count;
// pub use modal::*; // Пока не используется
pub use profile::get_available_actions_count; // Используется в main_input
// pub use search::*; // Пока не используется

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 //! Profile mode helper functions
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);
}
/// Возвращает количество доступных действий в профиле /// Возвращает количество доступных действий в профиле
pub fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize { 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::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg, with_timeout_ignore}; use crate::utils::{is_non_empty, with_timeout, with_timeout_msg, with_timeout_ignore};
use crate::utils::modal_handler::handle_yes_no; use crate::utils::modal_handler::handle_yes_no;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
/// Обработка режима профиля пользователя/чата /// Обработка режима профиля пользователя/чата
@@ -18,7 +18,7 @@ use std::time::{Duration, Instant};
/// - Навигацию по действиям профиля (Up/Down) /// - Навигацию по действиям профиля (Up/Down)
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу /// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
/// - Выход из режима профиля (Esc) /// - Выход из режима профиля (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(); let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 { if confirmation_step > 0 {
@@ -58,20 +58,20 @@ async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent)
} }
// Обычная навигация по профилю // Обычная навигация по профилю
match key.code { match command {
KeyCode::Esc => { Some(crate::config::Command::Cancel) => {
app.exit_profile_mode(); app.exit_profile_mode();
} }
KeyCode::Up => { Some(crate::config::Command::MoveUp) => {
app.select_previous_profile_action(); app.select_previous_profile_action();
} }
KeyCode::Down => { Some(crate::config::Command::MoveDown) => {
if let Some(profile) = app.get_profile_info() { if let Some(profile) = app.get_profile_info() {
let max_actions = get_available_actions_count(profile); let max_actions = get_available_actions_count(profile);
app.select_next_profile_action(max_actions); app.select_next_profile_action(max_actions);
} }
} }
KeyCode::Enter => { Some(crate::config::Command::SubmitMessage) => {
// Выполнить выбранное действие // Выполнить выбранное действие
let Some(profile) = app.get_profile_info() else { let Some(profile) = app.get_profile_info() else {
return; return;
@@ -170,17 +170,15 @@ async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
/// - Пересылку сообщения (f/а) /// - Пересылку сообщения (f/а)
/// - Копирование сообщения (y/н) /// - Копирование сообщения (y/н)
/// - Добавление реакции (e/у) /// - Добавление реакции (e/у)
async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) { async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
match key.code { match command {
KeyCode::Up => { Some(crate::config::Command::MoveUp) => {
app.select_previous_message(); app.select_previous_message();
} }
KeyCode::Down => { Some(crate::config::Command::MoveDown) => {
app.select_next_message(); 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 { let Some(msg) = app.get_selected_message() else {
return; 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(); app.start_reply_to_selected();
} }
KeyCode::Char('f') | KeyCode::Char('а') => { Some(crate::config::Command::ForwardMessage) => {
// Начать режим пересылки
app.start_forward_selected(); app.start_forward_selected();
} }
KeyCode::Char('y') | KeyCode::Char('н') => { Some(crate::config::Command::CopyMessage) => {
// Копировать сообщение
let Some(msg) = app.get_selected_message() else { let Some(msg) = app.get_selected_message() else {
return; return;
}; };
@@ -215,8 +210,7 @@ async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, key: KeyEv
} }
} }
} }
KeyCode::Char('e') | KeyCode::Char('у') => { Some(crate::config::Command::ReactMessage) => {
// Открыть emoji picker для добавления реакции
let Some(msg) = app.get_selected_message() else { let Some(msg) = app.get_selected_message() else {
return; 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.status_message = Some("Загрузка реакций...".to_string());
app.needs_redraw = true; app.needs_redraw = true;
// Запрашиваем доступные реакции
match with_timeout_msg( match with_timeout_msg(
Duration::from_secs(5), Duration::from_secs(5),
app.td_client app.td_client
@@ -452,42 +445,43 @@ async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
} }
} }
/// Обработка режима поиска по чатам (Ctrl+S) /// Обработка режима поиска по чатам
/// ///
/// Обрабатывает: /// Обрабатывает:
/// - Редактирование поискового запроса (Backspace, Char) /// - Редактирование поискового запроса (Backspace, Char)
/// - Навигацию по отфильтрованному списку (Up/Down) /// - Навигацию по отфильтрованному списку (Up/Down)
/// - Открытие выбранного чата (Enter) /// - Открытие выбранного чата (Enter)
/// - Отмену поиска (Esc) /// - Отмену поиска (Esc)
async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) { async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
match key.code { match command {
KeyCode::Esc => { Some(crate::config::Command::Cancel) => {
app.cancel_search(); app.cancel_search();
} }
KeyCode::Enter => { Some(crate::config::Command::SubmitMessage) => {
// Выбрать чат из отфильтрованного списка
app.select_filtered_chat(); app.select_filtered_chat();
if let Some(chat_id) = app.get_selected_chat_id() { if let Some(chat_id) = app.get_selected_chat_id() {
open_chat_and_load_data(app, chat_id).await; open_chat_and_load_data(app, chat_id).await;
} }
} }
KeyCode::Backspace => { Some(crate::config::Command::MoveDown) => {
app.search_query.pop();
// Сбрасываем выделение при изменении запроса
app.chat_list_state.select(Some(0));
}
KeyCode::Down => {
app.next_filtered_chat(); app.next_filtered_chat();
} }
KeyCode::Up => { Some(crate::config::Command::MoveUp) => {
app.previous_filtered_chat(); app.previous_filtered_chat();
} }
KeyCode::Char(c) => { _ => {
app.search_query.push(c); match key.code {
// Сбрасываем выделение при изменении запроса KeyCode::Backspace => {
app.chat_list_state.select(Some(0)); 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) /// - Навигацию по списку чатов (Up/Down)
/// - Пересылку сообщения в выбранный чат (Enter) /// - Пересылку сообщения в выбранный чат (Enter)
/// - Отмену пересылки (Esc) /// - Отмену пересылки (Esc)
async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) { async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
match key.code { match command {
KeyCode::Esc => { Some(crate::config::Command::Cancel) => {
app.cancel_forward(); app.cancel_forward();
} }
KeyCode::Enter => { Some(crate::config::Command::SubmitMessage) => {
forward_selected_message(app).await; forward_selected_message(app).await;
app.cancel_forward(); app.cancel_forward();
} }
KeyCode::Down => { Some(crate::config::Command::MoveDown) => {
app.next_chat(); app.next_chat();
} }
KeyCode::Up => { Some(crate::config::Command::MoveUp) => {
app.previous_chat(); 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) /// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
/// - Добавление/удаление реакции (Enter) /// - Добавление/удаление реакции (Enter)
/// - Выход из режима (Esc) /// - Выход из режима (Esc)
async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) { async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
match key.code { match command {
KeyCode::Left => { Some(crate::config::Command::MoveLeft) => {
app.select_previous_reaction(); app.select_previous_reaction();
app.needs_redraw = true; app.needs_redraw = true;
} }
KeyCode::Right => { Some(crate::config::Command::MoveRight) => {
app.select_next_reaction(); app.select_next_reaction();
app.needs_redraw = true; app.needs_redraw = true;
} }
KeyCode::Up => { Some(crate::config::Command::MoveUp) => {
// Переход на ряд выше (8 эмодзи в ряду)
if let crate::app::ChatState::ReactionPicker { if let crate::app::ChatState::ReactionPicker {
selected_index, selected_index,
.. ..
@@ -733,8 +726,7 @@ async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
} }
} }
} }
KeyCode::Down => { Some(crate::config::Command::MoveDown) => {
// Переход на ряд ниже (8 эмодзи в ряду)
if let crate::app::ChatState::ReactionPicker { if let crate::app::ChatState::ReactionPicker {
selected_index, selected_index,
available_reactions, 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; send_reaction(app).await;
} }
KeyCode::Esc => { Some(crate::config::Command::Cancel) => {
app.exit_reaction_picker_mode(); app.exit_reaction_picker_mode();
app.needs_redraw = true; app.needs_redraw = true;
} }
@@ -766,22 +757,20 @@ async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
/// - Навигацию по закреплённым сообщениям (Up/Down) /// - Навигацию по закреплённым сообщениям (Up/Down)
/// - Переход к сообщению в истории (Enter) /// - Переход к сообщению в истории (Enter)
/// - Выход из режима (Esc) /// - Выход из режима (Esc)
async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) { async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
match key.code { match command {
KeyCode::Esc => { Some(crate::config::Command::Cancel) => {
app.exit_pinned_mode(); app.exit_pinned_mode();
} }
KeyCode::Up => { Some(crate::config::Command::MoveUp) => {
app.select_previous_pinned(); app.select_previous_pinned();
} }
KeyCode::Down => { Some(crate::config::Command::MoveDown) => {
app.select_next_pinned(); app.select_next_pinned();
} }
KeyCode::Enter => { Some(crate::config::Command::SubmitMessage) => {
// Перейти к сообщению в истории
if let Some(msg_id) = app.get_selected_pinned_id() { if let Some(msg_id) = app.get_selected_pinned_id() {
let msg_id = MessageId::new(msg_id); let msg_id = MessageId::new(msg_id);
// Ищем индекс сообщения в текущей истории
let msg_index = app let msg_index = app
.td_client .td_client
.current_chat_messages() .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); .position(|m| m.id() == msg_id);
if let Some(idx) = msg_index { if let Some(idx) = msg_index {
// Вычисляем scroll offset чтобы показать сообщение
let total = app.td_client.current_chat_messages().len(); let total = app.td_client.current_chat_messages().len();
app.message_scroll_offset = total.saturating_sub(idx + 5); 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) /// - Переход к выбранному сообщению (Enter)
/// - Редактирование поискового запроса (Backspace, Char) /// - Редактирование поискового запроса (Backspace, Char)
/// - Выход из режима поиска (Esc) /// - Выход из режима поиска (Esc)
async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) { async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
match key.code { match command {
KeyCode::Esc => { Some(crate::config::Command::Cancel) => {
app.exit_message_search_mode(); app.exit_message_search_mode();
} }
KeyCode::Up | KeyCode::Char('N') => { Some(crate::config::Command::MoveUp) => {
app.select_previous_search_result(); app.select_previous_search_result();
} }
KeyCode::Down | KeyCode::Char('n') => { Some(crate::config::Command::MoveDown) => {
app.select_next_search_result(); app.select_next_search_result();
} }
KeyCode::Enter => { Some(crate::config::Command::SubmitMessage) => {
// Перейти к выбранному сообщению
if let Some(msg_id) = app.get_selected_search_result_id() { if let Some(msg_id) = app.get_selected_search_result_id() {
let msg_id = MessageId::new(msg_id); let msg_id = MessageId::new(msg_id);
let msg_index = app let msg_index = app
@@ -856,25 +843,33 @@ async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Key
app.exit_message_search_mode(); app.exit_message_search_mode();
} }
} }
KeyCode::Backspace => { _ => {
// Удаляем символ из запроса match key.code {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else { KeyCode::Char('N') => {
return; app.select_previous_search_result();
}; }
query.pop(); KeyCode::Char('n') => {
app.update_search_query(query.clone()); app.select_next_search_result();
perform_message_search(app, &query).await; }
KeyCode::Backspace => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.pop();
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
KeyCode::Char(c) => {
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.push(c);
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
_ => {}
}
} }
KeyCode::Char(c) => {
// Добавляем символ к запросу
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
return;
};
query.push(c);
app.update_search_query(query.clone());
perform_message_search(app, &query).await;
}
_ => {}
} }
} }
@@ -883,41 +878,61 @@ async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Key
/// Обрабатывает: /// Обрабатывает:
/// - Up/Down/j/k: навигация между чатами /// - Up/Down/j/k: навигация между чатами
/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib) /// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib)
async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) { async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
match key.code { match command {
KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('о') => { Some(crate::config::Command::MoveDown) => {
app.next_chat(); app.next_chat();
} }
KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('р') => { Some(crate::config::Command::MoveUp) => {
app.previous_chat(); app.previous_chat();
} }
// Цифры 1-9 - переключение папок Some(crate::config::Command::SelectFolder1) => {
KeyCode::Char(c) if c >= '1' && c <= '9' => { app.selected_folder_id = None;
let folder_num = (c as usize) - ('1' as usize); // 0-based
if folder_num == 0 {
// 1 = All
app.selected_folder_id = None;
} else {
// 2, 3, 4... = папки из TDLib
if let Some(folder) = app.td_client.folders().get(folder_num - 1) {
let folder_id = folder.id;
app.selected_folder_id = Some(folder_id);
// Загружаем чаты папки
app.status_message = Some("Загрузка чатов папки...".to_string());
let _ = with_timeout(
Duration::from_secs(5),
app.td_client.load_folder_chats(folder_id, 50),
)
.await;
app.status_message = None;
}
}
app.chat_list_state.select(Some(0)); 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),
app.td_client.load_folder_chats(folder_id, 50),
)
.await;
app.status_message = None;
app.chat_list_state.select(Some(0));
}
}
/// Обработка ввода с клавиатуры в открытом чате /// Обработка ввода с клавиатуры в открытом чате
/// ///
/// Обрабатывает: /// Обрабатывает:
@@ -930,14 +945,13 @@ async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>, key
KeyCode::Backspace => { KeyCode::Backspace => {
// Удаляем символ слева от курсора // Удаляем символ слева от курсора
if app.cursor_position > 0 { if app.cursor_position > 0 {
let chars: Vec<char> = app.message_input.chars().collect(); // Находим byte offset для позиции курсора
let mut new_input = String::new(); let byte_pos = app.message_input
for (i, ch) in chars.iter().enumerate() { .char_indices()
if i != app.cursor_position - 1 { .nth(app.cursor_position - 1)
new_input.push(*ch); .map(|(pos, _)| pos)
} .unwrap_or(0);
} app.message_input.remove(byte_pos);
app.message_input = new_input;
app.cursor_position -= 1; 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(); let len = app.message_input.chars().count();
if app.cursor_position < len { if app.cursor_position < len {
let chars: Vec<char> = app.message_input.chars().collect(); // Находим byte offset для текущей позиции курсора
let mut new_input = String::new(); let byte_pos = app.message_input
for (i, ch) in chars.iter().enumerate() { .char_indices()
if i != app.cursor_position { .nth(app.cursor_position)
new_input.push(*ch); .map(|(pos, _)| pos)
} .unwrap_or(app.message_input.len());
} app.message_input.remove(byte_pos);
app.message_input = new_input;
} }
} }
KeyCode::Char(c) => { KeyCode::Char(c) => {
// Вставляем символ в позицию курсора // Вставляем символ в позицию курсора
let chars: Vec<char> = app.message_input.chars().collect(); if app.cursor_position >= app.message_input.chars().count() {
let mut new_input = String::new(); // Вставка в конец строки - самый быстрый случай
for (i, ch) in chars.iter().enumerate() { app.message_input.push(c);
if i == app.cursor_position { } else {
new_input.push(c); // Находим byte offset для позиции курсора
} let byte_pos = app.message_input
new_input.push(*ch); .char_indices()
.nth(app.cursor_position)
.map(|(pos, _)| pos)
.unwrap_or(app.message_input.len());
app.message_input.insert(byte_pos, c);
} }
if app.cursor_position >= chars.len() {
new_input.push(c);
}
app.message_input = new_input;
app.cursor_position += 1; app.cursor_position += 1;
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек) // Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
@@ -1033,29 +1046,30 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
return; return;
} }
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); // Получаем команду из keybindings
let command = app.get_command(key);
// Режим профиля // Режим профиля
if app.is_profile_mode() { if app.is_profile_mode() {
handle_profile_mode(app, key).await; handle_profile_mode(app, key, command).await;
return; return;
} }
// Режим поиска по сообщениям // Режим поиска по сообщениям
if app.is_message_search_mode() { if app.is_message_search_mode() {
handle_message_search_mode(app, key).await; handle_message_search_mode(app, key, command).await;
return; return;
} }
// Режим просмотра закреплённых сообщений // Режим просмотра закреплённых сообщений
if app.is_pinned_mode() { if app.is_pinned_mode() {
handle_pinned_mode(app, key).await; handle_pinned_mode(app, key, command).await;
return; return;
} }
// Обработка ввода в режиме выбора реакции // Обработка ввода в режиме выбора реакции
if app.is_reaction_picker_mode() { if app.is_reaction_picker_mode() {
handle_reaction_picker_mode(app, key).await; handle_reaction_picker_mode(app, key, command).await;
return; return;
} }
@@ -1067,46 +1081,50 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// Режим выбора чата для пересылки // Режим выбора чата для пересылки
if app.is_forwarding() { if app.is_forwarding() {
handle_forward_mode(app, key).await; handle_forward_mode(app, key, command).await;
return; return;
} }
// Режим поиска // Режим поиска
if app.is_searching { if app.is_searching {
handle_chat_search_mode(app, key).await; handle_chat_search_mode(app, key, command).await;
return; return;
} }
// Enter - открыть чат, отправить сообщение или редактировать // Обработка команд через keybindings
if key.code == KeyCode::Enter { match command {
handle_enter_key(app).await; Some(crate::config::Command::SubmitMessage) => {
return; // Enter - открыть чат, отправить сообщение или редактировать
} handle_enter_key(app).await;
return;
// Esc - отменить выбор/редактирование/reply или закрыть чат }
if key.code == KeyCode::Esc { Some(crate::config::Command::Cancel) => {
handle_escape_key(app).await; // Esc - отменить выбор/редактирование/reply или закрыть чат
return; 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.selected_chat_id.is_some() {
// Режим выбора сообщения для редактирования/удаления // Режим выбора сообщения для редактирования/удаления
if app.is_selecting_message() { if app.is_selecting_message() {
handle_message_selection(app, key).await; handle_message_selection(app, key, command).await;
return;
}
// Ctrl+U для профиля
if key.code == KeyCode::Char('u') && has_ctrl {
handle_profile_open(app).await;
return; return;
} }
handle_open_chat_keyboard_input(app, key).await; handle_open_chat_keyboard_input(app, key).await;
} else { } 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.status_message = Some("Загрузка сообщений...".to_string());
app.message_scroll_offset = 0; app.message_scroll_offset = 0;
// Загружаем все доступные сообщения (без лимита) // Загружаем последние 100 сообщений для быстрого открытия чата
// Остальные сообщения будут подгружаться при скролле вверх
match with_timeout_msg( match with_timeout_msg(
Duration::from_secs(30), Duration::from_secs(10),
app.td_client.get_chat_history(ChatId::new(chat_id), i32::MAX), app.td_client.get_chat_history(ChatId::new(chat_id), 100),
"Таймаут загрузки сообщений", "Таймаут загрузки сообщений",
) )
.await .await

View File

@@ -7,6 +7,7 @@ pub mod constants;
pub mod formatting; pub mod formatting;
pub mod input; pub mod input;
pub mod message_grouping; pub mod message_grouping;
pub mod notifications;
pub mod tdlib; pub mod tdlib;
pub mod types; pub mod types;
pub mod ui; pub mod ui;

View File

@@ -4,6 +4,7 @@ mod constants;
mod formatting; mod formatting;
mod input; mod input;
mod message_grouping; mod message_grouping;
mod notifications;
mod tdlib; mod tdlib;
mod types; mod types;
mod ui; mod ui;
@@ -54,6 +55,14 @@ async fn main() -> Result<(), io::Error> {
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; 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 // Create app state
let mut app = App::new(config); 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() { if app.chat_list_state.selected().is_none() && !app.chats.is_empty() {
app.chat_list_state.select(Some(0)); app.chat_list_state.select(Some(0));
} }
// Синхронизируем muted чаты для notifications
app.td_client.sync_notification_muted_chats();
// Убираем статус загрузки когда чаты появились // Убираем статус загрузки когда чаты появились
if app.is_loading { if app.is_loading {
app.is_loading = false; 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::reactions::ReactionManager;
use super::types::{ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus}; use super::types::{ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus};
use super::users::UserCache; use super::users::UserCache;
use crate::notifications::NotificationManager;
/// TDLib client wrapper for Telegram integration. /// TDLib client wrapper for Telegram integration.
/// ///
@@ -52,6 +53,7 @@ pub struct TdClient {
pub message_manager: MessageManager, pub message_manager: MessageManager,
pub user_cache: UserCache, pub user_cache: UserCache,
pub reaction_manager: ReactionManager, pub reaction_manager: ReactionManager,
pub notification_manager: NotificationManager,
// Состояние сети // Состояние сети
pub network_state: NetworkState, pub network_state: NetworkState,
@@ -93,10 +95,27 @@ impl TdClient {
message_manager: MessageManager::new(client_id), message_manager: MessageManager::new(client_id),
user_cache: UserCache::new(client_id), user_cache: UserCache::new(client_id),
reaction_manager: ReactionManager::new(client_id), reaction_manager: ReactionManager::new(client_id),
notification_manager: NotificationManager::new(),
network_state: NetworkState::Connecting, 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 // Делегирование к auth
/// Sends phone number for authentication. /// Sends phone number for authentication.

View File

@@ -260,11 +260,17 @@ impl TdClientTrait for TdClient {
} }
fn user_cache_mut(&mut self) -> &mut UserCache { 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 ============ // ============ Update handling ============
fn handle_update(&mut self, update: Update) { 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::constants::{MAX_MESSAGES_IN_CHAT, TDLIB_MESSAGE_LIMIT};
use crate::types::{ChatId, MessageId}; 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::functions;
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextParseModeMarkdown}; 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. /// Менеджер сообщений TDLib.
/// ///
@@ -123,8 +124,6 @@ impl MessageManager {
chat_id: ChatId, chat_id: ChatId,
limit: i32, limit: i32,
) -> Result<Vec<MessageInfo>, String> { ) -> Result<Vec<MessageInfo>, String> {
use tokio::time::{sleep, Duration};
// ВАЖНО: Сначала открываем чат в TDLib // ВАЖНО: Сначала открываем чат в TDLib
// Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю // Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю
let _ = functions::open_chat(chat_id.as_i64(), self.client_id).await; let _ = functions::open_chat(chat_id.as_i64(), self.client_id).await;
@@ -193,6 +192,8 @@ impl MessageManager {
if all_messages.is_empty() && if all_messages.is_empty() &&
received_count < (chunk_size as usize) && received_count < (chunk_size as usize) &&
attempt < max_attempts_per_chunk { attempt < max_attempts_per_chunk {
// Даём TDLib время на синхронизацию с сервером
sleep(Duration::from_millis(100)).await;
continue; continue;
} }

View File

@@ -120,6 +120,9 @@ pub trait TdClientTrait: Send {
fn set_main_chat_list_position(&mut self, position: i32); fn set_main_chat_list_position(&mut self, position: i32);
fn user_cache_mut(&mut self) -> &mut UserCache; fn user_cache_mut(&mut self) -> &mut UserCache;
// ============ Notification methods ============
fn sync_notification_muted_chats(&mut self);
// ============ Update handling ============ // ============ Update handling ============
fn handle_update(&mut self, update: Update); fn handle_update(&mut self, update: Update);
} }

View File

@@ -1,3 +1,4 @@
use tdlib_rs::enums::TextEntityType;
use tdlib_rs::types::TextEntity; use tdlib_rs::types::TextEntity;
use crate::types::{ChatId, MessageId}; use crate::types::{ChatId, MessageId};
@@ -192,6 +193,16 @@ impl MessageInfo {
self.state.can_be_deleted_for_all_users 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> { pub fn reply_to(&self) -> Option<&ReplyInfo> {
self.interactions.reply_to.as_ref() self.interactions.reply_to.as_ref()
} }
@@ -475,6 +486,39 @@ mod tests {
assert!(message.can_be_edited()); assert!(message.can_be_edited());
assert!(message.can_be_deleted_for_all_users()); 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)] #[derive(Debug, Clone)]

View File

@@ -19,12 +19,29 @@ use super::types::ReactionInfo;
/// Обрабатывает Update::NewMessage - добавление нового сообщения /// Обрабатывает Update::NewMessage - добавление нового сообщения
pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessage) { pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessage) {
// Добавляем новое сообщение если это текущий открытый чат
let chat_id = ChatId::new(new_msg.message.chat_id); let chat_id = ChatId::new(new_msg.message.chat_id);
// Если сообщение НЕ для текущего открытого чата - отправляем уведомление
if Some(chat_id) != client.current_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; return;
} }
// Добавляем новое сообщение если это текущий открытый чат
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id); let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
let msg_id = msg_info.id(); let msg_id = msg_info.id();
let is_incoming = !msg_info.is_outgoing(); let is_incoming = !msg_info.is_outgoing();

View File

@@ -101,8 +101,6 @@ fn render_input_with_cursor(
/// Информация о строке после переноса: текст и позиция в оригинале /// Информация о строке после переноса: текст и позиция в оригинале
struct WrappedLine { struct WrappedLine {
text: String, 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 { if max_width == 0 {
return vec![WrappedLine { return vec![WrappedLine {
text: text.to_string(), text: text.to_string(),
start_offset: 0,
}]; }];
} }
let mut result = Vec::new(); let mut result = Vec::new();
let mut current_line = String::new(); let mut current_line = String::new();
let mut current_width = 0; let mut current_width = 0;
let mut line_start_offset = 0;
let chars: Vec<char> = text.chars().collect(); let chars: Vec<char> = text.chars().collect();
let mut word_start = 0; 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 { if current_width == 0 {
current_line = word; current_line = word;
current_width = word_width; current_width = word_width;
line_start_offset = word_start;
} else if current_width + 1 + word_width <= max_width { } else if current_width + 1 + word_width <= max_width {
current_line.push(' '); current_line.push(' ');
current_line.push_str(&word); current_line.push_str(&word);
@@ -141,11 +136,9 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
} else { } else {
result.push(WrappedLine { result.push(WrappedLine {
text: current_line, text: current_line,
start_offset: line_start_offset,
}); });
current_line = word; current_line = word;
current_width = word_width; current_width = word_width;
line_start_offset = word_start;
} }
in_word = false; in_word = false;
} }
@@ -161,31 +154,26 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
if current_width == 0 { if current_width == 0 {
current_line = word; current_line = word;
line_start_offset = word_start;
} else if current_width + 1 + word_width <= max_width { } else if current_width + 1 + word_width <= max_width {
current_line.push(' '); current_line.push(' ');
current_line.push_str(&word); current_line.push_str(&word);
} else { } else {
result.push(WrappedLine { result.push(WrappedLine {
text: current_line, text: current_line,
start_offset: line_start_offset,
}); });
current_line = word; current_line = word;
line_start_offset = word_start;
} }
} }
if !current_line.is_empty() { if !current_line.is_empty() {
result.push(WrappedLine { result.push(WrappedLine {
text: current_line, text: current_line,
start_offset: line_start_offset,
}); });
} }
if result.is_empty() { if result.is_empty() {
result.push(WrappedLine { result.push(WrappedLine {
text: String::new(), text: String::new(),
start_offset: 0,
}); });
} }

View File

@@ -4,82 +4,6 @@
use crossterm::event::KeyCode; 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. /// Обрабатывает клавиши для подтверждения Yes/No.
/// ///
/// Поддерживает: /// Поддерживает:
@@ -138,28 +62,6 @@ pub fn handle_yes_no(key_code: KeyCode) -> Option<bool> {
mod tests { mod tests {
use super::*; 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] #[test]
fn test_handle_yes_no() { fn test_handle_yes_no() {
// Yes variants // Yes variants

View File

@@ -2,8 +2,6 @@
//! //!
//! Переиспользуемые валидаторы для проверки пользовательского ввода. //! Переиспользуемые валидаторы для проверки пользовательского ввода.
use crate::types::{ChatId, MessageId, UserId};
/// Проверяет, что строка не пустая (после trim). /// Проверяет, что строка не пустая (после trim).
/// ///
/// # Examples /// # Examples
@@ -20,112 +18,6 @@ pub fn is_non_empty(text: &str) -> bool {
!text.trim().is_empty() !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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -138,54 +30,4 @@ mod tests {
assert!(!is_non_empty(" ")); assert!(!is_non_empty(" "));
assert!(!is_non_empty("\t\n")); 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 // Integration tests for config flow
use tele_tui::config::{Config, ColorsConfig, GeneralConfig, Keybindings}; use tele_tui::config::{Config, ColorsConfig, GeneralConfig, Keybindings, NotificationsConfig};
/// Test: Дефолтные значения конфигурации /// Test: Дефолтные значения конфигурации
#[test] #[test]
@@ -33,6 +33,7 @@ fn test_config_custom_values() {
reaction_other: "white".to_string(), reaction_other: "white".to_string(),
}, },
keybindings: Keybindings::default(), keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(),
}; };
assert_eq!(config.general.timezone, "+05:00"); assert_eq!(config.general.timezone, "+05:00");
@@ -116,6 +117,7 @@ fn test_config_toml_serialization() {
reaction_other: "white".to_string(), reaction_other: "white".to_string(),
}, },
keybindings: Keybindings::default(), keybindings: Keybindings::default(),
notifications: NotificationsConfig::default(),
}; };
// Сериализуем в TOML // Сериализуем в TOML

View File

@@ -175,7 +175,6 @@ impl TestAppBuilder {
pub fn forward_mode(mut self, message_id: i64) -> Self { pub fn forward_mode(mut self, message_id: i64) -> Self {
self.chat_state = Some(ChatState::Forward { self.chat_state = Some(ChatState::Forward {
message_id: MessageId::new(message_id), message_id: MessageId::new(message_id),
selecting_chat: true,
}); });
self self
} }

View File

@@ -299,6 +299,11 @@ impl TdClientTrait for FakeTdClient {
panic!("user_cache_mut not supported 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 ============ // ============ Update handling ============
fn handle_update(&mut self, _update: Update) { fn handle_update(&mut self, _update: Update) {
// Not implemented for fake client // Not implemented for fake client