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

@@ -51,6 +51,9 @@ pub struct FakeTdClient {
// Update channel для симуляции событий
pub update_tx: Arc<Mutex<Option<mpsc::UnboundedSender<TdUpdate>>>>,
// Скачанные файлы (file_id -> local_path)
pub downloaded_files: Arc<Mutex<HashMap<i32, String>>>,
// Настройки поведения
pub simulate_delays: bool,
pub fail_next_operation: Arc<Mutex<bool>>,
@@ -121,6 +124,7 @@ impl Clone for FakeTdClient {
viewed_messages: Arc::clone(&self.viewed_messages),
chat_actions: Arc::clone(&self.chat_actions),
pending_view_messages: Arc::clone(&self.pending_view_messages),
downloaded_files: Arc::clone(&self.downloaded_files),
update_tx: Arc::clone(&self.update_tx),
simulate_delays: self.simulate_delays,
fail_next_operation: Arc::clone(&self.fail_next_operation),
@@ -154,6 +158,7 @@ impl FakeTdClient {
viewed_messages: Arc::new(Mutex::new(vec![])),
chat_actions: Arc::new(Mutex::new(vec![])),
pending_view_messages: Arc::new(Mutex::new(vec![])),
downloaded_files: Arc::new(Mutex::new(HashMap::new())),
update_tx: Arc::new(Mutex::new(None)),
simulate_delays: false,
fail_next_operation: Arc::new(Mutex::new(false)),
@@ -237,6 +242,12 @@ impl FakeTdClient {
self
}
/// Добавить скачанный файл (для mock download_file)
pub fn with_downloaded_file(self, file_id: i32, path: &str) -> Self {
self.downloaded_files.lock().unwrap().insert(file_id, path.to_string());
self
}
/// Установить доступные реакции
pub fn with_available_reactions(self, reactions: Vec<String>) -> Self {
*self.available_reactions.lock().unwrap() = reactions;
@@ -587,6 +598,20 @@ impl FakeTdClient {
Ok(())
}
/// Скачать файл (mock)
pub async fn download_file(&self, file_id: i32) -> Result<String, String> {
if self.should_fail() {
return Err("Failed to download file".to_string());
}
self.downloaded_files
.lock()
.unwrap()
.get(&file_id)
.cloned()
.ok_or_else(|| format!("File {} not found", file_id))
}
/// Получить информацию о профиле
pub async fn get_profile_info(&self, chat_id: ChatId) -> Result<ProfileInfo, String> {
if self.should_fail() {

View File

@@ -161,6 +161,11 @@ impl TdClientTrait for FakeTdClient {
FakeTdClient::toggle_reaction(self, chat_id, message_id, reaction).await
}
// ============ File methods ============
async fn download_file(&self, file_id: i32) -> Result<String, String> {
FakeTdClient::download_file(self, file_id).await
}
// ============ Getters (immutable) ============
fn client_id(&self) -> i32 {
0 // Fake client ID