From: Stoiko Ivanov <s.ivanov@proxmox.com>
To: Dominik Csapak <d.csapak@proxmox.com>
Cc: pmg-devel@lists.proxmox.com
Subject: Re: [PATCH pmg-yew-quarantine-gui v2] mail view: show mark seen/unseen depending on mail state
Date: Fri, 19 Jun 2026 11:45:26 +0200 [thread overview]
Message-ID: <20260619114526.503074a2@rosa.proxmox.com> (raw)
In-Reply-To: <20260619093152.1440414-1-d.csapak@proxmox.com>
Thanks for the quick iteration!
verified the change and gave it another spin - still works as advertised:
Reviewed-by: Stoiko Ivanov <s.ivanov@proxmox.com>
Tested-by: Stoiko Ivanov <s.ivanov@proxmox.com>
On Fri, 19 Jun 2026 11:31:22 +0200
Dominik Csapak <d.csapak@proxmox.com> wrote:
> instead of always trying to show both. The content info was already
> loaded for the 'load images' checkbox, so reuse that to also fetch the
> seen status and show the actions depending on that.
>
> This has to be repeated whenever the seen state possibly has changed.
>
> While at it, refactor the deserialize_flexible_bool helper, and include
> the variants that were used for the 'external_images' flag.
>
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
> changes from v1:
> * use to_ascii_lowercase instead of wrong uppercase.
>
> src/main.rs | 15 ++++++
> src/page_mail_view.rs | 113 +++++++++++++++++++++++++++---------------
> src/spam_list.rs | 14 +-----
> 3 files changed, 88 insertions(+), 54 deletions(-)
>
> diff --git a/src/main.rs b/src/main.rs
> index 5eacd4d..5414e33 100644
> --- a/src/main.rs
> +++ b/src/main.rs
> @@ -243,3 +243,18 @@ fn main() {
>
> yew::Renderer::<PmgQuarantineApp>::new().render();
> }
> +
> +// deserialization helper for boolean values that can be 1/0/yes/on/off/etc.
> +pub(crate) fn deserialize_flexible_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
> +where
> + D: serde::Deserializer<'de>,
> +{
> + Ok(match Value::deserialize(deserializer)? {
> + Value::Bool(b) => b,
> + Value::Number(n) => n.as_i64().is_some_and(|v| v != 0),
> + Value::String(s) => {
> + matches!(s.to_ascii_lowercase().as_str(), "1" | "on" | "yes" | "true")
> + }
> + _ => false,
> + })
> +}
> diff --git a/src/page_mail_view.rs b/src/page_mail_view.rs
> index bdc5ffa..8f017b2 100644
> --- a/src/page_mail_view.rs
> +++ b/src/page_mail_view.rs
> @@ -1,35 +1,39 @@
> use std::rc::Rc;
>
> use anyhow::Error;
> +use serde::Deserialize;
> use serde_json::{json, Value};
>
> use yew::virtual_dom::{VComp, VNode};
>
> use proxmox_yew_comp::http_get;
> use pwt::dom::get_system_prefer_dark_mode;
> -use pwt::prelude::*;
> use pwt::state::{SharedState, Theme, ThemeObserver};
> use pwt::touch::{ApplicationBar, FabMenu, FabMenuEntry, Scaffold, SnackBar, SnackBarContextExt};
> use pwt::widget::form::Checkbox;
> use pwt::widget::{get_unique_element_id, FieldLabel, Row};
> +use pwt::{prelude::*, AsyncPool};
>
> -use crate::{mail_action, MailAction, QuarantineReload};
> -
> -// whether the mail has external images the on-demand mode blocks, so the
> -// "Load images" toggle is only offered when it would actually fetch something
> -async fn mail_has_external_images(id: &str) -> bool {
> - match http_get::<Value>("/quarantine/content", Some(json!({ "id": id }))).await {
> - // a boolean can arrive as a JSON bool, number or one of the strings the
> - // PVE::JSONSchema boolean type accepts (1/on/yes/true vs 0/off/no/false)
> - Ok(data) => match &data["external_images"] {
> - Value::Bool(b) => *b,
> - Value::Number(n) => n.as_i64().unwrap_or(0) != 0,
> - Value::String(s) => {
> - matches!(s.to_ascii_lowercase().as_str(), "1" | "on" | "yes" | "true")
> - }
> - _ => false,
> - },
> - Err(_) => false,
> +use crate::{deserialize_flexible_bool, mail_action, MailAction, QuarantineReload};
> +
> +/// Result of /quarantine/content, with relevant fields only
> +#[derive(Clone, PartialEq, Deserialize)]
> +pub struct MailContentInfo {
> + #[serde(default, deserialize_with = "deserialize_flexible_bool")]
> + external_images: bool,
> + #[serde(default, deserialize_with = "deserialize_flexible_bool")]
> + seen: bool,
> +}
> +
> +// get the email info we need
> +async fn get_mail_info(id: &str) -> Option<MailContentInfo> {
> + // ignore errors
> + match http_get::<MailContentInfo>("/quarantine/content", Some(json!({ "id": id }))).await {
> + Ok(res) => Some(res),
> + Err(err) => {
> + log::error!("could not retrieve mail-content-info for {id}: {err}");
> + None
> + }
> }
> }
>
> @@ -46,17 +50,18 @@ impl PageMailView {
>
> pub enum Msg {
> ActionResult(MailAction, Result<Value, Error>),
> - DarkmodeFilter(bool), // on/off
> - DarkmodeChange(bool), // on/off
> - LoadImages(bool), // on/off
> - ExternalImages(bool), // mail has external images to load
> + DarkmodeFilter(bool), // on/off
> + DarkmodeChange(bool), // on/off
> + LoadImages(bool), // on/off
> + MailContentInfo(Option<MailContentInfo>), // Loaded mail content info
> }
> pub struct PmgPageMailView {
> show_dark_mode_filter: bool,
> dark_mode_filter: bool,
> load_images: bool,
> - show_load_images: bool,
> + mail_content_res: Option<MailContentInfo>,
> _theme_observer: ThemeObserver,
> + async_pool: AsyncPool,
> reload: Option<SharedState<usize>>,
> }
>
> @@ -97,6 +102,14 @@ impl PmgPageMailView {
> </iframe>
> }
> }
> +
> + fn reload_info(&self, ctx: &Context<Self>) {
> + let link = ctx.link().clone();
> + let id = ctx.props().id.clone();
> + self.async_pool.spawn(async move {
> + link.send_message(Msg::MailContentInfo(get_mail_info(&id).await));
> + });
> + }
> }
>
> impl Component for PmgPageMailView {
> @@ -121,20 +134,17 @@ impl Component for PmgPageMailView {
> .context::<QuarantineReload>(Callback::noop())
> .map(|(reload, _handle)| reload.0);
>
> - let link = ctx.link().clone();
> - let id = ctx.props().id.clone();
> - wasm_bindgen_futures::spawn_local(async move {
> - link.send_message(Msg::ExternalImages(mail_has_external_images(&id).await));
> - });
> -
> - Self {
> + let this = Self {
> dark_mode_filter,
> show_dark_mode_filter: dark_mode_filter,
> load_images: false,
> - show_load_images: false,
> + mail_content_res: None,
> _theme_observer,
> + async_pool: AsyncPool::new(),
> reload,
> - }
> + };
> + this.reload_info(ctx);
> + this
> }
>
> fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
> @@ -146,6 +156,9 @@ impl Component for PmgPageMailView {
> if let Some(reload) = &self.reload {
> **reload.write() += 1;
> }
> + if matches!(action, MailAction::MarkUnseen | MailAction::MarkSeen) {
> + self.reload_info(ctx);
> + }
> tr!("Action '{0}' successful", action)
> }
> Err(err) => err.to_string(),
> @@ -172,16 +185,16 @@ impl Component for PmgPageMailView {
> self.load_images = load_images;
> changed
> }
> - Msg::ExternalImages(show_load_images) => {
> - let changed = self.show_load_images != show_load_images;
> - self.show_load_images = show_load_images;
> + Msg::MailContentInfo(result) => {
> + let changed = self.mail_content_res != result;
> + self.mail_content_res = result;
> changed
> }
> }
> }
>
> fn view(&self, ctx: &Context<Self>) -> Html {
> - let fab = FabMenu::new()
> + let mut fab = FabMenu::new()
> .main_icon_class("fa fa-bars")
> .with_child(FabMenuEntry::new(
> tr!("Deliver"),
> @@ -202,21 +215,39 @@ impl Component for PmgPageMailView {
> tr!("Blocklist"),
> "fa fa-times",
> self.action_callback(ctx, MailAction::Blocklist),
> - ))
> - .with_child(FabMenuEntry::new(
> + ));
> +
> + // show both when we don't have any info
> + let (show_mark_seen, show_mark_unseen) = match &self.mail_content_res {
> + Some(info) => (!info.seen, info.seen),
> + None => (true, true),
> + };
> +
> + if show_mark_seen {
> + fab.add_child(FabMenuEntry::new(
> tr!("Mark as Seen"),
> "fa fa-eye",
> self.action_callback(ctx, MailAction::MarkSeen),
> - ))
> - .with_child(FabMenuEntry::new(
> + ));
> + }
> +
> + if show_mark_unseen {
> + fab.add_child(FabMenuEntry::new(
> tr!("Mark as Unseen"),
> "fa fa-eye-slash",
> self.action_callback(ctx, MailAction::MarkUnseen),
> ));
> + }
>
> let mut app_bar = ApplicationBar::new().title(tr!("Preview"));
>
> - if self.show_load_images {
> + let show_load_images = self
> + .mail_content_res
> + .as_ref()
> + .map(|info| info.external_images)
> + .unwrap_or(false);
> +
> + if show_load_images {
> let id = get_unique_element_id();
> app_bar.add_action(
> Row::new()
> diff --git a/src/spam_list.rs b/src/spam_list.rs
> index 95a0611..6ea3e1d 100644
> --- a/src/spam_list.rs
> +++ b/src/spam_list.rs
> @@ -21,7 +21,7 @@ use pwt::{
> use proxmox_yew_comp::http_get;
> use pwt::widget::Column;
>
> -use crate::{mail_action, MailAction, QuarantineReload};
> +use crate::{deserialize_flexible_bool, mail_action, MailAction, QuarantineReload};
>
> #[derive(Copy, Clone, Serialize, Default, PartialEq)]
> pub struct SpamListParam {
> @@ -53,18 +53,6 @@ pub struct MailInfo {
> pub time: i64,
> }
>
> -fn deserialize_flexible_bool<'de, D>(deserializer: D) -> Result<bool, D::Error>
> -where
> - D: serde::Deserializer<'de>,
> -{
> - Ok(match Value::deserialize(deserializer)? {
> - Value::Bool(b) => b,
> - Value::Number(n) => n.as_i64().is_some_and(|v| v != 0),
> - Value::String(s) => s == "1" || s.eq_ignore_ascii_case("true"),
> - _ => false,
> - })
> -}
> -
> #[derive(Clone)]
> pub enum ListEntry {
> Date(String),
prev parent reply other threads:[~2026-06-19 9:46 UTC|newest]
Thread overview: 2+ messages / expand[flat|nested] mbox.gz Atom feed top
2026-06-19 9:31 [PATCH pmg-yew-quarantine-gui v2] mail view: show mark seen/unseen depending on mail state Dominik Csapak
2026-06-19 9:45 ` Stoiko Ivanov [this message]
Reply instructions:
You may reply publicly to this message via plain-text email
using any one of the following methods:
* Save the following mbox file, import it into your mail client,
and reply-to-all from there: mbox
Avoid top-posting and favor interleaved quoting:
https://en.wikipedia.org/wiki/Posting_style#Interleaved_style
* Reply using the --to, --cc, and --in-reply-to
switches of git-send-email(1):
git send-email \
--in-reply-to=20260619114526.503074a2@rosa.proxmox.com \
--to=s.ivanov@proxmox.com \
--cc=d.csapak@proxmox.com \
--cc=pmg-devel@lists.proxmox.com \
/path/to/YOUR_REPLY
https://kernel.org/pub/software/scm/git/docs/git-send-email.html
* If your mail client supports setting the In-Reply-To header
via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line
before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox