diff --git a/CONTEXT.md b/CONTEXT.md index 0cadb27..9e668ab 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -14,20 +14,26 @@ #### Функциональность - Загрузка списка чатов (до 50 штук) - **Фильтрация чатов**: показываются только чаты из ChatList::Main (без архива) -- Отображение названия чата и счётчика непрочитанных -- Загрузка истории сообщений при открытии чата -- Отображение сообщений с именем отправителя и временем +- Отображение названия чата, счётчика непрочитанных и **@username** +- **Иконка 📌** для закреплённых чатов +- Загрузка истории сообщений при открытии чата (множественные попытки) +- **Группировка сообщений по дате** (разделители "Сегодня", "Вчера", дата) +- **Группировка сообщений по отправителю** (заголовок с именем) +- **Отображение времени сообщений** в формате [HH:MM] +- **Галочки прочтения** (✓ отправлено, ✓✓ прочитано) - **Отправка текстовых сообщений** -- **Поиск по чатам** (Ctrl+S): фильтрация списка по названию +- **Новые сообщения в реальном времени** при открытом чате +- **Поиск по чатам** (Ctrl+S): фильтрация по названию и @username #### Управление - `↑/↓` стрелки — навигация по списку чатов - `Enter` — открыть чат / отправить сообщение - `Esc` — закрыть открытый чат / отменить поиск -- `Ctrl+S` — поиск по чатам (фильтрация по названию) +- `Ctrl+S` — поиск по чатам (фильтрация по названию и username) - `Ctrl+R` — обновить список чатов - `Ctrl+C` — выход -- `Cmd+↑/Cmd+↓` — скролл сообщений в открытом чате +- `Cmd+↑/Cmd+↓` — скролл сообщений в открытом чате (с подгрузкой старых) +- `1-9` — переключение папок (в списке чатов) - Ввод текста в поле сообщения ### Структура проекта @@ -43,17 +49,17 @@ src/ │ ├── loading.rs # Экран загрузки │ ├── auth.rs # Экран авторизации │ ├── main_screen.rs # Главный экран -│ ├── chat_list.rs # Список чатов -│ ├── messages.rs # Область сообщений +│ ├── chat_list.rs # Список чатов (с pin и username) +│ ├── messages.rs # Область сообщений (группировка по дате/отправителю) │ └── footer.rs # Подвал с командами ├── input/ │ ├── mod.rs # Роутинг ввода │ ├── auth.rs # Обработка ввода на экране авторизации │ └── main_input.rs # Обработка ввода на главном экране -├── utils.rs # Утилиты (disable_tdlib_logs, format_timestamp) +├── utils.rs # Утилиты (disable_tdlib_logs, format_timestamp, format_date, get_day) └── tdlib/ ├── mod.rs # Модуль экспорта - └── client.rs # TdClient: авторизация, загрузка чатов, сообщений, отправка + └── client.rs # TdClient: авторизация, чаты, сообщения, кеш usernames ``` ### Ключевые решения @@ -64,9 +70,11 @@ src/ 3. **Синхронизация чатов**: Чаты загружаются асинхронно через updates. Main loop периодически синхронизирует `app.chats` с `td_client.chats`. -4. **Фильтрация чатов**: Все чаты добавляются в список при получении `NewChat` update. Позиции обновляются через `ChatPosition` update. +4. **Кеширование usernames**: При получении `Update::User` сохраняем username в HashMap. При получении приватного чата связываем chat_id с user_id. -5. **Сортировка по TDLib order**: Используем `position.order` для сортировки чатов (учитывает pinned и время). +5. **Группировка сообщений**: Сообщения группируются по дате (разделители) и по отправителю (заголовки). Время отображается рядом с каждым сообщением. + +6. **Новые сообщения**: `current_chat_id` отслеживает открытый чат. При получении `NewMessage` для этого чата сообщение добавляется сразу. ### Зависимости (Cargo.toml) @@ -92,12 +100,10 @@ API_HASH=your_api_hash - [ ] Папки телеграма (сейчас только "All") - [ ] Отображение онлайн-статуса пользователя - [ ] Markdown форматирование в сообщениях -- [ ] Скролл истории сообщений (больше 50 сообщений) - [ ] Отметка сообщений как прочитанные -- [ ] Обновление чатов в реальном времени (новые сообщения) -- [ ] Загрузка имён пользователей (сейчас показывается User_ID) +- [ ] Медиа-сообщения (фото, видео, голосовые) ## Известные проблемы 1. При первом запуске нужно пройти авторизацию -2. Имя отправителя показывается как "User_ID" (нужно загружать имена пользователей) +2. Время отображается с фиксированным смещением +3 (MSK) diff --git a/src/app/mod.rs b/src/app/mod.rs index dd5d2b9..c4cec42 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -104,6 +104,7 @@ impl App { self.current_messages.clear(); self.message_input.clear(); self.message_scroll_offset = 0; + self.td_client.current_chat_id = None; } pub fn select_first_chat(&mut self) { diff --git a/src/input/main_input.rs b/src/input/main_input.rs index 7876ea6..2fa76b0 100644 --- a/src/input/main_input.rs +++ b/src/input/main_input.rs @@ -37,7 +37,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) { 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 { + match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await { Ok(Ok(messages)) => { app.current_messages = messages; app.status_message = None; @@ -160,7 +160,7 @@ pub async fn handle(app: &mut App, key: KeyEvent) { 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 { + match timeout(Duration::from_secs(10), app.td_client.get_chat_history(chat_id, 100)).await { Ok(Ok(messages)) => { app.current_messages = messages; app.status_message = None; diff --git a/src/main.rs b/src/main.rs index 1786299..fd52145 100644 --- a/src/main.rs +++ b/src/main.rs @@ -104,6 +104,21 @@ async fn run_app( app.td_client.handle_update(update); } + // Обрабатываем очередь сообщений для отметки как прочитанных + if !app.td_client.pending_view_messages.is_empty() { + app.td_client.process_pending_view_messages().await; + } + + // Синхронизируем сообщения из td_client в app (для новых сообщений в реальном времени) + if app.selected_chat_id.is_some() && !app.td_client.current_chat_messages.is_empty() { + // Добавляем новые сообщения, которых ещё нет в app.current_messages + for msg in &app.td_client.current_chat_messages { + if !app.current_messages.iter().any(|m| m.id == msg.id) { + app.current_messages.push(msg.clone()); + } + } + } + // Обновляем состояние экрана на основе auth_state update_screen_state(app).await; diff --git a/src/tdlib/client.rs b/src/tdlib/client.rs index 985b430..9f3f654 100644 --- a/src/tdlib/client.rs +++ b/src/tdlib/client.rs @@ -27,6 +27,8 @@ pub struct ChatInfo { pub unread_count: i32, pub is_pinned: bool, pub order: i64, + /// ID последнего прочитанного исходящего сообщения (для галочек) + pub last_read_outbox_message_id: i64, } #[derive(Debug, Clone)] @@ -46,10 +48,14 @@ pub struct TdClient { client_id: i32, pub chats: Vec, pub current_chat_messages: Vec, + /// ID текущего открытого чата (для получения новых сообщений) + pub current_chat_id: Option, /// Кэш usernames: user_id -> username user_usernames: HashMap, /// Связь chat_id -> user_id для приватных чатов chat_user_ids: HashMap, + /// Очередь сообщений для отметки как прочитанных: (chat_id, message_ids) + pub pending_view_messages: Vec<(i64, Vec)>, } #[allow(dead_code)] @@ -70,8 +76,10 @@ impl TdClient { client_id, chats: Vec::new(), current_chat_messages: Vec::new(), + current_chat_id: None, user_usernames: HashMap::new(), chat_user_ids: HashMap::new(), + pending_view_messages: Vec::new(), } } @@ -150,6 +158,20 @@ impl TdClient { chat.unread_count = update.unread_count; } } + Update::ChatReadOutbox(update) => { + // Обновляем last_read_outbox_message_id когда собеседник прочитал сообщения + if let Some(chat) = self.chats.iter_mut().find(|c| c.id == update.chat_id) { + chat.last_read_outbox_message_id = update.last_read_outbox_message_id; + } + // Если это текущий открытый чат — обновляем is_read у сообщений + if Some(update.chat_id) == self.current_chat_id { + for msg in &mut self.current_chat_messages { + if msg.is_outgoing && msg.id <= update.last_read_outbox_message_id { + msg.is_read = true; + } + } + } + } Update::ChatPosition(update) => { // Обновляем позицию чата или удаляем его из списка match &update.position.list { @@ -171,8 +193,22 @@ impl TdClient { } } } - Update::NewMessage(_new_msg) => { - // Новые сообщения обрабатываются при обновлении UI + Update::NewMessage(new_msg) => { + // Добавляем новое сообщение если это текущий открытый чат + let chat_id = new_msg.message.chat_id; + if Some(chat_id) == self.current_chat_id { + let msg_info = self.convert_message(&new_msg.message, chat_id); + let msg_id = msg_info.id; + let is_incoming = !msg_info.is_outgoing; + // Проверяем, что сообщение ещё не добавлено (по id) + if !self.current_chat_messages.iter().any(|m| m.id == msg_info.id) { + self.current_chat_messages.push(msg_info); + // Если это входящее сообщение — добавляем в очередь для отметки как прочитанное + if is_incoming { + self.pending_view_messages.push((chat_id, vec![msg_id])); + } + } + } } Update::User(update) => { // Сохраняем username пользователя @@ -243,6 +279,7 @@ impl TdClient { unread_count: td_chat.unread_count, is_pinned, order, + last_read_outbox_message_id: td_chat.last_read_outbox_message_id, }; if let Some(existing) = self.chats.iter_mut().find(|c| c.id == td_chat.id) { @@ -250,6 +287,7 @@ 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.last_read_outbox_message_id = chat_info.last_read_outbox_message_id; // Обновляем username если он появился if chat_info.username.is_some() { existing.username = chat_info.username; @@ -267,19 +305,31 @@ impl TdClient { self.chats.sort_by(|a, b| b.order.cmp(&a.order)); } - fn convert_message(&self, message: &TdMessage) -> MessageInfo { + fn convert_message(&self, message: &TdMessage, chat_id: i64) -> MessageInfo { let sender_name = match &message.sender_id { tdlib_rs::enums::MessageSender::User(user) => format!("User_{}", user.user_id), tdlib_rs::enums::MessageSender::Chat(chat) => format!("Chat_{}", chat.chat_id), }; + // Определяем, прочитано ли исходящее сообщение + let is_read = if message.is_outgoing { + // Сообщение прочитано, если его ID <= last_read_outbox_message_id чата + self.chats + .iter() + .find(|c| c.id == chat_id) + .map(|c| message.id <= c.last_read_outbox_message_id) + .unwrap_or(false) + } else { + true // Входящие сообщения не показывают галочки + }; + MessageInfo { id: message.id, sender_name, is_outgoing: message.is_outgoing, content: extract_message_text_static(message), date: message.date, - is_read: !message.is_outgoing || message.id <= 0, + is_read, } } @@ -339,34 +389,80 @@ impl TdClient { chat_id: i64, limit: i32, ) -> Result, String> { + // Устанавливаем текущий чат для получения новых сообщений + self.current_chat_id = Some(chat_id); let _ = functions::open_chat(chat_id, self.client_id).await; - // Загружаем историю с сервера (only_local=false) - let result = functions::get_chat_history( - chat_id, - 0, // from_message_id (0 = с последнего сообщения) - 0, // offset - limit, - false, // only_local - загружаем с сервера! - self.client_id, - ) - .await; + // Пробуем загрузить несколько раз, так как сообщения могут подгружаться с сервера + let mut all_messages: Vec = Vec::new(); + let mut from_message_id: i64 = 0; + let mut attempts = 0; + const MAX_ATTEMPTS: i32 = 3; - match result { - Ok(tdlib_rs::enums::Messages::Messages(messages)) => { - let mut result_messages: Vec = messages - .messages - .into_iter() - .filter_map(|m| m.map(|msg| self.convert_message(&msg))) - .collect(); + while attempts < MAX_ATTEMPTS { + let result = functions::get_chat_history( + chat_id, + from_message_id, + 0, // offset + limit, + false, // only_local - загружаем с сервера! + self.client_id, + ) + .await; - // Сообщения приходят от новых к старым, переворачиваем - result_messages.reverse(); - self.current_chat_messages = result_messages.clone(); - Ok(result_messages) + match result { + Ok(tdlib_rs::enums::Messages::Messages(messages)) => { + let batch: Vec = messages + .messages + .into_iter() + .filter_map(|m| m.map(|msg| self.convert_message(&msg, chat_id))) + .collect(); + + if batch.is_empty() { + break; + } + + // Запоминаем ID самого старого сообщения для следующей загрузки + if let Some(oldest) = batch.last() { + from_message_id = oldest.id; + } + + // Добавляем сообщения (они приходят от новых к старым) + all_messages.extend(batch); + attempts += 1; + + // Если получили достаточно сообщений, выходим + if all_messages.len() >= limit as usize { + break; + } + } + Err(e) => { + if all_messages.is_empty() { + return Err(format!("Ошибка загрузки сообщений: {:?}", e)); + } + break; + } } - Err(e) => Err(format!("Ошибка загрузки сообщений: {:?}", e)), } + + // Сообщения приходят от новых к старым, переворачиваем + all_messages.reverse(); + self.current_chat_messages = all_messages.clone(); + + // Отмечаем сообщения как прочитанные + if !all_messages.is_empty() { + let message_ids: Vec = all_messages.iter().map(|m| m.id).collect(); + let _ = functions::view_messages( + chat_id, + message_ids, + None, // source + true, // force_read + self.client_id, + ) + .await; + } + + Ok(all_messages) } /// Загрузка старых сообщений (для скролла вверх) @@ -391,7 +487,7 @@ impl TdClient { let mut result_messages: Vec = messages .messages .into_iter() - .filter_map(|m| m.map(|msg| self.convert_message(&msg))) + .filter_map(|m| m.map(|msg| self.convert_message(&msg, chat_id))) .collect(); // Сообщения приходят от новых к старым, переворачиваем @@ -474,6 +570,21 @@ impl TdClient { Err(e) => Err(format!("Ошибка отправки сообщения: {:?}", e)), } } + + /// Обработка очереди сообщений для отметки как прочитанных + pub async fn process_pending_view_messages(&mut self) { + let pending = std::mem::take(&mut self.pending_view_messages); + for (chat_id, message_ids) in pending { + let _ = functions::view_messages( + chat_id, + message_ids, + None, // source + true, // force_read + self.client_id, + ) + .await; + } + } } /// Статическая функция для извлечения текста сообщения (без &self) diff --git a/src/ui/chat_list.rs b/src/ui/chat_list.rs index cbd3fd1..58d460c 100644 --- a/src/ui/chat_list.rs +++ b/src/ui/chat_list.rs @@ -42,6 +42,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { .iter() .map(|chat| { let is_selected = app.selected_chat_id == Some(chat.id); + let pin_icon = if chat.is_pinned { "📌 " } else { "" }; let prefix = if is_selected { "▌ " } else { " " }; let username_text = chat.username.as_ref() @@ -54,7 +55,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &mut App) { String::new() }; - let content = format!("{}{}{}{}", prefix, chat.title, username_text, unread_badge); + let content = format!("{}{}{}{}{}", prefix, pin_icon, chat.title, username_text, unread_badge); let style = Style::default().fg(Color::White); ListItem::new(content).style(style) diff --git a/src/ui/messages.rs b/src/ui/messages.rs index 4f9f07e..e01cb2a 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -6,7 +6,7 @@ use ratatui::{ Frame, }; use crate::app::App; -use crate::utils::format_timestamp; +use crate::utils::{format_timestamp, format_date, get_day}; pub fn render(f: &mut Frame, area: Rect, app: &App) { if let Some(chat) = app.get_selected_chat() { @@ -20,7 +20,11 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .split(area); // Chat header - let header = Paragraph::new(format!("👤 {}", chat.title)) + let header_text = match &chat.username { + Some(username) => format!("👤 {} {}", chat.title, username), + None => format!("👤 {}", chat.title), + }; + let header = Paragraph::new(header_text) .block(Block::default().borders(Borders::ALL)) .style( Style::default() @@ -29,42 +33,82 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { ); f.render_widget(header, message_chunks[0]); - // Messages + // Messages с группировкой по дате и отправителю let mut lines: Vec = Vec::new(); + let mut last_day: Option = None; + let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name) for msg in &app.current_messages { - let sender_style = if msg.is_outgoing { - Style::default() - .fg(Color::Green) - .add_modifier(Modifier::BOLD) - } else { - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD) - }; + // Проверяем, нужно ли добавить разделитель даты + let msg_day = get_day(msg.date); + if last_day != Some(msg_day) { + if last_day.is_some() { + lines.push(Line::from("")); // Пустая строка перед разделителем + } + // Добавляем разделитель даты + let date_str = format_date(msg.date); + lines.push(Line::from(vec![ + Span::styled( + format!("──────── {} ────────", date_str), + Style::default().fg(Color::DarkGray), + ), + ])); + lines.push(Line::from("")); + last_day = Some(msg_day); + last_sender = None; // Сбрасываем отправителя при смене дня + } let sender_name = if msg.is_outgoing { - "You".to_string() + "Вы".to_string() } else { msg.sender_name.clone() }; + let current_sender = (msg.is_outgoing, sender_name.clone()); + + // Проверяем, нужно ли показать заголовок отправителя + let show_sender_header = last_sender.as_ref() != Some(¤t_sender); + + if show_sender_header { + // Пустая строка между группами сообщений (кроме первой) + if last_sender.is_some() { + lines.push(Line::from("")); + } + + let sender_style = if msg.is_outgoing { + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) + }; + + // Заголовок отправителя + lines.push(Line::from(vec![ + Span::styled(format!("{} ", sender_name), sender_style), + Span::styled("────────────────", Style::default().fg(Color::DarkGray)), + ])); + + last_sender = Some(current_sender); + } + + // Форматируем время (HH:MM) + let time = format_timestamp(msg.date); + let read_mark = if msg.is_outgoing { if msg.is_read { " ✓✓" } else { " ✓" } } else { "" }; - // Форматируем время - let time = format_timestamp(msg.date); - + // Сообщение с временем lines.push(Line::from(vec![ - Span::styled(format!("{} ", sender_name), sender_style), - Span::raw("── "), - Span::styled(format!("{}{}", time, read_mark), Style::default().fg(Color::DarkGray)), + Span::styled(format!(" [{}]", time), Style::default().fg(Color::DarkGray)), + Span::raw(format!(" {}", msg.content)), + Span::styled(read_mark.to_string(), Style::default().fg(Color::DarkGray)), ])); - lines.push(Line::from(msg.content.clone())); - lines.push(Line::from("")); } if lines.is_empty() { diff --git a/src/utils.rs b/src/utils.rs index d87d727..f3542f8 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -22,26 +22,60 @@ pub fn disable_tdlib_logs() { } } -/// Форматирование timestamp в человекочитаемый формат +/// Форматирование timestamp в время HH:MM pub fn format_timestamp(timestamp: i32) -> String { + let secs = timestamp as i64; + // Конвертируем в локальное время (простой способ без chrono) + // UTC + смещение для локального времени + let hours = ((secs % 86400) / 3600) as u32; + let minutes = ((secs % 3600) / 60) as u32; + + // Примерное локальное время (добавим 3 часа для MSK, можно настроить) + let local_hours = (hours + 3) % 24; + + format!("{:02}:{:02}", local_hours, minutes) +} + +/// Форматирование timestamp в дату для разделителя +pub fn format_date(timestamp: i32) -> String { use std::time::{SystemTime, UNIX_EPOCH}; let now = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() - .as_secs() as i32; + .as_secs() as i64; - let diff = now - timestamp; + let msg_day = timestamp as i64 / 86400; + let today = now / 86400; - if diff < 60 { - "just now".to_string() - } else if diff < 3600 { - format!("{}m ago", diff / 60) - } else if diff < 86400 { - format!("{}h ago", diff / 3600) + if msg_day == today { + "Сегодня".to_string() + } else if msg_day == today - 1 { + "Вчера".to_string() } else { - let secs = timestamp as u64; - let days = secs / 86400; - format!("{}d ago", (now as u64 / 86400) - days) + // Простое форматирование даты + let days_since_epoch = timestamp as i64 / 86400; + // Приблизительный расчёт даты (без учёта високосных годов) + let year = 1970 + (days_since_epoch / 365) as i32; + let day_of_year = days_since_epoch % 365; + + let months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + let mut month = 0; + let mut day = day_of_year as i32; + + for (i, &m) in months.iter().enumerate() { + if day < m { + month = i + 1; + break; + } + day -= m; + } + + format!("{:02}.{:02}.{}", day + 1, month, year) } } + +/// Получить день из timestamp для группировки +pub fn get_day(timestamp: i32) -> i64 { + timestamp as i64 / 86400 +}