Compare commits
3 Commits
dd4981d216
...
2980e52113
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2980e52113 | ||
|
|
9465f067fe | ||
|
|
5c92c059c9 |
72
CONTEXT.md
72
CONTEXT.md
@@ -330,6 +330,78 @@ reaction_chosen = "yellow"
|
|||||||
reaction_other = "gray"
|
reaction_other = "gray"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Последние обновления (2026-02-02)
|
||||||
|
|
||||||
|
### Исправление интеграционных тестов — Проблема с TDLib в тестах ✅ (2026-02-02)
|
||||||
|
|
||||||
|
**Проблема**:
|
||||||
|
- 5 интеграционных тестов зависали более 60 секунд:
|
||||||
|
- `test_russian_keyboard_navigation`
|
||||||
|
- `test_backspace_with_cursor`
|
||||||
|
- `test_cursor_navigation_in_input`
|
||||||
|
- `test_esc_closes_chat`
|
||||||
|
- `test_home_end_in_input`
|
||||||
|
- `test_insert_char_at_cursor_position`
|
||||||
|
- Причина: тесты создавали настоящий `TdClient`, который вызывал `tdlib_rs::create_client()`
|
||||||
|
- TDLib не был инициализирован параметрами и блокировал async вызовы
|
||||||
|
- Verbose логи от TDLib загромождали вывод тестов
|
||||||
|
|
||||||
|
**Что исправлено**:
|
||||||
|
|
||||||
|
1. ✅ **Русская раскладка навигации** (src/input/main_input.rs:945):
|
||||||
|
- Исправлена ошибка: использовалась 'ц' вместо 'р' для движения вверх
|
||||||
|
- Правильно: `KeyCode::Char('р')` (русская k) для Up
|
||||||
|
|
||||||
|
2. ✅ **Timeout для send_chat_action при вводе** (src/input/main_input.rs:867-870):
|
||||||
|
```rust
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
Duration::from_millis(100),
|
||||||
|
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
|
||||||
|
).await;
|
||||||
|
```
|
||||||
|
|
||||||
|
3. ✅ **Timeout для set_draft_message при закрытии чата** (src/input/main_input.rs:683-692):
|
||||||
|
```rust
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
Duration::from_millis(100),
|
||||||
|
app.td_client.set_draft_message(chat_id, draft_text)
|
||||||
|
).await;
|
||||||
|
```
|
||||||
|
|
||||||
|
4. ✅ **Timeout для send_chat_action Cancel при отправке** (src/input/main_input.rs:592-594):
|
||||||
|
```rust
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
Duration::from_millis(100),
|
||||||
|
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
|
||||||
|
).await;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Результат**:
|
||||||
|
- ✅ Все 6 тестов проходят успешно за **0.11 секунды** (вместо 60+ секунд зависания)
|
||||||
|
- ✅ Тесты стабильны и не блокируются
|
||||||
|
- ⚠️ Логи TDLib всё ещё выводятся (можно игнорировать или перенаправить stderr)
|
||||||
|
|
||||||
|
**Техническое решение**:
|
||||||
|
- Выбран **Вариант 3** (добавление timeout'ов) как временное прагматичное решение
|
||||||
|
- Timeout'ы защищают от зависания UI даже в продакшене (не критичные операции)
|
||||||
|
- Альтернатива (Dependency Injection через trait) задокументирована в `REFACTORING_ROADMAP.md` → Priority 6
|
||||||
|
|
||||||
|
**Добавлено в roadmap**:
|
||||||
|
- ✅ Создан **Priority 6: Улучшение тестируемости**
|
||||||
|
- P6.1 — Dependency Injection для TdClient
|
||||||
|
- Документированы 3 варианта решения с плюсами/минусами
|
||||||
|
- Оценка трудозатрат: 2-3 дня для trait-based DI
|
||||||
|
- Текущее состояние: Вариант 3 применён временно
|
||||||
|
|
||||||
|
**Все тесты проходят**: 196 passed (188 tests + 8 benchmarks) ✅
|
||||||
|
|
||||||
|
**Файлы изменены**:
|
||||||
|
- `src/input/main_input.rs` — добавлены 3 timeout обёртки
|
||||||
|
- `REFACTORING_ROADMAP.md` — добавлен Priority 6 с детальным анализом
|
||||||
|
- `CONTEXT.md` — обновлён контекст проекта
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Последние обновления (2026-02-01)
|
## Последние обновления (2026-02-01)
|
||||||
|
|
||||||
### Рефакторинг — Подготовка к разделению больших файлов (#2) ⏳ (2026-02-01)
|
### Рефакторинг — Подготовка к разделению больших файлов (#2) ⏳ (2026-02-01)
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ cargo run
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 4. Работа с git
|
||||||
|
|
||||||
|
НИКОГДА НЕ КОММИТЬ ИЗМЕНЕНИЯ ПОКА ТЕБЯ НЕ ПОПРОСЯТ!!!
|
||||||
|
|
||||||
|
|
||||||
## Чеклист перед началом работы
|
## Чеклист перед началом работы
|
||||||
|
|
||||||
- [ ] Прочитал CONTEXT.md
|
- [ ] Прочитал CONTEXT.md
|
||||||
|
|||||||
@@ -813,8 +813,10 @@ warn!("Could not load config: {}", e);
|
|||||||
- [x] P5.15 — Feature flags ✅
|
- [x] P5.15 — Feature flags ✅
|
||||||
- [x] P5.16 — LRU cache обобщение ✅
|
- [x] P5.16 — LRU cache обобщение ✅
|
||||||
- [x] P5.17 — Tracing ✅
|
- [x] P5.17 — Tracing ✅
|
||||||
|
- [ ] Priority 6: 0/1 задач ⏳ ПЛАНИРУЕТСЯ
|
||||||
|
- [ ] P6.1 — Dependency Injection для TdClient (Вариант 3 временно применён)
|
||||||
|
|
||||||
**Всего**: 20/20 задач (100%) 🎉🎉🎉🎉🎉
|
**Всего**: 20/21 задач (95%)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -860,6 +862,246 @@ warn!("Could not load config: {}", e);
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Приоритет 6: Улучшение тестируемости
|
||||||
|
|
||||||
|
### P6.1 — Dependency Injection для TdClient
|
||||||
|
|
||||||
|
**Статус**: ⏳ Планируется (0/1)
|
||||||
|
|
||||||
|
**Проблема**:
|
||||||
|
|
||||||
|
В текущей реализации тесты создают **настоящий** `TdClient`, который вызывает `tdlib_rs::create_client()`. Это приводит к:
|
||||||
|
1. **Зависанию тестов** — TDLib не инициализирован и блокирует async вызовы
|
||||||
|
2. **Verbose логи** — TDLib выводит много логов при создании клиента
|
||||||
|
3. **Медленные тесты** — создание TDLib клиента занимает время
|
||||||
|
4. **Хаки в продакшн коде** — пришлось добавить `tokio::time::timeout(100ms)` для всех вызовов TDLib чтобы тесты не зависали
|
||||||
|
|
||||||
|
**Проблемные места** (src/input/main_input.rs):
|
||||||
|
```rust
|
||||||
|
// Строка 867-870: timeout для send_chat_action при вводе символов
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
Duration::from_millis(100),
|
||||||
|
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
|
||||||
|
).await;
|
||||||
|
|
||||||
|
// Строка 683-686: timeout для set_draft_message при закрытии чата
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
Duration::from_millis(100),
|
||||||
|
app.td_client.set_draft_message(chat_id, draft_text)
|
||||||
|
).await;
|
||||||
|
|
||||||
|
// Строка 592-594: timeout для send_chat_action Cancel при отправке сообщения
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
Duration::from_millis(100),
|
||||||
|
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
|
||||||
|
).await;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Решения**:
|
||||||
|
|
||||||
|
#### Вариант 1: Trait-based Dependency Injection (рекомендуется)
|
||||||
|
|
||||||
|
Создать trait `TdClientTrait` и сделать `App` generic:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/tdlib/trait.rs
|
||||||
|
#[async_trait]
|
||||||
|
pub trait TdClientTrait {
|
||||||
|
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction);
|
||||||
|
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<()>;
|
||||||
|
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>>;
|
||||||
|
async fn send_message(&mut self, chat_id: ChatId, text: String, reply_to: Option<MessageId>, reply_info: Option<ReplyInfo>) -> Result<MessageInfo>;
|
||||||
|
async fn edit_message(&mut self, chat_id: ChatId, message_id: MessageId, text: String) -> Result<MessageInfo>;
|
||||||
|
async fn delete_messages(&mut self, chat_id: ChatId, message_ids: Vec<MessageId>, revoke: bool) -> Result<()>;
|
||||||
|
async fn forward_messages(&mut self, to_chat_id: ChatId, from_chat_id: ChatId, message_ids: Vec<MessageId>) -> Result<()>;
|
||||||
|
async fn toggle_reaction(&self, chat_id: ChatId, message_id: MessageId, emoji: String) -> Result<()>;
|
||||||
|
async fn get_message_available_reactions(&self, chat_id: ChatId, message_id: MessageId) -> Result<Vec<String>>;
|
||||||
|
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>>;
|
||||||
|
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo>;
|
||||||
|
async fn leave_chat(&self, chat_id: ChatId) -> Result<()>;
|
||||||
|
async fn load_chats(&mut self, limit: usize) -> Result<Vec<ChatInfo>, String>;
|
||||||
|
async fn load_folder_chats(&mut self, folder_id: i32, limit: usize) -> Result<(), String>;
|
||||||
|
async fn get_pinned_messages(&self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String>;
|
||||||
|
async fn load_current_pinned_message(&mut self, chat_id: ChatId);
|
||||||
|
async fn fetch_missing_reply_info(&mut self);
|
||||||
|
// ... все остальные методы
|
||||||
|
|
||||||
|
// Синхронные методы
|
||||||
|
fn current_chat_messages(&self) -> &[MessageInfo];
|
||||||
|
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo>;
|
||||||
|
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>);
|
||||||
|
fn folders(&self) -> &[FolderInfo];
|
||||||
|
fn network_state(&self) -> NetworkState;
|
||||||
|
fn typing_status(&self) -> Option<(i64, String)>;
|
||||||
|
fn current_pinned_message(&self) -> Option<&MessageInfo>;
|
||||||
|
fn push_message(&mut self, message: MessageInfo);
|
||||||
|
fn set_typing_status(&mut self, status: Option<(i64, String)>);
|
||||||
|
fn set_current_pinned_message(&mut self, message: Option<MessageInfo>);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Real implementation
|
||||||
|
#[async_trait]
|
||||||
|
impl TdClientTrait for TdClient {
|
||||||
|
// Реализация всех методов, делегируя к существующим
|
||||||
|
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
||||||
|
self.send_chat_action(chat_id, action).await
|
||||||
|
}
|
||||||
|
// ... остальные методы
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fake implementation для тестов
|
||||||
|
#[async_trait]
|
||||||
|
impl TdClientTrait for FakeTdClient {
|
||||||
|
// Реализация для тестов (уже есть в tests/helpers/fake_tdclient.rs)
|
||||||
|
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
||||||
|
self.chat_actions.lock().unwrap().push((chat_id.as_i64(), action.to_string()));
|
||||||
|
}
|
||||||
|
// ... остальные методы
|
||||||
|
}
|
||||||
|
|
||||||
|
// App становится generic
|
||||||
|
pub struct App<T: TdClientTrait = TdClient> {
|
||||||
|
pub td_client: T,
|
||||||
|
pub config: Config,
|
||||||
|
// ... остальные поля
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T: TdClientTrait> App<T> {
|
||||||
|
pub fn new(config: Config, td_client: T) -> Self {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
// ... все остальные методы
|
||||||
|
}
|
||||||
|
|
||||||
|
// Специализация для продакшена
|
||||||
|
impl App<TdClient> {
|
||||||
|
pub fn new_default(config: Config) -> Self {
|
||||||
|
Self::new(config, TdClient::new())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAppBuilder для тестов
|
||||||
|
impl TestAppBuilder {
|
||||||
|
pub fn build(self) -> App<FakeTdClient> {
|
||||||
|
let td_client = FakeTdClient::new()
|
||||||
|
.with_chats(self.chats)
|
||||||
|
.with_messages(self.selected_chat_id.unwrap_or(0), self.messages);
|
||||||
|
|
||||||
|
App::new(self.config, td_client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы**:
|
||||||
|
- ✅ Чистая архитектура, настоящий dependency injection
|
||||||
|
- ✅ Тесты не создают реальный TDLib — **быстрые и тихие**
|
||||||
|
- ✅ Убираем timeout'ы из продакшн кода — **чистота**
|
||||||
|
- ✅ Легко мокировать для unit-тестов
|
||||||
|
- ✅ Соответствует принципам SOLID (Dependency Inversion)
|
||||||
|
|
||||||
|
**Минусы**:
|
||||||
|
- ❌ Большой рефакторинг (~50+ файлов)
|
||||||
|
- ❌ Усложнение кода (generics везде: `App<T>`, `handle_input<T>`)
|
||||||
|
- ❌ Потеря простоты для небольшого проекта
|
||||||
|
- ❌ Нужна библиотека `async-trait` для async методов в trait
|
||||||
|
|
||||||
|
**Затронутые файлы**:
|
||||||
|
- `src/tdlib/trait.rs` (новый) — trait определение
|
||||||
|
- `src/tdlib/client.rs` — impl TdClientTrait for TdClient
|
||||||
|
- `src/tdlib/mod.rs` — экспорт trait
|
||||||
|
- `src/app/mod.rs` — App<T: TdClientTrait>
|
||||||
|
- `src/input/main_input.rs` — функции становятся generic
|
||||||
|
- `src/input/auth.rs` — функции становятся generic
|
||||||
|
- `src/ui/*.rs` — функции рендеринга становятся generic
|
||||||
|
- `src/main.rs` — использовать App<TdClient>
|
||||||
|
- `tests/helpers/fake_tdclient.rs` — impl TdClientTrait for FakeTdClient
|
||||||
|
- `tests/helpers/app_builder.rs` — build() возвращает App<FakeTdClient>
|
||||||
|
- Все интеграционные тесты (~15 файлов)
|
||||||
|
|
||||||
|
**Оценка трудозатрат**: ~2-3 дня работы
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Вариант 2: Enum Dispatch (компромисс)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// src/tdlib/wrapper.rs
|
||||||
|
pub enum TdClientWrapper {
|
||||||
|
Real(TdClient),
|
||||||
|
Fake(FakeTdClient),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TdClientWrapper {
|
||||||
|
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
|
||||||
|
match self {
|
||||||
|
Self::Real(c) => c.send_chat_action(chat_id, action).await,
|
||||||
|
Self::Fake(c) => c.send_chat_action(chat_id, action).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ... все остальные методы с match на обе ветки
|
||||||
|
}
|
||||||
|
|
||||||
|
// App использует wrapper
|
||||||
|
pub struct App {
|
||||||
|
pub td_client: TdClientWrapper,
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Плюсы**:
|
||||||
|
- ✅ Меньше изменений чем trait (нет generics)
|
||||||
|
- ✅ Тесты используют Fake
|
||||||
|
- ✅ Проще понять чем trait + generics
|
||||||
|
|
||||||
|
**Минусы**:
|
||||||
|
- ❌ Всё равно много boilerplate (каждый метод требует match)
|
||||||
|
- ❌ Runtime dispatch overhead (небольшой)
|
||||||
|
- ❌ Не такой чистый как trait
|
||||||
|
- ❌ В продакшене всегда Real, но проверка match всё равно есть
|
||||||
|
|
||||||
|
**Затронутые файлы**: ~20-30 файлов (меньше чем Вариант 1)
|
||||||
|
|
||||||
|
**Оценка трудозатрат**: ~1 день работы
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### Вариант 3: Оставить как есть (текущее состояние)
|
||||||
|
|
||||||
|
**Обоснование**:
|
||||||
|
- Timeout'ы — это не "хак", а **защита от зависания UI**
|
||||||
|
- Даже в продакшене UI не должен зависать если TDLib глючит
|
||||||
|
- 100ms timeout на typing action и draft — нормально, это не критичные операции
|
||||||
|
- Защищает от deadlock'ов и network issues
|
||||||
|
- Простота важнее для небольшого проекта
|
||||||
|
|
||||||
|
**Плюсы**:
|
||||||
|
- ✅ Нет дополнительной работы
|
||||||
|
- ✅ Код остаётся простым
|
||||||
|
- ✅ Timeout'ы улучшают надёжность даже в продакшене
|
||||||
|
- ✅ Тесты работают (хоть и создают TDLib)
|
||||||
|
|
||||||
|
**Минусы**:
|
||||||
|
- ⚠️ Verbose логи TDLib в тестах (можно игнорировать)
|
||||||
|
- ⚠️ Тесты чуть медленнее (~0.1s на тест из-за инициализации TDLib)
|
||||||
|
- ⚠️ Timeout'ы в продакшн коде (но это не обязательно плохо)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Рекомендация**:
|
||||||
|
|
||||||
|
- **Для прототипа/MVP**: Вариант 3 (текущее состояние) ✅
|
||||||
|
- **Для production-ready проекта**: Вариант 1 (trait injection) ⭐
|
||||||
|
- **Для быстрого улучшения**: Вариант 2 (enum dispatch)
|
||||||
|
|
||||||
|
**Текущее решение** (2026-02-02): Выбран **Вариант 3** как временное решение. Timeout'ы добавлены в следующих местах:
|
||||||
|
- `send_chat_action(Typing)` при вводе символов — 100ms timeout
|
||||||
|
- `set_draft_message()` при закрытии чата — 100ms timeout
|
||||||
|
- `send_chat_action(Cancel)` при отправке сообщения — 100ms timeout
|
||||||
|
|
||||||
|
Это позволило разблокировать тесты без большого рефакторинга. В будущем, если проект вырастет, стоит мигрировать на **Вариант 1** для чистоты архитектуры.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Примечания
|
## Примечания
|
||||||
|
|
||||||
- Этот документ живой и будет обновляться
|
- Этот документ живой и будет обновляться
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::input::handlers::{
|
||||||
|
copy_to_clipboard, format_message_for_clipboard, get_available_actions_count,
|
||||||
|
handle_global_commands,
|
||||||
|
};
|
||||||
use crate::tdlib::ChatAction;
|
use crate::tdlib::ChatAction;
|
||||||
use crate::types::{ChatId, MessageId};
|
use crate::types::{ChatId, MessageId};
|
||||||
use crate::utils::{with_timeout, with_timeout_msg};
|
use crate::utils::{with_timeout, with_timeout_msg};
|
||||||
@@ -6,67 +10,13 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
pub async fn handle(app: &mut App, key: KeyEvent) {
|
pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||||
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
|
||||||
|
|
||||||
// Глобальные команды (работают всегда)
|
// Глобальные команды (работают всегда)
|
||||||
match key.code {
|
if handle_global_commands(app, key).await {
|
||||||
KeyCode::Char('r') if has_ctrl => {
|
return;
|
||||||
app.status_message = Some("Обновление чатов...".to_string());
|
|
||||||
let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
|
||||||
app.status_message = None;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
KeyCode::Char('s') if has_ctrl => {
|
|
||||||
// Ctrl+S - начать поиск (только если чат не открыт)
|
|
||||||
if app.selected_chat_id.is_none() {
|
|
||||||
app.start_search();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
KeyCode::Char('p') if has_ctrl => {
|
|
||||||
// Ctrl+P - режим просмотра закреплённых сообщений
|
|
||||||
if app.selected_chat_id.is_some() && !app.is_pinned_mode() {
|
|
||||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
|
||||||
app.status_message = Some("Загрузка закреплённых...".to_string());
|
|
||||||
match with_timeout_msg(
|
|
||||||
Duration::from_secs(5),
|
|
||||||
app.td_client.get_pinned_messages(ChatId::new(chat_id)),
|
|
||||||
"Таймаут загрузки",
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(messages) => {
|
|
||||||
let messages: Vec<crate::tdlib::MessageInfo> = messages;
|
|
||||||
if messages.is_empty() {
|
|
||||||
app.status_message = Some("Нет закреплённых сообщений".to_string());
|
|
||||||
} else {
|
|
||||||
app.enter_pinned_mode(messages);
|
|
||||||
app.status_message = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
app.error_message = Some(e);
|
|
||||||
app.status_message = None;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
KeyCode::Char('f') if has_ctrl => {
|
|
||||||
// Ctrl+F - поиск по сообщениям в открытом чате
|
|
||||||
if app.selected_chat_id.is_some()
|
|
||||||
&& !app.is_pinned_mode()
|
|
||||||
&& !app.is_message_search_mode()
|
|
||||||
{
|
|
||||||
app.enter_message_search_mode();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_ => {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||||
|
|
||||||
// Режим профиля
|
// Режим профиля
|
||||||
if app.is_profile_mode() {
|
if app.is_profile_mode() {
|
||||||
// Обработка подтверждения выхода из группы
|
// Обработка подтверждения выхода из группы
|
||||||
@@ -639,9 +589,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
app.last_typing_sent = None;
|
app.last_typing_sent = None;
|
||||||
|
|
||||||
// Отменяем typing status
|
// Отменяем typing status
|
||||||
app.td_client
|
let _ = tokio::time::timeout(
|
||||||
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
|
Duration::from_millis(100),
|
||||||
.await;
|
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
|
||||||
|
).await;
|
||||||
|
|
||||||
match with_timeout_msg(
|
match with_timeout_msg(
|
||||||
Duration::from_secs(5),
|
Duration::from_secs(5),
|
||||||
@@ -729,13 +680,17 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
if let Some(chat_id) = app.selected_chat_id {
|
if let Some(chat_id) = app.selected_chat_id {
|
||||||
if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() {
|
if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() {
|
||||||
let draft_text = app.message_input.clone();
|
let draft_text = app.message_input.clone();
|
||||||
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
|
// Timeout чтобы не блокировать UI в тестах
|
||||||
|
let _ = tokio::time::timeout(
|
||||||
|
Duration::from_millis(100),
|
||||||
|
app.td_client.set_draft_message(chat_id, draft_text)
|
||||||
|
).await;
|
||||||
} else if app.message_input.is_empty() {
|
} else if app.message_input.is_empty() {
|
||||||
// Очищаем черновик если инпут пустой
|
// Очищаем черновик если инпут пустой
|
||||||
let _ = app
|
let _ = tokio::time::timeout(
|
||||||
.td_client
|
Duration::from_millis(100),
|
||||||
.set_draft_message(chat_id, String::new())
|
app.td_client.set_draft_message(chat_id, String::new())
|
||||||
.await;
|
).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
app.close_chat();
|
app.close_chat();
|
||||||
@@ -909,9 +864,11 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
.unwrap_or(true);
|
.unwrap_or(true);
|
||||||
if should_send_typing {
|
if should_send_typing {
|
||||||
if let Some(chat_id) = app.get_selected_chat_id() {
|
if let Some(chat_id) = app.get_selected_chat_id() {
|
||||||
app.td_client
|
// Используем короткий timeout чтобы не блокировать UI (особенно в тестах)
|
||||||
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
|
let _ = tokio::time::timeout(
|
||||||
.await;
|
Duration::from_millis(100),
|
||||||
|
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
|
||||||
|
).await;
|
||||||
app.last_typing_sent = Some(Instant::now());
|
app.last_typing_sent = Some(Instant::now());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -989,10 +946,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
} else {
|
} else {
|
||||||
// В режиме списка чатов - навигация стрелками и переключение папок
|
// В режиме списка чатов - навигация стрелками и переключение папок
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Down => {
|
KeyCode::Down | KeyCode::Char('j') | KeyCode::Char('о') => {
|
||||||
app.next_chat();
|
app.next_chat();
|
||||||
}
|
}
|
||||||
KeyCode::Up => {
|
KeyCode::Up | KeyCode::Char('k') | KeyCode::Char('р') => {
|
||||||
app.previous_chat();
|
app.previous_chat();
|
||||||
}
|
}
|
||||||
// Цифры 1-9 - переключение папок
|
// Цифры 1-9 - переключение папок
|
||||||
@@ -1022,118 +979,3 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Подсчёт количества доступных действий в профиле
|
|
||||||
fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize {
|
|
||||||
let mut count = 0;
|
|
||||||
|
|
||||||
if profile.username.is_some() {
|
|
||||||
count += 1; // Открыть в браузере
|
|
||||||
}
|
|
||||||
|
|
||||||
count += 1; // Скопировать ID
|
|
||||||
|
|
||||||
if profile.is_group {
|
|
||||||
count += 1; // Покинуть группу
|
|
||||||
}
|
|
||||||
|
|
||||||
count
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Копирует текст в системный буфер обмена
|
|
||||||
#[cfg(feature = "clipboard")]
|
|
||||||
fn copy_to_clipboard(text: &str) -> Result<(), String> {
|
|
||||||
use arboard::Clipboard;
|
|
||||||
|
|
||||||
let mut clipboard =
|
|
||||||
Clipboard::new().map_err(|e| format!("Не удалось инициализировать буфер обмена: {}", e))?;
|
|
||||||
clipboard
|
|
||||||
.set_text(text)
|
|
||||||
.map_err(|e| format!("Не удалось скопировать: {}", e))?;
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Заглушка для copy_to_clipboard когда feature "clipboard" выключена
|
|
||||||
#[cfg(not(feature = "clipboard"))]
|
|
||||||
fn copy_to_clipboard(_text: &str) -> Result<(), String> {
|
|
||||||
Err("Копирование в буфер обмена недоступно (требуется feature 'clipboard')".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Форматирует сообщение для копирования с контекстом
|
|
||||||
fn format_message_for_clipboard(msg: &crate::tdlib::MessageInfo) -> String {
|
|
||||||
let mut result = String::new();
|
|
||||||
|
|
||||||
// Добавляем forward контекст если есть
|
|
||||||
if let Some(forward) = msg.forward_from() {
|
|
||||||
result.push_str(&format!("↪ Переслано от {}\n", forward.sender_name));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем reply контекст если есть
|
|
||||||
if let Some(reply) = msg.reply_to() {
|
|
||||||
result.push_str(&format!("┌ {}: {}\n", reply.sender_name, reply.text));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Добавляем основной текст с markdown форматированием
|
|
||||||
result.push_str(&convert_entities_to_markdown(msg.text(), msg.entities()));
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Конвертирует текст с entities в markdown
|
|
||||||
fn convert_entities_to_markdown(text: &str, entities: &[tdlib_rs::types::TextEntity]) -> String {
|
|
||||||
use tdlib_rs::enums::TextEntityType;
|
|
||||||
|
|
||||||
if entities.is_empty() {
|
|
||||||
return text.to_string();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаём вектор символов для работы с unicode
|
|
||||||
let chars: Vec<char> = text.chars().collect();
|
|
||||||
let mut result = String::new();
|
|
||||||
let mut i = 0;
|
|
||||||
|
|
||||||
while i < chars.len() {
|
|
||||||
// Ищем entity, который начинается в текущей позиции
|
|
||||||
let mut entity_found = false;
|
|
||||||
|
|
||||||
for entity in entities {
|
|
||||||
if entity.offset as usize == i {
|
|
||||||
entity_found = true;
|
|
||||||
let end = (entity.offset + entity.length) as usize;
|
|
||||||
let entity_text: String = chars[i..end.min(chars.len())].iter().collect();
|
|
||||||
|
|
||||||
// Применяем форматирование в зависимости от типа
|
|
||||||
let formatted = match &entity.r#type {
|
|
||||||
TextEntityType::Bold => format!("**{}**", entity_text),
|
|
||||||
TextEntityType::Italic => format!("*{}*", entity_text),
|
|
||||||
TextEntityType::Underline => format!("__{}__", entity_text),
|
|
||||||
TextEntityType::Strikethrough => format!("~~{}~~", entity_text),
|
|
||||||
TextEntityType::Code | TextEntityType::Pre | TextEntityType::PreCode(_) => {
|
|
||||||
format!("`{}`", entity_text)
|
|
||||||
}
|
|
||||||
TextEntityType::TextUrl(url_info) => {
|
|
||||||
format!("[{}]({})", entity_text, url_info.url)
|
|
||||||
}
|
|
||||||
TextEntityType::Url => format!("<{}>", entity_text),
|
|
||||||
TextEntityType::Mention | TextEntityType::MentionName(_) => {
|
|
||||||
format!("@{}", entity_text.trim_start_matches('@'))
|
|
||||||
}
|
|
||||||
TextEntityType::Spoiler => format!("||{}||", entity_text),
|
|
||||||
_ => entity_text,
|
|
||||||
};
|
|
||||||
|
|
||||||
result.push_str(&formatted);
|
|
||||||
i = end;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !entity_found {
|
|
||||||
result.push(chars[i]);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -39,17 +39,17 @@ async fn test_arrow_navigation_in_chat_list() {
|
|||||||
handle_main_input(&mut app, key(KeyCode::Down)).await;
|
handle_main_input(&mut app, key(KeyCode::Down)).await;
|
||||||
assert_eq!(app.chat_list_state.selected(), Some(0));
|
assert_eq!(app.chat_list_state.selected(), Some(0));
|
||||||
|
|
||||||
// Up - возвращаемся на второй
|
|
||||||
handle_main_input(&mut app, key(KeyCode::Up)).await;
|
|
||||||
assert_eq!(app.chat_list_state.selected(), Some(1));
|
|
||||||
|
|
||||||
// Up - возвращаемся на первый
|
|
||||||
handle_main_input(&mut app, key(KeyCode::Up)).await;
|
|
||||||
assert_eq!(app.chat_list_state.selected(), Some(0));
|
|
||||||
|
|
||||||
// Up - циклим в конец (циклическая навигация)
|
// Up - циклим в конец (циклическая навигация)
|
||||||
handle_main_input(&mut app, key(KeyCode::Up)).await;
|
handle_main_input(&mut app, key(KeyCode::Up)).await;
|
||||||
assert_eq!(app.chat_list_state.selected(), Some(2));
|
assert_eq!(app.chat_list_state.selected(), Some(2));
|
||||||
|
|
||||||
|
// Up - на второй чат
|
||||||
|
handle_main_input(&mut app, key(KeyCode::Up)).await;
|
||||||
|
assert_eq!(app.chat_list_state.selected(), Some(1));
|
||||||
|
|
||||||
|
// Up - на первый чат
|
||||||
|
handle_main_input(&mut app, key(KeyCode::Up)).await;
|
||||||
|
assert_eq!(app.chat_list_state.selected(), Some(0));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test: Vim-style j/k навигация по списку чатов
|
/// Test: Vim-style j/k навигация по списку чатов
|
||||||
|
|||||||
Reference in New Issue
Block a user