use anyhow::{Result, anyhow}; use arboard::Clipboard; use ratatui::crossterm::event::KeyEvent; use ratatui::style::{Color, Style}; use ratatui::widgets::ListState; use ratatui_textarea::{TextArea, WrapMode}; use std::collections::{HashMap, HashSet}; use std::sync::Arc; use std::time::{Duration, Instant}; use crate::audio::{self, MicCaptureBox}; use crate::backend::{ BackendError, BackendEvent, Chat, ChatId, LoginStepState, MediaKind, Message, MessageId, MessengerKind, OutboundMessage, Provider, }; use crate::config::{Config, Keymap}; use crate::notify::{Notice, to_notice}; use crate::tui::chat::{ChatState, OpenChat}; use crate::tui::loading::{LoadingArea, LoadingState}; use crate::tui::player::PlaybackState; use crate::tui::popup::{AudioPopup, ImagePopup, PopupKind, VideoPopup}; use tracing::{debug, error, info, warn}; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum Focus { #[default] ChatList, Chat, Write, Popup, } #[derive(Clone)] pub struct RetryDraft { pub chat: ChatId, pub msg: OutboundMessage, pub message_id: Option, } /// An in-flight push-to-talk recording. Owns the live mic capture until it is /// finished (encoded and sent) or cancelled (dropped). pub struct AudioRecording { recorder: MicCaptureBox, pub(crate) started_at: Instant, } impl AudioRecording { pub fn new(recorder: MicCaptureBox) -> Self { Self { recorder, started_at: Instant::now(), } } /// Whole seconds recorded so far, for the live indicator. pub fn seconds(&self) -> u64 { self.recorder.duration().as_secs() } /// Most recent capture level for the live meter. pub fn rms(&self) -> f32 { self.recorder.rms() } } /// A record-key press outside a take is always allowed. Inside a take the /// send-on-press fallback (terminals without REPORT_EVENT_TYPES) requires the /// take to have outlived the OS auto-repeat initial delay AND the press to be /// more than one auto-repeat cadence away from the previous press, so a held /// key cannot finish a take by echoing. const RECORD_MIN_HOLD: Duration = Duration::from_millis(800); const RECORD_REPEAT_GAP: Duration = Duration::from_millis(150); #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Screen { Main, Settings, Login, } #[derive(Debug, Default, PartialEq, Eq)] pub enum Mode { Insert, #[default] Normal, } /// Common application state shared across pages. pub struct AppState { /// Whether the terminal window has the OS focus. Orthogonal to `focus`: /// losing the window never changes which pane owns the keyboard, so the /// same pane (and any open chat / half-typed draft) is still there on /// return. pub focused: bool, pub mode: Mode, pub config: Config, pub keymap: Keymap, pub messengers: Vec, /// main chat state handler pub chat_state: ChatState, /// Selection state for the settings provider list. pub settings_state: ListState, pub focus: Focus, pub screen: Screen, pub write: TextArea<'static>, pub pop_up: Option, pub running: bool, /// Active login session, if any. pub login_state: Option, /// True once the initial chat list has finished loading from all providers. /// While false, the chat pane shows the loading widget instead of a blank /// terminal (non-blocking startup). pub chats_loaded: bool, pub chat_load_generation: u64, /// Bumped every time a provider is disabled. Background fetches capture it /// when they spawn and discard their result on arrival if it moved on — a /// chat-list snapshot taken before a disable must not repopulate the rows /// the disable just removed. pub lifecycle_epoch: u64, pub history_refresh_in_flight: bool, pub chatlist_sync_in_flight: bool, pub chatlist_sync_pending: usize, pub backend_status: Option, pub retry_draft: Option, /// Set while a provider is flood-waiting; the deadline drives the live /// countdown in the status bar. pub rate_limit: Option<(Provider, Instant)>, /// Providers whose transport has successfully connected at least once. /// Drives the persistent disconnected indicator in the status bar. pub provider_connected: HashSet, /// Active loading indication (`None` = idle). A single slot that every /// loading action claims via [`AppState::start_loading`] and releases via /// [`AppState::finish_loading`]; the renderers only consult it to decide /// whether and where to draw a spinner. pub loading: Option, /// Latest player report for the active audio session (`None` = no session). pub playback: Option, /// Same as [`Self::playback`] for the video session. Kept separate so an /// audio and a video session can never overwrite each other's snapshot; /// the active engine is chosen by which popup is open, and a report is /// routed by matching [`PlaybackState::source`]. pub video: Option, /// An in-flight push-to-talk voice-note recording, if active. /// Visible to the TUI module so dispatch tests can install a take without /// opening a real mic. pub(crate) recording: Option, /// When the last push-to-talk key event arrived, for separating a deliberate /// second press (send) from the auto-repeat stream on terminals that do not /// report event types. last_record_key: Option, clipboard: Option, } pub struct LoginState { /// Which provider is being logged into. pub provider: Provider, /// Current step index within `MessengerKind::login_steps()`. pub step: usize, /// Error from the last submission, if any. pub error: Option, /// Text input reused by the generic login screen. pub login_input: TextArea<'static>, /// True while an auth step RPC is in flight (drives the loading widget). pub submitting: bool, } pub struct PopupState { pub popup_type: PopupKind, prev_focus: Focus, pub scroll_idx: usize, pub focused: bool, } /// A control decided from a key press on a media (playback) popup. The TUI maps /// it to `Player` commands; it is a plain value so the mapping is unit-testable /// without a player worker. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MediaAction { PlayPause, Seek { delta_secs: i32 }, } impl AppState { pub async fn new( config: Config, keymap: Keymap, messengers: Vec, open_settings: bool, ) -> Self { let mut write = TextArea::default(); write.set_placeholder_text("Write a message..."); write.set_cursor_style(Style::default().bg(Color::White).fg(Color::White)); write.set_cursor_line_style(Style::default()); write.set_wrap_mode(WrapMode::WordOrGlyph); let mut login_input = TextArea::default(); login_input.set_cursor_style(Style::default().fg(Color::Yellow)); login_input.set_cursor_line_style(Style::default()); login_input.set_wrap_mode(WrapMode::WordOrGlyph); let clipboard = Clipboard::new().ok(); let mut app = Self { mode: Mode::Normal, config, focused: true, keymap, messengers, settings_state: ListState::default().with_selected(Some(0)), focus: Focus::ChatList, screen: if open_settings { Screen::Settings } else { Screen::Main }, write, pop_up: None, chat_state: Default::default(), running: true, login_state: None, chats_loaded: false, chat_load_generation: 0, lifecycle_epoch: 0, history_refresh_in_flight: false, chatlist_sync_in_flight: false, chatlist_sync_pending: 0, backend_status: None, retry_draft: None, rate_limit: None, provider_connected: HashSet::new(), loading: None, playback: None, video: None, recording: None, last_record_key: None, clipboard, }; if let Ok(chat_cache) = crate::file::read_chats() { debug!(chat_count = chat_cache.len(), "Loaded persisted chat list"); app.apply_chats(chat_cache); } if open_settings { app.create_popup(PopupKind::Info(String::from( "Welcome! Press Enter on a provider to enable it.", ))); } app } pub fn selected_chat_idx(&self) -> Option { self.chat_state.chat_list_state.selected() } pub fn copy_to_clipboard(&mut self, msg: String) -> Result<()> { if let Some(clipboard) = &mut self.clipboard { clipboard .set_text(msg) .map_err(|err| anyhow!("arboard err: {err}")) } else { Err(anyhow!("No clipboard provider")) } } /// Record the terminal's window focus. Only [`AppState::focused`] changes: /// the pane focus is deliberately left alone so regaining the window /// returns the user to the same pane (and the same open chat / draft). pub fn set_window_focus(&mut self, focused: bool) { debug!(focused, "window focus change"); self.focused = focused; } /// True while the chat-list search UI is engaged and focused. pub fn chat_list_search_active(&self) -> bool { self.focus == Focus::ChatList && self.chat_state.search.is_active() } /// True while the chat-list search input is focused and being typed in. pub fn chat_list_search_typing(&self) -> bool { self.focus == Focus::ChatList && self.chat_state.search.is_inserting() } /// True while the message search UI is engaged and focused. pub fn message_search_active(&self) -> bool { self.focus == Focus::Chat && self.chat_state.message_search.is_active() } /// True while the message search input is focused and being typed in. pub fn message_search_typing(&self) -> bool { self.focus == Focus::Chat && self.chat_state.message_search.is_inserting() } /// True while any text input is focused and should eat key presses: /// the write box, either search input while live-typing, or the login /// input. Drives [`AppState::mode`] so app-level key bindings stay inert /// while the user is typing (except the explicitly captured keys). pub fn is_typing(&self) -> bool { matches!(self.screen, Screen::Login) || (self.screen == Screen::Main && (self.focus == Focus::Write || self.chat_list_search_typing() || self.message_search_typing())) } /// Recompute [`AppState::mode`] from the current focus/screen state. Called /// on every keypress so it can never go stale across focus switches. pub fn update_mode(&mut self) { self.mode = if self.is_typing() { Mode::Insert } else { Mode::Normal }; } /// Apply a previously fetched chat list to the TUI chat state, preserving /// each chat's saved scroll position. Providers that appear in a fresh, /// non-empty snapshot are treated as authoritative: rows they no longer /// report (e.g. a WhatsApp LID twin folded into its PN row by the backend /// merge) are pruned. Chat state for an enabled provider that returned no /// live entries at all (e.g. WhatsApp during a cold start, where the /// realtime client has not re-synced yet) is kept rather than wiped out. pub fn apply_chats(&mut self, chats: Vec) { let selected_id = self.chat_state.selected_chat().map(|chat| chat.id.clone()); let saved_scrolls: HashMap = self .chat_state .chats .iter() .map(|chat| (chat.id.clone(), chat.scroll)) .collect(); let mut seen = HashSet::new(); let mut chat_list: Vec = chats .into_iter() .filter(|chat| match &chat.id { ChatId::Myself => true, id => id.to_provider().is_enabled(&self.config.providers), }) .filter(|chat| seen.insert(chat.id.clone())) .map(|chat| { let scroll = saved_scrolls.get(&chat.id).copied().unwrap_or(0); Chat { scroll, ..chat } }) .collect(); debug!(total = chat_list.len(), "Applying deduplicated chat list"); let live_providers: HashSet = chat_list.iter().map(|chat| chat.id.to_provider()).collect(); // Preserve only rows whose provider is missing entirely from the fresh // snapshot (cold start / still reconnecting) or is disabled. A provider // that returned ANY chats is authoritative, so a stale row it no longer // reports (e.g. a merged-away LID twin) must be pruned, never kept. let preserved: Vec = self .chat_state .chats .iter() .filter(|old| match &old.id { ChatId::Myself => false, id => { let provider = id.to_provider(); !chat_list.iter().any(|c| c.id == *id) && !live_providers.contains(&provider) && provider.is_enabled(&self.config.providers) } }) .cloned() .collect(); chat_list.extend(preserved); self.chat_state.chats = chat_list; self.chat_state.sort_pinned_then_recent(); self.chat_state.refresh_search_with_id(selected_id); debug!(total = self.chat_state.chats.len(), "Chat list applied"); } pub async fn rebuild_chats(&mut self) -> Vec { debug!("Rebuilding chat list from all providers"); let (chats, errors) = fetch_all_chats(&self.messengers, &self.config.providers).await; self.apply_chats(chats); self.chats_loaded = true; errors } /// Apply the result of a (background) chat fetch: populate the chat list, /// mark loading as complete, and surface any provider errors as a popup. /// `epoch` is the lifecycle epoch the fetch was started under: a result /// from before a provider disable still releases the loading state, but /// its payload is discarded rather than repopulating the removed rows. /// Shared by the TUI event loop and tests so error-surfacing logic stays in /// one place. pub fn apply_fetched(&mut self, chats: Vec, errors: Vec, epoch: u64) { self.chats_loaded = true; self.finish_loading(LoadingArea::ChatList); if epoch != self.lifecycle_epoch { debug!( captured = epoch, current = self.lifecycle_epoch, "Discarding chat fetch from before a lifecycle change" ); return; } self.apply_chats(chats); if !errors.is_empty() { self.create_popup(PopupKind::Error( errors .iter() .map(|e| e.to_string()) .collect::>() .join("\n"), )); } self.persist_chats(); } /// Apply the result of a background chat-list sync for a single provider. /// The sync counter is always released first (it was claimed when the /// fetch spawned), then the payload is dropped if it predates a lifecycle /// change or its provider is disabled — a disabled provider must never be /// reconciled back into the list. Shared by the TUI event loop and tests. pub fn apply_synced_chats( &mut self, provider: Provider, result: Result, BackendError>, epoch: u64, ) { self.chatlist_sync_pending = self.chatlist_sync_pending.saturating_sub(1); self.chatlist_sync_in_flight = self.chatlist_sync_pending > 0; if !self.chatlist_sync_in_flight { self.finish_loading(LoadingArea::ChatList); } if epoch != self.lifecycle_epoch || !provider.is_enabled(&self.config.providers) { debug!( ?provider, captured = epoch, current = self.lifecycle_epoch, "Discarding chat-list sync from before a lifecycle change" ); return; } match result { // Skip the persistence write when nothing changed (both messenger // syncs every `chat_list_sync_secs`; identical snapshots must not // churn the cache file). Ok(chats) => { // TODO: hide archived chats from the main list once the TUI has // an Archived section; until then they stay visible to match // the phone. if self.chat_state.reconcile_provider_chats(provider, chats) { self.persist_chats(); } debug!(?provider, "Chat list sync OK"); } Err(e) => error!(?provider, error = %e, "Chat list sync failed"), } } /// Persist the current chat list so it survives a cold restart. pub(crate) fn persist_chats(&self) { if let Err(e) = crate::file::write_chats(&self.chat_state.chats) { error!(error = %e, "Failed to persist chat list"); } } /// Update the chat list entry for `chat_id` after a poll refresh detected new messages. /// Sets the recency timestamp from the newest message in `history` and re-sorts, /// keeping the currently selected chat selected. pub fn update_chat_list_from_poll(&mut self, chat_id: &ChatId, history: &[Message]) { let Some(newest) = history.last() else { return; }; let Some((_, chat)) = self.chat_state.find_mut(chat_id) else { return; }; // Never regress: a partial poll history must not lower the recency ts. if chat.last_message_ts.is_some_and(|t| newest.timestamp <= t) { return; } chat.last_message_ts = Some(newest.timestamp); // The sort reorders the source list, so a filtered selection must be // re-anchored by chat id (not by the stale index it held). let selected_id = self.chat_state.selected_chat().map(|chat| chat.id.clone()); self.chat_state.sort_pinned_then_recent(); self.chat_state.refresh_search_with_id(selected_id); } pub fn create_popup(&mut self, popup_type: PopupKind) { self.pop_up = Some(PopupState { popup_type, prev_focus: self.focus, scroll_idx: 0, focused: self.focused, }); self.focus = Focus::Popup; } pub fn dismiss_popup(&mut self) { if let Some(popup) = self.pop_up.take() { self.focus = popup.prev_focus; } // Closing any popup ends the media session: no position is retained, // so reopening starts from a fresh fetch + fresh engine load. Clearing // here also makes `apply_playback_state`'s guard drop any late reports. self.playback = None; self.video = None; } /// The message a Message/Image/Audio/Video popup quotes, when it can be /// replied to (confirmed, not pending/failed). The record key over such a /// popup starts a voice-note reply to it. pub fn reply_recording_target(&self) -> Option { let popup = self.pop_up.as_ref()?; let msg = match &popup.popup_type { PopupKind::Message(msg) | PopupKind::Image(ImagePopup { msg, .. }) | PopupKind::Audio(AudioPopup { msg, .. }) | PopupKind::Video(VideoPopup { msg, .. }) => msg, _ => return None, }; (!msg.pending && !msg.failed).then(|| msg.message_id.clone()) } /// Apply a player report. It is routed to whichever slot holds a session /// for the same message, so an audio and a video session can both be live /// and each report updates only its own snapshot. A report matching neither /// slot is dropped, so events racing with a dismissed popup can never /// resurrect stale playback state. pub fn apply_playback_state(&mut self, incoming: PlaybackState) { if self .playback .as_ref() .is_some_and(|current| current.source == incoming.source) { self.playback = Some(incoming); } else if self .video .as_ref() .is_some_and(|current| current.source == incoming.source) { self.video = Some(incoming); } } /// Map a key to a media-popup control. Returns `None` when the popup on /// screen is not a media popup (an Audio or Video popup) or the key is not /// bound. `seek_back` / `seek_forward` are compared by `KeyCode` only: /// terminals disagree on whether `<`/`>` arrive with a shift modifier. pub fn media_controls(&self, key: &KeyEvent, keymap: &Keymap) -> Option { let media_open = self .pop_up .as_ref() .is_some_and(|p| matches!(p.popup_type, PopupKind::Audio(_) | PopupKind::Video(_))); if !media_open { return None; } if *key == keymap.play_pause { Some(MediaAction::PlayPause) } else if key.code == keymap.seek_back.code { Some(MediaAction::Seek { delta_secs: -5 }) } else if key.code == keymap.seek_forward.code { Some(MediaAction::Seek { delta_secs: 5 }) } else { None } } pub fn cycle_focus(&mut self) { self.focus = match self.focus { Focus::ChatList => { // Invalidate a background history request when Escape is used // before the selected chat finishes opening. self.chat_load_generation = self.chat_load_generation.wrapping_add(1); if self.chat_state.open_chat.is_some() { self.chat_state.open_chat = None; Focus::Chat } else { Focus::ChatList } } Focus::Chat => { self.chat_load_generation = self.chat_load_generation.wrapping_add(1); // Save draft and clear write when exiting chat if let Some(chat_id) = self .chat_state .open_chat .as_ref() .map(|o| o.chat.id.clone()) { let text = self.write.lines().join("\n"); self.chat_state.save_draft(&chat_id, text); } self.write.clear(); self.chat_state.open_chat = None; Focus::ChatList } Focus::Write => { // Save draft and clear write when exiting chat // + close the current chat // BUG: this is not working? dismissing Write does not persist the message if let Some(chat_id) = self .chat_state .open_chat .as_ref() .map(|o| o.chat.id.clone()) { let text = self.write.lines().join("\n"); debug!("Saving {text} to draft"); self.chat_state.save_draft(&chat_id, text); } // The edit/reply should not persist if user dismisses Write self.chat_state.pending_reply = None; self.chat_state.pending_edit = None; self.write.clear(); Focus::Chat } Focus::Popup => Focus::ChatList, }; } /// Claim the loading slot for `area`, replacing any previous indication. /// The animation restarts (the frame lives inside the new [`LoadingState`]). // TODO: the spinner is not spinning. make it auto tick every x ms pub fn start_loading(&mut self, area: LoadingArea, context: impl Into) { self.loading = Some(LoadingState { area, context: context.into(), spinner: Default::default(), }); } /// Release the loading slot, but only when it belongs to `area`, so a stale /// completion (e.g. an outdated chat-list fetch) never clears a newer /// indication. pub fn finish_loading(&mut self, area: LoadingArea) { if self.loading.as_ref().is_some_and(|l| l.area == area) { self.loading = None; } } pub fn cancel_chat_load(&mut self) { self.chat_load_generation = self.chat_load_generation.wrapping_add(1); self.finish_loading(LoadingArea::Chat); } pub fn begin_chat_load(&mut self, index: usize) -> Option<(Chat, u64, MessengerKind)> { let src = self.chat_state.resolve_open_index(index)?; let chat = self.chat_state.chats.get(src).cloned()?; self.chat_state.clear_message_search(); if let Some(current_chat_id) = self .chat_state .open_chat .as_ref() .map(|o| o.chat.id.clone()) { let text = self.write.lines().join("\n"); self.chat_state.save_draft(¤t_chat_id, text); self.chat_state.save_message_selection(); } self.chat_load_generation = self.chat_load_generation.wrapping_add(1); self.chat_state.open_chat = None; self.chat_state.chats[src].unread = false; self.chat_state.chats[src].unread_count = 0; self.write.clear(); if let Some(draft) = self.chat_state.load_draft(&chat.id) { debug!("draft '{draft}' present on chat load, inserting..."); self.write.insert_str(draft); } self.focus = Focus::Chat; let messenger = self .messengers .iter() .find(|m| m.provider() == chat.id.to_provider())? .clone(); self.start_loading(LoadingArea::Chat, chat.contact_name.clone()); Some((chat, self.chat_load_generation, messenger)) } pub fn apply_chat_load( &mut self, chat: Chat, generation: u64, result: std::result::Result, BackendError>, status: Option, ) { // Return early if user closed the chat and opened a new one if generation != self.chat_load_generation || self.chat_state.selected_chat().map(|c| &c.id) != Some(&chat.id) { return; } self.finish_loading(LoadingArea::Chat); // The presence subscription was sent before history loaded; surface // the peer's status on the chat list row and, when the load succeeds, // on the freshly opened chat. if let Some(status) = status.as_deref() && let Some(chat) = self.chat_state.chats.iter_mut().find(|c| c.id == chat.id) { chat.status = Some(status.to_string()); } match result { Ok(messages) => { let history_len = messages.len(); self.chat_state.open_chat = Some(OpenChat { chat: chat.clone(), history: messages, has_more_history: true, }); if let (Some(status), Some(open)) = (status.as_deref(), self.chat_state.open_chat.as_mut()) { open.chat.status = Some(status.to_string()); } self.chat_state.restore_message_selection(history_len); } Err(e) => { error!(chat = %chat.contact_name, error = %e, "Failed to load history"); self.create_popup(PopupKind::Error(format!( "{} failed to load history for {}: {e}", chat.id.platform(), chat.contact_name ))); } } } /// Apply a lazy-history page produced by `App::load_more_history` to the /// open chat, stitching the older messages in front of the current history. /// The chat-id guard is the only check needed: fetching runs in a single /// in-flight slot (`App::chat_load_task`), so a page can never be overtaken /// by another page for the same or a different chat. pub fn apply_history_page( &mut self, chat_id: &ChatId, result: std::result::Result, BackendError>, ) { if self.chat_state.open_chat.as_ref().map(|o| &o.chat.id) != Some(chat_id) { return; } self.finish_loading(LoadingArea::Inline); let Some(open) = self.chat_state.open_chat.as_mut() else { return; }; match result { Ok(older) if !older.is_empty() => { let existing = open.history.clone(); let filtered = older .into_iter() .filter(|msg| { !existing .iter() .any(|item| item.message_id == msg.message_id) }) .collect::>(); if !filtered.is_empty() { let count = filtered.len(); self.chat_state.prepend_history(chat_id, filtered); self.chat_state.message_list_state.select(Some(count)); } } Ok(_) => { open.has_more_history = false; } Err(err) => { open.has_more_history = false; error!(chat = ?chat_id, error = %err, "Lazy history load failed"); } } } /// Inserts pasted text into the write box if it has focus. pub fn handle_paste(&mut self, text: String) { if self.focus == Focus::Write { self.write.insert_str(&text); } if let Some(state) = self.login_state.as_mut() && self.screen == Screen::Login { state.login_input.insert_str(&text); } } pub async fn send_message(&mut self) { let text = self.write.lines().join("\n"); if text.trim().is_empty() { return; } let Some(chat) = self.chat_state.selected_chat().cloned() else { self.create_popup(PopupKind::Error(String::from("No chat selected"))); return; }; let editing = self.chat_state.pending_edit.take(); let reply_to = self.chat_state.pending_reply.take().map(|i| i.message_id); if let Some(message) = editing { // Editing an existing message: update in place, no echo. let result = match self.chat_owner(&chat.id) { Some(messenger) => messenger.edit(&chat.id, &message.message_id, &text).await, None => Err(BackendError::Other("no messenger for this chat".into())), }; match result { Ok(()) => { self.write.clear(); self.chat_state.drafts.remove(&chat.id); self.chat_state .update_message_text(&message.message_id, &text); } Err(e) => { self.create_popup(PopupKind::Error(format!( "{} failed to edit message: {e}", chat.id.platform() ))); } } return; } let outgoing = OutboundMessage::Text { text }; if self.send_outbound(&chat.id, outgoing, reply_to).await { self.write.clear(); self.chat_state.drafts.remove(&chat.id); } } /// Send `outgoing` to `chat_id` quoting `reply_to`, echoing the confirmed /// message or stashing a retry draft and popping up the error. Returns /// whether the send was confirmed (the caller then clears its transient /// input). Shared by the text and voice-note paths. async fn send_outbound( &mut self, chat_id: &ChatId, outgoing: OutboundMessage, reply_to: Option, ) -> bool { let send_result = match self.chat_owner(chat_id) { Some(messenger) => messenger.send(chat_id, &outgoing, reply_to.clone()).await, None => Err(BackendError::Other("no messenger for this chat".into())), }; match send_result { Ok(confirmed) => { let sent_ts = confirmed.timestamp; let sent_chat_id = confirmed.chat_id.clone(); self.chat_state.upsert_chat(Chat { id: sent_chat_id, last_message_ts: Some(sent_ts), ..Default::default() }); self.chat_state.push_incoming(confirmed); true } Err(e) => { self.retry_draft = Some(RetryDraft { chat: chat_id.clone(), msg: outgoing, message_id: reply_to, }); self.create_popup(PopupKind::Error(format!( "{} failed to send message: {e}, press 1 to retry", chat_id.platform() ))); false } } } /// Begin a push-to-talk recording on the default input device. On failure /// shows an error popup and stays idle. pub fn start_recording(&mut self) { if self.recording.is_some() { return; } match audio::Recorder::start() { Ok(recorder) => self.recording = Some(AudioRecording::new(Box::new(recorder))), Err(e) => { warn!(error = %e, "failed to start audio recording"); self.create_popup(PopupKind::Error(format!( "Audio recording unavailable: {e}" ))); } } } /// Stop push-to-talk recording, encode the take as an Opus voice note and /// send it to the selected chat (quoting any pending reply). An empty take /// is dropped without sending. pub async fn finish_recording(&mut self) { let Some(mut recording) = self.recording.take() else { return; }; let sample_rate = recording.recorder.sample_rate(); let pcm = recording.recorder.finish(); let ogg = match audio::encode_opus_ogg(&pcm, sample_rate, 1) { Ok(ogg) => ogg, Err(audio::AudioError::EmptyRecording) => { return; } Err(e) => { self.create_popup(PopupKind::Error(format!("Failed to encode recording: {e}"))); return; } }; let Some(chat) = self.chat_state.selected_chat().cloned() else { self.create_popup(PopupKind::Error(String::from("No chat selected"))); return; }; let reply_to = self.chat_state.pending_reply.take().map(|i| i.message_id); let outgoing = OutboundMessage::Media { kind: MediaKind::Audio { duration_secs: Some((pcm.len() as u64 / u64::from(sample_rate)) as u32), is_voice: true, waveform: Some(audio::waveform_from_pcm(&pcm, 128)), }, data: Arc::from(ogg.as_slice()), file_name: "voice.oga".into(), caption: None, }; self.send_outbound(&chat.id, outgoing, reply_to).await; } /// Discard the current recording without sending. pub fn cancel_recording(&mut self) { self.recording = None; } pub fn recording_active(&self) -> bool { self.recording.is_some() } /// Account for a push-to-talk key event and report whether a press may /// finish the current take (the send-on-press fallback for terminals that /// do not report event types). Age gates out the auto-repeat's first fire /// (which waits out the initial repeat delay), while the gap from the last /// recorded event gates out the continuous repeat stream itself, so a held /// key can never finish a take on its own. pub fn record_key_pressed(&mut self, now: Instant) -> bool { let allowed = match self .recording .as_ref() .map(|recording| recording.started_at) { Some(started) => { let age = now.saturating_duration_since(started); let origin = self.last_record_key.unwrap_or(started); let gap = now.saturating_duration_since(origin); age >= RECORD_MIN_HOLD && gap >= RECORD_REPEAT_GAP } None => true, }; self.last_record_key = Some(now); allowed } /// Whole seconds recorded so far, for the push-to-talk indicator. pub fn recording_seconds(&self) -> u64 { self.recording .as_ref() .map(AudioRecording::seconds) .unwrap_or(0) } /// Most recent capture level, for the push-to-talk meter. pub fn recording_rms(&self) -> f32 { self.recording .as_ref() .map(AudioRecording::rms) .unwrap_or(0.0) } pub async fn retry_message(&mut self) { let Some(draft) = self.retry_draft.take() else { return; }; let send_result = match self.chat_owner(&draft.chat) { Some(messenger) => { messenger .send(&draft.chat, &draft.msg, draft.message_id.clone()) .await } None => Err(BackendError::Other("no messenger for this chat".into())), }; match send_result { Ok(confirmed) => { self.write.clear(); self.chat_state.drafts.remove(&draft.chat); let sent_ts = confirmed.timestamp; let sent_chat_id = confirmed.chat_id.clone(); self.chat_state.upsert_chat(Chat { id: sent_chat_id, last_message_ts: Some(sent_ts), ..Default::default() }); self.chat_state.push_incoming(confirmed); } Err(e) => { let chat = draft.chat.clone(); self.retry_draft = Some(draft); self.create_popup(PopupKind::Error(format!( "{} failed to resend message: {e}, press 1 to retry", chat.platform() ))); } } } /// Set up the write box to reply to `id`. The next send will quote it. pub fn reply_to_message(&mut self, id: &MessageId) { let Some(message) = self.chat_state.find_message(id).cloned() else { return; }; if message.pending || message.failed { return; } self.chat_state.pending_reply = Some(message); self.chat_state.pending_edit = None; self.focus = Focus::Write; } /// Load `id`'s text into the write box for editing. The next send will /// issue an edit instead of a new message. Returns false if not found. pub fn edit_message(&mut self, id: &MessageId) -> bool { let Some(msg) = self.chat_state.find_message(id).cloned() else { return false; }; if !msg.from_me || msg.pending || msg.failed { return false; } self.write.clear(); self.write.insert_str(&msg.text); self.chat_state.pending_edit = Some(msg); self.chat_state.pending_reply = None; self.focus = Focus::Write; true } /// Delete a message. Local/pending ones are simply dropped from view; /// confirmed ones are removed from the backend too. pub async fn delete_message(&mut self, id: &MessageId) { let Some(chat) = self.chat_state.selected_chat().cloned() else { return; }; let Some(message) = self.chat_state.find_message(id).cloned() else { return; }; if !message.from_me || message.pending || message.failed { self.create_popup(PopupKind::Error(format!( "{} cannot delete this message", chat.id.platform() ))); return; } if id.is_local() { self.chat_state.remove_message(id); return; } let result = match self.chat_owner(&chat.id) { Some(messenger) => messenger.delete(&chat.id, id).await, None => Ok(()), }; match result { Ok(()) => { self.chat_state.remove_message(id); } Err(e) => { self.create_popup(PopupKind::Error(format!( "{} failed to delete message: {e}", chat.id.platform() ))); } } } /// Gets the owner of the current chat pub(crate) fn chat_owner(&self, chat: &ChatId) -> Option<&MessengerKind> { let target = match chat { ChatId::Telegram(_) => Provider::Telegram, ChatId::WhatsApp(_) => Provider::WhatsApp, ChatId::Myself => return None, }; self.messengers.iter().find(|m| m.provider() == target) } pub(crate) fn provider_to_messenger(&self, provider: Provider) -> Option<&MessengerKind> { self.messengers.iter().find(|m| m.provider() == provider) } /// Lifecycle step of the startup sequence: start every provider the /// configuration enables, once all backend event receivers are registered. /// /// A provider the config leaves disabled stays `constructed/dormant` and /// therefore opens no transport, which is what makes the disabled-provider /// surfaces (no QR/`Connected`, no chat fetch, no notifications) hold. /// Enabling a provider later from settings re-enters the same idempotent /// [`Messenger::start`] path. pub async fn start_enabled_providers(&mut self) { let providers: Vec = self .messengers .iter() .map(MessengerKind::provider) .collect(); for provider in providers { if provider.is_enabled(&self.config.providers) { info!(provider = provider.name(), "starting enabled provider"); let result = match self.provider_to_messenger(provider) { Some(messenger) => messenger.start().await, None => Ok(()), }; if let Err(error) = result { // An enabled provider that failed before creating a run // must not remain enabled with no transport. Roll back to // the same dormant state used by the settings path. provider.toggle_enabled(&mut self.config.providers); if let Err(save_error) = self.config.save_config() { self.create_popup(PopupKind::Error(format!( "{} failed to start ({error}); could not persist the \ rollback: {save_error}", provider.name() ))); } else { self.create_popup(PopupKind::Error(format!( "{} failed to start: {error}", provider.name() ))); } self.provider_connected.remove(&provider); self.chat_state.chats.retain(|chat| { chat.id == ChatId::Myself || chat.id.to_provider() != provider }); self.chat_state.refresh_search(); self.persist_chats(); } } else { info!( provider = provider.name(), "provider disabled; left dormant" ); } } } fn provider_to_messenger_mut(&mut self, provider: Provider) -> Option<&mut MessengerKind> { self.messengers .iter_mut() .find(|m| m.provider() == provider) } /// Status-line text rendered under the chat list. Precedence: live /// flood-wait countdown > transient backend status > persistent /// disconnect/connection-lost hint. pub fn status_hint(&self) -> Option { if let Some((provider, until)) = self.rate_limit { let remaining = until.saturating_duration_since(Instant::now()); if !remaining.is_zero() { return Some(format!( "{} rate limited — retry in {}s", provider.name(), remaining.as_secs().max(1) )); } } if !self.focused { return Some("App not in focus!".to_string()); } if let Some(status) = self .backend_status .as_deref() .filter(|s| !s.trim().is_empty()) { return Some(status.to_string()); } for provider in Provider::all() { if provider.is_enabled(&self.config.providers) && !self.provider_connected.contains(provider) { return Some(format!("{} disconnected — reconnecting", provider.name())); } } None } /// Abort an in-flight flood-wait chat refresh, deferring the retry to the /// next chat-list sync tick. Returns whether a countdown was active. pub async fn cancel_rate_limit_wait(&mut self) -> bool { let Some((provider, _)) = self.rate_limit.take() else { return false; }; if let Some(messenger) = self.provider_to_messenger(provider) { messenger.cancel_chat_refresh().await; } true } /// Invalidate every background fetch started so far: their results land /// after a provider disable and must be discarded instead of repopulating /// the rows the disable removed. Called from each disable path. fn bump_lifecycle_epoch(&mut self) { self.lifecycle_epoch = self.lifecycle_epoch.wrapping_add(1); } pub async fn toggle_provider(&mut self, provider: Provider) { let name = provider.name(); // --- Disabling is always straightforward --- if provider.is_enabled(&self.config.providers) { // Invalidate in-flight fetches first, so a chat-list snapshot // taken before the disable cannot land afterwards. self.bump_lifecycle_epoch(); // Stop WhatsApp before changing the persisted configuration. If // teardown fails, keep the provider enabled and retryable instead // of leaving a live transport behind a disabled config flag. if provider == Provider::WhatsApp && let Some(messenger) = self.provider_to_messenger_mut(provider) && let Err(error) = messenger.disconnect().await { self.create_popup(PopupKind::Error(format!( "could not disable {name}: {error}" ))); return; } provider.toggle_enabled(&mut self.config.providers); match self.config.save_config() { Ok(()) => { // The transport died with the disable, and its // `Disconnected` event is now gated above — forget the // connection here so the status line cannot claim a // disabled provider is connected. self.provider_connected.remove(&provider); // If the user is currently viewing a chat belonging to the // disabled provider, close it so the UI does not retain an // inactive-provider conversation after its chats vanish. // `ChatId::Myself` is provider-less (`to_provider` panics // on it), so test for it first and leave it alone. if let Some(open) = &self.chat_state.open_chat && open.chat.id != ChatId::Myself && open.chat.id.to_provider() == provider { self.write.clear(); self.chat_state.open_chat = None; } // A reply still queued against one of the disabled // provider's messages must not survive into a later send. // (Rows, unread badges and search results are cleaned by // the rebuild below: `apply_chats` rejects rows of a // disabled provider and re-anchors the selection.) if let Some(reply) = &self.chat_state.pending_reply && reply.chat_id != ChatId::Myself && reply.chat_id.to_provider() == provider { self.chat_state.pending_reply = None; } let errors = self.rebuild_chats().await; self.create_popup(PopupKind::Info(format!("{name} disabled"))); if !errors.is_empty() { self.create_popup(PopupKind::Error( errors .iter() .map(|e| e.to_string()) .collect::>() .join("\n"), )); } } Err(e) => { provider.toggle_enabled(&mut self.config.providers); let restart = match self.provider_to_messenger(provider) { Some(messenger) => messenger.start().await, None => Ok(()), }; match restart { Ok(()) => self.create_popup(PopupKind::Error(format!( "could not disable {name}: {e}" ))), Err(restart_error) => self.create_popup(PopupKind::Error(format!( "could not disable {name}: {e}; could not restore the \ provider: {restart_error}" ))), } } } return; } // --- Enabling --- // Verify API credentials exist (mock providers don't need credentials) let is_mock = self.provider_to_messenger(provider).is_some(); if !is_mock && !provider.has_credentials(&self.config.providers) { self.create_popup(PopupKind::Error(format!( "{}: configure credentials in config.toml first", provider.name(), ))); return; } // Verify messenger was initialized let messenger = match self.provider_to_messenger(provider) { Some(m) => { if let MessengerKind::Telegram(t) = m { // A configured-but-disabled messenger has no running // listener; enabling it kicks one off so updates flow. t.set_enabled(true); } m } None => { self.create_popup(PopupKind::Error(format!( "{name}: messenger could not be initialized" ))); return; } }; if provider == Provider::WhatsApp { // Order matters: flip and persist the enabled flag *before* opening // the transport. A just-started run may emit `Connected` (paired) or // `PairingQrCode` (unpaired) immediately, and `handle_backend_event` // drops events from a disabled provider — starting first would let // that first event be discarded. provider.toggle_enabled(&mut self.config.providers); match self.config.save_config() { Ok(()) => { // Reborrow here: the early `messenger` binding is dead on // this path, so the config mutation above is unconstrained. let started = match self.provider_to_messenger(provider) { Some(m) => m.start().await, None => Ok(()), }; if let Err(e) = started { // The run never came up: roll the lifecycle back so the // persisted config does not claim a provider that is not // running. `start()` also emitted an `Error` event, but // the provider is disabled again, so the gate drops it — // this popup is the single surface for the failure. provider.toggle_enabled(&mut self.config.providers); if let Err(e2) = self.config.save_config() { self.create_popup(PopupKind::Error(format!( "could not save config: {e2}" ))); } self.create_popup(PopupKind::Error(format!("{name} could not start: {e}"))); } else { self.create_popup(PopupKind::Info(format!("{name} enabled"))); } } Err(e) => { self.create_popup(PopupKind::Error(format!("could not save config: {e}"))) } } return; } // Check if already authenticated — if so, just enable. Telegram's // session is settled at construction, so this reading is reliable for // it (unlike WhatsApp's mid-handshake probe above). if messenger.is_authenticated().await { provider.toggle_enabled(&mut self.config.providers); match self.config.save_config() { Ok(()) => { let errors = self.rebuild_chats().await; self.create_popup(PopupKind::Info(format!("{name} enabled"))); if !errors.is_empty() { self.create_popup(PopupKind::Error( errors .iter() .map(|e| e.to_string()) .collect::>() .join("\n"), )); } } Err(e) => { self.create_popup(PopupKind::Error(format!("could not save config: {e}"))) } } return; } // Not authenticated — check if the provider supports an interactive // login flow (Telegram phone/code/password). The QR *event*, not // `login_steps()`, is the source of truth for WhatsApp pairing. let steps = messenger.login_steps(); if steps.is_empty() { self.create_popup(PopupKind::Error(format!( "{name}: not authenticated and no login flow available" ))); return; } // Navigate to login screen if let Err(e) = self.start_login(provider) { self.create_popup(PopupKind::Error(format!("could not start login: {e}"))); } } pub fn start_login(&mut self, provider: Provider) -> Result<()> { let mut input = TextArea::default(); input.set_cursor_style(Style::default().fg(Color::Yellow)); input.set_cursor_line_style(Style::default()); self.login_state = Some(LoginState { provider, step: 0, error: None, login_input: input, submitting: false, }); self.screen = Screen::Login; Ok(()) } pub async fn submit_login(&mut self) { let Some(ref login) = self.login_state else { return; }; let provider = login.provider; let step = login.step; let input = login.login_input.lines().join("\n").trim().to_string(); if input.is_empty() { if let Some(ref mut ls) = self.login_state { ls.error = Some("input is empty".into()); } return; } if let Some(ref mut ls) = self.login_state { ls.submitting = true; } self.start_loading(LoadingArea::FullScreen, provider.name().to_string()); let result = { let messenger = self.provider_to_messenger_mut(provider).unwrap(); messenger.login_step(step, &input).await }; if let Some(ref mut ls) = self.login_state { ls.submitting = false; } self.finish_loading(LoadingArea::FullScreen); match result { Ok(LoginStepState::Done) => { let provider = self.login_state.as_ref().map(|s| s.provider); // Enable the provider in config and save if let Some(provider) = provider { if !provider.is_enabled(&self.config.providers) { provider.toggle_enabled(&mut self.config.providers); } match self.config.save_config() { Ok(()) => {} Err(e) => { self.create_popup(PopupKind::Warn(format!( "Error saving config after submit: {e} - Check your config file" ))); } }; } self.login_state = None; self.screen = Screen::Main; self.finish_loading(LoadingArea::FullScreen); self.create_popup(PopupKind::Info(String::from("Logged in successfully"))); let errors = self.rebuild_chats().await; if !errors.is_empty() { self.create_popup(PopupKind::Error( errors .iter() .map(|e| e.to_string()) .collect::>() .join("\n"), )); } } Ok(LoginStepState::NextStep) => { // Clamp so a single-step flow (e.g. WhatsApp QR) cannot // advance past the last login screen into an index error. let step_count = self .login_state .as_ref() .and_then(|ls| self.provider_to_messenger(ls.provider)) .map(|steps| steps.login_steps().len()); if let Some(ref mut ls) = self.login_state { let next = match step_count { Some(total) => (ls.step + 1).min(total.saturating_sub(1)), None => ls.step + 1, }; ls.step = next; ls.error = None; ls.login_input.clear(); } } Err(e) => { if let Some(ref mut ls) = self.login_state { ls.error = Some(e.to_string()); } } } } pub async fn cancel_login(&mut self) { if let Some(login) = self.login_state.take() { if let Some(messenger) = self.provider_to_messenger_mut(login.provider) { messenger.cancel_login().await; } // Cancelling an in-progress WhatsApp pairing that never // authenticated disables the provider again. This keeps the // provider disabled until authentication actually completes — and // because a cancelled pairing is a disable, the bot is stopped in // place too, so its QR flow and events go inert rather than // continuing behind a disabled config flag. if login.provider == Provider::WhatsApp && login.provider.is_enabled(&self.config.providers) { self.bump_lifecycle_epoch(); login.provider.toggle_enabled(&mut self.config.providers); if let Err(e) = self.config.save_config() { self.create_popup(PopupKind::Error(format!("could not save config: {e}"))); } if let Some(messenger) = self.provider_to_messenger_mut(login.provider) && let Err(e) = messenger.disconnect().await { self.create_popup(PopupKind::Error(e.to_string())); } } } self.screen = Screen::Main; self.finish_loading(LoadingArea::FullScreen); } /// The notification a new message implies, if it implies one at all. /// /// A read-only question about what the user can currently see, so the event /// loop can ask it before handing the event over: the name is resolved from /// the pre-update chat list, which is the row the event would have read /// anyway. /// /// A message counts as *seen* only when the chat is open **and** the window /// has the OS focus, so looking at the open chat from another workspace /// cannot mute a message the user is not there to read. The two conditions /// deliberately differ: an unfocused window still announces a message in the /// open chat, but [`Self::handle_backend_event`] does not raise an unread /// badge for it, because it is already pushed into the open chat and will be /// on screen when the user comes back. pub fn notice_for_message(&self, provider: Provider, message: &Message) -> Option { let contact_name = self .chat_state .display_name_for(&message.chat_id, &message.sender); let visible = self.focused && self.chat_state.is_open(&message.chat_id); to_notice(message, provider, &contact_name, visible) } pub fn handle_backend_event(&mut self, provider: Provider, event: BackendEvent) { // A disabled provider is inert. Its transport has been stopped by the // disable, so any event still in flight predates that stop: a late // message, chat snapshot, QR code or `Connected` must not reinsert // rows, raise unread badges or reopen the login screen. if !provider.is_enabled(&self.config.providers) { debug!( ?provider, event = ?std::mem::discriminant(&event), "Ignoring backend event from a disabled provider" ); return; } match event { BackendEvent::Connected => { info!(?provider, "Backend connected"); self.provider_connected.insert(provider); self.rate_limit = None; self.backend_status = None; // A newly authenticated provider becomes usable: close any // active pairing/login screen. The chat-list rebuild is kicked // off from the TUI event loop, which owns the UI channel // needed to deliver the result. Re-enabling a provider the // user disabled is not done here — the gate above already // dropped such a `Connected`. if provider == Provider::WhatsApp { let was_pairing = self .login_state .as_ref() .is_some_and(|ls| ls.provider == provider); if was_pairing { self.login_state = None; self.screen = Screen::Main; self.finish_loading(LoadingArea::FullScreen); } } } BackendEvent::RateLimited { retry_after_secs } => { warn!(?provider, retry_after_secs, "Provider rate limited"); self.rate_limit = Some(( provider, Instant::now() + std::time::Duration::from_secs(retry_after_secs), )); } BackendEvent::AuthInvalid(reason) => { error!(?provider, reason, "Provider session invalid"); if provider == Provider::Telegram { self.provider_connected.remove(&provider); if provider.is_enabled(&self.config.providers) { // An invalid session disables the provider: invalidate // in-flight fetches first so their late results cannot // bring the pruned rows back. self.bump_lifecycle_epoch(); provider.toggle_enabled(&mut self.config.providers); if let Err(e) = self.config.save_config() { self.create_popup(PopupKind::Error(format!( "could not save config: {e}" ))); } } // The provider is gone for good until the user re-logs in; // prune its chats so the list does not show a dead provider. self.chat_state .chats .retain(|c| c.id.to_provider() != provider); self.chat_state.refresh_search(); if let Some(open) = self.chat_state.open_chat.as_mut() && open.chat.id.to_provider() == provider { self.chat_state.open_chat = None; } self.persist_chats(); self.create_popup(PopupKind::Error(format!( "Telegram session invalid or revoked — re-enable Telegram in \ Settings to log in again. ({reason})" ))); } } BackendEvent::Status(status) => { // TODO: make this auto dissapear if status.is_empty() { self.rate_limit = None; } self.backend_status = (!status.is_empty()).then_some(status); } BackendEvent::Disconnected(message) => { warn!(message, "Backend disconnected"); self.provider_connected.remove(&provider); self.create_popup(PopupKind::Error(message)); } BackendEvent::QrCode(code) => { info!("WhatsApp QR code received"); // if wp qr pair ok, remove login screen // TODO: check if there's a better way to do this match code.as_str() { "ok" => { self.login_state = None; self.screen = Screen::Main; self.finish_loading(LoadingArea::FullScreen); self.config.providers.whatsapp = true; let _ = self.config.save_config().map_err(|err| { self.create_popup(PopupKind::Error(format!( "Error saving config after Qr code pair {err}" ))); }); } _ => { self.backend_status = Some("Scan this WhatsApp QR code with your phone".to_string()); } } // Pairing is shown through the login screen (which reads the QR // payload live each frame). Only surface it when the provider is // currently enabled (i.e. the user is pairing) and we are not // already sitting on its pairing screen. // // Once the user cancels pairing, `cancel_login` disables the // provider, so a late/refresh QrCode event must not yank them // back into the login screen. let already_showing = self .login_state .as_ref() .is_some_and(|ls| ls.provider == provider); if provider == Provider::WhatsApp && provider.is_enabled(&self.config.providers) && !already_showing { let _ = self.start_login(provider); } } BackendEvent::Error(context, err) => { error!(context, error = %err, "Backend error"); self.create_popup(PopupKind::Error(format!("{context}: {err}"))) } BackendEvent::MessageReceived(message) => { let is_open = self.chat_state.push_incoming(message.clone()); if is_open && let Some(len) = self.chat_state.open_chat.as_ref().map(|o| o.history.len()) { // FIX: don't change selected idx when new messages come self.chat_state.message_list_state.select(Some(len - 1)); } let open_chat_id = self .chat_state .open_chat .as_ref() .map(|o| o.chat.id.clone()); let previous_unread_count = self .chat_state .chats .iter() .find(|chat| chat.id == message.chat_id) .map(|chat| chat.unread_count) .unwrap_or(0); let chatlist_found = self .chat_state .chats .iter() .any(|chat| chat.id == message.chat_id); let contact_name = self .chat_state .display_name_for(&message.chat_id, &message.sender); let mut chat_update = Chat { id: message.chat_id.clone(), contact_name: contact_name.clone(), last_message_ts: Some(message.timestamp), ..Default::default() }; if !message.from_me && !is_open { chat_update.unread = true; chat_update.unread_count = previous_unread_count.saturating_add(1); } // Guard against polluting the chat list with a brand-new chat whose // sender name we cannot resolve (sender came through as // "Unknown" -> empty). Such a chat would only render as an // "Unnamed chat" and, when the provider is mid-lifecycle, can // appear spuriously. Existing chats still get their preview // bumped; only genuinely-new nameless entries are suppressed. if chatlist_found || !contact_name.is_empty() { self.chat_state.upsert_chat(chat_update); } if is_open { self.chat_state.mark_read(&message.chat_id); } let text_preview: String = message.text.chars().take(60).collect(); debug!( chat = ?message.chat_id, from_me = message.from_me, is_open, chatlist_found, open_chat_id = ?open_chat_id, text_preview = %text_preview, "TUI MessageReceived", ); if !message.from_me && !is_open { debug!(chat = ?message.chat_id, "TUI set unread"); } if !chatlist_found { debug!(chat = ?message.chat_id, "TUI MessageReceived: inserted chat list entry from push event"); } } BackendEvent::MessageUpdated(message) => { self.chat_state.update_message(message.clone()); self.refresh_chat_list_timestamp(&message.chat_id); } BackendEvent::MessageDeleted { chat, message_ids } => { let Some(chat) = chat else { return; }; for message_id in message_ids { self.chat_state.remove_message(&message_id); } self.refresh_chat_list_timestamp(&chat); if let Some(open) = self.chat_state.open_chat.as_ref() && open.chat.id == chat { self.chat_state.message_list_state.select(None); let len = self .chat_state .open_chat .as_ref() .map(|o| o.history.len()) .unwrap_or(0); if len > 0 { self.chat_state .message_list_state .select(Some(len.saturating_sub(1))); } } } BackendEvent::UnreadUpdated { chat, unread, unread_count, } => { let is_open = self.chat_state.is_open(&chat); if let Some((_, entry)) = self.chat_state.find_mut(&chat) { if is_open { entry.unread = false; entry.unread_count = 0; } else { entry.unread = unread; entry.unread_count = unread_count; } self.persist_chats(); } } BackendEvent::ChatUpdated(chat) => { debug!(chat = ?chat.id, "TUI ChatUpdated"); if let Some(open) = self.chat_state.open_chat.as_mut() && open.chat.id == chat.id { open.chat.status = chat.status.clone(); } self.chat_state.upsert_chat(chat); } BackendEvent::ChatRemoved { chat } => { debug!(chat = ?chat.id, "TUI ChatRemoved"); self.chat_state.chats.retain(|c| c.id != chat.id); self.chat_state.refresh_search(); if let Some(open) = self.chat_state.open_chat.as_mut() && open.chat.id == chat.id { self.chat_state.open_chat = None; } self.persist_chats(); } BackendEvent::ChatList(chats) => { debug!(provider = ?provider, total = chats.len(), "TUI ChatList applied"); if self.chat_state.reconcile_provider_chats(provider, chats) { self.persist_chats(); } } } } } impl AppState { fn refresh_chat_list_timestamp(&mut self, chat_id: &ChatId) { let latest = self .chat_state .open_chat .as_ref() .filter(|open| open.chat.id == *chat_id) .and_then(|open| open.history.last()) .map(|message| message.timestamp); let Some((_, chat)) = self.chat_state.find_mut(chat_id) else { return; }; let Some(ts) = latest else { return; }; // Never regress: an edit to a non-newest message (or a non-open chat) // must not wipe or lower the row's recency timestamp. if chat.last_message_ts.is_none_or(|since| ts > since) { chat.last_message_ts = Some(ts); let selected_id = self.chat_state.selected_chat().map(|chat| chat.id.clone()); self.chat_state.sort_pinned_then_recent(); self.chat_state.refresh_search_with_id(selected_id); } } } /// Fetch the chat list from all enabled providers, returning raw chats and any /// per-provider errors. Does not mutate any state and does not borrow [`AppState`], /// so it may be driven from a background task that owns its own messenger/config /// clones (used for non-blocking startup). pub async fn fetch_all_chats( messengers: &[MessengerKind], providers: &crate::config::ProvidersConfig, ) -> (Vec, Vec) { let mut chat_list = Vec::new(); let mut errors: Vec = Vec::new(); for messenger in messengers { let provider = messenger.provider(); if !provider.is_enabled(providers) { warn!("Provider {:#?} disabled! Skipping chat fetch", provider); continue; } if !messenger.is_authenticated().await { warn!( "Skipping chat fetch for unauthorized provider {}", provider.name() ); continue; } match messenger.chats().await { Ok(chats) => chat_list.extend(chats), Err(e) => { warn!(provider = provider.name(), error = %e, "Failed to fetch chats"); errors.push(e); } } } (chat_list, errors) } #[cfg(test)] mod tests { use super::*; use crate::audio::MicCapture; use crate::backend::mock::MockMessenger; use crate::backend::{MediaKind, Message, MessageAction, MessageId, MessageMedia, Messenger}; use crate::config::KeymapConfig; use crate::tui::image::{ImageWidgetState, VideoWidgetState}; use crate::tui::player::{PlayKey, PlayState, PlaybackState}; use crate::tui::popup::{AudioPopup, ImagePopup, PopUp, VideoPopup}; use ratatui::buffer::Buffer; use ratatui::crossterm::event::{KeyCode, KeyEvent}; use ratatui::layout::Rect; use ratatui::widgets::StatefulWidget; use ratatui_image::picker::Picker; use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; use tokio::sync::Mutex; use tokio::sync::broadcast; impl AppState { /// Gets a mutable handle on the messenger that owns `chat`. Test-only /// (the production flow drives finality/status directly through /// `BackendEvent`s). fn chat_owner_mut(&mut self, chat: &ChatId) -> Option<&mut MessengerKind> { let target = match chat { ChatId::Telegram(_) => Provider::Telegram, ChatId::WhatsApp(_) => Provider::WhatsApp, ChatId::Myself => return None, }; self.messengers.iter_mut().find(|m| m.provider() == target) } } /// Test helper: synchronously open the chat at the given *visible* /// chat-list index, loading history through the messenger directly. Mirrors /// the async open flow the production event loop drives via /// `begin_chat_load` + `apply_chat_load`. async fn select_chat(state: &mut AppState, index: usize) { let Some(src) = state.chat_state.search.map_to_source(index) else { return; }; let Some(chat) = state.chat_state.chats.get(src).cloned() else { return; }; state.chat_state.clear_message_search(); debug!(chat = %chat.contact_name, id = ?chat.id, "Opening chat"); // Save current draft and message selection before switching if let Some(current_chat_id) = state .chat_state .open_chat .as_ref() .map(|o| o.chat.id.clone()) { let text = state.write.lines().join("\n"); state.chat_state.save_draft(¤t_chat_id, text); state.chat_state.save_message_selection(); } let read_result = match state.chat_owner_mut(&chat.id) { Some(messenger) => messenger.set_read(&chat.id).await, None => Ok(()), }; if let Err(e) = read_result { warn!(chat = %chat.contact_name, error = %e, "Failed to mark chat as read"); state.create_popup(PopupKind::Error(format!( "{} failed to mark chat as read: {e}", chat.id.platform() ))); return; } state.chat_state.chats[src].unread = false; state.chat_state.chats[src].unread_count = 0; let status = match state.chat_owner(&chat.id) { Some(messenger) => messenger.status(&chat.id).await.ok().flatten(), None => None, }; if let Some(status) = status.clone() { state.chat_state.chats[src].status = Some(status.clone()); } let history_result = match state.chat_owner(&chat.id) { Some(messenger) => messenger.history(&chat.id).await, None => Ok(Vec::new()), }; match history_result { Ok(messages) => { let history_len = messages.len(); debug!(chat = %chat.contact_name, messages = history_len, "History loaded"); state.chat_state.open_chat = Some(OpenChat { chat: Chat { status: status.clone(), ..chat.clone() }, history: messages, has_more_history: true, }); state.chat_state.restore_message_selection(history_len); } Err(e) => { error!(chat = %chat.contact_name, error = %e, "Failed to load history"); state.chat_state.open_chat = None; state.create_popup(PopupKind::Error(format!( "{} failed to load history for {}: {e}", chat.id.platform(), chat.contact_name ))); } } // Load draft for the new chat state.write.clear(); if let Some(draft) = state.chat_state.load_draft(&chat.id) { state.write.insert_str(draft); } state.focus = Focus::Chat; } async fn app_state() -> AppState { let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mock = MockMessenger::new("Telegram"); mock.spawn_incoming_messages(); let messengers = vec![MessengerKind::Stub(Provider::Telegram, Box::new(mock))]; let mut app = AppState::new(config, keymap, messengers, false).await; // Mirror non-blocking startup: independently fetch and apply chats. let (chats, _) = fetch_all_chats(&app.messengers, &app.config.providers).await; app.apply_chats(chats); app.chats_loaded = true; app } /// Fetch chats from the state's messengers and apply them (plus any errors), /// as the TUI event loop does on `ChatsLoaded`. async fn fetch_and_apply(state: &mut AppState) { let (chats, errors) = fetch_all_chats(&state.messengers, &state.config.providers).await; state.apply_fetched(chats, errors, state.lifecycle_epoch); } /// Build an `AppState` hosting both a Telegram and a WhatsApp mock, with the /// given `whatsapp` enabled flag. Both mocks are authenticated by default. async fn dual_provider_state(whatsapp: bool) -> AppState { let mut config = Config::default(); config.providers.telegram.enabled = true; config.providers.whatsapp = whatsapp; let keymap = config.keys.parse().unwrap(); let tg = MockMessenger::new("Telegram"); tg.spawn_incoming_messages(); let wa = MockMessenger::new("WhatsApp"); wa.spawn_incoming_messages(); let messengers = vec![ MessengerKind::Stub(Provider::Telegram, Box::new(tg)), MessengerKind::Stub(Provider::WhatsApp, Box::new(wa)), ]; let mut app = AppState::new(config, keymap, messengers, false).await; fetch_and_apply(&mut app).await; app } #[tokio::test] async fn finish_loading_only_clears_the_matching_area() { let mut state = app_state().await; state.start_loading(LoadingArea::Chat, "Alice"); state.finish_loading(LoadingArea::Inline); assert!( state .loading .as_ref() .is_some_and(|l| l.area == LoadingArea::Chat), "finishing a different area must not clear the active indication" ); state.finish_loading(LoadingArea::Chat); assert!(state.loading.is_none()); state.finish_loading(LoadingArea::Chat); assert!(state.loading.is_none(), "finishing an idle area is a no-op"); } #[tokio::test] async fn chat_loading_engages_on_open_and_releases_on_apply() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); let chat = state.chat_state.chats[0].clone(); // `begin_chat_load` clones the messenger, which the test `Stub` cannot // provide; drive the same transitions it performs instead. state.start_loading(LoadingArea::Chat, chat.contact_name.clone()); assert_eq!(state.loading.as_ref().unwrap().area, LoadingArea::Chat); state.apply_chat_load( chat.clone(), state.chat_load_generation, Ok(Vec::new()), None, ); assert!( state.loading.is_none(), "a successful chat load releases the Chat indication" ); assert!(state.chat_state.open_chat.is_some()); state.start_loading(LoadingArea::Chat, chat.contact_name); state.cancel_chat_load(); assert!(state.loading.is_none(), "aborting a chat open releases it"); } #[tokio::test] async fn stale_chat_load_result_does_not_clear_newer_indication() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); let chat = state.chat_state.chats[0].clone(); // Simulate a chat open that is then aborted (bumping the generation), // followed by a newer indication (a scroll-back page) claiming the slot. let stale_generation = state.chat_load_generation; state.start_loading(LoadingArea::Chat, chat.contact_name.clone()); state.cancel_chat_load(); state.start_loading(LoadingArea::Inline, "Loading older messages..."); state.apply_chat_load(chat, stale_generation, Ok(Vec::new()), None); assert!( state .loading .as_ref() .is_some_and(|l| l.area == LoadingArea::Inline), "an outdated chat-load result must not clear the Inline indication" ); } #[tokio::test] async fn apply_fetched_releases_chatlist_loading() { let mut state = app_state().await; state.start_loading(LoadingArea::ChatList, "Fetching chats..."); state.apply_fetched(Vec::new(), Vec::new(), state.lifecycle_epoch); assert!(state.loading.is_none()); } /// A handle to a stub provider's call counter (start/disconnect), kept by /// the test after the stub itself was moved into the state. #[derive(Clone)] struct CallCount(Arc); impl CallCount { fn load(&self) -> usize { self.0.load(AtomicOrdering::SeqCst) } } /// Build an `AppState` holding one stub per provider, plus a `start()` count /// for each so a lifecycle assertion can inspect them after the move. async fn stub_provider_state(whatsapp: bool) -> (AppState, CallCount, CallCount) { let telegram = StubMessenger::new(); let whatsapp_stub = StubMessenger::new(); let telegram_starts = CallCount(telegram.start_calls.clone()); let whatsapp_starts = CallCount(whatsapp_stub.start_calls.clone()); let mut config = Config::default(); config.providers.telegram.enabled = true; config.providers.whatsapp = whatsapp; let keymap = config.keys.parse().unwrap(); let state = AppState::new( config, keymap, vec![ MessengerKind::Stub(Provider::Telegram, Box::new(telegram)), MessengerKind::Stub(Provider::WhatsApp, Box::new(whatsapp_stub)), ], false, ) .await; (state, telegram_starts, whatsapp_starts) } #[tokio::test] async fn startup_starts_only_enabled_providers() { let (mut state, telegram, whatsapp) = stub_provider_state(/* whatsapp */ false).await; state.start_enabled_providers().await; assert_eq!(telegram.load(), 1, "enabled Telegram must be started"); assert_eq!(whatsapp.load(), 0, "a disabled provider must stay dormant"); } #[tokio::test] async fn startup_starts_whatsapp_when_enabled() { let (mut state, _telegram, whatsapp) = stub_provider_state(/* whatsapp */ true).await; state.start_enabled_providers().await; assert_eq!(whatsapp.load(), 1, "enabled WhatsApp must be started"); } #[tokio::test] async fn subscribe_before_start_receives_the_first_event() { let (mut state, telegram, _) = stub_provider_state(/* whatsapp */ false).await; // Mirrors the startup order in `tui::run`: receivers first, then start. let mut rx = state.messengers[0].subscribe(); state.start_enabled_providers().await; assert_eq!(telegram.load(), 1, "the subscribed provider must start"); assert!( matches!(rx.recv().await, Ok(BackendEvent::Connected)), "a provider started after subscribing must not lose its first event" ); } #[tokio::test] async fn enabling_whatsapp_from_settings_starts_it() { let (mut state, _, whatsapp) = stub_provider_state(/* whatsapp */ false).await; state.toggle_provider(Provider::WhatsApp).await; assert_eq!( whatsapp.load(), 1, "the settings toggle must start WhatsApp" ); assert!(state.config.providers.whatsapp); assert!( state.login_state.is_none() && matches!(state.screen, Screen::Main), "enabling must not flash the login screen — pairing is driven by \ the QR event, not by a handshake probe" ); } #[tokio::test] async fn enabling_whatsapp_keeps_the_first_event_emitted_by_start() { let (mut state, _, whatsapp) = stub_provider_state(/* whatsapp */ false).await; // The event source is subscribed before the enable, exactly as the TUI // event loop does, so the very first event `start()` emits is receivable. let mut rx = state .provider_to_messenger(Provider::WhatsApp) .unwrap() .subscribe(); state.toggle_provider(Provider::WhatsApp).await; assert_eq!(whatsapp.load(), 1, "the toggle must start WhatsApp"); // The enable flips and persists the flag *before* opening the transport, // so the first event is not dropped by the disabled-provider gate. assert!(state.config.providers.whatsapp); let event = rx .try_recv() .expect("a freshly started provider must emit its first event"); assert!(matches!(event, BackendEvent::Connected)); state.handle_backend_event(Provider::WhatsApp, event); assert!( state.provider_connected.contains(&Provider::WhatsApp), "the first event from a just-enabled provider must survive the gate" ); } #[tokio::test] async fn a_failed_whatsapp_start_rolls_the_enable_back() { let telegram = StubMessenger::new(); let failing = StubMessenger::new().with_start_error(BackendError::Other("no store".into())); let mut config = Config::default(); config.providers.telegram.enabled = true; config.providers.whatsapp = false; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![ MessengerKind::Stub(Provider::Telegram, Box::new(telegram)), MessengerKind::Stub(Provider::WhatsApp, Box::new(failing)), ], false, ) .await; state.toggle_provider(Provider::WhatsApp).await; assert!( !state.config.providers.whatsapp, "a start that never came up must not leave WhatsApp enabled" ); match &state .pop_up .as_ref() .expect("rollback must explain itself") .popup_type { PopupKind::Error(msg) => assert!(msg.contains("could not start")), other => panic!("expected an Error popup, got {other:?}"), } } #[tokio::test] async fn enabling_whatsapp_does_not_open_login_until_a_qr_event() { let (mut state, _, _whatsapp) = stub_provider_state(/* whatsapp */ false).await; state.toggle_provider(Provider::WhatsApp).await; assert!( state.login_state.is_none() && matches!(state.screen, Screen::Main), "a handshake-less enable must stay on the main screen" ); // The transport announces itself as genuinely unpaired by emitting a // real QR code; only then does the QR screen open. state.handle_backend_event(Provider::WhatsApp, BackendEvent::QrCode("pair-abc".into())); assert_eq!( state.login_state.as_ref().map(|ls| ls.provider), Some(Provider::WhatsApp), "the QR event is the source of truth for opening pairing" ); assert!( matches!(state.screen, Screen::Login), "the QR event must hop to the login screen" ); assert!( state.config.providers.whatsapp, "pairing stays enabled while the QR screen is shown" ); } #[tokio::test] async fn paired_reconnect_stays_on_the_main_screen() { let (mut state, _telegram, _whatsapp) = stub_provider_state(/* whatsapp */ false).await; state.toggle_provider(Provider::WhatsApp).await; assert!(state.login_state.is_none(), "no login flash on enable"); // A transport that holds a valid session announces itself by // connecting, never by offering a QR code. state.handle_backend_event(Provider::WhatsApp, BackendEvent::Connected); assert!(state.provider_connected.contains(&Provider::WhatsApp)); assert!( state.login_state.is_none() && matches!(state.screen, Screen::Main), "a paired reconnect must never open the login screen" ); } #[tokio::test] async fn a_terminal_disconnect_shows_an_error_without_opening_login() { let (mut state, _telegram, _whatsapp) = stub_provider_state(/* whatsapp */ true).await; state.handle_backend_event(Provider::WhatsApp, BackendEvent::Connected); assert!( state.provider_connected.contains(&Provider::WhatsApp), "precondition: the provider is connected" ); // `Disconnected` from the run-loop watcher / logout handler means the // transport actually died — it is loud (error popup, connection // revoked) but never itself a pairing prompt. state.handle_backend_event( Provider::WhatsApp, BackendEvent::Disconnected("WhatsApp: connection lost".into()), ); assert!(!state.provider_connected.contains(&Provider::WhatsApp)); assert!( state.pop_up.is_some(), "a terminal disconnect must surface an error" ); assert!( state.login_state.is_none() && matches!(state.screen, Screen::Main), "a terminal disconnect must not open the QR/login screen" ); state.handle_backend_event(Provider::WhatsApp, BackendEvent::Connected); assert!(state.provider_connected.contains(&Provider::WhatsApp)); assert!( state.login_state.is_none(), "reconnect confirms the session and stays on the main screen" ); } #[tokio::test] async fn a_transient_reconnect_status_keeps_the_provider_connected() { let (mut state, _telegram, _whatsapp) = stub_provider_state(/* whatsapp */ true).await; state.handle_backend_event(Provider::WhatsApp, BackendEvent::Connected); assert!( state.provider_connected.contains(&Provider::WhatsApp), "precondition: the provider is connected" ); // A clean stream recycle is announced as a transient status, not a // terminal outage: no error popup, the connection is not revoked, the // session is not cleared, and no pairing prompt appears. state.handle_backend_event( Provider::WhatsApp, BackendEvent::Status("WhatsApp reconnecting".into()), ); assert_eq!( state.backend_status.as_deref(), Some("WhatsApp reconnecting") ); assert!( state.provider_connected.contains(&Provider::WhatsApp), "a recycle must not revoke the connection" ); assert!( state.pop_up.is_none(), "a recycle must not pop an error dialog" ); assert!(state.login_state.is_none()); // The single `Connected` that follows a successful reconnect clears the // transient status and stays on the main screen. state.handle_backend_event(Provider::WhatsApp, BackendEvent::Connected); assert!( state.backend_status.is_none(), "Connected clears the status" ); assert!(state.provider_connected.contains(&Provider::WhatsApp)); assert!(state.login_state.is_none()); } #[tokio::test] async fn disabling_then_re_enabling_whatsapp_stops_then_starts_it() { let tg = StubMessenger::new(); let wa = StubMessenger::new(); let wa_starts = CallCount(wa.start_calls.clone()); let wa_disconnects = CallCount(wa.disconnect_calls.clone()); let mut config = Config::default(); config.providers.telegram.enabled = true; config.providers.whatsapp = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![ MessengerKind::Stub(Provider::Telegram, Box::new(tg)), MessengerKind::Stub(Provider::WhatsApp, Box::new(wa)), ], false, ) .await; // A WhatsApp row that is open, with a reply queued against it and the // transport marked connected: all three must not survive the disable. state.chat_state.chats.push(Chat { id: ChatId::WhatsApp("15550000007@s.whatsapp.net".into()), contact_name: "WA Contact".into(), ..Default::default() }); let wa_chat = state.chat_state.chats[0].clone(); state.chat_state.open_chat = Some(OpenChat { chat: wa_chat.clone(), history: Vec::new(), has_more_history: true, }); let mut target = audio_message(); target.chat_id = wa_chat.id; state.chat_state.pending_reply = Some(target); state.provider_connected.insert(Provider::WhatsApp); let epoch_before = state.lifecycle_epoch; state.toggle_provider(Provider::WhatsApp).await; assert_eq!( wa_disconnects.load(), 1, "disabling must stop the transport exactly once" ); assert_eq!(wa_starts.load(), 0, "disabling must not start anything"); assert!(!state.config.providers.whatsapp, "the config flag flips"); assert_eq!( state.lifecycle_epoch, epoch_before + 1, "a disable invalidates in-flight fetches" ); assert!( state.chat_state.open_chat.is_none(), "an open chat of the disabled provider must close" ); assert!( state.chat_state.pending_reply.is_none(), "a reply queued against it must not survive" ); assert!( !state.provider_connected.contains(&Provider::WhatsApp), "a stopped transport is not connected" ); assert!( state .chat_state .chats .iter() .all(|c| !matches!(c.id, ChatId::WhatsApp(_))), "the disabled provider's rows are gone" ); // Re-enabling rebuilds a fresh start over the same durable session — // exactly one start(), no second disconnect, no login screen. state.toggle_provider(Provider::WhatsApp).await; assert!( state.config.providers.whatsapp, "the config flag flips back" ); assert_eq!( wa_starts.load(), 1, "re-enable must start the transport once" ); assert_eq!( wa_disconnects.load(), 1, "re-enabling must not disconnect again" ); assert!( state.login_state.is_none(), "re-enabling an authenticated provider must not reopen the login flow" ); } #[tokio::test] async fn disabling_telegram_never_disconnects_it() { let tg = StubMessenger::new(); let tg_disconnects = CallCount(tg.disconnect_calls.clone()); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(tg))], false, ) .await; state.toggle_provider(Provider::Telegram).await; assert!( !state.config.providers.telegram.enabled, "the config flag flips" ); assert_eq!( tg_disconnects.load(), 0, "Telegram's listener is governed by set_enabled; disconnect would \ make the next enable unrecoverable" ); } #[tokio::test] async fn stale_chat_fetch_from_before_a_disable_is_discarded() { let mut state = dual_provider_state(/* whatsapp */ true).await; let rows_before = state.chat_state.chats.clone(); assert!( rows_before .iter() .any(|c| matches!(c.id, ChatId::WhatsApp(_))), "the seeded snapshot must contain WhatsApp rows" ); let epoch = state.lifecycle_epoch; state.toggle_provider(Provider::WhatsApp).await; let rows_after_disable = state.chat_state.chats.len(); state.apply_fetched(rows_before, Vec::new(), epoch); assert_eq!( state.chat_state.chats.len(), rows_after_disable, "the pre-disable snapshot must be dropped wholesale" ); assert!( state .chat_state .chats .iter() .all(|c| !matches!(c.id, ChatId::WhatsApp(_))), "a pre-disable fetch must not resurrect the disabled rows" ); assert!( state.loading.is_none(), "a stale fetch still releases the loading state" ); } #[tokio::test] async fn stale_chatlist_sync_from_before_a_disable_is_discarded() { let mut state = dual_provider_state(/* whatsapp */ true).await; let epoch = state.lifecycle_epoch; state.chatlist_sync_in_flight = true; state.chatlist_sync_pending = 2; state.start_loading(LoadingArea::ChatList, "Syncing chats..."); state.toggle_provider(Provider::WhatsApp).await; let stale_row = Chat { id: ChatId::WhatsApp("15550000008@s.whatsapp.net".into()), contact_name: "WA Contact".into(), ..Default::default() }; state.apply_synced_chats(Provider::WhatsApp, Ok(vec![stale_row.clone()]), epoch); assert_eq!( state.chatlist_sync_pending, 1, "a stale sync still advances the counter" ); assert!( state.chatlist_sync_in_flight, "another sync is still pending" ); assert!( state .chat_state .chats .iter() .all(|c| !matches!(c.id, ChatId::WhatsApp(_))), "a stale sync must not resurrect the disabled rows" ); // Even a sync that started *after* the disable is rejected: the // provider flag alone must gate the payload. state.apply_synced_chats( Provider::WhatsApp, Ok(vec![stale_row]), state.lifecycle_epoch, ); assert_eq!(state.chatlist_sync_pending, 0); assert!(!state.chatlist_sync_in_flight); assert!( state.loading.is_none(), "the counter reaching zero releases it" ); assert!( state .chat_state .chats .iter() .all(|c| !matches!(c.id, ChatId::WhatsApp(_))), "a disabled provider never reconciles back into the list" ); } #[tokio::test] async fn late_events_from_a_disabled_provider_change_nothing() { let mut state = dual_provider_state(/* whatsapp */ true).await; state.toggle_provider(Provider::WhatsApp).await; let rows_after_disable = state.chat_state.chats.len(); let mut late_message = audio_message(); late_message.chat_id = ChatId::WhatsApp("15550000009@s.whatsapp.net".into()); state.handle_backend_event( Provider::WhatsApp, BackendEvent::MessageReceived(late_message), ); state.handle_backend_event(Provider::WhatsApp, BackendEvent::QrCode("qr-data".into())); state.handle_backend_event(Provider::WhatsApp, BackendEvent::Connected); assert_eq!( state.chat_state.chats.len(), rows_after_disable, "a late message must not resurrect rows" ); assert!( state.login_state.is_none(), "a late QR must not reopen the login flow" ); assert!( !state.provider_connected.contains(&Provider::WhatsApp), "a late Connected must not mark a stopped transport as connected" ); assert!( !state.config.providers.whatsapp, "stale events never re-enable the config" ); } #[tokio::test] async fn fetch_all_chats_excludes_disabled_whatsapp() { let state = dual_provider_state(/* whatsapp */ false).await; assert!( state .chat_state .chats .iter() .all(|c| c.id.to_provider() == Provider::Telegram), "disabled WhatsApp chats must not be fetched" ); assert!(!state.chat_state.chats.is_empty()); } #[tokio::test] async fn disabling_whatsapp_removes_only_whatsapp_chats() { let mut state = dual_provider_state(/* whatsapp */ true).await; assert!( state .chat_state .chats .iter() .any(|c| c.id.to_provider() == Provider::WhatsApp) ); // Flip the flag off in memory and reconcile — the same rebuild the // settings toggle drives — leaving WhatsApp chats out but Telegram in. state.config.providers.whatsapp = false; let errors = state.rebuild_chats().await; assert!( errors.is_empty(), "reconcile must surface, not hide, errors" ); assert!( state .chat_state .chats .iter() .all(|c| c.id.to_provider() == Provider::Telegram), "only WhatsApp chats should be removed on disable" ); assert!( state .chat_state .chats .iter() .any(|c| c.id.to_provider() == Provider::Telegram) ); } #[tokio::test] async fn disabling_whatsapp_closes_an_open_whatsapp_chat() { let mut state = dual_provider_state(/* whatsapp */ true).await; let wa_idx = state .chat_state .chats .iter() .position(|c| c.id.to_provider() == Provider::WhatsApp) .expect("expected a WhatsApp chat when enabled"); state.chat_state.chat_list_state.select(Some(wa_idx)); select_chat(&mut state, wa_idx).await; assert!( matches!( state.chat_state.open_chat.as_ref().map(|o| &o.chat.id), Some(ChatId::WhatsApp(_)) ), "test precondition: a WhatsApp chat should be open" ); // Disable WhatsApp. The toggle closes any open chat belonging to the // disabled provider so the UI does not retain a stale conversation. state.config.providers.whatsapp = false; if let Some(open) = &state.chat_state.open_chat && open.chat.id.to_provider() == Provider::WhatsApp { state.write.clear(); state.chat_state.open_chat = None; } state.rebuild_chats().await; assert!( state.chat_state.open_chat.is_none(), "an open WhatsApp chat must be closed when WhatsApp is disabled" ); assert!( state .chat_state .chats .iter() .all(|c| c.id.to_provider() == Provider::Telegram), "WhatsApp chats must be removed after disabling" ); } #[tokio::test] async fn apply_history_page_prepends_older_and_filters_duplicates() { let mut state = app_state().await; state.config.providers.whatsapp = false; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; let chat_id = state.chat_state.open_chat.as_ref().unwrap().chat.id.clone(); let before = state.chat_state.open_chat.as_ref().unwrap().history.len(); let first_existing = state .chat_state .open_chat .as_ref() .unwrap() .history .first() .map(|m| m.message_id.clone()) .expect("opened chat must have history"); let older = vec![ Message { message_id: "older-1".into(), chat_id: chat_id.clone(), sender: "Alice".into(), author_id: None, text: "older still".into(), timestamp: -1000, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }, // A duplicate of an already-cached message must be filtered away. Message { message_id: first_existing, chat_id: chat_id.clone(), sender: "Alice".into(), author_id: None, text: "duplicate".into(), timestamp: -1000, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }, ]; state.apply_history_page(&chat_id, Ok(older)); let open = state.chat_state.open_chat.as_ref().unwrap(); assert!( open.has_more_history, "a non-empty page keeps lazy loading enabled" ); assert_eq!(open.history.len(), before + 1, "duplicate must be filtered"); assert_eq!( open.history.first().map(|m| &m.message_id), Some(&MessageId("older-1".into())), "the older page is stitched in front" ); assert_eq!( state.chat_state.message_list_state.selected(), Some(1), "selection lands on the first message of the freshly prepended page" ); } #[tokio::test] async fn apply_history_page_empty_or_error_disables_lazy_load() { let mut state = app_state().await; state.config.providers.whatsapp = false; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; let chat_id = state.chat_state.open_chat.as_ref().unwrap().chat.id.clone(); let len = state.chat_state.open_chat.as_ref().unwrap().history.len(); state.apply_history_page(&chat_id, Ok(Vec::new())); assert!( !state .chat_state .open_chat .as_ref() .unwrap() .has_more_history, "an empty page means the server has no older messages" ); assert_eq!( state.chat_state.open_chat.as_ref().unwrap().history.len(), len, "an empty page must not alter the history" ); state .chat_state .open_chat .as_mut() .unwrap() .has_more_history = true; state.apply_history_page(&chat_id, Err(BackendError::Other("expired".into()))); assert!( !state .chat_state .open_chat .as_ref() .unwrap() .has_more_history, "a failed fetch also stops lazy loading for this chat" ); } #[tokio::test] async fn apply_history_page_ignores_results_for_another_chat() { let mut state = app_state().await; state.config.providers.whatsapp = false; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; let len = state.chat_state.open_chat.as_ref().unwrap().history.len(); let other_id = ChatId::Telegram(999_999); state.apply_history_page( &other_id, Ok(vec![Message { message_id: "stale".into(), chat_id: other_id.clone(), sender: "Alice".into(), author_id: None, text: "for another chat".into(), timestamp: -1000, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }]), ); let open = state.chat_state.open_chat.as_ref().unwrap(); assert_eq!( open.history.len(), len, "stale result must not touch the open chat" ); assert!( open.has_more_history, "a result for another chat cannot turn off lazy loading" ); } #[tokio::test] async fn boot_has_no_selection_and_empty_pane() { let state = app_state().await; assert_eq!(state.chat_state.chats.len(), 4); assert!(state.selected_chat_idx().is_none()); assert!(state.chat_state.open_chat.is_none()); } #[tokio::test] async fn applying_chats_deduplicates_chat_ids() { let mut state = app_state().await; let duplicate = state.chat_state.chats[0].clone(); let duplicate_id = duplicate.id.clone(); // Snapshot at least as large as the loaded set so the grow-only guard // does not kick in; the injected duplicate must still be collapsed. let snapshot: Vec<_> = state .chat_state .chats .iter() .cloned() .chain([duplicate]) .collect(); state.apply_chats(snapshot); assert_eq!(state.chat_state.chats.len(), 4); assert_eq!( state .chat_state .chats .iter() .filter(|chat| chat.id == duplicate_id) .count(), 1, "duplicate chat id must be deduplicated" ); } #[tokio::test] async fn opening_a_chat_clears_the_committed_search_filter() { let mut state = app_state().await; state.apply_chats(vec![ crate::backend::Chat { id: ChatId::Telegram(1), contact_name: "Alpha".into(), ..Default::default() }, crate::backend::Chat { id: ChatId::Telegram(2), contact_name: "Bob".into(), ..Default::default() }, crate::backend::Chat { id: ChatId::Telegram(3), contact_name: "Charlie".into(), ..Default::default() }, ]); state.chat_state.begin_search(); state .chat_state .search_input(KeyEvent::from(KeyCode::Char('b'))); state.chat_state.commit_search(); assert!(state.chat_list_search_active()); assert_eq!( state.chat_state.visible_indices().len(), 1, "only Bob matches" ); assert_eq!( state .chat_state .selected_chat() .map(|c| c.contact_name.as_str()), Some("Bob") ); // Opening the highlighted match (visible index 0 -> source index 1) // must drop the filter and re-select the opened chat in the full list. assert_eq!( state.chat_state.resolve_open_index(0), Some(1), "visible index resolves to the matching source chat" ); assert!( !state.chat_state.search.is_active(), "opening a chat must clear the active search" ); assert_eq!(state.chat_state.chats.len(), 3, "full list restored"); assert_eq!( state.selected_chat_idx(), Some(1), "opened chat re-selected by id after the filter drops" ); assert_eq!( state .chat_state .selected_chat() .map(|c| c.contact_name.as_str()), Some("Bob") ); } #[tokio::test] async fn apply_chats_prunes_stale_rows_missing_from_live_snapshot() { let mut state = app_state().await; state.config.providers.whatsapp = true; let original_ids: Vec<_> = state .chat_state .chats .iter() .map(|chat| chat.id.clone()) .collect(); assert!( original_ids.len() > 1, "test precondition: a populated chat list" ); // Two WhatsApp rows: one the backend still reports, one it merged away // (simulates a stale LID twin the live snapshot no longer carries). state.chat_state.chats.push(crate::backend::Chat { id: ChatId::WhatsApp("wa-keep".into()), contact_name: "WA Keep".into(), ..Default::default() }); state.chat_state.chats.push(crate::backend::Chat { id: ChatId::WhatsApp("wa-orphan".into()), contact_name: "WA Orphan".into(), ..Default::default() }); // The snapshot is non-empty and includes WhatsApp: the stale row the // provider no longer reports is pruned, while original rows are kept. let fresh: Vec<_> = original_ids .iter() .map(|id| crate::backend::Chat { id: id.clone(), contact_name: "refresh".into(), ..Default::default() }) .chain(std::iter::once(crate::backend::Chat { id: ChatId::WhatsApp("wa-keep".into()), contact_name: "WA Keep".into(), ..Default::default() })) .collect(); state.apply_chats(fresh); // Original Telegram rows must survive a live WhatsApp snapshot. for id in &original_ids { assert!( state.chat_state.chats.iter().any(|c| &c.id == id), "original chat {id:?} must survive" ); } assert!( state .chat_state .chats .iter() .any(|c| c.id == ChatId::WhatsApp("wa-keep".into())), "a row present in the live snapshot must survive" ); assert!( !state .chat_state .chats .iter() .any(|c| c.id == ChatId::WhatsApp("wa-orphan".into())), "a WhatsApp row absent from a live WhatsApp snapshot must be pruned" ); } #[tokio::test] async fn cold_start_keeps_enabled_provider_chats_when_live_fetch_empty() { let mut state = app_state().await; state.config.providers.whatsapp = true; state.chat_state.chats.push(crate::backend::Chat { id: ChatId::WhatsApp("wa-1".into()), contact_name: "WA One".into(), ..Default::default() }); state.chat_state.chats.push(crate::backend::Chat { id: ChatId::WhatsApp("wa-2".into()), contact_name: "WA Two".into(), ..Default::default() }); // The live fetch yields only Telegram chats: there is no WhatsApp // messenger connected (WhatsApp does not re-sync its history on a cold // restart), so the persisted WhatsApp chats must be preserved. let (telegram_only, _errors) = fetch_all_chats(&state.messengers, &state.config.providers).await; assert!( telegram_only .iter() .all(|c| c.id.to_provider() == Provider::Telegram), "test precondition: live fetch has no WhatsApp chats" ); state.apply_chats(telegram_only); assert!( state .chat_state .chats .iter() .any(|c| c.id == ChatId::WhatsApp("wa-1".into())), "persisted WhatsApp chats must survive a cold start without a live sync" ); assert!( state .chat_state .chats .iter() .any(|c| c.id == ChatId::WhatsApp("wa-2".into())) ); } #[tokio::test] async fn apply_fetched_drops_disabled_provider_chats() { let mut state = app_state().await; state.chat_state.chats.push(crate::backend::Chat { id: ChatId::WhatsApp("wa-1".into()), contact_name: "WA One".into(), ..Default::default() }); state.config.providers.whatsapp = false; let (telegram_only, _errors) = fetch_all_chats(&state.messengers, &state.config.providers).await; state.apply_fetched(telegram_only, Vec::new(), state.lifecycle_epoch); assert!( state .chat_state .chats .iter() .all(|c| c.id.to_provider() == Provider::Telegram), "disabled providers' chats must be dropped" ); } #[test] fn paste_goes_into_the_write_box_when_focused() { let mut state = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(app_state()); state.focus = Focus::Write; state.handle_paste("line one\nline two".into()); assert_eq!( state.write.lines().to_vec(), vec!["line one".to_string(), "line two".to_string()] ); } #[test] fn paste_is_ignored_when_write_is_not_focused() { let mut state = tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(app_state()); state.focus = Focus::Chat; state.handle_paste("line one\nline two".into()); assert_eq!(state.write.lines().to_vec(), vec![String::new()]); } #[tokio::test] async fn blur_and_refocus_restore_the_exact_pane_and_open_chat() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; state.focus = Focus::Write; state.write.insert_str("half-typed draft"); state.set_window_focus(false); assert!(!state.focused); assert_eq!( state.focus, Focus::Write, "losing the window must not move the pane focus" ); assert!(state.chat_state.open_chat.is_some()); state.set_window_focus(true); assert!(state.focused); assert_eq!(state.focus, Focus::Write); assert!(state.chat_state.open_chat.is_some()); assert_eq!(state.write.lines().join("\n"), "half-typed draft"); } #[tokio::test] async fn status_hint_reports_the_blurred_window() { let mut state = app_state().await; assert_ne!(state.status_hint().as_deref(), Some("App not in focus!")); state.set_window_focus(false); assert_eq!(state.status_hint().as_deref(), Some("App not in focus!")); } #[tokio::test] async fn write_uses_word_wrap() { let state = app_state().await; assert_eq!(state.write.wrap_mode(), WrapMode::WordOrGlyph); } #[tokio::test] async fn scroll_is_remembered_per_chat() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; assert_eq!(state.chat_state.chats[0].scroll, 0); state.chat_state.chats[0].scroll = 10; state.chat_state.chat_list_state.select(Some(1)); select_chat(&mut state, 1).await; assert_eq!(state.chat_state.chats[1].scroll, 0); state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; assert_eq!(state.chat_state.chats[0].scroll, 10); } #[tokio::test] async fn open_chat_tracks_the_opened_conversation() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(1)); select_chat(&mut state, 1).await; let current = state.chat_state.open_chat.unwrap(); assert_eq!(current.chat.id, ChatId::Telegram(102)); assert!(!current.history.is_empty()); } #[tokio::test] async fn incoming_message_updates_open_chat_without_unread() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; assert_eq!( state.chat_state.open_chat.as_ref().unwrap().history.len(), 5 ); assert!(!state.chat_state.chats[0].unread); assert_eq!(state.chat_state.chats[0].unread_count, 0); state.handle_backend_event( Provider::Telegram, BackendEvent::MessageReceived(Message { message_id: "incoming".into(), chat_id: ChatId::Telegram(103), sender: "Alice".into(), author_id: None, text: "breaking".into(), timestamp: 1000, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }), ); assert_eq!( state.chat_state.open_chat.as_ref().unwrap().history.len(), 6 ); assert_eq!(state.chat_state.chats[0].last_message_ts, Some(1000)); assert!(!state.chat_state.chats[0].unread); } #[tokio::test] async fn incoming_message_to_closed_chat_marks_unread() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; state.handle_backend_event( Provider::Telegram, BackendEvent::MessageReceived(Message { message_id: "incoming".into(), chat_id: ChatId::Telegram(101), sender: "Alice".into(), author_id: None, text: "hi".into(), timestamp: 0, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }), ); let chat = state .chat_state .chats .iter() .find(|chat| chat.id == ChatId::Telegram(101)) .expect("incoming chat remains in chat list"); assert!(chat.unread); assert_eq!(chat.unread_count, 1); } /// The boundary is provider-neutral: the same `MessageReceived` shape must /// badge the WhatsApp row, leave the open Telegram chat alone, and carry /// `Provider::WhatsApp` into the notification the user sees. #[tokio::test] async fn a_whatsapp_message_badges_only_the_whatsapp_row() { let mut state = dual_provider_state(true).await; // Open a Telegram chat, so a WhatsApp message is one the user cannot see. let tg_index = state .chat_state .chats .iter() .position(|c| matches!(c.id, ChatId::Telegram(_))) .expect("the merged list holds a Telegram row"); select_chat(&mut state, tg_index).await; let open = state.chat_state.open_chat.as_ref().unwrap(); let (open_id, open_len) = (open.chat.id.clone(), open.history.len()); let telegram_before: Vec<(ChatId, bool, i32, Option)> = state .chat_state .chats .iter() .filter(|c| matches!(c.id, ChatId::Telegram(_))) .map(|c| (c.id.clone(), c.unread, c.unread_count, c.last_message_ts)) .collect(); let design = ChatId::WhatsApp("5511999990002@s.whatsapp.net".into()); let mut incoming = inbound(design.clone(), "Lia", "shipping it"); incoming.timestamp = 600; let notice = state .notice_for_message(Provider::WhatsApp, &incoming) .expect("an unseen WhatsApp message is worth a notification"); assert_eq!(notice.provider, Provider::WhatsApp); assert_eq!(notice.chat, design.clone()); assert_eq!(notice.title, "Design Team"); assert_eq!(notice.body, "shipping it"); state.handle_backend_event(Provider::WhatsApp, BackendEvent::MessageReceived(incoming)); let row = state .chat_state .chats .iter() .find(|c| c.id == design) .expect("the WhatsApp row stays in the list"); assert!(row.unread, "the unseen WhatsApp row takes the badge"); assert_eq!(row.unread_count, 1); assert_eq!( row.last_message_ts, Some(600), "recency follows the message itself" ); // A message older than the row's newest activity must not pull the // chat back down the list, but it is still an unread message. let mut stale = inbound(design.clone(), "Lia", "an older message"); stale.timestamp = 100; state.handle_backend_event(Provider::WhatsApp, BackendEvent::MessageReceived(stale)); let row = state .chat_state .chats .iter() .find(|c| c.id == design) .expect("the WhatsApp row stays in the list"); assert_eq!( row.last_message_ts, Some(600), "recency never regresses to an older message" ); assert_eq!(row.unread_count, 2, "it is still an unread message"); let open = state.chat_state.open_chat.as_ref().unwrap(); assert_eq!(open.chat.id, open_id, "the open Telegram chat is untouched"); assert_eq!( open.history.len(), open_len, "the message did not land in the open conversation" ); let telegram_after: Vec<(ChatId, bool, i32, Option)> = state .chat_state .chats .iter() .filter(|c| matches!(c.id, ChatId::Telegram(_))) .map(|c| (c.id.clone(), c.unread, c.unread_count, c.last_message_ts)) .collect(); assert_eq!( telegram_before, telegram_after, "a WhatsApp message must not touch a Telegram row" ); } /// An inbound message for a chat the user is not reading, built by hand so /// each seam test can vary only what it is about. fn inbound(chat: ChatId, sender: &str, text: &str) -> Message { Message { message_id: "incoming".into(), chat_id: chat, sender: sender.into(), author_id: None, text: text.into(), timestamp: 0, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, } } #[tokio::test] async fn a_message_for_a_closed_chat_produces_a_notice() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; // Chat 102 ("Family Group") is a background chat while another one is // open, so this is the message that also raises the unread badge. let notice = state .notice_for_message( Provider::Telegram, &inbound(ChatId::Telegram(102), "Bob", "dinner at eight"), ) .expect("a message the user cannot see is worth a notification"); assert_eq!(notice.provider, Provider::Telegram); assert_eq!(notice.chat, ChatId::Telegram(102)); // The chat-list name wins over the sender: in a group the sender is just // a member. assert_eq!(notice.title, "Family Group"); assert_eq!(notice.body, "dinner at eight"); } /// The event loop asks for a notice and then applies the event. While the /// window is focused, the two must agree for the same reason: the open chat /// is both unbadged and unannounced. (An unfocused window is the documented /// exception — see `an_unfocused_window_announces_the_open_chat`.) #[tokio::test] async fn unread_state_and_notice_state_never_disagree_while_focused() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; let open_chat = state .chat_state .open_chat .as_ref() .expect("a chat is open") .chat .id .clone(); // Open chat: no unread badge, no notice. let open_message = inbound(open_chat.clone(), "Alice", "on my way"); let open_notice = state.notice_for_message(Provider::Telegram, &open_message); state.handle_backend_event( Provider::Telegram, BackendEvent::MessageReceived(open_message), ); assert!(open_notice.is_none()); let open = state .chat_state .chats .iter() .find(|chat| chat.id == open_chat) .expect("open chat is in the chat list"); assert!(!open.unread); // Own message in a closed chat: no unread badge, no notice. The family // group already starts unread in the fixture, so compare the count. let unread_before = state .chat_state .chats .iter() .find(|chat| chat.id == ChatId::Telegram(102)) .expect("family group is in the chat list") .unread_count; let mut sent = inbound(ChatId::Telegram(102), "Me", "sent from my laptop"); sent.from_me = true; let own_notice = state.notice_for_message(Provider::Telegram, &sent); state.handle_backend_event(Provider::Telegram, BackendEvent::MessageReceived(sent)); assert!(own_notice.is_none()); let family = state .chat_state .chats .iter() .find(|chat| chat.id == ChatId::Telegram(102)) .expect("family group is in the chat list"); assert_eq!(family.unread_count, unread_before); } /// The open chat is not a reason to stay quiet when the user is not looking: /// the message is still announced. It is deliberately *not* badged, because /// it is already pushed into the open chat and will be on screen when the /// user comes back — so the two disagree on purpose here. #[tokio::test] async fn an_unfocused_window_announces_the_open_chat() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; let open_chat = state .chat_state .open_chat .as_ref() .expect("a chat is open") .chat .id .clone(); // Same chat, same message, only the window focus differs. state.set_window_focus(false); let message = inbound(open_chat.clone(), "Alice", "still there?"); let notice = state.notice_for_message(Provider::Telegram, &message); state.handle_backend_event(Provider::Telegram, BackendEvent::MessageReceived(message)); assert_eq!( notice.map(|notice| notice.chat), Some(open_chat.clone()), "a message in the open chat is still announced while the user is elsewhere" ); let open = state .chat_state .chats .iter() .find(|chat| chat.id == open_chat) .expect("open chat is in the chat list"); assert!( !open.unread, "but it must not be badged: it is already in the open chat's history" ); // Refocusing makes the user able to see it again, so the next message in // that chat goes back to being silent. state.set_window_focus(true); assert!( state .notice_for_message(Provider::Telegram, &inbound(open_chat, "Alice", "back now")) .is_none(), "focused again means the open chat is visible again" ); } #[tokio::test] async fn unread_update_does_not_mark_open_chat_unread() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; state.handle_backend_event( Provider::Telegram, BackendEvent::UnreadUpdated { chat: ChatId::Telegram(103), unread: true, unread_count: 4, }, ); let chat = state .chat_state .chats .iter() .find(|chat| chat.id == ChatId::Telegram(103)) .expect("open chat remains in chat list"); assert!(!chat.unread); assert_eq!(chat.unread_count, 0); } #[tokio::test] async fn status_update_refreshes_chat_list_and_open_chat() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; state.handle_backend_event( Provider::Telegram, BackendEvent::ChatUpdated(Chat { id: ChatId::Telegram(103), status: Some("last seen 5m ago".into()), ..Default::default() }), ); assert_eq!( state.chat_state.chats[0].status.as_deref(), Some("last seen 5m ago") ); assert_eq!( state .chat_state .open_chat .as_ref() .and_then(|open| open.chat.status.as_deref()), Some("last seen 5m ago") ); } #[tokio::test] async fn closed_chat_status_update_preserves_existing_chat_list_metadata() { let mut state = app_state().await; let original_name = state.chat_state.chats[1].contact_name.clone(); let original_ts = state.chat_state.chats[1].last_message_ts; state.handle_backend_event( Provider::Telegram, BackendEvent::ChatUpdated(Chat { id: ChatId::Telegram(102), status: Some("online".into()), ..Default::default() }), ); let chat = &state.chat_state.chats[1]; assert_eq!(chat.contact_name, original_name); assert_eq!(chat.last_message_ts, original_ts); assert_eq!(chat.status.as_deref(), Some("online")); } #[tokio::test] async fn incoming_message_to_unknown_chat_inserts_chat_list_entry() { let mut state = app_state().await; let before = state.chat_state.chats.len(); state.handle_backend_event( Provider::Telegram, BackendEvent::MessageReceived(Message { message_id: "new-chat".into(), chat_id: ChatId::Telegram(999), sender: "New contact".into(), author_id: None, text: "hello there".into(), timestamp: 1000, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }), ); assert_eq!(state.chat_state.chats.len(), before + 1); assert_eq!(state.chat_state.chats[0].id, ChatId::Telegram(999)); assert_eq!(state.chat_state.chats[0].last_message_ts, Some(1000)); assert!(state.chat_state.chats[0].unread); assert_eq!(state.chat_state.chats[0].unread_count, 1); } #[tokio::test] async fn message_never_renames_existing_chat_list_chat_to_sender() { let mut state = app_state().await; state.chat_state.upsert_chat(Chat { id: ChatId::Telegram(777), contact_name: "Car Budget".into(), ..Default::default() }); // Group-style message: the sender is a member, not the chat itself. state.handle_backend_event( Provider::Telegram, BackendEvent::MessageReceived(Message { message_id: "g-1".into(), chat_id: ChatId::Telegram(777), sender: "A Member".into(), author_id: None, text: "updated".into(), timestamp: 0, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }), ); let chat = state .chat_state .chats .iter() .find(|c| c.id == ChatId::Telegram(777)) .expect("existing chat stays in chat list"); assert_eq!( chat.contact_name, "Car Budget", "a message sender must never rename the row title" ); assert_eq!(chat.last_message_ts, Some(0)); assert_eq!(chat.unread_count, 1); } #[tokio::test] async fn unknown_sender_message_does_not_insert_nameless_chat_list_chat() { let mut state = app_state().await; let before = state.chat_state.chats.len(); // A brand-new chat with an unresolved ("Unknown") sender must not // appear as an "Unnamed chat" entry in the chat list. state.handle_backend_event( Provider::Telegram, BackendEvent::MessageReceived(Message { message_id: "transient".into(), chat_id: ChatId::Telegram(4242), sender: "Unknown".into(), author_id: None, text: "system ping".into(), timestamp: 0, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }), ); assert_eq!( state.chat_state.chats.len(), before, "an unresolved sender must not create a nameless chat list chat" ); assert!( state .chat_state .chats .iter() .all(|c| c.id != ChatId::Telegram(4242)), "no entry should exist for the unknown-chat id" ); } #[tokio::test] async fn message_deleted_event_removes_open_history_message() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; let removed = state .chat_state .open_chat .as_ref() .and_then(|open| open.history.first()) .map(|message| message.message_id.clone()) .unwrap(); state.handle_backend_event( Provider::Telegram, BackendEvent::MessageDeleted { chat: Some(ChatId::Telegram(101)), message_ids: vec![removed.clone()], }, ); assert!( !state .chat_state .open_chat .as_ref() .unwrap() .history .iter() .any(|message| message.message_id == removed) ); } #[tokio::test] async fn draft_is_saved_when_switching_chats() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; state.write.insert_str("hello from chat 0"); state.chat_state.chat_list_state.select(Some(1)); select_chat(&mut state, 1).await; assert_eq!( state.chat_state.load_draft(&ChatId::Telegram(103)), Some("hello from chat 0") ); } #[tokio::test] async fn draft_is_loaded_when_selecting_chat() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; state.write.insert_str("draft message"); state.chat_state.chat_list_state.select(Some(1)); select_chat(&mut state, 1).await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; assert_eq!(state.write.lines().join("\n"), "draft message"); } #[tokio::test] async fn draft_is_cleared_on_exit() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; state.write.insert_str("unsent text"); state.focus = Focus::Chat; state.cycle_focus(); assert!(state.write.lines().join("\n").is_empty()); assert_eq!( state.chat_state.load_draft(&ChatId::Telegram(103)), Some("unsent text") ); } #[tokio::test] async fn empty_draft_is_not_stored() { let mut state = app_state().await; state .chat_state .save_draft(&ChatId::Telegram(101), " ".into()); assert!( state .chat_state .load_draft(&ChatId::Telegram(101)) .is_none() ); state .chat_state .save_draft(&ChatId::Telegram(101), "".into()); assert!( state .chat_state .load_draft(&ChatId::Telegram(101)) .is_none() ); } #[tokio::test] async fn draft_is_removed_after_sending() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; state.write.insert_str("hello"); state .chat_state .save_draft(&ChatId::Telegram(101), "hello".into()); state.chat_state.drafts.remove(&ChatId::Telegram(101)); assert!( state .chat_state .load_draft(&ChatId::Telegram(101)) .is_none() ); } #[tokio::test] async fn draft_per_chat_is_independent() { let mut state = app_state().await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; state.write.insert_str("msg for chat 0"); state.chat_state.chat_list_state.select(Some(1)); select_chat(&mut state, 1).await; state.write.insert_str("msg for chat 1"); state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; assert_eq!( state.chat_state.load_draft(&ChatId::Telegram(103)), Some("msg for chat 0") ); assert_eq!( state.chat_state.load_draft(&ChatId::Telegram(102)), Some("msg for chat 1") ); } // -- StubMessenger for error propagation tests -- struct StubMessenger { chats_result: std::sync::Mutex, BackendError>>>, send_result: std::sync::Mutex>>, history_result: std::sync::Mutex, BackendError>>>, set_read_result: std::sync::Mutex>>, start_error: std::sync::Mutex>, cancel_refresh_calls: std::sync::Arc, start_calls: std::sync::Arc, disconnect_calls: std::sync::Arc, tx: broadcast::Sender, } impl StubMessenger { fn new() -> Self { let (tx, _) = broadcast::channel(16); Self { chats_result: std::sync::Mutex::new(None), send_result: std::sync::Mutex::new(None), history_result: std::sync::Mutex::new(None), set_read_result: std::sync::Mutex::new(None), start_error: std::sync::Mutex::new(None), cancel_refresh_calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), start_calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), disconnect_calls: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)), tx, } } fn with_start_error(self, err: BackendError) -> Self { *self.start_error.lock().unwrap() = Some(err); self } fn with_chats_error(self, err: BackendError) -> Self { *self.chats_result.lock().unwrap() = Some(Err(err)); self } fn with_send_error(self, err: BackendError) -> Self { *self.send_result.lock().unwrap() = Some(Err(err)); self } fn with_history_error(self, err: BackendError) -> Self { *self.history_result.lock().unwrap() = Some(Err(err)); self } fn with_set_read_error(self, err: BackendError) -> Self { *self.set_read_result.lock().unwrap() = Some(Err(err)); self } fn with_chats(self, chats: Vec) -> Self { *self.chats_result.lock().unwrap() = Some(Ok(chats)); self } } #[async_trait::async_trait] impl Messenger for StubMessenger { async fn is_authenticated(&self) -> bool { true } async fn chats(&self) -> Result, BackendError> { match self.chats_result.lock().unwrap().take() { Some(result) => result, None => Ok(Vec::new()), } } async fn set_read(&mut self, _chat: &ChatId) -> Result<(), BackendError> { match self.set_read_result.lock().unwrap().take() { Some(result) => result, None => Ok(()), } } async fn history(&self, _chat: &ChatId) -> Result, BackendError> { match self.history_result.lock().unwrap().take() { Some(result) => result, None => Ok(Vec::new()), } } async fn reply_context( &self, _chat: &ChatId, _message_id: &MessageId, ) -> Result, BackendError> { Ok(None) } async fn send( &self, chat: &ChatId, msg: &crate::backend::OutboundMessage, _reply_to: Option, ) -> Result { let text = match msg { crate::backend::OutboundMessage::Text { text } => text.clone(), crate::backend::OutboundMessage::Media { .. } => { return Err(BackendError::Other( "stub: media send not implemented".into(), )); } }; match self.send_result.lock().unwrap().take() { Some(Err(err)) => Err(err), _ => Ok(Message { message_id: "stub-sent".into(), chat_id: chat.clone(), sender: "You".into(), author_id: None, text, timestamp: 0, from_me: true, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }), } } async fn delete(&self, _chat: &ChatId, _id: &MessageId) -> Result<(), BackendError> { Ok(()) } async fn edit( &self, _chat: &ChatId, _id: &MessageId, _text: &str, ) -> Result<(), BackendError> { Ok(()) } fn subscribe(&self) -> broadcast::Receiver { self.tx.subscribe() } async fn start(&self) -> Result<(), BackendError> { self.start_calls .fetch_add(1, std::sync::atomic::Ordering::SeqCst); if let Some(err) = self.start_error.lock().unwrap().take() { return Err(err); } // A started provider announces itself on the same channel a real // transport would use for its first `Connected` event. let _ = self.tx.send(BackendEvent::Connected); Ok(()) } async fn cancel_chat_refresh(&self) { self.cancel_refresh_calls .fetch_add(1, std::sync::atomic::Ordering::SeqCst); } async fn disconnect(&mut self) -> Result<(), BackendError> { self.disconnect_calls .fetch_add(1, std::sync::atomic::Ordering::SeqCst); Ok(()) } } // -- Error propagation tests -- #[tokio::test] async fn messenger_chats_failure_is_nonfatal() { let good = MockMessenger::new("Telegram"); let bad = StubMessenger::new().with_chats_error(BackendError::Other("connection refused".into())); let mut config = Config::default(); config.providers.telegram.enabled = true; config.providers.whatsapp = true; let keymap = config.keys.parse().unwrap(); let messengers = vec![ MessengerKind::Stub(Provider::Telegram, Box::new(good)), MessengerKind::Stub(Provider::WhatsApp, Box::new(bad)), ]; let mut state = AppState::new(config, keymap, messengers, false).await; fetch_and_apply(&mut state).await; assert_eq!( state.chat_state.chats.len(), 4, "Telegram chats should still load" ); let popup = state.pop_up.as_ref().expect("error popup should exist"); match &popup.popup_type { PopupKind::Error(msg) => assert!(msg.contains("connection refused")), other => panic!("expected Error popup, got {other:?}"), } } #[tokio::test] async fn messenger_send_failure_shows_overlay() { let chat = Chat { id: ChatId::Telegram(1), contact_name: "Test".into(), ..Default::default() }; let stub = StubMessenger::new() .with_chats(vec![chat]) .with_send_error(BackendError::Other("rate limited".into())); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(stub))], false, ) .await; fetch_and_apply(&mut state).await; state.chat_state.chat_list_state.select(Some(0)); state.write.insert_str("hello"); state.send_message().await; let popup = state .pop_up .as_ref() .expect("send error popup should exist"); match &popup.popup_type { PopupKind::Error(msg) => { assert!(msg.contains("Telegram"), "should mention provider: {msg}"); assert!(msg.contains("rate limited"), "should include error: {msg}"); } other => panic!("expected Error popup, got {other:?}"), } } /// A send that fails with a reply pending must not insert a misleading /// local echo: the draft (reply target included) is stashed for retry and /// the error surfaces, but the open chat's history stays untouched. #[tokio::test] async fn failed_reply_send_leaves_no_local_echo() { let chat = Chat { id: ChatId::Telegram(1), contact_name: "Test".into(), ..Default::default() }; let stub = StubMessenger::new() .with_chats(vec![chat]) .with_send_error(BackendError::Other("unavailable reply target".into())); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(stub))], false, ) .await; fetch_and_apply(&mut state).await; state.chat_state.chat_list_state.select(Some(0)); let chat = state.chat_state.chats[0].clone(); state.chat_state.open_chat = Some(OpenChat { chat, history: Vec::new(), has_more_history: true, }); let target = audio_message(); state.chat_state.pending_reply = Some(target.clone()); state.write.insert_str("hello"); state.send_message().await; let draft = state .retry_draft .as_ref() .expect("the failed send keeps its draft for retry"); assert_eq!( draft.message_id, Some(target.message_id.clone()), "the retry keeps the reply target" ); assert!( state .chat_state .open_chat .as_ref() .is_some_and(|open| open.history.is_empty()), "a failed send must not insert a local echo" ); let popup = state .pop_up .as_ref() .expect("send error popup should exist"); match &popup.popup_type { PopupKind::Error(msg) => assert!(msg.contains("unavailable reply target")), other => panic!("expected Error popup, got {other:?}"), } } #[tokio::test] async fn stub_media_send_returns_explicit_error() { let chat = Chat { id: ChatId::Telegram(1), contact_name: "Test".into(), ..Default::default() }; let stub = StubMessenger::new().with_chats(vec![chat.clone()]); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(stub))], false, ) .await; let owner = state .chat_owner(&chat.id) .expect("stub messenger should own the chat"); let media = crate::backend::OutboundMessage::Media { kind: crate::backend::MediaKind::Image, data: vec![1, 2, 3].into(), file_name: "photo.jpg".into(), caption: None, }; let err = owner.send(&chat.id, &media, None).await.unwrap_err(); assert!( err.to_string().contains("media send not implemented"), "unexpected error: {err}" ); } #[tokio::test] async fn messenger_history_failure_shows_overlay() { let chat = Chat { id: ChatId::Telegram(1), contact_name: "Test".into(), ..Default::default() }; let stub = StubMessenger::new() .with_chats(vec![chat]) .with_history_error(BackendError::Other("timeout".into())); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(stub))], false, ) .await; fetch_and_apply(&mut state).await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; let popup = state .pop_up .as_ref() .expect("history error popup should exist"); match &popup.popup_type { PopupKind::Error(msg) => { assert!(msg.contains("Telegram"), "should mention provider: {msg}"); assert!(msg.contains("history"), "should mention history: {msg}"); } other => panic!("expected Error popup, got {other:?}"), } } #[tokio::test] async fn messenger_set_read_failure_shows_overlay() { let chat = Chat { id: ChatId::Telegram(1), contact_name: "Test".into(), ..Default::default() }; let stub = StubMessenger::new() .with_chats(vec![chat]) .with_set_read_error(BackendError::Other("permission denied".into())); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(stub))], false, ) .await; fetch_and_apply(&mut state).await; state.chat_state.chat_list_state.select(Some(0)); select_chat(&mut state, 0).await; let popup = state .pop_up .as_ref() .expect("set_read error popup should exist"); match &popup.popup_type { PopupKind::Error(msg) => { assert!(msg.contains("Telegram"), "should mention provider: {msg}"); assert!(msg.contains("read"), "should mention read: {msg}"); } other => panic!("expected Error popup, got {other:?}"), } } #[tokio::test] async fn backend_error_event_shows_overlay() { let stub = StubMessenger::new(); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(stub))], false, ) .await; state.handle_backend_event( Provider::Telegram, BackendEvent::Error( "Telegram subscription failed".into(), BackendError::Other("stream closed".into()), ), ); let popup = state .pop_up .as_ref() .expect("backend error popup should exist"); match &popup.popup_type { PopupKind::Error(msg) => { assert!( msg.contains("Telegram subscription failed"), "should include context: {msg}" ); assert!(msg.contains("stream closed"), "should include error: {msg}"); } other => panic!("expected Error popup, got {other:?}"), } } #[tokio::test] async fn multiple_messenger_boot_errors_are_combined() { let bad_tg = StubMessenger::new().with_chats_error(BackendError::Other("auth failed".into())); let bad_wa = StubMessenger::new().with_chats_error(BackendError::Other("connection refused".into())); let mut config = Config::default(); config.providers.telegram.enabled = true; config.providers.whatsapp = true; let keymap = config.keys.parse().unwrap(); let messengers = vec![ MessengerKind::Stub(Provider::Telegram, Box::new(bad_tg)), MessengerKind::Stub(Provider::WhatsApp, Box::new(bad_wa)), ]; let mut state = AppState::new(config, keymap, messengers, false).await; fetch_and_apply(&mut state).await; assert!(state.chat_state.chats.is_empty()); let popup = state .pop_up .as_ref() .expect("combined error popup should exist"); match &popup.popup_type { PopupKind::Error(msg) => { assert!( msg.contains("auth failed"), "should mention auth failed: {msg}" ); assert!( msg.contains("connection refused"), "should mention connection refused: {msg}" ); } other => panic!("expected Error popup, got {other:?}"), } } #[tokio::test] async fn qr_event_opens_login_only_when_provider_is_enabled() { let mut config = Config::default(); config.providers.whatsapp = true; let keymap = config.keys.parse().unwrap(); let wa = MockMessenger::new("WhatsApp"); wa.spawn_incoming_messages(); let messengers = vec![MessengerKind::Stub(Provider::WhatsApp, Box::new(wa))]; let mut state = AppState::new(config, keymap, messengers, false).await; state.chats_loaded = true; // Provider enabled + no login_state → QrCode should open login. state.handle_backend_event(Provider::WhatsApp, BackendEvent::QrCode("test".into())); assert!( state.login_state.is_some(), "login should open when provider is enabled" ); // Simulate user cancelling pairing (Esc): login_state cleared, // provider disabled. A second QrCode must NOT reopen login. state.login_state = None; state.config.providers.whatsapp = false; state.handle_backend_event(Provider::WhatsApp, BackendEvent::QrCode("refresh".into())); assert!( state.login_state.is_none(), "QrCode must not reopen login after cancel (provider disabled)" ); } #[tokio::test] async fn cancel_login_disables_whatsapp_when_pairing() { // Config I/O is redirected to a temp dir under `cfg!(test)` (see // `Config::user_config_dir`), so the real user config is never touched. let mut config = Config::default(); config.providers.whatsapp = true; let keymap = config.keys.parse().unwrap(); let wa = MockMessenger::new("WhatsApp"); wa.spawn_incoming_messages(); let messengers = vec![MessengerKind::Stub(Provider::WhatsApp, Box::new(wa))]; let mut state = AppState::new(config, keymap, messengers, false).await; // Simulate an active pairing session: login_state set for WhatsApp. state.screen = Screen::Login; state.login_state = Some(LoginState { provider: Provider::WhatsApp, step: 0, error: None, login_input: TextArea::default(), submitting: false, }); let epoch_before = state.lifecycle_epoch; state.cancel_login().await; // Login dismissed. assert!(state.login_state.is_none()); assert!( matches!(state.screen, Screen::Main), "cancel should return to Main" ); assert!( state.lifecycle_epoch > epoch_before, "cancelling a pairing counts as a disable: in-flight fetches die too" ); // Provider disabled. assert!( !state.config.providers.whatsapp, "WhatsApp should be disabled after cancel" ); // Verify the config file on disk was also updated. let saved = Config::load().unwrap().expect("config should be saved"); assert!( !saved.providers.whatsapp, "saved config should have whatsapp disabled" ); } #[tokio::test] async fn message_search_active_helpers_gate_on_chat_focus() { let mut state = app_state().await; state.chat_state.open_chat = Some(OpenChat { chat: Chat { id: ChatId::Telegram(1), contact_name: "Bob".into(), ..Default::default() }, history: vec![Message { message_id: "m".into(), chat_id: ChatId::Telegram(1), sender: "Sender".into(), author_id: None, text: "hello".into(), timestamp: 0, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }], has_more_history: true, }); state.focus = Focus::Chat; state.chat_state.begin_message_search(); state .chat_state .message_search_input(KeyEvent::from(KeyCode::Char('l'))); assert!(state.message_search_active()); assert!(state.message_search_typing()); state.focus = Focus::ChatList; assert!(!state.message_search_active()); assert!(!state.message_search_typing()); } #[tokio::test] async fn is_typing_tracks_write_search_and_login_inputs() { let mut state = app_state().await; state.screen = Screen::Main; // No text input focused: Normal. state.focus = Focus::ChatList; assert!(!state.is_typing()); // Write box focused: Insert. state.focus = Focus::Write; assert!(state.is_typing()); // Live chat-list search: Insert. state.focus = Focus::ChatList; state.chat_state.begin_search(); assert!(state.is_typing()); // Committed (filter held, not typing): Normal again. state.chat_state.commit_search(); assert!(!state.is_typing()); state.chat_state.clear_search(); // Live message search over an open chat: Insert. state.chat_state.open_chat = Some(OpenChat { chat: Chat { id: ChatId::Telegram(1), contact_name: "Bob".into(), ..Default::default() }, history: Vec::new(), has_more_history: true, }); state.focus = Focus::Chat; state.chat_state.begin_message_search(); assert!(state.is_typing()); // Committed message search: Normal. state.chat_state.commit_message_search(); assert!(!state.is_typing()); // Login screen input: Insert regardless of focus. state.screen = Screen::Login; state.login_state = Some(LoginState { provider: Provider::Telegram, step: 0, error: None, login_input: TextArea::default(), submitting: false, }); assert!(state.is_typing()); // Settings screen has no text input: Normal. state.screen = Screen::Settings; assert!(!state.is_typing()); } #[tokio::test] async fn update_mode_reflects_text_input_focus() { let mut state = app_state().await; state.update_mode(); assert_eq!(state.mode, Mode::Normal); state.focus = Focus::Write; state.update_mode(); assert_eq!(state.mode, Mode::Insert); state.focus = Focus::ChatList; state.chat_state.begin_search(); state.update_mode(); assert_eq!(state.mode, Mode::Insert); state.chat_state.commit_search(); state.update_mode(); assert_eq!(state.mode, Mode::Normal); state.screen = Screen::Login; state.login_state = Some(LoginState { provider: Provider::Telegram, step: 0, error: None, login_input: TextArea::default(), submitting: false, }); state.update_mode(); assert_eq!(state.mode, Mode::Insert); } #[tokio::test] async fn opening_a_chat_clears_an_active_message_search() { let mut state = app_state().await; state.apply_chats(vec![Chat { id: ChatId::Telegram(1), contact_name: "Alpha".into(), ..Default::default() }]); state.chat_state.open_chat = Some(OpenChat { chat: Chat { id: ChatId::Telegram(1), contact_name: "Alpha".into(), ..Default::default() }, history: vec![Message { message_id: "m".into(), chat_id: ChatId::Telegram(1), sender: "Sender".into(), author_id: None, text: "hello".into(), timestamp: 0, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }], has_more_history: true, }); state.chat_state.begin_message_search(); assert!(state.chat_state.message_search.is_active()); // Opening a chat (as Enter on the chat list does) drops the search // bound to the previous history. Mirrors `begin_chat_load`, which the // test-only helper reproduces without cloning the messenger. select_chat(&mut state, 0).await; assert!(!state.chat_state.message_search.is_active()); } #[tokio::test] async fn chatlist_poll_reorder_keeps_the_selected_chat_selected_in_a_filtered_view() { let mut state = app_state().await; state.chat_state.chats = vec![ Chat { id: ChatId::Telegram(1), contact_name: "alice".into(), last_message_ts: Some(5), ..Default::default() }, Chat { id: ChatId::Telegram(2), contact_name: "bob".into(), last_message_ts: Some(4), ..Default::default() }, Chat { id: ChatId::Telegram(3), contact_name: "carol".into(), last_message_ts: Some(3), ..Default::default() }, Chat { id: ChatId::Telegram(4), contact_name: "dave".into(), last_message_ts: Some(2), ..Default::default() }, ]; state.chat_state.sort_pinned_then_recent(); state.chat_state.begin_search(); state .chat_state .search_input(KeyEvent::from(KeyCode::Char('a'))); state.chat_state.commit_search(); assert_eq!(state.chat_state.search.indices(), &[0, 2, 3]); state.chat_state.chat_list_state.select(Some(1)); assert_eq!( state.chat_state.selected_chat().unwrap().contact_name, "carol" ); // The open chat's poll returns a newer message for "dave", bumping it // to the top and reordering the source list behind the committed filter. state.update_chat_list_from_poll( &ChatId::Telegram(4), &[Message { message_id: "poll".into(), chat_id: ChatId::Telegram(4), sender: "Dave".into(), author_id: None, text: "new".into(), timestamp: 100, from_me: false, msg_actions: Vec::new(), media: None, reply_to_id: None, reply_ctx: None, pending: false, failed: false, }], ); assert_eq!( state.chat_state.selected_chat().map(|c| c.id.clone()), Some(ChatId::Telegram(3)), "the poll-triggered reorder must keep the filtered selection on carol" ); } #[tokio::test] async fn rate_limited_event_shows_countdown_and_esc_cancels() { let stub = StubMessenger::new(); let cancel_calls = stub.cancel_refresh_calls.clone(); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(stub))], false, ) .await; // An enabled provider that has not connected yet gets the persistent // disconnect hint rather than a blank status line. let initial = state.status_hint().expect("initial disconnect hint"); assert!(initial.contains("Telegram disconnected"), "{initial}"); state.handle_backend_event(Provider::Telegram, BackendEvent::Connected); assert!(state.status_hint().is_none()); state.handle_backend_event( Provider::Telegram, BackendEvent::RateLimited { retry_after_secs: 30, }, ); let hint = state.status_hint().expect("rate-limit countdown hint"); assert!(hint.contains("Telegram rate limited"), "{hint}"); let remainder = hint .strip_prefix("Telegram rate limited — retry in ") .expect("countdown prefix"); let secs: u64 = remainder .strip_suffix('s') .expect("countdown suffix") .parse() .expect("countdown is numeric"); assert!((1..=30).contains(&secs), "unexpected countdown: {hint}"); assert!(state.cancel_rate_limit_wait().await); assert!(state.rate_limit.is_none()); assert_eq!(cancel_calls.load(std::sync::atomic::Ordering::SeqCst), 1); assert!(state.status_hint().is_none()); } #[tokio::test] async fn rate_limit_wait_is_not_cancelled_when_inactive() { let stub = StubMessenger::new(); let cancel_calls = stub.cancel_refresh_calls.clone(); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(stub))], false, ) .await; assert!(!state.cancel_rate_limit_wait().await); assert_eq!(cancel_calls.load(std::sync::atomic::Ordering::SeqCst), 0); } #[tokio::test] async fn auth_invalid_disables_telegram_and_prunes_its_chats() { let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mock = MockMessenger::new("Telegram"); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(mock))], false, ) .await; fetch_and_apply(&mut state).await; assert!(!state.chat_state.chats.is_empty()); assert!(state.config.providers.telegram.enabled); state.handle_backend_event( Provider::Telegram, BackendEvent::AuthInvalid("session revoked".into()), ); assert!( !state.config.providers.telegram.enabled, "Telegram should be disabled after AuthInvalid" ); assert!( state.chat_state.chats.is_empty(), "Telegram chats should be pruned after AuthInvalid" ); let popup = state.pop_up.as_ref().expect("AuthInvalid popup"); match &popup.popup_type { PopupKind::Error(msg) => { assert!( msg.contains("session invalid or revoked"), "unexpected popup text: {msg}" ); } other => panic!("expected Error popup, got {other:?}"), } } fn image_message() -> Message { Message { message_id: "img-1".into(), chat_id: ChatId::Myself, sender: "Maria".into(), author_id: None, text: String::new(), timestamp: 100, from_me: false, msg_actions: Vec::new(), media: Some(MessageMedia { kind: MediaKind::Image, caption: Some("sunset".into()), file_name: Some("sunset.png".into()), }), reply_to_id: None, reply_ctx: None, pending: false, failed: false, } } #[tokio::test] async fn image_popup_is_created_and_holds_its_message() { let mut state = app_state().await; let (wake, _rx) = tokio::sync::mpsc::unbounded_channel(); let msg = image_message(); state.create_popup(PopupKind::Image(ImagePopup { msg: msg.clone(), view: ImageWidgetState::new(wake), })); let popup = state.pop_up.as_ref().expect("popup should be open"); match &popup.popup_type { PopupKind::Image(ImagePopup { msg, .. }) => { assert_eq!(msg.message_id, MessageId::from("img-1")); assert_eq!(msg.chat_id, ChatId::Myself); } other => panic!("expected Image popup, got {other:?}"), } } #[tokio::test] async fn image_popup_renders_without_panicking() { let (wake, _rx) = tokio::sync::mpsc::unbounded_channel(); let mut popup = PopupState { popup_type: PopupKind::Image(ImagePopup { msg: image_message(), view: ImageWidgetState::new(wake), }), prev_focus: Focus::Chat, scroll_idx: 0, focused: true, }; if let PopupKind::Image(image_popup) = &mut popup.popup_type { image_popup.view.set_image( &ratatui_image::picker::Picker::halfblocks(), ::image::DynamicImage::ImageRgb8(::image::RgbImage::from_fn(2, 2, |x, y| { let on = (x + y) % 2 == 0; ::image::Rgb([ if on { 220 } else { 0 }, if x == 0 { 120 } else { 40 }, if y == 0 { 200 } else { 30 }, ]) })), ); } let area = Rect::new(0, 0, 80, 24); let mut buf = Buffer::empty(area); let tag = app_state().await.chat_state.get_tag().unwrap_or(""); (&mut PopUp::new(tag)).render(area, &mut buf, &mut popup); } #[tokio::test] async fn media_bytes_defaults_to_none_via_delegate() { let mock = MockMessenger::new("Telegram"); let messenger = MessengerKind::Stub(Provider::Telegram, Box::new(mock)); let result = messenger .media_bytes(&ChatId::Myself, &MessageId::from("1")) .await; assert!(result.is_ok()); assert!(matches!(result, Ok(None))); } #[tokio::test] async fn apply_playback_state_guards_against_stale_reports() { let mut state = app_state().await; let source = PlayKey { chat: ChatId::Telegram(1), message_id: MessageId::from("42"), }; let report = |status| PlaybackState { source: source.clone(), status, position: 1.0, duration: 5.0, updated_at: Instant::now(), }; // No active session: reports are dropped. state.apply_playback_state(report(PlayState::Playing)); assert!(state.playback.is_none()); // An active session for the same message accepts newer reports. state.playback = Some(report(PlayState::Paused)); state.apply_playback_state(report(PlayState::Playing)); assert_eq!(state.playback.as_ref().unwrap().status, PlayState::Playing); // Stale reports from a different message are dropped, not resurrected. state.apply_playback_state(PlaybackState { source: PlayKey { chat: ChatId::Telegram(2), message_id: MessageId::from("43"), }, status: PlayState::Playing, position: 1.0, duration: 5.0, updated_at: Instant::now(), }); assert_eq!(state.playback.as_ref().unwrap().status, PlayState::Playing); } fn audio_message() -> Message { let mut msg = image_message(); msg.message_id = MessageId::from("audio-1"); msg.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()), }); msg.msg_actions = vec![MessageAction::Reply, MessageAction::Delete]; msg } fn video_message(caption: &str) -> Message { let mut msg = image_message(); msg.message_id = MessageId::from("video-1"); msg.media = Some(MessageMedia { kind: MediaKind::Video, caption: Some(caption.into()), file_name: Some("clip.mp4".into()), }); msg.msg_actions = vec![MessageAction::Reply, MessageAction::Delete]; msg } /// A video popup whose viewport has no frames yet, so the widget renders /// its placeholder instead of an image protocol. fn video_popup_kind( msg: Message, playback: Option, error_note: Option, ) -> PopupKind { let frames = Arc::new(Mutex::new(None)); let (wake, _rx) = tokio::sync::mpsc::unbounded_channel(); let view = VideoWidgetState::new(frames, Picker::halfblocks(), wake); PopupKind::Video(VideoPopup { msg, view, playback, error_note, }) } fn video_popup( msg: Message, playback: Option, error_note: Option, ) -> PopupState { PopupState { popup_type: video_popup_kind(msg, playback, error_note), prev_focus: Focus::Chat, scroll_idx: 0, focused: false, } } fn video_playback(status: PlayState, position: f64, duration: f64) -> PlaybackState { PlaybackState { source: PlayKey { chat: ChatId::Myself, message_id: MessageId::from("video-1"), }, status, position, duration, updated_at: Instant::now(), } } fn key(code: KeyCode) -> KeyEvent { KeyEvent::from(code) } #[tokio::test] async fn apply_playback_state_routes_to_the_slot_owning_the_session() { let mut state = app_state().await; let audio = PlayKey { chat: ChatId::Telegram(1), message_id: MessageId::from("audio-9"), }; let video = PlayKey { chat: ChatId::Telegram(1), message_id: MessageId::from("video-9"), }; let report = |source: PlayKey, position| PlaybackState { source, status: PlayState::Playing, position, duration: 5.0, updated_at: Instant::now(), }; // Both sessions live at once; each report updates only its own slot. state.playback = Some(report(audio.clone(), 1.0)); state.video = Some(report(video.clone(), 1.0)); state.apply_playback_state(report(video.clone(), 4.0)); assert_eq!(state.video.as_ref().unwrap().position, 4.0); assert_eq!( state.playback.as_ref().unwrap().position, 1.0, "a video report must not disturb the audio slot" ); state.apply_playback_state(report(audio.clone(), 3.0)); assert_eq!(state.playback.as_ref().unwrap().position, 3.0); assert_eq!(state.video.as_ref().unwrap().position, 4.0); // A report matching neither slot is dropped. let mut state = state; state.apply_playback_state(report( PlayKey { chat: ChatId::Telegram(1), message_id: MessageId::from("gone"), }, 9.0, )); assert_eq!(state.playback.as_ref().unwrap().position, 3.0); assert_eq!(state.video.as_ref().unwrap().position, 4.0); // Closing any popup ends both sessions. state.dismiss_popup(); assert!(state.playback.is_none()); assert!(state.video.is_none()); } #[tokio::test] async fn media_controls_are_scoped_to_media_popups() { let mut state = app_state().await; let km = KeymapConfig::default().parse().unwrap(); state.create_popup(PopupKind::Audio(AudioPopup { msg: audio_message(), playback: None, error_note: None, })); assert_eq!( state.media_controls(&key(KeyCode::Char(' ')), &km), Some(MediaAction::PlayPause) ); assert_eq!( state.media_controls(&key(KeyCode::Char('<')), &km), Some(MediaAction::Seek { delta_secs: -5 }) ); assert_eq!( state.media_controls(&key(KeyCode::Char('>')), &km), Some(MediaAction::Seek { delta_secs: 5 }) ); assert_eq!( state.media_controls(&key(KeyCode::Char('x')), &km), None, "unbound keys map to no action" ); // Without a media popup on screen the same keys must not map. state.dismiss_popup(); state.create_popup(PopupKind::Info("hello".into())); assert_eq!(state.media_controls(&key(KeyCode::Char(' ')), &km), None); // A Video popup is a media popup too, so the transport keys apply. state.dismiss_popup(); state.create_popup(video_popup_kind(video_message("clip"), None, None)); assert_eq!( state.media_controls(&key(KeyCode::Char(' ')), &km), Some(MediaAction::PlayPause) ); assert_eq!( state.media_controls(&key(KeyCode::Char('<')), &km), Some(MediaAction::Seek { delta_secs: -5 }) ); assert_eq!( state.media_controls(&key(KeyCode::Char('>')), &km), Some(MediaAction::Seek { delta_secs: 5 }) ); } #[tokio::test] async fn dismissing_audio_popup_stops_and_clears_session() { let mut state = app_state().await; state.create_popup(PopupKind::Audio(AudioPopup { msg: audio_message(), playback: None, error_note: None, })); state.playback = Some(PlaybackState { source: PlayKey { chat: ChatId::Myself, message_id: MessageId::from("audio-1"), }, status: PlayState::Playing, position: 3.0, duration: 10.0, updated_at: Instant::now(), }); state.dismiss_popup(); assert!(state.pop_up.is_none()); assert!( state.playback.is_none(), "session state must not survive dismissal" ); } #[tokio::test] async fn audio_popup_renders_progress_and_actions() { let mut popup = PopupState { popup_type: PopupKind::Audio(AudioPopup { msg: audio_message(), error_note: None, playback: Some(PlaybackState { source: PlayKey { chat: ChatId::Myself, message_id: MessageId::from("audio-1"), }, status: PlayState::Paused, position: 12.0, duration: 15.0, updated_at: Instant::now(), }), }), prev_focus: Focus::Chat, scroll_idx: 0, focused: false, }; let area = Rect::new(0, 0, 80, 24); let mut buf = Buffer::empty(area); let tag = app_state().await.chat_state.get_tag().unwrap_or(""); (&mut PopUp::new(tag)).render(area, &mut buf, &mut popup); let rendered: String = buf.content.iter().map(|c| c.symbol()).collect(); assert!(rendered.contains("Audio"), "audio label missing"); assert!(rendered.contains("00:12"), "elapsed time missing"); assert!(rendered.contains("00:15"), "duration missing"); assert!(rendered.contains("Reply"), "action bar missing"); } #[tokio::test] async fn video_popup_is_ninety_percent_and_centred() { let mut popup = video_popup( video_message("clip"), Some(video_playback(PlayState::Playing, 3.0, 12.0)), None, ); let area = Rect::new(0, 0, 100, 30); let mut buf = Buffer::empty(area); let tag = app_state().await.chat_state.get_tag().unwrap_or(""); (&mut PopUp::new(tag)).render(area, &mut buf, &mut popup); // The popup border is the outermost non-blank cell on each edge; the // 90% clamp plus centring must leave a margin all the way round. let blank = |x: u16, y: u16| buf[(x, y)].symbol() == " "; let left = (0..area.width) .find(|&x| (0..area.height).any(|y| !blank(x, y))) .unwrap(); let right = (0..area.width) .rev() .find(|&x| (0..area.height).any(|y| !blank(x, y))) .unwrap(); let top = (0..area.height) .find(|&y| (0..area.width).any(|x| !blank(x, y))) .unwrap(); let bottom = (0..area.height) .rev() .find(|&y| (0..area.width).any(|x| !blank(x, y))) .unwrap(); assert_eq!(left, 5, "width clamped to 90% leaves a 5 column margin"); assert_eq!(right - left + 1, 90, "popup is not 90% of the width"); assert_eq!(right, 94); assert!(top >= 1 && bottom <= 28, "popup not vertically centred"); } #[tokio::test] async fn video_popup_renders_progress_actions_and_hints() { let mut popup = video_popup( video_message("clip"), Some(video_playback(PlayState::Playing, 3.0, 12.0)), None, ); let area = Rect::new(0, 0, 80, 24); let mut buf = Buffer::empty(area); let tag = app_state().await.chat_state.get_tag().unwrap_or(""); (&mut PopUp::new(tag)).render(area, &mut buf, &mut popup); let rendered: String = buf.content.iter().map(|c| c.symbol()).collect(); assert!( rendered.contains("Video · 00:03 / 00:12"), "status line missing" ); assert!(rendered.contains('█'), "progress strip missing"); assert!(rendered.contains("Reply"), "action bar missing"); assert!(rendered.contains("Delete"), "action bar missing delete"); assert!(rendered.contains("space ▸ play/pause"), "hint line missing"); assert!(rendered.contains("esc ▸ close"), "hint line missing close"); } /// Height of the video frame viewport, derived from where the vertically /// centred placeholder landed: `placeholder_row = top + (height - 1) / 2`. fn frame_viewport_rows(buf: &Buffer, area: Rect, popup_top: u16) -> u16 { let placeholder_row = (0..area.height) .find(|y| (0..area.width).any(|x| buf[(x, *y)].symbol() == "L")) .expect("the loading placeholder should be drawn"); 2 * (placeholder_row - popup_top - 1) + 1 } #[tokio::test] async fn video_popup_frame_viewport_grows_with_the_terminal() { let render_at = |height: u16| { let mut popup = video_popup( video_message("clip"), Some(video_playback(PlayState::Paused, 0.0, 12.0)), None, ); let area = Rect::new(0, 0, 80, height); let mut buf = Buffer::empty(area); (&mut PopUp::new("")).render(area, &mut buf, &mut popup); // The popup is centred, so measure from its own left border column // and top row rather than the screen edges. let left = (0..area.width) .find(|&x| (0..area.height).any(|y| buf[(x, y)].symbol() != " ")) .expect("popup was rendered"); let popup_top = (0..area.height) .find(|&y| buf[(left, y)].symbol() != " ") .expect("popup was rendered"); let popup_height = (0..area.height) .filter(|y| buf[(left, *y)].symbol() != " ") .count() as u16; // A short caption must not shrink the popup: it takes the full 90% // and the frame gets every row the fixed lines do not. assert_eq!(popup_height, area.height * 9 / 10, "popup is not 90% tall"); frame_viewport_rows(&buf, area, popup_top) }; let short = render_at(24); let tall = render_at(50); assert!(short >= 8, "frame viewport collapsed at 24 rows: {short}"); assert!(tall >= 30, "frame viewport is tiny at 50 rows: {tall}"); assert!( tall > short * 2, "frame viewport did not grow with the terminal: {short} -> {tall}" ); } #[tokio::test] async fn video_popup_caption_wraps_beside_the_frame() { // Deliberately far more caption than the caption column can hold, with // a numbered word per token so the visible slice is identifiable. let words: Vec = (1..=300).map(|i| format!("w{i:03}")).collect(); let caption = words.join(" "); let mut popup = video_popup( video_message(&caption), Some(video_playback(PlayState::Paused, 2.0, 12.0)), None, ); let area = Rect::new(0, 0, 80, 24); let mut buf = Buffer::empty(area); let tag = app_state().await.chat_state.get_tag().unwrap_or(""); (&mut PopUp::new(tag)).render(area, &mut buf, &mut popup); let rows: Vec = buf .content .chunks(area.width as usize) .map(|row| row.iter().map(|c| c.symbol()).collect::()) .collect(); // The caption lives in the right half of the popup (72-wide popup // centred in 80 columns, 70 inner split 50/50 → right half from // column 40) and wraps across as many rows as it needs. let caption_rows: Vec<&String> = rows.iter().filter(|row| row.contains("w0")).collect(); assert!( caption_rows.len() >= 2, "caption did not wrap: {caption_rows:?}" ); let start = caption_rows .iter() .find(|row| row.contains("w001")) .expect("caption start is not on screen"); assert!( !start.chars().take(40).any(|c| c == 'w'), "caption spilled into the frame half: {start:?}" ); let rendered: String = buf.content.iter().map(|c| c.symbol()).collect(); assert!( !rendered.contains("w300"), "the caption spilled past its viewport" ); // The fixed rows survive the long caption, and the frame viewport is // unaffected by it. assert!(rendered.contains("Reply"), "action bar was squeezed out"); assert!(rendered.contains("esc ▸ close"), "hint was squeezed out"); assert!(rendered.contains("⏸ Video · 00:02 / 00:12"), "status lost"); assert!(rendered.contains('█'), "progress strip was squeezed out"); assert!( rendered.contains("Loading"), "frame viewport collapsed under a long caption" ); assert!( rows.iter() .filter(|row| row.contains("Reply") || row.contains("esc ▸ close")) .all(|row| !row.contains("w0")), "the caption overran the fixed rows" ); // The caption column scrolls with the popup, so an over-scrolled // viewport pushes it out of sight instead of pinning it. popup.scroll_idx = 99; let mut buf = Buffer::empty(area); (&mut PopUp::new(tag)).render(area, &mut buf, &mut popup); let scrolled: String = buf.content.iter().map(|c| c.symbol()).collect(); assert!( !scrolled.contains("w001"), "the caption ignored the popup scroll" ); } #[tokio::test] async fn paused_video_without_duration_shows_icon_and_unknown_time() { let mut popup = video_popup( video_message("clip"), Some(video_playback(PlayState::Paused, 0.0, 0.0)), None, ); let area = Rect::new(0, 0, 80, 24); let mut buf = Buffer::empty(area); let tag = app_state().await.chat_state.get_tag().unwrap_or(""); (&mut PopUp::new(tag)).render(area, &mut buf, &mut popup); let rendered: String = buf.content.iter().map(|c| c.symbol()).collect(); assert!( rendered.contains("⏸ Video · 00:00 / --:--"), "paused icon or unknown duration missing: {rendered}" ); } #[tokio::test] async fn video_popup_renders_error_note_in_bottom_border() { let mut popup = video_popup( video_message("clip"), Some(video_playback(PlayState::Error, 0.0, 0.0)), Some("no ffmpeg on PATH".into()), ); let area = Rect::new(0, 0, 80, 24); let mut buf = Buffer::empty(area); let tag = app_state().await.chat_state.get_tag().unwrap_or(""); (&mut PopUp::new(tag)).render(area, &mut buf, &mut popup); let rendered: String = buf.content.iter().map(|c| c.symbol()).collect(); assert!(rendered.contains("no ffmpeg on PATH"), "error note missing"); } #[tokio::test] async fn audio_popup_renders_error_note_in_bottom_border() { let mut popup = PopupState { focused: false, popup_type: PopupKind::Audio(AudioPopup { msg: audio_message(), error_note: Some("download failed: WA not found".into()), playback: Some(PlaybackState { source: PlayKey { chat: ChatId::Myself, message_id: MessageId::from("audio-1"), }, status: PlayState::Error, position: 0.0, duration: 0.0, updated_at: Instant::now(), }), }), prev_focus: Focus::Chat, scroll_idx: 0, }; let area = Rect::new(0, 0, 80, 24); let mut buf = Buffer::empty(area); let tag = app_state().await.chat_state.get_tag().unwrap_or(""); (&mut PopUp::new(tag)).render(area, &mut buf, &mut popup); let rendered: String = buf.content.iter().map(|c| c.symbol()).collect(); assert!( rendered.contains("download failed: WA not found"), "error note must appear in the popup" ); // A healthy popup renders no warning line. let mut popup = PopupState { popup_type: PopupKind::Audio(AudioPopup { msg: audio_message(), error_note: None, playback: None, }), prev_focus: Focus::Chat, scroll_idx: 0, focused: false, }; let area = Rect::new(0, 0, 80, 24); let mut buf = Buffer::empty(area); let tag = app_state().await.chat_state.get_tag().unwrap_or(""); (&mut PopUp::new(tag)).render(area, &mut buf, &mut popup); let rendered: String = buf.content.iter().map(|c| c.symbol()).collect(); assert!(!rendered.contains("⚠"), "no error note without a failure"); } /// Deterministic mic for push-to-talk tests: no hardware involved. struct FakeMic { pcm: Vec, rate: u32, level: f32, } impl MicCapture for FakeMic { fn duration(&self) -> std::time::Duration { std::time::Duration::from_secs_f64(self.pcm.len() as f64 / f64::from(self.rate)) } fn rms(&self) -> f32 { self.level } fn sample_rate(&self) -> u32 { self.rate } fn finish(&mut self) -> Vec { std::mem::take(&mut self.pcm) } } fn fake_mic(seconds: f32) -> FakeMic { let rate = audio::CAPTURE_RATE; let samples = (f64::from(rate) * f64::from(seconds)) as usize; let pcm = (0..samples) .map(|i| { let t = i as f64 / f64::from(rate); (t * 440.0 * std::f64::consts::TAU).sin() as f32 * 0.5 }) .collect(); FakeMic { pcm, rate, level: 0.42, } } #[tokio::test] async fn push_to_talk_starts_idle_and_cancel_discards() { let mut state = app_state().await; assert!(!state.recording_active()); assert_eq!(state.recording_seconds(), 0); assert_eq!(state.recording_rms(), 0.0); state.recording = Some(AudioRecording::new(Box::new(fake_mic(0.5)))); assert!(state.recording_active()); assert_eq!(state.recording_rms(), 0.42); assert_eq!(state.recording_seconds(), 0, "sub-second take"); state.cancel_recording(); assert!(!state.recording_active(), "cancel discards immediately"); assert_eq!(state.recording_seconds(), 0); } #[tokio::test] async fn record_key_fallback_requires_a_deliberate_press() { let mut state = app_state().await; let t0 = Instant::now(); // A fresh press with no take active is always allowed and remembered. assert!(state.record_key_pressed(t0)); // Start a take whose clock we control. state.recording = Some(AudioRecording { recorder: Box::new(fake_mic(2.0)), started_at: t0, }); // Auto-repeat stream while the key is held: every press is either // inside the initial repeat delay or too close to the previous one to // be deliberate. let t_early = t0 + Duration::from_millis(50); assert!( !state.record_key_pressed(t_early), "too early to be deliberate" ); let t_mid = t0 + Duration::from_millis(780); assert!( !state.record_key_pressed(t_mid), "still inside the hold floor" ); let t_repeat = t0 + Duration::from_millis(810); assert!( !state.record_key_pressed(t_repeat), "past the hold floor, but within repeat cadence of the previous press" ); assert!( state.recording_active(), "a rejected press must not end the take" ); // A deliberate repress well after the key was released is a send. let t_deliberate = t0 + Duration::from_millis(1_400); assert!(state.record_key_pressed(t_deliberate)); } #[tokio::test] async fn finish_recording_sends_a_voice_note_to_the_selected_chat() { let mock = MockMessenger::new("Telegram"); // Keep a receiver alive so the mock's send echo can broadcast back. let _keep_alive = mock.subscribe(); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(mock))], false, ) .await; fetch_and_apply(&mut state).await; state.chat_state.chat_list_state.select(Some(0)); let chat = state.chat_state.chats[0].clone(); state.chat_state.open_chat = Some(OpenChat { chat, history: Vec::new(), has_more_history: true, }); state.recording = Some(AudioRecording::new(Box::new(fake_mic(0.25)))); state.finish_recording().await; assert!(!state.recording_active()); assert!(state.retry_draft.is_none(), "send should have succeeded"); let echoed = state .chat_state .open_chat .as_ref() .and_then(|o| o.history.last()); let media = echoed .and_then(|m| m.media.as_ref()) .expect("echoed media present"); match &media.kind { MediaKind::Audio { is_voice, duration_secs, waveform, } => { assert!(is_voice, "voice note must carry the PTT flag"); assert_eq!(*duration_secs, Some(0), "0.25s rounds down to 0 sec"); assert_eq!( waveform.as_ref().map(Vec::len), Some(128), "waveform has one bucket per bar" ); } other => panic!("expected MediaKind::Audio, got {other:#?}"), } } #[tokio::test] async fn empty_take_is_dropped_without_sending() { let mut state = app_state().await; state.recording = Some(AudioRecording::new(Box::new(fake_mic(0.0)))); state.finish_recording().await; assert!(!state.recording_active()); assert!(state.retry_draft.is_none(), "empty take must not send"); assert!(state.pop_up.is_none(), "empty take is silent, not an error"); } #[tokio::test] async fn failed_voice_send_stashes_a_retry_draft() { let chat = Chat { id: ChatId::Telegram(1), contact_name: "Test".into(), ..Default::default() }; let stub = StubMessenger::new().with_chats(vec![chat]); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(stub))], false, ) .await; fetch_and_apply(&mut state).await; state.chat_state.chat_list_state.select(Some(0)); state.recording = Some(AudioRecording::new(Box::new(fake_mic(0.25)))); state.finish_recording().await; assert!(!state.recording_active()); let draft = state .retry_draft .as_ref() .expect("media send failure stashes a retry draft"); assert_eq!(draft.chat, ChatId::Telegram(1)); match &draft.msg { OutboundMessage::Media { kind: MediaKind::Audio { is_voice: true, .. }, data, file_name, caption, } => { assert_eq!(file_name, "voice.oga"); assert!(caption.is_none()); assert!(!data.is_empty(), "encoded take survives into the draft"); } other => panic!("expected OutboundMessage::Media audio, got {other:#?}"), } } #[tokio::test] async fn reply_recording_target_reads_confirmable_popup_messages() { let mut state = app_state().await; state.create_popup(PopupKind::Info("nope".into())); assert_eq!( state.reply_recording_target(), None, "popups without a message are not reply targets" ); let msg = audio_message(); state.dismiss_popup(); state.create_popup(PopupKind::Message(msg.clone())); assert_eq!(state.reply_recording_target(), Some(msg.message_id.clone())); state.dismiss_popup(); state.create_popup(PopupKind::Audio(AudioPopup { msg: msg.clone(), playback: None, error_note: None, })); assert_eq!(state.reply_recording_target(), Some(msg.message_id.clone())); state.dismiss_popup(); let (wake, _rx) = tokio::sync::mpsc::unbounded_channel(); state.create_popup(PopupKind::Image(ImagePopup { msg: msg.clone(), view: ImageWidgetState::new(wake), })); assert_eq!(state.reply_recording_target(), Some(msg.message_id.clone())); // Pending/failed sends are not replyable: the target must go quiet. state.dismiss_popup(); let mut pending = msg.clone(); pending.pending = true; state.create_popup(PopupKind::Message(pending)); assert_eq!(state.reply_recording_target(), None); state.dismiss_popup(); let mut failed = msg; failed.failed = true; state.create_popup(PopupKind::Audio(AudioPopup { msg: failed, playback: None, error_note: None, })); assert_eq!(state.reply_recording_target(), None); } #[tokio::test] async fn voice_reply_sends_with_reply_to() { let mock = MockMessenger::new("Telegram"); let _keep_alive = mock.subscribe(); let mut config = Config::default(); config.providers.telegram.enabled = true; let keymap = config.keys.parse().unwrap(); let mut state = AppState::new( config, keymap, vec![MessengerKind::Stub(Provider::Telegram, Box::new(mock))], false, ) .await; fetch_and_apply(&mut state).await; state.chat_state.chat_list_state.select(Some(0)); let chat = state.chat_state.chats[0].clone(); state.chat_state.open_chat = Some(OpenChat { chat, history: Vec::new(), has_more_history: true, }); // A voice reply: the popup's message was stashed as the reply target. let target = audio_message(); state.chat_state.pending_reply = Some(target.clone()); state.recording = Some(AudioRecording::new(Box::new(fake_mic(0.25)))); state.finish_recording().await; let echoed = state .chat_state .open_chat .as_ref() .and_then(|o| o.history.last()) .expect("send echo present"); assert_eq!( echoed.reply_to_id.as_ref(), Some(&target.message_id), "the voice note must quote the popup message" ); } #[tokio::test] async fn copies_text_to_clip() { let Some(clipboard) = Clipboard::new().ok() else { // Headless environments (e.g. CI runners) expose no clipboard // provider; there is nothing to verify here. return; }; let mut state = app_state().await; state.clipboard = Some(clipboard); let test = String::from("test"); state .copy_to_clipboard(test.clone()) .expect("copy to clipboard"); let res = state .clipboard .expect("Could not get clipboard") .get_text() .expect("Could not get_text"); assert_eq!(res, test); } }