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:
@@ -68,6 +68,10 @@ pub async fn handle_message_selection<T: TdClientTrait>(app: &mut App<T>, _key:
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(feature = "images")]
|
||||
Some(crate::config::Command::ViewImage) => {
|
||||
handle_view_image(app).await;
|
||||
}
|
||||
Some(crate::config::Command::ReactMessage) => {
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
@@ -461,4 +465,168 @@ pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>,
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обработка команды ViewImage — раскрыть/свернуть превью фото
|
||||
#[cfg(feature = "images")]
|
||||
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
||||
use crate::tdlib::PhotoDownloadState;
|
||||
|
||||
if !app.config().images.show_images {
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(msg) = app.get_selected_message() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !msg.has_photo() {
|
||||
app.status_message = Some("Сообщение не содержит фото".to_string());
|
||||
return;
|
||||
}
|
||||
|
||||
let photo = msg.photo_info().unwrap();
|
||||
let file_id = photo.file_id;
|
||||
let msg_id = msg.id();
|
||||
|
||||
match &photo.download_state {
|
||||
PhotoDownloadState::Downloaded(_) if photo.expanded => {
|
||||
// Свернуть
|
||||
collapse_photo(app, msg_id);
|
||||
}
|
||||
PhotoDownloadState::Downloaded(path) => {
|
||||
// Раскрыть (файл уже скачан)
|
||||
let path = path.clone();
|
||||
expand_photo(app, msg_id, &path);
|
||||
}
|
||||
PhotoDownloadState::NotDownloaded => {
|
||||
// Проверяем кэш, затем скачиваем
|
||||
download_and_expand(app, msg_id, file_id).await;
|
||||
}
|
||||
PhotoDownloadState::Downloading => {
|
||||
// Скачивание уже идёт, игнорируем
|
||||
}
|
||||
PhotoDownloadState::Error(_) => {
|
||||
// Попробуем перескачать
|
||||
download_and_expand(app, msg_id, file_id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
fn collapse_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId) {
|
||||
// Свернуть изображение
|
||||
let messages = app.td_client.current_chat_messages_mut();
|
||||
if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
photo.expanded = false;
|
||||
}
|
||||
}
|
||||
// Удаляем протокол из рендерера
|
||||
#[cfg(feature = "images")]
|
||||
if let Some(renderer) = &mut app.image_renderer {
|
||||
renderer.remove(&msg_id);
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
fn expand_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, path: &str) {
|
||||
// Загружаем изображение в рендерер
|
||||
#[cfg(feature = "images")]
|
||||
if let Some(renderer) = &mut app.image_renderer {
|
||||
if let Err(e) = renderer.load_image(msg_id, path) {
|
||||
app.error_message = Some(format!("Ошибка загрузки изображения: {}", e));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Ставим expanded = true
|
||||
let messages = app.td_client.current_chat_messages_mut();
|
||||
if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
photo.expanded = true;
|
||||
}
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
async fn download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, file_id: i32) {
|
||||
use crate::tdlib::PhotoDownloadState;
|
||||
|
||||
// Проверяем кэш
|
||||
#[cfg(feature = "images")]
|
||||
if let Some(ref cache) = app.image_cache {
|
||||
if let Some(cached_path) = cache.get_cached(file_id) {
|
||||
let path_str = cached_path.to_string_lossy().to_string();
|
||||
// Обновляем download_state
|
||||
let messages = app.td_client.current_chat_messages_mut();
|
||||
if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
photo.download_state = PhotoDownloadState::Downloaded(path_str.clone());
|
||||
}
|
||||
}
|
||||
expand_photo(app, msg_id, &path_str);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ставим состояние Downloading
|
||||
{
|
||||
let messages = app.td_client.current_chat_messages_mut();
|
||||
if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
photo.download_state = PhotoDownloadState::Downloading;
|
||||
}
|
||||
}
|
||||
}
|
||||
app.status_message = Some("Загрузка фото...".to_string());
|
||||
app.needs_redraw = true;
|
||||
|
||||
// Скачиваем
|
||||
match crate::utils::with_timeout_msg(
|
||||
Duration::from_secs(crate::constants::FILE_DOWNLOAD_TIMEOUT_SECS),
|
||||
app.td_client.download_file(file_id),
|
||||
"Таймаут скачивания фото",
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(path) => {
|
||||
// Кэшируем
|
||||
#[cfg(feature = "images")]
|
||||
let cache_path = if let Some(ref cache) = app.image_cache {
|
||||
cache.cache_file(file_id, &path).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
#[cfg(not(feature = "images"))]
|
||||
let cache_path: Option<std::path::PathBuf> = None;
|
||||
|
||||
let final_path = cache_path
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or(path);
|
||||
|
||||
// Обновляем download_state
|
||||
let messages = app.td_client.current_chat_messages_mut();
|
||||
if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
photo.download_state = PhotoDownloadState::Downloaded(final_path.clone());
|
||||
}
|
||||
}
|
||||
app.status_message = None;
|
||||
expand_photo(app, msg_id, &final_path);
|
||||
}
|
||||
Err(e) => {
|
||||
// Ставим Error
|
||||
let messages = app.td_client.current_chat_messages_mut();
|
||||
if let Some(msg) = messages.iter_mut().find(|m| m.id() == msg_id) {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
photo.download_state = PhotoDownloadState::Error(e.clone());
|
||||
}
|
||||
}
|
||||
app.error_message = Some(e);
|
||||
app.status_message = None;
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,18 @@ use crossterm::event::KeyEvent;
|
||||
async fn handle_escape_key<T: TdClientTrait>(app: &mut App<T>) {
|
||||
// Early return для режима выбора сообщения
|
||||
if app.is_selecting_message() {
|
||||
// Свернуть все раскрытые фото (но сохранить Downloaded paths для re-expansion)
|
||||
#[cfg(feature = "images")]
|
||||
{
|
||||
for msg in app.td_client.current_chat_messages_mut() {
|
||||
if let Some(photo) = msg.photo_info_mut() {
|
||||
photo.expanded = false;
|
||||
}
|
||||
}
|
||||
if let Some(renderer) = &mut app.image_renderer {
|
||||
renderer.clear();
|
||||
}
|
||||
}
|
||||
app.chat_state = crate::app::ChatState::Normal;
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user