mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-07 02:11:22 +02:00
Improve coverage around desktop notifications. Closes #115.
This commit is contained in:
@@ -9,6 +9,17 @@ const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
|
|||||||
type NotificationPermissionState = NotificationPermission | 'unsupported';
|
type NotificationPermissionState = NotificationPermission | 'unsupported';
|
||||||
type ConversationNotificationMap = Record<string, boolean>;
|
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 {
|
function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string {
|
||||||
return getStateKey(type, id);
|
return getStateKey(type, id);
|
||||||
}
|
}
|
||||||
@@ -92,6 +103,40 @@ function buildMessageNotificationHash(message: Message): string | null {
|
|||||||
return 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() {
|
export function useBrowserNotifications() {
|
||||||
const [permission, setPermission] = useState<NotificationPermissionState>(getInitialPermission);
|
const [permission, setPermission] = useState<NotificationPermissionState>(getInitialPermission);
|
||||||
const [enabledByConversation, setEnabledByConversation] =
|
const [enabledByConversation, setEnabledByConversation] =
|
||||||
@@ -110,8 +155,6 @@ export function useBrowserNotifications() {
|
|||||||
|
|
||||||
const toggleConversationNotifications = useCallback(
|
const toggleConversationNotifications = useCallback(
|
||||||
async (type: 'channel' | 'contact', id: string, label: string) => {
|
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);
|
const conversationKey = getConversationNotificationKey(type, id);
|
||||||
if (enabledByConversation[conversationKey]) {
|
if (enabledByConversation[conversationKey]) {
|
||||||
setEnabledByConversation((prev) => {
|
setEnabledByConversation((prev) => {
|
||||||
@@ -120,20 +163,23 @@ export function useBrowserNotifications() {
|
|||||||
writeStoredEnabledMap(next);
|
writeStoredEnabledMap(next);
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
toast.success(`${label} notifications disabled`);
|
toast.success('Notifications disabled', {
|
||||||
|
description: `Desktop notifications are off for ${label}.`,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permission === 'unsupported') {
|
if (permission === 'unsupported') {
|
||||||
toast.error('Browser notifications unavailable', {
|
toast.error('Notifications unavailable', {
|
||||||
description: 'This browser does not support desktop notifications.',
|
description: 'This browser does not support desktop notifications.',
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (permission === 'denied') {
|
if (permission === 'denied') {
|
||||||
toast.error('Browser notifications blocked', {
|
toast.error('Notifications blocked', {
|
||||||
description: blockedDescription,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -155,13 +201,24 @@ export function useBrowserNotifications() {
|
|||||||
icon: NOTIFICATION_ICON_PATH,
|
icon: NOTIFICATION_ICON_PATH,
|
||||||
tag: `meshcore-notification-preview-${conversationKey}`,
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
toast.error('Browser notifications not enabled', {
|
toast.error('Notifications not enabled', {
|
||||||
description:
|
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]
|
[enabledByConversation, permission]
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { act, renderHook } from '@testing-library/react';
|
import { act, renderHook } from '@testing-library/react';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { useBrowserNotifications } from '../hooks/useBrowserNotifications';
|
import {
|
||||||
|
getNotificationEnableToastInfo,
|
||||||
|
useBrowserNotifications,
|
||||||
|
} from '../hooks/useBrowserNotifications';
|
||||||
import type { Message } from '../types';
|
import type { Message } from '../types';
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
toast: {
|
toast: {
|
||||||
success: vi.fn(),
|
success: vi.fn(),
|
||||||
|
warning: vi.fn(),
|
||||||
error: vi.fn(),
|
error: vi.fn(),
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -57,6 +61,10 @@ describe('useBrowserNotifications', () => {
|
|||||||
configurable: true,
|
configurable: true,
|
||||||
value: NotificationMock,
|
value: NotificationMock,
|
||||||
});
|
});
|
||||||
|
Object.defineProperty(window, 'isSecureContext', {
|
||||||
|
configurable: true,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stores notification opt-in per conversation', async () => {
|
it('stores notification opt-in per conversation', async () => {
|
||||||
@@ -84,6 +92,10 @@ describe('useBrowserNotifications', () => {
|
|||||||
icon: '/favicon-256x256.png',
|
icon: '/favicon-256x256.png',
|
||||||
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
|
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 () => {
|
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:
|
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.',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user