refactor: split main_input.rs into modular handlers (1199→164 lines)
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 <noreply@anthropic.com>
This commit is contained in:
460
src/input/handlers/chat.rs
Normal file
460
src/input/handlers/chat.rs
Normal file
@@ -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<T: TdClientTrait>(app: &mut App<T>, _key: KeyEvent, command: Option<crate::config::Command>) {
|
||||
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<String> = 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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// Сценарий 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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// 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<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// 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<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Backspace => {
|
||||
// Удаляем символ слева от курсора
|
||||
if app.cursor_position > 0 {
|
||||
let chars: Vec<char> = app.message_input.chars().collect();
|
||||
let mut new_input = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i != app.cursor_position - 1 {
|
||||
new_input.push(*ch);
|
||||
}
|
||||
}
|
||||
app.message_input = new_input;
|
||||
app.cursor_position -= 1;
|
||||
}
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
// Удаляем символ справа от курсора
|
||||
let len = app.message_input.chars().count();
|
||||
if app.cursor_position < len {
|
||||
let chars: Vec<char> = app.message_input.chars().collect();
|
||||
let mut new_input = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i != app.cursor_position {
|
||||
new_input.push(*ch);
|
||||
}
|
||||
}
|
||||
app.message_input = new_input;
|
||||
}
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
|
||||
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
|
||||
if key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
|| key.modifiers.contains(KeyModifiers::ALT) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Вставляем символ в позицию курсора
|
||||
let chars: Vec<char> = app.message_input.chars().collect();
|
||||
let mut new_input = String::new();
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
if i == app.cursor_position {
|
||||
new_input.push(c);
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user