Browse Source

feat: enhance avatar handling with caching and fetching logic, update web interface for profile pictures

softword 3 days ago
parent
commit
1662c3ea28
6 changed files with 292 additions and 27 deletions
  1. 21 11
      build.rs
  2. 105 8
      src/app.rs
  3. 23 5
      src/main.rs
  4. 2 0
      src/web.rs
  5. 86 2
      src/web_page.html
  6. 55 1
      src/whatsapp.rs

+ 21 - 11
build.rs

@@ -1,13 +1,19 @@
-//! Build script: embeds the short git commit hash of the build as `WH_GIT_HASH`.
+//! Build script: embeds the short git commit hash as `WH_GIT_HASH` and the commit count
+//! (build number) as `WH_BUILD_NUMBER`.
 
 
 use std::process::Command;
 use std::process::Command;
 
 
 /// Name of the compile-time environment variable holding the commit hash.
 /// Name of the compile-time environment variable holding the commit hash.
 const GIT_HASH_ENV: &str = "WH_GIT_HASH";
 const GIT_HASH_ENV: &str = "WH_GIT_HASH";
+/// Name of the compile-time environment variable holding the build number (commit count).
+const BUILD_NUMBER_ENV: &str = "WH_BUILD_NUMBER";
 /// Value used when the hash cannot be read (no git, not a repository).
 /// Value used when the hash cannot be read (no git, not a repository).
 const UNKNOWN_HASH: &str = "unknown";
 const UNKNOWN_HASH: &str = "unknown";
+/// Value used when the build number cannot be read (no git, not a repository).
+const UNKNOWN_BUILD_NUMBER: &str = "0";
 
 
-/// Purpose: export the short commit hash to the crate and rebuild when the commit changes.
+/// Purpose: export the short commit hash and the build number (commit count) to the crate
+/// and rebuild when the commit changes.
 /// Inputs: none (runs `git` in the package directory).
 /// Inputs: none (runs `git` in the package directory).
 /// Output: none.
 /// Output: none.
 /// Side effects: prints cargo directives on stdout.
 /// Side effects: prints cargo directives on stdout.
@@ -15,19 +21,23 @@ fn main() {
     println!("cargo:rerun-if-changed=.git/HEAD");
     println!("cargo:rerun-if-changed=.git/HEAD");
     println!("cargo:rerun-if-changed=.git/refs");
     println!("cargo:rerun-if-changed=.git/refs");
     println!("cargo:rerun-if-changed=.git/packed-refs");
     println!("cargo:rerun-if-changed=.git/packed-refs");
-    let hash = git_short_hash().unwrap_or_else(|| UNKNOWN_HASH.to_owned());
+    let hash =
+        git_output(&["rev-parse", "--short", "HEAD"]).unwrap_or_else(|| UNKNOWN_HASH.to_owned());
     println!("cargo:rustc-env={GIT_HASH_ENV}={hash}");
     println!("cargo:rustc-env={GIT_HASH_ENV}={hash}");
+    let build_number = git_output(&["rev-list", "--count", "HEAD"])
+        .unwrap_or_else(|| UNKNOWN_BUILD_NUMBER.to_owned());
+    println!("cargo:rustc-env={BUILD_NUMBER_ENV}={build_number}");
 }
 }
 
 
-/// Purpose: read the short hash of the checked-out commit.
-/// Inputs: none.
-/// Output: the hash, or `None` when git is missing, fails or prints nothing.
-/// Side effects: runs `git rev-parse --short HEAD`.
-fn git_short_hash() -> Option<String> {
-    let output = Command::new("git").args(["rev-parse", "--short", "HEAD"]).output().ok()?;
+/// Purpose: run `git` with the given arguments and return its trimmed output.
+/// Inputs: `args` - the arguments passed to `git`.
+/// Output: the trimmed stdout, or `None` when git is missing, fails or prints nothing.
+/// Side effects: runs the `git` command.
+fn git_output(args: &[&str]) -> Option<String> {
+    let output = Command::new("git").args(args).output().ok()?;
     if !output.status.success() {
     if !output.status.success() {
         return None;
         return None;
     }
     }
-    let hash = String::from_utf8(output.stdout).ok()?.trim().to_owned();
-    (!hash.is_empty()).then_some(hash)
+    let text = String::from_utf8(output.stdout).ok()?.trim().to_owned();
+    (!text.is_empty()).then_some(text)
 }
 }

+ 105 - 8
src/app.rs

@@ -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");

+ 23 - 5
src/main.rs

@@ -28,8 +28,18 @@ use crate::whatsapp::WaEvent;
 
 
 /// Name of the thread that reads terminal input events.
 /// Name of the thread that reads terminal input events.
 const INPUT_THREAD_NAME: &str = "wh-input";
 const INPUT_THREAD_NAME: &str = "wh-input";
