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) +}