Handle absent media and selection state safely
This commit is contained in:
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user