Use better behavior on disconnected radio and allow deeplinking into settings. Closes #66.

This commit is contained in:
Jack Kingsman
2026-03-16 17:46:12 -07:00
parent ffb5fa51c1
commit b68bfc41d6
14 changed files with 359 additions and 56 deletions

View File

@@ -12,6 +12,7 @@ from app.config import settings
from app.keystore import clear_keys from app.keystore import clear_keys
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS = 3
class RadioOperationError(RuntimeError): class RadioOperationError(RuntimeError):
@@ -131,6 +132,7 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False self._setup_in_progress: bool = False
self._setup_complete: bool = False self._setup_complete: bool = False
self._frontend_reconnect_error_broadcasts: int = 0
self.device_info_loaded: bool = False self.device_info_loaded: bool = False
self.max_contacts: int | None = None self.max_contacts: int | None = None
self.device_model: str | None = None self.device_model: str | None = None
@@ -387,6 +389,21 @@ class RadioManager:
self._last_connected = False self._last_connected = False
await self.disconnect() await self.disconnect()
def _reset_reconnect_error_broadcasts(self) -> None:
self._frontend_reconnect_error_broadcasts = 0
def _broadcast_reconnect_error_if_needed(self, details: str) -> None:
from app.websocket import broadcast_error
self._frontend_reconnect_error_broadcasts += 1
if self._frontend_reconnect_error_broadcasts > MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
return
if self._frontend_reconnect_error_broadcasts == MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
details = f"{details} Further reconnect failures will be logged only until a connection succeeds."
broadcast_error("Reconnection failed", details)
async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None: async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None:
"""Disable library-managed reconnects so manual teardown fully releases transport.""" """Disable library-managed reconnects so manual teardown fully releases transport."""
connection_manager = getattr(mc, "connection_manager", None) connection_manager = getattr(mc, "connection_manager", None)
@@ -485,6 +502,7 @@ class RadioManager:
async def disconnect(self) -> None: async def disconnect(self) -> None:
"""Disconnect from the radio.""" """Disconnect from the radio."""
clear_keys() clear_keys()
self._reset_reconnect_error_broadcasts()
if self._meshcore is not None: if self._meshcore is not None:
logger.debug("Disconnecting from radio") logger.debug("Disconnecting from radio")
mc = self._meshcore mc = self._meshcore
@@ -511,7 +529,7 @@ class RadioManager:
Returns True if reconnection was successful, False otherwise. Returns True if reconnection was successful, False otherwise.
Uses a lock to prevent concurrent reconnection attempts. Uses a lock to prevent concurrent reconnection attempts.
""" """
from app.websocket import broadcast_error, broadcast_health from app.websocket import broadcast_health
# Lazily initialize lock (can't create in __init__ before event loop exists) # Lazily initialize lock (can't create in __init__ before event loop exists)
if self._reconnect_lock is None: if self._reconnect_lock is None:
@@ -549,6 +567,7 @@ class RadioManager:
if self.is_connected: if self.is_connected:
logger.info("Radio reconnected successfully at %s", self._connection_info) logger.info("Radio reconnected successfully at %s", self._connection_info)
self._reset_reconnect_error_broadcasts()
if broadcast_on_success: if broadcast_on_success:
broadcast_health(True, self._connection_info) broadcast_health(True, self._connection_info)
return True return True
@@ -558,7 +577,7 @@ class RadioManager:
except Exception as e: except Exception as e:
logger.warning("Reconnection failed: %s", e, exc_info=True) logger.warning("Reconnection failed: %s", e, exc_info=True)
broadcast_error("Reconnection failed", str(e)) self._broadcast_reconnect_error_if_needed(str(e))
return False return False
async def start_connection_monitor(self) -> None: async def start_connection_monitor(self) -> None:

View File

