From 699f50a59caed6e4bde4871a91c40f5571c6b81b Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 20 Jan 2026 13:37:02 +0300 Subject: [PATCH 1/2] fixes --- CONTEXT.md | 39 ++++++++++++++++++++++------ ROADMAP.md | 3 ++- src/tdlib/client.rs | 63 ++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 90 insertions(+), 15 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index ea706f7..e67097d 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,6 +1,6 @@ # Текущий контекст проекта -## Статус: Базовая интеграция с TDLib работает +## Статус: Фаза 3 — улучшение UX ### Что сделано @@ -13,27 +13,46 @@ #### Функциональность - Загрузка списка чатов (до 50 штук) +- **Фильтрация чатов**: показываются только чаты из ChatList::Main (без архива) - Отображение названия чата и счётчика непрочитанных - Загрузка истории сообщений при открытии чата - Отображение сообщений с именем отправителя и временем +- **Отправка текстовых сообщений** #### Управление - `j/k` или стрелки — навигация по списку чатов - `д/л` — русская раскладка для j/k -- `Enter` — открыть выбранный чат +- `Enter` — открыть чат / отправить сообщение - `Esc` — закрыть открытый чат - `Ctrl+k` — перейти к первому чату - `Ctrl+R` — обновить список чатов - `Ctrl+C` — выход +- Ввод текста в поле сообщения ### Структура проекта ``` src/ -├── main.rs # Точка входа, UI рендеринг, event loop -├── tdlib/ -│ ├── mod.rs # Модуль экспорта -│ └── client.rs # TdClient: авторизация, загрузка чатов, сообщений +├── main.rs # Точка входа, event loop, TDLib инициализация +├── app/ +│ ├── mod.rs # App структура и состояние +│ └── state.rs # AppScreen enum +├── ui/ +│ ├── mod.rs # Роутинг UI по экранам +│ ├── loading.rs # Экран загрузки +│ ├── auth.rs # Экран авторизации +│ ├── main_screen.rs # Главный экран +│ ├── chat_list.rs # Список чатов +│ ├── messages.rs # Область сообщений +│ └── footer.rs # Подвал с командами +├── input/ +│ ├── mod.rs # Роутинг ввода +│ ├── auth.rs # Обработка ввода на экране авторизации +│ └── main_input.rs # Обработка ввода на главном экране +├── utils.rs # Утилиты (disable_tdlib_logs, format_timestamp) +└── tdlib/ + ├── mod.rs # Модуль экспорта + └── client.rs # TdClient: авторизация, загрузка чатов, сообщений, отправка ``` ### Ключевые решения @@ -44,6 +63,10 @@ src/ 3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`. +4. **Фильтрация чатов по ChatList::Main**: Показываем только чаты с позицией в Main списке и ненулевым order. Архивные чаты и связанные группы не отображаются. + +5. **Сортировка по TDLib order**: Используем `position.order` для сортировки чатов (учитывает pinned и время). + ### Зависимости (Cargo.toml) ```toml @@ -65,14 +88,14 @@ API_HASH=your_api_hash ## Что НЕ сделано / TODO -- [ ] Отправка сообщений - [ ] Поиск по чатам - [ ] Папки телеграма (сейчас только "All") - [ ] Отображение онлайн-статуса пользователя - [ ] Markdown форматирование в сообщениях -- [ ] Скролл истории сообщений +- [ ] Скролл истории сообщений (больше 50 сообщений) - [ ] Отметка сообщений как прочитанные - [ ] Обновление чатов в реальном времени (новые сообщения) +- [ ] Загрузка имён пользователей (сейчас показывается User_ID) ## Известные проблемы diff --git a/ROADMAP.md b/ROADMAP.md index 6f3c57c..1dd8db7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -19,7 +19,8 @@ ## Фаза 3: Улучшение UX [IN PROGRESS] -- [ ] Отправка сообщений +- [x] Отправка сообщений +- [x] Фильтрация чатов (только Main, без архива) - [ ] Поиск по чатам (Ctrl+S) - [ ] Скролл истории сообщений - [ ] Загрузка имён пользователей (вместо User_ID) diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index ca253f5..256a5b3 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -124,14 +124,45 @@ impl TdClient { chat.last_message_date = last_message_date; } - // Пересортируем после обновления - self.chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date)); + // Обновляем позиции если они пришли + for pos in &update.positions { + if matches!(pos.list, ChatList::Main) { + if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) { + chat.order = pos.order; + chat.is_pinned = pos.is_pinned; + } + } + } + + // Пересортируем по order + self.chats.sort_by(|a, b| b.order.cmp(&a.order)); } Update::ChatReadInbox(update) => { if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { chat.unread_count = update.unread_count; } } + Update::ChatPosition(update) => { + // Обновляем позицию чата или удаляем его из списка + match &update.position.list { + ChatList::Main => { + if update.position.order == 0 { + // Чат больше не в Main (перемещён в архив и т.д.) + self.chats.retain(|c| c.id != update.chat_id); + } else if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { + // Обновляем позицию существующего чата + chat.order = update.position.order; + chat.is_pinned = update.position.is_pinned; + } + // Пересортируем по order + self.chats.sort_by(|a, b| b.order.cmp(&a.order)); + } + ChatList::Archive | ChatList::Folder(_) => { + // Если чат добавляется в архив или папку, ничего не делаем + // (он уже должен быть удалён из Main) + } + } + } Update::NewMessage(_new_msg) => { // Новые сообщения обрабатываются при обновлении UI } @@ -152,6 +183,24 @@ impl TdClient { } fn add_or_update_chat(&mut self, td_chat: &TdChat) { + // Проверяем, есть ли у чата позиция в ChatList::Main + // Если нет - не добавляем (это архивные чаты или связанные группы) + let main_position = td_chat.positions.iter().find(|pos| { + matches!(pos.list, ChatList::Main) + }); + + // Если чат не в Main списке - удаляем его если был, и выходим + let Some(position) = main_position else { + self.chats.retain(|c| c.id != td_chat.id); + return; + }; + + // Если order == 0, чат не должен отображаться + if position.order == 0 { + self.chats.retain(|c| c.id != td_chat.id); + return; + } + let (last_message, last_message_date) = td_chat .last_message .as_ref() @@ -164,8 +213,8 @@ impl TdClient { last_message, last_message_date, unread_count: td_chat.unread_count, - is_pinned: false, - order: 0, + is_pinned: position.is_pinned, + order: position.order, }; if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) { @@ -173,12 +222,14 @@ impl TdClient { existing.last_message = chat_info.last_message; existing.last_message_date = chat_info.last_message_date; existing.unread_count = chat_info.unread_count; + existing.is_pinned = chat_info.is_pinned; + existing.order = chat_info.order; } else { self.chats.push(chat_info); } - // Сортируем чаты по дате последнего сообщения (новые сверху) - self.chats.sort_by(|a, b| b.last_message_date.cmp(&a.last_message_date)); + // Сортируем чаты по order (TDLib order учитывает pinned и время) + self.chats.sort_by(|a, b| b.order.cmp(&a.order)); } fn convert_message(&self, message: &TdMessage) -> MessageInfo { From 9912ac11bd70e63c6b6a4f26921bb7e8806c8282 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Tue, 20 Jan 2026 14:54:30 +0300 Subject: [PATCH 2/2] fixes --- .gitignore | 1 + CONTEXT.md | 12 +++--- ROADMAP.md | 2 +- src/app/mod.rs | 81 +++++++++++++++++++++++++++++++++++++++++ src/input/main_input.rs | 79 ++++++++++++++++++++++++++++++++-------- src/tdlib/client.rs | 71 +++++++++++++++++++++++++++--------- src/ui/chat_list.rs | 30 ++++++++++++--- src/ui/footer.rs | 4 +- 8 files changed, 232 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index c1ace0e..bb9e215 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ # Environment variables (contains API keys) .env +.DS_Store diff --git a/CONTEXT.md b/CONTEXT.md index e67097d..0cadb27 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,15 +18,16 @@ - Загрузка истории сообщений при открытии чата - Отображение сообщений с именем отправителя и временем - **Отправка текстовых сообщений** +- **Поиск по чатам** (Ctrl+S): фильтрация списка по названию #### Управление -- `j/k` или стрелки — навигация по списку чатов -- `д/л` — русская раскладка для j/k +- `↑/↓` стрелки — навигация по списку чатов - `Enter` — открыть чат / отправить сообщение -- `Esc` — закрыть открытый чат -- `Ctrl+k` — перейти к первому чату +- `Esc` — закрыть открытый чат / отменить поиск +- `Ctrl+S` — поиск по чатам (фильтрация по названию) - `Ctrl+R` — обновить список чатов - `Ctrl+C` — выход +- `Cmd+↑/Cmd+↓` — скролл сообщений в открытом чате - Ввод текста в поле сообщения ### Структура проекта @@ -63,7 +64,7 @@ src/ 3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`. -4. **Фильтрация чатов по ChatList::Main**: Показываем только чаты с позицией в Main списке и ненулевым order. Архивные чаты и связанные группы не отображаются. +4. **Фильтрация чатов**: Все чаты добавляются в список при получении `NewChat` update. Позиции обновляются через `ChatPosition` update. 5. **Сортировка по TDLib order**: Используем `position.order` для сортировки чатов (учитывает pinned и время). @@ -88,7 +89,6 @@ API_HASH=your_api_hash ## Что НЕ сделано / TODO -- [ ] Поиск по чатам - [ ] Папки телеграма (сейчас только "All") - [ ] Отображение онлайн-статуса пользователя - [ ] Markdown форматирование в сообщениях diff --git a/ROADMAP.md b/ROADMAP.md index 1dd8db7..fcf9c4e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,7 +21,7 @@ - [x] Отправка сообщений - [x] Фильтрация чатов (только Main, без архива) -- [ ] Поиск по чатам (Ctrl+S) +- [x] Поиск по чатам (Ctrl+S) - [ ] Скролл истории сообщений - [ ] Загрузка имён пользователей (вместо User_ID) - [ ] Отметка сообщений как прочитанные diff --git a/src/app/mod.rs b/src/app/mod.rs index f71497b..dd5d2b9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -25,6 +25,9 @@ pub struct App { pub folders: Vec, pub selected_folder: usize, pub is_loading: bool, + // Search state + pub is_searching: bool, + pub search_query: String, } impl App { @@ -49,6 +52,8 @@ impl App { folders: vec!["All".to_string()], selected_folder: 0, is_loading: true, + is_searching: false, + search_query: String::new(), } } @@ -115,4 +120,80 @@ impl App { self.selected_chat_id .and_then(|id| self.chats.iter().find(|c| c.id == id)) } + + pub fn start_search(&mut self) { + self.is_searching = true; + self.search_query.clear(); + } + + pub fn cancel_search(&mut self) { + self.is_searching = false; + self.search_query.clear(); + self.chat_list_state.select(Some(0)); + } + + pub fn get_filtered_chats(&self) -> Vec<&ChatInfo> { + if self.search_query.is_empty() { + self.chats.iter().collect() + } else { + let query = self.search_query.to_lowercase(); + self.chats + .iter() + .filter(|c| { + // Поиск по названию чата + c.title.to_lowercase().contains(&query) || + // Поиск по username (@...) + c.username.as_ref() + .map(|u| u.to_lowercase().contains(&query)) + .unwrap_or(false) + }) + .collect() + } + } + + pub fn next_filtered_chat(&mut self) { + let filtered = self.get_filtered_chats(); + if filtered.is_empty() { + return; + } + let i = match self.chat_list_state.selected() { + Some(i) => { + if i >= filtered.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.chat_list_state.select(Some(i)); + } + + pub fn previous_filtered_chat(&mut self) { + let filtered = self.get_filtered_chats(); + if filtered.is_empty() { + return; + } + let i = match self.chat_list_state.selected() { + Some(i) => { + if i == 0 { + filtered.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.chat_list_state.select(Some(i)); + } + + pub fn select_filtered_chat(&mut self) { + let filtered = self.get_filtered_chats(); + if let Some(i) = self.chat_list_state.selected() { + if let Some(chat) = filtered.get(i) { + self.selected_chat_id = Some(chat.id); + self.cancel_search(); + } + } + } } diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 79355ee..7876ea6 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -15,14 +15,70 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.status_message = None; return; } + KeyCode::Char('s') if has_ctrl => { + // Ctrl+S - начать поиск (только если чат не открыт) + if app.selected_chat_id.is_none() { + app.start_search(); + } + return; + } _ => {} } + // Режим поиска + if app.is_searching { + match key.code { + KeyCode::Esc => { + app.cancel_search(); + } + KeyCode::Enter => { + // Выбрать чат из отфильтрованного списка + app.select_filtered_chat(); + 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; + } + } + } + } + KeyCode::Backspace => { + app.search_query.pop(); + // Сбрасываем выделение при изменении запроса + app.chat_list_state.select(Some(0)); + } + KeyCode::Down => { + app.next_filtered_chat(); + } + KeyCode::Up => { + app.previous_filtered_chat(); + } + KeyCode::Char(c) => { + app.search_query.push(c); + // Сбрасываем выделение при изменении запроса + app.chat_list_state.select(Some(0)); + } + _ => {} + } + return; + } + // Cmd+j/k - навигация (работает и в списке чатов, и для скролла сообщений) if has_super { match key.code { - // Cmd+j - вниз (следующий чат ИЛИ скролл вниз) - KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down => { + // Cmd+Down - вниз (следующий чат ИЛИ скролл вниз) + KeyCode::Down => { if app.selected_chat_id.is_some() { // В открытом чате - скролл вниз (к новым сообщениям) if app.message_scroll_offset > 0 { @@ -33,8 +89,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) { app.next_chat(); } } - // Cmd+k - вверх (предыдущий чат ИЛИ скролл вверх) - KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up => { + // Cmd+Up - вверх (предыдущий чат ИЛИ скролл вверх) + KeyCode::Up => { if app.selected_chat_id.is_some() { // В открытом чате - скролл вверх (к старым сообщениям) app.message_scroll_offset += 3; @@ -69,13 +125,6 @@ pub async fn handle(app: &mut App, key: KeyEvent) { 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 { @@ -151,14 +200,12 @@ pub async fn handle(app: &mut App, key: KeyEvent) { _ => {} } } else { - // В режиме списка чатов - навигация j/k и переключение папок + // В режиме списка чатов - навигация стрелками и переключение папок match key.code { - // j или д - следующий чат - KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down => { + KeyCode::Down => { app.next_chat(); } - // k или л - предыдущий чат - KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up => { + KeyCode::Up => { app.previous_chat(); } // Цифры - переключение папок diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 256a5b3..985b430 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -1,5 +1,6 @@ use std::env; -use tdlib_rs::enums::{AuthorizationState, ChatList, MessageContent, Update, User}; +use std::collections::HashMap; +use tdlib_rs::enums::{AuthorizationState, ChatList, ChatType, MessageContent, Update, User}; use tdlib_rs::functions; use tdlib_rs::types::{Chat as TdChat, Message as TdMessage}; @@ -20,6 +21,7 @@ pub enum AuthState { pub struct ChatInfo { pub id: i64, pub title: String, + pub username: Option, pub last_message: String, pub last_message_date: i32, pub unread_count: i32, @@ -44,6 +46,10 @@ pub struct TdClient { client_id: i32, pub chats: Vec, pub current_chat_messages: Vec, + /// Кэш usernames: user_id -> username + user_usernames: HashMap, + /// Связь chat_id -> user_id для приватных чатов + chat_user_ids: HashMap, } #[allow(dead_code)] @@ -64,6 +70,8 @@ impl TdClient { client_id, chats: Vec::new(), current_chat_messages: Vec::new(), + user_usernames: HashMap::new(), + chat_user_ids: HashMap::new(), } } @@ -166,6 +174,23 @@ impl TdClient { Update::NewMessage(_new_msg) => { // Новые сообщения обрабатываются при обновлении UI } + Update::User(update) => { + // Сохраняем username пользователя + let user = update.user; + if let Some(usernames) = user.usernames { + if let Some(username) = usernames.active_usernames.first() { + self.user_usernames.insert(user.id, username.clone()); + // Обновляем username в чатах, связанных с этим пользователем + for (&chat_id, &user_id) in &self.chat_user_ids.clone() { + if user_id == user.id { + if let Some(chat) = self.chats.iter_mut().find(|c| c.id == chat_id) { + chat.username = Some(format!("@{}", username)); + } + } + } + } + } + } _ => {} } } @@ -183,23 +208,15 @@ impl TdClient { } fn add_or_update_chat(&mut self, td_chat: &TdChat) { - // Проверяем, есть ли у чата позиция в ChatList::Main - // Если нет - не добавляем (это архивные чаты или связанные группы) + // Ищем позицию в Main списке (если есть) let main_position = td_chat.positions.iter().find(|pos| { matches!(pos.list, ChatList::Main) }); - // Если чат не в Main списке - удаляем его если был, и выходим - let Some(position) = main_position else { - self.chats.retain(|c| c.id != td_chat.id); - return; - }; - - // Если order == 0, чат не должен отображаться - if position.order == 0 { - self.chats.retain(|c| c.id != td_chat.id); - return; - } + // Получаем order и is_pinned из позиции, или используем значения по умолчанию + let (order, is_pinned) = main_position + .map(|p| (p.order, p.is_pinned)) + .unwrap_or((1, false)); // order=1 чтобы чат отображался let (last_message, last_message_date) = td_chat .last_message @@ -207,14 +224,25 @@ impl TdClient { .map(|m| (extract_message_text_static(m), m.date)) .unwrap_or_default(); + // Извлекаем user_id для приватных чатов и сохраняем связь + let username = match &td_chat.r#type { + ChatType::Private(private) => { + self.chat_user_ids.insert(td_chat.id, private.user_id); + // Проверяем, есть ли уже username в кэше + self.user_usernames.get(&private.user_id).map(|u| format!("@{}", u)) + } + _ => None, + }; + let chat_info = ChatInfo { id: td_chat.id, title: td_chat.title.clone(), + username, last_message, last_message_date, unread_count: td_chat.unread_count, - is_pinned: position.is_pinned, - order: position.order, + is_pinned, + order, }; if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) { @@ -222,8 +250,15 @@ impl TdClient { existing.last_message = chat_info.last_message; existing.last_message_date = chat_info.last_message_date; existing.unread_count = chat_info.unread_count; - existing.is_pinned = chat_info.is_pinned; - existing.order = chat_info.order; + // Обновляем username если он появился + if chat_info.username.is_some() { + existing.username = chat_info.username; + } + // Обновляем позицию только если она пришла + if main_position.is_some() { + existing.is_pinned = chat_info.is_pinned; + existing.order = chat_info.order; + } } else { self.chats.push(chat_info); } diff --git a/src/ui/chat_list.rs b/src/ui/chat_list.rs index 64efba0..cbd3fd1 100644 --- a/src/ui/chat_list.rs +++ b/src/ui/chat_list.rs @@ -17,26 +17,44 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { .split(area); // Search box - let search = Paragraph::new("🔍 Search...") + let search_text = if app.is_searching { + if app.search_query.is_empty() { + "🔍 Введите для поиска...".to_string() + } else { + format!("🔍 {}", app.search_query) + } + } else { + "🔍 Ctrl+S для поиска".to_string() + }; + let search_style = if app.is_searching { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + let search = Paragraph::new(search_text) .block(Block::default().borders(Borders::ALL)) - .style(Style::default().fg(Color::DarkGray)); + .style(search_style); f.render_widget(search, chat_chunks[0]); - // Chat list - let items: Vec = app - .chats + // Chat list (filtered if searching) + let filtered_chats = app.get_filtered_chats(); + let items: Vec = filtered_chats .iter() .map(|chat| { let is_selected = app.selected_chat_id == Some(chat.id); let prefix = if is_selected { "▌ " } else { " " }; + let username_text = chat.username.as_ref() + .map(|u| format!(" {}", u)) + .unwrap_or_default(); + let unread_badge = if chat.unread_count > 0 { format!(" ({})", chat.unread_count) } else { String::new() }; - let content = format!("{}{}{}", prefix, chat.title, unread_badge); + let content = format!("{}{}{}{}", prefix, chat.title, username_text, unread_badge); let style = Style::default().fg(Color::White); ListItem::new(content).style(style) diff --git a/src/ui/footer.rs b/src/ui/footer.rs index 2638232..5f34255 100644 --- a/src/ui/footer.rs +++ b/src/ui/footer.rs @@ -11,10 +11,12 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { format!(" {} ", msg) } else if let Some(err) = &app.error_message { format!(" Error: {} ", err) + } else if app.is_searching { + " j/k: Navigate | Enter: Select | Esc: Cancel ".to_string() } 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() + " j/k: Navigate | Enter: Open | Ctrl+S: Search | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string() }; let style = if app.error_message.is_some() {