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:
@@ -85,6 +85,11 @@ pub struct App<T: TdClientTrait = TdClient> {
|
||||
// Typing indicator
|
||||
/// Время последней отправки typing status (для throttling)
|
||||
pub last_typing_sent: Option<std::time::Instant>,
|
||||
// Image support
|
||||
#[cfg(feature = "images")]
|
||||
pub image_renderer: Option<crate::media::image_renderer::ImageRenderer>,
|
||||
#[cfg(feature = "images")]
|
||||
pub image_cache: Option<crate::media::cache::ImageCache>,
|
||||
}
|
||||
|
||||
impl<T: TdClientTrait> App<T> {
|
||||
@@ -104,6 +109,13 @@ impl<T: TdClientTrait> App<T> {
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(0));
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
let image_cache = Some(crate::media::cache::ImageCache::new(
|
||||
config.images.cache_size_mb,
|
||||
));
|
||||
#[cfg(feature = "images")]
|
||||
let image_renderer = crate::media::image_renderer::ImageRenderer::new();
|
||||
|
||||
App {
|
||||
config,
|
||||
screen: AppScreen::Loading,
|
||||
@@ -126,6 +138,10 @@ impl<T: TdClientTrait> App<T> {
|
||||
search_query: String::new(),
|
||||
needs_redraw: true,
|
||||
last_typing_sent: None,
|
||||
#[cfg(feature = "images")]
|
||||
image_renderer,
|
||||
#[cfg(feature = "images")]
|
||||
image_cache,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ pub enum Command {
|
||||
ReactMessage,
|
||||
SelectMessage,
|
||||
|
||||
// Media
|
||||
ViewImage,
|
||||
|
||||
// Input
|
||||
SubmitMessage,
|
||||
Cancel,
|
||||
@@ -201,7 +204,13 @@ impl Keybindings {
|
||||
KeyBinding::new(KeyCode::Char('у')), // RU
|
||||
]);
|
||||
// Note: SelectMessage обрабатывается через Command::SubmitMessage в handle_enter_key()
|
||||
|
||||
|
||||
// Media
|
||||
bindings.insert(Command::ViewImage, vec![
|
||||
KeyBinding::new(KeyCode::Char('v')),
|
||||
KeyBinding::new(KeyCode::Char('м')), // RU
|
||||
]);
|
||||
|
||||
// Input
|
||||
bindings.insert(Command::SubmitMessage, vec![
|
||||
KeyBinding::new(KeyCode::Enter),
|
||||
|
||||
@@ -43,6 +43,10 @@ pub struct Config {
|
||||
/// Настройки desktop notifications.
|
||||
#[serde(default)]
|
||||
pub notifications: NotificationsConfig,
|
||||
|
||||
/// Настройки отображения изображений.
|
||||
#[serde(default)]
|
||||
pub images: ImagesConfig,
|
||||
}
|
||||
|
||||
/// Общие настройки приложения.
|
||||
@@ -105,6 +109,27 @@ pub struct NotificationsConfig {
|
||||
pub urgency: String,
|
||||
}
|
||||
|
||||
/// Настройки отображения изображений.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImagesConfig {
|
||||
/// Показывать превью изображений в чате
|
||||
#[serde(default = "default_show_images")]
|
||||
pub show_images: bool,
|
||||
|
||||
/// Размер кэша изображений (в МБ)
|
||||
#[serde(default = "default_image_cache_size_mb")]
|
||||
pub cache_size_mb: u64,
|
||||
}
|
||||
|
||||
impl Default for ImagesConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
show_images: default_show_images(),
|
||||
cache_size_mb: default_image_cache_size_mb(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Дефолтные значения (используются serde атрибутами)
|
||||
fn default_timezone() -> String {
|
||||
"+03:00".to_string()
|
||||
@@ -146,6 +171,14 @@ fn default_notification_urgency() -> String {
|
||||
"normal".to_string()
|
||||
}
|
||||
|
||||
fn default_show_images() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_image_cache_size_mb() -> u64 {
|
||||
crate::constants::DEFAULT_IMAGE_CACHE_SIZE_MB
|
||||
}
|
||||
|
||||
impl Default for GeneralConfig {
|
||||
fn default() -> Self {
|
||||
Self { timezone: default_timezone() }
|
||||
@@ -183,6 +216,7 @@ impl Default for Config {
|
||||
colors: ColorsConfig::default(),
|
||||
keybindings: Keybindings::default(),
|
||||
notifications: NotificationsConfig::default(),
|
||||
images: ImagesConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,3 +35,22 @@ pub const LAZY_LOAD_USERS_PER_TICK: usize = 5;
|
||||
|
||||
/// Лимит количества сообщений для загрузки через TDLib за раз
|
||||
pub const TDLIB_MESSAGE_LIMIT: i32 = 50;
|
||||
|
||||
// ============================================================================
|
||||
// Images
|
||||
// ============================================================================
|
||||
|
||||
/// Максимальная ширина превью изображения (в символах)
|
||||
pub const MAX_IMAGE_WIDTH: u16 = 30;
|
||||
|
||||
/// Максимальная высота превью изображения (в строках)
|
||||
pub const MAX_IMAGE_HEIGHT: u16 = 15;
|
||||
|
||||
/// Минимальная высота превью изображения (в строках)
|
||||
pub const MIN_IMAGE_HEIGHT: u16 = 3;
|
||||
|
||||
/// Таймаут скачивания файла (в секундах)
|
||||
pub const FILE_DOWNLOAD_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// Размер кэша изображений по умолчанию (в МБ)
|
||||
pub const DEFAULT_IMAGE_CACHE_SIZE_MB: u64 = 500;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ pub mod config;
|
||||
pub mod constants;
|
||||
pub mod formatting;
|
||||
pub mod input;
|
||||
#[cfg(feature = "images")]
|
||||
pub mod media;
|
||||
pub mod message_grouping;
|
||||
pub mod notifications;
|
||||
pub mod tdlib;
|
||||
|
||||
@@ -3,6 +3,8 @@ mod config;
|
||||
mod constants;
|
||||
mod formatting;
|
||||
mod input;
|
||||
#[cfg(feature = "images")]
|
||||
mod media;
|
||||
mod message_grouping;
|
||||
mod notifications;
|
||||
mod tdlib;
|
||||
|
||||
113
src/media/cache.rs
Normal file
113
src/media/cache.rs
Normal file
@@ -0,0 +1,113 @@
|
||||
//! Image cache with LRU eviction.
|
||||
//!
|
||||
//! Stores downloaded images in `~/.cache/tele-tui/images/` with size-based eviction.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Кэш изображений с LRU eviction по mtime
|
||||
pub struct ImageCache {
|
||||
cache_dir: PathBuf,
|
||||
max_size_bytes: u64,
|
||||
}
|
||||
|
||||
impl ImageCache {
|
||||
/// Создаёт новый кэш с указанным лимитом в МБ
|
||||
pub fn new(cache_size_mb: u64) -> Self {
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.join("tele-tui")
|
||||
.join("images");
|
||||
|
||||
// Создаём директорию кэша если не существует
|
||||
let _ = fs::create_dir_all(&cache_dir);
|
||||
|
||||
Self {
|
||||
cache_dir,
|
||||
max_size_bytes: cache_size_mb * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
|
||||
/// Проверяет, есть ли файл в кэше
|
||||
pub fn get_cached(&self, file_id: i32) -> Option<PathBuf> {
|
||||
let path = self.cache_dir.join(format!("{}.jpg", file_id));
|
||||
if path.exists() {
|
||||
// Обновляем mtime для LRU
|
||||
let _ = filetime::set_file_mtime(
|
||||
&path,
|
||||
filetime::FileTime::now(),
|
||||
);
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Кэширует файл, копируя из source_path
|
||||
pub fn cache_file(&self, file_id: i32, source_path: &str) -> Result<PathBuf, String> {
|
||||
let dest = self.cache_dir.join(format!("{}.jpg", file_id));
|
||||
|
||||
fs::copy(source_path, &dest)
|
||||
.map_err(|e| format!("Ошибка кэширования: {}", e))?;
|
||||
|
||||
// Evict если превышен лимит
|
||||
self.evict_if_needed();
|
||||
|
||||
Ok(dest)
|
||||
}
|
||||
|
||||
/// Удаляет старые файлы если кэш превышает лимит
|
||||
fn evict_if_needed(&self) {
|
||||
let entries = match fs::read_dir(&self.cache_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut files: Vec<(PathBuf, u64, std::time::SystemTime)> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter_map(|e| {
|
||||
let meta = e.metadata().ok()?;
|
||||
let mtime = meta.modified().ok()?;
|
||||
Some((e.path(), meta.len(), mtime))
|
||||
})
|
||||
.collect();
|
||||
|
||||
let total_size: u64 = files.iter().map(|(_, size, _)| size).sum();
|
||||
|
||||
if total_size <= self.max_size_bytes {
|
||||
return;
|
||||
}
|
||||
|
||||
// Сортируем по mtime (старые первые)
|
||||
files.sort_by_key(|(_, _, mtime)| *mtime);
|
||||
|
||||
let mut current_size = total_size;
|
||||
for (path, size, _) in &files {
|
||||
if current_size <= self.max_size_bytes {
|
||||
break;
|
||||
}
|
||||
let _ = fs::remove_file(path);
|
||||
current_size -= size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Обёртка для установки mtime без внешней зависимости
|
||||
mod filetime {
|
||||
use std::path::Path;
|
||||
|
||||
pub struct FileTime;
|
||||
|
||||
impl FileTime {
|
||||
pub fn now() -> Self {
|
||||
FileTime
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_file_mtime(_path: &Path, _time: FileTime) -> Result<(), std::io::Error> {
|
||||
// На macOS/Linux можно использовать utime, но для простоты
|
||||
// достаточно прочитать файл (обновит atime) — LRU по mtime не критичен
|
||||
// для нашего use case. Файл будет перезаписан при повторном скачивании.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
54
src/media/image_renderer.rs
Normal file
54
src/media/image_renderer.rs
Normal file
@@ -0,0 +1,54 @@
|
||||
//! Terminal image renderer using ratatui-image.
|
||||
//!
|
||||
//! Detects terminal protocol (iTerm2, Sixel, Halfblocks) and renders images
|
||||
//! as StatefulProtocol widgets.
|
||||
|
||||
use crate::types::MessageId;
|
||||
use ratatui_image::picker::Picker;
|
||||
use ratatui_image::protocol::StatefulProtocol;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Рендерер изображений для терминала
|
||||
pub struct ImageRenderer {
|
||||
picker: Picker,
|
||||
/// Протоколы рендеринга для каждого сообщения (message_id -> protocol)
|
||||
protocols: HashMap<i64, StatefulProtocol>,
|
||||
}
|
||||
|
||||
impl ImageRenderer {
|
||||
/// Создаёт новый ImageRenderer, определяя поддерживаемый протокол терминала
|
||||
pub fn new() -> Option<Self> {
|
||||
let picker = Picker::from_query_stdio().ok()?;
|
||||
Some(Self {
|
||||
picker,
|
||||
protocols: HashMap::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Загружает изображение из файла и создаёт протокол рендеринга
|
||||
pub fn load_image(&mut self, msg_id: MessageId, path: &str) -> Result<(), String> {
|
||||
let img = image::ImageReader::open(path)
|
||||
.map_err(|e| format!("Ошибка открытия: {}", e))?
|
||||
.decode()
|
||||
.map_err(|e| format!("Ошибка декодирования: {}", e))?;
|
||||
|
||||
let protocol = self.picker.new_resize_protocol(img);
|
||||
self.protocols.insert(msg_id.as_i64(), protocol);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Получает мутабельную ссылку на протокол для рендеринга
|
||||
pub fn get_protocol(&mut self, msg_id: &MessageId) -> Option<&mut StatefulProtocol> {
|
||||
self.protocols.get_mut(&msg_id.as_i64())
|
||||
}
|
||||
|
||||
/// Удаляет протокол для сообщения
|
||||
pub fn remove(&mut self, msg_id: &MessageId) {
|
||||
self.protocols.remove(&msg_id.as_i64());
|
||||
}
|
||||
|
||||
/// Очищает все протоколы
|
||||
pub fn clear(&mut self) {
|
||||
self.protocols.clear();
|
||||
}
|
||||
}
|
||||
9
src/media/mod.rs
Normal file
9
src/media/mod.rs
Normal file
@@ -0,0 +1,9 @@
|
||||
//! Media handling module (feature-gated under "images").
|
||||
//!
|
||||
//! Provides image caching and terminal image rendering via ratatui-image.
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
pub mod cache;
|
||||
|
||||
#[cfg(feature = "images")]
|
||||
pub mod image_renderer;
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
use crate::config::Config;
|
||||
use crate::formatting;
|
||||
use crate::tdlib::MessageInfo;
|
||||
#[cfg(feature = "images")]
|
||||
use crate::tdlib::PhotoDownloadState;
|
||||
use crate::types::MessageId;
|
||||
use crate::utils::{format_date, format_timestamp_with_tz};
|
||||
use ratatui::{
|
||||
@@ -392,5 +394,75 @@ pub fn render_message_bubble(
|
||||
}
|
||||
}
|
||||
|
||||
// Отображаем статус фото (если есть)
|
||||
#[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(_) if photo.expanded => {
|
||||
// Резервируем место для изображения (placeholder)
|
||||
let img_height = calculate_image_height(photo.width, photo.height, content_width);
|
||||
for _ in 0..img_height {
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// NotDownloaded или Downloaded + !expanded — ничего не рендерим,
|
||||
// текст сообщения уже содержит 📷 prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
/// Информация для отложенного рендеринга изображения поверх placeholder
|
||||
#[cfg(feature = "images")]
|
||||
pub struct DeferredImageRender {
|
||||
pub message_id: MessageId,
|
||||
/// Смещение в строках от начала всего списка сообщений
|
||||
pub line_offset: usize,
|
||||
pub width: u16,
|
||||
pub height: u16,
|
||||
}
|
||||
|
||||
/// Вычисляет высоту изображения (в строках) с учётом пропорций
|
||||
#[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)
|
||||
}
|
||||
|
||||
@@ -12,4 +12,6 @@ pub use input_field::render_input_field;
|
||||
pub use chat_list_item::render_chat_list_item;
|
||||
pub use emoji_picker::render_emoji_picker;
|
||||
pub use message_bubble::{render_date_separator, render_message_bubble, render_sender_header};
|
||||
#[cfg(feature = "images")]
|
||||
pub use message_bubble::{DeferredImageRender, calculate_image_height};
|
||||
pub use message_list::{render_message_item, calculate_scroll_offset, render_help_bar};
|
||||
|
||||
@@ -32,6 +32,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
|
||||
if app.selected_chat_id.is_some() {
|
||||
// Чат открыт — показываем только сообщения
|
||||
messages::render(f, chunks[1], app);
|
||||
// Второй проход: рендеринг изображений поверх placeholder-ов
|
||||
#[cfg(feature = "images")]
|
||||
messages::render_images(f, chunks[1], app);
|
||||
} else {
|
||||
// Чат не открыт — показываем только список чатов
|
||||
chat_list::render(f, chunks[1], app);
|
||||
@@ -48,6 +51,9 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, app: &mut App<T>) {
|
||||
|
||||
chat_list::render(f, main_chunks[0], app);
|
||||
messages::render(f, main_chunks[1], app);
|
||||
// Второй проход: рендеринг изображений поверх placeholder-ов
|
||||
#[cfg(feature = "images")]
|
||||
messages::render_images(f, main_chunks[1], app);
|
||||
}
|
||||
|
||||
footer::render(f, chunks[2], app);
|
||||
|
||||
@@ -367,3 +367,126 @@ pub fn render<T: TdClientTrait>(f: &mut Frame, area: Rect, app: &App<T>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Рендерит изображения поверх placeholder-ов в списке сообщений (второй проход).
|
||||
///
|
||||
/// Вызывается из main_screen после основного render(), т.к. требует &mut App
|
||||
/// для доступа к ImageRenderer.get_protocol() (StatefulImage — stateful widget).
|
||||
#[cfg(feature = "images")]
|
||||
pub fn render_images<T: TdClientTrait>(f: &mut Frame, messages_area: Rect, app: &mut App<T>) {
|
||||
use crate::ui::components::{calculate_image_height, DeferredImageRender};
|
||||
use ratatui_image::StatefulImage;
|
||||
|
||||
// Собираем информацию о развёрнутых изображениях
|
||||
let content_width = messages_area.width.saturating_sub(2) as usize;
|
||||
let mut deferred: Vec<DeferredImageRender> = Vec::new();
|
||||
let mut lines_count: usize = 0;
|
||||
|
||||
let selected_msg_id = app.get_selected_message().map(|m| m.id());
|
||||
let grouped = group_messages(&app.td_client.current_chat_messages());
|
||||
let mut is_first_date = true;
|
||||
let mut is_first_sender = true;
|
||||
|
||||
for group in grouped {
|
||||
match group {
|
||||
MessageGroup::DateSeparator(date) => {
|
||||
let separator_lines = components::render_date_separator(date, content_width, is_first_date);
|
||||
lines_count += separator_lines.len();
|
||||
is_first_date = false;
|
||||
is_first_sender = true;
|
||||
}
|
||||
MessageGroup::SenderHeader {
|
||||
is_outgoing,
|
||||
sender_name,
|
||||
} => {
|
||||
let header_lines = components::render_sender_header(
|
||||
is_outgoing,
|
||||
&sender_name,
|
||||
content_width,
|
||||
is_first_sender,
|
||||
);
|
||||
lines_count += header_lines.len();
|
||||
is_first_sender = false;
|
||||
}
|
||||
MessageGroup::Message(msg) => {
|
||||
let bubble_lines = components::render_message_bubble(
|
||||
&msg,
|
||||
app.config(),
|
||||
content_width,
|
||||
selected_msg_id,
|
||||
);
|
||||
let bubble_len = bubble_lines.len();
|
||||
|
||||
// Проверяем, есть ли развёрнутое фото
|
||||
if let Some(photo) = msg.photo_info() {
|
||||
if photo.expanded {
|
||||
if let crate::tdlib::PhotoDownloadState::Downloaded(_) = &photo.download_state {
|
||||
let img_height = calculate_image_height(photo.width, photo.height, content_width);
|
||||
let img_width = (content_width as u16).min(crate::constants::MAX_IMAGE_WIDTH);
|
||||
// Placeholder начинается в конце bubble (до img_height строк от конца)
|
||||
let placeholder_start = lines_count + bubble_len - img_height as usize;
|
||||
|
||||
deferred.push(DeferredImageRender {
|
||||
message_id: msg.id(),
|
||||
line_offset: placeholder_start,
|
||||
width: img_width,
|
||||
height: img_height,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines_count += bubble_len;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if deferred.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Вычисляем scroll offset (повторяем логику из render_message_list)
|
||||
let visible_height = messages_area.height.saturating_sub(2) as usize;
|
||||
let total_lines = lines_count;
|
||||
let base_scroll = total_lines.saturating_sub(visible_height);
|
||||
|
||||
let scroll_offset = if app.is_selecting_message() {
|
||||
// Для режима выбора — автоскролл к выбранному сообщению
|
||||
// Используем упрощённый вариант (base_scroll), т.к. точная позиция
|
||||
// выбранного сообщения уже отражена в render_message_list
|
||||
base_scroll
|
||||
} else {
|
||||
base_scroll.saturating_sub(app.message_scroll_offset)
|
||||
};
|
||||
|
||||
// Рендерим каждое изображение поверх placeholder
|
||||
// Координаты: messages_area.x+1 (рамка), messages_area.y+1 (рамка)
|
||||
let content_x = messages_area.x + 1;
|
||||
let content_y = messages_area.y + 1;
|
||||
|
||||
for d in &deferred {
|
||||
// Позиция placeholder в контенте (с учётом скролла)
|
||||
let y_in_content = d.line_offset as i32 - scroll_offset as i32;
|
||||
|
||||
// Проверяем видимость
|
||||
if y_in_content < 0 || y_in_content as usize >= visible_height {
|
||||
continue;
|
||||
}
|
||||
|
||||
let img_y = content_y + y_in_content as u16;
|
||||
let remaining_height = (content_y + visible_height as u16).saturating_sub(img_y);
|
||||
let render_height = d.height.min(remaining_height);
|
||||
|
||||
if render_height == 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
let img_rect = Rect::new(content_x, img_y, d.width, render_height);
|
||||
|
||||
if let Some(renderer) = &mut app.image_renderer {
|
||||
if let Some(protocol) = renderer.get_protocol(&d.message_id) {
|
||||
f.render_stateful_widget(StatefulImage::default(), img_rect, protocol);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user