@@ -194,6 +194,7 @@ export function App() {
channels, channels,
contacts, contacts,
contactsLoaded, contactsLoaded,
suspendHashSync: showSettings,
setSidebarOpen, setSidebarOpen,
pendingDeleteFallbackRef, pendingDeleteFallbackRef,
hasSetDefaultConversation, hasSetDefaultConversation,
@@ -254,6 +255,12 @@ export function App() {
refreshUnreads, refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation); } = useUnreadCounts(channels, contacts, activeConversation);
useEffect(() => {
if (showSettings && !config && settingsSection === 'radio') {
setSettingsSection('local');
}
}, [config, settingsSection, setSettingsSection, showSettings]);
useEffect(() => { useEffect(() => {
if (activeConversation?.type !== 'channel') { if (activeConversation?.type !== 'channel') {
setChannelUnreadMarker(null); setChannelUnreadMarker(null);
@@ -566,6 +573,7 @@ export function App() {
settingsSection={settingsSection} settingsSection={settingsSection}
sidebarOpen={sidebarOpen} sidebarOpen={sidebarOpen}
showCracker={showCracker} showCracker={showCracker}
disabledSettingsSections={config ? [] : ['radio']}
onSettingsSectionChange={setSettingsSection} onSettingsSectionChange={setSettingsSection}
onSidebarOpenChange={setSidebarOpen} onSidebarOpenChange={setSidebarOpen}
onCrackerRunningChange={setCrackerRunning} onCrackerRunningChange={setCrackerRunning}

View File

@@ -41,6 +41,7 @@ interface AppShellProps {
settingsSection: SettingsSection; settingsSection: SettingsSection;
sidebarOpen: boolean; sidebarOpen: boolean;
showCracker: boolean; showCracker: boolean;
disabledSettingsSections?: SettingsSection[];
onSettingsSectionChange: (section: SettingsSection) => void; onSettingsSectionChange: (section: SettingsSection) => void;
onSidebarOpenChange: (open: boolean) => void; onSidebarOpenChange: (open: boolean) => void;
onCrackerRunningChange: (running: boolean) => void; onCrackerRunningChange: (running: boolean) => void;
@@ -69,6 +70,7 @@ export function AppShell({
settingsSection, settingsSection,
sidebarOpen, sidebarOpen,
showCracker, showCracker,
disabledSettingsSections = [],
onSettingsSectionChange, onSettingsSectionChange,
onSidebarOpenChange, onSidebarOpenChange,
onCrackerRunningChange, onCrackerRunningChange,
@@ -118,13 +120,16 @@ export function AppShell({
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]"> <div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
{SETTINGS_SECTION_ORDER.map((section) => { {SETTINGS_SECTION_ORDER.map((section) => {
const Icon = SETTINGS_SECTION_ICONS[section]; const Icon = SETTINGS_SECTION_ICONS[section];
const disabled = disabledSettingsSections.includes(section);
return ( return (
<button <button
key={section} key={section}
type="button" type="button"
disabled={disabled}
className={cn( className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset', 'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
settingsSection === section && 'bg-accent border-l-primary' !disabled && 'hover:bg-accent',
settingsSection === section && !disabled && 'bg-accent border-l-primary'
)} )}
aria-current={settingsSection === section ? 'true' : undefined} aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => onSettingsSectionChange(section)} onClick={() => onSettingsSectionChange(section)}

View File

@@ -155,11 +155,13 @@ export function SettingsModal(props: SettingsModalProps) {
const renderSectionHeader = (section: SettingsSection): ReactNode => { const renderSectionHeader = (section: SettingsSection): ReactNode => {
if (!showSectionButton) return null; if (!showSectionButton) return null;
const Icon = SETTINGS_SECTION_ICONS[section]; const Icon = SETTINGS_SECTION_ICONS[section];
const disabled = section === 'radio' && !config;
return ( return (
<button <button
type="button" type="button"
className={sectionButtonClasses} className={`${sectionButtonClasses} disabled:cursor-not-allowed disabled:opacity-50`}
aria-expanded={expandedSections[section]} aria-expanded={expandedSections[section]}
disabled={disabled}
onClick={() => toggleSection(section)} onClick={() => toggleSection(section)}
> >
<span className="inline-flex items-center gap-2 font-medium" role="heading" aria-level={3}> <span className="inline-flex items-center gap-2 font-medium" role="heading" aria-level={3}>
@@ -177,33 +179,38 @@ export function SettingsModal(props: SettingsModalProps) {
return null; return null;
} }
return !config ? ( return (
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
<div className={settingsContainerClass}> <div className={settingsContainerClass}>
{shouldRenderSection('radio') && ( {shouldRenderSection('radio') && (
<section className={sectionWrapperClass}> <section className={sectionWrapperClass}>
{renderSectionHeader('radio')} {renderSectionHeader('radio')}
{isSectionVisible('radio') && appSettings && ( {isSectionVisible('radio') &&
<SettingsRadioSection (config && appSettings ? (
config={config} <SettingsRadioSection
health={health} config={config}
appSettings={appSettings} health={health}
pageMode={pageMode} appSettings={appSettings}
onSave={onSave} pageMode={pageMode}
onSaveAppSettings={onSaveAppSettings} onSave={onSave}
onSetPrivateKey={onSetPrivateKey} onSaveAppSettings={onSaveAppSettings}
onReboot={onReboot} onSetPrivateKey={onSetPrivateKey}
onDisconnect={onDisconnect} onReboot={onReboot}
onReconnect={onReconnect} onDisconnect={onDisconnect}
onAdvertise={onAdvertise} onReconnect={onReconnect}
meshDiscovery={meshDiscovery} onAdvertise={onAdvertise}
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget} meshDiscovery={meshDiscovery}
onDiscoverMesh={onDiscoverMesh} meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
onClose={onClose} onDiscoverMesh={onDiscoverMesh}
className={sectionContentClass} onClose={onClose}
/> className={sectionContentClass}
)} />
) : (
<div className={sectionContentClass}>
<div className="rounded-md border border-input bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
Radio settings are unavailable until a radio connects.
</div>
</div>
))}
</section> </section>
)} )}
@@ -222,19 +229,26 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('database') && ( {shouldRenderSection('database') && (
<section className={sectionWrapperClass}> <section className={sectionWrapperClass}>
{renderSectionHeader('database')} {renderSectionHeader('database')}
{isSectionVisible('database') && appSettings && ( {isSectionVisible('database') &&
<SettingsDatabaseSection (appSettings ? (
appSettings={appSettings} <SettingsDatabaseSection
health={health} appSettings={appSettings}
onSaveAppSettings={onSaveAppSettings} health={health}
onHealthRefresh={onHealthRefresh} onSaveAppSettings={onSaveAppSettings}
blockedKeys={blockedKeys} onHealthRefresh={onHealthRefresh}
blockedNames={blockedNames} blockedKeys={blockedKeys}
onToggleBlockedKey={onToggleBlockedKey} blockedNames={blockedNames}
onToggleBlockedName={onToggleBlockedName} onToggleBlockedKey={onToggleBlockedKey}
className={sectionContentClass} onToggleBlockedName={onToggleBlockedName}
/> className={sectionContentClass}
)} />
) : (
<div className={sectionContentClass}>
<div className="rounded-md border border-input bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
Loading app settings...
</div>
</div>
))}
</section> </section>
)} )}

View File

@@ -173,7 +173,8 @@ export function SettingsDatabaseSection({
Deletes archival copies of raw packet bytes for messages that are already decrypted and Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '} visible in your chat history.{' '}
<em className="text-muted-foreground/80"> <em className="text-muted-foreground/80">
This will not affect any displayed messages or app functionality. This will not affect any displayed messages or app functionality, nor impact your
ability to do historical decryption.
</em>{' '} </em>{' '}
The raw bytes are only useful for manual packet analysis. The raw bytes are only useful for manual packet analysis.
</p> </p>

View File

@@ -1,7 +1,8 @@
import { startTransition, useCallback, useState } from 'react'; import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
import { getLocalLabel, type LocalLabel } from '../utils/localLabel'; import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
import type { SettingsSection } from '../components/settings/settingsConstants'; import type { SettingsSection } from '../components/settings/settingsConstants';
import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash';
interface UseAppShellResult { interface UseAppShellResult {
showNewMessage: boolean; showNewMessage: boolean;
@@ -23,25 +24,47 @@ interface UseAppShellResult {
} }
export function useAppShell(): UseAppShellResult { export function useAppShell(): UseAppShellResult {
const initialSettingsSection = typeof window === 'undefined' ? null : parseHashSettingsSection();
const [showNewMessage, setShowNewMessage] = useState(false); const [showNewMessage, setShowNewMessage] = useState(false);
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(() => initialSettingsSection !== null);
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio'); const [settingsSection, setSettingsSection] = useState<SettingsSection>(
() => initialSettingsSection ?? 'radio'
);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [showCracker, setShowCracker] = useState(false); const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false); const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel); const [localLabel, setLocalLabel] = useState(getLocalLabel);
const previousHashRef = useRef('');
useEffect(() => {
if (showSettings) {
updateSettingsHash(settingsSection);
}
}, [settingsSection, showSettings]);
const handleCloseSettingsView = useCallback(() => { const handleCloseSettingsView = useCallback(() => {
if (typeof window !== 'undefined' && parseHashSettingsSection() !== null) {
window.history.replaceState(null, '', previousHashRef.current || window.location.pathname);
}
startTransition(() => setShowSettings(false)); startTransition(() => setShowSettings(false));
setSidebarOpen(false); setSidebarOpen(false);
}, []); }, []);
const handleToggleSettingsView = useCallback(() => { const handleToggleSettingsView = useCallback(() => {
if (showSettings) {
handleCloseSettingsView();
return;
}
if (typeof window !== 'undefined') {
previousHashRef.current =
parseHashSettingsSection() === null ? window.location.hash : previousHashRef.current;
}
startTransition(() => { startTransition(() => {
setShowSettings((prev) => !prev); setShowSettings(true);
}); });
setSidebarOpen(false); setSidebarOpen(false);
}, []); }, [handleCloseSettingsView, showSettings]);
const handleOpenNewMessage = useCallback(() => { const handleOpenNewMessage = useCallback(() => {
setShowNewMessage(true); setShowNewMessage(true);

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect, useRef, type MutableRefObject } from 'react'; import { useState, useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import { import {
parseHashConversation, parseHashConversation,
parseHashSettingsSection,
updateUrlHash, updateUrlHash,
resolveChannelFromHashToken, resolveChannelFromHashToken,
resolveContactFromHashToken, resolveContactFromHashToken,
@@ -18,6 +19,7 @@ interface UseConversationRouterArgs {
channels: Channel[]; channels: Channel[];
contacts: Contact[]; contacts: Contact[];
contactsLoaded: boolean; contactsLoaded: boolean;
suspendHashSync: boolean;
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
pendingDeleteFallbackRef: MutableRefObject<boolean>; pendingDeleteFallbackRef: MutableRefObject<boolean>;
hasSetDefaultConversation: MutableRefObject<boolean>; hasSetDefaultConversation: MutableRefObject<boolean>;
@@ -27,6 +29,7 @@ export function useConversationRouter({
channels, channels,
contacts, contacts,
contactsLoaded, contactsLoaded,
suspendHashSync,
setSidebarOpen, setSidebarOpen,
pendingDeleteFallbackRef, pendingDeleteFallbackRef,
hasSetDefaultConversation, hasSetDefaultConversation,
@@ -34,7 +37,9 @@ export function useConversationRouter({
const [activeConversation, setActiveConversationState] = useState<Conversation | null>(null); const [activeConversation, setActiveConversationState] = useState<Conversation | null>(null);
const activeConversationRef = useRef<Conversation | null>(null); const activeConversationRef = useRef<Conversation | null>(null);
const hashSyncEnabledRef = useRef( const hashSyncEnabledRef = useRef(
typeof window !== 'undefined' ? window.location.hash.length > 0 : false typeof window !== 'undefined'
? window.location.hash.length > 0 && parseHashSettingsSection() === null
: false
); );
const setActiveConversation = useCallback((conv: Conversation | null) => { const setActiveConversation = useCallback((conv: Conversation | null) => {
@@ -58,7 +63,7 @@ export function useConversationRouter({
if (hasSetDefaultConversation.current || activeConversation) return; if (hasSetDefaultConversation.current || activeConversation) return;
if (channels.length === 0) return; if (channels.length === 0) return;
const hashConv = parseHashConversation(); const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
// Handle non-data views immediately // Handle non-data views immediately
if (hashConv?.type === 'raw') { if (hashConv?.type === 'raw') {
@@ -141,7 +146,7 @@ export function useConversationRouter({
useEffect(() => { useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return; if (hasSetDefaultConversation.current || activeConversation) return;
const hashConv = parseHashConversation(); const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
if (hashConv?.type === 'contact') { if (hashConv?.type === 'contact') {
if (!contactsLoaded) return; if (!contactsLoaded) return;
@@ -203,14 +208,14 @@ export function useConversationRouter({
useEffect(() => { useEffect(() => {
activeConversationRef.current = activeConversation; activeConversationRef.current = activeConversation;
if (activeConversation) { if (activeConversation) {
if (hashSyncEnabledRef.current) { if (hashSyncEnabledRef.current && !suspendHashSync) {
updateUrlHash(activeConversation); updateUrlHash(activeConversation);
} }
if (getReopenLastConversationEnabled() && activeConversation.type !== 'search') { if (getReopenLastConversationEnabled() && activeConversation.type !== 'search') {
saveLastViewedConversation(activeConversation); saveLastViewedConversation(activeConversation);
} }
} }
}, [activeConversation]); }, [activeConversation, suspendHashSync]);
// If a delete action left us without an active conversation, recover to Public // If a delete action left us without an active conversation, recover to Public
useEffect(() => { useEffect(() => {

View File

@@ -164,7 +164,10 @@ vi.mock('../components/ui/sonner', () => ({
vi.mock('../utils/urlHash', () => ({ vi.mock('../utils/urlHash', () => ({
parseHashConversation: () => null, parseHashConversation: () => null,
parseHashSettingsSection: () => null,
updateUrlHash: vi.fn(), updateUrlHash: vi.fn(),
updateSettingsHash: vi.fn(),
getSettingsHash: (section: string) => `#settings/${section}`,
getMapFocusHash: () => '#map', getMapFocusHash: () => '#map',
})); }));

View File

@@ -90,7 +90,9 @@ vi.mock('../components/NewMessageModal', () => ({
})); }));
vi.mock('../components/SettingsModal', () => ({ vi.mock('../components/SettingsModal', () => ({
SettingsModal: () => null, SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
),
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'], SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
SETTINGS_SECTION_LABELS: { SETTINGS_SECTION_LABELS: {
radio: 'Radio', radio: 'Radio',
@@ -293,4 +295,20 @@ describe('App startup hash resolution', () => {
}); });
expect(window.location.hash).toBe(''); expect(window.location.hash).toBe('');
}); });
it('opens settings from a settings hash and falls back away from radio when disconnected', async () => {
window.location.hash = '#settings/radio';
mocks.api.getRadioConfig.mockRejectedValue(new Error('radio offline'));
render(<App />);
await waitFor(() => {
expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('local');
});
for (const button of screen.getAllByRole('button', { name: 'Radio' })) {
expect(button).toBeDisabled();
}
expect(window.location.hash).toBe('#settings/local');
});
}); });

View File

@@ -63,6 +63,7 @@ const baseSettings: AppSettings = {
}; };
function renderModal(overrides?: { function renderModal(overrides?: {
config?: RadioConfig | null;
appSettings?: AppSettings; appSettings?: AppSettings;
health?: HealthStatus; health?: HealthStatus;
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>; onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
@@ -97,7 +98,7 @@ function renderModal(overrides?: {
const commonProps = { const commonProps = {
open: overrides?.open ?? true, open: overrides?.open ?? true,
pageMode: overrides?.pageMode, pageMode: overrides?.pageMode,
config: baseConfig, config: overrides?.config === undefined ? baseConfig : overrides.config,
health: overrides?.health ?? baseHealth, health: overrides?.health ?? baseHealth,
appSettings: overrides?.appSettings ?? baseSettings, appSettings: overrides?.appSettings ?? baseSettings,
onClose, onClose,
@@ -205,6 +206,32 @@ describe('SettingsModal', () => {
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument(); expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
}); });
it('keeps non-radio settings available when radio config is unavailable', () => {
renderModal({ config: null });
const radioToggle = screen.getByRole('button', { name: /Radio/i });
expect(radioToggle).toBeDisabled();
openLocalSection();
expect(screen.getByLabelText('Local label text')).toBeInTheDocument();
openDatabaseSection();
expect(screen.getByText('Delete Undecrypted Packets')).toBeInTheDocument();
});
it('shows a radio-unavailable message instead of blocking the whole settings page', () => {
renderModal({
config: null,
externalSidebarNav: true,
desktopSection: 'radio',
});
expect(
screen.getByText('Radio settings are unavailable until a radio connects.')
).toBeInTheDocument();
expect(screen.queryByText('Loading configuration...')).not.toBeInTheDocument();
});
it('shows cached radio firmware and capacity info under the connection status', () => { it('shows cached radio firmware and capacity info under the connection status', () => {
renderModal({ renderModal({
health: { health: {

View File

@@ -8,6 +8,8 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { import {
parseHashConversation, parseHashConversation,
parseHashSettingsSection,
getSettingsHash,
getMapFocusHash, getMapFocusHash,
resolveChannelFromHashToken, resolveChannelFromHashToken,
resolveContactFromHashToken, resolveContactFromHashToken,
@@ -147,6 +149,34 @@ describe('parseHashConversation', () => {
}); });
}); });
describe('settings URL hashes', () => {
let originalHash: string;
beforeEach(() => {
originalHash = window.location.hash;
});
afterEach(() => {
window.location.hash = originalHash;
});
it('parses a valid settings section hash', () => {
window.location.hash = '#settings/database';
expect(parseHashSettingsSection()).toBe('database');
});
it('returns null for an invalid settings section hash', () => {
window.location.hash = '#settings/not-a-section';
expect(parseHashSettingsSection()).toBeNull();
});
it('builds a stable settings hash', () => {
expect(getSettingsHash('local')).toBe('#settings/local');
});
});
describe('resolveChannelFromHashToken', () => { describe('resolveChannelFromHashToken', () => {
const channels: Channel[] = [ const channels: Channel[] = [
{ {

View File

@@ -1,9 +1,19 @@
import { act, renderHook } from '@testing-library/react'; import { act, renderHook, waitFor } from '@testing-library/react';
import { describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { useAppShell } from '../hooks/useAppShell'; import { useAppShell } from '../hooks/useAppShell';
describe('useAppShell', () => { describe('useAppShell', () => {
let originalHash: string;
beforeEach(() => {
originalHash = window.location.hash;
});
afterEach(() => {
window.location.hash = originalHash;
});
it('opens new-message modal and closes the sidebar', () => { it('opens new-message modal and closes the sidebar', () => {
const { result } = renderHook(() => useAppShell()); const { result } = renderHook(() => useAppShell());
@@ -34,6 +44,55 @@ describe('useAppShell', () => {
expect(result.current.showSettings).toBe(false); expect(result.current.showSettings).toBe(false);
}); });
it('initializes settings mode from the URL hash', () => {
window.location.hash = '#settings/database';
const { result } = renderHook(() => useAppShell());
expect(result.current.showSettings).toBe(true);
expect(result.current.settingsSection).toBe('database');
});
it('syncs the selected settings section into the URL hash', async () => {
const { result } = renderHook(() => useAppShell());
act(() => {
result.current.handleToggleSettingsView();
});
await waitFor(() => {
expect(window.location.hash).toBe('#settings/radio');
});
act(() => {
result.current.setSettingsSection('fanout');
});
await waitFor(() => {
expect(window.location.hash).toBe('#settings/fanout');
});
});
it('restores the previous hash when settings close', async () => {
window.location.hash = '#channel/test/Public';
const { result } = renderHook(() => useAppShell());
act(() => {
result.current.handleToggleSettingsView();
});
await waitFor(() => {
expect(window.location.hash).toBe('#settings/radio');
});
act(() => {
result.current.handleCloseSettingsView();
});
expect(window.location.hash).toBe('#channel/test/Public');
});
it('toggles the cracker shell without affecting sidebar state', () => { it('toggles the cracker shell without affecting sidebar state', () => {
const { result } = renderHook(() => useAppShell()); const { result } = renderHook(() => useAppShell());

View File

@@ -1,6 +1,7 @@
import type { Channel, Contact, Conversation } from '../types'; import type { Channel, Contact, Conversation } from '../types';
import { findPublicChannel, PUBLIC_CHANNEL_NAME } from './publicChannel'; import { findPublicChannel, PUBLIC_CHANNEL_NAME } from './publicChannel';
import { getContactDisplayName } from './pubkey'; import { getContactDisplayName } from './pubkey';
import type { SettingsSection } from '../components/settings/settingsConstants';
interface ParsedHashConversation { interface ParsedHashConversation {
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search'; type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
@@ -12,6 +13,15 @@ interface ParsedHashConversation {
mapFocusKey?: string; mapFocusKey?: string;
} }
const SETTINGS_SECTIONS: SettingsSection[] = [
'radio',
'local',
'fanout',
'database',
'statistics',
'about',
];
// Parse URL hash to get conversation // Parse URL hash to get conversation
// (e.g., #channel/ABCDEF0123456789ABCDEF0123456789 or #contact/<64-char-pubkey>). // (e.g., #channel/ABCDEF0123456789ABCDEF0123456789 or #contact/<64-char-pubkey>).
export function parseHashConversation(): ParsedHashConversation | null { export function parseHashConversation(): ParsedHashConversation | null {
@@ -70,6 +80,20 @@ export function parseHashConversation(): ParsedHashConversation | null {
}; };
} }
export function parseHashSettingsSection(): SettingsSection | null {
const hash = window.location.hash.slice(1);
if (!hash.startsWith('settings/')) {
return null;
}
const section = decodeURIComponent(hash.slice('settings/'.length)) as SettingsSection;
return SETTINGS_SECTIONS.includes(section) ? section : null;
}
export function getSettingsHash(section: SettingsSection): string {
return `#settings/${encodeURIComponent(section)}`;
}
export function resolveChannelFromHashToken(token: string, channels: Channel[]): Channel | null { export function resolveChannelFromHashToken(token: string, channels: Channel[]): Channel | null {
const normalizedToken = token.trim(); const normalizedToken = token.trim();
if (!normalizedToken) return null; if (!normalizedToken) return null;
@@ -141,3 +165,10 @@ export function updateUrlHash(conv: Conversation | null): void {
window.history.replaceState(null, '', newHash || window.location.pathname); window.history.replaceState(null, '', newHash || window.location.pathname);
} }
} }
export function updateSettingsHash(section: SettingsSection): void {
const newHash = getSettingsHash(section);
if (newHash !== window.location.hash) {
window.history.replaceState(null, '', newHash);
}
}

View File

@@ -449,6 +449,66 @@ class TestReconnectLock:
assert result is False assert result is False
rm.connect.assert_not_called() rm.connect.assert_not_called()
@pytest.mark.asyncio
async def test_reconnect_broadcasts_only_first_three_failures(self):
"""Frontend only sees the first few reconnect failures before suppression kicks in."""
from app.radio import MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS, RadioManager
rm = RadioManager()
rm.connect = AsyncMock(side_effect=RuntimeError("radio unavailable"))
with (
patch("app.websocket.broadcast_health"),
patch("app.websocket.broadcast_error") as mock_broadcast_error,
):
for _ in range(MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS + 2):
result = await rm.reconnect(broadcast_on_success=False)
assert result is False
assert mock_broadcast_error.call_count == MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS
assert mock_broadcast_error.call_args_list[0].args == (
"Reconnection failed",
"radio unavailable",
)
assert mock_broadcast_error.call_args_list[-1].args == (
"Reconnection failed",
"radio unavailable Further reconnect failures will be logged only until a connection succeeds.",
)
@pytest.mark.asyncio
async def test_reconnect_success_resets_error_broadcast_suppression(self):
"""A successful reconnect re-enables frontend error broadcasts for later failures."""
from app.radio import RadioManager
rm = RadioManager()
attempts = 0
async def mock_connect():
nonlocal attempts
attempts += 1
if attempts in (1, 2, 4):
raise RuntimeError("radio unavailable")
mock_mc = MagicMock()
mock_mc.is_connected = True
rm._meshcore = mock_mc
rm._connection_info = "TCP: test:4000"
rm.connect = AsyncMock(side_effect=mock_connect)
with (
patch("app.websocket.broadcast_health"),
patch("app.websocket.broadcast_error") as mock_broadcast_error,
):
assert await rm.reconnect(broadcast_on_success=False) is False
assert await rm.reconnect(broadcast_on_success=False) is False
assert await rm.reconnect(broadcast_on_success=False) is True
rm._meshcore = None
assert await rm.reconnect(broadcast_on_success=False) is False
assert mock_broadcast_error.call_count == 3
for call in mock_broadcast_error.call_args_list:
assert call.args == ("Reconnection failed", "radio unavailable")
class TestManualDisconnectCleanup: class TestManualDisconnectCleanup:
"""Tests for manual disconnect teardown behavior.""" """Tests for manual disconnect teardown behavior."""