mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-06 13:33:02 +02:00
713 lines
22 KiB
Markdown
713 lines
22 KiB
Markdown
# 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<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 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<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:
|
|
|
|
```typescript
|
|
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}/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;
|
|
|
|
<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):
|
|
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<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()`:
|
|
|
|
```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<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)
|
|
- **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 `<Toaster />` component is rendered in `App.tsx` with `position="top-right"`.
|