//! P5-41 — Native WinRT toast notifications (+ P5-35 click-opens-room, P5-41 quick reply). //! //! The web notification bridge calls `show_rich_toast` (see lib.rs //! `NOTIFICATION_BRIDGE`) instead of the basic plugin notification so desktop //! notifications gain a text reply box and a body-click that reopens the room. //! //! Windows: we build a `Windows.UI.Notifications.ToastNotification` from a toast //! XML document (`Windows.Data.Xml.Dom.XmlDocument`) carrying the title + body, //! an inline `` and a Send ``. Because the //! app lives in the tray (always alive) we subscribe to the toast's **in-process** //! `Activated` event rather than relying on COM activation: the handler downcasts //! the event args to `ToastActivatedEventArgs`, reads the reply text from //! `UserInput()` (keyed `"reply"`) and forwards it to the web client. A body click //! (no reply text) forwards the launch `path` so the web side can route to the //! room. Live `ToastNotification` objects are parked in a process-global `Vec` //! (behind a `Mutex`) so their handlers survive until the toast is dismissed. //! //! Coalescing (cinny-desktop #16): the web notification's `tag` becomes the //! toast's `Tag` (hashed — WinRT caps it at 64 chars) in a fixed `Group`, so a //! newer toast for the same room/thread *replaces* the older one in the Action //! Center instead of stacking, matching the browser's `tag` semantics. //! //! Reply routing (cinny-desktop #17): the reply target is the real `room_id` (+ //! `thread_id`), never the tag. Toasts without a room id (invites) get no reply //! box. //! //! Mark as read (cinny-desktop #9): toasts with a room id also carry a //! **Mark as read** button (`arguments="mark_read"`); activating it forwards the //! room id so the web client sends the read receipt, without raising the window. //! //! If ANY WinRT step fails (most importantly: no registered AppUserModelID — see //! the runtime note below), we fall back to the plain `tauri-plugin-notification` //! notification so notifications always work. //! //! Other platforms always take the fallback path; the command keeps an identical //! cross-platform signature so the web bridge stays unconditional. //! //! RUNTIME NOTE (AppUserModelID): WinRT toasts require the process to run under an //! AppUserModelID that maps to a Start-menu shortcut. The installed app's bundle //! id is `org.lotusguild.lotus-chat`; if no matching shortcut/AUMID is registered, //! `CreateToastNotifier()` / `Show()` will error and we silently fall back. Wiring //! `SetCurrentProcessExplicitAppUserModelID` (+ shortcut install) is handled //! separately. use tauri::AppHandle; /// Show a rich desktop notification. On Windows this is a WinRT toast with a /// reply box and click-to-open; elsewhere (or on any WinRT error) it degrades to /// a basic plugin notification. `room_id` is the raw Matrix room id used for the /// reply payload (no reply box without one) and `thread_id` threads the reply; /// `tag` coalesces toasts; `path` is the web hash route used for a body click. #[tauri::command] pub fn show_rich_toast( app: AppHandle, title: String, body: Option, tag: Option, room_id: Option, thread_id: Option, path: Option, ) -> Result<(), String> { #[cfg(target_os = "windows")] { match show_windows_toast( &app, &title, body.as_deref(), tag.as_deref(), room_id.as_deref(), thread_id.as_deref(), path.as_deref(), ) { Ok(()) => return Ok(()), Err(err) => { // Most commonly a missing AppUserModelID (see module note). Fall // through to the plugin notification so the user still sees it. eprintln!("toast: WinRT toast failed, falling back to plugin: {err:?}"); } } } // Bind the routing args so the signature is identical cross-platform and no // unused warnings fire on the fallback (non-Windows) path. let _ = (&tag, &room_id, &thread_id, &path); show_fallback(&app, &title, body.as_deref()) } /// Cross-platform fallback: a basic notification via `tauri-plugin-notification` /// (mirrors `send_notification` in lib.rs). Used off Windows and whenever the /// WinRT toast path errors. fn show_fallback(app: &AppHandle, title: &str, body: Option<&str>) -> Result<(), String> { use tauri_plugin_notification::NotificationExt; let mut builder = app.notification().builder().title(title); if let Some(b) = body { builder = builder.body(b); } builder.show().map_err(|e| e.to_string()) } /// A live toast plus the (hashed) coalescing tag it was shown under. #[cfg(target_os = "windows")] type StoredToast = (Option, windows::UI::Notifications::ToastNotification); /// Process-global store keeping live `ToastNotification` objects (and therefore /// their `Activated`/`Dismissed` handler registrations) alive until dismissed. /// Lazily initialized so no `native::setup()` wiring is required. #[cfg(target_os = "windows")] fn toast_store() -> &'static std::sync::Mutex> { static STORE: std::sync::OnceLock>> = std::sync::OnceLock::new(); STORE.get_or_init(|| std::sync::Mutex::new(Vec::new())) } /// Toast group shared by every Lotus toast, so `Tag` alone identifies a bucket. #[cfg(target_os = "windows")] const TOAST_GROUP: &str = "lotus"; /// Map a web notification tag (a room id, `room:thread`, `lotus-invites`, …) to /// a WinRT toast tag. WinRT limits `Tag` to 64 characters and room + thread ids /// easily exceed that, so hash it to a fixed 16-hex-char key. Stable within a /// process, which is all replacement needs. #[cfg(target_os = "windows")] fn toast_tag(tag: &str) -> String { use std::hash::{Hash, Hasher}; let mut hasher = std::collections::hash_map::DefaultHasher::new(); tag.hash(&mut hasher); format!("{:016x}", hasher.finish()) } /// Escape text for inclusion in the toast XML (attribute or element content). #[cfg(target_os = "windows")] fn xml_escape(input: &str) -> String { input .replace('&', "&") .replace('<', "<") .replace('>', ">") .replace('"', """) .replace('\'', "'") } #[cfg(target_os = "windows")] fn show_windows_toast( app: &AppHandle, title: &str, body: Option<&str>, tag: Option<&str>, room_id: Option<&str>, thread_id: Option<&str>, path: Option<&str>, ) -> windows::core::Result<()> { use windows::core::{HSTRING, IInspectable, Interface}; use windows::Data::Xml::Dom::XmlDocument; use windows::Foundation::TypedEventHandler; use windows::UI::Notifications::{ ToastActivatedEventArgs, ToastDismissedEventArgs, ToastNotification, ToastNotificationManager, }; // A body click carries the launch arguments back to us; prefer the web hash // route (`path`), falling back to the raw room id so clicks are never inert. let launch = path.or(room_id).unwrap_or_default(); let body_line = match body { Some(b) if !b.is_empty() => format!("{}", xml_escape(b)), _ => String::new(), }; // An inline reply input, a Send action and Mark as read, only when there is // a room to act on. `hint-inputId="reply"` binds the Send button to the text // box so the reply text arrives in `UserInput()` keyed "reply". Both are // "foreground" because the in-process Activated event (not COM background // activation) is what an unpackaged app receives; `arguments` tells them // apart. let actions = if room_id.is_some() { r#" "# } else { "" }; let xml = format!( r#" {title} {body_line} {actions} "#, launch = xml_escape(launch), title = xml_escape(title), body_line = body_line, actions = actions, ); let doc = XmlDocument::new()?; doc.LoadXml(&HSTRING::from(xml))?; let toast = ToastNotification::CreateToastNotification(&doc)?; // Same tag + group → Windows replaces the earlier toast instead of stacking. let win_tag = tag.map(toast_tag); if let Some(t) = &win_tag { toast.SetTag(&HSTRING::from(t.as_str()))?; toast.SetGroup(&HSTRING::from(TOAST_GROUP))?; } // In-process activation: the app is always alive in the tray, so we handle // clicks/replies directly instead of via COM activation. let app_activated = app.clone(); let room_id_owned = room_id.map(|s| s.to_string()); let thread_id_owned = thread_id.map(|s| s.to_string()); let path_owned = path.map(|s| s.to_string()); let activated = TypedEventHandler::::new( move |sender, args| { // Activation means this toast is done; drop it from the keep-alive // store now. A Dismissed event doesn't reliably fire for a toast the // user activated, so pruning only on Dismissed would leak it. if let Some(sender) = sender.as_ref() { if let Ok(mut store) = toast_store().lock() { store.retain(|(_, t)| t != sender); } } let Some(args) = args.as_ref() else { return Ok(()); }; let Ok(activated_args) = args.cast::() else { return Ok(()); }; // Extract the reply text (if the Send action / input was used). let reply = read_reply(&activated_args).unwrap_or_default(); let action = activated_args .Arguments() .map(|a| a.to_string()) .unwrap_or_default(); if action == "mark_read" { // Mark as read: no window raise, like the quick reply. let payload = serde_json::json!({ "roomId": room_id_owned.as_deref(), }) .to_string(); super::emit_to_web(&app_activated, "lotus-notification-mark-read", &payload); } else if !reply.is_empty() { // Quick reply: forward the room id + text to the web client. let payload = serde_json::json!({ "roomId": room_id_owned.as_deref(), "threadId": thread_id_owned.as_deref(), "text": reply, }) .to_string(); super::emit_to_web(&app_activated, "lotus-notification-reply", &payload); } else { // Plain body click: raise the window to the foreground (the // "foreground" activationType is unreliable for an unpackaged app, // so do it explicitly), then forward the launch path so the web // routes to the room. `show_main` is the shared tray/deep-link // helper. Not done for the reply branch — an inline quick-reply // shouldn't yank the window forward. crate::show_main(&app_activated); let payload = serde_json::json!({ "path": path_owned.as_deref(), }) .to_string(); super::emit_to_web(&app_activated, "lotus-notification-activate", &payload); } Ok(()) }, ); let _ = toast.Activated(&activated)?; // Prune the store once the toast leaves the action center so we don't leak // handler registrations for the app's lifetime. let dismissed = TypedEventHandler::::new( move |sender, _args| { if let Some(sender) = sender.as_ref() { if let Ok(mut store) = toast_store().lock() { store.retain(|(_, t)| t != sender); } } Ok(()) }, ); let _ = toast.Dismissed(&dismissed)?; // Keep the toast (and its handlers) alive until dismissed/activated. if let Ok(mut store) = toast_store().lock() { // The toast this one replaces is gone from the Action Center; drop its // keep-alive entry too (its Dismissed event isn't guaranteed to fire). if win_tag.is_some() { store.retain(|(t, _)| *t != win_tag); } store.push((win_tag.clone(), toast.clone())); // Hard cap: if some Dismissed/Activated events are missed, retain only // the most recent 20 toasts (dropping the oldest) so the store can't // grow unbounded for the app's lifetime. let len = store.len(); if len > 20 { store.drain(0..len - 20); } } // Bind the notifier to our registered AUMID (native::aumid) so it resolves to // the "Lotus Chat" Start-Menu shortcut rather than an ambient/absent default. let notifier = ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from( crate::native::aumid::APP_USER_MODEL_ID, ))?; notifier.Show(&toast)?; Ok(()) } /// Read the quick-reply text from a toast activation. Returns `None` when the /// toast was activated without submitting the "reply" input (a plain click). #[cfg(target_os = "windows")] fn read_reply( args: &windows::UI::Notifications::ToastActivatedEventArgs, ) -> Option { use windows::core::{HSTRING, Interface}; use windows::Foundation::IReference; // UserInput() returns a ValueSet; windows 0.61 exposes its IMap methods // (HasKey/Lookup) directly on the class (the generic IMap interface itself // moved to the separate windows-collections crate). The text input value is // boxed as an IReference. let inputs = args.UserInput().ok()?; let key = HSTRING::from("reply"); if !inputs.HasKey(&key).ok()? { return None; } let value = inputs.Lookup(&key).ok()?; let reference: IReference = value.cast().ok()?; let text = reference.Value().ok()?.to_string(); if text.is_empty() { None } else { Some(text) } }