refactor: implement trait-based DI for TdClient and fix stack overflow

Implement complete trait-based dependency injection pattern for TdClient
to enable testing with FakeTdClient mock. Fix critical stack overflow bugs
caused by infinite recursion in trait implementations.

Breaking Changes:
- App is now generic: App<T: TdClientTrait = TdClient>
- All UI and input handlers are generic over TdClientTrait
- TdClient methods now accessed through trait interface

New Files:
- src/tdlib/trait.rs: TdClientTrait definition with 40+ methods
- src/tdlib/client_impl.rs: TdClientTrait impl for TdClient
- tests/helpers/fake_tdclient_impl.rs: TdClientTrait impl for FakeTdClient

Critical Fixes:
- Fix stack overflow in send_message, edit_message, delete_messages
- Fix stack overflow in forward_messages, current_chat_messages
- Fix stack overflow in current_pinned_message
- All methods now call message_manager directly to avoid recursion

Testing:
- FakeTdClient supports configurable auth_state for auth screen tests
- Added pinned message support in FakeTdClient
- All 196+ tests passing (188 tests + 8 benchmarks)

Dependencies:
- Added async-trait = "0.1"

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-02 05:42:19 +03:00
parent ed5a4f9c72
commit 8e48d076de
38 changed files with 1053 additions and 161 deletions

View File

