extract conversation pane component

This commit is contained in:
Jack Kingsman
2026-03-09 19:41:03 -07:00
parent ae0ef90fe2
commit 19d7c3c98c
4 changed files with 459 additions and 148 deletions
+11
View File
@@ -45,6 +45,9 @@ frontend/src/
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
│ ├── useConversationRouter.ts # URL hash → active conversation routing
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
├── components/
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
│ └── ...
├── utils/
│ ├── urlHash.ts # Hash parsing and encoding
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
@@ -166,6 +169,14 @@ frontend/src/
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
`ConversationPane.tsx` owns the main active-conversation surface branching:
- empty state
- map view
- visualizer
- raw packet feed
- repeater dashboard
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
### Initial load + realtime
- Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads.
+34 -148
View File
@@ -1,13 +1,4 @@
import {
useState,
useEffect,
useCallback,
useMemo,
useRef,
startTransition,
lazy,
Suspense,
} from 'react';
import { useState, useEffect, useCallback, useRef, startTransition, lazy, Suspense } from 'react';
import { api } from './api';
import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
@@ -23,28 +14,16 @@ import {
} from './hooks';
import { StatusBar } from './components/StatusBar';
import { Sidebar } from './components/Sidebar';
import { ChatHeader } from './components/ChatHeader';
import { MessageList } from './components/MessageList';
import { MessageInput, type MessageInputHandle } from './components/MessageInput';
import { ConversationPane } from './components/ConversationPane';
import type { MessageInputHandle } from './components/MessageInput';
import { NewMessageModal } from './components/NewMessageModal';
import {
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
type SettingsSection,
} from './components/settings/settingsConstants';
import { RawPacketList } from './components/RawPacketList';
import { ContactInfoPane } from './components/ContactInfoPane';
import { ChannelInfoPane } from './components/ChannelInfoPane';
import { CONTACT_TYPE_REPEATER } from './types';
// Lazy-load heavy components to reduce initial bundle
const RepeaterDashboard = lazy(() =>
import('./components/RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
);
const MapView = lazy(() => import('./components/MapView').then((m) => ({ default: m.MapView })));
const VisualizerView = lazy(() =>
import('./components/VisualizerView').then((m) => ({ default: m.VisualizerView }))
);
const SettingsModal = lazy(() =>
import('./components/SettingsModal').then((m) => ({ default: m.SettingsModal }))
);
@@ -209,13 +188,6 @@ export function App() {
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
// Determine if active contact is a repeater (used for routing to dashboard)
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find((c) => c.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
const wsHandlers = useRealtimeAppState({
prevHealthRef,
setHealth,
@@ -431,123 +403,37 @@ export function App() {
(showSettings || activeConversation?.type === 'search') && 'hidden'
)}
>
{activeConversation ? (
activeConversation.type === 'map' ? (
<>
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Node Map
</h2>
<div className="flex-1 overflow-hidden">
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading map...
</div>
}
>
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
</Suspense>
</div>
</>
) : activeConversation.type === 'visualizer' ? (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading visualizer...
</div>
}
>
<VisualizerView packets={rawPackets} contacts={contacts} config={config} />
</Suspense>
) : activeConversation.type === 'raw' ? (
<>
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Raw Packet Feed
</h2>
<div className="flex-1 overflow-hidden">
<RawPacketList packets={rawPackets} />
</div>
</>
) : activeConversation.type === 'search' ? null : activeContactIsRepeater ? (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading dashboard...
</div>
}
>
<RepeaterDashboard
key={activeConversation.id}
conversation={activeConversation}
contacts={contacts}
favorites={favorites}
radioLat={config?.lat ?? null}
radioLon={config?.lon ?? null}
radioName={config?.name ?? null}
onTrace={handleTrace}
onToggleFavorite={handleToggleFavorite}
onDeleteContact={handleDeleteContact}
/>
</Suspense>
) : (
<>
<ChatHeader
conversation={activeConversation}
contacts={contacts}
channels={channels}
config={config}
favorites={favorites}
onTrace={handleTrace}
onToggleFavorite={handleToggleFavorite}
onSetChannelFloodScopeOverride={handleSetChannelFloodScopeOverride}
onDeleteChannel={handleDeleteChannel}
onDeleteContact={handleDeleteContact}
onOpenContactInfo={handleOpenContactInfo}
onOpenChannelInfo={handleOpenChannelInfo}
/>
<MessageList
key={activeConversation.id}
messages={messages}
contacts={contacts}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
onSenderClick={
activeConversation.type === 'channel' ? handleSenderClick : undefined
}
onLoadOlder={fetchOlderMessages}
onResendChannelMessage={
activeConversation.type === 'channel' ? handleResendChannelMessage : undefined
}
radioName={config?.name}
config={config}
onOpenContactInfo={handleOpenContactInfo}
targetMessageId={targetMessageId}
onTargetReached={() => setTargetMessageId(null)}
hasNewerMessages={hasNewerMessages}
loadingNewer={loadingNewer}
onLoadNewer={fetchNewerMessages}
onJumpToBottom={jumpToBottom}
/>
<MessageInput
ref={messageInputRef}
onSend={handleSendMessage}
disabled={!health?.radio_connected}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected
? 'Radio not connected'
: `Message ${activeConversation.name}...`
}
/>
</>
)
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select a conversation or start a new one
</div>
)}
<ConversationPane
activeConversation={activeConversation}
contacts={contacts}
channels={channels}
rawPackets={rawPackets}
config={config}
health={health}
favorites={favorites}
messages={messages}
messagesLoading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
targetMessageId={targetMessageId}
hasNewerMessages={hasNewerMessages}
loadingNewer={loadingNewer}
messageInputRef={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}
/>
</div>
{searchMounted.current && (
@@ -0,0 +1,219 @@
import { lazy, Suspense, useMemo, type Ref } from 'react';
import { ChatHeader } from './ChatHeader';
import { MessageInput, type MessageInputHandle } from './MessageInput';
import { MessageList } from './MessageList';
import { RawPacketList } from './RawPacketList';
import type {
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
RawPacket,
RadioConfig,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
const RepeaterDashboard = lazy(() =>
import('./RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
);
const MapView = lazy(() => import('./MapView').then((m) => ({ default: m.MapView })));
const VisualizerView = lazy(() =>
import('./VisualizerView').then((m) => ({ default: m.VisualizerView }))
);
interface ConversationPaneProps {
activeConversation: Conversation | null;
contacts: Contact[];
channels: Channel[];
rawPackets: RawPacket[];
config: RadioConfig | null;
health: HealthStatus | null;
favorites: Favorite[];
messages: Message[];
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
targetMessageId: number | null;
hasNewerMessages: boolean;
loadingNewer: boolean;
messageInputRef: Ref<MessageInputHandle>;
onTrace: () => Promise<void>;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
onDeleteContact: (publicKey: string) => Promise<void>;
onDeleteChannel: (key: string) => Promise<void>;
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
onOpenChannelInfo: (channelKey: string) => void;
onSenderClick: (sender: string) => void;
onLoadOlder: () => Promise<void>;
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
onTargetReached: () => void;
onLoadNewer: () => Promise<void>;
onJumpToBottom: () => void;
onSendMessage: (text: string) => Promise<void>;
}
function LoadingPane({ label }: { label: string }) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">{label}</div>
);
}
export function ConversationPane({
activeConversation,
contacts,
channels,
rawPackets,
config,
health,
favorites,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
targetMessageId,
hasNewerMessages,
loadingNewer,
messageInputRef,
onTrace,
onToggleFavorite,
onDeleteContact,
onDeleteChannel,
onSetChannelFloodScopeOverride,
onOpenContactInfo,
onOpenChannelInfo,
onSenderClick,
onLoadOlder,
onResendChannelMessage,
onTargetReached,
onLoadNewer,
onJumpToBottom,
onSendMessage,
}: ConversationPaneProps) {
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
if (!activeConversation) {
return (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Select a conversation or start a new one
</div>
);
}
if (activeConversation.type === 'map') {
return (
<>
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Node Map
</h2>
<div className="flex-1 overflow-hidden">
<Suspense fallback={<LoadingPane label="Loading map..." />}>
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
</Suspense>
</div>
</>
);
}
if (activeConversation.type === 'visualizer') {
return (
<Suspense fallback={<LoadingPane label="Loading visualizer..." />}>
<VisualizerView packets={rawPackets} contacts={contacts} config={config} />
</Suspense>
);
}
if (activeConversation.type === 'raw') {
return (
<>
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Raw Packet Feed
</h2>
<div className="flex-1 overflow-hidden">
<RawPacketList packets={rawPackets} />
</div>
</>
);
}
if (activeConversation.type === 'search') {
return null;
}
if (activeContactIsRepeater) {
return (
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
<RepeaterDashboard
key={activeConversation.id}
conversation={activeConversation}
contacts={contacts}
favorites={favorites}
radioLat={config?.lat ?? null}
radioLon={config?.lon ?? null}
radioName={config?.name ?? null}
onTrace={onTrace}
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
/>
</Suspense>
);
}
return (
<>
<ChatHeader
conversation={activeConversation}
contacts={contacts}
channels={channels}
config={config}
favorites={favorites}
onTrace={onTrace}
onToggleFavorite={onToggleFavorite}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
onDeleteChannel={onDeleteChannel}
onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo}
onOpenChannelInfo={onOpenChannelInfo}
/>
<MessageList
key={activeConversation.id}
messages={messages}
contacts={contacts}
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
onLoadOlder={onLoadOlder}
onResendChannelMessage={
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
}
radioName={config?.name}
config={config}
onOpenContactInfo={onOpenContactInfo}
targetMessageId={targetMessageId}
onTargetReached={onTargetReached}
hasNewerMessages={hasNewerMessages}
loadingNewer={loadingNewer}
onLoadNewer={onLoadNewer}
onJumpToBottom={onJumpToBottom}
/>
<MessageInput
ref={messageInputRef}
onSend={onSendMessage}
disabled={!health?.radio_connected}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected ? 'Radio not connected' : `Message ${activeConversation.name}...`
}
/>
</>
);
}
+195
View File
@@ -0,0 +1,195 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { ConversationPane } from '../components/ConversationPane';
import type {
Channel,
Contact,
Conversation,
Favorite,
HealthStatus,
Message,
RadioConfig,
} from '../types';
vi.mock('../components/ChatHeader', () => ({
ChatHeader: () => <div data-testid="chat-header" />,
}));
vi.mock('../components/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
}));
vi.mock('../components/MessageInput', () => ({
MessageInput: React.forwardRef((_props, ref) => {
React.useImperativeHandle(ref, () => ({ appendText: vi.fn() }));
return <div data-testid="message-input" />;
}),
}));
vi.mock('../components/RawPacketList', () => ({
RawPacketList: () => <div data-testid="raw-packet-list" />,
}));
vi.mock('../components/RepeaterDashboard', () => ({
RepeaterDashboard: () => <div data-testid="repeater-dashboard" />,
}));
vi.mock('../components/MapView', () => ({
MapView: () => <div data-testid="map-view" />,
}));
vi.mock('../components/VisualizerView', () => ({
VisualizerView: () => <div data-testid="visualizer-view" />,
}));
const config: RadioConfig = {
public_key: 'aa'.repeat(32),
name: 'Radio',
lat: 1,
lon: 2,
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: true,
};
const health: HealthStatus = {
status: 'ok',
radio_connected: true,
radio_initializing: false,
connection_info: 'serial',
database_size_mb: 1,
oldest_undecrypted_timestamp: null,
fanout_statuses: {},
bots_disabled: false,
};
const channel: Channel = {
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
name: 'Public',
is_hashtag: false,
on_radio: false,
last_read_at: null,
};
const message: Message = {
id: 1,
type: 'CHAN',
conversation_key: channel.key,
text: 'hello',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: null,
};
function createProps(overrides: Partial<React.ComponentProps<typeof ConversationPane>> = {}) {
return {
activeConversation: null as Conversation | null,
contacts: [] as Contact[],
channels: [channel],
rawPackets: [],
config,
health,
favorites: [] as Favorite[],
messages: [message],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
targetMessageId: null,
hasNewerMessages: false,
loadingNewer: false,
messageInputRef: { current: null },
onTrace: vi.fn(async () => {}),
onToggleFavorite: vi.fn(async () => {}),
onDeleteContact: vi.fn(async () => {}),
onDeleteChannel: vi.fn(async () => {}),
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
onOpenContactInfo: vi.fn(),
onOpenChannelInfo: vi.fn(),
onSenderClick: vi.fn(),
onLoadOlder: vi.fn(async () => {}),
onResendChannelMessage: vi.fn(async () => {}),
onTargetReached: vi.fn(),
onLoadNewer: vi.fn(async () => {}),
onJumpToBottom: vi.fn(),
onSendMessage: vi.fn(async () => {}),
...overrides,
};
}
describe('ConversationPane', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the empty state when no conversation is active', () => {
render(<ConversationPane {...createProps()} />);
expect(screen.getByText('Select a conversation or start a new one')).toBeInTheDocument();
});
it('renders repeater dashboard instead of chat chrome for repeater contacts', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'bb'.repeat(32),
name: 'Repeater',
},
contacts: [
{
public_key: 'bb'.repeat(32),
name: 'Repeater',
type: 2,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
],
})}
/>
);
expect(await screen.findByTestId('repeater-dashboard')).toBeInTheDocument();
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
});
it('renders chat chrome for normal channel conversations', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'channel',
id: channel.key,
name: channel.name,
},
})}
/>
);
await waitFor(() => {
expect(screen.getByTestId('chat-header')).toBeInTheDocument();
expect(screen.getByTestId('message-list')).toBeInTheDocument();
expect(screen.getByTestId('message-input')).toBeInTheDocument();
});
});
});