From 19d7c3c98c49280d967053196866ee7192c0383c Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Mon, 9 Mar 2026 19:41:03 -0700 Subject: [PATCH] extract conversation pane component --- frontend/AGENTS.md | 11 + frontend/src/App.tsx | 182 +++------------ frontend/src/components/ConversationPane.tsx | 219 +++++++++++++++++++ frontend/src/test/conversationPane.test.tsx | 195 +++++++++++++++++ 4 files changed, 459 insertions(+), 148 deletions(-) create mode 100644 frontend/src/components/ConversationPane.tsx create mode 100644 frontend/src/test/conversationPane.test.tsx diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 6531178..81ab0eb 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -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. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 51664c9..23a321b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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' ? ( - <> -

- Node Map -

-
- - Loading map... -
- } - > - - - - - ) : activeConversation.type === 'visualizer' ? ( - - Loading visualizer... - - } - > - - - ) : activeConversation.type === 'raw' ? ( - <> -

- Raw Packet Feed -

-
- -
- - ) : activeConversation.type === 'search' ? null : activeContactIsRepeater ? ( - - Loading dashboard... - - } - > - - - ) : ( - <> - - setTargetMessageId(null)} - hasNewerMessages={hasNewerMessages} - loadingNewer={loadingNewer} - onLoadNewer={fetchNewerMessages} - onJumpToBottom={jumpToBottom} - /> - - - ) - ) : ( -
- Select a conversation or start a new one -
- )} + setTargetMessageId(null)} + onLoadNewer={fetchNewerMessages} + onJumpToBottom={jumpToBottom} + onSendMessage={handleSendMessage} + /> {searchMounted.current && ( diff --git a/frontend/src/components/ConversationPane.tsx b/frontend/src/components/ConversationPane.tsx new file mode 100644 index 0000000..5def504 --- /dev/null +++ b/frontend/src/components/ConversationPane.tsx @@ -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; + onTrace: () => Promise; + onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise; + onDeleteContact: (publicKey: string) => Promise; + onDeleteChannel: (key: string) => Promise; + onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise; + onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; + onOpenChannelInfo: (channelKey: string) => void; + onSenderClick: (sender: string) => void; + onLoadOlder: () => Promise; + onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise; + onTargetReached: () => void; + onLoadNewer: () => Promise; + onJumpToBottom: () => void; + onSendMessage: (text: string) => Promise; +} + +function LoadingPane({ label }: { label: string }) { + return ( +
{label}
+ ); +} + +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 ( +
+ Select a conversation or start a new one +
+ ); + } + + if (activeConversation.type === 'map') { + return ( + <> +

+ Node Map +

+
+ }> + + +
+ + ); + } + + if (activeConversation.type === 'visualizer') { + return ( + }> + + + ); + } + + if (activeConversation.type === 'raw') { + return ( + <> +

+ Raw Packet Feed +

+
+ +
+ + ); + } + + if (activeConversation.type === 'search') { + return null; + } + + if (activeContactIsRepeater) { + return ( + }> + + + ); + } + + return ( + <> + + + + + ); +} diff --git a/frontend/src/test/conversationPane.test.tsx b/frontend/src/test/conversationPane.test.tsx new file mode 100644 index 0000000..9f35335 --- /dev/null +++ b/frontend/src/test/conversationPane.test.tsx @@ -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: () =>
, +})); + +vi.mock('../components/MessageList', () => ({ + MessageList: () =>
, +})); + +vi.mock('../components/MessageInput', () => ({ + MessageInput: React.forwardRef((_props, ref) => { + React.useImperativeHandle(ref, () => ({ appendText: vi.fn() })); + return
; + }), +})); + +vi.mock('../components/RawPacketList', () => ({ + RawPacketList: () =>
, +})); + +vi.mock('../components/RepeaterDashboard', () => ({ + RepeaterDashboard: () =>
, +})); + +vi.mock('../components/MapView', () => ({ + MapView: () =>
, +})); + +vi.mock('../components/VisualizerView', () => ({ + VisualizerView: () =>
, +})); + +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> = {}) { + 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(); + + 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( + + ); + + expect(await screen.findByTestId('repeater-dashboard')).toBeInTheDocument(); + expect(screen.queryByTestId('message-list')).not.toBeInTheDocument(); + }); + + it('renders chat chrome for normal channel conversations', async () => { + render( + + ); + + await waitFor(() => { + expect(screen.getByTestId('chat-header')).toBeInTheDocument(); + expect(screen.getByTestId('message-list')).toBeInTheDocument(); + expect(screen.getByTestId('message-input')).toBeInTheDocument(); + }); + }); +});