Menu ▾ ▴

#173 macOS: inline notification quick reply and action buttons

closed
nobody
2026-08-16
2026-07-22
Anonymous
No

Originally created by: kristofferR

The macOS half of [#86] (Ref [#86]), counterpart to Linux [#167] (Ref [#167]). Today macos/notifications.rs registers no UNNotificationCategory and no actions — clicks are the only interaction.

Design

  • Cargo features: add UNNotificationAction + UNNotificationCategory to objc2-user-notifications features (UNTextInputNotificationAction lives in the former; UNTextInputNotificationResponse is already enabled via UNNotificationResponse). Add NSSet to objc2-foundation features (setNotificationCategories: takes NSSet).
  • Category registration in setup_macos_notifications, right after setDelegate: (valid regardless of auth state): one category carrier.message containing a UNTextInputNotificationAction ("reply", title "Reply", button "Send", placeholder "Message…"). "Mark as read" is deferred to the same post-soak stretch as [#167] — no native mark-read exists and it needs fragile page automation.
  • Attaching: deliver_notification_macos grows a small opts struct (thread_id: Option<String>, reply_eligible: bool; sync alerts pass none/false). Eligibility computed on the trusted side in show_message_notification: !hide_notification_preview && validated_thread_path(...).is_some(). Eligible → setCategoryIdentifier("carrier.message"); otherwise no category at all (redacted/route-less banners simply have no reply field). Also stash the validated path in userInfo["path"] next to "id" — delivered notifications persist in Notification Center across app restarts while the in-memory route map does not; re-validate on readback.
  • Response handling in NotifyDelegate::didReceiveNotificationResponse: branch on actionIdentifier (default / dismiss / "reply"). Reply → downcast to UNTextInputNotificationResponse, take userText, trim + cap ~2000 chars (empty → treat as plain click), call new on_notification_reply(app, id, path, text), and call the completion handler immediately — the ack wait must never block the delegate callback. Default click gains the userInfo["path"] fallback so post-restart clicks still route.
  • on_notification_reply (top-level notifications.rs, platform-neutral — [#167] reuses it): resolve route (map, else userInfo path); serialize replies via a global REPLY_IN_FLIGHT mutex (the page helper drives SPA navigation; two concurrent automations would race the composer); register in a pending-ack map; eval window.__carrierQuickReply?.(path, text, id) (JSON-escaping load-bearing) without show_main — background send is the feature; wait ≤20 s for the carrier:reply-result ack ({id, ok}, new listen_any in lib.rs). Failure/timeout → show_main + __carrierOpenThread(path) (helper leaves the text in the composer per the [#167] contract) + one fixed-string "Reply not sent — opening the conversation" notification (no category, no thread id).
  • Page half: inject/src/messenger/features/quick-reply.ts + pure logic in inject/src/messenger/lib/ is specified in [#167] and platform-neutral — whichever issue lands first ships it; the other reuses it.

Edge cases: auth denied → categories register fine, nothing breaks; concurrent replies serialized, acks routed by id; pre-restart banners reply via userInfo path; logged-out page / composer drift fail closed to the fallback with text preserved; hide_notification_preview gates per delivery on the trusted side.

Testing. Unit (runs on Linux CI): reply-text sanitation, eligibility matrix, pending-ack map (timeout, unknown id, eviction), payload parsing. Manual on the Mac: reply with window hidden to tray (stays hidden, message arrives); kill + relaunch and reply to an old banner; failure path; hide-preview on → no reply field; two threads replied quickly → both route correctly. A debug --features mcp build drives the page flow via tauri-mcp.

Part of [#179].

Related

Tickets: #167
Tickets: #179
Tickets: #181
Tickets: #230
Tickets: #86

Discussion

  • Anonymous

    Anonymous - 2026-08-16

    Originally posted by: kristofferR

    Shipped in [#230], released in v1.9.0.

    The carrier.message category registers a UNTextInputNotificationAction, and replies route through the shared on_notification_reply without raising the window. Eligibility is decided on the trusted side, so redacted or route-less banners get no category and no reply field. Delivered notifications carry the validated path in userInfo, re-validated on readback, so a banner still replies correctly after a restart. A reply that cannot be sent opens the conversation with the text preserved as an unsent draft rather than being dropped.

    "Mark as read" stays deferred as the design specified.

     

    Related

    Tickets: #230

  • Anonymous

    Anonymous - 2026-08-16

    Ticket changed by: kristofferR

    • status: open --> closed
     

Log in to post a comment.