From 078a6b29a9afd6cf2c43b2e825644a41e4c0eb7f Mon Sep 17 00:00:00 2001 From: YellowSnnowmann <167776381+YellowSnnowmann@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:41:09 +0530 Subject: [PATCH] Fix notification-permission flow and onboarding copy for system test notifications (#1024) --- app/src/components/OpenhumanLinkModal.tsx | 97 ++++++++------ .../OpenhumanLinkModal.notifications.test.tsx | 122 ++++++++++++++++++ .../lib/nativeNotifications/tauriBridge.ts | 81 ++++++++---- app/src/services/webviewAccountService.ts | 2 + 4 files changed, 236 insertions(+), 66 deletions(-) create mode 100644 app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx diff --git a/app/src/components/OpenhumanLinkModal.tsx b/app/src/components/OpenhumanLinkModal.tsx index c2b1e83de..2eef5be37 100644 --- a/app/src/components/OpenhumanLinkModal.tsx +++ b/app/src/components/OpenhumanLinkModal.tsx @@ -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(null); - const TEST_NOTIFICATION_DELAY_MS = 3500; - const notificationTimeoutRef = useRef(null); - const cancelledRef = useRef(false); + const [permissionState, setPermissionState] = useState('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(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 }) => {

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.

+ {permissionState === 'denied' && ( +
+ Notifications are currently blocked. +
+ 1. Open System Settings → Notifications → OpenHuman +
+ 2. Turn on Allow Notifications +
+ 3. Return here and tap Retry test notification +
+ )} + {(permissionState === 'prompt' || permissionState === 'unknown') && ( +
+ First step: tap Send test notification and allow permission in the macOS prompt. +
+ )} - {status === 'queued' && ( -

- Timer started. Switch to another app for a moment so the banner can pop out. -

- )} {status === 'sent' && (

- 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.

)} {status === 'error' &&

Couldn't send: {error}

} diff --git a/app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx b/app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx new file mode 100644 index 000000000..2508964eb --- /dev/null +++ b/app/src/components/__tests__/OpenhumanLinkModal.notifications.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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); + }); +}); diff --git a/app/src/lib/nativeNotifications/tauriBridge.ts b/app/src/lib/nativeNotifications/tauriBridge.ts index c648dac36..b38604917 100644 --- a/app/src/lib/nativeNotifications/tauriBridge.ts +++ b/app/src/lib/nativeNotifications/tauriBridge.ts @@ -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 { + const requestIfNeeded = options?.requestIfNeeded ?? true; + if (!isTauri()) { + return 'not_tauri'; + } + + try { + const grantedRaw = await invoke('plugin:notification|is_permission_granted'); + if (grantedRaw === true) return 'granted'; + + if (!requestIfNeeded) { + return 'prompt'; + } + + const requestRaw = await invoke('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 { - if (!isTauri()) { - log('not running in tauri, skipping permission request'); - return false; - } - try { - const grantedRaw = await invoke('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('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('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 { +export async function showNativeNotification( + args: ShowNativeNotificationArgs +): Promise { 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 }; } } diff --git a/app/src/services/webviewAccountService.ts b/app/src/services/webviewAccountService.ts index 620f8c244..26335963b 100644 --- a/app/src/services/webviewAccountService.ts +++ b/app/src/services/webviewAccountService.ts @@ -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;