Improve coverage around desktop notifications. Closes #115.

This commit is contained in:
jkingsman
2026-03-26 16:39:38 -07:00
parent 54c24c50d3
commit bea3495b79
2 changed files with 137 additions and 12 deletions

View File

@@ -9,6 +9,17 @@ const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
type NotificationPermissionState = NotificationPermission | 'unsupported';
type ConversationNotificationMap = Record<string, boolean>;
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<NotificationEnvironment>
): 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<NotificationPermissionState>(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]

View File

@@ -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.',
});
});
});