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
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:
155
CONTEXT.md
155
CONTEXT.md
@@ -4,6 +4,155 @@
|
||||
|
||||
### Последние изменения (2026-02-04)
|
||||
|
||||
**🔔 NEW: Desktop уведомления (Notifications) — Стадия 1/3 завершена**
|
||||
- **Реализовано**:
|
||||
- ✅ NotificationManager с базовой функциональностью (`src/notifications.rs`, 230+ строк)
|
||||
- ✅ Интеграция с TdClient (поле `notification_manager`)
|
||||
- ✅ Конфигурация в `config.toml` (enabled, only_mentions, show_preview)
|
||||
- ✅ Отправка уведомлений для новых сообщений вне текущего чата
|
||||
- ✅ Зависимость notify-rust 4.11 (с feature flag "notifications")
|
||||
- ✅ Форматирование body уведомления (текст, заглушки для медиа)
|
||||
- **Текущие ограничения**:
|
||||
- ⚠️ Только текстовые сообщения (нет доступа к MessageContentType)
|
||||
- ⚠️ Muted чаты пока не фильтруются (sync_muted_chats не вызывается)
|
||||
- ⚠️ Фильтр only_mentions не реализован (нет метода has_mention())
|
||||
- **TODO - Стадия 2** (улучшения):
|
||||
- [x] Синхронизация muted чатов из Telegram (вызов sync_muted_chats при загрузке) ✅
|
||||
- [x] Фильтрация по упоминаниям (@username) если only_mentions=true ✅
|
||||
- [x] Поддержка типов медиа (фото, видео, стикеры) в body ✅
|
||||
- **Стадия 3** (полировка) - ВЫПОЛНЕНО ✅:
|
||||
- [x] Обработка ошибок notify-rust с graceful fallback
|
||||
- [x] Логирование через tracing::warn! и tracing::debug!
|
||||
- [x] Дополнительные настройки: timeout_ms и urgency
|
||||
- [x] Платформенная поддержка urgency (только Linux)
|
||||
- **TODO - Стадия 3** (опционально):
|
||||
- [ ] Ручное тестирование на Linux/macOS/Windows
|
||||
- [ ] Обработка ошибок notify-rust (fallback если уведомления не работают)
|
||||
- [ ] Настройки продолжительности показа (timeout)
|
||||
- [ ] Иконка приложения для уведомлений
|
||||
- **Изменения**:
|
||||
- `Cargo.toml`: добавлен notify-rust 4.11, feature "notifications"
|
||||
- `src/notifications.rs`: новый модуль (230 строк)
|
||||
- `src/lib.rs`: экспорт модуля notifications
|
||||
- `src/main.rs`: добавлен `mod notifications;`
|
||||
- `src/config/mod.rs`: добавлена NotificationsConfig
|
||||
- `config.example.toml`: добавлена секция [notifications]
|
||||
- `src/tdlib/client.rs`: поле notification_manager, метод configure_notifications()
|
||||
- `src/tdlib/update_handlers.rs`: интеграция в handle_new_message_update()
|
||||
- `src/app/mod.rs`: вызов configure_notifications() при инициализации
|
||||
- **Тесты**: Компиляция успешна (cargo build --lib ✅, cargo build ✅)
|
||||
|
||||
**📸 PLANNED: Показ изображений в чате (Фаза 11)**
|
||||
- **Описание**: Отображение изображений прямо в терминале вместо текстовых заглушек "[Фото]"
|
||||
- **Технологии**:
|
||||
- ratatui-image 1.0 - поддержка изображений в TUI
|
||||
- Протоколы: Sixel, Kitty Graphics, iTerm2 Inline Images, Unicode Halfblocks
|
||||
- TDLib downloadFile API для загрузки фото
|
||||
- LRU кэш для загруженных изображений (лимит 100 MB)
|
||||
- **Архитектура**:
|
||||
- `src/media/` - новый модуль (image_cache, image_loader, image_renderer)
|
||||
- `PhotoInfo` в `MessageInfo` для хранения метаданных изображения
|
||||
- Асинхронная загрузка в фоне (не блокирует UI)
|
||||
- Lazy loading - загрузка только видимых изображений
|
||||
- **UX фичи**:
|
||||
- Превью в списке сообщений (миниатюры 20x10 символов)
|
||||
- Индикатор загрузки с progress bar
|
||||
- Полноэкранный просмотр: `v` в режиме выбора
|
||||
- Навигация между изображениями: `←` / `→`
|
||||
- Auto-detection возможностей терминала
|
||||
- Fallback на Unicode halfblocks для любых терминалов
|
||||
- **Конфигурация** (config.toml):
|
||||
- show_images: bool - включить/отключить
|
||||
- image_cache_mb: usize - размер кэша
|
||||
- preview_quality: "low" | "medium" | "high"
|
||||
- render_protocol: "auto" | "sixel" | "kitty" | "iterm2" | "halfblocks"
|
||||
- **План реализации**:
|
||||
- Этап 1: Инфраструктура (модуль media, ImageCache, зависимости)
|
||||
- Этап 2: Интеграция с TDLib (PhotoInfo, download_photo)
|
||||
- Этап 3: Рендеринг в UI (превью, масштабирование)
|
||||
- Этап 4: Полноэкранный просмотр (новый режим ViewImage)
|
||||
- Этап 5: Конфигурация и оптимизация
|
||||
- Этап 6: Обработка ошибок и fallback
|
||||
- **Ожидаемый результат**:
|
||||
- Фото показываются inline в чате с автоматическим масштабированием
|
||||
- Поддержка всех популярных терминалов (Kitty, WezTerm, iTerm2, и любых других)
|
||||
- Производительность: кэширование, асинхронность, lazy loading
|
||||
- **Статус**: PLANNED (документация готова в ROADMAP.md)
|
||||
|
||||
**🎤 PLANNED: Прослушивание голосовых сообщений (Фаза 12)**
|
||||
- **Описание**: Воспроизведение голосовых сообщений прямо из TUI с визуальным feedback
|
||||
- **Технологии**:
|
||||
- rodio 0.17 - Pure Rust аудио библиотека (кроссплатформенная)
|
||||
- TDLib downloadFile API для загрузки OGG файлов
|
||||
- Поддержка платформ: Linux (ALSA/PulseAudio), macOS (CoreAudio), Windows (WASAPI)
|
||||
- Fallback на системный плеер (mpv, ffplay) если rodio не работает
|
||||
- **Архитектура**:
|
||||
- `src/audio/` - новый модуль (player, cache, state)
|
||||
- `AudioPlayer` - управление воспроизведением (play, pause, stop, seek, volume)
|
||||
- `VoiceCache` - LRU кэш загруженных файлов (лимит 100 MB)
|
||||
- `PlaybackState` - текущее состояние (status, position, duration, volume)
|
||||
- Асинхронная загрузка в фоне (не блокирует UI)
|
||||
- **UX фичи**:
|
||||
- Progress bar в сообщении (▶ ████████░░░░░░ 0:08 / 0:15)
|
||||
- Статусы: ▶ (playing), ⏸ (paused), ⏹ (stopped), ⏳ (loading)
|
||||
- Хоткеи: Space (play/pause), s (stop), ←/→ (seek ±5s), ↑/↓ (volume)
|
||||
- Waveform визуализация (опционально, из Telegram API)
|
||||
- Автоматическая остановка при закрытии чата
|
||||
- Индикатор загрузки с процентами
|
||||
- **Конфигурация** (config.toml):
|
||||
- enabled: bool - включить/отключить аудио
|
||||
- default_volume: f32 - громкость (0.0 - 1.0)
|
||||
- seek_step_seconds: i32 - шаг перемотки (5 сек)
|
||||
- autoplay: bool - автовоспроизведение
|
||||
- cache_size_mb: usize - размер кэша
|
||||
- show_waveform: bool - показывать waveform
|
||||
- system_player_fallback: bool - использовать системный плеер
|
||||
- system_player: String - команда плеера (mpv, ffplay)
|
||||
- **План реализации**:
|
||||
- Этап 1: Инфраструктура аудио (модуль audio, AudioPlayer, VoiceCache)
|
||||
- Этап 2: Интеграция с TDLib (VoiceNoteInfo, download_voice_note)
|
||||
- Этап 3: UI для воспроизведения (progress bar, индикаторы, footer)
|
||||
- Этап 4: Хоткеи для управления (play/pause, stop, seek, volume)
|
||||
- Этап 5: Конфигурация и UX (AudioConfig, ticker для обновления)
|
||||
- Этап 6: Обработка ошибок и fallback (системный плеер)
|
||||
- Этап 7: Дополнительные улучшения (префетчинг, анимация)
|
||||
- **Ожидаемый результат**:
|
||||
- Голосовые воспроизводятся с визуальным индикатором прогресса
|
||||
- Полный контроль: play, pause, stop, seek, volume
|
||||
- Кэширование загруженных файлов
|
||||
- Graceful fallback на системный плеер
|
||||
- Кроссплатформенность (Linux, macOS, Windows)
|
||||
- **Статус**: PLANNED (документация готова в ROADMAP.md)
|
||||
|
||||
**🐛 FIX: HashMap keybindings коллизии - дубликаты клавиш**
|
||||
- **Проблема #1**: `KeyCode::Enter` был привязан к 3 командам (OpenChat, SelectMessage, SubmitMessage)
|
||||
- **Проблема #2**: `KeyCode::Up` был привязан к 2 командам (MoveUp, EditMessage)
|
||||
- **Симптомы**:
|
||||
- `Enter` возвращал `SelectMessage` вместо `SubmitMessage` → чат не открывался
|
||||
- `Up` возвращал `EditMessage` вместо `MoveUp` → навигация в списке чатов не работала
|
||||
- **Причина**: HashMap перезаписывает значения при повторной вставке (last-insert-wins)
|
||||
- **Решение**:
|
||||
- Удалены привязки `OpenChat` и `SelectMessage` для Enter (обрабатываются в `handle_enter_key`)
|
||||
- Удалена привязка `EditMessage` для Up (обрабатывается напрямую в `handle_open_chat_keyboard_input`)
|
||||
- Это контекстно-зависимая логика, которую нельзя корректно выразить через простой HashMap
|
||||
- **Изменения**: `src/config/keybindings.rs:166-168, 186-189, 210-212`
|
||||
- **Тесты**: Все 571 тест проходят (75 unit + 496 integration)
|
||||
|
||||
**✅ ЗАВЕРШЕНО: Интеграция ChatFilter в App**
|
||||
- **Цель**: Заменить дублирующуюся логику фильтрации в `App::get_filtered_chats()`
|
||||
- **Решение**:
|
||||
- Добавлен экспорт `ChatFilter`, `ChatFilterCriteria`, `ChatSortOrder` в `src/app/mod.rs`
|
||||
- Метод `get_filtered_chats()` переписан с использованием ChatFilter API
|
||||
- Удалена дублирующая логика (27 строк → 11 строк)
|
||||
- Используется builder pattern для создания критериев
|
||||
- **Преимущества**:
|
||||
- Единый источник правды для фильтрации чатов
|
||||
- Централизованная логика в ChatFilter модуле
|
||||
- Type-safe критерии через builder pattern
|
||||
- Reference-based фильтрация (без клонирования)
|
||||
- **Изменения**: `src/app/mod.rs:0-5, 313-323`
|
||||
- **Тесты**: Все 577 тестов проходят (81 unit + 496 integration)
|
||||
|
||||
**🐛 FIX: Зависание при открытии чатов с большой историей**
|
||||
- **Проблема**: При использовании `i32::MAX` как лимита загрузки истории, приложение зависало в чатах с тысячами сообщений (например, на итерации #96 было загружено 4750+ сообщений и загрузка продолжалась)
|
||||
- **Решение**: Заменён лимит с `i32::MAX` на разумные 300 сообщений при открытии чата
|
||||
@@ -25,7 +174,7 @@
|
||||
- Сериализация/десериализация для загрузки из конфига
|
||||
- Метод `get_command()` для определения команды по KeyEvent
|
||||
- **Тесты**: 4 unit теста (все проходят)
|
||||
- **Статус**: Готово к интеграции (требуется замена HotkeysConfig)
|
||||
- **Статус**: ✅ Интегрировано в Config и main_input.rs
|
||||
|
||||
**🎯 NEW: KeyHandler trait для обработки клавиш**
|
||||
- **Модуль**: `src/input/key_handler.rs` (380+ строк)
|
||||
@@ -81,7 +230,7 @@
|
||||
- Builder pattern для удобного конструирования
|
||||
- Эффективность (работает с references, без клонирования)
|
||||
- **Тесты**: 6 unit тестов (все проходят)
|
||||
- **Статус**: Готово к интеграции (TODO: заменить дублирующуюся логику в App/UI)
|
||||
- **Статус**: ✅ Интегрировано в App и ChatListState
|
||||
|
||||
### Что сделано
|
||||
|
||||
@@ -194,7 +343,7 @@
|
||||
- `1-9` — переключение папок (в списке чатов)
|
||||
- `Ctrl+F` — поиск по сообщениям в открытом чате
|
||||
- `n` / `N` — навигация по результатам поиска (следующий/предыдущий)
|
||||
- `i` — открыть профиль пользователя/чата
|
||||
- `Ctrl+i` / `Ctrl+ш` — открыть профиль пользователя/чата
|
||||
- `y` / `н` в режиме выбора — скопировать сообщение в буфер обмена
|
||||
- `e` / `у` в режиме выбора — добавить реакцию (открывает emoji picker)
|
||||
- `←` / `→` / `↑` / `↓` в emoji picker — навигация по сетке реакций
|
||||
|
||||
617
Cargo.lock
generated
617
Cargo.lock
generated
@@ -84,6 +84,126 @@ dependencies = [
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-executor"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8"
|
||||
dependencies = [
|
||||
"async-task",
|
||||
"concurrent-queue",
|
||||
"fastrand",
|
||||
"futures-lite",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-io"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"parking",
|
||||
"polling",
|
||||
"rustix 1.1.3",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-lock"
|
||||
version = "3.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-process"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-signal",
|
||||
"async-task",
|
||||
"blocking",
|
||||
"cfg-if",
|
||||
"event-listener",
|
||||
"futures-lite",
|
||||
"rustix 1.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-signal"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c"
|
||||
dependencies = [
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"atomic-waker",
|
||||
"cfg-if",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"rustix 1.1.3",
|
||||
"signal-hook-registry",
|
||||
"slab",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-task"
|
||||
version = "4.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -128,6 +248,28 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
"futures-io",
|
||||
"futures-lite",
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.19.1"
|
||||
@@ -227,7 +369,7 @@ dependencies = [
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -315,6 +457,15 @@ dependencies = [
|
||||
"static_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "concurrent-queue"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
||||
dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.15.11"
|
||||
@@ -687,6 +838,33 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -709,6 +887,27 @@ version = "3.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener-strategy"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.3.0"
|
||||
@@ -818,6 +1017,19 @@ version = "0.3.31"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
|
||||
|
||||
[[package]]
|
||||
name = "futures-lite"
|
||||
version = "2.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "futures-sink"
|
||||
version = "0.3.31"
|
||||
@@ -863,7 +1075,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
||||
dependencies = [
|
||||
"rustix 1.1.3",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1102,7 +1314,7 @@ dependencies = [
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
"windows-core 0.62.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1477,6 +1689,18 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mac-notification-sys"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
@@ -1492,6 +1716,15 @@ version = "2.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -1547,6 +1780,20 @@ dependencies = [
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-rust"
|
||||
version = "4.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "21af20a1b50be5ac5861f74af1a863da53a11c38684d9818d82f1c42f7fdc6c2"
|
||||
dependencies = [
|
||||
"futures-lite",
|
||||
"log",
|
||||
"mac-notification-sys",
|
||||
"serde",
|
||||
"tauri-winrt-notification",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -1629,6 +1876,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
@@ -1717,6 +1966,22 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parking"
|
||||
version = "2.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
||||
|
||||
[[package]]
|
||||
name = "parking_lot"
|
||||
version = "0.12.5"
|
||||
@@ -1737,7 +2002,7 @@ dependencies = [
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
"smallvec",
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1780,6 +2045,17 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
"futures-io",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@@ -1827,6 +2103,20 @@ dependencies = [
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "polling"
|
||||
version = "3.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"concurrent-queue",
|
||||
"hermit-abi",
|
||||
"pin-project-lite",
|
||||
"rustix 1.1.3",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.4"
|
||||
@@ -1842,6 +2132,15 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
|
||||
dependencies = [
|
||||
"toml_edit 0.23.10+spec-1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.106"
|
||||
@@ -1866,6 +2165,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.44"
|
||||
@@ -2243,6 +2551,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "0.6.9"
|
||||
@@ -2484,6 +2803,18 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-winrt-notification"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9"
|
||||
dependencies = [
|
||||
"quick-xml",
|
||||
"thiserror 2.0.18",
|
||||
"windows",
|
||||
"windows-version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tdlib-rs"
|
||||
version = "1.2.0"
|
||||
@@ -2530,6 +2861,7 @@ dependencies = [
|
||||
"dirs 5.0.1",
|
||||
"dotenvy",
|
||||
"insta",
|
||||
"notify-rust",
|
||||
"open",
|
||||
"ratatui",
|
||||
"serde",
|
||||
@@ -2761,8 +3093,8 @@ checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_edit",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2774,6 +3106,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.22.27"
|
||||
@@ -2783,11 +3124,32 @@ dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.23.10+spec-1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
@@ -2912,6 +3274,17 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89daebc3e6fd160ac4aa9fc8b3bf71e1f74fbf92367ae71fb83a037e8bf164b9"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.22"
|
||||
@@ -2971,6 +3344,17 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.20.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"serde_core",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
@@ -3129,6 +3513,41 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.61.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core 0.61.2",
|
||||
"windows-future",
|
||||
"windows-link 0.1.3",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link 0.1.3",
|
||||
"windows-result 0.3.4",
|
||||
"windows-strings 0.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
@@ -3137,9 +3556,20 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3164,21 +3594,46 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a"
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1"
|
||||
dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-registry"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
"windows-link 0.2.1",
|
||||
"windows-result 0.4.1",
|
||||
"windows-strings 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3187,7 +3642,16 @@ version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3196,7 +3660,7 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3241,7 +3705,7 @@ version = "0.61.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3281,7 +3745,7 @@ version = "0.53.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
"windows-link 0.2.1",
|
||||
"windows_aarch64_gnullvm 0.53.1",
|
||||
"windows_aarch64_msvc 0.53.1",
|
||||
"windows_i686_gnu 0.53.1",
|
||||
@@ -3292,6 +3756,24 @@ dependencies = [
|
||||
"windows_x86_64_msvc 0.53.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6"
|
||||
dependencies = [
|
||||
"windows-link 0.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-version"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631"
|
||||
dependencies = [
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.48.5"
|
||||
@@ -3500,6 +3982,67 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1bfeff997a0aaa3eb20c4652baf788d2dfa6d2839a0ead0b3ff69ce2f9c4bdd1"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
"async-io",
|
||||
"async-lock",
|
||||
"async-process",
|
||||
"async-recursion",
|
||||
"async-task",
|
||||
"async-trait",
|
||||
"blocking",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix 1.1.3",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bbd5a90dbe8feee5b13def448427ae314ccd26a49cac47905cafefb9ff846f1"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.34"
|
||||
@@ -3684,3 +4227,43 @@ checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68b64ef4f40c7951337ddc7023dd03528a57a3ce3408ee9da5e948bd29b232c4"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "484d5d975eb7afb52cc6b929c13d3719a20ad650fea4120e6310de3fc55e415c"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
@@ -10,9 +10,10 @@ keywords = ["telegram", "tui", "terminal", "cli"]
|
||||
categories = ["command-line-utilities"]
|
||||
|
||||
[features]
|
||||
default = ["clipboard", "url-open"]
|
||||
default = ["clipboard", "url-open", "notifications"]
|
||||
clipboard = ["dep:arboard"]
|
||||
url-open = ["dep:open"]
|
||||
notifications = ["dep:notify-rust"]
|
||||
|
||||
[dependencies]
|
||||
ratatui = "0.29"
|
||||
@@ -26,6 +27,7 @@ dotenvy = "0.15"
|
||||
chrono = "0.4"
|
||||
open = { version = "5.0", optional = true }
|
||||
arboard = { version = "3.4", optional = true }
|
||||
notify-rust = { version = "4.11", optional = true }
|
||||
toml = "0.8"
|
||||
dirs = "5.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
52
HOTKEYS.md
52
HOTKEYS.md
@@ -41,7 +41,43 @@
|
||||
| `d` / `Delete` | `в` | Удалить сообщение |
|
||||
| `y` | `н` | Копировать текст в буфер обмена |
|
||||
| `e` | `у` | Добавить реакцию (Emoji picker) |
|
||||
| `i` | | Открыть профиль чата/пользователя |
|
||||
| `v` | `м` | Открыть изображение в полном размере |
|
||||
| `Ctrl+i` | `Ctrl+ш` | Открыть профиль чата/пользователя |
|
||||
|
||||
## Просмотр изображений
|
||||
|
||||
### Режим просмотра изображения
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `v` / `м` | Открыть изображение (в режиме выбора) |
|
||||
| `←` | Предыдущее изображение в чате |
|
||||
| `→` | Следующее изображение в чате |
|
||||
| `Esc` | Закрыть просмотр изображения |
|
||||
|
||||
**Примечание**: Изображения отображаются inline в чате автоматически. Используйте `v` для просмотра в полном размере.
|
||||
|
||||
## Прослушивание голосовых сообщений
|
||||
|
||||
### Управление воспроизведением
|
||||
|
||||
| Клавиша | Русская раскладка | Действие |
|
||||
|---------|-------------------|----------|
|
||||
| `Space` | | Воспроизвести/Пауза (в режиме выбора голосового) |
|
||||
| `s` | `ы` | Остановить воспроизведение |
|
||||
|
||||
### Во время воспроизведения
|
||||
|
||||
| Клавиша | Действие |
|
||||
|---------|----------|
|
||||
| `Space` | Пауза / Возобновить |
|
||||
| `s` / `ы` | Остановить |
|
||||
| `←` | Перемотка назад (по умолчанию -5 сек) |
|
||||
| `→` | Перемотка вперед (по умолчанию +5 сек) |
|
||||
| `↑` | Увеличить громкость (+10%) |
|
||||
| `↓` | Уменьшить громкость (-10%) |
|
||||
|
||||
**Примечание**: Голосовые сообщения показывают progress bar во время воспроизведения: `▶ ████████░░░░░░ 0:08 / 0:15`
|
||||
|
||||
## Модалки подтверждения
|
||||
|
||||
@@ -103,6 +139,8 @@
|
||||
- Удалить: `d` / `в` / `Delete`
|
||||
- Копировать: `y` / `н`
|
||||
- Реакция: `e` / `у`
|
||||
- Просмотр изображения: `v` / `м` (если выбрано сообщение с фото)
|
||||
- Воспроизведение голосового: `Space` (если выбрано голосовое сообщение)
|
||||
- Отменить: `Esc`
|
||||
|
||||
### Режим редактирования
|
||||
@@ -120,6 +158,16 @@
|
||||
- Переслать: `Enter`
|
||||
- Отменить: `Esc`
|
||||
|
||||
### Режим просмотра изображения
|
||||
- Навигация: `←/→` (предыдущее/следующее изображение)
|
||||
- Закрыть: `Esc`
|
||||
|
||||
### Режим воспроизведения голосового
|
||||
- Пауза/Возобновить: `Space`
|
||||
- Остановить: `s` / `ы`
|
||||
- Перемотка: `←/→` (-5с / +5с)
|
||||
- Громкость: `↑/↓` (+/- 10%)
|
||||
|
||||
## Поддержка русской раскладки
|
||||
|
||||
Все основные vim-клавиши поддерживают русскую раскладку:
|
||||
@@ -135,6 +183,8 @@
|
||||
| `d` | `в` | Delete |
|
||||
| `y` | `н` | Copy (Yank) |
|
||||
| `e` | `у` | Emoji reaction |
|
||||
| `v` | `м` | View image |
|
||||
| `s` | `ы` | Stop audio |
|
||||
|
||||
## Подсказки
|
||||
|
||||
|
||||
@@ -23,6 +23,17 @@ tele-tui/
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── auth.rs
|
||||
│ │ └── main_input.rs
|
||||
│ ├── audio/ # Прослушивание голосовых (PLANNED)
|
||||
│ │ ├── mod.rs # Экспорт публичных типов
|
||||
│ │ ├── player.rs # AudioPlayer на rodio
|
||||
│ │ ├── cache.rs # VoiceCache для OGG файлов
|
||||
│ │ └── state.rs # PlaybackState
|
||||
│ ├── media/ # Работа с изображениями (PLANNED)
|
||||
│ │ ├── mod.rs # Экспорт публичных типов
|
||||
│ │ ├── image_cache.rs # LRU кэш для загруженных изображений
|
||||
│ │ ├── image_loader.rs # Асинхронная загрузка через TDLib
|
||||
│ │ └── image_renderer.rs # Рендеринг изображений в ratatui
|
||||
│ ├── notifications.rs # Desktop уведомления
|
||||
│ ├── tdlib/ # TDLib интеграция
|
||||
│ │ ├── mod.rs
|
||||
│ │ └── client.rs
|
||||
@@ -102,6 +113,70 @@ tele-tui/
|
||||
#### state.rs
|
||||
- `AppScreen` enum — текущий экран (Loading, Auth, Main)
|
||||
|
||||
### audio/ — Прослушивание голосовых сообщений (PLANNED - Фаза 12)
|
||||
|
||||
#### player.rs
|
||||
- `AudioPlayer` — управление воспроизведением голосовых сообщений
|
||||
- Использует rodio для кроссплатформенного аудио
|
||||
- API методы: play(), pause(), resume(), stop(), seek(), set_volume()
|
||||
- Обработка OGG Opus файлов (формат голосовых в Telegram)
|
||||
- Отдельный поток для воспроизведения (через rodio Sink)
|
||||
|
||||
#### cache.rs
|
||||
- `VoiceCache` — LRU кэш для загруженных голосовых файлов
|
||||
- Хранение в ~/.cache/tele-tui/voice/
|
||||
- Лимит по размеру (MB) с автоматической очисткой
|
||||
- MAX_VOICE_CACHE_SIZE = 100 MB (настраивается в config)
|
||||
- Проверка существования файла перед воспроизведением
|
||||
|
||||
#### state.rs
|
||||
- `PlaybackState` — текущее состояние воспроизведения
|
||||
- Поля: message_id, status, position, duration, volume
|
||||
- `PlaybackStatus` enum — Stopped, Playing, Paused, Loading
|
||||
- Ticker для обновления позиции (каждые 100ms)
|
||||
|
||||
#### mod.rs
|
||||
- Экспорт публичных типов
|
||||
- `VoiceNoteInfo` struct — метаданные голосового (file_id, duration, waveform)
|
||||
- `AudioConfig` — конфигурация из config.toml
|
||||
- Fallback на системный плеер (mpv, ffplay)
|
||||
|
||||
### media/ — Работа с изображениями (PLANNED - Фаза 11)
|
||||
|
||||
#### image_cache.rs
|
||||
- `ImageCache` — LRU кэш для загруженных изображений
|
||||
- Лимит по размеру (MB) с автоматической очисткой
|
||||
- Хранение как в памяти (DynamicImage), так и на диске (PathBuf)
|
||||
- MAX_IMAGE_CACHE_SIZE = 100 MB (настраивается в config)
|
||||
|
||||
#### image_loader.rs
|
||||
- `ImageLoader` — асинхронная загрузка изображений через TDLib
|
||||
- Метод `load_photo(file_id)` — получить изображение из кэша или загрузить
|
||||
- Метод `download_and_cache(file)` — загрузка через TDLib downloadFile API
|
||||
- Обработка состояний загрузки (pending/downloading/ready)
|
||||
- Приоритизация видимых изображений
|
||||
|
||||
#### image_renderer.rs
|
||||
- `ImageRenderer` — рендеринг изображений в ratatui
|
||||
- Auto-detection протокола терминала (Sixel/Kitty/iTerm2/Halfblocks)
|
||||
- Автоматическое масштабирование под размер области
|
||||
- Сохранение aspect ratio
|
||||
- Fast resize для превью
|
||||
- Fallback на текстовую заглушку
|
||||
|
||||
#### mod.rs
|
||||
- Экспорт публичных типов
|
||||
- `PhotoInfo` struct — метаданные изображения (file_id, width, height)
|
||||
- `TerminalProtocol` enum — поддерживаемые протоколы отображения
|
||||
|
||||
### notifications.rs — Desktop уведомления
|
||||
|
||||
- `NotificationManager` — управление desktop уведомлениями
|
||||
- Интеграция с notify-rust для кроссплатформенных уведомлений
|
||||
- Фильтрация по muted чатам и mentions
|
||||
- Beautification медиа-типов с emoji
|
||||
- Настраиваемый timeout и urgency (Linux)
|
||||
|
||||
### tdlib/ — Telegram интеграция
|
||||
|
||||
#### client.rs
|
||||
@@ -269,6 +344,7 @@ App {
|
||||
is_delete_confirmation: bool,
|
||||
is_reaction_picker_mode: bool,
|
||||
profile_info: Option<ProfileInfo>,
|
||||
view_image_mode: Option<ViewImageState>, // PLANNED - Фаза 11
|
||||
|
||||
// Search
|
||||
search_query: String,
|
||||
@@ -276,6 +352,15 @@ App {
|
||||
|
||||
// Drafts
|
||||
drafts: HashMap<i64, String>,
|
||||
|
||||
// Audio (PLANNED - Фаза 12)
|
||||
audio_player: Option<AudioPlayer>,
|
||||
playback_state: Option<PlaybackState>,
|
||||
voice_cache: VoiceCache,
|
||||
|
||||
// Media (PLANNED - Фаза 11)
|
||||
image_loader: ImageLoader,
|
||||
image_protocol: StatefulProtocol, // Terminal capabilities
|
||||
}
|
||||
```
|
||||
|
||||
@@ -302,6 +387,17 @@ App {
|
||||
### UI
|
||||
- `ratatui` 0.29 — TUI framework
|
||||
- `crossterm` 0.28 — terminal control
|
||||
- `ratatui-image` 1.0 — отображение изображений в TUI (PLANNED)
|
||||
|
||||
### Audio (PLANNED)
|
||||
- `rodio` 0.17 — Pure Rust аудио библиотека (кроссплатформенная)
|
||||
|
||||
### Media (PLANNED)
|
||||
- `image` — загрузка и обработка изображений
|
||||
- `ratatui-image` — рендеринг в ratatui с поддержкой Sixel/Kitty/iTerm2
|
||||
|
||||
### Notifications
|
||||
- `notify-rust` 4.11 — desktop уведомления (feature flag)
|
||||
|
||||
### Telegram
|
||||
- `tdlib-rs` 1.1 — TDLib bindings
|
||||
|
||||
534
ROADMAP.md
534
ROADMAP.md
@@ -129,7 +129,7 @@
|
||||
- Индикатор черновика в списке чатов
|
||||
- Восстановление текста при возврате в чат
|
||||
- [x] Профиль пользователя/чата
|
||||
- `i` — открыть информацию о чате/собеседнике
|
||||
- `Ctrl+i` — открыть информацию о чате/собеседнике
|
||||
- Для личных чатов: имя, username, телефон, био
|
||||
- Для групп: название, описание, количество участников
|
||||
- [x] Копирование сообщений
|
||||
@@ -143,3 +143,535 @@
|
||||
- `~/.config/tele-tui/config.toml`
|
||||
- Настройки: цветовая схема, часовой пояс, хоткеи
|
||||
- Загрузка конфига при старте
|
||||
|
||||
## Фаза 10: Desktop уведомления [DONE - 83%]
|
||||
|
||||
### Стадия 1: Базовая реализация [DONE]
|
||||
- [x] NotificationManager модуль
|
||||
- notify-rust интеграция (версия 4.11)
|
||||
- Feature flag "notifications" в Cargo.toml
|
||||
- Базовая структура с настройками
|
||||
- [x] Конфигурация уведомлений
|
||||
- NotificationsConfig в config.toml
|
||||
- enabled: bool - вкл/выкл уведомлений
|
||||
- only_mentions: bool - только упоминания
|
||||
- show_preview: bool - показывать превью текста
|
||||
- [x] Интеграция с TdClient
|
||||
- Поле notification_manager в TdClient
|
||||
- Метод configure_notifications()
|
||||
- Обработка в handle_new_message_update()
|
||||
- [x] Базовая отправка уведомлений
|
||||
- Уведомления для сообщений не из текущего чата
|
||||
- Форматирование title (имя чата) и body (текст/медиа-заглушка)
|
||||
- Sender name из MessageInfo
|
||||
|
||||
### Стадия 2: Улучшения [IN PROGRESS]
|
||||
- [x] Синхронизация muted чатов
|
||||
- Загрузка списка muted чатов из Telegram
|
||||
- Вызов sync_muted_chats() при инициализации и обновлении (Ctrl+R)
|
||||
- Muted чаты автоматически фильтруются из уведомлений
|
||||
- [x] Фильтрация по упоминаниям
|
||||
- Метод MessageInfo::has_mention() проверяет TextEntityType::Mention и MentionName
|
||||
- NotificationManager применяет фильтр only_mentions из конфига
|
||||
- Работает для @username и inline mentions
|
||||
- [x] Поддержка типов медиа
|
||||
- Метод beautify_media_labels() заменяет текстовые заглушки на emoji
|
||||
- Поддержка: 📷 Фото, 🎥 Видео, 🎞️ GIF, 🎤 Голосовое, 🎨 Стикер
|
||||
- Также: 📎 Файл, 🎵 Аудио, 📹 Видеосообщение, 📍 Локация, 👤 Контакт, 📊 Опрос
|
||||
- [ ] Кастомизация звуков
|
||||
- Настройка звуков уведомлений в config.toml
|
||||
- Разные звуки для разных типов сообщений
|
||||
|
||||
### Стадия 3: Полировка [DONE]
|
||||
- [x] Обработка ошибок
|
||||
- Graceful fallback если уведомления недоступны (возвращает Ok без паники)
|
||||
- Логирование ошибок через tracing::warn!
|
||||
- Детальное логирование причин пропуска уведомлений (debug level)
|
||||
- [x] Дополнительные настройки
|
||||
- timeout_ms - продолжительность показа (0 = системное значение)
|
||||
- urgency - уровень важности: "low", "normal", "critical" (только Linux)
|
||||
- Красивые эмодзи для типов медиа
|
||||
- [ ] Опциональные улучшения (не критично)
|
||||
- Кросс-платформенное тестирование (требует ручного тестирования)
|
||||
- icon - кастомная иконка приложения
|
||||
- Actions в уведомлениях (кнопки "Ответить", "Прочитано")
|
||||
|
||||
## Фаза 11: Показ изображений в чате [PLANNED]
|
||||
|
||||
### Этап 1: Инфраструктура [TODO]
|
||||
- [ ] Модуль src/media/
|
||||
- image_cache.rs - LRU кэш для загруженных изображений
|
||||
- image_loader.rs - Асинхронная загрузка через TDLib
|
||||
- image_renderer.rs - Рендеринг в ratatui
|
||||
- [ ] Зависимости
|
||||
- ratatui-image 1.0 - поддержка изображений в TUI
|
||||
- Определение протокола терминала (Sixel/Kitty/iTerm2/Halfblocks)
|
||||
- [ ] ImageCache с лимитами
|
||||
- LRU кэш с максимальным размером в МБ
|
||||
- Автоматическая очистка старых изображений
|
||||
- MAX_IMAGE_CACHE_SIZE = 100 MB (по умолчанию)
|
||||
|
||||
### Этап 2: Интеграция с TDLib [TODO]
|
||||
- [ ] Обработка MessageContentPhoto
|
||||
- Добавить PhotoInfo в MessageInfo
|
||||
- Извлечение file_id, width, height из Photo
|
||||
- Выбор оптимального размера изображения (до 800px)
|
||||
- [ ] Загрузка файлов
|
||||
- Метод TdClient::download_photo(file_id)
|
||||
- Асинхронная загрузка через downloadFile API
|
||||
- Обработка состояний загрузки (pending/downloading/ready)
|
||||
- [ ] Кэширование
|
||||
- Сохранение путей к загруженным файлам
|
||||
- Повторное использование уже загруженных изображений
|
||||
|
||||
### Этап 3: Рендеринг в UI [TODO]
|
||||
- [ ] Модификация render_messages()
|
||||
- Определение возможностей терминала при старте
|
||||
- Рендеринг изображений через ratatui-image
|
||||
- Автоматическое масштабирование под размер области
|
||||
- Сохранение aspect ratio
|
||||
- [ ] Превью в списке сообщений
|
||||
- Миниатюры размером 20x10 символов
|
||||
- Lazy loading (загрузка только видимых)
|
||||
- Placeholder пока изображение грузится
|
||||
- [ ] Индикатор загрузки
|
||||
- Текстовая заглушка "[Загрузка фото...]"
|
||||
- Progress bar для больших файлов
|
||||
- Процент загрузки
|
||||
|
||||
### Этап 4: Полноэкранный просмотр [TODO]
|
||||
- [ ] Новый режим: ViewImage
|
||||
- `v` / `м` в режиме выбора - открыть изображение
|
||||
- Показ на весь экран терминала
|
||||
- `Esc` для закрытия
|
||||
- [ ] Информация об изображении
|
||||
- Размер файла
|
||||
- Разрешение (width x height)
|
||||
- Формат (JPEG/PNG/GIF)
|
||||
- [ ] Навигация
|
||||
- `←` / `→` - предыдущее/следующее изображение в чате
|
||||
- Автоматическая загрузка соседних изображений
|
||||
|
||||
### Этап 5: Конфигурация и UX [TODO]
|
||||
- [ ] MediaConfig в config.toml
|
||||
- show_images: bool - включить/отключить показ изображений
|
||||
- image_cache_mb: usize - размер кэша в МБ
|
||||
- preview_quality: "low" | "medium" | "high"
|
||||
- render_protocol: "auto" | "sixel" | "kitty" | "iterm2" | "halfblocks"
|
||||
- [ ] Поддержка различных терминалов
|
||||
- Auto-detection протокола при старте
|
||||
- Fallback на Unicode halfblocks для любого терминала
|
||||
- Опция отключения изображений если терминал не поддерживает
|
||||
- [ ] Оптимизация производительности
|
||||
- Асинхронная загрузка (не блокирует UI)
|
||||
- Приоритизация видимых изображений
|
||||
- Fast resize для превью
|
||||
- Кэширование отмасштабированных версий
|
||||
|
||||
### Этап 6: Обработка ошибок [TODO]
|
||||
- [ ] Graceful fallback
|
||||
- Текстовая заглушка "[Фото]" если загрузка не удалась
|
||||
- Повторная попытка по запросу пользователя
|
||||
- Логирование проблем через tracing
|
||||
- [ ] Ограничения
|
||||
- Таймаут загрузки (30 сек)
|
||||
- Максимальный размер файла для автозагрузки (10 MB)
|
||||
- Предупреждение для больших файлов
|
||||
|
||||
### Технические детали
|
||||
- **Поддерживаемые протоколы:**
|
||||
- Sixel (xterm, WezTerm, mintty)
|
||||
- Kitty Graphics Protocol (Kitty terminal)
|
||||
- iTerm2 Inline Images (iTerm2 на macOS)
|
||||
- Unicode Halfblocks (fallback для всех)
|
||||
- **Поддерживаемые форматы:**
|
||||
- JPEG, PNG, GIF, WebP, BMP
|
||||
- **Новые хоткеи:**
|
||||
- `v` / `м` - открыть изображение в полном размере (режим выбора)
|
||||
- `←` / `→` - навигация между изображениями (в режиме просмотра)
|
||||
- `Esc` - закрыть полноэкранный просмотр
|
||||
|
||||
## Фаза 12: Прослушивание голосовых сообщений [PLANNED]
|
||||
|
||||
### Этап 1: Инфраструктура аудио [TODO]
|
||||
- [ ] Модуль src/audio/
|
||||
- player.rs - AudioPlayer на rodio
|
||||
- cache.rs - VoiceCache для загруженных файлов
|
||||
- state.rs - PlaybackState (статус, позиция, громкость)
|
||||
- [ ] Зависимости
|
||||
- rodio 0.17 - Pure Rust аудио библиотека
|
||||
- Feature flag "audio" в Cargo.toml
|
||||
- [ ] AudioPlayer API
|
||||
- play() - воспроизведение файла
|
||||
- pause() / resume() - пауза/возобновление
|
||||
- stop() - остановка
|
||||
- seek() - перемотка
|
||||
- set_volume() - регулировка громкости
|
||||
- get_position() - текущая позиция
|
||||
- [ ] VoiceCache
|
||||
- Кэш загруженных OGG файлов в ~/.cache/tele-tui/voice/
|
||||
- LRU политика очистки
|
||||
- MAX_VOICE_CACHE_SIZE = 100 MB
|
||||
|
||||
### Этап 2: Интеграция с TDLib [TODO]
|
||||
- [ ] Обработка MessageContentVoiceNote
|
||||
- Добавить VoiceNoteInfo в MessageInfo
|
||||
- Извлечение file_id, duration, mime_type, waveform
|
||||
- Метка формата (OGG Opus обычно)
|
||||
- [ ] Загрузка файлов
|
||||
- Метод TdClient::download_voice_note(file_id)
|
||||
- Асинхронная загрузка через downloadFile API
|
||||
- Обработка состояний (pending/downloading/ready)
|
||||
- [ ] Кэширование
|
||||
- Сохранение путей к загруженным файлам
|
||||
- Не перезагружать уже скачанные голосовые
|
||||
- Проверка существования файла перед воспроизведением
|
||||
|
||||
### Этап 3: UI для воспроизведения [TODO]
|
||||
- [ ] Индикатор в сообщении
|
||||
- Иконка 🎤 и длительность голосового
|
||||
- Progress bar во время воспроизведения
|
||||
- Статус: ▶ (playing), ⏸ (paused), ⏹ (stopped), ⏳ (loading)
|
||||
- Текущее время / общая длительность (0:08 / 0:15)
|
||||
- [ ] Модификация render_messages()
|
||||
- render_voice_note() для голосовых сообщений
|
||||
- render_progress_bar() для индикатора воспроизведения
|
||||
- Hint "[Space] Воспроизвести" если не играет
|
||||
- [ ] Footer с управлением
|
||||
- Отображение доступных команд при воспроизведении
|
||||
- "[Space] Play/Pause [s] Stop [←/→] Seek [↑/↓] Volume"
|
||||
- [ ] Waveform визуализация (опционально)
|
||||
- Конвертация waveform данных из Telegram в ASCII bars
|
||||
- Использование символов ▁▂▃▄▅▆▇█ для визуализации
|
||||
|
||||
### Этап 4: Хоткеи для управления [TODO]
|
||||
- [ ] Новые команды
|
||||
- PlayVoice - Space в режиме выбора голосового
|
||||
- PauseVoice - Space во время воспроизведения
|
||||
- StopVoice - s / ы
|
||||
- SeekBackward - ← (перемотка назад на 5 сек)
|
||||
- SeekForward - → (перемотка вперед на 5 сек)
|
||||
- VolumeUp - ↑ (увеличить на 10%)
|
||||
- VolumeDown - ↓ (уменьшить на 10%)
|
||||
- [ ] Контекстная обработка
|
||||
- Space работает как play/pause в зависимости от состояния
|
||||
- ← / → для seek только во время воспроизведения
|
||||
- ↑ / ↓ для громкости только во время воспроизведения
|
||||
- [ ] Поддержка русской раскладки
|
||||
- s / ы - stop
|
||||
- Остальные клавиши универсальны (Space, стрелки)
|
||||
|
||||
### Этап 5: Конфигурация и UX [TODO]
|
||||
- [ ] AudioConfig в config.toml
|
||||
- enabled: bool - включить/отключить аудио
|
||||
- default_volume: f32 - громкость по умолчанию (0.0 - 1.0)
|
||||
- seek_step_seconds: i32 - шаг перемотки в секундах
|
||||
- autoplay: bool - автовоспроизведение при выборе
|
||||
- cache_size_mb: usize - размер кэша голосовых
|
||||
- show_waveform: bool - показывать waveform визуализацию
|
||||
- system_player_fallback: bool - использовать системный плеер
|
||||
- system_player: String - команда системного плеера (mpv, ffplay)
|
||||
- [ ] Асинхронная загрузка
|
||||
- Не блокировать UI во время загрузки файла
|
||||
- Индикатор загрузки с процентами
|
||||
- Возможность отмены загрузки
|
||||
- [ ] Обновление UI
|
||||
- Ticker для обновления progress bar (каждые 100ms)
|
||||
- Плавное обновление позиции воспроизведения
|
||||
- Автоматическая остановка при достижении конца
|
||||
|
||||
### Этап 6: Обработка ошибок [TODO]
|
||||
- [ ] Graceful fallback на системный плеер
|
||||
- Если rodio не работает - использовать mpv/ffplay
|
||||
- Логирование ошибок через tracing
|
||||
- Предупреждение пользователю если аудио недоступно
|
||||
- [ ] Обработка ошибок загрузки
|
||||
- Таймаут загрузки (30 сек)
|
||||
- Повторная попытка по запросу
|
||||
- Сообщение об ошибке в UI
|
||||
- [ ] Ограничения
|
||||
- Максимальный размер файла для кэша
|
||||
- Автоматическая очистка старых файлов
|
||||
- Предупреждение для очень длинных голосовых (>5 мин)
|
||||
|
||||
### Этап 7: Дополнительные улучшения [TODO]
|
||||
- [ ] Управление воспроизведением
|
||||
- Автоматическая остановка при закрытии чата
|
||||
- Сохранение позиции при паузе
|
||||
- Автопереход к следующему голосовому (опционально)
|
||||
- [ ] Оптимизация
|
||||
- Lazy loading (загрузка только при воспроизведении)
|
||||
- Префетчинг следующего голосового (опционально)
|
||||
- Минимальная задержка при нажатии Play
|
||||
- [ ] Визуальные улучшения
|
||||
- Анимация progress bar
|
||||
- Цветовая индикация статуса (зеленый - playing, желтый - paused)
|
||||
- Иконки в зависимости от статуса
|
||||
|
||||
### Технические детали
|
||||
- **Аудио библиотека:**
|
||||
- rodio 0.17 (Pure Rust, кроссплатформенная)
|
||||
- Поддержка OGG Opus (формат голосовых в Telegram)
|
||||
- Контроль воспроизведения через Sink API
|
||||
- **Платформы:**
|
||||
- Linux (ALSA, PulseAudio)
|
||||
- macOS (CoreAudio)
|
||||
- Windows (WASAPI)
|
||||
- **Fallback:**
|
||||
- mpv --no-video (универсальный плеер)
|
||||
- ffplay -nodisp (из ffmpeg)
|
||||
- **Новые хоткеи:**
|
||||
- `Space` - воспроизвести/пауза (в режиме выбора голосового)
|
||||
- `s` / `ы` - остановить воспроизведение
|
||||
- `←` / `→` - перемотка -5с / +5с (во время воспроизведения)
|
||||
- `↑` / `↓` - громкость +/- 10% (во время воспроизведения)
|
||||
|
||||
## Фаза 13: Глубокий рефакторинг архитектуры [PLANNED]
|
||||
|
||||
**Мотивация:** Код вырос до критических размеров - некоторые файлы содержат >1000 строк, что затрудняет поддержку и навигацию. Необходимо разбить монолитные файлы на логические модули.
|
||||
|
||||
**Проблемы:**
|
||||
- `src/input/main_input.rs` - 1199 строк (самый большой файл!)
|
||||
- `src/app/mod.rs` - 1015 строк, 116 функций (God Object)
|
||||
- `src/ui/messages.rs` - 893 строки
|
||||
- `src/tdlib/messages.rs` - 833 строки
|
||||
- `src/config/mod.rs` - 642 строки
|
||||
|
||||
### Этап 1: Разбить input/main_input.rs (1199 → <200 строк) [TODO]
|
||||
|
||||
**Текущая проблема:**
|
||||
- Весь input handling в одном файле
|
||||
- Функции по 300-400 строк
|
||||
- Невозможно быстро найти нужный handler
|
||||
|
||||
**План:**
|
||||
- [ ] Создать `src/input/handlers/` директорию
|
||||
- [ ] Создать `handlers/chat.rs` - обработка ввода в открытом чате
|
||||
- Переместить `handle_open_chat_keyboard_input()`
|
||||
- Обработка скролла, выбора сообщений
|
||||
- ~300-400 строк
|
||||
- [ ] Создать `handlers/chat_list.rs` - обработка в списке чатов
|
||||
- Переместить `handle_chat_list_keyboard_input()`
|
||||
- Навигация по чатам, папки
|
||||
- ~200-300 строк
|
||||
- [ ] Создать `handlers/compose.rs` - режимы edit/reply/forward
|
||||
- Обработка ввода в режимах редактирования
|
||||
- Input field управление (курсор, backspace, delete)
|
||||
- ~200 строк
|
||||
- [ ] Создать `handlers/modal.rs` - модалки
|
||||
- Delete confirmation
|
||||
- Emoji picker
|
||||
- Profile modal
|
||||
- ~150 строк
|
||||
- [ ] Создать `handlers/search.rs` - поиск
|
||||
- Search mode в чате
|
||||
- Search mode в списке чатов
|
||||
- ~100 строк
|
||||
- [ ] Обновить `main_input.rs` - только роутинг
|
||||
- Определение текущего режима
|
||||
- Делегация в нужный handler
|
||||
- <200 строк
|
||||
|
||||
**Результат:** 1199 строк → 6 файлов по <400 строк
|
||||
|
||||
### Этап 2: Уменьшить app/mod.rs (116 функций → traits) [TODO]
|
||||
|
||||
**Текущая проблема:**
|
||||
- God Object с 116 функциями
|
||||
- Сложно найти нужный метод
|
||||
- Нарушение Single Responsibility Principle
|
||||
|
||||
**План:**
|
||||
- [ ] Создать `app/methods/` директорию
|
||||
- [ ] Создать trait `NavigationMethods`
|
||||
- `next_chat()`, `previous_chat()`
|
||||
- `scroll_up()`, `scroll_down()`
|
||||
- `select_chat()`, `open_chat()`
|
||||
- ~15 методов
|
||||
- [ ] Создать trait `MessageMethods`
|
||||
- `send_message()`, `edit_message()`, `delete_message()`
|
||||
- `reply_to_message()`, `forward_message()`
|
||||
- `select_message()`, `deselect_message()`
|
||||
- ~20 методов
|
||||
- [ ] Создать trait `ComposeMethods`
|
||||
- `enter_edit_mode()`, `enter_reply_mode()`, `enter_forward_mode()`
|
||||
- `handle_input_char()`, `move_cursor_left()`, `move_cursor_right()`
|
||||
- ~15 методов
|
||||
- [ ] Создать trait `SearchMethods`
|
||||
- `start_search()`, `search_next()`, `search_previous()`
|
||||
- `clear_search()`
|
||||
- ~5 методов
|
||||
- [ ] Создать trait `ModalMethods`
|
||||
- `show_delete_confirmation()`, `show_emoji_picker()`
|
||||
- `show_profile()`, `close_modal()`
|
||||
- ~10 методов
|
||||
- [ ] Оставить в `app/mod.rs` только:
|
||||
- Struct definition
|
||||
- Constructor (new, with_client)
|
||||
- Getters/setters для полей
|
||||
- ~30-40 методов
|
||||
|
||||
**Структура:**
|
||||
```rust
|
||||
// app/mod.rs - только core
|
||||
impl<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
35
config.example.toml
Normal 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
|
||||
@@ -227,54 +227,6 @@ impl ChatFilter {
|
||||
}
|
||||
}
|
||||
|
||||
/// Сортировка чатов
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChatSortOrder {
|
||||
/// По времени последнего сообщения (новые сверху)
|
||||
ByLastMessage,
|
||||
|
||||
/// По названию (алфавит)
|
||||
ByTitle,
|
||||
|
||||
/// По количеству непрочитанных (больше сверху)
|
||||
ByUnreadCount,
|
||||
|
||||
/// Закреплённые сверху, остальные по последнему сообщению
|
||||
PinnedFirst,
|
||||
}
|
||||
|
||||
impl ChatSortOrder {
|
||||
/// Сортирует чаты согласно порядку
|
||||
///
|
||||
/// # Note
|
||||
///
|
||||
/// Модифицирует переданный slice in-place
|
||||
pub fn sort(&self, chats: &mut [&ChatInfo]) {
|
||||
match self {
|
||||
ChatSortOrder::ByLastMessage => {
|
||||
chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
|
||||
}
|
||||
ChatSortOrder::ByTitle => {
|
||||
chats.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||
}
|
||||
ChatSortOrder::ByUnreadCount => {
|
||||
chats.sort_by(|a, b| b.unread_count.cmp(&a.unread_count));
|
||||
}
|
||||
ChatSortOrder::PinnedFirst => {
|
||||
chats.sort_by(|a, b| {
|
||||
// Сначала по pinned статусу
|
||||
match (a.is_pinned, b.is_pinned) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
// Если оба pinned или оба не pinned - по времени
|
||||
_ => b.last_message_date.cmp(&a.last_message_date),
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -379,32 +331,4 @@ mod tests {
|
||||
assert_eq!(ChatFilter::count_unread_mentions(&chats, &criteria), 3); // 1 + 2
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_title() {
|
||||
let chat1 = create_test_chat(1, "Charlie", None, vec![0], 0, 0, false, false);
|
||||
let chat2 = create_test_chat(2, "Alice", None, vec![0], 0, 0, false, false);
|
||||
let chat3 = create_test_chat(3, "Bob", None, vec![0], 0, 0, false, false);
|
||||
|
||||
let mut chats = vec![&chat1, &chat2, &chat3];
|
||||
ChatSortOrder::ByTitle.sort(&mut chats);
|
||||
|
||||
assert_eq!(chats[0].title, "Alice");
|
||||
assert_eq!(chats[1].title, "Bob");
|
||||
assert_eq!(chats[2].title, "Charlie");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_pinned_first() {
|
||||
let chat1 = create_test_chat(1, "Chat 1", None, vec![0], 0, 0, false, false);
|
||||
let chat2 = create_test_chat(2, "Chat 2", None, vec![0], 0, 0, true, false);
|
||||
let chat3 = create_test_chat(3, "Chat 3", None, vec![0], 0, 0, true, false);
|
||||
|
||||
let mut chats = vec![&chat1, &chat2, &chat3];
|
||||
ChatSortOrder::PinnedFirst.sort(&mut chats);
|
||||
|
||||
// Pinned chats first
|
||||
assert!(chats[0].is_pinned);
|
||||
assert!(chats[1].is_pinned);
|
||||
assert!(!chats[2].is_pinned);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,8 +33,6 @@ pub enum ChatState {
|
||||
Forward {
|
||||
/// ID сообщения для пересылки
|
||||
message_id: MessageId,
|
||||
/// Находимся в режиме выбора чата для пересылки
|
||||
selecting_chat: bool,
|
||||
},
|
||||
|
||||
/// Подтверждение удаления сообщения
|
||||
|
||||
@@ -185,7 +185,6 @@ impl MessageViewState {
|
||||
pub fn start_forward(&mut self, message_id: MessageId) {
|
||||
self.chat_state = ChatState::Forward {
|
||||
message_id,
|
||||
selecting_chat: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
mod chat_filter;
|
||||
mod chat_state;
|
||||
mod state;
|
||||
|
||||
pub use chat_filter::{ChatFilter, ChatFilterCriteria};
|
||||
pub use chat_state::ChatState;
|
||||
pub use state::AppScreen;
|
||||
|
||||
@@ -119,6 +121,19 @@ impl<T: TdClientTrait> App<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Получить команду из KeyEvent используя настроенные keybindings.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key` - KeyEvent от пользователя
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// `Some(Command)` если найдена команда для этой клавиши, `None` если нет
|
||||
pub fn get_command(&self, key: crossterm::event::KeyEvent) -> Option<crate::config::Command> {
|
||||
self.config.keybindings.get_command(&key)
|
||||
}
|
||||
|
||||
pub fn next_chat(&mut self) {
|
||||
let filtered = self.get_filtered_chats();
|
||||
if filtered.is_empty() {
|
||||
@@ -297,31 +312,15 @@ impl<T: TdClientTrait> App<T> {
|
||||
}
|
||||
|
||||
pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> {
|
||||
let folder_filtered: Vec<&ChatInfo> = match self.selected_folder_id {
|
||||
None => self.chats.iter().collect(), // All - показываем все
|
||||
Some(folder_id) => self
|
||||
.chats
|
||||
.iter()
|
||||
.filter(|c| c.folder_ids.contains(&folder_id))
|
||||
.collect(),
|
||||
};
|
||||
// Используем ChatFilter для централизованной фильтрации
|
||||
let mut criteria = ChatFilterCriteria::new()
|
||||
.with_folder(self.selected_folder_id);
|
||||
|
||||
if self.search_query.is_empty() {
|
||||
folder_filtered
|
||||
} else {
|
||||
let query = self.search_query.to_lowercase();
|
||||
folder_filtered
|
||||
.into_iter()
|
||||
.filter(|c| {
|
||||
// Поиск по названию чата
|
||||
c.title.to_lowercase().contains(&query) ||
|
||||
// Поиск по username (@...)
|
||||
c.username.as_ref()
|
||||
.map(|u| u.to_lowercase().contains(&query))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect()
|
||||
if !self.search_query.is_empty() {
|
||||
criteria = criteria.with_search(self.search_query.clone());
|
||||
}
|
||||
|
||||
ChatFilter::filter(&self.chats, &criteria)
|
||||
}
|
||||
|
||||
pub fn next_filtered_chat(&mut self) {
|
||||
@@ -412,7 +411,6 @@ impl<T: TdClientTrait> App<T> {
|
||||
if let Some(msg) = self.get_selected_message() {
|
||||
self.chat_state = ChatState::Forward {
|
||||
message_id: msg.id(),
|
||||
selecting_chat: true,
|
||||
};
|
||||
// Сбрасываем выбор чата на первый
|
||||
self.chat_list_state.select(Some(0));
|
||||
@@ -1009,6 +1007,9 @@ impl App<TdClient> {
|
||||
///
|
||||
/// A new `App<TdClient>` instance ready to start authentication.
|
||||
pub fn new(config: crate::config::Config) -> App<TdClient> {
|
||||
App::with_client(config, TdClient::new())
|
||||
let mut client = TdClient::new();
|
||||
// Configure notifications from config
|
||||
client.configure_notifications(&config.notifications);
|
||||
App::with_client(config, client)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,13 +92,6 @@ impl KeyBinding {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_event(event: KeyEvent) -> Self {
|
||||
Self {
|
||||
key: event.code,
|
||||
modifiers: event.modifiers,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn matches(&self, event: &KeyEvent) -> bool {
|
||||
self.key == event.code && self.modifiers == event.modifiers
|
||||
}
|
||||
@@ -163,9 +156,7 @@ impl Keybindings {
|
||||
]);
|
||||
|
||||
// Chat list
|
||||
bindings.insert(Command::OpenChat, vec![
|
||||
KeyBinding::new(KeyCode::Enter),
|
||||
]);
|
||||
// Note: Enter обрабатывается через Command::SubmitMessage в handle_enter_key()
|
||||
for i in 1..=9 {
|
||||
let cmd = match i {
|
||||
1 => Command::SelectFolder1,
|
||||
@@ -185,9 +176,9 @@ impl Keybindings {
|
||||
}
|
||||
|
||||
// Message actions
|
||||
bindings.insert(Command::EditMessage, vec![
|
||||
KeyBinding::new(KeyCode::Up),
|
||||
]);
|
||||
// Note: EditMessage (Up) обрабатывается напрямую в handle_open_chat_keyboard_input
|
||||
// в зависимости от контекста (пустой инпут). Не привязываем здесь, чтобы не
|
||||
// конфликтовать с Command::MoveUp в списке чатов.
|
||||
bindings.insert(Command::DeleteMessage, vec![
|
||||
KeyBinding::new(KeyCode::Delete),
|
||||
KeyBinding::new(KeyCode::Char('d')),
|
||||
@@ -209,9 +200,7 @@ impl Keybindings {
|
||||
KeyBinding::new(KeyCode::Char('e')),
|
||||
KeyBinding::new(KeyCode::Char('у')), // RU
|
||||
]);
|
||||
bindings.insert(Command::SelectMessage, vec![
|
||||
KeyBinding::new(KeyCode::Enter),
|
||||
]);
|
||||
// Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key()
|
||||
|
||||
// Input
|
||||
bindings.insert(Command::SubmitMessage, vec![
|
||||
@@ -241,8 +230,8 @@ impl Keybindings {
|
||||
|
||||
// Profile
|
||||
bindings.insert(Command::OpenProfile, vec![
|
||||
KeyBinding::new(KeyCode::Char('i')),
|
||||
KeyBinding::new(KeyCode::Char('ш')), // RU
|
||||
KeyBinding::with_ctrl(KeyCode::Char('i')),
|
||||
KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU
|
||||
]);
|
||||
|
||||
Self { bindings }
|
||||
@@ -257,32 +246,6 @@ impl Keybindings {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Проверяет соответствует ли событие команде
|
||||
pub fn matches(&self, event: &KeyEvent, command: Command) -> bool {
|
||||
self.bindings
|
||||
.get(&command)
|
||||
.map(|bindings| bindings.iter().any(|binding| binding.matches(event)))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Возвращает все привязки для команды
|
||||
pub fn get_bindings(&self, command: Command) -> Option<&[KeyBinding]> {
|
||||
self.bindings.get(&command).map(|v| v.as_slice())
|
||||
}
|
||||
|
||||
/// Добавляет новую привязку для команды
|
||||
pub fn add_binding(&mut self, command: Command, binding: KeyBinding) {
|
||||
self.bindings
|
||||
.entry(command)
|
||||
.or_insert_with(Vec::new)
|
||||
.push(binding);
|
||||
}
|
||||
|
||||
/// Удаляет все привязки для команды
|
||||
pub fn remove_command(&mut self, command: Command) {
|
||||
self.bindings.remove(&command);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Keybindings {
|
||||
@@ -434,9 +397,9 @@ mod tests {
|
||||
let kb = Keybindings::default();
|
||||
|
||||
// Проверяем навигацию
|
||||
assert!(kb.matches(&KeyEvent::from(KeyCode::Up), Command::MoveUp));
|
||||
assert!(kb.matches(&KeyEvent::from(KeyCode::Char('k')), Command::MoveUp));
|
||||
assert!(kb.matches(&KeyEvent::from(KeyCode::Char('р')), Command::MoveUp));
|
||||
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Up)), Some(Command::MoveUp));
|
||||
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('k'))), Some(Command::MoveUp));
|
||||
assert_eq!(kb.get_command(&KeyEvent::from(KeyCode::Char('р'))), Some(Command::MoveUp));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -459,14 +422,4 @@ mod tests {
|
||||
|
||||
assert_eq!(kb.get_command(&event), Some(Command::OpenSearch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_binding() {
|
||||
let mut kb = Keybindings::default();
|
||||
|
||||
kb.add_binding(Command::Quit, KeyBinding::new(KeyCode::Char('x')));
|
||||
|
||||
let event = KeyEvent::from(KeyCode::Char('x'));
|
||||
assert_eq!(kb.get_command(&event), Some(Command::Quit));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
pub mod keybindings;
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub use keybindings::{Command, KeyBinding, Keybindings};
|
||||
pub use keybindings::{Command, Keybindings};
|
||||
|
||||
/// Главная конфигурация приложения.
|
||||
///
|
||||
@@ -35,6 +34,10 @@ pub struct Config {
|
||||
/// Горячие клавиши.
|
||||
#[serde(default)]
|
||||
pub keybindings: Keybindings,
|
||||
|
||||
/// Настройки desktop notifications.
|
||||
#[serde(default)]
|
||||
pub notifications: NotificationsConfig,
|
||||
}
|
||||
|
||||
/// Общие настройки приложения.
|
||||
@@ -72,6 +75,31 @@ pub struct ColorsConfig {
|
||||
pub reaction_other: String,
|
||||
}
|
||||
|
||||
/// Настройки desktop notifications.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationsConfig {
|
||||
/// Включить/выключить уведомления
|
||||
#[serde(default = "default_notifications_enabled")]
|
||||
pub enabled: bool,
|
||||
|
||||
/// Уведомлять только при @упоминаниях
|
||||
#[serde(default)]
|
||||
pub only_mentions: bool,
|
||||
|
||||
/// Показывать превью текста сообщения
|
||||
#[serde(default = "default_show_preview")]
|
||||
pub show_preview: bool,
|
||||
|
||||
/// Продолжительность показа уведомления (миллисекунды)
|
||||
/// 0 = системное значение по умолчанию
|
||||
#[serde(default = "default_notification_timeout")]
|
||||
pub timeout_ms: i32,
|
||||
|
||||
/// Уровень важности: "low", "normal", "critical"
|
||||
#[serde(default = "default_notification_urgency")]
|
||||
pub urgency: String,
|
||||
}
|
||||
|
||||
// Дефолтные значения
|
||||
fn default_timezone() -> String {
|
||||
"+03:00".to_string()
|
||||
@@ -97,6 +125,22 @@ fn default_reaction_other_color() -> String {
|
||||
"gray".to_string()
|
||||
}
|
||||
|
||||
fn default_notifications_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_show_preview() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_notification_timeout() -> i32 {
|
||||
5000 // 5 seconds
|
||||
}
|
||||
|
||||
fn default_notification_urgency() -> String {
|
||||
"normal".to_string()
|
||||
}
|
||||
|
||||
impl Default for GeneralConfig {
|
||||
fn default() -> Self {
|
||||
Self { timezone: default_timezone() }
|
||||
@@ -115,6 +159,17 @@ impl Default for ColorsConfig {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NotificationsConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: default_notifications_enabled(),
|
||||
only_mentions: false,
|
||||
show_preview: default_show_preview(),
|
||||
timeout_ms: default_notification_timeout(),
|
||||
urgency: default_notification_urgency(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Config {
|
||||
fn default() -> Self {
|
||||
@@ -122,6 +177,7 @@ impl Default for Config {
|
||||
general: GeneralConfig::default(),
|
||||
colors: ColorsConfig::default(),
|
||||
keybindings: Keybindings::default(),
|
||||
notifications: NotificationsConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -347,8 +403,6 @@ impl Config {
|
||||
/// API_HASH=your_api_hash_here
|
||||
/// ```
|
||||
pub fn load_credentials() -> Result<(i32, String), String> {
|
||||
use std::env;
|
||||
|
||||
// 1. Пробуем загрузить из ~/.config/tele-tui/credentials
|
||||
if let Some(credentials) = Self::load_credentials_from_file() {
|
||||
return Ok(credentials);
|
||||
@@ -423,7 +477,7 @@ impl Config {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crossterm::event::{KeyEvent, KeyModifiers};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
|
||||
#[test]
|
||||
fn test_config_default_includes_keybindings() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -19,29 +19,17 @@ use std::time::Duration;
|
||||
///
|
||||
/// `true` если команда была обработана, `false` если нет
|
||||
pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) -> bool {
|
||||
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
let command = app.get_command(key);
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('r') if has_ctrl => {
|
||||
// Ctrl+R - обновить список чатов
|
||||
app.status_message = Some("Обновление чатов...".to_string());
|
||||
let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||
app.status_message = None;
|
||||
true
|
||||
}
|
||||
KeyCode::Char('s') if has_ctrl => {
|
||||
match command {
|
||||
Some(crate::config::Command::OpenSearch) => {
|
||||
// Ctrl+S - начать поиск (только если чат не открыт)
|
||||
if app.selected_chat_id.is_none() {
|
||||
app.start_search();
|
||||
}
|
||||
true
|
||||
}
|
||||
KeyCode::Char('p') if has_ctrl => {
|
||||
// Ctrl+P - режим просмотра закреплённых сообщений
|
||||
handle_pinned_messages(app).await;
|
||||
true
|
||||
}
|
||||
KeyCode::Char('f') if has_ctrl => {
|
||||
Some(crate::config::Command::OpenSearchInChat) => {
|
||||
// Ctrl+F - поиск по сообщениям в открытом чате
|
||||
if app.selected_chat_id.is_some()
|
||||
&& !app.is_pinned_mode()
|
||||
@@ -51,8 +39,28 @@ pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: Key
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => {
|
||||
// Проверяем специальные комбинации, которых нет в Command enum
|
||||
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
match key.code {
|
||||
KeyCode::Char('r') if has_ctrl => {
|
||||
// Ctrl+R - обновить список чатов
|
||||
app.status_message = Some("Обновление чатов...".to_string());
|
||||
let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||
// Синхронизируем muted чаты после обновления
|
||||
app.td_client.sync_notification_muted_chats();
|
||||
app.status_message = None;
|
||||
true
|
||||
}
|
||||
KeyCode::Char('p') if has_ctrl => {
|
||||
// Ctrl+P - режим просмотра закреплённых сообщений
|
||||
handle_pinned_messages(app).await;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обрабатывает загрузку и отображение закреплённых сообщений
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,26 +1,14 @@
|
||||
//! Input handlers organized by screen/mode
|
||||
//! Input handlers organized by functionality
|
||||
//!
|
||||
//! This module contains handlers for different input contexts:
|
||||
//! - global: Global commands (Ctrl+R, Ctrl+S, etc.)
|
||||
//! - profile: Profile mode input
|
||||
//! - search: Search modes (chat search, message search)
|
||||
//! - modal: Modal modes (pinned, reactions, delete, forward)
|
||||
//! - messages: Message input when chat is open
|
||||
//! - chat_list: Chat list navigation
|
||||
//! - clipboard: Clipboard operations
|
||||
//! - profile: Profile helper functions
|
||||
|
||||
pub mod chat_list;
|
||||
pub mod clipboard;
|
||||
pub mod global;
|
||||
pub mod messages;
|
||||
pub mod modal;
|
||||
pub mod profile;
|
||||
pub mod search;
|
||||
|
||||
// pub use chat_list::*; // Пока не используется
|
||||
pub use clipboard::*;
|
||||
pub use global::*;
|
||||
// pub use messages::*; // Пока не используется
|
||||
// pub use modal::*; // Пока не используется
|
||||
pub use profile::get_available_actions_count; // Используется в main_input
|
||||
// pub use search::*; // Пока не используется
|
||||
pub use profile::get_available_actions_count;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,15 +1,4 @@
|
||||
//! Profile mode input handling
|
||||
|
||||
use crate::app::App;
|
||||
use crate::tdlib::TdClientTrait;
|
||||
use crossterm::event::KeyEvent;
|
||||
|
||||
/// Обрабатывает ввод в режиме профиля
|
||||
pub async fn handle_profile_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
// TODO: Implement profile input handling
|
||||
// Временно делегируем обратно в main_input
|
||||
let _ = (app, key);
|
||||
}
|
||||
//! Profile mode helper functions
|
||||
|
||||
/// Возвращает количество доступных действий в профиле
|
||||
pub fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use crate::tdlib::ChatAction;
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg, with_timeout_ignore};
|
||||
use crate::utils::modal_handler::handle_yes_no;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// Обработка режима профиля пользователя/чата
|
||||
@@ -18,7 +18,7 @@ use std::time::{Duration, Instant};
|
||||
/// - Навигацию по действиям профиля (Up/Down)
|
||||
/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу
|
||||
/// - Выход из режима профиля (Esc)
|
||||
async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
|
||||
// Обработка подтверждения выхода из группы
|
||||
let confirmation_step = app.get_leave_group_confirmation_step();
|
||||
if confirmation_step > 0 {
|
||||
@@ -58,20 +58,20 @@ async fn handle_profile_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent)
|
||||
}
|
||||
|
||||
// Обычная навигация по профилю
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_profile_mode();
|
||||
}
|
||||
KeyCode::Up => {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_profile_action();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
if let Some(profile) = app.get_profile_info() {
|
||||
let max_actions = get_available_actions_count(profile);
|
||||
app.select_next_profile_action(max_actions);
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
// Выполнить выбранное действие
|
||||
let Some(profile) = app.get_profile_info() else {
|
||||
return;
|
||||
@@ -170,17 +170,15 @@ async fn handle_profile_open<T: TdClientTrait>(app: &mut App<T>) {
|
||||
/// - Пересылку сообщения (f/а)
|
||||
/// - Копирование сообщения (y/н)
|
||||
/// - Добавление реакции (e/у)
|
||||
async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Up => {
|
||||
async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_message();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.select_next_message();
|
||||
// Если вышли из режима выбора (индекс стал None), ничего не делаем
|
||||
}
|
||||
KeyCode::Char('d') | KeyCode::Char('в') | KeyCode::Delete => {
|
||||
// Показать модалку подтверждения удаления
|
||||
Some(crate::config::Command::DeleteMessage) => {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
@@ -192,16 +190,13 @@ async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, key: KeyEv
|
||||
};
|
||||
}
|
||||
}
|
||||
KeyCode::Char('r') | KeyCode::Char('к') => {
|
||||
// Начать режим ответа на выбранное сообщение
|
||||
Some(crate::config::Command::ReplyMessage) => {
|
||||
app.start_reply_to_selected();
|
||||
}
|
||||
KeyCode::Char('f') | KeyCode::Char('а') => {
|
||||
// Начать режим пересылки
|
||||
Some(crate::config::Command::ForwardMessage) => {
|
||||
app.start_forward_selected();
|
||||
}
|
||||
KeyCode::Char('y') | KeyCode::Char('н') => {
|
||||
// Копировать сообщение
|
||||
Some(crate::config::Command::CopyMessage) => {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
@@ -215,8 +210,7 @@ async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, key: KeyEv
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Char('e') | KeyCode::Char('у') => {
|
||||
// Открыть emoji picker для добавления реакции
|
||||
Some(crate::config::Command::ReactMessage) => {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
@@ -226,7 +220,6 @@ async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, key: KeyEv
|
||||
app.status_message = Some("Загрузка реакций...".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
// Запрашиваем доступные реакции
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(5),
|
||||
app.td_client
|
||||
@@ -452,43 +445,44 @@ async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка режима поиска по чатам (Ctrl+S)
|
||||
/// Обработка режима поиска по чатам
|
||||
///
|
||||
/// Обрабатывает:
|
||||
/// - Редактирование поискового запроса (Backspace, Char)
|
||||
/// - Навигацию по отфильтрованному списку (Up/Down)
|
||||
/// - Открытие выбранного чата (Enter)
|
||||
/// - Отмену поиска (Esc)
|
||||
async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.cancel_search();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Выбрать чат из отфильтрованного списка
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
app.select_filtered_chat();
|
||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||
open_chat_and_load_data(app, chat_id).await;
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.search_query.pop();
|
||||
// Сбрасываем выделение при изменении запроса
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
KeyCode::Down => {
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.next_filtered_chat();
|
||||
}
|
||||
KeyCode::Up => {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.previous_filtered_chat();
|
||||
}
|
||||
_ => {
|
||||
match key.code {
|
||||
KeyCode::Backspace => {
|
||||
app.search_query.pop();
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
app.search_query.push(c);
|
||||
// Сбрасываем выделение при изменении запроса
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка режима выбора чата для пересылки сообщения
|
||||
@@ -497,19 +491,19 @@ async fn handle_chat_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEve
|
||||
/// - Навигацию по списку чатов (Up/Down)
|
||||
/// - Пересылку сообщения в выбранный чат (Enter)
|
||||
/// - Отмену пересылки (Esc)
|
||||
async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
async fn handle_forward_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.cancel_forward();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
forward_selected_message(app).await;
|
||||
app.cancel_forward();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.next_chat();
|
||||
}
|
||||
KeyCode::Up => {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.previous_chat();
|
||||
}
|
||||
_ => {}
|
||||
@@ -710,18 +704,17 @@ async fn handle_delete_confirmation<T: TdClientTrait>(app: &mut App<T>, key: Key
|
||||
/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6)
|
||||
/// - Добавление/удаление реакции (Enter)
|
||||
/// - Выход из режима (Esc)
|
||||
async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Left => {
|
||||
async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveLeft) => {
|
||||
app.select_previous_reaction();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
KeyCode::Right => {
|
||||
Some(crate::config::Command::MoveRight) => {
|
||||
app.select_next_reaction();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
// Переход на ряд выше (8 эмодзи в ряду)
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
if let crate::app::ChatState::ReactionPicker {
|
||||
selected_index,
|
||||
..
|
||||
@@ -733,8 +726,7 @@ async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Down => {
|
||||
// Переход на ряд ниже (8 эмодзи в ряду)
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
if let crate::app::ChatState::ReactionPicker {
|
||||
selected_index,
|
||||
available_reactions,
|
||||
@@ -748,11 +740,10 @@ async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Добавить/убрать реакцию
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
send_reaction(app).await;
|
||||
}
|
||||
KeyCode::Esc => {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_reaction_picker_mode();
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
@@ -766,22 +757,20 @@ async fn handle_reaction_picker_mode<T: TdClientTrait>(app: &mut App<T>, key: Ke
|
||||
/// - Навигацию по закреплённым сообщениям (Up/Down)
|
||||
/// - Переход к сообщению в истории (Enter)
|
||||
/// - Выход из режима (Esc)
|
||||
async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_pinned_mode();
|
||||
}
|
||||
KeyCode::Up => {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_pinned();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.select_next_pinned();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Перейти к сообщению в истории
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
if let Some(msg_id) = app.get_selected_pinned_id() {
|
||||
let msg_id = MessageId::new(msg_id);
|
||||
// Ищем индекс сообщения в текущей истории
|
||||
let msg_index = app
|
||||
.td_client
|
||||
.current_chat_messages()
|
||||
@@ -789,7 +778,6 @@ async fn handle_pinned_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
.position(|m| m.id() == msg_id);
|
||||
|
||||
if let Some(idx) = msg_index {
|
||||
// Вычисляем scroll offset чтобы показать сообщение
|
||||
let total = app.td_client.current_chat_messages().len();
|
||||
app.message_scroll_offset = total.saturating_sub(idx + 5);
|
||||
}
|
||||
@@ -828,19 +816,18 @@ async fn perform_message_search<T: TdClientTrait>(app: &mut App<T>, query: &str)
|
||||
/// - Переход к выбранному сообщению (Enter)
|
||||
/// - Редактирование поискового запроса (Backspace, Char)
|
||||
/// - Выход из режима поиска (Esc)
|
||||
async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent, command: Option<crate::config::Command>) {
|
||||
match command {
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
app.exit_message_search_mode();
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('N') => {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.select_previous_search_result();
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('n') => {
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.select_next_search_result();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
// Перейти к выбранному сообщению
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
if let Some(msg_id) = app.get_selected_search_result_id() {
|
||||
let msg_id = MessageId::new(msg_id);
|
||||
let msg_index = app
|
||||
@@ -856,8 +843,15 @@ async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Key
|
||||
app.exit_message_search_mode();
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
match key.code {
|
||||
KeyCode::Char('N') => {
|
||||
app.select_previous_search_result();
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
app.select_next_search_result();
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
// Удаляем символ из запроса
|
||||
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
|
||||
return;
|
||||
};
|
||||
@@ -866,7 +860,6 @@ async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Key
|
||||
perform_message_search(app, &query).await;
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// Добавляем символ к запросу
|
||||
let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else {
|
||||
return;
|
||||
};
|
||||
@@ -876,6 +869,8 @@ async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Key
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка навигации в списке чатов
|
||||
@@ -883,26 +878,50 @@ async fn handle_message_search_mode<T: TdClientTrait>(app: &mut App<T>, key: Key
|
||||
/// Обрабатывает:
|
||||
/// - Up/Down/j/k: навигация между чатами
|
||||
/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib)
|
||||
async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('о') => {
|
||||
async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
||||
match command {
|
||||
Some(crate::config::Command::MoveDown) => {
|
||||
app.next_chat();
|
||||
}
|
||||
KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('р') => {
|
||||
Some(crate::config::Command::MoveUp) => {
|
||||
app.previous_chat();
|
||||
}
|
||||
// Цифры 1-9 - переключение папок
|
||||
KeyCode::Char(c) if c >= '1' && c <= '9' => {
|
||||
let folder_num = (c as usize) - ('1' as usize); // 0-based
|
||||
if folder_num == 0 {
|
||||
// 1 = All
|
||||
Some(crate::config::Command::SelectFolder1) => {
|
||||
app.selected_folder_id = None;
|
||||
} else {
|
||||
// 2, 3, 4... = папки из TDLib
|
||||
if let Some(folder) = app.td_client.folders().get(folder_num - 1) {
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder2) => {
|
||||
select_folder(app, 0).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder3) => {
|
||||
select_folder(app, 1).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder4) => {
|
||||
select_folder(app, 2).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder5) => {
|
||||
select_folder(app, 3).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder6) => {
|
||||
select_folder(app, 4).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder7) => {
|
||||
select_folder(app, 5).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder8) => {
|
||||
select_folder(app, 6).await;
|
||||
}
|
||||
Some(crate::config::Command::SelectFolder9) => {
|
||||
select_folder(app, 7).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn select_folder<T: TdClientTrait>(app: &mut App<T>, folder_idx: usize) {
|
||||
if let Some(folder) = app.td_client.folders().get(folder_idx) {
|
||||
let folder_id = folder.id;
|
||||
app.selected_folder_id = Some(folder_id);
|
||||
// Загружаем чаты папки
|
||||
app.status_message = Some("Загрузка чатов папки...".to_string());
|
||||
let _ = with_timeout(
|
||||
Duration::from_secs(5),
|
||||
@@ -910,12 +929,8 @@ async fn handle_chat_list_navigation<T: TdClientTrait>(app: &mut App<T>, key: Ke
|
||||
)
|
||||
.await;
|
||||
app.status_message = None;
|
||||
}
|
||||
}
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка ввода с клавиатуры в открытом чате
|
||||
@@ -930,14 +945,13 @@ async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>, key
|
||||
KeyCode::Backspace => {
|
||||
// Удаляем символ слева от курсора
|
||||
if app.cursor_position > 0 {
|
||||
let chars: Vec<char> = app.message_input.chars().collect();
|
||||
let mut new_input = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i != app.cursor_position - 1 {
|
||||
new_input.push(*ch);
|
||||
}
|
||||
}
|
||||
app.message_input = new_input;
|
||||
// Находим byte offset для позиции курсора
|
||||
let byte_pos = app.message_input
|
||||
.char_indices()
|
||||
.nth(app.cursor_position - 1)
|
||||
.map(|(pos, _)| pos)
|
||||
.unwrap_or(0);
|
||||
app.message_input.remove(byte_pos);
|
||||
app.cursor_position -= 1;
|
||||
}
|
||||
}
|
||||
@@ -945,30 +959,29 @@ async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>, key
|
||||
// Удаляем символ справа от курсора
|
||||
let len = app.message_input.chars().count();
|
||||
if app.cursor_position < len {
|
||||
let chars: Vec<char> = app.message_input.chars().collect();
|
||||
let mut new_input = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i != app.cursor_position {
|
||||
new_input.push(*ch);
|
||||
}
|
||||
}
|
||||
app.message_input = new_input;
|
||||
// Находим byte offset для текущей позиции курсора
|
||||
let byte_pos = app.message_input
|
||||
.char_indices()
|
||||
.nth(app.cursor_position)
|
||||
.map(|(pos, _)| pos)
|
||||
.unwrap_or(app.message_input.len());
|
||||
app.message_input.remove(byte_pos);
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// Вставляем символ в позицию курсора
|
||||
let chars: Vec<char> = app.message_input.chars().collect();
|
||||
let mut new_input = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i == app.cursor_position {
|
||||
new_input.push(c);
|
||||
if app.cursor_position >= app.message_input.chars().count() {
|
||||
// Вставка в конец строки - самый быстрый случай
|
||||
app.message_input.push(c);
|
||||
} else {
|
||||
// Находим byte offset для позиции курсора
|
||||
let byte_pos = app.message_input
|
||||
.char_indices()
|
||||
.nth(app.cursor_position)
|
||||
.map(|(pos, _)| pos)
|
||||
.unwrap_or(app.message_input.len());
|
||||
app.message_input.insert(byte_pos, c);
|
||||
}
|
||||
new_input.push(*ch);
|
||||
}
|
||||
if app.cursor_position >= chars.len() {
|
||||
new_input.push(c);
|
||||
}
|
||||
app.message_input = new_input;
|
||||
app.cursor_position += 1;
|
||||
|
||||
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
|
||||
@@ -1033,29 +1046,30 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
return;
|
||||
}
|
||||
|
||||
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
// Получаем команду из keybindings
|
||||
let command = app.get_command(key);
|
||||
|
||||
// Режим профиля
|
||||
if app.is_profile_mode() {
|
||||
handle_profile_mode(app, key).await;
|
||||
handle_profile_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Режим поиска по сообщениям
|
||||
if app.is_message_search_mode() {
|
||||
handle_message_search_mode(app, key).await;
|
||||
handle_message_search_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Режим просмотра закреплённых сообщений
|
||||
if app.is_pinned_mode() {
|
||||
handle_pinned_mode(app, key).await;
|
||||
handle_pinned_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Обработка ввода в режиме выбора реакции
|
||||
if app.is_reaction_picker_mode() {
|
||||
handle_reaction_picker_mode(app, key).await;
|
||||
handle_reaction_picker_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1067,46 +1081,50 @@ pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
|
||||
// Режим выбора чата для пересылки
|
||||
if app.is_forwarding() {
|
||||
handle_forward_mode(app, key).await;
|
||||
handle_forward_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Режим поиска
|
||||
if app.is_searching {
|
||||
handle_chat_search_mode(app, key).await;
|
||||
handle_chat_search_mode(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Обработка команд через keybindings
|
||||
match command {
|
||||
Some(crate::config::Command::SubmitMessage) => {
|
||||
// Enter - открыть чат, отправить сообщение или редактировать
|
||||
if key.code == KeyCode::Enter {
|
||||
handle_enter_key(app).await;
|
||||
return;
|
||||
}
|
||||
|
||||
Some(crate::config::Command::Cancel) => {
|
||||
// Esc - отменить выбор/редактирование/reply или закрыть чат
|
||||
if key.code == KeyCode::Esc {
|
||||
handle_escape_key(app).await;
|
||||
return;
|
||||
}
|
||||
Some(crate::config::Command::OpenProfile) => {
|
||||
// Открыть профиль (обычно 'i')
|
||||
if app.selected_chat_id.is_some() {
|
||||
handle_profile_open(app).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// Режим открытого чата
|
||||
if app.selected_chat_id.is_some() {
|
||||
// Режим выбора сообщения для редактирования/удаления
|
||||
if app.is_selecting_message() {
|
||||
handle_message_selection(app, key).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+U для профиля
|
||||
if key.code == KeyCode::Char('u') && has_ctrl {
|
||||
handle_profile_open(app).await;
|
||||
handle_message_selection(app, key, command).await;
|
||||
return;
|
||||
}
|
||||
|
||||
handle_open_chat_keyboard_input(app, key).await;
|
||||
} else {
|
||||
// В режиме списка чатов - навигация стрелками и переключение папок
|
||||
handle_chat_list_navigation(app, key).await;
|
||||
handle_chat_list_navigation(app, key, command).await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1124,10 +1142,11 @@ async fn open_chat_and_load_data<T: TdClientTrait>(app: &mut App<T>, chat_id: i6
|
||||
app.status_message = Some("Загрузка сообщений...".to_string());
|
||||
app.message_scroll_offset = 0;
|
||||
|
||||
// Загружаем все доступные сообщения (без лимита)
|
||||
// Загружаем последние 100 сообщений для быстрого открытия чата
|
||||
// Остальные сообщения будут подгружаться при скролле вверх
|
||||
match with_timeout_msg(
|
||||
Duration::from_secs(30),
|
||||
app.td_client.get_chat_history(ChatId::new(chat_id), i32::MAX),
|
||||
Duration::from_secs(10),
|
||||
app.td_client.get_chat_history(ChatId::new(chat_id), 100),
|
||||
"Таймаут загрузки сообщений",
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -7,6 +7,7 @@ pub mod constants;
|
||||
pub mod formatting;
|
||||
pub mod input;
|
||||
pub mod message_grouping;
|
||||
pub mod notifications;
|
||||
pub mod tdlib;
|
||||
pub mod types;
|
||||
pub mod ui;
|
||||
|
||||
11
src/main.rs
11
src/main.rs
@@ -4,6 +4,7 @@ mod constants;
|
||||
mod formatting;
|
||||
mod input;
|
||||
mod message_grouping;
|
||||
mod notifications;
|
||||
mod tdlib;
|
||||
mod types;
|
||||
mod ui;
|
||||
@@ -54,6 +55,14 @@ async fn main() -> Result<(), io::Error> {
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// Ensure terminal restoration on panic
|
||||
let panic_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
|
||||
panic_hook(info);
|
||||
}));
|
||||
|
||||
// Create app state
|
||||
let mut app = App::new(config);
|
||||
|
||||
@@ -237,6 +246,8 @@ async fn update_screen_state<T: tdlib::TdClientTrait>(app: &mut App<T>) -> bool
|
||||
if app.chat_list_state.selected().is_none() && !app.chats.is_empty() {
|
||||
app.chat_list_state.select(Some(0));
|
||||
}
|
||||
// Синхронизируем muted чаты для notifications
|
||||
app.td_client.sync_notification_muted_chats();
|
||||
// Убираем статус загрузки когда чаты появились
|
||||
if app.is_loading {
|
||||
app.is_loading = false;
|
||||
|
||||
355
src/notifications.rs
Normal file
355
src/notifications.rs
Normal 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!"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use super::messages::MessageManager;
|
||||
use super::reactions::ReactionManager;
|
||||
use super::types::{ChatInfo, FolderInfo, MessageInfo, NetworkState, ProfileInfo, UserOnlineStatus};
|
||||
use super::users::UserCache;
|
||||
use crate::notifications::NotificationManager;
|
||||
|
||||
/// TDLib client wrapper for Telegram integration.
|
||||
///
|
||||
@@ -52,6 +53,7 @@ pub struct TdClient {
|
||||
pub message_manager: MessageManager,
|
||||
pub user_cache: UserCache,
|
||||
pub reaction_manager: ReactionManager,
|
||||
pub notification_manager: NotificationManager,
|
||||
|
||||
// Состояние сети
|
||||
pub network_state: NetworkState,
|
||||
@@ -93,10 +95,27 @@ impl TdClient {
|
||||
message_manager: MessageManager::new(client_id),
|
||||
user_cache: UserCache::new(client_id),
|
||||
reaction_manager: ReactionManager::new(client_id),
|
||||
notification_manager: NotificationManager::new(),
|
||||
network_state: NetworkState::Connecting,
|
||||
}
|
||||
}
|
||||
|
||||
/// Configures notification manager from app config
|
||||
pub fn configure_notifications(&mut self, config: &crate::config::NotificationsConfig) {
|
||||
self.notification_manager.set_enabled(config.enabled);
|
||||
self.notification_manager.set_only_mentions(config.only_mentions);
|
||||
self.notification_manager.set_timeout(config.timeout_ms);
|
||||
self.notification_manager.set_urgency(config.urgency.clone());
|
||||
// Note: show_preview is used when formatting notification body
|
||||
}
|
||||
|
||||
/// Synchronizes muted chats from Telegram to notification manager.
|
||||
///
|
||||
/// Should be called after chats are loaded to ensure muted chats don't trigger notifications.
|
||||
pub fn sync_notification_muted_chats(&mut self) {
|
||||
self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
|
||||
}
|
||||
|
||||
// Делегирование к auth
|
||||
|
||||
/// Sends phone number for authentication.
|
||||
|
||||
@@ -260,11 +260,17 @@ impl TdClientTrait for TdClient {
|
||||
}
|
||||
|
||||
fn user_cache_mut(&mut self) -> &mut UserCache {
|
||||
self.user_cache_mut()
|
||||
&mut self.user_cache
|
||||
}
|
||||
|
||||
// ============ Notification methods ============
|
||||
fn sync_notification_muted_chats(&mut self) {
|
||||
self.notification_manager.sync_muted_chats(&self.chat_manager.chats);
|
||||
}
|
||||
|
||||
// ============ Update handling ============
|
||||
fn handle_update(&mut self, update: Update) {
|
||||
self.handle_update(update)
|
||||
// Delegate to the real implementation
|
||||
TdClient::handle_update(self, update)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
use crate::constants::{MAX_MESSAGES_IN_CHAT, TDLIB_MESSAGE_LIMIT};
|
||||
use crate::types::{ChatId, MessageId};
|
||||
use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, MessageContent, MessageSender, SearchMessagesFilter, TextParseMode};
|
||||
use tdlib_rs::enums::{InputMessageContent, InputMessageReplyTo, SearchMessagesFilter, TextParseMode};
|
||||
use tdlib_rs::functions;
|
||||
use tdlib_rs::types::{FormattedText, InputMessageReplyToMessage, InputMessageText, Message as TdMessage, TextParseModeMarkdown};
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
use super::types::{ForwardInfo, MessageBuilder, MessageInfo, ReactionInfo, ReplyInfo};
|
||||
use super::types::{MessageBuilder, MessageInfo, ReplyInfo};
|
||||
|
||||
/// Менеджер сообщений TDLib.
|
||||
///
|
||||
@@ -123,8 +124,6 @@ impl MessageManager {
|
||||
chat_id: ChatId,
|
||||
limit: i32,
|
||||
) -> Result<Vec<MessageInfo>, String> {
|
||||
use tokio::time::{sleep, Duration};
|
||||
|
||||
// ВАЖНО: Сначала открываем чат в TDLib
|
||||
// Это сообщает TDLib что пользователь открыл чат и нужно загрузить историю
|
||||
let _ = functions::open_chat(chat_id.as_i64(), self.client_id).await;
|
||||
@@ -193,6 +192,8 @@ impl MessageManager {
|
||||
if all_messages.is_empty() &&
|
||||
received_count < (chunk_size as usize) &&
|
||||
attempt < max_attempts_per_chunk {
|
||||
// Даём TDLib время на синхронизацию с сервером
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,9 @@ pub trait TdClientTrait: Send {
|
||||
fn set_main_chat_list_position(&mut self, position: i32);
|
||||
fn user_cache_mut(&mut self) -> &mut UserCache;
|
||||
|
||||
// ============ Notification methods ============
|
||||
fn sync_notification_muted_chats(&mut self);
|
||||
|
||||
// ============ Update handling ============
|
||||
fn handle_update(&mut self, update: Update);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use tdlib_rs::enums::TextEntityType;
|
||||
use tdlib_rs::types::TextEntity;
|
||||
|
||||
use crate::types::{ChatId, MessageId};
|
||||
@@ -192,6 +193,16 @@ impl MessageInfo {
|
||||
self.state.can_be_deleted_for_all_users
|
||||
}
|
||||
|
||||
/// Checks if the message contains a mention (@username or user mention)
|
||||
pub fn has_mention(&self) -> bool {
|
||||
self.content.entities.iter().any(|entity| {
|
||||
matches!(
|
||||
entity.r#type,
|
||||
TextEntityType::Mention | TextEntityType::MentionName(_)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reply_to(&self) -> Option<&ReplyInfo> {
|
||||
self.interactions.reply_to.as_ref()
|
||||
}
|
||||
@@ -475,6 +486,39 @@ mod tests {
|
||||
assert!(message.can_be_edited());
|
||||
assert!(message.can_be_deleted_for_all_users());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_message_has_mention() {
|
||||
// Message without mentions
|
||||
let message = MessageBuilder::new(MessageId::new(1))
|
||||
.text("Hello world")
|
||||
.build();
|
||||
assert!(!message.has_mention());
|
||||
|
||||
// Message with @mention
|
||||
let message_with_mention = MessageBuilder::new(MessageId::new(2))
|
||||
.text("Hello @user")
|
||||
.entities(vec![TextEntity {
|
||||
offset: 6,
|
||||
length: 5,
|
||||
r#type: TextEntityType::Mention,
|
||||
}])
|
||||
.build();
|
||||
assert!(message_with_mention.has_mention());
|
||||
|
||||
// Message with MentionName
|
||||
let message_with_mention_name = MessageBuilder::new(MessageId::new(3))
|
||||
.text("Hello John")
|
||||
.entities(vec![TextEntity {
|
||||
offset: 6,
|
||||
length: 4,
|
||||
r#type: TextEntityType::MentionName(
|
||||
tdlib_rs::types::TextEntityTypeMentionName { user_id: 123 },
|
||||
),
|
||||
}])
|
||||
.build();
|
||||
assert!(message_with_mention_name.has_mention());
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
||||
@@ -19,12 +19,29 @@ use super::types::ReactionInfo;
|
||||
|
||||
/// Обрабатывает Update::NewMessage - добавление нового сообщения
|
||||
pub fn handle_new_message_update(client: &mut TdClient, new_msg: UpdateNewMessage) {
|
||||
// Добавляем новое сообщение если это текущий открытый чат
|
||||
let chat_id = ChatId::new(new_msg.message.chat_id);
|
||||
|
||||
// Если сообщение НЕ для текущего открытого чата - отправляем уведомление
|
||||
if Some(chat_id) != client.current_chat_id() {
|
||||
// Find and clone chat info to avoid borrow checker issues
|
||||
if let Some(chat) = client.chats().iter().find(|c| c.id == chat_id).cloned() {
|
||||
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
||||
|
||||
// Get sender name (from message or user cache)
|
||||
let sender_name = msg_info.sender_name();
|
||||
|
||||
// Send notification
|
||||
let _ = client.notification_manager.notify_new_message(
|
||||
&chat,
|
||||
&msg_info,
|
||||
sender_name,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Добавляем новое сообщение если это текущий открытый чат
|
||||
|
||||
let msg_info = crate::tdlib::message_converter::convert_message(client, &new_msg.message, chat_id);
|
||||
let msg_id = msg_info.id();
|
||||
let is_incoming = !msg_info.is_outgoing();
|
||||
|
||||
@@ -101,8 +101,6 @@ fn render_input_with_cursor(
|
||||
/// Информация о строке после переноса: текст и позиция в оригинале
|
||||
struct WrappedLine {
|
||||
text: String,
|
||||
/// Начальная позиция в символах от начала оригинального текста
|
||||
start_offset: usize,
|
||||
}
|
||||
|
||||
/// Разбивает текст на строки с учётом максимальной ширины
|
||||
@@ -111,14 +109,12 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
if max_width == 0 {
|
||||
return vec![WrappedLine {
|
||||
text: text.to_string(),
|
||||
start_offset: 0,
|
||||
}];
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut current_line = String::new();
|
||||
let mut current_width = 0;
|
||||
let mut line_start_offset = 0;
|
||||
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let mut word_start = 0;
|
||||
@@ -133,7 +129,6 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
if current_width == 0 {
|
||||
current_line = word;
|
||||
current_width = word_width;
|
||||
line_start_offset = word_start;
|
||||
} else if current_width + 1 + word_width <= max_width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(&word);
|
||||
@@ -141,11 +136,9 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
} else {
|
||||
result.push(WrappedLine {
|
||||
text: current_line,
|
||||
start_offset: line_start_offset,
|
||||
});
|
||||
current_line = word;
|
||||
current_width = word_width;
|
||||
line_start_offset = word_start;
|
||||
}
|
||||
in_word = false;
|
||||
}
|
||||
@@ -161,31 +154,26 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
|
||||
|
||||
if current_width == 0 {
|
||||
current_line = word;
|
||||
line_start_offset = word_start;
|
||||
} else if current_width + 1 + word_width <= max_width {
|
||||
current_line.push(' ');
|
||||
current_line.push_str(&word);
|
||||
} else {
|
||||
result.push(WrappedLine {
|
||||
text: current_line,
|
||||
start_offset: line_start_offset,
|
||||
});
|
||||
current_line = word;
|
||||
line_start_offset = word_start;
|
||||
}
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
result.push(WrappedLine {
|
||||
text: current_line,
|
||||
start_offset: line_start_offset,
|
||||
});
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
result.push(WrappedLine {
|
||||
text: String::new(),
|
||||
start_offset: 0,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,82 +4,6 @@
|
||||
|
||||
use crossterm::event::KeyCode;
|
||||
|
||||
/// Результат обработки клавиши в модальном окне.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModalAction {
|
||||
/// Закрыть модалку (Escape была нажата)
|
||||
Close,
|
||||
/// Подтвердить действие (Enter была нажата)
|
||||
Confirm,
|
||||
/// Продолжить обработку ввода (другая клавиша)
|
||||
Continue,
|
||||
}
|
||||
|
||||
/// Обрабатывает стандартные клавиши для модальных окон.
|
||||
///
|
||||
/// Проверяет клавиши Escape (закрыть) и Enter (подтвердить).
|
||||
/// Если нажата другая клавиша, возвращает `Continue`.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `key_code` - код нажатой клавиши
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// * `ModalAction::Close` - если нажата Escape
|
||||
/// * `ModalAction::Confirm` - если нажата Enter
|
||||
/// * `ModalAction::Continue` - для других клавиш
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use crossterm::event::KeyCode;
|
||||
/// use tele_tui::utils::modal_handler::{handle_modal_key, ModalAction};
|
||||
///
|
||||
/// assert_eq!(handle_modal_key(KeyCode::Esc), ModalAction::Close);
|
||||
/// assert_eq!(handle_modal_key(KeyCode::Enter), ModalAction::Confirm);
|
||||
/// assert_eq!(handle_modal_key(KeyCode::Char('a')), ModalAction::Continue);
|
||||
/// ```
|
||||
pub fn handle_modal_key(key_code: KeyCode) -> ModalAction {
|
||||
match key_code {
|
||||
KeyCode::Esc => ModalAction::Close,
|
||||
KeyCode::Enter => ModalAction::Confirm,
|
||||
_ => ModalAction::Continue,
|
||||
}
|
||||
}
|
||||
|
||||
/// Проверяет, нужно ли закрыть модалку (нажата Escape).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use crossterm::event::KeyCode;
|
||||
/// use tele_tui::utils::modal_handler::should_close_modal;
|
||||
///
|
||||
/// assert!(should_close_modal(KeyCode::Esc));
|
||||
/// assert!(!should_close_modal(KeyCode::Enter));
|
||||
/// assert!(!should_close_modal(KeyCode::Char('q')));
|
||||
/// ```
|
||||
pub fn should_close_modal(key_code: KeyCode) -> bool {
|
||||
matches!(key_code, KeyCode::Esc)
|
||||
}
|
||||
|
||||
/// Проверяет, нужно ли подтвердить действие в модалке (нажата Enter).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use crossterm::event::KeyCode;
|
||||
/// use tele_tui::utils::modal_handler::should_confirm_modal;
|
||||
///
|
||||
/// assert!(should_confirm_modal(KeyCode::Enter));
|
||||
/// assert!(!should_confirm_modal(KeyCode::Esc));
|
||||
/// assert!(!should_confirm_modal(KeyCode::Char('y')));
|
||||
/// ```
|
||||
pub fn should_confirm_modal(key_code: KeyCode) -> bool {
|
||||
matches!(key_code, KeyCode::Enter)
|
||||
}
|
||||
|
||||
/// Обрабатывает клавиши для подтверждения Yes/No.
|
||||
///
|
||||
/// Поддерживает:
|
||||
@@ -138,28 +62,6 @@ pub fn handle_yes_no(key_code: KeyCode) -> Option<bool> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_handle_modal_key() {
|
||||
assert_eq!(handle_modal_key(KeyCode::Esc), ModalAction::Close);
|
||||
assert_eq!(handle_modal_key(KeyCode::Enter), ModalAction::Confirm);
|
||||
assert_eq!(handle_modal_key(KeyCode::Char('a')), ModalAction::Continue);
|
||||
assert_eq!(handle_modal_key(KeyCode::Up), ModalAction::Continue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_close_modal() {
|
||||
assert!(should_close_modal(KeyCode::Esc));
|
||||
assert!(!should_close_modal(KeyCode::Enter));
|
||||
assert!(!should_close_modal(KeyCode::Char('q')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_confirm_modal() {
|
||||
assert!(should_confirm_modal(KeyCode::Enter));
|
||||
assert!(!should_confirm_modal(KeyCode::Esc));
|
||||
assert!(!should_confirm_modal(KeyCode::Char('y')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_yes_no() {
|
||||
// Yes variants
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
//!
|
||||
//! Переиспользуемые валидаторы для проверки пользовательского ввода.
|
||||
|
||||
use crate::types::{ChatId, MessageId, UserId};
|
||||
|
||||
/// Проверяет, что строка не пустая (после trim).
|
||||
///
|
||||
/// # Examples
|
||||
@@ -20,112 +18,6 @@ pub fn is_non_empty(text: &str) -> bool {
|
||||
!text.trim().is_empty()
|
||||
}
|
||||
|
||||
/// Проверяет, что текст не превышает максимальную длину.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `text` - текст для проверки
|
||||
/// * `max_length` - максимальная длина в символах
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use tele_tui::utils::validation::is_within_length;
|
||||
///
|
||||
/// assert!(is_within_length("hello", 10));
|
||||
/// assert!(!is_within_length("very long text here", 5));
|
||||
/// ```
|
||||
pub fn is_within_length(text: &str, max_length: usize) -> bool {
|
||||
text.chars().count() <= max_length
|
||||
}
|
||||
|
||||
/// Проверяет валидность ID чата (не нулевой).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use tele_tui::types::ChatId;
|
||||
/// use tele_tui::utils::validation::is_valid_chat_id;
|
||||
///
|
||||
/// assert!(is_valid_chat_id(ChatId::new(123)));
|
||||
/// assert!(!is_valid_chat_id(ChatId::new(0)));
|
||||
/// assert!(!is_valid_chat_id(ChatId::new(-1)));
|
||||
/// ```
|
||||
pub fn is_valid_chat_id(chat_id: ChatId) -> bool {
|
||||
chat_id.as_i64() > 0
|
||||
}
|
||||
|
||||
/// Проверяет валидность ID сообщения (не нулевой).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use tele_tui::types::MessageId;
|
||||
/// use tele_tui::utils::validation::is_valid_message_id;
|
||||
///
|
||||
/// assert!(is_valid_message_id(MessageId::new(456)));
|
||||
/// assert!(!is_valid_message_id(MessageId::new(0)));
|
||||
/// ```
|
||||
pub fn is_valid_message_id(message_id: MessageId) -> bool {
|
||||
message_id.as_i64() > 0
|
||||
}
|
||||
|
||||
/// Проверяет валидность ID пользователя (не нулевой).
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use tele_tui::types::UserId;
|
||||
/// use tele_tui::utils::validation::is_valid_user_id;
|
||||
///
|
||||
/// assert!(is_valid_user_id(UserId::new(789)));
|
||||
/// assert!(!is_valid_user_id(UserId::new(0)));
|
||||
/// ```
|
||||
pub fn is_valid_user_id(user_id: UserId) -> bool {
|
||||
user_id.as_i64() > 0
|
||||
}
|
||||
|
||||
/// Проверяет, что вектор не пустой.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use tele_tui::utils::validation::has_items;
|
||||
///
|
||||
/// assert!(has_items(&vec![1, 2, 3]));
|
||||
/// assert!(!has_items::<i32>(&vec![]));
|
||||
/// ```
|
||||
pub fn has_items<T>(items: &[T]) -> bool {
|
||||
!items.is_empty()
|
||||
}
|
||||
|
||||
/// Комбинированная валидация текстового ввода:
|
||||
/// - Не пустой (после trim)
|
||||
/// - В пределах максимальной длины
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use tele_tui::utils::validation::validate_text_input;
|
||||
///
|
||||
/// assert!(validate_text_input("hello", 100).is_ok());
|
||||
/// assert!(validate_text_input("", 100).is_err());
|
||||
/// assert!(validate_text_input(" ", 100).is_err());
|
||||
/// assert!(validate_text_input("very long text", 5).is_err());
|
||||
/// ```
|
||||
pub fn validate_text_input(text: &str, max_length: usize) -> Result<(), String> {
|
||||
if !is_non_empty(text) {
|
||||
return Err("Text cannot be empty".to_string());
|
||||
}
|
||||
if !is_within_length(text, max_length) {
|
||||
return Err(format!(
|
||||
"Text exceeds maximum length of {} characters",
|
||||
max_length
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -138,54 +30,4 @@ mod tests {
|
||||
assert!(!is_non_empty(" "));
|
||||
assert!(!is_non_empty("\t\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_within_length() {
|
||||
assert!(is_within_length("hello", 10));
|
||||
assert!(is_within_length("hello", 5));
|
||||
assert!(!is_within_length("hello", 4));
|
||||
assert!(is_within_length("", 0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_chat_id() {
|
||||
assert!(is_valid_chat_id(ChatId::new(123)));
|
||||
assert!(is_valid_chat_id(ChatId::new(999999)));
|
||||
assert!(!is_valid_chat_id(ChatId::new(0)));
|
||||
assert!(!is_valid_chat_id(ChatId::new(-1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_message_id() {
|
||||
assert!(is_valid_message_id(MessageId::new(456)));
|
||||
assert!(!is_valid_message_id(MessageId::new(0)));
|
||||
assert!(!is_valid_message_id(MessageId::new(-1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_valid_user_id() {
|
||||
assert!(is_valid_user_id(UserId::new(789)));
|
||||
assert!(!is_valid_user_id(UserId::new(0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_has_items() {
|
||||
assert!(has_items(&vec![1, 2, 3]));
|
||||
assert!(has_items(&vec!["a"]));
|
||||
assert!(!has_items::<i32>(&vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_validate_text_input() {
|
||||
// Valid
|
||||
assert!(validate_text_input("hello", 100).is_ok());
|
||||
assert!(validate_text_input("test message", 20).is_ok());
|
||||
|
||||
// Empty
|
||||
assert!(validate_text_input("", 100).is_err());
|
||||
assert!(validate_text_input(" ", 100).is_err());
|
||||
|
||||
// Too long
|
||||
assert!(validate_text_input("very long text", 5).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Integration tests for config flow
|
||||
|
||||
use tele_tui::config::{Config, ColorsConfig, GeneralConfig, Keybindings};
|
||||
use tele_tui::config::{Config, ColorsConfig, GeneralConfig, Keybindings, NotificationsConfig};
|
||||
|
||||
/// Test: Дефолтные значения конфигурации
|
||||
#[test]
|
||||
@@ -33,6 +33,7 @@ fn test_config_custom_values() {
|
||||
reaction_other: "white".to_string(),
|
||||
},
|
||||
keybindings: Keybindings::default(),
|
||||
notifications: NotificationsConfig::default(),
|
||||
};
|
||||
|
||||
assert_eq!(config.general.timezone, "+05:00");
|
||||
@@ -116,6 +117,7 @@ fn test_config_toml_serialization() {
|
||||
reaction_other: "white".to_string(),
|
||||
},
|
||||
keybindings: Keybindings::default(),
|
||||
notifications: NotificationsConfig::default(),
|
||||
};
|
||||
|
||||
// Сериализуем в TOML
|
||||
|
||||
@@ -175,7 +175,6 @@ impl TestAppBuilder {
|
||||
pub fn forward_mode(mut self, message_id: i64) -> Self {
|
||||
self.chat_state = Some(ChatState::Forward {
|
||||
message_id: MessageId::new(message_id),
|
||||
selecting_chat: true,
|
||||
});
|
||||
self
|
||||
}
|
||||
|
||||
@@ -299,6 +299,11 @@ impl TdClientTrait for FakeTdClient {
|
||||
panic!("user_cache_mut not supported for FakeTdClient")
|
||||
}
|
||||
|
||||
// ============ Notification methods ============
|
||||
fn sync_notification_muted_chats(&mut self) {
|
||||
// Not implemented for fake client (notifications are not tested)
|
||||
}
|
||||
|
||||
// ============ Update handling ============
|
||||
fn handle_update(&mut self, _update: Update) {
|
||||
// Not implemented for fake client
|
||||
|
||||
Reference in New Issue
Block a user