app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit f11101f749ffdc4cfcbcefad7e0e7635f5fd6d02
parent 47835176e939ecea38116ae5f45cff5b29dc4cbe
Author: triesap <triesap@radroots.dev>
Date:   Mon, 19 Jan 2026 02:44:42 +0000

app-core: add web notifications adapter

- add web notifications implementation with alerts, permissions, and file picker
- expose web notifications module in public exports
- expand web-sys feature set for notifications and file APIs
- add non-wasm notification coverage tests

Diffstat:
MCargo.toml | 11+++++++++++
Mcrates/core/src/notifications/mod.rs | 2++
Acrates/core/src/notifications/web.rs | 325+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 338 insertions(+), 0 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -27,9 +27,16 @@ web-sys = { version = "0.3.77", features = [ "CryptoKey", "SubtleCrypto", "Window", + "Document", + "Element", "DomException", "DomStringList", "Event", + "EventTarget", + "File", + "FileList", + "FileReader", + "HtmlInputElement", "IdbDatabase", "IdbFactory", "IdbObjectStore", @@ -37,6 +44,10 @@ web-sys = { version = "0.3.77", features = [ "IdbRequest", "IdbTransaction", "IdbTransactionMode", + "Navigator", + "Notification", + "NotificationOptions", + "NotificationPermission", ] } wasm-bindgen-futures = "0.4" base64 = "0.22" diff --git a/crates/core/src/notifications/mod.rs b/crates/core/src/notifications/mod.rs @@ -1,5 +1,6 @@ pub mod error; pub mod types; +pub mod web; pub use error::{RadrootsClientNotificationsError, RadrootsClientNotificationsErrorMessage}; pub use types::{ @@ -11,3 +12,4 @@ pub use types::{ RadrootsClientNotificationsSendOptions, RadrootsClientResolveStatus, }; +pub use web::RadrootsClientWebNotifications; diff --git a/crates/core/src/notifications/web.rs b/crates/core/src/notifications/web.rs @@ -0,0 +1,325 @@ +use async_trait::async_trait; + +#[cfg(target_arch = "wasm32")] +use wasm_bindgen::{JsCast, JsValue}; +#[cfg(target_arch = "wasm32")] +use wasm_bindgen_futures::JsFuture; + +use super::{ + RadrootsClientNotifications, + RadrootsClientNotificationsConfig, + RadrootsClientNotificationsDialogConfirmOpts, + RadrootsClientNotificationsError, + RadrootsClientNotificationsPermission, + RadrootsClientNotificationsResult, + RadrootsClientNotificationsSendOptions, + RadrootsClientResolveStatus, +}; + +pub struct RadrootsClientWebNotifications { + config: RadrootsClientNotificationsConfig, +} + +impl RadrootsClientWebNotifications { + pub fn new(config: Option<RadrootsClientNotificationsConfig>) -> Self { + let config = config.unwrap_or(RadrootsClientNotificationsConfig { + app_name: String::from("Radroots"), + }); + Self { config } + } + + pub fn get_config(&self) -> &RadrootsClientNotificationsConfig { + &self.config + } + + #[cfg(target_arch = "wasm32")] + fn notification_available(window: &web_sys::Window) -> bool { + js_sys::Reflect::has(window.as_ref(), &JsValue::from_str("Notification")) + .unwrap_or(false) + } + + #[cfg(target_arch = "wasm32")] + fn permission_from_web(permission: web_sys::NotificationPermission) -> RadrootsClientNotificationsPermission { + match permission { + web_sys::NotificationPermission::Granted => { + RadrootsClientNotificationsPermission::Granted + } + web_sys::NotificationPermission::Denied => RadrootsClientNotificationsPermission::Denied, + web_sys::NotificationPermission::Default => { + RadrootsClientNotificationsPermission::Default + } + _ => RadrootsClientNotificationsPermission::Unavailable, + } + } + + #[cfg(target_arch = "wasm32")] + async fn request_permission( + &self, + window: &web_sys::Window, + ) -> RadrootsClientNotificationsResult<RadrootsClientNotificationsPermission> { + if !Self::notification_available(window) { + return Ok(RadrootsClientNotificationsPermission::Unavailable); + } + let promise = web_sys::Notification::request_permission() + .map_err(|_| RadrootsClientNotificationsError::Unavailable)?; + let result = JsFuture::from(promise) + .await + .map_err(|_| RadrootsClientNotificationsError::Unavailable)?; + if let Some(permission) = result.as_string() { + if let Some(parsed) = RadrootsClientNotificationsPermission::parse(&permission) { + return Ok(parsed); + } + } + Ok(Self::permission_from_web(web_sys::Notification::permission())) + } + + #[cfg(target_arch = "wasm32")] + async fn read_photo_data( + &self, + file: web_sys::File, + ) -> RadrootsClientNotificationsResult<String> { + let reader = + web_sys::FileReader::new().map_err(|_| RadrootsClientNotificationsError::ReadFailure)?; + let reader_load = reader.clone(); + let reader_error = reader.clone(); + let promise = js_sys::Promise::new(&mut |resolve, reject| { + let onload = wasm_bindgen::closure::Closure::once(move |_event: web_sys::Event| { + match reader_load.result() { + Ok(value) => { + let _ = resolve.call1(&JsValue::NULL, &value); + } + Err(err) => { + let _ = reject.call1(&JsValue::NULL, &err); + } + } + }); + let onerror = wasm_bindgen::closure::Closure::once(move |_event: web_sys::Event| { + let err = reader_error + .error() + .map(JsValue::from) + .unwrap_or_else(|| { + JsValue::from_str(RadrootsClientNotificationsError::ReadFailure.message()) + }); + let _ = reject.call1(&JsValue::NULL, &err); + }); + reader.set_onload(Some(onload.as_ref().unchecked_ref())); + reader.set_onerror(Some(onerror.as_ref().unchecked_ref())); + onload.forget(); + onerror.forget(); + }); + reader + .read_as_data_url(&file) + .map_err(|_| RadrootsClientNotificationsError::ReadFailure)?; + let result = JsFuture::from(promise) + .await + .map_err(|_| RadrootsClientNotificationsError::ReadFailure)?; + result + .as_string() + .ok_or(RadrootsClientNotificationsError::ReadFailure) + } + + #[cfg(target_arch = "wasm32")] + async fn select_photo_files( + &self, + ) -> RadrootsClientNotificationsResult<Option<web_sys::FileList>> { + let window = web_sys::window().ok_or(RadrootsClientNotificationsError::Unavailable)?; + let document = window + .document() + .ok_or(RadrootsClientNotificationsError::Unavailable)?; + let input = document + .create_element("input") + .map_err(|_| RadrootsClientNotificationsError::Unavailable)?; + let input: web_sys::HtmlInputElement = input + .dyn_into() + .map_err(|_| RadrootsClientNotificationsError::Unavailable)?; + input.set_type("file"); + input.set_multiple(true); + input.set_accept("image/png,image/jpg"); + let input_handle = input.clone(); + let promise = js_sys::Promise::new(&mut |resolve, _reject| { + let onchange = wasm_bindgen::closure::Closure::once(move |_event: web_sys::Event| { + let files = input_handle.files(); + let value = files.map(JsValue::from).unwrap_or(JsValue::NULL); + let _ = resolve.call1(&JsValue::NULL, &value); + }); + input_handle.set_onchange(Some(onchange.as_ref().unchecked_ref())); + input_handle.click(); + onchange.forget(); + }); + let value = JsFuture::from(promise) + .await + .map_err(|_| RadrootsClientNotificationsError::Unavailable)?; + if value.is_null() || value.is_undefined() { + return Ok(None); + } + let list = value + .dyn_into::<web_sys::FileList>() + .map_err(|_| RadrootsClientNotificationsError::Unavailable)?; + Ok(Some(list)) + } +} + +#[async_trait(?Send)] +impl RadrootsClientNotifications for RadrootsClientWebNotifications { + async fn alert( + &self, + message: &str, + title: Option<&str>, + _status: Option<RadrootsClientResolveStatus>, + ) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = (message, title); + return false; + } + #[cfg(target_arch = "wasm32")] + { + let window = match web_sys::window() { + Some(window) => window, + None => return false, + }; + let msg = if let Some(title) = title { + format!("{title}\n\n{message}") + } else { + message.to_string() + }; + window.alert_with_message(&msg).is_ok() + } + } + + async fn confirm( + &self, + opts: RadrootsClientNotificationsDialogConfirmOpts, + ) -> bool { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = opts; + return false; + } + #[cfg(target_arch = "wasm32")] + { + let window = match web_sys::window() { + Some(window) => window, + None => return false, + }; + let msg = match opts { + RadrootsClientNotificationsDialogConfirmOpts::Message(message) => message, + RadrootsClientNotificationsDialogConfirmOpts::Config(config) => config.message, + }; + window.confirm_with_message(&msg).unwrap_or(false) + } + } + + async fn notify_init( + &self, + ) -> RadrootsClientNotificationsResult<RadrootsClientNotificationsPermission> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientNotificationsError::Unavailable); + } + #[cfg(target_arch = "wasm32")] + { + let window = web_sys::window().ok_or(RadrootsClientNotificationsError::Unavailable)?; + if !Self::notification_available(&window) { + return Ok(RadrootsClientNotificationsPermission::Unavailable); + } + let permission = Self::permission_from_web(web_sys::Notification::permission()); + match permission { + RadrootsClientNotificationsPermission::Granted + | RadrootsClientNotificationsPermission::Denied => Ok(permission), + RadrootsClientNotificationsPermission::Default => { + self.request_permission(&window).await + } + RadrootsClientNotificationsPermission::Unavailable => Ok(permission), + } + } + } + + async fn notify_send( + &self, + opts: RadrootsClientNotificationsSendOptions, + ) -> RadrootsClientNotificationsResult<()> { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = opts; + return Err(RadrootsClientNotificationsError::Unavailable); + } + #[cfg(target_arch = "wasm32")] + { + let window = web_sys::window().ok_or(RadrootsClientNotificationsError::Unavailable)?; + if !Self::notification_available(&window) { + return Err(RadrootsClientNotificationsError::Unavailable); + } + let permission = self.notify_init().await?; + if permission != RadrootsClientNotificationsPermission::Granted { + return Err(RadrootsClientNotificationsError::Unavailable); + } + let title = opts + .title + .as_deref() + .unwrap_or(&self.config.app_name); + if let Some(body) = opts.body.as_deref() { + let mut options = web_sys::NotificationOptions::new(); + options.set_body(body); + web_sys::Notification::new_with_options(title, &options) + .map_err(|_| RadrootsClientNotificationsError::Unavailable)?; + } else { + web_sys::Notification::new(title) + .map_err(|_| RadrootsClientNotificationsError::Unavailable)?; + } + Ok(()) + } + } + + async fn open_photos( + &self, + ) -> RadrootsClientNotificationsResult<Option<Vec<String>>> { + #[cfg(not(target_arch = "wasm32"))] + { + return Err(RadrootsClientNotificationsError::Unavailable); + } + #[cfg(target_arch = "wasm32")] + { + let files = self.select_photo_files().await?; + let Some(files) = files else { + return Ok(None); + }; + let mut results = Vec::new(); + for idx in 0..files.length() { + let Some(file) = files.item(idx) else { + continue; + }; + let data = self.read_photo_data(file).await?; + results.push(data); + } + Ok(Some(results)) + } + } +} + +#[cfg(test)] +mod tests { + use super::RadrootsClientWebNotifications; + use crate::notifications::{ + RadrootsClientNotifications, + RadrootsClientNotificationsConfig, + RadrootsClientNotificationsError, + }; + + #[test] + fn default_config_is_radroots() { + let client = RadrootsClientWebNotifications::new(None); + let config = RadrootsClientNotificationsConfig { + app_name: String::from("Radroots"), + }; + assert_eq!(client.get_config(), &config); + } + + #[test] + fn non_wasm_notify_init_errors() { + let client = RadrootsClientWebNotifications::new(None); + let err = futures::executor::block_on(client.notify_init()) + .expect_err("notify init errors"); + assert_eq!(err, RadrootsClientNotificationsError::Unavailable); + } +}