fix(native): toast coalescing, real reply target, Focus Assist mount query
Build Lotus Chat Desktop / prepare (push) Canceled after 0s
Build Lotus Chat Desktop / build-windows (push) Canceled after 0s
Build Lotus Chat Desktop / build-linux (push) Canceled after 0s
Build Lotus Chat Desktop / build-arch (push) Canceled after 0s
Build Lotus Chat Desktop / update-manifest (push) Canceled after 0s

- Rich toasts set a WinRT Tag (hash of the web notification tag, since
  room:thread tags exceed the 64-char limit) in a fixed Group, so a newer
  toast for the same room/thread replaces the older one instead of
  stacking; the replaced toast's keep-alive entry is dropped (#16).
- The bridge passes tag, roomId and threadId separately. The reply target
  is the real room id (+ thread), never the coalescing tag, which was
  `room:thread` for threads and `lotus-invites` for invites, so those
  replies failed silently. Toasts with no room id (invites) get no reply
  box (#17).
- New `get_focus_assist` command serves the latest Focus Assist poll so
  the web hook can hydrate on mount; the setup-time first emit was lost
  before the page listened (#15).
- Bump cinny to 3b6de2fd (the matching web-side hooks).

Closes #15, #16, #17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
Lotus CI
2026-09-23 19:33:30 -04:00
co-authored by Claude Opus 5.5
parent 7716307e54
commit c1ae01bf41
4 changed files with 107 additions and 26 deletions
+13 -7
View File
@@ -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,
])
+22 -2
View File
@@ -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<bool> {
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(
+71 -16
View File
@@ -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<String>,
tag: Option<String>,
room_id: Option<String>,
thread_id: Option<String>,
path: Option<String>,
) -> 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<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<windows::UI::Notifications::ToastNotification>> {
static STORE: std::sync::OnceLock<
std::sync::Mutex<Vec<windows::UI::Notifications::ToastNotification>>,
> = std::sync::OnceLock::new();
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 {
@@ -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#"<actions>
<input id="reply" type="text" placeHolder="Reply..."/>
<action content="Send" arguments="reply" activationType="foreground" hint-inputId="reply"/>
</actions>"#
} else {
""
};
let xml = format!(
r#"<toast activationType="foreground" launch="{launch}">
<visual>
@@ -137,14 +180,12 @@ fn show_windows_toast(
{body_line}
</binding>
</visual>
<actions>
<input id="reply" type="text" placeHolder="Reply..."/>
<action content="Send" arguments="reply" activationType="foreground" hint-inputId="reply"/>
</actions>
{actions}
</toast>"#,
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::<ToastNotification, IInspectable>::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.