Compare commits

..

5 Commits

Author SHA1 Message Date
de18d6978b Merge pull request 'refactor' (#24) from refactor into main
Reviewed-on: #24
2026-03-02 22:00:07 +00:00
Mikhail Kilin
dea3559da7 docs: remove out-of-scope items from Phase 14 Etap 4 roadmap
Some checks failed
ci/woodpecker/pr/check Pipeline failed
Remove account deletion from modal and parallel polling — these won't
be implemented in the current scope.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 00:57:18 +03:00
Mikhail Kilin
260b81443e style: replace DarkGray with Rgb(160,160,160) for better terminal compatibility
DarkGray renders differently across terminals; a specific RGB value gives
consistent appearance. Also always show the account indicator in the footer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 00:57:06 +03:00
Mikhail Kilin
df89c4e376 test: update footer snapshots to always show account name
Snapshots now reflect the new behaviour where the account indicator
is always visible (including "default"), matching the footer.rs change.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 00:52:56 +03:00
Mikhail Kilin
ec2758ce18 refactor: consolidate message loading logic into chat_loader.rs
Move all three phases of chat message loading from scattered locations
into a single dedicated module for better cohesion and navigability:
- Phase 1: open_chat_and_load_data (from handlers/chat_list.rs)
- Phase 2: process_pending_chat_init (extracted from 70-line inline block in main.rs)
- Phase 3: load_older_messages_if_needed (from handlers/chat.rs)

No behaviour changes — pure refactoring.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-03 00:48:39 +03:00
15 changed files with 219 additions and 198 deletions

View File

@@ -164,7 +164,5 @@
- `pending_account_switch` флаг → обработка в main loop - `pending_account_switch` флаг → обработка в main loop
- [ ] **Этап 4: Расширенные возможности мультиаккаунта** - [ ] **Этап 4: Расширенные возможности мультиаккаунта**
- Удаление аккаунта из модалки
- Хоткеи `Ctrl+1`..`Ctrl+9` — быстрое переключение - Хоткеи `Ctrl+1`..`Ctrl+9` — быстрое переключение
- Бейджи непрочитанных с других аккаунтов (требует множественных TdClient) - Бейджи непрочитанных с других аккаунтов (требует множественных TdClient)
- Параллельный polling updates со всех аккаунтов

View File

@@ -6,7 +6,7 @@
//! - Editing and sending messages //! - Editing and sending messages
//! - Loading older 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::{ use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods, compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
navigation::NavigationMethods, navigation::NavigationMethods,
@@ -16,7 +16,7 @@ use crate::app::InputMode;
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard}; use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
use crate::tdlib::{ChatAction, TdClientTrait}; use crate::tdlib::{ChatAction, TdClientTrait};
use crate::types::{ChatId, MessageId}; 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 crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -340,50 +340,6 @@ pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
} }
} }
/// Подгружает старые сообщения если скролл близко к верху
pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
// 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);
}
}
/// Обработка ввода клавиатуры в открытом чате /// Обработка ввода клавиатуры в открытом чате
/// ///
/// Обрабатывает: /// Обрабатывает:

View File

@@ -5,14 +5,10 @@
//! - Folder selection //! - Folder selection
//! - Opening chats //! - Opening chats
use crate::app::methods::{ use crate::app::methods::navigation::NavigationMethods;
compose::ComposeMethods, messages::MessageMethods, navigation::NavigationMethods,
};
use crate::app::App; use crate::app::App;
use crate::app::InputMode;
use crate::tdlib::TdClientTrait; use crate::tdlib::TdClientTrait;
use crate::types::{ChatId, MessageId}; use crate::utils::with_timeout;
use crate::utils::{with_timeout, with_timeout_msg};
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use std::time::Duration; use std::time::Duration;
@@ -79,62 +75,3 @@ pub async fn select_folder<T: TdClientTrait>(app: &mut App<T>, 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<T: TdClientTrait>(app: &mut App<T>, 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<MessageId> = 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;
}
}
}

View File

@@ -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<T: TdClientTrait>(app: &mut App<T>, 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<MessageId> = 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<T: TdClientTrait>(app: &mut App<T>, 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<i32> = 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<String, String>)>();
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<T: TdClientTrait>(app: &mut App<T>) {
// 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);
}
}

View File

@@ -6,12 +6,14 @@
//! - profile: Profile helper functions //! - profile: Profile helper functions
//! - chat: Keyboard input handling for open chat view //! - chat: Keyboard input handling for open chat view
//! - chat_list: Navigation and interaction in the chat list //! - chat_list: Navigation and interaction in the chat list
//! - chat_loader: All phases of chat message loading
//! - compose: Text input, editing, and message composition //! - compose: Text input, editing, and message composition
//! - modal: Modal dialogs (delete confirmation, emoji picker, etc.) //! - modal: Modal dialogs (delete confirmation, emoji picker, etc.)
//! - search: Search functionality (chat search, message search) //! - search: Search functionality (chat search, message search)
pub mod chat; pub mod chat;
pub mod chat_list; pub mod chat_list;
pub mod chat_loader;
pub mod clipboard; pub mod clipboard;
pub mod compose; pub mod compose;
pub mod global; pub mod global;
@@ -19,6 +21,7 @@ pub mod modal;
pub mod profile; pub mod profile;
pub mod search; 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 clipboard::*;
pub use global::*; pub use global::*;
pub use profile::get_available_actions_count; pub use profile::get_available_actions_count;

