From f2dfa7963e3805bffb19799e433b0cf0ebbd5928 Mon Sep 17 00:00:00 2001 From: spinline Date: Sun, 8 Feb 2026 22:30:46 +0300 Subject: [PATCH] refactor: modernize browser notifications with leptos-use and refactor utils/notification.rs --- Cargo.lock | 110 +++++++++++++++++++ frontend/src/components/layout/statusbar.rs | 1 - frontend/src/store.rs | 52 +++------ frontend/src/utils/notification.rs | 112 ++++++++------------ 4 files changed, 168 insertions(+), 107 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 409ef7d..3b15cc3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -566,6 +566,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "codee" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d3ad3122b0001c7f140cf4d605ef9a9e2c24d96ab0b4fb4347b76de2425f445" +dependencies = [ + "thiserror 1.0.69", +] + [[package]] name = "collection_literals" version = "1.0.3" @@ -790,6 +799,41 @@ version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b10589d1a5e400d61f9f38f12f884cfd080ff345de8f17efda36fe0e4a02aa8" +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.114", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.114", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -823,6 +867,18 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +[[package]] +name = "default-struct-builder" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0df63c21a4383f94bd5388564829423f35c316aed85dc4f8427aded372c7c0d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "der" version = "0.4.5" @@ -1144,7 +1200,9 @@ dependencies = [ name = "frontend" version = "0.1.0" dependencies = [ + "base64 0.22.1", "chrono", + "codee", "console_error_panic_hook", "console_log", "futures", @@ -1152,9 +1210,11 @@ dependencies = [ "gloo-timers", "js-sys", "leptos", + "leptos-use", "leptos_router", "log", "serde", + "serde-wasm-bindgen", "serde_json", "shared", "tailwind_fuse", @@ -1819,6 +1879,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1986,6 +2052,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "leptos-use" +version = "0.13.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "789bf9f4337e6ebd8f1b407e3f762fdc538d48dc145f9d1dce2338014b38f4dd" +dependencies = [ + "cfg-if", + "chrono", + "codee", + "cookie", + "default-struct-builder", + "futures-util", + "gloo-timers", + "js-sys", + "lazy_static", + "leptos", + "paste", + "thiserror 1.0.69", + "unic-langid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "leptos_config" version = "0.6.15" @@ -3934,6 +4024,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -4298,6 +4389,24 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unic-langid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658" +dependencies = [ + "tinystr", +] + [[package]] name = "unicase" version = "2.9.0" @@ -5055,6 +5164,7 @@ version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/frontend/src/components/layout/statusbar.rs b/frontend/src/components/layout/statusbar.rs index 8fc3323..0738660 100644 --- a/frontend/src/components/layout/statusbar.rs +++ b/frontend/src/components/layout/statusbar.rs @@ -1,5 +1,4 @@ use leptos::*; -use leptos_use::on_click_outside; use leptos_use::storage::use_local_storage; use codee::string::FromToStringCodec; use shared::GlobalLimitRequest; diff --git a/frontend/src/store.rs b/frontend/src/store.rs index e914ea2..cb26093 100644 --- a/frontend/src/store.rs +++ b/frontend/src/store.rs @@ -131,6 +131,9 @@ pub fn provide_torrent_store() { let notifications = create_rw_signal(Vec::::new()); let user = create_rw_signal(Option::::None); + // Browser notification hook + let show_browser_notification = crate::utils::notification::use_app_notification(); + let store = TorrentStore { torrents, filter, @@ -149,6 +152,7 @@ pub fn provide_torrent_store() { return; } + let show_browser_notification = show_browser_notification.clone(); spawn_local(async move { let mut backoff_ms: u32 = 1000; // Start with 1 second let max_backoff_ms: u32 = 30000; // Max 30 seconds @@ -247,7 +251,7 @@ pub fn provide_torrent_store() { shared::NotificationLevel::Info => "ℹ️ VibeTorrent", }; - crate::utils::notification::show_notification_if_enabled( + show_browser_notification( title, &n.message ); @@ -329,49 +333,21 @@ pub async fn subscribe_to_push_notifications() { // First, request notification permission if not already granted let window = web_sys::window().expect("window should exist"); - // Notification.permission is a static property, but web_sys exposes it via the Notification class instance or we check it manually. - // Actually, Notification::permission() is a static method in web_sys. - match web_sys::Notification::permission() { - web_sys::NotificationPermission::Granted => { - log::info!("Notification permission already granted"); - } - web_sys::NotificationPermission::Denied => { - log::warn!("Notification permission was denied"); + let permission = crate::utils::notification::get_notification_permission(); + if permission == "default" { + log::info!("Requesting notification permission..."); + if !crate::utils::notification::request_notification_permission().await { + log::warn!("Notification permission denied by user"); return; } - web_sys::NotificationPermission::Default => { - log::info!("Requesting notification permission..."); - let permission_promise = match web_sys::Notification::request_permission() { - Ok(p) => p, - Err(e) => { - log::error!("Failed to request notification permission: {:?}", e); - return; - } - }; - - match wasm_bindgen_futures::JsFuture::from(permission_promise).await { - Ok(val) => { - let permission = val.as_string().unwrap_or_default(); - if permission != "granted" { - log::warn!("Notification permission denied by user"); - return; - } - log::info!("Notification permission granted by user"); - } - Err(e) => { - log::error!("Failed to await notification permission: {:?}", e); - return; - } - } - } - _ => { - log::warn!("Unknown notification permission status"); - return; - } + } else if permission == "denied" { + log::warn!("Notification permission was denied"); + return; } log::info!("Notification permission granted! Proceeding with push subscription..."); + // Get VAPID public key from backend let public_key_response = match Request::get("/api/push/public-key").send().await { Ok(resp) => resp, diff --git a/frontend/src/utils/notification.rs b/frontend/src/utils/notification.rs index 22d5969..572de82 100644 --- a/frontend/src/utils/notification.rs +++ b/frontend/src/utils/notification.rs @@ -1,37 +1,19 @@ use wasm_bindgen::prelude::*; use web_sys::{Notification, NotificationOptions}; +use leptos::*; +use leptos_use::{use_web_notification, UseWebNotificationReturn, NotificationPermission}; /// Request browser notification permission from user pub async fn request_notification_permission() -> bool { - let window = web_sys::window().expect("no global window"); - - // Check if Notification API is available - if js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false) { - let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification")) - .expect("Notification should exist"); - - // Request permission - let promise = js_sys::Reflect::get(¬ification, &JsValue::from_str("requestPermission")) - .expect("requestPermission should exist"); - - if let Ok(function) = promise.dyn_into::() { - if let Ok(promise) = function.call0(¬ification) { - if let Ok(promise) = promise.dyn_into::() { - let result = wasm_bindgen_futures::JsFuture::from(promise).await; - - if let Ok(permission) = result { - let permission_str = permission.as_string().unwrap_or_default(); - return permission_str == "granted"; - } - } - } + if let Ok(promise) = Notification::request_permission() { + if let Ok(result) = wasm_bindgen_futures::JsFuture::from(promise).await { + return result.as_string().unwrap_or_default() == "granted"; } } - false } -/// Check if browser notifications are supported and permitted +/// Check if browser notifications are supported pub fn is_notification_supported() -> bool { let window = web_sys::window().expect("no global window"); js_sys::Reflect::has(&window, &JsValue::from_str("Notification")).unwrap_or(false) @@ -39,68 +21,62 @@ pub fn is_notification_supported() -> bool { /// Get current notification permission status pub fn get_notification_permission() -> String { - if !is_notification_supported() { - return "unsupported".to_string(); + match Notification::permission() { + web_sys::NotificationPermission::Granted => "granted".to_string(), + web_sys::NotificationPermission::Denied => "denied".to_string(), + web_sys::NotificationPermission::Default => "default".to_string(), + _ => "default".to_string(), } - - let window = web_sys::window().expect("no global window"); - let notification = js_sys::Reflect::get(&window, &JsValue::from_str("Notification")) - .expect("Notification should exist"); - - let permission = js_sys::Reflect::get(¬ification, &JsValue::from_str("permission")) - .unwrap_or(JsValue::from_str("default")); - - permission.as_string().unwrap_or("default".to_string()) } -/// Show a browser notification -/// Returns true if notification was shown successfully -pub fn show_browser_notification(title: &str, body: &str, icon: Option<&str>) -> bool { - // Check permission first - let permission = get_notification_permission(); - if permission != "granted" { - log::warn!("Notification permission not granted: {}", permission); +/// Hook for using browser notifications within Leptos components or effects. +/// This uses leptos-use for reactive permission tracking. +pub fn use_app_notification() -> impl Fn(&str, &str) + Clone { + let UseWebNotificationReturn { permission, .. } = use_web_notification(); + + move |title: &str, body: &str| { + // Check user preference from localStorage + let window = web_sys::window().expect("no global window"); + let storage = window.local_storage().ok().flatten(); + let enabled = storage + .and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten()) + .unwrap_or_else(|| "true".to_string()); + + // Use the reactive permission signal from leptos-use + if enabled == "true" && permission.get() == NotificationPermission::Granted { + show_browser_notification(title, body); + } + } +} + +/// Show a browser notification (non-reactive version) +pub fn show_browser_notification(title: &str, body: &str) -> bool { + if get_notification_permission() != "granted" { return false; } - // Create notification options let opts = NotificationOptions::new(); opts.set_body(body); - opts.set_icon(icon.unwrap_or("/icon-192.png")); - opts.set_badge("/icon-192.png"); + opts.set_icon("/icon-192.png"); opts.set_tag("vibetorrent"); - opts.set_require_interaction(false); - opts.set_silent(Some(false)); - // Create and show notification match Notification::new_with_options(title, &opts) { - Ok(_notification) => { - true - } - Err(e) => { - log::error!("Failed to create notification: {:?}", e); - false - } + Ok(_) => true, + Err(_) => false, } } -/// Show notification only if enabled in settings and permission granted +/// Legacy helper for showing notification if enabled in settings pub fn show_notification_if_enabled(title: &str, body: &str) -> bool { - // Check localStorage for user preference let window = web_sys::window().expect("no global window"); let storage = window.local_storage().ok().flatten(); - - if let Some(storage) = storage { - let enabled = storage - .get_item("vibetorrent_browser_notifications") - .ok() - .flatten() - .unwrap_or("true".to_string()); + let enabled = storage + .and_then(|s| s.get_item("vibetorrent_browser_notifications").ok().flatten()) + .unwrap_or_else(|| "true".to_string()); - if enabled == "true" { - return show_browser_notification(title, body, None); - } + if enabled == "true" { + return show_browser_notification(title, body); } false -} +} \ No newline at end of file