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
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:
co-authored by
Claude Opus 5.5
parent
7716307e54
commit
c1ae01bf41
+13
-7
@@ -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,
|
||||
])
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user