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

@@ -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<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")]
pub fn calculate_image_height(img_width: i32, img_height: i32, content_width: usize) -> u16 {