From 4a5a00cb96fdffc3c866ba2b8ea6051917abf8c1 Mon Sep 17 00:00:00 2001 From: Jwalin Shah Date: Tue, 21 Apr 2026 22:25:12 -0700 Subject: [PATCH] feat(notifications): webview notifications end-to-end (Phase 7) (#741) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip(notifications): core module skeleton + controller registration Phase 7 PR #714 work-in-progress. Shipped: - src/openhuman/webview_notifications/ — mod, types, dispatch, bus, schemas - pub mod declaration in src/openhuman/mod.rs - controllers entry in src/core/all.rs Remaining: schemas line in all.rs, Tauri shell edits (OpenHuman: prefix, feature flag gate, click event emit), new shell module for feature flag state + commands, frontend app/src/lib/webviewNotifications/ IPC wrapper, Redux click handler. * feat(notifications): register webview_notifications schemas in core registry Adds the schemas line next to the already-registered controllers entry so the domain participates in declared-schema validation. v1 controller list is empty (settings live shell-side), but the wiring is in place for future additions. * feat(notifications): add OpenHuman: prefix, feature flag, and click-routing event Updates the CEF notification hook in webview_accounts to: - Prefix OS toast titles with "OpenHuman:" so they visually disambiguate from natively installed apps (Slack, Gmail, Discord desktop) firing the same DM twice. - Gate delivery on a new shell-side feature flag (off by default). - Mirror each fire to the React frontend via a `webview-notification:fired` Tauri event carrying {account_id, provider, title, body, tag}, so the UI can route click-to-focus back to the originating webview. Adds a new notification_settings shell module holding the runtime toggle as an AtomicBool (lock-free read from the CEF callback thread) plus notification_settings_{get,set} Tauri commands for the settings UI to flip the flag. State lives shell-side rather than in the core sidecar so the toggle doesn't require a JSON-RPC round-trip. * feat(notifications): frontend IPC wrapper + Redux click routing Mirrors the Rust-side `webview-notification:fired` Tauri event into Redux so the UI can: - bump an unread badge on the originating account (sidebar surface reads `accounts.unread[accountId]`) - route a subsequent click intent back to the right embedded webview via `focusAccountFromNotification`, which sets `activeAccountId` and clears the unread counter in one action Plumbing: - `app/src/lib/webviewNotifications/` — typed subscribe/unsubscribe, idempotent `started` guard mirroring `webviewAccountService.ts`, `handleNotificationClick(accountId)` as the public click entrypoint so in-app toast UI or a future OS-notification click hook share one Redux intent - `accountsSlice`: `noteWebviewNotificationFired` and `focusAccountFromNotification` reducers (both no-op for unknown account ids — guards against stale payloads from a closed webview) - `App.tsx`: start the service at boot, right next to the existing `startWebviewAccountService()` call Tests: - `accountsSlice.webviewNotifications.test.ts` — reducer behavior - `service.test.ts` — fired dispatches + click focuses via real store Also: `cargo fmt` noise fixups on `src/core/all.rs` and `src/openhuman/webview_notifications/mod.rs` so the branch passes `cargo fmt --check` in CI. --------- Co-authored-by: Jwalin Shah --- app/src-tauri/src/lib.rs | 6 +- .../src/notification_settings/mod.rs | 83 +++++++++++++++++++ app/src-tauri/src/webview_accounts/mod.rs | 69 +++++++++++++-- app/src/App.tsx | 2 + app/src/lib/webviewNotifications/index.ts | 6 ++ .../lib/webviewNotifications/service.test.ts | 56 +++++++++++++ app/src/lib/webviewNotifications/service.ts | 82 ++++++++++++++++++ app/src/lib/webviewNotifications/types.ts | 15 ++++ ...accountsSlice.webviewNotifications.test.ts | 45 ++++++++++ app/src/store/accountsSlice.ts | 15 ++++ src/core/all.rs | 8 ++ src/openhuman/mod.rs | 1 + src/openhuman/webview_notifications/bus.rs | 8 ++ .../webview_notifications/dispatch.rs | 57 +++++++++++++ src/openhuman/webview_notifications/mod.rs | 25 ++++++ .../webview_notifications/schemas.rs | 19 +++++ src/openhuman/webview_notifications/types.rs | 39 +++++++++ 17 files changed, 529 insertions(+), 7 deletions(-) create mode 100644 app/src-tauri/src/notification_settings/mod.rs create mode 100644 app/src/lib/webviewNotifications/index.ts create mode 100644 app/src/lib/webviewNotifications/service.test.ts create mode 100644 app/src/lib/webviewNotifications/service.ts create mode 100644 app/src/lib/webviewNotifications/types.ts create mode 100644 app/src/store/__tests__/accountsSlice.webviewNotifications.test.ts create mode 100644 src/openhuman/webview_notifications/bus.rs create mode 100644 src/openhuman/webview_notifications/dispatch.rs create mode 100644 src/openhuman/webview_notifications/mod.rs create mode 100644 src/openhuman/webview_notifications/schemas.rs create mode 100644 src/openhuman/webview_notifications/types.rs diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index fd4629f59..76d024285 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ compile_error!("src-tauri host is desktop-only. Non-desktop targets are not supp mod core_process; mod core_update; +mod notification_settings; #[cfg(feature = "cef")] mod discord_scanner; #[cfg(feature = "cef")] @@ -574,7 +575,8 @@ pub fn run() { .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .manage(DictationHotkeyState(Mutex::new(Vec::new()))) - .manage(webview_accounts::WebviewAccountsState::default()); + .manage(webview_accounts::WebviewAccountsState::default()) + .manage(notification_settings::NotificationSettingsState::new()); #[cfg(feature = "cef")] let builder = builder.manage(std::sync::Arc::new(imessage_scanner::ScannerRegistry::new())); let builder = builder.manage(whatsapp_scanner::ScannerRegistry::new()); @@ -884,6 +886,8 @@ pub fn run() { webview_accounts::webview_account_show, webview_accounts::webview_recipe_event, webview_accounts::webview_account_eval, + notification_settings::notification_settings_get, + notification_settings::notification_settings_set, activate_main_window ]) .build(tauri::generate_context!()) diff --git a/app/src-tauri/src/notification_settings/mod.rs b/app/src-tauri/src/notification_settings/mod.rs new file mode 100644 index 000000000..59f0d8285 --- /dev/null +++ b/app/src-tauri/src/notification_settings/mod.rs @@ -0,0 +1,83 @@ +//! Shell-side runtime toggle for webview-originated OS notifications. +//! +//! The embedded webviews (Slack, Gmail, Discord, …) can fire native OS +//! notifications via the CEF IPC hook in `webview_accounts`. This domain +//! owns the on/off switch: OFF by default so v1 ships the plumbing +//! without producing a toast storm the first time someone opens a busy +//! Slack tab. +//! +//! State lives in the Tauri shell rather than the core sidecar so the +//! settings UI can flip it without a JSON-RPC round-trip. Persistence is +//! frontend-side (Redux/localStorage) — on boot the React side reads its +//! persisted value and pushes it down via `notification_settings_set`. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use serde::{Deserialize, Serialize}; + +/// Tauri-managed state holding the current feature-flag value. +/// +/// Wrapped in an `AtomicBool` so reads from the CEF notification +/// callback (which runs on a CEF thread, not the Tauri runtime thread) +/// stay lock-free. +pub struct NotificationSettingsState { + enabled: AtomicBool, +} + +impl NotificationSettingsState { + /// Construct the initial state — v1 defaults to **disabled**. + pub fn new() -> Self { + Self { + enabled: AtomicBool::new(false), + } + } + + /// Current feature-flag value. + pub fn enabled(&self) -> bool { + self.enabled.load(Ordering::Relaxed) + } + + /// Update the feature-flag value. Returns the previous value so + /// callers can log a single line about the transition if they want. + pub fn set_enabled(&self, value: bool) -> bool { + self.enabled.swap(value, Ordering::Relaxed) + } +} + +impl Default for NotificationSettingsState { + fn default() -> Self { + Self::new() + } +} + +/// Payload returned to the frontend. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct NotificationSettingsPayload { + pub enabled: bool, +} + +/// Read the current notification feature-flag value. +#[tauri::command] +pub fn notification_settings_get( + state: tauri::State<'_, NotificationSettingsState>, +) -> NotificationSettingsPayload { + NotificationSettingsPayload { + enabled: state.enabled(), + } +} + +/// Update the current notification feature-flag value. Returns the new +/// value so the caller can round-trip confirm. +#[tauri::command] +pub fn notification_settings_set( + state: tauri::State<'_, NotificationSettingsState>, + enabled: bool, +) -> NotificationSettingsPayload { + let prev = state.set_enabled(enabled); + log::info!( + "[notify-settings] feature-flag transition enabled={} (was {})", + enabled, + prev + ); + NotificationSettingsPayload { enabled } +} diff --git a/app/src-tauri/src/webview_accounts/mod.rs b/app/src-tauri/src/webview_accounts/mod.rs index 078ea6904..f970c7e00 100644 --- a/app/src-tauri/src/webview_accounts/mod.rs +++ b/app/src-tauri/src/webview_accounts/mod.rs @@ -229,10 +229,36 @@ pub struct WebviewAccountsState { browser_ids: Mutex>, } +/// Title prefix applied to every OS toast fired from an embedded webview. +/// Matches `openhuman_core::webview_notifications::OPENHUMAN_TITLE_PREFIX` +/// — kept inline here so the shell crate doesn't take a build-time dep on +/// the core library. Disambiguates from natively-installed apps (Slack, +/// Discord, Gmail desktop) firing the same message twice. +#[cfg(feature = "cef")] +const OPENHUMAN_TITLE_PREFIX: &str = "OpenHuman: "; + +/// Serialised fire-event payload shipped to the frontend over the +/// `webview-notification:fired` Tauri event. Carries `account_id` + +/// `provider` so the React side can route a subsequent click back to +/// the originating webview via Redux. +#[cfg(feature = "cef")] +#[derive(Debug, Clone, Serialize)] +struct WebviewNotificationFired { + account_id: String, + provider: String, + title: String, + body: String, + #[serde(skip_serializing_if = "Option::is_none")] + tag: Option, +} + /// Translate a `tauri-runtime-cef` notification payload into a native OS -/// toast via `tauri-plugin-notification`. Title is prefixed with the -/// human-readable provider label so a glance tells the user which webview -/// fired the ping. +/// toast via `tauri-plugin-notification`, and mirror the fire to the +/// React frontend so it can drive click-to-focus routing. +/// +/// Gated on the runtime `NotificationSettings` flag (OFF by default) so +/// v1 ships the plumbing without surprising users with a toast storm the +/// first time they open a busy Slack tab. #[cfg(feature = "cef")] fn forward_native_notification( app: &AppHandle, @@ -240,12 +266,25 @@ fn forward_native_notification( provider: &str, payload: &tauri_runtime_cef::notification::NotificationPayload, ) { + // Feature flag — bail early when the user hasn't opted in. + if let Some(settings) = app.try_state::() + { + if !settings.enabled() { + log::debug!( + "[notify-cef][{}] suppressed (feature flag off) provider={}", + account_id, + provider + ); + return; + } + } + let provider_label = provider_display_name(provider); - let raw_title = payload.title.as_str(); + let raw_title = payload.title.as_str().trim(); let notify_title = if raw_title.is_empty() { - provider_label.to_string() + format!("{OPENHUMAN_TITLE_PREFIX}{provider_label}") } else { - format!("{} — {}", provider_label, raw_title) + format!("{OPENHUMAN_TITLE_PREFIX}{provider_label} — {raw_title}") }; let body = payload.body.as_deref().unwrap_or(""); log::info!( @@ -257,6 +296,24 @@ fn forward_native_notification( raw_title, body.chars().count() ); + + // Mirror to the frontend BEFORE firing the OS toast so the Redux + // store has the routing context ready by the time the user clicks. + let fired = WebviewNotificationFired { + account_id: account_id.to_string(), + provider: provider.to_string(), + title: notify_title.clone(), + body: body.to_string(), + tag: payload.tag.clone(), + }; + if let Err(err) = app.emit("webview-notification:fired", &fired) { + log::warn!( + "[notify-cef][{}] emit webview-notification:fired failed: {}", + account_id, + err + ); + } + let mut builder = app.notification().builder().title(¬ify_title); if !body.is_empty() { builder = builder.body(body); diff --git a/app/src/App.tsx b/app/src/App.tsx index 7c393189b..c66aa83db 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -13,6 +13,7 @@ import MeshGradient from './components/MeshGradient'; import OnboardingOverlay from './components/OnboardingOverlay'; import RouteLoadingScreen from './components/RouteLoadingScreen'; import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner'; +import { startWebviewNotificationsService } from './lib/webviewNotifications'; import ChatRuntimeProvider from './providers/ChatRuntimeProvider'; import CoreStateProvider from './providers/CoreStateProvider'; import SocketProvider from './providers/SocketProvider'; @@ -27,6 +28,7 @@ import { isAccountsFullscreen } from './utils/accountsFullscreen'; // are handled even when the user hasn't navigated to /accounts yet. // Idempotent — the service uses a `started` singleton guard. startWebviewAccountService(); +startWebviewNotificationsService(); function App() { return ( diff --git a/app/src/lib/webviewNotifications/index.ts b/app/src/lib/webviewNotifications/index.ts new file mode 100644 index 000000000..559fb37e4 --- /dev/null +++ b/app/src/lib/webviewNotifications/index.ts @@ -0,0 +1,6 @@ +export { + handleNotificationClick, + startWebviewNotificationsService, + stopWebviewNotificationsService, +} from './service'; +export { WEBVIEW_NOTIFICATION_FIRED_EVENT, type WebviewNotificationFired } from './types'; diff --git a/app/src/lib/webviewNotifications/service.test.ts b/app/src/lib/webviewNotifications/service.test.ts new file mode 100644 index 000000000..3d1d76a8c --- /dev/null +++ b/app/src/lib/webviewNotifications/service.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { store } from '../../store'; +import { addAccount } from '../../store/accountsSlice'; +import { __handleFiredForTests, __resetForTests, handleNotificationClick } from './service'; + +const sampleAccount = { + id: 'acct1', + provider: 'slack' as const, + label: 'Slack', + createdAt: '2026-01-01T00:00:00Z', + status: 'open' as const, +}; + +describe('webviewNotifications service', () => { + beforeEach(() => { + __resetForTests(); + store.dispatch(addAccount(sampleAccount)); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('fired events increment unread via Redux', () => { + const before = store.getState().accounts.unread.acct1 ?? 0; + __handleFiredForTests({ + account_id: 'acct1', + provider: 'slack', + title: 'OpenHuman: Slack — Ping', + body: 'hi', + tag: null, + }); + const after = store.getState().accounts.unread.acct1 ?? 0; + expect(after).toBe(before + 1); + }); + + it('handleNotificationClick focuses account and clears unread', () => { + __handleFiredForTests({ + account_id: 'acct1', + provider: 'slack', + title: 'OpenHuman: Slack — Ping', + body: '', + }); + expect(store.getState().accounts.unread.acct1).toBeGreaterThan(0); + + handleNotificationClick('acct1'); + expect(store.getState().accounts.activeAccountId).toBe('acct1'); + expect(store.getState().accounts.unread.acct1).toBe(0); + }); + + it('fired events for unknown accounts are no-ops', () => { + __handleFiredForTests({ account_id: 'ghost', provider: 'slack', title: 't', body: 'b' }); + expect(store.getState().accounts.unread.ghost).toBeUndefined(); + }); +}); diff --git a/app/src/lib/webviewNotifications/service.ts b/app/src/lib/webviewNotifications/service.ts new file mode 100644 index 000000000..c469a92cc --- /dev/null +++ b/app/src/lib/webviewNotifications/service.ts @@ -0,0 +1,82 @@ +import { isTauri } from '@tauri-apps/api/core'; +import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import debug from 'debug'; + +import { store } from '../../store'; +import { + focusAccountFromNotification, + noteWebviewNotificationFired, +} from '../../store/accountsSlice'; +import { WEBVIEW_NOTIFICATION_FIRED_EVENT, type WebviewNotificationFired } from './types'; + +const log = debug('webview-notifications'); +const errLog = debug('webview-notifications:error'); + +let started = false; +let unlisten: UnlistenFn | null = null; + +/** + * Subscribe to `webview-notification:fired` events from the Tauri shell and + * mirror each fire into Redux so the sidebar can bump an unread badge on + * the originating account. Idempotent — subsequent calls are no-ops. + */ +export function startWebviewNotificationsService(): void { + if (started) return; + if (!isTauri()) { + log('not running in tauri, skipping subscription'); + return; + } + started = true; + + listen(WEBVIEW_NOTIFICATION_FIRED_EVENT, event => { + handleFired(event.payload); + }) + .then(fn => { + unlisten = fn; + log('subscribed to %s', WEBVIEW_NOTIFICATION_FIRED_EVENT); + }) + .catch(err => { + errLog('failed to subscribe: %O', err); + started = false; + }); +} + +export function stopWebviewNotificationsService(): void { + if (unlisten) { + unlisten(); + unlisten = null; + } + started = false; +} + +/** + * Route a user-visible "click this notification" intent back to the + * originating account — focuses it and clears the unread count. Safe to + * call from in-app toast UIs or a future OS-notification click hook. + */ +export function handleNotificationClick(accountId: string): void { + store.dispatch(focusAccountFromNotification({ accountId })); +} + +function handleFired(payload: WebviewNotificationFired): void { + const { account_id: accountId, provider, title, body } = payload; + log( + 'fired account=%s provider=%s title_chars=%d body_chars=%d', + accountId, + provider, + title.length, + body.length + ); + store.dispatch(noteWebviewNotificationFired({ accountId })); +} + +/** Exposed for tests — resets module singletons between runs. */ +export function __resetForTests(): void { + started = false; + unlisten = null; +} + +/** Exposed for tests — dispatches as if a fired event arrived. */ +export function __handleFiredForTests(payload: WebviewNotificationFired): void { + handleFired(payload); +} diff --git a/app/src/lib/webviewNotifications/types.ts b/app/src/lib/webviewNotifications/types.ts new file mode 100644 index 000000000..f50f6c85e --- /dev/null +++ b/app/src/lib/webviewNotifications/types.ts @@ -0,0 +1,15 @@ +/** + * Shape of the `webview-notification:fired` Tauri event payload emitted by + * the Rust shell whenever an embedded webview renderer creates a native + * notification. Mirror of `WebviewNotificationFired` in + * `app/src-tauri/src/webview_accounts/mod.rs` — keep the two in sync. + */ +export interface WebviewNotificationFired { + account_id: string; + provider: string; + title: string; + body: string; + tag?: string | null; +} + +export const WEBVIEW_NOTIFICATION_FIRED_EVENT = 'webview-notification:fired'; diff --git a/app/src/store/__tests__/accountsSlice.webviewNotifications.test.ts b/app/src/store/__tests__/accountsSlice.webviewNotifications.test.ts new file mode 100644 index 000000000..bb8d1475b --- /dev/null +++ b/app/src/store/__tests__/accountsSlice.webviewNotifications.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; + +import reducer, { + addAccount, + focusAccountFromNotification, + noteWebviewNotificationFired, +} from '../accountsSlice'; + +const sampleAccount = { + id: 'acct1', + provider: 'slack' as const, + label: 'Slack', + createdAt: '2026-01-01T00:00:00Z', + status: 'open' as const, +}; + +describe('accountsSlice webview-notification actions', () => { + it('noteWebviewNotificationFired increments unread for known accounts', () => { + const added = reducer(undefined, addAccount(sampleAccount)); + const fired = reducer(added, noteWebviewNotificationFired({ accountId: 'acct1' })); + const firedTwice = reducer(fired, noteWebviewNotificationFired({ accountId: 'acct1' })); + expect(firedTwice.unread.acct1).toBe(2); + }); + + it('noteWebviewNotificationFired ignores unknown accounts', () => { + const fired = reducer(undefined, noteWebviewNotificationFired({ accountId: 'ghost' })); + expect(fired.unread.ghost).toBeUndefined(); + }); + + it('focusAccountFromNotification sets active account and clears unread', () => { + let state = reducer(undefined, addAccount(sampleAccount)); + state = reducer(state, noteWebviewNotificationFired({ accountId: 'acct1' })); + state = reducer(state, noteWebviewNotificationFired({ accountId: 'acct1' })); + expect(state.unread.acct1).toBe(2); + + state = reducer(state, focusAccountFromNotification({ accountId: 'acct1' })); + expect(state.activeAccountId).toBe('acct1'); + expect(state.unread.acct1).toBe(0); + }); + + it('focusAccountFromNotification ignores unknown accounts', () => { + const state = reducer(undefined, focusAccountFromNotification({ accountId: 'ghost' })); + expect(state.activeAccountId).toBeNull(); + }); +}); diff --git a/app/src/store/accountsSlice.ts b/app/src/store/accountsSlice.ts index 7d96dcf65..622e75725 100644 --- a/app/src/store/accountsSlice.ts +++ b/app/src/store/accountsSlice.ts @@ -88,6 +88,19 @@ const accountsSlice = createSlice({ } }, + noteWebviewNotificationFired(state, action: PayloadAction<{ accountId: string }>) { + const { accountId } = action.payload; + if (!state.accounts[accountId]) return; + state.unread[accountId] = (state.unread[accountId] ?? 0) + 1; + }, + + focusAccountFromNotification(state, action: PayloadAction<{ accountId: string }>) { + const { accountId } = action.payload; + if (!state.accounts[accountId]) return; + state.activeAccountId = accountId; + state.unread[accountId] = 0; + }, + resetAccountsState() { return initialState; }, @@ -101,6 +114,8 @@ export const { setAccountStatus, appendMessages, appendLog, + noteWebviewNotificationFired, + focusAccountFromNotification, resetAccountsState, } = accountsSlice.actions; diff --git a/src/core/all.rs b/src/core/all.rs index 5965fb795..c094eb4d7 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -141,6 +141,10 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::learning::all_learning_registered_controllers()); // Conversation thread and message management controllers.extend(crate::openhuman::threads::all_threads_registered_controllers()); + // Embedded webview native notifications + controllers.extend( + crate::openhuman::webview_notifications::all_webview_notifications_registered_controllers(), + ); controllers } @@ -189,6 +193,10 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::learning::all_learning_controller_schemas()); // Conversation thread and message management schemas.extend(crate::openhuman::threads::all_threads_controller_schemas()); + // Embedded webview native notifications + schemas.extend( + crate::openhuman::webview_notifications::all_webview_notifications_controller_schemas(), + ); schemas } diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 77ee3d093..4cf3229f2 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -61,4 +61,5 @@ pub mod update; pub mod util; pub mod voice; pub mod webhooks; +pub mod webview_notifications; pub mod workspace; diff --git a/src/openhuman/webview_notifications/bus.rs b/src/openhuman/webview_notifications/bus.rs new file mode 100644 index 000000000..57687d0e7 --- /dev/null +++ b/src/openhuman/webview_notifications/bus.rs @@ -0,0 +1,8 @@ +//! Cross-module events for webview notifications. +//! +//! v1 is deliberately empty: the Tauri shell owns the CEF IPC hook and +//! fires notifications directly to the frontend over the Tauri event +//! bus (`webview-notification:fired`). When follow-up phases need core +//! subscribers (e.g. archiving notification history into the memory +//! store) they land here as `EventHandler` implementations wired from +//! the singleton bus. diff --git a/src/openhuman/webview_notifications/dispatch.rs b/src/openhuman/webview_notifications/dispatch.rs new file mode 100644 index 000000000..03ec620bd --- /dev/null +++ b/src/openhuman/webview_notifications/dispatch.rs @@ -0,0 +1,57 @@ +//! Title formatting shared between core and the Tauri shell. +//! +//! Why the prefix: embedded webviews (Slack, Discord, Gmail) may be +//! open alongside the user's locally-installed native apps for the +//! same service. Both would fire OS toasts for the same DM. Prefixing +//! the title with `OpenHuman:` makes it trivial for the user to tell +//! the two apart and also gives the OS notification centre a distinct +//! grouping key. + +/// Prefix applied to every OS notification title fired by a webview +/// event. Trailing space so the separation from the raw title reads +/// naturally (`OpenHuman: New message from …`). +pub const OPENHUMAN_TITLE_PREFIX: &str = "OpenHuman: "; + +/// Format the native-toast title for a webview notification. +/// +/// `provider_label` is the human-readable provider name (e.g. `Slack`), +/// `raw_title` is the renderer-supplied title (may be empty). +/// +/// Layout: `OpenHuman: ` when both pieces are +/// present, collapsing to `OpenHuman: ` when the raw title is +/// empty or whitespace-only. +pub fn format_title(provider_label: &str, raw_title: &str) -> String { + let raw = raw_title.trim(); + if raw.is_empty() { + format!("{OPENHUMAN_TITLE_PREFIX}{provider_label}") + } else { + format!("{OPENHUMAN_TITLE_PREFIX}{provider_label} — {raw}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn prefix_empty_title_falls_back_to_provider_only() { + assert_eq!(format_title("Slack", ""), "OpenHuman: Slack"); + assert_eq!(format_title("Slack", " "), "OpenHuman: Slack"); + } + + #[test] + fn prefix_with_title_joins_with_em_dash() { + assert_eq!( + format_title("Gmail", "New mail from Alice"), + "OpenHuman: Gmail — New mail from Alice" + ); + } + + #[test] + fn prefix_trims_raw_title_whitespace() { + assert_eq!( + format_title("Discord", " ping "), + "OpenHuman: Discord — ping" + ); + } +} diff --git a/src/openhuman/webview_notifications/mod.rs b/src/openhuman/webview_notifications/mod.rs new file mode 100644 index 000000000..843ba4ea2 --- /dev/null +++ b/src/openhuman/webview_notifications/mod.rs @@ -0,0 +1,25 @@ +//! Webview-originated Web Notifications routed to the OS. +//! +//! Scope (v1): deliver `window.Notification` invocations from embedded +//! webviews (Slack, Gmail, Discord, …) as native OS toasts, with the +//! account + provider encoded on the notification so the UI can focus +//! the right webview on click. +//! +//! The CEF IPC hook that captures the renderer-side call lives in the +//! Tauri shell crate (`openhuman` crate at `app/src-tauri/` — +//! `tauri_runtime_cef::notification::register`). This domain owns the +//! shared wire types, the title-formatting contract (`OpenHuman:` +//! prefix for dedup against installed native apps), and future +//! controllers that read/write the user-facing on/off toggle over +//! JSON-RPC. + +pub mod bus; +pub mod dispatch; +pub mod schemas; +pub mod types; + +pub use dispatch::{format_title, OPENHUMAN_TITLE_PREFIX}; +pub use schemas::{ + all_webview_notifications_controller_schemas, all_webview_notifications_registered_controllers, +}; +pub use types::{NotificationSettings, WebviewNotificationEvent}; diff --git a/src/openhuman/webview_notifications/schemas.rs b/src/openhuman/webview_notifications/schemas.rs new file mode 100644 index 000000000..217b9c5e5 --- /dev/null +++ b/src/openhuman/webview_notifications/schemas.rs @@ -0,0 +1,19 @@ +//! Controller registry for `webview_notifications`. +//! +//! v1 has no user-facing controllers: the on/off toggle lives in the +//! Tauri shell (per-install state rather than core config) so the +//! settings UI can flip it without a sidecar round-trip. The stubs +//! below exist so this domain participates in `src/core/all.rs` the +//! same way every other domain does, which keeps future additions +//! (notification history, per-account mute, etc.) a trivial extend. + +use crate::core::all::RegisteredController; +use crate::core::ControllerSchema; + +pub fn all_webview_notifications_controller_schemas() -> Vec { + Vec::new() +} + +pub fn all_webview_notifications_registered_controllers() -> Vec { + Vec::new() +} diff --git a/src/openhuman/webview_notifications/types.rs b/src/openhuman/webview_notifications/types.rs new file mode 100644 index 000000000..efd749b1e --- /dev/null +++ b/src/openhuman/webview_notifications/types.rs @@ -0,0 +1,39 @@ +//! Shared wire types for webview-originated notifications. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Payload emitted from the Tauri shell when a webview renderer fires a +/// `window.Notification`. Carried verbatim to the React side over the +/// `webview-notification:fired` Tauri event so the UI can bump unread +/// counts, show its own in-app toast, and route a subsequent click back +/// to the right embedded webview via Redux. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct WebviewNotificationEvent { + /// Stable account id from the Redux `accounts` slice (persisted). + pub account_id: String, + /// Provider id, e.g. `slack`, `gmail`, `discord`. + pub provider: String, + /// OS-visible title (already `OpenHuman:`-prefixed by `format_title`). + pub title: String, + /// OS-visible body. Empty string when the page didn't set one. + pub body: String, + /// Optional renderer-supplied `tag` for native dedup. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tag: Option, +} + +/// Runtime on/off toggle for the feature. Defaults to **disabled** — +/// v1 ships the plumbing but requires an explicit opt-in so the +/// release doesn't suddenly start firing OS toasts for every +/// background DM in an idle Slack tab. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, JsonSchema)] +pub struct NotificationSettings { + pub enabled: bool, +} + +impl Default for NotificationSettings { + fn default() -> Self { + Self { enabled: false } + } +}