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

@@ -15,6 +15,8 @@ pub enum MessageGroup {
SenderHeader { is_outgoing: bool, sender_name: String },
/// Сообщение
Message(MessageInfo),
/// Альбом (группа фото с одинаковым media_album_id)
Album(Vec<MessageInfo>),
}
/// Группирует сообщения по дате и отправителю
@@ -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<MessageGroup> {
let mut result = Vec::new();
let mut last_day: Option<i64> = None;
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 {
// Проверяем, нужно ли добавить разделитель даты
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<MessageGroup> {
let show_sender_header = last_sender.as_ref() != Some(&current_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<MessageGroup> {
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");
}
}
}