fixes
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@
|
||||
|
||||
# Environment variables (contains API keys)
|
||||
.env
|
||||
.DS_Store
|
||||
|
||||
12
CONTEXT.md
12
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 форматирование в сообщениях
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
- [x] Отправка сообщений
|
||||
- [x] Фильтрация чатов (только Main, без архива)
|
||||
- [ ] Поиск по чатам (Ctrl+S)
|
||||
- [x] Поиск по чатам (Ctrl+S)
|
||||
- [ ] Скролл истории сообщений
|
||||
- [ ] Загрузка имён пользователей (вместо User_ID)
|
||||
- [ ] Отметка сообщений как прочитанные
|
||||
|
||||
@@ -25,6 +25,9 @@ pub struct App {
|
||||
pub folders: Vec<String>,
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
// Цифры - переключение папок
|
||||
|
||||
@@ -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<String>,
|
||||
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<ChatInfo>,
|
||||
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)]
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ListItem> = app
|
||||
.chats
|
||||
// Chat list (filtered if searching)
|
||||
let filtered_chats = app.get_filtered_chats();
|
||||
let items: Vec<ListItem> = 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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user