# Frontend AGENTS.md This document provides context for AI assistants and developers working on the React frontend. ## Technology Stack - **React 18** - UI framework with hooks - **TypeScript** - Type safety - **Vite** - Build tool with HMR - **Vitest** - Testing framework - **Sonner** - Toast notifications - **shadcn/ui components** - Sheet, Tabs, Button (in `components/ui/`) - **meshcore-hashtag-cracker** - WebGPU-accelerated channel key bruteforcing - **nosleep.js** - Prevents device sleep during cracking - **leaflet / react-leaflet** - Interactive map for node locations ## Directory Structure ``` frontend/ ├── src/ │ ├── main.tsx # Entry point, renders App │ ├── App.tsx # Main component, all state management │ ├── api.ts # REST API client │ ├── types.ts # TypeScript interfaces │ ├── useWebSocket.ts # WebSocket hook with auto-reconnect │ ├── styles.css # Dark theme CSS │ ├── utils/ │ │ ├── messageParser.ts # Text parsing utilities │ │ ├── conversationState.ts # localStorage for message times (sidebar sorting) │ │ ├── pubkey.ts # Public key utilities (prefix matching, display names) │ │ └── contactAvatar.ts # Avatar generation (colors, initials/emoji) │ ├── components/ │ │ ├── ui/ # shadcn/ui components │ │ │ ├── sonner.tsx # Toast notifications (Sonner wrapper) │ │ │ ├── sheet.tsx # Slide-out panel │ │ │ ├── tabs.tsx # Tab navigation │ │ │ └── button.tsx # Button component │ │ ├── StatusBar.tsx # Radio status, reconnect button, config button │ │ ├── Sidebar.tsx # Contacts/channels list, search, unread badges │ │ ├── MessageList.tsx # Message display, avatars, clickable senders │ │ ├── MessageInput.tsx # Text input with imperative handle │ │ ├── ContactAvatar.tsx # Contact profile image component │ │ ├── RawPacketList.tsx # Raw packet feed display │ │ ├── MapView.tsx # Leaflet map showing node locations │ │ ├── CrackerPanel.tsx # WebGPU channel key cracker (lazy-loads wordlist) │ │ ├── NewMessageModal.tsx │ │ └── SettingsModal.tsx # Unified settings: radio config, identity, serial, database, advertise │ └── test/ │ ├── setup.ts # Test setup (jsdom, matchers) │ ├── messageParser.test.ts │ ├── unreadCounts.test.ts │ ├── contactAvatar.test.ts │ ├── messageDeduplication.test.ts │ └── websocket.test.ts ├── index.html ├── vite.config.ts # API proxy config ├── tsconfig.json └── package.json ``` ## State Management All application state lives in `App.tsx` using React hooks. No external state library. ### Core State ```typescript const [health, setHealth] = useState(null); const [config, setConfig] = useState(null); const [appSettings, setAppSettings] = useState(null); const [contacts, setContacts] = useState([]); const [channels, setChannels] = useState([]); const [messages, setMessages] = useState([]); const [rawPackets, setRawPackets] = useState([]); const [activeConversation, setActiveConversation] = useState(null); const [unreadCounts, setUnreadCounts] = useState>({}); ``` ### App Settings App settings are stored server-side and include: - `favorites` - List of favorited conversations (channels/contacts) - `sidebar_sort_order` - 'recent' or 'alpha' - `auto_decrypt_dm_on_advert` - Auto-decrypt historical DMs on new contact - `last_message_times` - Map of conversation keys to last message timestamps **Migration**: On first load, localStorage preferences are migrated to the server. The `preferences_migrated` flag prevents duplicate migrations. ### State Flow 1. **REST API** fetches initial data on mount in parallel (config, settings, channels, contacts, unreads) 2. **WebSocket** pushes real-time updates (health, messages, contact changes, raw packets) 3. **Components** receive state as props, call handlers to trigger changes **Note:** Contacts and channels are loaded via REST on mount (not from WebSocket initial push). The WebSocket only sends health on initial connect, then broadcasts real-time updates. ### Conversation Header For contacts, the header shows path information alongside "Last heard": - `(Last heard: 10:30 AM, direct)` - Direct neighbor (path_len=0) - `(Last heard: 10:30 AM, 2 hops)` - Routed through repeaters (path_len>0) - `(Last heard: 10:30 AM, flood)` - No known path (path_len=-1) ## WebSocket (`useWebSocket.ts`) The `useWebSocket` hook manages real-time connection: ```typescript const wsHandlers = useMemo(() => ({ onHealth: (data: HealthStatus) => setHealth(data), onMessage: (msg: Message) => { /* add to list, track unread */ }, onMessageAcked: (messageId: number, ackCount: number) => { /* update ack count */ }, // ... }), []); useWebSocket(wsHandlers); ``` ### Features - **Auto-reconnect**: Reconnects after 3 seconds on disconnect - **Heartbeat**: Sends ping every 30 seconds - **Event types**: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error` - **Error handling**: `onError` handler displays toast notifications for backend errors ### URL Detection ```typescript const isDev = window.location.port === '5173'; const wsUrl = isDev ? 'ws://localhost:8000/api/ws' : `${protocol}//${window.location.host}/api/ws`; ``` ## API Client (`api.ts`) Typed REST client with consistent error handling: ```typescript import { api } from './api'; // Health await api.getHealth(); // Radio await api.getRadioConfig(); await api.updateRadioConfig({ name: 'MyRadio' }); await api.sendAdvertisement(true); // Contacts/Channels await api.getContacts(); await api.createContact(publicKey, name, tryHistorical); // Create contact, optionally decrypt historical DMs await api.getChannels(); await api.createChannel('#test'); // Messages await api.getMessages({ type: 'CHAN', conversation_key: channelKey, limit: 200 }); await api.sendChannelMessage(channelKey, 'Hello'); await api.sendDirectMessage(publicKey, 'Hello'); // Historical decryption await api.decryptHistoricalPackets({ key_type: 'channel', channel_name: '#test' }); // Radio reconnection await api.reconnectRadio(); // Returns { status, message, connected } // Repeater telemetry await api.requestTelemetry(publicKey, password); // Returns TelemetryResponse // Repeater CLI commands (after login) await api.sendRepeaterCommand(publicKey, 'ver'); // Returns CommandResponse ``` ### API Proxy (Development) Vite proxies `/api/*` to backend (backend routes are already prefixed with `/api`): ```typescript // vite.config.ts server: { proxy: { '/api': { target: 'http://localhost:8000', changeOrigin: true, }, }, } ``` ## Type Definitions (`types.ts`) ### Key Interfaces ```typescript interface Contact { public_key: string; // 64-char hex public key name: string | null; type: number; // 0=unknown, 1=client, 2=repeater, 3=room on_radio: boolean; last_path_len: number; // -1=flood, 0=direct, >0=hops through repeaters last_path: string | null; // Hex routing path last_seen: number | null; // Unix timestamp // ... } interface Channel { key: string; // 32-char hex channel key name: string; is_hashtag: boolean; on_radio: boolean; } interface Message { id: number; type: 'PRIV' | 'CHAN'; conversation_key: string; // public key for PRIV, channel key for CHAN text: string; outgoing: boolean; acked: number; // 0=not acked, 1+=ack count (flood echoes) // ... } interface Conversation { type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer'; id: string; // public key for contacts, channel key for channels, 'raw'/'map'/'visualizer' for special views name: string; } interface Favorite { type: 'channel' | 'contact'; id: string; // Channel key or contact public key } interface AppSettings { max_radio_contacts: number; favorites: Favorite[]; auto_decrypt_dm_on_advert: boolean; sidebar_sort_order: 'recent' | 'alpha'; last_message_times: Record; preferences_migrated: boolean; } // Repeater telemetry types interface NeighborInfo { pubkey_prefix: string; name: string | null; snr: number; last_heard_seconds: number; } interface AclEntry { pubkey_prefix: string; name: string | null; permission: number; permission_name: string; } interface TelemetryResponse { battery_volts: number; uptime_seconds: number; // ... status fields neighbors: NeighborInfo[]; acl: AclEntry[]; } interface CommandResponse { command: string; response: string; sender_timestamp: number | null; } ``` ## Component Patterns ### MessageInput with Imperative Handle Exposes `appendText` method for click-to-mention: ```typescript export interface MessageInputHandle { appendText: (text: string) => void; } export const MessageInput = forwardRef( function MessageInput({ onSend, disabled, isRepeaterMode }, ref) { useImperativeHandle(ref, () => ({ appendText: (text: string) => { setText((prev) => prev + text); inputRef.current?.focus(); }, })); // ... } ); // Usage in App.tsx const messageInputRef = useRef(null); messageInputRef.current?.appendText(`@[${sender}] `); ``` ### Repeater Mode Repeater contacts (type=2) have a two-phase interaction: **Phase 1: Login (password mode)** - Input type changes to `password` - Button shows "Fetch" instead of "Send" - Enter "." for empty password (converted to empty string) - Submitting requests telemetry + logs in **Phase 2: CLI commands (after login)** - Input switches back to normal text - Placeholder shows "Enter CLI command..." - Commands sent via `/contacts/{key}/command` endpoint - Responses displayed as local messages (not persisted to database) ```typescript // State tracking const [repeaterLoggedIn, setRepeaterLoggedIn] = useState(false); // Reset on conversation change useEffect(() => { setRepeaterLoggedIn(false); }, [activeConversation?.id]); // Mode switches after successful telemetry const isRepeaterMode = activeContactIsRepeater && !repeaterLoggedIn; ``` Telemetry response is displayed as three local messages (not persisted): 1. **Telemetry** - Battery voltage, uptime, signal quality, packet stats 2. **Neighbors** - Sorted by SNR (highest first), with resolved names 3. **ACL** - Access control list with permission levels ### Repeater Message Rendering Repeater CLI responses often contain colons (e.g., `clock: 12:30:00`). To prevent incorrect sender parsing, MessageList skips `parseSenderFromText()` for repeater contacts: ```typescript const isRepeater = contact?.type === CONTACT_TYPE_REPEATER; const { sender, content } = isRepeater ? { sender: null, content: msg.text } // Preserve full text : parseSenderFromText(msg.text); ``` ### Unread Count Tracking Uses refs to avoid stale closures in memoized handlers: ```typescript const activeConversationRef = useRef(null); // Keep ref in sync useEffect(() => { activeConversationRef.current = activeConversation; }, [activeConversation]); // In WebSocket handler (can safely access current value) const activeConv = activeConversationRef.current; ``` ### State Tracking Keys State tracking keys (for message times used in sidebar sorting) are generated by `getStateKey()`: ```typescript import { getStateKey } from './utils/conversationState'; // Channels: "channel-{channelKey}" getStateKey('channel', channelKey) // e.g., "channel-8B3387E9C5CDEA6AC9E5EDBAA115CD72" // Contacts: "contact-{12-char-prefix}" getStateKey('contact', publicKey) // e.g., "contact-abc123def456" ``` **Note:** `getStateKey()` is NOT the same as `Message.conversation_key`. The state key is prefixed for local state tracking, while `conversation_key` is the raw database field. ### Read State (Server-Side) Unread tracking uses server-side `last_read_at` timestamps for cross-device consistency: ```typescript // Fetch aggregated unread counts from server (replaces bulk message fetch + client-side counting) await api.getUnreads(myName); // Returns { counts, mentions, last_message_times } // Mark as read via API (called automatically when viewing conversation) await api.markContactRead(publicKey); await api.markChannelRead(channelKey); await api.markAllRead(); // Bulk mark all as read ``` The `useUnreadCounts` hook fetches counts from `GET /api/read-state/unreads` on mount and when channels/contacts change. Real-time increments are still tracked client-side via WebSocket message events. The server computes unread counts using `last_read_at` vs `received_at`. ## Utility Functions ### Message Parser (`utils/messageParser.ts`) ```typescript // Parse "sender: message" format from channel messages parseSenderFromText(text: string): { sender: string | null; content: string } // Format Unix timestamp to time string formatTime(timestamp: number): string ``` ### Public Key Utilities (`utils/pubkey.ts`) Consistent handling of 64-char full keys and 12-char prefixes: ```typescript import { getPubkeyPrefix, pubkeysMatch, getContactDisplayName } from './utils/pubkey'; // Extract 12-char prefix (works with full keys or existing prefixes) getPubkeyPrefix(key) // "abc123def456..." // Compare keys by prefix (handles mixed full/prefix comparisons) pubkeysMatch(key1, key2) // true if prefixes match // Get display name with fallback to prefix getContactDisplayName(name, publicKey) // name or first 12 chars of key ``` ### Conversation State (`utils/conversationState.ts`) ```typescript import { getStateKey, setLastMessageTime, getLastMessageTimes } from './utils/conversationState'; // Generate state tracking key (NOT the same as Message.conversation_key) getStateKey('channel', channelKey) getStateKey('contact', publicKey) // Track message times for sidebar sorting (stored in localStorage) setLastMessageTime(stateKey, timestamp) getLastMessageTimes() // Returns all tracked message times ``` **Note:** Read state (`last_read_at`) is tracked server-side, not in localStorage. ### Contact Avatar (`utils/contactAvatar.ts`) Generates consistent profile "images" for contacts using hash-based colors: ```typescript import { getContactAvatar, CONTACT_TYPE_REPEATER } from './utils/contactAvatar'; // Get avatar info for a contact const avatar = getContactAvatar(name, publicKey, contactType); // Returns: { text: 'JD', background: 'hsl(180, 60%, 40%)', textColor: '#ffffff' } // Repeaters (type=2) always show 🛜 with gray background const repeaterAvatar = getContactAvatar('Some Repeater', key, CONTACT_TYPE_REPEATER); // Returns: { text: '🛜', background: '#444444', textColor: '#ffffff' } ``` Avatar text priority: 1. First emoji in name 2. Initials (first letter + first letter after space) 3. Single first letter 4. First 2 chars of public key (fallback) ## CSS Patterns The app uses a minimal dark theme in `styles.css`. ### Key Classes ```css .app /* Root container */ .status-bar /* Top bar with radio info */ .sidebar /* Left panel with contacts/channels */ .sidebar-item /* Individual contact/channel row */ .sidebar-item.unread /* Bold with badge */ .message-area /* Main content area */ .message-list /* Scrollable message container */ .message /* Individual message */ .message.outgoing /* Right-aligned, different color */ .message .sender /* Clickable sender name */ ``` ### Unread Badge ```css .sidebar-item.unread .name { font-weight: 700; color: #fff; } .sidebar-item .unread-badge { background: #4caf50; color: #fff; font-size: 10px; padding: 2px 6px; border-radius: 10px; } ``` ## Testing Run tests with: ```bash cd frontend npm run test:run # Single run npm run test # Watch mode ``` ### Test Files - `messageParser.test.ts` - Sender extraction, time formatting, conversation keys - `unreadCounts.test.ts` - Unread tracking logic - `contactAvatar.test.ts` - Avatar text extraction, color generation, repeater handling - `messageDeduplication.test.ts` - Message deduplication logic - `websocket.test.ts` - WebSocket message routing - `repeaterMode.test.ts` - Repeater CLI parsing, password "." conversion ### Test Setup Tests use jsdom environment with `@testing-library/react`: ```typescript // src/test/setup.ts import '@testing-library/jest-dom'; ``` ## Common Tasks ### Adding a New Component 1. Create component in `src/components/` 2. Add TypeScript props interface 3. Import and use in `App.tsx` or parent component 4. Add styles to `styles.css` ### Adding a New API Endpoint 1. Add method to `api.ts` 2. Add/update types in `types.ts` 3. Call from `App.tsx` or component ### Adding New WebSocket Event 1. Add handler option to `UseWebSocketOptions` interface in `useWebSocket.ts` 2. Add case to `onmessage` switch 3. Provide handler in `wsHandlers` object in `App.tsx` ### Adding State 1. Add `useState` in `App.tsx` 2. Pass down as props to components 3. If needed in WebSocket handler, also use a ref to avoid stale closures ## Development Workflow ```bash # Start dev server (hot reload) npm run dev # Build for production npm run build # Preview production build npm run preview # Run tests npm run test:run ``` The dev server runs on port 5173 and proxies API requests to `localhost:8000`. ### Production Build In production, the FastAPI backend serves the compiled frontend from `frontend/dist`: ```bash npm run build # Then run backend: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 ``` ## URL Hash Navigation Deep linking to conversations via URL hash: - `#channel/RoomName` - Opens a channel (leading `#` stripped from name for cleaner URLs) - `#contact/ContactName` - Opens a DM - `#raw` - Opens the raw packet feed - `#map` - Opens the node map ```typescript // Parse hash on initial load const hashConv = parseHashConversation(); // Update hash when conversation changes (uses replaceState to avoid history pollution) window.history.replaceState(null, '', newHash); ``` ## CrackerPanel The `CrackerPanel` component provides WebGPU-accelerated brute-forcing of channel keys for undecrypted GROUP_TEXT packets. ### Features - **Dictionary attack first**: Uses `words.txt` wordlist - **GPU bruteforce**: Falls back to character-by-character search - **Queue management**: Automatically processes new packets as they arrive - **Auto-channel creation**: Cracked channels are automatically added to the channel list - **Configurable max length**: Adjustable while running (default: 6) - **Retry failed**: Option to retry failed packets at increasing lengths - **NoSleep integration**: Prevents device sleep during cracking via `nosleep.js` - **Global collapsible panel**: Toggle from sidebar, runs in background when hidden ### Key Implementation Patterns Uses refs to avoid stale closures in async callbacks: ```typescript const isRunningRef = useRef(false); const isProcessingRef = useRef(false); // Prevents concurrent GPU operations const queueRef = useRef>(new Map()); const retryFailedRef = useRef(false); const maxLengthRef = useRef(6); ``` Progress reporting shows rate in Mkeys/s or Gkeys/s depending on speed. ## MapView The `MapView` component displays contacts with GPS coordinates on an interactive Leaflet map. ### Features - **Location filtering**: Only shows contacts with lat/lon that were heard within the last 7 days - **Freshness coloring**: Markers colored by how recently the contact was heard: - Bright green (`#22c55e`) - less than 1 hour ago - Light green (`#4ade80`) - less than 1 day ago - Yellow-green (`#a3e635`) - less than 3 days ago - Gray (`#9ca3af`) - older (up to 7 days) - **Node/repeater distinction**: Regular nodes have black outlines, repeaters are larger with no outline - **Geolocation**: Tries browser geolocation first, falls back to fitting all markers in view - **Popups**: Click a marker to see contact name, last heard time, and coordinates ### Data Source Contact location data (`lat`, `lon`) is extracted from advertisement packets in the backend (`decoder.py`). The `last_seen` timestamp determines marker freshness. ## Sidebar Features - **Sort toggle**: Default is 'recent' (most recent message first), can toggle to alphabetical - **Mark all as read**: Button appears when there are unread messages, clears all unread counts - **Cracker toggle**: Shows/hides the global cracker panel with running status indicator ## Toast Notifications The app uses Sonner for toast notifications via a custom wrapper at `components/ui/sonner.tsx`: ```typescript import { toast } from './components/ui/sonner'; // Success toast (use sparingly - only for significant/destructive actions) toast.success('Channel deleted'); // Error toast with details toast.error('Failed to send message', { description: err instanceof Error ? err.message : 'Check radio connection', }); ``` ### Error Handling Pattern All async operations that can fail should show error toasts. Keep console.error for debugging: ```typescript try { await api.someOperation(); } catch (err) { console.error('Failed to do X:', err); toast.error('Failed to do X', { description: err instanceof Error ? err.message : 'Check radio connection', }); } ``` ### Where Toasts Are Used **Error toasts** (shown when operations fail): - `App.tsx`: Advertisement, channel delete, contact delete - `useConversationMessages.ts`: Message loading (initial and pagination) - `MessageInput.tsx`: Message send, telemetry request - `CrackerPanel.tsx`: Channel save after cracking, WebGPU unavailable - `StatusBar.tsx`: Manual reconnection failure - `useWebSocket.ts`: Backend errors via WebSocket `error` events **Success toasts** (used sparingly for significant actions): - Radio connection/disconnection status changes - Manual reconnection success - Advertisement sent, channel/contact deleted (confirmation of intentional actions) **Avoid success toasts** for routine operations like sending messages - only show errors. The `` component is rendered in `App.tsx` with `position="top-right"`.