This commit is contained in:
Mikhail Kilin
2026-01-20 14:54:30 +03:00
parent 699f50a59c
commit 9912ac11bd
8 changed files with 232 additions and 48 deletions

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
# Environment variables (contains API keys) # Environment variables (contains API keys)
.env .env
.DS_Store

View File

@@ -18,15 +18,16 @@
- Загрузка истории сообщений при открытии чата - Загрузка истории сообщений при открытии чата
- Отображение сообщений с именем отправителя и временем - Отображение сообщений с именем отправителя и временем
- **Отправка текстовых сообщений** - **Отправка текстовых сообщений**
- **Поиск по чатам** (Ctrl+S): фильтрация списка по названию
#### Управление #### Управление
- `j/k` или стрелки — навигация по списку чатов - `↑/↓` стрелки — навигация по списку чатов
- `д/л` — русская раскладка для j/k
- `Enter` — открыть чат / отправить сообщение - `Enter` — открыть чат / отправить сообщение
- `Esc` — закрыть открытый чат - `Esc` — закрыть открытый чат / отменить поиск
- `Ctrl+k` — перейти к первому чату - `Ctrl+S` — поиск по чатам (фильтрация по названию)
- `Ctrl+R` — обновить список чатов - `Ctrl+R` — обновить список чатов
- `Ctrl+C` — выход - `Ctrl+C` — выход
- `Cmd+↑/Cmd+↓` — скролл сообщений в открытом чате
- Ввод текста в поле сообщения - Ввод текста в поле сообщения
### Структура проекта ### Структура проекта
@@ -63,7 +64,7 @@ src/
3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`. 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 и время). 5. **Сортировка по TDLib order**: Используем `position.order` для сортировки чатов (учитывает pinned и время).
@@ -88,7 +89,6 @@ API_HASH=your_api_hash
## Что НЕ сделано / TODO ## Что НЕ сделано / TODO
- [ ] Поиск по чатам
- [ ] Папки телеграма (сейчас только "All") - [ ] Папки телеграма (сейчас только "All")
- [ ] Отображение онлайн-статуса пользователя - [ ] Отображение онлайн-статуса пользователя
- [ ] Markdown форматирование в сообщениях - [ ] Markdown форматирование в сообщениях

View File

@@ -21,7 +21,7 @@
- [x] Отправка сообщений - [x] Отправка сообщений
- [x] Фильтрация чатов (только Main, без архива) - [x] Фильтрация чатов (только Main, без архива)
- [ ] Поиск по чатам (Ctrl+S) - [x] Поиск по чатам (Ctrl+S)
- [ ] Скролл истории сообщений - [ ] Скролл истории сообщений
- [ ] Загрузка имён пользователей (вместо User_ID) - [ ] Загрузка имён пользователей (вместо User_ID)
- [ ] Отметка сообщений как прочитанные - [ ] Отметка сообщений как прочитанные

View File

@@ -25,6 +25,9 @@ pub struct App {
pub folders: Vec<String>, pub folders: Vec<String>,
pub selected_folder: usize, pub selected_folder: usize,
pub is_loading: bool, pub is_loading: bool,
// Search state
pub is_searching: bool,
pub search_query: String,
} }
impl App { impl App {
@@ -49,6 +52,8 @@ impl App {
folders: vec!["All".to_string()], folders: vec!["All".to_string()],
selected_folder: 0, selected_folder: 0,
is_loading: true, is_loading: true,
is_searching: false,
search_query: String::new(),
} }
} }
@@ -115,4 +120,80 @@ impl App {
self.selected_chat_id self.selected_chat_id
.and_then(|id| self.chats.iter().find(|c| c.id == 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();
}
}
}
} }

View File

@@ -15,14 +15,70 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.status_message = None; app.status_message = None;
return; 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 - навигация (работает и в списке чатов, и для скролла сообщений) // Cmd+j/k - навигация (работает и в списке чатов, и для скролла сообщений)
if has_super { if has_super {
match key.code { match key.code {
// Cmd+j - вниз (следующий чат ИЛИ скролл вниз) // Cmd+Down - вниз (следующий чат ИЛИ скролл вниз)
KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down => { KeyCode::Down => {
if app.selected_chat_id.is_some() { if app.selected_chat_id.is_some() {
// В открытом чате - скролл вниз (к новым сообщениям) // В открытом чате - скролл вниз (к новым сообщениям)
if app.message_scroll_offset > 0 { if app.message_scroll_offset > 0 {
@@ -33,8 +89,8 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
app.next_chat(); app.next_chat();
} }
} }
// Cmd+k - вверх (предыдущий чат ИЛИ скролл вверх) // Cmd+Up - вверх (предыдущий чат ИЛИ скролл вверх)
KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up => { KeyCode::Up => {
if app.selected_chat_id.is_some() { if app.selected_chat_id.is_some() {
// В открытом чате - скролл вверх (к старым сообщениям) // В открытом чате - скролл вверх (к старым сообщениям)
app.message_scroll_offset += 3; app.message_scroll_offset += 3;
@@ -69,13 +125,6 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
return; 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 - открыть чат или отправить сообщение // Enter - открыть чат или отправить сообщение
if key.code == KeyCode::Enter { if key.code == KeyCode::Enter {
@@ -151,14 +200,12 @@ pub async fn handle(app: &mut App, key: KeyEvent) {
_ => {} _ => {}
} }
} else { } else {
// В режиме списка чатов - навигация j/k и переключение папок // В режиме списка чатов - навигация стрелками и переключение папок
match key.code { match key.code {
// j или д - следующий чат KeyCode::Down => {
KeyCode::Char('j') | KeyCode::Char('д') | KeyCode::Down => {
app.next_chat(); app.next_chat();
} }
// k или л - предыдущий чат KeyCode::Up => {
KeyCode::Char('k') | KeyCode::Char('л') | KeyCode::Up => {
app.previous_chat(); app.previous_chat();
} }
// Цифры - переключение папок // Цифры - переключение папок

View File

@@ -1,5 +1,6 @@
use std::env; 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::functions;
use tdlib_rs::types::{Chat as TdChat, Message as TdMessage}; use tdlib_rs::types::{Chat as TdChat, Message as TdMessage};
@@ -20,6 +21,7 @@ pub enum AuthState {
pub struct ChatInfo { pub struct ChatInfo {
pub id: i64, pub id: i64,
pub title: String, pub title: String,
pub username: Option<String>,
pub last_message: String, pub last_message: String,
pub last_message_date: i32, pub last_message_date: i32,
pub unread_count: i32, pub unread_count: i32,
@@ -44,6 +46,10 @@ pub struct TdClient {
client_id: i32, client_id: i32,
pub chats: Vec<ChatInfo>, pub chats: Vec<ChatInfo>,
pub current_chat_messages: Vec<MessageInfo>, pub current_chat_messages: Vec<MessageInfo>,
/// Кэш usernames: user_id -> username
user_usernames: HashMap<i64, String>,
/// Связь chat_id -> user_id для приватных чатов
chat_user_ids: HashMap<i64, i64>,
} }
#[allow(dead_code)] #[allow(dead_code)]
@@ -64,6 +70,8 @@ impl TdClient {
client_id, client_id,
chats: Vec::new(), chats: Vec::new(),
current_chat_messages: 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) => { Update::NewMessage(_new_msg) => {
// Новые сообщения обрабатываются при обновлении UI // Новые сообщения обрабатываются при обновлении 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) { fn add_or_update_chat(&mut self, td_chat: &TdChat) {
// Проверяем, есть ли у чата позиция в ChatList::Main // Ищем позицию в Main списке (если есть)
// Если нет - не добавляем (это архивные чаты или связанные группы)
let main_position = td_chat.positions.iter().find(|pos| { let main_position = td_chat.positions.iter().find(|pos| {
matches!(pos.list, ChatList::Main) matches!(pos.list, ChatList::Main)
}); });
// Если чат не в Main списке - удаляем его если был, и выходим // Получаем order и is_pinned из позиции, или используем значения по умолчанию
let Some(position) = main_position else { let (order, is_pinned) = main_position
self.chats.retain(|c| c.id != td_chat.id); .map(|p| (p.order, p.is_pinned))
return; .unwrap_or((1, false)); // order=1 чтобы чат отображался
};
// Если order == 0, чат не должен отображаться
if position.order == 0 {
self.chats.retain(|c| c.id != td_chat.id);
return;
}
let (last_message, last_message_date) = td_chat let (last_message, last_message_date) = td_chat
.last_message .last_message
@@ -207,14 +224,25 @@ impl TdClient {
.map(|m| (extract_message_text_static(m), m.date)) .map(|m| (extract_message_text_static(m), m.date))
.unwrap_or_default(); .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 { let chat_info = ChatInfo {
id: td_chat.id, id: td_chat.id,
title: td_chat.title.clone(), title: td_chat.title.clone(),
username,
last_message, last_message,
last_message_date, last_message_date,
unread_count: td_chat.unread_count, unread_count: td_chat.unread_count,
is_pinned: position.is_pinned, is_pinned,
order: position.order, order,
}; };
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) {
@@ -222,8 +250,15 @@ impl TdClient {
existing.last_message = chat_info.last_message; existing.last_message = chat_info.last_message;
existing.last_message_date = chat_info.last_message_date; existing.last_message_date = chat_info.last_message_date;
existing.unread_count = chat_info.unread_count; existing.unread_count = chat_info.unread_count;
existing.is_pinned = chat_info.is_pinned; // Обновляем username если он появился
existing.order = chat_info.order; 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 { } else {
self.chats.push(chat_info); self.chats.push(chat_info);
} }

View File

@@ -17,26 +17,44 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) {
.split(area); .split(area);
// Search box // 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)) .block(Block::default().borders(Borders::ALL))
.style(Style::default().fg(Color::DarkGray)); .style(search_style);
f.render_widget(search, chat_chunks[0]); f.render_widget(search, chat_chunks[0]);
// Chat list // Chat list (filtered if searching)
let items: Vec<ListItem> = app let filtered_chats = app.get_filtered_chats();
.chats let items: Vec<ListItem> = filtered_chats
.iter() .iter()
.map(|chat| { .map(|chat| {
let is_selected = app.selected_chat_id == Some(chat.id); let is_selected = app.selected_chat_id == Some(chat.id);
let prefix = if is_selected { "" } else { " " }; 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 { let unread_badge = if chat.unread_count > 0 {
format!(" ({})", chat.unread_count) format!(" ({})", chat.unread_count)
} else { } else {
String::new() 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); let style = Style::default().fg(Color::White);
ListItem::new(content).style(style) ListItem::new(content).style(style)

View File

@@ -11,10 +11,12 @@ pub fn render(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.is_searching {
" j/k: Navigate | Enter: Select | Esc: Cancel ".to_string()
} else if app.selected_chat_id.is_some() { } else if app.selected_chat_id.is_some() {
" Cmd+j/k: Scroll | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string() " Cmd+j/k: Scroll | Enter: Send | Esc: Close | Ctrl+R: Refresh | Ctrl+C: Quit ".to_string()
} else { } 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() { let style = if app.error_message.is_some() {