@@ -4,7 +4,7 @@ mod state;
pub use chat_state::ChatState;
pub use state::AppScreen;
use crate::tdlib::{ChatInfo, TdClient};
use crate::tdlib::{ChatInfo, TdClient, TdClientTrait};
use crate::types::{ChatId, MessageId};
use ratatui::widgets::ListState;
@@ -43,11 +43,11 @@ use ratatui::widgets::ListState;
/// // Open a chat
/// app.select_current_chat();
/// ```
pub struct App {
pub struct App<T: TdClientTrait = TdClient> {
// Core (config - readonly через getter)
config: crate::config::Config,
pub screen: AppScreen,
pub td_client: TdClient,
pub td_client: T,
/// Состояние чата - type-safe state machine (новое!)
pub chat_state: ChatState,
// Auth state (используются часто в UI)
@@ -77,27 +77,27 @@ pub struct App {
pub last_typing_sent: Option<std::time::Instant>,
}
impl App {
/// Creates a new App instance with the given configuration.
impl<T: TdClientTrait> App<T> {
/// Creates a new App instance with the given configuration and client.
///
/// Initializes TDLib client, sets up empty chat list, and configures
/// the app to start on the Loading screen.
/// Sets up empty chat list and configures the app to start on the Loading screen.
///
/// # Arguments
///
/// * `config` - Application configuration loaded from config.toml
/// * `td_client` - TDLib client instance (real or fake for tests)
///
/// # Returns
///
/// A new `App` instance ready to start authentication.
pub fn new(config: crate::config::Config) -> App {
pub fn with_client(config: crate::config::Config, td_client: T) -> App<T> {
let mut state = ListState::default();
state.select(Some(0));
App {
config,
screen: AppScreen::Loading,
td_client: TdClient::new(),
td_client,
chat_state: ChatState::Normal,
phone_input: String::new(),
code_input: String::new(),
@@ -174,7 +174,7 @@ impl App {
self.chat_state = ChatState::Normal;
// Очищаем данные в TdClient
self.td_client.set_current_chat_id(None);
self.td_client.current_chat_messages_mut().clear();
self.td_client.clear_current_chat_messages();
self.td_client.set_typing_status(None);
self.td_client.set_current_pinned_message(None);
}
@@ -215,9 +215,9 @@ impl App {
}
/// Получить выбранное сообщение
pub fn get_selected_message(&self) -> Option<&crate::tdlib::MessageInfo> {
pub fn get_selected_message(&self) -> Option<crate::tdlib::MessageInfo> {
self.chat_state.selected_message_index().and_then(|idx| {
self.td_client.current_chat_messages().get(idx)
self.td_client.current_chat_messages().get(idx).cloned()
})
}
@@ -397,12 +397,13 @@ impl App {
}
/// Получить сообщение, на которое отвечаем
pub fn get_replying_to_message(&self) -> Option<&crate::tdlib::MessageInfo> {
pub fn get_replying_to_message(&self) -> Option<crate::tdlib::MessageInfo> {
self.chat_state.selected_message_id().and_then(|id| {
self.td_client
.current_chat_messages()
.iter()
.find(|m| m.id() == id)
.cloned()
})
}
@@ -431,7 +432,7 @@ impl App {
}
/// Получить сообщение для пересылки
pub fn get_forwarding_message(&self) -> Option<&crate::tdlib::MessageInfo> {
pub fn get_forwarding_message(&self) -> Option<crate::tdlib::MessageInfo> {
if !self.chat_state.is_forward() {
return None;
}
@@ -440,6 +441,7 @@ impl App {
.current_chat_messages()
.iter()
.find(|m| m.id() == id)
.cloned()
})
}
@@ -991,3 +993,22 @@ impl App {
self.last_typing_sent = Some(std::time::Instant::now());
}
}
// Convenience constructor for real TdClient (production use)
impl App<TdClient> {
/// Creates a new App instance with the given configuration and a real TDLib client.
///
/// This is a convenience method for production use that automatically creates
/// a new TdClient instance.
///
/// # Arguments
///
/// * `config` - Application configuration loaded from config.toml
///
/// # Returns
///
/// A new `App<TdClient>` instance ready to start authentication.
pub fn new(config: crate::config::Config) -> App<TdClient> {
App::with_client(config, TdClient::new())
}
}

View File

@@ -1,10 +1,10 @@
use crate::app::App;
use crate::tdlib::AuthState;
use crate::tdlib::{AuthState, TdClientTrait};
use crossterm::event::KeyCode;
use std::time::Duration;
use tokio::time::timeout;
pub async fn handle(app: &mut App, key_code: KeyCode) {
pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key_code: KeyCode) {
match &app.td_client.auth_state() {
AuthState::WaitPhoneNumber => match key_code {
KeyCode::Char(c) => {

View File

@@ -1,10 +1,11 @@
//! Chat list navigation input handling
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crossterm::event::KeyEvent;
/// Обрабатывает ввод в списке чатов
pub async fn handle_chat_list_input(app: &mut App, key: KeyEvent) {
pub async fn handle_chat_list_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement chat list input handling
let _ = (app, key);
}

View File

@@ -7,6 +7,7 @@
//! - Ctrl+F: Search messages in chat
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::types::ChatId;
use crate::utils::{with_timeout, with_timeout_msg};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@@ -17,7 +18,7 @@ use std::time::Duration;
/// # Returns
///
/// `true` если команда была обработана, `false` если нет
pub async fn handle_global_commands(app: &mut App, key: KeyEvent) -> bool {
pub async fn handle_global_commands<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) -> bool {
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
@@ -55,7 +56,7 @@ pub async fn handle_global_commands(app: &mut App, key: KeyEvent) -> bool {
}
/// Обрабатывает загрузку и отображение закреплённых сообщений
async fn handle_pinned_messages(app: &mut App) {
async fn handle_pinned_messages<T: TdClientTrait>(app: &mut App<T>) {
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());

View File

@@ -1,10 +1,11 @@
//! Message input handling when chat is open
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crossterm::event::KeyEvent;
/// Обрабатывает ввод когда открыт чат
pub async fn handle_messages_input(app: &mut App, key: KeyEvent) {
pub async fn handle_messages_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement messages input handling
let _ = (app, key);
}

View File

@@ -7,28 +7,29 @@
//! - Forward mode
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crossterm::event::KeyEvent;
/// Обрабатывает ввод в режиме закреплённых сообщений
pub async fn handle_pinned_input(app: &mut App, key: KeyEvent) {
pub async fn handle_pinned_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement pinned messages input handling
let _ = (app, key);
}
/// Обрабатывает ввод в режиме выбора реакции
pub async fn handle_reaction_picker_input(app: &mut App, key: KeyEvent) {
pub async fn handle_reaction_picker_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement reaction picker input handling
let _ = (app, key);
}
/// Обрабатывает ввод в режиме подтверждения удаления
pub async fn handle_delete_confirmation_input(app: &mut App, key: KeyEvent) {
pub async fn handle_delete_confirmation_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement delete confirmation input handling
let _ = (app, key);
}
/// Обрабатывает ввод в режиме пересылки
pub async fn handle_forward_input(app: &mut App, key: KeyEvent) {
pub async fn handle_forward_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement forward mode input handling
let _ = (app, key);
}

View File

@@ -1,10 +1,11 @@
//! Profile mode input handling
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crossterm::event::KeyEvent;
/// Обрабатывает ввод в режиме профиля
pub async fn handle_profile_input(app: &mut App, key: KeyEvent) {
pub async fn handle_profile_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement profile input handling
// Временно делегируем обратно в main_input
let _ = (app, key);

View File

@@ -1,16 +1,17 @@
//! Search mode input handling (chat search and message search)
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crossterm::event::KeyEvent;
/// Обрабатывает ввод в режиме поиска чатов
pub async fn handle_chat_search_input(app: &mut App, key: KeyEvent) {
pub async fn handle_chat_search_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement chat search input handling
let _ = (app, key);
}
/// Обрабатывает ввод в режиме поиска сообщений
pub async fn handle_message_search_input(app: &mut App, key: KeyEvent) {
pub async fn handle_message_search_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// TODO: Implement message search input handling
let _ = (app, key);
}

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::input::handlers::{
copy_to_clipboard, format_message_for_clipboard, get_available_actions_count,
handle_global_commands,
@@ -9,7 +10,7 @@ use crate::utils::{with_timeout, with_timeout_msg};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant};
pub async fn handle(app: &mut App, key: KeyEvent) {
pub async fn handle<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
// Глобальные команды (работают всегда)
if handle_global_commands(app, key).await {
return;
@@ -445,7 +446,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
{
Ok(messages) => {
// Сохраняем загруженные сообщения
*app.td_client.current_chat_messages_mut() = messages;
app.td_client.set_current_chat_messages(messages);
// ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории
// Это предотвращает race condition с Update::NewMessage
app.td_client.set_current_chat_id(Some(ChatId::new(chat_id)));
@@ -589,10 +590,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.last_typing_sent = None;
// Отменяем typing status
let _ = tokio::time::timeout(
Duration::from_millis(100),
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
).await;
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel).await;
match with_timeout_msg(
Duration::from_secs(5),
@@ -633,7 +631,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
{
Ok(messages) => {
// Сохраняем загруженные сообщения
*app.td_client.current_chat_messages_mut() = messages;
app.td_client.set_current_chat_messages(messages);
// ВАЖНО: Устанавливаем current_chat_id ТОЛЬКО ПОСЛЕ сохранения истории
// Это предотвращает race condition с Update::NewMessage
app.td_client.set_current_chat_id(Some(ChatId::new(chat_id)));
@@ -680,17 +678,10 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
if let Some(chat_id) = app.selected_chat_id {
if !app.message_input.is_empty() && !app.is_editing() && !app.is_replying() {
let draft_text = app.message_input.clone();
// Timeout чтобы не блокировать UI в тестах
let _ = tokio::time::timeout(
Duration::from_millis(100),
app.td_client.set_draft_message(chat_id, draft_text)
).await;
let _ = app.td_client.set_draft_message(chat_id, draft_text).await;
} else if app.message_input.is_empty() {
// Очищаем черновик если инпут пустой
let _ = tokio::time::timeout(
Duration::from_millis(100),
app.td_client.set_draft_message(chat_id, String::new())
).await;
let _ = app.td_client.set_draft_message(chat_id, String::new()).await;
}
}
app.close_chat();
@@ -733,7 +724,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
KeyCode::Char('y') | KeyCode::Char('н') => {
// Копировать сообщение
if let Some(msg) = app.get_selected_message() {
let text = format_message_for_clipboard(msg);
let text = format_message_for_clipboard(&msg);
match copy_to_clipboard(&text) {
Ok(_) => {
app.status_message = Some("Сообщение скопировано".to_string());
@@ -864,11 +855,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
.unwrap_or(true);
if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() {
// Используем короткий timeout чтобы не блокировать UI (особенно в тестах)
let _ = tokio::time::timeout(
Duration::from_millis(100),
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
).await;
app.td_client.send_chat_action(ChatId::new(chat_id), ChatAction::Typing).await;
app.last_typing_sent = Some(Instant::now());
}
}

View File

@@ -56,49 +56,12 @@ async fn main() -> Result<(), io::Error> {
// Create app state
let mut app = App::new(config);
let res = run_app(&mut terminal, &mut app).await;
// Restore terminal
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("Error: {:?}", err);
}
Ok(())
}
async fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
) -> io::Result<()> {
// Флаг для остановки polling задачи
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_clone = should_stop.clone();
// Канал для передачи updates из polling задачи в main loop
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<Update>();
// Запускаем polling TDLib receive() в отдельной задаче
let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) {
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await;
if let Ok(Some((update, _client_id))) = result {
if update_tx.send(update).is_err() {
break; // Канал закрыт, выходим
}
}
}
});
// Запускаем инициализацию TDLib в фоне
// Запускаем инициализацию TDLib в фоне (только для реального клиента)
let client_id = app.td_client.client_id();
let api_id = app.td_client.api_id;
let api_hash = app.td_client.api_hash.clone();
tokio::spawn(async move {
let _ = tdlib_rs::functions::set_tdlib_parameters(
false, // use_test_dc
@@ -119,6 +82,44 @@ async fn run_app<B: ratatui::backend::Backend>(
)
.await;
});
let res = run_app(&mut terminal, &mut app).await;
// Restore terminal
disable_raw_mode()?;
execute!(terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture)?;
terminal.show_cursor()?;
if let Err(err) = res {
println!("Error: {:?}", err);
}
Ok(())
}
async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
terminal: &mut Terminal<B>,
app: &mut App<T>,
) -> io::Result<()> {
// Флаг для остановки polling задачи
let should_stop = Arc::new(AtomicBool::new(false));
let should_stop_clone = should_stop.clone();
// Канал для передачи updates из polling задачи в main loop
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<Update>();
// Запускаем polling TDLib receive() в отдельной задаче
let polling_handle = tokio::spawn(async move {
while !should_stop_clone.load(Ordering::Relaxed) {
// receive() с таймаутом 0.1 сек чтобы периодически проверять флаг
let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await;
if let Ok(Some((update, _client_id))) = result {
if update_tx.send(update).is_err() {
break; // Канал закрыт, выходим
}
}
}
});
loop {
// Обрабатываем updates от TDLib из канала (неблокирующе)
@@ -203,7 +204,7 @@ async fn run_app<B: ratatui::backend::Backend>(
}
/// Возвращает true если состояние изменилось и требуется перерисовка
async fn update_screen_state(app: &mut App) -> bool {
async fn update_screen_state<T: tdlib::TdClientTrait>(app: &mut App<T>) -> bool {
use tokio::time::timeout;
let prev_screen = app.screen.clone();

270
src/tdlib/client_impl.rs Normal file
View File

@@ -0,0 +1,270 @@
//! Implementation of TdClientTrait for TdClient
//!
//! This file contains the trait implementation that delegates to existing TdClient methods.
use super::client::TdClient;
use super::r#trait::TdClientTrait;
use super::{AuthState, ChatInfo, FolderInfo, MessageInfo, ProfileInfo, ReplyInfo, UserCache, UserOnlineStatus};
use crate::types::{ChatId, MessageId, UserId};
use async_trait::async_trait;
use tdlib_rs::enums::{ChatAction, Update};
#[async_trait]
impl TdClientTrait for TdClient {
// ============ Auth methods ============
async fn send_phone_number(&self, phone: String) -> Result<(), String> {
self.send_phone_number(phone).await
}
async fn send_code(&self, code: String) -> Result<(), String> {
self.send_code(code).await
}
async fn send_password(&self, password: String) -> Result<(), String> {
self.send_password(password).await
}
// ============ Chat methods ============
async fn load_chats(&mut self, limit: i32) -> Result<(), String> {
self.load_chats(limit).await
}
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String> {
self.load_folder_chats(folder_id, limit).await
}
async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String> {
self.leave_chat(chat_id).await
}
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
self.get_profile_info(chat_id).await
}
// ============ Chat actions ============
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction) {
self.send_chat_action(chat_id, action).await
}
fn clear_stale_typing_status(&mut self) -> bool {
self.clear_stale_typing_status()
}
// ============ Message methods ============
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String> {
self.get_chat_history(chat_id, limit).await
}
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String> {
self.load_older_messages(chat_id, from_message_id).await
}
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String> {
self.get_pinned_messages(chat_id).await
}
async fn load_current_pinned_message(&mut self, chat_id: ChatId) {
self.load_current_pinned_message(chat_id).await
}
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String> {
self.search_messages(chat_id, query).await
}
async fn send_message(
&mut self,
chat_id: ChatId,
text: String,
reply_to_message_id: Option<MessageId>,
reply_info: Option<ReplyInfo>,
) -> Result<MessageInfo, String> {
self.message_manager
.send_message(chat_id, text, reply_to_message_id, reply_info)
.await
}
async fn edit_message(
&mut self,
chat_id: ChatId,
message_id: MessageId,
new_text: String,
) -> Result<MessageInfo, String> {
self.message_manager
.edit_message(chat_id, message_id, new_text)
.await
}
async fn delete_messages(
&mut self,
chat_id: ChatId,
message_ids: Vec<MessageId>,
revoke: bool,
) -> Result<(), String> {
self.message_manager
.delete_messages(chat_id, message_ids, revoke)
.await
}
async fn forward_messages(
&mut self,
to_chat_id: ChatId,
from_chat_id: ChatId,
message_ids: Vec<MessageId>,
) -> Result<(), String> {
self.message_manager
.forward_messages(to_chat_id, from_chat_id, message_ids)
.await
}
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String> {
self.set_draft_message(chat_id, text).await
}
fn push_message(&mut self, msg: MessageInfo) {
self.push_message(msg)
}
async fn fetch_missing_reply_info(&mut self) {
self.fetch_missing_reply_info().await
}
async fn process_pending_view_messages(&mut self) {
self.process_pending_view_messages().await
}
// ============ User methods ============
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus> {
self.get_user_status_by_chat_id(chat_id)
}
async fn process_pending_user_ids(&mut self) {
self.process_pending_user_ids().await
}
// ============ Reaction methods ============
async fn get_message_available_reactions(
&self,
chat_id: ChatId,
message_id: MessageId,
) -> Result<Vec<String>, String> {
self.get_message_available_reactions(chat_id, message_id).await
}
async fn toggle_reaction(
&self,
chat_id: ChatId,
message_id: MessageId,
reaction: String,
) -> Result<(), String> {
self.toggle_reaction(chat_id, message_id, reaction).await
}
fn client_id(&self) -> i32 {
self.client_id()
}
async fn get_me(&self) -> Result<i64, String> {
self.get_me().await
}
fn auth_state(&self) -> &AuthState {
self.auth_state()
}
fn chats(&self) -> &[ChatInfo] {
self.chats()
}
fn folders(&self) -> &[FolderInfo] {
self.folders()
}
fn current_chat_messages(&self) -> Vec<MessageInfo> {
self.message_manager.current_chat_messages.to_vec()
}
fn current_chat_id(&self) -> Option<ChatId> {
self.current_chat_id()
}
fn current_pinned_message(&self) -> Option<MessageInfo> {
self.message_manager.current_pinned_message.clone()
}
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)> {
self.typing_status()
}
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)] {
self.pending_view_messages()
}
fn pending_user_ids(&self) -> &[UserId] {
self.pending_user_ids()
}
fn main_chat_list_position(&self) -> i32 {
self.main_chat_list_position()
}
fn user_cache(&self) -> &UserCache {
self.user_cache()
}
fn network_state(&self) -> super::types::NetworkState {
self.network_state.clone()
}
fn chats_mut(&mut self) -> &mut Vec<ChatInfo> {
self.chats_mut()
}
fn folders_mut(&mut self) -> &mut Vec<FolderInfo> {
self.folders_mut()
}
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo> {
self.current_chat_messages_mut()
}
fn clear_current_chat_messages(&mut self) {
self.current_chat_messages_mut().clear()
}
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>) {
*self.current_chat_messages_mut() = messages;
}
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>) {
self.set_current_chat_id(chat_id)
}
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>) {
self.set_current_pinned_message(msg)
}
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>) {
self.set_typing_status(status)
}
fn pending_view_messages_mut(&mut self) -> &mut Vec<(ChatId, Vec<MessageId>)> {
self.pending_view_messages_mut()
}
fn pending_user_ids_mut(&mut self) -> &mut Vec<UserId> {
self.pending_user_ids_mut()
}
fn set_main_chat_list_position(&mut self, position: i32) {
self.set_main_chat_list_position(position)
}
fn user_cache_mut(&mut self) -> &mut UserCache {
self.user_cache_mut()
}
// ============ Update handling ============
fn handle_update(&mut self, update: Update) {
self.handle_update(update)
}
}

View File

@@ -2,17 +2,21 @@
pub mod auth;
pub mod chats;
pub mod client;
mod client_impl; // Private module for trait implementation
pub mod messages;
pub mod reactions;
pub mod r#trait;
pub mod types;
pub mod users;
// Экспорт основных типов
pub use auth::AuthState;
pub use client::TdClient;
pub use r#trait::TdClientTrait;
pub use types::{
ChatInfo, MessageBuilder, MessageInfo, NetworkState, ProfileInfo, ReplyInfo, UserOnlineStatus,
ChatInfo, FolderInfo, MessageBuilder, MessageInfo, NetworkState, ProfileInfo, ReplyInfo, UserOnlineStatus,
};
pub use users::UserCache;
// Re-export ChatAction для удобства
pub use tdlib_rs::enums::ChatAction;

125
src/tdlib/trait.rs Normal file
View File

@@ -0,0 +1,125 @@
//! Trait definition for TdClient to enable dependency injection
//!
//! This trait allows tests to use FakeTdClient instead of real TDLib client.
use crate::tdlib::{AuthState, FolderInfo, MessageInfo, ProfileInfo, UserCache, UserOnlineStatus};
use crate::types::{ChatId, MessageId, UserId};
use async_trait::async_trait;
use tdlib_rs::enums::{ChatAction, Update};
use super::ChatInfo;
/// Trait for TDLib client operations
///
/// This trait defines the interface for both real and fake TDLib clients,
/// enabling dependency injection and easier testing.
#[async_trait]
pub trait TdClientTrait: Send {
// ============ Auth methods ============
async fn send_phone_number(&self, phone: String) -> Result<(), String>;
async fn send_code(&self, code: String) -> Result<(), String>;
async fn send_password(&self, password: String) -> Result<(), String>;
// ============ Chat methods ============
async fn load_chats(&mut self, limit: i32) -> Result<(), String>;
async fn load_folder_chats(&mut self, folder_id: i32, limit: i32) -> Result<(), String>;
async fn leave_chat(&self, chat_id: ChatId) -> Result<(), String>;
async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String>;
// ============ Chat actions ============
async fn send_chat_action(&self, chat_id: ChatId, action: ChatAction);
fn clear_stale_typing_status(&mut self) -> bool;
// ============ Message methods ============
async fn get_chat_history(&mut self, chat_id: ChatId, limit: i32) -> Result<Vec<MessageInfo>, String>;
async fn load_older_messages(&mut self, chat_id: ChatId, from_message_id: MessageId) -> Result<Vec<MessageInfo>, String>;
async fn get_pinned_messages(&mut self, chat_id: ChatId) -> Result<Vec<MessageInfo>, String>;
async fn load_current_pinned_message(&mut self, chat_id: ChatId);
async fn search_messages(&self, chat_id: ChatId, query: &str) -> Result<Vec<MessageInfo>, String>;
async fn send_message(
&mut self,
chat_id: ChatId,
text: String,
reply_to_message_id: Option<MessageId>,
reply_info: Option<super::ReplyInfo>,
) -> Result<MessageInfo, String>;
async fn edit_message(
&mut self,
chat_id: ChatId,
message_id: MessageId,
new_text: String,
) -> Result<MessageInfo, String>;
async fn delete_messages(
&mut self,
chat_id: ChatId,
message_ids: Vec<MessageId>,
revoke: bool,
) -> Result<(), String>;
async fn forward_messages(
&mut self,
to_chat_id: ChatId,
from_chat_id: ChatId,
message_ids: Vec<MessageId>,
) -> Result<(), String>;
async fn set_draft_message(&self, chat_id: ChatId, text: String) -> Result<(), String>;
fn push_message(&mut self, msg: MessageInfo);
async fn fetch_missing_reply_info(&mut self);
async fn process_pending_view_messages(&mut self);
// ============ User methods ============
fn get_user_status_by_chat_id(&self, chat_id: ChatId) -> Option<&UserOnlineStatus>;
async fn process_pending_user_ids(&mut self);
// ============ Reaction methods ============
async fn get_message_available_reactions(
&self,
chat_id: ChatId,
message_id: MessageId,
) -> Result<Vec<String>, String>;
async fn toggle_reaction(
&self,
chat_id: ChatId,
message_id: MessageId,
reaction: String,
) -> Result<(), String>;
// ============ Getters (immutable) ============
fn client_id(&self) -> i32;
async fn get_me(&self) -> Result<i64, String>;
fn auth_state(&self) -> &AuthState;
fn chats(&self) -> &[ChatInfo];
fn folders(&self) -> &[FolderInfo];
fn current_chat_messages(&self) -> Vec<MessageInfo>;
fn current_chat_id(&self) -> Option<ChatId>;
fn current_pinned_message(&self) -> Option<MessageInfo>;
fn typing_status(&self) -> Option<&(UserId, String, std::time::Instant)>;
fn pending_view_messages(&self) -> &[(ChatId, Vec<MessageId>)];
fn pending_user_ids(&self) -> &[UserId];
fn main_chat_list_position(&self) -> i32;
fn user_cache(&self) -> &UserCache;
fn network_state(&self) -> super::types::NetworkState;
// ============ Setters (mutable) ============
fn chats_mut(&mut self) -> &mut Vec<ChatInfo>;
fn folders_mut(&mut self) -> &mut Vec<FolderInfo>;
fn current_chat_messages_mut(&mut self) -> &mut Vec<MessageInfo>;
fn clear_current_chat_messages(&mut self);
fn set_current_chat_messages(&mut self, messages: Vec<MessageInfo>);
fn set_current_chat_id(&mut self, chat_id: Option<ChatId>);
fn set_current_pinned_message(&mut self, msg: Option<MessageInfo>);
fn set_typing_status(&mut self, status: Option<(UserId, String, std::time::Instant)>);
fn pending_view_messages_mut(&mut self) -> &mut Vec<(ChatId, Vec<MessageId>)>;
fn pending_user_ids_mut(&mut self) -> &mut Vec<UserId>;
fn set_main_chat_list_position(&mut self, position: i32);
fn user_cache_mut(&mut self) -> &mut UserCache;
// ============ Update handling ============
fn handle_update(&mut self, update: Update);
}

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::AuthState;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
@@ -8,7 +9,7 @@ use ratatui::{
Frame,
};
pub fn render(f: &mut Frame, app: &App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &App<T>) {
let area = f.area();
let vertical_chunks = Layout::default()

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::UserOnlineStatus;
use crate::ui::components;
use ratatui::{
@@ -8,7 +9,7 @@ use ratatui::{
Frame,
};
pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut App<T>) {
let chat_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::NetworkState;
use ratatui::{
layout::Rect,
@@ -7,9 +8,9 @@ use ratatui::{
Frame,
};
pub fn render(f: &mut Frame, area: Rect, app: &App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Индикатор состояния сети
let network_indicator = match app.td_client.network_state {
let network_indicator = match app.td_client.network_state() {
NetworkState::Ready => "",
NetworkState::WaitingForNetwork => "⚠ Нет сети | ",
NetworkState::ConnectingToProxy => "⏳ Прокси... | ",
@@ -32,9 +33,9 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
)
};
let style = if matches!(app.td_client.network_state, NetworkState::WaitingForNetwork) {
let style = if matches!(app.td_client.network_state(), NetworkState::WaitingForNetwork) {
Style::default().fg(Color::Red)
} else if !matches!(app.td_client.network_state, NetworkState::Ready) {
} else if !matches!(app.td_client.network_state(), NetworkState::Ready) {
Style::default().fg(Color::Cyan)
} else if app.error_message.is_some() {
Style::default().fg(Color::Red)

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style},
@@ -6,7 +7,7 @@ use ratatui::{
Frame,
};
pub fn render(f: &mut Frame, app: &App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &App<T>) {
let area = f.area();
let chunks = Layout::default()

View File

@@ -1,5 +1,6 @@
use super::{chat_list, footer, messages};
use crate::app::App;
use crate::tdlib::TdClientTrait;
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
@@ -11,7 +12,7 @@ use ratatui::{
/// Порог ширины для компактного режима (одна панель)
const COMPACT_WIDTH: u16 = 80;
pub fn render(f: &mut Frame, app: &mut App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
let area = f.area();
let is_compact = area.width < COMPACT_WIDTH;
@@ -52,7 +53,7 @@ pub fn render(f: &mut Frame, app: &mut App) {
footer::render(f, chunks[2], app);
}
fn render_folders(f: &mut Frame, area: Rect, app: &App) {
fn render_folders<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
let mut spans = vec![];
// "All" всегда первая (клавиша 1)

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::message_grouping::{group_messages, MessageGroup};
use crate::ui::components;
use ratatui::{
@@ -113,7 +114,7 @@ fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
result
}
pub fn render(f: &mut Frame, area: Rect, app: &App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Режим профиля
if app.is_profile_mode() {
if let Some(profile) = app.get_profile_info() {
@@ -213,7 +214,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
f.render_widget(header, message_chunks[0]);
// Pinned bar (если есть закреплённое сообщение)
if let Some(pinned_msg) = &app.td_client.current_pinned_message() {
if let Some(pinned_msg) = app.td_client.current_pinned_message() {
let pinned_preview: String = pinned_msg.text().chars().take(40).collect();
let ellipsis = if pinned_msg.text().chars().count() > 40 {
"..."
@@ -251,7 +252,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
let mut selected_msg_line: Option<usize> = None;
// Используем message_grouping для группировки сообщений
let grouped = group_messages(app.td_client.current_chat_messages());
let grouped = group_messages(&app.td_client.current_chat_messages());
let mut is_first_date = true;
let mut is_first_sender = true;
@@ -357,9 +358,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
// Режим выбора сообщения - подсказка зависит от возможностей
let selected_msg = app.get_selected_message();
let can_edit = selected_msg
.as_ref()
.map(|m| m.can_be_edited() && m.is_outgoing())
.unwrap_or(false);
let can_delete = selected_msg
.as_ref()
.map(|m| m.can_be_deleted_only_for_self() || m.can_be_deleted_for_all_users())
.unwrap_or(false);
@@ -501,7 +504,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) {
}
/// Рендерит режим поиска по сообщениям
fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
fn render_search_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (query, results, selected_index) =
if let crate::app::ChatState::SearchInChat {
@@ -696,7 +699,7 @@ fn render_search_mode(f: &mut Frame, area: Rect, app: &App) {
}
/// Рендерит режим просмотра закреплённых сообщений
fn render_pinned_mode(f: &mut Frame, area: Rect, app: &App) {
fn render_pinned_mode<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
// Извлекаем данные из ChatState
let (messages, selected_index) = if let crate::app::ChatState::PinnedMessages {
messages,

View File

@@ -8,6 +8,7 @@ pub mod messages;
pub mod profile;
use crate::app::{App, AppScreen};
use crate::tdlib::TdClientTrait;
use ratatui::layout::Alignment;
use ratatui::style::{Color, Modifier, Style};
use ratatui::widgets::Paragraph;
@@ -18,7 +19,7 @@ const MIN_HEIGHT: u16 = 10;
/// Минимальная ширина терминала
const MIN_WIDTH: u16 = 40;
pub fn render(f: &mut Frame, app: &mut App) {
pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
let area = f.area();
// Проверяем минимальный размер терминала

View File

@@ -1,4 +1,5 @@
use crate::app::App;
use crate::tdlib::TdClientTrait;
use crate::tdlib::ProfileInfo;
use ratatui::{
layout::{Alignment, Constraint, Direction, Layout, Rect},
@@ -9,7 +10,7 @@ use ratatui::{
};
/// Рендерит режим просмотра профиля
pub fn render(f: &mut Frame, area: Rect, app: &App, profile: &ProfileInfo) {
pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>, profile: &ProfileInfo) {
// Проверяем, показывать ли модалку подтверждения
let confirmation_step = app.get_leave_group_confirmation_step();
if confirmation_step > 0 {