diff --git a/CONTEXT.md b/CONTEXT.md index d10fbc7..60865de 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -2,6 +2,48 @@ ## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS) +### Photo Albums (Media Groups) — DONE + +Фото-альбомы (несколько фото в одном сообщении) теперь группируются в один пузырь с сеткой фото. + +**Проблема**: TDLib отправляет альбомы как отдельные `Message` с общим `media_album_id: i64`. Ранее проект это поле игнорировал — каждое фото отображалось как отдельный пузырь. + +**Решение:** + +1. **Data Model** — `media_album_id: i64` в `MessageMetadata`, `MessageBuilder`, getter `MessageInfo::media_album_id()`. Оба конвертера (async + sync) передают поле из TDLib. + +2. **Message Grouping** — новый вариант `MessageGroup::Album(Vec)`. Сообщения с одинаковым `media_album_id != 0` группируются; одиночное сообщение с album_id остаётся `Message`. + +3. **Album Grid Constants** — `ALBUM_PHOTO_WIDTH: 16`, `ALBUM_PHOTO_HEIGHT: 8`, `ALBUM_PHOTO_GAP: 1`, `ALBUM_GRID_MAX_COLS: 3` (3×16 + 2×1 = 50 = `INLINE_IMAGE_MAX_WIDTH`). + +4. **`render_album_bubble()`** — сетка фото (до 3 в ряд), `DeferredImageRender` с `x_offset` для каждого фото, общая подпись и timestamp, индикация выбора, статусы загрузки. + +5. **Integration** — `Album` arm в `render_message_list`, `x_offset` в second pass. Без feature `images` — fallback через отдельные bubble. + +**Модифицированные файлы:** +- `src/tdlib/types.rs` — `media_album_id` в `MessageMetadata`, `MessageBuilder`, getter +- `src/tdlib/messages/convert.rs` — передача `media_album_id` в builder +- `src/tdlib/message_converter.rs` — передача `media_album_id` в builder +- `src/message_grouping.rs` — `Album` variant + album detection + 4 новых теста +- `src/constants.rs` — album grid constants +- `src/ui/components/message_bubble.rs` — `x_offset` в `DeferredImageRender`, `render_album_bubble()` +- `src/ui/components/mod.rs` — export `render_album_bubble` +- `src/ui/messages.rs` — `Album` arm + `x_offset` в second pass + +6. **Навигация j/k по альбомам** — альбом обрабатывается как одно сообщение. `select_previous_message()` / `select_next_message()` перескакивают через все сообщения альбома. `start_message_selection()` встаёт на первый элемент альбома если последнее сообщение — часть альбома. + +7. **Тесты** — 4 unit-теста в `message_grouping.rs`, 5 snapshot-тестов в `tests/messages.rs`, 3 теста навигации в `tests/input_navigation.rs`. + +**Дополнительно модифицированные файлы:** +- `src/app/methods/messages.rs` — навигация перескакивает альбомы +- `tests/helpers/test_data.rs` — `TestMessageBuilder::media_album_id()` +- `tests/messages.rs` — 5 snapshot-тестов для альбомов +- `tests/input_navigation.rs` — 3 теста навигации по альбомам + +**Что НЕ меняется:** image modal (v), auto-download, одиночные фото. + +--- + ### Оптимизация: Ленивая загрузка сообщений при открытии чата (DONE) Чат открывается мгновенно (< 1 сек) вместо 5-30 сек для больших чатов. diff --git a/src/app/methods/messages.rs b/src/app/methods/messages.rs index 20c9ed5..c4a2b5c 100644 --- a/src/app/methods/messages.rs +++ b/src/app/methods/messages.rs @@ -35,18 +35,50 @@ pub trait MessageMethods { impl MessageMethods for App { fn start_message_selection(&mut self) { - let total = self.td_client.current_chat_messages().len(); + let messages = self.td_client.current_chat_messages(); + let total = messages.len(); if total == 0 { return; } // Начинаем с последнего сообщения (индекс len-1 = самое новое внизу) - self.chat_state = ChatState::MessageSelection { selected_index: total - 1 }; + // Если оно часть альбома — перемещаемся к первому элементу альбома + let mut idx = total - 1; + let album_id = messages[idx].media_album_id(); + if album_id != 0 { + while idx > 0 && messages[idx - 1].media_album_id() == album_id { + idx -= 1; + } + } + self.chat_state = ChatState::MessageSelection { selected_index: idx }; } fn select_previous_message(&mut self) { if let ChatState::MessageSelection { selected_index } = &mut self.chat_state { if *selected_index > 0 { - *selected_index -= 1; + let messages = self.td_client.current_chat_messages(); + let current_album_id = messages[*selected_index].media_album_id(); + + // Перескакиваем через все сообщения текущего альбома назад + let mut new_index = *selected_index - 1; + if current_album_id != 0 { + while new_index > 0 + && messages[new_index].media_album_id() == current_album_id + { + new_index -= 1; + } + } + + // Если попали в середину другого альбома — перемещаемся к его первому элементу + let target_album_id = messages[new_index].media_album_id(); + if target_album_id != 0 { + while new_index > 0 + && messages[new_index - 1].media_album_id() == target_album_id + { + new_index -= 1; + } + } + + *selected_index = new_index; self.stop_playback(); } } @@ -59,7 +91,30 @@ impl MessageMethods for App { } if let ChatState::MessageSelection { selected_index } = &mut self.chat_state { if *selected_index < total - 1 { - *selected_index += 1; + let messages = self.td_client.current_chat_messages(); + let current_album_id = messages[*selected_index].media_album_id(); + + // Перескакиваем через все сообщения текущего альбома вперёд + let mut new_index = *selected_index + 1; + if current_album_id != 0 { + while new_index < total - 1 + && messages[new_index].media_album_id() == current_album_id + { + new_index += 1; + } + // Если мы ещё на последнем элементе альбома — нужно шагнуть на следующее + if messages[new_index].media_album_id() == current_album_id + && new_index < total - 1 + { + new_index += 1; + } + } + + if new_index >= total { + self.chat_state = ChatState::Normal; + } else { + *selected_index = new_index; + } self.stop_playback(); } else { // Дошли до самого нового сообщения - выходим из режима выбора diff --git a/src/constants.rs b/src/constants.rs index 4321107..692cce6 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -59,6 +59,22 @@ pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500; #[cfg(feature = "images")] pub const INLINE_IMAGE_MAX_WIDTH: usize = 50; +/// Ширина одного фото в альбоме (в символах) +#[cfg(feature = "images")] +pub const ALBUM_PHOTO_WIDTH: u16 = 16; + +/// Высота одного фото в альбоме (в строках) +#[cfg(feature = "images")] +pub const ALBUM_PHOTO_HEIGHT: u16 = 8; + +/// Отступ между фото в альбоме (в символах) +#[cfg(feature = "images")] +pub const ALBUM_PHOTO_GAP: u16 = 1; + +/// Максимальное количество фото в одном ряду альбома +#[cfg(feature = "images")] +pub const ALBUM_GRID_MAX_COLS: usize = 3; + // ============================================================================ // Audio // ============================================================================ diff --git a/src/main.rs b/src/main.rs index a4fe205..b0286e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -182,6 +182,34 @@ async fn run_app( app.needs_redraw = true; } + // Обрабатываем результаты фоновой загрузки фото + #[cfg(feature = "images")] + { + use crate::tdlib::PhotoDownloadState; + + let mut got_photos = false; + if let Some(ref mut rx) = app.photo_download_rx { + while let Ok((file_id, result)) = rx.try_recv() { + let new_state = match result { + Ok(path) => PhotoDownloadState::Downloaded(path), + Err(_) => PhotoDownloadState::Error("Ошибка загрузки".to_string()), + }; + for msg in app.td_client.current_chat_messages_mut() { + if let Some(photo) = msg.photo_info_mut() { + if photo.file_id == file_id { + photo.download_state = new_state; + got_photos = true; + break; + } + } + } + } + } + if got_photos { + app.needs_redraw = true; + } + } + // Очищаем устаревший typing status if app.td_client.clear_stale_typing_status() { app.needs_redraw = true; @@ -315,7 +343,7 @@ async fn run_app( ) .await; - // Авто-загрузка фото (последние 30 сообщений) + // Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно) #[cfg(feature = "images")] { use crate::tdlib::PhotoDownloadState; @@ -326,7 +354,7 @@ async fn run_app( .current_chat_messages() .iter() .rev() - .take(30) + .take(5) .filter_map(|msg| { msg.photo_info().and_then(|p| { matches!(p.download_state, PhotoDownloadState::NotDownloaded) @@ -335,22 +363,42 @@ async fn run_app( }) .collect(); - for file_id in &photo_file_ids { - if let Ok(Ok(path)) = tokio::time::timeout( - Duration::from_secs(5), - app.td_client.download_file(*file_id), - ) - .await - { - for msg in app.td_client.current_chat_messages_mut() { - if let Some(photo) = msg.photo_info_mut() { - if photo.file_id == *file_id { - photo.download_state = - PhotoDownloadState::Downloaded(path); - break; - } - } - } + if !photo_file_ids.is_empty() { + let client_id = app.td_client.client_id(); + let (tx, rx) = + tokio::sync::mpsc::unbounded_channel::<(i32, Result)>(); + app.photo_download_rx = Some(rx); + + for file_id in photo_file_ids { + let tx = tx.clone(); + tokio::spawn(async move { + let result = tokio::time::timeout( + Duration::from_secs(5), + async { + match tdlib_rs::functions::download_file( + file_id, 1, 0, 0, true, client_id, + ) + .await + { + Ok(tdlib_rs::enums::File::File(file)) + if file.local.is_downloading_completed + && !file.local.path.is_empty() => + { + Ok(file.local.path) + } + Ok(_) => Err("Файл не скачан".to_string()), + Err(e) => Err(format!("{:?}", e)), + } + }, + ) + .await; + + let result = match result { + Ok(r) => r, + Err(_) => Err("Таймаут загрузки".to_string()), + }; + let _ = tx.send((file_id, result)); + }); } } } @@ -371,8 +419,15 @@ async fn run_app( } // 3. Reset app state - app.current_account_name = account_name; + app.current_account_name = account_name.clone(); app.screen = AppScreen::Loading; + + // 4. Persist selected account as default for next launch + let mut accounts_config = accounts::load_or_create(); + accounts_config.default_account = account_name; + if let Err(e) = accounts::save(&accounts_config) { + tracing::warn!("Could not save default account: {}", e); + } app.chats.clear(); app.selected_chat_id = None; app.chat_state = Default::default(); diff --git a/src/message_grouping.rs b/src/message_grouping.rs index 5ccb0c2..020c12b 100644 --- a/src/message_grouping.rs +++ b/src/message_grouping.rs @@ -15,6 +15,8 @@ pub enum MessageGroup { SenderHeader { is_outgoing: bool, sender_name: String }, /// Сообщение Message(MessageInfo), + /// Альбом (группа фото с одинаковым media_album_id) + Album(Vec), } /// Группирует сообщения по дате и отправителю @@ -51,6 +53,10 @@ pub enum MessageGroup { /// // Рендерим сообщение /// println!("{}", msg.text()); /// } +/// MessageGroup::Album(messages) => { +/// // Рендерим альбом (группу фото) +/// println!("Album with {} photos", messages.len()); +/// } /// } /// } /// ``` @@ -58,12 +64,28 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec { let mut result = Vec::new(); let mut last_day: Option = None; let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name) + let mut album_acc: Vec = Vec::new(); + + /// Сбрасывает аккумулятор альбома в результат + fn flush_album(acc: &mut Vec, result: &mut Vec) { + if acc.is_empty() { + return; + } + if acc.len() >= 2 { + result.push(MessageGroup::Album(std::mem::take(acc))); + } else { + // Одно сообщение — не альбом + result.push(MessageGroup::Message(acc.remove(0))); + } + } for msg in messages { // Проверяем, нужно ли добавить разделитель даты let msg_day = get_day(msg.date()); if last_day != Some(msg_day) { + // Flush аккумулятор перед разделителем даты + flush_album(&mut album_acc, &mut result); // Добавляем разделитель даты result.push(MessageGroup::DateSeparator(msg.date())); last_day = Some(msg_day); @@ -82,6 +104,8 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec { let show_sender_header = last_sender.as_ref() != Some(¤t_sender); if show_sender_header { + // Flush аккумулятор перед сменой отправителя + flush_album(&mut album_acc, &mut result); result.push(MessageGroup::SenderHeader { is_outgoing: msg.is_outgoing(), sender_name, @@ -89,10 +113,36 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec { last_sender = Some(current_sender); } - // Добавляем само сообщение + // Проверяем, является ли сообщение частью альбома + let album_id = msg.media_album_id(); + if album_id != 0 { + // Проверяем, совпадает ли album_id с текущим аккумулятором + if let Some(first) = album_acc.first() { + if first.media_album_id() == album_id { + // Тот же альбом — добавляем + album_acc.push(msg.clone()); + continue; + } else { + // Другой альбом — flush старый, начинаем новый + flush_album(&mut album_acc, &mut result); + album_acc.push(msg.clone()); + continue; + } + } else { + // Аккумулятор пуст — начинаем новый альбом + album_acc.push(msg.clone()); + continue; + } + } + + // Обычное сообщение (не альбом) — flush аккумулятор + flush_album(&mut album_acc, &mut result); result.push(MessageGroup::Message(msg.clone())); } + // Flush оставшийся аккумулятор + flush_album(&mut album_acc, &mut result); + result } @@ -246,4 +296,152 @@ mod tests { assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. })); assert!(matches!(grouped[2], MessageGroup::Message(_))); } + + #[test] + fn test_album_grouping_two_photos() { + let msg1 = MessageBuilder::new(MessageId::new(1)) + .sender_name("Alice") + .text("Photo 1") + .date(1609459200) + .incoming() + .media_album_id(12345) + .build(); + + let msg2 = MessageBuilder::new(MessageId::new(2)) + .sender_name("Alice") + .text("Photo 2") + .date(1609459201) + .incoming() + .media_album_id(12345) + .build(); + + let messages = vec![msg1, msg2]; + let grouped = group_messages(&messages); + + // DateSep, SenderHeader, Album + assert_eq!(grouped.len(), 3); + assert!(matches!(grouped[0], MessageGroup::DateSeparator(_))); + assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. })); + if let MessageGroup::Album(album) = &grouped[2] { + assert_eq!(album.len(), 2); + assert_eq!(album[0].id(), MessageId::new(1)); + assert_eq!(album[1].id(), MessageId::new(2)); + } else { + panic!("Expected Album, got {:?}", grouped[2]); + } + } + + #[test] + fn test_album_single_photo_not_album() { + // Одно сообщение с album_id → не альбом, обычное сообщение + let msg = MessageBuilder::new(MessageId::new(1)) + .sender_name("Alice") + .text("Single photo") + .date(1609459200) + .incoming() + .media_album_id(12345) + .build(); + + let messages = vec![msg]; + let grouped = group_messages(&messages); + + // DateSep, SenderHeader, Message (не Album) + assert_eq!(grouped.len(), 3); + assert!(matches!(grouped[2], MessageGroup::Message(_))); + } + + #[test] + fn test_album_with_regular_messages() { + let msg1 = MessageBuilder::new(MessageId::new(1)) + .sender_name("Alice") + .text("Text message") + .date(1609459200) + .incoming() + .build(); + + let msg2 = MessageBuilder::new(MessageId::new(2)) + .sender_name("Alice") + .text("Photo 1") + .date(1609459201) + .incoming() + .media_album_id(100) + .build(); + + let msg3 = MessageBuilder::new(MessageId::new(3)) + .sender_name("Alice") + .text("Photo 2") + .date(1609459202) + .incoming() + .media_album_id(100) + .build(); + + let msg4 = MessageBuilder::new(MessageId::new(4)) + .sender_name("Alice") + .text("After album") + .date(1609459203) + .incoming() + .build(); + + let messages = vec![msg1, msg2, msg3, msg4]; + let grouped = group_messages(&messages); + + // DateSep, SenderHeader, Message, Album, Message + assert_eq!(grouped.len(), 5); + assert!(matches!(grouped[2], MessageGroup::Message(_))); + assert!(matches!(grouped[3], MessageGroup::Album(_))); + assert!(matches!(grouped[4], MessageGroup::Message(_))); + } + + #[test] + fn test_two_different_albums() { + let msg1 = MessageBuilder::new(MessageId::new(1)) + .sender_name("Alice") + .text("Album 1 - Photo 1") + .date(1609459200) + .incoming() + .media_album_id(100) + .build(); + + let msg2 = MessageBuilder::new(MessageId::new(2)) + .sender_name("Alice") + .text("Album 1 - Photo 2") + .date(1609459201) + .incoming() + .media_album_id(100) + .build(); + + let msg3 = MessageBuilder::new(MessageId::new(3)) + .sender_name("Alice") + .text("Album 2 - Photo 1") + .date(1609459202) + .incoming() + .media_album_id(200) + .build(); + + let msg4 = MessageBuilder::new(MessageId::new(4)) + .sender_name("Alice") + .text("Album 2 - Photo 2") + .date(1609459203) + .incoming() + .media_album_id(200) + .build(); + + let messages = vec![msg1, msg2, msg3, msg4]; + let grouped = group_messages(&messages); + + // DateSep, SenderHeader, Album(2), Album(2) + assert_eq!(grouped.len(), 4); + if let MessageGroup::Album(a1) = &grouped[2] { + assert_eq!(a1.len(), 2); + assert_eq!(a1[0].media_album_id(), 100); + } else { + panic!("Expected first Album"); + } + if let MessageGroup::Album(a2) = &grouped[3] { + assert_eq!(a2.len(), 2); + assert_eq!(a2[0].media_album_id(), 200); + } else { + panic!("Expected second Album"); + } + } } diff --git a/src/tdlib/message_converter.rs b/src/tdlib/message_converter.rs index 466b6e2..6e72d8f 100644 --- a/src/tdlib/message_converter.rs +++ b/src/tdlib/message_converter.rs @@ -76,7 +76,8 @@ pub fn convert_message( .text(content) .entities(entities) .date(message.date) - .edit_date(message.edit_date); + .edit_date(message.edit_date) + .media_album_id(message.media_album_id); // Применяем флаги if message.is_outgoing { diff --git a/src/tdlib/messages/convert.rs b/src/tdlib/messages/convert.rs index feedc9f..bdf7d5d 100644 --- a/src/tdlib/messages/convert.rs +++ b/src/tdlib/messages/convert.rs @@ -30,7 +30,8 @@ impl MessageManager { .text(content_text) .entities(entities) .date(msg.date) - .edit_date(msg.edit_date); + .edit_date(msg.edit_date) + .media_album_id(msg.media_album_id); if msg.is_outgoing { builder = builder.outgoing(); diff --git a/src/tdlib/types.rs b/src/tdlib/types.rs index 580b96e..a929a83 100644 --- a/src/tdlib/types.rs +++ b/src/tdlib/types.rs @@ -107,6 +107,8 @@ pub struct MessageMetadata { pub date: i32, /// Дата редактирования (0 если не редактировалось) pub edit_date: i32, + /// ID медиа-альбома (0 если не часть альбома) + pub media_album_id: i64, } /// Контент сообщения (текст и форматирование) @@ -175,6 +177,7 @@ impl MessageInfo { sender_name, date, edit_date, + media_album_id: 0, }, content: MessageContent { text: content, @@ -213,6 +216,10 @@ impl MessageInfo { self.metadata.edit_date > 0 } + pub fn media_album_id(&self) -> i64 { + self.metadata.media_album_id + } + pub fn text(&self) -> &str { &self.content.text } @@ -337,6 +344,7 @@ pub struct MessageBuilder { forward_from: Option, reactions: Vec, media: Option, + media_album_id: i64, } impl MessageBuilder { @@ -358,6 +366,7 @@ impl MessageBuilder { forward_from: None, reactions: Vec::new(), media: None, + media_album_id: 0, } } @@ -461,6 +470,12 @@ impl MessageBuilder { self } + /// Установить ID медиа-альбома + pub fn media_album_id(mut self, id: i64) -> Self { + self.media_album_id = id; + self + } + /// Построить MessageInfo из данных builder'а pub fn build(self) -> MessageInfo { let mut msg = MessageInfo::new( @@ -480,6 +495,7 @@ impl MessageBuilder { self.reactions, ); msg.content.media = self.media; + msg.metadata.media_album_id = self.media_album_id; msg } } diff --git a/src/ui/components/message_bubble.rs b/src/ui/components/message_bubble.rs index 60d4058..4a4a521 100644 --- a/src/ui/components/message_bubble.rs +++ b/src/ui/components/message_bubble.rs @@ -524,10 +524,197 @@ pub struct DeferredImageRender { pub photo_path: String, /// Смещение в строках от начала всего списка сообщений pub line_offset: usize, + /// Горизонтальное смещение от левого края контента (для сетки альбомов) + pub x_offset: u16, pub width: u16, pub height: u16, } +/// Рендерит bubble для альбома (группы фото с общим media_album_id) +/// +/// Фото отображаются в сетке (до 3 в ряд), с общей подписью и timestamp. +#[cfg(feature = "images")] +pub fn render_album_bubble( + messages: &[MessageInfo], + config: &Config, + content_width: usize, + selected_msg_id: Option, +) -> (Vec>, Vec) { + use crate::constants::{ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH}; + + let mut lines: Vec> = Vec::new(); + let mut deferred: Vec = Vec::new(); + + let is_selected = messages.iter().any(|m| selected_msg_id == Some(m.id())); + let is_outgoing = messages.first().map_or(false, |m| m.is_outgoing()); + + // Selection marker + let selection_marker = if is_selected { "▶ " } else { "" }; + + // Фильтруем фото + let photos: Vec<&MessageInfo> = messages.iter().filter(|m| m.has_photo()).collect(); + let photo_count = photos.len(); + + if photo_count == 0 { + // Нет фото — рендерим как обычные сообщения + for msg in messages { + lines.extend(render_message_bubble(msg, config, content_width, selected_msg_id, None)); + } + return (lines, deferred); + } + + // Grid layout + let cols = photo_count.min(ALBUM_GRID_MAX_COLS); + let rows = (photo_count + cols - 1) / cols; + + // Добавляем маркер выбора на первую строку + if is_selected { + lines.push(Line::from(vec![ + Span::styled( + selection_marker, + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD), + ), + ])); + } + + let grid_start_line = lines.len(); + + // Генерируем placeholder-строки для сетки + for row in 0..rows { + for line_in_row in 0..ALBUM_PHOTO_HEIGHT { + let mut spans = Vec::new(); + + // Для исходящих — добавляем отступ справа + if is_outgoing { + let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH + + (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP; + let padding = content_width.saturating_sub(grid_width as usize + 1); + spans.push(Span::raw(" ".repeat(padding))); + } + + // Для каждого столбца в этом ряду + for col in 0..cols { + let photo_idx = row * cols + col; + if photo_idx >= photo_count { + break; + } + + let msg = photos[photo_idx]; + if let Some(photo) = msg.photo_info() { + match &photo.download_state { + PhotoDownloadState::Downloaded(path) => { + if line_in_row == 0 { + // Регистрируем deferred render для этого фото + let x_off = if is_outgoing { + let grid_width = cols as u16 * ALBUM_PHOTO_WIDTH + + (cols as u16).saturating_sub(1) * ALBUM_PHOTO_GAP; + let padding = content_width.saturating_sub(grid_width as usize + 1) as u16; + padding + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP) + } else { + col as u16 * (ALBUM_PHOTO_WIDTH + ALBUM_PHOTO_GAP) + }; + + deferred.push(DeferredImageRender { + message_id: msg.id(), + photo_path: path.clone(), + line_offset: grid_start_line + row * ALBUM_PHOTO_HEIGHT as usize, + x_offset: x_off, + width: ALBUM_PHOTO_WIDTH, + height: ALBUM_PHOTO_HEIGHT, + }); + } + // Пустая строка — placeholder для изображения + } + PhotoDownloadState::Downloading => { + if line_in_row == ALBUM_PHOTO_HEIGHT / 2 { + spans.push(Span::styled( + "⏳ Загрузка...", + Style::default().fg(Color::Yellow), + )); + } + } + PhotoDownloadState::Error(e) => { + if line_in_row == ALBUM_PHOTO_HEIGHT / 2 { + let err_text: String = e.chars().take(14).collect(); + spans.push(Span::styled( + format!("❌ {}", err_text), + Style::default().fg(Color::Red), + )); + } + } + PhotoDownloadState::NotDownloaded => { + if line_in_row == ALBUM_PHOTO_HEIGHT / 2 { + spans.push(Span::styled( + "📷", + Style::default().fg(Color::Gray), + )); + } + } + } + } + } + + lines.push(Line::from(spans)); + } + } + + // Caption: собираем непустые тексты (без "📷 [Фото]" prefix) + let captions: Vec<&str> = messages + .iter() + .map(|m| m.text()) + .filter(|t| !t.is_empty() && !t.starts_with("📷")) + .collect(); + + let msg_color = if is_selected { + config.parse_color(&config.colors.selected_message) + } else if is_outgoing { + config.parse_color(&config.colors.outgoing_message) + } else { + config.parse_color(&config.colors.incoming_message) + }; + + // Timestamp из последнего сообщения + let last_msg = messages.last().unwrap(); + let time = format_timestamp_with_tz(last_msg.date(), &config.general.timezone); + + if !captions.is_empty() { + let caption_text = captions.join(" "); + let time_suffix = format!(" ({})", time); + + if is_outgoing { + let total_len = caption_text.chars().count() + time_suffix.chars().count(); + let padding = content_width.saturating_sub(total_len + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(caption_text, Style::default().fg(msg_color)), + Span::styled(time_suffix, Style::default().fg(Color::Gray)), + ])); + } else { + lines.push(Line::from(vec![ + Span::styled(format!(" ({})", time), Style::default().fg(Color::Gray)), + Span::raw(" "), + Span::styled(caption_text, Style::default().fg(msg_color)), + ])); + } + } else { + // Без подписи — только timestamp + let time_text = format!("({})", time); + if is_outgoing { + let padding = content_width.saturating_sub(time_text.chars().count() + 1); + lines.push(Line::from(vec![ + Span::raw(" ".repeat(padding)), + Span::styled(time_text, Style::default().fg(Color::Gray)), + ])); + } else { + lines.push(Line::from(vec![ + Span::styled(format!(" {}", time_text), Style::default().fg(Color::Gray)), + ])); + } + } + + (lines, deferred) +} + /// Вычисляет высоту изображения (в строках) с учётом пропорций #[cfg(feature = "images")] pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 { diff --git a/src/ui/components/mod.rs b/src/ui/components/mod.rs index ef148fa..d338a9d 100644 --- a/src/ui/components/mod.rs +++ b/src/ui/components/mod.rs @@ -13,5 +13,5 @@ pub use chat_list_item::render_chat_list_item; pub use emoji_picker::render_emoji_picker; pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header}; #[cfg(feature = "images")] -pub use message_bubble::{DeferredImageRender, calculate_image_height}; +pub use message_bubble::{DeferredImageRender, calculate_image_height, render_album_bubble}; pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar}; diff --git a/src/ui/messages.rs b/src/ui/messages.rs index b9121e6..843e73b 100644 --- a/src/ui/messages.rs +++ b/src/ui/messages.rs @@ -251,6 +251,7 @@ fn render_message_list(f: &mut Frame, area: Rect, app: &mut Ap message_id: msg.id(), photo_path: path.clone(), line_offset: placeholder_start, + x_offset: 0, width: img_width, height: img_height, }); @@ -259,6 +260,48 @@ fn render_message_list(f: &mut Frame, area: Rect, app: &mut Ap lines.extend(bubble_lines); } + MessageGroup::Album(album_messages) => { + #[cfg(feature = "images")] + { + let is_selected = album_messages + .iter() + .any(|m| selected_msg_id == Some(m.id())); + if is_selected { + selected_msg_line = Some(lines.len()); + } + + let (bubble_lines, album_deferred) = components::render_album_bubble( + &album_messages, + app.config(), + content_width, + selected_msg_id, + ); + + for mut d in album_deferred { + d.line_offset += lines.len(); + deferred_images.push(d); + } + + lines.extend(bubble_lines); + } + #[cfg(not(feature = "images"))] + { + // Fallback: рендерим каждое сообщение отдельно + for msg in &album_messages { + let is_selected = selected_msg_id == Some(msg.id()); + if is_selected { + selected_msg_line = Some(lines.len()); + } + lines.extend(components::render_message_bubble( + msg, + app.config(), + content_width, + selected_msg_id, + app.playback_state.as_ref(), + )); + } + } + } } } @@ -334,7 +377,7 @@ fn render_message_list(f: &mut Frame, area: Rect, app: &mut Ap } // Рендерим с ПОЛНОЙ высотой (не сжимаем) - let img_rect = Rect::new(content_x, img_y, d.width, d.height); + let img_rect = Rect::new(content_x + d.x_offset, img_y, d.width, d.height); // ОПТИМИЗАЦИЯ: Загружаем только видимые изображения (не все сразу) // Используем inline_renderer с Halfblocks для скорости diff --git a/tests/helpers/test_data.rs b/tests/helpers/test_data.rs index 02a7ac4..982043b 100644 --- a/tests/helpers/test_data.rs +++ b/tests/helpers/test_data.rs @@ -115,6 +115,7 @@ pub struct TestMessageBuilder { reply_to: Option, forward_from: Option, reactions: Vec, + media_album_id: i64, } impl TestMessageBuilder { @@ -134,6 +135,7 @@ impl TestMessageBuilder { reply_to: None, forward_from: None, reactions: vec![], + media_album_id: 0, } } @@ -187,8 +189,13 @@ impl TestMessageBuilder { self } + pub fn media_album_id(mut self, id: i64) -> Self { + self.media_album_id = id; + self + } + pub fn build(self) -> MessageInfo { - MessageInfo::new( + let mut msg = MessageInfo::new( MessageId::new(self.id), self.sender_name, self.is_outgoing, @@ -203,7 +210,9 @@ impl TestMessageBuilder { self.reply_to, self.forward_from, self.reactions, - ) + ); + msg.metadata.media_album_id = self.media_album_id; + msg } } diff --git a/tests/input_navigation.rs b/tests/input_navigation.rs index 98dbbec..1829383 100644 --- a/tests/input_navigation.rs +++ b/tests/input_navigation.rs @@ -288,6 +288,137 @@ async fn test_normal_mode_auto_enters_message_selection() { assert!(app.is_selecting_message()); } +/// Test: j/k перескакивают через альбом как одно сообщение +#[tokio::test] +async fn test_album_navigation_skips_grouped_messages() { + let messages = vec![ + TestMessageBuilder::new("Before album", 1).sender("Alice").build(), + TestMessageBuilder::new("Photo 1", 2) + .sender("Alice") + .media_album_id(100) + .build(), + TestMessageBuilder::new("Photo 2", 3) + .sender("Alice") + .media_album_id(100) + .build(), + TestMessageBuilder::new("Photo 3", 4) + .sender("Alice") + .media_album_id(100) + .build(), + TestMessageBuilder::new("After album", 5).sender("Alice").build(), + ]; + + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat 1", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .build(); + + // Входим в режим выбора — начинаем с последнего (index=4, "After album") + app.start_message_selection(); + assert!(app.is_selecting_message()); + + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "After album"); + + // k (up) — перескакиваем альбом, попадаем на первый элемент альбома (index=1) + app.select_previous_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "Photo 1"); + assert_eq!(msg.media_album_id(), 100); + + // k (up) — перескакиваем на сообщение до альбома (index=0) + app.select_previous_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "Before album"); + + // j (down) — перескакиваем на первый элемент альбома (index=1) + app.select_next_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "Photo 1"); + + // j (down) — перескакиваем альбом, попадаем на "After album" (index=4) + app.select_next_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "After album"); +} + +/// Test: Начало выбора, когда последнее сообщение — часть альбома +#[tokio::test] +async fn test_album_navigation_start_at_album_end() { + let messages = vec![ + TestMessageBuilder::new("Regular", 1).sender("Alice").build(), + TestMessageBuilder::new("Album Photo 1", 2) + .sender("Alice") + .media_album_id(200) + .build(), + TestMessageBuilder::new("Album Photo 2", 3) + .sender("Alice") + .media_album_id(200) + .build(), + ]; + + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat 1", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .build(); + + // Входим в режим выбора — должны оказаться на первом элементе альбома (index=1) + app.start_message_selection(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "Album Photo 1"); + + // k (up) — на обычное сообщение + app.select_previous_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "Regular"); +} + +/// Test: Два альбома подряд — навигация между ними +#[tokio::test] +async fn test_album_navigation_two_albums() { + let messages = vec![ + TestMessageBuilder::new("A1-P1", 1) + .sender("Alice") + .media_album_id(100) + .build(), + TestMessageBuilder::new("A1-P2", 2) + .sender("Alice") + .media_album_id(100) + .build(), + TestMessageBuilder::new("A2-P1", 3) + .sender("Alice") + .media_album_id(200) + .build(), + TestMessageBuilder::new("A2-P2", 4) + .sender("Alice") + .media_album_id(200) + .build(), + ]; + + let mut app = TestAppBuilder::new() + .with_chats(vec![create_test_chat("Chat 1", 101)]) + .selected_chat(101) + .with_messages(101, messages) + .build(); + + // Начинаем — последний альбом (index=2, первый элемент album 200) + app.start_message_selection(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "A2-P1"); + + // k — перескакиваем на первый альбом (index=0) + app.select_previous_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "A1-P1"); + + // j — перескакиваем на второй альбом (index=2) + app.select_next_message(); + let msg = app.get_selected_message().unwrap(); + assert_eq!(msg.text(), "A2-P1"); +} + /// Test: Циклическая навигация по списку чатов (переход с конца в начало) #[tokio::test] async fn test_circular_navigation_optional() { diff --git a/tests/messages.rs b/tests/messages.rs index 746158b..3c89e99 100644 --- a/tests/messages.rs +++ b/tests/messages.rs @@ -387,3 +387,118 @@ fn snapshot_selected_message() { let output = buffer_to_string(&buffer); assert_snapshot!("selected_message", output); } + +#[test] +fn snapshot_album_incoming() { + let chat = create_test_chat("Mom", 123); + let msg1 = TestMessageBuilder::new("📷 [Фото]", 1) + .sender("Alice") + .media_album_id(12345) + .build(); + let msg2 = TestMessageBuilder::new("Caption for album", 2) + .sender("Alice") + .media_album_id(12345) + .build(); + let msg3 = TestMessageBuilder::new("📷 [Фото]", 3) + .sender("Alice") + .media_album_id(12345) + .build(); + + let mut app = TestAppBuilder::new() + .with_chat(chat) + .with_messages(123, vec![msg1, msg2, msg3]) + .selected_chat(123) + .build(); + + let buffer = render_to_buffer(80, 24, |f| { + tele_tui::ui::messages::render(f, f.area(), &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("album_incoming", output); +} + +#[test] +fn snapshot_album_outgoing() { + let chat = create_test_chat("Mom", 123); + let msg1 = TestMessageBuilder::new("📷 [Фото]", 1) + .outgoing() + .media_album_id(99999) + .build(); + let msg2 = TestMessageBuilder::new("My vacation photos", 2) + .outgoing() + .media_album_id(99999) + .build(); + + let mut app = TestAppBuilder::new() + .with_chat(chat) + .with_messages(123, vec![msg1, msg2]) + .selected_chat(123) + .build(); + + let buffer = render_to_buffer(80, 24, |f| { + tele_tui::ui::messages::render(f, f.area(), &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("album_outgoing", output); +} + +#[test] +fn snapshot_album_with_regular_messages() { + let chat = create_test_chat("Group Chat", 123); + let msg1 = TestMessageBuilder::new("Regular message before", 1) + .sender("Alice") + .build(); + let msg2 = TestMessageBuilder::new("📷 [Фото]", 2) + .sender("Alice") + .media_album_id(555) + .build(); + let msg3 = TestMessageBuilder::new("Album caption", 3) + .sender("Alice") + .media_album_id(555) + .build(); + let msg4 = TestMessageBuilder::new("Regular message after", 4) + .sender("Alice") + .build(); + + let mut app = TestAppBuilder::new() + .with_chat(chat) + .with_messages(123, vec![msg1, msg2, msg3, msg4]) + .selected_chat(123) + .build(); + + let buffer = render_to_buffer(80, 24, |f| { + tele_tui::ui::messages::render(f, f.area(), &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("album_with_regular_messages", output); +} + +#[test] +fn snapshot_album_selected() { + let chat = create_test_chat("Mom", 123); + let msg1 = TestMessageBuilder::new("📷 [Фото]", 1) + .sender("Alice") + .media_album_id(777) + .build(); + let msg2 = TestMessageBuilder::new("📷 [Фото]", 2) + .sender("Alice") + .media_album_id(777) + .build(); + + let mut app = TestAppBuilder::new() + .with_chat(chat) + .with_messages(123, vec![msg1, msg2]) + .selected_chat(123) + .selecting_message(1) // Выбираем одно из сообщений альбома + .build(); + + let buffer = render_to_buffer(80, 24, |f| { + tele_tui::ui::messages::render(f, f.area(), &mut app); + }); + + let output = buffer_to_string(&buffer); + assert_snapshot!("album_selected", output); +} diff --git a/tests/snapshots/messages__album_incoming.snap b/tests/snapshots/messages__album_incoming.snap new file mode 100644 index 0000000..4e9c3b5 --- /dev/null +++ b/tests/snapshots/messages__album_incoming.snap @@ -0,0 +1,28 @@ +--- +source: tests/messages.rs +expression: output +--- +┌──────────────────────────────────────────────────────────────────────────────┐ +│👤 Mom │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ──────── 02.01.2022 ──────── │ +│ │ +│Alice ──────────────── │ +│ (14:33) 📷 [Фото] │ +│ (14:33) Caption for album │ +│ (14:33) 📷 [Фото] │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│> Press i to type... │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__album_outgoing.snap b/tests/snapshots/messages__album_outgoing.snap new file mode 100644 index 0000000..0aa2f09 --- /dev/null +++ b/tests/snapshots/messages__album_outgoing.snap @@ -0,0 +1,28 @@ +--- +source: tests/messages.rs +expression: output +--- +┌──────────────────────────────────────────────────────────────────────────────┐ +│👤 Mom │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ──────── 02.01.2022 ──────── │ +│ │ +│ Вы ──────────────── │ +│ 📷 [Фото] (14:33 ✓✓)│ +│ My vacation photos (14:33 ✓✓) │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│> Press i to type... │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__album_selected.snap b/tests/snapshots/messages__album_selected.snap new file mode 100644 index 0000000..c8dd19e --- /dev/null +++ b/tests/snapshots/messages__album_selected.snap @@ -0,0 +1,28 @@ +--- +source: tests/messages.rs +expression: output +--- +┌──────────────────────────────────────────────────────────────────────────────┐ +│👤 Mom │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ──────── 02.01.2022 ──────── │ +│ │ +│Alice ──────────────── │ +│ (14:33) 📷 [Фото] │ +│▶ (14:33) 📷 [Фото] │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌ Выбор сообщения ─────────────────────────────────────────────────────────────┐ +│↑↓ · r ответ · f переслать · y копир. · d удалить · Esc │ +└──────────────────────────────────────────────────────────────────────────────┘ diff --git a/tests/snapshots/messages__album_with_regular_messages.snap b/tests/snapshots/messages__album_with_regular_messages.snap new file mode 100644 index 0000000..264475b --- /dev/null +++ b/tests/snapshots/messages__album_with_regular_messages.snap @@ -0,0 +1,28 @@ +--- +source: tests/messages.rs +expression: output +--- +┌──────────────────────────────────────────────────────────────────────────────┐ +│👤 Group Chat │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│ ──────── 02.01.2022 ──────── │ +│ │ +│Alice ──────────────── │ +│ (14:33) Regular message before │ +│ (14:33) 📷 [Фото] │ +│ (14:33) Album caption │ +│ (14:33) Regular message after │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +│ │ +└──────────────────────────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────────────────────┐ +│> Press i to type... │ +└──────────────────────────────────────────────────────────────────────────────┘