diff --git a/.env b/.env deleted file mode 100644 index 9829081..0000000 --- a/.env +++ /dev/null @@ -1,2 +0,0 @@ -API_ID=36457397 -API_HASH=f74f670f33f3fa30a89b46c58dac2ff7 \ No newline at end of file diff --git a/.gitignore b/.gitignore index ea8c4bf..c1ace0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,7 @@ /target + +# TDLib session data (contains auth tokens - NEVER commit!) +/tdlib_data/ + +# Environment variables (contains API keys) +.env diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..ea706f7 --- /dev/null +++ b/CONTEXT.md @@ -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" (нужно загружать имена пользователей) diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index e1ee5e3..9e1278b 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -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 - кроссплатформенный терминал ## Нефункциональные требования diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..6f3c57c --- /dev/null +++ b/ROADMAP.md @@ -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 diff --git a/src/main.rs b/src/main.rs index 4e18047..98a6019 100644 --- a/src/main.rs +++ b/src/main.rs @@ -65,6 +65,7 @@ struct App { chat_list_state: ListState, selected_chat: Option, current_messages: Vec, + message_input: String, // Input for new message folders: Vec, selected_folder: usize, is_loading: bool, @@ -87,6 +88,7 @@ impl App { 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, @@ -138,6 +140,7 @@ impl App { fn close_chat(&mut self) { self.selected_chat = None; self.current_messages.clear(); + self.message_input.clear(); } 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_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(); + // Если чат открыт - режим ввода сообщения + 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); + } + _ => {} } - // 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(); + } 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; + // Если выбрали новый чат, загружаем историю + 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); + KeyCode::Esc => { + // Ничего не делаем, чат и так не открыт } - 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; + 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; + } + } + _ => {} } - _ => {} } } @@ -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]); // 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)) - .style(Style::default().fg(Color::Yellow)); + .style(input_style); f.render_widget(input, message_chunks[2]); } } else { @@ -796,7 +852,11 @@ fn render_footer(f: &mut Frame, area: Rect, app: &App) { 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() }; diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index f826847..857c3d1 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -19,6 +19,7 @@ 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, @@ -105,15 +106,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 +145,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 +164,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 { diff --git a/tdlib_data/db.sqlite b/tdlib_data/db.sqlite deleted file mode 100644 index 27c0e02..0000000 Binary files a/tdlib_data/db.sqlite and /dev/null differ diff --git a/tdlib_data/db.sqlite-shm b/tdlib_data/db.sqlite-shm deleted file mode 100644 index 138386c..0000000 Binary files a/tdlib_data/db.sqlite-shm and /dev/null differ diff --git a/tdlib_data/db.sqlite-wal b/tdlib_data/db.sqlite-wal deleted file mode 100644 index f18d8bc..0000000 Binary files a/tdlib_data/db.sqlite-wal and /dev/null differ diff --git a/tdlib_data/td.binlog b/tdlib_data/td.binlog deleted file mode 100644 index 2c8db61..0000000 Binary files a/tdlib_data/td.binlog and /dev/null differ