use ::image::DynamicImage; use anyhow::Result; use ratatui::crossterm::ExecutableCommand; use ratatui::crossterm::event::{ self, DisableBracketedPaste, DisableFocusChange, EnableBracketedPaste, EnableFocusChange, Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags, }; use ratatui::crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen}; use ratatui::layout::{Constraint, Layout}; use ratatui::widgets::{StatefulWidget, Widget}; use ratatui::{DefaultTerminal, Frame}; use ratatui_image::picker::{Picker, ProtocolType}; use std::sync::Arc; use std::time::Instant; use tokio::sync::{Mutex, mpsc}; use tokio::time::{Duration, MissedTickBehavior}; use tracing::{debug, error, info, warn}; use crate::backend::{ self, AuthSteps, BackendError, BackendEvent, Chat, ChatId, MediaKind, Message, MessageAction, MessageId, MessengerKind, Provider, }; use crate::config::{Config, Keymap}; use crate::helpers::available_message_actions; use crate::notify::{DesktopNotifier, Notifier}; use crate::tui::chat::chat_list::ChatList; use crate::tui::chat::chat_widget::ChatWidget; use crate::tui::image::{ImageWidgetState, VideoWidgetState}; use crate::tui::loading::{LoadingArea, LoadingWidget}; // Re-exported so the notifier can key its deduplication on the same message // identity the player uses, without reaching into a private module. pub use crate::tui::player::PlayKey; use crate::tui::player::{FfmpegEngine, PlayState, PlaybackState, Player, RodioEngine}; use crate::tui::popup::{AudioPopup, ImagePopup, PopupKind, VideoPopup}; use crate::tui::settings::Settings; use crate::tui::state::{AppState, Focus, MediaAction, Mode, Screen}; use crate::tui::status_bar::StatusBarWidget; mod chat; mod image; mod loading; mod login; mod player; mod popup; mod search; mod settings; mod state; mod status_bar; enum UiEvent { Key(KeyEvent), Paste(String), WindowFocused(bool), Shutdown, Backend(Provider, BackendEvent), /// Wake signal: a background image (re)encode finished; redraw to apply it. Redraw, HistoryRefreshResult(ChatId, Result, BackendError>), /// Result of a backgrounded lazy-history load (scroll-up with the chat at its /// topmost message); applied by `AppState::apply_history_page`. HistoryPageResult(ChatId, Result, BackendError>), ChatLoaded { chat: Chat, generation: u64, result: Result, BackendError>, status: Option, }, /// Result of a backgrounded per-provider chat-list sync. The `u64` is the /// lifecycle epoch the fetch started under; results from before a provider /// disable are discarded by `AppState::apply_synced_chats`. ChatList(Provider, Result, BackendError>, u64), /// Result of a backgrounded chat-list fetch across all providers, tagged /// with the lifecycle epoch it started under (see [`UiEvent::ChatList`]). ChatsLoaded { chats: Vec, errors: Vec, epoch: u64, }, /// Result of a backgrounded `Messenger::media_bytes` fetch for the image /// popup; applied by `App::apply_image_media` (a no-op if the popup for /// `message_id` is no longer open). ImageMedia { chat: ChatId, message_id: MessageId, result: Result>, BackendError>, }, /// Result of a backgrounded `Messenger::media_bytes` fetch for the audio /// popup; bytes are handed to the player worker (guarded by the popup still /// showing that message). AudioMedia { chat: ChatId, message_id: MessageId, result: Result>, BackendError>, }, /// Result of a backgrounded `Messenger::media_bytes` fetch for the video /// popup; bytes are handed to the video player worker (guarded by the /// popup still showing that message). /// BUG: check video rotation logic: landscape videos are not being shown as such, all videos /// are being rendered as portrait VideoMedia { chat: ChatId, message_id: MessageId, result: Result>, BackendError>, }, /// Snapshot of the active session from a player worker; applied by /// `AppState::apply_playback_state` (a no-op if no session is active for /// that message). PlaybackState(PlaybackState), Resize, } /// Upper bound on backend teardown once the terminal is back. Long enough for /// the normal path (a connected provider flushes in milliseconds), short enough /// that a wedged one cannot hold the process open. const BACKEND_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(2); pub async fn run( config: Config, keymap: Keymap, messengers: Vec, open_settings: bool, ) -> Result<()> { let mut terminal = ratatui::init(); // The terminal belongs to the TUI until the restore below, so every exit path // has to undo raw mode and the alt screen — not just the clean one. A failed // setup step or an error out of `run_app` returns through the same path. let app = async { // Let the terminal send pasted text as a single bracketed-paste event instead of a stream of // raw keys, so multi-line paste cannot trigger Enter=send mid-paste. std::io::stdout().execute(EnableBracketedPaste)?; std::io::stdout().execute(EnterAlternateScreen)?; std::io::stdout().execute(EnableFocusChange)?; // Push-to-talk needs a clean Press→Release lifecycle: report event types // (release/repeat kinds) and disambiguate modified keys as CSI-u sequences. // Terminals that reject these flags keep working via the second-press // fallback in `handle_main_key`. // // TODO: holding the PTT record key on kitty yields a Press stream with no // Release, because `a` is a text key and kitty only reports text keys as // events under REPORT_ALL_KEYS_AS_ESCAPE_CODES (bit 8); sending on release // therefore falls back to the gated second press. Worth checking whether // enabling bit 8 (real key-up finishing) is a worthwhile compat fix. std::io::stdout().execute(PushKeyboardEnhancementFlags( KeyboardEnhancementFlags::REPORT_EVENT_TYPES | KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES, ))?; run_app(&mut terminal, config, keymap, messengers, open_settings).await } .await; // Give the terminal back before anything else, including propagating an // error out of the TUI. A provider that is mid-connect makes `disconnect` // wait for that attempt to finish (up to the transport's own connect // timeout), and holding the screen in raw mode for it leaves a frozen TUI on // Ctrl+C. Shutdown is best-effort from here. let _ = std::io::stdout().execute(PopKeyboardEnhancementFlags); let _ = std::io::stdout().execute(DisableBracketedPaste); let _ = std::io::stdout().execute(DisableFocusChange); let _ = std::io::stdout().execute(LeaveAlternateScreen); ratatui::restore(); let mut app = app?; // The TUI is de-rendered and the shell is back, so tell the user what the // process is doing: teardown below can still take up to // BACKEND_SHUTDOWN_TIMEOUT, and an idle terminal reads as a hung process. println!("Shutting down..."); match tokio::time::timeout(BACKEND_SHUTDOWN_TIMEOUT, app.shutdown()).await { Ok(()) => {} Err(_) => warn!( timeout_secs = BACKEND_SHUTDOWN_TIMEOUT.as_secs(), "Backend teardown did not finish in time; exiting anyway" ), } Ok(()) } fn spawn_signal_listener(tx: mpsc::UnboundedSender) { tokio::spawn(async move { #[cfg(unix)] { use tokio::signal::unix::{SignalKind, signal}; let mut sigint = signal(SignalKind::interrupt()).expect("SIGINT listener"); let mut sigterm = signal(SignalKind::terminate()).expect("SIGTERM listener"); tokio::select! { _ = sigint.recv() => { let _ = tx.send(UiEvent::Shutdown); } _ = sigterm.recv() => { let _ = tx.send(UiEvent::Shutdown); } } } #[cfg(windows)] { use tokio::signal::windows::{ ctrl_break, ctrl_c, ctrl_close, ctrl_logoff, ctrl_shutdown, }; let mut sigint = ctrl_c().expect("Ctrl-C listener"); let mut sigbreak = ctrl_break().expect("Ctrl-Break listener"); let mut sigclose = ctrl_close().expect("Ctrl-Close listener"); let mut siglogoff = ctrl_logoff().expect("Ctrl-Logoff listener"); let mut sigshutdown = ctrl_shutdown().expect("Ctrl-Shutdown listener"); tokio::select! { _ = sigint.recv() => { let _ = tx.send(UiEvent::Shutdown); } _ = sigbreak.recv() => { let _ = tx.send(UiEvent::Shutdown); } _ = sigclose.recv() => { let _ = tx.send(UiEvent::Shutdown); } _ = siglogoff.recv() => { let _ = tx.send(UiEvent::Shutdown); } _ = sigshutdown.recv() => { let _ = tx.send(UiEvent::Shutdown); } } } }); } async fn run_app( terminal: &mut DefaultTerminal, config: Config, keymap: Keymap, messengers: Vec, open_settings: bool, ) -> Result { let (tx, mut rx) = mpsc::unbounded_channel::(); // Query the terminal's image protocol once, before the input reader starts // competing for stdin. Any failure degrades to halfblocks instead of // aborting the app. let mut picker = match Picker::from_query_stdio() { Ok(picker) => picker, Err(err) => { warn!(%err, "failed to query terminal image protocol; using halfblocks"); Picker::halfblocks() } }; // ratatui-image can misdetect iTerm2 (ratatui/ratatui-image#158); pin the // native protocol when running inside it. let is_iterm2 = std::env::var("TERM_PROGRAM").is_ok_and(|v| v == "iTerm.app") || std::env::var("LC_TERMINAL").is_ok_and(|v| v == "iTerm2"); if is_iterm2 && picker.protocol_type() != ProtocolType::Iterm2 { picker.set_protocol_type(ProtocolType::Iterm2); } info!(protocol = ?picker.protocol_type(), "terminal image protocol selected"); spawn_terminal_reader(tx.clone(), keymap.record_voice); spawn_signal_listener(tx.clone()); // handling new events for each messenger type for messenger in messengers.iter() { // Every provider is subscribed, enabled or not: subscribing only // installs a receiver, and a dormant provider has nothing to emit yet. // Gating the subscription on the config instead would leave a provider // enabled later from settings with no receiver attached. let mut backend_rx = messenger.subscribe(); let forward_tx = tx.clone(); let provider = messenger.provider(); // TODO: review provider message receive pipeline: // Messages are being delivered > notification is firing > but takes a little longer // for the UI to load the new message -> Notifications should only fire if the TUI // already has the msg tokio::spawn(async move { loop { match backend_rx.recv().await { Ok(event) => { if forward_tx.send(UiEvent::Backend(provider, event)).is_err() { break; } } Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { warn!( provider = ?provider, skipped, "Backend event lagged; dropping missed events" ); continue; } Err(tokio::sync::broadcast::error::RecvError::Closed) => break, } } }); } let mut app = App::new( config, keymap, messengers, open_settings, picker, tx.clone(), ) .await; // Lifecycle step: every backend receiver above is registered, so an enabled // provider can be started without a `broadcast` channel dropping its first // `Connected` / `QrCode` event. Providers the configuration leaves disabled // stay dormant: no transport, no provider fetch. app.state.start_enabled_providers().await; info!("TUI started"); let telegram_needs_login = if app.state.config.providers.telegram.enabled { match app.state.provider_to_messenger(Provider::Telegram) { Some(messenger) => !messenger.is_authenticated().await, None => false, } } else { false }; if telegram_needs_login { app.state.start_login(Provider::Telegram)?; } // Engage the chat-list loading indicator for the non-blocking startup // fetch; `apply_fetched` releases it when results arrive. app.state .start_loading(LoadingArea::ChatList, "Fetching chats...".to_string()); // Kick off the initial chat list fetch in the background so the UI renders // immediately (non-blocking startup). Results arrive via `ChatsLoaded`, // tagged with the epoch they started under so a disable that lands while // the fetch is in flight discards its WhatsApp rows. let chat_loader_tx = tx.clone(); let loader_messengers = app.state.messengers.clone(); let provider_configs = app.state.config.providers.clone(); { let provider_configs = provider_configs.clone(); let epoch = app.state.lifecycle_epoch; tokio::spawn(async move { let (chats, errors) = crate::tui::state::fetch_all_chats(&loader_messengers, &provider_configs).await; let _ = chat_loader_tx.send(UiEvent::ChatsLoaded { chats, errors, epoch, }); }); }; let mut chat_poll_interval = tokio::time::interval(Duration::from_secs( app.state.config.chat_poll_interval_secs, )); chat_poll_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); let chat_list_period = Duration::from_secs(app.state.config.chat_list_sync_secs); let mut chatlist_sync_interval = tokio::time::interval_at( tokio::time::Instant::now() + chat_list_period, chat_list_period, ); chatlist_sync_interval.set_missed_tick_behavior(MissedTickBehavior::Delay); let mut status_clock = tokio::time::interval(Duration::from_secs(1)); status_clock.set_missed_tick_behavior(MissedTickBehavior::Delay); loop { terminal.draw(|frame| app.draw(frame))?; tokio::select! { biased; // Wakes the loop every second so the live flood-wait countdown and // reconnect hint re-render without needing an input or a backend // event to drive the redraw. _ = status_clock.tick() => {} _ = chat_poll_interval.tick() => { let chat_id = match app.state.chat_state.open_chat.as_ref() { Some(open) => open.chat.id.clone(), None => continue, }; let messenger_idx = app.state.messengers.iter().position(|m| { m.provider() == chat_id.to_provider() }); if let Some(idx) = messenger_idx { if app.state.history_refresh_in_flight { continue; } app.state.history_refresh_in_flight = true; let messenger = app.state.messengers[idx].clone(); let tx = tx.clone(); let chat_for_task = chat_id.clone(); tokio::spawn(async move { let result = messenger.history(&chat_for_task).await; let _ = tx.send(UiEvent::HistoryRefreshResult(chat_for_task, result)); }); } } // load chatlist task _ = chatlist_sync_interval.tick() => { if !app.state.chats_loaded { continue; } if app.state.chatlist_sync_in_flight { continue; } // Only enabled providers are fetched, so `pending` counts // exactly what is spawned below: one disabled provider must // not strand the counter (and with it every later sync). // Enabled state is read live, not from the startup snapshot — // a provider disabled since then must not be polled again. let enabled: Vec<_> = app .state .messengers .iter() .filter(|messenger| { messenger .provider() .is_enabled(&app.state.config.providers) }) .cloned() .collect(); if enabled.is_empty() { continue; } app.state.chatlist_sync_in_flight = true; app.state.chatlist_sync_pending = enabled.len(); app.state.start_loading( LoadingArea::ChatList, "Syncing chats...".to_string(), ); let epoch = app.state.lifecycle_epoch; for messenger in enabled { let tx = tx.clone(); tokio::spawn(async move { let provider = messenger.provider(); let result = messenger.chats().await; let _ = tx.send(UiEvent::ChatList(provider, result, epoch)); }); } } event = rx.recv() => { let Some(event) = event else { break; }; match event { UiEvent::WindowFocused(state) => app.state.set_window_focus(state), UiEvent::Key(key) => app.handle_key(key).await, UiEvent::Paste(text) => app.state.handle_paste(text), UiEvent::Shutdown => { app.state.running = false; break; } UiEvent::Backend(provider, backend_event) => { debug!( provider = ?provider, event = ?std::mem::discriminant(&backend_event), "TUI processing backend event, pending render" ); let rebuild = matches!(backend_event, BackendEvent::Connected) && provider.is_enabled(&app.state.config.providers); // Asked before the event is consumed: the notice reads // the pre-update chat list, which is exactly the state // the event is about to act on. A disabled provider // never announces: its events are stale leftovers from // the stop. let notice = match &backend_event { BackendEvent::MessageReceived(message) if provider.is_enabled(&app.state.config.providers) => { app.state.notice_for_message(provider, message) } _ => None, }; app.state.handle_backend_event(provider, backend_event); if let Some(notice) = notice { let dispatch = app.notifier.dispatch(¬ice, Instant::now()); // One message, one noise: a desktop notification // only stays silent if we really did play a cue. let played_cue = dispatch.cue.is_some(); if let Some(desktop) = dispatch.os { // Showing a notification is a blocking round // trip to the session bus, so it must not run // on the event loop. The result is logged and // dropped: a failed toast is not worth // interrupting the user for. let notice = notice.clone(); tokio::task::spawn_blocking(move || { if let Err(error) = desktop.notify(¬ice, played_cue) { warn!(%error, "OS notification failed"); } }); } if let Some(cue) = dispatch.cue { // Non-blocking, and the engine drops the cue if // the user is listening to something. app.player.cue(cue); } } // On a newly established connection (e.g. WhatsApp just // paired via QR), refresh the shared chat list so the // new provider's dialogs appear without a restart. if rebuild { app.state.start_loading( LoadingArea::ChatList, "Fetching chats...".to_string(), ); let chat_loader_tx = tx.clone(); let loader_messengers = app.state.messengers.clone(); let loader_providers = app.state.config.providers.clone(); let epoch = app.state.lifecycle_epoch; tokio::spawn(async move { let (chats, errors) = crate::tui::state::fetch_all_chats( &loader_messengers, &loader_providers, ) .await; let _ = chat_loader_tx.send(UiEvent::ChatsLoaded { chats, errors, epoch, }); }); } } UiEvent::Redraw => {} UiEvent::HistoryRefreshResult(chat_id, result) => { app.state.history_refresh_in_flight = false; if app.state.chat_state.open_chat.as_ref().map(|open| &open.chat.id) != Some(&chat_id) { continue; } if let Ok(history) = result { debug!(chat = ?chat_id, msgs = history.len(), "Poll refresh OK"); app.state.update_chat_list_from_poll(&chat_id, &history); app.state.chat_state.refresh_chat_history(&chat_id, history); } else if let Err(e) = result { error!(chat = ?chat_id, error = %e, "Poll refresh failed"); } } UiEvent::HistoryPageResult(chat_id, result) => { if app.state.chat_state.open_chat.as_ref().map(|open| &open.chat.id) != Some(&chat_id) { continue; } app.state.apply_history_page(&chat_id, result); } UiEvent::ChatLoaded { chat, generation, result, status, } => app.state.apply_chat_load(chat, generation, result, status), UiEvent::ChatList(provider, result, epoch) => { app.state.apply_synced_chats(provider, result, epoch); } UiEvent::ChatsLoaded { chats, errors, epoch } => { debug!( chats = chats.len(), errors = errors.len(), "ChatsLoaded applied" ); app.state.apply_fetched(chats, errors, epoch); } UiEvent::ImageMedia { chat, message_id, result, } => app.apply_image_media(chat, message_id, result).await, UiEvent::AudioMedia { chat, message_id, result, } => app.apply_audio_media(chat, message_id, result).await, UiEvent::VideoMedia { chat, message_id, result, } => app.apply_video_media(chat, message_id, result).await, UiEvent::PlaybackState(playback) => { app.state.apply_playback_state(playback); } UiEvent::Resize=>{} } if !app.state.running { break; } } } } // Shutdown is deliberately left to the caller: it owns the terminal and // must hand it back before this app's backends are torn down. Ok(app) } fn spawn_terminal_reader(tx: mpsc::UnboundedSender, record_key: KeyEvent) { std::thread::spawn(move || { loop { match event::read() { Ok(Event::FocusGained) => { debug!("Event::FocusGained"); if tx.send(UiEvent::WindowFocused(true)).is_err() { break; } } Ok(Event::FocusLost) => { debug!("Event::FocusLost"); if tx.send(UiEvent::WindowFocused(false)).is_err() { break; } } Ok(Event::Resize(..)) => { if tx.send(UiEvent::Resize).is_err() { break; } } // Press and Repeat are the events the whole UI already consumes // (Repeat keeps hold-down scrolling/typing working on // terminals that report kinds). Release is forwarded ONLY for // the push-to-talk record key so PTT gets a clean key-up signal // while no stray Release can ever reach the write box. Ok(Event::Key(key)) if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat || (key.kind == KeyEventKind::Release && key.code == record_key.code && key.modifiers == record_key.modifiers) => { if tx.send(UiEvent::Key(key)).is_err() { break; }; } Ok(Event::Paste(text)) => { if tx.send(UiEvent::Paste(text)).is_err() { break; } } Ok(_) => {} Err(_) => break, } } }); } struct App { state: AppState, picker: Picker, player: Player, /// The video engine writes each decoded frame into this slot; the video /// popup's widget holds a clone and picks it up at draw time, so the /// engine never has to hand a frame back (D6). video_frames: Arc>>, video_player: Player, notifier: Notifier, tx: mpsc::UnboundedSender, chat_load_task: Option>, } impl App { async fn new( config: Config, keymap: Keymap, messengers: Vec, open_settings: bool, picker: Picker, tx: mpsc::UnboundedSender, ) -> Self { // Built from the config before it is handed to the state: the sound // files are read once, here, not on the first message. let notifier = Notifier::new(&config.notifications, Arc::new(DesktopNotifier)); // One slot, cloned into the engine factory and later into the popup's // widget, so decoded frames flow engine -> widget without a round trip. let video_frames = Arc::new(Mutex::new(None)); let engine_frames = Arc::clone(&video_frames); let engine_wake = tx.clone(); let video_player = Player::new( move || Box::new(FfmpegEngine::new(engine_frames, engine_wake)), tx.clone(), ); Self { state: AppState::new(config, keymap, messengers, open_settings).await, picker, player: Player::new(|| Box::new(RodioEngine::default()), tx.clone()), video_frames, video_player, notifier, tx, chat_load_task: None, } } fn start_chat_load(&mut self, index: usize) { self.cancel_chat_load(); let Some((chat, generation, mut messenger)) = self.state.begin_chat_load(index) else { return; }; let tx = self.tx.clone(); self.chat_load_task = Some(tokio::spawn(async move { // Subscribe to presence and read the current user status while the // chat loads; WhatsApp only sends presence updates to chats it was // asked to track, so asking here is what makes Online/Last Seen // appear at all. let status = messenger.status(&chat.id).await.unwrap_or(None); let result = match messenger.set_read(&chat.id).await { Ok(()) => messenger.history(&chat.id).await, Err(e) => Err(e), }; let _ = tx.send(UiEvent::ChatLoaded { chat, generation, result, status, }); })); } fn cancel_chat_load(&mut self) { if let Some(task) = self.chat_load_task.take() { task.abort(); } self.state.cancel_chat_load(); } fn load_more_history(&mut self) { let Some(chat_id) = self .state .chat_state .open_chat .as_ref() .map(|open| open.chat.id.clone()) else { return; }; let Some(messenger) = self .state .provider_to_messenger(chat_id.to_provider()) .cloned() else { return; }; let Some(open) = self.state.chat_state.open_chat.as_ref() else { return; }; if !open.has_more_history { return; } // Telegram paginates by numeric message id; WhatsApp's ids are opaque // (`to_i32()` → `None`), so the backend anchors on its own oldest cached // message instead. Fetch in a background task: WhatsApp's on-demand sync // can take seconds, and waiting on it inline would freeze the UI. let offset_id = open.history.first().and_then(|msg| msg.message_id.to_i32()); self.cancel_chat_load(); self.state .start_loading(LoadingArea::Inline, "Loading older messages...".to_string()); let tx = self.tx.clone(); let task_chat = chat_id.clone(); self.chat_load_task = Some(tokio::spawn(async move { let result = messenger.history_page(&task_chat, offset_id, 25).await; let _ = tx.send(UiEvent::HistoryPageResult(task_chat, result)); })); } fn open_image_popup(&mut self, msg: Message) { debug!("Opening image for {:?}", msg.message_id); let Some(messenger) = self.state.chat_owner(&msg.chat_id).cloned() else { return; }; let chat = msg.chat_id.clone(); let message_id = msg.message_id.clone(); self.state.create_popup(PopupKind::Image(ImagePopup { msg, view: ImageWidgetState::new(self.tx.clone()), })); let tx = self.tx.clone(); tokio::spawn(async move { let result = messenger.media_bytes(&chat, &message_id).await; let _ = tx.send(UiEvent::ImageMedia { chat, message_id, result, }); }); } async fn apply_image_media( &mut self, chat: ChatId, message_id: MessageId, result: Result>, BackendError>, ) { let Some(popup) = self.state.pop_up.as_mut() else { return; }; let PopupKind::Image(image_popup) = &mut popup.popup_type else { return; }; // Only apply if the popup still shows the same message. if image_popup.msg.chat_id != chat || image_popup.msg.message_id != message_id { return; } let view = &mut image_popup.view; match result { Ok(Some(bytes)) => { // Decode off the async runtime; image decoding is CPU-bound. let decoded = tokio::task::spawn_blocking(move || ::image::load_from_memory(&bytes)).await; match decoded { Ok(Ok(image)) => view.set_image(&self.picker, image), Ok(Err(e)) => { error!("Err Failed to decode image {e}"); view.set_error(format!("Failed to decode image: {e}")) } Err(e) => { error!("Image decode task failed: {e}"); view.set_error(format!("Image decode task failed: {e}")) } } } Ok(None) => { warn!("Media not available error!"); view.set_error("Media not available".to_string()) } Err(e) => { error!("Failed to load image: {e}"); view.set_error(format!("Failed to load image: {e}")) } } } async fn apply_audio_media( &mut self, chat: ChatId, message_id: MessageId, result: Result>, BackendError>, ) { let same_target = match self.state.pop_up.as_ref().map(|p| &p.popup_type) { Some(PopupKind::Audio(AudioPopup { msg, .. })) => { msg.chat_id == chat && msg.message_id == message_id } _ => false, }; if !same_target { return; } let source = PlayKey { chat, message_id }; // Failure reasons differ: `Ok(None)` means the provider has no media // (e.g. WhatsApp still lacks a cached CDN reference), `Err` is a real // download/decrypt failure. Both land in `error_note` so the popup // shows why instead of a bare ⚠. let note = match result { Ok(Some(bytes)) => { debug!(chat = ?source.chat, msg = %source.message_id, "Audio media loaded, awaiting play"); // Load only; playback starts on the first space press so // opening a popup never surprises the user with sound. self.player.load(source, bytes); return; } Ok(None) => { warn!(chat = ?source.chat, msg = %source.message_id, "No audio media available"); "audio not available yet — waiting on WhatsApp history re-sync".to_string() } Err(e) => { error!(chat = ?source.chat, msg = %source.message_id, error = %e, "Audio media download failed"); format!("download failed: {e}") } }; if let Some(popup) = self.state.pop_up.as_mut() && let PopupKind::Audio(audio) = &mut popup.popup_type { audio.error_note = Some(note); } self.state.playback = Some(PlaybackState { source, status: PlayState::Error, position: 0.0, duration: 0.0, updated_at: Instant::now(), }); } fn open_audio_popup(&mut self, msg: Message) { debug!("Opening audio for {:?}", msg.message_id); let Some(messenger) = self.state.chat_owner(&msg.chat_id).cloned() else { return; }; let chat = msg.chat_id.clone(); let message_id = msg.message_id.clone(); // Seed the session so early worker reports are accepted by // `apply_playback_state`'s same-source guard. The provider-reported // duration (Telegram/WhatsApp audio metadata) is shown until the first // decode reports the real playback length. Audio metadata lives inside // the kind variant, and this popup only opens for audio media. let known_duration = msg .media .as_ref() .and_then(|m| match &m.kind { MediaKind::Audio { duration_secs, .. } => *duration_secs, _ => None, }) .unwrap_or(0) as f64; self.state.playback = Some(PlaybackState { source: PlayKey { chat: chat.clone(), message_id: message_id.clone(), }, status: PlayState::Loading, position: 0.0, duration: known_duration, updated_at: Instant::now(), }); self.state.create_popup(PopupKind::Audio(AudioPopup { msg, playback: None, error_note: None, })); let tx = self.tx.clone(); tokio::spawn(async move { let result = messenger.media_bytes(&chat, &message_id).await; let _ = tx.send(UiEvent::AudioMedia { chat, message_id, result, }); }); } fn open_video_popup(&mut self, msg: Message) { debug!("Opening video for {:?}", msg.message_id); let Some(messenger) = self.state.chat_owner(&msg.chat_id).cloned() else { return; }; let chat = msg.chat_id.clone(); let message_id = msg.message_id.clone(); // Seed the session so early worker reports are accepted by // `apply_playback_state`'s same-source guard, which routes to this slot. self.state.video = Some(PlaybackState { source: PlayKey { chat: chat.clone(), message_id: message_id.clone(), }, status: PlayState::Loading, position: 0.0, duration: 0.0, updated_at: Instant::now(), }); self.state.create_popup(PopupKind::Video(VideoPopup { msg, view: VideoWidgetState::new( Arc::clone(&self.video_frames), self.picker.clone(), self.tx.clone(), ), playback: None, error_note: None, })); let tx = self.tx.clone(); tokio::spawn(async move { let result = messenger.media_bytes(&chat, &message_id).await; let _ = tx.send(UiEvent::VideoMedia { chat, message_id, result, }); }); } async fn apply_video_media( &mut self, chat: ChatId, message_id: MessageId, result: Result>, BackendError>, ) { let same_target = match self.state.pop_up.as_ref().map(|p| &p.popup_type) { Some(PopupKind::Video(VideoPopup { msg, .. })) => { msg.chat_id == chat && msg.message_id == message_id } _ => false, }; if !same_target { return; } let source = PlayKey { chat, message_id }; // Same split as audio: `Ok(None)` is the provider having no bytes yet, // `Err` is a genuine download/decrypt failure. Both become a note in // the popup's bottom border plus an `Error` snapshot, so the transport // line shows ⚠ instead of pretending playback is possible. let note = match result { Ok(Some(bytes)) => { debug!(chat = ?source.chat, msg = %source.message_id, "Video media loaded, awaiting play"); // Load only; playback starts on the first space press, so // opening a video never starts decoding unasked. self.video_player.load(source, bytes); return; } Ok(None) => { warn!(chat = ?source.chat, msg = %source.message_id, "No video media available"); "video not available yet — waiting on WhatsApp history re-sync".to_string() } Err(e) => { error!(chat = ?source.chat, msg = %source.message_id, error = %e, "Video media download failed"); format!("download failed: {e}") } }; if let Some(popup) = self.state.pop_up.as_mut() && let PopupKind::Video(video) = &mut popup.popup_type { // The border note and the placeholder both say it; the note is // what the user reads first. video.view.set_error(note.clone()); video.error_note = Some(note); } self.state.video = Some(PlaybackState { source, status: PlayState::Error, position: 0.0, duration: 0.0, updated_at: Instant::now(), }); } /// Dismiss the current popup, stopping an active media session. Closing an /// audio or video popup must silence playback and drop its state so /// reopening starts from a fresh fetch (no position is retained). fn dismiss_popup(&mut self) { // Read before the popup is dropped below. let popup_type = self.state.pop_up.as_ref().map(|p| &p.popup_type); let was_audio = popup_type.is_some_and(|p| matches!(p, PopupKind::Audio(_))); let was_video = popup_type.is_some_and(|p| matches!(p, PopupKind::Video(_))); // `AppState::dismiss_popup` clears the stored playback snapshot. // TODO: check the memory storage/usage of media when rendering a popup: // Memory is apparently not being freed on dismiss // toggling play/pause is consuming (increasing?) mem usage self.state.dismiss_popup(); if was_audio { debug!("audio popup dismissed, stopping playback"); self.player.stop(); } if was_video { debug!("video popup dismissed, stopping playback"); self.video_player.stop(); } } async fn shutdown(&mut self) { self.cancel_chat_load(); self.player.stop(); self.video_player.stop(); for messenger in &mut self.state.messengers { if let Err(err) = messenger.disconnect().await { error!( provider = ?messenger.provider(), error = %err, "Messenger shutdown failed" ); } } self.state.running = false; } async fn handle_key(&mut self, mut key: KeyEvent) { // Num/Caps Lock are layout status, not modifiers. kitty tags every // non-text key (CSI-u reporting) with the active lock bits, which // would otherwise poison each `key == binding` comparison: the keymap // is parsed with an empty KeyEventState. Strip them here so bindings // keep working regardless of lock state. key.state .remove(KeyEventState::CAPS_LOCK | KeyEventState::NUM_LOCK); // TODO: add a overlay menu / screen to display all the keymappings if key == self.state.keymap.quit { self.state.running = false; return; } // A key can still be delivered in the gap before the terminal reports // focus (or by a multiplexer that never reports it at all). Ignoring it // keeps a stray keystroke from moving the selection, typing into the // write box or sending a message into a window the user left. if !self.state.focused { debug!("key ignored while the window is unfocused"); return; } // Keep the input mode in sync with which pane is focused, so // app-level bindings can be gated on whether a text box has focus. self.state.update_mode(); // Push-to-talk owns the keyboard while recording: key-up sends, and on // terminals that report no event types a deliberate second press sends // and Esc cancels; anything else is ignored so navigation or the OS // auto-repeat can't interrupt the take. Releases never carry a fresh // press, so `record_key_pressed` gates only Press events. if self.state.recording_active() { let record_key = self.state.keymap.record_voice; let is_record_key = key.code == record_key.code && key.modifiers == record_key.modifiers; if key == self.state.keymap.dismiss { self.state.cancel_recording(); return; } let finish_take = match key.kind { // Key-up always sends. KeyEventKind::Release => true, // Terminals without event types auto-repeat a Press; only a // deliberate press (aged past the repeat delay/cadence) sends. KeyEventKind::Press => self.state.record_key_pressed(Instant::now()), // OS-level hold-repeat: never a send in itself. _ => false, }; if is_record_key && finish_take { self.state.finish_recording().await; } return; } if self.state.pop_up.is_some() { // Pressing the record key over a message popup records a voice note // sent as a reply to the popup's message (popup dismisses, the // write box shows the pending reply). let record_key = self.state.keymap.record_voice; if key.code == record_key.code && key.modifiers == record_key.modifiers && key.kind == KeyEventKind::Press && let Some(msg_id) = self.state.reply_recording_target() { self.dismiss_popup(); self.state.reply_to_message(&msg_id); self.state.record_key_pressed(Instant::now()); self.state.start_recording(); return; } self.handle_popup_key(key).await; return; } match self.state.screen { Screen::Settings => self.handle_settings_key(key).await, Screen::Main => self.handle_main_key(key).await, Screen::Login => self.handle_login_key(key).await, } } async fn handle_settings_key(&mut self, key: KeyEvent) { let km = self.state.keymap.clone(); if key == km.dismiss || key == km.open_settings { self.state.screen = Screen::Main; } else if key == km.scroll_up { let index = self .state .settings_state .selected() .unwrap_or(0) .saturating_sub(1); self.state.settings_state.select(Some(index)); } else if key == km.scroll_down { let index = self.state.settings_state.selected().unwrap_or(0); if index + 1 < Provider::all().len() { self.state.settings_state.select(Some(index + 1)); } } else if key == km.select { let index = self.state.settings_state.selected().unwrap_or(0); let Some(&provider) = Provider::all().get(index) else { return; }; self.state.toggle_provider(provider).await; } } async fn handle_login_key(&mut self, key: KeyEvent) { let km = self.state.keymap.clone(); if key == km.dismiss { self.state.cancel_login().await; } else if key == km.send { self.state.submit_login().await; } else if let Some(ref mut ls) = self.state.login_state { ls.login_input.input(key); } } async fn handle_main_key(&mut self, key: KeyEvent) { let km = self.state.keymap.clone(); let searching = self.state.chat_list_search_active() || self.state.message_search_active(); // App-level bindings are inert while a text box has focus; only the // explicitly captured keys (esc/ctrl+c/enter, tab in the write box) // are handled, everything else reaches the text widget. let insert = self.state.mode == Mode::Insert; let record_key = km.record_voice; let is_record_key = key.code == record_key.code && key.modifiers == record_key.modifiers; // Esc while a flood-wait countdown is active cancels the in-flight // refresh and defers it to the next chat-list sync tick, instead of // cycling focus like a regular dismiss. if !insert && key == km.dismiss && !searching && self.state.rate_limit.is_some() && self.state.cancel_rate_limit_wait().await { return; } // Manually force a reconnection after a provider entered the // "connection lost" state. No-op for providers without a persistent // transport or when no reconnect is pending. if !insert && key == km.retry_connection { if let Some(messenger) = self.state.provider_to_messenger(Provider::Telegram) && let Err(e) = messenger.reconnect().await { warn!(error = %e, "Manual reconnect failed"); } return; } // While an active chat-list search is showing, Esc is handled inside the // ChatList branch (cancel the search) instead of cycling focus. if !insert && key == km.dismiss && !searching { if self.state.focus == Focus::Chat && self.state.chat_state.open_chat.is_none() { self.cancel_chat_load(); self.state.focus = Focus::ChatList; return; } self.state.cycle_focus(); return; } if !insert && key == km.open_settings { self.state.screen = Screen::Settings; return; } // Tab exits the write box even while typing (insert mode). if key == km.pane_next && (!insert || self.state.focus == Focus::Write) { self.state.cycle_focus(); return; } // Jump from chat list to Write Box in selected chat if !insert && key == km.focus_write { match &self.state.chat_state.open_chat { Some(_) => { self.state.focus = Focus::Write; return; } None => match self.state.selected_chat_idx() { Some(chat) => { self.start_chat_load(chat); self.state.focus = Focus::Write; return; } None => { return; } }, } } match self.state.focus { Focus::ChatList => { if self.state.chat_list_search_typing() { if key == km.dismiss { self.state.chat_state.clear_search(); } else if key == km.select { self.state.chat_state.commit_search(); } else { self.state.chat_state.search_input(key); } return; } if key == km.dismiss && searching { self.state.chat_state.clear_search(); } else if key == km.search_text { self.state.chat_state.begin_search(); } else if key == km.scroll_up { self.state.chat_state.chat_list_state.select_previous(); } else if key == km.scroll_to_bottom { self.state.chat_state.chat_list_state.select_last(); } else if key == km.scroll_to_top { self.state.chat_state.chat_list_state.select_first(); } else if key == km.scroll_down { self.state.chat_state.chat_list_state.select_next(); } else if key == km.select && !self.state.chat_state.chats.is_empty() && self.state.chat_state.chat_list_state.selected().is_some() { match self.state.selected_chat_idx() { Some(chat) => self.start_chat_load(chat), None => { self.state .create_popup(PopupKind::Error(String::from("No chat selected"))); } }; } } Focus::Chat => { if self.state.message_search_typing() { if key == km.dismiss { self.state.chat_state.clear_message_search(); } else if key == km.select { self.state.chat_state.commit_message_search(); } else { self.state.chat_state.message_search_input(key); } return; } if key == km.dismiss && self.state.message_search_active() { self.state.chat_state.clear_message_search(); return; } // Push-to-talk start: with a chat open, a fresh press begins a // voice-note recording (letters are otherwise unused here). if is_record_key && key.kind == KeyEventKind::Press && self.state.chat_state.open_chat.is_some() { self.state.record_key_pressed(Instant::now()); self.state.start_recording(); return; } if key == km.search_text { self.state.chat_state.begin_message_search(); return; } if key == km.scroll_up { if self .state .chat_state .message_list_state .selected() .is_some_and(|current| current == 0) { self.load_more_history(); } else { self.state.chat_state.message_list_state.select_previous(); } } else if key == km.scroll_down { self.state.chat_state.message_list_state.select_next(); } else if key == km.scroll_to_top { self.state.chat_state.message_list_state.select_first(); } else if key == km.scroll_to_bottom { let last = self .state .chat_state .open_chat .as_ref() .map(|o| o.history.len().saturating_sub(1)); self.state.chat_state.message_list_state.select(last); } else if key.code == KeyCode::PageUp { if let Some(current) = self.state.chat_state.message_list_state.selected() && current > 0 { let page = self.state.chat_state.visible_page; let new = current.saturating_sub(page); self.state.chat_state.message_list_state.select(Some(new)); } else { self.load_more_history(); } } else if key.code == KeyCode::PageDown { // TODO: use scroll_up_by/ scroll_down_by here let page = self.state.chat_state.visible_page; let current = self .state .chat_state .message_list_state .selected() .unwrap_or(0); let new = current.saturating_add(page); let max = self .state .chat_state .open_chat .as_ref() .map(|o| o.history.len().saturating_sub(1)) .unwrap_or(0); self.state .chat_state .message_list_state .select(Some(new.min(max))); } else if key == km.select && let Some(idx) = self.state.chat_state.message_list_state.selected() && let Some(open) = &self.state.chat_state.open_chat && let Some(msg) = open.history.get(idx) { let mut msg = msg.clone(); if msg.reply_ctx.is_none() && msg.reply_to_id.is_some() && let Some(messenger) = self.state.chat_owner(&msg.chat_id) { match messenger.reply_context(&msg.chat_id, &msg.message_id).await { Ok(context) => msg.reply_ctx = context, Err(error) => { debug!( message = %msg.message_id, %error, "Unable to load reply context for popup" ); } } } // Offer actions appropriate to the message's state. msg.msg_actions = available_message_actions(&msg); match msg.media.as_ref().map(|m| m.kind.clone()) { Some(MediaKind::Image) => self.open_image_popup(msg), Some(MediaKind::Audio { .. }) => self.open_audio_popup(msg), Some(MediaKind::Video) => self.open_video_popup(msg), _ => self.state.create_popup(PopupKind::Message(msg)), } } } Focus::Write => { // Esc leaves the write box (saving the draft); the global // dismiss binding is gated off while a text box has focus, so // it is captured here instead. if key == km.dismiss { self.state.cycle_focus(); return; } if key.kind == KeyEventKind::Release { // Only the record key's Release is ever forwarded; never // let a key-up land in the draft. return; } if key.kind != KeyEventKind::Press { // Paste-sourced keys are never send/newline actions. self.state.write.input(key); } else if key == km.send { self.state.send_message().await; } else if key == km.newline || key.code == KeyCode::Enter { // A configured newline key (Shift+Enter by default) inserts a newline. // Any Enter with a modifier (Alt/Ctrl) also inserts a newline, since many // terminals cannot report modifiers on Enter and treat it as plain Enter. self.state.write.insert_newline(); } else { self.state.write.input(key); } } Focus::Popup => {} // this will never happen > key event is captured before } } async fn handle_popup_key(&mut self, key: KeyEvent) { if key == self.state.keymap.dismiss { self.dismiss_popup(); } let km = self.state.keymap.clone(); // Playback controls only apply while a media popup is open. Read here // (before borrowing `pop_up` mutably); after a dismissal `pop_up` is // gone, so the match below returns before the action can leak. let media_action = self.state.media_controls(&key, &km); let popup = match self.state.pop_up.as_mut() { Some(p) => p, None => return, }; if key == km.scroll_up { popup.scroll_idx = popup.scroll_idx.saturating_sub(1); } else if key == km.scroll_to_top { popup.scroll_idx = 0 } else if key == km.scroll_down { popup.scroll_idx = popup.scroll_idx.saturating_add(1); } if let Some(action) = media_action { // The popup on screen decides which engine (and which state slot) // the control applies to, so an audio popup can never drive the // video player and vice versa. let is_video = matches!( self.state.pop_up.as_ref().map(|p| &p.popup_type), Some(PopupKind::Video(_)) ); let player = if is_video { &self.video_player } else { &self.player }; let session = if is_video { &self.state.video } else { &self.state.playback }; match action { MediaAction::PlayPause => { let playing = session .as_ref() .is_some_and(|p| p.status == PlayState::Playing); if playing { player.pause(); } else { player.play(); } } MediaAction::Seek { delta_secs } => { debug!(video = is_video, "Seeking {delta_secs}"); player.seek_by(f64::from(delta_secs)); } } return; } let action = { if let PopupKind::Error(_) = &popup.popup_type && self.state.retry_draft.is_some() && key == KeyCode::Char('1').into() { self.dismiss_popup(); self.state.retry_message().await; return; } else if let PopupKind::Message(msg) | PopupKind::Image(ImagePopup { msg, .. }) | PopupKind::Audio(AudioPopup { msg, .. }) | PopupKind::Video(VideoPopup { msg, .. }) = &popup.popup_type && let KeyCode::Char(c) = key.code && let Some(digit) = c.to_digit(10) { let idx = (digit as usize).saturating_sub(1); (idx < msg.msg_actions.len()) .then(|| (msg.msg_actions[idx].clone(), msg.message_id.clone())) } else if let PopupKind::Question(_) = &popup.popup_type && let KeyCode::Char(c) = key.code && let Some(digit) = c.to_digit(10) { if let Some(msg) = &self.state.chat_state.selected_message() { let idx = (digit as usize).saturating_sub(1); (idx <= 2).then(|| (MessageAction::Delete, msg.message_id.clone())) } else { None } } else { None } }; if let Some((action, msg_id)) = action { self.dismiss_popup(); match action { MessageAction::Reply => self.state.reply_to_message(&msg_id), MessageAction::Edit => { self.state.edit_message(&msg_id); } MessageAction::Delete => { self.state.create_popup(PopupKind::Question(String::from( "Do you really want to delete this message?", ))); if key == KeyCode::Char('1').into() { self.state.delete_message(&msg_id).await; self.state.dismiss_popup(); } if key == KeyCode::Char('2').into() { self.state.dismiss_popup(); } } MessageAction::Retry => { let _ = msg_id; } MessageAction::Copy => { let Some(message) = self.state.chat_state.find_message(&msg_id).cloned() else { return; }; if let Err(err) = self.state.copy_to_clipboard(message.text) { debug!("error copying to clipboard {err}"); self.state.create_popup(PopupKind::Error(format!("{err}"))); } self.state .create_popup(PopupKind::Info(String::from("Copied to clipboard!"))); } } } } fn draw(&mut self, frame: &mut Frame) { if let Some(loading) = &mut self.state.loading { loading.spinner.tick(); } match self.state.screen { Screen::Main => self.draw_main(frame), Screen::Settings => { Settings::new(&self.state.config.providers).render( frame.area(), frame.buffer_mut(), &mut self.state.settings_state, ); } Screen::Login => { if let Some(login_state) = self.state.login_state.as_ref() { let provider = login_state.provider; let step = login_state.step; if let Some(messenger) = self.state.provider_to_messenger(provider) { let steps = messenger.login_steps(); let placeholder = messenger.login_placeholder(step); let masked = steps.get(step) == Some(&AuthSteps::Password); if let Some(ls) = self.state.login_state.as_mut() { if ls.submitting && let Some(loading) = self.state.loading.as_ref() && loading.area == LoadingArea::FullScreen { let step_label = steps.get(step).map(|s| s.to_string()).unwrap_or_default(); LoadingWidget::new( &format!(" {} — {} ", provider.name(), step_label), Some(format!("Waiting for {} ...", loading.context)), loading.spinner.frame(), ) .render(frame.area(), frame.buffer_mut()); return; } ls.login_input.set_placeholder_text(placeholder); if masked { ls.login_input.set_mask_char('●'); } else { ls.login_input.clear_mask_char(); } login::LoginScreen { provider_name: provider.name(), steps: &steps, } .render( frame.area(), frame.buffer_mut(), ls, ); } } } } } // add this here to the popup actually clears the content below it if let Some(popup_state) = &mut self.state.pop_up { // Keep each media popup's render mirror in sync with the live // session (authoritative source: `AppState::playback`/`video`). if let PopupKind::Audio(audio) = &mut popup_state.popup_type { audio.playback = self.state.playback.clone(); } if let PopupKind::Video(video) = &mut popup_state.popup_type { video.playback = self.state.video.clone(); } let tag = self.state.chat_state.get_tag(); let focused = self.state.focused; popup_state.focused = focused; popup::PopUp::new(tag.unwrap_or("")).render( frame.area(), frame.buffer_mut(), popup_state, ); } } fn draw_main(&mut self, frame: &mut Frame) { let area = frame.area(); let vertical = Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).split(area); let horizontal = Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)]) .split(vertical[0]); let visible = self.state.chat_state.visible_indices(); let visible_chats: Vec<&Chat> = if visible.is_empty() { self.state.chat_state.chats.iter().collect() } else { visible .iter() .filter_map(|&idx| self.state.chat_state.chats.get(idx)) .collect() }; let chatlist_loading_frame = self .state .loading .as_ref() .filter(|l| l.area == LoadingArea::ChatList) .map(|l| l.spinner.frame()); ChatList::new( self.state.chat_state.get_tag(), visible_chats, self.state.focus, self.state.focused, &self.state.chat_state.search, chatlist_loading_frame, ) .render( horizontal[0], frame.buffer_mut(), &mut self.state.chat_state.chat_list_state, ); if !self.state.chats_loaded && self.state.chat_state.chats.is_empty() { LoadingWidget::new( " Sender ", Some("Fetching chats...".to_string()), chatlist_loading_frame.unwrap_or(0), ) .render(horizontal[0], frame.buffer_mut()); } let (message_search_bar, message_needle) = { let search = &self.state.chat_state.message_search; ( search.title_line(), search .is_active() .then(|| search.query_text().trim().to_string()), ) }; let recording = self .state .recording_active() .then(|| (self.state.recording_seconds(), self.state.recording_rms())); ChatWidget::new( self.state.focus, self.state.focused, &mut self.state.write, self.state.config.max_write_lines, message_search_bar, message_needle, self.state.loading.as_ref(), recording, ) .render( horizontal[1], frame.buffer_mut(), &mut self.state.chat_state, ); let name = self .state .chat_state .selected_chat() .map(|c| c.contact_name.clone()); StatusBarWidget::new(name, self.state.focus, self.state.status_hint()) .render(vertical[1], frame.buffer_mut()); if let Some(open) = &self.state.chat_state.open_chat { debug!( chat = %open.chat.contact_name, history_len = open.history.len(), focus = ?self.state.focus, "TUI render complete" ); } } } #[cfg(test)] mod tests { use super::*; use crate::backend::MessageMedia; use crate::backend::mock::MockMessenger; use crate::tui::chat::OpenChat; use ratatui::crossterm::event::{KeyCode, KeyEventKind, KeyEventState, KeyModifiers}; const TONE_WAV: &[u8] = include_bytes!("player/engine/fixtures/tone.wav"); async fn test_app() -> App { let config = Config::default(); let keymap = config.keys.parse().unwrap(); let mock = Box::new(MockMessenger::new("Telegram")); App::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, mock)], false, ratatui_image::picker::Picker::halfblocks(), tokio::sync::mpsc::unbounded_channel().0, ) .await } fn audio_demo_message() -> Message { Message { message_id: MessageId::from("audio-1"), chat_id: ChatId::Myself, sender: "Maria".into(), author_id: None, text: String::new(), timestamp: 100, from_me: false, msg_actions: vec![MessageAction::Reply, MessageAction::Delete], media: Some(MessageMedia { kind: MediaKind::Audio { duration_secs: Some(2), is_voice: true, waveform: None, }, caption: Some("voice note".into()), file_name: Some("voice.ogg".into()), }), reply_to_id: None, reply_ctx: None, pending: false, failed: false, } } fn audio_chat() -> Chat { Chat { id: ChatId::Myself, contact_name: "Maria".into(), last_message_ts: Some(100), status: None, fixed: false, scroll: 0, unread: false, unread_count: 0, verified: false, } } #[tokio::test] async fn audio_popup_digit_routes_to_message_action() { let mut app = test_app().await; app.state.chat_state.open_chat = Some(OpenChat { chat: audio_chat(), history: vec![audio_demo_message()], has_more_history: false, }); app.state.create_popup(PopupKind::Audio(AudioPopup { msg: audio_demo_message(), playback: None, error_note: None, })); app.handle_popup_key(KeyEvent::from(KeyCode::Char('1'))) .await; // Digit 1 = Reply: the popup is dismissed and the write box enters // reply mode for the audio message. assert!(app.state.pop_up.is_none()); assert_eq!(app.state.focus, Focus::Write); let reply = app .state .chat_state .pending_reply .as_ref() .expect("reply should be armed"); assert_eq!(reply.message_id, MessageId::from("audio-1")); } #[tokio::test] async fn dismissing_audio_popup_is_clean() { let mut app = test_app().await; app.state.chat_state.open_chat = Some(OpenChat { chat: audio_chat(), history: vec![audio_demo_message()], has_more_history: false, }); app.state.create_popup(PopupKind::Audio(AudioPopup { msg: audio_demo_message(), playback: None, error_note: None, })); assert!( app.state .pop_up .as_ref() .is_some_and(|p| matches!(p.popup_type, PopupKind::Audio(_))) ); // Esc dismissal closes the popup and leaves no session behind. app.handle_popup_key(KeyEvent::from(KeyCode::Esc)).await; assert!(app.state.pop_up.is_none()); assert!(app.state.playback.is_none()); } #[tokio::test] async fn opening_audio_does_not_autoplay() { let config = Config::default(); let keymap = config.keys.parse().unwrap(); let mock = Box::new(MockMessenger::new("Telegram")); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, mock)], false, ratatui_image::picker::Picker::halfblocks(), tx, ) .await; app.state.chat_state.open_chat = Some(OpenChat { chat: audio_chat(), history: vec![audio_demo_message()], has_more_history: false, }); app.state.create_popup(PopupKind::Audio(AudioPopup { msg: audio_demo_message(), playback: None, error_note: None, })); // Mirrors `open_audio_popup`: the same-source session is seeded so the // worker's reports are accepted (see `apply_playback_state` guard). let seeded_source = PlayKey { chat: ChatId::Myself, message_id: MessageId::from("audio-1"), }; app.state.playback = Some(PlaybackState { source: seeded_source, status: PlayState::Loading, position: 0.0, duration: 0.0, updated_at: Instant::now(), }); // Feeding the downloaded bytes must load the session only; playback // starts on the first space press, never on open. app.apply_audio_media( ChatId::Myself, MessageId::from("audio-1"), Ok(Some(TONE_WAV.to_vec())), ) .await; let mut saw_loading = false; loop { match rx.recv().await.unwrap() { UiEvent::PlaybackState(state) => { app.state.apply_playback_state(state.clone()); if state.status == PlayState::Loading { saw_loading = true; continue; } assert_eq!( state.status, PlayState::Paused, "load must leave media paused, not playing" ); break; } _ => continue, } } assert!(saw_loading); let playback = app.state.playback.as_ref().unwrap(); assert_eq!(playback.status, PlayState::Paused); assert!(playback.position.abs() < 0.001, "must sit at the start"); assert!((playback.duration - 1.0).abs() < 0.05); } fn video_demo_message() -> Message { let mut msg = audio_demo_message(); msg.message_id = MessageId::from("video-1"); msg.media = Some(MessageMedia { kind: MediaKind::Video, caption: Some("clip".into()), file_name: Some("clip.mp4".into()), }); msg } fn video_popup() -> PopupKind { PopupKind::Video(VideoPopup { msg: video_demo_message(), view: VideoWidgetState::new( Arc::new(Mutex::new(None)), ratatui_image::picker::Picker::halfblocks(), tokio::sync::mpsc::unbounded_channel().0, ), playback: None, error_note: None, }) } /// The `Loading` snapshot `open_video_popup` seeds; worker reports are only /// accepted for a session with this source. fn seed_video_session(app: &mut App) -> PlayKey { let source = PlayKey { chat: ChatId::Myself, message_id: MessageId::from("video-1"), }; app.state.video = Some(PlaybackState { source: source.clone(), status: PlayState::Loading, position: 0.0, duration: 0.0, updated_at: Instant::now(), }); source } /// Drain player reports, applying each to `app` and stopping once `done` /// accepts the latest one. Every wait is bounded: a report that never /// arrives must fail the test rather than hang the suite. async fn wait_for_playback( app: &mut App, rx: &mut mpsc::UnboundedReceiver, mut done: impl FnMut(&PlaybackState) -> bool, ) { let deadline = tokio::time::Instant::now() + Duration::from_secs(20); let mut latest: Option = None; loop { let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); let Ok(Some(event)) = tokio::time::timeout(remaining, rx.recv()).await else { panic!( "timed out waiting for a player report; last was {:?}", latest.as_ref().map(|s| s.status) ); }; let UiEvent::PlaybackState(state) = event else { continue; }; app.state.apply_playback_state(state.clone()); if done(&state) { return; } latest = Some(state); } } fn sample_video_bytes() -> Option> { use std::process::{Command, Stdio}; let probe = Command::new("ffmpeg") .arg("-version") .stdout(Stdio::null()) .stderr(Stdio::null()) .status() .is_ok_and(|status| status.success()); if !probe { return None; } let clip = tempfile::Builder::new() .prefix("senders-video-test-") .suffix(".mp4") .tempfile() .expect("scratch file for the generated clip"); let status = Command::new("ffmpeg") .args([ "-hide_banner", "-loglevel", "error", "-nostdin", "-f", "lavfi", "-i", "testsrc=size=64x64:rate=25:duration=1", "-pix_fmt", "yuv420p", "-y", "-f", "mp4", ]) .arg(clip.path()) .status() .expect("ffmpeg generates the test clip"); assert!(status.success(), "ffmpeg failed to build the test clip"); Some(std::fs::read(clip.path()).expect("generated clip is readable")) } #[tokio::test] async fn selecting_a_video_message_opens_a_video_popup_and_fetches() { // `open_video_popup` clones the messenger to move the fetch into a // task, and `MessengerKind::Stub` panics on `clone`, so this one test // uses the cloneable `Mock` variant rather than the `Stub` that // `test_app` installs. let config = Config::default(); let keymap = config.keys.parse().unwrap(); let mock = MockMessenger::new("Telegram"); let mut app = App::new( config, keymap, vec![MessengerKind::Mock(Provider::Telegram, mock.clone())], false, ratatui_image::picker::Picker::halfblocks(), tokio::sync::mpsc::unbounded_channel().0, ) .await; // A real provider chat: `open_video_popup` resolves the messenger from // `msg.chat` and bails out when there is none (as for `ChatId::Myself`). let mut msg = video_demo_message(); msg.chat_id = ChatId::Telegram(103); app.state.chat_state.open_chat = Some(OpenChat { chat: audio_chat(), history: vec![msg], has_more_history: false, }); app.state.chat_state.message_list_state.select(Some(0)); app.state.focus = Focus::Chat; let select = app.state.keymap.select; app.handle_main_key(select).await; let Some(PopupKind::Video(video)) = app.state.pop_up.as_ref().map(|p| &p.popup_type) else { panic!("selecting a video must open a Video popup"); }; assert_eq!(video.msg.message_id, MessageId::from("video-1")); assert!(video.error_note.is_none()); // The session is seeded as Loading so early worker reports route to it. let session = app.state.video.as_ref().expect("video session seeded"); assert_eq!(session.status, PlayState::Loading); assert_eq!(session.source.message_id, MessageId::from("video-1")); assert_eq!(session.source.chat, ChatId::Telegram(103)); } #[tokio::test] async fn video_media_fetch_failure_leaves_a_note_and_an_error_status() { let mut app = test_app().await; app.state.create_popup(video_popup()); seed_video_session(&mut app); // The mock has no video fixture, so the backgrounded fetch yields // `Ok(None)` — the "provider has no bytes yet" path. app.apply_video_media(ChatId::Myself, MessageId::from("video-1"), Ok(None)) .await; let Some(PopupKind::Video(video)) = app.state.pop_up.as_ref().map(|p| &p.popup_type) else { panic!("popup must still be open"); }; let note = video .error_note .as_deref() .expect("a failed fetch must explain itself"); assert!(!note.is_empty(), "error note must not be empty"); let session = app.state.video.as_ref().expect("video session kept"); assert_eq!(session.status, PlayState::Error); } #[tokio::test] async fn video_media_result_for_another_message_is_ignored() { let mut app = test_app().await; app.state.create_popup(video_popup()); seed_video_session(&mut app); app.apply_video_media( ChatId::Myself, MessageId::from("some-other-message"), Ok(None), ) .await; let Some(PopupKind::Video(video)) = app.state.pop_up.as_ref().map(|p| &p.popup_type) else { panic!("popup must still be open"); }; assert!( video.error_note.is_none(), "a report for a different message must not touch this popup" ); assert_eq!(app.state.video.as_ref().unwrap().status, PlayState::Loading); } #[tokio::test] async fn dismissing_video_popup_is_clean() { let mut app = test_app().await; app.state.create_popup(video_popup()); seed_video_session(&mut app); app.handle_popup_key(KeyEvent::from(KeyCode::Esc)).await; assert!(app.state.pop_up.is_none()); assert!( app.state.video.is_none(), "the video session must not survive dismissal" ); } #[tokio::test] async fn video_popup_digit_routes_to_message_action() { let mut app = test_app().await; app.state.chat_state.open_chat = Some(OpenChat { chat: audio_chat(), history: vec![video_demo_message()], has_more_history: false, }); app.state.create_popup(video_popup()); app.handle_popup_key(KeyEvent::from(KeyCode::Char('1'))) .await; assert!(app.state.pop_up.is_none()); assert_eq!(app.state.focus, Focus::Write); let reply = app .state .chat_state .pending_reply .as_ref() .expect("reply should be armed"); assert_eq!(reply.message_id, MessageId::from("video-1")); } #[tokio::test] async fn opening_a_video_does_not_autoplay_and_the_first_space_plays() { let Some(bytes) = sample_video_bytes() else { eprintln!("skipping: ffmpeg is not installed"); return; }; let config = Config::default(); let keymap = config.keys.parse().unwrap(); let mock = Box::new(MockMessenger::new("Telegram")); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let mut app = App::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, mock)], false, ratatui_image::picker::Picker::halfblocks(), tx, ) .await; app.state.chat_state.open_chat = Some(OpenChat { chat: audio_chat(), history: vec![video_demo_message()], has_more_history: false, }); app.state.create_popup(video_popup()); let source = seed_video_session(&mut app); // Feeding the downloaded bytes must load the session only. Playback // starts on the first space press, never on open — loading merely // leaves one decoded frame on screen. app.apply_video_media(ChatId::Myself, MessageId::from("video-1"), Ok(Some(bytes))) .await; let mut saw_loading = false; wait_for_playback(&mut app, &mut rx, |state| { if state.status == PlayState::Loading { saw_loading = true; return false; } assert_eq!( state.source, source, "a video report must carry the session's source" ); assert_eq!( state.status, PlayState::Paused, "load must leave the video paused, not playing" ); true }) .await; assert!(saw_loading); assert_eq!(app.state.video.as_ref().unwrap().status, PlayState::Paused); // Loading must publish a poster frame while still paused: the frame is // on screen *before* the user presses space, not as a side effect of // pressing it. Without this the popup sits on "Loading video…" until // playback has already started. let frame_before_play = app.video_frames.try_lock().is_ok_and(|slot| slot.is_some()); assert!( frame_before_play, "load must leave a decoded frame in the slot while paused" ); // The first space press is what starts playback. let space = KeyEvent::from(KeyCode::Char(' ')); assert_eq!( app.state.media_controls(&space, &app.state.keymap), Some(MediaAction::PlayPause), "space must be bound on a video popup" ); app.handle_popup_key(space).await; wait_for_playback(&mut app, &mut rx, |state| { state.status == PlayState::Playing }) .await; assert_eq!(app.state.video.as_ref().unwrap().status, PlayState::Playing); // The clip is a second long, so the engine reaches EOF on its own and // stops; that must not be mistaken for a failure. wait_for_playback(&mut app, &mut rx, |state| { if state.status == PlayState::Playing { return false; } assert_ne!( state.status, PlayState::Error, "a 1s clip must play to EOF, not error" ); true }) .await; } /// Hardware-free mic for push-to-talk dispatch tests; always yields an /// empty take so finishing it is a silent drop. #[derive(Default)] struct FakeMic { pcm: Vec, } impl crate::audio::MicCapture for FakeMic { fn duration(&self) -> std::time::Duration { std::time::Duration::from_secs_f64( self.pcm.len() as f64 / f64::from(crate::audio::CAPTURE_RATE), ) } fn rms(&self) -> f32 { 0.0 } fn sample_rate(&self) -> u32 { crate::audio::CAPTURE_RATE } fn finish(&mut self) -> Vec { std::mem::take(&mut self.pcm) } } #[tokio::test] async fn push_to_talk_ignores_auto_repeat_and_finishes_on_release() { let mut app = test_app().await; app.state.focus = Focus::Chat; app.state.chat_state.open_chat = Some(OpenChat { chat: audio_chat(), history: Vec::new(), has_more_history: false, }); // Simulate a take right after its start press (fresh mic, empty PCM). app.state.recording = Some(crate::tui::state::AudioRecording::new(Box::new( FakeMic::default(), ))); app.state.record_key_pressed(Instant::now()); // An auto-repeat press lands milliseconds later: it must not send. app.handle_key(KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)) .await; assert!( app.state.recording_active(), "in-hold auto-repeat must not finish the take" ); // A key-up finishes and encodes the take. app.handle_key(KeyEvent::new_with_kind( KeyCode::Char('a'), KeyModifiers::NONE, KeyEventKind::Release, )) .await; assert!(!app.state.recording_active(), "Release must send the take"); } #[tokio::test] async fn lock_key_state_is_ignored_for_binding_matching() { let mut app = test_app().await; // kitty tags non-text keys with the active lock bits (e.g. NUM_LOCK // with Num Lock on); the parsed keymap is state-less, so the Esc // binding must match anyway. Dismissing from the write box cycles to // the chat pane. app.state.focus = Focus::Write; app.handle_key(KeyEvent::new_with_kind_and_state( KeyCode::Esc, KeyModifiers::NONE, KeyEventKind::Press, KeyEventState::NUM_LOCK, )) .await; assert_eq!( app.state.focus, Focus::Chat, "Num Lock state must not block the Esc dismiss" ); } #[tokio::test] async fn lock_key_state_does_not_block_popup_dismissal() { let mut app = test_app().await; app.state.create_popup(PopupKind::Error("test".into())); assert!(app.state.pop_up.is_some()); app.handle_key(KeyEvent::new_with_kind_and_state( KeyCode::Esc, KeyModifiers::NONE, KeyEventKind::Press, KeyEventState::NUM_LOCK, )) .await; assert!( app.state.pop_up.is_none(), "Num Lock state must not block popup Esc dismissal" ); } #[tokio::test] async fn record_key_over_message_popup_starts_a_voice_reply() { let mut app = test_app().await; app.state.chat_state.open_chat = Some(OpenChat { chat: audio_chat(), history: vec![audio_demo_message()], has_more_history: false, }); let msg = audio_demo_message(); app.state.create_popup(PopupKind::Message(msg.clone())); app.handle_key(KeyEvent::from(KeyCode::Char('a'))).await; let reply = app .state .chat_state .pending_reply .as_ref() .expect("the voice reply must be armed"); assert_eq!(reply.message_id, msg.message_id); // The message popup is dismissed when the voice reply starts. Opening // a real mic may fail in a headless run and surface an error popup // instead; either way the popup can never linger as the message one. assert!( !matches!( app.state.pop_up.as_ref().map(|p| &p.popup_type), Some(PopupKind::Message(_) | PopupKind::Audio(_)) ), "the message popup must be dismissed when a voice reply starts" ); } #[tokio::test] async fn record_key_over_popup_without_message_is_inert() { let mut app = test_app().await; app.state.create_popup(PopupKind::Error("nope".into())); app.handle_key(KeyEvent::from(KeyCode::Char('a'))).await; assert!(app.state.pop_up.is_some(), "non-message popup stays open"); assert!( app.state.chat_state.pending_reply.is_none(), "no reply target without a message popup" ); } }