mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Fix notification-permission flow and onboarding copy for system test notifications (#1024)
This commit is contained in:
@@ -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 didn’t 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 didn’t 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 didn’t receive it/i)
|
||||
).toBeInTheDocument();
|
||||
expect(showNativeNotification).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user