add typings in/out
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,9 @@ pub struct App {
|
|||||||
pub forwarding_message_id: Option<i64>,
|
pub forwarding_message_id: Option<i64>,
|
||||||
/// Режим выбора чата для пересылки
|
/// Режим выбора чата для пересылки
|
||||||
pub is_selecting_forward_chat: bool,
|
pub is_selecting_forward_chat: bool,
|
||||||
|
// Typing indicator
|
||||||
|
/// Время последней отправки typing status (для throttling)
|
||||||
|
pub last_typing_sent: Option<std::time::Instant>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -79,6 +82,7 @@ impl App {
|
|||||||
replying_to_message_id: None,
|
replying_to_message_id: None,
|
||||||
forwarding_message_id: None,
|
forwarding_message_id: None,
|
||||||
is_selecting_forward_chat: false,
|
is_selecting_forward_chat: false,
|
||||||
|
last_typing_sent: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +139,11 @@ impl App {
|
|||||||
self.editing_message_id = None;
|
self.editing_message_id = None;
|
||||||
self.selected_message_index = None;
|
self.selected_message_index = None;
|
||||||
self.replying_to_message_id = None;
|
self.replying_to_message_id = None;
|
||||||
|
self.last_typing_sent = None;
|
||||||
// Очищаем данные в TdClient
|
// Очищаем данные в TdClient
|
||||||
self.td_client.current_chat_id = None;
|
self.td_client.current_chat_id = None;
|
||||||
self.td_client.current_chat_messages.clear();
|
self.td_client.current_chat_messages.clear();
|
||||||
|
self.td_client.typing_status = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте)
|
/// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use std::time::Duration;
|
use std::time::{Duration, Instant};
|
||||||
use tokio::time::timeout;
|
use tokio::time::timeout;
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
|
use crate::tdlib::ChatAction;
|
||||||
|
|
||||||
pub async fn handle(app: &mut App, key: KeyEvent) {
|
pub async fn handle(app: &mut App, key: KeyEvent) {
|
||||||
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
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.message_input.clear();
|
||||||
app.cursor_position = 0;
|
app.cursor_position = 0;
|
||||||
app.replying_to_message_id = None;
|
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 {
|
match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text, reply_to_id, reply_info)).await {
|
||||||
Ok(Ok(sent_msg)) => {
|
Ok(Ok(sent_msg)) => {
|
||||||
@@ -363,6 +368,17 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
app.message_input = new_input;
|
app.message_input = new_input;
|
||||||
app.cursor_position += 1;
|
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 => {
|
KeyCode::Left => {
|
||||||
// Курсор влево
|
// Курсор влево
|
||||||
|
|||||||
@@ -119,6 +119,11 @@ async fn run_app<B: ratatui::backend::Backend>(
|
|||||||
app.needs_redraw = true;
|
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() {
|
if !app.td_client.pending_view_messages.is_empty() {
|
||||||
app.td_client.process_pending_view_messages().await;
|
app.td_client.process_pending_view_messages().await;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::collections::HashMap;
|
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;
|
use tdlib_rs::types::TextEntity;
|
||||||
|
|
||||||
/// Максимальный размер кэшей пользователей
|
/// Максимальный размер кэшей пользователей
|
||||||
@@ -129,7 +130,8 @@ pub struct ReplyInfo {
|
|||||||
pub struct ForwardInfo {
|
pub struct ForwardInfo {
|
||||||
/// Имя оригинального отправителя
|
/// Имя оригинального отправителя
|
||||||
pub sender_name: String,
|
pub sender_name: String,
|
||||||
/// Дата оригинального сообщения
|
/// Дата оригинального сообщения (для будущего использования)
|
||||||
|
#[allow(dead_code)]
|
||||||
pub date: i32,
|
pub date: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -222,6 +224,8 @@ pub struct TdClient {
|
|||||||
user_statuses: LruCache<UserOnlineStatus>,
|
user_statuses: LruCache<UserOnlineStatus>,
|
||||||
/// Состояние сетевого соединения
|
/// Состояние сетевого соединения
|
||||||
pub network_state: NetworkState,
|
pub network_state: NetworkState,
|
||||||
|
/// Typing status для текущего чата: (user_id, action_text, timestamp)
|
||||||
|
pub typing_status: Option<(i64, String, Instant)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
@@ -252,6 +256,7 @@ impl TdClient {
|
|||||||
main_chat_list_position: 0,
|
main_chat_list_position: 0,
|
||||||
user_statuses: LruCache::new(MAX_USER_CACHE_SIZE),
|
user_statuses: LruCache::new(MAX_USER_CACHE_SIZE),
|
||||||
network_state: NetworkState::Connecting,
|
network_state: NetworkState::Connecting,
|
||||||
|
typing_status: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -290,6 +295,30 @@ impl TdClient {
|
|||||||
.and_then(|user_id| self.user_statuses.peek(user_id))
|
.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<String> {
|
||||||
|
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 с параметрами
|
/// Инициализация TDLib с параметрами
|
||||||
pub async fn init(&mut self) -> Result<(), String> {
|
pub async fn init(&mut self) -> Result<(), String> {
|
||||||
let result = functions::set_tdlib_parameters(
|
let result = functions::set_tdlib_parameters(
|
||||||
@@ -525,6 +554,41 @@ impl TdClient {
|
|||||||
ConnectionState::Ready => NetworkState::Ready,
|
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
|
/// Отправка текстового сообщения с поддержкой Markdown и reply
|
||||||
pub async fn send_message(&self, chat_id: i64, text: String, reply_to_message_id: Option<i64>, reply_info: Option<ReplyInfo>) -> Result<MessageInfo, String> {
|
pub async fn send_message(&self, chat_id: i64, text: String, reply_to_message_id: Option<i64>, reply_info: Option<ReplyInfo>) -> Result<MessageInfo, String> {
|
||||||
use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown, InputMessageReplyToMessage};
|
use tdlib_rs::types::{FormattedText, InputMessageText, TextParseModeMarkdown, InputMessageReplyToMessage};
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ pub mod client;
|
|||||||
pub use client::TdClient;
|
pub use client::TdClient;
|
||||||
pub use client::UserOnlineStatus;
|
pub use client::UserOnlineStatus;
|
||||||
pub use client::NetworkState;
|
pub use client::NetworkState;
|
||||||
|
pub use tdlib_rs::enums::ChatAction;
|
||||||
|
|||||||
@@ -328,18 +328,40 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|||||||
])
|
])
|
||||||
.split(area);
|
.split(area);
|
||||||
|
|
||||||
// Chat header
|
// 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 {
|
let header_text = match &chat.username {
|
||||||
Some(username) => format!("👤 {} {}", chat.title, username),
|
Some(username) => format!("👤 {} {}", chat.title, username),
|
||||||
None => format!("👤 {}", chat.title),
|
None => format!("👤 {}", chat.title),
|
||||||
};
|
};
|
||||||
let header = Paragraph::new(header_text)
|
Line::from(Span::styled(
|
||||||
.block(Block::default().borders(Borders::ALL))
|
header_text,
|
||||||
.style(
|
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
|
||||||
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]);
|
f.render_widget(header, message_chunks[0]);
|
||||||
|
|
||||||
// Ширина области сообщений (без рамок)
|
// Ширина области сообщений (без рамок)
|
||||||
|
|||||||
Reference in New Issue
Block a user