Fix notification-permission flow and onboarding copy for system test notifications (#1024)

This commit is contained in:
YellowSnnowmann
2026-04-29 11:11:09 -07:00
committed by GitHub
parent a09222b4ec
commit 078a6b29a9
4 changed files with 236 additions and 66 deletions
+58 -39
View File
@@ -10,15 +10,16 @@
*
* Mounted once at AppShell root.
*/
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useChannelDefinitions } from '../hooks/useChannelDefinitions';
import {
ensureNotificationPermission,
getNotificationPermissionState,
type NotificationPermissionState,
showNativeNotification,
} from '../lib/nativeNotifications/tauriBridge';
import { purgeWebviewAccount } from '../services/webviewAccountService';
import { isTauri, purgeWebviewAccount } from '../services/webviewAccountService';
import { addAccount, removeAccount } from '../store/accountsSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { type Account, type AccountProvider, PROVIDERS } from '../types/accounts';
@@ -185,53 +186,61 @@ function renderBody(path: string, close: () => void) {
// ── Notifications ────────────────────────────────────────────────────────
const NotificationsBody = ({ close }: { close: () => void }) => {
const [status, setStatus] = useState<'idle' | 'sending' | 'queued' | 'sent' | 'error'>('idle');
const [status, setStatus] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle');
const [error, setError] = useState<string | null>(null);
const TEST_NOTIFICATION_DELAY_MS = 3500;
const notificationTimeoutRef = useRef<number | null>(null);
const cancelledRef = useRef(false);
const [permissionState, setPermissionState] = useState<NotificationPermissionState>('unknown');
useEffect(() => {
let mounted = true;
void getNotificationPermissionState({ requestIfNeeded: false }).then(next => {
if (!mounted) return;
setPermissionState(next);
});
return () => {
cancelledRef.current = true;
if (notificationTimeoutRef.current !== null) {
window.clearTimeout(notificationTimeoutRef.current);
notificationTimeoutRef.current = null;
}
mounted = false;
};
}, []);
const handleAllow = async () => {
if (status === 'sending') {
return;
}
setStatus('sending');
setError(null);
try {
const granted = await ensureNotificationPermission();
if (coreIsTauri() && !granted) {
if (!isTauri()) {
setStatus('error');
setError(
'Notification permission was denied. Please enable notifications for OpenHuman in System Settings Notifications.'
'Native notifications are only available in the desktop app (run `pnpm dev:app`).'
);
return;
}
// Delay the test ping so users can move focus away from OpenHuman.
// macOS commonly suppresses banner popouts while the app is foreground.
setStatus('queued');
await new Promise<void>(resolve => {
notificationTimeoutRef.current = window.setTimeout(() => {
notificationTimeoutRef.current = null;
resolve();
}, TEST_NOTIFICATION_DELAY_MS);
});
if (cancelledRef.current) {
const granted = await ensureNotificationPermission();
if (!granted) {
const nextState = await getNotificationPermissionState({ requestIfNeeded: false });
setPermissionState(nextState);
setStatus('error');
setError(
'Notification permission is off. Enable OpenHuman in System Settings Notifications, then retry.'
);
return;
}
await showNativeNotification({
const sendResult = await showNativeNotification({
title: 'OpenHuman is good to go',
body: 'You will get pings here when something needs your attention.',
tag: 'welcome-notification-test',
});
if (cancelledRef.current) {
if (!sendResult.delivered) {
setStatus('error');
setError(
sendResult.error ??
'OpenHuman could not trigger a system notification. Check OS notification settings and retry.'
);
return;
}
setPermissionState('granted');
setStatus('sent');
} catch (e) {
setStatus('error');
@@ -243,29 +252,39 @@ const NotificationsBody = ({ close }: { close: () => void }) => {
<div className="space-y-4 text-sm text-stone-700">
<p>
OpenHuman uses native notifications so it can ping you when something needs your attention,
even when the chat window is hidden. Click below to send a test, your OS will ask for
permission the first time.
even when the chat window is hidden.
</p>
{permissionState === 'denied' && (
<div className="rounded-xl border border-coral-200 bg-coral-50 p-3 text-xs text-coral-700">
Notifications are currently blocked.
<br />
1. Open System Settings → Notifications → OpenHuman
<br />
2. Turn on Allow Notifications
<br />
3. Return here and tap Retry test notification
</div>
)}
{(permissionState === 'prompt' || permissionState === 'unknown') && (
<div className="rounded-xl border border-stone-200 bg-stone-50 p-3 text-xs text-stone-700">
First step: tap Send test notification and allow permission in the macOS prompt.
</div>
)}
<button
type="button"
onClick={() => void handleAllow()}
disabled={status === 'sending' || status === 'queued'}
disabled={status === 'sending'}
className="w-full rounded-xl bg-primary-500 text-white text-sm font-medium py-2.5 hover:bg-primary-600 transition-colors disabled:opacity-60">
{status === 'sending'
? 'Asking your OS'
: status === 'queued'
? 'Sending test in 3.5s switch apps now'
: status === 'error'
? 'Retry test notification'
: 'Send test notification'}
</button>
{status === 'queued' && (
<p className="text-xs text-stone-600">
Timer started. Switch to another app for a moment so the banner can pop out.
</p>
)}
{status === 'sent' && (
<p className="text-xs text-sage-700">
Sent. If you saw a pop-up in the corner, you're all set. If your OS asked for permission,
allow it and then tell the agent it's done.
Test notification sent. If you didnt receive it, go to System Settings → Notifications →
OpenHuman, turn on Allow Notifications, and set Banner Style to Persistent.
</p>
)}
{status === 'error' && <p className="text-xs text-coral-600">Couldn't send: {error}</p>}
@@ -0,0 +1,122 @@
import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
ensureNotificationPermission,
getNotificationPermissionState,
showNativeNotification,
} from '../../lib/nativeNotifications/tauriBridge';
import OpenhumanLinkModal, { OPENHUMAN_LINK_EVENT } from '../OpenhumanLinkModal';
vi.mock('@tauri-apps/api/core', () => ({ isTauri: vi.fn() }));
vi.mock('../../lib/nativeNotifications/tauriBridge', () => ({
ensureNotificationPermission: vi.fn(),
getNotificationPermissionState: vi.fn(),
showNativeNotification: vi.fn(),
}));
describe('OpenhumanLinkModal notifications test flow', () => {
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getNotificationPermissionState).mockResolvedValue('prompt');
});
function openNotificationsModal() {
act(() => {
window.dispatchEvent(
new CustomEvent(OPENHUMAN_LINK_EVENT, { detail: { path: 'settings/notifications' } })
);
});
}
async function flushAsyncWork() {
await act(async () => {
await Promise.resolve();
});
}
it('shows success after permission is granted and native notification send succeeds', async () => {
vi.mocked(coreIsTauri).mockReturnValue(true);
vi.mocked(ensureNotificationPermission).mockResolvedValue(true);
vi.mocked(showNativeNotification).mockResolvedValue({ delivered: true });
render(<OpenhumanLinkModal />);
openNotificationsModal();
fireEvent.click(screen.getByRole('button', { name: 'Send test notification' }));
await flushAsyncWork();
expect(
screen.getByText(/Test notification sent\. If you didnt receive it/i)
).toBeInTheDocument();
expect(showNativeNotification).toHaveBeenCalledWith(
expect.objectContaining({ tag: 'welcome-notification-test' })
);
});
it('shows actionable error when permission is denied and does not send notification', async () => {
vi.mocked(coreIsTauri).mockReturnValue(true);
vi.mocked(ensureNotificationPermission).mockResolvedValue(false);
vi.mocked(getNotificationPermissionState).mockResolvedValue('denied');
render(<OpenhumanLinkModal />);
openNotificationsModal();
fireEvent.click(screen.getByRole('button', { name: 'Send test notification' }));
await flushAsyncWork();
expect(screen.getByText(/Notification permission is off\./i)).toBeInTheDocument();
expect(showNativeNotification).not.toHaveBeenCalled();
expect(screen.getByRole('button', { name: 'Retry test notification' })).toBeInTheDocument();
});
it('shows send failure message when native notification call fails', async () => {
vi.mocked(coreIsTauri).mockReturnValue(true);
vi.mocked(ensureNotificationPermission).mockResolvedValue(true);
vi.mocked(showNativeNotification).mockResolvedValue({
delivered: false,
reason: 'send_failed',
error: 'notification show failed: test error',
});
render(<OpenhumanLinkModal />);
openNotificationsModal();
fireEvent.click(screen.getByRole('button', { name: 'Send test notification' }));
await flushAsyncWork();
expect(
screen.getByText(/Couldn't send: notification show failed: test error/i)
).toBeInTheDocument();
});
it('retries successfully after user grants permission on a second attempt', async () => {
vi.mocked(coreIsTauri).mockReturnValue(true);
vi.mocked(ensureNotificationPermission)
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);
vi.mocked(getNotificationPermissionState)
.mockResolvedValueOnce('denied')
.mockResolvedValueOnce('denied')
.mockResolvedValueOnce('granted');
vi.mocked(showNativeNotification).mockResolvedValue({ delivered: true });
render(<OpenhumanLinkModal />);
openNotificationsModal();
fireEvent.click(screen.getByRole('button', { name: 'Send test notification' }));
await flushAsyncWork();
expect(screen.getByText(/Notification permission is off\./i)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Retry test notification' }));
await flushAsyncWork();
expect(
screen.getByText(/Test notification sent\. If you didnt receive it/i)
).toBeInTheDocument();
expect(showNativeNotification).toHaveBeenCalledTimes(1);
});
});
+54 -27
View File
@@ -4,9 +4,51 @@ import debug from 'debug';
const log = debug('native-notifications:bridge');
const errLog = debug('native-notifications:bridge:error');
export type NotificationPermissionState = 'not_tauri' | 'granted' | 'denied' | 'prompt' | 'unknown';
export interface ShowNativeNotificationArgs {
title: string;
body: string;
tag?: string;
}
export interface ShowNativeNotificationResult {
delivered: boolean;
reason?: 'not_tauri' | 'send_failed';
error?: string;
}
function isGrantedState(state: string): boolean {
return state === 'granted' || state === 'provisional' || state === 'ephemeral';
}
export async function getNotificationPermissionState(options?: {
requestIfNeeded?: boolean;
}): Promise<NotificationPermissionState> {
const requestIfNeeded = options?.requestIfNeeded ?? true;
if (!isTauri()) {
return 'not_tauri';
}
try {
const grantedRaw = await invoke<boolean | null>('plugin:notification|is_permission_granted');
if (grantedRaw === true) return 'granted';
if (!requestIfNeeded) {
return 'prompt';
}
const requestRaw = await invoke<string>('plugin:notification|request_permission');
const requestState = String(requestRaw ?? 'unknown').toLowerCase();
if (isGrantedState(requestState)) return 'granted';
if (requestState === 'denied') return 'denied';
if (requestState === 'prompt' || requestState === 'default') return 'prompt';
return 'unknown';
} catch (err) {
errLog('getNotificationPermissionState failed: %O', err);
return 'unknown';
}
}
/**
@@ -15,46 +57,31 @@ export interface ShowNativeNotificationArgs {
* No-op (returns false) when running outside Tauri.
*/
export async function ensureNotificationPermission(): Promise<boolean> {
if (!isTauri()) {
log('not running in tauri, skipping permission request');
return false;
}
try {
const grantedRaw = await invoke<boolean | null>('plugin:notification|is_permission_granted');
const granted = grantedRaw === true;
log('notification permission check (plugin): granted=%s raw=%o', granted, grantedRaw);
if (granted) return true;
const requestResult = await invoke<string>('plugin:notification|request_permission');
const requestState = String(requestResult ?? 'unknown').toLowerCase();
const nowGranted = requestState === 'granted' || requestState === 'provisional';
log('notification permission request result=%s granted=%s', requestState, nowGranted);
if (nowGranted) return true;
// Re-check once after request because some platforms may not return
// a definitive granted state from request_permission directly.
const pluginGranted = await invoke<boolean | null>('plugin:notification|is_permission_granted');
return pluginGranted === true;
} catch (err) {
errLog('ensureNotificationPermission failed: %O', err);
return false;
}
const state = await getNotificationPermissionState();
log('notification permission ensure resolved state=%s', state);
return state === 'granted';
}
/**
* Invoke the Tauri shell to show a native OS notification. No-op when the
* app is running outside Tauri (e.g. Vitest / pure-web dev server).
*/
export async function showNativeNotification(args: ShowNativeNotificationArgs): Promise<void> {
export async function showNativeNotification(
args: ShowNativeNotificationArgs
): Promise<ShowNativeNotificationResult> {
if (!isTauri()) {
log('not running in tauri, skipping %o', args);
return;
return { delivered: false, reason: 'not_tauri' };
}
try {
await invoke('plugin:notification|notify', {
options: { title: args.title, body: args.body, sound: 'default' },
});
log('plugin notify success tag=%s', args.tag ?? 'none');
return { delivered: true };
} catch (err) {
errLog('plugin:notification|notify failed: %O', err);
const message = err instanceof Error ? err.message : String(err);
errLog('plugin notify failed: %O', err);
return { delivered: false, reason: 'send_failed', error: message };
}
}
@@ -22,6 +22,8 @@ const MEET_ORCHESTRATOR_MODEL = 'reasoning-v1';
const log = debug('webview-accounts');
const errLog = debug('webview-accounts:error');
export { isTauri };
interface RecipeEventPayload {
account_id: string;
provider: string;