diff --git a/cinny b/cinny index 738067c..3b6de2f 160000 --- a/cinny +++ b/cinny @@ -1 +1 @@ -Subproject commit 738067cfc6c4b3ed240a278d4dfb1d1fe8ee7efc +Subproject commit 3b6de2fdacd7b73cd02421083d31487821db328a diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d9125b1..5f172c9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -70,14 +70,19 @@ const NOTIFICATION_BRIDGE: &str = r#"(function(){ var opts=options||{}; try{ var body=opts.body!=null?String(opts.body):undefined; - // cinny tags message notifications with the roomId (options.tag) and the - // in-app route (options.data.path). When present, route to the rich WinRT - // toast (click-opens-room + quick reply); otherwise a plain toast. - var roomId=opts.tag!=null?String(opts.tag):undefined; - var path=(opts.data&&opts.data.path!=null)?String(opts.data.path):undefined; - if(roomId){ + // cinny coalesces notifications by options.tag and carries the in-app + // route + real Matrix ids in options.data. Anything tagged goes to the + // rich WinRT toast (coalescing + click-opens-room); only a real room id + // gets a reply box. The tag is NOT a room id — thread toasts tag + // `room:thread`, invites 'lotus-invites' (cinny-desktop #17). + var data=opts.data||{}; + var tag=opts.tag!=null?String(opts.tag):undefined; + var roomId=data.roomId!=null?String(data.roomId):undefined; + var threadId=data.threadId!=null?String(data.threadId):undefined; + var path=data.path!=null?String(data.path):undefined; + if(tag||roomId){ window.__TAURI_INTERNALS__.invoke('show_rich_toast',{ - title:String(title),body:body,roomId:roomId,path:path + title:String(title),body:body,tag:tag,roomId:roomId,threadId:threadId,path:path }).catch(function(){}); }else{ window.__TAURI_INTERNALS__.invoke('send_notification',{ @@ -519,6 +524,7 @@ pub fn run() { native::chrome::window_start_drag, native::chrome::window_close, native::toast::show_rich_toast, + native::focus_assist::get_focus_assist, native::hotkeys::global_hotkeys_supported, native::hotkeys::set_global_hotkeys, ]) diff --git a/src-tauri/src/native/focus_assist.rs b/src-tauri/src/native/focus_assist.rs index bdeb6f8..70c587c 100644 --- a/src-tauri/src/native/focus_assist.rs +++ b/src-tauri/src/native/focus_assist.rs @@ -11,14 +11,33 @@ //! ~5 seconds. We prefer a robust poll over hooking shell events — the poll is //! trivial to reason about and a 5s cadence is more than responsive enough for a //! notification-suppression hint. We emit **only on a boolean transition**, so the -//! web side gets one event per change rather than a steady heartbeat; the first -//! read always emits so the frontend learns the initial state. +//! web side gets one event per change rather than a steady heartbeat. The latest +//! reading is also kept in [`LAST_STATE`] and served by [`get_focus_assist`]: the +//! first read happens during app setup, before the page has loaded, so that +//! event is lost, and the web atom resets on every reload anyway. The web hook +//! queries it on mount (Gitea cinny-desktop #15). //! //! Other platforms are a no-op: there's no equivalent cross-platform signal, and //! the web hook stays unconditional so nothing there needs guarding. +use std::sync::atomic::{AtomicU8, Ordering}; use tauri::AppHandle; +/// Latest poll result: 0 = not read yet (or not Windows), 1 = inactive, 2 = active. +static LAST_STATE: AtomicU8 = AtomicU8::new(0); + +/// Return the latest Focus Assist reading so the web side can hydrate +/// `focusAssistActiveAtom` on mount. `None` until the first successful poll, and +/// always `None` off Windows. +#[tauri::command] +pub fn get_focus_assist() -> Option { + match LAST_STATE.load(Ordering::Relaxed) { + 1 => Some(false), + 2 => Some(true), + _ => None, + } +} + /// Payload for the `focus-assist-changed` DOM event (`{ active: bool }`). #[cfg(target_os = "windows")] #[derive(serde::Serialize)] @@ -81,6 +100,7 @@ fn watch_focus_assist(app: AppHandle) { || state == QUNS_PRESENTATION_MODE || state == QUNS_RUNNING_D3D_FULL_SCREEN || state == QUNS_BUSY; + LAST_STATE.store(if active { 2 } else { 1 }, Ordering::Relaxed); if last != Some(active) { last = Some(active); super::emit_to_web( diff --git a/src-tauri/src/native/toast.rs b/src-tauri/src/native/toast.rs index 9c6887f..9247d4d 100644 --- a/src-tauri/src/native/toast.rs +++ b/src-tauri/src/native/toast.rs @@ -15,6 +15,15 @@ //! 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. +//! //! 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. @@ -34,13 +43,16 @@ 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; `path` is the web hash route used for a body click. +/// 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")] @@ -49,7 +61,9 @@ pub fn show_rich_toast( &app, &title, body.as_deref(), + tag.as_deref(), room_id.as_deref(), + thread_id.as_deref(), path.as_deref(), ) { Ok(()) => return Ok(()), @@ -63,7 +77,7 @@ pub fn show_rich_toast( // Bind the routing args so the signature is identical cross-platform and no // unused warnings fire on the fallback (non-Windows) path. - let _ = (&room_id, &path); + let _ = (&tag, &room_id, &thread_id, &path); show_fallback(&app, &title, body.as_deref()) } @@ -79,17 +93,36 @@ fn show_fallback(app: &AppHandle, title: &str, body: Option<&str>) -> Result<(), 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::Mutex>, - > = std::sync::OnceLock::new(); +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 { @@ -106,7 +139,9 @@ 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}; @@ -126,9 +161,17 @@ fn show_windows_toast( _ => String::new(), }; - // ToastGeneric visual + an inline reply input and a foreground Send action. - // `hint-inputId="reply"` binds the Send button to the text box so the reply - // text arrives in `UserInput()` keyed "reply". + // An inline reply input and a foreground Send action, only when there is a + // room to reply to. `hint-inputId="reply"` binds the Send button to the text + // box so the reply text arrives in `UserInput()` keyed "reply". + let actions = if room_id.is_some() { + r#" + + + "# + } else { + "" + }; let xml = format!( r#" @@ -137,14 +180,12 @@ fn show_windows_toast( {body_line} - - - - + {actions} "#, launch = xml_escape(launch), title = xml_escape(title), body_line = body_line, + actions = actions, ); let doc = XmlDocument::new()?; @@ -152,10 +193,18 @@ fn show_windows_toast( 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| { @@ -164,7 +213,7 @@ fn show_windows_toast( // 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); + store.retain(|(_, t)| t != sender); } } let Some(args) = args.as_ref() else { @@ -181,6 +230,7 @@ fn show_windows_toast( // 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(); @@ -210,7 +260,7 @@ fn show_windows_toast( move |sender, _args| { if let Some(sender) = sender.as_ref() { if let Ok(mut store) = toast_store().lock() { - store.retain(|t| t != sender); + store.retain(|(_, t)| t != sender); } } Ok(()) @@ -220,7 +270,12 @@ fn show_windows_toast( // Keep the toast (and its handlers) alive until dismissed/activated. if let Ok(mut store) = toast_store().lock() { - store.push(toast.clone()); + // 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.