Handle absent media and selection state safely

This commit is contained in:
Mikhail Kilin
2026-05-17 18:41:52 +03:00
parent 679892beca
commit 91e4f118f3
7 changed files with 68 additions and 36 deletions

View File

@@ -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:

View File

@@ -132,9 +132,9 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
_ => 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<T: TdClientTrait> MessageMethods<T> for App<T> {
// 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
}

View File

@@ -91,10 +91,13 @@ pub async fn handle_message_selection<T: TdClientTrait>(
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());

View File

@@ -112,7 +112,10 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
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<T: TdClientTrait>(app: &mut App<T>) {
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 {

View File

@@ -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() {

View File

@@ -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());
}
}
}

View File

@@ -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() {