diff --git a/frontend/src/hooks/useBrowserNotifications.ts b/frontend/src/hooks/useBrowserNotifications.ts index 640d499..1b4ed38 100644 --- a/frontend/src/hooks/useBrowserNotifications.ts +++ b/frontend/src/hooks/useBrowserNotifications.ts @@ -9,6 +9,17 @@ const NOTIFICATION_ICON_PATH = '/favicon-256x256.png'; type NotificationPermissionState = NotificationPermission | 'unsupported'; type ConversationNotificationMap = Record; +interface NotificationEnableToastInfo { + level: 'success' | 'warning'; + title: string; + description?: string; +} + +interface NotificationEnvironment { + protocol: string; + isSecureContext: boolean; +} + function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string { return getStateKey(type, id); } @@ -92,6 +103,40 @@ function buildMessageNotificationHash(message: Message): string | null { return null; } +export function getNotificationEnableToastInfo( + environment?: Partial +): NotificationEnableToastInfo { + if (typeof window === 'undefined') { + return { level: 'success', title: 'Notifications enabled' }; + } + + const protocol = environment?.protocol ?? window.location.protocol; + const isSecureContext = environment?.isSecureContext ?? window.isSecureContext; + + if (protocol === 'http:') { + return { + level: 'warning', + title: 'Notifications enabled with warning', + description: + 'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.', + }; + } + + // Best-effort heuristic only. Browsers do not expose certificate trust details + // directly to page JS, so an HTTPS page that is not a secure context is the + // closest signal we have for an untrusted/self-signed setup. + if (protocol === 'https:' && !isSecureContext) { + return { + level: 'warning', + title: 'Notifications enabled with warning', + description: + 'Desktop notifications are on for this conversation, but your HTTPS connection is untrusted, such as a self-signed certificate. Notification delivery may be inconsistent depending on your browser.', + }; + } + + return { level: 'success', title: 'Notifications enabled' }; +} + export function useBrowserNotifications() { const [permission, setPermission] = useState(getInitialPermission); const [enabledByConversation, setEnabledByConversation] = @@ -110,8 +155,6 @@ export function useBrowserNotifications() { const toggleConversationNotifications = useCallback( async (type: 'channel' | 'contact', id: string, label: string) => { - const blockedDescription = - 'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.'; const conversationKey = getConversationNotificationKey(type, id); if (enabledByConversation[conversationKey]) { setEnabledByConversation((prev) => { @@ -120,20 +163,23 @@ export function useBrowserNotifications() { writeStoredEnabledMap(next); return next; }); - toast.success(`${label} notifications disabled`); + toast.success('Notifications disabled', { + description: `Desktop notifications are off for ${label}.`, + }); return; } if (permission === 'unsupported') { - toast.error('Browser notifications unavailable', { + toast.error('Notifications unavailable', { description: 'This browser does not support desktop notifications.', }); return; } if (permission === 'denied') { - toast.error('Browser notifications blocked', { - description: blockedDescription, + toast.error('Notifications blocked', { + description: + 'Desktop notifications are blocked by your browser. Allow notifications in browser settings, then try again. Non-HTTPS or untrusted HTTPS origins may also prevent notifications from working reliably.', }); return; } @@ -155,13 +201,24 @@ export function useBrowserNotifications() { icon: NOTIFICATION_ICON_PATH, tag: `meshcore-notification-preview-${conversationKey}`, }); - toast.success(`${label} notifications enabled`); + const toastInfo = getNotificationEnableToastInfo(); + if (toastInfo.level === 'warning') { + toast.warning(toastInfo.title, { + description: toastInfo.description, + }); + } else { + toast.success(toastInfo.title, { + description: `Desktop notifications are on for ${label}.`, + }); + } return; } - toast.error('Browser notifications not enabled', { + toast.error('Notifications not enabled', { description: - nextPermission === 'denied' ? blockedDescription : 'Permission request was dismissed.', + nextPermission === 'denied' + ? 'Desktop notifications were denied by your browser. Allow notifications in browser settings, then try again.' + : 'The browser permission request was dismissed.', }); }, [enabledByConversation, permission] diff --git a/frontend/src/test/useBrowserNotifications.test.ts b/frontend/src/test/useBrowserNotifications.test.ts index b73063d..db158be 100644 --- a/frontend/src/test/useBrowserNotifications.test.ts +++ b/frontend/src/test/useBrowserNotifications.test.ts @@ -1,12 +1,16 @@ import { act, renderHook } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { useBrowserNotifications } from '../hooks/useBrowserNotifications'; +import { + getNotificationEnableToastInfo, + useBrowserNotifications, +} from '../hooks/useBrowserNotifications'; import type { Message } from '../types'; const mocks = vi.hoisted(() => ({ toast: { success: vi.fn(), + warning: vi.fn(), error: vi.fn(), }, })); @@ -57,6 +61,10 @@ describe('useBrowserNotifications', () => { configurable: true, value: NotificationMock, }); + Object.defineProperty(window, 'isSecureContext', { + configurable: true, + value: true, + }); }); it('stores notification opt-in per conversation', async () => { @@ -84,6 +92,10 @@ describe('useBrowserNotifications', () => { icon: '/favicon-256x256.png', tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`, }); + expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', { + description: + 'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.', + }); }); it('only sends desktop notifications for opted-in conversations', async () => { @@ -164,9 +176,65 @@ describe('useBrowserNotifications', () => { ); }); - expect(mocks.toast.error).toHaveBeenCalledWith('Browser notifications blocked', { + expect(mocks.toast.error).toHaveBeenCalledWith('Notifications blocked', { description: - 'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.', + 'Desktop notifications are blocked by your browser. Allow notifications in browser settings, then try again. Non-HTTPS or untrusted HTTPS origins may also prevent notifications from working reliably.', + }); + }); + + it('shows a warning toast when notifications are enabled on HTTP', async () => { + const { result } = renderHook(() => useBrowserNotifications()); + + await act(async () => { + await result.current.toggleConversationNotifications( + 'channel', + incomingChannelMessage.conversation_key, + '#flightless' + ); + }); + + expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', { + description: + 'Desktop notifications are on for this conversation, but you are using HTTP instead of HTTPS. Notifications will likely not work reliably.', + }); + expect(mocks.toast.success).not.toHaveBeenCalledWith('Notifications enabled'); + }); + + it('best-effort detects insecure HTTPS for the enable-warning copy', () => { + expect( + getNotificationEnableToastInfo({ + protocol: 'https:', + isSecureContext: false, + }) + ).toEqual({ + level: 'warning', + title: 'Notifications enabled with warning', + description: + 'Desktop notifications are on for this conversation, but your HTTPS connection is untrusted, such as a self-signed certificate. Notification delivery may be inconsistent depending on your browser.', + }); + }); + + it('shows a descriptive success toast when notifications are disabled', async () => { + const { result } = renderHook(() => useBrowserNotifications()); + + await act(async () => { + await result.current.toggleConversationNotifications( + 'channel', + incomingChannelMessage.conversation_key, + '#flightless' + ); + }); + + await act(async () => { + await result.current.toggleConversationNotifications( + 'channel', + incomingChannelMessage.conversation_key, + '#flightless' + ); + }); + + expect(mocks.toast.success).toHaveBeenCalledWith('Notifications disabled', { + description: 'Desktop notifications are off for #flightless.', }); }); });