Files
openhuman/app/src/lib/webviewNotifications/service.ts
T
4a5a00cb96 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>
2026-04-21 22:25:12 -07:00

83 lines
2.4 KiB
TypeScript

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);
}