-/// Build version: package version and short git commit hash (see `build.rs`).
-pub const BUILD_VERSION: &str = concat!(env!("CARGO_PKG_VERSION"), " (", env!("WH_GIT_HASH"), ")");
+/// Build version: package major.minor, build number (git commit count) and short commit hash
+/// (see `build.rs`).
+pub const BUILD_VERSION: &str = concat!(
+    env!("CARGO_PKG_VERSION_MAJOR"),
+    ".",
+    env!("CARGO_PKG_VERSION_MINOR"),
+    ".",
+    env!("WH_BUILD_NUMBER"),
+    " (",
+    env!("WH_GIT_HASH"),
+    ")"
+);
 /// Command-line flags that print the build version and exit.
 /// Command-line flags that print the build version and exit.
 const VERSION_FLAGS: [&str; 2] = ["--version", "-V"];
 const VERSION_FLAGS: [&str; 2] = ["--version", "-V"];
 
 
@@ -223,8 +233,16 @@ mod tests {
     }
     }
 
 
     #[test]
     #[test]
-    fn build_version_starts_with_package_version_and_names_a_commit() {
-        assert!(BUILD_VERSION.starts_with(env!("CARGO_PKG_VERSION")));
-        assert!(BUILD_VERSION.ends_with(')') && BUILD_VERSION.contains(" ("));
+    fn build_version_has_major_minor_build_number_and_commit() {
+        let prefix = concat!(
+            env!("CARGO_PKG_VERSION_MAJOR"),
+            ".",
+            env!("CARGO_PKG_VERSION_MINOR"),
+            "."
+        );
+        let rest = BUILD_VERSION.strip_prefix(prefix).expect("major.minor prefix");
+        let (build_number, _) = rest.split_once(" (").expect("commit hash in parentheses");
+        assert!(!build_number.is_empty() && build_number.chars().all(|c| c.is_ascii_digit()));
+        assert!(BUILD_VERSION.ends_with(')'));
     }
     }
 }
 }

+ 2 - 0
src/web.rs

@@ -118,6 +118,8 @@ pub struct WebChat {
     pub name: String,
     pub name: String,
     /// Whether the autoreply is enabled for this chat.
     /// Whether the autoreply is enabled for this chat.
     pub autoreply: bool,
     pub autoreply: bool,
+    /// URL of the contact's profile picture preview (WhatsApp CDN), or `None` when it is not known yet or there is none.
+    pub avatar_url: Option<String>,
 }
 }
 
 
 /// Application state returned to the web panel.
 /// Application state returned to the web panel.

+ 86 - 2
src/web_page.html

@@ -178,8 +178,42 @@ body {
   transition: background var(--transition);
   transition: background var(--transition);
 }
 }
 
 
+.row__label {
+  grid-template-columns: auto minmax(0, 1fr) auto;
+  column-gap: 12px;
+}
+
 .row__label:hover { background: var(--surface-hover); }
 .row__label:hover { background: var(--surface-hover); }
 
 
