Files
cinny-desktop/src-tauri/src/native/toast.rs
T
Lotus CIandClaude Opus 5.5 5a9bfa8fe4
Build Lotus Chat Desktop / prepare (push) Successful in 7s
Build Lotus Chat Desktop / build-windows (push) Canceled after 32m48s
Build Lotus Chat Desktop / build-linux (push) Canceled after 32m25s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s
feat(native): "Mark as read" button on rich toasts (#9)
Toasts with a room id get a Mark as read action next to Send; it emits
`lotus-notification-mark-read {roomId}` without raising the window, and
cinny marks the room read through its existing markAsRead path. Invite
toasts (no room id) get neither button.

Bump cinny to 23649f12.

Closes #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 19:49:54 -04:00

344 lines
14 KiB
Rust

//! 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 `<input id="reply" type="text"/>` and a Send `<action>`. 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<String>,
tag: Option<String>,
room_id: Option<String>,
thread_id: Option<String>,
path: Option<String>,
) -> 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<String>, 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<Vec<StoredToast>> {
static STORE: std::sync::OnceLock<std::sync::Mutex<Vec<StoredToast>>> =
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[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!("<text>{}</text>", 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#"<actions>
<input id="reply" type="text" placeHolder="Reply..."/>
<action content="Send" arguments="reply" activationType="foreground" hint-inputId="reply"/>
<action content="Mark as read" arguments="mark_read" activationType="foreground"/>
</actions>"#
} else {
""
};
let xml = format!(
r#"<toast activationType="foreground" launch="{launch}">
<visual>
<binding template="ToastGeneric">
<text>{title}</text>
{body_line}
</binding>
</visual>
{actions}
</toast>"#,
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::<ToastNotification, IInspectable>::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::<ToastActivatedEventArgs>() 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::<ToastNotification, ToastDismissedEventArgs>::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<String> {
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<HSTRING>.
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<HSTRING> = value.cast().ok()?;
let text = reference.Value().ok()?.to_string();
if text.is_empty() {
None
} else {
Some(text)
}
}