Compare commits
2 Commits
b2aecf11da
...
e1bceada6d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bceada6d | ||
|
|
dfce86d3db |
118
src/app/mod.rs
Normal file
118
src/app/mod.rs
Normal file
@@ -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<String>,
|
||||||
|
pub status_message: Option<String>,
|
||||||
|
// Main app state
|
||||||
|
pub chats: Vec<ChatInfo>,
|
||||||
|
pub chat_list_state: ListState,
|
||||||
|
pub selected_chat_id: Option<i64>,
|
||||||
|
pub current_messages: Vec<MessageInfo>,
|
||||||
|
pub message_input: String,
|
||||||
|
pub message_scroll_offset: usize,
|
||||||
|
pub folders: Vec<String>,
|
||||||
|
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<i64> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
6
src/app/state.rs
Normal file
6
src/app/state.rs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
#[derive(PartialEq, Clone)]
|
||||||
|
pub enum AppScreen {
|
||||||
|
Loading,
|
||||||
|
Auth,
|
||||||
|
Main,
|
||||||
|
}
|
||||||
101
src/input/auth.rs
Normal file
101
src/input/auth.rs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
174
src/input/main_input.rs
Normal file
174
src/input/main_input.rs
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/input/mod.rs
Normal file
5
src/input/mod.rs
Normal file
@@ -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;
|
||||||
843
src/main.rs
843
src/main.rs
@@ -1,160 +1,23 @@
|
|||||||
|
mod app;
|
||||||
|
mod input;
|
||||||
mod tdlib;
|
mod tdlib;
|
||||||
|
mod ui;
|
||||||
|
mod utils;
|
||||||
|
|
||||||
use crossterm::{
|
use crossterm::{
|
||||||
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers},
|
||||||
execute,
|
execute,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
use ratatui::{
|
use ratatui::{backend::CrosstermBackend, Terminal};
|
||||||
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 std::io;
|
use std::io;
|
||||||
use std::os::raw::c_char;
|
|
||||||
use std::sync::mpsc;
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tdlib::client::{AuthState, ChatInfo, MessageInfo};
|
|
||||||
use tdlib::TdClient;
|
|
||||||
use tdlib_rs::enums::Update;
|
use tdlib_rs::enums::Update;
|
||||||
|
|
||||||
// FFI для синхронного вызова TDLib (отключение логов до создания клиента)
|
use app::{App, AppScreen};
|
||||||
#[link(name = "tdjson")]
|
use input::{handle_auth_input, handle_main_input};
|
||||||
extern "C" {
|
use tdlib::client::AuthState;
|
||||||
fn td_execute(request: *const c_char) -> *const c_char;
|
use utils::disable_tdlib_logs;
|
||||||
}
|
|
||||||
|
|
||||||
/// Отключаем логи 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<String>,
|
|
||||||
status_message: Option<String>,
|
|
||||||
// Main app state
|
|
||||||
chats: Vec<ChatInfo>,
|
|
||||||
chat_list_state: ListState,
|
|
||||||
selected_chat: Option<usize>,
|
|
||||||
current_messages: Vec<MessageInfo>,
|
|
||||||
message_input: String, // Input for new message
|
|
||||||
folders: Vec<String>,
|
|
||||||
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<i64> {
|
|
||||||
self.selected_chat
|
|
||||||
.and_then(|idx| self.chats.get(idx))
|
|
||||||
.map(|chat| chat.id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> Result<(), io::Error> {
|
async fn main() -> Result<(), io::Error> {
|
||||||
@@ -164,18 +27,6 @@ async fn main() -> Result<(), io::Error> {
|
|||||||
// Отключаем логи TDLib ДО создания клиента
|
// Отключаем логи TDLib ДО создания клиента
|
||||||
disable_tdlib_logs();
|
disable_tdlib_logs();
|
||||||
|
|
||||||
// Запускаем поток для получения updates от TDLib
|
|
||||||
let (update_tx, update_rx) = mpsc::channel::<Update>();
|
|
||||||
std::thread::spawn(move || {
|
|
||||||
loop {
|
|
||||||
if let Some((update, _client_id)) = tdlib_rs::receive() {
|
|
||||||
if update_tx.send(update).is_err() {
|
|
||||||
break; // Канал закрыт, выходим
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Setup terminal
|
// Setup terminal
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
@@ -185,7 +36,7 @@ async fn main() -> Result<(), io::Error> {
|
|||||||
|
|
||||||
// Create app state
|
// Create app state
|
||||||
let mut app = App::new();
|
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
|
// Restore terminal
|
||||||
disable_raw_mode()?;
|
disable_raw_mode()?;
|
||||||
@@ -206,15 +57,49 @@ async fn main() -> Result<(), io::Error> {
|
|||||||
async fn run_app<B: ratatui::backend::Backend>(
|
async fn run_app<B: ratatui::backend::Backend>(
|
||||||
terminal: &mut Terminal<B>,
|
terminal: &mut Terminal<B>,
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
update_rx: mpsc::Receiver<Update>,
|
|
||||||
) -> io::Result<()> {
|
) -> io::Result<()> {
|
||||||
// Инициализируем TDLib
|
// Канал для передачи updates из polling задачи в main loop
|
||||||
if let Err(e) = app.td_client.init().await {
|
let (update_tx, mut update_rx) = tokio::sync::mpsc::unbounded_channel::<Update>();
|
||||||
app.error_message = Some(e);
|
|
||||||
}
|
// Запускаем 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 {
|
loop {
|
||||||
// Обрабатываем все доступные обновления от TDLib (неблокирующе)
|
// Обрабатываем updates от TDLib из канала (неблокирующе)
|
||||||
while let Ok(update) = update_rx.try_recv() {
|
while let Ok(update) = update_rx.try_recv() {
|
||||||
app.td_client.handle_update(update);
|
app.td_client.handle_update(update);
|
||||||
}
|
}
|
||||||
@@ -222,7 +107,7 @@ async fn run_app<B: ratatui::backend::Backend>(
|
|||||||
// Обновляем состояние экрана на основе auth_state
|
// Обновляем состояние экрана на основе auth_state
|
||||||
update_screen_state(app).await;
|
update_screen_state(app).await;
|
||||||
|
|
||||||
terminal.draw(|f| ui(f, app))?;
|
terminal.draw(|f| ui::render(f, app))?;
|
||||||
|
|
||||||
// Используем poll для неблокирующего чтения событий
|
// Используем poll для неблокирующего чтения событий
|
||||||
if event::poll(Duration::from_millis(100))? {
|
if event::poll(Duration::from_millis(100))? {
|
||||||
@@ -245,6 +130,8 @@ async fn run_app<B: ratatui::backend::Backend>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn update_screen_state(app: &mut App) {
|
async fn update_screen_state(app: &mut App) {
|
||||||
|
use tokio::time::timeout;
|
||||||
|
|
||||||
let prev_screen = app.screen.clone();
|
let prev_screen = app.screen.clone();
|
||||||
|
|
||||||
match &app.td_client.auth_state {
|
match &app.td_client.auth_state {
|
||||||
@@ -262,20 +149,21 @@ async fn update_screen_state(app: &mut App) {
|
|||||||
app.is_loading = true;
|
app.is_loading = true;
|
||||||
app.status_message = Some("Загрузка чатов...".to_string());
|
app.status_message = Some("Загрузка чатов...".to_string());
|
||||||
|
|
||||||
// Запрашиваем загрузку чатов (они придут через updates)
|
// Запрашиваем загрузку чатов с таймаутом
|
||||||
if let Err(e) = app.td_client.load_chats(50).await {
|
let _ = timeout(Duration::from_secs(5), app.td_client.load_chats(50)).await;
|
||||||
app.error_message = Some(e);
|
|
||||||
}
|
|
||||||
app.is_loading = false;
|
|
||||||
app.status_message = None;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Синхронизируем чаты из td_client в app
|
// Синхронизируем чаты из 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();
|
app.chats = app.td_client.chats.clone();
|
||||||
if app.chat_list_state.selected().is_none() && !app.chats.is_empty() {
|
if app.chat_list_state.selected().is_none() && !app.chats.is_empty() {
|
||||||
app.chat_list_state.select(Some(0));
|
app.chat_list_state.select(Some(0));
|
||||||
}
|
}
|
||||||
|
// Убираем статус загрузки когда чаты появились
|
||||||
|
if app.is_loading {
|
||||||
|
app.is_loading = false;
|
||||||
|
app.status_message = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
AuthState::Closed => {
|
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<ListItem> = 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<Line> = 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use tdlib_rs::functions;
|
|||||||
use tdlib_rs::types::{Chat as TdChat, Message as TdMessage};
|
use tdlib_rs::types::{Chat as TdChat, Message as TdMessage};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub enum AuthState {
|
pub enum AuthState {
|
||||||
WaitTdlibParameters,
|
WaitTdlibParameters,
|
||||||
WaitPhoneNumber,
|
WaitPhoneNumber,
|
||||||
@@ -15,6 +16,7 @@ pub enum AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
#[allow(dead_code)]
|
||||||
pub struct ChatInfo {
|
pub struct ChatInfo {
|
||||||
pub id: i64,
|
pub id: i64,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
@@ -44,6 +46,7 @@ pub struct TdClient {
|
|||||||
pub current_chat_messages: Vec<MessageInfo>,
|
pub current_chat_messages: Vec<MessageInfo>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
impl TdClient {
|
impl TdClient {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let api_id: i32 = env::var("API_ID")
|
let api_id: i32 = env::var("API_ID")
|
||||||
@@ -68,13 +71,17 @@ impl TdClient {
|
|||||||
matches!(self.auth_state, AuthState::Ready)
|
matches!(self.auth_state, AuthState::Ready)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn client_id(&self) -> i32 {
|
||||||
|
self.client_id
|
||||||
|
}
|
||||||
|
|
||||||
/// Инициализация TDLib с параметрами
|
/// Инициализация TDLib с параметрами
|
||||||
pub async fn init(&mut self) -> Result<(), String> {
|
pub async fn init(&mut self) -> Result<(), String> {
|
||||||
let result = functions::set_tdlib_parameters(
|
let result = functions::set_tdlib_parameters(
|
||||||
false, // use_test_dc
|
false, // use_test_dc
|
||||||
"tdlib_data".to_string(), // database_directory
|
"tdlib_data".to_string(), // database_directory
|
||||||
"".to_string(), // files_directory
|
"".to_string(), // files_directory
|
||||||
"".to_string(), // database_encryption_key (String, not Vec)
|
"".to_string(), // database_encryption_key
|
||||||
true, // use_file_database
|
true, // use_file_database
|
||||||
true, // use_chat_info_database
|
true, // use_chat_info_database
|
||||||
true, // use_message_database
|
true, // use_message_database
|
||||||
@@ -248,12 +255,13 @@ impl TdClient {
|
|||||||
) -> Result<Vec<MessageInfo>, String> {
|
) -> Result<Vec<MessageInfo>, String> {
|
||||||
let _ = functions::open_chat(chat_id, self.client_id).await;
|
let _ = functions::open_chat(chat_id, self.client_id).await;
|
||||||
|
|
||||||
|
// Загружаем историю с сервера (only_local=false)
|
||||||
let result = functions::get_chat_history(
|
let result = functions::get_chat_history(
|
||||||
chat_id,
|
chat_id,
|
||||||
0,
|
0, // from_message_id (0 = с последнего сообщения)
|
||||||
0,
|
0, // offset
|
||||||
limit,
|
limit,
|
||||||
false,
|
false, // only_local - загружаем с сервера!
|
||||||
self.client_id,
|
self.client_id,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
@@ -266,6 +274,7 @@ impl TdClient {
|
|||||||
.filter_map(|m| m.map(|msg| self.convert_message(&msg)))
|
.filter_map(|m| m.map(|msg| self.convert_message(&msg)))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
|
// Сообщения приходят от новых к старым, переворачиваем
|
||||||
result_messages.reverse();
|
result_messages.reverse();
|
||||||
self.current_chat_messages = result_messages.clone();
|
self.current_chat_messages = result_messages.clone();
|
||||||
Ok(result_messages)
|
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<Vec<MessageInfo>, 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<MessageInfo> = 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
|
/// Получение информации о пользователе по ID
|
||||||
pub async fn get_user_name(&self, user_id: i64) -> String {
|
pub async fn get_user_name(&self, user_id: i64) -> String {
|
||||||
match functions::get_user(user_id, self.client_id).await {
|
match functions::get_user(user_id, self.client_id).await {
|
||||||
@@ -306,6 +348,46 @@ impl TdClient {
|
|||||||
Err(e) => Err(format!("Ошибка получения профиля: {:?}", e)),
|
Err(e) => Err(format!("Ошибка получения профиля: {:?}", e)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Отправка текстового сообщения
|
||||||
|
pub async fn send_message(&self, chat_id: i64, text: String) -> Result<MessageInfo, String> {
|
||||||
|
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)
|
/// Статическая функция для извлечения текста сообщения (без &self)
|
||||||
|
|||||||
136
src/ui/auth.rs
Normal file
136
src/ui/auth.rs
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/ui/chat_list.rs
Normal file
61
src/ui/chat_list.rs
Normal file
@@ -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<ListItem> = 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]);
|
||||||
|
}
|
||||||
30
src/ui/footer.rs
Normal file
30
src/ui/footer.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
40
src/ui/loading.rs
Normal file
40
src/ui/loading.rs
Normal file
@@ -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]);
|
||||||
|
}
|
||||||
62
src/ui/main_screen.rs
Normal file
62
src/ui/main_screen.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
116
src/ui/messages.rs
Normal file
116
src/ui/messages.rs
Normal file
@@ -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<Line> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/ui/mod.rs
Normal file
17
src/ui/mod.rs
Normal file
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
47
src/utils.rs
Normal file
47
src/utils.rs
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user