diff --git a/ROADMAP.md b/ROADMAP.md index 7a252a5..518ea81 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -164,7 +164,5 @@ - `pending_account_switch` флаг → обработка в main loop - [ ] **Этап 4: Расширенные возможности мультиаккаунта** - - Удаление аккаунта из модалки - Хоткеи `Ctrl+1`..`Ctrl+9` — быстрое переключение - Бейджи непрочитанных с других аккаунтов (требует множественных TdClient) - - Параллельный polling updates со всех аккаунтов diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs index 5c687c3..e3c56a3 100644 --- a/src/input/handlers/chat.rs +++ b/src/input/handlers/chat.rs @@ -6,7 +6,7 @@ //! - Editing and sending messages //! - Loading older messages -use super::chat_list::open_chat_and_load_data; +use super::chat_loader::{load_older_messages_if_needed, open_chat_and_load_data}; use crate::app::methods::{ compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods, navigation::NavigationMethods, @@ -16,7 +16,7 @@ use crate::app::InputMode; use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard}; use crate::tdlib::{ChatAction, TdClientTrait}; use crate::types::{ChatId, MessageId}; -use crate::utils::{is_non_empty, with_timeout, with_timeout_msg}; +use crate::utils::{is_non_empty, with_timeout_msg}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use std::time::{Duration, Instant}; @@ -340,50 +340,6 @@ pub async fn send_reaction(app: &mut App) { } } -/// Подгружает старые сообщения если скролл близко к верху -pub async fn load_older_messages_if_needed(app: &mut App) { - // Check if there are messages to load from - if app.td_client.current_chat_messages().is_empty() { - return; - } - - // Get the oldest message ID - let oldest_msg_id = app - .td_client - .current_chat_messages() - .first() - .map(|m| m.id()) - .unwrap_or(MessageId::new(0)); - - // Get current chat ID - let Some(chat_id) = app.get_selected_chat_id() else { - return; - }; - - // Check if scroll is near the top - let message_count = app.td_client.current_chat_messages().len(); - if app.message_scroll_offset <= message_count.saturating_sub(10) { - return; - } - - // Load older messages with timeout - let Ok(older) = with_timeout( - Duration::from_secs(3), - app.td_client - .load_older_messages(ChatId::new(chat_id), oldest_msg_id), - ) - .await - else { - return; - }; - - // Add older messages to the beginning if any were loaded - if !older.is_empty() { - let msgs = app.td_client.current_chat_messages_mut(); - msgs.splice(0..0, older); - } -} - /// Обработка ввода клавиатуры в открытом чате /// /// Обрабатывает: diff --git a/src/input/handlers/chat_list.rs b/src/input/handlers/chat_list.rs index 6a747c3..49b6aba 100644 --- a/src/input/handlers/chat_list.rs +++ b/src/input/handlers/chat_list.rs @@ -5,14 +5,10 @@ //! - Folder selection //! - Opening chats -use crate::app::methods::{ - compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods, -}; +use crate::app::methods::navigation::NavigationMethods; use crate::app::App; -use crate::app::InputMode; use crate::tdlib::TdClientTrait; -use crate::types::{ChatId, MessageId}; -use crate::utils::{with_timeout, with_timeout_msg}; +use crate::utils::with_timeout; use crossterm::event::KeyEvent; use std::time::Duration; @@ -79,62 +75,3 @@ pub async fn select_folder(app: &mut App, folder_idx: usize } } -/// Открывает чат и загружает последние сообщения (быстро). -/// -/// Загружает только 50 последних сообщений для мгновенного отображения. -/// Фоновые задачи (reply info, pinned, photos) откладываются в `pending_chat_init` -/// и выполняются на следующем тике main loop. -/// -/// При ошибке устанавливает error_message и очищает status_message. -pub async fn open_chat_and_load_data(app: &mut App, chat_id: i64) { - app.status_message = Some("Загрузка сообщений...".to_string()); - app.message_scroll_offset = 0; - - // Загружаем только 50 последних сообщений (один запрос к TDLib) - match with_timeout_msg( - Duration::from_secs(10), - app.td_client.get_chat_history(ChatId::new(chat_id), 50), - "Таймаут загрузки сообщений", - ) - .await - { - Ok(messages) => { - // Собираем ID всех входящих сообщений для отметки как прочитанные - let incoming_message_ids: Vec = messages - .iter() - .filter(|msg| !msg.is_outgoing()) - .map(|msg| msg.id()) - .collect(); - - // Сохраняем загруженные сообщения - app.td_client.set_current_chat_messages(messages); - - // Добавляем входящие сообщения в очередь для отметки как прочитанные - if !incoming_message_ids.is_empty() { - app.td_client - .pending_view_messages_mut() - .push((ChatId::new(chat_id), incoming_message_ids)); - } - - // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории - // Это предотвращает race condition с Update::NewMessage - app.td_client - .set_current_chat_id(Some(ChatId::new(chat_id))); - - // Загружаем черновик (локальная операция, мгновенно) - app.load_draft(); - - // Показываем чат СРАЗУ - app.status_message = None; - app.input_mode = InputMode::Normal; - app.start_message_selection(); - - // Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop - app.pending_chat_init = Some(ChatId::new(chat_id)); - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } -} diff --git a/src/input/handlers/chat_loader.rs b/src/input/handlers/chat_loader.rs new file mode 100644 index 0000000..1388da4 --- /dev/null +++ b/src/input/handlers/chat_loader.rs @@ -0,0 +1,194 @@ +//! Chat loading logic — all three phases of message loading +//! +//! - Phase 1: `open_chat_and_load_data` — fast initial load (50 messages) +//! - Phase 2: `process_pending_chat_init` — background tasks (reply info, pinned, photos) +//! - Phase 3: `load_older_messages_if_needed` — lazy load on scroll up + +use crate::app::methods::{compose::ComposeMethods, messages::MessageMethods}; +use crate::app::App; +use crate::app::InputMode; +use crate::tdlib::TdClientTrait; +use crate::types::{ChatId, MessageId}; +use crate::utils::{with_timeout, with_timeout_ignore, with_timeout_msg}; +use std::time::Duration; + +/// Открывает чат и загружает последние сообщения (быстро). +/// +/// Загружает только 50 последних сообщений для мгновенного отображения. +/// Фоновые задачи (reply info, pinned, photos) откладываются в `pending_chat_init` +/// и выполняются на следующем тике main loop. +/// +/// При ошибке устанавливает error_message и очищает status_message. +pub async fn open_chat_and_load_data(app: &mut App, chat_id: i64) { + app.status_message = Some("Загрузка сообщений...".to_string()); + app.message_scroll_offset = 0; + + // Загружаем только 50 последних сообщений (один запрос к TDLib) + match with_timeout_msg( + Duration::from_secs(10), + app.td_client.get_chat_history(ChatId::new(chat_id), 50), + "Таймаут загрузки сообщений", + ) + .await + { + Ok(messages) => { + // Собираем ID всех входящих сообщений для отметки как прочитанные + let incoming_message_ids: Vec = messages + .iter() + .filter(|msg| !msg.is_outgoing()) + .map(|msg| msg.id()) + .collect(); + + // Сохраняем загруженные сообщения + app.td_client.set_current_chat_messages(messages); + + // Добавляем входящие сообщения в очередь для отметки как прочитанные + if !incoming_message_ids.is_empty() { + app.td_client + .pending_view_messages_mut() + .push((ChatId::new(chat_id), incoming_message_ids)); + } + + // ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории + // Это предотвращает race condition с Update::NewMessage + app.td_client + .set_current_chat_id(Some(ChatId::new(chat_id))); + + // Загружаем черновик (локальная операция, мгновенно) + app.load_draft(); + + // Показываем чат СРАЗУ + app.status_message = None; + app.input_mode = InputMode::Normal; + app.start_message_selection(); + + // Фоновые задачи (reply info, pinned, photos) — на следующем тике main loop + app.pending_chat_init = Some(ChatId::new(chat_id)); + } + Err(e) => { + app.error_message = Some(e); + app.status_message = None; + } + } +} + +/// Выполняет фоновую инициализацию после открытия чата. +/// +/// Вызывается на следующем тике main loop после `open_chat_and_load_data`. +/// Загружает reply info, закреплённое сообщение и начинает авто-загрузку фото. +pub async fn process_pending_chat_init(app: &mut App, chat_id: ChatId) { + // Загружаем недостающие reply info (игнорируем ошибки) + with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()) + .await; + + // Загружаем последнее закреплённое сообщение (игнорируем ошибки) + with_timeout_ignore( + Duration::from_secs(2), + app.td_client.load_current_pinned_message(chat_id), + ) + .await; + + // Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно) + #[cfg(feature = "images")] + { + use crate::tdlib::PhotoDownloadState; + + if app.config().images.auto_download_images && app.config().images.show_images { + let photo_file_ids: Vec = app + .td_client + .current_chat_messages() + .iter() + .rev() + .take(5) + .filter_map(|msg| { + msg.photo_info().and_then(|p| { + matches!(p.download_state, PhotoDownloadState::NotDownloaded) + .then_some(p.file_id) + }) + }) + .collect(); + + if !photo_file_ids.is_empty() { + let client_id = app.td_client.client_id(); + let (tx, rx) = + tokio::sync::mpsc::unbounded_channel::<(i32, Result)>(); + app.photo_download_rx = Some(rx); + + for file_id in photo_file_ids { + let tx = tx.clone(); + tokio::spawn(async move { + let result = tokio::time::timeout(Duration::from_secs(5), async { + match tdlib_rs::functions::download_file( + file_id, 1, 0, 0, true, client_id, + ) + .await + { + Ok(tdlib_rs::enums::File::File(file)) + if file.local.is_downloading_completed + && !file.local.path.is_empty() => + { + Ok(file.local.path) + } + Ok(_) => Err("Файл не скачан".to_string()), + Err(e) => Err(format!("{:?}", e)), + } + }) + .await; + + let result = match result { + Ok(r) => r, + Err(_) => Err("Таймаут загрузки".to_string()), + }; + let _ = tx.send((file_id, result)); + }); + } + } + } + } + + app.needs_redraw = true; +} + +/// Подгружает старые сообщения если скролл близко к верху +pub async fn load_older_messages_if_needed(app: &mut App) { + // Check if there are messages to load from + if app.td_client.current_chat_messages().is_empty() { + return; + } + + // Get the oldest message ID + let oldest_msg_id = app + .td_client + .current_chat_messages() + .first() + .map(|m| m.id()) + .unwrap_or(MessageId::new(0)); + + // Get current chat ID + let Some(chat_id) = app.get_selected_chat_id() else { + return; + }; + + // Check if scroll is near the top + let message_count = app.td_client.current_chat_messages().len(); + if app.message_scroll_offset <= message_count.saturating_sub(10) { + return; + } + + // Load older messages with timeout + let Ok(older) = with_timeout( + Duration::from_secs(3), + app.td_client + .load_older_messages(ChatId::new(chat_id), oldest_msg_id), + ) + .await + else { + return; + }; + + // Add older messages to the beginning if any were loaded + if !older.is_empty() { + let msgs = app.td_client.current_chat_messages_mut(); + msgs.splice(0..0, older); + } +} diff --git a/src/input/handlers/mod.rs b/src/input/handlers/mod.rs index 998a4ac..f94e35d 100644 --- a/src/input/handlers/mod.rs +++ b/src/input/handlers/mod.rs @@ -6,12 +6,14 @@ //! - profile: Profile helper functions //! - chat: Keyboard input handling for open chat view //! - chat_list: Navigation and interaction in the chat list +//! - chat_loader: All phases of chat message loading //! - compose: Text input, editing, and message composition //! - modal: Modal dialogs (delete confirmation, emoji picker, etc.) //! - search: Search functionality (chat search, message search) pub mod chat; pub mod chat_list; +pub mod chat_loader; pub mod clipboard; pub mod compose; pub mod global; @@ -19,6 +21,7 @@ pub mod modal; pub mod profile; pub mod search; +pub use chat_loader::{load_older_messages_if_needed, open_chat_and_load_data, process_pending_chat_init}; pub use clipboard::*; pub use global::*; pub use profile::get_available_actions_count; diff --git a/src/input/handlers/search.rs b/src/input/handlers/search.rs index 1bb151c..466b53a 100644 --- a/src/input/handlers/search.rs +++ b/src/input/handlers/search.rs @@ -13,7 +13,7 @@ use crate::utils::with_timeout; use crossterm::event::{KeyCode, KeyEvent}; use std::time::Duration; -use super::chat_list::open_chat_and_load_data; +use super::chat_loader::open_chat_and_load_data; use super::scroll_to_message; /// Обработка режима поиска по чатам diff --git a/src/main.rs b/src/main.rs index a6a9530..19e92a1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -29,6 +29,7 @@ use tdlib_rs::enums::Update; use app::{App, AppScreen}; use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS}; use input::{handle_auth_input, handle_main_input}; +use input::handlers::process_pending_chat_init; use tdlib::AuthState; use utils::{disable_tdlib_logs, with_timeout_ignore}; @@ -345,76 +346,7 @@ async fn run_app( // Process pending chat initialization (reply info, pinned, photos) if let Some(chat_id) = app.pending_chat_init.take() { - // Загружаем недостающие reply info (игнорируем ошибки) - with_timeout_ignore(Duration::from_secs(5), app.td_client.fetch_missing_reply_info()) - .await; - - // Загружаем последнее закреплённое сообщение (игнорируем ошибки) - with_timeout_ignore( - Duration::from_secs(2), - app.td_client.load_current_pinned_message(chat_id), - ) - .await; - - // Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно) - #[cfg(feature = "images")] - { - use crate::tdlib::PhotoDownloadState; - - if app.config().images.auto_download_images && app.config().images.show_images { - let photo_file_ids: Vec = app - .td_client - .current_chat_messages() - .iter() - .rev() - .take(5) - .filter_map(|msg| { - msg.photo_info().and_then(|p| { - matches!(p.download_state, PhotoDownloadState::NotDownloaded) - .then_some(p.file_id) - }) - }) - .collect(); - - if !photo_file_ids.is_empty() { - let client_id = app.td_client.client_id(); - let (tx, rx) = - tokio::sync::mpsc::unbounded_channel::<(i32, Result)>(); - app.photo_download_rx = Some(rx); - - for file_id in photo_file_ids { - let tx = tx.clone(); - tokio::spawn(async move { - let result = tokio::time::timeout(Duration::from_secs(5), async { - match tdlib_rs::functions::download_file( - file_id, 1, 0, 0, true, client_id, - ) - .await - { - Ok(tdlib_rs::enums::File::File(file)) - if file.local.is_downloading_completed - && !file.local.path.is_empty() => - { - Ok(file.local.path) - } - Ok(_) => Err("Файл не скачан".to_string()), - Err(e) => Err(format!("{:?}", e)), - } - }) - .await; - - let result = match result { - Ok(r) => r, - Err(_) => Err("Таймаут загрузки".to_string()), - }; - let _ = tx.send((file_id, result)); - }); - } - } - } - } - - app.needs_redraw = true; + process_pending_chat_init(app, chat_id).await; } // Check pending account switch diff --git a/src/ui/chat_list.rs b/src/ui/chat_list.rs index 2e5cc64..a9695e4 100644 --- a/src/ui/chat_list.rs +++ b/src/ui/chat_list.rs @@ -35,7 +35,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { let search_style = if app.is_searching { Style::default().fg(Color::Yellow) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Rgb(160, 160, 160)) }; let search = Paragraph::new(search_text) .block(Block::default().borders(Borders::ALL)) diff --git a/src/ui/footer.rs b/src/ui/footer.rs index 135c399..b5753de 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -19,12 +19,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { NetworkState::Updating => "⏳ Обновление... | ", }; - // Account indicator (shown if not "default") - let account_indicator = if app.current_account_name != "default" { - format!("[{}] ", app.current_account_name) - } else { - String::new() - }; + let account_indicator = format!("[{}] ", app.current_account_name); let status = if let Some(msg) = &app.status_message { format!(" {}{}{} ", account_indicator, network_indicator, msg) @@ -57,7 +52,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { } else if app.status_message.is_some() { Style::default().fg(Color::Yellow) } else { - Style::default().fg(Color::DarkGray) + Style::default().fg(Color::Rgb(160, 160, 160)) }; let footer = Paragraph::new(status).style(style); diff --git a/tests/snapshots/footer__footer_chat_list.snap b/tests/snapshots/footer__footer_chat_list.snap index 45442c2..f8ed97c 100644 --- a/tests/snapshots/footer__footer_chat_list.snap +++ b/tests/snapshots/footer__footer_chat_list.snap @@ -1,5 +1,6 @@ --- source: tests/footer.rs +assertion_line: 22 expression: output --- - Инициализация TDLib... + [default] Инициализация TDLib... diff --git a/tests/snapshots/footer__footer_network_connecting.snap b/tests/snapshots/footer__footer_network_connecting.snap index 7207354..5e56a22 100644 --- a/tests/snapshots/footer__footer_network_connecting.snap +++ b/tests/snapshots/footer__footer_network_connecting.snap @@ -1,5 +1,6 @@ --- source: tests/footer.rs +assertion_line: 90 expression: output --- - ⏳ Подключение... | Инициализация TDLib... + [default] ⏳ Подключение... | Инициализация TDLib... diff --git a/tests/snapshots/footer__footer_network_connecting_proxy.snap b/tests/snapshots/footer__footer_network_connecting_proxy.snap index 24a7bcf..8bb5beb 100644 --- a/tests/snapshots/footer__footer_network_connecting_proxy.snap +++ b/tests/snapshots/footer__footer_network_connecting_proxy.snap @@ -1,5 +1,6 @@ --- source: tests/footer.rs +assertion_line: 73 expression: output --- - ⏳ Прокси... | Инициализация TDLib... + [default] ⏳ Прокси... | Инициализация TDLib... diff --git a/tests/snapshots/footer__footer_network_waiting.snap b/tests/snapshots/footer__footer_network_waiting.snap index 711037b..425c713 100644 --- a/tests/snapshots/footer__footer_network_waiting.snap +++ b/tests/snapshots/footer__footer_network_waiting.snap @@ -1,5 +1,6 @@ --- source: tests/footer.rs +assertion_line: 56 expression: output --- - ⚠ Нет сети | Инициализация TDLib... + [default] ⚠ Нет сети | Инициализация TDLib... diff --git a/tests/snapshots/footer__footer_open_chat.snap b/tests/snapshots/footer__footer_open_chat.snap index 45442c2..b4687bf 100644 --- a/tests/snapshots/footer__footer_open_chat.snap +++ b/tests/snapshots/footer__footer_open_chat.snap @@ -1,5 +1,6 @@ --- source: tests/footer.rs +assertion_line: 39 expression: output --- - Инициализация TDLib... + [default] Инициализация TDLib... diff --git a/tests/snapshots/footer__footer_search_mode.snap b/tests/snapshots/footer__footer_search_mode.snap index 45442c2..f2be4cd 100644 --- a/tests/snapshots/footer__footer_search_mode.snap +++ b/tests/snapshots/footer__footer_search_mode.snap @@ -1,5 +1,6 @@ --- source: tests/footer.rs +assertion_line: 107 expression: output --- - Инициализация TDLib... + [default] Инициализация TDLib...