From e1bceada6dddb53ab24e000768a55fd8e3f7d1d3 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 20 Jan 2026 00:57:28 +0300 Subject: [PATCH] fixes --- src/app/mod.rs | 118 ++++++ src/app/state.rs | 6 + src/input/auth.rs | 101 +++++ src/input/main_input.rs | 174 +++++++++ src/input/mod.rs | 5 + src/main.rs | 843 +++------------------------------------- src/tdlib/client.rs | 90 ++++- src/ui/auth.rs | 136 +++++++ src/ui/chat_list.rs | 61 +++ src/ui/footer.rs | 30 ++ src/ui/loading.rs | 40 ++ src/ui/main_screen.rs | 62 +++ src/ui/messages.rs | 116 ++++++ src/ui/mod.rs | 17 + src/utils.rs | 47 +++ 15 files changed, 1060 insertions(+), 786 deletions(-) create mode 100644 src/app/mod.rs create mode 100644 src/app/state.rs create mode 100644 src/input/auth.rs create mode 100644 src/input/main_input.rs create mode 100644 src/input/mod.rs create mode 100644 src/ui/auth.rs create mode 100644 src/ui/chat_list.rs create mode 100644 src/ui/footer.rs create mode 100644 src/ui/loading.rs create mode 100644 src/ui/main_screen.rs create mode 100644 src/ui/messages.rs create mode 100644 src/ui/mod.rs create mode 100644 src/utils.rs diff --git a/src/app/mod.rs b/src/app/mod.rs new file mode 100644 index 0000000..f71497b --- /dev/null +++ b/src/app/mod.rs @@ -0,0 +1,118 @@ +mod state; + +pub use state::AppScreen; + +use ratatui::widgets::ListState; +use crate::tdlib::client::{ChatInfo, MessageInfo}; +use crate::tdlib::TdClient; + +pub struct App { + pub screen: AppScreen, + pub td_client: TdClient, + // Auth state + pub phone_input: String, + pub code_input: String, + pub password_input: String, + pub error_message: Option, + pub status_message: Option, + // Main app state + pub chats: Vec, + pub chat_list_state: ListState, + pub selected_chat_id: Option, + pub current_messages: Vec, + pub message_input: String, + pub message_scroll_offset: usize, + pub folders: Vec, + pub selected_folder: usize, + pub is_loading: bool, +} + +impl App { + pub fn new() -> App { + let mut state = ListState::default(); + state.select(Some(0)); + + App { + screen: AppScreen::Loading, + td_client: TdClient::new(), + phone_input: String::new(), + code_input: String::new(), + password_input: String::new(), + error_message: None, + status_message: Some("Инициализация TDLib...".to_string()), + chats: Vec::new(), + chat_list_state: state, + selected_chat_id: None, + current_messages: Vec::new(), + message_input: String::new(), + message_scroll_offset: 0, + folders: vec!["All".to_string()], + selected_folder: 0, + is_loading: true, + } + } + + pub fn next_chat(&mut self) { + if self.chats.is_empty() { + return; + } + let i = match self.chat_list_state.selected() { + Some(i) => { + if i >= self.chats.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.chat_list_state.select(Some(i)); + } + + pub fn previous_chat(&mut self) { + if self.chats.is_empty() { + return; + } + let i = match self.chat_list_state.selected() { + Some(i) => { + if i == 0 { + self.chats.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.chat_list_state.select(Some(i)); + } + + pub fn select_current_chat(&mut self) { + if let Some(i) = self.chat_list_state.selected() { + if let Some(chat) = self.chats.get(i) { + self.selected_chat_id = Some(chat.id); + } + } + } + + pub fn close_chat(&mut self) { + self.selected_chat_id = None; + self.current_messages.clear(); + self.message_input.clear(); + self.message_scroll_offset = 0; + } + + pub fn select_first_chat(&mut self) { + if !self.chats.is_empty() { + self.chat_list_state.select(Some(0)); + } + } + + pub fn get_selected_chat_id(&self) -> Option { + self.selected_chat_id + } + + pub fn get_selected_chat(&self) -> Option<&ChatInfo> { + self.selected_chat_id + .and_then(|id| self.chats.iter().find(|c| c.id == id)) + } +} diff --git a/src/app/state.rs b/src/app/state.rs new file mode 100644 index 0000000..71db9c7 --- /dev/null +++ b/src/app/state.rs @@ -0,0 +1,6 @@ +#[derive(PartialEq, Clone)] +pub enum AppScreen { + Loading, + Auth, + Main, +} diff --git a/src/input/auth.rs b/src/input/auth.rs new file mode 100644 index 0000000..d385a06 --- /dev/null +++ b/src/input/auth.rs @@ -0,0 +1,101 @@ +use crossterm::event::KeyCode; +use std::time::Duration; +use tokio::time::timeout; +use crate::app::App; +use crate::tdlib::client::AuthState; + +pub async fn handle(app: &mut App, key_code: KeyCode) { + match &app.td_client.auth_state { + AuthState::WaitPhoneNumber => match key_code { + KeyCode::Char(c) => { + app.phone_input.push(c); + app.error_message = None; + } + KeyCode::Backspace => { + app.phone_input.pop(); + app.error_message = None; + } + KeyCode::Enter => { + if !app.phone_input.is_empty() { + app.status_message = Some("Отправка номера...".to_string()); + match timeout(Duration::from_secs(10), app.td_client.send_phone_number(app.phone_input.clone())).await { + Ok(Ok(_)) => { + app.error_message = None; + 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; + } + } + } + } + _ => {} + }, + AuthState::WaitCode => match key_code { + KeyCode::Char(c) if c.is_numeric() => { + app.code_input.push(c); + app.error_message = None; + } + KeyCode::Backspace => { + app.code_input.pop(); + app.error_message = None; + } + KeyCode::Enter => { + if !app.code_input.is_empty() { + app.status_message = Some("Проверка кода...".to_string()); + match timeout(Duration::from_secs(10), app.td_client.send_code(app.code_input.clone())).await { + Ok(Ok(_)) => { + app.error_message = None; + 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; + } + } + } + } + _ => {} + }, + AuthState::WaitPassword => match key_code { + KeyCode::Char(c) => { + app.password_input.push(c); + app.error_message = None; + } + KeyCode::Backspace => { + app.password_input.pop(); + app.error_message = None; + } + KeyCode::Enter => { + if !app.password_input.is_empty() { + app.status_message = Some("Проверка пароля...".to_string()); + match timeout(Duration::from_secs(10), app.td_client.send_password(app.password_input.clone())).await { + Ok(Ok(_)) => { + app.error_message = None; + 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; + } + } + } + } + _ => {} + }, + _ => {} + } +} diff --git a/src/input/main_input.rs b/src/input/main_input.rs new file mode 100644 index 0000000..79355ee --- /dev/null +++ b/src/input/main_input.rs @@ -0,0 +1,174 @@ +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use std::time::Duration; +use tokio::time::timeout; +use crate::app::App; + +pub async fn handle(app: &mut App, key: KeyEvent) { + let has_super = key.modifiers.contains(KeyModifiers::SUPER); + let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + + // Глобальные команды (работают всегда) + match key.code { + KeyCode::Char('r') if has_ctrl => { + app.status_message = Some("Обновление чатов...".to_string()); + let _ = timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; + app.status_message = None; + return; + } + _ => {} + } + + // Cmd+j/k - навигация (работает и в списке чатов, и для скролла сообщений) + if has_super { + match key.code { + // Cmd+j - вниз (следующий чат ИЛИ скролл вниз) + KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down => { + if app.selected_chat_id.is_some() { + // В открытом чате - скролл вниз (к новым сообщениям) + if app.message_scroll_offset > 0 { + app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3); + } + } else { + // В списке чатов - следующий чат + app.next_chat(); + } + } + // Cmd+k - вверх (предыдущий чат ИЛИ скролл вверх) + KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up => { + if app.selected_chat_id.is_some() { + // В открытом чате - скролл вверх (к старым сообщениям) + app.message_scroll_offset += 3; + + // Проверяем, нужно ли подгрузить старые сообщения + if !app.current_messages.is_empty() { + let oldest_msg_id = app.current_messages.first().map(|m| m.id).unwrap_or(0); + if let Some(chat_id) = app.get_selected_chat_id() { + // Подгружаем больше сообщений если скролл близко к верху + if app.message_scroll_offset > app.current_messages.len().saturating_sub(10) { + if let Ok(Ok(older)) = timeout( + Duration::from_secs(3), + app.td_client.load_older_messages(chat_id, oldest_msg_id, 20) + ).await { + if !older.is_empty() { + // Добавляем старые сообщения в начало + let mut new_messages = older; + new_messages.extend(app.current_messages.drain(..)); + app.current_messages = new_messages; + } + } + } + } + } + } else { + // В списке чатов - предыдущий чат + app.previous_chat(); + } + } + _ => {} + } + return; + } + + // Ctrl+k - в первый чат (только в режиме списка) + if has_ctrl && matches!(key.code, KeyCode::Char('k') | KeyCode::Char('л')) { + if app.selected_chat_id.is_none() { + app.select_first_chat(); + } + return; + } + + // Enter - открыть чат или отправить сообщение + if key.code == KeyCode::Enter { + if app.selected_chat_id.is_some() { + // Отправка сообщения + if !app.message_input.is_empty() { + if let Some(chat_id) = app.get_selected_chat_id() { + let text = app.message_input.clone(); + app.message_input.clear(); + + match timeout(Duration::from_secs(5), app.td_client.send_message(chat_id, text.clone())).await { + Ok(Ok(sent_msg)) => { + // Добавляем отправленное сообщение в список + app.current_messages.push(sent_msg); + // Сбрасываем скролл чтобы видеть новое сообщение + app.message_scroll_offset = 0; + } + Ok(Err(e)) => { + app.error_message = Some(e); + } + Err(_) => { + app.error_message = Some("Таймаут отправки".to_string()); + } + } + } + } + } else { + // Открываем чат + let prev_selected = app.selected_chat_id; + app.select_current_chat(); + + if app.selected_chat_id != prev_selected { + if let Some(chat_id) = app.get_selected_chat_id() { + app.status_message = Some("Загрузка сообщений...".to_string()); + app.message_scroll_offset = 0; + match timeout(Duration::from_secs(5), app.td_client.get_chat_history(chat_id, 50)).await { + Ok(Ok(messages)) => { + app.current_messages = 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; + } + + // Esc - закрыть чат + if key.code == KeyCode::Esc { + if app.selected_chat_id.is_some() { + app.close_chat(); + } + return; + } + + // Ввод текста в режиме открытого чата + if app.selected_chat_id.is_some() { + match key.code { + KeyCode::Backspace => { + app.message_input.pop(); + } + KeyCode::Char(c) => { + app.message_input.push(c); + } + _ => {} + } + } else { + // В режиме списка чатов - навигация j/k и переключение папок + match key.code { + // j или д - следующий чат + KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down => { + app.next_chat(); + } + // k или л - предыдущий чат + KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up => { + app.previous_chat(); + } + // Цифры - переключение папок + KeyCode::Char(c) if c >= '1' && c <= '9' => { + let folder_idx = (c as usize) - ('1' as usize); + if folder_idx < app.folders.len() { + app.selected_folder = folder_idx; + } + } + _ => {} + } + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs new file mode 100644 index 0000000..b7d31ea --- /dev/null +++ b/src/input/mod.rs @@ -0,0 +1,5 @@ +mod auth; +mod main_input; + +pub use auth::handle as handle_auth_input; +pub use main_input::handle as handle_main_input; diff --git a/src/main.rs b/src/main.rs index 98a6019..1786299 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,160 +1,23 @@ +mod app; +mod input; mod tdlib; +mod ui; +mod utils; use crossterm::{ event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers}, execute, terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, }; -use ratatui::{ - backend::CrosstermBackend, - layout::{Alignment, Constraint, Direction, Layout, Rect}, - style::{Color, Modifier, Style}, - text::{Line, Span}, - widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, - Frame, Terminal, -}; -use std::ffi::CString; +use ratatui::{backend::CrosstermBackend, Terminal}; use std::io; -use std::os::raw::c_char; -use std::sync::mpsc; use std::time::Duration; -use tdlib::client::{AuthState, ChatInfo, MessageInfo}; -use tdlib::TdClient; use tdlib_rs::enums::Update; -// FFI для синхронного вызова TDLib (отключение логов до создания клиента) -#[link(name = "tdjson")] -extern "C" { - fn td_execute(request: *const c_char) -> *const c_char; -} - -/// Отключаем логи TDLib синхронно, до создания клиента -fn disable_tdlib_logs() { - let request = r#"{"@type":"setLogVerbosityLevel","new_verbosity_level":0}"#; - let c_request = CString::new(request).unwrap(); - unsafe { - let _ = td_execute(c_request.as_ptr()); - } - - // Также перенаправляем логи в никуда - let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#; - let c_request2 = CString::new(request2).unwrap(); - unsafe { - let _ = td_execute(c_request2.as_ptr()); - } -} - -#[derive(PartialEq, Clone)] -enum AppScreen { - Loading, - Auth, - Main, -} - -struct App { - screen: AppScreen, - td_client: TdClient, - // Auth state - phone_input: String, - code_input: String, - password_input: String, - error_message: Option, - status_message: Option, - // Main app state - chats: Vec, - chat_list_state: ListState, - selected_chat: Option, - current_messages: Vec, - message_input: String, // Input for new message - folders: Vec, - selected_folder: usize, - is_loading: bool, -} - -impl App { - fn new() -> App { - let mut state = ListState::default(); - state.select(Some(0)); - - App { - screen: AppScreen::Loading, - td_client: TdClient::new(), - phone_input: String::new(), - code_input: String::new(), - password_input: String::new(), - error_message: None, - status_message: Some("Инициализация TDLib...".to_string()), - chats: Vec::new(), - chat_list_state: state, - selected_chat: None, - current_messages: Vec::new(), - message_input: String::new(), - folders: vec!["All".to_string()], - selected_folder: 0, - is_loading: true, - } - } - - fn next_chat(&mut self) { - if self.chats.is_empty() { - return; - } - let i = match self.chat_list_state.selected() { - Some(i) => { - if i >= self.chats.len() - 1 { - 0 - } else { - i + 1 - } - } - None => 0, - }; - self.chat_list_state.select(Some(i)); - } - - fn previous_chat(&mut self) { - if self.chats.is_empty() { - return; - } - let i = match self.chat_list_state.selected() { - Some(i) => { - if i == 0 { - self.chats.len() - 1 - } else { - i - 1 - } - } - None => 0, - }; - self.chat_list_state.select(Some(i)); - } - - fn select_current_chat(&mut self) { - if let Some(i) = self.chat_list_state.selected() { - if i < self.chats.len() { - self.selected_chat = Some(i); - } - } - } - - fn close_chat(&mut self) { - self.selected_chat = None; - self.current_messages.clear(); - self.message_input.clear(); - } - - fn select_first_chat(&mut self) { - if !self.chats.is_empty() { - self.chat_list_state.select(Some(0)); - } - } - - fn get_selected_chat_id(&self) -> Option { - self.selected_chat - .and_then(|idx| self.chats.get(idx)) - .map(|chat| chat.id) - } -} +use app::{App, AppScreen}; +use input::{handle_auth_input, handle_main_input}; +use tdlib::client::AuthState; +use utils::disable_tdlib_logs; #[tokio::main] async fn main() -> Result<(), io::Error> { @@ -164,18 +27,6 @@ async fn main() -> Result<(), io::Error> { // Отключаем логи TDLib ДО создания клиента disable_tdlib_logs(); - // Запускаем поток для получения updates от TDLib - let (update_tx, update_rx) = mpsc::channel::(); - std::thread::spawn(move || { - loop { - if let Some((update, _client_id)) = tdlib_rs::receive() { - if update_tx.send(update).is_err() { - break; // Канал закрыт, выходим - } - } - } - }); - // Setup terminal enable_raw_mode()?; let mut stdout = io::stdout(); @@ -185,7 +36,7 @@ async fn main() -> Result<(), io::Error> { // Create app state let mut app = App::new(); - let res = run_app(&mut terminal, &mut app, update_rx).await; + let res = run_app(&mut terminal, &mut app).await; // Restore terminal disable_raw_mode()?; @@ -206,15 +57,49 @@ async fn main() -> Result<(), io::Error> { async fn run_app( terminal: &mut Terminal, app: &mut App, - update_rx: mpsc::Receiver, ) -> io::Result<()> { - // Инициализируем TDLib - if let Err(e) = app.td_client.init().await { - app.error_message = Some(e); - } + // Канал для передачи updates из polling задачи в main loop + let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::(); + + // Запускаем polling TDLib receive() в отдельной задаче + tokio::spawn(async move { + loop { + // receive() блокирующий, поэтому запускаем в blocking thread + let result = tokio::task::spawn_blocking(|| tdlib_rs::receive()).await; + if let Ok(Some((update, _client_id))) = result { + let _ = update_tx.send(update); + } + } + }); + + // Запускаем инициализацию 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 + "tdlib_data".to_string(), // database_directory + "".to_string(), // files_directory + "".to_string(), // database_encryption_key + true, // use_file_database + true, // use_chat_info_database + true, // use_message_database + false, // use_secret_chats + api_id, + api_hash, + "en".to_string(), // system_language_code + "Desktop".to_string(), // device_model + "".to_string(), // system_version + env!("CARGO_PKG_VERSION").to_string(), // application_version + client_id, + ) + .await; + }); loop { - // Обрабатываем все доступные обновления от TDLib (неблокирующе) + // Обрабатываем updates от TDLib из канала (неблокирующе) while let Ok(update) = update_rx.try_recv() { app.td_client.handle_update(update); } @@ -222,7 +107,7 @@ async fn run_app( // Обновляем состояние экрана на основе auth_state update_screen_state(app).await; - terminal.draw(|f| ui(f, app))?; + terminal.draw(|f| ui::render(f, app))?; // Используем poll для неблокирующего чтения событий if event::poll(Duration::from_millis(100))? { @@ -245,6 +130,8 @@ async fn run_app( } async fn update_screen_state(app: &mut App) { + use tokio::time::timeout; + let prev_screen = app.screen.clone(); match &app.td_client.auth_state { @@ -262,20 +149,21 @@ async fn update_screen_state(app: &mut App) { app.is_loading = true; app.status_message = Some("Загрузка чатов...".to_string()); - // Запрашиваем загрузку чатов (они придут через updates) - if let Err(e) = app.td_client.load_chats(50).await { - app.error_message = Some(e); - } - app.is_loading = false; - app.status_message = None; + // Запрашиваем загрузку чатов с таймаутом + let _ = timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await; } // Синхронизируем чаты из td_client в app - if !app.td_client.chats.is_empty() && app.chats.len() != app.td_client.chats.len() { + if !app.td_client.chats.is_empty() { app.chats = app.td_client.chats.clone(); if app.chat_list_state.selected().is_none() && !app.chats.is_empty() { app.chat_list_state.select(Some(0)); } + // Убираем статус загрузки когда чаты появились + if app.is_loading { + app.is_loading = false; + app.status_message = None; + } } } AuthState::Closed => { @@ -286,612 +174,3 @@ async fn update_screen_state(app: &mut App) { } } } - -async fn handle_auth_input(app: &mut App, key_code: KeyCode) { - match &app.td_client.auth_state { - AuthState::WaitPhoneNumber => match key_code { - KeyCode::Char(c) => { - app.phone_input.push(c); - app.error_message = None; - } - KeyCode::Backspace => { - app.phone_input.pop(); - app.error_message = None; - } - KeyCode::Enter => { - if !app.phone_input.is_empty() { - app.status_message = Some("Отправка номера...".to_string()); - match app.td_client.send_phone_number(app.phone_input.clone()).await { - Ok(_) => { - app.error_message = None; - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } - } - } - _ => {} - }, - AuthState::WaitCode => match key_code { - KeyCode::Char(c) if c.is_numeric() => { - app.code_input.push(c); - app.error_message = None; - } - KeyCode::Backspace => { - app.code_input.pop(); - app.error_message = None; - } - KeyCode::Enter => { - if !app.code_input.is_empty() { - app.status_message = Some("Проверка кода...".to_string()); - match app.td_client.send_code(app.code_input.clone()).await { - Ok(_) => { - app.error_message = None; - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } - } - } - _ => {} - }, - AuthState::WaitPassword => match key_code { - KeyCode::Char(c) => { - app.password_input.push(c); - app.error_message = None; - } - KeyCode::Backspace => { - app.password_input.pop(); - app.error_message = None; - } - KeyCode::Enter => { - if !app.password_input.is_empty() { - app.status_message = Some("Проверка пароля...".to_string()); - match app.td_client.send_password(app.password_input.clone()).await { - Ok(_) => { - app.error_message = None; - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } - } - } - _ => {} - }, - _ => {} - } -} - -async fn handle_main_input(app: &mut App, key: event::KeyEvent) { - let has_super = key.modifiers.contains(KeyModifiers::SUPER); - let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); - - // Если чат открыт - режим ввода сообщения - if app.selected_chat.is_some() { - match key.code { - KeyCode::Esc => { - app.close_chat(); - } - KeyCode::Char('r') if has_ctrl => { - // Обновить список чатов работает и в режиме чата - app.status_message = Some("Обновление чатов...".to_string()); - if let Err(e) = app.td_client.load_chats(50).await { - app.error_message = Some(e); - } - app.status_message = None; - } - KeyCode::Backspace => { - app.message_input.pop(); - } - KeyCode::Enter => { - // TODO: отправка сообщения - // Пока просто очищаем инпут - if !app.message_input.is_empty() { - app.message_input.clear(); - } - } - KeyCode::Char(c) => { - // Вводим символы в инпут сообщения - app.message_input.push(c); - } - _ => {} - } - } else { - // Режим навигации по списку чатов - match key.code { - // Navigate down: j, Down, д (Russian) - KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down if !has_super && !has_ctrl => { - app.next_chat(); - } - // Navigate up: k, Up, л (Russian) - KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up if !has_super && !has_ctrl => { - app.previous_chat(); - } - // Jump to first chat: Cmd+Up or Ctrl+k/л - KeyCode::Up if has_super => { - app.select_first_chat(); - } - KeyCode::Char('k') | KeyCode::Char('л') if has_ctrl => { - app.select_first_chat(); - } - KeyCode::Enter => { - let prev_selected = app.selected_chat; - app.select_current_chat(); - - // Если выбрали новый чат, загружаем историю - if app.selected_chat != prev_selected { - if let Some(chat_id) = app.get_selected_chat_id() { - app.status_message = Some("Загрузка сообщений...".to_string()); - match app.td_client.get_chat_history(chat_id, 30).await { - Ok(messages) => { - app.current_messages = messages; - app.status_message = None; - } - Err(e) => { - app.error_message = Some(e); - app.status_message = None; - } - } - } - } - } - KeyCode::Esc => { - // Ничего не делаем, чат и так не открыт - } - KeyCode::Char('r') if has_ctrl => { - // Обновить список чатов - app.status_message = Some("Обновление чатов...".to_string()); - if let Err(e) = app.td_client.load_chats(50).await { - app.error_message = Some(e); - } - app.status_message = None; - } - KeyCode::Char(c) if c >= '1' && c <= '9' => { - let folder_idx = (c as usize) - ('1' as usize); - if folder_idx < app.folders.len() { - app.selected_folder = folder_idx; - } - } - _ => {} - } - } -} - -fn ui(f: &mut Frame, app: &mut App) { - match app.screen { - AppScreen::Loading => render_loading_screen(f, app), - AppScreen::Auth => render_auth_screen(f, app), - AppScreen::Main => render_main_screen(f, app), - } -} - -fn render_loading_screen(f: &mut Frame, app: &App) { - let area = f.area(); - - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(40), - Constraint::Length(5), - Constraint::Percentage(40), - ]) - .split(area); - - let message = app - .status_message - .as_deref() - .unwrap_or("Загрузка..."); - - let loading = Paragraph::new(message) - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .title(" TTUI "), - ); - - f.render_widget(loading, chunks[1]); -} - -fn render_auth_screen(f: &mut Frame, app: &App) { - let area = f.area(); - - let vertical_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage(30), - Constraint::Length(15), - Constraint::Percentage(30), - ]) - .split(area); - - let horizontal_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(25), - Constraint::Percentage(50), - Constraint::Percentage(25), - ]) - .split(vertical_chunks[1]); - - let auth_area = horizontal_chunks[1]; - - let auth_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Title - Constraint::Length(4), // Instructions - Constraint::Length(3), // Input - Constraint::Length(2), // Error/Status message - Constraint::Min(0), // Spacer - ]) - .split(auth_area); - - // Title - let title = Paragraph::new("TTUI - Telegram Authentication") - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL)); - f.render_widget(title, auth_chunks[0]); - - // Instructions and Input based on auth state - match &app.td_client.auth_state { - AuthState::WaitPhoneNumber => { - let instructions = vec![ - Line::from("Введите номер телефона в международном формате"), - Line::from("Пример: +79991111111"), - ]; - let instructions_widget = Paragraph::new(instructions) - .style(Style::default().fg(Color::Gray)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::NONE)); - f.render_widget(instructions_widget, auth_chunks[1]); - - let input_text = format!("📱 {}", app.phone_input); - let input = Paragraph::new(input_text) - .style(Style::default().fg(Color::Yellow)) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Phone Number "), - ); - f.render_widget(input, auth_chunks[2]); - } - AuthState::WaitCode => { - let instructions = vec![ - Line::from("Введите код подтверждения из Telegram"), - Line::from("Код был отправлен на ваш номер"), - ]; - let instructions_widget = Paragraph::new(instructions) - .style(Style::default().fg(Color::Gray)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::NONE)); - f.render_widget(instructions_widget, auth_chunks[1]); - - let input_text = format!("🔐 {}", app.code_input); - let input = Paragraph::new(input_text) - .style(Style::default().fg(Color::Yellow)) - .alignment(Alignment::Center) - .block( - Block::default() - .borders(Borders::ALL) - .title(" Verification Code "), - ); - f.render_widget(input, auth_chunks[2]); - } - AuthState::WaitPassword => { - let instructions = vec![ - Line::from("Введите пароль двухфакторной аутентификации"), - Line::from(""), - ]; - let instructions_widget = Paragraph::new(instructions) - .style(Style::default().fg(Color::Gray)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::NONE)); - f.render_widget(instructions_widget, auth_chunks[1]); - - let masked_password = "*".repeat(app.password_input.len()); - let input_text = format!("🔒 {}", masked_password); - let input = Paragraph::new(input_text) - .style(Style::default().fg(Color::Yellow)) - .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).title(" Password ")); - f.render_widget(input, auth_chunks[2]); - } - _ => {} - } - - // Error or status message - if let Some(error) = &app.error_message { - let error_widget = Paragraph::new(error.as_str()) - .style(Style::default().fg(Color::Red)) - .alignment(Alignment::Center); - f.render_widget(error_widget, auth_chunks[3]); - } else if let Some(status) = &app.status_message { - let status_widget = Paragraph::new(status.as_str()) - .style(Style::default().fg(Color::Yellow)) - .alignment(Alignment::Center); - f.render_widget(status_widget, auth_chunks[3]); - } -} - -fn render_main_screen(f: &mut Frame, app: &mut App) { - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Folders/tabs - Constraint::Min(0), // Main content - Constraint::Length(1), // Commands footer - ]) - .split(f.area()); - - render_folders(f, chunks[0], app); - - let main_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage(30), // Chat list - Constraint::Percentage(70), // Messages area - ]) - .split(chunks[1]); - - render_chat_list(f, main_chunks[0], app); - render_messages(f, main_chunks[1], app); - render_footer(f, chunks[2], app); -} - -fn render_folders(f: &mut Frame, area: Rect, app: &App) { - let mut spans = vec![]; - - for (i, folder) in app.folders.iter().enumerate() { - let style = if i == app.selected_folder { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - spans.push(Span::styled(format!(" {}:{} ", i + 1, folder), style)); - if i < app.folders.len() - 1 { - spans.push(Span::raw("│")); - } - } - - let folders_line = Line::from(spans); - let folders_widget = Paragraph::new(folders_line).block( - Block::default() - .title(" TTUI ") - .borders(Borders::ALL), - ); - - f.render_widget(folders_widget, area); -} - -fn render_chat_list(f: &mut Frame, area: Rect, app: &mut App) { - let chat_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Search box - Constraint::Min(0), // Chat list - Constraint::Length(3), // User status - ]) - .split(area); - - // Search box - let search = Paragraph::new("🔍 Search...") - .block(Block::default().borders(Borders::ALL)) - .style(Style::default().fg(Color::DarkGray)); - f.render_widget(search, chat_chunks[0]); - - // Chat list - let items: Vec = app - .chats - .iter() - .enumerate() - .map(|(idx, chat)| { - let is_selected = app.selected_chat == Some(idx); - let prefix = if is_selected { "▌ " } else { " " }; - - let unread_badge = if chat.unread_count > 0 { - format!(" ({})", chat.unread_count) - } else { - String::new() - }; - - let content = format!("{}{}{}", prefix, chat.title, unread_badge); - let style = Style::default().fg(Color::White); - - ListItem::new(content).style(style) - }) - .collect(); - - let chats_list = List::new(items) - .block(Block::default().borders(Borders::ALL)) - .highlight_style( - Style::default() - .add_modifier(Modifier::ITALIC) - .fg(Color::Yellow), - ); - - f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state); - - // User status - let status = Paragraph::new("[User: Online]") - .block(Block::default().borders(Borders::ALL)) - .style(Style::default().fg(Color::Green)); - f.render_widget(status, chat_chunks[2]); -} - -fn render_messages(f: &mut Frame, area: Rect, app: &App) { - if let Some(chat_idx) = app.selected_chat { - if let Some(chat) = app.chats.get(chat_idx) { - let message_chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(3), // Chat header - Constraint::Min(0), // Messages - Constraint::Length(3), // Input box - ]) - .split(area); - - // Chat header - let header = Paragraph::new(format!("👤 {}", chat.title)) - .block(Block::default().borders(Borders::ALL)) - .style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ); - f.render_widget(header, message_chunks[0]); - - // Messages - let mut lines: Vec = Vec::new(); - - for msg in &app.current_messages { - let sender_style = if msg.is_outgoing { - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - }; - - let sender_name = if msg.is_outgoing { - "You".to_string() - } else { - msg.sender_name.clone() - }; - - let read_mark = if msg.is_outgoing { - if msg.is_read { " ✓✓" } else { " ✓" } - } else { - "" - }; - - // Форматируем время - let time = format_timestamp(msg.date); - - lines.push(Line::from(vec![ - Span::styled(format!("{} ", sender_name), sender_style), - Span::raw("── "), - Span::styled(format!("{}{}", time, read_mark), Style::default().fg(Color::DarkGray)), - ])); - lines.push(Line::from(msg.content.clone())); - lines.push(Line::from("")); - } - - if lines.is_empty() { - lines.push(Line::from(Span::styled( - "Нет сообщений", - Style::default().fg(Color::DarkGray), - ))); - } - - // Вычисляем скролл, чтобы показать последние сообщения - let visible_height = message_chunks[1].height.saturating_sub(2) as usize; // -2 для borders - let total_lines = lines.len(); - let scroll_offset = if total_lines > visible_height { - (total_lines - visible_height) as u16 - } else { - 0 - }; - - let messages_widget = Paragraph::new(lines) - .block(Block::default().borders(Borders::ALL)) - .scroll((scroll_offset, 0)); - f.render_widget(messages_widget, message_chunks[1]); - - // Input box - let input_text = if app.message_input.is_empty() { - "> Введите сообщение...".to_string() - } else { - format!("> {}", app.message_input) - }; - let input_style = if app.message_input.is_empty() { - Style::default().fg(Color::DarkGray) - } else { - Style::default().fg(Color::Yellow) - }; - let input = Paragraph::new(input_text) - .block(Block::default().borders(Borders::ALL)) - .style(input_style); - f.render_widget(input, message_chunks[2]); - } - } else { - let empty = Paragraph::new("Выберите чат").block(Block::default().borders(Borders::ALL)) - .style(Style::default().fg(Color::DarkGray)) - .alignment(Alignment::Center); - f.render_widget(empty, area); - } -} - -fn render_footer(f: &mut Frame, area: Rect, app: &App) { - let status = if let Some(msg) = &app.status_message { - format!(" {} ", msg) - } else if let Some(err) = &app.error_message { - format!(" Error: {} ", err) - } else if app.selected_chat.is_some() { - // Режим ввода сообщения - " Enter: Send | Esc: Close chat | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string() - } else { - // Режим навигации - " j/k: Navigate | Ctrl+k: First | Enter: Open | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string() - }; - - let style = if app.error_message.is_some() { - Style::default().fg(Color::Red) - } else if app.status_message.is_some() { - Style::default().fg(Color::Yellow) - } else { - Style::default().fg(Color::DarkGray) - }; - - let footer = Paragraph::new(status).style(style); - f.render_widget(footer, area); -} - -fn format_timestamp(timestamp: i32) -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i32; - - let diff = now - timestamp; - - if diff < 60 { - "just now".to_string() - } else if diff < 3600 { - format!("{}m ago", diff / 60) - } else if diff < 86400 { - format!("{}h ago", diff / 3600) - } else { - // Показываем дату - let secs = timestamp as u64; - let days = secs / 86400; - format!("{}d ago", (now as u64 / 86400) - days) - } -} diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 857c3d1..ca253f5 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -4,6 +4,7 @@ use tdlib_rs::functions; use tdlib_rs::types::{Chat as TdChat, Message as TdMessage}; #[derive(Debug, Clone, PartialEq)] +#[allow(dead_code)] pub enum AuthState { WaitTdlibParameters, WaitPhoneNumber, @@ -15,6 +16,7 @@ pub enum AuthState { } #[derive(Debug, Clone)] +#[allow(dead_code)] pub struct ChatInfo { pub id: i64, pub title: String, @@ -44,6 +46,7 @@ pub struct TdClient { pub current_chat_messages: Vec, } +#[allow(dead_code)] impl TdClient { pub fn new() -> Self { let api_id: i32 = env::var("API_ID") @@ -68,13 +71,17 @@ impl TdClient { matches!(self.auth_state, AuthState::Ready) } + pub fn client_id(&self) -> i32 { + self.client_id + } + /// Инициализация TDLib с параметрами pub async fn init(&mut self) -> Result<(), String> { let result = functions::set_tdlib_parameters( false, // use_test_dc "tdlib_data".to_string(), // database_directory "".to_string(), // files_directory - "".to_string(), // database_encryption_key (String, not Vec) + "".to_string(), // database_encryption_key true, // use_file_database true, // use_chat_info_database true, // use_message_database @@ -248,12 +255,13 @@ impl TdClient { ) -> Result, String> { let _ = functions::open_chat(chat_id, self.client_id).await; + // Загружаем историю с сервера (only_local=false) let result = functions::get_chat_history( chat_id, - 0, - 0, + 0, // from_message_id (0 = с последнего сообщения) + 0, // offset limit, - false, + false, // only_local - загружаем с сервера! self.client_id, ) .await; @@ -266,6 +274,7 @@ impl TdClient { .filter_map(|m| m.map(|msg| self.convert_message(&msg))) .collect(); + // Сообщения приходят от новых к старым, переворачиваем result_messages.reverse(); self.current_chat_messages = result_messages.clone(); Ok(result_messages) @@ -274,6 +283,39 @@ impl TdClient { } } + /// Загрузка старых сообщений (для скролла вверх) + pub async fn load_older_messages( + &mut self, + chat_id: i64, + from_message_id: i64, + limit: i32, + ) -> Result, String> { + let result = functions::get_chat_history( + chat_id, + from_message_id, + 0, // offset + limit, + false, // only_local + self.client_id, + ) + .await; + + match result { + Ok(tdlib_rs::enums::Messages::Messages(messages)) => { + let mut result_messages: Vec = messages + .messages + .into_iter() + .filter_map(|m| m.map(|msg| self.convert_message(&msg))) + .collect(); + + // Сообщения приходят от новых к старым, переворачиваем + result_messages.reverse(); + Ok(result_messages) + } + Err(e) => Err(format!("Ошибка загрузки сообщений: {:?}", e)), + } + } + /// Получение информации о пользователе по ID pub async fn get_user_name(&self, user_id: i64) -> String { match functions::get_user(user_id, self.client_id).await { @@ -306,6 +348,46 @@ impl TdClient { Err(e) => Err(format!("Ошибка получения профиля: {:?}", e)), } } + + /// Отправка текстового сообщения + pub async fn send_message(&self, chat_id: i64, text: String) -> Result { + use tdlib_rs::types::{FormattedText, InputMessageText}; + use tdlib_rs::enums::InputMessageContent; + + let content = InputMessageContent::InputMessageText(InputMessageText { + text: FormattedText { + text: text.clone(), + entities: vec![], + }, + link_preview_options: None, + clear_draft: true, + }); + + let result = functions::send_message( + chat_id, + 0, // message_thread_id + None, // reply_to + None, // options + content, + self.client_id, + ) + .await; + + match result { + Ok(tdlib_rs::enums::Message::Message(msg)) => { + // Конвертируем отправленное сообщение в MessageInfo + Ok(MessageInfo { + id: msg.id, + sender_name: "You".to_string(), + is_outgoing: true, + content: text, + date: msg.date, + is_read: false, + }) + } + Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)), + } + } } /// Статическая функция для извлечения текста сообщения (без &self) diff --git a/src/ui/auth.rs b/src/ui/auth.rs new file mode 100644 index 0000000..a6fadb4 --- /dev/null +++ b/src/ui/auth.rs @@ -0,0 +1,136 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::Line, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use crate::app::App; +use crate::tdlib::client::AuthState; + +pub fn render(f: &mut Frame, app: &App) { + let area = f.area(); + + let vertical_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(30), + Constraint::Length(15), + Constraint::Percentage(30), + ]) + .split(area); + + let horizontal_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(50), + Constraint::Percentage(25), + ]) + .split(vertical_chunks[1]); + + let auth_area = horizontal_chunks[1]; + + let auth_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Title + Constraint::Length(4), // Instructions + Constraint::Length(3), // Input + Constraint::Length(2), // Error/Status message + Constraint::Min(0), // Spacer + ]) + .split(auth_area); + + // Title + let title = Paragraph::new("TTUI - Telegram Authentication") + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL)); + f.render_widget(title, auth_chunks[0]); + + // Instructions and Input based on auth state + match &app.td_client.auth_state { + AuthState::WaitPhoneNumber => { + let instructions = vec![ + Line::from("Введите номер телефона в международном формате"), + Line::from("Пример: +79991111111"), + ]; + let instructions_widget = Paragraph::new(instructions) + .style(Style::default().fg(Color::Gray)) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::NONE)); + f.render_widget(instructions_widget, auth_chunks[1]); + + let input_text = format!("📱 {}", app.phone_input); + let input = Paragraph::new(input_text) + .style(Style::default().fg(Color::Yellow)) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Phone Number "), + ); + f.render_widget(input, auth_chunks[2]); + } + AuthState::WaitCode => { + let instructions = vec![ + Line::from("Введите код подтверждения из Telegram"), + Line::from("Код был отправлен на ваш номер"), + ]; + let instructions_widget = Paragraph::new(instructions) + .style(Style::default().fg(Color::Gray)) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::NONE)); + f.render_widget(instructions_widget, auth_chunks[1]); + + let input_text = format!("🔐 {}", app.code_input); + let input = Paragraph::new(input_text) + .style(Style::default().fg(Color::Yellow)) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Verification Code "), + ); + f.render_widget(input, auth_chunks[2]); + } + AuthState::WaitPassword => { + let instructions = vec![ + Line::from("Введите пароль двухфакторной аутентификации"), + Line::from(""), + ]; + let instructions_widget = Paragraph::new(instructions) + .style(Style::default().fg(Color::Gray)) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::NONE)); + f.render_widget(instructions_widget, auth_chunks[1]); + + let masked_password = "*".repeat(app.password_input.len()); + let input_text = format!("🔒 {}", masked_password); + let input = Paragraph::new(input_text) + .style(Style::default().fg(Color::Yellow)) + .alignment(Alignment::Center) + .block(Block::default().borders(Borders::ALL).title(" Password ")); + f.render_widget(input, auth_chunks[2]); + } + _ => {} + } + + // Error or status message + if let Some(error) = &app.error_message { + let error_widget = Paragraph::new(error.as_str()) + .style(Style::default().fg(Color::Red)) + .alignment(Alignment::Center); + f.render_widget(error_widget, auth_chunks[3]); + } else if let Some(status) = &app.status_message { + let status_widget = Paragraph::new(status.as_str()) + .style(Style::default().fg(Color::Yellow)) + .alignment(Alignment::Center); + f.render_widget(status_widget, auth_chunks[3]); + } +} diff --git a/src/ui/chat_list.rs b/src/ui/chat_list.rs new file mode 100644 index 0000000..64efba0 --- /dev/null +++ b/src/ui/chat_list.rs @@ -0,0 +1,61 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, List, ListItem, Paragraph}, + Frame, +}; +use crate::app::App; + +pub fn render(f: &mut Frame, area: Rect, app: &mut App) { + let chat_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Search box + Constraint::Min(0), // Chat list + Constraint::Length(3), // User status + ]) + .split(area); + + // Search box + let search = Paragraph::new("🔍 Search...") + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::DarkGray)); + f.render_widget(search, chat_chunks[0]); + + // Chat list + let items: Vec = app + .chats + .iter() + .map(|chat| { + let is_selected = app.selected_chat_id == Some(chat.id); + let prefix = if is_selected { "▌ " } else { " " }; + + let unread_badge = if chat.unread_count > 0 { + format!(" ({})", chat.unread_count) + } else { + String::new() + }; + + let content = format!("{}{}{}", prefix, chat.title, unread_badge); + let style = Style::default().fg(Color::White); + + ListItem::new(content).style(style) + }) + .collect(); + + let chats_list = List::new(items) + .block(Block::default().borders(Borders::ALL)) + .highlight_style( + Style::default() + .add_modifier(Modifier::ITALIC) + .fg(Color::Yellow), + ); + + f.render_stateful_widget(chats_list, chat_chunks[1], &mut app.chat_list_state); + + // User status + let status = Paragraph::new("[User: Online]") + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::Green)); + f.render_widget(status, chat_chunks[2]); +} diff --git a/src/ui/footer.rs b/src/ui/footer.rs new file mode 100644 index 0000000..2638232 --- /dev/null +++ b/src/ui/footer.rs @@ -0,0 +1,30 @@ +use ratatui::{ + layout::Rect, + style::{Color, Style}, + widgets::Paragraph, + Frame, +}; +use crate::app::App; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + let status = if let Some(msg) = &app.status_message { + format!(" {} ", msg) + } else if let Some(err) = &app.error_message { + format!(" Error: {} ", err) + } else if app.selected_chat_id.is_some() { + " Cmd+j/k: Scroll | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string() + } else { + " Cmd+j/k: Navigate | Ctrl+k: First | Enter: Open | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string() + }; + + let style = if app.error_message.is_some() { + Style::default().fg(Color::Red) + } else if app.status_message.is_some() { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + + let footer = Paragraph::new(status).style(style); + f.render_widget(footer, area); +} diff --git a/src/ui/loading.rs b/src/ui/loading.rs new file mode 100644 index 0000000..bb1a64b --- /dev/null +++ b/src/ui/loading.rs @@ -0,0 +1,40 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use crate::app::App; + +pub fn render(f: &mut Frame, app: &App) { + let area = f.area(); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(40), + Constraint::Length(5), + Constraint::Percentage(40), + ]) + .split(area); + + let message = app + .status_message + .as_deref() + .unwrap_or("Загрузка..."); + + let loading = Paragraph::new(message) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .title(" TTUI "), + ); + + f.render_widget(loading, chunks[1]); +} diff --git a/src/ui/main_screen.rs b/src/ui/main_screen.rs new file mode 100644 index 0000000..85bb007 --- /dev/null +++ b/src/ui/main_screen.rs @@ -0,0 +1,62 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use crate::app::App; +use super::{chat_list, messages, footer}; + +pub fn render(f: &mut Frame, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Folders/tabs + Constraint::Min(0), // Main content + Constraint::Length(1), // Commands footer + ]) + .split(f.area()); + + render_folders(f, chunks[0], app); + + let main_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(30), // Chat list + Constraint::Percentage(70), // Messages area + ]) + .split(chunks[1]); + + chat_list::render(f, main_chunks[0], app); + messages::render(f, main_chunks[1], app); + footer::render(f, chunks[2], app); +} + +fn render_folders(f: &mut Frame, area: Rect, app: &App) { + let mut spans = vec![]; + + for (i, folder) in app.folders.iter().enumerate() { + let style = if i == app.selected_folder { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::White) + }; + + spans.push(Span::styled(format!(" {}:{} ", i + 1, folder), style)); + if i < app.folders.len() - 1 { + spans.push(Span::raw("│")); + } + } + + let folders_line = Line::from(spans); + let folders_widget = Paragraph::new(folders_line).block( + Block::default() + .title(" TTUI ") + .borders(Borders::ALL), + ); + + f.render_widget(folders_widget, area); +} diff --git a/src/ui/messages.rs b/src/ui/messages.rs new file mode 100644 index 0000000..4f9f07e --- /dev/null +++ b/src/ui/messages.rs @@ -0,0 +1,116 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; +use crate::app::App; +use crate::utils::format_timestamp; + +pub fn render(f: &mut Frame, area: Rect, app: &App) { + if let Some(chat) = app.get_selected_chat() { + let message_chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Chat header + Constraint::Min(0), // Messages + Constraint::Length(3), // Input box + ]) + .split(area); + + // Chat header + let header = Paragraph::new(format!("👤 {}", chat.title)) + .block(Block::default().borders(Borders::ALL)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ); + f.render_widget(header, message_chunks[0]); + + // Messages + let mut lines: Vec = Vec::new(); + + for msg in &app.current_messages { + let sender_style = if msg.is_outgoing { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + }; + + let sender_name = if msg.is_outgoing { + "You".to_string() + } else { + msg.sender_name.clone() + }; + + let read_mark = if msg.is_outgoing { + if msg.is_read { " ✓✓" } else { " ✓" } + } else { + "" + }; + + // Форматируем время + let time = format_timestamp(msg.date); + + lines.push(Line::from(vec![ + Span::styled(format!("{} ", sender_name), sender_style), + Span::raw("── "), + Span::styled(format!("{}{}", time, read_mark), Style::default().fg(Color::DarkGray)), + ])); + lines.push(Line::from(msg.content.clone())); + lines.push(Line::from("")); + } + + if lines.is_empty() { + lines.push(Line::from(Span::styled( + "Нет сообщений", + Style::default().fg(Color::DarkGray), + ))); + } + + // Вычисляем скролл с учётом пользовательского offset + let visible_height = message_chunks[1].height.saturating_sub(2) as usize; + let total_lines = lines.len(); + + let base_scroll = if total_lines > visible_height { + total_lines - visible_height + } else { + 0 + }; + + let scroll_offset = base_scroll.saturating_sub(app.message_scroll_offset) as u16; + + let messages_widget = Paragraph::new(lines) + .block(Block::default().borders(Borders::ALL)) + .scroll((scroll_offset, 0)); + f.render_widget(messages_widget, message_chunks[1]); + + // Input box + let input_text = if app.message_input.is_empty() { + "> Введите сообщение...".to_string() + } else { + format!("> {}", app.message_input) + }; + let input_style = if app.message_input.is_empty() { + Style::default().fg(Color::DarkGray) + } else { + Style::default().fg(Color::Yellow) + }; + let input = Paragraph::new(input_text) + .block(Block::default().borders(Borders::ALL)) + .style(input_style); + f.render_widget(input, message_chunks[2]); + } else { + let empty = Paragraph::new("Выберите чат") + .block(Block::default().borders(Borders::ALL)) + .style(Style::default().fg(Color::DarkGray)) + .alignment(Alignment::Center); + f.render_widget(empty, area); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs new file mode 100644 index 0000000..ccd55a1 --- /dev/null +++ b/src/ui/mod.rs @@ -0,0 +1,17 @@ +mod loading; +mod auth; +mod main_screen; +mod chat_list; +mod messages; +mod footer; + +use ratatui::Frame; +use crate::app::{App, AppScreen}; + +pub fn render(f: &mut Frame, app: &mut App) { + match app.screen { + AppScreen::Loading => loading::render(f, app), + AppScreen::Auth => auth::render(f, app), + AppScreen::Main => main_screen::render(f, app), + } +} diff --git a/src/utils.rs b/src/utils.rs new file mode 100644 index 0000000..d87d727 --- /dev/null +++ b/src/utils.rs @@ -0,0 +1,47 @@ +use std::ffi::CString; +use std::os::raw::c_char; + +#[link(name = "tdjson")] +extern "C" { + fn td_execute(request: *const c_char) -> *const c_char; +} + +/// Отключаем логи TDLib синхронно, до создания клиента +pub fn disable_tdlib_logs() { + let request = r#"{"@type":"setLogVerbosityLevel","new_verbosity_level":0}"#; + let c_request = CString::new(request).unwrap(); + unsafe { + let _ = td_execute(c_request.as_ptr()); + } + + // Также перенаправляем логи в никуда + let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#; + let c_request2 = CString::new(request2).unwrap(); + unsafe { + let _ = td_execute(c_request2.as_ptr()); + } +} + +/// Форматирование timestamp в человекочитаемый формат +pub fn format_timestamp(timestamp: i32) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i32; + + let diff = now - timestamp; + + if diff < 60 { + "just now".to_string() + } else if diff < 3600 { + format!("{}m ago", diff / 60) + } else if diff < 86400 { + format!("{}h ago", diff / 3600) + } else { + let secs = timestamp as u64; + let days = secs / 86400; + format!("{}d ago", (now as u64 / 86400) - days) + } +}