diff --git a/CONTEXT.md b/CONTEXT.md index 2f50cef..970dad4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -332,6 +332,29 @@ reaction_other = "gray" ## Последние обновления (2026-02-01) +### Рефакторинг — Подготовка к разделению больших файлов (#2) ⏳ (2026-02-01) + +**Что сделано**: +- ✅ Создана модульная структура `src/input/handlers/` (подготовка): + - `clipboard.rs` (~100 строк) - извлечены операции с буфером обмена + - `global.rs` (~90 строк) - извлечены глобальные команды (Ctrl+R/S/P/F) + - Заглушки: `profile.rs`, `search.rs`, `modal.rs`, `messages.rs`, `chat_list.rs` +- ⏳ `main_input.rs` остаётся монолитным (1139 строк) + - Попытка полной миграции привела к поломке навигации - откачено + - Handlers остаются как подготовка к постепенной миграции + +**Статус Большие файлы (#2.1)**: ⏳ Подготовка (2/7) +- ✅ Структура handlers создана +- ✅ clipboard.rs извлечён (не используется, подготовка) +- ✅ global.rs извлечён (не используется, подготовка) +- ⏳ Требуется постепенная миграция с тщательным тестированием + +**Урок**: Критичная логика ввода требует осторожного рефакторинга с проверкой функциональности после каждого шага. + +**Все тесты проходят**: 563 passed; 0 failed ✅ + +--- + ### Рефакторинг — Быстрые победы (Вариант 1) ✅ (2026-02-01) **Что сделано**: diff --git a/REFACTORING_OPPORTUNITIES.md b/REFACTORING_OPPORTUNITIES.md index e30fc56..d2cf4f5 100644 --- a/REFACTORING_OPPORTUNITIES.md +++ b/REFACTORING_OPPORTUNITIES.md @@ -69,7 +69,7 @@ ## 2. Большие файлы/функции **Приоритет:** 🔴 Высокий -**Статус:** ❌ Не начато +**Статус:** ✅ Частично выполнено (2026-02-01) **Объем:** 4 файла, 1000+ строк каждый ### Проблемы @@ -83,14 +83,15 @@ ### Решение -#### 2.1. Разделить `src/input/main_input.rs` +#### 2.1. Разделить `src/input/main_input.rs` - ⏳ В процессе (2026-02-01) -- [ ] Создать `src/input/handlers/chat_list_input.rs` -- [ ] Создать `src/input/handlers/messages_input.rs` -- [ ] Создать `src/input/handlers/compose_input.rs` -- [ ] Создать `src/input/handlers/search_input.rs` -- [ ] Создать `src/input/handlers/modal_input.rs` -- [ ] Главный `handle()` делегирует по screen state +- [x] Создана структура `src/input/handlers/` (7 модулей) - ПОДГОТОВКА +- [x] Создан `handlers/clipboard.rs` (~100 строк) - извлечён из main_input +- [x] Создан `handlers/global.rs` (~90 строк) - извлечён из main_input +- [x] Созданы заглушки: `profile.rs`, `search.rs`, `modal.rs`, `messages.rs`, `chat_list.rs` +- [ ] Постепенно мигрировать логику в handlers (требуется тщательное тестирование) + +**Примечание**: Попытка полного переноса была откачена из-за поломки навигации. Handlers остаются как подготовка к будущей миграции. Текущий подход: извлекать независимые модули (clipboard, global), не трогая критичную логику ввода. #### 2.2. Разделить `src/tdlib/client.rs` diff --git a/src/input/handlers/chat_list.rs b/src/input/handlers/chat_list.rs new file mode 100644 index 0000000..80703fe --- /dev/null +++ b/src/input/handlers/chat_list.rs @@ -0,0 +1,10 @@ +//! Chat list navigation input handling + +use crate::app::App; +use crossterm::event::KeyEvent; + +/// Обрабатывает ввод в списке чатов +pub async fn handle_chat_list_input(app: &mut App, key: KeyEvent) { + // TODO: Implement chat list input handling + let _ = (app, key); +} diff --git a/src/input/handlers/clipboard.rs b/src/input/handlers/clipboard.rs new file mode 100644 index 0000000..b92605c --- /dev/null +++ b/src/input/handlers/clipboard.rs @@ -0,0 +1,101 @@ +//! Clipboard operations for copying messages + +use crate::tdlib::MessageInfo; + +/// Копирует текст в системный буфер обмена +#[cfg(feature = "clipboard")] +pub 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"))] +pub fn copy_to_clipboard(_text: &str) -> Result<(), String> { + Err("Копирование в буфер обмена недоступно (требуется feature 'clipboard')".to_string()) +} + +/// Форматирует сообщение для копирования с контекстом +pub fn format_message_for_clipboard(msg: &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 = 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 +} diff --git a/src/input/handlers/global.rs b/src/input/handlers/global.rs new file mode 100644 index 0000000..4480c38 --- /dev/null +++ b/src/input/handlers/global.rs @@ -0,0 +1,85 @@ +//! Global commands that work from any screen +//! +//! Handles Ctrl+ combinations: +//! - Ctrl+R: Refresh chats +//! - Ctrl+S: Start search +//! - Ctrl+P: View pinned messages +//! - Ctrl+F: Search messages in chat + +use crate::app::App; +use crate::types::ChatId; +use crate::utils::{with_timeout, with_timeout_msg}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::Duration; + +/// Обрабатывает глобальные команды (Ctrl+ combinations). +/// +/// # Returns +/// +/// `true` если команда была обработана, `false` если нет +pub async fn handle_global_commands(app: &mut App, key: KeyEvent) -> bool { + let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + match key.code { + KeyCode::Char('r') if has_ctrl => { + // Ctrl+R - обновить список чатов + app.status_message = Some("Обновление чатов...".to_string()); + let _ = with_timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; + app.status_message = None; + true + } + KeyCode::Char('s') if has_ctrl => { + // Ctrl+S - начать поиск (только если чат не открыт) + if app.selected_chat_id.is_none() { + app.start_search(); + } + true + } + KeyCode::Char('p') if has_ctrl => { + // Ctrl+P - режим просмотра закреплённых сообщений + handle_pinned_messages(app).await; + true + } + KeyCode::Char('f') if has_ctrl => { + // Ctrl+F - поиск по сообщениям в открытом чате + if app.selected_chat_id.is_some() + && !app.is_pinned_mode() + && !app.is_message_search_mode() + { + app.enter_message_search_mode(); + } + true + } + _ => false, + } +} + +/// Обрабатывает загрузку и отображение закреплённых сообщений +async fn handle_pinned_messages(app: &mut App) { + 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 = 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; + } + } + } + } +} diff --git a/src/input/handlers/messages.rs b/src/input/handlers/messages.rs new file mode 100644 index 0000000..199a815 --- /dev/null +++ b/src/input/handlers/messages.rs @@ -0,0 +1,10 @@ +//! Message input handling when chat is open + +use crate::app::App; +use crossterm::event::KeyEvent; + +/// Обрабатывает ввод когда открыт чат +pub async fn handle_messages_input(app: &mut App, key: KeyEvent) { + // TODO: Implement messages input handling + let _ = (app, key); +} diff --git a/src/input/handlers/mod.rs b/src/input/handlers/mod.rs new file mode 100644 index 0000000..12146b1 --- /dev/null +++ b/src/input/handlers/mod.rs @@ -0,0 +1,26 @@ +//! Input handlers organized by screen/mode +//! +//! This module contains handlers for different input contexts: +//! - global: Global commands (Ctrl+R, Ctrl+S, etc.) +//! - profile: Profile mode input +//! - search: Search modes (chat search, message search) +//! - modal: Modal modes (pinned, reactions, delete, forward) +//! - messages: Message input when chat is open +//! - chat_list: Chat list navigation +//! - clipboard: Clipboard operations + +pub mod chat_list; +pub mod clipboard; +pub mod global; +pub mod messages; +pub mod modal; +pub mod profile; +pub mod search; + +pub use chat_list::*; +pub use clipboard::*; +pub use global::*; +pub use messages::*; +pub use modal::*; +pub use profile::*; +pub use search::*; diff --git a/src/input/handlers/modal.rs b/src/input/handlers/modal.rs new file mode 100644 index 0000000..54eb589 --- /dev/null +++ b/src/input/handlers/modal.rs @@ -0,0 +1,34 @@ +//! Modal mode input handling +//! +//! Handles input for modal states: +//! - Pinned messages view +//! - Reaction picker +//! - Delete confirmation +//! - Forward mode + +use crate::app::App; +use crossterm::event::KeyEvent; + +/// Обрабатывает ввод в режиме закреплённых сообщений +pub async fn handle_pinned_input(app: &mut App, key: KeyEvent) { + // TODO: Implement pinned messages input handling + let _ = (app, key); +} + +/// Обрабатывает ввод в режиме выбора реакции +pub async fn handle_reaction_picker_input(app: &mut App, key: KeyEvent) { + // TODO: Implement reaction picker input handling + let _ = (app, key); +} + +/// Обрабатывает ввод в режиме подтверждения удаления +pub async fn handle_delete_confirmation_input(app: &mut App, key: KeyEvent) { + // TODO: Implement delete confirmation input handling + let _ = (app, key); +} + +/// Обрабатывает ввод в режиме пересылки +pub async fn handle_forward_input(app: &mut App, key: KeyEvent) { + // TODO: Implement forward mode input handling + let _ = (app, key); +} diff --git a/src/input/handlers/profile.rs b/src/input/handlers/profile.rs new file mode 100644 index 0000000..8926b7b --- /dev/null +++ b/src/input/handlers/profile.rs @@ -0,0 +1,31 @@ +//! Profile mode input handling + +use crate::app::App; +use crossterm::event::KeyEvent; + +/// Обрабатывает ввод в режиме профиля +pub async fn handle_profile_input(app: &mut App, key: KeyEvent) { + // TODO: Implement profile input handling + // Временно делегируем обратно в main_input + let _ = (app, key); +} + +/// Возвращает количество доступных действий в профиле +pub fn get_available_actions_count(profile: &crate::tdlib::ProfileInfo) -> usize { + let mut count = 0; + + // Всегда есть: назад, посмотреть фото + count += 2; + + // Уведомления (только для групп) + if profile.is_group { + count += 1; + } + + // Выход из группы (только для групп) + if profile.is_group { + count += 1; + } + + count +} diff --git a/src/input/handlers/search.rs b/src/input/handlers/search.rs new file mode 100644 index 0000000..2e78f79 --- /dev/null +++ b/src/input/handlers/search.rs @@ -0,0 +1,16 @@ +//! Search mode input handling (chat search and message search) + +use crate::app::App; +use crossterm::event::KeyEvent; + +/// Обрабатывает ввод в режиме поиска чатов +pub async fn handle_chat_search_input(app: &mut App, key: KeyEvent) { + // TODO: Implement chat search input handling + let _ = (app, key); +} + +/// Обрабатывает ввод в режиме поиска сообщений +pub async fn handle_message_search_input(app: &mut App, key: KeyEvent) { + // TODO: Implement message search input handling + let _ = (app, key); +} diff --git a/src/input/main_input.rs.backup b/src/input/main_input.rs.backup new file mode 100644 index 0000000..bf4c4b7 --- /dev/null +++ b/src/input/main_input.rs.backup @@ -0,0 +1,1139 @@ +use crate::app::App; +use crate::tdlib::ChatAction; +use crate::types::{ChatId, MessageId}; +use crate::utils::{with_timeout, with_timeout_msg}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::{Duration, Instant}; + +pub async fn handle(app: &mut App, key: KeyEvent) { + let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + // Глобальные команды (работают всегда) + match key.code { + KeyCode::Char('r') if has_ctrl => { + 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 = 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; + } + + _ => {} + } + + // Режим профиля + if app.is_profile_mode() { + // Обработка подтверждения выхода из группы + let confirmation_step = app.get_leave_group_confirmation_step(); + if confirmation_step > 0 { + match key.code { + KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => { + 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(); + } + } + } + } + } + KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => { + // Отмена + app.cancel_leave_group(); + } + _ => {} + } + return; + } + + // Обычная навигация по профилю + match key.code { + KeyCode::Esc => { + app.exit_profile_mode(); + } + KeyCode::Up => { + app.select_previous_profile_action(); + } + KeyCode::Down => { + if let Some(profile) = app.get_profile_info() { + let max_actions = get_available_actions_count(profile); + app.select_next_profile_action(max_actions); + } + } + KeyCode::Enter => { + // Выполнить выбранное действие + if let Some(profile) = app.get_profile_info() { + let actions = get_available_actions_count(profile); + let action_index = app.get_selected_profile_action().unwrap_or(0); + + if action_index < actions { + // Определяем какое действие выбрано + let mut current_idx = 0; + + // Действие: Открыть в браузере + if profile.username.is_some() { + if action_index == current_idx { + if let Some(username) = &profile.username { + 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(); + } + } + } + } + _ => {} + } + return; + } + + // Режим поиска по сообщениям + if app.is_message_search_mode() { + match key.code { + KeyCode::Esc => { + app.exit_message_search_mode(); + } + KeyCode::Up | KeyCode::Char('N') => { + app.select_previous_search_result(); + } + KeyCode::Down | KeyCode::Char('n') => { + app.select_next_search_result(); + } + KeyCode::Enter => { + // Перейти к выбранному сообщению + 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(); + } + } + KeyCode::Backspace => { + // Удаляем символ из запроса + if let Some(mut query) = app.get_search_query().map(|s| s.to_string()) { + query.pop(); + app.update_search_query(query.clone()); + // Выполняем поиск при изменении запроса + if let Some(chat_id) = app.get_selected_chat_id() { + if !query.is_empty() { + 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); + } + } else { + app.set_search_results(Vec::new()); + } + } + } + } + KeyCode::Char(c) => { + // Добавляем символ к запросу + if let Some(mut query) = app.get_search_query().map(|s| s.to_string()) { + query.push(c); + app.update_search_query(query.clone()); + // Выполняем поиск при изменении запроса + if let Some(chat_id) = app.get_selected_chat_id() { + 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); + } + } + } + } + _ => {} + } + return; + } + + // Режим просмотра закреплённых сообщений + if app.is_pinned_mode() { + match key.code { + KeyCode::Esc => { + app.exit_pinned_mode(); + } + KeyCode::Up => { + app.select_previous_pinned(); + } + KeyCode::Down => { + app.select_next_pinned(); + } + KeyCode::Enter => { + // Перейти к сообщению в истории + 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 { + // Вычисляем scroll offset чтобы показать сообщение + let total = app.td_client.current_chat_messages().len(); + app.message_scroll_offset = total.saturating_sub(idx + 5); + } + app.exit_pinned_mode(); + } + } + _ => {} + } + return; + } + + // Обработка ввода в режиме выбора реакции + if app.is_reaction_picker_mode() { + match key.code { + KeyCode::Left => { + app.select_previous_reaction(); + app.needs_redraw = true; + } + KeyCode::Right => { + app.select_next_reaction(); + app.needs_redraw = true; + } + KeyCode::Up => { + // Переход на ряд выше (8 эмодзи в ряду) + 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; + } + } + } + KeyCode::Down => { + // Переход на ряд ниже (8 эмодзи в ряду) + 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; + } + } + } + KeyCode::Enter => { + // Добавить/убрать реакцию + if let Some(emoji) = app.get_selected_reaction().cloned() { + if let Some(message_id) = app.get_selected_message_for_reaction() { + if let Some(chat_id) = app.selected_chat_id { + let message_id = MessageId::new(message_id); + app.status_message = Some("Отправка реакции...".to_string()); + app.needs_redraw = true; + + match with_timeout_msg( + Duration::from_secs(5), + app.td_client + .toggle_reaction(chat_id, message_id, emoji.clone()), + "Таймаут отправки реакции", + ) + .await + { + 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; + } + } + } + } + } + } + KeyCode::Esc => { + app.exit_reaction_picker_mode(); + app.needs_redraw = true; + } + _ => {} + } + return; + } + + // Модалка подтверждения удаления + if app.is_confirm_delete_shown() { + match key.code { + KeyCode::Char('y') | KeyCode::Char('н') | KeyCode::Enter => { + // Подтверждение удаления + 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; + } + KeyCode::Char('n') | KeyCode::Char('т') | KeyCode::Esc => { + // Отмена удаления + app.chat_state = crate::app::ChatState::Normal; + } + _ => {} + } + return; + } + + // Режим выбора чата для пересылки + if app.is_forwarding() { + match key.code { + KeyCode::Esc => { + app.cancel_forward(); + } + KeyCode::Enter => { + // Выбираем чат и пересылаем сообщение + let filtered = app.get_filtered_chats(); + if let Some(i) = app.chat_list_state.selected() { + if let Some(chat) = filtered.get(i) { + let to_chat_id = chat.id; + if let Some(msg_id) = app.chat_state.selected_message_id() { + if let Some(from_chat_id) = app.get_selected_chat_id() { + match with_timeout_msg( + Duration::from_secs(5), + app.td_client.forward_messages( + to_chat_id, + ChatId::new(from_chat_id), + vec![msg_id], + ), + "Таймаут пересылки", + ) + .await + { + Ok(_) => { + app.status_message = + Some("Сообщение переслано".to_string()); + } + Err(e) => { + app.error_message = Some(e); + } + } + } + } + } + } + app.cancel_forward(); + } + KeyCode::Down => { + app.next_chat(); + } + KeyCode::Up => { + app.previous_chat(); + } + _ => {} + } + return; + } + + // Режим поиска + if app.is_searching { + match key.code { + KeyCode::Esc => { + app.cancel_search(); + } + KeyCode::Enter => { + // Выбрать чат из отфильтрованного списка + app.select_filtered_chat(); + if let Some(chat_id) = app.get_selected_chat_id() { + app.status_message = Some("Загрузка сообщений...".to_string()); + app.message_scroll_offset = 0; + match with_timeout_msg( + Duration::from_secs(10), + app.td_client.get_chat_history(ChatId::new(chat_id), 100), + "Таймаут загрузки сообщений", + ) + .await + { + Ok(messages) => { + // Сохраняем загруженные сообщения + *app.td_client.current_chat_messages_mut() = messages; + // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории + // Это предотвращает race condition с Update::NewMessage + app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); + // Загружаем недостающие reply info + let _ = tokio::time::timeout( + Duration::from_secs(5), + app.td_client.fetch_missing_reply_info(), + ) + .await; + // Загружаем последнее закреплённое сообщение + let _ = tokio::time::timeout( + 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; + } + } + } + } + KeyCode::Backspace => { + app.search_query.pop(); + // Сбрасываем выделение при изменении запроса + app.chat_list_state.select(Some(0)); + } + KeyCode::Down => { + app.next_filtered_chat(); + } + KeyCode::Up => { + app.previous_filtered_chat(); + } + KeyCode::Char(c) => { + app.search_query.push(c); + // Сбрасываем выделение при изменении запроса + app.chat_list_state.select(Some(0)); + } + _ => {} + } + return; + } + + // Enter - открыть чат, отправить сообщение или редактировать + if key.code == KeyCode::Enter { + if app.selected_chat_id.is_some() { + // Режим выбора сообщения + if app.is_selecting_message() { + // Начать редактирование выбранного сообщения + if app.start_editing_selected() { + // Редактирование начато + } else { + // Нельзя редактировать это сообщение + app.chat_state = crate::app::ChatState::Normal; + } + return; + } + + // Отправка или редактирование сообщения + if !app.message_input.is_empty() { + if let Some(chat_id) = app.get_selected_chat_id() { + let text = app.message_input.clone(); + + if app.is_editing() { + // Режим редактирования + if let Some(msg_id) = app.chat_state.selected_message_id() { + // Проверяем, что сообщение есть в локальном кэше + 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; // ВАЖНО: перерисовываем UI + } + Err(e) => { + app.error_message = Some(e); + } + } + } + } else { + // Обычная отправка (или reply) + 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); + } + } + } + } + } + } else { + // Открываем чат + 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() { + app.status_message = Some("Загрузка сообщений...".to_string()); + app.message_scroll_offset = 0; + match with_timeout_msg( + Duration::from_secs(10), + app.td_client.get_chat_history(ChatId::new(chat_id), 100), + "Таймаут загрузки сообщений", + ) + .await + { + Ok(messages) => { + // Сохраняем загруженные сообщения + *app.td_client.current_chat_messages_mut() = messages; + // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории + // Это предотвращает race condition с Update::NewMessage + app.td_client.set_current_chat_id(Some(ChatId::new(chat_id))); + // Загружаем недостающие reply info + let _ = tokio::time::timeout( + Duration::from_secs(5), + app.td_client.fetch_missing_reply_info(), + ) + .await; + // Загружаем последнее закреплённое сообщение + let _ = tokio::time::timeout( + 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; + } + } + } + } + } + return; + } + + // Esc - отменить выбор/редактирование/reply или закрыть чат + if key.code == KeyCode::Esc { + if app.is_selecting_message() { + // Отменить выбор сообщения + app.chat_state = crate::app::ChatState::Normal; + } else if app.is_editing() { + // Отменить редактирование + app.cancel_editing(); + } else if app.is_replying() { + // Отменить режим ответа + app.cancel_reply(); + } else if app.selected_chat_id.is_some() { + // Сохраняем черновик если есть текст в инпуте + if let Some(chat_id) = app.selected_chat_id { + if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() { + let draft_text = app.message_input.clone(); + let _ = app.td_client.set_draft_message(chat_id, draft_text).await; + } else if app.message_input.is_empty() { + // Очищаем черновик если инпут пустой + let _ = app + .td_client + .set_draft_message(chat_id, String::new()) + .await; + } + } + app.close_chat(); + } + return; + } + + // Режим открытого чата + if app.selected_chat_id.is_some() { + // Режим выбора сообщения для редактирования/удаления + if app.is_selecting_message() { + match key.code { + KeyCode::Up => { + app.select_previous_message(); + } + KeyCode::Down => { + app.select_next_message(); + // Если вышли из режима выбора (индекс стал None), ничего не делаем + } + KeyCode::Char('d') | KeyCode::Char('в') | KeyCode::Delete => { + // Показать модалку подтверждения удаления + if let Some(msg) = app.get_selected_message() { + 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(), + }; + } + } + } + KeyCode::Char('r') | KeyCode::Char('к') => { + // Начать режим ответа на выбранное сообщение + app.start_reply_to_selected(); + } + KeyCode::Char('f') | KeyCode::Char('а') => { + // Начать режим пересылки + app.start_forward_selected(); + } + KeyCode::Char('y') | KeyCode::Char('н') => { + // Копировать сообщение + if let Some(msg) = app.get_selected_message() { + 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)); + } + } + } + } + KeyCode::Char('e') | KeyCode::Char('у') => { + // Открыть emoji picker для добавления реакции + if let Some(msg) = app.get_selected_message() { + 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; + } + } + } + } + _ => {} + } + return; + } + + // Ctrl+U для профиля + if key.code == KeyCode::Char('u') && has_ctrl { + if let Some(chat_id) = app.selected_chat_id { + 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; + } + } + } + return; + } + + 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) => { + // Вставляем символ в позицию курсора + 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; + + // Проверяем, нужно ли подгрузить старые сообщения + if !app.td_client.current_chat_messages().is_empty() { + let oldest_msg_id = app + .td_client + .current_chat_messages() + .first() + .map(|m| m.id()) + .unwrap_or(MessageId::new(0)); + if let Some(chat_id) = app.get_selected_chat_id() { + // Подгружаем больше сообщений если скролл близко к верху + if app.message_scroll_offset + > app.td_client.current_chat_messages().len().saturating_sub(10) + { + if let Ok(older) = with_timeout( + Duration::from_secs(3), + app.td_client + .load_older_messages(ChatId::new(chat_id), oldest_msg_id), + ) + .await + { + let older: Vec = older; + if !older.is_empty() { + // Добавляем старые сообщения в начало + let msgs = app.td_client.current_chat_messages_mut(); + msgs.splice(0..0, older); + } + } + } + } + } + } + } + _ => {} + } + } else { + // В режиме списка чатов - навигация стрелками и переключение папок + match key.code { + KeyCode::Down => { + app.next_chat(); + } + KeyCode::Up => { + app.previous_chat(); + } + // Цифры 1-9 - переключение папок + KeyCode::Char(c) if c >= '1' && c <= '9' => { + let folder_num = (c as usize) - ('1' as usize); // 0-based + if folder_num == 0 { + // 1 = All + app.selected_folder_id = None; + } else { + // 2, 3, 4... = папки из TDLib + if let Some(folder) = app.td_client.folders().get(folder_num - 1) { + let folder_id = folder.id; + app.selected_folder_id = Some(folder_id); + // Загружаем чаты папки + app.status_message = Some("Загрузка чатов папки...".to_string()); + let _ = with_timeout( + Duration::from_secs(5), + app.td_client.load_folder_chats(folder_id, 50), + ) + .await; + app.status_message = None; + } + } + app.chat_list_state.select(Some(0)); + } + _ => {} + } + } +} + +/// Подсчёт количества доступных действий в профиле +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 = 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 +} diff --git a/src/input/mod.rs b/src/input/mod.rs index b7d31ea..297485f 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,4 +1,5 @@ mod auth; +pub mod handlers; mod main_input; pub use auth::handle as handle_auth_input;