extract app shell prop assembly

This commit is contained in:
Jack Kingsman
2026-03-09 21:07:56 -07:00
parent 319b84455b
commit 3316f00271
5 changed files with 584 additions and 113 deletions

View File

@@ -42,6 +42,7 @@ frontend/src/
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery
│ ├── useAppShell.ts # App-shell view state (settings/sidebar/modals/cracker)
│ ├── useAppShellProps.ts # AppShell child prop assembly + cracker create/decrypt flow
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
│ ├── useRadioControl.ts # Radio health/config state, reconnection
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
@@ -147,6 +148,7 @@ frontend/src/
├── useConversationMessages.test.ts
├── useConversationMessages.race.test.ts
├── useConversationNavigation.test.ts
├── useAppShellProps.test.ts
├── useAppShell.test.ts
├── useRepeaterDashboard.test.ts
├── useContactsAndChannels.test.ts
@@ -178,6 +180,7 @@ High-level state is delegated to hooks:
- `useConversationRouter`: URL hash → active conversation routing
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
- `useConversationActions`: send/resend/trace/block handlers and channel override updates
- `useAppShellProps`: assembles the prop bundles passed into `AppShell` children, including the cracker-created-channel historical decrypt flow
- `useConversationMessages`: dedup/update helpers and pending ACK buffering
- `useConversationTimeline`: conversation switch loading, cache restore, jump-target loading, pagination, reconcile
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps

View File

@@ -4,6 +4,7 @@ import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
import {
useAppShell,
useAppShellProps,
useUnreadCounts,
useConversationMessages,
useRadioControl,
@@ -222,6 +223,81 @@ export function App() {
messageInputRef,
});
const {
statusProps,
sidebarProps,
conversationPaneProps,
searchProps,
settingsProps,
crackerProps,
newMessageModalProps,
contactInfoPaneProps,
channelInfoPaneProps,
} = useAppShellProps({
contacts,
channels,
rawPackets,
undecryptedCount,
activeConversation,
config,
health,
favorites,
appSettings,
unreadCounts,
mentions,
lastMessageTimes,
showCracker,
crackerRunning,
messageInputRef,
targetMessageId,
infoPaneContactKey,
infoPaneFromChannel,
infoPaneChannelKey,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
hasNewerMessages,
loadingNewer,
handleOpenNewMessage,
handleToggleCracker,
markAllRead,
handleSortOrderChange,
handleSelectConversationWithTargetReset,
handleNavigateToMessage,
handleSaveConfig,
handleSaveAppSettings,
handleSetPrivateKey,
handleReboot,
handleAdvertise,
handleHealthRefresh,
fetchAppSettings,
setChannels,
fetchUndecryptedCount,
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleDeleteContact,
handleDeleteChannel,
handleToggleFavorite,
handleSetChannelFloodScopeOverride,
handleOpenContactInfo,
handleOpenChannelInfo,
handleCloseContactInfo,
handleCloseChannelInfo,
handleSenderClick,
handleResendChannelMessage,
handleTrace,
handleSendMessage,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
setTargetMessageId,
handleNavigateToChannel,
handleBlockKey,
handleBlockName,
});
// Connect to WebSocket
useWebSocket(wsHandlers);
@@ -266,119 +342,15 @@ export function App() {
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onLocalLabelChange={setLocalLabel}
statusProps={{ health, config }}
sidebarProps={{
contacts,
channels,
activeConversation,
onSelectConversation: handleSelectConversationWithTargetReset,
onNewMessage: handleOpenNewMessage,
lastMessageTimes,
unreadCounts,
mentions,
showCracker,
crackerRunning,
onToggleCracker: handleToggleCracker,
onMarkAllRead: markAllRead,
favorites,
sortOrder: appSettings?.sidebar_sort_order ?? 'recent',
onSortOrderChange: handleSortOrderChange,
}}
conversationPaneProps={{
activeConversation,
contacts,
channels,
rawPackets,
config,
health,
favorites,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
targetMessageId,
hasNewerMessages,
loadingNewer,
messageInputRef,
onTrace: handleTrace,
onToggleFavorite: handleToggleFavorite,
onDeleteContact: handleDeleteContact,
onDeleteChannel: handleDeleteChannel,
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
onOpenContactInfo: handleOpenContactInfo,
onOpenChannelInfo: handleOpenChannelInfo,
onSenderClick: handleSenderClick,
onLoadOlder: fetchOlderMessages,
onResendChannelMessage: handleResendChannelMessage,
onTargetReached: () => setTargetMessageId(null),
onLoadNewer: fetchNewerMessages,
onJumpToBottom: jumpToBottom,
onSendMessage: handleSendMessage,
}}
searchProps={{
contacts,
channels,
onNavigateToMessage: handleNavigateToMessage,
}}
settingsProps={{
config,
health,
appSettings,
onSave: handleSaveConfig,
onSaveAppSettings: handleSaveAppSettings,
onSetPrivateKey: handleSetPrivateKey,
onReboot: handleReboot,
onAdvertise: handleAdvertise,
onHealthRefresh: handleHealthRefresh,
onRefreshAppSettings: fetchAppSettings,
blockedKeys: appSettings?.blocked_keys,
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
}}
crackerProps={{
packets: rawPackets,
channels,
onChannelCreate: async (name, key) => {
const created = await api.createChannel(name, key);
const data = await api.getChannels();
setChannels(data);
await api.decryptHistoricalPackets({
key_type: 'channel',
channel_key: created.key,
});
fetchUndecryptedCount();
},
}}
newMessageModalProps={{
contacts,
undecryptedCount,
onSelectConversation: handleSelectConversationWithTargetReset,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,
}}
contactInfoPaneProps={{
contactKey: infoPaneContactKey,
fromChannel: infoPaneFromChannel,
onClose: handleCloseContactInfo,
contacts,
config,
favorites,
onToggleFavorite: handleToggleFavorite,
onNavigateToChannel: handleNavigateToChannel,
blockedKeys: appSettings?.blocked_keys,
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
}}
channelInfoPaneProps={{
channelKey: infoPaneChannelKey,
onClose: handleCloseChannelInfo,
channels,
favorites,
onToggleFavorite: handleToggleFavorite,
}}
statusProps={statusProps}
sidebarProps={sidebarProps}
conversationPaneProps={conversationPaneProps}
searchProps={searchProps}
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
/>
);
}

