From 4d5625f950f993059dc1a7a7020ecc0b1bf253c6 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 27 Jan 2026 03:59:49 +0300 Subject: [PATCH] add typings in/out Co-Authored-By: Claude Opus 4.5 --- src/app/mod.rs | 6 ++++ src/input/main_input.rs | 18 +++++++++- src/main.rs | 5 +++ src/tdlib/client.rs | 78 +++++++++++++++++++++++++++++++++++++++-- src/tdlib/mod.rs | 1 + src/ui/messages.rs | 44 +++++++++++++++++------ 6 files changed, 138 insertions(+), 14 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 4ac612c..98784e5 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -47,6 +47,9 @@ pub struct App { pub forwarding_message_id: Option, /// Режим выбора чата для пересылки pub is_selecting_forward_chat: bool, + // Typing indicator + /// Время последней отправки typing status (для throttling) + pub last_typing_sent: Option, } impl App { @@ -79,6 +82,7 @@ impl App { replying_to_message_id: None, forwarding_message_id: None, is_selecting_forward_chat: false, + last_typing_sent: None, } } @@ -135,9 +139,11 @@ impl App { self.editing_message_id = None; self.selected_message_index = None; self.replying_to_message_id = None; + self.last_typing_sent = None; // Очищаем данные в TdClient self.td_client.current_chat_id = None; self.td_client.current_chat_messages.clear(); + self.td_client.typing_status = None; } /// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте) diff --git a/src/input/main_input.rs b/src/input/main_input.rs index a23809c..24d3f6f 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -1,7 +1,8 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio::time::timeout; use crate::app::App; +use crate::tdlib::ChatAction; pub async fn handle(app: &mut App, key: KeyEvent) { let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); @@ -220,6 +221,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.message_input.clear(); app.cursor_position = 0; app.replying_to_message_id = None; + app.last_typing_sent = None; + + // Отменяем typing status + app.td_client.send_chat_action(chat_id, ChatAction::Cancel).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)) => { @@ -363,6 +368,17 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } 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(chat_id, ChatAction::Typing).await; + app.last_typing_sent = Some(Instant::now()); + } + } } KeyCode::Left => { // Курсор влево diff --git a/src/main.rs b/src/main.rs index e972970..3324c0c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -119,6 +119,11 @@ async fn run_app( app.needs_redraw = true; } + // Очищаем устаревший typing status + if app.td_client.clear_stale_typing_status() { + app.needs_redraw = true; + } + // Обрабатываем очередь сообщений для отметки как прочитанных if !app.td_client.pending_view_messages.is_empty() { app.td_client.process_pending_view_messages().await; diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index a7f376c..b3d4fd0 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -1,6 +1,7 @@ use std::env; use std::collections::HashMap; -use tdlib_rs::enums::{AuthorizationState, ChatList, ChatType, ConnectionState, MessageContent, Update, User, UserStatus}; +use std::time::Instant; +use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, ChatType, ConnectionState, MessageContent, MessageSender, Update, User, UserStatus}; use tdlib_rs::types::TextEntity; /// Максимальный размер кэшей пользователей @@ -129,7 +130,8 @@ pub struct ReplyInfo { pub struct ForwardInfo { /// Имя оригинального отправителя pub sender_name: String, - /// Дата оригинального сообщения + /// Дата оригинального сообщения (для будущего использования) + #[allow(dead_code)] pub date: i32, } @@ -222,6 +224,8 @@ pub struct TdClient { user_statuses: LruCache, /// Состояние сетевого соединения pub network_state: NetworkState, + /// Typing status для текущего чата: (user_id, action_text, timestamp) + pub typing_status: Option<(i64, String, Instant)>, } #[allow(dead_code)] @@ -252,6 +256,7 @@ impl TdClient { main_chat_list_position: 0, user_statuses: LruCache::new(MAX_USER_CACHE_SIZE), network_state: NetworkState::Connecting, + typing_status: None, } } @@ -290,6 +295,30 @@ impl TdClient { .and_then(|user_id| self.user_statuses.peek(user_id)) } + /// Очищает typing status если прошло более 6 секунд + /// Возвращает true если статус был очищен (нужна перерисовка) + pub fn clear_stale_typing_status(&mut self) -> bool { + if let Some((_, _, timestamp)) = &self.typing_status { + if timestamp.elapsed().as_secs() > 6 { + self.typing_status = None; + return true; + } + } + false + } + + /// Возвращает текст typing status с именем пользователя + /// Например: "Вася печатает..." + pub fn get_typing_text(&self) -> Option { + self.typing_status.as_ref().map(|(user_id, action, _)| { + let name = self.user_names + .peek(user_id) + .cloned() + .unwrap_or_else(|| "Кто-то".to_string()); + format!("{} {}", name, action) + }) + } + /// Инициализация TDLib с параметрами pub async fn init(&mut self) -> Result<(), String> { let result = functions::set_tdlib_parameters( @@ -525,6 +554,41 @@ impl TdClient { ConnectionState::Ready => NetworkState::Ready, }; } + Update::ChatAction(update) => { + // Обрабатываем только для текущего открытого чата + if Some(update.chat_id) == self.current_chat_id { + // Извлекаем user_id из sender_id + let user_id = match update.sender_id { + MessageSender::User(user) => Some(user.user_id), + MessageSender::Chat(_) => None, // Игнорируем действия от имени чата + }; + + if let Some(user_id) = user_id { + // Определяем текст действия + let action_text = match update.action { + ChatAction::Typing => Some("печатает...".to_string()), + ChatAction::RecordingVideo => Some("записывает видео...".to_string()), + ChatAction::UploadingVideo(_) => Some("отправляет видео...".to_string()), + ChatAction::RecordingVoiceNote => Some("записывает голосовое...".to_string()), + ChatAction::UploadingVoiceNote(_) => Some("отправляет голосовое...".to_string()), + ChatAction::UploadingPhoto(_) => Some("отправляет фото...".to_string()), + ChatAction::UploadingDocument(_) => Some("отправляет файл...".to_string()), + ChatAction::ChoosingSticker => Some("выбирает стикер...".to_string()), + ChatAction::RecordingVideoNote => Some("записывает видеосообщение...".to_string()), + ChatAction::UploadingVideoNote(_) => Some("отправляет видеосообщение...".to_string()), + ChatAction::Cancel => None, // Отмена — сбрасываем статус + _ => None, + }; + + if let Some(text) = action_text { + self.typing_status = Some((user_id, text, Instant::now())); + } else { + // Cancel или неизвестное действие — сбрасываем + self.typing_status = None; + } + } + } + } _ => {} } } @@ -1104,6 +1168,16 @@ impl TdClient { } } + /// Отправка статуса действия в чат (typing, cancel и т.д.) + pub async fn send_chat_action(&self, chat_id: i64, action: ChatAction) { + let _ = functions::send_chat_action( + chat_id, + 0, // message_thread_id + Some(action), + self.client_id, + ).await; + } + /// Отправка текстового сообщения с поддержкой 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}; diff --git a/src/tdlib/mod.rs b/src/tdlib/mod.rs index c4e4937..4bd9a4c 100644 --- a/src/tdlib/mod.rs +++ b/src/tdlib/mod.rs @@ -3,3 +3,4 @@ pub mod client; pub use client::TdClient; pub use client::UserOnlineStatus; pub use client::NetworkState; +pub use tdlib_rs::enums::ChatAction; diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 9335a5c..a9dacf3 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -328,18 +328,40 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { ]) .split(area); - // Chat header - let header_text = match &chat.username { - Some(username) => format!("👤 {} {}", chat.title, username), - None => format!("👤 {}", chat.title), + // Chat header с typing status + let typing_action = app.td_client.typing_status.as_ref().map(|(_, action, _)| action.clone()); + let header_line = if let Some(action) = typing_action { + // Показываем typing status: "👤 Имя @username печатает..." + let mut spans = vec![ + Span::styled( + format!("👤 {}", chat.title), + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + ]; + if let Some(username) = &chat.username { + spans.push(Span::styled( + format!(" {}", username), + Style::default().fg(Color::Gray), + )); + } + spans.push(Span::styled( + format!(" {}", action), + Style::default().fg(Color::Yellow).add_modifier(Modifier::ITALIC), + )); + Line::from(spans) + } else { + // Показываем username + let header_text = match &chat.username { + Some(username) => format!("👤 {} {}", chat.title, username), + None => format!("👤 {}", chat.title), + }; + Line::from(Span::styled( + header_text, + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + )) }; - let header = Paragraph::new(header_text) - .block(Block::default().borders(Borders::ALL)) - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ); + let header = Paragraph::new(header_line) + .block(Block::default().borders(Borders::ALL)); f.render_widget(header, message_chunks[0]); // Ширина области сообщений (без рамок)