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:
@@ -362,6 +362,22 @@ impl TdClient {
|
||||
.await
|
||||
}
|
||||
|
||||
// Делегирование файловых операций
|
||||
|
||||
/// Скачивает файл по file_id и возвращает локальный путь.
|
||||
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||
match functions::download_file(file_id, 1, 0, 0, true, self.client_id).await {
|
||||
Ok(tdlib_rs::enums::File::File(file)) => {
|
||||
if file.local.is_downloading_completed && !file.local.path.is_empty() {
|
||||
Ok(file.local.path)
|
||||
} else {
|
||||
Err("Файл не скачан".to_string())
|
||||
}
|
||||
}
|
||||
Err(e) => Err(format!("Ошибка скачивания файла: {:?}", e)),
|
||||
}
|
||||
}
|
||||
|
||||
// Вспомогательные методы
|
||||
pub fn client_id(&self) -> i32 {
|
||||
self.client_id
|
||||
|
||||
@@ -159,6 +159,11 @@ impl TdClientTrait for TdClient {
|
||||
self.toggle_reaction(chat_id, message_id, reaction).await
|
||||
}
|
||||
|
||||
// ============ File methods ============
|
||||
async fn download_file(&self, file_id: i32) -> Result<String, String> {
|
||||
self.download_file(file_id).await
|
||||
}
|
||||
|
||||
fn client_id(&self) -> i32 {
|
||||
self.client_id()
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use crate::types::MessageId;
|
||||
use tdlib_rs::enums::{MessageContent, MessageSender};
|
||||
use tdlib_rs::types::Message as TdMessage;
|
||||
|
||||
use super::types::{ForwardInfo, ReactionInfo, ReplyInfo};
|
||||
use super::types::{ForwardInfo, MediaInfo, PhotoDownloadState, PhotoInfo, ReactionInfo, ReplyInfo};
|
||||
|
||||
/// Извлекает текст контента из TDLib Message
|
||||
///
|
||||
@@ -19,9 +19,9 @@ pub fn extract_content_text(msg: &TdMessage) -> String {
|
||||
MessageContent::MessagePhoto(p) => {
|
||||
let caption_text = p.caption.text.clone();
|
||||
if caption_text.is_empty() {
|
||||
"[Фото]".to_string()
|
||||
"📷 [Фото]".to_string()
|
||||
} else {
|
||||
caption_text
|
||||
format!("📷 {}", caption_text)
|
||||
}
|
||||
}
|
||||
MessageContent::MessageVideo(v) => {
|
||||
@@ -132,6 +132,40 @@ pub fn extract_reply_info(msg: &TdMessage) -> Option<ReplyInfo> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Извлекает информацию о медиа-контенте из TDLib Message
|
||||
///
|
||||
/// Для MessagePhoto: получает лучший размер фото, извлекает file_id, width, height.
|
||||
/// Возвращает None для не-медийных типов сообщений.
|
||||
pub fn extract_media_info(msg: &TdMessage) -> Option<MediaInfo> {
|
||||
match &msg.content {
|
||||
MessageContent::MessagePhoto(p) => {
|
||||
// Берём лучший (последний = самый большой) размер фото
|
||||
let best_size = p.photo.sizes.last()?;
|
||||
let file_id = best_size.photo.id;
|
||||
let width = best_size.width;
|
||||
let height = best_size.height;
|
||||
|
||||
// Проверяем, скачан ли файл
|
||||
let download_state = if !best_size.photo.local.path.is_empty()
|
||||
&& best_size.photo.local.is_downloading_completed
|
||||
{
|
||||
PhotoDownloadState::Downloaded(best_size.photo.local.path.clone())
|
||||
} else {
|
||||
PhotoDownloadState::NotDownloaded
|
||||
};
|
||||
|
||||
Some(MediaInfo::Photo(PhotoInfo {
|
||||
file_id,
|
||||
width,
|
||||
height,
|
||||
download_state,
|
||||
expanded: false,
|
||||
}))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Извлекает реакции из TDLib Message
|
||||
pub fn extract_reactions(msg: &TdMessage) -> Vec<ReactionInfo> {
|
||||
msg.interaction_info
|
||||
|
||||
@@ -13,7 +13,7 @@ impl MessageManager {
|
||||
pub(crate) async fn convert_message(&self, msg: &TdMessage) -> Option<MessageInfo> {
|
||||
use crate::tdlib::message_conversion::{
|
||||
extract_content_text, extract_entities, extract_forward_info,
|
||||
extract_reactions, extract_reply_info, extract_sender_name,
|
||||
extract_media_info, extract_reactions, extract_reply_info, extract_sender_name,
|
||||
};
|
||||
|
||||
// Извлекаем все части сообщения используя вспомогательные функции
|
||||
@@ -23,6 +23,7 @@ impl MessageManager {
|
||||
let forward_from = extract_forward_info(msg);
|
||||
let reply_to = extract_reply_info(msg);
|
||||
let reactions = extract_reactions(msg);
|
||||
let media = extract_media_info(msg);
|
||||
|
||||
let mut builder = MessageBuilder::new(MessageId::new(msg.id))
|
||||
.sender_name(sender_name)
|
||||
@@ -65,6 +66,10 @@ impl MessageManager {
|
||||
|
||||
builder = builder.reactions(reactions);
|
||||
|
||||
if let Some(media) = media {
|
||||
builder = builder.media(media);
|
||||
}
|
||||
|
||||
Some(builder.build())
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ pub use auth::AuthState;
|
||||
pub use client::TdClient;
|
||||
pub use r#trait::TdClientTrait;
|
||||
pub use types::{
|
||||
ChatInfo, FolderInfo, MessageBuilder, MessageInfo, NetworkState, ProfileInfo, ReplyInfo, UserOnlineStatus,
|
||||
ChatInfo, FolderInfo, MediaInfo, MessageBuilder, MessageInfo, NetworkState, PhotoDownloadState,
|
||||
PhotoInfo, ProfileInfo, ReplyInfo, UserOnlineStatus,
|
||||
};
|
||||
pub use users::UserCache;
|
||||
|
||||
|
||||
@@ -90,6 +90,9 @@ pub trait TdClientTrait: Send {
|
||||
reaction: String,
|
||||
) -> Result<(), String>;
|
||||
|
||||
// ============ File methods ============
|
||||
async fn download_file(&self, file_id: i32) -> Result<String, String>;
|
||||
|
||||
// ============ Getters (immutable) ============
|
||||
fn client_id(&self) -> i32;
|
||||
async fn get_me(&self) -> Result<i64, String>;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user