View File

@@ -9,3 +9,4 @@ export { useContactsAndChannels } from './useContactsAndChannels';
export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';
export { useAppShellProps } from './useAppShellProps';

View File

@@ -0,0 +1,306 @@
import { useCallback, type ComponentProps, type Dispatch, type SetStateAction } from 'react';
import { api } from '../api';
import { ChannelInfoPane } from '../components/ChannelInfoPane';
import { ContactInfoPane } from '../components/ContactInfoPane';
import { ConversationPane } from '../components/ConversationPane';
import { NewMessageModal } from '../components/NewMessageModal';
import { SearchView } from '../components/SearchView';
import { SettingsModal } from '../components/SettingsModal';
import { Sidebar } from '../components/Sidebar';
import { StatusBar } from '../components/StatusBar';
import { CrackerPanel } from '../components/CrackerPanel';
import type {
AppSettings,
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
RadioConfig,
RawPacket,
} from '../types';
type StatusProps = Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
type SidebarProps = ComponentProps<typeof Sidebar>;
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
type SearchProps = ComponentProps<typeof SearchView>;
type SettingsProps = Omit<
ComponentProps<typeof SettingsModal>,
'open' | 'pageMode' | 'externalSidebarNav' | 'desktopSection' | 'onClose' | 'onLocalLabelChange'
>;
type CrackerProps = Omit<ComponentProps<typeof CrackerPanel>, 'visible' | 'onRunningChange'>;
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
interface UseAppShellPropsArgs {
contacts: Contact[];
channels: Channel[];
rawPackets: RawPacket[];
undecryptedCount: number;
activeConversation: Conversation | null;
config: RadioConfig | null;
health: HealthStatus | null;
favorites: Favorite[];
appSettings: AppSettings | null;
unreadCounts: Record<string, number>;
mentions: Record<string, boolean>;
lastMessageTimes: Record<string, number>;
showCracker: boolean;
crackerRunning: boolean;
messageInputRef: ConversationPaneProps['messageInputRef'];
targetMessageId: number | null;
infoPaneContactKey: string | null;
infoPaneFromChannel: boolean;
infoPaneChannelKey: string | null;
messages: Message[];
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
hasNewerMessages: boolean;
loadingNewer: boolean;
handleOpenNewMessage: () => void;
handleToggleCracker: () => void;
markAllRead: () => void;
handleSortOrderChange: (sortOrder: 'recent' | 'alpha') => Promise<void>;
handleSelectConversationWithTargetReset: (
conv: Conversation,
options?: { preserveTarget?: boolean }
) => void;
handleNavigateToMessage: SearchProps['onNavigateToMessage'];
handleSaveConfig: SettingsProps['onSave'];
handleSaveAppSettings: SettingsProps['onSaveAppSettings'];
handleSetPrivateKey: SettingsProps['onSetPrivateKey'];
handleReboot: SettingsProps['onReboot'];
handleAdvertise: SettingsProps['onAdvertise'];
handleHealthRefresh: SettingsProps['onHealthRefresh'];
fetchAppSettings: () => Promise<void>;
setChannels: Dispatch<SetStateAction<Channel[]>>;
fetchUndecryptedCount: () => Promise<void>;
handleCreateContact: NewMessageModalProps['onCreateContact'];
handleCreateChannel: NewMessageModalProps['onCreateChannel'];
handleCreateHashtagChannel: NewMessageModalProps['onCreateHashtagChannel'];
handleDeleteContact: ConversationPaneProps['onDeleteContact'];
handleDeleteChannel: ConversationPaneProps['onDeleteChannel'];
handleToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
handleSetChannelFloodScopeOverride: ConversationPaneProps['onSetChannelFloodScopeOverride'];
handleOpenContactInfo: ConversationPaneProps['onOpenContactInfo'];
handleOpenChannelInfo: ConversationPaneProps['onOpenChannelInfo'];
handleCloseContactInfo: () => void;
handleCloseChannelInfo: () => void;
handleSenderClick: NonNullable<ConversationPaneProps['onSenderClick']>;
handleResendChannelMessage: NonNullable<ConversationPaneProps['onResendChannelMessage']>;
handleTrace: ConversationPaneProps['onTrace'];
handleSendMessage: ConversationPaneProps['onSendMessage'];
fetchOlderMessages: ConversationPaneProps['onLoadOlder'];
fetchNewerMessages: ConversationPaneProps['onLoadNewer'];
jumpToBottom: ConversationPaneProps['onJumpToBottom'];
setTargetMessageId: Dispatch<SetStateAction<number | null>>;
handleNavigateToChannel: ContactInfoPaneProps['onNavigateToChannel'];
handleBlockKey: NonNullable<ContactInfoPaneProps['onToggleBlockedKey']>;
handleBlockName: NonNullable<ContactInfoPaneProps['onToggleBlockedName']>;
}
interface UseAppShellPropsResult {
statusProps: StatusProps;
sidebarProps: SidebarProps;
conversationPaneProps: ConversationPaneProps;
searchProps: SearchProps;
settingsProps: SettingsProps;
crackerProps: CrackerProps;
newMessageModalProps: NewMessageModalProps;
contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps;
}
export function useAppShellProps({
contacts,
channels,
rawPackets,
undecryptedCount,
activeConversation,
config,
health,
favorites,
appSettings,
unreadCounts,
mentions,
lastMessageTimes,
showCracker,
crackerRunning,
messageInputRef,
targetMessageId,
infoPaneContactKey,
infoPaneFromChannel,
infoPaneChannelKey,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
hasNewerMessages,
loadingNewer,
handleOpenNewMessage,
handleToggleCracker,
markAllRead,
handleSortOrderChange,
handleSelectConversationWithTargetReset,
handleNavigateToMessage,
handleSaveConfig,
handleSaveAppSettings,
handleSetPrivateKey,
handleReboot,
handleAdvertise,
handleHealthRefresh,
fetchAppSettings,
setChannels,
fetchUndecryptedCount,
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleDeleteContact,
handleDeleteChannel,
handleToggleFavorite,
handleSetChannelFloodScopeOverride,
handleOpenContactInfo,
handleOpenChannelInfo,
handleCloseContactInfo,
handleCloseChannelInfo,
handleSenderClick,
handleResendChannelMessage,
handleTrace,
handleSendMessage,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
setTargetMessageId,
handleNavigateToChannel,
handleBlockKey,
handleBlockName,
}: UseAppShellPropsArgs): UseAppShellPropsResult {
const handleCreateCrackedChannel = useCallback<CrackerProps['onChannelCreate']>(
async (name, key) => {
const created = await api.createChannel(name, key);
const updatedChannels = await api.getChannels();
setChannels(updatedChannels);
await api.decryptHistoricalPackets({
key_type: 'channel',
channel_key: created.key,
});
await fetchUndecryptedCount();
},
[fetchUndecryptedCount, setChannels]
);
return {
statusProps: { health, config },
sidebarProps: {
contacts,
channels,
activeConversation,
onSelectConversation: handleSelectConversationWithTargetReset,
onNewMessage: handleOpenNewMessage,
lastMessageTimes,
unreadCounts,
mentions,
showCracker,
crackerRunning,
onToggleCracker: handleToggleCracker,
onMarkAllRead: () => {
void markAllRead();
},
favorites,
sortOrder: appSettings?.sidebar_sort_order ?? 'recent',
onSortOrderChange: (sortOrder) => {
void handleSortOrderChange(sortOrder);
},
},
conversationPaneProps: {
activeConversation,
contacts,
channels,
rawPackets,
config,
health,
favorites,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
targetMessageId,
hasNewerMessages,
loadingNewer,
messageInputRef,
onTrace: handleTrace,
onToggleFavorite: handleToggleFavorite,
onDeleteContact: handleDeleteContact,
onDeleteChannel: handleDeleteChannel,
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
onOpenContactInfo: handleOpenContactInfo,
onOpenChannelInfo: handleOpenChannelInfo,
onSenderClick: handleSenderClick,
onLoadOlder: fetchOlderMessages,
onResendChannelMessage: handleResendChannelMessage,
onTargetReached: () => setTargetMessageId(null),
onLoadNewer: fetchNewerMessages,
onJumpToBottom: jumpToBottom,
onSendMessage: handleSendMessage,
},
searchProps: {
contacts,
channels,
onNavigateToMessage: handleNavigateToMessage,
},
settingsProps: {
config,
health,
appSettings,
onSave: handleSaveConfig,
onSaveAppSettings: handleSaveAppSettings,
onSetPrivateKey: handleSetPrivateKey,
onReboot: handleReboot,
onAdvertise: handleAdvertise,
onHealthRefresh: handleHealthRefresh,
onRefreshAppSettings: fetchAppSettings,
blockedKeys: appSettings?.blocked_keys,
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
},
crackerProps: {
packets: rawPackets,
channels,
onChannelCreate: handleCreateCrackedChannel,
},
newMessageModalProps: {
contacts,
undecryptedCount,
onSelectConversation: handleSelectConversationWithTargetReset,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,
},
contactInfoPaneProps: {
contactKey: infoPaneContactKey,
fromChannel: infoPaneFromChannel,
onClose: handleCloseContactInfo,
contacts,
config,
favorites,
onToggleFavorite: handleToggleFavorite,
onNavigateToChannel: handleNavigateToChannel,
blockedKeys: appSettings?.blocked_keys,
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
},
channelInfoPaneProps: {
channelKey: infoPaneChannelKey,
onClose: handleCloseChannelInfo,
channels,
favorites,
onToggleFavorite: handleToggleFavorite,
},
};
}

