Files
telegram-tui/src/input/handlers/chat.rs
Mikhail Kilin d4e1ed1376
Some checks failed
ci/woodpecker/pr/check Pipeline failed
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / Build (macos-latest) (pull_request) Has been cancelled
CI / Build (ubuntu-latest) (pull_request) Has been cancelled
CI / Build (windows-latest) (pull_request) Has been cancelled
fix: resolve all 23 clippy warnings
2026-02-22 17:28:50 +03:00

858 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Chat input handlers
//!
//! Handles keyboard input when a chat is open, including:
//! - Message scrolling and navigation
//! - Message selection and actions
//! - Editing and sending messages
//! - Loading older messages
use super::chat_list::open_chat_and_load_data;
use crate::app::methods::{
compose::ComposeMethods, messages::MessageMethods, modal::ModalMethods,
navigation::NavigationMethods,
};
use crate::app::App;
use crate::app::InputMode;
use crate::input::handlers::{copy_to_clipboard, format_message_for_clipboard};
use crate::tdlib::{ChatAction, TdClientTrait};
use crate::types::{ChatId, MessageId};
use crate::utils::{is_non_empty, with_timeout, with_timeout_msg};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::time::{Duration, Instant};
/// Обработка режима выбора сообщения для действий
///
/// Обрабатывает:
/// - Навигацию по сообщениям (Up/Down)
/// - Удаление сообщения (d/в/Delete)
/// - Ответ на сообщение (r/к)
/// - Пересылку сообщения (f/а)
/// - Копирование сообщения (y/н)
/// - Добавление реакции (e/у)
pub async fn handle_message_selection<T: TdClientTrait>(
app: &mut App<T>,
_key: KeyEvent,
command: Option<crate::config::Command>,
) {
match command {
Some(crate::config::Command::MoveUp) => {
app.select_previous_message();
}
Some(crate::config::Command::MoveDown) => {
app.select_next_message();
}
Some(crate::config::Command::DeleteMessage) => {
let Some(msg) = app.get_selected_message() else {
return;
};
let can_delete =
msg.can_be_deleted_only_for_self() || msg.can_be_deleted_for_all_users();
if can_delete {
app.chat_state = crate::app::ChatState::DeleteConfirmation { message_id: msg.id() };
}
}
Some(crate::config::Command::EnterInsertMode) => {
app.input_mode = InputMode::Insert;
app.chat_state = crate::app::ChatState::Normal;
}
Some(crate::config::Command::ReplyMessage) => {
app.start_reply_to_selected();
app.input_mode = InputMode::Insert;
}
Some(crate::config::Command::ForwardMessage) => {
app.start_forward_selected();
}
Some(crate::config::Command::CopyMessage) => {
let Some(msg) = app.get_selected_message() else {
return;
};
let text = format_message_for_clipboard(&msg);
match copy_to_clipboard(&text) {
Ok(_) => {
app.status_message = Some("Сообщение скопировано".to_string());
}
Err(e) => {
app.error_message = Some(format!("Ошибка копирования: {}", e));
}
}
}
Some(crate::config::Command::ViewImage) => {
handle_view_or_play_media(app).await;
}
Some(crate::config::Command::TogglePlayback) => {
handle_toggle_voice_playback(app).await;
}
Some(crate::config::Command::SeekForward | crate::config::Command::MoveRight) => {
handle_voice_seek(app, 5.0);
}
Some(crate::config::Command::SeekBackward | crate::config::Command::MoveLeft) => {
handle_voice_seek(app, -5.0);
}
Some(crate::config::Command::ReactMessage) => {
let Some(msg) = app.get_selected_message() else {
return;
};
let chat_id = app.selected_chat_id.unwrap();
let message_id = msg.id();
app.status_message = Some("Загрузка реакций...".to_string());
app.needs_redraw = true;
match with_timeout_msg(
Duration::from_secs(5),
app.td_client
.get_message_available_reactions(chat_id, message_id),
"Таймаут загрузки реакций",
)
.await
{
Ok(reactions) => {
let reactions: Vec<String> = reactions;
if reactions.is_empty() {
app.error_message =
Some("Реакции недоступны для этого сообщения".to_string());
app.status_message = None;
app.needs_redraw = true;
} else {
app.enter_reaction_picker_mode(message_id.as_i64(), reactions);
app.status_message = None;
app.needs_redraw = true;
}
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
app.needs_redraw = true;
}
}
}
_ => {}
}
}
/// Редактирование существующего сообщения
pub async fn edit_message<T: TdClientTrait>(
app: &mut App<T>,
chat_id: i64,
msg_id: MessageId,
text: String,
) {
// Проверяем, что сообщение есть в локальном кэше
let msg_exists = app
.td_client
.current_chat_messages()
.iter()
.any(|m| m.id() == msg_id);
if !msg_exists {
app.error_message =
Some(format!("Сообщение {} не найдено в кэше чата {}", msg_id.as_i64(), chat_id));
app.chat_state = crate::app::ChatState::Normal;
app.message_input.clear();
app.cursor_position = 0;
return;
}
match with_timeout_msg(
Duration::from_secs(5),
app.td_client
.edit_message(ChatId::new(chat_id), msg_id, text),
"Таймаут редактирования",
)
.await
{
Ok(mut edited_msg) => {
// Сохраняем reply_to из старого сообщения (если есть)
let messages = app.td_client.current_chat_messages_mut();
if let Some(pos) = messages.iter().position(|m| m.id() == msg_id) {
let old_reply_to = messages[pos].interactions.reply_to.clone();
// Если в старом сообщении был reply и в новом он "Unknown" - сохраняем старый
if let Some(old_reply) = old_reply_to {
if edited_msg
.interactions
.reply_to
.as_ref()
.is_none_or(|r| r.sender_name == "Unknown")
{
edited_msg.interactions.reply_to = Some(old_reply);
}
}
// Заменяем сообщение
messages[pos] = edited_msg;
}
// Очищаем инпут и сбрасываем состояние ПОСЛЕ успешного редактирования
app.message_input.clear();
app.cursor_position = 0;
app.chat_state = crate::app::ChatState::Normal;
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(e);
}
}
}
/// Отправка нового сообщения (с опциональным reply)
pub async fn send_new_message<T: TdClientTrait>(app: &mut App<T>, chat_id: i64, text: String) {
let reply_to_id = if app.is_replying() {
app.chat_state.selected_message_id()
} else {
None
};
// Создаём ReplyInfo ДО отправки, пока сообщение точно доступно
let reply_info = app
.get_replying_to_message()
.map(|m| crate::tdlib::ReplyInfo {
message_id: m.id(),
sender_name: m.sender_name().to_string(),
text: m.text().to_string(),
});
app.message_input.clear();
app.cursor_position = 0;
// Сбрасываем режим reply если он был активен
if app.is_replying() {
app.chat_state = crate::app::ChatState::Normal;
}
app.last_typing_sent = None;
// Отменяем typing status
app.td_client
.send_chat_action(ChatId::new(chat_id), ChatAction::Cancel)
.await;
match with_timeout_msg(
Duration::from_secs(5),
app.td_client
.send_message(ChatId::new(chat_id), text, reply_to_id, reply_info),
"Таймаут отправки",
)
.await
{
Ok(sent_msg) => {
// Добавляем отправленное сообщение в список (с лимитом)
app.td_client.push_message(sent_msg);
// Сбрасываем скролл чтобы видеть новое сообщение
app.message_scroll_offset = 0;
}
Err(e) => {
app.error_message = Some(e);
}
}
}
/// Обработка клавиши Enter
///
/// Обрабатывает три сценария:
/// 1. В режиме выбора сообщения: начать редактирование
/// 2. В открытом чате: отправить новое или редактировать существующее сообщение
/// 3. В списке чатов: открыть выбранный чат
pub async fn handle_enter_key<T: TdClientTrait>(app: &mut App<T>) {
// Сценарий 1: Открытие чата из списка
if app.selected_chat_id.is_none() {
let prev_selected = app.selected_chat_id;
app.select_current_chat();
if app.selected_chat_id != prev_selected {
if let Some(chat_id) = app.get_selected_chat_id() {
open_chat_and_load_data(app, chat_id).await;
}
}
return;
}
// Сценарий 2: Режим выбора сообщения - начать редактирование
if app.is_selecting_message() {
if app.start_editing_selected() {
app.input_mode = InputMode::Insert;
} else {
// Нельзя редактировать это сообщение
app.chat_state = crate::app::ChatState::Normal;
}
return;
}
// Сценарий 3: Отправка или редактирование сообщения
if !is_non_empty(&app.message_input) {
return;
}
let Some(chat_id) = app.get_selected_chat_id() else {
return;
};
let text = app.message_input.clone();
if app.is_editing() {
// Редактирование существующего сообщения
if let Some(msg_id) = app.chat_state.selected_message_id() {
edit_message(app, chat_id, msg_id, text).await;
}
} else {
// Отправка нового сообщения
send_new_message(app, chat_id, text).await;
}
}
/// Отправляет реакцию на выбранное сообщение
pub async fn send_reaction<T: TdClientTrait>(app: &mut App<T>) {
// Get selected reaction emoji
let Some(emoji) = app.get_selected_reaction().cloned() else {
return;
};
// Get selected message ID
let Some(message_id) = app.get_selected_message_for_reaction() else {
return;
};
// Get chat ID
let Some(chat_id) = app.selected_chat_id else {
return;
};
let message_id = MessageId::new(message_id);
app.status_message = Some("Отправка реакции...".to_string());
app.needs_redraw = true;
// Send reaction with timeout
let result = with_timeout_msg(
Duration::from_secs(5),
app.td_client
.toggle_reaction(chat_id, message_id, emoji.clone()),
"Таймаут отправки реакции",
)
.await;
// Handle result
match result {
Ok(_) => {
app.status_message = Some(format!("Реакция {} добавлена", emoji));
app.exit_reaction_picker_mode();
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(e);
app.status_message = None;
app.needs_redraw = true;
}
}
}
/// Подгружает старые сообщения если скролл близко к верху
pub async fn load_older_messages_if_needed<T: TdClientTrait>(app: &mut App<T>) {
// Check if there are messages to load from
if app.td_client.current_chat_messages().is_empty() {
return;
}
// Get the oldest message ID
let oldest_msg_id = app
.td_client
.current_chat_messages()
.first()
.map(|m| m.id())
.unwrap_or(MessageId::new(0));
// Get current chat ID
let Some(chat_id) = app.get_selected_chat_id() else {
return;
};
// Check if scroll is near the top
let message_count = app.td_client.current_chat_messages().len();
if app.message_scroll_offset <= message_count.saturating_sub(10) {
return;
}
// Load older messages with timeout
let Ok(older) = with_timeout(
Duration::from_secs(3),
app.td_client
.load_older_messages(ChatId::new(chat_id), oldest_msg_id),
)
.await
else {
return;
};
// Add older messages to the beginning if any were loaded
if !older.is_empty() {
let msgs = app.td_client.current_chat_messages_mut();
msgs.splice(0..0, older);
}
}
/// Обработка ввода клавиатуры в открытом чате
///
/// Обрабатывает:
/// - Backspace/Delete: удаление символов относительно курсора
/// - Char: вставка символов в позицию курсора + typing status
/// - Left/Right/Home/End: навигация курсора
/// - Up/Down: скролл сообщений или начало режима выбора
pub async fn handle_open_chat_keyboard_input<T: TdClientTrait>(app: &mut App<T>, key: KeyEvent) {
match key.code {
KeyCode::Backspace => {
// Удаляем символ слева от курсора
if app.cursor_position > 0 {
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i != app.cursor_position - 1 {
new_input.push(*ch);
}
}
app.message_input = new_input;
app.cursor_position -= 1;
}
}
KeyCode::Delete => {
// Удаляем символ справа от курсора
let len = app.message_input.chars().count();
if app.cursor_position < len {
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i != app.cursor_position {
new_input.push(*ch);
}
}
app.message_input = new_input;
}
}
KeyCode::Char(c) => {
// Игнорируем символы с Ctrl/Alt модификаторами (кроме Shift)
// Это позволяет обрабатывать хоткеи типа Ctrl+U для профиля
if key.modifiers.contains(KeyModifiers::CONTROL)
|| key.modifiers.contains(KeyModifiers::ALT)
{
return;
}
// Вставляем символ в позицию курсора
let chars: Vec<char> = app.message_input.chars().collect();
let mut new_input = String::new();
for (i, ch) in chars.iter().enumerate() {
if i == app.cursor_position {
new_input.push(c);
}
new_input.push(*ch);
}
if app.cursor_position >= chars.len() {
new_input.push(c);
}
app.message_input = new_input;
app.cursor_position += 1;
// Отправляем typing status с throttling (не чаще 1 раза в 5 сек)
let should_send_typing = app
.last_typing_sent
.map(|t| t.elapsed().as_secs() >= 5)
.unwrap_or(true);
if should_send_typing {
if let Some(chat_id) = app.get_selected_chat_id() {
app.td_client
.send_chat_action(ChatId::new(chat_id), ChatAction::Typing)
.await;
app.last_typing_sent = Some(Instant::now());
}
}
}
KeyCode::Left => {
// Курсор влево
if app.cursor_position > 0 {
app.cursor_position -= 1;
}
}
KeyCode::Right => {
// Курсор вправо
let len = app.message_input.chars().count();
if app.cursor_position < len {
app.cursor_position += 1;
}
}
KeyCode::Home => {
// Курсор в начало
app.cursor_position = 0;
}
KeyCode::End => {
// Курсор в конец
app.cursor_position = app.message_input.chars().count();
}
// Стрелки вверх/вниз - скролл сообщений (в Insert mode)
KeyCode::Down => {
if app.message_scroll_offset > 0 {
app.message_scroll_offset = app.message_scroll_offset.saturating_sub(3);
}
}
KeyCode::Up => {
// В Insert mode — только скролл
app.message_scroll_offset += 3;
load_older_messages_if_needed(app).await;
}
_ => {}
}
}
/// Обработка команды ViewImage — только фото
async fn handle_view_or_play_media<T: TdClientTrait>(app: &mut App<T>) {
let Some(msg) = app.get_selected_message() else {
return;
};
if msg.has_photo() {
#[cfg(feature = "images")]
handle_view_image(app).await;
#[cfg(not(feature = "images"))]
{
app.status_message = Some("Просмотр изображений отключён".to_string());
}
} else {
app.status_message = Some("Сообщение не содержит фото".to_string());
}
}
/// Space: play/pause toggle для голосовых сообщений
async fn handle_toggle_voice_playback<T: TdClientTrait>(app: &mut App<T>) {
use crate::tdlib::PlaybackStatus;
// Если уже есть активное воспроизведение — toggle pause/resume
if let Some(ref mut playback) = app.playback_state {
if let Some(ref player) = app.audio_player {
match playback.status {
PlaybackStatus::Playing => {
player.pause();
playback.status = PlaybackStatus::Paused;
app.last_playback_tick = None;
app.status_message = Some("⏸ Пауза".to_string());
}
PlaybackStatus::Paused => {
// Откатываем на 1 секунду для контекста
let resume_pos = (playback.position - 1.0).max(0.0);
// Перезапускаем ffplay с нужной позиции (-ss)
if player.resume_from(resume_pos).is_ok() {
playback.position = resume_pos;
} else {
// Fallback: простой SIGCONT без перемотки
player.resume();
}
playback.status = PlaybackStatus::Playing;
app.last_playback_tick = Some(Instant::now());
app.status_message = Some("▶ Воспроизведение".to_string());
}
_ => {}
}
app.needs_redraw = true;
}
return;
}
// Нет активного воспроизведения — пробуем запустить текущее голосовое
let Some(msg) = app.get_selected_message() else {
return;
};
if msg.has_voice() {
handle_play_voice(app).await;
}
}
/// Seek голосового сообщения на delta секунд
fn handle_voice_seek<T: TdClientTrait>(app: &mut App<T>, delta: f32) {
use crate::tdlib::PlaybackStatus;
let Some(ref mut playback) = app.playback_state else {
return;
};
let Some(ref player) = app.audio_player else {
return;
};
let was_playing = matches!(playback.status, PlaybackStatus::Playing);
let was_paused = matches!(playback.status, PlaybackStatus::Paused);
if was_playing || was_paused {
let new_position = (playback.position + delta).clamp(0.0, playback.duration);
if was_playing {
// Перезапускаем ffplay с новой позиции
if player.resume_from(new_position).is_ok() {
playback.position = new_position;
app.last_playback_tick = Some(std::time::Instant::now());
}
} else {
// На паузе — только двигаем позицию, воспроизведение начнётся при resume
player.stop();
playback.position = new_position;
}
let arrow = if delta > 0.0 { "" } else { "" };
app.status_message = Some(format!("{} {:.0}s", arrow, new_position));
app.needs_redraw = true;
}
}
/// Обработка команды ViewImage — открыть модальное окно с фото
#[cfg(feature = "images")]
async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
use crate::tdlib::{ImageModalState, 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 msg_id = msg.id();
let file_id = photo.file_id;
let photo_width = photo.width;
let photo_height = photo.height;
let download_state = photo.download_state.clone();
match download_state {
PhotoDownloadState::Downloaded(path) => {
// Открываем модальное окно
app.image_modal = Some(ImageModalState {
message_id: msg_id,
photo_path: path,
photo_width,
photo_height,
});
app.needs_redraw = true;
}
PhotoDownloadState::Downloading => {
app.status_message = Some("Загрузка фото...".to_string());
}
PhotoDownloadState::NotDownloaded => {
// Скачиваем фото и открываем
app.status_message = Some("Загрузка фото...".to_string());
app.needs_redraw = true;
match app.td_client.download_file(file_id).await {
Ok(path) => {
// Обновляем состояние загрузки в сообщении
for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state = PhotoDownloadState::Downloaded(path.clone());
break;
}
}
}
// Открываем модалку
app.image_modal = Some(ImageModalState {
message_id: msg_id,
photo_path: path,
photo_width,
photo_height,
});
app.status_message = None;
}
Err(e) => {
for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state = PhotoDownloadState::Error(e.clone());
break;
}
}
}
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
app.status_message = None;
}
}
}
PhotoDownloadState::Error(_) => {
// Повторная попытка загрузки
app.status_message = Some("Повторная загрузка фото...".to_string());
app.needs_redraw = true;
match app.td_client.download_file(file_id).await {
Ok(path) => {
for msg in app.td_client.current_chat_messages_mut() {
if let Some(photo) = msg.photo_info_mut() {
if photo.file_id == file_id {
photo.download_state = PhotoDownloadState::Downloaded(path.clone());
break;
}
}
}
app.image_modal = Some(ImageModalState {
message_id: msg_id,
photo_path: path,
photo_width,
photo_height,
});
app.status_message = None;
}
Err(e) => {
app.error_message = Some(format!("Ошибка загрузки фото: {}", e));
app.status_message = None;
}
}
}
}
}
/// Вспомогательная функция для воспроизведения из конкретного пути
async fn handle_play_voice_from_path<T: TdClientTrait>(
app: &mut App<T>,
path: &str,
voice: &crate::tdlib::VoiceInfo,
msg: &crate::tdlib::MessageInfo,
) {
use crate::tdlib::{PlaybackState, PlaybackStatus};
if let Some(ref player) = app.audio_player {
match player.play(path) {
Ok(_) => {
app.playback_state = Some(PlaybackState {
message_id: msg.id(),
status: PlaybackStatus::Playing,
position: 0.0,
duration: voice.duration as f32,
volume: player.volume(),
});
app.last_playback_tick = Some(Instant::now());
app.status_message = Some(format!("▶ Воспроизведение ({:.0}s)", voice.duration));
app.needs_redraw = true;
}
Err(e) => {
app.error_message = Some(format!("Ошибка воспроизведения: {}", e));
}
}
} else {
app.error_message = Some("Аудиоплеер не инициализирован".to_string());
}
}
/// Воспроизведение голосового сообщения
async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
use crate::tdlib::VoiceDownloadState;
let Some(msg) = app.get_selected_message() else {
return;
};
if !msg.has_voice() {
return;
}
let voice = msg.voice_info().unwrap();
let file_id = voice.file_id;
match &voice.download_state {
VoiceDownloadState::Downloaded(path) => {
// TDLib может вернуть путь без расширения — ищем файл с .oga
use std::path::Path;
let audio_path = if Path::new(path).exists() {
path.clone()
} else {
// Пробуем добавить .oga
let with_oga = format!("{}.oga", path);
if Path::new(&with_oga).exists() {
with_oga
} else {
// Пробуем найти файл с похожим именем в той же папке
if let Some(parent) = Path::new(path).parent() {
if let Some(stem) = Path::new(path).file_name() {
if let Ok(entries) = std::fs::read_dir(parent) {
for entry in entries.flatten() {
let entry_name = entry.file_name();
if entry_name
.to_string_lossy()
.starts_with(&stem.to_string_lossy().to_string())
{
let found_path = entry.path().to_string_lossy().to_string();
// Кэшируем найденный файл
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(
&file_id.to_string(),
Path::new(&found_path),
);
}
return handle_play_voice_from_path(
app,
&found_path,
voice,
&msg,
)
.await;
}
}
}
}
}
app.error_message = Some(format!("Файл не найден: {}", path));
return;
}
};
// Кэшируем файл если ещё не в кэше
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(&file_id.to_string(), Path::new(&audio_path));
}
handle_play_voice_from_path(app, &audio_path, voice, &msg).await;
}
VoiceDownloadState::Downloading => {
app.status_message = Some("Загрузка голосового...".to_string());
}
VoiceDownloadState::NotDownloaded => {
// Проверяем кэш перед загрузкой
let cache_key = file_id.to_string();
if let Some(cached_path) = app.voice_cache.as_mut().and_then(|c| c.get(&cache_key)) {
let path_str = cached_path.to_string_lossy().to_string();
handle_play_voice_from_path(app, &path_str, voice, &msg).await;
return;
}
// Начинаем загрузку
app.status_message = Some("Загрузка голосового...".to_string());
match app.td_client.download_voice_note(file_id).await {
Ok(path) => {
// Кэшируем загруженный файл
if let Some(ref mut cache) = app.voice_cache {
let _ = cache.store(&cache_key, std::path::Path::new(&path));
}
handle_play_voice_from_path(app, &path, voice, &msg).await;
}
Err(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e));
}
}
}
VoiceDownloadState::Error(e) => {
app.error_message = Some(format!("Ошибка загрузки: {}", e));
}
}
}
// TODO (Этап 4): Эти функции будут переписаны для модального просмотрщика
/*
#[cfg(feature = "images")]
fn collapse_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId) {
// Закомментировано - будет реализовано в Этапе 4
}
#[cfg(feature = "images")]
fn expand_photo<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, path: &str) {
// Закомментировано - будет реализовано в Этапе 4
}
*/
// TODO (Этап 4): Функция _download_and_expand будет переписана
/*
#[cfg(feature = "images")]
async fn _download_and_expand<T: TdClientTrait>(app: &mut App<T>, msg_id: crate::types::MessageId, file_id: i32) {
// Закомментировано - будет реализовано в Этапе 4
}
*/