From 91e4f118f30c4a07db141316b0d2742b43a12d24 Mon Sep 17 00:00:00 2001 From: Mikhail Kilin Date: Sun, 17 May 2026 18:41:52 +0300 Subject: [PATCH] Handle absent media and selection state safely --- docs/REFACTOR_PLAN.md | 12 +++++----- src/app/methods/messages.rs | 6 ++--- src/input/handlers/chat.rs | 5 ++++- src/input/handlers/chat/media.rs | 10 +++++++-- src/ui/components/message_bubble.rs | 34 ++++++++++++++--------------- src/utils/tdlib.rs | 14 +++++++----- tests/reactions.rs | 23 +++++++++++++++++++ 7 files changed, 68 insertions(+), 36 deletions(-) diff --git a/docs/REFACTOR_PLAN.md b/docs/REFACTOR_PLAN.md index 70fde3e..a4e5795 100644 --- a/docs/REFACTOR_PLAN.md +++ b/docs/REFACTOR_PLAN.md @@ -169,12 +169,12 @@ rg -n "unwrap\\(|expect\\(|panic!\\(" src Steps: -- [ ] Replace `photo_info().unwrap()` and `voice_info().unwrap()` with `let Some(...) else { ... }`. -- [ ] Replace `selected_chat_id.unwrap()` with an early return or status message. -- [ ] Review playback/message unwraps in `message_bubble.rs` and convert absent data into graceful UI fallback. -- [ ] Audit mutex unwraps separately; leave only cases where poisoning should be fatal and documented by context. -- [ ] Add tests for missing media metadata and absent selected chat. -- [ ] Run `cargo clippy --all-targets --all-features -- -D warnings`. +- [x] Replace `photo_info().unwrap()` and `voice_info().unwrap()` with `let Some(...) else { ... }`. +- [x] Replace `selected_chat_id.unwrap()` with an early return or status message. +- [x] Review playback/message unwraps in `message_bubble.rs` and convert absent data into graceful UI fallback. +- [x] Audit mutex unwraps separately; leave only cases where poisoning should be fatal and documented by context. +- [x] Add tests for missing media metadata and absent selected chat. +- [x] Run `cargo clippy --all-targets --all-features -- -D warnings`. Acceptance criteria: diff --git a/src/app/methods/messages.rs b/src/app/methods/messages.rs index 2a7ba41..8e302f8 100644 --- a/src/app/methods/messages.rs +++ b/src/app/methods/messages.rs @@ -132,9 +132,9 @@ impl MessageMethods for App { _ => None, }; - if selected_idx.is_none() { + let Some(selected_idx) = selected_idx else { return false; - } + }; // Сначала извлекаем данные из сообщения let msg_data = self.get_selected_message().and_then(|msg| { @@ -143,7 +143,7 @@ impl MessageMethods for App { // 2. Это исходящее сообщение // 3. ID не временный (временные ID в TDLib отрицательные) if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 { - Some((msg.id(), msg.text().to_string(), selected_idx.unwrap())) + Some((msg.id(), msg.text().to_string(), selected_idx)) } else { None } diff --git a/src/input/handlers/chat.rs b/src/input/handlers/chat.rs index d701a36..f4a3d3d 100644 --- a/src/input/handlers/chat.rs +++ b/src/input/handlers/chat.rs @@ -91,10 +91,13 @@ pub async fn handle_message_selection( media::handle_voice_seek(app, -5.0); } Some(crate::config::Command::ReactMessage) => { + let Some(chat_id) = app.selected_chat_id else { + app.error_message = Some("Чат не выбран".to_string()); + return; + }; 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()); diff --git a/src/input/handlers/chat/media.rs b/src/input/handlers/chat/media.rs index 9a7ffa7..6e052b3 100644 --- a/src/input/handlers/chat/media.rs +++ b/src/input/handlers/chat/media.rs @@ -112,7 +112,10 @@ async fn handle_view_image(app: &mut App) { return; } - let photo = msg.photo_info().unwrap(); + let Some(photo) = msg.photo_info() else { + app.status_message = Some("Сообщение не содержит фото".to_string()); + return; + }; let msg_id = msg.id(); let file_id = photo.file_id; let photo_width = photo.width; @@ -238,7 +241,10 @@ async fn handle_play_voice(app: &mut App) { return; } - let voice = msg.voice_info().unwrap(); + let Some(voice) = msg.voice_info() else { + app.status_message = Some("Сообщение не содержит голосовое".to_string()); + return; + }; let file_id = voice.file_id; match &voice.download_state { diff --git a/src/ui/components/message_bubble.rs b/src/ui/components/message_bubble.rs index 5e20bfc..251a533 100644 --- a/src/ui/components/message_bubble.rs +++ b/src/ui/components/message_bubble.rs @@ -433,24 +433,20 @@ pub fn render_message_bubble( // Отображаем индикатор воспроизведения голосового if msg.has_voice() { if let Some(voice) = msg.voice_info() { - let is_this_playing = playback_state - .map(|ps| ps.message_id == msg.id()) - .unwrap_or(false); - - let status_line = if is_this_playing { - let ps = playback_state.unwrap(); - let icon = match ps.status { - PlaybackStatus::Playing => "▶", - PlaybackStatus::Paused => "⏸", - PlaybackStatus::Loading => "⏳", - _ => "⏹", + let status_line = + if let Some(ps) = playback_state.filter(|ps| ps.message_id == msg.id()) { + let icon = match ps.status { + PlaybackStatus::Playing => "▶", + PlaybackStatus::Paused => "⏸", + PlaybackStatus::Loading => "⏳", + _ => "⏹", + }; + let bar = render_progress_bar(ps.position, ps.duration, 20); + format!("{} {} {:.0}s/{:.0}s", icon, bar, ps.position, ps.duration) + } else { + let waveform = render_waveform(&voice.waveform, 20); + format!(" {} {:.0}s", waveform, voice.duration) }; - let bar = render_progress_bar(ps.position, ps.duration, 20); - format!("{} {} {:.0}s/{:.0}s", icon, bar, ps.position, ps.duration) - } else { - let waveform = render_waveform(&voice.waveform, 20); - format!(" {} {:.0}s", waveform, voice.duration) - }; let status_len = status_line.chars().count(); if msg.is_outgoing() { @@ -670,7 +666,9 @@ pub fn render_album_bubble( }; // Timestamp из последнего сообщения - let last_msg = messages.last().unwrap(); + let Some(last_msg) = messages.last() else { + return (lines, deferred); + }; let time = format_timestamp(last_msg.date()); if !captions.is_empty() { diff --git a/src/utils/tdlib.rs b/src/utils/tdlib.rs index 681fe34..6d9c1f7 100644 --- a/src/utils/tdlib.rs +++ b/src/utils/tdlib.rs @@ -9,15 +9,17 @@ extern "C" { /// Отключаем логи TDLib синхронно, до создания клиента pub fn disable_tdlib_logs() { let request = r#"{"@type":"setLogVerbosityLevel","new_verbosity_level":0}"#; - let c_request = CString::new(request).unwrap(); - unsafe { - let _ = td_execute(c_request.as_ptr()); + if let Ok(c_request) = CString::new(request) { + unsafe { + let _ = td_execute(c_request.as_ptr()); + } } // Также перенаправляем логи в никуда let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#; - let c_request2 = CString::new(request2).unwrap(); - unsafe { - let _ = td_execute(c_request2.as_ptr()); + if let Ok(c_request2) = CString::new(request2) { + unsafe { + let _ = td_execute(c_request2.as_ptr()); + } } } diff --git a/tests/reactions.rs b/tests/reactions.rs index 46b01d9..eb74ebb 100644 --- a/tests/reactions.rs +++ b/tests/reactions.rs @@ -2,8 +2,12 @@ mod helpers; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use helpers::app_builder::TestAppBuilder; use helpers::fake_tdclient::FakeTdClient; use helpers::test_data::TestMessageBuilder; +use tele_tui::config::Command; +use tele_tui::input::handlers::chat::handle_message_selection; use tele_tui::types::ChatId; /// Test: Добавление реакции к сообщению @@ -32,6 +36,25 @@ async fn test_add_reaction_to_message() { assert!(messages[0].reactions()[0].is_chosen); } +#[tokio::test] +async fn test_react_without_selected_chat_does_not_panic() { + let msg = TestMessageBuilder::new("React safely", 100).build(); + let mut app = TestAppBuilder::new() + .with_message(123, msg) + .selecting_message(0) + .build(); + *app.td_client.current_chat_id.lock().unwrap() = Some(123); + + handle_message_selection( + &mut app, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + Some(Command::ReactMessage), + ) + .await; + + assert_eq!(app.error_message.as_deref(), Some("Чат не выбран")); +} + /// Test: Удаление реакции (toggle) - вторичное нажатие #[tokio::test] async fn test_toggle_reaction_removes_it() {