Compare commits

...

3 Commits

Author SHA1 Message Date
Mikhail Kilin
b2aecf11da fixes 2026-01-18 21:34:54 +03:00
Mikhail Kilin
79861bbbac fixes 2026-01-18 21:33:04 +03:00
Mikhail Kilin
eea5eb9584 fixes 2026-01-18 20:06:17 +03:00
11 changed files with 268 additions and 58 deletions

2
.env
View File

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

6
.gitignore vendored
View File

@@ -1 +1,7 @@
/target /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 - закрытие открытого чата 7) Esc - закрытие открытого чата
8) command + стрелка вверх (или ctrl + k) - выделяем самый верхний чат (без открытия) 8) command + стрелка вверх (или ctrl + k) - выделяем самый верхний чат (без открытия)
9) поддержка русской раскладки: "р о л д" соответствует "h j k l" 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-е Пишем на rust-е
1) ratatui - для tui интерфейса 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

View File

@@ -65,6 +65,7 @@ struct App {
chat_list_state: ListState, chat_list_state: ListState,
selected_chat: Option<usize>, selected_chat: Option<usize>,
current_messages: Vec<MessageInfo>, current_messages: Vec<MessageInfo>,
message_input: String, // Input for new message
folders: Vec<String>, folders: Vec<String>,
selected_folder: usize, selected_folder: usize,
is_loading: bool, is_loading: bool,
@@ -87,6 +88,7 @@ impl App {
chat_list_state: state, chat_list_state: state,
selected_chat: None, selected_chat: None,
current_messages: Vec::new(), current_messages: Vec::new(),
message_input: String::new(),
folders: vec!["All".to_string()], folders: vec!["All".to_string()],
selected_folder: 0, selected_folder: 0,
is_loading: true, is_loading: true,
@@ -138,6 +140,7 @@ impl App {
fn close_chat(&mut self) { fn close_chat(&mut self) {
self.selected_chat = None; self.selected_chat = None;
self.current_messages.clear(); self.current_messages.clear();
self.message_input.clear();
} }
fn select_first_chat(&mut self) { fn select_first_chat(&mut self) {
@@ -372,61 +375,94 @@ async fn handle_main_input(app: &mut App, key: event::KeyEvent) {
let has_super = key.modifiers.contains(KeyModifiers::SUPER); let has_super = key.modifiers.contains(KeyModifiers::SUPER);
let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL); let has_ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code { // Если чат открыт - режим ввода сообщения
// Navigate down: j, Down, д (Russian) if app.selected_chat.is_some() {
KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down if !has_super && !has_ctrl => { match key.code {
app.next_chat(); 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);
}
_ => {}
} }
// Navigate up: k, Up, л (Russian) } else {
KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up if !has_super && !has_ctrl => { // Режим навигации по списку чатов
app.previous_chat(); match key.code {
} // Navigate down: j, Down, д (Russian)
// Jump to first chat: Cmd+Up or Ctrl+k/л KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down if !has_super && !has_ctrl => {
KeyCode::Up if has_super => { app.next_chat();
app.select_first_chat(); }
} // Navigate up: k, Up, л (Russian)
KeyCode::Char('k') | KeyCode::Char('л') if has_ctrl => { KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up if !has_super && !has_ctrl => {
app.select_first_chat(); app.previous_chat();
} }
KeyCode::Enter => { // Jump to first chat: Cmd+Up or Ctrl+k/л
let prev_selected = app.selected_chat; KeyCode::Up if has_super => {
app.select_current_chat(); 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 app.selected_chat != prev_selected {
if let Some(chat_id) = app.get_selected_chat_id() { if let Some(chat_id) = app.get_selected_chat_id() {
app.status_message = Some("Загрузка сообщений...".to_string()); app.status_message = Some("Загрузка сообщений...".to_string());
match app.td_client.get_chat_history(chat_id, 30).await { match app.td_client.get_chat_history(chat_id, 30).await {
Ok(messages) => { Ok(messages) => {
app.current_messages = messages; app.current_messages = messages;
app.status_message = None; app.status_message = None;
} }
Err(e) => { Err(e) => {
app.error_message = Some(e); app.error_message = Some(e);
app.status_message = None; app.status_message = None;
}
} }
} }
} }
} }
} KeyCode::Esc => {
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('r') if has_ctrl => {
} // Обновить список чатов
KeyCode::Char(c) if c >= '1' && c <= '9' => { app.status_message = Some("Обновление чатов...".to_string());
let folder_idx = (c as usize) - ('1' as usize); if let Err(e) = app.td_client.load_chats(50).await {
if folder_idx < app.folders.len() { app.error_message = Some(e);
app.selected_folder = folder_idx; }
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;
}
}
_ => {}
} }
_ => {}
} }
} }
@@ -773,14 +809,34 @@ fn render_messages(f: &mut Frame, area: Rect, app: &App) {
))); )));
} }
let messages_widget = // Вычисляем скролл, чтобы показать последние сообщения
Paragraph::new(lines).block(Block::default().borders(Borders::ALL)); 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]); f.render_widget(messages_widget, message_chunks[1]);
// Input box // Input box
let input = Paragraph::new("> ...") 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)) .block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::Yellow)); .style(input_style);
f.render_widget(input, message_chunks[2]); f.render_widget(input, message_chunks[2]);
} }
} else { } else {
@@ -796,7 +852,11 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) {
format!(" {} ", msg) format!(" {} ", msg)
} else if let Some(err) = &app.error_message { } else if let Some(err) = &app.error_message {
format!(" Error: {} ", err) format!(" Error: {} ", err)
} else if app.selected_chat.is_some() {
// Режим ввода сообщения
" Enter: Send | Esc: Close chat | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string()
} else { } else {
// Режим навигации
" j/k: Navigate | Ctrl+k: First | Enter: Open | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string() " j/k: Navigate | Ctrl+k: First | Enter: Open | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string()
}; };

