mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-05 09:22:04 +02:00
Use better behavior on disconnected radio and allow deeplinking into settings. Closes #66.
This commit is contained in:
@@ -194,6 +194,7 @@ export function App() {
|
||||
channels,
|
||||
contacts,
|
||||
contactsLoaded,
|
||||
suspendHashSync: showSettings,
|
||||
setSidebarOpen,
|
||||
pendingDeleteFallbackRef,
|
||||
hasSetDefaultConversation,
|
||||
@@ -254,6 +255,12 @@ export function App() {
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
|
||||
useEffect(() => {
|
||||
if (showSettings && !config && settingsSection === 'radio') {
|
||||
setSettingsSection('local');
|
||||
}
|
||||
}, [config, settingsSection, setSettingsSection, showSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeConversation?.type !== 'channel') {
|
||||
setChannelUnreadMarker(null);
|
||||
@@ -566,6 +573,7 @@ export function App() {
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
showCracker={showCracker}
|
||||
disabledSettingsSections={config ? [] : ['radio']}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
onSidebarOpenChange={setSidebarOpen}
|
||||
onCrackerRunningChange={setCrackerRunning}
|
||||
|
||||
@@ -41,6 +41,7 @@ interface AppShellProps {
|
||||
settingsSection: SettingsSection;
|
||||
sidebarOpen: boolean;
|
||||
showCracker: boolean;
|
||||
disabledSettingsSections?: SettingsSection[];
|
||||
onSettingsSectionChange: (section: SettingsSection) => void;
|
||||
onSidebarOpenChange: (open: boolean) => void;
|
||||
onCrackerRunningChange: (running: boolean) => void;
|
||||
@@ -69,6 +70,7 @@ export function AppShell({
|
||||
settingsSection,
|
||||
sidebarOpen,
|
||||
showCracker,
|
||||
disabledSettingsSections = [],
|
||||
onSettingsSectionChange,
|
||||
onSidebarOpenChange,
|
||||
onCrackerRunningChange,
|
||||
@@ -118,13 +120,16 @@ export function AppShell({
|
||||
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
|
||||
{SETTINGS_SECTION_ORDER.map((section) => {
|
||||
const Icon = SETTINGS_SECTION_ICONS[section];
|
||||
const disabled = disabledSettingsSections.includes(section);
|
||||
return (
|
||||
<button
|
||||
key={section}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
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',
|
||||
settingsSection === section && 'bg-accent border-l-primary'
|
||||
'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',
|
||||
!disabled && 'hover:bg-accent',
|
||||
settingsSection === section && !disabled && 'bg-accent border-l-primary'
|
||||
)}
|
||||
aria-current={settingsSection === section ? 'true' : undefined}
|
||||
onClick={() => onSettingsSectionChange(section)}
|
||||
|
||||
@@ -155,11 +155,13 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
const renderSectionHeader = (section: SettingsSection): ReactNode => {
|
||||
if (!showSectionButton) return null;
|
||||
const Icon = SETTINGS_SECTION_ICONS[section];
|
||||
const disabled = section === 'radio' && !config;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={sectionButtonClasses}
|
||||
className={`${sectionButtonClasses} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
aria-expanded={expandedSections[section]}
|
||||
disabled={disabled}
|
||||
onClick={() => toggleSection(section)}
|
||||
>
|
||||
<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 !config ? (
|
||||
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
|
||||
) : (
|
||||
return (
|
||||
<div className={settingsContainerClass}>
|
||||
{shouldRenderSection('radio') && (
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('radio')}
|
||||
{isSectionVisible('radio') && appSettings && (
|
||||
<SettingsRadioSection
|
||||
config={config}
|
||||
health={health}
|
||||
appSettings={appSettings}
|
||||
pageMode={pageMode}
|
||||
onSave={onSave}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onSetPrivateKey={onSetPrivateKey}
|
||||
onReboot={onReboot}
|
||||
onDisconnect={onDisconnect}
|
||||
onReconnect={onReconnect}
|
||||
onAdvertise={onAdvertise}
|
||||
meshDiscovery={meshDiscovery}
|
||||
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
|
||||
onDiscoverMesh={onDiscoverMesh}
|
||||
onClose={onClose}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
{isSectionVisible('radio') &&
|
||||
(config && appSettings ? (
|
||||
<SettingsRadioSection
|
||||
config={config}
|
||||
health={health}
|
||||
appSettings={appSettings}
|
||||
pageMode={pageMode}
|
||||
onSave={onSave}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onSetPrivateKey={onSetPrivateKey}
|
||||
onReboot={onReboot}
|
||||
onDisconnect={onDisconnect}
|
||||
onReconnect={onReconnect}
|
||||
onAdvertise={onAdvertise}
|
||||
meshDiscovery={meshDiscovery}
|
||||
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
|
||||
onDiscoverMesh={onDiscoverMesh}
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -222,19 +229,26 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
{shouldRenderSection('database') && (
|
||||
<section className={sectionWrapperClass}>
|
||||
{renderSectionHeader('database')}
|
||||
{isSectionVisible('database') && appSettings && (
|
||||
<SettingsDatabaseSection
|
||||
appSettings={appSettings}
|
||||
health={health}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onHealthRefresh={onHealthRefresh}
|
||||
blockedKeys={blockedKeys}
|
||||
blockedNames={blockedNames}
|
||||
onToggleBlockedKey={onToggleBlockedKey}
|
||||
onToggleBlockedName={onToggleBlockedName}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
)}
|
||||
{isSectionVisible('database') &&
|
||||
(appSettings ? (
|
||||
<SettingsDatabaseSection
|
||||
appSettings={appSettings}
|
||||
health={health}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onHealthRefresh={onHealthRefresh}
|
||||
blockedKeys={blockedKeys}
|
||||
blockedNames={blockedNames}
|
||||
onToggleBlockedKey={onToggleBlockedKey}
|
||||
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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -173,7 +173,8 @@ export function SettingsDatabaseSection({
|
||||
Deletes archival copies of raw packet bytes for messages that are already decrypted and
|
||||
visible in your chat history.{' '}
|
||||
<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>{' '}
|
||||
The raw bytes are only useful for manual packet analysis.
|
||||
</p>
|
||||
|
||||
@@ -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 type { SettingsSection } from '../components/settings/settingsConstants';
|
||||
import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash';
|
||||
|
||||
interface UseAppShellResult {
|
||||
showNewMessage: boolean;
|
||||
@@ -23,25 +24,47 @@ interface UseAppShellResult {
|
||||
}
|
||||
|
||||
export function useAppShell(): UseAppShellResult {
|
||||
const initialSettingsSection = typeof window === 'undefined' ? null : parseHashSettingsSection();
|
||||
const [showNewMessage, setShowNewMessage] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio');
|
||||
const [showSettings, setShowSettings] = useState(() => initialSettingsSection !== null);
|
||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>(
|
||||
() => initialSettingsSection ?? 'radio'
|
||||
);
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [showCracker, setShowCracker] = useState(false);
|
||||
const [crackerRunning, setCrackerRunning] = useState(false);
|
||||
const [localLabel, setLocalLabel] = useState(getLocalLabel);
|
||||
const previousHashRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
if (showSettings) {
|
||||
updateSettingsHash(settingsSection);
|
||||
}
|
||||
}, [settingsSection, showSettings]);
|
||||
|
||||
const handleCloseSettingsView = useCallback(() => {
|
||||
if (typeof window !== 'undefined' && parseHashSettingsSection() !== null) {
|
||||
window.history.replaceState(null, '', previousHashRef.current || window.location.pathname);
|
||||
}
|
||||
startTransition(() => setShowSettings(false));
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleSettingsView = useCallback(() => {
|
||||
if (showSettings) {
|
||||
handleCloseSettingsView();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
previousHashRef.current =
|
||||
parseHashSettingsSection() === null ? window.location.hash : previousHashRef.current;
|
||||
}
|
||||
startTransition(() => {
|
||||
setShowSettings((prev) => !prev);
|
||||
setShowSettings(true);
|
||||
});
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
}, [handleCloseSettingsView, showSettings]);
|
||||
|
||||
const handleOpenNewMessage = useCallback(() => {
|
||||
setShowNewMessage(true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback, useEffect, useRef, type MutableRefObject } from 'react';
|
||||
import {
|
||||
parseHashConversation,
|
||||
parseHashSettingsSection,
|
||||
updateUrlHash,
|
||||
resolveChannelFromHashToken,
|
||||
resolveContactFromHashToken,
|
||||
@@ -18,6 +19,7 @@ interface UseConversationRouterArgs {
|
||||
channels: Channel[];
|
||||
contacts: Contact[];
|
||||
contactsLoaded: boolean;
|
||||
suspendHashSync: boolean;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||
hasSetDefaultConversation: MutableRefObject<boolean>;
|
||||
@@ -27,6 +29,7 @@ export function useConversationRouter({
|
||||
channels,
|
||||
contacts,
|
||||
contactsLoaded,
|
||||
suspendHashSync,
|
||||
setSidebarOpen,
|
||||
pendingDeleteFallbackRef,
|
||||
hasSetDefaultConversation,
|
||||
@@ -34,7 +37,9 @@ export function useConversationRouter({
|
||||
const [activeConversation, setActiveConversationState] = useState<Conversation | null>(null);
|
||||
const activeConversationRef = useRef<Conversation | null>(null);
|
||||
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) => {
|
||||
@@ -58,7 +63,7 @@ export function useConversationRouter({
|
||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||
if (channels.length === 0) return;
|
||||
|
||||
const hashConv = parseHashConversation();
|
||||
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
|
||||
|
||||
// Handle non-data views immediately
|
||||
if (hashConv?.type === 'raw') {
|
||||
@@ -141,7 +146,7 @@ export function useConversationRouter({
|
||||
useEffect(() => {
|
||||
if (hasSetDefaultConversation.current || activeConversation) return;
|
||||
|
||||
const hashConv = parseHashConversation();
|
||||
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
|
||||
if (hashConv?.type === 'contact') {
|
||||
if (!contactsLoaded) return;
|
||||
|
||||
@@ -203,14 +208,14 @@ export function useConversationRouter({
|
||||
useEffect(() => {
|
||||
activeConversationRef.current = activeConversation;
|
||||
if (activeConversation) {
|
||||
if (hashSyncEnabledRef.current) {
|
||||
if (hashSyncEnabledRef.current && !suspendHashSync) {
|
||||
updateUrlHash(activeConversation);
|
||||
}
|
||||
if (getReopenLastConversationEnabled() && activeConversation.type !== 'search') {
|
||||
saveLastViewedConversation(activeConversation);
|
||||
}
|
||||
}
|
||||
}, [activeConversation]);
|
||||
}, [activeConversation, suspendHashSync]);
|
||||
|
||||
// If a delete action left us without an active conversation, recover to Public
|
||||
useEffect(() => {
|
||||
|
||||
@@ -164,7 +164,10 @@ vi.mock('../components/ui/sonner', () => ({
|
||||
|
||||
vi.mock('../utils/urlHash', () => ({
|
||||
parseHashConversation: () => null,
|
||||
parseHashSettingsSection: () => null,
|
||||
updateUrlHash: vi.fn(),
|
||||
updateSettingsHash: vi.fn(),
|
||||
getSettingsHash: (section: string) => `#settings/${section}`,
|
||||
getMapFocusHash: () => '#map',
|
||||
}));
|
||||
|
||||
|
||||
@@ -90,7 +90,9 @@ vi.mock('../components/NewMessageModal', () => ({
|
||||
}));
|
||||
|
||||
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_LABELS: {
|
||||
radio: 'Radio',
|
||||
@@ -293,4 +295,20 @@ describe('App startup hash resolution', () => {
|
||||
});
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,6 +63,7 @@ const baseSettings: AppSettings = {
|
||||
};
|
||||
|
||||
function renderModal(overrides?: {
|
||||
config?: RadioConfig | null;
|
||||
appSettings?: AppSettings;
|
||||
health?: HealthStatus;
|
||||
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
|
||||
@@ -97,7 +98,7 @@ function renderModal(overrides?: {
|
||||
const commonProps = {
|
||||
open: overrides?.open ?? true,
|
||||
pageMode: overrides?.pageMode,
|
||||
config: baseConfig,
|
||||
config: overrides?.config === undefined ? baseConfig : overrides.config,
|
||||
health: overrides?.health ?? baseHealth,
|
||||
appSettings: overrides?.appSettings ?? baseSettings,
|
||||
onClose,
|
||||
@@ -205,6 +206,32 @@ describe('SettingsModal', () => {
|
||||
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', () => {
|
||||
renderModal({
|
||||
health: {
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
parseHashConversation,
|
||||
parseHashSettingsSection,
|
||||
getSettingsHash,
|
||||
getMapFocusHash,
|
||||
resolveChannelFromHashToken,
|
||||
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', () => {
|
||||
const channels: Channel[] = [
|
||||
{
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { act, renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { useAppShell } from '../hooks/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', () => {
|
||||
const { result } = renderHook(() => useAppShell());
|
||||
|
||||
@@ -34,6 +44,55 @@ describe('useAppShell', () => {
|
||||
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', () => {
|
||||
const { result } = renderHook(() => useAppShell());
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Channel, Contact, Conversation } from '../types';
|
||||
import { findPublicChannel, PUBLIC_CHANNEL_NAME } from './publicChannel';
|
||||
import { getContactDisplayName } from './pubkey';
|
||||
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||
|
||||
interface ParsedHashConversation {
|
||||
type: 'channel' | 'contact' | 'raw' | 'map' | 'visualizer' | 'search';
|
||||
@@ -12,6 +13,15 @@ interface ParsedHashConversation {
|
||||
mapFocusKey?: string;
|
||||
}
|
||||
|
||||
const SETTINGS_SECTIONS: SettingsSection[] = [
|
||||
'radio',
|
||||
'local',
|
||||
'fanout',
|
||||
'database',
|
||||
'statistics',
|
||||
'about',
|
||||
];
|
||||
|
||||
// Parse URL hash to get conversation
|
||||
// (e.g., #channel/ABCDEF0123456789ABCDEF0123456789 or #contact/<64-char-pubkey>).
|
||||
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 {
|
||||
const normalizedToken = token.trim();
|
||||
if (!normalizedToken) return null;
|
||||
@@ -141,3 +165,10 @@ export function updateUrlHash(conv: Conversation | null): void {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user