From 46720b35849c9b173b01d5b562be78f0b2faa268 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Sun, 25 Jan 2026 21:21:07 +0300 Subject: [PATCH 1/4] fixes --- CONTEXT.md | 22 ++++++++++------------ ROADMAP.md | 34 +++++++++++++++++++++++++++++++++- 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index f64a990..be71814 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # Текущий контекст проекта -## Статус: Фаза 8 в процессе — Дополнительные фичи +## Статус: Фаза 9 — Расширенные возможности ### Что сделано @@ -161,18 +161,16 @@ API_ID=your_api_id API_HASH=your_api_hash ``` -## Что НЕ сделано / TODO (Фаза 8) +## Что НЕ сделано / TODO (Фаза 9) -- [x] Удалить дублирование current_messages между App и TdClient -- [x] Использовать единый источник данных для сообщений (td_client.current_chat_messages) -- [x] Реализовать LRU-кэш для user_names/user_statuses/user_usernames -- [x] Lazy loading для имён пользователей (батчевая загрузка последних 5 за цикл, лимит очереди 50) -- [x] Лимиты памяти: сообщения (500), чаты (200), chat_user_ids (500) -- [x] Markdown форматирование в сообщениях -- [x] Редактирование сообщений -- [x] Удаление сообщений -- [x] Reply на сообщения -- [x] Forward сообщений +- [ ] Typing indicator ("печатает...") +- [ ] Закреплённые сообщения (Pinned) — отображение вверху чата +- [ ] Поиск по сообщениям в чате (Ctrl+F) +- [ ] Черновики — сохранение текста при переключении чатов +- [ ] Профиль пользователя/чата (хоткей `i`) +- [ ] Копирование сообщений в буфер обмена (`y` в режиме выбора) +- [ ] Реакции — просмотр и добавление +- [ ] Конфигурационный файл (~/.config/tele-tui/config.toml) ## Известные проблемы diff --git a/ROADMAP.md b/ROADMAP.md index fec4296..ae967e1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,7 +79,7 @@ - MAX_CHAT_USER_IDS = 500 - MAX_USER_CACHE_SIZE = 500 (LRU) -## Фаза 8: Дополнительные фичи [IN PROGRESS] +## Фаза 8: Дополнительные фичи [DONE] - [x] Markdown форматирование в сообщениях - Bold, Italic, Underline, Strikethrough @@ -111,3 +111,35 @@ - Выбор чата стрелками, Enter для пересылки - Esc для отмены - Отображение "↪ Переслано от" для пересланных сообщений + +## Фаза 9: Расширенные возможности [TODO] + +- [ ] Typing indicator ("печатает...") + - Показывать когда собеседник печатает + - Отправлять свой статус печати при наборе текста +- [ ] Закреплённые сообщения (Pinned) + - Отображать pinned message вверху открытого чата + - Клик/хоткей для перехода к закреплённому сообщению +- [ ] Поиск по сообщениям в чате + - `Ctrl+F` — поиск текста внутри открытого чата + - Навигация по результатам (n/N или стрелки) + - Подсветка найденных совпадений +- [ ] Черновики + - Сохранять набранный текст при переключении между чатами + - Индикатор черновика в списке чатов + - Восстановление текста при возврате в чат +- [ ] Профиль пользователя/чата + - `i` — открыть информацию о чате/собеседнике + - Для личных чатов: имя, username, телефон, био + - Для групп: название, описание, количество участников +- [ ] Копирование сообщений + - `y` / `н` в режиме выбора — скопировать текст в системный буфер обмена + - Использовать clipboard crate для кроссплатформенности +- [ ] Реакции + - Отображение реакций под сообщениями + - `e` в режиме выбора — добавить реакцию (emoji picker) + - Список доступных реакций чата +- [ ] Конфигурационный файл + - `~/.config/tele-tui/config.toml` + - Настройки: цветовая схема, часовой пояс, хоткеи + - Загрузка конфига при старте From 4d5625f950f993059dc1a7a7020ecc0b1bf253c6 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 27 Jan 2026 03:59:49 +0300 Subject: [PATCH 2/4] 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]); // Ширина области сообщений (без рамок) From 81dc5b9007f7ba4206ed0a429f68ece6ec89a465 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 27 Jan 2026 04:38:29 +0300 Subject: [PATCH 3/4] add pinned messages --- src/app/mod.rs | 62 +++++++++++++ src/input/main_input.rs | 64 ++++++++++++++ src/tdlib/client.rs | 64 +++++++++++++- src/ui/messages.rs | 189 +++++++++++++++++++++++++++++++++++++--- src/utils.rs | 29 ++++++ 5 files changed, 395 insertions(+), 13 deletions(-) diff --git a/src/app/mod.rs b/src/app/mod.rs index 98784e5..8d48dc6 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -50,6 +50,13 @@ pub struct App { // Typing indicator /// Время последней отправки typing status (для throttling) pub last_typing_sent: Option, + // Pinned messages mode + /// Режим просмотра закреплённых сообщений + pub is_pinned_mode: bool, + /// Список закреплённых сообщений + pub pinned_messages: Vec, + /// Индекс выбранного pinned сообщения + pub selected_pinned_index: usize, } impl App { @@ -83,6 +90,9 @@ impl App { forwarding_message_id: None, is_selecting_forward_chat: false, last_typing_sent: None, + is_pinned_mode: false, + pinned_messages: Vec::new(), + selected_pinned_index: 0, } } @@ -140,10 +150,15 @@ impl App { self.selected_message_index = None; self.replying_to_message_id = None; self.last_typing_sent = None; + // Сбрасываем pinned режим + self.is_pinned_mode = false; + self.pinned_messages.clear(); + self.selected_pinned_index = 0; // Очищаем данные в TdClient self.td_client.current_chat_id = None; self.td_client.current_chat_messages.clear(); self.td_client.typing_status = None; + self.td_client.current_pinned_message = None; } /// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте) @@ -382,4 +397,51 @@ impl App { self.td_client.current_chat_messages.iter().find(|m| m.id == id) }) } + + // === Pinned messages mode === + + /// Проверка режима pinned + pub fn is_pinned_mode(&self) -> bool { + self.is_pinned_mode + } + + /// Войти в режим pinned (вызывается после загрузки pinned сообщений) + pub fn enter_pinned_mode(&mut self, messages: Vec) { + if !messages.is_empty() { + self.pinned_messages = messages; + self.selected_pinned_index = 0; + self.is_pinned_mode = true; + } + } + + /// Выйти из режима pinned + pub fn exit_pinned_mode(&mut self) { + self.is_pinned_mode = false; + self.pinned_messages.clear(); + self.selected_pinned_index = 0; + } + + /// Выбрать предыдущий pinned (вверх = более старый) + pub fn select_previous_pinned(&mut self) { + if !self.pinned_messages.is_empty() && self.selected_pinned_index < self.pinned_messages.len() - 1 { + self.selected_pinned_index += 1; + } + } + + /// Выбрать следующий pinned (вниз = более новый) + pub fn select_next_pinned(&mut self) { + if self.selected_pinned_index > 0 { + self.selected_pinned_index -= 1; + } + } + + /// Получить текущее выбранное pinned сообщение + pub fn get_selected_pinned(&self) -> Option<&crate::tdlib::client::MessageInfo> { + self.pinned_messages.get(self.selected_pinned_index) + } + + /// Получить ID текущего pinned для перехода в историю + pub fn get_selected_pinned_id(&self) -> Option { + self.get_selected_pinned().map(|m| m.id) + } } diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 24d3f6f..dd86f25 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -22,9 +22,69 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } return; } + KeyCode::Char('p') if has_ctrl => { + // Ctrl+P - режим просмотра закреплённых сообщений + if app.selected_chat_id.is_some() && !app.is_pinned_mode() { + if let Some(chat_id) = app.get_selected_chat_id() { + app.status_message = Some("Загрузка закреплённых...".to_string()); + match timeout(Duration::from_secs(5), app.td_client.get_pinned_messages(chat_id)).await { + Ok(Ok(messages)) => { + if messages.is_empty() { + app.status_message = Some("Нет закреплённых сообщений".to_string()); + } else { + app.enter_pinned_mode(messages); + app.status_message = None; + } + } + Ok(Err(e)) => { + app.error_message = Some(e); + app.status_message = None; + } + Err(_) => { + app.error_message = Some("Таймаут загрузки".to_string()); + app.status_message = None; + } + } + } + } + return; + } _ => {} } + // Режим просмотра закреплённых сообщений + if app.is_pinned_mode() { + match key.code { + KeyCode::Esc => { + app.exit_pinned_mode(); + } + KeyCode::Up => { + app.select_previous_pinned(); + } + KeyCode::Down => { + app.select_next_pinned(); + } + KeyCode::Enter => { + // Перейти к сообщению в истории + if let Some(msg_id) = app.get_selected_pinned_id() { + // Ищем индекс сообщения в текущей истории + let msg_index = app.td_client.current_chat_messages + .iter() + .position(|m| m.id == msg_id); + + if let Some(idx) = msg_index { + // Вычисляем scroll offset чтобы показать сообщение + let total = app.td_client.current_chat_messages.len(); + app.message_scroll_offset = total.saturating_sub(idx + 5); + } + app.exit_pinned_mode(); + } + } + _ => {} + } + return; + } + // Модалка подтверждения удаления if app.is_confirm_delete_shown() { match key.code { @@ -129,6 +189,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { Ok(Ok(_)) => { // Загружаем недостающие reply info let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await; + // Загружаем последнее закреплённое сообщение + let _ = timeout(Duration::from_secs(2), app.td_client.load_current_pinned_message(chat_id)).await; app.status_message = None; } Ok(Err(e)) => { @@ -256,6 +318,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { Ok(Ok(_)) => { // Загружаем недостающие reply info let _ = timeout(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()).await; + // Загружаем последнее закреплённое сообщение + let _ = timeout(Duration::from_secs(2), app.td_client.load_current_pinned_message(chat_id)).await; app.status_message = None; } Ok(Err(e)) => { diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index b3d4fd0..58329f0 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -1,7 +1,7 @@ use std::env; use std::collections::HashMap; use std::time::Instant; -use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, ChatType, ConnectionState, MessageContent, MessageSender, Update, User, UserStatus}; +use tdlib_rs::enums::{AuthorizationState, ChatAction, ChatList, ChatType, ConnectionState, MessageContent, MessageSender, SearchMessagesFilter, Update, User, UserStatus}; use tdlib_rs::types::TextEntity; /// Максимальный размер кэшей пользователей @@ -226,6 +226,8 @@ pub struct TdClient { pub network_state: NetworkState, /// Typing status для текущего чата: (user_id, action_text, timestamp) pub typing_status: Option<(i64, String, Instant)>, + /// Последнее закреплённое сообщение текущего чата + pub current_pinned_message: Option, } #[allow(dead_code)] @@ -257,6 +259,7 @@ impl TdClient { user_statuses: LruCache::new(MAX_USER_CACHE_SIZE), network_state: NetworkState::Connecting, typing_status: None, + current_pinned_message: None, } } @@ -1103,6 +1106,65 @@ impl TdClient { Ok(all_messages) } + /// Загрузка закреплённых сообщений чата + pub async fn get_pinned_messages(&mut self, chat_id: i64) -> Result, String> { + let result = functions::search_chat_messages( + chat_id, + "".to_string(), // query + None, // sender_id + 0, // from_message_id + 0, // offset + 100, // limit + Some(SearchMessagesFilter::Pinned), // filter + 0, // message_thread_id + 0, // saved_messages_topic_id + self.client_id, + ) + .await; + + match result { + Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => { + let mut messages: Vec = Vec::new(); + for m in found.messages { + messages.push(self.convert_message(&m, chat_id)); + } + // Сообщения приходят от новых к старым, оставляем как есть + Ok(messages) + } + Err(e) => Err(format!("Ошибка загрузки закреплённых: {:?}", e)), + } + } + + /// Загружает последнее закреплённое сообщение для текущего чата + pub async fn load_current_pinned_message(&mut self, chat_id: i64) { + let result = functions::search_chat_messages( + chat_id, + "".to_string(), + None, + 0, + 0, + 1, // Только одно сообщение + Some(SearchMessagesFilter::Pinned), + 0, + 0, + self.client_id, + ) + .await; + + match result { + Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => { + if let Some(m) = found.messages.first() { + self.current_pinned_message = Some(self.convert_message(m, chat_id)); + } else { + self.current_pinned_message = None; + } + } + Err(_) => { + self.current_pinned_message = None; + } + } + } + /// Загрузка старых сообщений (для скролла вверх) pub async fn load_older_messages( &mut self, diff --git a/src/ui/messages.rs b/src/ui/messages.rs index a9dacf3..1bd6646 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -307,6 +307,12 @@ fn adjust_entities_for_substring( } pub fn render(f: &mut Frame, area: Rect, app: &App) { + // Режим просмотра закреплённых сообщений + if app.is_pinned_mode() { + render_pinned_mode(f, area, app); + return; + } + if let Some(chat) = app.get_selected_chat() { // Вычисляем динамическую высоту инпута на основе длины текста let input_width = area.width.saturating_sub(4) as usize; // -2 для рамок, -2 для "> " @@ -319,14 +325,30 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { // Минимум 3 строки (1 контент + 2 рамки), максимум 10 let input_height = (input_lines + 2).min(10).max(3); - let message_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Chat header - Constraint::Min(0), // Messages - Constraint::Length(input_height), // Input box (динамическая высота) - ]) - .split(area); + // Проверяем, есть ли закреплённое сообщение + let has_pinned = app.td_client.current_pinned_message.is_some(); + + let message_chunks = if has_pinned { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Chat header + Constraint::Length(1), // Pinned bar + Constraint::Min(0), // Messages + Constraint::Length(input_height), // Input box (динамическая высота) + ]) + .split(area) + } else { + Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Chat header + Constraint::Length(0), // Pinned bar (hidden) + Constraint::Min(0), // Messages + Constraint::Length(input_height), // Input box (динамическая высота) + ]) + .split(area) + }; // Chat header с typing status let typing_action = app.td_client.typing_status.as_ref().map(|(_, action, _)| action.clone()); @@ -364,8 +386,31 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .block(Block::default().borders(Borders::ALL)); f.render_widget(header, message_chunks[0]); + // Pinned bar (если есть закреплённое сообщение) + if let Some(pinned_msg) = &app.td_client.current_pinned_message { + let pinned_preview: String = pinned_msg.content.chars().take(40).collect(); + let ellipsis = if pinned_msg.content.chars().count() > 40 { "..." } else { "" }; + let pinned_datetime = crate::utils::format_datetime(pinned_msg.date); + let pinned_text = format!("📌 {} {}{}", pinned_datetime, pinned_preview, ellipsis); + let pinned_hint = "Ctrl+P"; + + let pinned_bar_width = message_chunks[1].width as usize; + let text_len = pinned_text.chars().count(); + let hint_len = pinned_hint.chars().count(); + let padding = pinned_bar_width.saturating_sub(text_len + hint_len + 2); + + let pinned_line = Line::from(vec![ + Span::styled(pinned_text, Style::default().fg(Color::Magenta)), + Span::raw(" ".repeat(padding)), + Span::styled(pinned_hint, Style::default().fg(Color::Gray)), + ]); + let pinned_bar = Paragraph::new(pinned_line) + .style(Style::default().bg(Color::Rgb(40, 20, 40))); + f.render_widget(pinned_bar, message_chunks[1]); + } + // Ширина области сообщений (без рамок) - let content_width = message_chunks[1].width.saturating_sub(2) as usize; + let content_width = message_chunks[2].width.saturating_sub(2) as usize; // Messages с группировкой по дате и отправителю let mut lines: Vec = Vec::new(); @@ -616,7 +661,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } // Вычисляем скролл с учётом пользовательского offset - let visible_height = message_chunks[1].height.saturating_sub(2) as usize; + let visible_height = message_chunks[2].height.saturating_sub(2) as usize; let total_lines = lines.len(); // Базовый скролл (показываем последние сообщения) @@ -650,7 +695,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let messages_widget = Paragraph::new(lines) .block(Block::default().borders(Borders::ALL)) .scroll((scroll_offset, 0)); - f.render_widget(messages_widget, message_chunks[1]); + f.render_widget(messages_widget, message_chunks[2]); // Input box с wrap для длинного текста и блочным курсором let (input_line, input_title) = if app.is_forwarding() { @@ -752,7 +797,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { let input = Paragraph::new(input_line) .block(input_block) .wrap(ratatui::widgets::Wrap { trim: false }); - f.render_widget(input, message_chunks[2]); + f.render_widget(input, message_chunks[3]); } else { let empty = Paragraph::new("Выберите чат") .block(Block::default().borders(Borders::ALL)) @@ -767,6 +812,126 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } } +/// Рендерит режим просмотра закреплённых сообщений +fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Min(0), // Pinned messages list + Constraint::Length(3), // Help bar + ]) + .split(area); + + // Header + let total = app.pinned_messages.len(); + let current = app.selected_pinned_index + 1; + let header_text = format!("📌 ЗАКРЕПЛЁННЫЕ ({}/{})", current, total); + let header = Paragraph::new(header_text) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + ) + .style(Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)); + f.render_widget(header, chunks[0]); + + // Pinned messages list + let content_width = chunks[1].width.saturating_sub(2) as usize; + let mut lines: Vec = Vec::new(); + + for (idx, msg) in app.pinned_messages.iter().enumerate() { + let is_selected = idx == app.selected_pinned_index; + + // Пустая строка между сообщениями + if idx > 0 { + lines.push(Line::from("")); + } + + // Маркер выбора и имя отправителя + let marker = if is_selected { "▶ " } else { " " }; + let sender_color = if msg.is_outgoing { Color::Green } else { Color::Cyan }; + let sender_name = if msg.is_outgoing { "Вы".to_string() } else { msg.sender_name.clone() }; + + lines.push(Line::from(vec![ + Span::styled(marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::styled( + format!("{} ", sender_name), + Style::default().fg(sender_color).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("({})", crate::utils::format_datetime(msg.date)), + Style::default().fg(Color::Gray), + ), + ])); + + // Текст сообщения (с переносом) + let msg_color = if is_selected { Color::Yellow } else { Color::White }; + let max_width = content_width.saturating_sub(4); + let wrapped = wrap_text_with_offsets(&msg.content, max_width); + let wrapped_count = wrapped.len(); + + for wrapped_line in wrapped.into_iter().take(3) { // Максимум 3 строки на сообщение + lines.push(Line::from(vec![ + Span::raw(" "), // Отступ + Span::styled(wrapped_line.text, Style::default().fg(msg_color)), + ])); + } + if wrapped_count > 3 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("...", Style::default().fg(Color::Gray)), + ])); + } + } + + if lines.is_empty() { + lines.push(Line::from(Span::styled( + "Нет закреплённых сообщений", + Style::default().fg(Color::Gray), + ))); + } + + // Скролл к выбранному сообщению + let visible_height = chunks[1].height.saturating_sub(2) as usize; + let lines_per_msg = 5; // Примерно строк на сообщение + let selected_line = app.selected_pinned_index * lines_per_msg; + let scroll_offset = if selected_line > visible_height / 2 { + (selected_line - visible_height / 2) as u16 + } else { + 0 + }; + + let messages_widget = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + ) + .scroll((scroll_offset, 0)); + f.render_widget(messages_widget, chunks[1]); + + // Help bar + let help_line = Line::from(vec![ + Span::styled(" ↑↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw("навигация"), + Span::raw(" "), + Span::styled(" Enter ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw("перейти"), + Span::raw(" "), + Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw("выход"), + ]); + let help = Paragraph::new(help_line) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Magenta)) + ) + .alignment(Alignment::Center); + f.render_widget(help, chunks[2]); +} + /// Рендерит модалку подтверждения удаления fn render_delete_confirm_modal(f: &mut Frame, area: Rect) { use ratatui::widgets::Clear; diff --git a/src/utils.rs b/src/utils.rs index f3542f8..832aa94 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -79,3 +79,32 @@ pub fn format_date(timestamp: i32) -> String { pub fn get_day(timestamp: i32) -> i64 { timestamp as i64 / 86400 } + +/// Форматирование timestamp в полную дату и время (DD.MM.YYYY HH:MM) +pub fn format_datetime(timestamp: i32) -> String { + let secs = timestamp as i64; + + // Время + let hours = ((secs % 86400) / 3600) as u32; + let minutes = ((secs % 3600) / 60) as u32; + let local_hours = (hours + 3) % 24; // MSK + + // Дата + let days_since_epoch = secs / 86400; + let year = 1970 + (days_since_epoch / 365) as i32; + let day_of_year = days_since_epoch % 365; + + let months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let mut month = 1; + let mut day = day_of_year as i32; + + for (i, &m) in months.iter().enumerate() { + if day < m { + month = i + 1; + break; + } + day -= m; + } + + format!("{:02}.{:02}.{} {:02}:{:02}", day + 1, month, year, local_hours, minutes) +} From dc76e01f3caacc889fb91b5d608a56ae31a139fd Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 27 Jan 2026 12:09:05 +0300 Subject: [PATCH 4/4] add find messages --- src/app/mod.rs | 73 ++++++++++++++++++++ src/input/main_input.rs | 66 ++++++++++++++++++ src/tdlib/client.rs | 32 +++++++++ src/ui/messages.rs | 147 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 318 insertions(+) diff --git a/src/app/mod.rs b/src/app/mod.rs index 8d48dc6..d86a479 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -57,6 +57,15 @@ pub struct App { pub pinned_messages: Vec, /// Индекс выбранного pinned сообщения pub selected_pinned_index: usize, + // Message search mode + /// Режим поиска по сообщениям + pub is_message_search_mode: bool, + /// Поисковый запрос + pub message_search_query: String, + /// Результаты поиска + pub message_search_results: Vec, + /// Индекс выбранного результата + pub selected_search_result_index: usize, } impl App { @@ -93,6 +102,10 @@ impl App { is_pinned_mode: false, pinned_messages: Vec::new(), selected_pinned_index: 0, + is_message_search_mode: false, + message_search_query: String::new(), + message_search_results: Vec::new(), + selected_search_result_index: 0, } } @@ -159,6 +172,11 @@ impl App { self.td_client.current_chat_messages.clear(); self.td_client.typing_status = None; self.td_client.current_pinned_message = None; + // Сбрасываем режим поиска + self.is_message_search_mode = false; + self.message_search_query.clear(); + self.message_search_results.clear(); + self.selected_search_result_index = 0; } /// Начать выбор сообщения для редактирования (при стрелке вверх в пустом инпуте) @@ -444,4 +462,59 @@ impl App { pub fn get_selected_pinned_id(&self) -> Option { self.get_selected_pinned().map(|m| m.id) } + + // === Message Search Mode === + + /// Проверить, активен ли режим поиска по сообщениям + pub fn is_message_search_mode(&self) -> bool { + self.is_message_search_mode + } + + /// Войти в режим поиска по сообщениям + pub fn enter_message_search_mode(&mut self) { + self.is_message_search_mode = true; + self.message_search_query.clear(); + self.message_search_results.clear(); + self.selected_search_result_index = 0; + } + + /// Выйти из режима поиска + pub fn exit_message_search_mode(&mut self) { + self.is_message_search_mode = false; + self.message_search_query.clear(); + self.message_search_results.clear(); + self.selected_search_result_index = 0; + } + + /// Установить результаты поиска + pub fn set_search_results(&mut self, results: Vec) { + self.message_search_results = results; + self.selected_search_result_index = 0; + } + + /// Выбрать предыдущий результат (вверх) + pub fn select_previous_search_result(&mut self) { + if self.selected_search_result_index > 0 { + self.selected_search_result_index -= 1; + } + } + + /// Выбрать следующий результат (вниз) + pub fn select_next_search_result(&mut self) { + if !self.message_search_results.is_empty() + && self.selected_search_result_index < self.message_search_results.len() - 1 + { + self.selected_search_result_index += 1; + } + } + + /// Получить текущий выбранный результат + pub fn get_selected_search_result(&self) -> Option<&crate::tdlib::client::MessageInfo> { + self.message_search_results.get(self.selected_search_result_index) + } + + /// Получить ID выбранного результата для перехода + pub fn get_selected_search_result_id(&self) -> Option { + self.get_selected_search_result().map(|m| m.id) + } } diff --git a/src/input/main_input.rs b/src/input/main_input.rs index dd86f25..d2c96c6 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -49,9 +49,75 @@ pub async fn handle(app: &mut App, key: KeyEvent) { } return; } + KeyCode::Char('f') if has_ctrl => { + // Ctrl+F - поиск по сообщениям в открытом чате + if app.selected_chat_id.is_some() && !app.is_pinned_mode() && !app.is_message_search_mode() { + app.enter_message_search_mode(); + } + return; + } _ => {} } + // Режим поиска по сообщениям + if app.is_message_search_mode() { + match key.code { + KeyCode::Esc => { + app.exit_message_search_mode(); + } + KeyCode::Up | KeyCode::Char('N') => { + app.select_previous_search_result(); + } + KeyCode::Down | KeyCode::Char('n') => { + app.select_next_search_result(); + } + KeyCode::Enter => { + // Перейти к выбранному сообщению + if let Some(msg_id) = app.get_selected_search_result_id() { + let msg_index = app.td_client.current_chat_messages + .iter() + .position(|m| m.id == msg_id); + + if let Some(idx) = msg_index { + let total = app.td_client.current_chat_messages.len(); + app.message_scroll_offset = total.saturating_sub(idx + 5); + } + app.exit_message_search_mode(); + } + } + KeyCode::Backspace => { + app.message_search_query.pop(); + // Выполняем поиск при изменении запроса + if let Some(chat_id) = app.get_selected_chat_id() { + if !app.message_search_query.is_empty() { + if let Ok(Ok(results)) = timeout( + Duration::from_secs(3), + app.td_client.search_messages(chat_id, &app.message_search_query) + ).await { + app.set_search_results(results); + } + } else { + app.set_search_results(Vec::new()); + } + } + } + KeyCode::Char(c) => { + app.message_search_query.push(c); + // Выполняем поиск при изменении запроса + if let Some(chat_id) = app.get_selected_chat_id() { + if let Ok(Ok(results)) = timeout( + Duration::from_secs(3), + app.td_client.search_messages(chat_id, &app.message_search_query) + ).await { + app.set_search_results(results); + } + } + } + _ => {} + } + return; + } + // Режим просмотра закреплённых сообщений if app.is_pinned_mode() { match key.code { diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 58329f0..9880a1a 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -1165,6 +1165,38 @@ impl TdClient { } } + /// Поиск сообщений в чате по тексту + pub async fn search_messages(&mut self, chat_id: i64, query: &str) -> Result, String> { + if query.trim().is_empty() { + return Ok(Vec::new()); + } + + let result = functions::search_chat_messages( + chat_id, + query.to_string(), + None, // sender_id + 0, // from_message_id + 0, // offset + 50, // limit + None, // filter (no filter = search by text) + 0, // message_thread_id + 0, // saved_messages_topic_id + self.client_id, + ) + .await; + + match result { + Ok(tdlib_rs::enums::FoundChatMessages::FoundChatMessages(found)) => { + let mut messages: Vec = Vec::new(); + for m in found.messages { + messages.push(self.convert_message(&m, chat_id)); + } + Ok(messages) + } + Err(e) => Err(format!("Ошибка поиска: {:?}", e)), + } + } + /// Загрузка старых сообщений (для скролла вверх) pub async fn load_older_messages( &mut self, diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 1bd6646..db7d7c9 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -307,6 +307,12 @@ fn adjust_entities_for_substring( } pub fn render(f: &mut Frame, area: Rect, app: &App) { + // Режим поиска по сообщениям + if app.is_message_search_mode() { + render_search_mode(f, area, app); + return; + } + // Режим просмотра закреплённых сообщений if app.is_pinned_mode() { render_pinned_mode(f, area, app); @@ -812,6 +818,147 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } } +/// Рендерит режим поиска по сообщениям +fn render_search_mode(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Search input + Constraint::Min(0), // Search results + Constraint::Length(3), // Help bar + ]) + .split(area); + + // Search input + let total = app.message_search_results.len(); + let current = if total > 0 { app.selected_search_result_index + 1 } else { 0 }; + + let input_line = if app.message_search_query.is_empty() { + Line::from(vec![ + Span::styled("🔍 ", Style::default().fg(Color::Yellow)), + Span::styled("█", Style::default().fg(Color::Yellow)), + Span::styled(" Введите текст для поиска...", Style::default().fg(Color::Gray)), + ]) + } else { + Line::from(vec![ + Span::styled("🔍 ", Style::default().fg(Color::Yellow)), + Span::styled(&app.message_search_query, Style::default().fg(Color::White)), + Span::styled("█", Style::default().fg(Color::Yellow)), + Span::styled(format!(" ({}/{})", current, total), Style::default().fg(Color::Gray)), + ]) + }; + + let search_input = Paragraph::new(input_line) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + .title(" Поиск по сообщениям ") + .title_style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)) + ); + f.render_widget(search_input, chunks[0]); + + // Search results + let content_width = chunks[1].width.saturating_sub(2) as usize; + let mut lines: Vec = Vec::new(); + + if app.message_search_results.is_empty() { + if !app.message_search_query.is_empty() { + lines.push(Line::from(Span::styled( + "Ничего не найдено", + Style::default().fg(Color::Gray), + ))); + } + } else { + for (idx, msg) in app.message_search_results.iter().enumerate() { + let is_selected = idx == app.selected_search_result_index; + + // Пустая строка между результатами + if idx > 0 { + lines.push(Line::from("")); + } + + // Маркер выбора, имя и дата + let marker = if is_selected { "▶ " } else { " " }; + let sender_color = if msg.is_outgoing { Color::Green } else { Color::Cyan }; + let sender_name = if msg.is_outgoing { "Вы".to_string() } else { msg.sender_name.clone() }; + + lines.push(Line::from(vec![ + Span::styled(marker, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::styled( + format!("{} ", sender_name), + Style::default().fg(sender_color).add_modifier(Modifier::BOLD), + ), + Span::styled( + format!("({})", crate::utils::format_datetime(msg.date)), + Style::default().fg(Color::Gray), + ), + ])); + + // Текст сообщения (с переносом) + let msg_color = if is_selected { Color::Yellow } else { Color::White }; + let max_width = content_width.saturating_sub(4); + let wrapped = wrap_text_with_offsets(&msg.content, max_width); + let wrapped_count = wrapped.len(); + + for wrapped_line in wrapped.into_iter().take(2) { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(wrapped_line.text, Style::default().fg(msg_color)), + ])); + } + if wrapped_count > 2 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("...", Style::default().fg(Color::Gray)), + ])); + } + } + } + + // Скролл к выбранному результату + let visible_height = chunks[1].height.saturating_sub(2) as usize; + let lines_per_result = 4; + let selected_line = app.selected_search_result_index * lines_per_result; + let scroll_offset = if selected_line > visible_height / 2 { + (selected_line - visible_height / 2) as u16 + } else { + 0 + }; + + let results_widget = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + ) + .scroll((scroll_offset, 0)); + f.render_widget(results_widget, chunks[1]); + + // Help bar + let help_line = Line::from(vec![ + Span::styled(" ↑↓ ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw("навигация"), + Span::raw(" "), + Span::styled(" n/N ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)), + Span::raw("след./пред."), + Span::raw(" "), + Span::styled(" Enter ", Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)), + Span::raw("перейти"), + Span::raw(" "), + Span::styled(" Esc ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)), + Span::raw("выход"), + ]); + let help = Paragraph::new(help_line) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)) + ) + .alignment(Alignment::Center); + f.render_widget(help, chunks[2]); +} + /// Рендерит режим просмотра закреплённых сообщений fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) { let chunks = Layout::default()