mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-29 22:23:01 +00:00
feat(notifications): webview notifications end-to-end (Phase 7) (#741)
* 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 <jshah1331@gmail.com>
This commit is contained in:
co-authored by
Jwalin Shah
parent
1b6a16f1ab
commit
4a5a00cb96
@@ -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!())
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -229,10 +229,36 @@ pub struct WebviewAccountsState {
|
||||
browser_ids: Mutex<HashMap<String, i32>>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
/// 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<R: Runtime>(
|
||||
app: &AppHandle<R>,
|
||||
@@ -240,12 +266,25 @@ fn forward_native_notification<R: Runtime>(
|
||||
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::<crate::notification_settings::NotificationSettingsState>()
|
||||
{
|
||||
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<R: Runtime>(
|
||||
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);
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
handleNotificationClick,
|
||||
startWebviewNotificationsService,
|
||||
stopWebviewNotificationsService,
|
||||
} from './service';
|
||||
export { WEBVIEW_NOTIFICATION_FIRED_EVENT, type WebviewNotificationFired } from './types';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<WebviewNotificationFired>(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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -141,6 +141,10 @@ fn build_registered_controllers() -> Vec<RegisteredController> {
|
||||
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<ControllerSchema> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -61,4 +61,5 @@ pub mod update;
|
||||
pub mod util;
|
||||
pub mod voice;
|
||||
pub mod webhooks;
|
||||
pub mod webview_notifications;
|
||||
pub mod workspace;
|
||||
|
||||
@@ -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.
|
||||
@@ -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: <Provider> — <raw title>` when both pieces are
|
||||
/// present, collapsing to `OpenHuman: <Provider>` 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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<ControllerSchema> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn all_webview_notifications_registered_controllers() -> Vec<RegisteredController> {
|
||||
Vec::new()
|
||||
}
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user