feat: implement photo albums (media groups) and persist account selection

Group photos with shared media_album_id into single album bubbles with
grid layout (up to 3x cols). Album navigation treats grouped photos as
one unit (j/k skip entire album). Persist selected account to
accounts.toml so it survives app restart.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-22 16:18:04 +03:00
parent 8bd08318bb
commit 78fe09bf11
18 changed files with 1011 additions and 30 deletions

View File

@@ -2,6 +2,48 @@
## Статус: Фаза 14 — Мультиаккаунт (IN PROGRESS) ## Статус: Фаза 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<MessageInfo>)`. Сообщения с одинаковым `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) ### Оптимизация: Ленивая загрузка сообщений при открытии чата (DONE)
Чат открывается мгновенно (< 1 сек) вместо 5-30 сек для больших чатов. Чат открывается мгновенно (< 1 сек) вместо 5-30 сек для больших чатов.

View File

@@ -35,18 +35,50 @@ pub trait MessageMethods<T: TdClientTrait> {
impl<T: TdClientTrait> MessageMethods<T> for App<T> { impl<T: TdClientTrait> MessageMethods<T> for App<T> {
fn start_message_selection(&mut self) { 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 { if total == 0 {
return; return;
} }
// Начинаем с последнего сообщения (индекс len-1 = самое новое внизу) // Начинаем с последнего сообщения (индекс 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) { fn select_previous_message(&mut self) {
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state { if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index > 0 { 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(); self.stop_playback();
} }
} }
@@ -59,7 +91,30 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
} }
if let ChatState::MessageSelection { selected_index } = &mut self.chat_state { if let ChatState::MessageSelection { selected_index } = &mut self.chat_state {
if *selected_index < total - 1 { 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(); self.stop_playback();
} else { } else {
// Дошли до самого нового сообщения - выходим из режима выбора // Дошли до самого нового сообщения - выходим из режима выбора

View File

@@ -59,6 +59,22 @@ pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500;
#[cfg(feature = "images")] #[cfg(feature = "images")]
pub const INLINE_IMAGE_MAX_WIDTH: usize = 50; 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 // Audio
// ============================================================================ // ============================================================================

View File

@@ -182,6 +182,34 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
app.needs_redraw = true; 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 // Очищаем устаревший typing status
if app.td_client.clear_stale_typing_status() { if app.td_client.clear_stale_typing_status() {
app.needs_redraw = true; app.needs_redraw = true;
@@ -315,7 +343,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
) )
.await; .await;
// Авто-загрузка фото (последние 30 сообщений) // Авто-загрузка фото — неблокирующая фоновая задача (до 5 фото параллельно)
#[cfg(feature = "images")] #[cfg(feature = "images")]
{ {
use crate::tdlib::PhotoDownloadState; use crate::tdlib::PhotoDownloadState;
@@ -326,7 +354,7 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
.current_chat_messages() .current_chat_messages()
.iter() .iter()
.rev() .rev()
.take(30) .take(5)
.filter_map(|msg| { .filter_map(|msg| {
msg.photo_info().and_then(|p| { msg.photo_info().and_then(|p| {
matches!(p.download_state, PhotoDownloadState::NotDownloaded) matches!(p.download_state, PhotoDownloadState::NotDownloaded)
@@ -335,22 +363,42 @@ async fn run_app<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
}) })
.collect(); .collect();
for file_id in &photo_file_ids { if !photo_file_ids.is_empty() {
if let Ok(Ok(path)) = tokio::time::timeout( let client_id = app.td_client.client_id();
let (tx, rx) =
tokio::sync::mpsc::unbounded_channel::<(i32, Result<String, String>)>();
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), Duration::from_secs(5),
app.td_client.download_file(*file_id), async {
match tdlib_rs::functions::download_file(
file_id, 1, 0, 0, true, client_id,
) )
.await .await
{ {
for msg in app.td_client.current_chat_messages_mut() { Ok(tdlib_rs::enums::File::File(file))
if let Some(photo) = msg.photo_info_mut() { if file.local.is_downloading_completed
if photo.file_id == *file_id { && !file.local.path.is_empty() =>
photo.download_state = {
PhotoDownloadState::Downloaded(path); Ok(file.local.path)
break;
}
} }
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<B: ratatui::backend::Backend, T: tdlib::TdClientTrait>(
} }
// 3. Reset app state // 3. Reset app state
app.current_account_name = account_name; app.current_account_name = account_name.clone();
app.screen = AppScreen::Loading; 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.chats.clear();
app.selected_chat_id = None; app.selected_chat_id = None;
app.chat_state = Default::default(); app.chat_state = Default::default();

View File

@@ -15,6 +15,8 @@ pub enum MessageGroup {
SenderHeader { is_outgoing: bool, sender_name: String }, SenderHeader { is_outgoing: bool, sender_name: String },
/// Сообщение /// Сообщение
Message(MessageInfo), Message(MessageInfo),
/// Альбом (группа фото с одинаковым media_album_id)
Album(Vec<MessageInfo>),
} }
/// Группирует сообщения по дате и отправителю /// Группирует сообщения по дате и отправителю
@@ -51,6 +53,10 @@ pub enum MessageGroup {
/// // Рендерим сообщение /// // Рендерим сообщение
/// println!("{}", msg.text()); /// println!("{}", msg.text());
/// } /// }
/// MessageGroup::Album(messages) => {
/// // Рендерим альбом (группу фото)
/// println!("Album with {} photos", messages.len());
/// }
/// } /// }
/// } /// }
/// ``` /// ```
@@ -58,12 +64,28 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
let mut result = Vec::new(); let mut result = Vec::new();
let mut last_day: Option<i64> = None; let mut last_day: Option<i64> = None;
let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name) let mut last_sender: Option<(bool, String)> = None; // (is_outgoing, sender_name)
let mut album_acc: Vec<MessageInfo> = Vec::new();
/// Сбрасывает аккумулятор альбома в результат
fn flush_album(acc: &mut Vec<MessageInfo>, result: &mut Vec<MessageGroup>) {
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 { for msg in messages {
// Проверяем, нужно ли добавить разделитель даты // Проверяем, нужно ли добавить разделитель даты
let msg_day = get_day(msg.date()); let msg_day = get_day(msg.date());
if last_day != Some(msg_day) { if last_day != Some(msg_day) {
// Flush аккумулятор перед разделителем даты
flush_album(&mut album_acc, &mut result);
// Добавляем разделитель даты // Добавляем разделитель даты
result.push(MessageGroup::DateSeparator(msg.date())); result.push(MessageGroup::DateSeparator(msg.date()));
last_day = Some(msg_day); last_day = Some(msg_day);
@@ -82,6 +104,8 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
let show_sender_header = last_sender.as_ref() != Some(&current_sender); let show_sender_header = last_sender.as_ref() != Some(&current_sender);
if show_sender_header { if show_sender_header {
// Flush аккумулятор перед сменой отправителя
flush_album(&mut album_acc, &mut result);
result.push(MessageGroup::SenderHeader { result.push(MessageGroup::SenderHeader {
is_outgoing: msg.is_outgoing(), is_outgoing: msg.is_outgoing(),
sender_name, sender_name,
@@ -89,10 +113,36 @@ pub fn group_messages(messages: &[MessageInfo]) -> Vec<MessageGroup> {
last_sender = Some(current_sender); 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())); result.push(MessageGroup::Message(msg.clone()));
} }
// Flush оставшийся аккумулятор
flush_album(&mut album_acc, &mut result);
result result
} }
@@ -246,4 +296,152 @@ mod tests {
assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. })); assert!(matches!(grouped[1], MessageGroup::SenderHeader { .. }));
assert!(matches!(grouped[2], MessageGroup::Message(_))); 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");
}
}
} }

View File

@@ -76,7 +76,8 @@ pub fn convert_message(
.text(content) .text(content)
.entities(entities) .entities(entities)
.date(message.date) .date(message.date)
.edit_date(message.edit_date); .edit_date(message.edit_date)
.media_album_id(message.media_album_id);
// Применяем флаги // Применяем флаги
if message.is_outgoing { if message.is_outgoing {

View File

@@ -30,7 +30,8 @@ impl MessageManager {
.text(content_text) .text(content_text)
.entities(entities) .entities(entities)
.date(msg.date) .date(msg.date)
.edit_date(msg.edit_date); .edit_date(msg.edit_date)
.media_album_id(msg.media_album_id);
if msg.is_outgoing { if msg.is_outgoing {
builder = builder.outgoing(); builder = builder.outgoing();

View File

@@ -107,6 +107,8 @@ pub struct MessageMetadata {
pub date: i32, pub date: i32,
/// Дата редактирования (0 если не редактировалось) /// Дата редактирования (0 если не редактировалось)
pub edit_date: i32, pub edit_date: i32,
/// ID медиа-альбома (0 если не часть альбома)
pub media_album_id: i64,
} }
/// Контент сообщения (текст и форматирование) /// Контент сообщения (текст и форматирование)
@@ -175,6 +177,7 @@ impl MessageInfo {
sender_name, sender_name,
date, date,
edit_date, edit_date,
media_album_id: 0,
}, },
content: MessageContent { content: MessageContent {
text: content, text: content,
@@ -213,6 +216,10 @@ impl MessageInfo {
self.metadata.edit_date > 0 self.metadata.edit_date > 0
} }
pub fn media_album_id(&self) -> i64 {
self.metadata.media_album_id
}
pub fn text(&self) -> &str { pub fn text(&self) -> &str {
&self.content.text &self.content.text
} }
@@ -337,6 +344,7 @@ pub struct MessageBuilder {
forward_from: Option<ForwardInfo>, forward_from: Option<ForwardInfo>,
reactions: Vec<ReactionInfo>, reactions: Vec<ReactionInfo>,
media: Option<MediaInfo>, media: Option<MediaInfo>,
media_album_id: i64,
} }
impl MessageBuilder { impl MessageBuilder {
@@ -358,6 +366,7 @@ impl MessageBuilder {
forward_from: None, forward_from: None,
reactions: Vec::new(), reactions: Vec::new(),
media: None, media: None,
media_album_id: 0,
} }
} }
@@ -461,6 +470,12 @@ impl MessageBuilder {
self self
} }
/// Установить ID медиа-альбома
pub fn media_album_id(mut self, id: i64) -> Self {
self.media_album_id = id;
self
}
/// Построить MessageInfo из данных builder'а /// Построить MessageInfo из данных builder'а
pub fn build(self) -> MessageInfo { pub fn build(self) -> MessageInfo {
let mut msg = MessageInfo::new( let mut msg = MessageInfo::new(
@@ -480,6 +495,7 @@ impl MessageBuilder {
self.reactions, self.reactions,
); );
msg.content.media = self.media; msg.content.media = self.media;
msg.metadata.media_album_id = self.media_album_id;
msg msg
} }
} }

View File

@@ -524,10 +524,197 @@ pub struct DeferredImageRender {
pub photo_path: String, pub photo_path: String,
/// Смещение в строках от начала всего списка сообщений /// Смещение в строках от начала всего списка сообщений
pub line_offset: usize, pub line_offset: usize,
/// Горизонтальное смещение от левого края контента (для сетки альбомов)
pub x_offset: u16,
pub width: u16, pub width: u16,
pub height: 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<MessageId>,
) -> (Vec<Line<'static>>, Vec<DeferredImageRender>) {
use crate::constants::{ALBUM_GRID_MAX_COLS, ALBUM_PHOTO_GAP, ALBUM_PHOTO_HEIGHT, ALBUM_PHOTO_WIDTH};
let mut lines: Vec<Line<'static>> = Vec::new();
let mut deferred: Vec<DeferredImageRender> = 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")] #[cfg(feature = "images")]
pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 { pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 {

View File

@@ -13,5 +13,5 @@ pub use chat_list_item::render_chat_list_item;
pub use emoji_picker::render_emoji_picker; pub use emoji_picker::render_emoji_picker;
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header}; pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
#[cfg(feature = "images")] #[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}; pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};

View File

@@ -251,6 +251,7 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
message_id: msg.id(), message_id: msg.id(),
photo_path: path.clone(), photo_path: path.clone(),
line_offset: placeholder_start, line_offset: placeholder_start,
x_offset: 0,
width: img_width, width: img_width,
height: img_height, height: img_height,
}); });
@@ -259,6 +260,48 @@ fn render_message_list<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &mut Ap
lines.extend(bubble_lines); 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<T: TdClientTrait>(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 для скорости // Используем inline_renderer с Halfblocks для скорости

View File

@@ -115,6 +115,7 @@ pub struct TestMessageBuilder {
reply_to: Option<ReplyInfo>, reply_to: Option<ReplyInfo>,
forward_from: Option<ForwardInfo>, forward_from: Option<ForwardInfo>,
reactions: Vec<ReactionInfo>, reactions: Vec<ReactionInfo>,
media_album_id: i64,
} }
impl TestMessageBuilder { impl TestMessageBuilder {
@@ -134,6 +135,7 @@ impl TestMessageBuilder {
reply_to: None, reply_to: None,
forward_from: None, forward_from: None,
reactions: vec![], reactions: vec![],
media_album_id: 0,
} }
} }
@@ -187,8 +189,13 @@ impl TestMessageBuilder {
self self
} }
pub fn media_album_id(mut self, id: i64) -> Self {
self.media_album_id = id;
self
}
pub fn build(self) -> MessageInfo { pub fn build(self) -> MessageInfo {
MessageInfo::new( let mut msg = MessageInfo::new(
MessageId::new(self.id), MessageId::new(self.id),
self.sender_name, self.sender_name,
self.is_outgoing, self.is_outgoing,
@@ -203,7 +210,9 @@ impl TestMessageBuilder {
self.reply_to, self.reply_to,
self.forward_from, self.forward_from,
self.reactions, self.reactions,
) );
msg.metadata.media_album_id = self.media_album_id;
msg
} }
} }

View File

@@ -288,6 +288,137 @@ async fn test_normal_mode_auto_enters_message_selection() {
assert!(app.is_selecting_message()); 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: Циклическая навигация по списку чатов (переход с конца в начало) /// Test: Циклическая навигация по списку чатов (переход с конца в начало)
#[tokio::test] #[tokio::test]
async fn test_circular_navigation_optional() { async fn test_circular_navigation_optional() {

View File

@@ -387,3 +387,118 @@ fn snapshot_selected_message() {
let output = buffer_to_string(&buffer); let output = buffer_to_string(&buffer);
assert_snapshot!("selected_message", output); 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);
}

View File

@@ -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... │
└──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -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... │
└──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -0,0 +1,28 @@
---
source: tests/messages.rs
expression: output
---
┌──────────────────────────────────────────────────────────────────────────────┐
│👤 Mom │
└──────────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────┐
│ ──────── 02.01.2022 ──────── │
│ │
│Alice ──────────────── │
│ (14:33) 📷 [Фото] │
│▶ (14:33) 📷 [Фото] │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
│ │
└──────────────────────────────────────────────────────────────────────────────┘
┌ Выбор сообщения ─────────────────────────────────────────────────────────────┐
│↑↓ · r ответ · f переслать · y копир. · d удалить · Esc │
└──────────────────────────────────────────────────────────────────────────────┘

View File

@@ -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... │
└──────────────────────────────────────────────────────────────────────────────┘