View File

@@ -19,6 +19,7 @@ pub struct ChatInfo {
pub id: i64, pub id: i64,
pub title: String, pub title: String,
pub last_message: String, pub last_message: String,
pub last_message_date: i32,
pub unread_count: i32, pub unread_count: i32,
pub is_pinned: bool, pub is_pinned: bool,
pub order: i64, pub order: i64,
@@ -105,15 +106,19 @@ impl TdClient {
} }
Update::ChatLastMessage(update) => { Update::ChatLastMessage(update) => {
let chat_id = update.chat_id; let chat_id = update.chat_id;
let last_message_text = update let (last_message_text, last_message_date) = update
.last_message .last_message
.as_ref() .as_ref()
.map(|msg| extract_message_text_static(msg)) .map(|msg| (extract_message_text_static(msg), msg.date))
.unwrap_or_default(); .unwrap_or_default();
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) { if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) {
chat.last_message = last_message_text; 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) => { Update::ChatReadInbox(update) => {
if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) {
@@ -140,16 +145,17 @@ impl TdClient {
} }
fn add_or_update_chat(&mut self, td_chat: &TdChat) { 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 .last_message
.as_ref() .as_ref()
.map(|m| extract_message_text_static(m)) .map(|m| (extract_message_text_static(m), m.date))
.unwrap_or_default(); .unwrap_or_default();
let chat_info = ChatInfo { let chat_info = ChatInfo {
id: td_chat.id, id: td_chat.id,
title: td_chat.title.clone(), title: td_chat.title.clone(),
last_message, last_message,
last_message_date,
unread_count: td_chat.unread_count, unread_count: td_chat.unread_count,
is_pinned: false, is_pinned: false,
order: 0, order: 0,
@@ -158,10 +164,14 @@ impl TdClient {
if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) { if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) {
existing.title = chat_info.title; existing.title = chat_info.title;
existing.last_message = chat_info.last_message; existing.last_message = chat_info.last_message;
existing.last_message_date = chat_info.last_message_date;
existing.unread_count = chat_info.unread_count; existing.unread_count = chat_info.unread_count;
} else { } else {
self.chats.push(chat_info); 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 { fn convert_message(&self, message: &TdMessage) -> MessageInfo {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.