Files
telegram-tui/src/ui/components/message_bubble.rs
Mikhail Kilin 78fe09bf11 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>
2026-02-22 16:18:04 +03:00

769 lines
30 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Message bubble component
//!
//! Отвечает за рендеринг отдельных элементов списка сообщений:
//! - Разделители дат
//! - Заголовки отправителей
//! - Сами сообщения (с forward, reply, reactions)
use crate::config::Config;
use crate::formatting;
use crate::tdlib::{MessageInfo, PlaybackState, PlaybackStatus};
#[cfg(feature = "images")]
use crate::tdlib::PhotoDownloadState;
use crate::types::MessageId;
use crate::utils::{format_date, format_timestamp_with_tz};
use ratatui::{
style::{Color, Modifier, Style},
text::{Line, Span},
};
/// Информация о строке после переноса: текст и позиция в оригинале
struct WrappedLine {
text: String,
/// Начальная позиция в символах от начала оригинального текста
start_offset: usize,
}
/// Разбивает текст на строки с учётом максимальной ширины и `\n`
fn wrap_text_with_offsets(text: &str, max_width: usize) -> Vec<WrappedLine> {
let mut all_lines = Vec::new();
let mut char_offset = 0;
for segment in text.split('\n') {
let wrapped = wrap_paragraph(segment, max_width, char_offset);
all_lines.extend(wrapped);
char_offset += segment.chars().count() + 1; // +1 за '\n'
}
if all_lines.is_empty() {
all_lines.push(WrappedLine {
text: String::new(),
start_offset: 0,
});
}
all_lines
}
/// Разбивает один абзац (без `\n`) на строки по ширине
fn wrap_paragraph(text: &str, max_width: usize, base_offset: usize) -> Vec<WrappedLine> {
if max_width == 0 {
return vec![WrappedLine {
text: text.to_string(),
start_offset: base_offset,
}];
}
let mut result = Vec::new();
let mut current_line = String::new();
let mut current_width = 0;
let mut line_start_offset = base_offset;
let chars: Vec<char> = text.chars().collect();
let mut word_start = 0;
let mut in_word = false;
for (i, ch) in chars.iter().enumerate() {
if ch.is_whitespace() {
if in_word {
let word: String = chars[word_start..i].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
current_width = word_width;
line_start_offset = base_offset + word_start;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
current_width += 1 + word_width;
} else {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
current_line = word;
current_width = word_width;
line_start_offset = base_offset + word_start;
}
in_word = false;
}
} else if !in_word {
word_start = i;
in_word = true;
}
}
if in_word {
let word: String = chars[word_start..].iter().collect();
let word_width = word.chars().count();
if current_width == 0 {
current_line = word;
line_start_offset = base_offset + word_start;
} else if current_width + 1 + word_width <= max_width {
current_line.push(' ');
current_line.push_str(&word);
} else {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
current_line = word;
line_start_offset = base_offset + word_start;
}
}
if !current_line.is_empty() {
result.push(WrappedLine {
text: current_line,
start_offset: line_start_offset,
});
}
if result.is_empty() {
result.push(WrappedLine {
text: String::new(),
start_offset: base_offset,
});
}
result
}
/// Рендерит разделитель даты
///
/// # Аргументы
///
/// * `date` - timestamp сообщения
/// * `content_width` - ширина области для центрирования
/// * `is_first` - первый ли это разделитель (если нет, добавляется пустая строка сверху)
pub fn render_date_separator(date: i32, content_width: usize, is_first: bool) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if !is_first {
lines.push(Line::from("")); // Пустая строка перед разделителем
}
let date_str = format_date(date);
let date_line = format!("──────── {} ────────", date_str);
let padding = content_width.saturating_sub(date_line.chars().count()) / 2;
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(date_line, Style::default().fg(Color::Gray)),
]));
lines.push(Line::from(""));
lines
}
/// Рендерит заголовок отправителя
///
/// # Аргументы
///
/// * `is_outgoing` - исходящее ли сообщение
/// * `sender_name` - имя отправителя
/// * `content_width` - ширина области для выравнивания
/// * `is_first` - первый ли это заголовок в группе (если нет, добавляется пустая строка сверху)
pub fn render_sender_header(
is_outgoing: bool,
sender_name: &str,
content_width: usize,
is_first: bool,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if !is_first {
lines.push(Line::from("")); // Пустая строка между группами
}
let sender_style = if is_outgoing {
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD)
} else {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
};
if is_outgoing {
// Заголовок "Вы" справа
let header_text = format!("{} ────────────────", sender_name);
let header_len = header_text.chars().count();
let padding = content_width.saturating_sub(header_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(format!("{} ", sender_name), sender_style),
Span::styled("────────────────", Style::default().fg(Color::Gray)),
]));
} else {
// Заголовок входящих слева
lines.push(Line::from(vec![
Span::styled(format!("{} ", sender_name), sender_style),
Span::styled("────────────────", Style::default().fg(Color::Gray)),
]));
}
lines
}
/// Рендерит bubble одного сообщения
///
/// # Аргументы
///
/// * `msg` - сообщение для рендеринга
/// * `config` - конфигурация (цвета, timezone)
/// * `content_width` - ширина области для рендеринга
/// * `selected_msg_id` - ID выбранного сообщения (для подсветки)
pub fn render_message_bubble(
msg: &MessageInfo,
config: &Config,
content_width: usize,
selected_msg_id: Option<MessageId>,
playback_state: Option<&PlaybackState>,
) -> Vec<Line<'static>> {
let mut lines = Vec::new();
let is_selected = selected_msg_id == Some(msg.id());
// Маркер выбора
let selection_marker = if is_selected { "" } else { "" };
let marker_len = selection_marker.chars().count();
// Цвет сообщения
let msg_color = if is_selected {
config.parse_color(&config.colors.selected_message)
} else if msg.is_outgoing() {
config.parse_color(&config.colors.outgoing_message)
} else {
config.parse_color(&config.colors.incoming_message)
};
// Отображаем forward если есть
if let Some(forward) = msg.forward_from() {
let forward_line = format!("↪ Переслано от {}", forward.sender_name);
let forward_len = forward_line.chars().count();
if msg.is_outgoing() {
let padding = content_width.saturating_sub(forward_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(forward_line, Style::default().fg(Color::Magenta)),
]));
} else {
lines.push(Line::from(vec![Span::styled(
forward_line,
Style::default().fg(Color::Magenta),
)]));
}
}
// Отображаем reply если есть
if let Some(reply) = msg.reply_to() {
let reply_text: String = reply.text.chars().take(40).collect();
let ellipsis = if reply.text.chars().count() > 40 {
"..."
} else {
""
};
let reply_line = format!("{}: {}{}", reply.sender_name, reply_text, ellipsis);
let reply_len = reply_line.chars().count();
if msg.is_outgoing() {
let padding = content_width.saturating_sub(reply_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(reply_line, Style::default().fg(Color::Cyan)),
]));
} else {
lines.push(Line::from(vec![Span::styled(
reply_line,
Style::default().fg(Color::Cyan),
)]));
}
}
// Форматируем время
let time = format_timestamp_with_tz(msg.date(), &config.general.timezone);
if msg.is_outgoing() {
// Исходящие: справа, формат "текст (HH:MM ✎ ✓✓)"
let read_mark = if msg.is_read() { "✓✓" } else { "" };
let edit_mark = if msg.is_edited() { "" } else { "" };
let time_mark = format!("({} {}{})", time, edit_mark, read_mark);
let time_mark_len = time_mark.chars().count() + 1;
let max_msg_width = content_width.saturating_sub(time_mark_len + marker_len + 2);
let wrapped_lines = wrap_text_with_offsets(msg.text(), max_msg_width);
let total_wrapped = wrapped_lines.len();
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let is_last_line = i == total_wrapped - 1;
let line_len = wrapped.text.chars().count();
let line_entities =
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len);
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if is_last_line {
let full_len = line_len + time_mark_len + marker_len;
let padding = content_width.saturating_sub(full_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if is_selected && i == 0 {
// Одна строка — маркер на ней
line_spans.push(Span::styled(
selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
));
} else if is_selected {
// Последняя строка multi-line — пробелы вместо маркера
line_spans.push(Span::raw(" ".repeat(marker_len)));
}
line_spans.extend(formatted_spans);
line_spans.push(Span::styled(format!(" {}", time_mark), Style::default().fg(Color::Gray)));
lines.push(Line::from(line_spans));
} else {
let padding = content_width.saturating_sub(line_len + marker_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
if i == 0 && is_selected {
line_spans.push(Span::styled(
selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
));
} else if is_selected {
// Средние строки multi-line — пробелы вместо маркера
line_spans.push(Span::raw(" ".repeat(marker_len)));
}
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
}
}
} else {
// Входящие: слева, формат "(HH:MM ✎) текст"
let edit_mark = if msg.is_edited() { "" } else { "" };
let time_str = format!("({}{})", time, edit_mark);
let time_prefix_len = time_str.chars().count() + 2;
let max_msg_width = content_width.saturating_sub(time_prefix_len + 1);
let wrapped_lines = wrap_text_with_offsets(msg.text(), max_msg_width);
for (i, wrapped) in wrapped_lines.into_iter().enumerate() {
let line_len = wrapped.text.chars().count();
let line_entities =
formatting::adjust_entities_for_substring(msg.entities(), wrapped.start_offset, line_len);
let formatted_spans = formatting::format_text_with_entities(&wrapped.text, &line_entities, msg_color);
if i == 0 {
let mut line_spans = vec![];
if is_selected {
line_spans.push(Span::styled(
selection_marker,
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD),
));
}
line_spans.push(Span::styled(format!(" {}", time_str), Style::default().fg(Color::Gray)));
line_spans.push(Span::raw(" "));
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
} else {
let indent = " ".repeat(time_prefix_len + marker_len);
let mut line_spans = vec![Span::raw(indent)];
line_spans.extend(formatted_spans);
lines.push(Line::from(line_spans));
}
}
}
// Отображаем реакции под сообщением
if !msg.reactions().is_empty() {
let mut reaction_spans = vec![];
for reaction in msg.reactions() {
if !reaction_spans.is_empty() {
reaction_spans.push(Span::raw(" "));
}
let reaction_text = if reaction.is_chosen {
if reaction.count > 1 {
format!("[{}] {}", reaction.emoji, reaction.count)
} else {
format!("[{}]", reaction.emoji)
}
} else {
if reaction.count > 1 {
format!("{} {}", reaction.emoji, reaction.count)
} else {
reaction.emoji.clone()
}
};
let style = if reaction.is_chosen {
Style::default().fg(config.parse_color(&config.colors.reaction_chosen))
} else {
Style::default().fg(config.parse_color(&config.colors.reaction_other))
};
reaction_spans.push(Span::styled(reaction_text, style));
}
if msg.is_outgoing() {
let reactions_text: String = reaction_spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join(" ");
let reactions_len = reactions_text.chars().count();
let padding = content_width.saturating_sub(reactions_len + 1);
let mut line_spans = vec![Span::raw(" ".repeat(padding))];
line_spans.extend(reaction_spans);
lines.push(Line::from(line_spans));
} else {
lines.push(Line::from(reaction_spans));
}
}
// Отображаем индикатор воспроизведения голосового
if msg.has_voice() {
if let Some(voice) = msg.voice_info() {
let is_this_playing = playback_state
.map(|ps| ps.message_id == msg.id())
.unwrap_or(false);
let status_line = if is_this_playing {
let ps = playback_state.unwrap();
let icon = match ps.status {
PlaybackStatus::Playing => "",
PlaybackStatus::Paused => "",
PlaybackStatus::Loading => "",
_ => "",
};
let bar = render_progress_bar(ps.position, ps.duration, 20);
format!(
"{} {} {:.0}s/{:.0}s",
icon, bar, ps.position, ps.duration
)
} else {
let waveform = render_waveform(&voice.waveform, 20);
format!(" {} {:.0}s", waveform, voice.duration)
};
let status_len = status_line.chars().count();
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status_len + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status_line, Style::default().fg(Color::Cyan)),
]));
} else {
lines.push(Line::from(Span::styled(
status_line,
Style::default().fg(Color::Cyan),
)));
}
}
}
// Отображаем статус фото (если есть)
#[cfg(feature = "images")]
if let Some(photo) = msg.photo_info() {
match &photo.download_state {
PhotoDownloadState::Downloading => {
let status = "📷 ⏳ Загрузка...";
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status.chars().count() + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status, Style::default().fg(Color::Yellow)),
]));
} else {
lines.push(Line::from(Span::styled(
status,
Style::default().fg(Color::Yellow),
)));
}
}
PhotoDownloadState::Error(e) => {
let status = format!("📷 [Ошибка: {}]", e);
if msg.is_outgoing() {
let padding = content_width.saturating_sub(status.chars().count() + 1);
lines.push(Line::from(vec![
Span::raw(" ".repeat(padding)),
Span::styled(status, Style::default().fg(Color::Red)),
]));
} else {
lines.push(Line::from(Span::styled(
status,
Style::default().fg(Color::Red),
)));
}
}
PhotoDownloadState::Downloaded(_) => {
// Всегда показываем inline превью для загруженных фото
let inline_width = content_width.min(crate::constants::INLINE_IMAGE_MAX_WIDTH);
let img_height = calculate_image_height(photo.width, photo.height, inline_width);
for _ in 0..img_height {
lines.push(Line::from(""));
}
}
PhotoDownloadState::NotDownloaded => {
// Для незагруженных фото ничего не рендерим,
// текст сообщения уже содержит 📷 prefix
}
}
}
lines
}
/// Информация для отложенного рендеринга изображения поверх placeholder
#[cfg(feature = "images")]
pub struct DeferredImageRender {
pub message_id: MessageId,
/// Путь к файлу изображения
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 {
use crate::constants::{MAX_IMAGE_HEIGHT, MAX_IMAGE_WIDTH, MIN_IMAGE_HEIGHT};
let display_width = (content_width as u16).min(MAX_IMAGE_WIDTH);
let aspect = img_height as f64 / img_width as f64;
// Терминальные символы ~2:1 по высоте, компенсируем
let raw_height = (display_width as f64 * aspect * 0.5) as u16;
raw_height.clamp(MIN_IMAGE_HEIGHT, MAX_IMAGE_HEIGHT)
}
/// Рендерит progress bar для воспроизведения
fn render_progress_bar(position: f32, duration: f32, width: usize) -> String {
if duration <= 0.0 {
return "".repeat(width);
}
let ratio = (position / duration).clamp(0.0, 1.0);
let filled = (ratio * width as f32) as usize;
let empty = width.saturating_sub(filled + 1);
format!("{}{}", "".repeat(filled), "".repeat(empty))
}
/// Рендерит waveform из base64-encoded данных TDLib
fn render_waveform(waveform_b64: &str, width: usize) -> String {
const BARS: &[char] = &['▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
if waveform_b64.is_empty() {
return "".repeat(width);
}
// Декодируем waveform (каждый байт = амплитуда 0-255)
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(waveform_b64)
.unwrap_or_default();
if bytes.is_empty() {
return "".repeat(width);
}
// Сэмплируем до нужной ширины
let mut result = String::with_capacity(width * 4);
for i in 0..width {
let byte_idx = i * bytes.len() / width;
let amplitude = bytes.get(byte_idx).copied().unwrap_or(0);
let bar_idx = (amplitude as usize * (BARS.len() - 1)) / 255;
result.push(BARS[bar_idx]);
}
result
}