Compare commits

...

3 Commits

Author SHA1 Message Date
652b101571 Merge pull request 'yet-another-changes' (#1) from yet-another-changes into main
Reviewed-on: #1
2026-01-20 11:55:25 +00:00
Mikhail Kilin
9912ac11bd fixes 2026-01-20 14:54:30 +03:00
Mikhail Kilin
699f50a59c fixes 2026-01-20 13:37:02 +03:00
8 changed files with 304 additions and 45 deletions

1
.gitignore vendored
View File

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

View File

@@ -1,6 +1,6 @@
# Текущий контекст проекта
## Статус: Базовая интеграция с TDLib работает
## Статус: Фаза 3 — улучшение UX
### Что сделано
@@ -13,27 +13,47 @@
#### Функциональность
- Загрузка списка чатов (до 50 штук)
- **Фильтрация чатов**: показываются только чаты из ChatList::Main (без архива)
- Отображение названия чата и счётчика непрочитанных
- Загрузка истории сообщений при открытии чата
- Отображение сообщений с именем отправителя и временем
- **Отправка текстовых сообщений**
- **Поиск по чатам** (Ctrl+S): фильтрация списка по названию
#### Управление
- `j/k` или стрелки — навигация по списку чатов
- `д/л` — русская раскладка для j/k
- `Enter`открыть выбранный чат
- `Esc` — закрыть открытый чат
- `Ctrl+k` — перейти к первому чату
- `↑/↓` стрелки — навигация по списку чатов
- `Enter` — открыть чат / отправить сообщение
- `Esc`закрыть открытый чат / отменить поиск
- `Ctrl+S` — поиск по чатам (фильтрация по названию)
- `Ctrl+R` — обновить список чатов
- `Ctrl+C` — выход
- `Cmd+↑/Cmd+↓` — скролл сообщений в открытом чате
- Ввод текста в поле сообщения
### Структура проекта
```
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 +64,10 @@ src/
3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`.
4. **Фильтрация чатов**: Все чаты добавляются в список при получении `NewChat` update. Позиции обновляются через `ChatPosition` update.
5. **Сортировка по TDLib order**: Используем `position.order` для сортировки чатов (учитывает pinned и время).
### Зависимости (Cargo.toml)
```toml
@@ -65,14 +89,13 @@ API_HASH=your_api_hash
## Что НЕ сделано / TODO
- [ ] Отправка сообщений
- [ ] Поиск по чатам
- [ ] Папки телеграма (сейчас только "All")
- [ ] Отображение онлайн-статуса пользователя
- [ ] Markdown форматирование в сообщениях
- [ ] Скролл истории сообщений
- [ ] Скролл истории сообщений (больше 50 сообщений)
- [ ] Отметка сообщений как прочитанные
- [ ] Обновление чатов в реальном времени (новые сообщения)
- [ ] Загрузка имён пользователей (сейчас показывается User_ID)
## Известные проблемы

View File

@@ -19,8 +19,9 @@
## Фаза 3: Улучшение UX [IN PROGRESS]
- [ ] Отправка сообщений
- [ ] Поиск по чатам (Ctrl+S)
- [x] Отправка сообщений
- [x] Фильтрация чатов (только Main, без архива)
- [x] Поиск по чатам (Ctrl+S)
- [ ] Скролл истории сообщений
- [ ] Загрузка имён пользователей (вместо User_ID)
- [ ] Отметка сообщений как прочитанные

View File

@@ -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();
}
}
}
}

View File

@@ -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();
}
// Цифры - переключение папок

View File

@@ -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(),
}
}
@@ -124,17 +132,65 @@ 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
}
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));
}
}
}
}
}
}
_ => {}
}
}
@@ -152,20 +208,41 @@ impl TdClient {
}
fn add_or_update_chat(&mut self, td_chat: &TdChat) {
// Ищем позицию в Main списке (если есть)
let main_position = td_chat.positions.iter().find(|pos| {
matches!(pos.list, ChatList::Main)
});
// Получаем 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
.as_ref()
.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: false,
order: 0,
is_pinned,
order,
};
if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) {
@@ -173,12 +250,21 @@ 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;
// Обновляем 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);
}
// Сортируем чаты по дате последнего сообщения (новые сверху)
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 {

View File

@@ -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)

View File

@@ -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() {