|
@@ -1,7 +1,7 @@
|
|
|
//! Application state, key handling and routing of events between UI, WhatsApp, history and AI.
|
|
//! Application state, key handling and routing of events between UI, WhatsApp, history and AI.
|
|
|
|
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::collections::{HashMap, HashSet};
|
|
|
-use std::time::Duration;
|
|
|
|
|
|
|
+use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
use anyhow::Result;
|
|
use anyhow::Result;
|
|
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
|
@@ -113,6 +113,12 @@ const STATUS_CONFIG_RELOADED: &str = "Configurazione ricaricata";
|
|
|
const STATUS_CONFIG_RELOAD_FAILED_PREFIX: &str = "Configurazione non ricaricata";
|
|
const STATUS_CONFIG_RELOAD_FAILED_PREFIX: &str = "Configurazione non ricaricata";
|
|
|
/// Status shown when the web control panel toggles a chat that is not in its list.
|
|
/// Status shown when the web control panel toggles a chat that is not in its list.
|
|
|
const STATUS_WEB_CHAT_UNKNOWN: &str = "Chat non trovata";
|
|
const STATUS_WEB_CHAT_UNKNOWN: &str = "Chat non trovata";
|
|
|
|
|
+/// Seconds a cached avatar stays valid; also the delay before retrying after a failure.
|
|
|
|
|
+const AVATAR_TTL_SECONDS: u64 = 86_400;
|
|
|
|
|
+/// Seconds to wait between two profile picture requests, to avoid WhatsApp rate limits.
|
|
|
|
|
+const AVATAR_FETCH_INTERVAL_SECONDS: u64 = 2;
|
|
|
|
|
+/// Prefix of the status shown when the avatar fetch is interrupted by an error.
|
|
|
|
|
+const STATUS_AVATAR_FETCH_FAILED_PREFIX: &str = "Foto profilo non caricate";
|
|
|
|
|
|
|
|
/// Event delivered to `App::handle_event` by the main loop and by background tasks.
|
|
/// Event delivered to `App::handle_event` by the main loop and by background tasks.
|
|
|
#[derive(Debug)]
|
|
#[derive(Debug)]
|
|
@@ -158,6 +164,19 @@ pub enum AppEvent {
|
|
|
ConfigReloaded(Result<Box<Config>, String>),
|
|
ConfigReloaded(Result<Box<Config>, String>),
|
|
|
/// Outcome of a relink: the new unpaired session is started, or the error.
|
|
/// Outcome of a relink: the new unpaired session is started, or the error.
|
|
|
Relinked(Result<(), String>),
|
|
Relinked(Result<(), String>),
|
|
|
|
|
+ /// The profile picture URL of `jid` was fetched (`None`: no picture, hidden or fetch failed).
|
|
|
|
|
+ AvatarFetched { jid: String, url: Option<String> },
|
|
|
|
|
+ /// The avatar fetch batch ended: completed (`None`) or interrupted by the error text.
|
|
|
|
|
+ AvatarFetchFinished { error: Option<String> },
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/// Profile picture URL of a chat with the moment it was fetched.
|
|
|
|
|
+#[derive(Debug)]
|
|
|
|
|
+struct CachedAvatar {
|
|
|
|
|
+ /// Preview picture URL; `None` when the chat has no picture, hides it or the fetch failed.
|
|
|
|
|
+ url: Option<String>,
|
|
|
|
|
+ /// When the entry was stored, to expire it after `AVATAR_TTL_SECONDS`.
|
|
|
|
|
+ fetched_at: Instant,
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// The chat whose images are being described, and what to do once they are.
|
|
/// The chat whose images are being described, and what to do once they are.
|
|
@@ -262,6 +281,12 @@ pub struct App {
|
|
|
pending_relink: bool,
|
|
pending_relink: bool,
|
|
|
/// Whether a relink is running (further requests are ignored until it ends).
|
|
/// Whether a relink is running (further requests are ignored until it ends).
|
|
|
relinking: bool,
|
|
relinking: bool,
|
|
|
|
|
+ /// Profile pictures fetched for the web panel, keyed by chat JID.
|
|
|
|
|
+ avatars: HashMap<String, CachedAvatar>,
|
|
|
|
|
+ /// Whether an avatar fetch task is running (at most one at a time).
|
|
|
|
|
+ avatar_fetch_running: bool,
|
|
|
|
|
+ /// Whether the WhatsApp client is currently connected; gates the avatar fetch.
|
|
|
|
|
+ wa_connected: bool,
|
|
|
wa: WaHandle,
|
|
wa: WaHandle,
|
|
|
events: UnboundedSender<AppEvent>,
|
|
events: UnboundedSender<AppEvent>,
|
|
|
}
|
|
}
|
|
@@ -311,6 +336,9 @@ impl App {
|
|
|
pending_delete: None,
|
|
pending_delete: None,
|
|
|
pending_relink: false,
|
|
pending_relink: false,
|
|
|
relinking: false,
|
|
relinking: false,
|
|
|
|
|
+ avatars: HashMap::new(),
|
|
|
|
|
+ avatar_fetch_running: false,
|
|
|
|
|
+ wa_connected: false,
|
|
|
wa,
|
|
wa,
|
|
|
events,
|
|
events,
|
|
|
})
|
|
})
|
|
@@ -343,6 +371,17 @@ impl App {
|
|
|
self.apply_reloaded_config(result);
|
|
self.apply_reloaded_config(result);
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
|
|
|
+ AppEvent::AvatarFetched { jid, url } => {
|
|
|
|
|
+ self.avatars.insert(jid, CachedAvatar { url, fetched_at: Instant::now() });
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ }
|
|
|
|
|
+ AppEvent::AvatarFetchFinished { error } => {
|
|
|
|
|
+ self.avatar_fetch_running = false;
|
|
|
|
|
+ if let Some(error) = error {
|
|
|
|
|
+ self.status = format!("{STATUS_AVATAR_FETCH_FAILED_PREFIX}: {error}");
|
|
|
|
|
+ }
|
|
|
|
|
+ Ok(())
|
|
|
|
|
+ }
|
|
|
AppEvent::Relinked(result) => {
|
|
AppEvent::Relinked(result) => {
|
|
|
self.finish_relink(result);
|
|
self.finish_relink(result);
|
|
|
Ok(())
|
|
Ok(())
|
|
@@ -363,6 +402,7 @@ impl App {
|
|
|
return;
|
|
return;
|
|
|
}
|
|
}
|
|
|
self.relinking = true;
|
|
self.relinking = true;
|
|
|
|
|
+ self.wa_connected = false;
|
|
|
self.screen = Screen::Connecting;
|
|
self.screen = Screen::Connecting;
|
|
|
self.qr_code = None;
|
|
self.qr_code = None;
|
|
|
self.status = STATUS_RELINKING.to_owned();
|
|
self.status = STATUS_RELINKING.to_owned();
|
|
@@ -444,13 +484,19 @@ impl App {
|
|
|
self.qr_code = Some(code);
|
|
self.qr_code = Some(code);
|
|
|
}
|
|
}
|
|
|
WaEvent::Connected => {
|
|
WaEvent::Connected => {
|
|
|
|
|
+ self.wa_connected = true;
|
|
|
self.screen = Screen::Main;
|
|
self.screen = Screen::Main;
|
|
|
self.qr_code = None;
|
|
self.qr_code = None;
|
|
|
self.status = STATUS_CONNECTED.to_owned();
|
|
self.status = STATUS_CONNECTED.to_owned();
|
|
|
self.refresh_own_jids();
|
|
self.refresh_own_jids();
|
|
|
}
|
|
}
|
|
|
- WaEvent::Disconnected(text) | WaEvent::Error(text) => self.status = text,
|
|
|
|
|
|
|
+ WaEvent::Disconnected(text) => {
|
|
|
|
|
+ self.wa_connected = false;
|
|
|
|
|
+ self.status = text;
|
|
|
|
|
+ }
|
|
|
|
|
+ WaEvent::Error(text) => self.status = text,
|
|
|
WaEvent::LoggedOut => {
|
|
WaEvent::LoggedOut => {
|
|
|
|
|
+ self.wa_connected = false;
|
|
|
self.screen = Screen::Connecting;
|
|
self.screen = Screen::Connecting;
|
|
|
self.qr_code = None;
|
|
self.qr_code = None;
|
|
|
self.status = STATUS_LOGGED_OUT.to_owned();
|
|
self.status = STATUS_LOGGED_OUT.to_owned();
|
|
@@ -1390,19 +1436,44 @@ impl App {
|
|
|
WebCommand::Relink => self.start_relink(),
|
|
WebCommand::Relink => self.start_relink(),
|
|
|
WebCommand::Quit => self.should_quit = true,
|
|
WebCommand::Quit => self.should_quit = true,
|
|
|
}
|
|
}
|
|
|
|
|
+ self.maybe_start_avatar_fetch();
|
|
|
|
|
|
|
|
// The receiver is gone when the HTTP client disconnected or the app is quitting.
|
|
// The receiver is gone when the HTTP client disconnected or the app is quitting.
|
|
|
let _ = request.reply.send(self.web_state());
|
|
let _ = request.reply.send(self.web_state());
|
|
|
Ok(())
|
|
Ok(())
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /// Purpose: start the background fetch of the missing or expired avatars of the web chats.
|
|
|
|
|
+ /// Inputs: none.
|
|
|
|
|
+ /// Output: none.
|
|
|
|
|
+ /// Side effects: does nothing unless the main screen is shown, WhatsApp is connected and no
|
|
|
|
|
+ /// fetch is running; when some avatar is missing or older than `AVATAR_TTL_SECONDS`, marks
|
|
|
|
|
+ /// the fetch as running and spawns `fetch_avatars`, which sends `AvatarFetched` and
|
|
|
|
|
+ /// `AvatarFetchFinished` events.
|
|
|
|
|
+ fn maybe_start_avatar_fetch(&mut self) {
|
|
|
|
|
+ if self.screen != Screen::Main || !self.wa_connected || self.avatar_fetch_running {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ let ttl = Duration::from_secs(AVATAR_TTL_SECONDS);
|
|
|
|
|
+ let jids: Vec<String> = web_chats(&self.chats, &self.avatars)
|
|
|
|
|
+ .into_iter()
|
|
|
|
|
+ .map(|chat| chat.jid)
|
|
|
|
|
+ .filter(|jid| self.avatars.get(jid).is_none_or(|avatar| avatar.fetched_at.elapsed() >= ttl))
|
|
|
|
|
+ .collect();
|
|
|
|
|
+ if jids.is_empty() {
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ self.avatar_fetch_running = true;
|
|
|
|
|
+ tokio::spawn(fetch_avatars(self.wa.clone(), self.events.clone(), jids));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
/// Purpose: toggle the autoreply of a chat requested by the web control panel.
|
|
/// Purpose: toggle the autoreply of a chat requested by the web control panel.
|
|
|
/// Inputs: `jid`, the chat identifier sent by the web client.
|
|
/// Inputs: `jid`, the chat identifier sent by the web client.
|
|
|
/// Output: `Ok(())`, or a history error.
|
|
/// Output: `Ok(())`, or a history error.
|
|
|
/// Side effects: as `toggle_chat_autoreply`; sets `status` to `STATUS_WEB_CHAT_UNKNOWN`
|
|
/// Side effects: as `toggle_chat_autoreply`; sets `status` to `STATUS_WEB_CHAT_UNKNOWN`
|
|
|
/// when `jid` is not among the chats listed by the web panel.
|
|
/// when `jid` is not among the chats listed by the web panel.
|
|
|
fn toggle_web_chat(&mut self, jid: &str) -> Result<()> {
|
|
fn toggle_web_chat(&mut self, jid: &str) -> Result<()> {
|
|
|
- if !web_chats(&self.chats).iter().any(|chat| chat.jid == jid) {
|
|
|
|
|
|
|
+ if !web_chats(&self.chats, &self.avatars).iter().any(|chat| chat.jid == jid) {
|
|
|
self.status = STATUS_WEB_CHAT_UNKNOWN.to_owned();
|
|
self.status = STATUS_WEB_CHAT_UNKNOWN.to_owned();
|
|
|
return Ok(());
|
|
return Ok(());
|
|
|
}
|
|
}
|
|
@@ -1417,7 +1488,7 @@ impl App {
|
|
|
fn web_state(&self) -> WebState {
|
|
fn web_state(&self) -> WebState {
|
|
|
WebState {
|
|
WebState {
|
|
|
global_autoreply: self.global_autoreply,
|
|
global_autoreply: self.global_autoreply,
|
|
|
- chats: web_chats(&self.chats),
|
|
|
|
|
|
|
+ chats: web_chats(&self.chats, &self.avatars),
|
|
|
status: self.status.clone(),
|
|
status: self.status.clone(),
|
|
|
screen: web_screen(self.screen),
|
|
screen: web_screen(self.screen),
|
|
|
qr_svg: self.qr_code.as_deref().and_then(web::qr_svg),
|
|
qr_svg: self.qr_code.as_deref().and_then(web::qr_svg),
|
|
@@ -1914,10 +1985,11 @@ fn web_screen(screen: Screen) -> &'static str {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/// Purpose: build the chat list exposed by the web control panel.
|
|
/// Purpose: build the chat list exposed by the web control panel.
|
|
|
-/// Inputs: `chats`, the chats shown by the TUI.
|
|
|
|
|
-/// Output: the chats whose autoreply can be toggled (private chats and groups), in the same order.
|
|
|
|
|
|
|
+/// Inputs: `chats`, the chats shown by the TUI; `avatars`, the cached profile pictures by chat JID.
|
|
|
|
|
+/// Output: the chats whose autoreply can be toggled (private chats and groups), in the same order,
|
|
|
|
|
+/// each with its cached avatar URL (`None` when not fetched yet or without picture).
|
|
|
/// Side effects: none.
|
|
/// Side effects: none.
|
|
|
-fn web_chats(chats: &[ChatSummary]) -> Vec<WebChat> {
|
|
|
|
|
|
|
+fn web_chats(chats: &[ChatSummary], avatars: &HashMap<String, CachedAvatar>) -> Vec<WebChat> {
|
|
|
chats
|
|
chats
|
|
|
.iter()
|
|
.iter()
|
|
|
.filter(|chat| supports_autoreply(&chat.jid))
|
|
.filter(|chat| supports_autoreply(&chat.jid))
|
|
@@ -1925,10 +1997,35 @@ fn web_chats(chats: &[ChatSummary]) -> Vec<WebChat> {
|
|
|
jid: chat.jid.clone(),
|
|
jid: chat.jid.clone(),
|
|
|
name: chat.name.clone(),
|
|
name: chat.name.clone(),
|
|
|
autoreply: chat.autoreply,
|
|
autoreply: chat.autoreply,
|
|
|
|
|
+ avatar_url: avatars.get(&chat.jid).and_then(|avatar| avatar.url.clone()),
|
|
|
})
|
|
})
|
|
|
.collect()
|
|
.collect()
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/// Purpose: fetch the profile pictures of the given chats slowly, to avoid WhatsApp rate limits.
|
|
|
|
|
+/// Inputs: `wa`, the WhatsApp handle; `events`, the app event sender; `jids`, the chats to fetch.
|
|
|
|
|
+/// Output: none.
|
|
|
|
|
+/// Side effects: sleeps `AVATAR_FETCH_INTERVAL_SECONDS` before every request but the first and
|
|
|
|
|
+/// sends `AppEvent::AvatarFetched` per chat; stops at the first error (that chat is cached
|
|
|
|
|
+/// without picture so it is not retried before the TTL) and always ends with
|
|
|
|
|
+/// `AppEvent::AvatarFetchFinished`, carrying the error text if the batch was interrupted.
|
|
|
|
|
+async fn fetch_avatars(wa: WaHandle, events: UnboundedSender<AppEvent>, jids: Vec<String>) {
|
|
|
|
|
+ for (index, jid) in jids.into_iter().enumerate() {
|
|
|
|
|
+ if index > 0 {
|
|
|
|
|
+ tokio::time::sleep(Duration::from_secs(AVATAR_FETCH_INTERVAL_SECONDS)).await;
|
|
|
|
|
+ }
|
|
|
|
|
+ match wa.profile_picture_url(&jid).await {
|
|
|
|
|
+ Ok(url) => send_event(&events, AppEvent::AvatarFetched { jid, url }),
|
|
|
|
|
+ Err(err) => {
|
|
|
|
|
+ send_event(&events, AppEvent::AvatarFetched { jid, url: None });
|
|
|
|
|
+ send_event(&events, AppEvent::AvatarFetchFinished { error: Some(format!("{err:#}")) });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ send_event(&events, AppEvent::AvatarFetchFinished { error: None });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/// Purpose: tell whether the modifiers denote a Ctrl command chord rather than AltGr.
|
|
/// Purpose: tell whether the modifiers denote a Ctrl command chord rather than AltGr.
|
|
|
/// Inputs: `modifiers`, the modifiers of a key event.
|
|
/// Inputs: `modifiers`, the modifiers of a key event.
|
|
|
/// Output: `true` when Control is held and Alt is not; `false` otherwise.
|
|
/// Output: `true` when Control is held and Alt is not; `false` otherwise.
|
|
@@ -1980,7 +2077,7 @@ mod tests {
|
|
|
chat_summary("120363000000@g.us", "Famiglia", false),
|
|
chat_summary("120363000000@g.us", "Famiglia", false),
|
|
|
chat_summary("120363111111111111@newsletter", "Notizie", false),
|
|
chat_summary("120363111111111111@newsletter", "Notizie", false),
|
|
|
];
|
|
];
|
|
|
- let web = web_chats(&chats);
|
|
|
|
|
|
|
+ let web = web_chats(&chats, &HashMap::new());
|
|
|
let jids: Vec<&str> = web.iter().map(|chat| chat.jid.as_str()).collect();
|
|
let jids: Vec<&str> = web.iter().map(|chat| chat.jid.as_str()).collect();
|
|
|
assert_eq!(jids, ["393331234567@s.whatsapp.net", "120363000000@g.us"]);
|
|
assert_eq!(jids, ["393331234567@s.whatsapp.net", "120363000000@g.us"]);
|
|
|
assert_eq!(web[0].name, "Mario");
|
|
assert_eq!(web[0].name, "Mario");
|