From 1d0bfb53e05ea397371d43f980c8b957a930f711 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Fri, 6 Feb 2026 00:43:52 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20split=20main=5Finput.rs=20into=20mo?= =?UTF-8?q?dular=20handlers=20(1199=E2=86=92164=20lines)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split monolithic input handler into 5 specialized modules: - handlers/chat.rs (452 lines) - chat keyboard input - handlers/modal.rs (316 lines) - modal dialogs - handlers/chat_list.rs (142 lines) - chat list navigation - handlers/search.rs (140 lines) - search functionality - handlers/compose.rs (80 lines) - forward/reply/edit modes Changes: - main_input.rs: 1199→164 lines (removed 1035 lines, -86%) - Preserved existing handlers: clipboard, global, profile - Created clean router pattern in main_input.rs - Fixed keybinding conflict: Ctrl+I→Ctrl+U for profile - Fixed modifier handling in chat input (ignore Ctrl/Alt chars) - Updated CONTEXT.md with refactoring metrics - Updated ROADMAP.md: Phase 13 Etap 1 marked as DONE Phase 13 Etap 1: COMPLETED (100%) Co-Authored-By: Claude Sonnet 4.5 --- CONTEXT.md | 42 +- ROADMAP.md | 34 +- src/config/keybindings.rs | 4 +- src/input/handlers/chat.rs | 460 +++++++++++++ src/input/handlers/chat_list.rs | 143 +++++ src/input/handlers/compose.rs | 81 +++ src/input/handlers/mod.rs | 10 + src/input/handlers/modal.rs | 314 +++++++++ src/input/handlers/search.rs | 141 ++++ src/input/main_input.rs | 1071 +------------------------------ 10 files changed, 1228 insertions(+), 1072 deletions(-) create mode 100644 src/input/handlers/chat.rs create mode 100644 src/input/handlers/chat_list.rs create mode 100644 src/input/handlers/compose.rs create mode 100644 src/input/handlers/modal.rs create mode 100644 src/input/handlers/search.rs diff --git a/CONTEXT.md b/CONTEXT.md index bfacd58..1e73d33 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,8 +1,46 @@ # Текущий контекст проекта -## Статус: Фаза 9 — ЗАВЕРШЕНО + Тестирование (100%!) 🎉 +## Статус: Фаза 13 Этап 1 — ЗАВЕРШЕНО (100%!) 🎉 -### Последние изменения (2026-02-04) +### Последние изменения (2026-02-06) + +**🔧 COMPLETED: Глубокий рефакторинг input/main_input.rs (Фаза 13, Этап 1)** +- **Проблема**: `src/input/main_input.rs` содержал 1199 строк монолитного кода +- **Решение**: Разбит на модульную структуру handlers с 6 специализированными модулями +- **Результат**: + - ✅ `main_input.rs`: **164 строки** (было 1199) - чистый роутер + - ✅ Создано 5 новых handler модулей: + - `handlers/chat.rs` - 452 строки (обработка открытого чата) + - `handlers/modal.rs` - 316 строк (модальные окна) + - `handlers/chat_list.rs` - 142 строки (навигация по чатам) + - `handlers/search.rs` - 140 строк (поиск) + - `handlers/compose.rs` - 80 строк (forward/reply/edit) + - ✅ Сохранены существующие модули: clipboard.rs, global.rs, profile.rs + - ✅ **Удалено 1035 строк** (86% кода) из monolithic файла + - ✅ Улучшена модульность и читаемость кода +- **Дополнительные изменения**: + - 🔧 Исправлен хоткей профиля: Ctrl+I → Ctrl+U (конфликт с Tab в терминале) + - 🔕 Уведомления отключены по умолчанию (enabled: false в config) +- **Структура handlers/**: + ``` + src/input/handlers/ + ├── mod.rs # Module exports + ├── chat.rs # Chat keyboard input (452 lines) + ├── chat_list.rs # Chat list navigation (142 lines) + ├── compose.rs # Forward/reply/edit modes (80 lines) + ├── modal.rs # Modal dialogs (316 lines) + ├── search.rs # Search functionality (140 lines) + ├── clipboard.rs # Clipboard operations (existing) + ├── global.rs # Global commands (existing) + └── profile.rs # Profile helpers (existing) + ``` +- **Метрики успеха**: + - До: 1199 строк в 1 файле + - После: 164 строки в main_input.rs + 1367 строк в 9 handler файлах + - Достигнута цель: main_input.rs < 200 строк ✅ +- **Тестирование**: Требуется ручное тестирование всех функций приложения + +### Изменения (2026-02-04) **🔔 NEW: Desktop уведомления (Notifications) — Стадия 1/3 завершена** - **Реализовано**: diff --git a/ROADMAP.md b/ROADMAP.md index 5809a50..ca589f5 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -437,7 +437,7 @@ - `src/tdlib/messages.rs` - 833 строки - `src/config/mod.rs` - 642 строки -### Этап 1: Разбить input/main_input.rs (1199 → <200 строк) [TODO] +### Этап 1: Разбить input/main_input.rs (1199 → <200 строк) [DONE ✅] **Текущая проблема:** - Весь input handling в одном файле @@ -445,34 +445,38 @@ - Невозможно быстро найти нужный handler **План:** -- [ ] Создать `src/input/handlers/` директорию -- [ ] Создать `handlers/chat.rs` - обработка ввода в открытом чате +- [x] Создать `src/input/handlers/` директорию +- [x] Создать `handlers/chat.rs` - обработка ввода в открытом чате - Переместить `handle_open_chat_keyboard_input()` - Обработка скролла, выбора сообщений - - ~300-400 строк -- [ ] Создать `handlers/chat_list.rs` - обработка в списке чатов + - **452 строки** (7 функций) +- [x] Создать `handlers/chat_list.rs` - обработка в списке чатов - Переместить `handle_chat_list_keyboard_input()` - Навигация по чатам, папки - - ~200-300 строк -- [ ] Создать `handlers/compose.rs` - режимы edit/reply/forward + - **142 строки** (3 функции) +- [x] Создать `handlers/compose.rs` - режимы edit/reply/forward - Обработка ввода в режимах редактирования - Input field управление (курсор, backspace, delete) - - ~200 строк -- [ ] Создать `handlers/modal.rs` - модалки + - **80 строк** (2 функции) +- [x] Создать `handlers/modal.rs` - модалки - Delete confirmation - Emoji picker - Profile modal - - ~150 строк -- [ ] Создать `handlers/search.rs` - поиск + - **316 строк** (5 функций) +- [x] Создать `handlers/search.rs` - поиск - Search mode в чате - Search mode в списке чатов - - ~100 строк -- [ ] Обновить `main_input.rs` - только роутинг + - **140 строк** (3 функций) +- [x] Обновить `main_input.rs` - только роутинг - Определение текущего режима - Делегация в нужный handler - - <200 строк + - **164 строки** (2 функции) -**Результат:** 1199 строк → 6 файлов по <400 строк +**Результат:** 1199 строк → **164 строки** (удалено 1035 строк, -86%) +- Создано 5 новых модулей обработки ввода +- Чистый router pattern в main_input.rs +- Каждый handler отвечает за свою область +- **Дополнительно:** Исправлен конфликт Ctrl+I → Ctrl+U для профиля ### Этап 2: Уменьшить app/mod.rs (116 функций → traits) [TODO] diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index e52fe87..ed53b11 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -230,8 +230,8 @@ impl Keybindings { // Profile bindings.insert(Command::OpenProfile, vec![ - KeyBinding::with_ctrl(KeyCode::Char('i')), - KeyBinding::with_ctrl(KeyCode::Char('ш')), // RU + KeyBinding::with_ctrl(KeyCode::Char('u')), // Ctrl+U instead of Ctrl+I + KeyBinding::with_ctrl(KeyCode::Char('г')), // RU ]); Self { bindings } diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs new file mode 100644 index 0000000..09ae926 --- /dev/null +++ b/src/input/handlers/chat.rs @@ -0,0 +1,460 @@ +//! Chat input handlers +//! +//! Handles keyboard input when a chat is open, including: +//! - Message scrolling and navigation +//! - Message selection and actions +//! - Editing and sending messages +//! - Loading older messages + +use crate::app::App; +use crate::tdlib::{TdClientTrait, ChatAction, ReplyInfo}; +use crate::types::{ChatId, MessageId}; +use crate::utils::{is_non_empty, with_timeout, with_timeout_msg}; +use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard}; +use super::chat_list::open_chat_and_load_data; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::{Duration, Instant}; + +/// Обработка режима выбора сообщения для действий +/// +/// Обрабатывает: +/// - Навигацию по сообщениям (Up/Down) +/// - Удаление сообщения (d/в/Delete) +/// - Ответ на сообщение (r/к) +/// - Пересылку сообщения (f/а) +/// - Копирование сообщения (y/н) +/// - Добавление реакции (e/у) +pub async fn handle_message_selection(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::MoveUp) => { + app.select_previous_message(); + } + Some(crate::config::Command::MoveDown) => { + app.select_next_message(); + } + Some(crate::config::Command::DeleteMessage) => { + let Some(msg) = app.get_selected_message() else { + return; + }; + let can_delete = + msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users(); + if can_delete { + app.chat_state = crate::app::ChatState::DeleteConfirmation { + message_id: msg.id(), + }; + } + } + Some(crate::config::Command::ReplyMessage) => { + app.start_reply_to_selected(); + } + Some(crate::config::Command::ForwardMessage) => { + app.start_forward_selected(); + } + Some(crate::config::Command::CopyMessage) => { + let Some(msg) = app.get_selected_message() else { + return; + }; + let text = format_message_for_clipboard(&msg); + match copy_to_clipboard(&text) { + Ok(_) => { + app.status_message = Some("Сообщение скопировано".to_string()); + } + Err(e) => { + app.error_message = Some(format!("Ошибка копирования: {}", e)); + } + } + } + Some(crate::config::Command::ReactMessage) => { + let Some(msg) = app.get_selected_message() else { + return; + }; + let chat_id = app.selected_chat_id.unwrap(); + let message_id = msg.id(); + + app.status_message = Some("Загрузка реакций...".to_string()); + app.needs_redraw = true; + + match with_timeout_msg( + Duration::from_secs(5), + app.td_client + .get_message_available_reactions(chat_id, message_id), + "Таймаут загрузки реакций", + ) + .await + { + Ok(reactions) => { + let reactions: Vec = reactions; + if reactions.is_empty() { + app.error_message = + Some("Реакции недоступны для этого сообщения".to_string()); + app.status_message = None; + app.needs_redraw = true; + } else { + app.enter_reaction_picker_mode(message_id.as_i64(), reactions); + app.status_message = None; + app.needs_redraw = true; + } + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + app.needs_redraw = true; + } + } + } + _ => {} + } +} + +/// Редактирование существующего сообщения +pub async fn edit_message(app: &mut App, chat_id: i64, msg_id: MessageId, text: String) { + // Проверяем, что сообщение есть в локальном кэше + let msg_exists = app.td_client.current_chat_messages() + .iter() + .any(|m| m.id() == msg_id); + + if !msg_exists { + app.error_message = Some(format!( + "Сообщение {} не найдено в кэше чата {}", + msg_id.as_i64(), chat_id + )); + app.chat_state = crate::app::ChatState::Normal; + app.message_input.clear(); + app.cursor_position = 0; + return; + } + + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.edit_message(ChatId::new(chat_id), msg_id, text), + "Таймаут редактирования", + ) + .await + { + Ok(mut edited_msg) => { + // Сохраняем reply_to из старого сообщения (если есть) + let messages = app.td_client.current_chat_messages_mut(); + if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) { + let old_reply_to = messages[pos].interactions.reply_to.clone(); + // Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый + if let Some(old_reply) = old_reply_to { + if edited_msg.interactions.reply_to.as_ref() + .map_or(true, |r| r.sender_name == "Unknown") { + edited_msg.interactions.reply_to = Some(old_reply); + } + } + // Заменяем сообщение + messages[pos] = edited_msg; + } + // Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования + app.message_input.clear(); + app.cursor_position = 0; + app.chat_state = crate::app::ChatState::Normal; + app.needs_redraw = true; + } + Err(e) => { + app.error_message = Some(e); + } + } +} + +/// Отправка нового сообщения (с опциональным reply) +pub async fn send_new_message(app: &mut App, chat_id: i64, text: String) { + let reply_to_id = if app.is_replying() { + app.chat_state.selected_message_id() + } else { + None + }; + + // Создаём ReplyInfo ДО отправки, пока сообщение точно доступно + let reply_info = app.get_replying_to_message().map(|m| { + crate::tdlib::ReplyInfo { + message_id: m.id(), + sender_name: m.sender_name().to_string(), + text: m.text().to_string(), + } + }); + + app.message_input.clear(); + app.cursor_position = 0; + // Сбрасываем режим reply если он был активен + if app.is_replying() { + app.chat_state = crate::app::ChatState::Normal; + } + app.last_typing_sent = None; + + // Отменяем typing status + app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel).await; + + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info), + "Таймаут отправки", + ) + .await + { + Ok(sent_msg) => { + // Добавляем отправленное сообщение в список (с лимитом) + app.td_client.push_message(sent_msg); + // Сбрасываем скролл чтобы видеть новое сообщение + app.message_scroll_offset = 0; + } + Err(e) => { + app.error_message = Some(e); + } + } +} + +/// Обработка клавиши Enter +/// +/// Обрабатывает три сценария: +/// 1. В режиме выбора сообщения: начать редактирование +/// 2. В открытом чате: отправить новое или редактировать существующее сообщение +/// 3. В списке чатов: открыть выбранный чат +pub async fn handle_enter_key(app: &mut App) { + // Сценарий 1: Открытие чата из списка + if app.selected_chat_id.is_none() { + let prev_selected = app.selected_chat_id; + app.select_current_chat(); + + if app.selected_chat_id != prev_selected { + if let Some(chat_id) = app.get_selected_chat_id() { + open_chat_and_load_data(app, chat_id).await; + } + } + return; + } + + // Сценарий 2: Режим выбора сообщения - начать редактирование + if app.is_selecting_message() { + if !app.start_editing_selected() { + // Нельзя редактировать это сообщение + app.chat_state = crate::app::ChatState::Normal; + } + return; + } + + // Сценарий 3: Отправка или редактирование сообщения + if !is_non_empty(&app.message_input) { + return; + } + + let Some(chat_id) = app.get_selected_chat_id() else { + return; + }; + + let text = app.message_input.clone(); + + if app.is_editing() { + // Редактирование существующего сообщения + if let Some(msg_id) = app.chat_state.selected_message_id() { + edit_message(app, chat_id, msg_id, text).await; + } + } else { + // Отправка нового сообщения + send_new_message(app, chat_id, text).await; + } +} + +/// Отправляет реакцию на выбранное сообщение +pub async fn send_reaction(app: &mut App) { + // Get selected reaction emoji + let Some(emoji) = app.get_selected_reaction().cloned() else { + return; + }; + + // Get selected message ID + let Some(message_id) = app.get_selected_message_for_reaction() else { + return; + }; + + // Get chat ID + let Some(chat_id) = app.selected_chat_id else { + return; + }; + + let message_id = MessageId::new(message_id); + app.status_message = Some("Отправка реакции...".to_string()); + app.needs_redraw = true; + + // Send reaction with timeout + let result = with_timeout_msg( + Duration::from_secs(5), + app.td_client.toggle_reaction(chat_id, message_id, emoji.clone()), + "Таймаут отправки реакции", + ) + .await; + + // Handle result + match result { + Ok(_) => { + app.status_message = Some(format!("Реакция {} добавлена", emoji)); + app.exit_reaction_picker_mode(); + app.needs_redraw = true; + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + app.needs_redraw = true; + } + } +} + +/// Подгружает старые сообщения если скролл близко к верху +pub async fn load_older_messages_if_needed(app: &mut App) { + // Check if there are messages to load from + if app.td_client.current_chat_messages().is_empty() { + return; + } + + // Get the oldest message ID + let oldest_msg_id = app + .td_client + .current_chat_messages() + .first() + .map(|m| m.id()) + .unwrap_or(MessageId::new(0)); + + // Get current chat ID + let Some(chat_id) = app.get_selected_chat_id() else { + return; + }; + + // Check if scroll is near the top + let message_count = app.td_client.current_chat_messages().len(); + if app.message_scroll_offset <= message_count.saturating_sub(10) { + return; + } + + // Load older messages with timeout + let Ok(older) = with_timeout( + Duration::from_secs(3), + app.td_client.load_older_messages(ChatId::new(chat_id), oldest_msg_id), + ) + .await + else { + return; + }; + + // Add older messages to the beginning if any were loaded + if !older.is_empty() { + let msgs = app.td_client.current_chat_messages_mut(); + msgs.splice(0..0, older); + } +} + +/// Обработка ввода клавиатуры в открытом чате +/// +/// Обрабатывает: +/// - Backspace/Delete: удаление символов относительно курсора +/// - Char: вставка символов в позицию курсора + typing status +/// - Left/Right/Home/End: навигация курсора +/// - Up/Down: скролл сообщений или начало режима выбора +pub async fn handle_open_chat_keyboard_input(app: &mut App, key: KeyEvent) { + match key.code { + KeyCode::Backspace => { + // Удаляем символ слева от курсора + if app.cursor_position > 0 { + let chars: Vec = app.message_input.chars().collect(); + let mut new_input = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i != app.cursor_position - 1 { + new_input.push(*ch); + } + } + app.message_input = new_input; + app.cursor_position -= 1; + } + } + KeyCode::Delete => { + // Удаляем символ справа от курсора + let len = app.message_input.chars().count(); + if app.cursor_position < len { + let chars: Vec = app.message_input.chars().collect(); + let mut new_input = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i != app.cursor_position { + new_input.push(*ch); + } + } + app.message_input = new_input; + } + } + KeyCode::Char(c) => { + // Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift) + // Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля + if key.modifiers.contains(KeyModifiers::CONTROL) + || key.modifiers.contains(KeyModifiers::ALT) { + return; + } + + // Вставляем символ в позицию курсора + let chars: Vec = app.message_input.chars().collect(); + let mut new_input = String::new(); + for (i, ch) in chars.iter().enumerate() { + if i == app.cursor_position { + new_input.push(c); + } + new_input.push(*ch); + } + if app.cursor_position >= chars.len() { + new_input.push(c); + } + app.message_input = new_input; + app.cursor_position += 1; + + // Отправляем typing status с throttling (не чаще 1 раза в 5 сек) + let should_send_typing = app + .last_typing_sent + .map(|t| t.elapsed().as_secs() >= 5) + .unwrap_or(true); + if should_send_typing { + if let Some(chat_id) = app.get_selected_chat_id() { + app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await; + app.last_typing_sent = Some(Instant::now()); + } + } + } + KeyCode::Left => { + // Курсор влево + if app.cursor_position > 0 { + app.cursor_position -= 1; + } + } + KeyCode::Right => { + // Курсор вправо + let len = app.message_input.chars().count(); + if app.cursor_position < len { + app.cursor_position += 1; + } + } + KeyCode::Home => { + // Курсор в начало + app.cursor_position = 0; + } + KeyCode::End => { + // Курсор в конец + app.cursor_position = app.message_input.chars().count(); + } + // Стрелки вверх/вниз - скролл сообщений или начало выбора + KeyCode::Down => { + // Скролл вниз (к новым сообщениям) + if app.message_scroll_offset > 0 { + app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3); + } + } + KeyCode::Up => { + // Если инпут пустой и не в режиме редактирования — начать выбор сообщения + if app.message_input.is_empty() && !app.is_editing() { + app.start_message_selection(); + } else { + // Скролл вверх (к старым сообщениям) + app.message_scroll_offset += 3; + + // Подгружаем старые сообщения если нужно + load_older_messages_if_needed(app).await; + } + } + _ => {} + } +} \ No newline at end of file diff --git a/src/input/handlers/chat_list.rs b/src/input/handlers/chat_list.rs new file mode 100644 index 0000000..af50730 --- /dev/null +++ b/src/input/handlers/chat_list.rs @@ -0,0 +1,143 @@ +//! Chat list input handlers +//! +//! Handles keyboard input for the chat list view, including: +//! - Navigation between chats +//! - Folder selection +//! - Opening chats + +use crate::app::App; +use crate::tdlib::TdClientTrait; +use crate::types::{ChatId, MessageId}; +use crate::utils::{with_timeout, with_timeout_msg, with_timeout_ignore}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::Duration; + +/// Обработка навигации в списке чатов +/// +/// Обрабатывает: +/// - Up/Down/j/k: навигация между чатами +/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib) +pub async fn handle_chat_list_navigation(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::MoveDown) => { + app.next_chat(); + } + Some(crate::config::Command::MoveUp) => { + app.previous_chat(); + } + Some(crate::config::Command::SelectFolder1) => { + app.selected_folder_id = None; + app.chat_list_state.select(Some(0)); + } + Some(crate::config::Command::SelectFolder2) => { + select_folder(app, 0).await; + } + Some(crate::config::Command::SelectFolder3) => { + select_folder(app, 1).await; + } + Some(crate::config::Command::SelectFolder4) => { + select_folder(app, 2).await; + } + Some(crate::config::Command::SelectFolder5) => { + select_folder(app, 3).await; + } + Some(crate::config::Command::SelectFolder6) => { + select_folder(app, 4).await; + } + Some(crate::config::Command::SelectFolder7) => { + select_folder(app, 5).await; + } + Some(crate::config::Command::SelectFolder8) => { + select_folder(app, 6).await; + } + Some(crate::config::Command::SelectFolder9) => { + select_folder(app, 7).await; + } + _ => {} + } +} + +/// Выбирает папку по индексу и загружает её чаты +pub async fn select_folder(app: &mut App, folder_idx: usize) { + if let Some(folder) = app.td_client.folders().get(folder_idx) { + let folder_id = folder.id; + app.selected_folder_id = Some(folder_id); + app.status_message = Some("Загрузка чатов папки...".to_string()); + let _ = with_timeout( + Duration::from_secs(5), + app.td_client.load_folder_chats(folder_id, 50), + ) + .await; + app.status_message = None; + app.chat_list_state.select(Some(0)); + } +} + +/// Открывает чат и загружает все необходимые данные. +/// +/// Выполняет: +/// - Загрузку истории сообщений (с timeout) +/// - Установку current_chat_id (после загрузки, чтобы избежать race condition) +/// - Загрузку reply info (с timeout) +/// - Загрузку закреплённого сообщения (с timeout) +/// - Загрузку черновика +/// +/// При ошибке устанавливает error_message и очищает status_message. +pub async fn open_chat_and_load_data(app: &mut App, chat_id: i64) { + app.status_message = Some("Загрузка сообщений...".to_string()); + app.message_scroll_offset = 0; + + // Загружаем все доступные сообщения (без лимита) + match with_timeout_msg( + Duration::from_secs(30), + app.td_client.get_chat_history(ChatId::new(chat_id), i32::MAX), + "Таймаут загрузки сообщений", + ) + .await + { + Ok(messages) => { + // Собираем ID всех входящих сообщений для отметки как прочитанные + let incoming_message_ids: Vec = messages + .iter() + .filter(|msg| !msg.is_outgoing()) + .map(|msg| msg.id()) + .collect(); + + // Сохраняем загруженные сообщения + app.td_client.set_current_chat_messages(messages); + + // Добавляем входящие сообщения в очередь для отметки как прочитанные + if !incoming_message_ids.is_empty() { + app.td_client + .pending_view_messages_mut() + .push((ChatId::new(chat_id), incoming_message_ids)); + } + + // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории + // Это предотвращает race condition с Update::NewMessage + app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); + + // Загружаем недостающие reply info (игнорируем ошибки) + with_timeout_ignore( + Duration::from_secs(5), + app.td_client.fetch_missing_reply_info(), + ) + .await; + + // Загружаем последнее закреплённое сообщение (игнорируем ошибки) + with_timeout_ignore( + Duration::from_secs(2), + app.td_client.load_current_pinned_message(ChatId::new(chat_id)), + ) + .await; + + // Загружаем черновик + app.load_draft(); + app.status_message = None; + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + } + } +} \ No newline at end of file diff --git a/src/input/handlers/compose.rs b/src/input/handlers/compose.rs new file mode 100644 index 0000000..3090f61 --- /dev/null +++ b/src/input/handlers/compose.rs @@ -0,0 +1,81 @@ +//! Compose input handlers +//! +//! Handles text input and message composition, including: +//! - Forward mode +//! - Reply mode +//! - Edit mode +//! - Cursor movement and text editing + +use crate::app::App; +use crate::tdlib::TdClientTrait; +use crate::types::ChatId; +use crate::utils::with_timeout_msg; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::Duration; + +/// Обработка режима выбора чата для пересылки сообщения +/// +/// Обрабатывает: +/// - Навигацию по списку чатов (Up/Down) +/// - Пересылку сообщения в выбранный чат (Enter) +/// - Отмену пересылки (Esc) +pub async fn handle_forward_mode(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::Cancel) => { + app.cancel_forward(); + } + Some(crate::config::Command::SubmitMessage) => { + forward_selected_message(app).await; + app.cancel_forward(); + } + Some(crate::config::Command::MoveDown) => { + app.next_chat(); + } + Some(crate::config::Command::MoveUp) => { + app.previous_chat(); + } + _ => {} + } +} + +/// Пересылает выбранное сообщение в выбранный чат +pub async fn forward_selected_message(app: &mut App) { + // Get all required IDs with early returns + let filtered = app.get_filtered_chats(); + let Some(i) = app.chat_list_state.selected() else { + return; + }; + let Some(chat) = filtered.get(i) else { + return; + }; + let to_chat_id = chat.id; + + let Some(msg_id) = app.chat_state.selected_message_id() else { + return; + }; + let Some(from_chat_id) = app.get_selected_chat_id() else { + return; + }; + + // Forward the message with timeout + let result = with_timeout_msg( + Duration::from_secs(5), + app.td_client.forward_messages( + to_chat_id, + ChatId::new(from_chat_id), + vec![msg_id], + ), + "Таймаут пересылки", + ) + .await; + + // Handle result + match result { + Ok(_) => { + app.status_message = Some("Сообщение переслано".to_string()); + } + Err(e) => { + app.error_message = Some(e); + } + } +} \ No newline at end of file diff --git a/src/input/handlers/mod.rs b/src/input/handlers/mod.rs index 3b1a0b0..3c06e1c 100644 --- a/src/input/handlers/mod.rs +++ b/src/input/handlers/mod.rs @@ -4,10 +4,20 @@ //! - global: Global commands (Ctrl+R, Ctrl+S, etc.) //! - clipboard: Clipboard operations //! - profile: Profile helper functions +//! - chat: Keyboard input handling for open chat view +//! - chat_list: Navigation and interaction in the chat list +//! - compose: Text input, editing, and message composition +//! - modal: Modal dialogs (delete confirmation, emoji picker, etc.) +//! - search: Search functionality (chat search, message search) pub mod clipboard; pub mod global; pub mod profile; +pub mod chat; +pub mod chat_list; +pub mod compose; +pub mod modal; +pub mod search; pub use clipboard::*; pub use global::*; diff --git a/src/input/handlers/modal.rs b/src/input/handlers/modal.rs new file mode 100644 index 0000000..e71d96a --- /dev/null +++ b/src/input/handlers/modal.rs @@ -0,0 +1,314 @@ +//! Modal dialog handlers +//! +//! Handles keyboard input for modal dialogs, including: +//! - Delete confirmation +//! - Reaction picker (emoji selector) +//! - Pinned messages view +//! - Profile information modal + +use crate::app::App; +use crate::tdlib::TdClientTrait; +use crate::types::{ChatId, MessageId}; +use crate::utils::{with_timeout_msg, modal_handler::handle_yes_no}; +use crate::input::handlers::get_available_actions_count; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::Duration; + +/// Обработка режима профиля пользователя/чата +/// +/// Обрабатывает: +/// - Модалку подтверждения выхода из группы (двухшаговая) +/// - Навигацию по действиям профиля (Up/Down) +/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу +/// - Выход из режима профиля (Esc) +pub async fn handle_profile_mode(app: &mut App, key: KeyEvent, command: Option) { + // Обработка подтверждения выхода из группы + let confirmation_step = app.get_leave_group_confirmation_step(); + if confirmation_step > 0 { + match handle_yes_no(key.code) { + Some(true) => { + // Подтверждение + if confirmation_step == 1 { + // Первое подтверждение - показываем второе + app.show_leave_group_final_confirmation(); + } else if confirmation_step == 2 { + // Второе подтверждение - выходим из группы + if let Some(chat_id) = app.selected_chat_id { + let leave_result = app.td_client.leave_chat(chat_id).await; + match leave_result { + Ok(_) => { + app.status_message = Some("Вы вышли из группы".to_string()); + app.exit_profile_mode(); + app.close_chat(); + } + Err(e) => { + app.error_message = Some(e); + app.cancel_leave_group(); + } + } + } + } + } + Some(false) => { + // Отмена + app.cancel_leave_group(); + } + None => { + // Другая клавиша - игнорируем + } + } + return; + } + + // Обычная навигация по профилю + match command { + Some(crate::config::Command::Cancel) => { + app.exit_profile_mode(); + } + Some(crate::config::Command::MoveUp) => { + app.select_previous_profile_action(); + } + 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); + } + } + Some(crate::config::Command::SubmitMessage) => { + // Выполнить выбранное действие + let Some(profile) = app.get_profile_info() else { + return; + }; + + let actions = get_available_actions_count(profile); + let action_index = app.get_selected_profile_action().unwrap_or(0); + + // Guard: проверяем, что индекс действия валидный + if action_index >= actions { + return; + } + + // Определяем какое действие выбрано + let mut current_idx = 0; + + // Действие: Открыть в браузере + if let Some(username) = &profile.username { + if action_index == current_idx { + let url = format!( + "https://t.me/{}", + username.trim_start_matches('@') + ); + #[cfg(feature = "url-open")] + { + match open::that(&url) { + Ok(_) => { + app.status_message = Some(format!("Открыто: {}", url)); + } + Err(e) => { + app.error_message = + Some(format!("Ошибка открытия браузера: {}", e)); + } + } + } + #[cfg(not(feature = "url-open"))] + { + app.error_message = Some( + "Открытие URL недоступно (требуется feature 'url-open')".to_string() + ); + } + return; + } + current_idx += 1; + } + + // Действие: Скопировать ID + if action_index == current_idx { + app.status_message = Some(format!("ID скопирован: {}", profile.chat_id)); + return; + } + current_idx += 1; + + // Действие: Покинуть группу + if profile.is_group && action_index == current_idx { + app.show_leave_group_confirmation(); + } + } + _ => {} + } +} + +/// Обработка Ctrl+U для открытия профиля чата/пользователя +/// +/// Загружает информацию о профиле и переключает в режим просмотра профиля +pub async fn handle_profile_open(app: &mut App) { + let Some(chat_id) = app.selected_chat_id else { + return; + }; + + app.status_message = Some("Загрузка профиля...".to_string()); + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.get_profile_info(chat_id), + "Таймаут загрузки профиля", + ) + .await + { + Ok(profile) => { + app.enter_profile_mode(profile); + app.status_message = None; + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + } + } +} + +/// Обработка модалки подтверждения удаления сообщения +/// +/// Обрабатывает: +/// - Подтверждение удаления (Y/y/Д/д) +/// - Отмена удаления (N/n/Т/т) +/// - Удаление для себя или для всех (зависит от can_be_deleted_for_all_users) +pub async fn handle_delete_confirmation(app: &mut App, key: KeyEvent) { + match handle_yes_no(key.code) { + Some(true) => { + // Подтверждение удаления + if let Some(msg_id) = app.chat_state.selected_message_id() { + if let Some(chat_id) = app.get_selected_chat_id() { + // Находим сообщение для проверки can_be_deleted_for_all_users + let can_delete_for_all = app + .td_client + .current_chat_messages() + .iter() + .find(|m| m.id() == msg_id) + .map(|m| m.can_be_deleted_for_all_users()) + .unwrap_or(false); + + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.delete_messages( + ChatId::new(chat_id), + vec![msg_id], + can_delete_for_all, + ), + "Таймаут удаления", + ) + .await + { + Ok(_) => { + // Удаляем из локального списка + app.td_client + .current_chat_messages_mut() + .retain(|m| m.id() != msg_id); + // Сбрасываем состояние + app.chat_state = crate::app::ChatState::Normal; + } + Err(e) => { + app.error_message = Some(e); + } + } + } + } + // Закрываем модалку + app.chat_state = crate::app::ChatState::Normal; + } + Some(false) => { + // Отмена удаления + app.chat_state = crate::app::ChatState::Normal; + } + None => { + // Другая клавиша - игнорируем + } + } +} + +/// Обработка режима выбора реакции (emoji picker) +/// +/// Обрабатывает: +/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6) +/// - Добавление/удаление реакции (Enter) +/// - Выход из режима (Esc) +pub async fn handle_reaction_picker_mode(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::MoveLeft) => { + app.select_previous_reaction(); + app.needs_redraw = true; + } + Some(crate::config::Command::MoveRight) => { + app.select_next_reaction(); + app.needs_redraw = true; + } + Some(crate::config::Command::MoveUp) => { + if let crate::app::ChatState::ReactionPicker { + selected_index, + .. + } = &mut app.chat_state + { + if *selected_index >= 8 { + *selected_index = selected_index.saturating_sub(8); + app.needs_redraw = true; + } + } + } + Some(crate::config::Command::MoveDown) => { + if let crate::app::ChatState::ReactionPicker { + selected_index, + available_reactions, + .. + } = &mut app.chat_state + { + let new_index = *selected_index + 8; + if new_index < available_reactions.len() { + *selected_index = new_index; + app.needs_redraw = true; + } + } + } + Some(crate::config::Command::SubmitMessage) => { + super::chat::send_reaction(app).await; + } + Some(crate::config::Command::Cancel) => { + app.exit_reaction_picker_mode(); + app.needs_redraw = true; + } + _ => {} + } +} + +/// Обработка режима просмотра закреплённых сообщений +/// +/// Обрабатывает: +/// - Навигацию по закреплённым сообщениям (Up/Down) +/// - Переход к сообщению в истории (Enter) +/// - Выход из режима (Esc) +pub async fn handle_pinned_mode(app: &mut App, _key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::Cancel) => { + app.exit_pinned_mode(); + } + Some(crate::config::Command::MoveUp) => { + app.select_previous_pinned(); + } + Some(crate::config::Command::MoveDown) => { + app.select_next_pinned(); + } + 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() + .iter() + .position(|m| m.id() == msg_id); + + if let Some(idx) = msg_index { + let total = app.td_client.current_chat_messages().len(); + app.message_scroll_offset = total.saturating_sub(idx + 5); + } + app.exit_pinned_mode(); + } + } + _ => {} + } +} \ No newline at end of file diff --git a/src/input/handlers/search.rs b/src/input/handlers/search.rs new file mode 100644 index 0000000..6b7ef55 --- /dev/null +++ b/src/input/handlers/search.rs @@ -0,0 +1,141 @@ +//! Search input handlers +//! +//! Handles keyboard input for search functionality, including: +//! - Chat list search mode +//! - Message search mode +//! - Search query input + +use crate::app::App; +use crate::tdlib::TdClientTrait; +use crate::types::{ChatId, MessageId}; +use crate::utils::with_timeout; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::Duration; + +// Import from chat_list module +use super::chat_list::open_chat_and_load_data; + +/// Обработка режима поиска по чатам +/// +/// Обрабатывает: +/// - Редактирование поискового запроса (Backspace, Char) +/// - Навигацию по отфильтрованному списку (Up/Down) +/// - Открытие выбранного чата (Enter) +/// - Отмену поиска (Esc) +pub async fn handle_chat_search_mode(app: &mut App, key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::Cancel) => { + app.cancel_search(); + } + 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; + } + } + Some(crate::config::Command::MoveDown) => { + app.next_filtered_chat(); + } + 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)); + } + _ => {} + } + } + } +} + +/// Обработка режима поиска по сообщениям в открытом чате +/// +/// Обрабатывает: +/// - Навигацию по результатам поиска (Up/Down/N/n) +/// - Переход к выбранному сообщению (Enter) +/// - Редактирование поискового запроса (Backspace, Char) +/// - Выход из режима поиска (Esc) +pub async fn handle_message_search_mode(app: &mut App, key: KeyEvent, command: Option) { + match command { + Some(crate::config::Command::Cancel) => { + app.exit_message_search_mode(); + } + Some(crate::config::Command::MoveUp) => { + app.select_previous_search_result(); + } + Some(crate::config::Command::MoveDown) => { + app.select_next_search_result(); + } + 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 + .td_client + .current_chat_messages() + .iter() + .position(|m| m.id() == msg_id); + + if let Some(idx) = msg_index { + let total = app.td_client.current_chat_messages().len(); + app.message_scroll_offset = total.saturating_sub(idx + 5); + } + 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; + }; + query.pop(); + app.update_search_query(query.clone()); + perform_message_search(app, &query).await; + } + KeyCode::Char(c) => { + let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else { + return; + }; + query.push(c); + app.update_search_query(query.clone()); + perform_message_search(app, &query).await; + } + _ => {} + } + } + } +} + +/// Выполняет поиск по сообщениям с обновлением результатов +pub async fn perform_message_search(app: &mut App, query: &str) { + let Some(chat_id) = app.get_selected_chat_id() else { + return; + }; + + if query.is_empty() { + app.set_search_results(Vec::new()); + return; + } + + if let Ok(results) = with_timeout( + Duration::from_secs(3), + app.td_client.search_messages(ChatId::new(chat_id), query), + ) + .await + { + app.set_search_results(results); + } +} \ No newline at end of file diff --git a/src/input/main_input.rs b/src/input/main_input.rs index c178639..fd9823c 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -3,6 +3,23 @@ use crate::tdlib::TdClientTrait; use crate::input::handlers::{ copy_to_clipboard, format_message_for_clipboard, get_available_actions_count, handle_global_commands, + modal::{ + handle_profile_mode, handle_profile_open, handle_delete_confirmation, + handle_reaction_picker_mode, handle_pinned_mode, + }, + search::{ + handle_chat_search_mode, handle_message_search_mode, perform_message_search, + }, + compose::{ + handle_forward_mode, forward_selected_message, + }, + chat_list::{ + handle_chat_list_navigation, select_folder, open_chat_and_load_data, + }, + chat::{ + handle_message_selection, handle_enter_key, send_reaction, + load_older_messages_if_needed, handle_open_chat_keyboard_input, + }, }; use crate::tdlib::ChatAction; use crate::types::{ChatId, MessageId}; @@ -11,246 +28,7 @@ use crate::utils::modal_handler::handle_yes_no; use crossterm::event::{KeyCode, KeyEvent}; use std::time::{Duration, Instant}; -/// Обработка режима профиля пользователя/чата -/// -/// Обрабатывает: -/// - Модалку подтверждения выхода из группы (двухшаговая) -/// - Навигацию по действиям профиля (Up/Down) -/// - Выполнение выбранного действия (Enter): открыть в браузере, скопировать ID, покинуть группу -/// - Выход из режима профиля (Esc) -async fn handle_profile_mode(app: &mut App, key: KeyEvent, command: Option) { - // Обработка подтверждения выхода из группы - let confirmation_step = app.get_leave_group_confirmation_step(); - if confirmation_step > 0 { - match handle_yes_no(key.code) { - Some(true) => { - // Подтверждение - if confirmation_step == 1 { - // Первое подтверждение - показываем второе - app.show_leave_group_final_confirmation(); - } else if confirmation_step == 2 { - // Второе подтверждение - выходим из группы - if let Some(chat_id) = app.selected_chat_id { - let leave_result = app.td_client.leave_chat(chat_id).await; - match leave_result { - Ok(_) => { - app.status_message = Some("Вы вышли из группы".to_string()); - app.exit_profile_mode(); - app.close_chat(); - } - Err(e) => { - app.error_message = Some(e); - app.cancel_leave_group(); - } - } - } - } - } - Some(false) => { - // Отмена - app.cancel_leave_group(); - } - None => { - // Другая клавиша - игнорируем - } - } - return; - } - // Обычная навигация по профилю - match command { - Some(crate::config::Command::Cancel) => { - app.exit_profile_mode(); - } - Some(crate::config::Command::MoveUp) => { - app.select_previous_profile_action(); - } - 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); - } - } - Some(crate::config::Command::SubmitMessage) => { - // Выполнить выбранное действие - let Some(profile) = app.get_profile_info() else { - return; - }; - - let actions = get_available_actions_count(profile); - let action_index = app.get_selected_profile_action().unwrap_or(0); - - // Guard: проверяем, что индекс действия валидный - if action_index >= actions { - return; - } - - // Определяем какое действие выбрано - let mut current_idx = 0; - - // Действие: Открыть в браузере - if let Some(username) = &profile.username { - if action_index == current_idx { - let url = format!( - "https://t.me/{}", - username.trim_start_matches('@') - ); - #[cfg(feature = "url-open")] - { - match open::that(&url) { - Ok(_) => { - app.status_message = Some(format!("Открыто: {}", url)); - } - Err(e) => { - app.error_message = - Some(format!("Ошибка открытия браузера: {}", e)); - } - } - } - #[cfg(not(feature = "url-open"))] - { - app.error_message = Some( - "Открытие URL недоступно (требуется feature 'url-open')".to_string() - ); - } - return; - } - current_idx += 1; - } - - // Действие: Скопировать ID - if action_index == current_idx { - app.status_message = Some(format!("ID скопирован: {}", profile.chat_id)); - return; - } - current_idx += 1; - - // Действие: Покинуть группу - if profile.is_group && action_index == current_idx { - app.show_leave_group_confirmation(); - } - } - _ => {} - } -} - -/// Обработка Ctrl+U для открытия профиля чата/пользователя -/// -/// Загружает информацию о профиле и переключает в режим просмотра профиля -async fn handle_profile_open(app: &mut App) { - let Some(chat_id) = app.selected_chat_id else { - return; - }; - - app.status_message = Some("Загрузка профиля...".to_string()); - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.get_profile_info(chat_id), - "Таймаут загрузки профиля", - ) - .await - { - Ok(profile) => { - app.enter_profile_mode(profile); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } -} - -/// Обработка режима выбора сообщения для действий -/// -/// Обрабатывает: -/// - Навигацию по сообщениям (Up/Down) -/// - Удаление сообщения (d/в/Delete) -/// - Ответ на сообщение (r/к) -/// - Пересылку сообщения (f/а) -/// - Копирование сообщения (y/н) -/// - Добавление реакции (e/у) -async fn handle_message_selection(app: &mut App, _key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::MoveUp) => { - app.select_previous_message(); - } - Some(crate::config::Command::MoveDown) => { - app.select_next_message(); - } - Some(crate::config::Command::DeleteMessage) => { - let Some(msg) = app.get_selected_message() else { - return; - }; - let can_delete = - msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users(); - if can_delete { - app.chat_state = crate::app::ChatState::DeleteConfirmation { - message_id: msg.id(), - }; - } - } - Some(crate::config::Command::ReplyMessage) => { - app.start_reply_to_selected(); - } - Some(crate::config::Command::ForwardMessage) => { - app.start_forward_selected(); - } - Some(crate::config::Command::CopyMessage) => { - let Some(msg) = app.get_selected_message() else { - return; - }; - let text = format_message_for_clipboard(&msg); - match copy_to_clipboard(&text) { - Ok(_) => { - app.status_message = Some("Сообщение скопировано".to_string()); - } - Err(e) => { - app.error_message = Some(format!("Ошибка копирования: {}", e)); - } - } - } - Some(crate::config::Command::ReactMessage) => { - let Some(msg) = app.get_selected_message() else { - return; - }; - let chat_id = app.selected_chat_id.unwrap(); - let message_id = msg.id(); - - app.status_message = Some("Загрузка реакций...".to_string()); - app.needs_redraw = true; - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client - .get_message_available_reactions(chat_id, message_id), - "Таймаут загрузки реакций", - ) - .await - { - Ok(reactions) => { - let reactions: Vec = reactions; - if reactions.is_empty() { - app.error_message = - Some("Реакции недоступны для этого сообщения".to_string()); - app.status_message = None; - app.needs_redraw = true; - } else { - app.enter_reaction_picker_mode(message_id.as_i64(), reactions); - app.status_message = None; - app.needs_redraw = true; - } - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - app.needs_redraw = true; - } - } - } - _ => {} - } -} /// Обработка клавиши Esc /// @@ -295,751 +73,7 @@ async fn handle_escape_key(app: &mut App) { app.close_chat(); } -/// Редактирование существующего сообщения -async fn edit_message(app: &mut App, chat_id: i64, msg_id: MessageId, text: String) { - // Проверяем, что сообщение есть в локальном кэше - let msg_exists = app.td_client.current_chat_messages() - .iter() - .any(|m| m.id() == msg_id); - - if !msg_exists { - app.error_message = Some(format!( - "Сообщение {} не найдено в кэше чата {}", - msg_id.as_i64(), chat_id - )); - app.chat_state = crate::app::ChatState::Normal; - app.message_input.clear(); - app.cursor_position = 0; - return; - } - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.edit_message(ChatId::new(chat_id), msg_id, text), - "Таймаут редактирования", - ) - .await - { - Ok(mut edited_msg) => { - // Сохраняем reply_to из старого сообщения (если есть) - let messages = app.td_client.current_chat_messages_mut(); - if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) { - let old_reply_to = messages[pos].interactions.reply_to.clone(); - // Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый - if let Some(old_reply) = old_reply_to { - if edited_msg.interactions.reply_to.as_ref() - .map_or(true, |r| r.sender_name == "Unknown") { - edited_msg.interactions.reply_to = Some(old_reply); - } - } - // Заменяем сообщение - messages[pos] = edited_msg; - } - // Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования - app.message_input.clear(); - app.cursor_position = 0; - app.chat_state = crate::app::ChatState::Normal; - app.needs_redraw = true; - } - Err(e) => { - app.error_message = Some(e); - } - } -} - -/// Отправка нового сообщения (с опциональным reply) -async fn send_new_message(app: &mut App, chat_id: i64, text: String) { - let reply_to_id = if app.is_replying() { - app.chat_state.selected_message_id() - } else { - None - }; - - // Создаём ReplyInfo ДО отправки, пока сообщение точно доступно - let reply_info = app.get_replying_to_message().map(|m| { - crate::tdlib::ReplyInfo { - message_id: m.id(), - sender_name: m.sender_name().to_string(), - text: m.text().to_string(), - } - }); - - app.message_input.clear(); - app.cursor_position = 0; - // Сбрасываем режим reply если он был активен - if app.is_replying() { - app.chat_state = crate::app::ChatState::Normal; - } - app.last_typing_sent = None; - - // Отменяем typing status - app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel).await; - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info), - "Таймаут отправки", - ) - .await - { - Ok(sent_msg) => { - // Добавляем отправленное сообщение в список (с лимитом) - app.td_client.push_message(sent_msg); - // Сбрасываем скролл чтобы видеть новое сообщение - app.message_scroll_offset = 0; - } - Err(e) => { - app.error_message = Some(e); - } - } -} - -/// Обработка клавиши Enter -/// -/// Обрабатывает три сценария: -/// 1. В режиме выбора сообщения: начать редактирование -/// 2. В открытом чате: отправить новое или редактировать существующее сообщение -/// 3. В списке чатов: открыть выбранный чат -async fn handle_enter_key(app: &mut App) { - // Сценарий 1: Открытие чата из списка - if app.selected_chat_id.is_none() { - let prev_selected = app.selected_chat_id; - app.select_current_chat(); - - if app.selected_chat_id != prev_selected { - if let Some(chat_id) = app.get_selected_chat_id() { - open_chat_and_load_data(app, chat_id).await; - } - } - return; - } - - // Сценарий 2: Режим выбора сообщения - начать редактирование - if app.is_selecting_message() { - if !app.start_editing_selected() { - // Нельзя редактировать это сообщение - app.chat_state = crate::app::ChatState::Normal; - } - return; - } - - // Сценарий 3: Отправка или редактирование сообщения - if !is_non_empty(&app.message_input) { - return; - } - - let Some(chat_id) = app.get_selected_chat_id() else { - return; - }; - - let text = app.message_input.clone(); - - if app.is_editing() { - // Редактирование существующего сообщения - if let Some(msg_id) = app.chat_state.selected_message_id() { - edit_message(app, chat_id, msg_id, text).await; - } - } else { - // Отправка нового сообщения - send_new_message(app, chat_id, text).await; - } -} - -/// Обработка режима поиска по чатам -/// -/// Обрабатывает: -/// - Редактирование поискового запроса (Backspace, Char) -/// - Навигацию по отфильтрованному списку (Up/Down) -/// - Открытие выбранного чата (Enter) -/// - Отмену поиска (Esc) -async fn handle_chat_search_mode(app: &mut App, key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::Cancel) => { - app.cancel_search(); - } - 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; - } - } - Some(crate::config::Command::MoveDown) => { - app.next_filtered_chat(); - } - 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)); - } - _ => {} - } - } - } -} - -/// Обработка режима выбора чата для пересылки сообщения -/// -/// Обрабатывает: -/// - Навигацию по списку чатов (Up/Down) -/// - Пересылку сообщения в выбранный чат (Enter) -/// - Отмену пересылки (Esc) -async fn handle_forward_mode(app: &mut App, _key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::Cancel) => { - app.cancel_forward(); - } - Some(crate::config::Command::SubmitMessage) => { - forward_selected_message(app).await; - app.cancel_forward(); - } - Some(crate::config::Command::MoveDown) => { - app.next_chat(); - } - Some(crate::config::Command::MoveUp) => { - app.previous_chat(); - } - _ => {} - } -} - -/// Пересылает выбранное сообщение в выбранный чат -async fn forward_selected_message(app: &mut App) { - // Get all required IDs with early returns - let filtered = app.get_filtered_chats(); - let Some(i) = app.chat_list_state.selected() else { - return; - }; - let Some(chat) = filtered.get(i) else { - return; - }; - let to_chat_id = chat.id; - - let Some(msg_id) = app.chat_state.selected_message_id() else { - return; - }; - let Some(from_chat_id) = app.get_selected_chat_id() else { - return; - }; - - // Forward the message with timeout - let result = with_timeout_msg( - Duration::from_secs(5), - app.td_client.forward_messages( - to_chat_id, - ChatId::new(from_chat_id), - vec![msg_id], - ), - "Таймаут пересылки", - ) - .await; - - // Handle result - match result { - Ok(_) => { - app.status_message = Some("Сообщение переслано".to_string()); - } - Err(e) => { - app.error_message = Some(e); - } - } -} - -/// Отправляет реакцию на выбранное сообщение -async fn send_reaction(app: &mut App) { - // Get selected reaction emoji - let Some(emoji) = app.get_selected_reaction().cloned() else { - return; - }; - - // Get selected message ID - let Some(message_id) = app.get_selected_message_for_reaction() else { - return; - }; - - // Get chat ID - let Some(chat_id) = app.selected_chat_id else { - return; - }; - - let message_id = MessageId::new(message_id); - app.status_message = Some("Отправка реакции...".to_string()); - app.needs_redraw = true; - - // Send reaction with timeout - let result = with_timeout_msg( - Duration::from_secs(5), - app.td_client.toggle_reaction(chat_id, message_id, emoji.clone()), - "Таймаут отправки реакции", - ) - .await; - - // Handle result - match result { - Ok(_) => { - app.status_message = Some(format!("Реакция {} добавлена", emoji)); - app.exit_reaction_picker_mode(); - app.needs_redraw = true; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - app.needs_redraw = true; - } - } -} - -/// Подгружает старые сообщения если скролл близко к верху -async fn load_older_messages_if_needed(app: &mut App) { - // Check if there are messages to load from - if app.td_client.current_chat_messages().is_empty() { - return; - } - - // Get the oldest message ID - let oldest_msg_id = app - .td_client - .current_chat_messages() - .first() - .map(|m| m.id()) - .unwrap_or(MessageId::new(0)); - - // Get current chat ID - let Some(chat_id) = app.get_selected_chat_id() else { - return; - }; - - // Check if scroll is near the top - let message_count = app.td_client.current_chat_messages().len(); - if app.message_scroll_offset <= message_count.saturating_sub(10) { - return; - } - - // Load older messages with timeout - let Ok(older) = with_timeout( - Duration::from_secs(3), - app.td_client.load_older_messages(ChatId::new(chat_id), oldest_msg_id), - ) - .await - else { - return; - }; - - // Add older messages to the beginning if any were loaded - if !older.is_empty() { - let msgs = app.td_client.current_chat_messages_mut(); - msgs.splice(0..0, older); - } -} - -/// Обработка модалки подтверждения удаления сообщения -/// -/// Обрабатывает: -/// - Подтверждение удаления (Y/y/Д/д) -/// - Отмена удаления (N/n/Т/т) -/// - Удаление для себя или для всех (зависит от can_be_deleted_for_all_users) -async fn handle_delete_confirmation(app: &mut App, key: KeyEvent) { - match handle_yes_no(key.code) { - Some(true) => { - // Подтверждение удаления - if let Some(msg_id) = app.chat_state.selected_message_id() { - if let Some(chat_id) = app.get_selected_chat_id() { - // Находим сообщение для проверки can_be_deleted_for_all_users - let can_delete_for_all = app - .td_client - .current_chat_messages() - .iter() - .find(|m| m.id() == msg_id) - .map(|m| m.can_be_deleted_for_all_users()) - .unwrap_or(false); - - match with_timeout_msg( - Duration::from_secs(5), - app.td_client.delete_messages( - ChatId::new(chat_id), - vec![msg_id], - can_delete_for_all, - ), - "Таймаут удаления", - ) - .await - { - Ok(_) => { - // Удаляем из локального списка - app.td_client - .current_chat_messages_mut() - .retain(|m| m.id() != msg_id); - // Сбрасываем состояние - app.chat_state = crate::app::ChatState::Normal; - } - Err(e) => { - app.error_message = Some(e); - } - } - } - } - // Закрываем модалку - app.chat_state = crate::app::ChatState::Normal; - } - Some(false) => { - // Отмена удаления - app.chat_state = crate::app::ChatState::Normal; - } - None => { - // Другая клавиша - игнорируем - } - } -} - -/// Обработка режима выбора реакции (emoji picker) -/// -/// Обрабатывает: -/// - Навигацию по сетке реакций: Left/Right, Up/Down (сетка 8x6) -/// - Добавление/удаление реакции (Enter) -/// - Выход из режима (Esc) -async fn handle_reaction_picker_mode(app: &mut App, _key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::MoveLeft) => { - app.select_previous_reaction(); - app.needs_redraw = true; - } - Some(crate::config::Command::MoveRight) => { - app.select_next_reaction(); - app.needs_redraw = true; - } - Some(crate::config::Command::MoveUp) => { - if let crate::app::ChatState::ReactionPicker { - selected_index, - .. - } = &mut app.chat_state - { - if *selected_index >= 8 { - *selected_index = selected_index.saturating_sub(8); - app.needs_redraw = true; - } - } - } - Some(crate::config::Command::MoveDown) => { - if let crate::app::ChatState::ReactionPicker { - selected_index, - available_reactions, - .. - } = &mut app.chat_state - { - let new_index = *selected_index + 8; - if new_index < available_reactions.len() { - *selected_index = new_index; - app.needs_redraw = true; - } - } - } - Some(crate::config::Command::SubmitMessage) => { - send_reaction(app).await; - } - Some(crate::config::Command::Cancel) => { - app.exit_reaction_picker_mode(); - app.needs_redraw = true; - } - _ => {} - } -} - -/// Обработка режима просмотра закреплённых сообщений -/// -/// Обрабатывает: -/// - Навигацию по закреплённым сообщениям (Up/Down) -/// - Переход к сообщению в истории (Enter) -/// - Выход из режима (Esc) -async fn handle_pinned_mode(app: &mut App, _key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::Cancel) => { - app.exit_pinned_mode(); - } - Some(crate::config::Command::MoveUp) => { - app.select_previous_pinned(); - } - Some(crate::config::Command::MoveDown) => { - app.select_next_pinned(); - } - 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() - .iter() - .position(|m| m.id() == msg_id); - - if let Some(idx) = msg_index { - let total = app.td_client.current_chat_messages().len(); - app.message_scroll_offset = total.saturating_sub(idx + 5); - } - app.exit_pinned_mode(); - } - } - _ => {} - } -} - -/// Выполняет поиск по сообщениям с обновлением результатов -async fn perform_message_search(app: &mut App, query: &str) { - let Some(chat_id) = app.get_selected_chat_id() else { - return; - }; - - if query.is_empty() { - app.set_search_results(Vec::new()); - return; - } - - if let Ok(results) = with_timeout( - Duration::from_secs(3), - app.td_client.search_messages(ChatId::new(chat_id), query), - ) - .await - { - app.set_search_results(results); - } -} - -/// Обработка режима поиска по сообщениям в открытом чате -/// -/// Обрабатывает: -/// - Навигацию по результатам поиска (Up/Down/N/n) -/// - Переход к выбранному сообщению (Enter) -/// - Редактирование поискового запроса (Backspace, Char) -/// - Выход из режима поиска (Esc) -async fn handle_message_search_mode(app: &mut App, key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::Cancel) => { - app.exit_message_search_mode(); - } - Some(crate::config::Command::MoveUp) => { - app.select_previous_search_result(); - } - Some(crate::config::Command::MoveDown) => { - app.select_next_search_result(); - } - 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 - .td_client - .current_chat_messages() - .iter() - .position(|m| m.id() == msg_id); - - if let Some(idx) = msg_index { - let total = app.td_client.current_chat_messages().len(); - app.message_scroll_offset = total.saturating_sub(idx + 5); - } - 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; - }; - query.pop(); - app.update_search_query(query.clone()); - perform_message_search(app, &query).await; - } - KeyCode::Char(c) => { - let Some(mut query) = app.get_search_query().map(|s| s.to_string()) else { - return; - }; - query.push(c); - app.update_search_query(query.clone()); - perform_message_search(app, &query).await; - } - _ => {} - } - } - } -} - -/// Обработка навигации в списке чатов -/// -/// Обрабатывает: -/// - Up/Down/j/k: навигация между чатами -/// - Цифры 1-9: переключение папок (1=All, 2-9=папки из TDLib) -async fn handle_chat_list_navigation(app: &mut App, _key: KeyEvent, command: Option) { - match command { - Some(crate::config::Command::MoveDown) => { - app.next_chat(); - } - Some(crate::config::Command::MoveUp) => { - app.previous_chat(); - } - Some(crate::config::Command::SelectFolder1) => { - app.selected_folder_id = None; - app.chat_list_state.select(Some(0)); - } - Some(crate::config::Command::SelectFolder2) => { - select_folder(app, 0).await; - } - Some(crate::config::Command::SelectFolder3) => { - select_folder(app, 1).await; - } - Some(crate::config::Command::SelectFolder4) => { - select_folder(app, 2).await; - } - Some(crate::config::Command::SelectFolder5) => { - select_folder(app, 3).await; - } - Some(crate::config::Command::SelectFolder6) => { - select_folder(app, 4).await; - } - Some(crate::config::Command::SelectFolder7) => { - select_folder(app, 5).await; - } - Some(crate::config::Command::SelectFolder8) => { - select_folder(app, 6).await; - } - Some(crate::config::Command::SelectFolder9) => { - select_folder(app, 7).await; - } - _ => {} - } -} - -async fn select_folder(app: &mut App, folder_idx: usize) { - if let Some(folder) = app.td_client.folders().get(folder_idx) { - let folder_id = folder.id; - app.selected_folder_id = Some(folder_id); - app.status_message = Some("Загрузка чатов папки...".to_string()); - let _ = with_timeout( - Duration::from_secs(5), - app.td_client.load_folder_chats(folder_id, 50), - ) - .await; - app.status_message = None; - app.chat_list_state.select(Some(0)); - } -} - -/// Обработка ввода с клавиатуры в открытом чате -/// -/// Обрабатывает: -/// - Backspace/Delete: удаление символов относительно курсора -/// - Char: вставка символов в позицию курсора + typing status -/// - Left/Right/Home/End: навигация курсора -/// - Up/Down: скролл сообщений или начало режима выбора -async fn handle_open_chat_keyboard_input(app: &mut App, key: KeyEvent) { - match key.code { - KeyCode::Backspace => { - // Удаляем символ слева от курсора - if app.cursor_position > 0 { - // Находим 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; - } - } - KeyCode::Delete => { - // Удаляем символ справа от курсора - let len = app.message_input.chars().count(); - if app.cursor_position < len { - // Находим 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) => { - // Вставляем символ в позицию курсора - 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); - } - app.cursor_position += 1; - - // Отправляем typing status с throttling (не чаще 1 раза в 5 сек) - let should_send_typing = app - .last_typing_sent - .map(|t| t.elapsed().as_secs() >= 5) - .unwrap_or(true); - if should_send_typing { - if let Some(chat_id) = app.get_selected_chat_id() { - app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await; - app.last_typing_sent = Some(Instant::now()); - } - } - } - KeyCode::Left => { - // Курсор влево - if app.cursor_position > 0 { - app.cursor_position -= 1; - } - } - KeyCode::Right => { - // Курсор вправо - let len = app.message_input.chars().count(); - if app.cursor_position < len { - app.cursor_position += 1; - } - } - KeyCode::Home => { - // Курсор в начало - app.cursor_position = 0; - } - KeyCode::End => { - // Курсор в конец - app.cursor_position = app.message_input.chars().count(); - } - // Стрелки вверх/вниз - скролл сообщений или начало выбора - KeyCode::Down => { - // Скролл вниз (к новым сообщениям) - if app.message_scroll_offset > 0 { - app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3); - } - } - KeyCode::Up => { - // Если инпут пустой и не в режиме редактирования — начать выбор сообщения - if app.message_input.is_empty() && !app.is_editing() { - app.start_message_selection(); - } else { - // Скролл вверх (к старым сообщениям) - app.message_scroll_offset += 3; - - // Подгружаем старые сообщения если нужно - load_older_messages_if_needed(app).await; - } - } - _ => {} - } -} - +/// Главный обработчик ввода - роутер для всех режимов приложения pub async fn handle(app: &mut App, key: KeyEvent) { // Глобальные команды (работают всегда) if handle_global_commands(app, key).await { @@ -1128,72 +162,3 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } } -/// Открывает чат и загружает все необходимые данные. -/// -/// Выполняет: -/// - Загрузку истории сообщений (с timeout) -/// - Установку current_chat_id (после загрузки, чтобы избежать race condition) -/// - Загрузку reply info (с timeout) -/// - Загрузку закреплённого сообщения (с timeout) -/// - Загрузку черновика -/// -/// При ошибке устанавливает error_message и очищает status_message. -async fn open_chat_and_load_data(app: &mut App, chat_id: i64) { - app.status_message = Some("Загрузка сообщений...".to_string()); - app.message_scroll_offset = 0; - - // Загружаем последние 100 сообщений для быстрого открытия чата - // Остальные сообщения будут подгружаться при скролле вверх - match with_timeout_msg( - Duration::from_secs(10), - app.td_client.get_chat_history(ChatId::new(chat_id), 100), - "Таймаут загрузки сообщений", - ) - .await - { - Ok(messages) => { - // Собираем ID всех входящих сообщений для отметки как прочитанные - let incoming_message_ids: Vec = messages - .iter() - .filter(|msg| !msg.is_outgoing()) - .map(|msg| msg.id()) - .collect(); - - // Сохраняем загруженные сообщения - app.td_client.set_current_chat_messages(messages); - - // Добавляем входящие сообщения в очередь для отметки как прочитанные - if !incoming_message_ids.is_empty() { - app.td_client - .pending_view_messages_mut() - .push((ChatId::new(chat_id), incoming_message_ids)); - } - - // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории - // Это предотвращает race condition с Update::NewMessage - app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); - - // Загружаем недостающие reply info (игнорируем ошибки) - with_timeout_ignore( - Duration::from_secs(5), - app.td_client.fetch_missing_reply_info(), - ) - .await; - - // Загружаем последнее закреплённое сообщение (игнорируем ошибки) - with_timeout_ignore( - Duration::from_secs(2), - app.td_client.load_current_pinned_message(ChatId::new(chat_id)), - ) - .await; - - // Загружаем черновик - app.load_draft(); - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } -}