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
858 lines
33 KiB
Rust
858 lines
33 KiB
Rust
//! 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
|
||
}
|
||
*/
|