Handle absent media and selection state safely
This commit is contained in:
@@ -169,12 +169,12 @@ rg -n "unwrap\\(|expect\\(|panic!\\(" src
|
|||||||
|
|
||||||
Steps:
|
Steps:
|
||||||
|
|
||||||
- [ ] Replace `photo_info().unwrap()` and `voice_info().unwrap()` with `let Some(...) else { ... }`.
|
- [x] Replace `photo_info().unwrap()` and `voice_info().unwrap()` with `let Some(...) else { ... }`.
|
||||||
- [ ] Replace `selected_chat_id.unwrap()` with an early return or status message.
|
- [x] 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.
|
- [x] 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.
|
- [x] 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.
|
- [x] Add tests for missing media metadata and absent selected chat.
|
||||||
- [ ] Run `cargo clippy --all-targets --all-features -- -D warnings`.
|
- [x] Run `cargo clippy --all-targets --all-features -- -D warnings`.
|
||||||
|
|
||||||
Acceptance criteria:
|
Acceptance criteria:
|
||||||
|
|
||||||
|
|||||||
@@ -132,9 +132,9 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
if selected_idx.is_none() {
|
let Some(selected_idx) = selected_idx else {
|
||||||
return false;
|
return false;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Сначала извлекаем данные из сообщения
|
// Сначала извлекаем данные из сообщения
|
||||||
let msg_data = self.get_selected_message().and_then(|msg| {
|
let msg_data = self.get_selected_message().and_then(|msg| {
|
||||||
@@ -143,7 +143,7 @@ impl<T: TdClientTrait> MessageMethods<T> for App<T> {
|
|||||||
// 2. Это исходящее сообщение
|
// 2. Это исходящее сообщение
|
||||||
// 3. ID не временный (временные ID в TDLib отрицательные)
|
// 3. ID не временный (временные ID в TDLib отрицательные)
|
||||||
if msg.can_be_edited() && msg.is_outgoing() && msg.id().as_i64() > 0 {
|
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 {
|
} else {
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,10 +91,13 @@ pub async fn handle_message_selection<T: TdClientTrait>(
|
|||||||
media::handle_voice_seek(app, -5.0);
|
media::handle_voice_seek(app, -5.0);
|
||||||
}
|
}
|
||||||
Some(crate::config::Command::ReactMessage) => {
|
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 {
|
let Some(msg) = app.get_selected_message() else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let chat_id = app.selected_chat_id.unwrap();
|
|
||||||
let message_id = msg.id();
|
let message_id = msg.id();
|
||||||
|
|
||||||
app.status_message = Some("Загрузка реакций...".to_string());
|
app.status_message = Some("Загрузка реакций...".to_string());
|
||||||
|
|||||||
@@ -112,7 +112,10 @@ async fn handle_view_image<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
return;
|
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 msg_id = msg.id();
|
||||||
let file_id = photo.file_id;
|
let file_id = photo.file_id;
|
||||||
let photo_width = photo.width;
|
let photo_width = photo.width;
|
||||||
@@ -238,7 +241,10 @@ async fn handle_play_voice<T: TdClientTrait>(app: &mut App<T>) {
|
|||||||
return;
|
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;
|
let file_id = voice.file_id;
|
||||||
|
|
||||||
match &voice.download_state {
|
match &voice.download_state {
|
||||||
|
|||||||
@@ -433,24 +433,20 @@ pub fn render_message_bubble(
|
|||||||
// Отображаем индикатор воспроизведения голосового
|
// Отображаем индикатор воспроизведения голосового
|
||||||
if msg.has_voice() {
|
if msg.has_voice() {
|
||||||
if let Some(voice) = msg.voice_info() {
|
if let Some(voice) = msg.voice_info() {
|
||||||
let is_this_playing = playback_state
|
let status_line =
|
||||||
.map(|ps| ps.message_id == msg.id())
|
if let Some(ps) = playback_state.filter(|ps| ps.message_id == msg.id()) {
|
||||||
.unwrap_or(false);
|
let icon = match ps.status {
|
||||||
|
PlaybackStatus::Playing => "▶",
|
||||||
let status_line = if is_this_playing {
|
PlaybackStatus::Paused => "⏸",
|
||||||
let ps = playback_state.unwrap();
|
PlaybackStatus::Loading => "⏳",
|
||||||
let icon = match ps.status {
|
_ => "⏹",
|
||||||
PlaybackStatus::Playing => "▶",
|
};
|
||||||
PlaybackStatus::Paused => "⏸",
|
let bar = render_progress_bar(ps.position, ps.duration, 20);
|
||||||
PlaybackStatus::Loading => "⏳",
|
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();
|
let status_len = status_line.chars().count();
|
||||||
if msg.is_outgoing() {
|
if msg.is_outgoing() {
|
||||||
@@ -670,7 +666,9 @@ pub fn render_album_bubble(
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Timestamp из последнего сообщения
|
// Timestamp из последнего сообщения
|
||||||
let last_msg = messages.last().unwrap();
|
let Some(last_msg) = messages.last() else {
|
||||||
|
return (lines, deferred);
|
||||||
|
};
|
||||||
let time = format_timestamp(last_msg.date());
|
let time = format_timestamp(last_msg.date());
|
||||||
|
|
||||||
if !captions.is_empty() {
|
if !captions.is_empty() {
|
||||||
|
|||||||
@@ -9,15 +9,17 @@ extern "C" {
|
|||||||
/// Отключаем логи TDLib синхронно, до создания клиента
|
/// Отключаем логи TDLib синхронно, до создания клиента
|
||||||
pub fn disable_tdlib_logs() {
|
pub fn disable_tdlib_logs() {
|
||||||
let request = r#"{"@type":"setLogVerbosityLevel","new_verbosity_level":0}"#;
|
let request = r#"{"@type":"setLogVerbosityLevel","new_verbosity_level":0}"#;
|
||||||
let c_request = CString::new(request).unwrap();
|
if let Ok(c_request) = CString::new(request) {
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = td_execute(c_request.as_ptr());
|
let _ = td_execute(c_request.as_ptr());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Также перенаправляем логи в никуда
|
// Также перенаправляем логи в никуда
|
||||||
let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#;
|
let request2 = r#"{"@type":"setLogStream","log_stream":{"@type":"logStreamEmpty"}}"#;
|
||||||
let c_request2 = CString::new(request2).unwrap();
|
if let Ok(c_request2) = CString::new(request2) {
|
||||||
unsafe {
|
unsafe {
|
||||||
let _ = td_execute(c_request2.as_ptr());
|
let _ = td_execute(c_request2.as_ptr());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
mod helpers;
|
mod helpers;
|
||||||
|
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
use helpers::app_builder::TestAppBuilder;
|
||||||
use helpers::fake_tdclient::FakeTdClient;
|
use helpers::fake_tdclient::FakeTdClient;
|
||||||
use helpers::test_data::TestMessageBuilder;
|
use helpers::test_data::TestMessageBuilder;
|
||||||
|
use tele_tui::config::Command;
|
||||||
|
use tele_tui::input::handlers::chat::handle_message_selection;
|
||||||
use tele_tui::types::ChatId;
|
use tele_tui::types::ChatId;
|
||||||
|
|
||||||
/// Test: Добавление реакции к сообщению
|
/// Test: Добавление реакции к сообщению
|
||||||
@@ -32,6 +36,25 @@ async fn test_add_reaction_to_message() {
|
|||||||
assert!(messages[0].reactions()[0].is_chosen);
|
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) - вторичное нажатие
|
/// Test: Удаление реакции (toggle) - вторичное нажатие
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_toggle_reaction_removes_it() {
|
async fn test_toggle_reaction_removes_it() {
|
||||||
|
|||||||
Reference in New Issue
Block a user