View File

@@ -0,0 +1,189 @@
import { act, renderHook } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { useAppShellProps } from '../hooks/useAppShellProps';
import type {
AppSettings,
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
RadioConfig,
RawPacket,
} from '../types';
const mocks = vi.hoisted(() => ({
api: {
createChannel: vi.fn(),
getChannels: vi.fn(),
decryptHistoricalPackets: vi.fn(),
},
}));
vi.mock('../api', () => ({
api: mocks.api,
}));
const publicChannel: Channel = {
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
name: 'Public',
is_hashtag: false,
on_radio: false,
last_read_at: null,
};
const config: RadioConfig = {
public_key: 'aa'.repeat(32),
name: 'TestNode',
lat: 0,
lon: 0,
tx_power: 17,
max_tx_power: 22,
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
path_hash_mode: 0,
path_hash_mode_supported: false,
};
const health: HealthStatus = {
status: 'connected',
radio_connected: true,
radio_initializing: false,
connection_info: null,
database_size_mb: 1,
oldest_undecrypted_timestamp: null,
fanout_statuses: {},
bots_disabled: false,
};
const appSettings: AppSettings = {
max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {},
preferences_migrated: true,
advert_interval: 0,
last_advert_time: 0,
flood_scope: '',
blocked_keys: [],
blocked_names: [],
};
function createArgs(overrides: Partial<Parameters<typeof useAppShellProps>[0]> = {}) {
const activeConversation: Conversation = {
type: 'channel',
id: publicChannel.key,
name: publicChannel.name,
};
const contacts: Contact[] = [];
const channels: Channel[] = [publicChannel];
const rawPackets: RawPacket[] = [];
const favorites: Favorite[] = [];
const messages: Message[] = [];
return {
contacts,
channels,
rawPackets,
undecryptedCount: 0,
activeConversation,
config,
health,
favorites,
appSettings,
unreadCounts: {},
mentions: {},
lastMessageTimes: {},
showCracker: false,
crackerRunning: false,
messageInputRef: { current: null },
targetMessageId: null,
infoPaneContactKey: null,
infoPaneFromChannel: false,
infoPaneChannelKey: null,
messages,
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
handleOpenNewMessage: vi.fn(),
handleToggleCracker: vi.fn(),
markAllRead: vi.fn(async () => {}),
handleSortOrderChange: vi.fn(async () => {}),
handleSelectConversationWithTargetReset: vi.fn(),
handleNavigateToMessage: vi.fn(),
handleSaveConfig: vi.fn(async () => {}),
handleSaveAppSettings: vi.fn(async () => {}),
handleSetPrivateKey: vi.fn(async () => {}),
handleReboot: vi.fn(async () => {}),
handleAdvertise: vi.fn(async () => {}),
handleHealthRefresh: vi.fn(async () => {}),
fetchAppSettings: vi.fn(async () => {}),
setChannels: vi.fn(),
fetchUndecryptedCount: vi.fn(async () => {}),
handleCreateContact: vi.fn(async () => {}),
handleCreateChannel: vi.fn(async () => {}),
handleCreateHashtagChannel: vi.fn(async () => {}),
handleDeleteContact: vi.fn(async () => {}),
handleDeleteChannel: vi.fn(async () => {}),
handleToggleFavorite: vi.fn(async () => {}),
handleSetChannelFloodScopeOverride: vi.fn(async () => {}),
handleOpenContactInfo: vi.fn(),
handleOpenChannelInfo: vi.fn(),
handleCloseContactInfo: vi.fn(),
handleCloseChannelInfo: vi.fn(),
handleSenderClick: vi.fn(),
handleResendChannelMessage: vi.fn(async () => {}),
handleTrace: vi.fn(async () => {}),
handleSendMessage: vi.fn(async () => {}),
fetchOlderMessages: vi.fn(async () => {}),
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
setTargetMessageId: vi.fn(),
handleNavigateToChannel: vi.fn(),
handleBlockKey: vi.fn(async () => {}),
handleBlockName: vi.fn(async () => {}),
...overrides,
};
}
describe('useAppShellProps', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('creates a cracked channel, refreshes channels, decrypts history, and refreshes undecrypted count', async () => {
mocks.api.createChannel.mockResolvedValue({
key: '11'.repeat(16),
name: 'Found',
is_hashtag: false,
});
mocks.api.getChannels.mockResolvedValue([
publicChannel,
{ ...publicChannel, key: '11'.repeat(16), name: 'Found' },
]);
mocks.api.decryptHistoricalPackets.mockResolvedValue({ decrypted_count: 4 });
const args = createArgs();
const { result } = renderHook(() => useAppShellProps(args));
await act(async () => {
await result.current.crackerProps.onChannelCreate('Found', '11'.repeat(16));
});
expect(mocks.api.createChannel).toHaveBeenCalledWith('Found', '11'.repeat(16));
expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
expect(args.setChannels).toHaveBeenCalledWith([
publicChannel,
{ ...publicChannel, key: '11'.repeat(16), name: 'Found' },
]);
expect(mocks.api.decryptHistoricalPackets).toHaveBeenCalledWith({
key_type: 'channel',
channel_key: '11'.repeat(16),
});
expect(args.fetchUndecryptedCount).toHaveBeenCalledTimes(1);
});
});