//! 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::app::methods::{ compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods, navigation::NavigationMethods, }; use crate::tdlib::{TdClientTrait, ChatAction}; 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::ViewImage) => { handle_view_or_play_media(app).await; } Some(crate::config::Command::TogglePlayback) => { handle_toggle_voice_playback(app).await; } Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => { handle_voice_seek(app, 5.0); } Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => { handle_voice_seek(app, -5.0); } 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; } } _ => {} } } /// Обработка команды ViewImage — только фото async fn handle_view_or_play_media(app: &mut App) { let Some(msg) = app.get_selected_message() else { return; }; if msg.has_photo() { #[cfg(feature = "images")] handle_view_image(app).await; #[cfg(not(feature = "images"))] { app.status_message = Some("Просмотр изображений отключён".to_string()); } } else { app.status_message = Some("Сообщение не содержит фото".to_string()); } } /// Space: play/pause toggle для голосовых сообщений async fn handle_toggle_voice_playback(app: &mut App) { use crate::tdlib::PlaybackStatus; // Если уже есть активное воспроизведение — toggle pause/resume if let Some(ref mut playback) = app.playback_state { if let Some(ref player) = app.audio_player { match playback.status { PlaybackStatus::Playing => { player.pause(); playback.status = PlaybackStatus::Paused; app.last_playback_tick = None; app.status_message = Some("⏸ Пауза".to_string()); } PlaybackStatus::Paused => { // Откатываем на 1 секунду для контекста let resume_pos = (playback.position - 1.0).max(0.0); // Перезапускаем ffplay с нужной позиции (-ss) if player.resume_from(resume_pos).is_ok() { playback.position = resume_pos; } else { // Fallback: простой SIGCONT без перемотки player.resume(); } playback.status = PlaybackStatus::Playing; app.last_playback_tick = Some(Instant::now()); app.status_message = Some("▶ Воспроизведение".to_string()); } _ => {} } app.needs_redraw = true; } return; } // Нет активного воспроизведения — пробуем запустить текущее голосовое let Some(msg) = app.get_selected_message() else { return; }; if msg.has_voice() { handle_play_voice(app).await; } } /// Seek голосового сообщения на delta секунд fn handle_voice_seek(app: &mut App, delta: f32) { use crate::tdlib::PlaybackStatus; let Some(ref mut playback) = app.playback_state else { return; }; let Some(ref player) = app.audio_player else { return; }; let was_playing = matches!(playback.status, PlaybackStatus::Playing); let was_paused = matches!(playback.status, PlaybackStatus::Paused); if was_playing || was_paused { let new_position = (playback.position + delta).clamp(0.0, playback.duration); if was_playing { // Перезапускаем ffplay с новой позиции if player.resume_from(new_position).is_ok() { playback.position = new_position; app.last_playback_tick = Some(std::time::Instant::now()); } } else { // На паузе — только двигаем позицию, воспроизведение начнётся при resume player.stop(); playback.position = new_position; } let arrow = if delta > 0.0 { "→" } else { "←" }; app.status_message = Some(format!("{} {:.0}s", arrow, new_position)); app.needs_redraw = true; } } /// Обработка команды ViewImage — открыть модальное окно с фото #[cfg(feature = "images")] async fn handle_view_image(app: &mut App) { use crate::tdlib::{ImageModalState, PhotoDownloadState}; if !app.config().images.show_images { return; } let Some(msg) = app.get_selected_message() else { return; }; if !msg.has_photo() { app.status_message = Some("Сообщение не содержит фото".to_string()); return; } let photo = msg.photo_info().unwrap(); match &photo.download_state { PhotoDownloadState::Downloaded(path) => { // Открываем модальное окно app.image_modal = Some(ImageModalState { message_id: msg.id(), photo_path: path.clone(), photo_width: photo.width, photo_height: photo.height, }); app.needs_redraw = true; } PhotoDownloadState::Downloading => { app.status_message = Some("Загрузка фото...".to_string()); } PhotoDownloadState::NotDownloaded => { app.status_message = Some("Фото не загружено".to_string()); } PhotoDownloadState::Error(e) => { app.error_message = Some(format!("Ошибка загрузки: {}", e)); } } } /// Вспомогательная функция для воспроизведения из конкретного пути async fn handle_play_voice_from_path( app: &mut App, path: &str, voice: &crate::tdlib::VoiceInfo, msg: &crate::tdlib::MessageInfo, ) { use crate::tdlib::{PlaybackState, PlaybackStatus}; if let Some(ref player) = app.audio_player { match player.play(path) { Ok(_) => { app.playback_state = Some(PlaybackState { message_id: msg.id(), status: PlaybackStatus::Playing, position: 0.0, duration: voice.duration as f32, volume: player.volume(), }); app.last_playback_tick = Some(Instant::now()); app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration)); app.needs_redraw = true; } Err(e) => { app.error_message = Some(format!("Ошибка воспроизведения: {}", e)); } } } else { app.error_message = Some("Аудиоплеер не инициализирован".to_string()); } } /// Воспроизведение голосового сообщения async fn handle_play_voice(app: &mut App) { use crate::tdlib::VoiceDownloadState; let Some(msg) = app.get_selected_message() else { return; }; if !msg.has_voice() { return; } let voice = msg.voice_info().unwrap(); let file_id = voice.file_id; match &voice.download_state { VoiceDownloadState::Downloaded(path) => { // TDLib может вернуть путь без расширения — ищем файл с .oga use std::path::Path; let audio_path = if Path::new(path).exists() { path.clone() } else { // Пробуем добавить .oga let with_oga = format!("{}.oga", path); if Path::new(&with_oga).exists() { with_oga } else { // Пробуем найти файл с похожим именем в той же папке if let Some(parent) = Path::new(path).parent() { if let Some(stem) = Path::new(path).file_name() { if let Ok(entries) = std::fs::read_dir(parent) { for entry in entries.flatten() { let entry_name = entry.file_name(); if entry_name.to_string_lossy().starts_with(&stem.to_string_lossy().to_string()) { let found_path = entry.path().to_string_lossy().to_string(); // Кэшируем найденный файл if let Some(ref mut cache) = app.voice_cache { let _ = cache.store(&file_id.to_string(), Path::new(&found_path)); } return handle_play_voice_from_path(app, &found_path, &voice, &msg).await; } } } } } app.error_message = Some(format!("Файл не найден: {}", path)); return; } }; // Кэшируем файл если ещё не в кэше if let Some(ref mut cache) = app.voice_cache { let _ = cache.store(&file_id.to_string(), Path::new(&audio_path)); } handle_play_voice_from_path(app, &audio_path, &voice, &msg).await; } VoiceDownloadState::Downloading => { app.status_message = Some("Загрузка голосового...".to_string()); } VoiceDownloadState::NotDownloaded => { // Проверяем кэш перед загрузкой let cache_key = file_id.to_string(); if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) { let path_str = cached_path.to_string_lossy().to_string(); handle_play_voice_from_path(app, &path_str, &voice, &msg).await; return; } // Начинаем загрузку app.status_message = Some("Загрузка голосового...".to_string()); match app.td_client.download_voice_note(file_id).await { Ok(path) => { // Кэшируем загруженный файл if let Some(ref mut cache) = app.voice_cache { let _ = cache.store(&cache_key, std::path::Path::new(&path)); } handle_play_voice_from_path(app, &path, &voice, &msg).await; } Err(e) => { app.error_message = Some(format!("Ошибка загрузки: {}", e)); } } } VoiceDownloadState::Error(e) => { app.error_message = Some(format!("Ошибка загрузки: {}", e)); } } } // TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика /* #[cfg(feature = "images")] fn collapse_photo(app: &mut App, msg_id: crate::types::MessageId) { // Закомментировано - будет реализовано в Этапе 4 } #[cfg(feature = "images")] fn expand_photo(app: &mut App, msg_id: crate::types::MessageId, path: &str) { // Закомментировано - будет реализовано в Этапе 4 } */ // TODO (Этап 4): Функция _download_and_expand будет переписана /* #[cfg(feature = "images")] async fn _download_and_expand(app: &mut App, msg_id: crate::types::MessageId, file_id: i32) { // Закомментировано - будет реализовано в Этапе 4 } */