Compare commits

...

2 Commits

Author SHA1 Message Date
Mikhail Kilin
e1bceada6d fixes 2026-01-20 00:57:28 +03:00
Mikhail Kilin
dfce86d3db fixes 2026-01-18 21:37:42 +03:00
24 changed files with 1218 additions and 734 deletions

2
.env
View File

@@ -1,2 +0,0 @@
API_ID=36457397
API_HASH=f74f670f33f3fa30a89b46c58dac2ff7

6
.gitignore vendored
View File

@@ -1 +1,7 @@
/target
# TDLib session data (contains auth tokens - NEVER commit!)
/tdlib_data/
# Environment variables (contains API keys)
.env

80
CONTEXT.md Normal file
View File

@@ -0,0 +1,80 @@
# Текущий контекст проекта
## Статус: Базовая интеграция с TDLib работает
### Что сделано
#### TDLib интеграция
- Подключена библиотека `tdlib-rs` v1.1 с автоматической загрузкой TDLib
- Реализована авторизация через телефон + код + 2FA пароль
- Сессия сохраняется автоматически в папке `tdlib_data/`
- Отключены логи TDLib через FFI вызов `td_execute` до создания клиента
- Updates обрабатываются в отдельном потоке через `mpsc` канал (неблокирующе)
#### Функциональность
- Загрузка списка чатов (до 50 штук)
- Отображение названия чата и счётчика непрочитанных
- Загрузка истории сообщений при открытии чата
- Отображение сообщений с именем отправителя и временем
#### Управление
- `j/k` или стрелки — навигация по списку чатов
- `д/л` — русская раскладка для j/k
- `Enter` — открыть выбранный чат
- `Esc` — закрыть открытый чат
- `Ctrl+k` — перейти к первому чату
- `Ctrl+R` — обновить список чатов
- `Ctrl+C` — выход
### Структура проекта
```
src/
├── main.rs # Точка входа, UI рендеринг, event loop
├── tdlib/
│ ├── mod.rs # Модуль экспорта
│ └── client.rs # TdClient: авторизация, загрузка чатов, сообщений
```
### Ключевые решения
1. **Неблокирующий receive**: TDLib updates приходят в отдельном потоке и передаются в main loop через `mpsc::channel`. Это позволяет UI оставаться отзывчивым.
2. **FFI для логов**: Используем прямой вызов `td_execute` для отключения логов синхронно, до создания клиента, чтобы избежать вывода в терминал.
3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`.
### Зависимости (Cargo.toml)
```toml
ratatui = "0.29"
crossterm = "0.28"
tdlib-rs = { version = "1.1", features = ["download-tdlib"] }
tokio = { version = "1", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
dotenvy = "0.15"
```
### Переменные окружения (.env)
```
API_ID=your_api_id
API_HASH=your_api_hash
```
## Что НЕ сделано / TODO
- [ ] Отправка сообщений
- [ ] Поиск по чатам
- [ ] Папки телеграма (сейчас только "All")
- [ ] Отображение онлайн-статуса пользователя
- [ ] Markdown форматирование в сообщениях
- [ ] Скролл истории сообщений
- [ ] Отметка сообщений как прочитанные
- [ ] Обновление чатов в реальном времени (новые сообщения)
## Известные проблемы
1. При первом запуске нужно пройти авторизацию
2. Имя отправителя показывается как "User_ID" (нужно загружать имена пользователей)

View File

@@ -65,13 +65,21 @@
7) Esc - закрытие открытого чата
8) command + стрелка вверх (или ctrl + k) - выделяем самый верхний чат (без открытия)
9) поддержка русской раскладки: "р о л д" соответствует "h j k l"
10) `**commands**` - сюда вставь описания команд, которые есть в приложении
10) Ctrl+R - обновить список чатов
### Реализованные команды (footer)
```
j/k: Navigate | Ctrl+k: First | Enter: Open | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit
```
## Технологии
Пишем на rust-е
1) ratatui - для tui интерфейса
2) rust-tdlib - для подключения апи телеграма
2) tdlib-rs - для подключения апи телеграма (обёртка над TDLib)
3) tokio - async runtime
4) crossterm - кроссплатформенный терминал
## Нефункциональные требования

48
ROADMAP.md Normal file
View File

@@ -0,0 +1,48 @@
# Roadmap
## Фаза 1: Базовая инфраструктура [DONE]
- [x] Настройка проекта (Cargo.toml)
- [x] TUI фреймворк (ratatui + crossterm)
- [x] Базовый layout (папки, список чатов, область сообщений)
- [x] Vim-style навигация (hjkl, стрелки)
- [x] Русская раскладка (ролд)
## Фаза 2: TDLib интеграция [DONE]
- [x] Подключение tdlib-rs
- [x] Авторизация (телефон + код + 2FA)
- [x] Сохранение сессии
- [x] Загрузка списка чатов
- [x] Загрузка истории сообщений
- [x] Отключение логов TDLib
## Фаза 3: Улучшение UX [IN PROGRESS]
- [ ] Отправка сообщений
- [ ] Поиск по чатам (Ctrl+S)
- [ ] Скролл истории сообщений
- [ ] Загрузка имён пользователей (вместо User_ID)
- [ ] Отметка сообщений как прочитанные
- [ ] Реальное время: новые сообщения
## Фаза 4: Папки и фильтрация
- [ ] Загрузка папок из Telegram
- [ ] Переключение между папками (Cmd+1, Cmd+2, ...)
- [ ] Фильтрация чатов по папке
## Фаза 5: Расширенный функционал
- [ ] Отображение онлайн-статуса
- [ ] Статус доставки/прочтения (✓, ✓✓)
- [ ] Поддержка медиа-заглушек (фото, видео, голосовые)
- [ ] Mentions (@)
- [ ] Muted чаты (серый цвет)
## Фаза 6: Полировка
- [ ] Оптимизация 60 FPS
- [ ] Минимальное разрешение 600 символов
- [ ] Обработка ошибок сети
- [ ] Graceful shutdown

118
src/app/mod.rs Normal file
View 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
View File

@@ -0,0 +1,6 @@
#[derive(PartialEq, Clone)]
pub enum AppScreen {
Loading,
Auth,
Main,
}

101
src/input/auth.rs Normal file
View 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
View 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
View 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;

View File

@@ -1,157 +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<String>,
status_message: Option<String>,
// Main app state
chats: Vec<ChatInfo>,
chat_list_state: ListState,
selected_chat: Option<usize>,
current_messages: Vec<MessageInfo>,
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(),
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();
}
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)
}
}
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> {
@@ -161,18 +27,6 @@ async fn main() -> Result<(), io::Error> {
// Отключаем логи TDLib ДО создания клиента
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
enable_raw_mode()?;
let mut stdout = io::stdout();
@@ -182,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()?;
@@ -203,15 +57,49 @@ async fn main() -> Result<(), io::Error> {
async fn run_app<B: ratatui::backend::Backend>(
terminal: &mut Terminal<B>,
app: &mut App,
update_rx: mpsc::Receiver<Update>,
) -> 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::<Update>();
// Запускаем 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);
}
@@ -219,7 +107,7 @@ async fn run_app<B: ratatui::backend::Backend>(
// Обновляем состояние экрана на основе 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))? {
@@ -242,6 +130,8 @@ async fn run_app<B: ratatui::backend::Backend>(
}
async fn update_screen_state(app: &mut App) {
use tokio::time::timeout;
let prev_screen = app.screen.clone();
match &app.td_client.auth_state {
@@ -259,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 => {
@@ -283,555 +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);
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 => {
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::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 messages_widget =
Paragraph::new(lines).block(Block::default().borders(Borders::ALL));
f.render_widget(messages_widget, message_chunks[1]);
// Input box
let input = Paragraph::new("> ...")
.block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Yellow));
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 {
" 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)
}
}

View File

@@ -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,10 +16,12 @@ pub enum AuthState {
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
pub struct ChatInfo {
pub id: i64,
pub title: String,
pub last_message: String,
pub last_message_date: i32,
pub unread_count: i32,
pub is_pinned: bool,
pub order: i64,
@@ -43,6 +46,7 @@ pub struct TdClient {
pub current_chat_messages: Vec<MessageInfo>,
}
#[allow(dead_code)]
impl TdClient {
pub fn new() -> Self {
let api_id: i32 = env::var("API_ID")
@@ -67,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
@@ -105,15 +113,19 @@ impl TdClient {
}
Update::ChatLastMessage(update) => {
let chat_id = update.chat_id;
let last_message_text = update
let (last_message_text, last_message_date) = update
.last_message
.as_ref()
.map(|msg| extract_message_text_static(msg))
.map(|msg| (extract_message_text_static(msg), msg.date))
.unwrap_or_default();
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
chat.last_message = last_message_text;
chat.last_message_date = last_message_date;
}
// Пересортируем после обновления
self.chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
}
Update::ChatReadInbox(update) => {
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) {
@@ -140,16 +152,17 @@ impl TdClient {
}
fn add_or_update_chat(&mut self, td_chat: &TdChat) {
let last_message = td_chat
let (last_message, last_message_date) = td_chat
.last_message
.as_ref()
.map(|m| extract_message_text_static(m))
.map(|m| (extract_message_text_static(m), m.date))
.unwrap_or_default();
let chat_info = ChatInfo {
id: td_chat.id,
title: td_chat.title.clone(),
last_message,
last_message_date,
unread_count: td_chat.unread_count,
is_pinned: false,
order: 0,
@@ -158,10 +171,14 @@ impl TdClient {
if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) {
existing.title = chat_info.title;
existing.last_message = chat_info.last_message;
existing.last_message_date = chat_info.last_message_date;
existing.unread_count = chat_info.unread_count;
} else {
self.chats.push(chat_info);
}
// Сортируем чаты по дате последнего сообщения (новые сверху)
self.chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date));
}
fn convert_message(&self, message: &TdMessage) -> MessageInfo {
@@ -238,12 +255,13 @@ impl TdClient {
) -> Result<Vec<MessageInfo>, 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;
@@ -256,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)
@@ -264,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
pub async fn get_user_name(&self, user_id: i64) -> String {
match functions::get_user(user_id, self.client_id).await {
@@ -296,6 +348,46 @@ impl TdClient {
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)

136
src/ui/auth.rs Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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)
}
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.