feat: implement Phase 11 — inline photo viewing with ratatui-image

Add feature-gated (`images`) inline photo support:
- New types: MediaInfo, PhotoInfo, PhotoDownloadState, ImagesConfig
- Media module: ImageCache (LRU filesystem cache), ImageRenderer (terminal protocol detection)
- Photo metadata extraction from TDLib MessagePhoto with download_file() API
- ViewImage command (v/м) to toggle photo expand/collapse in message selection
- Two-pass UI rendering: placeholder lines in message bubbles + StatefulImage overlay
- Collapse all expanded photos on Esc (exit selection mode)

Dependencies: ratatui-image 8.1, image 0.25 (optional, behind `images` feature flag)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Kilin
2026-02-06 21:25:17 +03:00
parent 6845ee69bf
commit b0f1f9fdc2
29 changed files with 1505 additions and 102 deletions

View File

@@ -54,6 +54,31 @@ pub struct ReactionInfo {
pub is_chosen: bool,
}
/// Информация о медиа-контенте сообщения
#[derive(Debug, Clone)]
pub enum MediaInfo {
Photo(PhotoInfo),
}
/// Информация о фотографии в сообщении
#[derive(Debug, Clone)]
pub struct PhotoInfo {
pub file_id: i32,
pub width: i32,
pub height: i32,
pub download_state: PhotoDownloadState,
pub expanded: bool,
}
/// Состояние загрузки фотографии
#[derive(Debug, Clone)]
pub enum PhotoDownloadState {
NotDownloaded,
Downloading,
Downloaded(String),
Error(String),
}
/// Метаданные сообщения (ID, отправитель, время)
#[derive(Debug, Clone)]
pub struct MessageMetadata {
@@ -65,11 +90,13 @@ pub struct MessageMetadata {
}
/// Контент сообщения (текст и форматирование)
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone)]
pub struct MessageContent {
pub text: String,
/// Сущности форматирования (bold, italic, code и т.д.)
pub entities: Vec<TextEntity>,
/// Медиа-контент (фото, видео и т.д.)
pub media: Option<MediaInfo>,
}
/// Состояние и права доступа к сообщению
@@ -132,6 +159,7 @@ impl MessageInfo {
content: MessageContent {
text: content,
entities,
media: None,
},
state: MessageState {
is_outgoing,
@@ -203,6 +231,27 @@ impl MessageInfo {
})
}
/// Проверяет, содержит ли сообщение фото
pub fn has_photo(&self) -> bool {
matches!(self.content.media, Some(MediaInfo::Photo(_)))
}
/// Возвращает ссылку на PhotoInfo (если есть)
pub fn photo_info(&self) -> Option<&PhotoInfo> {
match &self.content.media {
Some(MediaInfo::Photo(info)) => Some(info),
_ => None,
}
}
/// Возвращает мутабельную ссылку на PhotoInfo (если есть)
pub fn photo_info_mut(&mut self) -> Option<&mut PhotoInfo> {
match &mut self.content.media {
Some(MediaInfo::Photo(info)) => Some(info),
_ => None,
}
}
pub fn reply_to(&self) -> Option<&ReplyInfo> {
self.interactions.reply_to.as_ref()
}
@@ -246,6 +295,7 @@ pub struct MessageBuilder {
reply_to: Option<ReplyInfo>,
forward_from: Option<ForwardInfo>,
reactions: Vec<ReactionInfo>,
media: Option<MediaInfo>,
}
impl MessageBuilder {
@@ -266,6 +316,7 @@ impl MessageBuilder {
reply_to: None,
forward_from: None,
reactions: Vec::new(),
media: None,
}
}
@@ -363,9 +414,15 @@ impl MessageBuilder {
self
}
/// Установить медиа-контент
pub fn media(mut self, media: MediaInfo) -> Self {
self.media = Some(media);
self
}
/// Построить MessageInfo из данных builder'а
pub fn build(self) -> MessageInfo {
MessageInfo::new(
let mut msg = MessageInfo::new(
self.id,
self.sender_name,
self.is_outgoing,
@@ -380,7 +437,9 @@ impl MessageBuilder {
self.reply_to,
self.forward_from,
self.reactions,
)
);
msg.content.media = self.media;
msg
}
}