+/* Round avatar: profile picture, or the initial of the name on a grey circle */
+.avatar {
+  position: relative;
+  display: flex;
+  flex: none;
+  align-items: center;
+  justify-content: center;
+  width: 40px;
+  height: 40px;
+  border-radius: 50%;
+  overflow: hidden;
+  background: var(--track-off);
+  color: var(--text);
+  font-size: 1.125rem;
+  font-weight: 600;
+  line-height: 1;
+  user-select: none;
+}
+
+.avatar__img {
+  position: absolute;
+  inset: 0;
+  width: 100%;
+  height: 100%;
+  object-fit: cover;
+}
+
+.avatar__img[hidden] { display: none; }
+
 .row__name {
 .row__name {
   min-width: 0;
   min-width: 0;
   overflow: hidden;
   overflow: hidden;
@@ -507,7 +541,31 @@ function handleFailure(error) {
 }
 }
 
 
 /**
 /**
- * Builds a chat row (list item with label, name and switch) for a jid.
+ * Builds the round avatar: an initial placeholder and a hidden picture shown only once it has a URL.
+ * Output: the avatar element, with its initial and image elements.
+ * Side effect: a failed image load (e.g. expired CDN URL) hides the image so the initial shows.
+ */
+function createAvatar() {
+  const avatar = document.createElement("span");
+  avatar.className = "avatar";
+  const initial = document.createElement("span");
+  initial.className = "avatar__initial";
+  initial.setAttribute("aria-hidden", "true");
+  const image = document.createElement("img");
+  image.className = "avatar__img";
+  image.alt = "";
+  image.referrerPolicy = "no-referrer";
+  image.loading = "lazy";
+  image.decoding = "async";
+  image.hidden = true;
+  image.addEventListener("error", () => { image.hidden = true; });
+
+  avatar.append(initial, image);
+  return { avatar, initial, image };
+}
+
+/**
+ * Builds a chat row (list item with label, avatar, name and switch) for a jid.
  * Output: the li element, with its parts exposed as properties.
  * Output: the li element, with its parts exposed as properties.
  */
  */
 function createRow(jid) {
 function createRow(jid) {
@@ -516,6 +574,7 @@ function createRow(jid) {
 
 
   const label = document.createElement("label");
   const label = document.createElement("label");
   label.className = "row__label";
   label.className = "row__label";
+  const { avatar, initial, image } = createAvatar();
   const name = document.createElement("span");
   const name = document.createElement("span");
   name.className = "row__name";
   name.className = "row__name";
 
 
@@ -531,15 +590,39 @@ function createRow(jid) {
 
 
   track.append(knob);
   track.append(knob);
   switchWrap.append(input, track);
   switchWrap.append(input, track);
-  label.append(name, switchWrap);
+  label.append(avatar, name, switchWrap);
   item.append(label);
   item.append(label);
 
 
   input.addEventListener("change", () => toggleChat(jid));
   input.addEventListener("change", () => toggleChat(jid));
   item.nameElement = name;
   item.nameElement = name;
   item.inputElement = input;
   item.inputElement = input;
+  item.avatarInitialElement = initial;
+  item.avatarImageElement = image;
+  item.avatarUrl = "";
   return item;
   return item;
 }
 }
 
 
+/**
+ * Updates the avatar of a row: the initial on every call, the picture only when its URL changes.
+ * Input: the row, the display name and the avatar URL (string, null or undefined).
+ * Side effect: sets or removes the image src and toggles its visibility; an unchanged URL is left alone
+ * so polling never reloads the picture.
+ */
+function updateAvatar(row, displayName, avatarUrl) {
+  row.avatarInitialElement.textContent = (Array.from(displayName)[0] ?? "").toUpperCase();
+
+  const url = typeof avatarUrl === "string" ? avatarUrl : "";
+  if (url === row.avatarUrl) return;
+
+  row.avatarUrl = url;
+  if (url) {
+    row.avatarImageElement.src = url;
+  } else {
+    row.avatarImageElement.removeAttribute("src");
+  }
+  row.avatarImageElement.hidden = !url;
+}
+
 /**
 /**
  * Updates a row from a chat entry using textContent only.
  * Updates a row from a chat entry using textContent only.
  * A pending switch keeps the position given by the user: only the server reply may change it.
  * A pending switch keeps the position given by the user: only the server reply may change it.
@@ -549,6 +632,7 @@ function updateRow(row, chat) {
   const isPending = pendingKeys.has(chat.jid);
   const isPending = pendingKeys.has(chat.jid);
   row.nameElement.textContent = displayName;
   row.nameElement.textContent = displayName;
   row.nameElement.title = displayName;
   row.nameElement.title = displayName;
+  updateAvatar(row, displayName, chat.avatar_url);
   if (!isPending) row.inputElement.checked = Boolean(chat.autoreply);
   if (!isPending) row.inputElement.checked = Boolean(chat.autoreply);
   row.inputElement.disabled = isPending;
   row.inputElement.disabled = isPending;
 }
 }

+ 55 - 1
src/whatsapp.rs

@@ -28,6 +28,7 @@ use anyhow::{Context, Result, anyhow, bail};
 use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
 use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
 use tokio::sync::watch;
 use tokio::sync::watch;
 use tokio::task::AbortHandle;
 use tokio::task::AbortHandle;
+use whatsapp_rust::PresenceError;
 use whatsapp_rust::prelude::*;
 use whatsapp_rust::prelude::*;
 use whatsapp_rust::types::events::{
 use whatsapp_rust::types::events::{
     ConnectFailure, ContactUpdate, LazyHistorySync, MessageBatch, PushNameUpdate,
     ConnectFailure, ContactUpdate, LazyHistorySync, MessageBatch, PushNameUpdate,
@@ -72,10 +73,15 @@ const SUBSCRIBED_KINDS: &[EventKind] = &[
     EventKind::HistorySync,
     EventKind::HistorySync,
     EventKind::ContactUpdate,
     EventKind::ContactUpdate,
     EventKind::PushNameUpdate,
     EventKind::PushNameUpdate,
+    EventKind::SelfPushNameUpdated,
     EventKind::AppStateSyncFailed,
     EventKind::AppStateSyncFailed,
     EventKind::OfflineSyncCompleted,
     EventKind::OfflineSyncCompleted,
 ];
 ];
 
 
+/// Seconds to wait after the library announces the account as available before
+/// sending the "unavailable" presence that restores phone notifications.
+const PRESENCE_SETTLE_SECONDS: u64 = 5;
+
 /// JID prefix of the status-broadcast pseudo chat, skipped in history.
 /// JID prefix of the status-broadcast pseudo chat, skipped in history.
 const STATUS_BROADCAST_PREFIX: &str = "status@broadcast";
 const STATUS_BROADCAST_PREFIX: &str = "status@broadcast";
 
 
@@ -329,6 +335,26 @@ impl WaHandle {
         Ok(DownloadedAudio { bytes, mime })
         Ok(DownloadedAudio { bytes, mime })
     }
     }
 
 
+    /// Fetches the URL of a chat's profile picture (small preview).
+    ///
+    /// Inputs: `chat_jid` is a JID in `user@server` form (private chat or group).
+    /// Output: `Some(url)` with the WhatsApp CDN URL of the preview picture, `None` when the
+    /// chat has no picture or hides it (privacy); an error for an invalid JID, while no session
+    /// is running, or on a network failure.
+    /// Side effects: one IQ request to the WhatsApp server.
+    pub async fn profile_picture_url(&self, chat_jid: &str) -> Result<Option<String>> {
+        let jid: Jid = chat_jid
+            .parse()
+            .map_err(|e| anyhow!("invalid JID '{chat_jid}': {e}"))?;
+        let picture = self
+            .client()?
+            .contacts()
+            .get_profile_picture(&jid, true)
+            .await
+            .with_context(|| format!("failed to fetch the profile picture of {chat_jid}"))?;
+        Ok(picture.map(|p| p.url))
+    }
+
     /// Lists this account's own JIDs.
     /// Lists this account's own JIDs.
     ///
     ///
     /// Inputs: none (reads the client's device identity).
     /// Inputs: none (reads the client's device identity).
@@ -645,7 +671,11 @@ impl Translator {
                 self.report("pairing QR codes exhausted; request a new QR to pair again".to_owned());
                 self.report("pairing QR codes exhausted; request a new QR to pair again".to_owned());
             }
             }
             Event::PairError(e) => self.report(format!("pairing failed: {}", e.error)),
             Event::PairError(e) => self.report(format!("pairing failed: {}", e.error)),
-            Event::Connected(_) => self.emit(WaEvent::Connected),
+            Event::Connected(_) => {
+                self.emit(WaEvent::Connected);
+                self.schedule_offline_presence();
+            }
+            Event::SelfPushNameUpdated(_) => self.schedule_offline_presence(),
             Event::Disconnected(d) => self.emit(WaEvent::Disconnected(d.reason.to_string())),
             Event::Disconnected(d) => self.emit(WaEvent::Disconnected(d.reason.to_string())),
             Event::StreamReplaced(_) => {
             Event::StreamReplaced(_) => {
                 self.emit(WaEvent::Disconnected(STREAM_REPLACED_REASON.to_owned()));
                 self.emit(WaEvent::Disconnected(STREAM_REPLACED_REASON.to_owned()));
@@ -686,6 +716,30 @@ impl Translator {
         }
         }
     }
     }
 
 
+    /// Schedules one "unavailable" presence so the phone gets its notifications back.
+    ///
+    /// The library marks the account available on every (re)connect and push-name
+    /// update, which suppresses push notifications on the phone.
+    /// Inputs: none. Output: none.
+    /// Side effects: spawns a task that sleeps, then sends the presence only if the
+    /// client is still connected; failures are reported as [`WaEvent::Error`].
+    fn schedule_offline_presence(self: &Arc<Self>) {
+        let translator = Arc::clone(self);
+        tokio::spawn(async move {
+            tokio::time::sleep(Duration::from_secs(PRESENCE_SETTLE_SECONDS)).await;
+            if !translator.client.is_connected() {
+                return;
+            }
+            match translator.client.presence().set_unavailable().await {
+                Ok(()) => {}
+                // First pairing: the push name is not known yet, and the later
+                // SelfPushNameUpdated event schedules the presence again.
+                Err(PresenceError::PushNameEmpty) => {}
+                Err(e) => translator.report(format!("failed to set unavailable presence: {e}")),
+            }
+        });
+    }
+
     /// Translates a batch of decrypted inbound messages.
     /// Translates a batch of decrypted inbound messages.
     ///
     ///
     /// Inputs: the batch. Output: none. Side effects: emits message/contact events.
     /// Inputs: the batch. Output: none. Side effects: emits message/contact events.