mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
12 Commits
richer-ins
...
settings-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
498770bd88 | ||
|
|
bf0533807a | ||
|
|
bea3495b79 | ||
|
|
54c24c50d3 | ||
|
|
26b740fe3c | ||
|
|
b0f5930e01 | ||
|
|
5b05fdefa1 | ||
|
|
b63153b3a1 | ||
|
|
3c5a832bef | ||
|
|
2d943dedc5 | ||
|
|
137f41970d | ||
|
|
c833f1036b |
@@ -102,7 +102,7 @@ class BaseMqttPublisher(ABC):
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"%s publish failed on %s. This is usually transient network noise; "
|
||||
"if it self-resolves and reconnects, it is generally not a concern: %s",
|
||||
"if it self-resolves and reconnects, it is generally not a concern. Persistent errors may indicate a problem with your network connection or MQTT broker. Original error: %s",
|
||||
self._integration_label(),
|
||||
topic,
|
||||
e,
|
||||
@@ -239,7 +239,7 @@ class BaseMqttPublisher(ABC):
|
||||
logger.warning(
|
||||
"%s connection error. This is usually transient network noise; "
|
||||
"if it self-resolves, it is generally not a concern: %s "
|
||||
"(reconnecting in %ds)",
|
||||
"(reconnecting in %ds). If this error persists, check your network connection and MQTT broker status.",
|
||||
self._integration_label(),
|
||||
e,
|
||||
backoff,
|
||||
|
||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "2.7.9",
|
||||
"version": "3.6.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "2.7.9",
|
||||
"version": "3.6.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
@@ -29,6 +29,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
@@ -5695,6 +5696,15 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-swipeable": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/react-swipeable/-/react-swipeable-7.0.2.tgz",
|
||||
"integrity": "sha512-v1Qx1l+aC2fdxKa9aKJiaU/ZxmJ5o98RMoFwUqAAzVWUcxgfHFXDDruCKXhw6zIYXm6V64JiHgP9f6mlME5l8w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.3 || ^17 || ^18 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/read-cache": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-leaflet": "^4.2.1",
|
||||
"react-swipeable": "^7.0.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { Sidebar } from './Sidebar';
|
||||
@@ -89,6 +90,24 @@ export function AppShell({
|
||||
contactInfoPaneProps,
|
||||
channelInfoPaneProps,
|
||||
}: AppShellProps) {
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedRight: ({ initial }) => {
|
||||
if (initial[0] < 30 && !sidebarOpen && window.innerWidth < 768) {
|
||||
onSidebarOpenChange(true);
|
||||
}
|
||||
},
|
||||
trackTouch: true,
|
||||
trackMouse: false,
|
||||
preventScrollOnSwipe: true,
|
||||
});
|
||||
|
||||
const closeSwipeHandlers = useSwipeable({
|
||||
onSwipedLeft: () => onSidebarOpenChange(false),
|
||||
trackTouch: true,
|
||||
trackMouse: false,
|
||||
preventScrollOnSwipe: false,
|
||||
});
|
||||
|
||||
const searchMounted = useRef(false);
|
||||
if (conversationPaneProps.activeConversation?.type === 'search') {
|
||||
searchMounted.current = true;
|
||||
@@ -153,7 +172,7 @@ export function AppShell({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full" {...swipeHandlers}>
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
|
||||
@@ -196,7 +215,9 @@ export function AppShell({
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Sidebar navigation</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
|
||||
<div className="flex-1 overflow-hidden" {...closeSwipeHandlers}>
|
||||
{activeSidebarContent}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
|
||||
@@ -505,7 +505,7 @@ export function CrackerPanel({
|
||||
? 'GPU Not Available'
|
||||
: !wordlistLoaded
|
||||
? 'Loading dictionary...'
|
||||
: 'Find Rooms'}
|
||||
: 'Find Channels'}
|
||||
</button>
|
||||
|
||||
{/* Status */}
|
||||
|
||||
@@ -68,7 +68,7 @@ export function PathModal({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||
<DialogContent className="max-w-md max-h-[80dvh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{hasPaths
|
||||
|
||||
@@ -784,7 +784,7 @@ export function RawPacketInspectorDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogContent className="flex h-[92dvh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border px-5 py-3">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{description}</DialogDescription>
|
||||
|
||||
@@ -69,7 +69,7 @@ export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
className="top-3 w-[calc(100vw-1rem)] max-w-[42rem] translate-y-0 gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100vh-1.5rem)] sm:top-[50%] sm:w-full sm:max-h-[min(90vh,48rem)] sm:translate-y-[-50%] sm:px-6"
|
||||
className="w-[calc(100vw-1rem)] max-w-[42rem] gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100dvh-2rem)] sm:w-full sm:max-h-[min(85dvh,48rem)] sm:px-6"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
|
||||
@@ -147,8 +147,8 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
: 'mx-auto w-full max-w-[800px] space-y-4 border-t border-input p-4';
|
||||
|
||||
const settingsContainerClass = externalDesktopSidebarMode
|
||||
? 'w-full h-full overflow-y-auto'
|
||||
: 'w-full h-full overflow-y-auto space-y-3';
|
||||
? 'w-full h-full min-w-0 overflow-x-hidden overflow-y-auto [contain:layout_paint]'
|
||||
: 'w-full h-full min-w-0 overflow-x-hidden overflow-y-auto space-y-3 [contain:layout_paint]';
|
||||
|
||||
const sectionButtonClasses =
|
||||
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset';
|
||||
|
||||
@@ -844,7 +844,7 @@ export function Sidebar({
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search rooms/contacts..."
|
||||
placeholder="Search channels/contacts..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user