View File

@@ -13,7 +13,7 @@ use crate::utils::with_timeout;
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use std::time::Duration; 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; use super::scroll_to_message;
/// Обработка режима поиска по чатам /// Обработка режима поиска по чатам

View File

@@ -29,6 +29,7 @@ use tdlib_rs::enums::Update;
use app::{App, AppScreen}; use app::{App, AppScreen};
use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS}; use constants::{POLL_TIMEOUT_MS, SHUTDOWN_TIMEOUT_SECS};
use input::{handle_auth_input, handle_main_input}; use input::{handle_auth_input, handle_main_input};
use input::handlers::process_pending_chat_init;
use tdlib::AuthState; use tdlib::AuthState;
use utils::{disable_tdlib_logs, with_timeout_ignore}; use utils::{disable_tdlib_logs, with_timeout_ignore};
@@ -345,76 +346,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
// Process pending chat initialization (reply info, pinned, photos) // Process pending chat initialization (reply info, pinned, photos)
if let Some(chat_id) = app.pending_chat_init.take() { if let Some(chat_id) = app.pending_chat_init.take() {
// Загружаем недостающие reply info (игнорируем ошибки) process_pending_chat_init(app, chat_id).await;
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<i32> = 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<String, String>)>();
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;
} }
// Check pending account switch // Check pending account switch

View File

@@ -35,7 +35,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
let search_style = if app.is_searching { let search_style = if app.is_searching {
Style::default().fg(Color::Yellow) Style::default().fg(Color::Yellow)
} else { } else {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::Rgb(160, 160, 160))
}; };
let search = Paragraph::new(search_text) let search = Paragraph::new(search_text)
.block(Block::default().borders(Borders::ALL)) .block(Block::default().borders(Borders::ALL))

View File

@@ -19,12 +19,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
NetworkState::Updating => "⏳ Обновление... | ", NetworkState::Updating => "⏳ Обновление... | ",
}; };
// Account indicator (shown if not "default") let account_indicator = format!("[{}] ", app.current_account_name);
let account_indicator = if app.current_account_name != "default" {
format!("[{}] ", app.current_account_name)
} else {
String::new()
};
let status = if let Some(msg) = &app.status_message { let status = if let Some(msg) = &app.status_message {
format!(" {}{}{} ", account_indicator, network_indicator, msg) format!(" {}{}{} ", account_indicator, network_indicator, msg)
@@ -57,7 +52,7 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
} else if app.status_message.is_some() { } else if app.status_message.is_some() {
Style::default().fg(Color::Yellow) Style::default().fg(Color::Yellow)
} else { } else {
Style::default().fg(Color::DarkGray) Style::default().fg(Color::Rgb(160, 160, 160))
}; };
let footer = Paragraph::new(status).style(style); let footer = Paragraph::new(status).style(style);

View File

@@ -1,5 +1,6 @@
--- ---
source: tests/footer.rs source: tests/footer.rs
assertion_line: 22
expression: output expression: output
--- ---
Инициализация TDLib... [default] Инициализация TDLib...

View File

@@ -1,5 +1,6 @@
--- ---
source: tests/footer.rs source: tests/footer.rs
assertion_line: 90
expression: output expression: output
--- ---
⏳ Подключение... | Инициализация TDLib... [default] ⏳ Подключение... | Инициализация TDLib...

View File

@@ -1,5 +1,6 @@
--- ---
source: tests/footer.rs source: tests/footer.rs
assertion_line: 73
expression: output expression: output
--- ---
⏳ Прокси... | Инициализация TDLib... [default] ⏳ Прокси... | Инициализация TDLib...

View File

@@ -1,5 +1,6 @@
--- ---
source: tests/footer.rs source: tests/footer.rs
assertion_line: 56
expression: output expression: output
--- ---
⚠ Нет сети | Инициализация TDLib... [default] ⚠ Нет сети | Инициализация TDLib...

View File

@@ -1,5 +1,6 @@
--- ---
source: tests/footer.rs source: tests/footer.rs
assertion_line: 39
expression: output expression: output
--- ---
Инициализация TDLib... [default] Инициализация TDLib...

View File

@@ -1,5 +1,6 @@
--- ---
source: tests/footer.rs source: tests/footer.rs
assertion_line: 107
expression: output expression: output
--- ---
Инициализация TDLib... [default] Инициализация TDLib...