22 KiB
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
const [health, setHealth] = useState<HealthStatus | null>(null);
const [config, setConfig] = useState<RadioConfig | null>(null);
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
const [contacts, setContacts] = useState<Contact[]>([]);
const [channels, setChannels] = useState<Channel[]>([]);
const [messages, setMessages] = useState<Message[]>([]);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
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 contactlast_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
- REST API fetches initial data on mount in parallel (config, settings, channels, contacts, unreads)
- WebSocket pushes real-time updates (health, messages, contact changes, raw packets)
- 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:
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:
onErrorhandler displays toast notifications for backend errors
URL Detection
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:
import { api } from './api';
// Health
await api.getHealth();
// Radio
await api.getRadioConfig();
await api.updateRadioConfig({ name: 'MyRadio' });
await api.sendAdvertisement();
// 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):
// vite.config.ts
server: {
proxy: {
'/api': {
target: 'http://localhost:8000',
changeOrigin: true,
},
},
}
Type Definitions (types.ts)
Key Interfaces
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<string, number>;
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:
export interface MessageInputHandle {
appendText: (text: string) => void;
}
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(
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<MessageInputHandle>(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}/commandendpoint - Responses displayed as local messages (not persisted to database)
// 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;
<MessageInput
onSend={isRepeaterMode ? handleTelemetryRequest :
(repeaterLoggedIn ? handleRepeaterCommand : handleSendMessage)}
isRepeaterMode={isRepeaterMode}
placeholder={repeaterLoggedIn ? 'Enter CLI command...' : undefined}
/>
Telemetry response is displayed as three local messages (not persisted):
- Telemetry - Battery voltage, uptime, signal quality, packet stats
- Neighbors - Sorted by SNR (highest first), with resolved names
- 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:
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:
const activeConversationRef = useRef<Conversation | null>(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():
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:
// 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)
// 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:
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)
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:
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:
- First emoji in name
- Initials (first letter + first letter after space)
- Single first letter
- First 2 chars of public key (fallback)
CSS Patterns
The app uses a minimal dark theme in styles.css.
Key Classes
.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
.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:
cd frontend
npm run test:run # Single run
npm run test # Watch mode
Test Files
messageParser.test.ts- Sender extraction, time formatting, conversation keysunreadCounts.test.ts- Unread tracking logiccontactAvatar.test.ts- Avatar text extraction, color generation, repeater handlingmessageDeduplication.test.ts- Message deduplication logicwebsocket.test.ts- WebSocket message routingrepeaterMode.test.ts- Repeater CLI parsing, password "." conversion
Test Setup
Tests use jsdom environment with @testing-library/react:
// src/test/setup.ts
import '@testing-library/jest-dom';
Common Tasks
Adding a New Component
- Create component in
src/components/ - Add TypeScript props interface
- Import and use in
App.tsxor parent component - Add styles to
styles.css
Adding a New API Endpoint
- Add method to
api.ts - Add/update types in
types.ts - Call from
App.tsxor component
Adding New WebSocket Event
- Add handler option to
UseWebSocketOptionsinterface inuseWebSocket.ts - Add case to
onmessageswitch - Provide handler in
wsHandlersobject inApp.tsx
Adding State
- Add
useStateinApp.tsx - Pass down as props to components
- If needed in WebSocket handler, also use a ref to avoid stale closures
Development Workflow
# 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:
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
// 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.txtwordlist - 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:
const isRunningRef = useRef(false);
const isProcessingRef = useRef(false); // Prevents concurrent GPU operations
const queueRef = useRef<Map<number, QueueItem>>(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)
- Bright green (
- 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:
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:
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 deleteuseConversationMessages.ts: Message loading (initial and pagination)MessageInput.tsx: Message send, telemetry requestCrackerPanel.tsx: Channel save after cracking, WebGPU unavailableStatusBar.tsx: Manual reconnection failureuseWebSocket.ts: Backend errors via WebSocketerrorevents
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 <Toaster /> component is rendered in App.tsx with position="top-right".