From fa749d24c516725e80bb760129411404d3a6e0e1 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Sat, 24 Jan 2026 18:53:35 +0300 Subject: [PATCH 1/2] fixes --- CONTEXT.md | 11 +- ROADMAP.md | 12 +- src/app/mod.rs | 70 +++++++++ src/input/main_input.rs | 78 +++++++++- src/tdlib/client.rs | 324 ++++++++++++++++++++++++++++++++++++++-- src/ui/chat_list.rs | 12 +- src/ui/messages.rs | 98 +++++++++++- 7 files changed, 576 insertions(+), 29 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 56e1423..f64a990 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -32,6 +32,9 @@ - **Отправка текстовых сообщений** - **Редактирование сообщений**: ↑ при пустом инпуте → выбор → Enter → редактирование - **Удаление сообщений**: в режиме выбора нажать `d` / `в` / `Delete` → модалка подтверждения +- **Reply на сообщения**: в режиме выбора нажать `r` / `к` → режим ответа с превью +- **Forward сообщений**: в режиме выбора нажать `f` / `а` → выбор чата для пересылки +- **Отображение пересланных сообщений**: индикатор "↪ Переслано от" с именем отправителя - **Индикатор редактирования**: ✎ рядом с временем для отредактированных сообщений - **Новые сообщения в реальном времени** при открытом чате - **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username @@ -78,10 +81,12 @@ - `↑/↓` в открытом чате — скролл сообщений (с подгрузкой старых) - `↑` при пустом инпуте — выбор сообщения для редактирования - `Enter` в режиме выбора — начать редактирование +- `r` / `к` в режиме выбора — ответить на сообщение (reply) +- `f` / `а` в режиме выбора — переслать сообщение (forward) - `d` / `в` / `Delete` в режиме выбора — удалить сообщение (с подтверждением) - `y` / `н` / `Enter` — подтвердить удаление в модалке - `n` / `т` / `Esc` — отменить удаление в модалке -- `Esc` — отменить выбор/редактирование +- `Esc` — отменить выбор/редактирование/reply - `1-9` — переключение папок (в списке чатов) - **Редактирование текста в инпуте:** - `←` / `→` — перемещение курсора @@ -166,8 +171,8 @@ API_HASH=your_api_hash - [x] Markdown форматирование в сообщениях - [x] Редактирование сообщений - [x] Удаление сообщений -- [ ] Reply на сообщения -- [ ] Forward сообщений +- [x] Reply на сообщения +- [x] Forward сообщений ## Известные проблемы diff --git a/ROADMAP.md b/ROADMAP.md index 64b4957..fec4296 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -101,5 +101,13 @@ - Vim-style курсор █ - Перемещение ←/→, Home/End - Редактирование в любой позиции -- [ ] Reply на сообщения -- [ ] Forward сообщений +- [x] Reply на сообщения + - `r` / `к` в режиме выбора → режим ответа + - Превью сообщения в поле ввода + - Esc для отмены +- [x] Forward сообщений + - `f` / `а` в режиме выбора → режим пересылки + - Превью сообщения в поле ввода + - Выбор чата стрелками, Enter для пересылки + - Esc для отмены + - Отображение "↪ Переслано от" для пересланных сообщений diff --git a/src/app/mod.rs b/src/app/mod.rs index f1a4c52..4ac612c 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -39,6 +39,14 @@ pub struct App { // Delete confirmation /// ID сообщения для подтверждения удаления (показывает модалку) pub confirm_delete_message_id: Option, + // Reply state + /// ID сообщения, на которое отвечаем (None = обычная отправка) + pub replying_to_message_id: Option, + // Forward state + /// ID сообщения для пересылки + pub forwarding_message_id: Option, + /// Режим выбора чата для пересылки + pub is_selecting_forward_chat: bool, } impl App { @@ -68,6 +76,9 @@ impl App { editing_message_id: None, selected_message_index: None, confirm_delete_message_id: None, + replying_to_message_id: None, + forwarding_message_id: None, + is_selecting_forward_chat: false, } } @@ -123,6 +134,7 @@ impl App { self.message_scroll_offset = 0; self.editing_message_id = None; self.selected_message_index = None; + self.replying_to_message_id = None; // Очищаем данные в TdClient self.td_client.current_chat_id = None; self.td_client.current_chat_messages.clear(); @@ -306,4 +318,62 @@ impl App { pub fn is_confirm_delete_shown(&self) -> bool { self.confirm_delete_message_id.is_some() } + + /// Начать режим ответа на выбранное сообщение + pub fn start_reply_to_selected(&mut self) -> bool { + if let Some(msg) = self.get_selected_message() { + self.replying_to_message_id = Some(msg.id); + self.selected_message_index = None; + return true; + } + false + } + + /// Отменить режим ответа + pub fn cancel_reply(&mut self) { + self.replying_to_message_id = None; + } + + /// Проверить, находимся ли в режиме ответа + pub fn is_replying(&self) -> bool { + self.replying_to_message_id.is_some() + } + + /// Получить сообщение, на которое отвечаем + pub fn get_replying_to_message(&self) -> Option<&crate::tdlib::client::MessageInfo> { + self.replying_to_message_id.and_then(|id| { + self.td_client.current_chat_messages.iter().find(|m| m.id == id) + }) + } + + /// Начать режим пересылки выбранного сообщения + pub fn start_forward_selected(&mut self) -> bool { + if let Some(msg) = self.get_selected_message() { + self.forwarding_message_id = Some(msg.id); + self.selected_message_index = None; + self.is_selecting_forward_chat = true; + // Сбрасываем выбор чата на первый + self.chat_list_state.select(Some(0)); + return true; + } + false + } + + /// Отменить режим пересылки + pub fn cancel_forward(&mut self) { + self.forwarding_message_id = None; + self.is_selecting_forward_chat = false; + } + + /// Проверить, находимся ли в режиме выбора чата для пересылки + pub fn is_forwarding(&self) -> bool { + self.is_selecting_forward_chat && self.forwarding_message_id.is_some() + } + + /// Получить сообщение для пересылки + pub fn get_forwarding_message(&self) -> Option<&crate::tdlib::client::MessageInfo> { + self.forwarding_message_id.and_then(|id| { + self.td_client.current_chat_messages.iter().find(|m| m.id == id) + }) + } } diff --git a/src/input/main_input.rs b/src/input/main_input.rs index dd5801a..a23809c 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -67,6 +67,51 @@ pub async fn handle(app: &mut App, key: KeyEvent) { 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.forwarding_message_id { + if let Some(from_chat_id) = app.get_selected_chat_id() { + match timeout( + Duration::from_secs(5), + app.td_client.forward_messages(to_chat_id, from_chat_id, vec![msg_id]) + ).await { + Ok(Ok(_)) => { + app.status_message = Some("Сообщение переслано".to_string()); + } + Ok(Err(e)) => { + app.error_message = Some(e); + } + Err(_) => { + app.error_message = Some("Таймаут пересылки".to_string()); + } + } + } + } + } + } + app.cancel_forward(); + } + KeyCode::Down => { + app.next_chat(); + } + KeyCode::Up => { + app.previous_chat(); + } + _ => {} + } + return; + } + // Режим поиска if app.is_searching { match key.code { @@ -81,7 +126,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.message_scroll_offset = 0; match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await { Ok(Ok(_)) => { - // Сообщения уже сохранены в td_client.current_chat_messages + // Загружаем недостающие reply info + let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await; app.status_message = None; } Ok(Err(e)) => { @@ -161,11 +207,21 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } } } else { - // Обычная отправка + // Обычная отправка (или reply) + let reply_to_id = app.replying_to_message_id; + // Создаём ReplyInfo ДО отправки, пока сообщение точно доступно + let reply_info = app.get_replying_to_message().map(|m| { + crate::tdlib::client::ReplyInfo { + message_id: m.id, + sender_name: m.sender_name.clone(), + text: m.content.clone(), + } + }); app.message_input.clear(); app.cursor_position = 0; + app.replying_to_message_id = None; - match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text)).await { + match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text, reply_to_id, reply_info)).await { Ok(Ok(sent_msg)) => { // Добавляем отправленное сообщение в список (с лимитом) app.td_client.push_message(sent_msg); @@ -193,7 +249,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.message_scroll_offset = 0; match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await { Ok(Ok(_)) => { - // Сообщения уже сохранены в td_client.current_chat_messages + // Загружаем недостающие reply info + let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await; app.status_message = None; } Ok(Err(e)) => { @@ -211,7 +268,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) { return; } - // Esc - отменить выбор/редактирование или закрыть чат + // Esc - отменить выбор/редактирование/reply или закрыть чат if key.code == KeyCode::Esc { if app.is_selecting_message() { // Отменить выбор сообщения @@ -219,6 +276,9 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } else if app.is_editing() { // Отменить редактирование app.cancel_editing(); + } else if app.is_replying() { + // Отменить режим ответа + app.cancel_reply(); } else if app.selected_chat_id.is_some() { app.close_chat(); } @@ -246,6 +306,14 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } } } + KeyCode::Char('r') | KeyCode::Char('к') => { + // Начать режим ответа на выбранное сообщение + app.start_reply_to_selected(); + } + KeyCode::Char('f') | KeyCode::Char('а') => { + // Начать режим пересылки + app.start_forward_selected(); + } _ => {} } return; diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 025a261..37bb618 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -113,6 +113,26 @@ pub struct ChatInfo { pub is_muted: bool, } +/// Информация о сообщении, на которое отвечают +#[derive(Debug, Clone)] +pub struct ReplyInfo { + /// ID сообщения, на которое отвечают + pub message_id: i64, + /// Имя отправителя оригинального сообщения + pub sender_name: String, + /// Текст оригинального сообщения (превью) + pub text: String, +} + +/// Информация о пересланном сообщении +#[derive(Debug, Clone)] +pub struct ForwardInfo { + /// Имя оригинального отправителя + pub sender_name: String, + /// Дата оригинального сообщения + pub date: i32, +} + #[derive(Debug, Clone)] pub struct MessageInfo { pub id: i64, @@ -131,6 +151,10 @@ pub struct MessageInfo { pub can_be_deleted_only_for_self: bool, /// Можно ли удалить для всех pub can_be_deleted_for_all_users: bool, + /// Информация о reply (если это ответ на сообщение) + pub reply_to: Option, + /// Информация о forward (если сообщение переслано) + pub forward_from: Option, } #[derive(Debug, Clone)] @@ -240,7 +264,17 @@ impl TdClient { } /// Добавляет сообщение в текущий чат с соблюдением лимита + /// Если сообщение с таким id уже есть — заменяет его (сохраняя reply_to) pub fn push_message(&mut self, msg: MessageInfo) { + // Проверяем, есть ли уже сообщение с таким id + if let Some(idx) = self.current_chat_messages.iter().position(|m| m.id == msg.id) { + // Если новое сообщение имеет reply_to, или старое не имеет — заменяем + if msg.reply_to.is_some() || self.current_chat_messages[idx].reply_to.is_none() { + self.current_chat_messages[idx] = msg; + } + return; + } + self.current_chat_messages.push(msg); // Ограничиваем количество сообщений (удаляем старые) if self.current_chat_messages.len() > MAX_MESSAGES_IN_CHAT { @@ -389,12 +423,25 @@ impl TdClient { let msg_info = self.convert_message(&new_msg.message, chat_id); let msg_id = msg_info.id; let is_incoming = !msg_info.is_outgoing; - // Проверяем, что сообщение ещё не добавлено (по id) - if !self.current_chat_messages.iter().any(|m| m.id == msg_info.id) { - self.push_message(msg_info); - // Если это входящее сообщение — добавляем в очередь для отметки как прочитанное - if is_incoming { - self.pending_view_messages.push((chat_id, vec![msg_id])); + + // Проверяем, есть ли уже сообщение с таким id + let existing_idx = self.current_chat_messages.iter().position(|m| m.id == msg_info.id); + + match existing_idx { + Some(idx) => { + // Сообщение уже есть - обновляем только если входящее + // (исходящие уже добавлены через send_message с правильным reply_to) + if is_incoming { + self.current_chat_messages[idx] = msg_info; + } + } + None => { + // Нового сообщения нет - добавляем + self.push_message(msg_info); + // Если это входящее сообщение — добавляем в очередь для отметки как прочитанное + if is_incoming { + self.pending_view_messages.push((chat_id, vec![msg_id])); + } } } } @@ -630,6 +677,12 @@ impl TdClient { let (content, entities) = extract_message_text_static(message); + // Извлекаем информацию о reply + let reply_to = self.extract_reply_info(message); + + // Извлекаем информацию о forward + let forward_from = self.extract_forward_info(message); + MessageInfo { id: message.id, sender_name, @@ -642,6 +695,187 @@ impl TdClient { can_be_edited: message.can_be_edited, can_be_deleted_only_for_self: message.can_be_deleted_only_for_self, can_be_deleted_for_all_users: message.can_be_deleted_for_all_users, + reply_to, + forward_from, + } + } + + /// Извлекает информацию о reply из сообщения + fn extract_reply_info(&self, message: &TdMessage) -> Option { + use tdlib_rs::enums::MessageReplyTo; + + match &message.reply_to { + Some(MessageReplyTo::Message(reply)) => { + // Получаем имя отправителя из origin или ищем сообщение в текущем списке + let sender_name = if let Some(origin) = &reply.origin { + self.get_origin_sender_name(origin) + } else { + // Пробуем найти оригинальное сообщение в текущем списке + self.current_chat_messages + .iter() + .find(|m| m.id == reply.message_id) + .map(|m| m.sender_name.clone()) + .unwrap_or_else(|| "...".to_string()) + }; + + // Получаем текст из content или quote + let text = if let Some(quote) = &reply.quote { + quote.text.text.clone() + } else if let Some(content) = &reply.content { + extract_content_text(content) + } else { + // Пробуем найти в текущих сообщениях + self.current_chat_messages + .iter() + .find(|m| m.id == reply.message_id) + .map(|m| m.content.clone()) + .unwrap_or_default() + }; + + Some(ReplyInfo { + message_id: reply.message_id, + sender_name, + text, + }) + } + _ => None, + } + } + + /// Извлекает информацию о forward из сообщения + fn extract_forward_info(&self, message: &TdMessage) -> Option { + message.forward_info.as_ref().map(|info| { + let sender_name = self.get_origin_sender_name(&info.origin); + ForwardInfo { + sender_name, + date: info.date, + } + }) + } + + /// Получает имя отправителя из MessageOrigin + fn get_origin_sender_name(&self, origin: &tdlib_rs::enums::MessageOrigin) -> String { + use tdlib_rs::enums::MessageOrigin; + match origin { + MessageOrigin::User(u) => { + self.user_names.peek(&u.sender_user_id) + .cloned() + .unwrap_or_else(|| format!("User_{}", u.sender_user_id)) + } + MessageOrigin::Chat(c) => { + self.chats.iter() + .find(|chat| chat.id == c.sender_chat_id) + .map(|chat| chat.title.clone()) + .unwrap_or_else(|| "Чат".to_string()) + } + MessageOrigin::HiddenUser(h) => h.sender_name.clone(), + MessageOrigin::Channel(c) => { + self.chats.iter() + .find(|chat| chat.id == c.chat_id) + .map(|chat| chat.title.clone()) + .unwrap_or_else(|| "Канал".to_string()) + } + } + } + + /// Обновляет reply info для сообщений, где данные не были загружены + /// Вызывается после загрузки истории, когда все сообщения уже в списке + fn update_reply_info_from_loaded_messages(&mut self) { + // Собираем данные для обновления (id -> (sender_name, content)) + let msg_data: std::collections::HashMap = self + .current_chat_messages + .iter() + .map(|m| (m.id, (m.sender_name.clone(), m.content.clone()))) + .collect(); + + // Обновляем reply_to для сообщений с неполными данными + for msg in &mut self.current_chat_messages { + if let Some(ref mut reply) = msg.reply_to { + // Если sender_name = "..." или text пустой — пробуем заполнить + if reply.sender_name == "..." || reply.text.is_empty() { + if let Some((sender, content)) = msg_data.get(&reply.message_id) { + if reply.sender_name == "..." { + reply.sender_name = sender.clone(); + } + if reply.text.is_empty() { + reply.text = content.clone(); + } + } + } + } + } + } + + /// Асинхронно обновляет reply info, загружая недостающие сообщения + pub async fn fetch_missing_reply_info(&mut self) { + let chat_id = match self.current_chat_id { + Some(id) => id, + None => return, + }; + + // Собираем message_id для которых нужно загрузить данные + let missing_ids: Vec = self + .current_chat_messages + .iter() + .filter_map(|msg| { + msg.reply_to.as_ref().and_then(|reply| { + if reply.sender_name == "..." || reply.text.is_empty() { + Some(reply.message_id) + } else { + None + } + }) + }) + .collect(); + + if missing_ids.is_empty() { + return; + } + + // Загружаем каждое сообщение и кэшируем данные + let mut reply_cache: std::collections::HashMap = + std::collections::HashMap::new(); + + for msg_id in missing_ids { + if reply_cache.contains_key(&msg_id) { + continue; + } + + if let Ok(tdlib_rs::enums::Message::Message(msg)) = + functions::get_message(chat_id, msg_id, self.client_id).await + { + let sender_name = match &msg.sender_id { + tdlib_rs::enums::MessageSender::User(user) => { + self.user_names + .get(&user.user_id) + .cloned() + .unwrap_or_else(|| format!("User_{}", user.user_id)) + } + tdlib_rs::enums::MessageSender::Chat(chat) => { + self.chats + .iter() + .find(|c| c.id == chat.chat_id) + .map(|c| c.title.clone()) + .unwrap_or_else(|| "Чат".to_string()) + } + }; + let (content, _) = extract_message_text_static(&msg); + reply_cache.insert(msg_id, (sender_name, content)); + } + } + + // Применяем загруженные данные + for msg in &mut self.current_chat_messages { + if let Some(ref mut reply) = msg.reply_to { + if let Some((sender, content)) = reply_cache.get(&reply.message_id) { + if reply.sender_name == "..." { + reply.sender_name = sender.clone(); + } + if reply.text.is_empty() { + reply.text = content.clone(); + } + } + } } } @@ -779,6 +1013,9 @@ impl TdClient { all_messages.reverse(); self.current_chat_messages = all_messages.clone(); + // Обновляем reply info для сообщений где данные не были загружены + self.update_reply_info_from_loaded_messages(); + // Отмечаем сообщения как прочитанные if !all_messages.is_empty() { let message_ids: Vec = all_messages.iter().map(|m| m.id).collect(); @@ -860,10 +1097,10 @@ impl TdClient { } } - /// Отправка текстового сообщения с поддержкой Markdown - pub async fn send_message(&self, chat_id: i64, text: String) -> Result { - use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown}; - use tdlib_rs::enums::{InputMessageContent, TextParseMode}; + /// Отправка текстового сообщения с поддержкой Markdown и reply + pub async fn send_message(&self, chat_id: i64, text: String, reply_to_message_id: Option, reply_info: Option) -> Result { + use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown, InputMessageReplyToMessage}; + use tdlib_rs::enums::{InputMessageContent, TextParseMode, InputMessageReplyTo}; // Парсим markdown в тексте let formatted_text = match functions::parse_text_entities( @@ -890,10 +1127,20 @@ impl TdClient { clear_draft: true, }); + // Создаём reply_to если есть message_id для ответа + // chat_id: 0 означает ответ в том же чате + let reply_to = reply_to_message_id.map(|msg_id| { + InputMessageReplyTo::Message(InputMessageReplyToMessage { + chat_id: 0, + message_id: msg_id, + quote: None, + }) + }); + let result = functions::send_message( chat_id, 0, // message_thread_id - None, // reply_to + reply_to, None, // options content, self.client_id, @@ -904,6 +1151,7 @@ impl TdClient { Ok(tdlib_rs::enums::Message::Message(msg)) => { // Извлекаем текст и entities из отправленного сообщения let (content, entities) = extract_message_text_static(&msg); + Ok(MessageInfo { id: msg.id, sender_name: "Вы".to_string(), @@ -916,6 +1164,8 @@ impl TdClient { can_be_edited: msg.can_be_edited, can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self, can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, + reply_to: reply_info, + forward_from: None, }) } Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)), @@ -975,6 +1225,8 @@ impl TdClient { can_be_edited: msg.can_be_edited, can_be_deleted_only_for_self: msg.can_be_deleted_only_for_self, can_be_deleted_for_all_users: msg.can_be_deleted_for_all_users, + reply_to: None, // При редактировании reply сохраняется из оригинала + forward_from: None, // При редактировании forward сохраняется из оригинала }) } Err(e) => Err(format!("Ошибка редактирования сообщения: {:?}", e)), @@ -998,6 +1250,26 @@ impl TdClient { } } + /// Пересылка сообщений + pub async fn forward_messages(&self, to_chat_id: i64, from_chat_id: i64, message_ids: Vec) -> Result<(), String> { + let result = functions::forward_messages( + to_chat_id, + 0, // message_thread_id + from_chat_id, + message_ids, + None, // options + false, // send_copy + false, // remove_caption + self.client_id, + ) + .await; + + match result { + Ok(_) => Ok(()), + Err(e) => Err(format!("Ошибка пересылки сообщения: {:?}", e)), + } + } + /// Обработка очереди сообщений для отметки как прочитанных pub async fn process_pending_view_messages(&mut self) { let pending = std::mem::take(&mut self.pending_view_messages); @@ -1125,3 +1397,33 @@ fn extract_message_text_static(message: &TdMessage) -> (String, Vec) _ => ("[Сообщение]".to_string(), vec![]), } } + +/// Извлекает текст из MessageContent (для reply preview) +fn extract_content_text(content: &MessageContent) -> String { + match content { + MessageContent::MessageText(text) => text.text.text.clone(), + MessageContent::MessagePhoto(photo) => { + if photo.caption.text.is_empty() { + "[Фото]".to_string() + } else { + format!("[Фото] {}", photo.caption.text) + } + } + MessageContent::MessageVideo(video) => { + if video.caption.text.is_empty() { + "[Видео]".to_string() + } else { + format!("[Видео] {}", video.caption.text) + } + } + MessageContent::MessageDocument(doc) => format!("[Файл: {}]", doc.document.file_name), + MessageContent::MessageVoiceNote(_) => "[Голосовое]".to_string(), + MessageContent::MessageVideoNote(_) => "[Видеосообщение]".to_string(), + MessageContent::MessageSticker(sticker) => format!("[Стикер: {}]", sticker.sticker.emoji), + MessageContent::MessageAnimation(_) => "[GIF]".to_string(), + MessageContent::MessageAudio(audio) => format!("[Аудио: {}]", audio.audio.title), + MessageContent::MessageCall(_) => "[Звонок]".to_string(), + MessageContent::MessagePoll(poll) => format!("[Опрос: {}]", poll.poll.question.text), + _ => "[Сообщение]".to_string(), + } +} diff --git a/src/ui/chat_list.rs b/src/ui/chat_list.rs index 955bc1a..e060774 100644 --- a/src/ui/chat_list.rs +++ b/src/ui/chat_list.rs @@ -83,8 +83,18 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { }) .collect(); + // Заголовок блока: обычный или режим пересылки + let block = if app.is_forwarding() { + Block::default() + .title(" ↪ Выберите чат ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)) + } else { + Block::default().borders(Borders::ALL) + }; + let chats_list = List::new(items) - .block(Block::default().borders(Borders::ALL)) + .block(block) .highlight_style( Style::default() .add_modifier(Modifier::ITALIC) diff --git a/src/ui/messages.rs b/src/ui/messages.rs index f611b73..0827899 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -144,7 +144,7 @@ fn styles_equal(a: &CharStyle, b: &CharStyle) -> bool { } /// Рендерит текст инпута с блочным курсором -fn render_input_with_cursor<'a>(prefix: &'a str, text: &str, cursor_pos: usize, color: Color) -> Line<'a> { +fn render_input_with_cursor(prefix: &str, text: &str, cursor_pos: usize, color: Color) -> Line<'static> { let chars: Vec = text.chars().collect(); let mut spans: Vec = vec![Span::raw(prefix.to_string())]; @@ -435,6 +435,48 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let selection_marker = if is_selected { "▶ " } else { "" }; let marker_len = selection_marker.chars().count(); + // Отображаем forward если есть + if let Some(forward) = &msg.forward_from { + let forward_line = format!("↪ Переслано от {}", forward.sender_name); + let forward_len = forward_line.chars().count(); + + if msg.is_outgoing { + // Forward справа для исходящих + let padding = content_width.saturating_sub(forward_len + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(forward_line, Style::default().fg(Color::Magenta)), + ])); + } else { + // Forward слева для входящих + lines.push(Line::from(vec![ + Span::styled(forward_line, Style::default().fg(Color::Magenta)), + ])); + } + } + + // Отображаем reply если есть + if let Some(reply) = &msg.reply_to { + let reply_text: String = reply.text.chars().take(40).collect(); + let ellipsis = if reply.text.chars().count() > 40 { "..." } else { "" }; + let reply_line = format!("┌ {}: {}{}", reply.sender_name, reply_text, ellipsis); + let reply_len = reply_line.chars().count(); + + if msg.is_outgoing { + // Reply справа для исходящих + let padding = content_width.saturating_sub(reply_len + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(reply_line, Style::default().fg(Color::Cyan)), + ])); + } else { + // Reply слева для входящих + lines.push(Line::from(vec![ + Span::styled(reply_line, Style::default().fg(Color::Cyan)), + ])); + } + } + if msg.is_outgoing { // Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)" let read_mark = if msg.is_read { "✓✓" } else { "✓" }; @@ -562,17 +604,29 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { f.render_widget(messages_widget, message_chunks[1]); // Input box с wrap для длинного текста и блочным курсором - let (input_line, input_title) = if app.is_selecting_message() { + let (input_line, input_title) = if app.is_forwarding() { + // Режим пересылки - показываем превью сообщения + let forward_preview = app.get_forwarding_message() + .map(|m| { + let text_preview: String = m.content.chars().take(40).collect(); + let ellipsis = if m.content.chars().count() > 40 { "..." } else { "" }; + format!("↪ {}{}", text_preview, ellipsis) + }) + .unwrap_or_else(|| "↪ ...".to_string()); + + let line = Line::from(Span::styled(forward_preview, Style::default().fg(Color::Cyan))); + (line, " Выберите чат ← ") + } else if app.is_selecting_message() { // Режим выбора сообщения - подсказка зависит от возможностей let selected_msg = app.get_selected_message(); let can_edit = selected_msg.map(|m| m.can_be_edited && m.is_outgoing).unwrap_or(false); let can_delete = selected_msg.map(|m| m.can_be_deleted_only_for_self || m.can_be_deleted_for_all_users).unwrap_or(false); let hint = match (can_edit, can_delete) { - (true, true) => "↑↓ выбрать · Enter редакт. · d удалить · Esc отмена", - (true, false) => "↑↓ выбрать · Enter редакт. · Esc отмена", - (false, true) => "↑↓ выбрать · d удалить · Esc отмена", - (false, false) => "↑↓ выбрать · Esc отмена", + (true, true) => "↑↓ · Enter ред. · r ответ · f перслть · d удал. · Esc", + (true, false) => "↑↓ · Enter ред. · r ответ · f переслть · Esc", + (false, true) => "↑↓ · r ответ · f переслать · d удалить · Esc", + (false, false) => "↑↓ · r ответить · f переслать · Esc", }; (Line::from(Span::styled(hint, Style::default().fg(Color::Cyan))), " Выбор сообщения ") } else if app.is_editing() { @@ -590,6 +644,31 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let line = render_input_with_cursor("✏ ", &app.message_input, app.cursor_position, Color::Magenta); (line, " Редактирование (Esc отмена) ") } + } else if app.is_replying() { + // Режим ответа на сообщение + let reply_preview = app.get_replying_to_message() + .map(|m| { + let sender = if m.is_outgoing { "Вы" } else { &m.sender_name }; + let text_preview: String = m.content.chars().take(30).collect(); + let ellipsis = if m.content.chars().count() > 30 { "..." } else { "" }; + format!("{}: {}{}", sender, text_preview, ellipsis) + }) + .unwrap_or_else(|| "...".to_string()); + + if app.message_input.is_empty() { + let line = Line::from(vec![ + Span::styled("↪ ", Style::default().fg(Color::Cyan)), + Span::styled(reply_preview, Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::styled("█", Style::default().fg(Color::Yellow)), + ]); + (line, " Ответ (Esc отмена) ") + } else { + let short_preview: String = reply_preview.chars().take(15).collect(); + let prefix = format!("↪ {} > ", short_preview); + let line = render_input_with_cursor(&prefix, &app.message_input, app.cursor_position, Color::Yellow); + (line, " Ответ (Esc отмена) ") + } } else { // Обычный режим if app.message_input.is_empty() { @@ -610,10 +689,15 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let input_block = if input_title.is_empty() { Block::default().borders(Borders::ALL) } else { + let title_color = if app.is_replying() || app.is_forwarding() { + Color::Cyan + } else { + Color::Magenta + }; Block::default() .borders(Borders::ALL) .title(input_title) - .title_style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)) + .title_style(Style::default().fg(title_color).add_modifier(Modifier::BOLD)) }; let input = Paragraph::new(input_line) From e4dabbe3ac1f6e50d68aef9f85461a5a92aaa81e Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Sun, 25 Jan 2026 01:47:38 +0300 Subject: [PATCH 2/2] fixes --- src/tdlib/client.rs | 11 +++++++++-- src/ui/messages.rs | 29 ++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 37bb618..a7f376c 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -429,10 +429,17 @@ impl TdClient { match existing_idx { Some(idx) => { - // Сообщение уже есть - обновляем только если входящее - // (исходящие уже добавлены через send_message с правильным reply_to) + // Сообщение уже есть - обновляем if is_incoming { self.current_chat_messages[idx] = msg_info; + } else { + // Для исходящих: обновляем can_be_edited и другие поля, + // но сохраняем reply_to (добавленный при отправке) + let existing = &mut self.current_chat_messages[idx]; + existing.can_be_edited = msg_info.can_be_edited; + existing.can_be_deleted_only_for_self = msg_info.can_be_deleted_only_for_self; + existing.can_be_deleted_for_all_users = msg_info.can_be_deleted_for_all_users; + existing.is_read = msg_info.is_read; } } None => { diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 0827899..9335a5c 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -352,10 +352,17 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { // ID выбранного сообщения для подсветки let selected_msg_id = app.get_selected_message().map(|m| m.id); + // Номер строки, где начинается выбранное сообщение (для автоскролла) + let mut selected_msg_line: Option = None; for msg in &app.td_client.current_chat_messages { // Проверяем, выбрано ли это сообщение let is_selected = selected_msg_id == Some(msg.id); + + // Запоминаем строку начала выбранного сообщения + if is_selected { + selected_msg_line = Some(lines.len()); + } // Проверяем, нужно ли добавить разделитель даты let msg_day = get_day(msg.date); if last_day != Some(msg_day) { @@ -590,13 +597,33 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let visible_height = message_chunks[1].height.saturating_sub(2) as usize; let total_lines = lines.len(); + // Базовый скролл (показываем последние сообщения) let base_scroll = if total_lines > visible_height { total_lines - visible_height } else { 0 }; - let scroll_offset = base_scroll.saturating_sub(app.message_scroll_offset) as u16; + // Если выбрано сообщение, автоскроллим к нему + let scroll_offset = if app.is_selecting_message() { + if let Some(selected_line) = selected_msg_line { + // Вычисляем нужный скролл, чтобы выбранное сообщение было видно + if selected_line < visible_height / 2 { + // Сообщение в начале — скроллим к началу + 0 + } else if selected_line > total_lines.saturating_sub(visible_height / 2) { + // Сообщение в конце — скроллим к концу + base_scroll + } else { + // Центрируем выбранное сообщение + selected_line.saturating_sub(visible_height / 2) + } + } else { + base_scroll.saturating_sub(app.message_scroll_offset) + } + } else { + base_scroll.saturating_sub(app.message_scroll_offset) + } as u16; let messages_widget = Paragraph::new(lines) .block(Block::default().borders(Borders::ALL))