96 Commits

Author SHA1 Message Date
Jack Kingsman
5f039b9c41 Phase 4 2026-03-07 15:05:13 -08:00
Jack Kingsman
f302cc04ae Phase 3 2026-03-06 23:48:24 -08:00
Jack Kingsman
3edc7d9bd1 Step 2 2026-03-06 23:12:21 -08:00
Jack Kingsman
9c54ea623e First wave of work 2026-03-06 17:41:37 -08:00
Jack Kingsman
b5e2a4c269 Soften the 'always run tests' 2026-03-06 16:26:19 -08:00
Jack Kingsman
d4f73d318a Fanout modules!
Overhaul post-message fanout. Closes PR #37.
2026-03-06 16:08:23 -08:00
Jack Kingsman
8ffae50b87 Add some brutal tests for webhooks 2026-03-06 16:06:13 -08:00
Jack Kingsman
dd13768a44 Tighten up message broadcast contract 2026-03-06 15:55:04 -08:00
Jack Kingsman
3330028d27 Elevate error logging for message poll loop issues 2026-03-06 15:43:29 -08:00
Jack Kingsman
3144910cd9 Fix regression around direct path DMs 2026-03-06 15:41:47 -08:00
Jack Kingsman
819470cb40 Add missing httpx dep 2026-03-06 15:39:11 -08:00
Jack Kingsman
d7d06ec1f8 Remove some dead code and unify param names around not sending for actual real life messages vs. historical decrypt 2026-03-06 14:44:48 -08:00
Jack Kingsman
9d03844371 Improve module lifecycling 2026-03-06 14:44:48 -08:00
Jack Kingsman
929a931ce9 Add channel name to broadcasts 2026-03-06 14:44:48 -08:00
Jack Kingsman
cba9835568 Rework more coverage in e2e tests and don't force radio restart + better startup error handling 2026-03-06 14:44:48 -08:00
Jack Kingsman
58daf63d00 Change fanout tab name. 2026-03-06 14:44:48 -08:00
Jack Kingsman
bb13d223ca Remove unused IATA regex and reduncant community enabled check that's always true 2026-03-06 14:44:48 -08:00
Jack Kingsman
863251d670 Remove dead on_raw method and move json import somewhere not dumb 2026-03-06 14:44:48 -08:00
Jack Kingsman
5e042b7bcc Add health refresh to delete handler and correct concurrency description 2026-03-06 14:44:48 -08:00
Jack Kingsman
4d15c7d894 Add per-config id lock to reload and remove stale comment 2026-03-06 14:44:48 -08:00
Jack Kingsman
22e28a9e5b Add min length to name, 400 on unknown scope, normalize IATA 2026-03-06 14:44:48 -08:00
Jack Kingsman
e72c3abd7f Correct sender name and use non-deprecated loop getter 2026-03-06 14:44:48 -08:00
Jack Kingsman
439face70b Tweak fanout display and update docs 2026-03-06 14:44:48 -08:00
Jack Kingsman
55ac9df681 Parallelize fanouts and add module watchdog 2026-03-06 14:44:48 -08:00
Jack Kingsman
5808504ee0 Flesh out the fanout agents a bit more 2026-03-06 14:44:48 -08:00
Jack Kingsman
cb4333df4f Fanout hitlist fixes: bugs, quality, tests, webhook HMAC signing 2026-03-06 14:44:48 -08:00
Jack Kingsman
7534f0cc54 Patch smome doc issues and do minor bug mop up (outgoing bot message flaggin, unprotected bot endpoint, contact filtering on scope selection, don't drop disabled but configured community endpoints 2026-03-06 14:44:48 -08:00
Jack Kingsman
adfb4addb7 Add MQTT removal migration and fix tests + docs 2026-03-06 14:44:48 -08:00
Jack Kingsman
e99fed2e76 Add some test coverage 2026-03-06 14:44:47 -08:00
Jack Kingsman
13fa94acaa Richer saving options + more popping color on disabled integration 2026-03-06 14:44:47 -08:00
Jack Kingsman
418955198f Add Apprise 2026-03-06 14:44:47 -08:00
Jack Kingsman
e3e4e0b839 Add webhooks & reformat a bit 2026-03-06 14:44:47 -08:00
Jack Kingsman
5ecb63fde9 Move bots into Fanout & Forwarding 2026-03-06 14:44:47 -08:00
Jack Kingsman
7cd54d14d8 Move to modular fanout bus 2026-03-06 14:41:51 -08:00
Jack Kingsman
93b5bd908a Extend tests and fix docs 2026-03-06 14:41:34 -08:00
Jack Kingsman
de30dfe87b Backfill channel sender identity when it's available 2026-03-06 14:36:33 -08:00
Jack Kingsman
d5a60d6ca3 Attach contact name to DMs for blocking purposes 2026-03-06 14:28:41 -08:00
Jack Kingsman
8f2d55277f Add clearer messaging around back to chat 2026-03-06 11:40:29 -08:00
Jack Kingsman
cdf5c0b81e Some new theme movement 2026-03-06 09:17:22 -08:00
Jack Kingsman
ae51755f07 Fix issue with backend restart letting unreads accumulate on open thread 2026-03-05 23:01:04 -08:00
Jack Kingsman
7715732e69 Add sender_key to outgoing and make unread counts respect block list 2026-03-05 10:43:16 -08:00
Jack Kingsman
01a5dc8d93 A11y bug bash 2026-03-05 10:24:22 -08:00
Jack Kingsman
c7bd4dd3fc Updating changelog + build for 2.5.0 2026-03-05 00:13:15 -08:00
Jack Kingsman
a069af8364 Doc updates 2026-03-04 21:18:07 -08:00
Jack Kingsman
03f4963966 Guard flood scope and be better about blocking 2026-03-04 20:15:44 -08:00
Jack Kingsman
e439bc913a Reorganize the settings panes a bit 2026-03-04 19:46:20 -08:00
Jack Kingsman
d5fe9c677f Add contact blocking 2026-03-04 18:54:21 -08:00
Jack Kingsman
145609faf9 Add outgoing message region tagging. Closes #35. 2026-03-04 15:42:21 -08:00
Jack Kingsman
c2931a266e Add hop display map in pathing modal 2026-03-04 13:03:43 -08:00
Jack Kingsman
9629a35fe1 Don't exclude 7 day stale nodes on Map view when linked in 2026-03-04 12:28:37 -08:00
Jack Kingsman
1ce72ecdf7 Add #remoteterm as default channel 2026-03-04 10:56:31 -08:00
Jack Kingsman
e0fb093612 Fix non-cache refresh on unfocused active threads; doc and test improvements 2026-03-04 10:16:17 -08:00
Jack Kingsman
1f37da8d2d s/router/repeater/ 2026-03-03 22:22:08 -08:00
Jack Kingsman
a9472870f3 Add 'Show Key' prompt on private rooms to prevent key leakage. Closes #36 2026-03-03 21:39:03 -08:00
Jack Kingsman
d6611e8518 Add better logic around node-last-heard timing 2026-03-03 21:24:50 -08:00
Jack Kingsman
6274df7244 Add some color themes 2026-03-03 21:08:19 -08:00
Jack Kingsman
813a47ee14 Shard up frontend variables 2026-03-03 20:07:45 -08:00
Jack Kingsman
e0e71180b2 Add global message search and more e2e tests 2026-03-03 19:19:24 -08:00
Jack Kingsman
73a835688d Add channel info box 2026-03-03 17:09:48 -08:00
Jack Kingsman
5d2aaa802b Move controls to top 2026-03-03 16:19:33 -08:00
Jack Kingsman
eb78285b8f Add bot disable flow 2026-03-03 15:57:37 -08:00
Jack Kingsman
e8538c55ea Fix flaky test selector 2026-03-03 14:02:05 -08:00
Jack Kingsman
b1cb531911 Make node recency customizable in the visualizer 2026-03-03 13:52:55 -08:00
Jack Kingsman
8fa37fe6dc Websocket for contact deletion, radio contact deletion flag fix, resent message now appends sender name 2026-03-03 12:43:27 -08:00
Jack Kingsman
73d4647cfc Add node last heard reason to hover 2026-03-03 12:08:20 -08:00
Jack Kingsman
31afb7b9c0 Fix some doc gaps 2026-03-03 12:01:58 -08:00
Jack Kingsman
73f082c06c Fallback DM handler cleanliness (I never use this lol) 2026-03-03 11:59:15 -08:00
Jack Kingsman
ea49bdff35 ID tiebreaker for same-second messages 2026-03-03 11:57:53 -08:00
Jack Kingsman
21fd505fb9 Add sender name on outgoing messages 2026-03-03 11:56:18 -08:00
Jack Kingsman
62943f6292 Add why-active reason to visualizer nodes 2026-03-03 11:54:54 -08:00
Jack Kingsman
c76b7895dd Clarify MQTT limitation comment 2026-03-03 10:29:04 -08:00
Jack Kingsman
285c90f71e Conform status colors 2026-03-03 10:24:25 -08:00
Jack Kingsman
e4662229b4 Shorten client name 2026-03-03 09:40:20 -08:00
Jack Kingsman
be21b434cf Get closer to parity with meshcore packet capture 2026-03-03 09:31:24 -08:00
Jack Kingsman
662e84adbe Add community mqtt stats reporting 2026-03-03 09:17:57 -08:00
Jack Kingsman
5a72adc75b Add some QOL features to readiness scripts 2026-03-03 09:17:45 -08:00
Jack Kingsman
707f98d203 Fix packet sidebar retention 2026-03-03 09:05:21 -08:00
Jack Kingsman
4c1d5fb8ec Add status/LWT to community MQTT ingest 2026-03-02 23:10:25 -08:00
Jack Kingsman
fb279ccf1a Accessibility overhaul 2026-03-02 20:34:06 -08:00
Jack Kingsman
7d39e726b4 Local serving optimizations and Windows docs updates 2026-03-02 20:19:49 -08:00
Jack Kingsman
d9aa67d254 No more package locks 2026-03-02 19:25:40 -08:00
Jack Kingsman
81694e7ab3 Fix an e2e test and add a warning about nodes not showing up on community listings 2026-03-02 19:15:28 -08:00
Jack Kingsman
99f31c8226 Carve out dead code and cruft, and unify repeater status pane 2026-03-02 18:56:18 -08:00
Jack Kingsman
f715d72467 Doc and typo hunting 2026-03-02 18:05:45 -08:00
Jack Kingsman
f335fc56cc Patch up some missing tests and fix+test channel add not clearing on channel submission without add-another checked 2026-03-02 18:02:53 -08:00
Jack Kingsman
d8294a8383 Add more warnings around radio config, stats loading, and packet decrypt (and remove accidentally committed script whoops) 2026-03-02 16:46:18 -08:00
Jack Kingsman
79db09bd15 Don't show mark all as read if there's nothing to read 2026-03-02 16:42:59 -08:00
Jack Kingsman
e3fe36dc19 Fix stale closure on existing keys 2026-03-02 16:38:44 -08:00
Jack Kingsman
69584051f5 Don't message fetch on map or visualizer 2026-03-02 16:35:26 -08:00
Jack Kingsman
58ea1d7eb9 Be more protective around stripping at null byte, not after 2026-03-02 16:34:13 -08:00
Jack Kingsman
c9776639a0 Commit package-lock 2026-03-02 16:33:08 -08:00
Jack Kingsman
d860ea706d Add log line to show if our polling loop actually mops anything up 2026-03-02 15:32:58 -08:00
Jack Kingsman
b7976206fc Add some additional documentation notes 2026-03-02 15:28:32 -08:00
Jack Kingsman
f73d10328b Add note about let's mesh 2026-03-02 15:05:17 -08:00
Jack Kingsman
a8ff2b4133 Updating changelog + build for 2.4.0 2026-03-02 14:54:21 -08:00
Jack Kingsman
09ad642d79 Merge pull request #34 from jkingsman/community-mqtt
Community MQTT (LetsMesh). Closes #33.
2026-03-02 14:42:04 -08:00
187 changed files with 18785 additions and 4382 deletions

2
.gitignore vendored
View File

@@ -8,11 +8,11 @@ wheels/
# Virtual environments
.venv
frontend/node_modules/
frontend/package-lock.json
frontend/test-results/
# Frontend build output (built from source by end users)
frontend/dist/
frontend/package-lock.json
frontend/.eslintcache
# reference libraries

View File

@@ -10,16 +10,19 @@ If instructed to "run all tests" or "get ready for a commit" or other summative,
./scripts/all_quality.sh
```
This runs all linting, formatting, type checking, tests, and builds for both backend and frontend in parallel. All checks must pass green.
This runs all linting, formatting, type checking, tests, and builds for both backend and frontend sequentially. All checks must pass green.
## Overview
A web interface for MeshCore mesh radio networks. The backend connects to a MeshCore-compatible radio over Serial, TCP, or BLE and exposes REST/WebSocket APIs. The React frontend provides real-time messaging and radio configuration.
**For detailed component documentation, see:**
**For detailed component documentation, see these primary AGENTS.md files:**
- `app/AGENTS.md` - Backend (FastAPI, database, radio connection, packet decryption)
- `frontend/AGENTS.md` - Frontend (React, state management, WebSocket, components)
- `frontend/src/components/AGENTS.md` - Frontend visualizer feature (a particularly complex and long force-directed graph visualizer component; can skip this file unless you're working on that feature)
Ancillary AGENTS.md files which should generally not be reviewed unless specific work is being performed on those features include:
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise)
- `frontend/src/components/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
## Architecture Overview
@@ -72,7 +75,7 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
- Raw packet feed — a debug/observation tool ("radio aquarium"); interesting to watch or copy packets from, but not critical infrastructure
- Map view — visual display of node locations from advertisements
- Network visualizer — force-directed graph of mesh topology
- Bot system — automated message responses
- Fanout integrations (MQTT, bots, webhooks, Apprise) — see `app/fanout/AGENTS_fanout.md`
- Read state tracking / mark-all-read — convenience feature for unread badges; no need for transactional atomicity or race-condition hardening
## Error Handling Philosophy
@@ -94,7 +97,7 @@ The following are **deliberate design choices**, not bugs. They are documented i
1. **No CORS restrictions**: The backend allows all origins (`allow_origins=["*"]`). This lets users access their radio from any device/origin on their network without configuration hassle.
2. **No authentication or authorization**: There is no login, no API keys, no session management. The app is designed for trusted networks (home LAN, VPN). The README warns users not to expose it to untrusted networks.
3. **Arbitrary bot code execution**: The bot system (`app/bot.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this.
3. **Arbitrary bot code execution**: The bot system (`app/fanout/bot_exec.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this. Operators can set `MESHCORE_DISABLE_BOTS=true` to completely disable the bot system at startup — this skips all bot execution, returns 403 on bot settings updates, and shows a disabled message in the frontend.
## Intentional Packet Handling Decision
@@ -111,7 +114,7 @@ To improve repeater disambiguation in the network visualizer, the backend stores
- This is independent of raw-packet payload deduplication.
- Paths are keyed per contact + path, with `heard_count`, `first_seen`, and `last_seen`.
- Only the N most recent unique paths are retained per contact (currently 10).
- See `frontend/src/components/AGENTS.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
- See `frontend/src/components/AGENTS_packet_visualizer.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
## Data Flow
@@ -144,17 +147,14 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
.
├── app/ # FastAPI backend
│ ├── AGENTS.md # Backend documentation
│ ├── bot.py # Bot execution and outbound bot sends
│ ├── main.py # App entry, lifespan
│ ├── routers/ # API endpoints
│ ├── packet_processor.py # Raw packet pipeline, dedup, path handling
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings)
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings, fanout)
│ ├── event_handlers.py # Radio events
│ ├── decoder.py # Packet decryption
│ ├── websocket.py # Real-time broadcasts
── mqtt_base.py # Shared MQTT publisher base class (lifecycle, reconnect, backoff)
│ ├── mqtt.py # Private MQTT publisher
│ └── community_mqtt.py # Community MQTT publisher (raw packet sharing)
── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
├── frontend/ # React frontend
│ ├── AGENTS.md # Frontend documentation
│ ├── src/
@@ -167,9 +167,11 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ │ └── ...
│ └── vite.config.ts
├── scripts/
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (parallelized)
│ ├── publish.sh # Version bump, changelog, docker build & push
── deploy.sh # Deploy to production server
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (sequential)
│ ├── collect_licenses.sh # Gather third-party license attributions
── e2e.sh # End-to-end test runner
│ └── publish.sh # Version bump, changelog, docker build & push
├── remoteterm.service # Systemd unit file for production deployment
├── tests/ # Backend tests (pytest)
├── data/ # SQLite database (runtime)
└── pyproject.toml # Python dependencies
@@ -232,10 +234,13 @@ Key test files:
- `tests/test_api.py` - API endpoints, read state tracking
- `tests/test_migrations.py` - Database migration system
- `tests/test_frontend_static.py` - Frontend static route registration (missing `dist`/`index.html` handling)
- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
- `tests/test_real_crypto.py` - Real cryptographic operations
- `tests/test_disable_bots.py` - MESHCORE_DISABLE_BOTS=true feature
### Frontend (Vitest)
@@ -246,7 +251,7 @@ npm run test:run
### Before Completing Changes
**Always run `./scripts/all_quality.sh` before finishing any changes.** This runs all linting, formatting, type checking, tests, and builds in parallel, catching type mismatches, breaking changes, and compilation errors.
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** This runs all linting, formatting, type checking, tests, and builds sequentially, catching type mismatches, breaking changes, and compilation errors. This is not necessary for docs-only changes.
## API Summary
@@ -254,7 +259,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Connection status |
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
| GET | `/api/radio/config` | Radio configuration |
| PATCH | `/api/radio/config` | Update name, location, radio params |
| PUT | `/api/radio/private-key` | Import private key to radio |
@@ -285,15 +290,17 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
| GET | `/api/channels` | List channels |
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
| GET | `/api/channels/{key}` | Get channel by key |
| POST | `/api/channels` | Create channel |
| DELETE | `/api/channels/{key}` | Delete channel |
| POST | `/api/channels/sync` | Pull from radio |
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
| GET | `/api/messages` | List with filters |
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
| POST | `/api/messages/direct` | Send direct message |
| POST | `/api/messages/channel` | Send channel message |
| POST | `/api/messages/channel/{message_id}/resend` | Resend an outgoing channel message (within 30 seconds) |
| POST | `/api/messages/channel/{message_id}/resend` | Resend channel message (default: byte-perfect within 30s; `?new_timestamp=true`: fresh timestamp, no time limit, creates new message row) |
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
@@ -302,7 +309,13 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/settings` | Get app settings |
| PATCH | `/api/settings` | Update app settings |
| POST | `/api/settings/favorites/toggle` | Toggle favorite status |
| POST | `/api/settings/blocked-keys/toggle` | Toggle blocked key |
| POST | `/api/settings/blocked-names/toggle` | Toggle blocked name |
| POST | `/api/settings/migrate` | One-time migration from frontend localStorage |
| GET | `/api/fanout` | List all fanout configs |
| POST | `/api/fanout` | Create new fanout config |
| PATCH | `/api/fanout/{id}` | Update fanout config (triggers module reload) |
| DELETE | `/api/fanout/{id}` | Delete fanout config (stops module) |
| GET | `/api/statistics` | Aggregated mesh network statistics |
| WS | `/api/ws` | Real-time updates |
@@ -320,6 +333,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
- `1` - Client (regular node)
- `2` - Repeater
- `3` - Room
- `4` - Sensor
### Channel Keys
@@ -347,33 +361,11 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
### MQTT Publishing
### Fanout Bus (MQTT, Bots, Webhooks, Apprise)
Optional MQTT integration forwards mesh events to an external broker for home automation, logging, or alerting. All MQTT config is stored in the database (`app_settings`), not env vars — configured from the Settings pane, no server restart needed.
All external integrations are managed through the fanout bus (`app/fanout/`). Each integration is a `FanoutModule` with scope-based event filtering, stored in the `fanout_configs` table and managed via `GET/POST/PATCH/DELETE /api/fanout`.
**Two independent toggles**: publish decrypted messages, publish raw packets.
**Topic structure** (default prefix `meshcore`):
- `meshcore/dm:<contact_public_key>` — decrypted DM
- `meshcore/gm:<channel_key>` — decrypted channel message
- `meshcore/raw/dm:<contact_key>` — raw packet attributed to a DM contact
- `meshcore/raw/gm:<channel_key>` — raw packet attributed to a channel
- `meshcore/raw/unrouted` — raw packets that couldn't be attributed
**Architecture**: `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — a single hook covering all message and raw_packet broadcasts. The `MqttPublisher` in `app/mqtt.py` manages a background connection loop with auto-reconnect and backoff. Publishes are fire-and-forget (silent drop if disconnected). Connection state changes trigger toasts via `broadcast_error`/`broadcast_success`. The health endpoint includes `mqtt_status` (`disabled` when no broker host is set, or when both publish toggles are off).
**Security**: MQTT password stored in plaintext in SQLite, consistent with the project's trusted-network design.
### Community MQTT Sharing
Separate from private MQTT, the community publisher (`app/community_mqtt.py`) shares raw packets with the MeshCore community aggregator for coverage mapping and analysis. Only raw packets are shared — never decrypted messages.
- Connects to community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS.
- Authentication via Ed25519 JWT signed with the radio's private key. Tokens auto-renew before 24h expiry.
- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`.
- Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code.
- JWT `email` claim enables node claiming on the community aggregator.
- Config: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email` in `app_settings`.
`broadcast_event()` in `websocket.py` dispatches `message` and `raw_packet` events to the fanout manager. See `app/fanout/AGENTS_fanout.md` for full architecture details.
### Server-Side Decryption
@@ -415,8 +407,9 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `bots`, all MQTT configuration (`mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`), and community MQTT configuration (`community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`). They are configured via `GET/PATCH /api/settings` (and related settings endpoints).
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.

View File

@@ -1,3 +1,38 @@
## [2.5.0] - 2026-03-05
Feature: Far better accessibility across the app (with far to go)
Feature: Add community MQTT stats reporting, and improve over a few commits
Feature: Color schemes and misc. settings reorg
Feature: Add why-active to filtered nodes
Feature: Add channel and contact info box
Feature: Add contact blocking
Feature: Add potential repeater path map display
Feature: Add flood scoping/regions
Feature: Global message search
Feature: Fully safe bot disable
Feature: Add default #remoteterm channel (lol sorry I had to)
Feature: Custom recency pruning in visualizer
Bugfix: Be more cautious around null byte stripping
Bugfix: Clear channel-add interface on not-add-another
Bugfix: Add status/name/MQTT LWT
Bugfix: Channel deletion propagates over WS
Bugfix: Show map location for all nodes on link, not 7-day-limited
Bugfix: Hide private key channel keys by default
Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
Misc: Doc, changelog, and test improvements
Misc: Add, and remove, package lock (sorry Windows users)
Misc: Don't show mark all as read if not necessary
Misc: Fix stale closures and misc. frontend perf/correctness improvements
Misc: Add Windows startup notes
Misc: E2E expansion + improvement
Misc: Move around visualizer settings
## [2.4.0] - 2026-03-02
Feature: Add community MQTT reporting (e.g. LetsMesh.net)
Misc: Build scripts and library attribution
Misc: Add sign of life to E2E tests
## [2.3.0] - 2026-03-01
Feature: Click path description to reset to flood
@@ -17,14 +52,14 @@ Feature: Contact info pane
Feature: Overhaul repeater interface
Bugfix: Misc. frontend rendering + perf improvements
Bugfix: Better behavior around radio locking and autofetch/polling
Bugifx: Clear channel name field on new-channel modal tab change
Bugfix: Clear channel name field on new-channel modal tab change
Bugfix: Repeater inforbox can scroll
Bugfix: Better handling of historical DM encrypts
Bugfix: Handle errors if returned in prefetch phase
Misc: Radio event response failure is logged/surfaced better
Misc: Improve test coverage and remove dead code
Misc: Documentatin and errata improvements
Misc: Databse storage optimization
Misc: Documentation and errata improvements
Misc: Database storage optimization
## [2.1.0] - 2026-02-23
@@ -64,11 +99,11 @@ Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
Feature: Frontend UX + log overhaul
Bugfix: Use contact object from DB for broadcast rather than handrolling
Bugfix: Fix our of order path WS messages voerwriting each other
Bugfix: Fix out of order path WS messages overwriting each other
Bugfix: Make broadcast timestamp match fallback logic used in storage code
Bugfix: Fir repeater command timestamp selection logic
Bugfix: Fix repeater command timestamp selection logic
Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
Bugfix: Add missing radio operation locks in a few sports
Bugfix: Add missing radio operation locks in a few spots
Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
Misc: Visualizer layout refinement & option labels
@@ -94,7 +129,7 @@ Feature: Upgrade the room finder to support two-word rooms
## [1.9.2] - 2026-02-12
Feature: Options dialog sucks less
Bugix: Move tests to isolated memory DB
Bugfix: Move tests to isolated memory DB
Bugfix: Mention case sensitivity
Bugfix: Stale header retention on settings page view
Bugfix: Non-isolated path writing
@@ -147,30 +182,13 @@ Misc: Always flood advertisements
Misc: Better packet dupe handling
Misc: Dead code cleanup, test improvements
## [1.8.0] - 2026-02-07
Feature: Single hop ping
Feature: PWA viewport fixes(thanks @rgregg)
Feature (?): No frontend distribution; build it yourself ;P
Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
Bugfix: Better guarding around reconnection
Bugfix: Duplicate websocket connection fixes
Bugfix: Settings tab error cleanliness on tab swap
Bugfix: Fix path traversal vuln
UI: Swap visualizer legend ordering (yay prettier)
Misc: Perf and locking improvements
Misc: Always flood advertisements
Misc: Better packet dupe handling
Misc: Dead code cleanup, test improvements
## [1.7.1] - 2026-02-03
Feature: Clickable hyperlinks
Bugfix: More consistent public key normalization
Bugfix: Use more reliable cursor paging
Bugfix: Fix null timestamp dedupe failure
Bugfix: More concistent prefix-based message claiming on key reciept
Bugfix: More consistent prefix-based message claiming on key receipt
Misc: Bot can respond to its own messages
Misc: Additional tests
Misc: Remove unneeded message dedupe logic

View File

@@ -5,8 +5,8 @@ ARG COMMIT_HASH=unknown
WORKDIR /build
COPY frontend/package*.json ./
RUN npm ci
COPY frontend/package.json ./
RUN npm install
COPY frontend/ ./
RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build

View File

@@ -56,6 +56,41 @@ SOFTWARE.
</details>
### apprise (1.9.7) — BSD-2-Clause
<details>
<summary>Full license text</summary>
```
BSD 2-Clause License
Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
</details>
### fastapi (0.128.0) — MIT
<details>
@@ -87,6 +122,28 @@ THE SOFTWARE.
</details>
### httpx (0.28.1) — BSD License
<details>
<summary>Full license text</summary>
```
Copyright © 2019, [Encode OSS Ltd](https://www.encode.io/).
All rights reserved.
Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```
</details>
### meshcore (2.2.5) — MIT
<details>
@@ -722,7 +779,7 @@ SOFTWARE.
</details>
### @uiw/react-codemirror (4.25.4) — MIT
### @uiw/react-codemirror (4.25.7) — MIT
*License file not found in package.*
@@ -1380,7 +1437,7 @@ SOFTWARE.
</details>
### tailwind-merge (3.4.0) — MIT
### tailwind-merge (3.5.0) — MIT
<details>
<summary>Full license text</summary>

View File

@@ -8,12 +8,12 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
* Access your radio remotely over your network or VPN
* Search for hashtag room names for channels you don't have keys for yet
* Forward decrypted packets to MQTT brokers
* Forward packets to MQTT brokers (private: decrypted messages and/or raw packets; community aggregators like LetsMesh.net: raw packets only)
* Visualize the mesh as a map or node set, view repeater stats, and more!
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
![Screenshot of the application's web interface](screenshot.png)
![Screenshot of the application's web interface](app_screenshot.png)
## Disclaimer
@@ -42,6 +42,12 @@ ls /dev/ttyUSB* /dev/ttyACM*
#######
ls /dev/cu.usbserial-* /dev/cu.usbmodem*
###########
# Windows
###########
# In PowerShell:
Get-CimInstance Win32_SerialPort | Select-Object DeviceID, Caption
######
# WSL2
######
@@ -50,8 +56,11 @@ winget install usbipd
# restart console
# then find device ID
usbipd list
# attach device to WSL
# make device shareable
usbipd bind --busid 3-8 # (or whatever the right ID is)
# attach device to WSL (run this each time you plug in the device)
usbipd attach --wsl --busid 3-8
# device will appear in WSL as /dev/ttyUSB0 or /dev/ttyACM0
```
</details>
@@ -85,6 +94,12 @@ MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:a
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
On Windows (PowerShell), set environment variables as a separate statement:
```powershell
$env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Access at http://localhost:8000
> **Note:** WebGPU cracking requires HTTPS when not on localhost. See the HTTPS section under Additional Setup.
@@ -146,6 +161,15 @@ uv run uvicorn app.main:app --reload # autodetects serial port
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --reload
```
On Windows (PowerShell):
```powershell
uv sync
$env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
uv run uvicorn app.main:app --reload
```
> **Windows note:** I've seen an intermittent startup issue like `"Received empty packet: index out of range"` with failed contact sync. I can't figure out why this happens. The issue typically resolves on restart. If you can figure out why this happens, I will buy you a virtual or iRL six pack if you're in the PNW. As a former always-windows-girlie before embracing WSL2, I despise second-classing M$FT users, but I'm just stuck with this one.
### Frontend
```bash
@@ -161,7 +185,7 @@ Run both the backend and `npm run dev` for hot-reloading frontend development.
Please test, lint, format, and quality check your code before PRing or committing. At the least, run a lint + autoformat + pyright check on the backend, and a lint + autoformat on the frontend.
Run everything at once (parallelized):
Run everything at once:
```bash
./scripts/all_quality.sh
@@ -198,6 +222,7 @@ npm run build # build the frontend
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config) |
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
@@ -288,7 +313,7 @@ npm run test:run
**E2E:**
Warning: these tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly. E2E tests are generally not necessary to run for normal development work.
Warning: these tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly and an available autodetect-able radio, as well as a contact in the test database (which you can provide in `tests/e2e/.tmp/e2e-test.db` after an initial run). E2E tests are generally not necessary to run for normal development work.
```bash
cd tests/e2e

View File

@@ -27,10 +27,7 @@ app/
├── packet_processor.py # Raw packet pipeline, dedup, path handling
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
├── websocket.py # WS manager + broadcast helpers
├── mqtt_base.py # Shared MQTT publisher base class (lifecycle, reconnect, backoff)
├── mqtt.py # Private MQTT publisher (fire-and-forget forwarding)
├── community_mqtt.py # Community MQTT publisher (raw packet sharing)
├── bot.py # Bot execution and outbound bot sends
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
├── dependencies.py # Shared FastAPI dependency providers
├── keystore.py # Ephemeral private/public key storage for DM decryption
├── frontend_static.py # Mount/serve built frontend (production)
@@ -43,6 +40,7 @@ app/
├── packets.py
├── read_state.py
├── settings.py
├── fanout.py
├── repeaters.py
├── statistics.py
└── ws.py
@@ -63,7 +61,7 @@ app/
2. Message is persisted as outgoing.
3. Endpoint broadcasts WS `message` event so all live clients update.
4. ACK/repeat updates arrive later as `message_acked` events.
5. Channel resend (`POST /messages/channel/{id}/resend`) strips the sender name prefix by exact match against the current radio name. This assumes the radio name hasn't changed between the original send and the resend — a safe assumption since name changes require a radio config update and are not something that happens mid-conversation.
5. Channel resend (`POST /messages/channel/{id}/resend`) strips the sender name prefix by exact match against the current radio name. This assumes the radio name hasn't changed between the original send and the resend. Name changes require an explicit radio config update and are rare, but the `new_timestamp=true` resend path has no time window, so a mismatch is possible if the name was changed between the original send and a later resend.
### Connection lifecycle
@@ -103,33 +101,13 @@ app/
- `0` means disabled.
- Last send time tracked in `app_settings.last_advert_time`.
### MQTT publishing
### Fanout bus
- Optional forwarding of mesh events to an external MQTT broker.
- All config in `app_settings` (not env vars): `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`, `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`.
- Disabled when `mqtt_broker_host` is empty, or when both publish toggles are off (`mqtt_publish_messages=false` and `mqtt_publish_raw_packets=false`).
- `broadcast_event()` in `websocket.py` calls `mqtt_broadcast()` — single hook covers all message and raw_packet events.
- `MqttPublisher` (`app/mqtt.py`) runs a background connection loop with auto-reconnect and exponential backoff (5s → 30s).
- Publishes are fire-and-forget; individual publish failures logged but not surfaced to users.
- Connection state changes surface via `broadcast_error`/`broadcast_success` toasts.
- Health endpoint includes `mqtt_status` field (`connected`, `disconnected`, `disabled`), where `disabled` covers both "no broker host configured" and "nothing enabled to publish".
- Settings changes trigger `mqtt_publisher.restart()` — no server restart needed.
- Topics: `{prefix}/dm:{key}`, `{prefix}/gm:{key}`, `{prefix}/raw/dm:{key}`, `{prefix}/raw/gm:{key}`, `{prefix}/raw/unrouted`.
### Community MQTT
- Separate publisher (`app/community_mqtt.py`) for sharing raw packets with the MeshCore community aggregator.
- Implementation intent: keep functional parity with the reference implementation at `https://github.com/agessaman/meshcore-packet-capture` unless this repository explicitly documents a deliberate deviation.
- Independent from the private `MqttPublisher` — different broker, authentication, and topic structure.
- Connects to the community broker (default `mqtt-us-v1.letsmesh.net:443`) via WebSockets over TLS.
- Authentication: Ed25519 JWT tokens signed with the radio's expanded "orlp" private key. Tokens expire after 24 hours; proactive renewal at 23 hours.
- Broker address: separate `community_mqtt_broker_host` and `community_mqtt_broker_port` fields; defaults to `mqtt-us-v1.letsmesh.net:443`.
- JWT claims include `publicKey`, `owner` (radio pubkey), `client` (app identifier), and optional `email` (for node claiming on the community aggregator).
- Topic: `meshcore/{IATA}/{pubkey}/packets` — IATA is a 3-letter region code (required to enable; no default).
- Only raw packets are published — never decrypted messages.
- Publishes are fire-and-forget. The connection loop detects publish failures via `connected` flag and reconnects within 60 seconds.
- Health endpoint includes `community_mqtt_status` field.
- Settings: `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`.
- All external integrations (MQTT, bots, webhooks, Apprise) are managed through the fanout bus (`app/fanout/`).
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message` and `raw_packet` events.
- Each integration is a `FanoutModule` with scope-based filtering.
- See `app/fanout/AGENTS_fanout.md` for full architecture details.
## API Surface (all under `/api`)
@@ -170,6 +148,7 @@ app/
### Channels
- `GET /channels`
- `GET /channels/{key}/detail`
- `GET /channels/{key}`
- `POST /channels`
- `DELETE /channels/{key}`
@@ -177,7 +156,8 @@ app/
- `POST /channels/{key}/mark-read`
### Messages
- `GET /messages`
- `GET /messages` — list with filters; supports `q` (full-text search), `after`/`after_id` (forward cursor)
- `GET /messages/around/{message_id}` — context messages around a target (for jump-to-message navigation)
- `POST /messages/direct`
- `POST /messages/channel`
- `POST /messages/channel/{message_id}/resend`
@@ -195,8 +175,16 @@ app/
- `GET /settings`
- `PATCH /settings`
- `POST /settings/favorites/toggle`
- `POST /settings/blocked-keys/toggle`
- `POST /settings/blocked-names/toggle`
- `POST /settings/migrate`
### Fanout
- `GET /fanout` — list all fanout configs
- `POST /fanout` — create new fanout config
- `PATCH /fanout/{id}` — update fanout config (triggers module reload)
- `DELETE /fanout/{id}` — delete fanout config (stops module)
### Statistics
- `GET /statistics` — aggregated mesh network stats (entity counts, message/packet splits, activity windows, busiest channels)
@@ -210,6 +198,8 @@ app/
- `message` — new message (channel or DM, from packet processor or send endpoints)
- `message_acked` — ACK/echo update for existing message (ack count + paths)
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
- `contact_deleted` — contact removed from database (payload: `{ public_key }`)
- `channel_deleted` — channel removed from database (payload: `{ key }`)
- `error` — toast notification (reconnect failure, missing private key, etc.)
- `success` — toast notification (historical decrypt complete, etc.)
@@ -236,10 +226,10 @@ Main tables:
- `preferences_migrated`
- `advert_interval`
- `last_advert_time`
- `bots`
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`
- `flood_scope`
- `blocked_keys`, `blocked_names`
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
## Security Posture (intentional)
@@ -269,7 +259,10 @@ tests/
├── test_config.py # Configuration validation
├── test_contacts_router.py # Contacts router endpoints
├── test_decoder.py # Packet parsing/decryption
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
├── test_fanout_integration.py # Fanout integration tests
├── test_event_handlers.py # ACK tracking, event registration, cleanup
├── test_frontend_static.py # Frontend static file serving
├── test_health_mqtt_status.py # Health endpoint MQTT status field
@@ -283,12 +276,15 @@ tests/
├── test_packet_pipeline.py # End-to-end packet processing
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
├── test_radio.py # RadioManager, serial detection
├── test_real_crypto.py # Real cryptographic operations
├── test_radio_operation.py # radio_operation() context manager
├── test_radio_router.py # Radio router endpoints
├── test_radio_sync.py # Polling, sync, advertisement
├── test_repeater_routes.py # Repeater command/telemetry/trace + granular pane endpoints
├── test_repository.py # Data access layer
├── test_rx_log_data.py # on_rx_log_data event handler integration
├── test_messages_search.py # Message search, around, forward pagination
├── test_block_lists.py # Blocked keys/names filtering
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
├── test_settings_router.py # Settings endpoints, advert validation
├── test_statistics.py # Statistics aggregation

View File

@@ -16,6 +16,7 @@ class Settings(BaseSettings):
ble_pin: str = ""
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
database_path: str = "data/meshcore.db"
disable_bots: bool = False
@model_validator(mode="after")
def validate_transport_exclusivity(self) -> "Settings":
@@ -47,6 +48,40 @@ class Settings(BaseSettings):
settings = Settings()
class _RepeatSquelch(logging.Filter):
"""Suppress rapid-fire identical messages and emit a summary instead.
Attached to the ``meshcore`` library logger to catch its repeated
"Serial Connection started" lines that flood the log when another
process holds the serial port.
"""
def __init__(self, threshold: int = 3) -> None:
super().__init__()
self._last_msg: str | None = None
self._repeat_count: int = 0
self._threshold = threshold
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
if msg == self._last_msg:
self._repeat_count += 1
if self._repeat_count == self._threshold:
record.msg = (
"%s (repeated %d times — possible serial port contention from another process)"
)
record.args = (msg, self._repeat_count)
record.levelno = logging.WARNING
record.levelname = "WARNING"
return True
# Suppress further repeats beyond the threshold
return self._repeat_count < self._threshold
else:
self._last_msg = msg
self._repeat_count = 1
return True
def setup_logging() -> None:
"""Configure logging for the application."""
logging.basicConfig(
@@ -54,3 +89,6 @@ def setup_logging() -> None:
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
# "Serial Connection started" when the port is contended).
logging.getLogger("meshcore").addFilter(_RepeatSquelch())

View File

@@ -15,6 +15,7 @@ CREATE TABLE IF NOT EXISTS contacts (
flags INTEGER DEFAULT 0,
last_path TEXT,
last_path_len INTEGER DEFAULT -1,
out_path_hash_mode INTEGER,
last_advert INTEGER,
lat REAL,
lon REAL,

View File

@@ -79,9 +79,11 @@ class PacketInfo:
route_type: RouteType
payload_type: PayloadType
payload_version: int
path_length: int
path: bytes # The routing path (empty if path_length is 0)
path_length: int # Hop count encoded in the lower 6 bits of the path byte
path: bytes # The routing path bytes (empty if path_length is 0)
payload: bytes
path_hash_size: int = 1 # Bytes per hop encoded in the upper 2 bits of the path byte
path_byte_length: int = 0
def calculate_channel_hash(channel_key: bytes) -> str:
@@ -93,6 +95,14 @@ def calculate_channel_hash(channel_key: bytes) -> str:
return format(hash_bytes[0], "02x")
def decode_path_metadata(path_byte: int) -> tuple[int, int, int]:
"""Decode the packed path byte into hop count and byte length."""
path_hash_size = (path_byte >> 6) + 1
path_length = path_byte & 0x3F
path_byte_length = path_length * path_hash_size
return path_length, path_hash_size, path_byte_length
def extract_payload(raw_packet: bytes) -> bytes | None:
"""
Extract just the payload from a raw packet, skipping header and path.
@@ -100,8 +110,10 @@ def extract_payload(raw_packet: bytes) -> bytes | None:
Packet structure:
- Byte 0: header (route_type, payload_type, version)
- For TRANSPORT routes: bytes 1-4 are transport codes
- Next byte: path_length
- Next path_length bytes: path data
- Next byte: packed path metadata
- upper 2 bits: bytes per hop minus 1
- lower 6 bits: hop count
- Next hop_count * path_hash_size bytes: path data
- Remaining: payload
Returns the payload bytes, or None if packet is malformed.
@@ -120,16 +132,16 @@ def extract_payload(raw_packet: bytes) -> bytes | None:
return None
offset += 4
# Get path length
# Decode packed path metadata
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
path_length, _path_hash_size, path_byte_length = decode_path_metadata(raw_packet[offset])
offset += 1
# Skip path data
if len(raw_packet) < offset + path_length:
if len(raw_packet) < offset + path_byte_length:
return None
offset += path_length
offset += path_byte_length
# Rest is payload
return raw_packet[offset:]
@@ -156,17 +168,17 @@ def parse_packet(raw_packet: bytes) -> PacketInfo | None:
return None
offset += 4
# Get path length
# Decode packed path metadata
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
path_length, path_hash_size, path_byte_length = decode_path_metadata(raw_packet[offset])
offset += 1
# Extract path data
if len(raw_packet) < offset + path_length:
if len(raw_packet) < offset + path_byte_length:
return None
path = raw_packet[offset : offset + path_length]
offset += path_length
path = raw_packet[offset : offset + path_byte_length]
offset += path_byte_length
# Rest is payload
payload = raw_packet[offset:]
@@ -178,6 +190,8 @@ def parse_packet(raw_packet: bytes) -> PacketInfo | None:
path_length=path_length,
path=path,
payload=payload,
path_hash_size=path_hash_size,
path_byte_length=path_byte_length,
)
except (ValueError, IndexError):
return None
@@ -529,8 +543,10 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
message_bytes = decrypted[5:]
try:
message_text = message_bytes.decode("utf-8")
# Remove null terminator and any padding
message_text = message_text.rstrip("\x00")
# Truncate at first null terminator (consistent with channel message handling)
null_idx = message_text.find("\x00")
if null_idx >= 0:
message_text = message_text[:null_idx]
except UnicodeDecodeError:
return None

View File

@@ -1,4 +1,3 @@
import asyncio
import logging
import time
from typing import TYPE_CHECKING
@@ -104,15 +103,28 @@ async def on_contact_message(event: "Event") -> None:
# Try to create message - INSERT OR IGNORE handles duplicates atomically
# If the packet processor already stored this message, this returns None
ts = payload.get("sender_timestamp")
sender_timestamp = ts if ts is not None else received_at
sender_name = contact.name if contact else None
path = payload.get("path")
payload_path_len = payload.get("path_len")
normalized_path_len = (
payload_path_len
if isinstance(payload_path_len, int)
else (len(path) // 2 if path is not None else None)
)
msg_id = await MessageRepository.create(
msg_type="PRIV",
text=payload.get("text", ""),
conversation_key=sender_pubkey,
sender_timestamp=payload.get("sender_timestamp") or received_at,
sender_timestamp=sender_timestamp,
received_at=received_at,
path=payload.get("path"),
path=path,
path_len=normalized_path_len,
txt_type=txt_type,
signature=payload.get("signature"),
sender_key=sender_pubkey,
sender_name=sender_name,
)
if msg_id is None:
@@ -125,8 +137,11 @@ async def on_contact_message(event: "Event") -> None:
logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12])
# Build paths array for broadcast
path = payload.get("path")
paths = [MessagePath(path=path or "", received_at=received_at)] if path is not None else None
paths = (
[MessagePath(path=path or "", received_at=received_at, path_len=normalized_path_len)]
if path is not None
else None
)
# Broadcast the new message
broadcast_event(
@@ -136,11 +151,13 @@ async def on_contact_message(event: "Event") -> None:
type="PRIV",
conversation_key=sender_pubkey,
text=payload.get("text", ""),
sender_timestamp=payload.get("sender_timestamp") or received_at,
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=paths,
txt_type=txt_type,
signature=payload.get("signature"),
sender_key=sender_pubkey,
sender_name=sender_name,
).model_dump(),
)
@@ -148,23 +165,6 @@ async def on_contact_message(event: "Event") -> None:
if contact:
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
# Run bot if enabled
from app.bot import run_bot_for_message
asyncio.create_task(
run_bot_for_message(
sender_name=contact.name if contact else None,
sender_key=sender_pubkey,
message_text=payload.get("text", ""),
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=payload.get("sender_timestamp"),
path=payload.get("path"),
is_outgoing=False,
)
)
async def on_rx_log_data(event: "Event") -> None:
"""Store raw RF packet data and process via centralized packet processor.
@@ -260,6 +260,13 @@ async def on_new_contact(event: "Event") -> None:
await ContactNameHistoryRepository.record_name(
public_key.lower(), adv_name, int(time.time())
)
backfilled = await MessageRepository.backfill_channel_sender_key(public_key, adv_name)
if backfilled > 0:
logger.info(
"Backfilled sender_key on %d channel message(s) for %s",
backfilled,
adv_name,
)
# Read back from DB so the broadcast includes all fields (last_contacted,
# last_read_at, etc.) matching the REST Contact shape exactly.

284
app/fanout/AGENTS_fanout.md Normal file
View File

@@ -0,0 +1,284 @@
# Fanout Bus Architecture
The fanout bus is a unified system for dispatching mesh radio events (decoded messages and raw packets) to external integrations. It replaces the previous scattered singleton MQTT publishers with a modular, configurable framework.
## Core Concepts
### FanoutModule (base.py)
Base class that all integration modules extend:
- `__init__(config_id, config, *, name="")` — constructor; receives the config UUID, the type-specific config dict, and the user-assigned name
- `start()` / `stop()` — async lifecycle (e.g. open/close connections)
- `on_message(data)` — receive decoded messages (DM/channel)
- `on_raw(data)` — receive raw RF packets
- `status` property (**must override**) — return `"connected"`, `"disconnected"`, or `"error"`
### FanoutManager (manager.py)
Singleton that owns all active modules and dispatches events:
- `load_from_db()` — startup: load enabled configs, instantiate modules
- `reload_config(id)` — CRUD: stop old, start new
- `remove_config(id)` — delete: stop and remove
- `broadcast_message(data)` — scope-check + dispatch `on_message`
- `broadcast_raw(data)` — scope-check + dispatch `on_raw`
- `stop_all()` — shutdown
- `get_statuses()` — health endpoint data
All modules are constructed uniformly: `cls(config_id, config_blob, name=cfg.get("name", ""))`.
### Scope Matching
Each config has a `scope` JSON blob controlling what events reach it:
```json
{"messages": "all", "raw_packets": "all"}
{"messages": "none", "raw_packets": "all"}
{"messages": {"channels": ["key1"], "contacts": "all"}, "raw_packets": "none"}
```
Community MQTT always enforces `{"messages": "none", "raw_packets": "all"}`.
## Event Flow
```
Radio Event -> packet_processor / event_handler
-> broadcast_event("message"|"raw_packet", data, realtime=True)
-> WebSocket broadcast (always)
-> FanoutManager.broadcast_message/raw (only if realtime=True)
-> scope check per module
-> module.on_message / on_raw
```
Setting `realtime=False` (used during historical decryption) skips fanout dispatch entirely.
## Current Module Types
### mqtt_private (mqtt_private.py)
Wraps `MqttPublisher` from `app/fanout/mqtt.py`. Config blob:
- `broker_host`, `broker_port`, `username`, `password`
- `use_tls`, `tls_insecure`, `topic_prefix`
### mqtt_community (mqtt_community.py)
Wraps `CommunityMqttPublisher` from `app/fanout/community_mqtt.py`. Config blob:
- `broker_host`, `broker_port`, `iata`, `email`
- Only publishes raw packets (on_message is a no-op)
### bot (bot.py)
Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
- `code` — Python bot function source code
- Executes in a thread pool with timeout and semaphore concurrency control
- Rate-limits outgoing messages for repeater compatibility
### webhook (webhook.py)
HTTP webhook delivery. Config blob:
- `url`, `method` (POST/PUT/PATCH)
- `hmac_secret` (optional) — when set, each request includes an HMAC-SHA256 signature of the JSON body
- `hmac_header` (optional, default `X-Webhook-Signature`) — header name for the signature (value format: `sha256=<hex>`)
- `headers` — arbitrary extra headers (JSON object)
### apprise (apprise_mod.py)
Push notifications via Apprise library. Config blob:
- `urls` — newline-separated Apprise notification service URLs
- `preserve_identity` — suppress Discord webhook name/avatar override
- `include_path` — include routing path in notification body
## Adding a New Integration Type
### Step-by-step checklist
#### 1. Backend module (`app/fanout/my_type.py`)
Create a class extending `FanoutModule`:
```python
from app.fanout.base import FanoutModule
class MyTypeModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
# Initialize module-specific state
async def start(self) -> None:
"""Open connections, create clients, etc."""
async def stop(self) -> None:
"""Close connections, clean up resources."""
async def on_message(self, data: dict) -> None:
"""Handle decoded messages. Omit if not needed."""
async def on_raw(self, data: dict) -> None:
"""Handle raw packets. Omit if not needed."""
@property
def status(self) -> str:
"""Required. Return 'connected', 'disconnected', or 'error'."""
...
```
Constructor requirements:
- Must accept `config_id: str, config: dict, *, name: str = ""`
- Must forward `name` to super: `super().__init__(config_id, config, name=name)`
#### 2. Register in manager (`app/fanout/manager.py`)
Add import and mapping in `_register_module_types()`:
```python
from app.fanout.my_type import MyTypeModule
_MODULE_TYPES["my_type"] = MyTypeModule
```
#### 3. Router changes (`app/routers/fanout.py`)
Three changes needed:
**a)** Add to `_VALID_TYPES` set:
```python
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "my_type"}
```
**b)** Add a validation function:
```python
def _validate_my_type_config(config: dict) -> None:
"""Validate my_type config blob."""
if not config.get("some_required_field"):
raise HTTPException(status_code=400, detail="some_required_field is required")
```
**c)** Wire validation into both `create_fanout_config` and `update_fanout_config` — add an `elif` to the validation block in each:
```python
elif body.type == "my_type":
_validate_my_type_config(body.config)
```
Note: validation only runs when the config will be enabled (disabled configs are treated as drafts).
**d)** Add scope enforcement in `_enforce_scope()` if the type has fixed scope constraints (e.g. raw_packets always none). Otherwise it falls through to the `mqtt_private` default which allows both messages and raw_packets to be configurable.
#### 4. Frontend editor component (`SettingsFanoutSection.tsx`)
Four changes needed in this single file:
**a)** Add to `TYPE_LABELS` and `TYPE_OPTIONS` at the top:
```tsx
const TYPE_LABELS: Record<string, string> = {
// ... existing entries ...
my_type: 'My Type',
};
const TYPE_OPTIONS = [
// ... existing entries ...
{ value: 'my_type', label: 'My Type' },
];
```
**b)** Create an editor component (follows the same pattern as existing editors):
```tsx
function MyTypeConfigEditor({
config,
scope,
onChange,
onScopeChange,
}: {
config: Record<string, unknown>;
scope: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
onScopeChange: (scope: Record<string, unknown>) => void;
}) {
return (
<div className="space-y-3">
{/* Type-specific config fields */}
<Separator />
<ScopeSelector scope={scope} onChange={onScopeChange} />
</div>
);
}
```
If your type does NOT have user-configurable scope (like bot or community MQTT), omit the `scope`/`onScopeChange` props and the `ScopeSelector`.
The `ScopeSelector` component is defined within the same file. It accepts an optional `showRawPackets` prop:
- **Without `showRawPackets`** (webhook, apprise): shows message scope only (all/only/except — no "none" option since that would make the integration a no-op). A warning appears when the effective selection matches nothing.
- **With `showRawPackets`** (private MQTT): adds a "Forward raw packets" toggle and includes the "No messages" option (valid when raw packets are enabled). The warning appears only when both raw packets and messages are effectively disabled.
**c)** Add default config and scope in `handleAddCreate`:
```tsx
const defaults: Record<string, Record<string, unknown>> = {
// ... existing entries ...
my_type: { some_field: '', other_field: true },
};
const defaultScopes: Record<string, Record<string, unknown>> = {
// ... existing entries ...
my_type: { messages: 'all', raw_packets: 'none' },
};
```
**d)** Wire the editor into the detail view's conditional render block:
```tsx
{editingConfig.type === 'my_type' && (
<MyTypeConfigEditor
config={editConfig}
scope={editScope}
onChange={setEditConfig}
onScopeChange={setEditScope}
/>
)}
```
#### 5. Tests
**Backend integration tests** (`tests/test_fanout_integration.py`):
- Test that a configured + enabled module receives messages via `FanoutManager.broadcast_message`
- Test scope filtering (all, none, selective)
- Test that a disabled module does not receive messages
**Backend unit tests** (`tests/test_fanout_hitlist.py` or a dedicated file):
- Test config validation (required fields, bad values)
- Test module-specific logic in isolation
**Frontend tests** (`frontend/src/test/fanoutSection.test.tsx`):
- The existing suite covers the list/edit/create flow generically. If your editor has special behavior, add specific test cases.
#### Summary of files to touch
| File | Change |
|------|--------|
| `app/fanout/my_type.py` | New module class |
| `app/fanout/manager.py` | Import + register in `_register_module_types()` |
| `app/routers/fanout.py` | `_VALID_TYPES` + validator function + scope enforcement |
| `frontend/.../SettingsFanoutSection.tsx` | `TYPE_LABELS` + `TYPE_OPTIONS` + editor component + defaults + detail view wiring |
| `tests/test_fanout_integration.py` | Integration tests |
## REST API
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/fanout` | List all fanout configs |
| POST | `/api/fanout` | Create new config |
| PATCH | `/api/fanout/{id}` | Update config (triggers module reload) |
| DELETE | `/api/fanout/{id}` | Delete config (stops module) |
## Database
`fanout_configs` table:
- `id` TEXT PRIMARY KEY
- `type`, `name`, `enabled`, `config` (JSON), `scope` (JSON)
- `sort_order`, `created_at`
Migrations:
- **36**: Creates `fanout_configs` table, migrates existing MQTT settings from `app_settings`
- **37**: Migrates bot configs from `app_settings.bots` JSON column into fanout rows
- **38**: Drops legacy `mqtt_*`, `community_mqtt_*`, and `bots` columns from `app_settings`
## Key Files
- `app/fanout/base.py` — FanoutModule base class
- `app/fanout/manager.py` — FanoutManager singleton
- `app/fanout/mqtt_base.py` — BaseMqttPublisher ABC (shared MQTT connection loop)
- `app/fanout/mqtt.py` — MqttPublisher (private MQTT publishing)
- `app/fanout/community_mqtt.py` — CommunityMqttPublisher (community MQTT with JWT auth)
- `app/fanout/mqtt_private.py` — Private MQTT fanout module
- `app/fanout/mqtt_community.py` — Community MQTT fanout module
- `app/fanout/bot.py` — Bot fanout module
- `app/fanout/bot_exec.py` — Bot code execution, response processing, rate limiting
- `app/fanout/webhook.py` — Webhook fanout module
- `app/fanout/apprise_mod.py` — Apprise fanout module
- `app/repository/fanout.py` — Database CRUD
- `app/routers/fanout.py` — REST API
- `app/websocket.py``broadcast_event()` dispatches to fanout
- `frontend/src/components/settings/SettingsFanoutSection.tsx` — UI

8
app/fanout/__init__.py Normal file
View File

@@ -0,0 +1,8 @@
from app.fanout.base import FanoutModule
from app.fanout.manager import FanoutManager, fanout_manager
__all__ = [
"FanoutManager",
"FanoutModule",
"fanout_manager",
]

136
app/fanout/apprise_mod.py Normal file
View File

@@ -0,0 +1,136 @@
"""Fanout module for Apprise push notifications."""
from __future__ import annotations
import asyncio
import logging
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
from app.fanout.base import FanoutModule
logger = logging.getLogger(__name__)
def _parse_urls(raw: str) -> list[str]:
"""Split multi-line URL string into individual URLs."""
return [line.strip() for line in raw.splitlines() if line.strip()]
def _normalize_discord_url(url: str) -> str:
"""Add avatar=no to Discord URLs to suppress identity override."""
parts = urlsplit(url)
scheme = parts.scheme.lower()
host = parts.netloc.lower()
is_discord = scheme in ("discord", "discords") or (
scheme in ("http", "https")
and host in ("discord.com", "discordapp.com")
and parts.path.lower().startswith("/api/webhooks/")
)
if not is_discord:
return url
query = dict(parse_qsl(parts.query, keep_blank_values=True))
query["avatar"] = "no"
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
def _format_body(data: dict, *, include_path: bool) -> str:
"""Build a human-readable notification body from message data."""
msg_type = data.get("type", "")
text = data.get("text", "")
sender_name = data.get("sender_name") or "Unknown"
via = ""
if include_path:
paths = data.get("paths")
first_path = (
paths[0]
if isinstance(paths, list) and len(paths) > 0 and isinstance(paths[0], dict)
else None
)
if first_path is not None:
path_str = first_path.get("path", "")
else:
path_str = None
if msg_type == "PRIV" and path_str is None:
via = " **via:** [`direct`]"
elif path_str is not None:
path_str = path_str.strip().lower()
if path_str == "":
via = " **via:** [`direct`]"
else:
path_len = first_path.get("path_len") if first_path is not None else None
hop_chars = (
len(path_str) // path_len
if isinstance(path_len, int) and path_len > 0 and len(path_str) % path_len == 0
else 2
)
hops = [path_str[i : i + hop_chars] for i in range(0, len(path_str), hop_chars)]
if hops:
hop_list = ", ".join(f"`{h}`" for h in hops)
via = f" **via:** [{hop_list}]"
if msg_type == "PRIV":
return f"**DM:** {sender_name}: {text}{via}"
channel_name = data.get("channel_name") or data.get("conversation_key", "channel")
return f"**{channel_name}:** {sender_name}: {text}{via}"
def _send_sync(urls_raw: str, body: str, *, preserve_identity: bool) -> bool:
"""Send notification synchronously via Apprise. Returns True on success."""
import apprise as apprise_lib
urls = _parse_urls(urls_raw)
if not urls:
return False
notifier = apprise_lib.Apprise()
for url in urls:
if preserve_identity:
url = _normalize_discord_url(url)
notifier.add(url)
return bool(notifier.notify(title="", body=body))
class AppriseModule(FanoutModule):
"""Sends push notifications via Apprise for incoming messages."""
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._last_error: str | None = None
async def on_message(self, data: dict) -> None:
# Skip outgoing messages — only notify on incoming
if data.get("outgoing"):
return
urls = self.config.get("urls", "")
if not urls or not urls.strip():
return
preserve_identity = self.config.get("preserve_identity", True)
include_path = self.config.get("include_path", True)
body = _format_body(data, include_path=include_path)
try:
success = await asyncio.to_thread(
_send_sync, urls, body, preserve_identity=preserve_identity
)
self._last_error = None if success else "Apprise notify returned failure"
if not success:
logger.warning("Apprise notification failed for module %s", self.config_id)
except Exception as exc:
self._last_error = str(exc)
logger.exception("Apprise send error for module %s", self.config_id)
@property
def status(self) -> str:
if not self.config.get("urls", "").strip():
return "disconnected"
if self._last_error:
return "error"
return "connected"

35
app/fanout/base.py Normal file
View File

@@ -0,0 +1,35 @@
"""Base class for fanout integration modules."""
from __future__ import annotations
class FanoutModule:
"""Base class for all fanout integrations.
Each module wraps a specific integration (MQTT, webhook, etc.) and
receives dispatched messages/packets from the FanoutManager.
Subclasses must override the ``status`` property.
"""
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
self.config_id = config_id
self.config = config
self.name = name
async def start(self) -> None:
"""Start the module (e.g. connect to broker). Override for persistent connections."""
async def stop(self) -> None:
"""Stop the module (e.g. disconnect from broker)."""
async def on_message(self, data: dict) -> None:
"""Called for decoded messages (DM/channel). Override if needed."""
async def on_raw(self, data: dict) -> None:
"""Called for raw RF packets. Override if needed."""
@property
def status(self) -> str:
"""Return 'connected', 'disconnected', or 'error'."""
raise NotImplementedError

142
app/fanout/bot.py Normal file
View File

@@ -0,0 +1,142 @@
"""Fanout module wrapping bot execution logic."""
from __future__ import annotations
import asyncio
import logging
from app.fanout.base import FanoutModule
logger = logging.getLogger(__name__)
class BotModule(FanoutModule):
"""Wraps a single bot's code execution and response routing.
Each BotModule represents one bot configuration. It receives decoded
messages via ``on_message``, executes the bot's Python code in a
background task (after a 2-second settle delay), and sends any response
back through the radio.
"""
def __init__(self, config_id: str, config: dict, *, name: str = "Bot") -> None:
super().__init__(config_id, config, name=name)
self._tasks: set[asyncio.Task] = set()
self._active = True
async def stop(self) -> None:
self._active = False
for task in self._tasks:
task.cancel()
# Wait briefly for tasks to acknowledge cancellation
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
self._tasks.clear()
async def on_message(self, data: dict) -> None:
"""Kick off bot execution in a background task so we don't block dispatch."""
task = asyncio.create_task(self._run_for_message(data))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _run_for_message(self, data: dict) -> None:
from app.fanout.bot_exec import (
BOT_EXECUTION_TIMEOUT,
execute_bot_code,
process_bot_response,
)
code = self.config.get("code", "")
if not code or not code.strip():
return
msg_type = data.get("type", "")
is_dm = msg_type == "PRIV"
# Extract bot parameters from broadcast data
if is_dm:
conversation_key = data.get("conversation_key", "")
sender_key = data.get("sender_key") or conversation_key
is_outgoing = data.get("outgoing", False)
message_text = data.get("text", "")
channel_key = None
channel_name = None
# Outgoing DMs: sender is us, not the contact
if is_outgoing:
sender_name = None
else:
sender_name = data.get("sender_name")
if sender_name is None:
from app.repository import ContactRepository
contact = await ContactRepository.get_by_key(conversation_key)
sender_name = contact.name if contact else None
else:
conversation_key = data.get("conversation_key", "")
sender_key = None
is_outgoing = bool(data.get("outgoing", False))
sender_name = data.get("sender_name")
channel_key = conversation_key
channel_name = data.get("channel_name")
if channel_name is None:
from app.repository import ChannelRepository
channel = await ChannelRepository.get_by_key(conversation_key)
channel_name = channel.name if channel else None
# Strip "sender: " prefix from channel message text
text = data.get("text", "")
if sender_name and text.startswith(f"{sender_name}: "):
message_text = text[len(f"{sender_name}: ") :]
else:
message_text = text
sender_timestamp = data.get("sender_timestamp")
path_value = data.get("path")
# Message model serializes paths as list of dicts; extract first path string
if path_value is None:
paths = data.get("paths")
if paths and isinstance(paths, list) and len(paths) > 0:
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
# Wait for message to settle (allows retransmissions to be deduped)
await asyncio.sleep(2)
# Execute bot code in thread pool with timeout
from app.fanout.bot_exec import _bot_executor, _bot_semaphore
async with _bot_semaphore:
loop = asyncio.get_running_loop()
try:
response = await asyncio.wait_for(
loop.run_in_executor(
_bot_executor,
execute_bot_code,
code,
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path_value,
is_outgoing,
),
timeout=BOT_EXECUTION_TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning("Bot '%s' execution timed out", self.name)
return
except Exception as e:
logger.warning("Bot '%s' execution error: %s", self.name, e)
return
if response and self._active:
await process_bot_response(response, is_dm, sender_key or "", channel_key)
@property
def status(self) -> str:
return "connected"

View File

@@ -257,94 +257,3 @@ async def _send_single_bot_message(
# Update last send time after successful send
_last_bot_send_time = time.monotonic()
async def run_bot_for_message(
sender_name: str | None,
sender_key: str | None,
message_text: str,
is_dm: bool,
channel_key: str | None,
channel_name: str | None = None,
sender_timestamp: int | None = None,
path: str | None = None,
is_outgoing: bool = False,
) -> None:
"""
Run all enabled bots for a message (incoming or outgoing).
This is the main entry point called by message handlers after
a message is successfully decrypted and stored. Bots run serially,
and errors in one bot don't prevent others from running.
Args:
sender_name: Display name of the sender
sender_key: 64-char hex public key of sender (DMs only, None for channels)
message_text: The message content
is_dm: True for direct messages, False for channel messages
channel_key: Channel key for channel messages
channel_name: Channel name (e.g. "#general"), None for DMs
sender_timestamp: Sender's timestamp from the message
path: Hex-encoded routing path
is_outgoing: Whether this is our own outgoing message
"""
# Early check if any bots are enabled (will re-check after sleep)
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
if not enabled_bots:
return
async with _bot_semaphore:
logger.debug(
"Running %d bot(s) for message from %s (is_dm=%s)",
len(enabled_bots),
sender_name or (sender_key[:12] if sender_key else "unknown"),
is_dm,
)
# Wait for the initiating message's retransmissions to propagate through the mesh
await asyncio.sleep(2)
# Re-check settings after sleep (user may have changed bot config)
settings = await AppSettingsRepository.get()
enabled_bots = [b for b in settings.bots if b.enabled and b.code.strip()]
if not enabled_bots:
logger.debug("All bots disabled during wait, skipping")
return
# Run each enabled bot serially
loop = asyncio.get_event_loop()
for bot in enabled_bots:
logger.debug("Executing bot '%s'", bot.name)
try:
response = await asyncio.wait_for(
loop.run_in_executor(
_bot_executor,
execute_bot_code,
bot.code,
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing,
),
timeout=BOT_EXECUTION_TIMEOUT,
)
except asyncio.TimeoutError:
logger.warning(
"Bot '%s' execution timed out after %ds", bot.name, BOT_EXECUTION_TIMEOUT
)
continue # Continue to next bot
except Exception as e:
logger.warning("Bot '%s' execution error: %s", bot.name, e)
continue # Continue to next bot
# Send response if any
if response:
await process_bot_response(response, is_dm, sender_key or "", channel_key)

View File

@@ -12,18 +12,19 @@ from __future__ import annotations
import asyncio
import base64
import hashlib
import importlib.metadata
import json
import logging
import re
import ssl
import time
from datetime import datetime
from typing import Any
from typing import Any, Protocol
import aiomqtt
import nacl.bindings
from app.models import AppSettings
from app.mqtt_base import BaseMqttPublisher
from app.decoder import decode_path_metadata
from app.fanout.mqtt_base import BaseMqttPublisher
logger = logging.getLogger(__name__)
@@ -35,14 +36,27 @@ _CLIENT_ID = "RemoteTerm (github.com/jkingsman/Remote-Terminal-for-MeshCore)"
_TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp)
_TOKEN_RENEWAL_THRESHOLD = _TOKEN_LIFETIME - 3600 # 23 hours
# Periodic status republish interval (matches meshcore-packet-capture reference)
_STATS_REFRESH_INTERVAL = 300 # 5 minutes
_STATS_MIN_CACHE_SECS = 60 # Don't re-fetch stats within 60s
# Ed25519 group order
_L = 2**252 + 27742317777372353535851937790883648493
_IATA_RE = re.compile(r"^[A-Z]{3}$")
# Route type mapping: bottom 2 bits of first byte
_ROUTE_MAP = {0: "F", 1: "F", 2: "D", 3: "T"}
class CommunityMqttSettings(Protocol):
"""Attributes expected on the settings object for the community MQTT publisher."""
community_mqtt_enabled: bool
community_mqtt_broker_host: str
community_mqtt_broker_port: int
community_mqtt_iata: str
community_mqtt_email: str
def _base64url_encode(data: bytes) -> str:
"""Base64url encode without padding."""
return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
@@ -133,23 +147,24 @@ def _calculate_packet_hash(raw_bytes: bytes) -> str:
if has_transport:
offset += 4 # Skip 4 bytes of transport codes
# Read path_len (1 byte on wire). Invalid/truncated packets map to zero hash.
# Read packed path metadata. Invalid/truncated packets map to zero hash.
if offset >= len(raw_bytes):
return "0" * 16
path_len = raw_bytes[offset]
packed_path_len = raw_bytes[offset]
path_len, _path_hash_size, path_byte_length = decode_path_metadata(packed_path_len)
offset += 1
# Skip past path to get to payload. Invalid/truncated packets map to zero hash.
if len(raw_bytes) < offset + path_len:
if len(raw_bytes) < offset + path_byte_length:
return "0" * 16
payload_start = offset + path_len
payload_start = offset + path_byte_length
payload_data = raw_bytes[payload_start:]
# Hash: payload_type(1 byte) [+ path_len as uint16_t LE for TRACE] + payload_data
# Hash: payload_type(1 byte) [+ packed path_len as uint16_t LE for TRACE] + payload_data
hash_obj = hashlib.sha256()
hash_obj.update(bytes([payload_type]))
if payload_type == 9: # PAYLOAD_TYPE_TRACE
hash_obj.update(path_len.to_bytes(2, byteorder="little"))
hash_obj.update(packed_path_len.to_bytes(2, byteorder="little"))
hash_obj.update(payload_data)
return hash_obj.hexdigest()[:16].upper()
@@ -189,20 +204,24 @@ def _decode_packet_fields(raw_bytes: bytes) -> tuple[str, str, str, list[str], i
if len(raw_bytes) <= offset:
return route, packet_type, payload_len, path_values, payload_type
path_len = raw_bytes[offset]
path_len, path_hash_size, path_byte_length = decode_path_metadata(raw_bytes[offset])
offset += 1
if len(raw_bytes) < offset + path_len:
if len(raw_bytes) < offset + path_byte_length:
return route, packet_type, payload_len, path_values, payload_type
path_bytes = raw_bytes[offset : offset + path_len]
offset += path_len
path_bytes = raw_bytes[offset : offset + path_byte_length]
offset += path_byte_length
payload_type = (header >> 2) & 0x0F
route = _ROUTE_MAP.get(route_type, "U")
packet_type = str(payload_type)
payload_len = str(max(0, len(raw_bytes) - offset))
path_values = [f"{b:02x}" for b in path_bytes]
path_values = [
path_bytes[i : i + path_hash_size].hex()
for i in range(0, len(path_bytes), path_hash_size)
if i + path_hash_size <= len(path_bytes)
]
return route, packet_type, payload_len, path_values, payload_type
except Exception:
@@ -252,6 +271,42 @@ def _format_raw_packet(data: dict[str, Any], device_name: str, public_key_hex: s
return packet
def _build_status_topic(settings: CommunityMqttSettings, pubkey_hex: str) -> str:
"""Build the ``meshcore/{IATA}/{PUBKEY}/status`` topic string."""
iata = settings.community_mqtt_iata.upper().strip()
return f"meshcore/{iata}/{pubkey_hex}/status"
def _build_radio_info() -> str:
"""Format the radio parameters string from self_info.
Matches the reference format: ``"freq,bw,sf,cr"`` (comma-separated raw
values). Falls back to ``"0,0,0,0"`` when unavailable.
"""
from app.radio import radio_manager
try:
if radio_manager.meshcore and radio_manager.meshcore.self_info:
info = radio_manager.meshcore.self_info
freq = info.get("radio_freq", 0)
bw = info.get("radio_bw", 0)
sf = info.get("radio_sf", 0)
cr = info.get("radio_cr", 0)
return f"{freq},{bw},{sf},{cr}"
except Exception:
pass
return "0,0,0,0"
def _get_client_version() -> str:
"""Return a client version string like ``'RemoteTerm 2.4.0'``."""
try:
version = importlib.metadata.version("remoteterm-meshcore")
return f"RemoteTerm {version}"
except Exception:
return "RemoteTerm unknown"
class CommunityMqttPublisher(BaseMqttPublisher):
"""Manages the community MQTT connection and publishes raw packets."""
@@ -262,21 +317,27 @@ class CommunityMqttPublisher(BaseMqttPublisher):
def __init__(self) -> None:
super().__init__()
self._key_unavailable_warned: bool = False
self._cached_device_info: dict[str, str] | None = None
self._cached_stats: dict[str, Any] | None = None
self._stats_supported: bool | None = None
self._last_stats_fetch: float = 0.0
self._last_status_publish: float = 0.0
async def start(self, settings: AppSettings) -> None:
async def start(self, settings: object) -> None:
self._key_unavailable_warned = False
self._cached_device_info = None
self._cached_stats = None
self._stats_supported = None
self._last_stats_fetch = 0.0
self._last_status_publish = 0.0
await super().start(settings)
def _on_not_configured(self) -> None:
from app.keystore import has_private_key
from app.websocket import broadcast_error
if (
self._settings
and self._settings.community_mqtt_enabled
and not has_private_key()
and not self._key_unavailable_warned
):
s: CommunityMqttSettings | None = self._settings
if s and not has_private_key() and not self._key_unavailable_warned:
broadcast_error(
"Community MQTT unavailable",
"Radio firmware does not support private key export.",
@@ -287,27 +348,44 @@ class CommunityMqttPublisher(BaseMqttPublisher):
"""Check if community MQTT is enabled and keys are available."""
from app.keystore import has_private_key
return bool(self._settings and self._settings.community_mqtt_enabled and has_private_key())
s: CommunityMqttSettings | None = self._settings
return bool(s and s.community_mqtt_enabled and has_private_key())
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
s: CommunityMqttSettings = settings # type: ignore[assignment]
from app.keystore import get_private_key, get_public_key
from app.radio import radio_manager
private_key = get_private_key()
public_key = get_public_key()
assert private_key is not None and public_key is not None # guaranteed by _pre_connect
pubkey_hex = public_key.hex().upper()
broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
jwt_token = _generate_jwt_token(
private_key,
public_key,
audience=broker_host,
email=settings.community_mqtt_email or "",
email=s.community_mqtt_email or "",
)
tls_context = ssl.create_default_context()
device_name = ""
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
status_topic = _build_status_topic(s, pubkey_hex)
offline_payload = json.dumps(
{
"status": "offline",
"timestamp": datetime.now().isoformat(),
"origin": device_name or "MeshCore Device",
"origin_id": pubkey_hex,
}
)
return {
"hostname": broker_host,
"port": broker_port,
@@ -316,13 +394,151 @@ class CommunityMqttPublisher(BaseMqttPublisher):
"websocket_path": "/",
"username": f"v1_{pubkey_hex}",
"password": jwt_token,
"will": aiomqtt.Will(status_topic, offline_payload, retain=True),
}
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
broker_host = settings.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = settings.community_mqtt_broker_port or _DEFAULT_PORT
def _on_connected(self, settings: object) -> tuple[str, str]:
s: CommunityMqttSettings = settings # type: ignore[assignment]
broker_host = s.community_mqtt_broker_host or _DEFAULT_BROKER
broker_port = s.community_mqtt_broker_port or _DEFAULT_PORT
return ("Community MQTT connected", f"{broker_host}:{broker_port}")
async def _fetch_device_info(self) -> dict[str, str]:
"""Fetch firmware model/version from the radio (cached for the connection)."""
if self._cached_device_info is not None:
return self._cached_device_info
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
fallback = {"model": "unknown", "firmware_version": "unknown"}
try:
async with radio_manager.radio_operation(
"community_stats_device_info", blocking=False
) as mc:
event = await mc.commands.send_device_query()
from meshcore.events import EventType
if event.type == EventType.DEVICE_INFO:
fw_ver = event.payload.get("fw ver", 0)
if fw_ver >= 3:
model = event.payload.get("model", "unknown") or "unknown"
ver = event.payload.get("ver", "unknown") or "unknown"
fw_build = event.payload.get("fw_build", "") or ""
fw_str = f"v{ver} (Build: {fw_build})" if fw_build else f"v{ver}"
self._cached_device_info = {
"model": model,
"firmware_version": fw_str,
}
else:
# Old firmware — cache what we can
self._cached_device_info = {
"model": "unknown",
"firmware_version": f"v{fw_ver}" if fw_ver else "unknown",
}
return self._cached_device_info
except (RadioOperationBusyError, RadioDisconnectedError):
pass
except Exception as e:
logger.debug("Community MQTT: device info fetch failed: %s", e)
# Don't cache transient failures — allow retry on next status publish
return fallback
async def _fetch_stats(self) -> dict[str, Any] | None:
"""Fetch core + radio stats from the radio (best-effort, cached)."""
if self._stats_supported is False:
return self._cached_stats
now = time.monotonic()
if (
now - self._last_stats_fetch
) < _STATS_MIN_CACHE_SECS and self._cached_stats is not None:
return self._cached_stats
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
try:
async with radio_manager.radio_operation("community_stats_fetch", blocking=False) as mc:
from meshcore.events import EventType
result: dict[str, Any] = {}
core_event = await mc.commands.get_stats_core()
if core_event.type == EventType.ERROR:
logger.info("Community MQTT: firmware does not support stats commands")
self._stats_supported = False
return self._cached_stats
if core_event.type == EventType.STATS_CORE:
result.update(core_event.payload)
radio_event = await mc.commands.get_stats_radio()
if radio_event.type == EventType.ERROR:
logger.info("Community MQTT: firmware does not support stats commands")
self._stats_supported = False
return self._cached_stats
if radio_event.type == EventType.STATS_RADIO:
result.update(radio_event.payload)
if result:
self._cached_stats = result
self._last_stats_fetch = now
return self._cached_stats
except (RadioOperationBusyError, RadioDisconnectedError):
pass
except Exception as e:
logger.debug("Community MQTT: stats fetch failed: %s", e)
return self._cached_stats
async def _publish_status(
self, settings: CommunityMqttSettings, *, refresh_stats: bool = True
) -> None:
"""Build and publish the enriched retained status message."""
from app.keystore import get_public_key
from app.radio import radio_manager
public_key = get_public_key()
if public_key is None:
return
pubkey_hex = public_key.hex().upper()
device_name = ""
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
device_info = await self._fetch_device_info()
stats = await self._fetch_stats() if refresh_stats else self._cached_stats
status_topic = _build_status_topic(settings, pubkey_hex)
payload: dict[str, Any] = {
"status": "online",
"timestamp": datetime.now().isoformat(),
"origin": device_name or "MeshCore Device",
"origin_id": pubkey_hex,
"model": device_info.get("model", "unknown"),
"firmware_version": device_info.get("firmware_version", "unknown"),
"radio": _build_radio_info(),
"client_version": _get_client_version(),
}
if stats:
payload["stats"] = stats
await self.publish(status_topic, payload, retain=True)
self._last_status_publish = time.monotonic()
async def _on_connected_async(self, settings: object) -> None:
"""Publish a retained online status message after connecting."""
await self._publish_status(settings) # type: ignore[arg-type]
async def _on_periodic_wake(self, elapsed: float) -> None:
if not self._settings:
return
now = time.monotonic()
if (now - self._last_status_publish) >= _STATS_REFRESH_INTERVAL:
await self._publish_status(self._settings, refresh_stats=True)
def _on_error(self) -> tuple[str, str]:
return (
"Community MQTT connection failure",
@@ -338,7 +554,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
return True
return False
async def _pre_connect(self, settings: AppSettings) -> bool:
async def _pre_connect(self, settings: object) -> bool:
from app.keystore import get_private_key, get_public_key
private_key = get_private_key()
@@ -353,50 +569,3 @@ class CommunityMqttPublisher(BaseMqttPublisher):
pass
return False
return True
# Module-level singleton
community_publisher = CommunityMqttPublisher()
def community_mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
"""Fire-and-forget community MQTT publish for raw packets only."""
if event_type != "raw_packet":
return
if not community_publisher.connected or community_publisher._settings is None:
return
asyncio.create_task(_community_maybe_publish(data))
async def _community_maybe_publish(data: dict[str, Any]) -> None:
"""Format and publish a raw packet to the community broker."""
settings = community_publisher._settings
if settings is None or not settings.community_mqtt_enabled:
return
try:
from app.keystore import get_public_key
from app.radio import radio_manager
public_key = get_public_key()
if public_key is None:
return
pubkey_hex = public_key.hex().upper()
# Get device name from radio
device_name = ""
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
packet = _format_raw_packet(data, device_name, pubkey_hex)
iata = settings.community_mqtt_iata.upper().strip()
if not _IATA_RE.fullmatch(iata):
logger.debug("Community MQTT: skipping publish — no valid IATA code configured")
return
topic = f"meshcore/{iata}/{pubkey_hex}/packets"
await community_publisher.publish(topic, packet)
except Exception as e:
logger.warning("Community MQTT broadcast error: %s", e)

243
app/fanout/manager.py Normal file
View File

@@ -0,0 +1,243 @@
"""FanoutManager: owns all active fanout modules and dispatches events."""
from __future__ import annotations
import asyncio
import logging
from typing import Any
from app.fanout.base import FanoutModule
logger = logging.getLogger(__name__)
_DISPATCH_TIMEOUT_SECONDS = 30.0
# Type string -> module class mapping
_MODULE_TYPES: dict[str, type] = {}
def _register_module_types() -> None:
"""Lazily populate the type registry to avoid circular imports."""
if _MODULE_TYPES:
return
from app.fanout.apprise_mod import AppriseModule
from app.fanout.bot import BotModule
from app.fanout.mqtt_community import MqttCommunityModule
from app.fanout.mqtt_private import MqttPrivateModule
from app.fanout.webhook import WebhookModule
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
_MODULE_TYPES["mqtt_community"] = MqttCommunityModule
_MODULE_TYPES["bot"] = BotModule
_MODULE_TYPES["webhook"] = WebhookModule
_MODULE_TYPES["apprise"] = AppriseModule
def _matches_filter(filter_value: Any, key: str) -> bool:
"""Check a single filter value (channels or contacts) against a key.
Supported shapes:
"all" -> True
"none" -> False
["key1", "key2"] -> key in list (only listed)
{"except": ["key1", "key2"]} -> key not in list (all except listed)
"""
if filter_value == "all":
return True
if filter_value == "none":
return False
if isinstance(filter_value, list):
return key in filter_value
if isinstance(filter_value, dict) and "except" in filter_value:
return key not in filter_value["except"]
return False
def _scope_matches_message(scope: dict, data: dict) -> bool:
"""Check whether a message event matches the given scope."""
messages = scope.get("messages", "none")
if messages == "all":
return True
if messages == "none":
return False
if isinstance(messages, dict):
msg_type = data.get("type", "")
conversation_key = data.get("conversation_key", "")
if msg_type == "CHAN":
return _matches_filter(messages.get("channels", "none"), conversation_key)
elif msg_type == "PRIV":
return _matches_filter(messages.get("contacts", "none"), conversation_key)
return False
def _scope_matches_raw(scope: dict, _data: dict) -> bool:
"""Check whether a raw packet event matches the given scope."""
return scope.get("raw_packets", "none") == "all"
class FanoutManager:
"""Owns all active fanout modules and dispatches events."""
def __init__(self) -> None:
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
self._restart_locks: dict[str, asyncio.Lock] = {}
async def load_from_db(self) -> None:
"""Read enabled fanout_configs and instantiate modules."""
_register_module_types()
from app.repository.fanout import FanoutConfigRepository
configs = await FanoutConfigRepository.get_enabled()
for cfg in configs:
await self._start_module(cfg)
async def _start_module(self, cfg: dict[str, Any]) -> None:
"""Instantiate and start a single module from a config dict."""
config_id = cfg["id"]
config_type = cfg["type"]
config_blob = cfg["config"]
scope = cfg["scope"]
# Skip bot modules when bots are disabled server-wide
if config_type == "bot":
from app.config import settings as server_settings
if server_settings.disable_bots:
logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
return
cls = _MODULE_TYPES.get(config_type)
if cls is None:
logger.warning("Unknown fanout type %r for config %s, skipping", config_type, config_id)
return
try:
module = cls(config_id, config_blob, name=cfg.get("name", ""))
await module.start()
self._modules[config_id] = (module, scope)
logger.info(
"Started fanout module %s (type=%s)", cfg.get("name", config_id), config_type
)
except Exception:
logger.exception("Failed to start fanout module %s", config_id)
async def reload_config(self, config_id: str) -> None:
"""Stop old module (if any) and start updated config."""
lock = self._restart_locks.setdefault(config_id, asyncio.Lock())
async with lock:
await self.remove_config(config_id)
from app.repository.fanout import FanoutConfigRepository
cfg = await FanoutConfigRepository.get(config_id)
if cfg is None or not cfg["enabled"]:
return
await self._start_module(cfg)
async def remove_config(self, config_id: str) -> None:
"""Stop and remove a module."""
entry = self._modules.pop(config_id, None)
if entry is not None:
module, _ = entry
try:
await module.stop()
except Exception:
logger.exception("Error stopping fanout module %s", config_id)
async def _dispatch_matching(
self,
data: dict,
*,
matcher: Any,
handler_name: str,
log_label: str,
) -> None:
"""Dispatch to all matching modules concurrently."""
tasks = []
for config_id, (module, scope) in list(self._modules.items()):
if matcher(scope, data):
tasks.append(self._run_handler(config_id, module, handler_name, data, log_label))
if tasks:
await asyncio.gather(*tasks)
async def _run_handler(
self,
config_id: str,
module: FanoutModule,
handler_name: str,
data: dict,
log_label: str,
) -> None:
"""Run one module handler with per-module exception isolation."""
try:
handler = getattr(module, handler_name)
await asyncio.wait_for(handler(data), timeout=_DISPATCH_TIMEOUT_SECONDS)
except asyncio.TimeoutError:
logger.error(
"Fanout %s %s timed out after %.1fs; restarting module",
config_id,
log_label,
_DISPATCH_TIMEOUT_SECONDS,
)
await self._restart_module(config_id, module)
except Exception:
logger.exception("Fanout %s %s error", config_id, log_label)
async def _restart_module(self, config_id: str, module: FanoutModule) -> None:
"""Restart a timed-out module if it is still the active instance."""
lock = self._restart_locks.setdefault(config_id, asyncio.Lock())
async with lock:
entry = self._modules.get(config_id)
if entry is None or entry[0] is not module:
return
try:
await module.stop()
await module.start()
except Exception:
logger.exception("Failed to restart timed-out fanout module %s", config_id)
self._modules.pop(config_id, None)
async def broadcast_message(self, data: dict) -> None:
"""Dispatch a decoded message to modules whose scope matches."""
await self._dispatch_matching(
data,
matcher=_scope_matches_message,
handler_name="on_message",
log_label="on_message",
)
async def broadcast_raw(self, data: dict) -> None:
"""Dispatch a raw packet to modules whose scope matches."""
await self._dispatch_matching(
data,
matcher=_scope_matches_raw,
handler_name="on_raw",
log_label="on_raw",
)
async def stop_all(self) -> None:
"""Shutdown all modules."""
for config_id, (module, _) in list(self._modules.items()):
try:
await module.stop()
except Exception:
logger.exception("Error stopping fanout module %s", config_id)
self._modules.clear()
self._restart_locks.clear()
def get_statuses(self) -> dict[str, dict[str, str]]:
"""Return status info for each active module."""
from app.repository.fanout import _configs_cache
result: dict[str, dict[str, str]] = {}
for config_id, (module, _) in self._modules.items():
info = _configs_cache.get(config_id, {})
result[config_id] = {
"name": info.get("name", config_id),
"type": info.get("type", "unknown"),
"status": module.status,
}
return result
# Module-level singleton
fanout_manager = FanoutManager()

91
app/fanout/mqtt.py Normal file
View File

@@ -0,0 +1,91 @@
"""MQTT publisher for forwarding mesh network events to an MQTT broker."""
from __future__ import annotations
import logging
import ssl
from typing import Any, Protocol
from app.fanout.mqtt_base import BaseMqttPublisher
logger = logging.getLogger(__name__)
class PrivateMqttSettings(Protocol):
"""Attributes expected on the settings object for the private MQTT publisher."""
mqtt_broker_host: str
mqtt_broker_port: int
mqtt_username: str
mqtt_password: str
mqtt_use_tls: bool
mqtt_tls_insecure: bool
mqtt_publish_messages: bool
mqtt_publish_raw_packets: bool
class MqttPublisher(BaseMqttPublisher):
"""Manages an MQTT connection and publishes mesh network events."""
_backoff_max = 30
_log_prefix = "MQTT"
def _is_configured(self) -> bool:
"""Check if MQTT is configured and has something to publish."""
s: PrivateMqttSettings | None = self._settings
return bool(
s and s.mqtt_broker_host and (s.mqtt_publish_messages or s.mqtt_publish_raw_packets)
)
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
s: PrivateMqttSettings = settings # type: ignore[assignment]
return {
"hostname": s.mqtt_broker_host,
"port": s.mqtt_broker_port,
"username": s.mqtt_username or None,
"password": s.mqtt_password or None,
"tls_context": self._build_tls_context(s),
}
def _on_connected(self, settings: object) -> tuple[str, str]:
s: PrivateMqttSettings = settings # type: ignore[assignment]
return ("MQTT connected", f"{s.mqtt_broker_host}:{s.mqtt_broker_port}")
def _on_error(self) -> tuple[str, str]:
return ("MQTT connection failure", "Please correct the settings or disable.")
@staticmethod
def _build_tls_context(settings: PrivateMqttSettings) -> ssl.SSLContext | None:
"""Build TLS context from settings, or None if TLS is disabled."""
if not settings.mqtt_use_tls:
return None
ctx = ssl.create_default_context()
if settings.mqtt_tls_insecure:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
def _build_message_topic(prefix: str, data: dict[str, Any]) -> str:
"""Build MQTT topic for a decrypted message."""
msg_type = data.get("type", "")
conversation_key = data.get("conversation_key", "unknown")
if msg_type == "PRIV":
return f"{prefix}/dm:{conversation_key}"
elif msg_type == "CHAN":
return f"{prefix}/gm:{conversation_key}"
return f"{prefix}/message:{conversation_key}"
def _build_raw_packet_topic(prefix: str, data: dict[str, Any]) -> str:
"""Build MQTT topic for a raw packet."""
info = data.get("decrypted_info")
if info and isinstance(info, dict):
contact_key = info.get("contact_key")
channel_key = info.get("channel_key")
if contact_key:
return f"{prefix}/raw/dm:{contact_key}"
if channel_key:
return f"{prefix}/raw/gm:{channel_key}"
return f"{prefix}/raw/unrouted"

View File

@@ -18,8 +18,6 @@ from typing import Any
import aiomqtt
from app.models import AppSettings
logger = logging.getLogger(__name__)
_BACKOFF_MIN = 5
@@ -38,6 +36,11 @@ class BaseMqttPublisher(ABC):
Subclasses implement the abstract hooks to control configuration checks,
client construction, toast messages, and optional wait-loop behavior.
The settings type is duck-typed each subclass defines a Protocol
describing the attributes it expects (e.g. ``PrivateMqttSettings``,
``CommunityMqttSettings``). Callers pass ``SimpleNamespace`` instances
that satisfy the protocol.
"""
_backoff_max: int = 30
@@ -47,14 +50,14 @@ class BaseMqttPublisher(ABC):
def __init__(self) -> None:
self._client: aiomqtt.Client | None = None
self._task: asyncio.Task[None] | None = None
self._settings: AppSettings | None = None
self._settings: Any = None
self._settings_version: int = 0
self._version_event: asyncio.Event = asyncio.Event()
self.connected: bool = False
# ── Lifecycle ──────────────────────────────────────────────────────
async def start(self, settings: AppSettings) -> None:
async def start(self, settings: object) -> None:
"""Start the background connection loop."""
self._settings = settings
self._settings_version += 1
@@ -74,17 +77,17 @@ class BaseMqttPublisher(ABC):
self._client = None
self.connected = False
async def restart(self, settings: AppSettings) -> None:
async def restart(self, settings: object) -> None:
"""Called when settings change — stop + start."""
await self.stop()
await self.start(settings)
async def publish(self, topic: str, payload: dict[str, Any]) -> None:
async def publish(self, topic: str, payload: dict[str, Any], *, retain: bool = False) -> None:
"""Publish a JSON payload. Drops silently if not connected."""
if self._client is None or not self.connected:
return
try:
await self._client.publish(topic, json.dumps(payload))
await self._client.publish(topic, json.dumps(payload), retain=retain)
except Exception as e:
logger.warning("%s publish failed on %s: %s", self._log_prefix, topic, e)
self.connected = False
@@ -99,11 +102,11 @@ class BaseMqttPublisher(ABC):
"""Return True when this publisher should attempt to connect."""
@abstractmethod
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
"""Return the keyword arguments for ``aiomqtt.Client(...)``."""
@abstractmethod
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
def _on_connected(self, settings: object) -> tuple[str, str]:
"""Return ``(title, detail)`` for the success toast on connect."""
@abstractmethod
@@ -116,7 +119,7 @@ class BaseMqttPublisher(ABC):
"""Return True to break the inner wait (e.g. token expiry)."""
return False
async def _pre_connect(self, settings: AppSettings) -> bool:
async def _pre_connect(self, settings: object) -> bool:
"""Called before connecting. Return True to proceed, False to retry."""
return True
@@ -124,6 +127,17 @@ class BaseMqttPublisher(ABC):
"""Called each time the loop finds the publisher not configured."""
return # no-op by default; subclasses may override
async def _on_connected_async(self, settings: object) -> None:
"""Async hook called after connection succeeds (before health broadcast).
Subclasses can override to publish messages immediately after connecting.
"""
return # no-op by default
async def _on_periodic_wake(self, elapsed: float) -> None:
"""Called every ~60s while connected. Subclasses may override."""
return
# ── Connection loop ────────────────────────────────────────────────
async def _connection_loop(self) -> None:
@@ -170,6 +184,7 @@ class BaseMqttPublisher(ABC):
title, detail = self._on_connected(settings)
broadcast_success(title, detail)
await self._on_connected_async(settings)
_broadcast_health()
# Wait until cancelled or settings version changes.
@@ -181,6 +196,7 @@ class BaseMqttPublisher(ABC):
await asyncio.wait_for(self._version_event.wait(), timeout=60)
except asyncio.TimeoutError:
elapsed = time.monotonic() - connect_time
await self._on_periodic_wake(elapsed)
if self._should_break_wait(elapsed):
break
continue

View File

@@ -0,0 +1,89 @@
"""Fanout module wrapping the community MQTT publisher."""
from __future__ import annotations
import logging
import re
from types import SimpleNamespace
from typing import Any
from app.fanout.base import FanoutModule
from app.fanout.community_mqtt import CommunityMqttPublisher, _format_raw_packet
logger = logging.getLogger(__name__)
_IATA_RE = re.compile(r"^[A-Z]{3}$")
def _config_to_settings(config: dict) -> SimpleNamespace:
"""Map a fanout config blob to a settings namespace for the CommunityMqttPublisher."""
return SimpleNamespace(
community_mqtt_enabled=True,
community_mqtt_broker_host=config.get("broker_host", "mqtt-us-v1.letsmesh.net"),
community_mqtt_broker_port=config.get("broker_port", 443),
community_mqtt_iata=config.get("iata", ""),
community_mqtt_email=config.get("email", ""),
)
class MqttCommunityModule(FanoutModule):
"""Wraps a CommunityMqttPublisher for community packet sharing."""
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = CommunityMqttPublisher()
async def start(self) -> None:
settings = _config_to_settings(self.config)
await self._publisher.start(settings)
async def stop(self) -> None:
await self._publisher.stop()
async def on_message(self, data: dict) -> None:
# Community MQTT only publishes raw packets, not decoded messages.
pass
async def on_raw(self, data: dict) -> None:
if not self._publisher.connected or self._publisher._settings is None:
return
await _publish_community_packet(self._publisher, self.config, data)
@property
def status(self) -> str:
if self._publisher._is_configured():
return "connected" if self._publisher.connected else "disconnected"
return "disconnected"
async def _publish_community_packet(
publisher: CommunityMqttPublisher,
config: dict,
data: dict[str, Any],
) -> None:
"""Format and publish a raw packet to the community broker."""
try:
from app.keystore import get_public_key
from app.radio import radio_manager
public_key = get_public_key()
if public_key is None:
return
pubkey_hex = public_key.hex().upper()
device_name = ""
if radio_manager.meshcore and radio_manager.meshcore.self_info:
device_name = radio_manager.meshcore.self_info.get("name", "")
packet = _format_raw_packet(data, device_name, pubkey_hex)
iata = config.get("iata", "").upper().strip()
if not _IATA_RE.fullmatch(iata):
logger.debug("Community MQTT: skipping publish — no valid IATA code configured")
return
topic = f"meshcore/{iata}/{pubkey_hex}/packets"
await publisher.publish(topic, packet)
except Exception as e:
logger.warning("Community MQTT broadcast error: %s", e)

View File

@@ -0,0 +1,61 @@
"""Fanout module wrapping the private MQTT publisher."""
from __future__ import annotations
import logging
from types import SimpleNamespace
from app.fanout.base import FanoutModule
from app.fanout.mqtt import MqttPublisher, _build_message_topic, _build_raw_packet_topic
logger = logging.getLogger(__name__)
def _config_to_settings(config: dict) -> SimpleNamespace:
"""Map a fanout config blob to a settings namespace for the MqttPublisher."""
return SimpleNamespace(
mqtt_broker_host=config.get("broker_host", ""),
mqtt_broker_port=config.get("broker_port", 1883),
mqtt_username=config.get("username", ""),
mqtt_password=config.get("password", ""),
mqtt_use_tls=config.get("use_tls", False),
mqtt_tls_insecure=config.get("tls_insecure", False),
mqtt_topic_prefix=config.get("topic_prefix", "meshcore"),
mqtt_publish_messages=True,
mqtt_publish_raw_packets=True,
)
class MqttPrivateModule(FanoutModule):
"""Wraps an MqttPublisher instance for private MQTT forwarding."""
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = MqttPublisher()
async def start(self) -> None:
settings = _config_to_settings(self.config)
await self._publisher.start(settings)
async def stop(self) -> None:
await self._publisher.stop()
async def on_message(self, data: dict) -> None:
if not self._publisher.connected or self._publisher._settings is None:
return
prefix = self.config.get("topic_prefix", "meshcore")
topic = _build_message_topic(prefix, data)
await self._publisher.publish(topic, data)
async def on_raw(self, data: dict) -> None:
if not self._publisher.connected or self._publisher._settings is None:
return
prefix = self.config.get("topic_prefix", "meshcore")
topic = _build_raw_packet_topic(prefix, data)
await self._publisher.publish(topic, data)
@property
def status(self) -> str:
if not self.config.get("broker_host"):
return "disconnected"
return "connected" if self._publisher.connected else "disconnected"

84
app/fanout/webhook.py Normal file
View File

@@ -0,0 +1,84 @@
"""Fanout module for webhook (HTTP POST) delivery."""
from __future__ import annotations
import hashlib
import hmac
import json
import logging
import httpx
from app.fanout.base import FanoutModule
logger = logging.getLogger(__name__)
class WebhookModule(FanoutModule):
"""Delivers message data to an HTTP endpoint via POST (or configurable method)."""
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._client: httpx.AsyncClient | None = None
self._last_error: str | None = None
async def start(self) -> None:
self._client = httpx.AsyncClient(timeout=httpx.Timeout(10.0))
self._last_error = None
async def stop(self) -> None:
if self._client:
await self._client.aclose()
self._client = None
async def on_message(self, data: dict) -> None:
await self._send(data, event_type="message")
async def _send(self, data: dict, *, event_type: str) -> None:
if not self._client:
return
url = self.config.get("url", "")
if not url:
return
method = self.config.get("method", "POST").upper()
extra_headers = self.config.get("headers", {})
hmac_secret = self.config.get("hmac_secret", "")
hmac_header = self.config.get("hmac_header", "X-Webhook-Signature")
headers = {
"Content-Type": "application/json",
"X-Webhook-Event": event_type,
**extra_headers,
}
body_bytes = json.dumps(data, separators=(",", ":"), sort_keys=True).encode()
if hmac_secret:
sig = hmac.new(hmac_secret.encode(), body_bytes, hashlib.sha256).hexdigest()
headers[hmac_header or "X-Webhook-Signature"] = f"sha256={sig}"
try:
resp = await self._client.request(method, url, content=body_bytes, headers=headers)
resp.raise_for_status()
self._last_error = None
except httpx.HTTPStatusError as exc:
self._last_error = f"HTTP {exc.response.status_code}"
logger.warning(
"Webhook %s returned %s for %s",
self.config_id,
exc.response.status_code,
url,
)
except httpx.RequestError as exc:
self._last_error = str(exc)
logger.warning("Webhook %s request error: %s", self.config_id, exc)
@property
def status(self) -> str:
if not self.config.get("url"):
return "disconnected"
if self._last_error:
return "error"
return "connected"

View File

@@ -18,6 +18,7 @@ from app.radio_sync import (
from app.routers import (
channels,
contacts,
fanout,
health,
messages,
packets,
@@ -56,23 +57,18 @@ async def lifespan(app: FastAPI):
# Always start connection monitor (even if initial connection failed)
await radio_manager.start_connection_monitor()
# Start MQTT publishers if configured
from app.community_mqtt import community_publisher
from app.mqtt import mqtt_publisher
from app.repository import AppSettingsRepository
# Start fanout modules (MQTT, etc.) from database configs
from app.fanout.manager import fanout_manager
try:
mqtt_settings = await AppSettingsRepository.get()
await mqtt_publisher.start(mqtt_settings)
await community_publisher.start(mqtt_settings)
await fanout_manager.load_from_db()
except Exception as e:
logger.warning("Failed to start MQTT publisher(s): %s", e)
logger.warning("Failed to start fanout modules: %s", e)
yield
logger.info("Shutting down")
await community_publisher.stop()
await mqtt_publisher.stop()
await fanout_manager.stop_all()
await radio_manager.stop_connection_monitor()
await stop_message_polling()
await stop_periodic_advert()
@@ -119,6 +115,7 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
# API routes - all prefixed with /api for production compatibility
app.include_router(health.router, prefix="/api")
app.include_router(fanout.router, prefix="/api")
app.include_router(radio.router, prefix="/api")
app.include_router(contacts.router, prefix="/api")
app.include_router(repeaters.router, prefix="/api")

View File

@@ -13,6 +13,9 @@ from hashlib import sha256
import aiosqlite
from app.decoder import extract_payload, parse_packet
from app.models import Contact
logger = logging.getLogger(__name__)
@@ -261,6 +264,55 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 32)
applied += 1
# Migration 33: Seed #remoteterm channel on initial install
if version < 33:
logger.info("Applying migration 33: seed #remoteterm channel")
await _migrate_033_seed_remoteterm_channel(conn)
await set_version(conn, 33)
applied += 1
# Migration 34: Add flood_scope column to app_settings
if version < 34:
logger.info("Applying migration 34: add flood_scope column to app_settings")
await _migrate_034_add_flood_scope(conn)
await set_version(conn, 34)
applied += 1
# Migration 35: Add blocked_keys and blocked_names columns to app_settings
if version < 35:
logger.info("Applying migration 35: add blocked_keys and blocked_names to app_settings")
await _migrate_035_add_block_lists(conn)
await set_version(conn, 35)
applied += 1
# Migration 36: Create fanout_configs table and migrate existing MQTT settings
if version < 36:
logger.info("Applying migration 36: create fanout_configs and migrate MQTT settings")
await _migrate_036_create_fanout_configs(conn)
await set_version(conn, 36)
applied += 1
# Migration 37: Migrate bots from app_settings to fanout_configs
if version < 37:
logger.info("Applying migration 37: migrate bots to fanout_configs")
await _migrate_037_bots_to_fanout(conn)
await set_version(conn, 37)
applied += 1
# Migration 38: Drop legacy MQTT, community MQTT, and bots columns from app_settings
if version < 38:
logger.info("Applying migration 38: drop legacy MQTT/bot columns from app_settings")
await _migrate_038_drop_legacy_columns(conn)
await set_version(conn, 38)
applied += 1
# Migration 39: Persist exact contact out_path_hash_mode from radio sync
if version < 39:
logger.info("Applying migration 39: add contacts.out_path_hash_mode")
await _migrate_039_add_contact_out_path_hash_mode(conn)
await set_version(conn, 39)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -400,35 +452,7 @@ def _extract_payload_for_hash(raw_packet: bytes) -> bytes | None:
Returns the payload bytes, or None if packet is malformed.
"""
if len(raw_packet) < 2:
return None
try:
header = raw_packet[0]
route_type = header & 0x03
offset = 1
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path length
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
offset += 1
# Skip path bytes
if len(raw_packet) < offset + path_length:
return None
offset += path_length
# Rest is payload (may be empty, matching decoder.py behavior)
return raw_packet[offset:]
except (IndexError, ValueError):
return None
return extract_payload(raw_packet)
async def _migrate_005_backfill_payload_hashes(conn: aiosqlite.Connection) -> None:
@@ -582,34 +606,10 @@ def _extract_path_from_packet(raw_packet: bytes) -> str | None:
Returns the path as a hex string, or None if packet is malformed.
"""
if len(raw_packet) < 2:
return None
try:
header = raw_packet[0]
route_type = header & 0x03
offset = 1
# Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if route_type in (0x00, 0x03):
if len(raw_packet) < offset + 4:
return None
offset += 4
# Get path length
if len(raw_packet) < offset + 1:
return None
path_length = raw_packet[offset]
offset += 1
# Extract path bytes
if len(raw_packet) < offset + path_length:
return None
path_bytes = raw_packet[offset : offset + path_length]
return path_bytes.hex()
except (IndexError, ValueError):
packet_info = parse_packet(raw_packet)
if packet_info is None:
return None
return packet_info.path.hex()
async def _migrate_007_backfill_message_paths(conn: aiosqlite.Connection) -> None:
@@ -1926,3 +1926,356 @@ async def _migrate_032_add_community_mqtt_columns(conn: aiosqlite.Connection) ->
await conn.execute(f"ALTER TABLE app_settings ADD COLUMN {col_name} {col_def}")
await conn.commit()
async def _migrate_033_seed_remoteterm_channel(conn: aiosqlite.Connection) -> None:
"""Seed the #remoteterm hashtag channel so new installs have it by default.
Uses INSERT OR IGNORE so it's a no-op if the channel already exists
(e.g. existing users who already added it manually). The channels table
is created by the base schema before migrations run, so it always exists
in production.
"""
try:
await conn.execute(
"INSERT OR IGNORE INTO channels (key, name, is_hashtag, on_radio) VALUES (?, ?, ?, ?)",
("8959AE053F2201801342A1DBDDA184F6", "#remoteterm", 1, 0),
)
await conn.commit()
except Exception:
logger.debug("Skipping #remoteterm seed (channels table not ready)")
async def _migrate_034_add_flood_scope(conn: aiosqlite.Connection) -> None:
"""Add flood_scope column to app_settings for outbound region tagging.
Empty string means disabled (no scope set, messages sent unscoped).
"""
try:
await conn.execute("ALTER TABLE app_settings ADD COLUMN flood_scope TEXT DEFAULT ''")
await conn.commit()
except Exception as e:
error_msg = str(e).lower()
if "duplicate column" in error_msg:
logger.debug("flood_scope column already exists, skipping")
elif "no such table" in error_msg:
logger.debug("app_settings table not ready, skipping flood_scope migration")
else:
raise
async def _migrate_035_add_block_lists(conn: aiosqlite.Connection) -> None:
"""Add blocked_keys and blocked_names columns to app_settings.
These store JSON arrays of blocked public keys and display names.
Blocking hides messages from the UI but does not affect MQTT or bots.
"""
try:
await conn.execute("ALTER TABLE app_settings ADD COLUMN blocked_keys TEXT DEFAULT '[]'")
except Exception as e:
error_msg = str(e).lower()
if "duplicate column" in error_msg:
logger.debug("blocked_keys column already exists, skipping")
elif "no such table" in error_msg:
logger.debug("app_settings table not ready, skipping blocked_keys migration")
else:
raise
try:
await conn.execute("ALTER TABLE app_settings ADD COLUMN blocked_names TEXT DEFAULT '[]'")
except Exception as e:
error_msg = str(e).lower()
if "duplicate column" in error_msg:
logger.debug("blocked_names column already exists, skipping")
elif "no such table" in error_msg:
logger.debug("app_settings table not ready, skipping blocked_names migration")
else:
raise
await conn.commit()
async def _migrate_036_create_fanout_configs(conn: aiosqlite.Connection) -> None:
"""Create fanout_configs table and migrate existing MQTT settings.
Reads existing MQTT settings from app_settings and creates corresponding
fanout_configs rows. Old columns are NOT dropped (rollback safety).
"""
import json
import uuid
# 1. Create fanout_configs table
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS fanout_configs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 0,
config TEXT NOT NULL DEFAULT '{}',
scope TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL
)
"""
)
# 2. Read existing MQTT settings
try:
cursor = await conn.execute(
"""
SELECT mqtt_broker_host, mqtt_broker_port, mqtt_username, mqtt_password,
mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix,
mqtt_publish_messages, mqtt_publish_raw_packets,
community_mqtt_enabled, community_mqtt_iata,
community_mqtt_broker_host, community_mqtt_broker_port,
community_mqtt_email
FROM app_settings WHERE id = 1
"""
)
row = await cursor.fetchone()
except Exception:
row = None
if row is None:
await conn.commit()
return
import time
now = int(time.time())
sort_order = 0
# 3. Migrate private MQTT if configured
broker_host = row["mqtt_broker_host"] or ""
if broker_host:
publish_messages = bool(row["mqtt_publish_messages"])
publish_raw = bool(row["mqtt_publish_raw_packets"])
enabled = publish_messages or publish_raw
config = {
"broker_host": broker_host,
"broker_port": row["mqtt_broker_port"] or 1883,
"username": row["mqtt_username"] or "",
"password": row["mqtt_password"] or "",
"use_tls": bool(row["mqtt_use_tls"]),
"tls_insecure": bool(row["mqtt_tls_insecure"]),
"topic_prefix": row["mqtt_topic_prefix"] or "meshcore",
}
scope = {
"messages": "all" if publish_messages else "none",
"raw_packets": "all" if publish_raw else "none",
}
await conn.execute(
"""
INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
"mqtt_private",
"Private MQTT",
1 if enabled else 0,
json.dumps(config),
json.dumps(scope),
sort_order,
now,
),
)
sort_order += 1
logger.info("Migrated private MQTT settings to fanout_configs (enabled=%s)", enabled)
# 4. Migrate community MQTT if enabled OR configured (preserve disabled-but-configured)
community_enabled = bool(row["community_mqtt_enabled"])
community_iata = row["community_mqtt_iata"] or ""
community_host = row["community_mqtt_broker_host"] or ""
community_email = row["community_mqtt_email"] or ""
community_has_config = bool(
community_iata
or community_email
or (community_host and community_host != "mqtt-us-v1.letsmesh.net")
)
if community_enabled or community_has_config:
config = {
"broker_host": community_host or "mqtt-us-v1.letsmesh.net",
"broker_port": row["community_mqtt_broker_port"] or 443,
"iata": community_iata,
"email": community_email,
}
scope = {
"messages": "none",
"raw_packets": "all",
}
await conn.execute(
"""
INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
"mqtt_community",
"Community MQTT",
1 if community_enabled else 0,
json.dumps(config),
json.dumps(scope),
sort_order,
now,
),
)
logger.info(
"Migrated community MQTT settings to fanout_configs (enabled=%s)", community_enabled
)
await conn.commit()
async def _migrate_037_bots_to_fanout(conn: aiosqlite.Connection) -> None:
"""Migrate bots from app_settings.bots JSON to fanout_configs rows."""
import json
import uuid
try:
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
row = await cursor.fetchone()
except Exception:
row = None
if row is None:
await conn.commit()
return
bots_json = row["bots"] or "[]"
try:
bots = json.loads(bots_json)
except (json.JSONDecodeError, TypeError):
bots = []
if not bots:
await conn.commit()
return
import time
now = int(time.time())
# Use sort_order starting at 200 to place bots after MQTT configs (0-99)
for i, bot in enumerate(bots):
bot_name = bot.get("name") or f"Bot {i + 1}"
bot_enabled = bool(bot.get("enabled", False))
bot_code = bot.get("code", "")
config_blob = json.dumps({"code": bot_code})
scope = json.dumps({"messages": "all", "raw_packets": "none"})
await conn.execute(
"""
INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
VALUES (?, 'bot', ?, ?, ?, ?, ?, ?)
""",
(
str(uuid.uuid4()),
bot_name,
1 if bot_enabled else 0,
config_blob,
scope,
200 + i,
now,
),
)
logger.info("Migrated bot '%s' to fanout_configs (enabled=%s)", bot_name, bot_enabled)
await conn.commit()
async def _migrate_038_drop_legacy_columns(conn: aiosqlite.Connection) -> None:
"""Drop legacy MQTT, community MQTT, and bots columns from app_settings.
These columns were migrated to fanout_configs in migrations 36 and 37.
SQLite 3.35.0+ supports ALTER TABLE DROP COLUMN. For older versions,
the columns remain but are harmless (no longer read or written).
"""
# Check if app_settings table exists (some test DBs may not have it)
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='app_settings'"
)
if await cursor.fetchone() is None:
await conn.commit()
return
columns_to_drop = [
"bots",
"mqtt_broker_host",
"mqtt_broker_port",
"mqtt_username",
"mqtt_password",
"mqtt_use_tls",
"mqtt_tls_insecure",
"mqtt_topic_prefix",
"mqtt_publish_messages",
"mqtt_publish_raw_packets",
"community_mqtt_enabled",
"community_mqtt_iata",
"community_mqtt_broker_host",
"community_mqtt_broker_port",
"community_mqtt_email",
]
for column in columns_to_drop:
try:
await conn.execute(f"ALTER TABLE app_settings DROP COLUMN {column}")
logger.debug("Dropped %s from app_settings", column)
except aiosqlite.OperationalError as e:
error_msg = str(e).lower()
if "no such column" in error_msg:
logger.debug("app_settings.%s already dropped, skipping", column)
elif "syntax error" in error_msg or "drop column" in error_msg:
logger.debug("SQLite doesn't support DROP COLUMN, %s column will remain", column)
else:
raise
await conn.commit()
async def _migrate_039_add_contact_out_path_hash_mode(conn: aiosqlite.Connection) -> None:
"""Add contacts.out_path_hash_mode and backfill best-effort values for existing rows."""
tables = await conn.execute(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'contacts'"
)
if await tables.fetchone() is None:
await conn.commit()
return
columns_cursor = await conn.execute("PRAGMA table_info(contacts)")
columns = {row["name"] for row in await columns_cursor.fetchall()}
if "out_path_hash_mode" not in columns:
await conn.execute("ALTER TABLE contacts ADD COLUMN out_path_hash_mode INTEGER")
columns.add("out_path_hash_mode")
if not {"last_path", "last_path_len"}.issubset(columns):
await conn.commit()
return
rows_cursor = await conn.execute(
"""
SELECT public_key, last_path, last_path_len, out_path_hash_mode
FROM contacts
"""
)
rows = await rows_cursor.fetchall()
for row in rows:
if row["out_path_hash_mode"] is not None:
continue
if row["last_path_len"] is None or row["last_path_len"] < 0:
continue
derived = Contact._derive_out_path_hash_mode(row["last_path"], row["last_path_len"])
await conn.execute(
"UPDATE contacts SET out_path_hash_mode = ? WHERE public_key = ?",
(derived, row["public_key"]),
)
await conn.commit()

View File

@@ -6,10 +6,11 @@ from pydantic import BaseModel, Field
class Contact(BaseModel):
public_key: str = Field(description="Public key (64-char hex)")
name: str | None = None
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room
type: int = 0 # 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
out_path_hash_mode: int | None = None
last_advert: int | None = None
lat: float | None = None
lon: float | None = None
@@ -19,12 +20,36 @@ class Contact(BaseModel):
last_read_at: int | None = None # Server-side read state tracking
first_seen: int | None = None
@staticmethod
def _derive_out_path_hash_mode(path_hex: str | None, path_len: int) -> int:
"""Infer the contact path hash mode from stored path bytes and hop count."""
if path_len < 0:
return -1
if path_len == 0 or not path_hex:
return 0
if len(path_hex) % 2 != 0:
return 0
path_bytes = len(path_hex) // 2
if path_bytes == 0 or path_bytes % path_len != 0:
return 0
bytes_per_hop = path_bytes // path_len
if bytes_per_hop < 1:
return 0
return bytes_per_hop - 1
def to_radio_dict(self) -> dict:
"""Convert to the dict format expected by meshcore radio commands.
The radio API uses different field names (adv_name, out_path, etc.)
than our database schema (name, last_path, etc.).
"""
out_path_hash_mode = self.out_path_hash_mode
if out_path_hash_mode is None:
out_path_hash_mode = self._derive_out_path_hash_mode(self.last_path, self.last_path_len)
return {
"public_key": self.public_key,
"adv_name": self.name or "",
@@ -32,6 +57,7 @@ class Contact(BaseModel):
"flags": self.flags,
"out_path": self.last_path or "",
"out_path_len": self.last_path_len,
"out_path_hash_mode": out_path_hash_mode,
"adv_lat": self.lat if self.lat is not None else 0.0,
"adv_lon": self.lon if self.lon is not None else 0.0,
"last_advert": self.last_advert if self.last_advert is not None else 0,
@@ -51,6 +77,7 @@ class Contact(BaseModel):
"flags": radio_data.get("flags", 0),
"last_path": radio_data.get("out_path"),
"last_path_len": radio_data.get("out_path_len", -1),
"out_path_hash_mode": radio_data.get("out_path_hash_mode"),
"lat": radio_data.get("adv_lat"),
"lon": radio_data.get("adv_lon"),
"last_advert": radio_data.get("last_advert"),
@@ -79,7 +106,7 @@ class ContactAdvertPath(BaseModel):
path: str = Field(description="Hex-encoded routing path (empty string for direct)")
path_len: int = Field(description="Number of hops in the path")
next_hop: str | None = Field(
default=None, description="First hop toward us (2-char hex), or null for direct"
default=None, description="First hop toward us, or null for direct"
)
first_seen: int = Field(description="Unix timestamp of first observation")
last_seen: int = Field(description="Unix timestamp of most recent observation")
@@ -145,11 +172,40 @@ class Channel(BaseModel):
last_read_at: int | None = None # Server-side read state tracking
class ChannelMessageCounts(BaseModel):
"""Time-windowed message counts for a channel."""
last_1h: int = 0
last_24h: int = 0
last_48h: int = 0
last_7d: int = 0
all_time: int = 0
class ChannelTopSender(BaseModel):
"""A top sender in a channel over the last 24 hours."""
sender_name: str
sender_key: str | None = None
message_count: int
class ChannelDetail(BaseModel):
"""Comprehensive channel profile data."""
channel: Channel
message_counts: ChannelMessageCounts = Field(default_factory=ChannelMessageCounts)
first_message_at: int | None = None
unique_sender_count: int = 0
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
class MessagePath(BaseModel):
"""A single path that a message took to reach us."""
path: str = Field(description="Hex-encoded routing path (2 chars per hop)")
path: str = Field(description="Hex-encoded routing path")
received_at: int = Field(description="Unix timestamp when this path was received")
path_len: int | None = Field(default=None, description="Number of hops in the path, when known")
class Message(BaseModel):
@@ -164,8 +220,17 @@ class Message(BaseModel):
)
txt_type: int = 0
signature: str | None = None
sender_key: str | None = None
outgoing: bool = False
acked: int = 0
sender_name: str | None = None
channel_name: str | None = None
class MessagesAroundResponse(BaseModel):
messages: list[Message]
has_older: bool
has_newer: bool
class RawPacketDecryptedInfo(BaseModel):
@@ -363,15 +428,6 @@ class Favorite(BaseModel):
id: str = Field(description="Channel key or contact public key")
class BotConfig(BaseModel):
"""Configuration for a single bot."""
id: str = Field(description="UUID for stable identity across renames/reorders")
name: str = Field(description="User-editable name")
enabled: bool = Field(default=False, description="Whether this bot is enabled")
code: str = Field(default="", description="Python code for this bot")
class UnreadCounts(BaseModel):
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
@@ -423,68 +479,33 @@ class AppSettings(BaseModel):
default=0,
description="Unix timestamp of last advertisement sent (0 = never)",
)
bots: list[BotConfig] = Field(
flood_scope: str = Field(
default="",
description="Outbound flood scope / region name (empty = disabled, no tagging)",
)
blocked_keys: list[str] = Field(
default_factory=list,
description="List of bot configurations",
description="Public keys whose messages are hidden from the UI",
)
mqtt_broker_host: str = Field(
default="",
description="MQTT broker hostname (empty = disabled)",
)
mqtt_broker_port: int = Field(
default=1883,
description="MQTT broker port",
)
mqtt_username: str = Field(
default="",
description="MQTT username (optional)",
)
mqtt_password: str = Field(
default="",
description="MQTT password (optional)",
)
mqtt_use_tls: bool = Field(
default=False,
description="Whether to use TLS for MQTT connection",
)
mqtt_tls_insecure: bool = Field(
default=False,
description="Skip TLS certificate verification (for self-signed certs)",
)
mqtt_topic_prefix: str = Field(
default="meshcore",
description="MQTT topic prefix",
)
mqtt_publish_messages: bool = Field(
default=False,
description="Whether to publish decrypted messages to MQTT",
)
mqtt_publish_raw_packets: bool = Field(
default=False,
description="Whether to publish raw packets to MQTT",
)
community_mqtt_enabled: bool = Field(
default=False,
description="Whether to publish raw packets to the community MQTT broker (letsmesh.net)",
)
community_mqtt_iata: str = Field(
default="",
description="IATA region code for community MQTT topic routing (3 alpha chars)",
)
community_mqtt_broker_host: str = Field(
default="mqtt-us-v1.letsmesh.net",
description="Community MQTT broker hostname",
)
community_mqtt_broker_port: int = Field(
default=443,
description="Community MQTT broker port",
)
community_mqtt_email: str = Field(
default="",
description="Email address for node claiming on the community aggregator (optional)",
blocked_names: list[str] = Field(
default_factory=list,
description="Display names whose messages are hidden from the UI",
)
class FanoutConfig(BaseModel):
"""Configuration for a single fanout integration."""
id: str
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise'
name: str
enabled: bool
config: dict
scope: dict
sort_order: int = 0
created_at: int = 0
class BusyChannel(BaseModel):
channel_key: str
channel_name: str

View File

@@ -1,111 +0,0 @@
"""MQTT publisher for forwarding mesh network events to an MQTT broker."""
from __future__ import annotations
import asyncio
import logging
import ssl
from typing import Any
from app.models import AppSettings
from app.mqtt_base import BaseMqttPublisher
logger = logging.getLogger(__name__)
class MqttPublisher(BaseMqttPublisher):
"""Manages an MQTT connection and publishes mesh network events."""
_backoff_max = 30
_log_prefix = "MQTT"
def _is_configured(self) -> bool:
"""Check if MQTT is configured and has something to publish."""
return bool(
self._settings
and self._settings.mqtt_broker_host
and (self._settings.mqtt_publish_messages or self._settings.mqtt_publish_raw_packets)
)
def _build_client_kwargs(self, settings: AppSettings) -> dict[str, Any]:
return {
"hostname": settings.mqtt_broker_host,
"port": settings.mqtt_broker_port,
"username": settings.mqtt_username or None,
"password": settings.mqtt_password or None,
"tls_context": self._build_tls_context(settings),
}
def _on_connected(self, settings: AppSettings) -> tuple[str, str]:
return ("MQTT connected", f"{settings.mqtt_broker_host}:{settings.mqtt_broker_port}")
def _on_error(self) -> tuple[str, str]:
return ("MQTT connection failure", "Please correct the settings or disable.")
@staticmethod
def _build_tls_context(settings: AppSettings) -> ssl.SSLContext | None:
"""Build TLS context from settings, or None if TLS is disabled."""
if not settings.mqtt_use_tls:
return None
ctx = ssl.create_default_context()
if settings.mqtt_tls_insecure:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx
# Module-level singleton
mqtt_publisher = MqttPublisher()
def mqtt_broadcast(event_type: str, data: dict[str, Any]) -> None:
"""Fire-and-forget MQTT publish, matching broadcast_event's pattern."""
if event_type not in ("message", "raw_packet"):
return
if not mqtt_publisher.connected or mqtt_publisher._settings is None:
return
asyncio.create_task(_mqtt_maybe_publish(event_type, data))
async def _mqtt_maybe_publish(event_type: str, data: dict[str, Any]) -> None:
"""Check settings and build topic, then publish."""
settings = mqtt_publisher._settings
if settings is None:
return
try:
if event_type == "message" and settings.mqtt_publish_messages:
topic = _build_message_topic(settings.mqtt_topic_prefix, data)
await mqtt_publisher.publish(topic, data)
elif event_type == "raw_packet" and settings.mqtt_publish_raw_packets:
topic = _build_raw_packet_topic(settings.mqtt_topic_prefix, data)
await mqtt_publisher.publish(topic, data)
except Exception as e:
logger.warning("MQTT broadcast error: %s", e)
def _build_message_topic(prefix: str, data: dict[str, Any]) -> str:
"""Build MQTT topic for a decrypted message."""
msg_type = data.get("type", "")
conversation_key = data.get("conversation_key", "unknown")
if msg_type == "PRIV":
return f"{prefix}/dm:{conversation_key}"
elif msg_type == "CHAN":
return f"{prefix}/gm:{conversation_key}"
return f"{prefix}/message:{conversation_key}"
def _build_raw_packet_topic(prefix: str, data: dict[str, Any]) -> str:
"""Build MQTT topic for a raw packet."""
info = data.get("decrypted_info")
if info and isinstance(info, dict):
contact_key = info.get("contact_key")
channel_key = info.get("channel_key")
if contact_key:
return f"{prefix}/raw/dm:{contact_key}"
if channel_key:
return f"{prefix}/raw/gm:{channel_key}"
return f"{prefix}/raw/unrouted"

View File

@@ -57,6 +57,7 @@ async def _handle_duplicate_message(
text: str,
sender_timestamp: int,
path: str | None,
path_len: int | None,
received: int,
) -> None:
"""Handle a duplicate message by updating paths/acks on the existing record.
@@ -90,7 +91,7 @@ async def _handle_duplicate_message(
# Add path if provided
if path is not None:
paths = await MessageRepository.add_path(existing_msg.id, path, received)
paths = await MessageRepository.add_path(existing_msg.id, path, received, path_len=path_len)
else:
# Get current paths for broadcast
paths = existing_msg.paths or []
@@ -128,8 +129,9 @@ async def create_message_from_decrypted(
timestamp: int,
received_at: int | None = None,
path: str | None = None,
path_len: int | None = None,
channel_name: str | None = None,
trigger_bot: bool = True,
realtime: bool = True,
) -> int | None:
"""Create a message record from decrypted channel packet content.
@@ -145,11 +147,14 @@ async def create_message_from_decrypted(
timestamp: Sender timestamp from the packet
received_at: When the packet was received (defaults to now)
path: Hex-encoded routing path
trigger_bot: Whether to trigger bot response (False for historical decryption)
realtime: If False, skip fanout dispatch (used for historical decryption)
Returns the message ID if created, None if duplicate.
"""
received = received_at or int(time.time())
normalized_path_len = (
path_len if isinstance(path_len, int) else (len(path) // 2 if path is not None else None)
)
# Format the message text with sender prefix if present
text = f"{sender}: {message_text}" if sender else message_text
@@ -172,6 +177,7 @@ async def create_message_from_decrypted(
sender_timestamp=timestamp,
received_at=received,
path=path,
path_len=normalized_path_len,
sender_name=sender,
sender_key=resolved_sender_key,
)
@@ -182,7 +188,14 @@ async def create_message_from_decrypted(
# 2. Same message arrives via multiple paths before first is committed
# In either case, add the path to the existing message.
await _handle_duplicate_message(
packet_id, "CHAN", channel_key_normalized, text, timestamp, path, received
packet_id,
"CHAN",
channel_key_normalized,
text,
timestamp,
path,
normalized_path_len,
received,
)
return None
@@ -193,9 +206,13 @@ async def create_message_from_decrypted(
# Build paths array for broadcast
# Use "is not None" to include empty string (direct/0-hop messages)
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
paths = (
[MessagePath(path=path or "", received_at=received, path_len=normalized_path_len)]
if path is not None
else None
)
# Broadcast new message to connected clients
# Broadcast new message to connected clients (and fanout modules when realtime)
broadcast_event(
"message",
Message(
@@ -206,27 +223,13 @@ async def create_message_from_decrypted(
sender_timestamp=timestamp,
received_at=received,
paths=paths,
sender_name=sender,
sender_key=resolved_sender_key,
channel_name=channel_name,
).model_dump(),
realtime=realtime,
)
# Run bot if enabled (for incoming channel messages, not historical decryption)
if trigger_bot:
from app.bot import run_bot_for_message
asyncio.create_task(
run_bot_for_message(
sender_name=sender,
sender_key=None, # Channel messages don't have a sender public key
message_text=message_text,
is_dm=False,
channel_key=channel_key_normalized,
channel_name=channel_name,
sender_timestamp=timestamp,
path=path,
is_outgoing=False,
)
)
return msg_id
@@ -237,8 +240,9 @@ async def create_dm_message_from_decrypted(
our_public_key: str | None,
received_at: int | None = None,
path: str | None = None,
path_len: int | None = None,
outgoing: bool = False,
trigger_bot: bool = True,
realtime: bool = True,
) -> int | None:
"""Create a message record from decrypted direct message packet content.
@@ -253,7 +257,7 @@ async def create_dm_message_from_decrypted(
received_at: When the packet was received (defaults to now)
path: Hex-encoded routing path
outgoing: Whether this is an outgoing message (we sent it)
trigger_bot: Whether to trigger bot response (False for historical decryption)
realtime: If False, skip fanout dispatch (used for historical decryption)
Returns the message ID if created, None if duplicate.
"""
@@ -269,10 +273,16 @@ async def create_dm_message_from_decrypted(
return None
received = received_at or int(time.time())
normalized_path_len = (
path_len if isinstance(path_len, int) else (len(path) // 2 if path is not None else None)
)
# conversation_key is always the other party's public key
conversation_key = their_public_key.lower()
# Resolve sender name for incoming messages (used for name-based blocking)
sender_name = contact.name if contact and not outgoing else None
# Try to create message - INSERT OR IGNORE handles duplicates atomically
msg_id = await MessageRepository.create(
msg_type="PRIV",
@@ -281,8 +291,10 @@ async def create_dm_message_from_decrypted(
sender_timestamp=decrypted.timestamp,
received_at=received,
path=path,
path_len=normalized_path_len,
outgoing=outgoing,
sender_key=conversation_key if not outgoing else None,
sender_name=sender_name,
)
if msg_id is None:
@@ -294,6 +306,7 @@ async def create_dm_message_from_decrypted(
decrypted.message,
decrypted.timestamp,
path,
normalized_path_len,
received,
)
return None
@@ -309,9 +322,14 @@ async def create_dm_message_from_decrypted(
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
# Build paths array for broadcast
paths = [MessagePath(path=path or "", received_at=received)] if path is not None else None
paths = (
[MessagePath(path=path or "", received_at=received, path_len=normalized_path_len)]
if path is not None
else None
)
# Broadcast new message to connected clients
# Broadcast new message to connected clients (and fanout modules when realtime)
sender_name = contact.name if contact and not outgoing else None
broadcast_event(
"message",
Message(
@@ -323,34 +341,15 @@ async def create_dm_message_from_decrypted(
received_at=received,
paths=paths,
outgoing=outgoing,
sender_name=sender_name,
sender_key=conversation_key if not outgoing else None,
).model_dump(),
realtime=realtime,
)
# Update contact's last_contacted timestamp (for sorting)
await ContactRepository.update_last_contacted(conversation_key, received)
# Run bot if enabled (for all real-time DMs, including our own outgoing messages)
if trigger_bot:
from app.bot import run_bot_for_message
# Get contact name for the bot
contact = await ContactRepository.get_by_key(their_public_key)
sender_name = contact.name if contact else None
asyncio.create_task(
run_bot_for_message(
sender_name=sender_name,
sender_key=their_public_key,
message_text=decrypted.message,
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=decrypted.timestamp,
path=path,
is_outgoing=outgoing,
)
)
return msg_id
@@ -419,8 +418,9 @@ async def run_historical_dm_decryption(
our_public_key=our_public_key_bytes.hex(),
received_at=packet_timestamp,
path=path_hex,
path_len=packet_info.path_length if packet_info else None,
outgoing=outgoing,
trigger_bot=False, # Historical decryption should not trigger bot
realtime=False, # Historical decryption should not trigger fanout
)
if msg_id is not None:
@@ -518,6 +518,13 @@ async def process_raw_packet(
payload_type = packet_info.payload_type if packet_info else None
payload_type_name = payload_type.name if payload_type else "Unknown"
if packet_info is None and len(raw_bytes) > 2:
logger.warning(
"Failed to parse %d-byte packet (id=%d); stored undecrypted",
len(raw_bytes),
packet_id,
)
# Log packet arrival at debug level
path_hex = packet_info.path.hex() if packet_info and packet_info.path else ""
logger.debug(
@@ -627,6 +634,7 @@ async def _process_group_text(
timestamp=decrypted.timestamp,
received_at=timestamp,
path=packet_info.path.hex() if packet_info else None,
path_len=packet_info.path_length if packet_info else None,
)
return {
@@ -721,6 +729,7 @@ async def _process_advertisement(
path_hex=new_path_hex,
timestamp=timestamp,
max_paths=10,
path_len=new_path_len,
)
# Record name history
@@ -752,6 +761,16 @@ async def _process_advertisement(
claimed,
advert.public_key[:12],
)
if advert.name:
backfilled = await MessageRepository.backfill_channel_sender_key(
advert.public_key, advert.name
)
if backfilled > 0:
logger.info(
"Backfilled sender_key on %d channel message(s) for %s",
backfilled,
advert.name,
)
# Read back from DB so the broadcast includes all fields (last_contacted,
# last_read_at, flags, on_radio, etc.) matching the REST Contact shape exactly.
@@ -882,7 +901,8 @@ async def _process_direct_message(
their_public_key=contact.public_key,
our_public_key=our_public_key.hex(),
received_at=timestamp,
path=packet_info.path.hex() if packet_info.path else None,
path=packet_info.path.hex() if packet_info else None,
path_len=packet_info.path_length if packet_info else None,
outgoing=is_outgoing,
)

32
app/path_utils.py Normal file
View File

@@ -0,0 +1,32 @@
"""Helpers for working with hex-encoded routing paths."""
def get_path_hop_width(path_hex: str | None, path_len: int | None) -> int:
"""Return hop width in hex chars, falling back to legacy 1-byte hops."""
if not path_hex:
return 2
if isinstance(path_len, int) and path_len > 0 and len(path_hex) % path_len == 0:
hop_width = len(path_hex) // path_len
if hop_width > 0 and hop_width % 2 == 0:
return hop_width
return 2
def split_path_hops(path_hex: str | None, path_len: int | None) -> list[str]:
"""Split a hex path string into hop-sized chunks."""
if not path_hex:
return []
hop_width = get_path_hop_width(path_hex, path_len)
normalized = path_hex.lower()
return [
normalized[i : i + hop_width]
for i in range(0, len(normalized), hop_width)
if i + hop_width <= len(normalized)
]
def first_path_hop(path_hex: str | None, path_len: int | None) -> str | None:
"""Return the first hop from a hex path string, if any."""
hops = split_path_hops(path_hex, path_len)
return hops[0] if hops else None

View File

@@ -1,11 +1,12 @@
import asyncio
import glob
import inspect
import logging
import platform
from contextlib import asynccontextmanager, nullcontext
from pathlib import Path
from meshcore import MeshCore
from meshcore import EventType, MeshCore
from app.config import settings
@@ -128,6 +129,8 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False
self._setup_complete: bool = False
self._path_hash_mode: int = 0
self._path_hash_mode_supported: bool = False
async def _acquire_operation_lock(
self,
@@ -257,6 +260,21 @@ class RadioManager:
# Sync radio clock with system time
await sync_radio_time(mc)
await self.refresh_path_hash_mode_info(mc)
# Apply flood scope from settings (best-effort; older firmware
# may not support set_flood_scope)
from app.repository import AppSettingsRepository
app_settings = await AppSettingsRepository.get()
scope = app_settings.flood_scope
try:
await mc.commands.set_flood_scope(scope if scope else "")
logger.info("Applied flood_scope=%r", scope or "(disabled)")
except Exception as exc:
logger.warning(
"set_flood_scope failed (firmware may not support it): %s", exc
)
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
@@ -317,6 +335,48 @@ class RadioManager:
def is_setup_complete(self) -> bool:
return self._setup_complete
@property
def path_hash_mode_info(self) -> tuple[int, bool]:
return self._path_hash_mode, self._path_hash_mode_supported
def set_path_hash_mode_info(self, mode: int, supported: bool) -> None:
self._path_hash_mode = mode if supported and 0 <= mode <= 2 else 0
self._path_hash_mode_supported = supported
async def refresh_path_hash_mode_info(self, mc: MeshCore | None = None) -> tuple[int, bool]:
target = mc or self._meshcore
if target is None:
self.set_path_hash_mode_info(0, False)
return self.path_hash_mode_info
commands = getattr(target, "commands", None)
send_device_query = getattr(commands, "send_device_query", None)
if commands is None or not callable(send_device_query):
self.set_path_hash_mode_info(0, False)
return self.path_hash_mode_info
try:
result = send_device_query()
if inspect.isawaitable(result):
result = await result
except Exception as exc:
logger.debug("Failed to query device info for path hash mode: %s", exc)
self.set_path_hash_mode_info(0, False)
return self.path_hash_mode_info
if result is None or getattr(result, "type", None) == EventType.ERROR:
self.set_path_hash_mode_info(0, False)
return self.path_hash_mode_info
payload_obj = getattr(result, "payload", None)
payload = payload_obj if isinstance(payload_obj, dict) else {}
mode = payload.get("path_hash_mode")
if isinstance(mode, int) and 0 <= mode <= 2:
self.set_path_hash_mode_info(mode, True)
else:
self.set_path_hash_mode_info(0, False)
return self.path_hash_mode_info
async def connect(self) -> None:
"""Connect to the radio using the configured transport."""
if self._meshcore is not None:
@@ -355,6 +415,7 @@ class RadioManager:
self._connection_info = f"Serial: {port}"
self._last_connected = True
self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("Serial connection established")
async def _connect_tcp(self) -> None:
@@ -372,6 +433,7 @@ class RadioManager:
self._connection_info = f"TCP: {host}:{port}"
self._last_connected = True
self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("TCP connection established")
async def _connect_ble(self) -> None:
@@ -389,6 +451,7 @@ class RadioManager:
self._connection_info = f"BLE: {address}"
self._last_connected = True
self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("BLE connection established")
async def disconnect(self) -> None:
@@ -398,6 +461,7 @@ class RadioManager:
await self._meshcore.disconnect()
self._meshcore = None
self._setup_complete = False
self.set_path_hash_mode_info(0, False)
logger.debug("Radio disconnected")
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
@@ -456,6 +520,8 @@ class RadioManager:
from app.websocket import broadcast_health
CHECK_INTERVAL_SECONDS = 5
UNRESPONSIVE_THRESHOLD = 3
consecutive_setup_failures = 0
while True:
try:
@@ -469,6 +535,7 @@ class RadioManager:
logger.warning("Radio connection lost, broadcasting status change")
broadcast_health(False, self._connection_info)
self._last_connected = False
consecutive_setup_failures = 0
if not current_connected:
# Attempt reconnection on every loop while disconnected
@@ -478,6 +545,7 @@ class RadioManager:
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
self._last_connected = True
consecutive_setup_failures = 0
elif not self._last_connected and current_connected:
# Connection restored (might have reconnected automatically).
@@ -486,19 +554,34 @@ class RadioManager:
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
self._last_connected = True
consecutive_setup_failures = 0
elif current_connected and not self._setup_complete:
# Transport connected but setup incomplete — retry
logger.info("Retrying post-connect setup...")
await self.post_connect_setup()
broadcast_health(True, self._connection_info)
consecutive_setup_failures = 0
except asyncio.CancelledError:
# Task is being cancelled, exit cleanly
break
except Exception as e:
# Log error but continue monitoring - don't let the monitor die
logger.exception("Error in connection monitor, continuing: %s", e)
consecutive_setup_failures += 1
if consecutive_setup_failures == UNRESPONSIVE_THRESHOLD:
logger.error(
"Post-connect setup has failed %d times in a row. "
"The radio port appears open but the radio is not "
"responding to commands. Common causes: another "
"process has the serial port open (check for other "
"RemoteTerm instances, serial monitors, etc.), the "
"firmware is in repeater mode (not client), or the "
"radio needs a power cycle. Will keep retrying.",
consecutive_setup_failures,
)
elif consecutive_setup_failures < UNRESPONSIVE_THRESHOLD:
logger.exception("Error in connection monitor, continuing: %s", e)
# After the threshold, silently retry (avoid log spam)
self._reconnect_task = asyncio.create_task(monitor_loop())
logger.info("Radio connection monitor started")

View File

@@ -117,7 +117,16 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.error("Failed to get contacts from radio: %s", result)
logger.error(
"Failed to get contacts from radio: %s. "
"If you see this repeatedly, the radio may be visible on the "
"serial/TCP/BLE port but not responding to commands. Check for "
"another process with the serial port open (other RemoteTerm "
"instances, serial monitors, etc.), verify the firmware is "
"up-to-date and in client mode (not repeater), or try a "
"power cycle.",
result,
)
return {"synced": 0, "removed": 0, "error": str(result)}
contacts = result.payload or {}
@@ -136,6 +145,17 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
claimed,
public_key[:12],
)
adv_name = contact_data.get("adv_name")
if adv_name:
backfilled = await MessageRepository.backfill_channel_sender_key(
public_key, adv_name
)
if backfilled > 0:
logger.info(
"Backfilled sender_key on %d channel message(s) for %s",
backfilled,
adv_name,
)
synced += 1
# Remove from radio
@@ -305,7 +325,7 @@ async def drain_pending_messages(mc: MeshCore) -> int:
except asyncio.TimeoutError:
break
except Exception as e:
logger.debug("Error draining messages: %s", e)
logger.warning("Error draining messages: %s", e, exc_info=True)
break
return count
@@ -339,7 +359,7 @@ async def poll_for_messages(mc: MeshCore) -> int:
except asyncio.TimeoutError:
pass
except Exception as e:
logger.debug("Message poll exception: %s", e)
logger.warning("Message poll exception: %s", e, exc_info=True)
return count
@@ -361,14 +381,19 @@ async def _message_poll_loop():
blocking=False,
suspend_auto_fetch=True,
) as mc:
await poll_for_messages(mc)
count = await poll_for_messages(mc)
if count > 0:
logger.warning(
"Poll loop caught %d message(s) missed by auto-fetch",
count,
)
except RadioOperationBusyError:
logger.debug("Skipping message poll: radio busy")
except asyncio.CancelledError:
break
except Exception as e:
logger.debug("Error in message poll loop: %s", e)
logger.warning("Error in message poll loop: %s", e, exc_info=True)
def start_message_polling():
@@ -646,8 +671,19 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
else:
failed += 1
reason = result.payload
hint = ""
if reason is None:
hint = (
" (no response from radio — if this repeats, check for "
"serial port contention from another process or try a "
"power cycle)"
)
logger.warning(
"Failed to load contact %s: %s", contact.public_key[:12], result.payload
"Failed to load contact %s: %s%s",
contact.public_key[:12],
reason,
hint,
)
except Exception as e:
failed += 1

View File

@@ -5,6 +5,7 @@ from app.repository.contacts import (
ContactNameHistoryRepository,
ContactRepository,
)
from app.repository.fanout import FanoutConfigRepository
from app.repository.messages import MessageRepository
from app.repository.raw_packets import RawPacketRepository
from app.repository.settings import AppSettingsRepository, StatisticsRepository
@@ -16,6 +17,7 @@ __all__ = [
"ContactAdvertPathRepository",
"ContactNameHistoryRepository",
"ContactRepository",
"FanoutConfigRepository",
"MessageRepository",
"RawPacketRepository",
"StatisticsRepository",

View File

@@ -8,6 +8,7 @@ from app.models import (
ContactAdvertPathSummary,
ContactNameHistory,
)
from app.path_utils import first_path_hop
class AmbiguousPublicKeyPrefixError(ValueError):
@@ -25,15 +26,19 @@ class ContactRepository:
await db.conn.execute(
"""
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
out_path_hash_mode,
last_advert, lat, lon, last_seen, on_radio, last_contacted,
first_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(public_key) DO UPDATE SET
name = COALESCE(excluded.name, contacts.name),
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
flags = excluded.flags,
last_path = COALESCE(excluded.last_path, contacts.last_path),
last_path_len = excluded.last_path_len,
out_path_hash_mode = COALESCE(
excluded.out_path_hash_mode, contacts.out_path_hash_mode
),
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
lat = COALESCE(excluded.lat, contacts.lat),
lon = COALESCE(excluded.lon, contacts.lon),
@@ -49,6 +54,7 @@ class ContactRepository:
contact.get("flags", 0),
contact.get("last_path"),
contact.get("last_path_len", -1),
contact.get("out_path_hash_mode"),
contact.get("last_advert"),
contact.get("lat"),
contact.get("lon"),
@@ -70,6 +76,7 @@ class ContactRepository:
flags=row["flags"],
last_path=row["last_path"],
last_path_len=row["last_path_len"],
out_path_hash_mode=row["out_path_hash_mode"],
last_advert=row["last_advert"],
lat=row["lat"],
lon=row["lon"],
@@ -200,11 +207,23 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def update_path(public_key: str, path: str, path_len: int) -> None:
await db.conn.execute(
"UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
(path, path_len, int(time.time()), public_key.lower()),
)
async def update_path(
public_key: str, path: str, path_len: int, out_path_hash_mode: int | None = None
) -> None:
if out_path_hash_mode is None:
await db.conn.execute(
"UPDATE contacts SET last_path = ?, last_path_len = ?, last_seen = ? WHERE public_key = ?",
(path, path_len, int(time.time()), public_key.lower()),
)
else:
await db.conn.execute(
"""
UPDATE contacts
SET last_path = ?, last_path_len = ?, out_path_hash_mode = ?, last_seen = ?
WHERE public_key = ?
""",
(path, path_len, out_path_hash_mode, int(time.time()), public_key.lower()),
)
await db.conn.commit()
@staticmethod
@@ -215,6 +234,19 @@ class ContactRepository:
)
await db.conn.commit()
@staticmethod
async def clear_on_radio_except(keep_keys: list[str]) -> None:
"""Set on_radio=False for all contacts NOT in keep_keys."""
if not keep_keys:
await db.conn.execute("UPDATE contacts SET on_radio = 0 WHERE on_radio = 1")
else:
placeholders = ",".join("?" * len(keep_keys))
await db.conn.execute(
f"UPDATE contacts SET on_radio = 0 WHERE on_radio = 1 AND public_key NOT IN ({placeholders})",
keep_keys,
)
await db.conn.commit()
@staticmethod
async def delete(public_key: str) -> None:
normalized = public_key.lower()
@@ -274,7 +306,7 @@ class ContactAdvertPathRepository:
@staticmethod
def _row_to_path(row) -> ContactAdvertPath:
path = row["path_hex"] or ""
next_hop = path[:2].lower() if len(path) >= 2 else None
next_hop = first_path_hop(path, row["path_len"])
return ContactAdvertPath(
path=path,
path_len=row["path_len"],
@@ -290,6 +322,7 @@ class ContactAdvertPathRepository:
path_hex: str,
timestamp: int,
max_paths: int = 10,
path_len: int | None = None,
) -> None:
"""
Upsert a unique advert path observation for a contact and prune to N most recent.
@@ -299,7 +332,7 @@ class ContactAdvertPathRepository:
normalized_key = public_key.lower()
normalized_path = path_hex.lower()
path_len = len(normalized_path) // 2
normalized_path_len = path_len if isinstance(path_len, int) else len(normalized_path) // 2
await db.conn.execute(
"""
@@ -311,7 +344,7 @@ class ContactAdvertPathRepository:
path_len = excluded.path_len,
heard_count = contact_advert_paths.heard_count + 1
""",
(normalized_key, normalized_path, path_len, timestamp, timestamp),
(normalized_key, normalized_path, normalized_path_len, timestamp, timestamp),
)
# Keep only the N most recent unique paths per contact.

137
app/repository/fanout.py Normal file
View File

@@ -0,0 +1,137 @@
"""Repository for fanout_configs table."""
import json
import logging
import time
import uuid
from typing import Any
from app.database import db
logger = logging.getLogger(__name__)
# In-memory cache of config metadata (name, type) for status reporting.
# Populated by get_all/get/create/update and read by FanoutManager.get_statuses().
_configs_cache: dict[str, dict[str, Any]] = {}
def _row_to_dict(row: Any) -> dict[str, Any]:
"""Convert a database row to a config dict."""
result = {
"id": row["id"],
"type": row["type"],
"name": row["name"],
"enabled": bool(row["enabled"]),
"config": json.loads(row["config"]) if row["config"] else {},
"scope": json.loads(row["scope"]) if row["scope"] else {},
"sort_order": row["sort_order"] or 0,
"created_at": row["created_at"] or 0,
}
_configs_cache[result["id"]] = result
return result
class FanoutConfigRepository:
"""CRUD operations for fanout_configs table."""
@staticmethod
async def get_all() -> list[dict[str, Any]]:
"""Get all fanout configs ordered by sort_order."""
cursor = await db.conn.execute(
"SELECT * FROM fanout_configs ORDER BY sort_order, created_at"
)
rows = await cursor.fetchall()
return [_row_to_dict(row) for row in rows]
@staticmethod
async def get(config_id: str) -> dict[str, Any] | None:
"""Get a single fanout config by ID."""
cursor = await db.conn.execute("SELECT * FROM fanout_configs WHERE id = ?", (config_id,))
row = await cursor.fetchone()
if row is None:
return None
return _row_to_dict(row)
@staticmethod
async def create(
config_type: str,
name: str,
config: dict,
scope: dict,
enabled: bool = True,
config_id: str | None = None,
) -> dict[str, Any]:
"""Create a new fanout config."""
new_id = config_id or str(uuid.uuid4())
now = int(time.time())
# Get next sort_order
cursor = await db.conn.execute(
"SELECT COALESCE(MAX(sort_order), -1) + 1 FROM fanout_configs"
)
row = await cursor.fetchone()
sort_order = row[0] if row else 0
await db.conn.execute(
"""
INSERT INTO fanout_configs (id, type, name, enabled, config, scope, sort_order, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(
new_id,
config_type,
name,
1 if enabled else 0,
json.dumps(config),
json.dumps(scope),
sort_order,
now,
),
)
await db.conn.commit()
result = await FanoutConfigRepository.get(new_id)
assert result is not None
return result
@staticmethod
async def update(config_id: str, **fields: Any) -> dict[str, Any] | None:
"""Update a fanout config. Only provided fields are updated."""
updates = []
params: list[Any] = []
for field in ("name", "enabled", "config", "scope", "sort_order"):
if field in fields:
value = fields[field]
if field == "enabled":
value = 1 if value else 0
elif field in ("config", "scope"):
value = json.dumps(value)
updates.append(f"{field} = ?")
params.append(value)
if not updates:
return await FanoutConfigRepository.get(config_id)
params.append(config_id)
query = f"UPDATE fanout_configs SET {', '.join(updates)} WHERE id = ?"
await db.conn.execute(query, params)
await db.conn.commit()
return await FanoutConfigRepository.get(config_id)
@staticmethod
async def delete(config_id: str) -> None:
"""Delete a fanout config."""
await db.conn.execute("DELETE FROM fanout_configs WHERE id = ?", (config_id,))
await db.conn.commit()
_configs_cache.pop(config_id, None)
@staticmethod
async def get_enabled() -> list[dict[str, Any]]:
"""Get all enabled fanout configs."""
cursor = await db.conn.execute(
"SELECT * FROM fanout_configs WHERE enabled = 1 ORDER BY sort_order, created_at"
)
rows = await cursor.fetchall()
return [_row_to_dict(row) for row in rows]

View File

@@ -15,7 +15,7 @@ class MessageRepository:
try:
paths_data = json.loads(paths_json)
return [MessagePath(**p) for p in paths_data]
except (json.JSONDecodeError, TypeError, KeyError):
except (json.JSONDecodeError, TypeError, KeyError, ValueError):
return None
@staticmethod
@@ -26,6 +26,7 @@ class MessageRepository:
conversation_key: str,
sender_timestamp: int | None = None,
path: str | None = None,
path_len: int | None = None,
txt_type: int = 0,
signature: str | None = None,
outgoing: bool = False,
@@ -43,7 +44,10 @@ class MessageRepository:
# Convert single path to paths array format
paths_json = None
if path is not None:
paths_json = json.dumps([{"path": path, "received_at": received_at}])
normalized_path_len = path_len if isinstance(path_len, int) else len(path) // 2
path_entry: dict[str, Any] = {"path": path, "received_at": received_at}
path_entry["path_len"] = normalized_path_len
paths_json = json.dumps([path_entry])
cursor = await db.conn.execute(
"""
@@ -74,7 +78,7 @@ class MessageRepository:
@staticmethod
async def add_path(
message_id: int, path: str, received_at: int | None = None
message_id: int, path: str, received_at: int | None = None, path_len: int | None = None
) -> list[MessagePath]:
"""Add a new path to an existing message.
@@ -85,7 +89,10 @@ class MessageRepository:
# Atomic append: use json_insert to avoid read-modify-write race when
# multiple duplicate packets arrive concurrently for the same message.
new_entry = json.dumps({"path": path, "received_at": ts})
normalized_path_len = path_len if isinstance(path_len, int) else len(path) // 2
new_entry_dict: dict[str, Any] = {"path": path, "received_at": ts}
new_entry_dict["path_len"] = normalized_path_len
new_entry = json.dumps(new_entry_dict)
await db.conn.execute(
"""UPDATE messages SET paths = json_insert(
COALESCE(paths, '[]'), '$[#]', json(?)
@@ -128,6 +135,54 @@ class MessageRepository:
await db.conn.commit()
return cursor.rowcount
@staticmethod
async def backfill_channel_sender_key(public_key: str, name: str) -> int:
"""Backfill sender_key on channel messages that match a contact's name.
When a contact becomes known (via advert, sync, or manual creation),
any channel messages with a matching sender_name but no sender_key
are updated to associate them with this contact's public key.
"""
cursor = await db.conn.execute(
"""UPDATE messages SET sender_key = ?
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL""",
(public_key.lower(), name),
)
await db.conn.commit()
return cursor.rowcount
@staticmethod
def _normalize_conversation_key(conversation_key: str) -> tuple[str, str]:
"""Normalize a conversation key and return (sql_clause, normalized_key).
Returns the WHERE clause fragment and the normalized key value.
"""
if len(conversation_key) == 64:
return "AND conversation_key = ?", conversation_key.lower()
elif len(conversation_key) == 32:
return "AND conversation_key = ?", conversation_key.upper()
else:
return "AND conversation_key LIKE ?", f"{conversation_key}%"
@staticmethod
def _row_to_message(row: Any) -> Message:
"""Convert a database row to a Message model."""
return Message(
id=row["id"],
type=row["type"],
conversation_key=row["conversation_key"],
text=row["text"],
sender_timestamp=row["sender_timestamp"],
received_at=row["received_at"],
paths=MessageRepository._parse_paths(row["paths"]),
txt_type=row["txt_type"],
signature=row["signature"],
sender_key=row["sender_key"],
outgoing=bool(row["outgoing"]),
acked=row["acked"],
sender_name=row["sender_name"],
)
@staticmethod
async def get_all(
limit: int = 100,
@@ -136,57 +191,165 @@ class MessageRepository:
conversation_key: str | None = None,
before: int | None = None,
before_id: int | None = None,
after: int | None = None,
after_id: int | None = None,
q: str | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
) -> list[Message]:
query = "SELECT * FROM messages WHERE 1=1"
params: list[Any] = []
if blocked_keys:
placeholders = ",".join("?" for _ in blocked_keys)
query += (
f" AND NOT (outgoing=0 AND ("
f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))"
f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))"
f"))"
)
params.extend(blocked_keys)
params.extend(blocked_keys)
if blocked_names:
placeholders = ",".join("?" for _ in blocked_names)
query += (
f" AND NOT (outgoing=0 AND sender_name IS NOT NULL"
f" AND sender_name IN ({placeholders}))"
)
params.extend(blocked_names)
if msg_type:
query += " AND type = ?"
params.append(msg_type)
if conversation_key:
normalized_key = conversation_key
# Prefer exact matching for full keys.
if len(conversation_key) == 64:
normalized_key = conversation_key.lower()
query += " AND conversation_key = ?"
params.append(normalized_key)
elif len(conversation_key) == 32:
normalized_key = conversation_key.upper()
query += " AND conversation_key = ?"
params.append(normalized_key)
else:
# Prefix match is only for legacy/partial key callers.
query += " AND conversation_key LIKE ?"
params.append(f"{conversation_key}%")
clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key)
query += f" {clause}"
params.append(norm_key)
if before is not None and before_id is not None:
query += " AND (received_at < ? OR (received_at = ? AND id < ?))"
params.extend([before, before, before_id])
if q:
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
query += " AND text LIKE ? ESCAPE '\\' COLLATE NOCASE"
params.append(f"%{escaped_q}%")
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
params.append(limit)
if before is None or before_id is None:
query += " OFFSET ?"
params.append(offset)
# Forward cursor (after/after_id) — mutually exclusive with before/before_id
if after is not None and after_id is not None:
query += " AND (received_at > ? OR (received_at = ? AND id > ?))"
params.extend([after, after, after_id])
query += " ORDER BY received_at ASC, id ASC LIMIT ?"
params.append(limit)
else:
if before is not None and before_id is not None:
query += " AND (received_at < ? OR (received_at = ? AND id < ?))"
params.extend([before, before, before_id])
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
params.append(limit)
if before is None or before_id is None:
query += " OFFSET ?"
params.append(offset)
cursor = await db.conn.execute(query, params)
rows = await cursor.fetchall()
return [
Message(
id=row["id"],
type=row["type"],
conversation_key=row["conversation_key"],
text=row["text"],
sender_timestamp=row["sender_timestamp"],
received_at=row["received_at"],
paths=MessageRepository._parse_paths(row["paths"]),
txt_type=row["txt_type"],
signature=row["signature"],
outgoing=bool(row["outgoing"]),
acked=row["acked"],
return [MessageRepository._row_to_message(row) for row in rows]
@staticmethod
async def get_around(
message_id: int,
msg_type: str | None = None,
conversation_key: str | None = None,
context_size: int = 100,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
) -> tuple[list[Message], bool, bool]:
"""Get messages around a target message.
Returns (messages, has_older, has_newer).
"""
# Build common WHERE clause for optional conversation/type filtering.
# If the target message doesn't match filters, return an empty result.
where_parts: list[str] = []
base_params: list[Any] = []
if msg_type:
where_parts.append("type = ?")
base_params.append(msg_type)
if conversation_key:
clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key)
where_parts.append(clause.removeprefix("AND "))
base_params.append(norm_key)
if blocked_keys:
placeholders = ",".join("?" for _ in blocked_keys)
where_parts.append(
f"NOT (outgoing=0 AND ("
f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))"
f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))"
f"))"
)
for row in rows
base_params.extend(blocked_keys)
base_params.extend(blocked_keys)
if blocked_names:
placeholders = ",".join("?" for _ in blocked_names)
where_parts.append(
f"NOT (outgoing=0 AND sender_name IS NOT NULL AND sender_name IN ({placeholders}))"
)
base_params.extend(blocked_names)
where_sql = " AND ".join(["1=1", *where_parts])
# 1. Get the target message (must satisfy filters if provided)
target_cursor = await db.conn.execute(
f"SELECT * FROM messages WHERE id = ? AND {where_sql}",
(message_id, *base_params),
)
target_row = await target_cursor.fetchone()
if not target_row:
return [], False, False
target = MessageRepository._row_to_message(target_row)
# 2. Get context_size+1 messages before target (DESC)
before_query = f"""
SELECT * FROM messages WHERE {where_sql}
AND (received_at < ? OR (received_at = ? AND id < ?))
ORDER BY received_at DESC, id DESC LIMIT ?
"""
before_params = [
*base_params,
target.received_at,
target.received_at,
target.id,
context_size + 1,
]
before_cursor = await db.conn.execute(before_query, before_params)
before_rows = list(await before_cursor.fetchall())
has_older = len(before_rows) > context_size
before_messages = [MessageRepository._row_to_message(r) for r in before_rows[:context_size]]
# 3. Get context_size+1 messages after target (ASC)
after_query = f"""
SELECT * FROM messages WHERE {where_sql}
AND (received_at > ? OR (received_at = ? AND id > ?))
ORDER BY received_at ASC, id ASC LIMIT ?
"""
after_params = [
*base_params,
target.received_at,
target.received_at,
target.id,
context_size + 1,
]
after_cursor = await db.conn.execute(after_query, after_params)
after_rows = list(await after_cursor.fetchall())
has_newer = len(after_rows) > context_size
after_messages = [MessageRepository._row_to_message(r) for r in after_rows[:context_size]]
# Combine: before (reversed to ASC) + target + after
all_messages = list(reversed(before_messages)) + [target] + after_messages
return all_messages, has_older, has_newer
@staticmethod
async def increment_ack_count(message_id: int) -> int:
@@ -212,31 +375,14 @@ class MessageRepository:
async def get_by_id(message_id: int) -> "Message | None":
"""Look up a message by its ID."""
cursor = await db.conn.execute(
"""
SELECT id, type, conversation_key, text, sender_timestamp, received_at,
paths, txt_type, signature, outgoing, acked
FROM messages
WHERE id = ?
""",
"SELECT * FROM messages WHERE id = ?",
(message_id,),
)
row = await cursor.fetchone()
if not row:
return None
return Message(
id=row["id"],
type=row["type"],
conversation_key=row["conversation_key"],
text=row["text"],
sender_timestamp=row["sender_timestamp"],
received_at=row["received_at"],
paths=MessageRepository._parse_paths(row["paths"]),
txt_type=row["txt_type"],
signature=row["signature"],
outgoing=bool(row["outgoing"]),
acked=row["acked"],
)
return MessageRepository._row_to_message(row)
@staticmethod
async def get_by_content(
@@ -248,9 +394,7 @@ class MessageRepository:
"""Look up a message by its unique content fields."""
cursor = await db.conn.execute(
"""
SELECT id, type, conversation_key, text, sender_timestamp, received_at,
paths, txt_type, signature, outgoing, acked
FROM messages
SELECT * FROM messages
WHERE type = ? AND conversation_key = ? AND text = ?
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
""",
@@ -260,36 +404,20 @@ class MessageRepository:
if not row:
return None
paths = None
if row["paths"]:
try:
paths_data = json.loads(row["paths"])
paths = [
MessagePath(path=p["path"], received_at=p["received_at"]) for p in paths_data
]
except (json.JSONDecodeError, KeyError):
pass
return Message(
id=row["id"],
type=row["type"],
conversation_key=row["conversation_key"],
text=row["text"],
sender_timestamp=row["sender_timestamp"],
received_at=row["received_at"],
paths=paths,
txt_type=row["txt_type"],
signature=row["signature"],
outgoing=bool(row["outgoing"]),
acked=row["acked"],
)
return MessageRepository._row_to_message(row)
@staticmethod
async def get_unread_counts(name: str | None = None) -> dict:
async def get_unread_counts(
name: str | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
) -> dict:
"""Get unread message counts, mention flags, and last message times for all conversations.
Args:
name: User's display name for @[name] mention detection. If None, mentions are skipped.
blocked_keys: Public keys whose messages should be excluded from counts.
blocked_names: Display names whose messages should be excluded from counts.
Returns:
Dict with 'counts', 'mentions', and 'last_message_times' keys.
@@ -300,9 +428,25 @@ class MessageRepository:
mention_token = f"@[{name}]" if name else None
# Build optional block-list WHERE fragments for channel messages
chan_block_sql = ""
chan_block_params: list[Any] = []
if blocked_keys:
placeholders = ",".join("?" for _ in blocked_keys)
chan_block_sql += (
f" AND NOT (m.sender_key IS NOT NULL AND LOWER(m.sender_key) IN ({placeholders}))"
)
chan_block_params.extend(blocked_keys)
if blocked_names:
placeholders = ",".join("?" for _ in blocked_names)
chan_block_sql += (
f" AND NOT (m.sender_name IS NOT NULL AND m.sender_name IN ({placeholders}))"
)
chan_block_params.extend(blocked_names)
# Channel unreads
cursor = await db.conn.execute(
"""
f"""
SELECT m.conversation_key,
COUNT(*) as unread_count,
SUM(CASE
@@ -313,9 +457,10 @@ class MessageRepository:
JOIN channels c ON m.conversation_key = c.key
WHERE m.type = 'CHAN' AND m.outgoing = 0
AND m.received_at > COALESCE(c.last_read_at, 0)
{chan_block_sql}
GROUP BY m.conversation_key
""",
(mention_token or "", mention_token or ""),
(mention_token or "", mention_token or "", *chan_block_params),
)
rows = await cursor.fetchall()
for row in rows:
@@ -324,9 +469,17 @@ class MessageRepository:
if mention_token and row["has_mention"]:
mention_flags[state_key] = True
# Build block-list exclusion for contact (DM) unreads
contact_block_sql = ""
contact_block_params: list[Any] = []
if blocked_keys:
placeholders = ",".join("?" for _ in blocked_keys)
contact_block_sql += f" AND LOWER(m.conversation_key) NOT IN ({placeholders})"
contact_block_params.extend(blocked_keys)
# Contact unreads
cursor = await db.conn.execute(
"""
f"""
SELECT m.conversation_key,
COUNT(*) as unread_count,
SUM(CASE
@@ -337,9 +490,10 @@ class MessageRepository:
JOIN contacts ct ON m.conversation_key = ct.public_key
WHERE m.type = 'PRIV' AND m.outgoing = 0
AND m.received_at > COALESCE(ct.last_read_at, 0)
{contact_block_sql}
GROUP BY m.conversation_key
""",
(mention_token or "", mention_token or ""),
(mention_token or "", mention_token or "", *contact_block_params),
)
rows = await cursor.fetchall()
for row in rows:
@@ -388,6 +542,72 @@ class MessageRepository:
row = await cursor.fetchone()
return row["cnt"] if row else 0
@staticmethod
async def get_channel_stats(conversation_key: str) -> dict:
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h.
"""
import time as _time
now = int(_time.time())
t_1h = now - 3600
t_24h = now - 86400
t_48h = now - 172800
t_7d = now - 604800
cursor = await db.conn.execute(
"""
SELECT COUNT(*) AS all_time,
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_1h,
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_24h,
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_48h,
SUM(CASE WHEN received_at >= ? THEN 1 ELSE 0 END) AS last_7d,
MIN(received_at) AS first_message_at,
COUNT(DISTINCT sender_key) AS unique_sender_count
FROM messages WHERE type = 'CHAN' AND conversation_key = ?
""",
(t_1h, t_24h, t_48h, t_7d, conversation_key),
)
row = await cursor.fetchone()
assert row is not None # Aggregate query always returns a row
message_counts = {
"last_1h": row["last_1h"] or 0,
"last_24h": row["last_24h"] or 0,
"last_48h": row["last_48h"] or 0,
"last_7d": row["last_7d"] or 0,
"all_time": row["all_time"] or 0,
}
cursor2 = await db.conn.execute(
"""
SELECT COALESCE(sender_name, sender_key, 'Unknown') AS display_name,
sender_key, COUNT(*) AS cnt
FROM messages
WHERE type = 'CHAN' AND conversation_key = ?
AND received_at >= ? AND sender_key IS NOT NULL
GROUP BY sender_key ORDER BY cnt DESC LIMIT 5
""",
(conversation_key, t_24h),
)
top_rows = await cursor2.fetchall()
top_senders = [
{
"sender_name": r["display_name"],
"sender_key": r["sender_key"],
"message_count": r["cnt"],
}
for r in top_rows
]
return {
"message_counts": message_counts,
"first_message_at": row["first_message_at"],
"unique_sender_count": row["unique_sender_count"] or 0,
"top_senders_24h": top_senders,
}
@staticmethod
async def get_most_active_rooms(sender_key: str, limit: int = 5) -> list[tuple[str, str, int]]:
"""Get channels where a contact has sent the most messages.

View File

@@ -4,7 +4,7 @@ import time
from typing import Any, Literal
from app.database import db
from app.models import AppSettings, BotConfig, Favorite
from app.models import AppSettings, Favorite
logger = logging.getLogger(__name__)
@@ -26,13 +26,8 @@ class AppSettingsRepository:
"""
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, bots,
mqtt_broker_host, mqtt_broker_port, mqtt_username, mqtt_password,
mqtt_use_tls, mqtt_tls_insecure, mqtt_topic_prefix,
mqtt_publish_messages, mqtt_publish_raw_packets,
community_mqtt_enabled, community_mqtt_iata,
community_mqtt_broker_host, community_mqtt_broker_port,
community_mqtt_email
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names
FROM app_settings WHERE id = 1
"""
)
@@ -68,19 +63,21 @@ class AppSettingsRepository:
)
last_message_times = {}
# Parse bots JSON
bots: list[BotConfig] = []
if row["bots"]:
# Parse blocked_keys JSON
blocked_keys: list[str] = []
if row["blocked_keys"]:
try:
bots_data = json.loads(row["bots"])
bots = [BotConfig(**b) for b in bots_data]
except (json.JSONDecodeError, TypeError, KeyError) as e:
logger.warning(
"Failed to parse bots JSON, using empty list: %s (data=%r)",
e,
row["bots"][:100] if row["bots"] else None,
)
bots = []
blocked_keys = json.loads(row["blocked_keys"])
except (json.JSONDecodeError, TypeError):
blocked_keys = []
# Parse blocked_names JSON
blocked_names: list[str] = []
if row["blocked_names"]:
try:
blocked_names = json.loads(row["blocked_names"])
except (json.JSONDecodeError, TypeError):
blocked_names = []
# Validate sidebar_sort_order (fallback to "recent" if invalid)
sort_order = row["sidebar_sort_order"]
@@ -96,22 +93,9 @@ class AppSettingsRepository:
preferences_migrated=bool(row["preferences_migrated"]),
advert_interval=row["advert_interval"] or 0,
last_advert_time=row["last_advert_time"] or 0,
bots=bots,
mqtt_broker_host=row["mqtt_broker_host"] or "",
mqtt_broker_port=row["mqtt_broker_port"] or 1883,
mqtt_username=row["mqtt_username"] or "",
mqtt_password=row["mqtt_password"] or "",
mqtt_use_tls=bool(row["mqtt_use_tls"]),
mqtt_tls_insecure=bool(row["mqtt_tls_insecure"]),
mqtt_topic_prefix=row["mqtt_topic_prefix"] or "meshcore",
mqtt_publish_messages=bool(row["mqtt_publish_messages"]),
mqtt_publish_raw_packets=bool(row["mqtt_publish_raw_packets"]),
community_mqtt_enabled=bool(row["community_mqtt_enabled"]),
community_mqtt_iata=row["community_mqtt_iata"] or "",
community_mqtt_broker_host=row["community_mqtt_broker_host"]
or "mqtt-us-v1.letsmesh.net",
community_mqtt_broker_port=row["community_mqtt_broker_port"] or 443,
community_mqtt_email=row["community_mqtt_email"] or "",
flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys,
blocked_names=blocked_names,
)
@staticmethod
@@ -124,21 +108,9 @@ class AppSettingsRepository:
preferences_migrated: bool | None = None,
advert_interval: int | None = None,
last_advert_time: int | None = None,
bots: list[BotConfig] | None = None,
mqtt_broker_host: str | None = None,
mqtt_broker_port: int | None = None,
mqtt_username: str | None = None,
mqtt_password: str | None = None,
mqtt_use_tls: bool | None = None,
mqtt_tls_insecure: bool | None = None,
mqtt_topic_prefix: str | None = None,
mqtt_publish_messages: bool | None = None,
mqtt_publish_raw_packets: bool | None = None,
community_mqtt_enabled: bool | None = None,
community_mqtt_iata: str | None = None,
community_mqtt_broker_host: str | None = None,
community_mqtt_broker_port: int | None = None,
community_mqtt_email: str | None = None,
flood_scope: str | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -177,66 +149,17 @@ class AppSettingsRepository:
updates.append("last_advert_time = ?")
params.append(last_advert_time)
if bots is not None:
updates.append("bots = ?")
bots_json = json.dumps([b.model_dump() for b in bots])
params.append(bots_json)
if flood_scope is not None:
updates.append("flood_scope = ?")
params.append(flood_scope)
if mqtt_broker_host is not None:
updates.append("mqtt_broker_host = ?")
params.append(mqtt_broker_host)
if blocked_keys is not None:
updates.append("blocked_keys = ?")
params.append(json.dumps(blocked_keys))
if mqtt_broker_port is not None:
updates.append("mqtt_broker_port = ?")
params.append(mqtt_broker_port)
if mqtt_username is not None:
updates.append("mqtt_username = ?")
params.append(mqtt_username)
if mqtt_password is not None:
updates.append("mqtt_password = ?")
params.append(mqtt_password)
if mqtt_use_tls is not None:
updates.append("mqtt_use_tls = ?")
params.append(1 if mqtt_use_tls else 0)
if mqtt_tls_insecure is not None:
updates.append("mqtt_tls_insecure = ?")
params.append(1 if mqtt_tls_insecure else 0)
if mqtt_topic_prefix is not None:
updates.append("mqtt_topic_prefix = ?")
params.append(mqtt_topic_prefix)
if mqtt_publish_messages is not None:
updates.append("mqtt_publish_messages = ?")
params.append(1 if mqtt_publish_messages else 0)
if mqtt_publish_raw_packets is not None:
updates.append("mqtt_publish_raw_packets = ?")
params.append(1 if mqtt_publish_raw_packets else 0)
if community_mqtt_enabled is not None:
updates.append("community_mqtt_enabled = ?")
params.append(1 if community_mqtt_enabled else 0)
if community_mqtt_iata is not None:
updates.append("community_mqtt_iata = ?")
params.append(community_mqtt_iata)
if community_mqtt_broker_host is not None:
updates.append("community_mqtt_broker_host = ?")
params.append(community_mqtt_broker_host)
if community_mqtt_broker_port is not None:
updates.append("community_mqtt_broker_port = ?")
params.append(community_mqtt_broker_port)
if community_mqtt_email is not None:
updates.append("community_mqtt_email = ?")
params.append(community_mqtt_email)
if blocked_names is not None:
updates.append("blocked_names = ?")
params.append(json.dumps(blocked_names))
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
@@ -266,6 +189,27 @@ class AppSettingsRepository:
]
return await AppSettingsRepository.update(favorites=new_favorites)
@staticmethod
async def toggle_blocked_key(key: str) -> AppSettings:
"""Toggle a public key in the blocked list. Keys are normalized to lowercase."""
normalized = key.lower()
settings = await AppSettingsRepository.get()
if normalized in settings.blocked_keys:
new_keys = [k for k in settings.blocked_keys if k != normalized]
else:
new_keys = settings.blocked_keys + [normalized]
return await AppSettingsRepository.update(blocked_keys=new_keys)
@staticmethod
async def toggle_blocked_name(name: str) -> AppSettings:
"""Toggle a display name in the blocked list."""
settings = await AppSettingsRepository.get()
if name in settings.blocked_names:
new_names = [n for n in settings.blocked_names if n != name]
else:
new_names = settings.blocked_names + [name]
return await AppSettingsRepository.update(blocked_names=new_names)
@staticmethod
async def migrate_preferences_from_frontend(
favorites: list[dict],

View File

@@ -6,10 +6,10 @@ from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import Channel
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.radio import radio_manager
from app.radio_sync import upsert_channel_from_radio_slot
from app.repository import ChannelRepository
from app.repository import ChannelRepository, MessageRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
@@ -29,6 +29,24 @@ async def list_channels() -> list[Channel]:
return await ChannelRepository.get_all()
@router.get("/{key}/detail", response_model=ChannelDetail)
async def get_channel_detail(key: str) -> ChannelDetail:
"""Get comprehensive channel profile data with message statistics."""
channel = await ChannelRepository.get_by_key(key)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
stats = await MessageRepository.get_channel_stats(channel.key)
return ChannelDetail(
channel=channel,
message_counts=ChannelMessageCounts(**stats["message_counts"]),
first_message_at=stats["first_message_at"],
unique_sender_count=stats["unique_sender_count"],
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
)
@router.get("/{key}", response_model=Channel)
async def get_channel(key: str) -> Channel:
"""Get a specific channel by key (32-char hex string)."""
@@ -127,4 +145,9 @@ async def delete_channel(key: str) -> dict:
"""
logger.info("Deleting channel %s from database", key)
await ChannelRepository.delete(key)
from app.websocket import broadcast_event
broadcast_event("channel_deleted", {"key": key})
return {"status": "ok"}

View File

@@ -16,6 +16,7 @@ from app.models import (
TraceResponse,
)
from app.packet_processor import start_historical_dm_decryption
from app.path_utils import first_path_hop
from app.radio import radio_manager
from app.repository import (
AmbiguousPublicKeyPrefixError,
@@ -154,6 +155,14 @@ async def create_contact(
if claimed > 0:
logger.info("Claimed %d prefix messages for contact %s", claimed, lower_key[:12])
# Backfill sender_key on channel messages that match this contact's name
if request.name:
backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, request.name)
if backfilled > 0:
logger.info(
"Backfilled sender_key on %d channel message(s) for %s", backfilled, request.name
)
# Trigger historical decryption if requested
if request.try_historical:
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
@@ -193,11 +202,11 @@ async def get_contact_detail(public_key: str) -> ContactDetail:
if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2)
# Compute nearest repeaters from first-hop prefixes in advert paths
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen}
# Compute nearest repeaters from first hops in advert paths
first_hop_stats: dict[str, dict] = {} # first hop -> {heard_count, path_len, last_seen}
for p in advert_paths:
if p.path and len(p.path) >= 2:
prefix = p.path[:2].lower()
prefix = first_path_hop(p.path, p.path_len)
if prefix:
if prefix not in first_hop_stats:
first_hop_stats[prefix] = {
"heard_count": 0,
@@ -271,16 +280,30 @@ async def sync_contacts_from_radio() -> dict:
contacts = result.payload
count = 0
synced_keys: list[str] = []
for public_key, contact_data in contacts.items():
lower_key = public_key.lower()
await ContactRepository.upsert(
Contact.from_radio_dict(lower_key, contact_data, on_radio=True)
)
synced_keys.append(lower_key)
claimed = await MessageRepository.claim_prefix_messages(lower_key)
if claimed > 0:
logger.info("Claimed %d prefix DM message(s) for contact %s", claimed, public_key[:12])
adv_name = contact_data.get("adv_name")
if adv_name:
backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, adv_name)
if backfilled > 0:
logger.info(
"Backfilled sender_key on %d channel message(s) for %s",
backfilled,
adv_name,
)
count += 1
# Clear on_radio for contacts not found on the radio
await ContactRepository.clear_on_radio_except(synced_keys)
logger.info("Synced %d contacts from radio", count)
return {"synced": count}
@@ -368,6 +391,10 @@ async def delete_contact(public_key: str) -> dict:
await ContactRepository.delete(contact.public_key)
logger.info("Deleted contact %s", contact.public_key[:12])
from app.websocket import broadcast_event
broadcast_event("contact_deleted", {"public_key": contact.public_key})
return {"status": "ok"}
@@ -436,7 +463,7 @@ async def reset_contact_path(public_key: str) -> dict:
"""Reset a contact's routing path to flood."""
contact = await _resolve_contact_or_404(public_key)
await ContactRepository.update_path(contact.public_key, "", -1)
await ContactRepository.update_path(contact.public_key, "", -1, out_path_hash_mode=-1)
logger.info("Reset path to flood for %s", contact.public_key[:12])
# Push the updated path to radio if connected and contact is on radio

235
app/routers/fanout.py Normal file
View File

@@ -0,0 +1,235 @@
"""REST API for fanout config CRUD."""
import logging
import re
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.config import settings as server_settings
from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/fanout", tags=["fanout"])
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise"}
_IATA_RE = re.compile(r"^[A-Z]{3}$")
class FanoutConfigCreate(BaseModel):
type: str = Field(description="Integration type: 'mqtt_private' or 'mqtt_community'")
name: str = Field(min_length=1, description="User-assigned label")
config: dict = Field(default_factory=dict, description="Type-specific config blob")
scope: dict = Field(default_factory=dict, description="Scope controls")
enabled: bool = Field(default=True, description="Whether enabled on creation")
class FanoutConfigUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, description="Updated label")
config: dict | None = Field(default=None, description="Updated config blob")
scope: dict | None = Field(default=None, description="Updated scope controls")
enabled: bool | None = Field(default=None, description="Enable/disable toggle")
def _validate_mqtt_private_config(config: dict) -> None:
"""Validate mqtt_private config blob."""
if not config.get("broker_host"):
raise HTTPException(status_code=400, detail="broker_host is required for mqtt_private")
port = config.get("broker_port", 1883)
if not isinstance(port, int) or port < 1 or port > 65535:
raise HTTPException(status_code=400, detail="broker_port must be between 1 and 65535")
def _validate_mqtt_community_config(config: dict) -> None:
"""Validate mqtt_community config blob. Normalizes IATA to uppercase."""
iata = config.get("iata", "").upper().strip()
if not iata or not _IATA_RE.fullmatch(iata):
raise HTTPException(
status_code=400,
detail="IATA code is required and must be exactly 3 uppercase alphabetic characters",
)
config["iata"] = iata
def _validate_bot_config(config: dict) -> None:
"""Validate bot config blob (syntax-check the code)."""
code = config.get("code", "")
if not code or not code.strip():
raise HTTPException(status_code=400, detail="Bot code cannot be empty")
try:
compile(code, "<bot_code>", "exec")
except SyntaxError as e:
raise HTTPException(
status_code=400,
detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
) from None
def _validate_apprise_config(config: dict) -> None:
"""Validate apprise config blob."""
urls = config.get("urls", "")
if not urls or not urls.strip():
raise HTTPException(status_code=400, detail="At least one Apprise URL is required")
def _validate_webhook_config(config: dict) -> None:
"""Validate webhook config blob."""
url = config.get("url", "")
if not url:
raise HTTPException(status_code=400, detail="url is required for webhook")
if not url.startswith(("http://", "https://")):
raise HTTPException(status_code=400, detail="url must start with http:// or https://")
method = config.get("method", "POST").upper()
if method not in ("POST", "PUT", "PATCH"):
raise HTTPException(status_code=400, detail="method must be POST, PUT, or PATCH")
headers = config.get("headers", {})
if not isinstance(headers, dict):
raise HTTPException(status_code=400, detail="headers must be a JSON object")
def _enforce_scope(config_type: str, scope: dict) -> dict:
"""Enforce type-specific scope constraints. Returns normalized scope."""
if config_type == "mqtt_community":
return {"messages": "none", "raw_packets": "all"}
if config_type == "bot":
return {"messages": "all", "raw_packets": "none"}
if config_type in ("webhook", "apprise"):
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
raise HTTPException(
status_code=400,
detail="scope.messages must be 'all', 'none', or a filter object",
)
return {"messages": messages, "raw_packets": "none"}
# For mqtt_private, validate scope values
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
raise HTTPException(
status_code=400,
detail="scope.messages must be 'all', 'none', or a filter object",
)
raw_packets = scope.get("raw_packets", "all")
if raw_packets not in ("all", "none"):
raise HTTPException(
status_code=400,
detail="scope.raw_packets must be 'all' or 'none'",
)
return {"messages": messages, "raw_packets": raw_packets}
@router.get("")
async def list_fanout_configs() -> list[dict]:
"""List all fanout configs."""
return await FanoutConfigRepository.get_all()
@router.post("")
async def create_fanout_config(body: FanoutConfigCreate) -> dict:
"""Create a new fanout config."""
if body.type not in _VALID_TYPES:
raise HTTPException(
status_code=400,
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
)
if body.type == "bot" and server_settings.disable_bots:
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
# Only validate config when creating as enabled — disabled configs
# are drafts the user hasn't finished configuring yet.
if body.enabled:
if body.type == "mqtt_private":
_validate_mqtt_private_config(body.config)
elif body.type == "mqtt_community":
_validate_mqtt_community_config(body.config)
elif body.type == "bot":
_validate_bot_config(body.config)
elif body.type == "webhook":
_validate_webhook_config(body.config)
elif body.type == "apprise":
_validate_apprise_config(body.config)
scope = _enforce_scope(body.type, body.scope)
cfg = await FanoutConfigRepository.create(
config_type=body.type,
name=body.name,
config=body.config,
scope=scope,
enabled=body.enabled,
)
# Start the module if enabled
if cfg["enabled"]:
from app.fanout.manager import fanout_manager
await fanout_manager.reload_config(cfg["id"])
logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
return cfg
@router.patch("/{config_id}")
async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict:
"""Update a fanout config. Triggers module reload."""
existing = await FanoutConfigRepository.get(config_id)
if existing is None:
raise HTTPException(status_code=404, detail="Fanout config not found")
if existing["type"] == "bot" and server_settings.disable_bots:
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
kwargs = {}
if body.name is not None:
kwargs["name"] = body.name
if body.enabled is not None:
kwargs["enabled"] = body.enabled
if body.config is not None:
kwargs["config"] = body.config
if body.scope is not None:
kwargs["scope"] = _enforce_scope(existing["type"], body.scope)
# Validate config when the result will be enabled
will_be_enabled = body.enabled if body.enabled is not None else existing["enabled"]
if will_be_enabled:
config_to_validate = body.config if body.config is not None else existing["config"]
if existing["type"] == "mqtt_private":
_validate_mqtt_private_config(config_to_validate)
elif existing["type"] == "mqtt_community":
_validate_mqtt_community_config(config_to_validate)
elif existing["type"] == "bot":
_validate_bot_config(config_to_validate)
elif existing["type"] == "webhook":
_validate_webhook_config(config_to_validate)
elif existing["type"] == "apprise":
_validate_apprise_config(config_to_validate)
updated = await FanoutConfigRepository.update(config_id, **kwargs)
if updated is None:
raise HTTPException(status_code=404, detail="Fanout config not found")
# Reload the module to pick up changes
from app.fanout.manager import fanout_manager
await fanout_manager.reload_config(config_id)
logger.info("Updated fanout config %s", config_id)
return updated
@router.delete("/{config_id}")
async def delete_fanout_config(config_id: str) -> dict:
"""Delete a fanout config."""
existing = await FanoutConfigRepository.get(config_id)
if existing is None:
raise HTTPException(status_code=404, detail="Fanout config not found")
# Stop the module first
from app.fanout.manager import fanout_manager
await fanout_manager.remove_config(config_id)
await FanoutConfigRepository.delete(config_id)
logger.info("Deleted fanout config %s", config_id)
return {"deleted": True}

View File

@@ -1,4 +1,5 @@
import os
from typing import Any
from fastapi import APIRouter
from pydantic import BaseModel
@@ -16,8 +17,8 @@ class HealthResponse(BaseModel):
connection_info: str | None
database_size_mb: float
oldest_undecrypted_timestamp: int | None
mqtt_status: str | None = None
community_mqtt_status: str | None = None
fanout_statuses: dict[str, dict[str, str]] = {}
bots_disabled: bool = False
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
@@ -35,27 +36,12 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
except RuntimeError:
pass # Database not connected
# MQTT status
mqtt_status: str | None = None
# Fanout module statuses
fanout_statuses: dict[str, Any] = {}
try:
from app.mqtt import mqtt_publisher
from app.fanout.manager import fanout_manager
if mqtt_publisher._is_configured():
mqtt_status = "connected" if mqtt_publisher.connected else "disconnected"
else:
mqtt_status = "disabled"
except Exception:
pass
# Community MQTT status
community_mqtt_status: str | None = None
try:
from app.community_mqtt import community_publisher
if community_publisher._is_configured():
community_mqtt_status = "connected" if community_publisher.connected else "disconnected"
else:
community_mqtt_status = "disabled"
fanout_statuses = fanout_manager.get_statuses()
except Exception:
pass
@@ -65,8 +51,8 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
"connection_info": connection_info,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
"mqtt_status": mqtt_status,
"community_mqtt_status": community_mqtt_status,
"fanout_statuses": fanout_statuses,
"bots_disabled": settings.disable_bots,
}

View File

@@ -1,4 +1,3 @@
import asyncio
import logging
import time
@@ -7,15 +6,42 @@ from meshcore import EventType
from app.dependencies import require_connected
from app.event_handlers import track_pending_ack
from app.models import Message, SendChannelMessageRequest, SendDirectMessageRequest
from app.models import (
Message,
MessagesAroundResponse,
SendChannelMessageRequest,
SendDirectMessageRequest,
)
from app.radio import radio_manager
from app.repository import AmbiguousPublicKeyPrefixError, MessageRepository
from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository
from app.websocket import broadcast_event
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/messages", tags=["messages"])
@router.get("/around/{message_id}", response_model=MessagesAroundResponse)
async def get_messages_around(
message_id: int,
type: str | None = Query(default=None, description="Filter by type: PRIV or CHAN"),
conversation_key: str | None = Query(default=None, description="Filter by conversation key"),
context: int = Query(default=100, ge=1, le=500, description="Number of messages before/after"),
) -> MessagesAroundResponse:
"""Get messages around a specific message for jump-to-message navigation."""
settings = await AppSettingsRepository.get()
blocked_keys = settings.blocked_keys or None
blocked_names = settings.blocked_names or None
messages, has_older, has_newer = await MessageRepository.get_around(
message_id=message_id,
msg_type=type,
conversation_key=conversation_key,
context_size=context,
blocked_keys=blocked_keys,
blocked_names=blocked_names,
)
return MessagesAroundResponse(messages=messages, has_older=has_older, has_newer=has_newer)
@router.get("", response_model=list[Message])
async def list_messages(
limit: int = Query(default=100, ge=1, le=1000),
@@ -28,8 +54,18 @@ async def list_messages(
default=None, description="Cursor: received_at of last seen message"
),
before_id: int | None = Query(default=None, description="Cursor: id of last seen message"),
after: int | None = Query(
default=None, description="Forward cursor: received_at of last seen message"
),
after_id: int | None = Query(
default=None, description="Forward cursor: id of last seen message"
),
q: str | None = Query(default=None, description="Full-text search query"),
) -> list[Message]:
"""List messages from the database."""
settings = await AppSettingsRepository.get()
blocked_keys = settings.blocked_keys or None
blocked_names = settings.blocked_names or None
return await MessageRepository.get_all(
limit=limit,
offset=offset,
@@ -37,6 +73,11 @@ async def list_messages(
conversation_key=conversation_key,
before=before,
before_id=before_id,
after=after,
after_id=after_id,
q=q,
blocked_keys=blocked_keys,
blocked_names=blocked_names,
)
@@ -134,25 +175,9 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
)
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
# Fanout modules (including bots) are triggered via broadcast_event's realtime dispatch.
broadcast_event("message", message.model_dump())
# Trigger bots for outgoing DMs (runs in background, doesn't block response)
from app.bot import run_bot_for_message
asyncio.create_task(
run_bot_for_message(
sender_name=None,
sender_key=db_contact.public_key.lower(),
message_text=request.text,
is_dm=True,
channel_key=None,
channel_name=None,
sender_timestamp=now,
path=None,
is_outgoing=True,
)
)
return message
@@ -197,8 +222,11 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
radio_name: str = ""
text_with_sender: str = request.text
our_public_key: str | None = None
async with radio_manager.radio_operation("send_channel_message") as mc:
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
our_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text
# Load the channel to a temporary radio slot before sending
set_result = await mc.commands.set_channel(
@@ -243,6 +271,8 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
sender_timestamp=now,
received_at=now,
outgoing=True,
sender_name=radio_name or None,
sender_key=our_public_key,
)
if message_id is None:
raise HTTPException(
@@ -264,6 +294,9 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
received_at=now,
outgoing=True,
acked=0,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=db_channel.name,
).model_dump(),
)
@@ -282,23 +315,9 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
outgoing=True,
acked=acked_count,
paths=paths,
)
# Trigger bots for outgoing channel messages (runs in background, doesn't block response)
from app.bot import run_bot_for_message
asyncio.create_task(
run_bot_for_message(
sender_name=radio_name or None,
sender_key=None,
message_text=request.text,
is_dm=False,
channel_key=channel_key_upper,
channel_name=db_channel.name,
sender_timestamp=now,
path=None,
is_outgoing=True,
)
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=db_channel.name,
)
return message
@@ -361,9 +380,12 @@ async def resend_channel_message(
status_code=400, detail=f"Invalid channel key format: {msg.conversation_key}"
) from None
resend_public_key: str | None = None
async with radio_manager.radio_operation("resend_channel_message") as mc:
# Strip sender prefix: DB stores "RadioName: message" but radio needs "message"
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
resend_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
text_to_send = msg.text
if radio_name and text_to_send.startswith(f"{radio_name}: "):
text_to_send = text_to_send[len(f"{radio_name}: ") :]
@@ -398,6 +420,8 @@ async def resend_channel_message(
sender_timestamp=now,
received_at=now,
outgoing=True,
sender_name=radio_name or None,
sender_key=resend_public_key,
)
if new_msg_id is None:
# Timestamp-second collision (same text+channel within the same second).
@@ -420,6 +444,9 @@ async def resend_channel_message(
received_at=now,
outgoing=True,
acked=0,
sender_name=radio_name or None,
sender_key=resend_public_key,
channel_name=db_channel.name,
).model_dump(),
)

View File

@@ -71,7 +71,8 @@ async def _run_historical_channel_decryption(
timestamp=result.timestamp,
received_at=packet_timestamp,
path=path_hex,
trigger_bot=False, # Historical decryption should not trigger bot
path_len=packet_info.path_length if packet_info else None,
realtime=False, # Historical decryption should not trigger fanout
)
if msg_id is not None:

View File

@@ -1,4 +1,6 @@
import logging
from collections.abc import Awaitable, Callable
from typing import Any, cast
from fastapi import APIRouter, HTTPException
from meshcore import EventType
@@ -27,6 +29,12 @@ class RadioConfigResponse(BaseModel):
lon: float
tx_power: int = Field(description="Transmit power in dBm")
max_tx_power: int = Field(description="Maximum transmit power in dBm")
path_hash_mode: int = Field(
default=0, description="Default outbound path hash mode (0=1 byte, 1=2 bytes, 2=3 bytes)"
)
path_hash_mode_supported: bool = Field(
default=False, description="Whether the connected radio/firmware exposes path hash mode"
)
radio: RadioSettings
@@ -35,6 +43,9 @@ class RadioConfigUpdate(BaseModel):
lat: float | None = None
lon: float | None = None
tx_power: int | None = Field(default=None, description="Transmit power in dBm")
path_hash_mode: int | None = Field(
default=None, ge=0, le=2, description="Default outbound path hash mode"
)
radio: RadioSettings | None = None
@@ -42,15 +53,47 @@ class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string")
async def _set_path_hash_mode(mc, mode: int):
"""Set path hash mode using either the new helper or raw command fallback."""
commands = getattr(mc, "commands", None)
if commands is None:
raise HTTPException(status_code=503, detail="Radio command interface unavailable")
set_path_hash_mode = cast(
Callable[[int], Awaitable[Any]] | None, getattr(commands, "set_path_hash_mode", None)
)
send_raw = cast(
Callable[[bytes, list[EventType]], Awaitable[Any]] | None,
getattr(commands, "send", None),
)
if callable(set_path_hash_mode):
result = await set_path_hash_mode(mode)
elif callable(send_raw):
data = b"\x3d\x00" + int(mode).to_bytes(1, "little")
result = await send_raw(data, [EventType.OK, EventType.ERROR])
else:
raise HTTPException(
status_code=400,
detail="Installed meshcore interface library cannot set path hash mode",
)
if result is not None and result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail="Failed to set path hash mode on radio")
return result
@router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration."""
mc = require_connected()
info = mc.self_info
if not info:
raise HTTPException(status_code=503, detail="Radio info not available")
path_hash_mode, path_hash_mode_supported = radio_manager.path_hash_mode_info
return RadioConfigResponse(
public_key=info.get("public_key", ""),
name=info.get("name", ""),
@@ -58,6 +101,8 @@ async def get_radio_config() -> RadioConfigResponse:
lon=info.get("adv_lon", 0.0),
tx_power=info.get("tx_power", 0),
max_tx_power=info.get("max_tx_power", 0),
path_hash_mode=path_hash_mode,
path_hash_mode_supported=path_hash_mode_supported,
radio=RadioSettings(
freq=info.get("radio_freq", 0.0),
bw=info.get("radio_bw", 0.0),
@@ -88,6 +133,20 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
logger.info("Setting TX power to %d dBm", update.tx_power)
await mc.commands.set_tx_power(val=update.tx_power)
if update.path_hash_mode is not None:
current_mode, supported = radio_manager.path_hash_mode_info
if not supported:
current_mode, supported = await radio_manager.refresh_path_hash_mode_info(mc)
if not supported:
raise HTTPException(
status_code=400,
detail="Connected radio/firmware does not expose path hash mode",
)
if current_mode != update.path_hash_mode:
logger.info("Setting path hash mode to %d", update.path_hash_mode)
await _set_path_hash_mode(mc, update.path_hash_mode)
radio_manager.set_path_hash_mode_info(update.path_hash_mode, True)
if update.radio is not None:
logger.info(
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",

View File

@@ -7,7 +7,12 @@ from fastapi import APIRouter
from app.models import UnreadCounts
from app.radio import radio_manager
from app.repository import ChannelRepository, ContactRepository, MessageRepository
from app.repository import (
AppSettingsRepository,
ChannelRepository,
ContactRepository,
MessageRepository,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/read-state", tags=["read-state"])
@@ -26,7 +31,12 @@ async def get_unreads() -> UnreadCounts:
mc = radio_manager.meshcore
if mc and mc.self_info:
name = mc.self_info.get("name") or None
data = await MessageRepository.get_unread_counts(name)
settings = await AppSettingsRepository.get()
blocked_keys = settings.blocked_keys or None
blocked_names = settings.blocked_names or None
data = await MessageRepository.get_unread_counts(
name, blocked_keys=blocked_keys, blocked_names=blocked_names
)
return UnreadCounts(**data)

View File

@@ -1,39 +1,17 @@
import asyncio
import logging
import re
from typing import Literal
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter
from pydantic import BaseModel, Field
from app.models import AppSettings, BotConfig
from app.models import AppSettings
from app.repository import AppSettingsRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"])
def validate_bot_code(code: str, bot_name: str | None = None) -> None:
"""Validate bot code syntax. Raises HTTPException on error."""
if not code or not code.strip():
return # Empty code is valid (disables bot)
try:
compile(code, "<bot_code>", "exec")
except SyntaxError as e:
name_part = f"'{bot_name}' " if bot_name else ""
raise HTTPException(
status_code=400,
detail=f"Bot {name_part}has syntax error at line {e.lineno}: {e.msg}",
) from None
def validate_all_bots(bots: list[BotConfig]) -> None:
"""Validate all bots' code syntax. Raises HTTPException on first error."""
for bot in bots:
validate_bot_code(bot.code, bot.name)
class AppSettingsUpdate(BaseModel):
max_radio_contacts: int | None = Field(
default=None,
@@ -56,72 +34,28 @@ class AppSettingsUpdate(BaseModel):
ge=0,
description="Periodic advertisement interval in seconds (0 = disabled, minimum 3600)",
)
bots: list[BotConfig] | None = Field(
flood_scope: str | None = Field(
default=None,
description="List of bot configurations",
description="Outbound flood scope / region name (empty = disabled)",
)
mqtt_broker_host: str | None = Field(
blocked_keys: list[str] | None = Field(
default=None,
description="MQTT broker hostname (empty = disabled)",
description="Public keys whose messages are hidden from the UI",
)
mqtt_broker_port: int | None = Field(
blocked_names: list[str] | None = Field(
default=None,
ge=1,
le=65535,
description="MQTT broker port",
)
mqtt_username: str | None = Field(
default=None,
description="MQTT username (optional)",
)
mqtt_password: str | None = Field(
default=None,
description="MQTT password (optional)",
)
mqtt_use_tls: bool | None = Field(
default=None,
description="Whether to use TLS for MQTT connection",
)
mqtt_tls_insecure: bool | None = Field(
default=None,
description="Skip TLS certificate verification (for self-signed certs)",
)
mqtt_topic_prefix: str | None = Field(
default=None,
description="MQTT topic prefix",
)
mqtt_publish_messages: bool | None = Field(
default=None,
description="Whether to publish decrypted messages to MQTT",
)
mqtt_publish_raw_packets: bool | None = Field(
default=None,
description="Whether to publish raw packets to MQTT",
)
community_mqtt_enabled: bool | None = Field(
default=None,
description="Whether to publish raw packets to the community MQTT broker",
)
community_mqtt_iata: str | None = Field(
default=None,
description="IATA region code for community MQTT topic routing (3 alpha chars)",
)
community_mqtt_broker_host: str | None = Field(
default=None,
description="Community MQTT broker hostname",
)
community_mqtt_broker_port: int | None = Field(
default=None,
ge=1,
le=65535,
description="Community MQTT broker port",
)
community_mqtt_email: str | None = Field(
default=None,
description="Email address for node claiming on the community aggregator",
description="Display names whose messages are hidden from the UI",
)
class BlockKeyRequest(BaseModel):
key: str = Field(description="Public key to toggle block status")
class BlockNameRequest(BaseModel):
name: str = Field(description="Display name to toggle block status")
class FavoriteRequest(BaseModel):
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
id: str = Field(description="Channel key or contact public key")
@@ -180,85 +114,34 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating advert_interval to %d", interval)
kwargs["advert_interval"] = interval
if update.bots is not None:
validate_all_bots(update.bots)
logger.info("Updating bots (count=%d)", len(update.bots))
kwargs["bots"] = update.bots
# Block lists
if update.blocked_keys is not None:
kwargs["blocked_keys"] = [k.lower() for k in update.blocked_keys]
if update.blocked_names is not None:
kwargs["blocked_names"] = update.blocked_names
# MQTT fields
mqtt_fields = [
"mqtt_broker_host",
"mqtt_broker_port",
"mqtt_username",
"mqtt_password",
"mqtt_use_tls",
"mqtt_tls_insecure",
"mqtt_topic_prefix",
"mqtt_publish_messages",
"mqtt_publish_raw_packets",
]
mqtt_changed = False
for field in mqtt_fields:
value = getattr(update, field)
if value is not None:
kwargs[field] = value
mqtt_changed = True
# Community MQTT fields
community_mqtt_changed = False
if update.community_mqtt_enabled is not None:
kwargs["community_mqtt_enabled"] = update.community_mqtt_enabled
community_mqtt_changed = True
if update.community_mqtt_iata is not None:
iata = update.community_mqtt_iata.upper().strip()
if iata and not re.fullmatch(r"[A-Z]{3}", iata):
raise HTTPException(
status_code=400,
detail="IATA code must be exactly 3 uppercase alphabetic characters",
)
kwargs["community_mqtt_iata"] = iata
community_mqtt_changed = True
if update.community_mqtt_broker_host is not None:
kwargs["community_mqtt_broker_host"] = update.community_mqtt_broker_host
community_mqtt_changed = True
if update.community_mqtt_broker_port is not None:
kwargs["community_mqtt_broker_port"] = update.community_mqtt_broker_port
community_mqtt_changed = True
if update.community_mqtt_email is not None:
kwargs["community_mqtt_email"] = update.community_mqtt_email
community_mqtt_changed = True
# Require IATA when enabling community MQTT
if kwargs.get("community_mqtt_enabled", False):
# Check the IATA value being set, or fall back to current settings
iata_value = kwargs.get("community_mqtt_iata")
if iata_value is None:
current = await AppSettingsRepository.get()
iata_value = current.community_mqtt_iata
if not iata_value or not re.fullmatch(r"[A-Z]{3}", iata_value):
raise HTTPException(
status_code=400,
detail="A valid IATA region code is required to enable community sharing",
)
# Flood scope
flood_scope_changed = False
if update.flood_scope is not None:
stripped = update.flood_scope.strip()
kwargs["flood_scope"] = stripped
flood_scope_changed = True
if kwargs:
result = await AppSettingsRepository.update(**kwargs)
# Restart MQTT publisher if any MQTT settings changed
if mqtt_changed:
from app.mqtt import mqtt_publisher
# Apply flood scope to radio immediately if changed
if flood_scope_changed:
from app.radio import radio_manager
await mqtt_publisher.restart(result)
# Restart community MQTT publisher if any community settings changed
if community_mqtt_changed:
from app.community_mqtt import community_publisher
await community_publisher.restart(result)
if radio_manager.is_connected:
try:
scope = result.flood_scope
async with radio_manager.radio_operation("set_flood_scope") as mc:
await mc.commands.set_flood_scope(scope if scope else "")
logger.info("Applied flood_scope=%r to radio", scope or "(disabled)")
except Exception as e:
logger.warning("Failed to apply flood_scope to radio: %s", e)
return result
@@ -288,6 +171,20 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
return result
@router.post("/blocked-keys/toggle", response_model=AppSettings)
async def toggle_blocked_key(request: BlockKeyRequest) -> AppSettings:
"""Toggle a public key's blocked status."""
logger.info("Toggling blocked key: %s", request.key[:12])
return await AppSettingsRepository.toggle_blocked_key(request.key)
@router.post("/blocked-names/toggle", response_model=AppSettings)
async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
"""Toggle a display name's blocked status."""
logger.info("Toggling blocked name: %s", request.name)
return await AppSettingsRepository.toggle_blocked_name(request.name)
@router.post("/migrate", response_model=MigratePreferencesResponse)
async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
"""Migrate all preferences from frontend localStorage to database.

View File

@@ -92,21 +92,26 @@ class WebSocketManager:
ws_manager = WebSocketManager()
def broadcast_event(event_type: str, data: dict) -> None:
def broadcast_event(event_type: str, data: dict, *, realtime: bool = True) -> None:
"""Schedule a broadcast without blocking.
Convenience function that creates an asyncio task to broadcast
an event to all connected WebSocket clients and forward to MQTT.
an event to all connected WebSocket clients and forward to fanout modules.
Args:
event_type: Event type string (e.g. "message", "raw_packet")
data: Event payload dict
realtime: If False, skip fanout dispatch (used for historical decryption)
"""
asyncio.create_task(ws_manager.broadcast(event_type, data))
from app.mqtt import mqtt_broadcast
if realtime:
from app.fanout.manager import fanout_manager
mqtt_broadcast(event_type, data)
from app.community_mqtt import community_mqtt_broadcast
community_mqtt_broadcast(event_type, data)
if event_type == "message":
asyncio.create_task(fanout_manager.broadcast_message(data))
elif event_type == "raw_packet":
asyncio.create_task(fanout_manager.broadcast_raw(data))
def broadcast_error(message: str, details: str | None = None) -> None:

View File

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -27,6 +27,7 @@ frontend/src/
├── prefetch.ts # Consumes prefetched API promises started in index.html
├── index.css # Global styles/utilities
├── styles.css # Additional global app styles
├── themes.css # Color theme definitions
├── lib/
│ └── utils.ts # cn() — clsx + tailwind-merge helper
├── hooks/
@@ -48,10 +49,13 @@ frontend/src/
│ ├── contactAvatar.ts # Avatar color derivation from public key
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
│ ├── a11y.ts # Keyboard accessibility helper
│ ├── lastViewedConversation.ts # localStorage for last-viewed conversation
│ ├── contactMerge.ts # Merge WS contact updates into list
│ ├── localLabel.ts # Local label (text + color) in localStorage
── radioPresets.ts # LoRa radio preset configurations
── radioPresets.ts # LoRa radio preset configurations
│ └── theme.ts # Theme switching helpers
├── components/
│ ├── StatusBar.tsx
│ ├── Sidebar.tsx
@@ -59,29 +63,32 @@ frontend/src/
│ ├── MessageList.tsx
│ ├── MessageInput.tsx
│ ├── NewMessageModal.tsx
│ ├── SearchView.tsx # Full-text message search pane
│ ├── SettingsModal.tsx # Layout shell — delegates to settings/ sections
│ ├── RawPacketList.tsx
│ ├── MapView.tsx
│ ├── VisualizerView.tsx
│ ├── PacketVisualizer3D.tsx
│ ├── PathModal.tsx
│ ├── PathRouteMap.tsx
│ ├── CrackerPanel.tsx
│ ├── BotCodeEditor.tsx
│ ├── ContactAvatar.tsx
│ ├── ContactInfoPane.tsx # Contact detail sheet (stats, name history, paths)
│ ├── ContactStatusInfo.tsx # Contact status info component
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
│ ├── settings/
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
│ │ ├── SettingsRadioSection.tsx # Preset, freq/bw/sf/cr, txPower, lat/lon
│ │ ├── SettingsIdentitySection.tsx # Name, keys, advert interval
│ │ ├── SettingsConnectivitySection.tsx # Connection status, max contacts, reboot
│ │ ├── SettingsMqttSection.tsx # MQTT broker config, TLS, publish toggles
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
│ │ ├── SettingsBotSection.tsx # Bot list, code editor, add/delete/reset
│ │ ├── SettingsStatisticsSection.tsx # Read-only mesh network stats
│ │ ── SettingsAboutSection.tsx # Version, author, license, links
│ │ ── SettingsAboutSection.tsx # Version, author, license, links
│ │ └── ThemeSelector.tsx # Color theme picker
│ ├── repeater/
│ │ ├── repeaterPaneShared.tsx # Shared: RepeaterPane, KvRow, format helpers
│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
@@ -94,7 +101,8 @@ frontend/src/
│ │ └── RepeaterConsolePane.tsx # CLI console with history
│ └── ui/ # shadcn/ui primitives
├── types/
── d3-force-3d.d.ts # Type declarations for d3-force-3d
── d3-force-3d.d.ts # Type declarations for d3-force-3d
│ └── globals.d.ts # Global type declarations (__APP_VERSION__, __COMMIT_HASH__)
└── test/
├── setup.ts
├── fixtures/websocket_events.json
@@ -114,13 +122,20 @@ frontend/src/
├── repeaterLogin.test.tsx
├── repeaterMessageParsing.test.ts
├── localLabel.test.ts
├── messageInput.test.tsx
├── newMessageModal.test.tsx
├── settingsModal.test.tsx
├── sidebar.test.tsx
├── unreadCounts.test.ts
├── urlHash.test.ts
├── appSearchJump.test.tsx
├── channelInfoKeyVisibility.test.tsx
├── chatHeaderKeyVisibility.test.tsx
├── searchView.test.tsx
├── useConversationMessages.test.ts
├── useConversationMessages.race.test.ts
├── useRepeaterDashboard.test.ts
├── useContactsAndChannels.test.ts
├── useWebSocket.dispatch.test.ts
└── useWebSocket.lifecycle.test.ts
```
@@ -146,7 +161,7 @@ frontend/src/
### New Message modal
`NewMessageModal` intentionally preserves form state (tab, inputs, checkboxes) when closed and reopened. The component instance persists across open/close cycles. This is by design so users don't lose in-progress input if they accidentally dismiss the dialog.
`NewMessageModal` resets form state on close. The component instance persists across open/close cycles for smooth animations.
### Message behavior
@@ -169,7 +184,7 @@ frontend/src/
- Auto reconnect (3s) with cleanup guard on unmount.
- Heartbeat ping every 30s.
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `error`, `success`, `pong` (ignored).
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.
## URL Hash Navigation (`utils/urlHash.ts`)
@@ -179,6 +194,7 @@ Supported routes:
- `#map`
- `#map/focus/{pubkey_or_prefix}`
- `#visualizer`
- `#search`
- `#channel/{channelKey}`
- `#channel/{channelKey}/{label}`
- `#contact/{publicKey}`
@@ -225,13 +241,14 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `preferences_migrated`
- `advert_interval`
- `last_advert_time`
- `bots`
- `mqtt_broker_host`, `mqtt_broker_port`, `mqtt_username`, `mqtt_password`
- `mqtt_use_tls`, `mqtt_tls_insecure`, `mqtt_topic_prefix`, `mqtt_publish_messages`, `mqtt_publish_raw_packets`
- `community_mqtt_enabled`, `community_mqtt_iata`, `community_mqtt_broker_host`, `community_mqtt_broker_port`, `community_mqtt_email`
- `flood_scope`
- `blocked_keys`, `blocked_names`
`HealthStatus` includes `mqtt_status` (`"connected"`, `"disconnected"`, `"disabled"`, or `null`).
`HealthStatus` also includes `community_mqtt_status` with the same status values.
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
`HealthStatus` includes `fanout_statuses: Record<string, FanoutStatusEntry>` mapping config IDs to `{name, type, status}`. Also includes `bots_disabled: boolean`.
`FanoutConfig` represents a single fanout integration: `{id, type, name, enabled, config, scope, sort_order, created_at}`.
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
@@ -252,6 +269,18 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
## Channel Info Pane
Clicking a channel name in `ChatHeader` opens a `ChannelInfoPane` sheet (right drawer) showing channel details fetched from `GET /api/channels/{key}/detail`:
- Header: channel name, key (clickable copy), type badge (hashtag/private key), on-radio badge
- Favorite toggle
- Message activity: time-windowed counts (1h, 24h, 48h, 7d, all time) + unique senders
- First message date
- Top senders in last 24h (name + count)
State: `infoPaneChannelKey` in App.tsx controls open/close. Live channel data from the `channels` array is preferred over the initial detail snapshot.
## Repeater Dashboard
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
@@ -266,6 +295,16 @@ For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of
All state is managed by `useRepeaterDashboard` hook. State resets on conversation change.
## Message Search Pane
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
- **State**: `targetMessageId` in `App.tsx` drives the jump-to-message flow. When a search result is clicked, `handleNavigateToMessage` sets `targetMessageId` and switches to the target conversation.
- **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results.
- **Jump-to-message**: `useConversationMessages` accepts optional `targetMessageId`. When set, it calls `api.getMessagesAround()` instead of normal fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation.
- **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling.
- **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page).
## Styling
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
@@ -279,7 +318,7 @@ Do not rely on old class-only layout assumptions.
## Testing
Run all quality checks (backend + frontend, parallelized) from the repo root:
Run all quality checks (backend + frontend) from the repo root:
```bash
./scripts/all_quality.sh

View File

@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "2.3.0",
"version": "2.5.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -45,12 +45,13 @@
"@eslint/js": "^9.17.0",
"@testing-library/jest-dom": "^6.6.0",
"@testing-library/react": "^16.0.0",
"@testing-library/user-event": "^14.6.1",
"@types/d3-force": "^3.0.10",
"@types/leaflet": "^1.9.21",
"@types/three": "^0.182.0",
"@types/node": "^25.0.3",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.182.0",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.23",
"eslint": "^9.17.0",

View File

@@ -34,6 +34,7 @@ import {
} 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
@@ -50,7 +51,16 @@ const SettingsModal = lazy(() =>
const CrackerPanel = lazy(() =>
import('./components/CrackerPanel').then((m) => ({ default: m.CrackerPanel }))
);
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './components/ui/sheet';
const SearchView = lazy(() =>
import('./components/SearchView').then((m) => ({ default: m.SearchView }))
);
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from './components/ui/sheet';
import { Toaster, toast } from './components/ui/sonner';
import { getStateKey } from './utils/conversationState';
import { appendRawPacketUnique } from './utils/rawPacketIdentity';
@@ -58,6 +68,7 @@ import { messageContainsMention } from './utils/messageParser';
import { mergeContactIntoList } from './utils/contactMerge';
import { getLocalLabel, getContrastTextColor } from './utils/localLabel';
import { cn } from '@/lib/utils';
import type { SearchNavigateTarget } from './components/SearchView';
import type { Contact, Conversation, HealthStatus, Message, MessagePath, RawPacket } from './types';
const MAX_RAW_PACKETS = 500;
@@ -73,6 +84,9 @@ export function App() {
const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const [infoPaneContactKey, setInfoPaneContactKey] = useState<string | null>(null);
const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false);
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(null);
const [targetMessageId, setTargetMessageId] = useState<number | null>(null);
// Defer CrackerPanel mount until first opened (lazy-loaded, but keep mounted after for state)
const crackerMounted = useRef(false);
@@ -110,6 +124,8 @@ export function App() {
handleSaveAppSettings,
handleSortOrderChange,
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
} = useAppSettings();
// Keep user's name in ref for mention detection in WebSocket callback
@@ -118,6 +134,14 @@ export function App() {
myNameRef.current = config?.name ?? null;
}, [config?.name]);
// Keep block lists in refs for WS callback filtering
const blockedKeysRef = useRef<string[]>([]);
const blockedNamesRef = useRef<string[]>([]);
useEffect(() => {
blockedKeysRef.current = appSettings?.blocked_keys ?? [];
blockedNamesRef.current = appSettings?.blocked_names ?? [];
}, [appSettings?.blocked_keys, appSettings?.blocked_names]);
// Check if a message mentions the user
const checkMention = useCallback(
(text: string): boolean => messageContainsMention(text, myNameRef.current),
@@ -164,17 +188,26 @@ export function App() {
// Wire up the ref bridge so useContactsAndChannels handlers reach the real setter
setActiveConversationRef.current = setActiveConversation;
// Keep SearchView mounted after first open to preserve search state
const searchMounted = useRef(false);
if (activeConversation?.type === 'search') searchMounted.current = true;
// Custom hooks for conversation-specific functionality
const {
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
addMessageIfNew,
updateMessageAck,
triggerReconcile,
} = useConversationMessages(activeConversation);
} = useConversationMessages(activeConversation, targetMessageId);
const {
unreadCounts,
@@ -240,6 +273,29 @@ export function App() {
.catch(console.error);
},
onMessage: (msg: Message) => {
// Filter blocked contacts on incoming (non-outgoing) messages
if (!msg.outgoing) {
const bKeys = blockedKeysRef.current;
const bNames = blockedNamesRef.current;
// Block DMs by sender key
if (
bKeys.length > 0 &&
msg.type === 'PRIV' &&
bKeys.includes(msg.conversation_key.toLowerCase())
)
return;
// Block channel messages by sender key
if (
bKeys.length > 0 &&
msg.type === 'CHAN' &&
msg.sender_key &&
bKeys.includes(msg.sender_key.toLowerCase())
)
return;
// Block by sender name (works for both DMs and channel messages)
if (bNames.length > 0 && msg.sender_name && bNames.includes(msg.sender_name)) return;
}
const activeConv = activeConversationRef.current;
// Check if message belongs to the active conversation
@@ -255,7 +311,8 @@ export function App() {
})();
// Only add to message list if it's for the active conversation
if (isForActiveConversation) {
// and we're not viewing historical messages (hasNewerMessages means we jumped mid-history)
if (isForActiveConversation && !hasNewerMessagesRef.current) {
addMessageIfNew(msg);
}
@@ -287,6 +344,24 @@ export function App() {
onContact: (contact: Contact) => {
setContacts((prev) => mergeContactIntoList(prev, contact));
},
onContactDeleted: (publicKey: string) => {
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
messageCache.remove(publicKey);
const active = activeConversationRef.current;
if (active?.type === 'contact' && active.id === publicKey) {
pendingDeleteFallbackRef.current = true;
setActiveConversation(null);
}
},
onChannelDeleted: (key: string) => {
setChannels((prev) => prev.filter((c) => c.key !== key));
messageCache.remove(key);
const active = activeConversationRef.current;
if (active?.type === 'channel' && active.id === key) {
pendingDeleteFallbackRef.current = true;
setActiveConversation(null);
}
},
onRawPacket: (packet: RawPacket) => {
setRawPackets((prev) => appendRawPacketUnique(prev, packet, MAX_RAW_PACKETS));
},
@@ -305,7 +380,10 @@ export function App() {
setHealth,
setConfig,
activeConversationRef,
hasNewerMessagesRef,
setActiveConversation,
setContacts,
setChannels,
triggerReconcile,
refreshUnreads,
fetchAllContacts,
@@ -403,6 +481,28 @@ export function App() {
}
}, [activeConversation]);
// Wrappers that clear cache and hard-refetch messages after block changes.
// jumpToBottom does cache.remove + fetchMessages(true) which fully replaces
// the message state; triggerReconcile only merges diffs and would keep
// blocked messages already in state.
const handleBlockKey = useCallback(
async (key: string) => {
await handleToggleBlockedKey(key);
messageCache.clear();
jumpToBottom();
},
[handleToggleBlockedKey, jumpToBottom]
);
const handleBlockName = useCallback(
async (name: string) => {
await handleToggleBlockedName(name);
messageCache.clear();
jumpToBottom();
},
[handleToggleBlockedName, jumpToBottom]
);
const handleCloseSettingsView = useCallback(() => {
startTransition(() => setShowSettings(false));
setSidebarOpen(false);
@@ -424,23 +524,62 @@ export function App() {
setShowCracker((prev) => !prev);
}, []);
const handleOpenContactInfo = useCallback((publicKey: string) => {
const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => {
setInfoPaneContactKey(publicKey);
setInfoPaneFromChannel(fromChannel ?? false);
}, []);
const handleCloseContactInfo = useCallback(() => {
setInfoPaneContactKey(null);
}, []);
const handleOpenChannelInfo = useCallback((channelKey: string) => {
setInfoPaneChannelKey(channelKey);
}, []);
const handleCloseChannelInfo = useCallback(() => {
setInfoPaneChannelKey(null);
}, []);
const handleSelectConversationWithTargetReset = useCallback(
(conv: Conversation, options?: { preserveTarget?: boolean }) => {
if (conv.type !== 'search' && !options?.preserveTarget) {
setTargetMessageId(null);
}
handleSelectConversation(conv);
},
[handleSelectConversation]
);
const handleNavigateToChannel = useCallback(
(channelKey: string) => {
const channel = channels.find((c) => c.key === channelKey);
if (channel) {
handleSelectConversation({ type: 'channel', id: channel.key, name: channel.name });
handleSelectConversationWithTargetReset({
type: 'channel',
id: channel.key,
name: channel.name,
});
setInfoPaneContactKey(null);
}
},
[channels, handleSelectConversation]
[channels, handleSelectConversationWithTargetReset]
);
const handleNavigateToMessage = useCallback(
(target: SearchNavigateTarget) => {
const convType = target.type === 'CHAN' ? 'channel' : 'contact';
setTargetMessageId(target.id);
handleSelectConversationWithTargetReset(
{
type: convType,
id: target.conversation_key,
name: target.conversation_name,
},
{ preserveTarget: true }
);
},
[handleSelectConversationWithTargetReset]
);
// Sidebar content (shared between desktop and mobile)
@@ -449,7 +588,7 @@ export function App() {
contacts={contacts}
channels={channels}
activeConversation={activeConversation}
onSelectConversation={handleSelectConversation}
onSelectConversation={handleSelectConversationWithTargetReset}
onNewMessage={handleNewMessage}
lastMessageTimes={lastMessageTimes}
unreadCounts={unreadCounts}
@@ -465,7 +604,10 @@ export function App() {
);
const settingsSidebarContent = (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
<nav
className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col"
aria-label="Settings"
>
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
Settings
@@ -473,11 +615,11 @@ export function App() {
<button
type="button"
onClick={handleCloseSettingsView}
className="h-6 w-6 rounded text-muted-foreground hover:text-foreground hover:bg-accent transition-colors"
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-status-connected/15 border border-status-connected/30 text-status-connected hover:bg-status-connected/25 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Back to conversations"
aria-label="Back to conversations"
>
&larr;
&larr; Back to Chat
</button>
</div>
<div className="flex-1 overflow-y-auto py-1">
@@ -486,22 +628,29 @@ export function App() {
key={section}
type="button"
className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors',
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
settingsSection === section && 'bg-accent border-l-primary'
)}
aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => setSettingsSection(section)}
>
{SETTINGS_SECTION_LABELS[section]}
</button>
))}
</div>
</div>
</nav>
);
const activeSidebarContent = showSettings ? settingsSidebarContent : sidebarContent;
return (
<div className="flex flex-col h-full">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
>
Skip to content
</a>
{localLabel.text && (
<div
style={{
@@ -530,19 +679,25 @@ export function App() {
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Sidebar navigation</SheetDescription>
</SheetHeader>
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
</SheetContent>
</Sheet>
<main className="flex-1 flex flex-col bg-background min-w-0">
<div className={cn('flex-1 flex flex-col min-h-0', showSettings && 'hidden')}>
<main id="main-content" className="flex-1 flex flex-col bg-background min-w-0">
<div
className={cn(
'flex-1 flex flex-col min-h-0',
(showSettings || activeConversation?.type === 'search') && 'hidden'
)}
>
{activeConversation ? (
activeConversation.type === 'map' ? (
<>
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Node Map
</div>
</h2>
<div className="flex-1 overflow-hidden">
<Suspense
fallback={
@@ -567,14 +722,14 @@ export function App() {
</Suspense>
) : activeConversation.type === 'raw' ? (
<>
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Raw Packet Feed
</div>
</h2>
<div className="flex-1 overflow-hidden">
<RawPacketList packets={rawPackets} />
</div>
</>
) : activeContactIsRepeater ? (
) : activeConversation.type === 'search' ? null : activeContactIsRepeater ? (
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -600,6 +755,7 @@ export function App() {
<ChatHeader
conversation={activeConversation}
contacts={contacts}
channels={channels}
config={config}
favorites={favorites}
onTrace={handleTrace}
@@ -607,6 +763,7 @@ export function App() {
onDeleteChannel={handleDeleteChannel}
onDeleteContact={handleDeleteContact}
onOpenContactInfo={handleOpenContactInfo}
onOpenChannelInfo={handleOpenChannelInfo}
/>
<MessageList
key={activeConversation.id}
@@ -625,6 +782,12 @@ export function App() {
radioName={config?.name}
config={config}
onOpenContactInfo={handleOpenContactInfo}
targetMessageId={targetMessageId}
onTargetReached={() => setTargetMessageId(null)}
hasNewerMessages={hasNewerMessages}
loadingNewer={loadingNewer}
onLoadNewer={fetchNewerMessages}
onJumpToBottom={jumpToBottom}
/>
<MessageInput
ref={messageInputRef}
@@ -647,14 +810,37 @@ export function App() {
)}
</div>
{searchMounted.current && (
<div
className={cn(
'flex-1 flex flex-col min-h-0',
(activeConversation?.type !== 'search' || showSettings) && 'hidden'
)}
>
<Suspense
fallback={
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading search...
</div>
}
>
<SearchView
contacts={contacts}
channels={channels}
onNavigateToMessage={handleNavigateToMessage}
/>
</Suspense>
</div>
)}
{showSettings && (
<div className="flex-1 flex flex-col min-h-0">
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<span>Radio & Settings</span>
<span className="text-sm text-muted-foreground hidden md:inline">
{SETTINGS_SECTION_LABELS[settingsSection]}
</span>
</div>
</h2>
<div className="flex-1 min-h-0 overflow-hidden">
<Suspense
fallback={
@@ -680,6 +866,10 @@ export function App() {
onHealthRefresh={handleHealthRefresh}
onRefreshAppSettings={fetchAppSettings}
onLocalLabelChange={setLocalLabel}
blockedKeys={appSettings?.blocked_keys}
blockedNames={appSettings?.blocked_names}
onToggleBlockedKey={handleBlockKey}
onToggleBlockedName={handleBlockName}
/>
</Suspense>
</div>
@@ -690,6 +880,13 @@ export function App() {
{/* Global Cracker Panel - deferred until first opened, then kept mounted for state */}
<div
ref={(el) => {
// Focus the panel when it becomes visible
if (showCracker && el) {
const focusable = el.querySelector<HTMLElement>('input, button:not([disabled])');
if (focusable) setTimeout(() => focusable.focus(), 210);
}
}}
className={cn(
'border-t border-border bg-background transition-all duration-200 overflow-hidden',
showCracker ? 'h-[275px]' : 'h-0'
@@ -729,7 +926,7 @@ export function App() {
undecryptedCount={undecryptedCount}
onClose={() => setShowNewMessage(false)}
onSelectConversation={(conv) => {
setActiveConversation(conv);
handleSelectConversationWithTargetReset(conv);
setShowNewMessage(false);
}}
onCreateContact={handleCreateContact}
@@ -739,12 +936,25 @@ export function App() {
<ContactInfoPane
contactKey={infoPaneContactKey}
fromChannel={infoPaneFromChannel}
onClose={handleCloseContactInfo}
contacts={contacts}
config={config}
favorites={favorites}
onToggleFavorite={handleToggleFavorite}
onNavigateToChannel={handleNavigateToChannel}
blockedKeys={appSettings?.blocked_keys}
blockedNames={appSettings?.blocked_names}
onToggleBlockedKey={handleBlockKey}
onToggleBlockedName={handleBlockName}
/>
<ChannelInfoPane
channelKey={infoPaneChannelKey}
onClose={handleCloseChannelInfo}
channels={channels}
favorites={favorites}
onToggleFavorite={handleToggleFavorite}
/>
<Toaster position="top-right" />

View File

@@ -2,15 +2,18 @@ import type {
AppSettings,
AppSettingsUpdate,
Channel,
ChannelDetail,
CommandResponse,
Contact,
ContactAdvertPath,
ContactAdvertPathSummary,
ContactDetail,
FanoutConfig,
Favorite,
HealthStatus,
MaintenanceResult,
Message,
MessagesAroundResponse,
MigratePreferencesRequest,
MigratePreferencesResponse,
RadioConfig,
@@ -148,6 +151,7 @@ export const api = {
}),
deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
markChannelRead: (key: string) =>
fetchJson<{ status: string; key: string }>(`/channels/${key}/mark-read`, {
method: 'POST',
@@ -162,6 +166,9 @@ export const api = {
conversation_key?: string;
before?: number;
before_id?: number;
after?: number;
after_id?: number;
q?: string;
},
signal?: AbortSignal
) => {
@@ -172,9 +179,27 @@ export const api = {
if (params?.conversation_key) searchParams.set('conversation_key', params.conversation_key);
if (params?.before !== undefined) searchParams.set('before', params.before.toString());
if (params?.before_id !== undefined) searchParams.set('before_id', params.before_id.toString());
if (params?.after !== undefined) searchParams.set('after', params.after.toString());
if (params?.after_id !== undefined) searchParams.set('after_id', params.after_id.toString());
if (params?.q) searchParams.set('q', params.q);
const query = searchParams.toString();
return fetchJson<Message[]>(`/messages${query ? `?${query}` : ''}`, { signal });
},
getMessagesAround: (
messageId: number,
type?: 'PRIV' | 'CHAN',
conversationKey?: string,
signal?: AbortSignal
) => {
const searchParams = new URLSearchParams();
if (type) searchParams.set('type', type);
if (conversationKey) searchParams.set('conversation_key', conversationKey);
const query = searchParams.toString();
return fetchJson<MessagesAroundResponse>(
`/messages/around/${messageId}${query ? `?${query}` : ''}`,
{ signal }
);
},
sendDirectMessage: (destination: string, text: string) =>
fetchJson<Message>('/messages/direct', {
method: 'POST',
@@ -230,6 +255,18 @@ export const api = {
body: JSON.stringify(settings),
}),
// Block lists
toggleBlockedKey: (key: string) =>
fetchJson<AppSettings>('/settings/blocked-keys/toggle', {
method: 'POST',
body: JSON.stringify({ key }),
}),
toggleBlockedName: (name: string) =>
fetchJson<AppSettings>('/settings/blocked-names/toggle', {
method: 'POST',
body: JSON.stringify({ name }),
}),
// Favorites
toggleFavorite: (type: Favorite['type'], id: string) =>
fetchJson<AppSettings>('/settings/favorites/toggle', {
@@ -244,6 +281,37 @@ export const api = {
body: JSON.stringify(request),
}),
// Fanout
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
createFanoutConfig: (config: {
type: string;
name: string;
config: Record<string, unknown>;
scope: Record<string, unknown>;
enabled?: boolean;
}) =>
fetchJson<FanoutConfig>('/fanout', {
method: 'POST',
body: JSON.stringify(config),
}),
updateFanoutConfig: (
id: string,
update: {
name?: string;
config?: Record<string, unknown>;
scope?: Record<string, unknown>;
enabled?: boolean;
}
) =>
fetchJson<FanoutConfig>(`/fanout/${id}`, {
method: 'PATCH',
body: JSON.stringify(update),
}),
deleteFanoutConfig: (id: string) =>
fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
method: 'DELETE',
}),
// Statistics
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),

View File

@@ -25,6 +25,7 @@ export function BotCodeEditor({ value, onChange, id, height = '256px' }: BotCode
}}
className="text-sm"
id={id}
aria-label="Bot code editor"
/>
</div>
);

View File

@@ -0,0 +1,227 @@
import { useEffect, useState } from 'react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import type { Channel, ChannelDetail, Favorite } from '../types';
interface ChannelInfoPaneProps {
channelKey: string | null;
onClose: () => void;
channels: Channel[];
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
}
export function ChannelInfoPane({
channelKey,
onClose,
channels,
favorites,
onToggleFavorite,
}: ChannelInfoPaneProps) {
const [detail, setDetail] = useState<ChannelDetail | null>(null);
const [loading, setLoading] = useState(false);
const [showKey, setShowKey] = useState(false);
// Get live channel data from channels array (real-time via WS)
const liveChannel = channelKey ? (channels.find((c) => c.key === channelKey) ?? null) : null;
useEffect(() => {
setShowKey(false);
if (!channelKey) {
setDetail(null);
return;
}
let cancelled = false;
setLoading(true);
api
.getChannelDetail(channelKey)
.then((data) => {
if (!cancelled) setDetail(data);
})
.catch((err) => {
if (!cancelled) {
console.error('Failed to fetch channel detail:', err);
toast.error('Failed to load channel info');
}
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [channelKey]);
// Use live channel data where available, fall back to detail snapshot
const channel = liveChannel ?? detail?.channel ?? null;
return (
<Sheet open={channelKey !== null} onOpenChange={(open) => !open && onClose()}>
<SheetContent side="right" className="w-full sm:max-w-[400px] p-0 flex flex-col">
<SheetHeader className="sr-only">
<SheetTitle>Channel Info</SheetTitle>
<SheetDescription>Channel details and statistics</SheetDescription>
</SheetHeader>
{loading && !detail ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading...
</div>
) : channel ? (
<div className="flex-1 overflow-y-auto">
{/* Header */}
<div className="px-5 pt-5 pb-4 border-b border-border">
<h2 className="text-lg font-semibold truncate">
{channel.is_hashtag && !channel.name.startsWith('#')
? `#${channel.name}`
: channel.name}
</h2>
{!channel.is_hashtag && !showKey ? (
<button
className="text-xs font-mono text-muted-foreground hover:text-primary transition-colors"
onClick={() => setShowKey(true)}
title="Reveal channel key"
>
Show Key
</button>
) : (
<span
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
navigator.clipboard.writeText(channel.key);
toast.success('Channel key copied!');
}}
title="Click to copy"
>
{channel.key.toLowerCase()}
</span>
)}
<div className="flex items-center gap-2 mt-1.5">
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
{channel.is_hashtag ? 'Hashtag' : 'Private Key'}
</span>
{channel.on_radio && (
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
On Radio
</span>
)}
</div>
</div>
{/* Favorite toggle */}
<div className="px-5 py-3 border-b border-border">
<button
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleFavorite('channel', channel.key)}
>
{isFavorite(favorites, 'channel', channel.key) ? (
<>
<span className="text-favorite text-lg">&#9733;</span>
<span>Remove from favorites</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#9734;</span>
<span>Add to favorites</span>
</>
)}
</button>
</div>
{/* Message Activity */}
{detail && detail.message_counts.all_time > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Message Activity</SectionLabel>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<InfoItem
label="Last Hour"
value={detail.message_counts.last_1h.toLocaleString()}
/>
<InfoItem
label="Last 24h"
value={detail.message_counts.last_24h.toLocaleString()}
/>
<InfoItem
label="Last 48h"
value={detail.message_counts.last_48h.toLocaleString()}
/>
<InfoItem
label="Last 7d"
value={detail.message_counts.last_7d.toLocaleString()}
/>
<InfoItem
label="All Time"
value={detail.message_counts.all_time.toLocaleString()}
/>
<InfoItem
label="Unique Senders"
value={detail.unique_sender_count.toLocaleString()}
/>
</div>
</div>
)}
{/* First Message */}
{detail && detail.first_message_at && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>First Message</SectionLabel>
<p className="text-sm font-medium">{formatTime(detail.first_message_at)}</p>
</div>
)}
{/* Top Senders 24h */}
{detail && detail.top_senders_24h.length > 0 && (
<div className="px-5 py-3">
<SectionLabel>Top Senders (24h)</SectionLabel>
<div className="space-y-1">
{detail.top_senders_24h.map((sender, idx) => (
<div
key={sender.sender_key ?? idx}
className="flex justify-between items-center text-sm"
>
<span className="truncate">{sender.sender_name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{sender.message_count.toLocaleString()} msg
{sender.message_count !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
</div>
)}
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Channel not found
</div>
)}
</SheetContent>
</Sheet>
);
}
function SectionLabel({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
{children}
</h3>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<span className="text-muted-foreground text-xs">{label}</span>
<p className="font-medium text-sm leading-tight">{value}</p>
</div>
);
}

View File

@@ -1,16 +1,15 @@
import type React from 'react';
import { useEffect, useState } from 'react';
import { toast } from './ui/sonner';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import type { Contact, Conversation, Favorite, RadioConfig } from '../types';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types';
interface ChatHeaderProps {
conversation: Conversation;
contacts: Contact[];
channels: Channel[];
config: RadioConfig | null;
favorites: Favorite[];
onTrace: () => void;
@@ -18,11 +17,13 @@ interface ChatHeaderProps {
onDeleteChannel: (key: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
onOpenChannelInfo?: (channelKey: string) => void;
}
export function ChatHeader({
conversation,
contacts,
channels,
config,
favorites,
onTrace,
@@ -30,15 +31,32 @@ export function ChatHeader({
onDeleteChannel,
onDeleteContact,
onOpenContactInfo,
onOpenChannelInfo,
}: ChatHeaderProps) {
const [showKey, setShowKey] = useState(false);
useEffect(() => {
setShowKey(false);
}, [conversation.id]);
const isPrivateChannel =
conversation.type === 'channel' && !channels.find((c) => c.key === conversation.id)?.is_hashtag;
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
(conversation.type === 'channel' && onOpenChannelInfo);
return (
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-center gap-x-2 min-w-0 flex-1">
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
{conversation.type === 'contact' && onOpenContactInfo && (
<span
className="flex-shrink-0 cursor-pointer"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => onOpenContactInfo(conversation.id)}
title="View contact info"
aria-label={`View info for ${conversation.name}`}
>
<ContactAvatar
name={conversation.name}
@@ -49,137 +67,90 @@ export function ChatHeader({
/>
</span>
)}
<span
className={`flex-shrink-0 font-semibold text-base ${conversation.type === 'contact' && onOpenContactInfo ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
<h2
className={`flex-shrink-0 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
role={titleClickable ? 'button' : undefined}
tabIndex={titleClickable ? 0 : undefined}
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
onClick={
conversation.type === 'contact' && onOpenContactInfo
? () => onOpenContactInfo(conversation.id)
titleClickable
? () => {
if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id);
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
onOpenChannelInfo(conversation.id);
}
}
: undefined
}
>
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
conversation.name !== 'Public'
channels.find((c) => c.key === conversation.id)?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
<span
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
);
}}
title="Click to copy"
>
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
</span>
</h2>
{isPrivateChannel && !showKey ? (
<button
className="font-normal text-[11px] text-muted-foreground font-mono hover:text-primary transition-colors"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
>
Show Key
</button>
) : (
<span
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
);
}}
title="Click to copy"
aria-label={conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'}
>
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
</span>
)}
{conversation.type === 'contact' &&
(() => {
const contact = contacts.find((c) => c.public_key === conversation.id);
if (!contact) return null;
const parts: React.ReactNode[] = [];
if (contact.last_seen) {
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
}
if (contact.last_path_len === -1) {
parts.push('flood');
} else if (contact.last_path_len === 0) {
parts.push(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Reset path to flood?')) {
api.resetContactPath(contact.public_key).then(
() => toast.success('Path reset to flood'),
() => toast.error('Failed to reset path')
);
}
}}
title="Click to reset path to flood"
>
direct
</span>
);
} else if (contact.last_path_len > 0) {
parts.push(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Reset path to flood?')) {
api.resetContactPath(contact.public_key).then(
() => toast.success('Path reset to flood'),
() => toast.error('Failed to reset path')
);
}
}}
title="Click to reset path to flood"
>
{contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''}
</span>
);
}
if (isValidLocation(contact.lat, contact.lon)) {
const distFromUs =
config && isValidLocation(config.lat, config.lon)
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
: null;
parts.push(
<span key="coords">
<span
className="font-mono cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
const url =
window.location.origin +
window.location.pathname +
getMapFocusHash(contact.public_key);
window.open(url, '_blank');
}}
title="View on map"
>
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
</span>
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
</span>
);
}
return parts.length > 0 ? (
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
(
{parts.map((part, i) => (
<span key={i}>
{i > 0 && ', '}
{part}
</span>
))}
)
</span>
) : null;
return (
<ContactStatusInfo
contact={contact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
);
})()}
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
{/* Direct trace button (contacts only) */}
{conversation.type === 'contact' && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
title="Direct Trace"
aria-label="Direct Trace"
>
&#x1F6CE;
<span aria-hidden="true">&#x1F6CE;</span>
</button>
)}
{/* Favorite button */}
{(conversation.type === 'channel' || conversation.type === 'contact') && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() =>
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
}
@@ -188,9 +159,14 @@ export function ChatHeader({
? 'Remove from favorites'
: 'Add to favorites'
}
aria-label={
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites'
}
>
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
<span className="text-amber-400">&#9733;</span>
<span className="text-favorite">&#9733;</span>
) : (
<span className="text-muted-foreground">&#9734;</span>
)}
@@ -199,7 +175,7 @@ export function ChatHeader({
{/* Delete button */}
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
<button
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
if (conversation.type === 'channel') {
onDeleteChannel(conversation.id);
@@ -208,11 +184,12 @@ export function ChatHeader({
}
}}
title="Delete"
aria-label="Delete"
>
&#128465;
<span aria-hidden="true">&#128465;</span>
</button>
)}
</div>
</div>
</header>
);
}

View File

@@ -27,6 +27,7 @@ export function ContactAvatar({
height: size,
fontSize: size * 0.45,
}}
aria-hidden="true"
>
{avatar.text}
</div>

View File

@@ -1,11 +1,17 @@
import { useEffect, useState } from 'react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import {
isValidLocation,
calculateDistance,
formatDistance,
parsePathHops,
} from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetHeader, SheetTitle } from './ui/sheet';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import type { Contact, ContactDetail, Favorite, RadioConfig } from '../types';
@@ -19,33 +25,45 @@ const CONTACT_TYPE_LABELS: Record<number, string> = {
interface ContactInfoPaneProps {
contactKey: string | null;
fromChannel?: boolean;
onClose: () => void;
contacts: Contact[];
config: RadioConfig | null;
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onNavigateToChannel?: (channelKey: string) => void;
blockedKeys?: string[];
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
}
export function ContactInfoPane({
contactKey,
fromChannel = false,
onClose,
contacts,
config,
favorites,
onToggleFavorite,
onNavigateToChannel,
blockedKeys = [],
blockedNames = [],
onToggleBlockedKey,
onToggleBlockedName,
}: ContactInfoPaneProps) {
const isNameOnly = contactKey?.startsWith('name:') ?? false;
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
const [detail, setDetail] = useState<ContactDetail | null>(null);
const [loading, setLoading] = useState(false);
// Get live contact data from contacts array (real-time via WS)
const liveContact = contactKey
? (contacts.find((c) => c.public_key === contactKey) ?? null)
: null;
const liveContact =
contactKey && !isNameOnly ? (contacts.find((c) => c.public_key === contactKey) ?? null) : null;
useEffect(() => {
if (!contactKey) {
if (!contactKey || isNameOnly) {
setDetail(null);
return;
}
@@ -69,7 +87,7 @@ export function ContactInfoPane({
return () => {
cancelled = true;
};
}, [contactKey]);
}, [contactKey, isNameOnly]);
// Use live contact data where available, fall back to detail snapshot
const contact = liveContact ?? detail?.contact ?? null;
@@ -87,9 +105,51 @@ export function ContactInfoPane({
<SheetContent side="right" className="w-full sm:max-w-[400px] p-0 flex flex-col">
<SheetHeader className="sr-only">
<SheetTitle>Contact Info</SheetTitle>
<SheetDescription>Contact details and actions</SheetDescription>
</SheetHeader>
{loading && !detail ? (
{isNameOnly && nameOnlyValue ? (
<div className="flex-1 overflow-y-auto">
{/* Name-only header */}
<div className="px-5 pt-5 pb-4 border-b border-border">
<div className="flex items-start gap-4">
<ContactAvatar name={nameOnlyValue} publicKey={`name:${nameOnlyValue}`} size={56} />
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">{nameOnlyValue}</h2>
<p className="text-xs text-muted-foreground mt-1">
We have not heard an advertisement associated with this name, so we cannot
identify their key.
</p>
</div>
</div>
</div>
{fromChannel && <ChannelAttributionWarning />}
{/* Block by name toggle */}
{onToggleBlockedName && (
<div className="px-5 py-3 border-b border-border">
<button
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleBlockedName(nameOnlyValue)}
>
{blockedNames.includes(nameOnlyValue) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<span>Unblock this name</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<span>Block this name</span>
</>
)}
</button>
</div>
)}
</div>
) : loading && !detail ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading...
</div>
@@ -110,6 +170,9 @@ export function ContactInfoPane({
</h2>
<span
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
navigator.clipboard.writeText(contact.public_key);
toast.success('Public key copied!');
@@ -132,6 +195,8 @@ export function ContactInfoPane({
</div>
</div>
{fromChannel && <ChannelAttributionWarning />}
{/* Info grid */}
<div className="px-5 py-3 border-b border-border">
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
@@ -167,6 +232,9 @@ export function ContactInfoPane({
<SectionLabel>Location</SectionLabel>
<span
className="text-sm font-mono cursor-pointer hover:text-primary hover:underline transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
const url =
window.location.origin +
@@ -190,7 +258,7 @@ export function ContactInfoPane({
>
{isFavorite(favorites, 'contact', contact.public_key) ? (
<>
<span className="text-amber-400 text-lg">&#9733;</span>
<span className="text-favorite text-lg">&#9733;</span>
<span>Remove from favorites</span>
</>
) : (
@@ -202,6 +270,50 @@ export function ContactInfoPane({
</button>
</div>
{/* Block toggles */}
{(onToggleBlockedKey || onToggleBlockedName) && (
<div className="px-5 py-3 border-b border-border space-y-2">
{onToggleBlockedKey && (
<button
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleBlockedKey(contact.public_key)}
>
{blockedKeys.includes(contact.public_key.toLowerCase()) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<span>Unblock this key</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<span>Block this key</span>
</>
)}
</button>
)}
{onToggleBlockedName && contact.name && (
<button
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleBlockedName(contact.name!)}
>
{blockedNames.includes(contact.name) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<span>Unblock name &ldquo;{contact.name}&rdquo;</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<span>Block name &ldquo;{contact.name}&rdquo;</span>
</>
)}
</button>
)}
</div>
)}
{/* AKA (Name History) - only show if more than one name */}
{detail && detail.name_history.length > 1 && (
<div className="px-5 py-3 border-b border-border">
@@ -256,6 +368,9 @@ export function ContactInfoPane({
? 'cursor-pointer hover:text-primary transition-colors truncate'
: 'truncate'
}
role={onNavigateToChannel ? 'button' : undefined}
tabIndex={onNavigateToChannel ? 0 : undefined}
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
onClick={() => onNavigateToChannel?.(room.channel_key)}
>
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
@@ -303,7 +418,7 @@ export function ContactInfoPane({
className="flex justify-between items-center text-sm"
>
<span className="font-mono text-xs truncate">
{p.path ? p.path.match(/.{2}/g)!.join(' → ') : '(direct)'}
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{p.heard_count}x · {formatTime(p.last_seen)}
@@ -332,6 +447,18 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
);
}
function ChannelAttributionWarning() {
return (
<div className="mx-5 my-3 px-3 py-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<p className="text-xs text-yellow-600 dark:text-yellow-400">
Channel sender identity is based on best-effort name matching. Different nodes using the
same name will be attributed to the same contact. Message counts and key-based statistics
may be inaccurate.
</p>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>

View File

@@ -0,0 +1,118 @@
import type { ReactNode } from 'react';
import { toast } from './ui/sonner';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { handleKeyboardActivate } from '../utils/a11y';
import type { Contact } from '../types';
interface ContactStatusInfoProps {
contact: Contact;
ourLat: number | null;
ourLon: number | null;
}
/**
* Renders the "(Last heard: ..., N hops, lat, lon (dist))" status line
* shared between ChatHeader and RepeaterDashboard.
*/
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
const parts: ReactNode[] = [];
if (contact.last_seen) {
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
}
if (contact.last_path_len === -1) {
parts.push('flood');
} else if (contact.last_path_len === 0) {
parts.push(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Reset path to flood?')) {
api.resetContactPath(contact.public_key).then(
() => toast.success('Path reset to flood'),
() => toast.error('Failed to reset path')
);
}
}}
title="Click to reset path to flood"
>
direct
</span>
);
} else if (contact.last_path_len > 0) {
parts.push(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Reset path to flood?')) {
api.resetContactPath(contact.public_key).then(
() => toast.success('Path reset to flood'),
() => toast.error('Failed to reset path')
);
}
}}
title="Click to reset path to flood"
>
{contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''}
</span>
);
}
if (isValidLocation(contact.lat, contact.lon)) {
const distFromUs =
ourLat != null && ourLon != null && isValidLocation(ourLat, ourLon)
? calculateDistance(ourLat, ourLon, contact.lat, contact.lon)
: null;
parts.push(
<span key="coords">
<span
className="font-mono cursor-pointer hover:text-primary hover:underline"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
const url =
window.location.origin +
window.location.pathname +
getMapFocusHash(contact.public_key);
window.open(url, '_blank');
}}
title="View on map"
>
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
</span>
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
</span>
);
}
if (parts.length === 0) return null;
return (
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
(
{parts.map((part, i) => (
<span key={i}>
{i > 0 && ', '}
{part}
</span>
))}
)
</span>
);
}

View File

@@ -1,46 +1,13 @@
import '../utils/meshcoreDecoderPatch';
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { GroupTextCracker, type ProgressReport } from 'meshcore-hashtag-cracker';
import NoSleep from 'nosleep.js';
import type { RawPacket, Channel } from '../types';
import { api } from '../api';
import { extractRawPacketPayload } from '../utils/rawPacketPayload';
import { toast } from './ui/sonner';
import { cn } from '@/lib/utils';
/**
* Extract the payload from a raw packet hex string, skipping header and path.
* Returns the payload as a hex string, or null if malformed.
*/
function extractPayload(packetHex: string): string | null {
if (packetHex.length < 4) return null; // Need at least 2 bytes
try {
const header = parseInt(packetHex.slice(0, 2), 16);
const routeType = header & 0x03;
let offset = 2; // 1 byte = 2 hex chars
// Skip transport codes if present (TRANSPORT_FLOOD=0, TRANSPORT_DIRECT=3)
if (routeType === 0x00 || routeType === 0x03) {
if (packetHex.length < offset + 8) return null; // Need 4 more bytes
offset += 8; // 4 bytes = 8 hex chars
}
// Get path length
if (packetHex.length < offset + 2) return null;
const pathLength = parseInt(packetHex.slice(offset, offset + 2), 16);
offset += 2;
// Skip path data
const pathBytes = pathLength * 2; // hex chars
if (packetHex.length < offset + pathBytes) return null;
offset += pathBytes;
// Rest is payload
return packetHex.slice(offset);
} catch {
return null;
}
}
interface CrackedRoom {
roomName: string;
key: string;
@@ -98,6 +65,7 @@ export function CrackerPanel({
const twoWordModeRef = useRef(false);
const undecryptedIdsRef = useRef<Set<number>>(new Set());
const seenPayloadsRef = useRef<Set<string>>(new Set());
const existingChannelKeysRef = useRef<Set<string>>(new Set());
// Initialize cracker and NoSleep
useEffect(() => {
@@ -155,6 +123,10 @@ export function CrackerPanel({
[channels]
);
useEffect(() => {
existingChannelKeysRef.current = existingChannelKeys;
}, [existingChannelKeys]);
// Filter packets to only undecrypted GROUP_TEXT
const undecryptedGroupText = packets.filter(
(p) => p.payload_type === 'GROUP_TEXT' && !p.decrypted
@@ -172,7 +144,7 @@ export function CrackerPanel({
for (const packet of undecryptedGroupText) {
if (!newQueue.has(packet.id)) {
// Extract payload and check for duplicates
const payload = extractPayload(packet.data);
const payload = extractRawPacketPayload(packet.data);
if (payload && seenPayloadsRef.current.has(payload)) {
// Skip - we already have a packet with this payload queued
newSkipped++;
@@ -365,7 +337,7 @@ export function CrackerPanel({
// Auto-add channel if not already exists
const keyUpper = result.key.toUpperCase();
if (!existingChannelKeys.has(keyUpper)) {
if (!existingChannelKeysRef.current.has(keyUpper)) {
try {
const channelName = '#' + result.roomName;
await onChannelCreate(channelName, result.key);
@@ -426,7 +398,7 @@ export function CrackerPanel({
if (isRunningRef.current) {
setTimeout(() => processNext(), 100);
}
}, [existingChannelKeys, onChannelCreate]);
}, [onChannelCreate]);
// Start/stop handlers
const handleStart = () => {
@@ -521,7 +493,7 @@ export function CrackerPanel({
onClick={isRunning ? handleStop : handleStart}
disabled={!wordlistLoaded || gpuAvailable === false}
className={cn(
'w-48 px-4 py-1.5 rounded text-sm font-medium',
'w-48 px-4 py-1.5 rounded text-sm font-medium focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
isRunning
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
: 'bg-primary text-primary-foreground hover:bg-primary/90',
@@ -543,7 +515,7 @@ export function CrackerPanel({
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
</span>
<span className="text-muted-foreground">
Cracked: <span className="text-green-500 font-medium">{crackedCount}</span>
Cracked: <span className="text-success font-medium">{crackedCount}</span>
</span>
<span className="text-muted-foreground">
Failed: <span className="text-destructive font-medium">{failedCount}</span>
@@ -581,7 +553,14 @@ export function CrackerPanel({
: `${Math.round(progress.etaSeconds / 60)}m`}
</span>
</div>
<div className="h-2 bg-muted rounded overflow-hidden">
<div
className="h-2 bg-muted rounded overflow-hidden"
role="progressbar"
aria-valuenow={Math.round(progress.percent)}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Cracking progress"
>
<div
className="h-full bg-primary transition-all duration-200"
style={{ width: `${progress.percent}%` }}
@@ -592,12 +571,14 @@ export function CrackerPanel({
{/* GPU status */}
{gpuAvailable === false && (
<div className="text-sm text-destructive">
<div className="text-sm text-destructive" role="alert">
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
</div>
)}
{!wordlistLoaded && gpuAvailable !== false && (
<div className="text-sm text-muted-foreground">Loading wordlist...</div>
<div className="text-sm text-muted-foreground" role="status">
Loading wordlist...
</div>
)}
{/* Cracked rooms list */}
@@ -608,9 +589,9 @@ export function CrackerPanel({
{crackedRooms.map((room, i) => (
<div
key={i}
className="text-sm bg-green-950/30 border border-green-900/50 rounded px-2 py-1"
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
>
<span className="text-green-400 font-medium">#{room.roomName}</span>
<span className="text-success font-medium">#{room.roomName}</span>
<span className="text-muted-foreground ml-2 text-xs">
"{room.message.slice(0, 50)}
{room.message.length > 50 ? '...' : ''}"

View File

@@ -94,13 +94,16 @@ function MapBoundsHandler({
}
export function MapView({ contacts, focusedKey }: MapViewProps) {
// Filter to contacts with GPS coordinates, heard within the last 7 days
// Filter to contacts with GPS coordinates, heard within the last 7 days.
// Always include the focused contact so "view on map" links work for older nodes.
const mappableContacts = useMemo(() => {
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
return contacts.filter(
(c) => isValidLocation(c.lat, c.lon) && c.last_seen != null && c.last_seen > sevenDaysAgo
(c) =>
isValidLocation(c.lat, c.lon) &&
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
);
}, [contacts]);
}, [contacts, focusedKey]);
// Find the focused contact by key
const focusedContact = useMemo(() => {
@@ -137,22 +140,27 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#22c55e]" /> &lt;1h
<span className="w-3 h-3 rounded-full bg-[#22c55e]" aria-hidden="true" /> &lt;1h
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#4ade80]" /> &lt;1d
<span className="w-3 h-3 rounded-full bg-[#4ade80]" aria-hidden="true" /> &lt;1d
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#a3e635]" /> &lt;3d
<span className="w-3 h-3 rounded-full bg-[#a3e635]" aria-hidden="true" /> &lt;3d
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#9ca3af]" /> older
<span className="w-3 h-3 rounded-full bg-[#9ca3af]" aria-hidden="true" /> older
</span>
</div>
</div>
{/* Map - z-index constrained to stay below modals/sheets */}
<div className="flex-1 relative" style={{ zIndex: 0 }}>
<div
className="flex-1 relative"
style={{ zIndex: 0 }}
role="img"
aria-label="Map showing mesh node locations"
>
<MapContainer
center={[20, 0]}
zoom={2}
@@ -186,7 +194,11 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
<Popup>
<div className="text-sm">
<div className="font-medium flex items-center gap-1">
{isRepeater && <span title="Repeater">🛜</span>}
{isRepeater && (
<span title="Repeater" aria-hidden="true">
🛜
</span>
)}
{displayName}
</div>
<div className="text-xs text-gray-500 mt-1">

View File

@@ -160,6 +160,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
type="text"
autoComplete="off"
name="chat-message-input"
aria-label={placeholder || 'Type a message'}
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
@@ -185,9 +186,9 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
className={cn(
'tabular-nums',
limitState === 'error' || limitState === 'danger'
? 'text-red-500 font-medium'
? 'text-destructive font-medium'
: limitState === 'warning'
? 'text-yellow-500'
? 'text-warning'
: 'text-muted-foreground'
)}
>
@@ -195,7 +196,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
{remaining < 0 && ` (${remaining})`}
</span>
{warningMessage && (
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
<span className={cn(limitState === 'error' ? 'text-destructive' : 'text-warning')}>
{warningMessage}
</span>
)}
@@ -208,9 +209,9 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
className={cn(
'tabular-nums',
limitState === 'error' || limitState === 'danger'
? 'text-red-500 font-medium'
? 'text-destructive font-medium'
: limitState === 'warning'
? 'text-yellow-500'
? 'text-warning'
: 'text-muted-foreground'
)}
>
@@ -219,7 +220,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
</span>
)}
{warningMessage && (
<span className={cn(limitState === 'error' ? 'text-red-500' : 'text-yellow-500')}>
<span className={cn(limitState === 'error' ? 'text-destructive' : 'text-warning')}>
{warningMessage}
</span>
)}

View File

@@ -13,6 +13,7 @@ import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
import { PathModal } from './PathModal';
import { handleKeyboardActivate } from '../utils/a11y';
import { cn } from '@/lib/utils';
interface MessageListProps {
@@ -26,7 +27,13 @@ interface MessageListProps {
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
radioName?: string;
config?: RadioConfig | null;
onOpenContactInfo?: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string, fromChannel?: boolean) => void;
targetMessageId?: number | null;
onTargetReached?: () => void;
hasNewerMessages?: boolean;
loadingNewer?: boolean;
onLoadNewer?: () => void;
onJumpToBottom?: () => void;
}
// URL regex for linkifying plain text
@@ -125,11 +132,15 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
return (
<span
className={className}
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
title="View message path"
aria-label={`${hopInfo.display}, view path`}
>
{label}
</span>
@@ -150,6 +161,12 @@ export function MessageList({
radioName,
config,
onOpenContactInfo,
targetMessageId,
onTargetReached,
hasNewerMessages = false,
loadingNewer = false,
onLoadNewer,
onJumpToBottom,
}: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0);
@@ -163,6 +180,8 @@ export function MessageList({
} | null>(null);
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
const targetScrolledRef = useRef(false);
// Capture scroll state in the scroll handler BEFORE any state updates
const scrollStateRef = useRef({
@@ -201,8 +220,10 @@ export function MessageList({
if (scrollStateRef.current.wasNearTop && scrollHeightDiff > 0) {
// User was near top (loading older) - preserve position by adding the height diff
list.scrollTop = scrollStateRef.current.scrollTop + scrollHeightDiff;
} else if (scrollStateRef.current.wasNearBottom) {
// User was near bottom - scroll to bottom for new messages (including sent)
} else if (scrollStateRef.current.wasNearBottom && !hasNewerMessagesRef.current) {
// User was near bottom - scroll to bottom for new messages (including sent).
// Skip when browsing mid-history (hasNewerMessages) so that forward-pagination
// appends in place instead of chasing the bottom in an infinite load loop.
list.scrollTop = list.scrollHeight;
}
}
@@ -210,6 +231,25 @@ export function MessageList({
prevMessagesLengthRef.current = messages.length;
}, [messages]);
// Scroll to target message and highlight it
useLayoutEffect(() => {
if (!targetMessageId || targetScrolledRef.current || messages.length === 0) return;
const el = listRef.current?.querySelector(`[data-message-id="${targetMessageId}"]`);
if (!el) return;
// Prevent the initial-load layout effect from overriding our scroll
isInitialLoadRef.current = false;
el.scrollIntoView({ block: 'center' });
setHighlightedMessageId(targetMessageId);
targetScrolledRef.current = true;
onTargetReached?.();
}, [messages, targetMessageId, onTargetReached]);
// Reset target scroll tracking when targetMessageId changes
useEffect(() => {
targetScrolledRef.current = false;
}, [targetMessageId]);
// Reset initial load flag when conversation changes (messages becomes empty then filled)
useEffect(() => {
if (messages.length === 0) {
@@ -267,9 +307,15 @@ export function MessageList({
const onLoadOlderRef = useRef(onLoadOlder);
const loadingOlderRef = useRef(loadingOlder);
const hasOlderMessagesRef = useRef(hasOlderMessages);
const onLoadNewerRef = useRef(onLoadNewer);
const loadingNewerRef = useRef(loadingNewer);
const hasNewerMessagesRef = useRef(hasNewerMessages);
onLoadOlderRef.current = onLoadOlder;
loadingOlderRef.current = loadingOlder;
hasOlderMessagesRef.current = hasOlderMessages;
onLoadNewerRef.current = onLoadNewer;
loadingNewerRef.current = loadingNewer;
hasNewerMessagesRef.current = hasNewerMessages;
// Handle scroll - capture state and detect when user is near top/bottom
// Stable callback: reads changing values from refs, never recreated.
@@ -291,26 +337,39 @@ export function MessageList({
// Show scroll-to-bottom button when not near the bottom (more than 100px away)
setShowScrollToBottom(distanceFromBottom > 100);
if (!onLoadOlderRef.current || loadingOlderRef.current || !hasOlderMessagesRef.current) return;
// Trigger load when within 100px of top
if (scrollTop < 100) {
if (!onLoadOlderRef.current || loadingOlderRef.current || !hasOlderMessagesRef.current) {
// skip older load
} else if (scrollTop < 100) {
onLoadOlderRef.current();
}
// Trigger load newer when within 100px of bottom
if (
onLoadNewerRef.current &&
!loadingNewerRef.current &&
hasNewerMessagesRef.current &&
distanceFromBottom < 100
) {
onLoadNewerRef.current();
}
}, []);
// Scroll to bottom handler
// Scroll to bottom handler (or jump to bottom if viewing historical messages)
const scrollToBottom = useCallback(() => {
if (hasNewerMessages && onJumpToBottom) {
onJumpToBottom();
return;
}
if (listRef.current) {
listRef.current.scrollTop = listRef.current.scrollHeight;
}
}, []);
}, [hasNewerMessages, onJumpToBottom]);
// Sort messages by received_at ascending (oldest first)
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
const sortedMessages = useMemo(
() => [...messages].sort((a, b) => a.received_at - b.received_at),
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
[messages]
);
@@ -377,7 +436,7 @@ export function MessageList({
if (loading) {
return (
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground">
<div className="flex-1 overflow-y-auto p-5 text-center text-muted-foreground" role="status">
Loading messages...
</div>
);
@@ -406,7 +465,7 @@ export function MessageList({
onScroll={handleScroll}
>
{loadingOlder && (
<div className="text-center py-2 text-muted-foreground text-sm">
<div className="text-center py-2 text-muted-foreground text-sm" role="status">
Loading older messages...
</div>
)}
@@ -459,6 +518,7 @@ export function MessageList({
return (
<div
key={msg.id}
data-message-id={msg.id}
className={cn(
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
@@ -469,9 +529,12 @@ export function MessageList({
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<span
role={onOpenContactInfo ? 'button' : undefined}
tabIndex={onOpenContactInfo ? 0 : undefined}
onKeyDown={onOpenContactInfo ? handleKeyboardActivate : undefined}
onClick={
onOpenContactInfo && !avatarKey.startsWith('name:')
? () => onOpenContactInfo(avatarKey)
onOpenContactInfo
? () => onOpenContactInfo(avatarKey, msg.type === 'CHAN')
: undefined
}
>
@@ -479,7 +542,7 @@ export function MessageList({
name={avatarName}
publicKey={avatarKey}
size={32}
clickable={!!onOpenContactInfo && !avatarKey.startsWith('name:')}
clickable={!!onOpenContactInfo}
/>
</span>
)}
@@ -488,7 +551,8 @@ export function MessageList({
<div
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming'
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
highlightedMessageId === msg.id && 'message-highlight'
)}
>
{showAvatar && (
@@ -496,6 +560,9 @@ export function MessageList({
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
@@ -552,6 +619,9 @@ export function MessageList({
msg.paths && msg.paths.length > 0 ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
@@ -562,6 +632,7 @@ export function MessageList({
});
}}
title="View echo paths"
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
@@ -569,6 +640,9 @@ export function MessageList({
) : onResendChannelMessage && msg.type === 'CHAN' ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
@@ -579,6 +653,7 @@ export function MessageList({
});
}}
title="Message status"
aria-label="No echoes yet — view message status"
>
{' '}
?
@@ -594,14 +669,25 @@ export function MessageList({
</div>
);
})}
{loadingNewer && (
<div className="text-center py-2 text-muted-foreground text-sm" role="status">
Loading newer messages...
</div>
)}
{!loadingNewer && hasNewerMessages && (
<div className="text-center py-2 text-muted-foreground text-xs">
Scroll down for newer messages
</div>
)}
</div>
{/* Scroll to bottom button */}
{showScrollToBottom && (
<button
onClick={scrollToBottom}
className="absolute bottom-4 right-4 w-9 h-9 rounded-full bg-card hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-all hover:scale-105"
className="absolute bottom-4 right-4 w-9 h-9 rounded-full bg-card hover:bg-accent border border-border flex items-center justify-center shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title="Scroll to bottom"
aria-label="Scroll to bottom"
>
<svg
xmlns="http://www.w3.org/2000/svg"

View File

@@ -29,7 +29,11 @@ export function NeighborsMiniMap({ neighbors, radioLat, radioLon, radioName }: P
const center: [number, number] = hasRadio ? [radioLat!, radioLon!] : [valid[0].lat, valid[0].lon];
return (
<div className="min-h-48 flex-1 rounded border border-border overflow-hidden">
<div
className="min-h-48 flex-1 rounded border border-border overflow-hidden"
role="img"
aria-label="Map showing repeater neighbor locations"
>
<MapContainer
center={center}
zoom={10}

View File

@@ -48,6 +48,15 @@ export function NewMessageModal({
const [loading, setLoading] = useState(false);
const hashtagInputRef = useRef<HTMLInputElement>(null);
const resetForm = () => {
setName('');
setContactKey('');
setRoomKey('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
};
const handleCreate = async () => {
setError('');
setLoading(true);
@@ -77,6 +86,7 @@ export function NewMessageModal({
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
}
resetForm();
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to create');
@@ -121,7 +131,15 @@ export function NewMessageModal({
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
resetForm();
onClose();
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
@@ -137,8 +155,7 @@ export function NewMessageModal({
value={tab}
onValueChange={(v) => {
setTab(v as Tab);
setName('');
setError('');
resetForm();
}}
className="w-full"
>
@@ -157,13 +174,22 @@ export function NewMessageModal({
contacts.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent"
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
onClick={() => {
onSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
});
resetForm();
onClose();
}}
>
@@ -228,8 +254,9 @@ export function NewMessageModal({
setRoomKey(hex);
}}
title="Generate random key"
aria-label="Generate random key"
>
🎲
<span aria-hidden="true">🎲</span>
</Button>
</div>
</div>
@@ -291,10 +318,20 @@ export function NewMessageModal({
</div>
)}
{error && <div className="text-sm text-destructive">{error}</div>}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>
<Button
variant="outline"
onClick={() => {
resetForm();
onClose();
}}
>
Cancel
</Button>
{tab === 'hashtag' && (

View File

@@ -56,6 +56,7 @@ interface GraphNode extends SimulationNodeDatum3D {
type: NodeType;
isAmbiguous: boolean;
lastActivity: number;
lastActivityReason?: string;
lastSeen?: number | null;
probableIdentity?: string | null;
ambiguousNames?: string[];
@@ -110,6 +111,24 @@ function arraysEqual(a: string[], b: string[]): boolean {
return true;
}
function formatRelativeTime(timestamp: number): string {
const seconds = Math.floor((Date.now() - timestamp) / 1000);
if (seconds < 5) return 'just now';
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return secs > 0 ? `${minutes}m ${secs}s ago` : `${minutes}m ago`;
}
function normalizePacketTimestampMs(timestamp: number | null | undefined): number {
if (!Number.isFinite(timestamp) || !timestamp || timestamp <= 0) {
return Date.now();
}
const ts = Number(timestamp);
// Backend currently sends Unix seconds; tolerate millis if already provided.
return ts > 1_000_000_000_000 ? ts : ts * 1000;
}
// =============================================================================
// DATA LAYER HOOK (3D variant)
// =============================================================================
@@ -128,6 +147,7 @@ interface UseVisualizerData3DOptions {
particleSpeedMultiplier: number;
observationWindowSec: number;
pruneStaleNodes: boolean;
pruneStaleMinutes: number;
}
interface VisualizerData3D {
@@ -153,6 +173,7 @@ function useVisualizerData3D({
particleSpeedMultiplier,
observationWindowSec,
pruneStaleNodes,
pruneStaleMinutes,
}: UseVisualizerData3DOptions): VisualizerData3D {
const nodesRef = useRef<Map<string, GraphNode>>(new Map());
const linksRef = useRef<Map<string, GraphLink>>(new Map());
@@ -341,11 +362,13 @@ function useVisualizerData3D({
isAmbiguous: boolean,
probableIdentity?: string | null,
ambiguousNames?: string[],
lastSeen?: number | null
lastSeen?: number | null,
activityAtMs?: number
) => {
const activityAt = activityAtMs ?? Date.now();
const existing = nodesRef.current.get(id);
if (existing) {
existing.lastActivity = Date.now();
existing.lastActivity = Math.max(existing.lastActivity, activityAt);
if (name) existing.name = name;
if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity;
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
@@ -360,7 +383,7 @@ function useVisualizerData3D({
name,
type,
isAmbiguous,
lastActivity: Date.now(),
lastActivity: activityAt,
probableIdentity,
lastSeen,
ambiguousNames,
@@ -373,13 +396,14 @@ function useVisualizerData3D({
[]
);
const addLink = useCallback((sourceId: string, targetId: string) => {
const addLink = useCallback((sourceId: string, targetId: string, activityAtMs?: number) => {
const activityAt = activityAtMs ?? Date.now();
const key = [sourceId, targetId].sort().join('->');
const existing = linksRef.current.get(key);
if (existing) {
existing.lastActivity = Date.now();
existing.lastActivity = Math.max(existing.lastActivity, activityAt);
} else {
linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: Date.now() });
linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: activityAt });
}
}, []);
@@ -461,6 +485,7 @@ function useVisualizerData3D({
isRepeater: boolean,
showAmbiguous: boolean,
myPrefix: string | null,
activityAtMs: number,
trafficContext?: { packetSource: string | null; nextPrefix: string | null }
): string | null => {
if (source.type === 'pubkey') {
@@ -475,7 +500,8 @@ function useVisualizerData3D({
false,
undefined,
undefined,
contact?.last_seen
contact?.last_seen,
activityAtMs
);
return nodeId;
}
@@ -492,12 +518,22 @@ function useVisualizerData3D({
false,
undefined,
undefined,
contact.last_seen
contact.last_seen,
activityAtMs
);
return nodeId;
}
const nodeId = `name:${source.value}`;
addNode(nodeId, source.value, 'client', false, undefined);
addNode(
nodeId,
source.value,
'client',
false,
undefined,
undefined,
undefined,
activityAtMs
);
return nodeId;
}
@@ -514,7 +550,8 @@ function useVisualizerData3D({
false,
undefined,
undefined,
contact.last_seen
contact.last_seen,
activityAtMs
);
return nodeId;
}
@@ -527,7 +564,16 @@ function useVisualizerData3D({
if (filtered.length === 1) {
const c = filtered[0];
const nodeId = c.public_key.slice(0, 12).toLowerCase();
addNode(nodeId, c.name, getNodeType(c), false, undefined, undefined, c.last_seen);
addNode(
nodeId,
c.name,
getNodeType(c),
false,
undefined,
undefined,
c.last_seen,
activityAtMs
);
return nodeId;
}
@@ -587,7 +633,8 @@ function useVisualizerData3D({
true,
probableIdentity,
ambiguousNames,
lastSeen
lastSeen,
activityAtMs
);
return nodeId;
}
@@ -608,7 +655,8 @@ function useVisualizerData3D({
(
parsed: ReturnType<typeof parsePacket>,
packet: RawPacket,
myPrefix: string | null
myPrefix: string | null,
activityAtMs: number
): string[] => {
if (!parsed) return [];
const path: string[] = [];
@@ -619,7 +667,8 @@ function useVisualizerData3D({
{ type: 'pubkey', value: parsed.advertPubkey },
false,
false,
myPrefix
myPrefix,
activityAtMs
);
if (nodeId) {
path.push(nodeId);
@@ -630,7 +679,8 @@ function useVisualizerData3D({
{ type: 'pubkey', value: parsed.anonRequestPubkey },
false,
false,
myPrefix
myPrefix,
activityAtMs
);
if (nodeId) {
path.push(nodeId);
@@ -645,7 +695,8 @@ function useVisualizerData3D({
{ type: 'prefix', value: parsed.srcHash },
false,
showAmbiguousNodes,
myPrefix
myPrefix,
activityAtMs
);
if (nodeId) {
path.push(nodeId);
@@ -655,10 +706,16 @@ function useVisualizerData3D({
} else if (parsed.payloadType === PayloadType.GroupText) {
const senderName = parsed.groupTextSender || packet.decrypted_info?.sender;
if (senderName) {
const nodeId = resolveNode({ type: 'name', value: senderName }, false, false, myPrefix);
if (nodeId) {
path.push(nodeId);
packetSource = nodeId;
const resolved = resolveNode(
{ type: 'name', value: senderName },
false,
false,
myPrefix,
activityAtMs
);
if (resolved) {
path.push(resolved);
packetSource = resolved;
}
}
}
@@ -671,6 +728,7 @@ function useVisualizerData3D({
true,
showAmbiguousPaths,
myPrefix,
activityAtMs,
{ packetSource, nextPrefix }
);
if (nodeId) path.push(nodeId);
@@ -684,7 +742,8 @@ function useVisualizerData3D({
{ type: 'prefix', value: parsed.dstHash },
false,
showAmbiguousNodes,
myPrefix
myPrefix,
activityAtMs
);
if (nodeId) path.push(nodeId);
else path.push('self');
@@ -722,12 +781,22 @@ function useVisualizerData3D({
const parsed = parsePacket(packet.data);
if (!parsed) continue;
const path = buildPath(parsed, packet, myPrefix);
const packetActivityAt = normalizePacketTimestampMs(packet.timestamp);
const path = buildPath(parsed, packet, myPrefix, packetActivityAt);
if (path.length < 2) continue;
// Tag each node with why it's considered active
const label = getPacketLabel(parsed.payloadType);
for (let i = 0; i < path.length; i++) {
const n = nodesRef.current.get(path[i]);
if (n && n.id !== 'self') {
n.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`;
}
}
for (let i = 0; i < path.length - 1; i++) {
if (path[i] !== path[i + 1]) {
addLink(path[i], path[i + 1]);
addLink(path[i], path[i + 1], packetActivityAt);
needsUpdate = true;
}
}
@@ -911,12 +980,12 @@ function useVisualizerData3D({
};
}, []);
// Prune nodes with no activity in the last 5 minutes
// Prune nodes with no recent activity
useEffect(() => {
if (!pruneStaleNodes) return;
const STALE_MS = 5 * 60 * 1000;
const PRUNE_INTERVAL_MS = 10_000;
const STALE_MS = pruneStaleMinutes * 60 * 1000;
const PRUNE_INTERVAL_MS = 1_000;
const interval = setInterval(() => {
const cutoff = Date.now() - STALE_MS;
@@ -943,7 +1012,7 @@ function useVisualizerData3D({
}, PRUNE_INTERVAL_MS);
return () => clearInterval(interval);
}, [pruneStaleNodes, syncSimulation]);
}, [pruneStaleNodes, pruneStaleMinutes, syncSimulation]);
return useMemo(
() => ({
@@ -1025,11 +1094,13 @@ export function PacketVisualizer3D({
const [showControls, setShowControls] = useState(savedSettings.showControls);
const [autoOrbit, setAutoOrbit] = useState(savedSettings.autoOrbit);
const [pruneStaleNodes, setPruneStaleNodes] = useState(savedSettings.pruneStaleNodes);
const [pruneStaleMinutes, setPruneStaleMinutes] = useState(savedSettings.pruneStaleMinutes);
const [repeaterAdvertPaths, setRepeaterAdvertPaths] = useState<ContactAdvertPathSummary[]>([]);
// Persist visualizer controls to localStorage on change
useEffect(() => {
saveVisualizerSettings({
...getVisualizerSettings(),
showAmbiguousPaths,
showAmbiguousNodes,
useAdvertPathHints,
@@ -1039,6 +1110,7 @@ export function PacketVisualizer3D({
letEmDrift,
particleSpeedMultiplier,
pruneStaleNodes,
pruneStaleMinutes,
autoOrbit,
showControls,
});
@@ -1052,6 +1124,7 @@ export function PacketVisualizer3D({
letEmDrift,
particleSpeedMultiplier,
pruneStaleNodes,
pruneStaleMinutes,
autoOrbit,
showControls,
]);
@@ -1103,6 +1176,7 @@ export function PacketVisualizer3D({
particleSpeedMultiplier,
observationWindowSec,
pruneStaleNodes,
pruneStaleMinutes,
});
const dataRef = useRef(data);
useEffect(() => {
@@ -1649,7 +1723,12 @@ export function PacketVisualizer3D({
}, []);
return (
<div ref={containerRef} className="w-full h-full bg-background relative overflow-hidden">
<div
ref={containerRef}
className="w-full h-full bg-background relative overflow-hidden"
role="img"
aria-label="3D mesh network visualizer showing radio nodes as colored spheres and packet transmissions as animated arcs between them"
>
{/* Legend */}
{showControls && (
<div className="absolute bottom-4 left-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border z-10">
@@ -1689,12 +1768,30 @@ export function PacketVisualizer3D({
)}
{/* Options */}
<div className="absolute top-4 left-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border z-10">
<div
className={`absolute top-4 left-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border z-10 transition-opacity ${!showControls ? 'opacity-40 hover:opacity-100' : ''}`}
>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={showControls}
onCheckedChange={(c) => setShowControls(c === true)}
/>
<span title="Toggle legends and controls visibility">Show controls</span>
</label>
{onFullScreenChange && (
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={!fullScreen}
onCheckedChange={(c) => onFullScreenChange(c !== true)}
/>
<span title="Show or hide the packet feed sidebar">Show packet feed sidebar</span>
</label>
)}
</div>
{showControls && (
<>
<div>Nodes: {data.stats.nodes}</div>
<div>Links: {data.stats.links}</div>
<div className="border-t border-border pt-2 mt-1 flex flex-col gap-2">
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
@@ -1769,10 +1866,35 @@ export function PacketVisualizer3D({
checked={pruneStaleNodes}
onCheckedChange={(c) => setPruneStaleNodes(c === true)}
/>
<span title="Automatically remove nodes with no traffic in the last 5 minutes to keep the mesh manageable">
Only show nodes heard/pathed in last 5min
<span title="Automatically remove nodes with no traffic within the configured window to keep the mesh manageable">
Only show recently heard/in-a-path nodes
</span>
</label>
{pruneStaleNodes && (
<div className="flex items-center gap-2 pl-6">
<label
htmlFor="prune-window"
className="text-muted-foreground whitespace-nowrap"
>
Window:
</label>
<input
id="prune-window"
type="number"
min={1}
max={60}
value={pruneStaleMinutes}
onChange={(e) => {
const v = parseInt(e.target.value, 10);
if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v);
}}
className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm"
/>
<span className="text-muted-foreground" aria-hidden="true">
min
</span>
</div>
)}
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={letEmDrift}
@@ -1793,12 +1915,14 @@ export function PacketVisualizer3D({
</label>
<div className="flex flex-col gap-1 mt-1">
<label
htmlFor="viz-repulsion"
className="text-muted-foreground"
title="How strongly nodes repel each other. Higher values spread nodes out more."
>
Repulsion: {Math.abs(chargeStrength)}
</label>
<input
id="viz-repulsion"
type="range"
min="50"
max="2500"
@@ -1809,12 +1933,14 @@ export function PacketVisualizer3D({
</div>
<div className="flex flex-col gap-1 mt-1">
<label
htmlFor="viz-packet-speed"
className="text-muted-foreground"
title="How fast particles travel along links. Higher values make packets move faster."
>
Packet speed: {particleSpeedMultiplier}x
</label>
<input
id="viz-packet-speed"
type="range"
min="1"
max="5"
@@ -1840,32 +1966,12 @@ export function PacketVisualizer3D({
Clear &amp; Reset
</button>
</div>
<div className="border-t border-border pt-2 mt-1">
<div>Nodes: {data.stats.nodes}</div>
<div>Links: {data.stats.links}</div>
</div>
</>
)}
<div
className={
!showControls
? 'flex flex-col gap-2'
: 'border-t border-border pt-2 mt-1 flex flex-col gap-2'
}
>
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={showControls}
onCheckedChange={(c) => setShowControls(c === true)}
/>
<span title="Toggle legends and controls visibility">Show controls</span>
</label>
{onFullScreenChange && (
<label className="flex items-center gap-2 cursor-pointer">
<Checkbox
checked={!fullScreen}
onCheckedChange={(c) => onFullScreenChange(c !== true)}
/>
<span title="Show or hide the packet feed sidebar">Show packet feed sidebar</span>
</label>
)}
</div>
</div>
</div>
@@ -1903,6 +2009,12 @@ export function PacketVisualizer3D({
{node.ambiguousNames.join(', ')}
</div>
)}
{node.type !== 'self' && (
<div className="text-muted-foreground border-t border-border pt-1 mt-1">
<div>Last active: {formatRelativeTime(node.lastActivity)}</div>
{node.lastActivityReason && <div>Reason: {node.lastActivityReason}</div>}
</div>
)}
{neighbors.length > 0 && (
<div className="text-muted-foreground border-t border-border pt-1 mt-1">
<div className="mb-0.5">Traffic exchanged with:</div>

View File

@@ -1,3 +1,4 @@
import { useState, lazy, Suspense } from 'react';
import type { Contact, RadioConfig, MessagePath } from '../types';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
import { Button } from './ui/button';
@@ -14,6 +15,10 @@ import {
import { formatTime } from '../utils/messageParser';
import { getMapFocusHash } from '../utils/urlHash';
const PathRouteMap = lazy(() =>
import('./PathRouteMap').then((m) => ({ default: m.PathRouteMap }))
);
interface PathModalProps {
open: boolean;
onClose: () => void;
@@ -39,6 +44,7 @@ export function PathModal({
isResendable,
onResend,
}: PathModalProps) {
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
const hasPaths = paths.length > 0;
@@ -46,7 +52,7 @@ export function PathModal({
const resolvedPaths = hasPaths
? paths.map((p) => ({
...p,
resolved: resolvePath(p.path, senderInfo, contacts, config),
resolved: resolvePath(p.path, senderInfo, contacts, config, p.path_len),
}))
: [];
@@ -67,13 +73,13 @@ export function PathModal({
) : hasSinglePath ? (
<>
This shows <em>one route</em> that this message traveled through the mesh network.
Routers may be incorrectly identified due to prefix collisions between heard and
non-heard router advertisements.
Repeaters may be incorrectly identified due to prefix collisions between heard and
non-heard repeater advertisements.
</>
) : (
<>
This message was received via <strong>{paths.length} different routes</strong>.
Routers may be incorrectly identified due to prefix collisions.
Repeaters may be incorrectly identified due to prefix collisions.
</>
)}
</DialogDescription>
@@ -84,7 +90,7 @@ export function PathModal({
{/* Raw path summary */}
<div className="text-sm">
{paths.map((p, index) => {
const hops = parsePathHops(p.path);
const hops = parsePathHops(p.path, p.path_len);
const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
return (
<div key={index}>
@@ -120,19 +126,55 @@ export function PathModal({
</div>
)}
{resolvedPaths.map((pathData, index) => (
<div key={index}>
{!hasSinglePath && (
<div className="text-sm text-foreground/70 font-semibold mb-2 pb-1 border-b border-border">
Path {index + 1}{' '}
<span className="font-normal text-muted-foreground">
received {formatTime(pathData.received_at)}
</span>
{resolvedPaths.map((pathData, index) => {
const mapExpanded = expandedMaps.has(index);
const toggleMap = () =>
setExpandedMaps((prev) => {
const next = new Set(prev);
if (next.has(index)) next.delete(index);
else next.add(index);
return next;
});
return (
<div key={index}>
<div className="flex items-center justify-between mb-2 pb-1 border-b border-border">
{!hasSinglePath ? (
<div className="text-sm text-foreground/70 font-semibold">
Path {index + 1}{' '}
<span className="font-normal text-muted-foreground">
received {formatTime(pathData.received_at)}
</span>
</div>
) : (
<div />
)}
<button
onClick={toggleMap}
aria-expanded={mapExpanded}
className="text-xs text-primary hover:underline cursor-pointer shrink-0 ml-2"
>
{mapExpanded ? 'Hide map' : 'Map route'}
</button>
</div>
)}
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
</div>
))}
{mapExpanded && (
<div className="mb-2">
<Suspense
fallback={
<div
className="rounded border border-border bg-muted/30 animate-pulse"
style={{ height: 220 }}
/>
}
>
<PathRouteMap resolved={pathData.resolved} senderInfo={senderInfo} />
</Suspense>
</div>
)}
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
</div>
);
})}
</div>
)}
@@ -360,7 +402,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
<div className="text-sm font-semibold">
<span className="text-foreground/80">Hop {hopNumber}:</span>{' '}
<span className="text-primary font-mono">{hop.prefix}</span>
{isAmbiguous && <span className="text-yellow-500 ml-1 font-normal">(ambiguous)</span>}
{isAmbiguous && <span className="text-warning ml-1 font-normal">(ambiguous)</span>}
</div>
{isUnknown ? (
@@ -426,6 +468,14 @@ function CoordinateLink({ lat, lon, publicKey }: { lat: number; lon: number; pub
return (
<span
className="text-xs text-muted-foreground font-mono cursor-pointer hover:text-primary hover:underline ml-1"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
onClick={handleClick}
title="View on map"
>

View File

@@ -0,0 +1,183 @@
import { useEffect, useRef } from 'react';
import { MapContainer, TileLayer, Marker, Tooltip, useMap } from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { isValidLocation } from '../utils/pathUtils';
import type { ResolvedPath, SenderInfo } from '../utils/pathUtils';
interface PathRouteMapProps {
resolved: ResolvedPath;
senderInfo: SenderInfo;
}
// Colors for hop markers (indexed by hop number - 1)
const HOP_COLORS = [
'#f97316', // Hop 1: orange
'#eab308', // Hop 2: yellow
'#22c55e', // Hop 3: green
'#06b6d4', // Hop 4: cyan
'#ec4899', // Hop 5: pink
'#f43f5e', // Hop 6: rose
'#a855f7', // Hop 7: purple
'#64748b', // Hop 8: slate
];
const SENDER_COLOR = '#3b82f6'; // blue
const RECEIVER_COLOR = '#8b5cf6'; // violet
function makeIcon(label: string, color: string): L.DivIcon {
return L.divIcon({
className: '',
iconSize: [24, 24],
iconAnchor: [12, 12],
html: `<div style="
width:24px;height:24px;border-radius:50%;
background:${color};color:#fff;
display:flex;align-items:center;justify-content:center;
font-size:11px;font-weight:700;
border:2px solid rgba(255,255,255,0.8);
box-shadow:0 1px 4px rgba(0,0,0,0.4);
">${label}</div>`,
});
}
function getHopColor(hopIndex: number): string {
return HOP_COLORS[hopIndex % HOP_COLORS.length];
}
/** Collect all valid [lat, lon] points for bounds fitting */
function collectPoints(resolved: ResolvedPath): [number, number][] {
const pts: [number, number][] = [];
if (isValidLocation(resolved.sender.lat, resolved.sender.lon)) {
pts.push([resolved.sender.lat!, resolved.sender.lon!]);
}
for (const hop of resolved.hops) {
for (const m of hop.matches) {
if (isValidLocation(m.lat, m.lon)) {
pts.push([m.lat!, m.lon!]);
}
}
}
if (isValidLocation(resolved.receiver.lat, resolved.receiver.lon)) {
pts.push([resolved.receiver.lat!, resolved.receiver.lon!]);
}
return pts;
}
/** Fit map bounds once on mount, then let the user pan/zoom freely */
function RouteMapBounds({ points }: { points: [number, number][] }) {
const map = useMap();
const fitted = useRef(false);
useEffect(() => {
if (fitted.current || points.length === 0) return;
fitted.current = true;
if (points.length === 1) {
map.setView(points[0], 12);
} else {
map.fitBounds(points as L.LatLngBoundsExpression, { padding: [30, 30], maxZoom: 14 });
}
}, [map, points]);
return null;
}
export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
const points = collectPoints(resolved);
const hasAnyGps = points.length > 0;
// Check if some nodes are missing GPS
let totalNodes = 2; // sender + receiver
let nodesWithGps = 0;
if (isValidLocation(resolved.sender.lat, resolved.sender.lon)) nodesWithGps++;
if (isValidLocation(resolved.receiver.lat, resolved.receiver.lon)) nodesWithGps++;
for (const hop of resolved.hops) {
if (hop.matches.length === 0) {
totalNodes++;
} else {
totalNodes += hop.matches.length;
nodesWithGps += hop.matches.filter((m) => isValidLocation(m.lat, m.lon)).length;
}
}
const someMissingGps = hasAnyGps && nodesWithGps < totalNodes;
if (!hasAnyGps) {
return (
<div className="h-14 rounded border border-border bg-muted/30 flex items-center justify-center text-sm text-muted-foreground">
No nodes in this route have GPS coordinates
</div>
);
}
const center: [number, number] = points[0];
return (
<div>
<div
className="rounded border border-border overflow-hidden"
role="img"
aria-label="Map showing message route between nodes"
style={{ height: 220 }}
>
<MapContainer
center={center}
zoom={10}
className="h-full w-full"
style={{ background: '#1a1a2e' }}
>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<RouteMapBounds points={points} />
{/* Sender marker */}
{isValidLocation(resolved.sender.lat, resolved.sender.lon) && (
<Marker
position={[resolved.sender.lat!, resolved.sender.lon!]}
icon={makeIcon('S', SENDER_COLOR)}
>
<Tooltip direction="top" offset={[0, -14]}>
{senderInfo.name || 'Sender'}
</Tooltip>
</Marker>
)}
{/* Hop markers */}
{resolved.hops.map((hop, hopIdx) =>
hop.matches
.filter((m) => isValidLocation(m.lat, m.lon))
.map((m, mIdx) => (
<Marker
key={`hop-${hopIdx}-${mIdx}`}
position={[m.lat!, m.lon!]}
icon={makeIcon(String(hopIdx + 1), getHopColor(hopIdx))}
>
<Tooltip direction="top" offset={[0, -14]}>
{m.name || m.public_key.slice(0, 12)}
</Tooltip>
</Marker>
))
)}
{/* Receiver marker */}
{isValidLocation(resolved.receiver.lat, resolved.receiver.lon) && (
<Marker
position={[resolved.receiver.lat!, resolved.receiver.lon!]}
icon={makeIcon('R', RECEIVER_COLOR)}
>
<Tooltip direction="top" offset={[0, -14]}>
{resolved.receiver.name || 'Receiver'}
</Tooltip>
</Marker>
)}
</MapContainer>
</div>
{someMissingGps && (
<p className="text-xs text-muted-foreground mt-1">
Some nodes in this route have no GPS and are not shown
</p>
)}
</div>
);
}

View File

@@ -1,3 +1,4 @@
import '../utils/meshcoreDecoderPatch';
import { useEffect, useRef, useMemo } from 'react';
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
import type { RawPacket } from '../types';
@@ -149,9 +150,9 @@ function decodePacketSummary(
function getRouteTypeColor(routeType: string): string {
switch (routeType) {
case 'Flood':
return 'bg-blue-500/20 text-blue-400';
return 'bg-info/20 text-info';
case 'Direct':
return 'bg-green-500/20 text-green-400';
return 'bg-success/20 text-success';
case 'Transport Flood':
return 'bg-purple-500/20 text-purple-400';
case 'Transport Direct':
@@ -225,7 +226,12 @@ export function RawPacketList({ packets }: RawPacketListProps) {
</span>
{/* Encryption status */}
{!packet.decrypted && <span title="Encrypted">🔒</span>}
{!packet.decrypted && (
<>
<span aria-hidden="true">🔒</span>
<span className="sr-only">Encrypted</span>
</>
)}
{/* Summary */}
<span

View File

@@ -1,14 +1,11 @@
import { type ReactNode } from 'react';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { RepeaterLogin } from './RepeaterLogin';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, Favorite } from '../types';
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
import { getMapFocusHash } from '../utils/urlHash';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
import { AclPane } from './repeater/RepeaterAclPane';
@@ -73,11 +70,14 @@ export function RepeaterDashboard({
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<div className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
<span className="flex-shrink-0 font-semibold text-base">{conversation.name}</span>
<span
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
navigator.clipboard.writeText(conversation.id);
toast.success('Contact key copied!');
@@ -86,91 +86,7 @@ export function RepeaterDashboard({
>
{conversation.id}
</span>
{contact &&
(() => {
const parts: ReactNode[] = [];
if (contact.last_seen) {
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
}
if (contact.last_path_len === -1) {
parts.push('flood');
} else if (contact.last_path_len === 0) {
parts.push(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Reset path to flood?')) {
api.resetContactPath(contact.public_key).then(
() => toast.success('Path reset to flood'),
() => toast.error('Failed to reset path')
);
}
}}
title="Click to reset path to flood"
>
direct
</span>
);
} else if (contact.last_path_len > 0) {
parts.push(
<span
key="path"
className="cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
if (window.confirm('Reset path to flood?')) {
api.resetContactPath(contact.public_key).then(
() => toast.success('Path reset to flood'),
() => toast.error('Failed to reset path')
);
}
}}
title="Click to reset path to flood"
>
{contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''}
</span>
);
}
if (isValidLocation(contact.lat, contact.lon)) {
const distFromUs =
radioLat != null && radioLon != null && isValidLocation(radioLat, radioLon)
? calculateDistance(radioLat, radioLon, contact.lat, contact.lon)
: null;
parts.push(
<span key="coords">
<span
className="font-mono cursor-pointer hover:text-primary hover:underline"
onClick={(e) => {
e.stopPropagation();
const url =
window.location.origin +
window.location.pathname +
getMapFocusHash(contact.public_key);
window.open(url, '_blank');
}}
title="View on map"
>
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
</span>
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
</span>
);
}
return parts.length > 0 ? (
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
(
{parts.map((part, i) => (
<span key={i}>
{i > 0 && ', '}
{part}
</span>
))}
)
</span>
) : null;
})()}
{contact && <ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />}
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
{loggedIn && (
@@ -179,38 +95,41 @@ export function RepeaterDashboard({
size="sm"
onClick={loadAll}
disabled={anyLoading}
className="text-xs border-green-600 text-green-600 hover:bg-green-600/10 hover:text-green-600"
className="text-xs border-success text-success hover:bg-success/10 hover:text-success"
>
{anyLoading ? 'Loading...' : 'Load All'}
</Button>
)}
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
title="Direct Trace"
aria-label="Direct Trace"
>
&#x1F6CE;
<span aria-hidden="true">&#x1F6CE;</span>
</button>
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onToggleFavorite('contact', conversation.id)}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
{isFav ? (
<span className="text-amber-400">&#9733;</span>
<span className="text-favorite">&#9733;</span>
) : (
<span className="text-muted-foreground">&#9734;</span>
)}
</button>
<button
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors"
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onDeleteContact(conversation.id)}
title="Delete"
aria-label="Delete"
>
&#128465;
<span aria-hidden="true">&#128465;</span>
</button>
</div>
</div>
</header>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4">

View File

@@ -47,11 +47,16 @@ export function RepeaterLogin({
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Repeater password..."
aria-label="Repeater password"
disabled={loading}
autoFocus
/>
{error && <p className="text-sm text-destructive text-center">{error}</p>}
{error && (
<p className="text-sm text-destructive text-center" role="alert">
{error}
</p>
)}
<div className="flex flex-col gap-2">
<Button type="submit" disabled={loading} className="w-full">

View File

@@ -0,0 +1,272 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { api, isAbortError } from '../api';
import type { Contact, Channel } from '../types';
import { formatTime } from '../utils/messageParser';
import { Input } from './ui/input';
import { Button } from './ui/button';
import { cn } from '@/lib/utils';
const SEARCH_PAGE_SIZE = 50;
const DEBOUNCE_MS = 300;
interface SearchResult {
id: number;
type: 'PRIV' | 'CHAN';
conversation_key: string;
text: string;
received_at: number;
outgoing: boolean;
sender_name: string | null;
}
export interface SearchNavigateTarget {
id: number;
type: 'PRIV' | 'CHAN';
conversation_key: string;
conversation_name: string;
}
interface SearchViewProps {
contacts: Contact[];
channels: Channel[];
onNavigateToMessage: (target: SearchNavigateTarget) => void;
}
function highlightMatch(text: string, query: string): React.ReactNode[] {
if (!query) return [text];
const parts: React.ReactNode[] = [];
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
const segments = text.split(regex);
for (let i = 0; i < segments.length; i++) {
if (regex.test(segments[i])) {
parts.push(
<mark key={i} className="bg-primary/30 text-foreground rounded-sm px-0.5">
{segments[i]}
</mark>
);
} else {
parts.push(segments[i]);
}
// Reset lastIndex since we're using test() in a loop
regex.lastIndex = 0;
}
return parts;
}
export function SearchView({ contacts, channels, onNavigateToMessage }: SearchViewProps) {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Debounce query
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedQuery(query.trim());
}, DEBOUNCE_MS);
return () => clearTimeout(timer);
}, [query]);
// Reset results when query changes
useEffect(() => {
setResults([]);
setOffset(0);
setHasMore(false);
}, [debouncedQuery]);
// Fetch search results
useEffect(() => {
if (!debouncedQuery) {
setResults([]);
setHasMore(false);
return;
}
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
api
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset: 0 }, controller.signal)
.then((data) => {
setResults(data as SearchResult[]);
setHasMore(data.length >= SEARCH_PAGE_SIZE);
setOffset(data.length);
})
.catch((err) => {
if (!isAbortError(err)) {
console.error('Search failed:', err);
}
})
.finally(() => {
setLoading(false);
});
return () => controller.abort();
}, [debouncedQuery]);
const loadMore = useCallback(() => {
if (!debouncedQuery || loading) return;
abortRef.current?.abort();
const controller = new AbortController();
abortRef.current = controller;
setLoading(true);
api
.getMessages({ q: debouncedQuery, limit: SEARCH_PAGE_SIZE, offset }, controller.signal)
.then((data) => {
setResults((prev) => [...prev, ...(data as SearchResult[])]);
setHasMore(data.length >= SEARCH_PAGE_SIZE);
setOffset((prev) => prev + data.length);
})
.catch((err) => {
if (!isAbortError(err)) {
console.error('Search load more failed:', err);
}
})
.finally(() => {
setLoading(false);
});
}, [debouncedQuery, loading, offset]);
// Resolve conversation name from contacts/channels
const getConversationName = useCallback(
(result: SearchResult): string => {
if (result.type === 'CHAN') {
const channel = channels.find(
(c) => c.key.toUpperCase() === result.conversation_key.toUpperCase()
);
return channel?.name || result.conversation_key.slice(0, 8);
}
const contact = contacts.find(
(c) => c.public_key.toLowerCase() === result.conversation_key.toLowerCase()
);
return contact?.name || result.conversation_key.slice(0, 12);
},
[contacts, channels]
);
const handleResultClick = useCallback(
(result: SearchResult) => {
onNavigateToMessage({
id: result.id,
type: result.type,
conversation_key: result.conversation_key,
conversation_name: getConversationName(result),
});
},
[onNavigateToMessage, getConversationName]
);
// Focus input on mount
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<div className="flex flex-col h-full">
{/* Header */}
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
Message Search
</h2>
{/* Search input */}
<div className="px-4 py-3 border-b border-border">
<Input
ref={inputRef}
type="text"
placeholder="Search all messages..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="h-9 text-sm"
aria-label="Search messages"
/>
</div>
{/* Results */}
<div className="flex-1 overflow-y-auto">
{!debouncedQuery && (
<div className="p-8 text-center text-muted-foreground text-sm">
Type to search across all messages
</div>
)}
{debouncedQuery && results.length === 0 && !loading && (
<div className="p-8 text-center text-muted-foreground text-sm">
No messages found for &ldquo;{debouncedQuery}&rdquo;
</div>
)}
{results.map((result) => {
const convName = getConversationName(result);
const typeBadge = result.type === 'CHAN' ? 'Channel' : 'DM';
return (
<div
key={result.id}
className="px-4 py-3 border-b border-border/50 cursor-pointer hover:bg-accent/50 transition-colors"
role="button"
tabIndex={0}
onClick={() => handleResultClick(result)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleResultClick(result);
}
}}
>
<div className="flex items-center gap-2 mb-1">
<span
className={cn(
'text-[10px] font-medium px-1.5 py-0.5 rounded',
result.type === 'CHAN'
? 'bg-primary/20 text-primary'
: 'bg-secondary text-secondary-foreground'
)}
>
{typeBadge}
</span>
<span className="text-[12px] font-medium text-foreground truncate">{convName}</span>
<span className="text-[11px] text-muted-foreground ml-auto flex-shrink-0">
{formatTime(result.received_at)}
</span>
</div>
<div className="text-[13px] text-foreground/80 line-clamp-2 break-words">
{result.sender_name && !result.outgoing && (
<span className="text-muted-foreground">{result.sender_name}: </span>
)}
{result.outgoing && <span className="text-muted-foreground">You: </span>}
{highlightMatch(
result.sender_name && result.text.startsWith(`${result.sender_name}: `)
? result.text.slice(result.sender_name.length + 2)
: result.text,
debouncedQuery
)}
</div>
</div>
);
})}
{loading && (
<div className="p-4 text-center text-muted-foreground text-sm" role="status">
Searching...
</div>
)}
{hasMore && !loading && (
<div className="p-4 text-center">
<Button variant="outline" size="sm" onClick={loadMore}>
Load more results
</Button>
</div>
)}
</div>
</div>
);
}

View File

@@ -10,11 +10,9 @@ import type { LocalLabel } from '../utils/localLabel';
import { SETTINGS_SECTION_LABELS, type SettingsSection } from './settings/settingsConstants';
import { SettingsRadioSection } from './settings/SettingsRadioSection';
import { SettingsIdentitySection } from './settings/SettingsIdentitySection';
import { SettingsConnectivitySection } from './settings/SettingsConnectivitySection';
import { SettingsMqttSection } from './settings/SettingsMqttSection';
import { SettingsLocalSection } from './settings/SettingsLocalSection';
import { SettingsFanoutSection } from './settings/SettingsFanoutSection';
import { SettingsDatabaseSection } from './settings/SettingsDatabaseSection';
import { SettingsBotSection } from './settings/SettingsBotSection';
import { SettingsStatisticsSection } from './settings/SettingsStatisticsSection';
import { SettingsAboutSection } from './settings/SettingsAboutSection';
@@ -33,6 +31,10 @@ interface SettingsModalBaseProps {
onHealthRefresh: () => Promise<void>;
onRefreshAppSettings: () => Promise<void>;
onLocalLabelChange?: (label: LocalLabel) => void;
blockedKeys?: string[];
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
}
type SettingsModalProps = SettingsModalBaseProps &
@@ -57,6 +59,10 @@ export function SettingsModal(props: SettingsModalProps) {
onHealthRefresh,
onRefreshAppSettings,
onLocalLabelChange,
blockedKeys,
blockedNames,
onToggleBlockedKey,
onToggleBlockedName,
} = props;
const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -68,18 +74,13 @@ export function SettingsModal(props: SettingsModalProps) {
const [isMobileLayout, setIsMobileLayout] = useState(getIsMobileLayout);
const externalDesktopSidebarMode = externalSidebarNav && !isMobileLayout;
const [expandedSections, setExpandedSections] = useState<Record<SettingsSection, boolean>>(() => {
const isMobile = getIsMobileLayout();
return {
radio: !isMobile,
identity: false,
connectivity: false,
mqtt: false,
database: false,
bot: false,
statistics: false,
about: false,
};
const [expandedSections, setExpandedSections] = useState<Record<SettingsSection, boolean>>({
radio: false,
local: false,
fanout: false,
database: false,
statistics: false,
about: false,
});
// Refresh settings from server when modal opens
@@ -108,14 +109,6 @@ export function SettingsModal(props: SettingsModalProps) {
return () => query.removeListener(onChange);
}, []);
// On mobile with external sidebar nav, auto-expand the selected section
useEffect(() => {
if (!externalSidebarNav || !isMobileLayout || !desktopSection) return;
setExpandedSections((prev) =>
prev[desktopSection] ? prev : { ...prev, [desktopSection]: true }
);
}, [externalSidebarNav, isMobileLayout, desktopSection]);
const toggleSection = (section: SettingsSection) => {
setExpandedSections((prev) => ({
...prev,
@@ -141,14 +134,21 @@ export function SettingsModal(props: SettingsModalProps) {
: 'w-full h-full overflow-y-auto space-y-3';
const sectionButtonClasses =
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40';
'w-full flex items-center justify-between px-4 py-3 text-left hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset';
const renderSectionHeader = (section: SettingsSection): ReactNode => {
if (!showSectionButton) return null;
return (
<button type="button" className={sectionButtonClasses} onClick={() => toggleSection(section)}>
<span className="font-medium">{SETTINGS_SECTION_LABELS[section]}</span>
<span className="text-muted-foreground md:hidden">
<button
type="button"
className={sectionButtonClasses}
aria-expanded={expandedSections[section]}
onClick={() => toggleSection(section)}
>
<span className="font-medium" role="heading" aria-level={3}>
{SETTINGS_SECTION_LABELS[section]}
</span>
<span className="text-muted-foreground md:hidden" aria-hidden="true">
{expandedSections[section] ? '' : '+'}
</span>
</button>
@@ -164,26 +164,10 @@ export function SettingsModal(props: SettingsModalProps) {
) : (
<div className={settingsContainerClass}>
{shouldRenderSection('radio') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('radio')}
{isSectionVisible('radio') && (
{isSectionVisible('radio') && appSettings && (
<SettingsRadioSection
config={config}
pageMode={pageMode}
onSave={onSave}
onReboot={onReboot}
onClose={onClose}
className={sectionContentClass}
/>
)}
</div>
)}
{shouldRenderSection('identity') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('identity')}
{isSectionVisible('identity') && appSettings && (
<SettingsIdentitySection
config={config}
health={health}
appSettings={appSettings}
@@ -197,28 +181,23 @@ export function SettingsModal(props: SettingsModalProps) {
className={sectionContentClass}
/>
)}
</div>
</section>
)}
{shouldRenderSection('connectivity') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('connectivity')}
{isSectionVisible('connectivity') && appSettings && (
<SettingsConnectivitySection
appSettings={appSettings}
health={health}
pageMode={pageMode}
onSaveAppSettings={onSaveAppSettings}
onReboot={onReboot}
onClose={onClose}
{shouldRenderSection('local') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('local')}
{isSectionVisible('local') && (
<SettingsLocalSection
onLocalLabelChange={onLocalLabelChange}
className={sectionContentClass}
/>
)}
</div>
</section>
)}
{shouldRenderSection('database') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('database')}
{isSectionVisible('database') && appSettings && (
<SettingsDatabaseSection
@@ -226,55 +205,43 @@ export function SettingsModal(props: SettingsModalProps) {
health={health}
onSaveAppSettings={onSaveAppSettings}
onHealthRefresh={onHealthRefresh}
onLocalLabelChange={onLocalLabelChange}
blockedKeys={blockedKeys}
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
className={sectionContentClass}
/>
)}
</div>
</section>
)}
{shouldRenderSection('bot') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('bot')}
{isSectionVisible('bot') && appSettings && (
<SettingsBotSection
appSettings={appSettings}
isMobileLayout={isMobileLayout}
onSaveAppSettings={onSaveAppSettings}
className={sectionContentClass}
/>
)}
</div>
)}
{shouldRenderSection('mqtt') && (
<div className={sectionWrapperClass}>
{renderSectionHeader('mqtt')}
{isSectionVisible('mqtt') && appSettings && (
<SettingsMqttSection
appSettings={appSettings}
{shouldRenderSection('fanout') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('fanout')}
{isSectionVisible('fanout') && (
<SettingsFanoutSection
health={health}
onSaveAppSettings={onSaveAppSettings}
onHealthRefresh={onHealthRefresh}
className={sectionContentClass}
/>
)}
</div>
</section>
)}
{shouldRenderSection('statistics') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('statistics')}
{isSectionVisible('statistics') && (
<SettingsStatisticsSection className={sectionContentClass} />
)}
</div>
</section>
)}
{shouldRenderSection('about') && (
<div className={sectionWrapperClass}>
<section className={sectionWrapperClass}>
{renderSectionHeader('about')}
{isSectionVisible('about') && <SettingsAboutSection className={sectionContentClass} />}
</div>
</section>
)}
</div>
);

View File

@@ -8,6 +8,7 @@ import {
} from '../types';
import { getStateKey, type ConversationTimes, type SortOrder } from '../utils/conversationState';
import { getContactDisplayName } from '../utils/pubkey';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { isFavorite } from '../utils/favorites';
import { Input } from './ui/input';
@@ -115,8 +116,10 @@ export function Sidebar({
onSelectConversation(conversation);
};
const isActive = (type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer', id: string) =>
activeConversation?.type === type && activeConversation?.id === id;
const isActive = (
type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer' | 'search',
id: string
) => activeConversation?.type === type && activeConversation?.id === id;
// Get unread count for a conversation
const getUnreadCount = (type: 'channel' | 'contact', id: string): number => {
@@ -378,10 +381,14 @@ export function Sidebar({
<div
key={row.key}
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors',
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive(row.type, row.id) && 'bg-accent border-l-primary',
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
)}
role="button"
tabIndex={0}
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: row.type,
@@ -404,9 +411,10 @@ export function Sidebar({
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
row.isMention
? 'bg-destructive text-destructive-foreground'
: 'bg-primary/90 text-primary-foreground'
? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
@@ -444,30 +452,37 @@ export function Sidebar({
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
<button
className={cn(
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors',
'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
isSearching && 'cursor-default'
)}
aria-expanded={!effectiveCollapsed}
onClick={() => {
if (!isSearching) onToggle();
}}
title={effectiveCollapsed ? `Expand ${title}` : `Collapse ${title}`}
>
<span className="text-[9px]">{effectiveCollapsed ? '▸' : '▾'}</span>
<span className="text-[9px]" aria-hidden="true">
{effectiveCollapsed ? '▸' : '▾'}
</span>
<span>{title}</span>
</button>
{(showSortToggle || unreadCount > 0) && (
<div className="ml-auto flex items-center gap-1.5">
{showSortToggle && (
<button
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors"
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={handleSortToggle}
aria-label={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
title={sortOrder === 'alpha' ? 'Sort by recent' : 'Sort alphabetically'}
>
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
)}
{unreadCount > 0 && (
<span className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-secondary text-muted-foreground">
<span
className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-secondary text-muted-foreground"
aria-label={`${unreadCount} unread`}
>
{unreadCount}
</span>
)}
@@ -478,7 +493,10 @@ export function Sidebar({
};
return (
<div className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col">
<nav
className="sidebar w-60 h-full min-h-0 bg-card border-r border-border flex flex-col"
aria-label="Conversations"
>
{/* Header */}
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
@@ -489,6 +507,7 @@ export function Sidebar({
size="sm"
onClick={onNewMessage}
title="New Message"
aria-label="New message"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground transition-colors"
>
+
@@ -500,15 +519,17 @@ export function Sidebar({
<Input
type="text"
placeholder="Search..."
aria-label="Search conversations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 text-[13px] pr-8 bg-background/50"
/>
{searchQuery && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors"
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
onClick={() => setSearchQuery('')}
title="Clear search"
aria-label="Clear search"
>
×
</button>
@@ -521,9 +542,13 @@ export function Sidebar({
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive('raw', 'raw') && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={isActive('raw', 'raw') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'raw',
@@ -532,7 +557,9 @@ export function Sidebar({
})
}
>
<span className="text-muted-foreground text-xs">📡</span>
<span className="text-muted-foreground text-xs" aria-hidden="true">
📡
</span>
<span className="flex-1 truncate text-muted-foreground">Packet Feed</span>
</div>
)}
@@ -541,9 +568,13 @@ export function Sidebar({
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive('map', 'map') && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={isActive('map', 'map') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'map',
@@ -552,7 +583,9 @@ export function Sidebar({
})
}
>
<span className="text-muted-foreground text-xs">🗺</span>
<span className="text-muted-foreground text-xs" aria-hidden="true">
🗺
</span>
<span className="flex-1 truncate text-muted-foreground">Node Map</span>
</div>
)}
@@ -561,9 +594,13 @@ export function Sidebar({
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive('visualizer', 'visualizer') && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={isActive('visualizer', 'visualizer') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'visualizer',
@@ -572,21 +609,54 @@ export function Sidebar({
})
}
>
<span className="text-muted-foreground text-xs"></span>
<span className="text-muted-foreground text-xs" aria-hidden="true">
</span>
<span className="flex-1 truncate text-muted-foreground">Mesh Visualizer</span>
</div>
)}
{/* Message Search */}
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive('search', 'search') && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
aria-current={isActive('search', 'search') ? 'page' : undefined}
onKeyDown={handleKeyboardActivate}
onClick={() =>
handleSelectConversation({
type: 'search',
id: 'search',
name: 'Message Search',
})
}
>
<span className="text-muted-foreground text-xs" aria-hidden="true">
🔍
</span>
<span className="flex-1 truncate text-muted-foreground">Message Search</span>
</div>
)}
{/* Cracker Toggle */}
{!query && (
<div
className={cn(
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]',
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
showCracker && 'bg-accent border-l-primary'
)}
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={onToggleCracker}
>
<span className="text-muted-foreground text-xs">🔓</span>
<span className="text-muted-foreground text-xs" aria-hidden="true">
🔓
</span>
<span className="flex-1 truncate text-muted-foreground">
{showCracker ? 'Hide' : 'Show'} Room Finder
<span
@@ -602,12 +672,17 @@ export function Sidebar({
)}
{/* Mark All Read */}
{!query && Object.keys(unreadCounts).length > 0 && (
{!query && Object.values(unreadCounts).some((c) => c > 0) && (
<div
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px]"
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={onMarkAllRead}
>
<span className="text-muted-foreground text-xs"></span>
<span className="text-muted-foreground text-xs" aria-hidden="true">
</span>
<span className="flex-1 truncate text-muted-foreground">Mark all as read</span>
</div>
)}
@@ -682,6 +757,6 @@ export function Sidebar({
</div>
)}
</div>
</div>
</nav>
);
}

View File

@@ -3,6 +3,7 @@ import { Menu } from 'lucide-react';
import type { HealthStatus, RadioConfig } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
import { handleKeyboardActivate } from '../utils/a11y';
import { cn } from '@/lib/utils';
interface StatusBarProps {
@@ -40,7 +41,7 @@ export function StatusBar({
};
return (
<div className="flex items-center gap-3 px-4 py-2.5 bg-card border-b border-border text-xs">
<header className="flex items-center gap-3 px-4 py-2.5 bg-card border-b border-border text-xs">
{/* Mobile menu button - only visible on small screens */}
{onMenuClick && (
<button
@@ -66,14 +67,19 @@ export function StatusBar({
RemoteTerm
</h1>
<div className="flex items-center gap-1.5">
<div
className="flex items-center gap-1.5"
role="status"
aria-label={connected ? 'Connected' : 'Disconnected'}
>
<div
className={cn(
'w-2 h-2 rounded-full transition-colors',
connected
? 'bg-primary shadow-[0_0_6px_hsl(var(--primary)/0.5)]'
: 'bg-muted-foreground'
? 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]'
: 'bg-status-disconnected'
)}
aria-hidden="true"
/>
<span className="hidden lg:inline text-muted-foreground">
{connected ? 'Connected' : 'Disconnected'}
@@ -85,11 +91,15 @@ export function StatusBar({
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
<span
className="font-mono text-[11px] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
navigator.clipboard.writeText(config.public_key);
toast.success('Public key copied!');
}}
title="Click to copy public key"
aria-label="Copy public key"
>
{config.public_key.toLowerCase()}
</span>
@@ -100,17 +110,22 @@ export function StatusBar({
<button
onClick={handleReconnect}
disabled={reconnecting}
className="px-3 py-1 bg-amber-500/10 border border-amber-500/20 text-amber-400 rounded-md text-xs cursor-pointer hover:bg-amber-500/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="px-3 py-1 bg-warning/10 border border-warning/20 text-warning rounded-md text-xs cursor-pointer hover:bg-warning/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
</button>
)}
<button
onClick={onSettingsClick}
className="px-3 py-1.5 bg-secondary border border-border text-muted-foreground rounded-md text-xs cursor-pointer hover:bg-accent hover:text-foreground transition-colors"
className={cn(
'px-3 py-1.5 rounded-md text-xs cursor-pointer transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
settingsMode
? 'bg-status-connected/15 border border-status-connected/30 text-status-connected hover:bg-status-connected/25'
: 'bg-secondary border border-border text-muted-foreground hover:bg-accent hover:text-foreground'
)}
>
{settingsMode ? 'Back to Chat' : 'Settings'}
</button>
</div>
</header>
);
}

View File

@@ -5,6 +5,7 @@ import { PacketVisualizer3D } from './PacketVisualizer3D';
import { RawPacketList } from './RawPacketList';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { cn } from '@/lib/utils';
import { getVisualizerSettings, saveVisualizerSettings } from '../utils/visualizerSettings';
interface VisualizerViewProps {
packets: RawPacket[];
@@ -13,10 +14,18 @@ interface VisualizerViewProps {
}
export function VisualizerView({ packets, contacts, config }: VisualizerViewProps) {
const [fullScreen, setFullScreen] = useState(false);
const [fullScreen, setFullScreen] = useState(() => getVisualizerSettings().hidePacketFeed);
const [paneFullScreen, setPaneFullScreen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Persist packet feed visibility to localStorage
useEffect(() => {
const current = getVisualizerSettings();
if (current.hidePacketFeed !== fullScreen) {
saveVisualizerSettings({ ...current, hidePacketFeed: fullScreen });
}
}, [fullScreen]);
// Sync state when browser exits fullscreen (Escape, F11, etc.)
useEffect(() => {
const handler = () => {
@@ -40,11 +49,12 @@ export function VisualizerView({ packets, contacts, config }: VisualizerViewProp
<div ref={containerRef} className="flex flex-col h-full bg-background">
{/* Header */}
<div className="flex justify-between items-center px-4 py-3 border-b border-border font-medium text-lg">
<span>Mesh Visualizer</span>
<span>{paneFullScreen ? 'RemoteTerm MeshCore Visualizer' : 'Mesh Visualizer'}</span>
<button
className="hidden md:inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:text-foreground transition-colors"
className="hidden md:inline-flex items-center justify-center rounded-md p-1.5 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={toggleFullScreen}
title={paneFullScreen ? 'Exit fullscreen' : 'Fullscreen'}
aria-label={paneFullScreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{paneFullScreen ? <Minimize2 size={18} /> : <Maximize2 size={18} />}
</button>

View File

@@ -15,9 +15,9 @@ export function AclPane({
}) {
const permColor: Record<number, string> = {
0: 'bg-muted text-muted-foreground',
1: 'bg-blue-500/10 text-blue-500',
2: 'bg-green-500/10 text-green-500',
3: 'bg-amber-500/10 text-amber-500',
1: 'bg-info/10 text-info',
2: 'bg-success/10 text-success',
3: 'bg-warning/10 text-warning',
};
return (

View File

@@ -39,18 +39,18 @@ export function ConsolePane({
</div>
<div
ref={outputRef}
className="h-48 overflow-y-auto p-3 font-mono text-xs bg-black/50 text-green-400 space-y-1"
className="h-48 overflow-y-auto p-3 font-mono text-xs bg-console-bg/50 text-console space-y-1"
>
{history.length === 0 && (
<p className="text-muted-foreground italic">Type a CLI command below...</p>
)}
{history.map((entry, i) =>
entry.outgoing ? (
<div key={i} className="text-green-300">
<div key={i} className="text-console-command">
&gt; {entry.command}
</div>
) : (
<div key={i} className="text-green-400/80 whitespace-pre-wrap">
<div key={i} className="text-console/80 whitespace-pre-wrap">
{entry.response}
</div>
)
@@ -65,6 +65,7 @@ export function ConsolePane({
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="CLI command..."
aria-label="Console command"
disabled={loading}
className="flex-1 font-mono text-sm"
/>

View File

@@ -101,7 +101,7 @@ export function NeighborsPane({
const dist = n.distance;
const snrStr = n.snr >= 0 ? `+${n.snr.toFixed(1)}` : n.snr.toFixed(1);
const snrColor =
n.snr >= 6 ? 'text-green-500' : n.snr >= 0 ? 'text-yellow-500' : 'text-red-500';
n.snr >= 6 ? 'text-success' : n.snr >= 0 ? 'text-warning' : 'text-destructive';
return (
<tr key={i} className="border-t border-border/50">
<td className="py-1">{n.name || n.pubkey_prefix}</td>

View File

@@ -66,7 +66,7 @@ export function RadioSettingsPane({
<span
className={cn(
'ml-2 text-xs',
clockDrift.isLarge ? 'text-red-500' : 'text-muted-foreground'
clockDrift.isLarge ? 'text-destructive' : 'text-muted-foreground'
)}
>
(drift: {clockDrift.text})
@@ -88,9 +88,10 @@ export function RadioSettingsPane({
'p-1 rounded transition-colors disabled:opacity-50',
disabled || advertState.loading
? 'text-muted-foreground'
: 'text-green-500 hover:bg-accent hover:text-green-400'
: 'text-success hover:bg-accent hover:text-success'
)}
title="Refresh Advert Intervals"
aria-label="Refresh Advert Intervals"
>
<RefreshIcon
className={cn(

View File

@@ -109,12 +109,13 @@ export function RepeaterPane({
onClick={onRefresh}
disabled={disabled || state.loading}
className={cn(
'p-1 rounded transition-colors disabled:opacity-50',
'p-1 rounded transition-colors disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
disabled || state.loading
? 'text-muted-foreground'
: 'text-green-500 hover:bg-accent hover:text-green-400'
: 'text-success hover:bg-accent hover:text-success'
)}
title="Refresh"
aria-label={`Refresh ${title}`}
>
<RefreshIcon
className={cn(

View File

@@ -1,313 +0,0 @@
import { useState, useEffect, lazy, Suspense } from 'react';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import type { AppSettings, AppSettingsUpdate, BotConfig } from '../../types';
const BotCodeEditor = lazy(() =>
import('../BotCodeEditor').then((m) => ({ default: m.BotCodeEditor }))
);
const DEFAULT_BOT_CODE = `def bot(
sender_name: str | None,
sender_key: str | None,
message_text: str,
is_dm: bool,
channel_key: str | None,
channel_name: str | None,
sender_timestamp: int | None,
path: str | None,
is_outgoing: bool = False,
) -> str | list[str] | None:
"""
Process messages and optionally return a reply.
Args:
sender_name: Display name of sender (may be None)
sender_key: 64-char hex public key (None for channel msgs)
message_text: The message content
is_dm: True for direct messages, False for channel
channel_key: 32-char hex key for channels, None for DMs
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
sender_timestamp: Sender's timestamp (unix seconds, may be None)
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
Returns:
None for no reply, a string for a single reply,
or a list of strings to send multiple messages in order
"""
# Don't reply to our own outgoing messages
if is_outgoing:
return None
# Example: Only respond in #bot channel to "!pling" command
if channel_name == "#bot" and "!pling" in message_text.lower():
return "[BOT] Plong!"
return None`;
export function SettingsBotSection({
appSettings,
isMobileLayout,
onSaveAppSettings,
className,
}: {
appSettings: AppSettings;
isMobileLayout: boolean;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
className?: string;
}) {
const [bots, setBots] = useState<BotConfig[]>([]);
const [expandedBotId, setExpandedBotId] = useState<string | null>(null);
const [editingNameId, setEditingNameId] = useState<string | null>(null);
const [editingNameValue, setEditingNameValue] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setBots(appSettings.bots || []);
}, [appSettings]);
const handleSave = async () => {
setBusy(true);
setError(null);
try {
await onSaveAppSettings({ bots });
toast.success('Bot settings saved');
} catch (err) {
console.error('Failed to save bot settings:', err);
const errorMsg = err instanceof Error ? err.message : 'Failed to save';
setError(errorMsg);
toast.error(errorMsg);
} finally {
setBusy(false);
}
};
const handleAddBot = () => {
const newBot: BotConfig = {
id: crypto.randomUUID(),
name: `Bot ${bots.length + 1}`,
enabled: false,
code: DEFAULT_BOT_CODE,
};
setBots([...bots, newBot]);
setExpandedBotId(newBot.id);
};
const handleDeleteBot = (botId: string) => {
const bot = bots.find((b) => b.id === botId);
if (bot && bot.code.trim() && bot.code !== DEFAULT_BOT_CODE) {
if (!confirm(`Delete "${bot.name}"? This will remove all its code.`)) {
return;
}
}
setBots(bots.filter((b) => b.id !== botId));
if (expandedBotId === botId) {
setExpandedBotId(null);
}
};
const handleToggleBotEnabled = (botId: string) => {
setBots(bots.map((b) => (b.id === botId ? { ...b, enabled: !b.enabled } : b)));
};
const handleBotCodeChange = (botId: string, code: string) => {
setBots(bots.map((b) => (b.id === botId ? { ...b, code } : b)));
};
const handleStartEditingName = (bot: BotConfig) => {
setEditingNameId(bot.id);
setEditingNameValue(bot.name);
};
const handleFinishEditingName = () => {
if (editingNameId && editingNameValue.trim()) {
setBots(
bots.map((b) => (b.id === editingNameId ? { ...b, name: editingNameValue.trim() } : b))
);
}
setEditingNameId(null);
setEditingNameValue('');
};
const handleResetBotCode = (botId: string) => {
setBots(bots.map((b) => (b.id === botId ? { ...b, code: DEFAULT_BOT_CODE } : b)));
};
return (
<div className={className}>
<div className="p-3 bg-red-500/10 border border-red-500/30 rounded-md">
<p className="text-sm text-red-500">
<strong>Experimental:</strong> This is an alpha feature and introduces automated message
sending to your radio; unexpected behavior may occur. Use with caution, and please report
any bugs!
</p>
</div>
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md">
<p className="text-sm text-yellow-500">
<strong>Security Warning:</strong> This feature executes arbitrary Python code on the
server. Only run trusted code, and be cautious of arbitrary usage of message parameters.
</p>
</div>
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-md">
<p className="text-sm text-yellow-500">
<strong>Don&apos;t wreck the mesh!</strong> Bots process ALL messages, including their
own. Be careful of creating infinite loops!
</p>
</div>
<div className="flex justify-between items-center">
<Label>Bots</Label>
<Button type="button" variant="outline" size="sm" onClick={handleAddBot}>
+ New Bot
</Button>
</div>
{bots.length === 0 ? (
<div className="text-center py-8 border border-dashed border-input rounded-md">
<p className="text-muted-foreground mb-4">No bots configured</p>
<Button type="button" variant="outline" onClick={handleAddBot}>
Create your first bot
</Button>
</div>
) : (
<div className="space-y-2">
{bots.map((bot) => (
<div key={bot.id} className="border border-input rounded-md overflow-hidden">
<div
className="flex items-center gap-2 px-3 py-2 bg-muted/50 cursor-pointer hover:bg-muted/80"
onClick={(e) => {
if ((e.target as HTMLElement).closest('input, button')) return;
setExpandedBotId(expandedBotId === bot.id ? null : bot.id);
}}
>
<span className="text-muted-foreground">
{expandedBotId === bot.id ? '▼' : '▶'}
</span>
{editingNameId === bot.id ? (
<input
type="text"
value={editingNameValue}
onChange={(e) => setEditingNameValue(e.target.value)}
onBlur={handleFinishEditingName}
onKeyDown={(e) => {
if (e.key === 'Enter') handleFinishEditingName();
if (e.key === 'Escape') {
setEditingNameId(null);
setEditingNameValue('');
}
}}
autoFocus
className="px-2 py-0.5 text-sm bg-background border border-input rounded flex-1 max-w-[200px]"
onClick={(e) => e.stopPropagation()}
/>
) : (
<span
className="text-sm font-medium flex-1 hover:text-primary cursor-text"
onClick={(e) => {
e.stopPropagation();
handleStartEditingName(bot);
}}
title="Click to rename"
>
{bot.name}
</span>
)}
<label
className="flex items-center gap-1.5 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={bot.enabled}
onChange={() => handleToggleBotEnabled(bot.id)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-xs text-muted-foreground">Enabled</span>
</label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={(e) => {
e.stopPropagation();
handleDeleteBot(bot.id);
}}
title="Delete bot"
>
🗑
</Button>
</div>
{expandedBotId === bot.id && (
<div className="p-3 space-y-3 border-t border-input">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Define a <code className="bg-muted px-1 rounded">bot()</code> function that
receives message data and optionally returns a reply.
</p>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => handleResetBotCode(bot.id)}
>
Reset to Example
</Button>
</div>
<Suspense
fallback={
<div className="h-64 md:h-96 rounded-md border border-input bg-[#282c34] flex items-center justify-center text-muted-foreground">
Loading editor...
</div>
}
>
<BotCodeEditor
value={bot.code}
onChange={(code) => handleBotCodeChange(bot.id, code)}
id={`bot-code-${bot.id}`}
height={isMobileLayout ? '256px' : '384px'}
/>
</Suspense>
</div>
)}
</div>
))}
</div>
)}
<Separator />
<div className="text-xs text-muted-foreground space-y-1">
<p>
<strong>Available:</strong> Standard Python libraries and any modules installed in the
server environment.
</p>
<p>
<strong>Limits:</strong> 10 second timeout per bot.
</p>
<p>
<strong>Note:</strong> Bots respond to all messages, including your own. For channel
messages, <code>sender_key</code> is <code>None</code>. Multiple enabled bots run
serially, with a two-second delay between messages to prevent repeater collision.
</p>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Bot Settings'}
</Button>
</div>
);
}

View File

@@ -1,134 +0,0 @@
import { useState, useEffect } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
export function SettingsConnectivitySection({
appSettings,
health,
pageMode,
onSaveAppSettings,
onReboot,
onClose,
className,
}: {
appSettings: AppSettings;
health: HealthStatus | null;
pageMode: boolean;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onReboot: () => Promise<void>;
onClose: () => void;
className?: string;
}) {
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}, [appSettings]);
const handleSave = async () => {
setError(null);
setBusy(true);
try {
const update: AppSettingsUpdate = {};
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings.max_radio_contacts) {
update.max_radio_contacts = newMaxRadioContacts;
}
if (Object.keys(update).length > 0) {
await onSaveAppSettings(update);
}
toast.success('Connectivity settings saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setBusy(false);
}
};
const handleReboot = async () => {
if (
!confirm('Are you sure you want to reboot the radio? The connection will drop temporarily.')
) {
return;
}
setError(null);
setBusy(true);
setRebooting(true);
try {
await onReboot();
if (!pageMode) {
onClose();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to reboot radio');
} finally {
setRebooting(false);
setBusy(false);
}
};
return (
<div className={className}>
<div className="space-y-2">
<Label>Connection</Label>
{health?.connection_info ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-green-500" />
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
{health.connection_info}
</code>
</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-gray-500" />
<span>Not connected</span>
</div>
)}
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Favorite contacts load first, then recent non-repeater contacts until this limit is
reached (1-1000)
</p>
</div>
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
<Button
variant="outline"
onClick={handleReboot}
disabled={rebooting || busy}
className="w-full border-red-500/50 text-red-400 hover:bg-red-500/10"
>
{rebooting ? 'Rebooting...' : 'Reboot Radio'}
</Button>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
);
}

View File

@@ -6,12 +6,6 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { api } from '../../api';
import { formatTime } from '../../utils/messageParser';
import {
captureLastViewedConversationFromHash,
getReopenLastConversationEnabled,
setReopenLastConversationEnabled,
} from '../../utils/lastViewedConversation';
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
export function SettingsDatabaseSection({
@@ -19,25 +13,26 @@ export function SettingsDatabaseSection({
health,
onSaveAppSettings,
onHealthRefresh,
onLocalLabelChange,
blockedKeys = [],
blockedNames = [],
onToggleBlockedKey,
onToggleBlockedName,
className,
}: {
appSettings: AppSettings;
health: HealthStatus | null;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onHealthRefresh: () => Promise<void>;
onLocalLabelChange?: (label: LocalLabel) => void;
blockedKeys?: string[];
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
className?: string;
}) {
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled
);
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -46,10 +41,6 @@ export function SettingsDatabaseSection({
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
}, [appSettings]);
useEffect(() => {
setReopenLastConversation(getReopenLastConversationEnabled());
}, []);
const handleCleanup = async () => {
const days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) {
@@ -112,14 +103,6 @@ export function SettingsDatabaseSection({
}
};
const handleToggleReopenLastConversation = (enabled: boolean) => {
setReopenLastConversation(enabled);
setReopenLastConversationEnabled(enabled);
if (enabled) {
captureLastViewedConversationFromHash();
}
};
return (
<div className={className}>
<div className="space-y-3">
@@ -175,7 +158,7 @@ export function SettingsDatabaseSection({
variant="outline"
onClick={handleCleanup}
disabled={cleaning}
className="border-red-500/50 text-red-400 hover:bg-red-500/10"
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{cleaning ? 'Deleting...' : 'Permanently Delete'}
</Button>
@@ -198,7 +181,7 @@ export function SettingsDatabaseSection({
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-yellow-500/50 text-yellow-400 hover:bg-yellow-500/10"
className="w-full border-warning/50 text-warning hover:bg-warning/10"
>
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
</Button>
@@ -226,56 +209,69 @@ export function SettingsDatabaseSection({
<Separator />
<div className="space-y-3">
<Label>Interface</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<Label>Blocked Contacts</Label>
<p className="text-xs text-muted-foreground">
This applies only to this device/browser. It does not sync to server settings.
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
affected. Messages are still stored and will reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Keys</span>
<div className="mt-1 space-y-1">
{blockedKeys.map((key) => (
<div key={key} className="flex items-center justify-between gap-2">
<span className="text-xs font-mono truncate flex-1">{key}</span>
{onToggleBlockedKey && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedKey(key)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
{blockedNames.length > 0 && (
<div>
<span className="text-xs text-muted-foreground font-medium">Blocked Names</span>
<div className="mt-1 space-y-1">
{blockedNames.map((name) => (
<div key={name} className="flex items-center justify-between gap-2">
<span className="text-sm truncate flex-1">{name}</span>
{onToggleBlockedName && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleBlockedName(name)}
className="h-7 text-xs flex-shrink-0"
>
Unblock
</Button>
)}
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
<Separator />
<div className="space-y-3">
<Label>Local Label</Label>
<div className="flex items-center gap-2">
<Input
value={localLabelText}
onChange={(e) => {
const text = e.target.value;
setLocalLabelText(text);
setLocalLabel(text, localLabelColor);
onLocalLabelChange?.({ text, color: localLabelColor });
}}
placeholder="e.g. Home Base, Field Radio 2"
className="flex-1"
/>
<input
type="color"
value={localLabelColor}
onChange={(e) => {
const color = e.target.value;
setLocalLabelColor(color);
setLocalLabel(localLabelText, color);
onLocalLabelChange?.({ text: localLabelText, color });
}}
className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
/>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
<p className="text-xs text-muted-foreground">
Display a colored banner at the top of the page to identify this instance. This applies
only to this device/browser.
</p>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
)}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}

File diff suppressed because it is too large Load Diff

View File

@@ -1,190 +0,0 @@
import { useState, useEffect } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioConfig,
RadioConfigUpdate,
} from '../../types';
export function SettingsIdentitySection({
config,
health,
appSettings,
pageMode,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
onAdvertise,
onClose,
className,
}: {
config: RadioConfig;
health: HealthStatus | null;
appSettings: AppSettings;
pageMode: boolean;
onSave: (update: RadioConfigUpdate) => Promise<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
onAdvertise: () => Promise<void>;
onClose: () => void;
className?: string;
}) {
const [name, setName] = useState('');
const [privateKey, setPrivateKey] = useState('');
const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [advertising, setAdvertising] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setName(config.name);
}, [config]);
useEffect(() => {
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
}, [appSettings]);
const handleSaveIdentity = async () => {
setError(null);
setBusy(true);
try {
const update: RadioConfigUpdate = { name };
await onSave(update);
const hours = parseInt(advertIntervalHours, 10);
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
if (newAdvertInterval !== appSettings.advert_interval) {
await onSaveAppSettings({ advert_interval: newAdvertInterval });
}
toast.success('Identity settings saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setBusy(false);
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setError('Private key is required');
return;
}
setError(null);
setBusy(true);
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
toast.success('Private key set, rebooting...');
setRebooting(true);
await onReboot();
if (!pageMode) {
onClose();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to set private key');
} finally {
setRebooting(false);
setBusy(false);
}
};
const handleAdvertise = async () => {
setAdvertising(true);
try {
await onAdvertise();
} finally {
setAdvertising(false);
}
};
return (
<div className={className}>
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<Button onClick={handleSaveIdentity} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Identity Settings'}
</Button>
<Separator />
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={busy || rebooting || !privateKey.trim()}
className="w-full"
>
{busy || rebooting ? 'Setting & Rebooting...' : 'Set Private Key & Reboot'}
</Button>
</div>
<Separator />
<div className="space-y-2">
<Label>Send Advertisement</Label>
<p className="text-xs text-muted-foreground">
Send a flood advertisement to announce your presence on the mesh network.
</p>
<Button
onClick={handleAdvertise}
disabled={advertising || !health?.radio_connected}
className="w-full bg-yellow-600 hover:bg-yellow-700 text-white"
>
{advertising ? 'Sending...' : 'Send Advertisement'}
</Button>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
);
}

View File

@@ -0,0 +1,93 @@
import { useState } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Separator } from '../ui/separator';
import {
captureLastViewedConversationFromHash,
getReopenLastConversationEnabled,
setReopenLastConversationEnabled,
} from '../../utils/lastViewedConversation';
import { ThemeSelector } from './ThemeSelector';
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
export function SettingsLocalSection({
onLocalLabelChange,
className,
}: {
onLocalLabelChange?: (label: LocalLabel) => void;
className?: string;
}) {
const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled
);
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const handleToggleReopenLastConversation = (enabled: boolean) => {
setReopenLastConversation(enabled);
setReopenLastConversationEnabled(enabled);
if (enabled) {
captureLastViewedConversationFromHash();
}
};
return (
<div className={className}>
<p className="text-sm text-muted-foreground">
These settings apply only to this device/browser.
</p>
<div className="space-y-1">
<Label>Color Scheme</Label>
<ThemeSelector />
</div>
<Separator />
<div className="space-y-3">
<Label>Local Label</Label>
<div className="flex items-center gap-2">
<Input
value={localLabelText}
onChange={(e) => {
const text = e.target.value;
setLocalLabelText(text);
setLocalLabel(text, localLabelColor);
onLocalLabelChange?.({ text, color: localLabelColor });
}}
placeholder="e.g. Home Base, Field Radio 2"
aria-label="Local label text"
className="flex-1"
/>
<input
type="color"
value={localLabelColor}
onChange={(e) => {
const color = e.target.value;
setLocalLabelColor(color);
setLocalLabel(localLabelText, color);
onLocalLabelChange?.({ text: localLabelText, color });
}}
aria-label="Local label color"
className="w-10 h-9 rounded border border-input cursor-pointer bg-transparent p-0.5"
/>
</div>
<p className="text-xs text-muted-foreground">
Display a colored banner at the top of the page to identify this instance.
</p>
</div>
<Separator />
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={reopenLastConversation}
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
</div>
);
}

View File

@@ -1,412 +0,0 @@
import { useState, useEffect } from 'react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
export function SettingsMqttSection({
appSettings,
health,
onSaveAppSettings,
className,
}: {
appSettings: AppSettings;
health: HealthStatus | null;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
className?: string;
}) {
const [mqttBrokerHost, setMqttBrokerHost] = useState('');
const [mqttBrokerPort, setMqttBrokerPort] = useState('1883');
const [mqttUsername, setMqttUsername] = useState('');
const [mqttPassword, setMqttPassword] = useState('');
const [mqttUseTls, setMqttUseTls] = useState(false);
const [mqttTlsInsecure, setMqttTlsInsecure] = useState(false);
const [mqttTopicPrefix, setMqttTopicPrefix] = useState('meshcore');
const [mqttPublishMessages, setMqttPublishMessages] = useState(false);
const [mqttPublishRawPackets, setMqttPublishRawPackets] = useState(false);
// Community MQTT state
const [communityMqttEnabled, setCommunityMqttEnabled] = useState(false);
const [communityMqttIata, setCommunityMqttIata] = useState('');
const [communityMqttBrokerHost, setCommunityMqttBrokerHost] = useState('mqtt-us-v1.letsmesh.net');
const [communityMqttBrokerPort, setCommunityMqttBrokerPort] = useState('443');
const [communityMqttEmail, setCommunityMqttEmail] = useState('');
const [privateExpanded, setPrivateExpanded] = useState(false);
const [communityExpanded, setCommunityExpanded] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setMqttBrokerHost(appSettings.mqtt_broker_host ?? '');
setMqttBrokerPort(String(appSettings.mqtt_broker_port ?? 1883));
setMqttUsername(appSettings.mqtt_username ?? '');
setMqttPassword(appSettings.mqtt_password ?? '');
setMqttUseTls(appSettings.mqtt_use_tls ?? false);
setMqttTlsInsecure(appSettings.mqtt_tls_insecure ?? false);
setMqttTopicPrefix(appSettings.mqtt_topic_prefix ?? 'meshcore');
setMqttPublishMessages(appSettings.mqtt_publish_messages ?? false);
setMqttPublishRawPackets(appSettings.mqtt_publish_raw_packets ?? false);
setCommunityMqttEnabled(appSettings.community_mqtt_enabled ?? false);
setCommunityMqttIata(appSettings.community_mqtt_iata ?? '');
setCommunityMqttBrokerHost(appSettings.community_mqtt_broker_host ?? 'mqtt-us-v1.letsmesh.net');
setCommunityMqttBrokerPort(String(appSettings.community_mqtt_broker_port ?? 443));
setCommunityMqttEmail(appSettings.community_mqtt_email ?? '');
}, [appSettings]);
const handleSave = async () => {
setError(null);
setBusy(true);
try {
const update: AppSettingsUpdate = {
mqtt_broker_host: mqttBrokerHost,
mqtt_broker_port: parseInt(mqttBrokerPort, 10) || 1883,
mqtt_username: mqttUsername,
mqtt_password: mqttPassword,
mqtt_use_tls: mqttUseTls,
mqtt_tls_insecure: mqttTlsInsecure,
mqtt_topic_prefix: mqttTopicPrefix || 'meshcore',
mqtt_publish_messages: mqttPublishMessages,
mqtt_publish_raw_packets: mqttPublishRawPackets,
community_mqtt_enabled: communityMqttEnabled,
community_mqtt_iata: communityMqttIata,
community_mqtt_broker_host: communityMqttBrokerHost || 'mqtt-us-v1.letsmesh.net',
community_mqtt_broker_port: parseInt(communityMqttBrokerPort, 10) || 443,
community_mqtt_email: communityMqttEmail,
};
await onSaveAppSettings(update);
toast.success('MQTT settings saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setBusy(false);
}
};
return (
<div className={className}>
<div className="rounded-md border border-yellow-600/50 bg-yellow-950/30 px-4 py-3 text-sm text-yellow-200">
MQTT support is an experimental feature in open beta. All publishing uses QoS 0
(at-most-once delivery). Please report any bugs on the{' '}
<a
href="https://github.com/jkingsman/Remote-Terminal-for-MeshCore/issues"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-yellow-100"
>
GitHub issues page
</a>
.
</div>
{/* Private MQTT Broker */}
<div className="border border-input rounded-md overflow-hidden">
<button
type="button"
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40"
onClick={() => setPrivateExpanded(!privateExpanded)}
>
<span className="text-muted-foreground">{privateExpanded ? '▼' : '▶'}</span>
<h4 className="text-sm font-medium">Private MQTT Broker</h4>
{health?.mqtt_status === 'connected' ? (
<>
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-xs text-green-400">Connected</span>
</>
) : health?.mqtt_status === 'disconnected' ? (
<>
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-xs text-red-400">Disconnected</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-gray-500" />
<span className="text-xs text-muted-foreground">Disabled</span>
</>
)}
</button>
{privateExpanded && (
<div className="px-4 pb-4 space-y-3 border-t border-input">
<p className="text-xs text-muted-foreground pt-3">
Forward mesh data to your own MQTT broker for home automation, logging, or alerting.
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttPublishMessages}
onChange={(e) => setMqttPublishMessages(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Publish Messages</span>
</label>
<p className="text-xs text-muted-foreground ml-7">
Forward decrypted DM and channel messages
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttPublishRawPackets}
onChange={(e) => setMqttPublishRawPackets(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Publish Raw Packets</span>
</label>
<p className="text-xs text-muted-foreground ml-7">Forward all RF packets</p>
{(mqttPublishMessages || mqttPublishRawPackets) && (
<div className="space-y-3">
<Separator />
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mqtt-host">Broker Host</Label>
<Input
id="mqtt-host"
type="text"
placeholder="e.g. 192.168.1.100"
value={mqttBrokerHost}
onChange={(e) => setMqttBrokerHost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-port">Broker Port</Label>
<Input
id="mqtt-port"
type="number"
min="1"
max="65535"
value={mqttBrokerPort}
onChange={(e) => setMqttBrokerPort(e.target.value)}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="mqtt-username">Username</Label>
<Input
id="mqtt-username"
type="text"
placeholder="Optional"
value={mqttUsername}
onChange={(e) => setMqttUsername(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="mqtt-password">Password</Label>
<Input
id="mqtt-password"
type="password"
placeholder="Optional"
value={mqttPassword}
onChange={(e) => setMqttPassword(e.target.value)}
/>
</div>
</div>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={mqttUseTls}
onChange={(e) => setMqttUseTls(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Use TLS</span>
</label>
{mqttUseTls && (
<>
<label className="flex items-center gap-3 cursor-pointer ml-7">
<input
type="checkbox"
checked={mqttTlsInsecure}
onChange={(e) => setMqttTlsInsecure(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Skip certificate verification</span>
</label>
<p className="text-xs text-muted-foreground ml-7">
Allow self-signed or untrusted broker certificates
</p>
</>
)}
<Separator />
<div className="space-y-2">
<Label htmlFor="mqtt-prefix">Topic Prefix</Label>
<Input
id="mqtt-prefix"
type="text"
value={mqttTopicPrefix}
onChange={(e) => setMqttTopicPrefix(e.target.value)}
/>
<div className="text-xs text-muted-foreground space-y-2">
<div>
<p className="font-medium">
Decrypted messages{' '}
<span className="font-mono font-normal opacity-75">
{'{'}id, type, conversation_key, text, sender_timestamp, received_at,
paths, outgoing, acked{'}'}
</span>
</p>
<div className="font-mono ml-2 space-y-0.5">
<div>{mqttTopicPrefix || 'meshcore'}/dm:&lt;contact_key&gt;</div>
<div>{mqttTopicPrefix || 'meshcore'}/gm:&lt;channel_key&gt;</div>
</div>
</div>
<div>
<p className="font-medium">
Raw packets{' '}
<span className="font-mono font-normal opacity-75">
{'{'}id, observation_id, timestamp, data, payload_type, snr, rssi,
decrypted, decrypted_info{'}'}
</span>
</p>
<div className="font-mono ml-2 space-y-0.5">
<div>{mqttTopicPrefix || 'meshcore'}/raw/dm:&lt;contact_key&gt;</div>
<div>{mqttTopicPrefix || 'meshcore'}/raw/gm:&lt;channel_key&gt;</div>
<div>{mqttTopicPrefix || 'meshcore'}/raw/unrouted</div>
</div>
</div>
</div>
</div>
</div>
)}
</div>
)}
</div>
{/* Community Analytics */}
<div className="border border-input rounded-md overflow-hidden">
<button
type="button"
className="w-full flex items-center gap-2 px-4 py-3 text-left hover:bg-muted/40"
onClick={() => setCommunityExpanded(!communityExpanded)}
>
<span className="text-muted-foreground">{communityExpanded ? '▼' : '▶'}</span>
<h4 className="text-sm font-medium">Community Analytics</h4>
{health?.community_mqtt_status === 'connected' ? (
<>
<div className="w-2 h-2 rounded-full bg-green-500" />
<span className="text-xs text-green-400">Connected</span>
</>
) : health?.community_mqtt_status === 'disconnected' ? (
<>
<div className="w-2 h-2 rounded-full bg-red-500" />
<span className="text-xs text-red-400">Disconnected</span>
</>
) : (
<>
<div className="w-2 h-2 rounded-full bg-gray-500" />
<span className="text-xs text-muted-foreground">Disabled</span>
</>
)}
</button>
{communityExpanded && (
<div className="px-4 pb-4 space-y-3 border-t border-input">
<p className="text-xs text-muted-foreground pt-3">
Share raw packet data with the MeshCore community for coverage mapping and network
analysis. Only raw RF packets are shared never decrypted messages.
</p>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={communityMqttEnabled}
onChange={(e) => setCommunityMqttEnabled(e.target.checked)}
className="h-4 w-4 rounded border-border"
/>
<span className="text-sm">Enable Community Analytics</span>
</label>
{communityMqttEnabled && (
<div className="space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="community-broker-host">Broker Host</Label>
<Input
id="community-broker-host"
type="text"
placeholder="mqtt-us-v1.letsmesh.net"
value={communityMqttBrokerHost}
onChange={(e) => setCommunityMqttBrokerHost(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label htmlFor="community-broker-port">Broker Port</Label>
<Input
id="community-broker-port"
type="number"
min="1"
max="65535"
value={communityMqttBrokerPort}
onChange={(e) => setCommunityMqttBrokerPort(e.target.value)}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="community-iata">Region Code (IATA)</Label>
<Input
id="community-iata"
type="text"
maxLength={3}
placeholder="e.g. DEN, LAX, NYC"
value={communityMqttIata}
onChange={(e) => setCommunityMqttIata(e.target.value.toUpperCase())}
className="w-32"
/>
<p className="text-xs text-muted-foreground">
Your nearest airport&apos;s{' '}
<a
href="https://en.wikipedia.org/wiki/List_of_airports_by_IATA_airport_code:_A"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-foreground"
>
IATA code
</a>{' '}
(required)
</p>
{communityMqttIata && (
<p className="text-xs text-muted-foreground">
Topic: meshcore/{communityMqttIata}/&lt;pubkey&gt;/packets
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="community-email">Owner Email (optional)</Label>
<Input
id="community-email"
type="email"
placeholder="you@example.com"
value={communityMqttEmail}
onChange={(e) => setCommunityMqttEmail(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Used to claim your node on the community aggregator
</p>
</div>
</div>
)}
</div>
)}
</div>
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save MQTT Settings'}
</Button>
{error && <div className="text-sm text-destructive">{error}</div>}
</div>
);
}

View File

@@ -5,46 +5,88 @@ import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { RADIO_PRESETS } from '../../utils/radioPresets';
import type { RadioConfig, RadioConfigUpdate } from '../../types';
import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioConfig,
RadioConfigUpdate,
} from '../../types';
export function SettingsRadioSection({
config,
health,
appSettings,
pageMode,
onSave,
onSaveAppSettings,
onSetPrivateKey,
onReboot,
onAdvertise,
onClose,
className,
}: {
config: RadioConfig;
health: HealthStatus | null;
appSettings: AppSettings;
pageMode: boolean;
onSave: (update: RadioConfigUpdate) => Promise<void>;
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
onAdvertise: () => Promise<void>;
onClose: () => void;
className?: string;
}) {
// Radio config state
const [name, setName] = useState('');
const [lat, setLat] = useState('');
const [lon, setLon] = useState('');
const [txPower, setTxPower] = useState('');
const [pathHashMode, setPathHashMode] = useState('0');
const [freq, setFreq] = useState('');
const [bw, setBw] = useState('');
const [sf, setSf] = useState('');
const [cr, setCr] = useState('');
const [gettingLocation, setGettingLocation] = useState(false);
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Identity state
const [privateKey, setPrivateKey] = useState('');
const [identityBusy, setIdentityBusy] = useState(false);
const [identityRebooting, setIdentityRebooting] = useState(false);
const [identityError, setIdentityError] = useState<string | null>(null);
// Flood & advert control state
const [advertIntervalHours, setAdvertIntervalHours] = useState('0');
const [floodScope, setFloodScope] = useState('');
const [maxRadioContacts, setMaxRadioContacts] = useState('');
const [floodBusy, setFloodBusy] = useState(false);
const [floodError, setFloodError] = useState<string | null>(null);
// Advertise state
const [advertising, setAdvertising] = useState(false);
useEffect(() => {
setName(config.name);
setLat(String(config.lat));
setLon(String(config.lon));
setTxPower(String(config.tx_power));
setPathHashMode(String(config.path_hash_mode));
setFreq(String(config.radio.freq));
setBw(String(config.radio.bw));
setSf(String(config.radio.sf));
setCr(String(config.radio.cr));
}, [config]);
useEffect(() => {
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
setFloodScope(appSettings.flood_scope);
setMaxRadioContacts(String(appSettings.max_radio_contacts));
}, [appSettings]);
const currentPreset = useMemo(() => {
const freqNum = parseFloat(freq);
const bwNum = parseFloat(bw);
@@ -101,22 +143,71 @@ export function SettingsRadioSection({
);
};
const buildUpdate = (): RadioConfigUpdate | null => {
const parsedLat = parseFloat(lat);
const parsedLon = parseFloat(lon);
const parsedTxPower = parseInt(txPower, 10);
const parsedPathHashMode = parseInt(pathHashMode, 10);
const parsedFreq = parseFloat(freq);
const parsedBw = parseFloat(bw);
const parsedSf = parseInt(sf, 10);
const parsedCr = parseInt(cr, 10);
if (
[parsedLat, parsedLon, parsedTxPower, parsedFreq, parsedBw, parsedSf, parsedCr].some((v) =>
isNaN(v)
)
) {
setError('All numeric fields must have valid values');
return null;
}
if (
config.path_hash_mode_supported &&
(isNaN(parsedPathHashMode) || parsedPathHashMode < 0 || parsedPathHashMode > 2)
) {
setError('Path hash mode must be between 0 and 2');
return null;
}
return {
name,
lat: parsedLat,
lon: parsedLon,
tx_power: parsedTxPower,
...(config.path_hash_mode_supported && { path_hash_mode: parsedPathHashMode }),
radio: {
freq: parsedFreq,
bw: parsedBw,
sf: parsedSf,
cr: parsedCr,
},
};
};
const handleSave = async () => {
setError(null);
setBusy(true);
const update = buildUpdate();
if (!update) return;
setBusy(true);
try {
await onSave(update);
toast.success('Radio config saved');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setBusy(false);
}
};
const handleSaveAndReboot = async () => {
setError(null);
const update = buildUpdate();
if (!update) return;
setBusy(true);
try {
const update: RadioConfigUpdate = {
lat: parseFloat(lat),
lon: parseFloat(lon),
tx_power: parseInt(txPower, 10),
radio: {
freq: parseFloat(freq),
bw: parseFloat(bw),
sf: parseInt(sf, 10),
cr: parseInt(cr, 10),
},
};
await onSave(update);
toast.success('Radio config saved, rebooting...');
setRebooting(true);
@@ -132,8 +223,98 @@ export function SettingsRadioSection({
}
};
const handleSetPrivateKey = async () => {
if (!privateKey.trim()) {
setIdentityError('Private key is required');
return;
}
setIdentityError(null);
setIdentityBusy(true);
try {
await onSetPrivateKey(privateKey.trim());
setPrivateKey('');
toast.success('Private key set, rebooting...');
setIdentityRebooting(true);
await onReboot();
if (!pageMode) {
onClose();
}
} catch (err) {
setIdentityError(err instanceof Error ? err.message : 'Failed to set private key');
} finally {
setIdentityRebooting(false);
setIdentityBusy(false);
}
};
const handleSaveFloodSettings = async () => {
setFloodError(null);
setFloodBusy(true);
try {
const update: AppSettingsUpdate = {};
const hours = parseInt(advertIntervalHours, 10);
const newAdvertInterval = isNaN(hours) ? 0 : hours * 3600;
if (newAdvertInterval !== appSettings.advert_interval) {
update.advert_interval = newAdvertInterval;
}
if (floodScope !== appSettings.flood_scope) {
update.flood_scope = floodScope;
}
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
if (!isNaN(newMaxRadioContacts) && newMaxRadioContacts !== appSettings.max_radio_contacts) {
update.max_radio_contacts = newMaxRadioContacts;
}
if (Object.keys(update).length > 0) {
await onSaveAppSettings(update);
}
toast.success('Settings saved');
} catch (err) {
setFloodError(err instanceof Error ? err.message : 'Failed to save');
} finally {
setFloodBusy(false);
}
};
const handleAdvertise = async () => {
setAdvertising(true);
try {
await onAdvertise();
} finally {
setAdvertising(false);
}
};
return (
<div className={className}>
{/* Connection display */}
<div className="space-y-2">
<Label>Connection</Label>
{health?.connection_info ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-status-connected" />
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
{health.connection_info}
</code>
</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-status-disconnected" />
<span>Not connected</span>
</div>
)}
</div>
{/* Radio Name */}
<div className="space-y-2">
<Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<Separator />
{/* Radio Config */}
<div className="space-y-2">
<Label htmlFor="preset">Preset</Label>
<select
@@ -215,6 +396,26 @@ export function SettingsRadioSection({
</div>
</div>
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
disabled={!config.path_hash_mode_supported}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<option value="0">1 byte per hop</option>
<option value="1">2 bytes per hop</option>
<option value="2">3 bytes per hop</option>
</select>
<p className="text-xs text-muted-foreground">
{config.path_hash_mode_supported
? 'Controls the default hop hash width your radio uses for outbound routed paths.'
: 'Connected radio or firmware does not expose this setting.'}
</p>
</div>
<Separator />
<div className="space-y-2">
@@ -258,11 +459,150 @@ export function SettingsRadioSection({
</div>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Button onClick={handleSave} disabled={busy || rebooting} className="w-full">
{busy || rebooting ? 'Saving & Rebooting...' : 'Save Radio Config & Reboot'}
<div className="flex gap-2">
<Button
onClick={handleSave}
disabled={busy || rebooting}
variant="outline"
className="flex-1"
>
{busy && !rebooting ? 'Saving...' : 'Save'}
</Button>
<Button onClick={handleSaveAndReboot} disabled={busy || rebooting} className="flex-1">
{rebooting ? 'Rebooting...' : 'Save & Reboot'}
</Button>
</div>
<p className="text-xs text-muted-foreground">
Some settings may require a reboot to take effect on some radios.
</p>
<Separator />
{/* Keys */}
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={identityBusy || identityRebooting || !privateKey.trim()}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div>
)}
<Separator />
{/* Flood & Advert Control */}
<div className="space-y-2">
<Label className="text-base">Flood & Advert Control</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="flood-scope">Flood Scope / Region</Label>
<Input
id="flood-scope"
value={floodScope}
onChange={(e) => setFloodScope(e.target.value)}
placeholder="#MyRegion"
/>
<p className="text-xs text-muted-foreground">
Tag outgoing flood messages with a region name (e.g. #MyRegion). Repeaters with this
region configured will prioritize your traffic. Leave empty to disable.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="max-contacts">Max Contacts on Radio</Label>
<Input
id="max-contacts"
type="number"
min="1"
max="1000"
value={maxRadioContacts}
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Favorite contacts load first, then recent non-repeater contacts until this limit is
reached (1-1000)
</p>
</div>
{floodError && (
<div className="text-sm text-destructive" role="alert">
{floodError}
</div>
)}
<Button onClick={handleSaveFloodSettings} disabled={floodBusy} className="w-full">
{floodBusy ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
{/* Send Advertisement */}
<div className="space-y-2">
<Label>Send Advertisement</Label>
<p className="text-xs text-muted-foreground">
Send a flood advertisement to announce your presence on the mesh network.
</p>
<Button
onClick={handleAdvertise}
disabled={advertising || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{advertising ? 'Sending...' : 'Send Advertisement'}
</Button>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}
</div>
</div>
);
}

View File

@@ -6,10 +6,12 @@ import type { StatisticsResponse } from '../../types';
export function SettingsStatisticsSection({ className }: { className?: string }) {
const [stats, setStats] = useState<StatisticsResponse | null>(null);
const [statsLoading, setStatsLoading] = useState(false);
const [statsError, setStatsError] = useState(false);
useEffect(() => {
let cancelled = false;
setStatsLoading(true);
setStatsError(false);
api.getStatistics().then(
(data) => {
if (!cancelled) {
@@ -18,7 +20,10 @@ export function SettingsStatisticsSection({ className }: { className?: string })
}
},
() => {
if (!cancelled) setStatsLoading(false);
if (!cancelled) {
setStatsError(true);
setStatsLoading(false);
}
}
);
return () => {
@@ -83,12 +88,12 @@ export function SettingsStatisticsSection({ className }: { className?: string })
<span className="font-medium">{stats.total_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-green-500">Decrypted</span>
<span className="font-medium text-green-500">{stats.decrypted_packets}</span>
<span className="text-sm text-success">Decrypted</span>
<span className="font-medium text-success">{stats.decrypted_packets}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-yellow-500">Undecrypted</span>
<span className="font-medium text-yellow-500">{stats.undecrypted_packets}</span>
<span className="text-sm text-warning">Undecrypted</span>
<span className="font-medium text-warning">{stats.undecrypted_packets}</span>
</div>
</div>
</div>
@@ -145,6 +150,8 @@ export function SettingsStatisticsSection({ className }: { className?: string })
</>
)}
</div>
) : statsError ? (
<div className="py-8 text-center text-muted-foreground">Failed to load statistics.</div>
) : null}
</div>
);

View File

@@ -0,0 +1,54 @@
import { useState } from 'react';
import { THEMES, getSavedTheme, applyTheme } from '../../utils/theme';
/** 3x2 grid of colored dots previewing a theme's palette. */
function ThemeSwatch({ colors }: { colors: readonly string[] }) {
return (
<div className="grid grid-cols-3 gap-[3px]" aria-hidden="true">
{colors.map((c, i) => (
<div
key={i}
className="w-3 h-3 rounded-full ring-1 ring-border/40"
style={{ backgroundColor: c }}
/>
))}
</div>
);
}
export function ThemeSelector() {
const [current, setCurrent] = useState(getSavedTheme);
const handleChange = (themeId: string) => {
setCurrent(themeId);
applyTheme(themeId);
};
return (
<fieldset className="flex flex-wrap gap-2">
<legend className="sr-only">Color theme</legend>
{THEMES.map((theme) => (
<label
key={theme.id}
className={
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer border transition-colors focus-within:ring-2 focus-within:ring-ring ' +
(current === theme.id
? 'border-primary bg-primary/5'
: 'border-transparent hover:bg-accent/50')
}
>
<input
type="radio"
name="theme"
value={theme.id}
checked={current === theme.id}
onChange={() => handleChange(theme.id)}
className="sr-only"
/>
<ThemeSwatch colors={theme.swatches} />
<span className="text-xs whitespace-nowrap">{theme.name}</span>
</label>
))}
</fieldset>
);
}

View File

@@ -1,31 +1,19 @@
export type SettingsSection =
| 'radio'
| 'identity'
| 'connectivity'
| 'mqtt'
| 'database'
| 'bot'
| 'statistics'
| 'about';
export type SettingsSection = 'radio' | 'local' | 'database' | 'fanout' | 'statistics' | 'about';
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
'radio',
'identity',
'connectivity',
'local',
'database',
'bot',
'mqtt',
'fanout',
'statistics',
'about',
];
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
radio: '📻 Radio',
identity: '🪪 Identity',
connectivity: '📡 Connectivity',
database: '🗄️ Database & Interface',
bot: '🤖 Bots',
mqtt: '📤 MQTT',
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
fanout: '📤 MQTT & Automation',
statistics: '📊 Statistics',
about: 'About',
};

View File

@@ -21,7 +21,7 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-overlay/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}

View File

@@ -21,7 +21,7 @@ const SheetOverlay = React.forwardRef<
>(({ className, ...props }, ref) => (
<SheetPrimitive.Overlay
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'fixed inset-0 z-50 bg-overlay/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}

View File

@@ -16,7 +16,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
cancelButton: 'group-[.toast]:bg-muted group-[.toast]:text-muted-foreground',
// Muted error style - dark red-tinted background with readable text
error:
'group-[.toaster]:bg-[#2a1a1a] group-[.toaster]:text-[#e8a0a0] group-[.toaster]:border-[#4a2a2a] [&_[data-description]]:text-[#d4a0a0]',
'group-[.toaster]:bg-toast-error group-[.toaster]:text-toast-error-foreground group-[.toaster]:border-toast-error-border [&_[data-description]]:text-toast-error-foreground',
},
}}
{...props}

View File

@@ -62,6 +62,57 @@ export function useAppSettings() {
[appSettings?.sidebar_sort_order]
);
const handleToggleBlockedKey = useCallback(async (key: string) => {
const normalizedKey = key.toLowerCase();
setAppSettings((prev) => {
if (!prev) return prev;
const current = prev.blocked_keys ?? [];
const wasBlocked = current.includes(normalizedKey);
const optimistic = wasBlocked
? current.filter((k) => k !== normalizedKey)
: [...current, normalizedKey];
return { ...prev, blocked_keys: optimistic };
});
try {
const updatedSettings = await api.toggleBlockedKey(key);
setAppSettings(updatedSettings);
} catch (err) {
console.error('Failed to toggle blocked key:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
toast.error('Failed to update blocked key');
}
}, []);
const handleToggleBlockedName = useCallback(async (name: string) => {
setAppSettings((prev) => {
if (!prev) return prev;
const current = prev.blocked_names ?? [];
const wasBlocked = current.includes(name);
const optimistic = wasBlocked ? current.filter((n) => n !== name) : [...current, name];
return { ...prev, blocked_names: optimistic };
});
try {
const updatedSettings = await api.toggleBlockedName(name);
setAppSettings(updatedSettings);
} catch (err) {
console.error('Failed to toggle blocked name:', err);
try {
const settings = await api.getSettings();
setAppSettings(settings);
} catch {
// If refetch also fails, leave optimistic state
}
toast.error('Failed to update blocked name');
}
}, []);
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
setAppSettings((prev) => {
if (!prev) return prev;
@@ -149,5 +200,7 @@ export function useAppSettings() {
handleSaveAppSettings,
handleSortOrderChange,
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
};
}

View File

@@ -12,7 +12,7 @@ interface PendingAckUpdate {
paths?: MessagePath[];
}
function mergePendingAck(
export function mergePendingAck(
existing: PendingAckUpdate | undefined,
ackCount: number,
paths?: MessagePath[]
@@ -62,20 +62,28 @@ interface UseConversationMessagesResult {
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
hasNewerMessages: boolean;
loadingNewer: boolean;
hasNewerMessagesRef: React.MutableRefObject<boolean>;
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
fetchOlderMessages: () => Promise<void>;
fetchNewerMessages: () => Promise<void>;
jumpToBottom: () => void;
addMessageIfNew: (msg: Message) => boolean;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
triggerReconcile: () => void;
}
export function useConversationMessages(
activeConversation: Conversation | null
activeConversation: Conversation | null,
targetMessageId?: number | null
): UseConversationMessagesResult {
const [messages, setMessages] = useState<Message[]>([]);
const [messagesLoading, setMessagesLoading] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasOlderMessages, setHasOlderMessages] = useState(false);
const [hasNewerMessages, setHasNewerMessages] = useState(false);
const [loadingNewer, setLoadingNewer] = useState(false);
// Track seen message content for deduplication
const seenMessageContent = useRef<Set<string>>(new Set());
@@ -94,6 +102,7 @@ export function useConversationMessages(
// Keep refs in sync with state so we can read current values in the switch effect
const messagesRef = useRef<Message[]>([]);
const hasOlderMessagesRef = useRef(false);
const hasNewerMessagesRef = useRef(false);
const prevConversationIdRef = useRef<string | null>(null);
useEffect(() => {
@@ -104,6 +113,10 @@ export function useConversationMessages(
hasOlderMessagesRef.current = hasOlderMessages;
}, [hasOlderMessages]);
useEffect(() => {
hasNewerMessagesRef.current = hasNewerMessages;
}, [hasNewerMessages]);
const setPendingAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
const existing = pendingAcksRef.current.get(messageId);
@@ -142,7 +155,13 @@ export function useConversationMessages(
// don't need cancellation.
const fetchMessages = useCallback(
async (showLoading = false, signal?: AbortSignal) => {
if (!activeConversation || activeConversation.type === 'raw') {
if (
!activeConversation ||
activeConversation.type === 'raw' ||
activeConversation.type === 'map' ||
activeConversation.type === 'visualizer' ||
activeConversation.type === 'search'
) {
setMessages([]);
setHasOlderMessages(false);
return;
@@ -259,11 +278,86 @@ export function useConversationMessages(
}
}, [activeConversation, loadingOlder, hasOlderMessages, messages, applyPendingAck]);
// Fetch newer messages (forward cursor pagination)
const fetchNewerMessages = useCallback(async () => {
if (
!activeConversation ||
activeConversation.type === 'raw' ||
loadingNewer ||
!hasNewerMessages
)
return;
const conversationId = activeConversation.id;
// Get the newest message as forward cursor
const newestMessage = messages.reduce(
(newest, msg) => {
if (!newest) return msg;
if (msg.received_at > newest.received_at) return msg;
if (msg.received_at === newest.received_at && msg.id > newest.id) return msg;
return newest;
},
null as Message | null
);
if (!newestMessage) return;
setLoadingNewer(true);
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: conversationId,
limit: MESSAGE_PAGE_SIZE,
after: newestMessage.received_at,
after_id: newestMessage.id,
});
if (fetchingConversationIdRef.current !== conversationId) return;
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
// Deduplicate against already-seen messages (WS race)
const newMessages = dataWithPendingAck.filter(
(msg) => !seenMessageContent.current.has(getMessageContentKey(msg))
);
if (newMessages.length > 0) {
setMessages((prev) => [...prev, ...newMessages]);
for (const msg of newMessages) {
seenMessageContent.current.add(getMessageContentKey(msg));
}
}
setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
console.error('Failed to fetch newer messages:', err);
toast.error('Failed to load newer messages', {
description: err instanceof Error ? err.message : 'Check your connection',
});
} finally {
setLoadingNewer(false);
}
}, [activeConversation, loadingNewer, hasNewerMessages, messages, applyPendingAck]);
// Jump to bottom: re-fetch latest page, clear hasNewerMessages
const jumpToBottom = useCallback(() => {
if (!activeConversation) return;
setHasNewerMessages(false);
// Invalidate cache so fetchMessages does a fresh load
messageCache.remove(activeConversation.id);
fetchMessages(true);
}, [activeConversation, fetchMessages]);
// Trigger a background reconciliation for the current conversation.
// Used after WebSocket reconnects to silently recover any missed messages.
const triggerReconcile = useCallback(() => {
const conv = activeConversation;
if (!conv || conv.type === 'raw' || conv.type === 'map' || conv.type === 'visualizer') return;
if (
!conv ||
conv.type === 'raw' ||
conv.type === 'map' ||
conv.type === 'visualizer' ||
conv.type === 'search'
)
return;
const controller = new AbortController();
reconcileFromBackend(conv, controller.signal);
}, [activeConversation]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -313,9 +407,39 @@ export function useConversationMessages(
abortControllerRef.current.abort();
}
// Save outgoing conversation to cache (if it had messages loaded)
const prevId = prevConversationIdRef.current;
if (prevId && messagesRef.current.length > 0) {
// Track which conversation we're now on
const newId = activeConversation?.id ?? null;
const conversationChanged = prevId !== newId;
fetchingConversationIdRef.current = newId;
prevConversationIdRef.current = newId;
// When targetMessageId goes from a value to null (onTargetReached cleared it)
// but the conversation hasn't changed, the around-loaded messages are already
// displayed — do nothing. Without this guard the effect would re-enter the
// normal fetch path and replace the mid-history view with the latest page.
if (!conversationChanged && !targetMessageId) {
return;
}
// Reset loadingOlder/loadingNewer — the previous conversation's in-flight
// fetch is irrelevant now (its stale-check will discard the response).
setLoadingOlder(false);
setLoadingNewer(false);
if (conversationChanged) {
setHasNewerMessages(false);
}
// Save outgoing conversation to cache only when actually leaving it, and
// only if we were on the latest page (mid-history views would restore stale
// partial data on switch-back).
if (
conversationChanged &&
prevId &&
messagesRef.current.length > 0 &&
!hasNewerMessagesRef.current
) {
messageCache.set(prevId, {
messages: messagesRef.current,
seenContent: new Set(seenMessageContent.current),
@@ -323,17 +447,14 @@ export function useConversationMessages(
});
}
// Track which conversation we're now on
const newId = activeConversation?.id ?? null;
fetchingConversationIdRef.current = newId;
prevConversationIdRef.current = newId;
// Reset loadingOlder — the previous conversation's in-flight older-message
// fetch is irrelevant now (its stale-check will discard the response).
setLoadingOlder(false);
// Clear state for new conversation
if (!activeConversation || activeConversation.type === 'raw') {
// Clear state for non-message views
if (
!activeConversation ||
activeConversation.type === 'raw' ||
activeConversation.type === 'map' ||
activeConversation.type === 'visualizer' ||
activeConversation.type === 'search'
) {
setMessages([]);
setHasOlderMessages(false);
return;
@@ -343,19 +464,52 @@ export function useConversationMessages(
const controller = new AbortController();
abortControllerRef.current = controller;
// Check cache for the new conversation
const cached = messageCache.get(activeConversation.id);
if (cached) {
// Restore from cache instantly — no spinner
setMessages(cached.messages);
seenMessageContent.current = new Set(cached.seenContent);
setHasOlderMessages(cached.hasOlderMessages);
setMessagesLoading(false);
// Silently reconcile with backend in case we missed a WS message
reconcileFromBackend(activeConversation, controller.signal);
// Jump-to-message: skip cache and load messages around the target
if (targetMessageId) {
setMessagesLoading(true);
setMessages([]);
const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV';
api
.getMessagesAround(
targetMessageId,
msgType as 'PRIV' | 'CHAN',
activeConversation.id,
controller.signal
)
.then((response) => {
if (fetchingConversationIdRef.current !== activeConversation.id) return;
const withAcks = response.messages.map((msg) => applyPendingAck(msg));
setMessages(withAcks);
seenMessageContent.current.clear();
for (const msg of withAcks) {
seenMessageContent.current.add(getMessageContentKey(msg));
}
setHasOlderMessages(response.has_older);
setHasNewerMessages(response.has_newer);
})
.catch((err) => {
if (isAbortError(err)) return;
console.error('Failed to fetch messages around target:', err);
toast.error('Failed to jump to message');
})
.finally(() => {
setMessagesLoading(false);
});
} else {
// Not cached — full fetch with spinner
fetchMessages(true, controller.signal);
// Check cache for the new conversation
const cached = messageCache.get(activeConversation.id);
if (cached) {
// Restore from cache instantly — no spinner
setMessages(cached.messages);
seenMessageContent.current = new Set(cached.seenContent);
setHasOlderMessages(cached.hasOlderMessages);
setMessagesLoading(false);
// Silently reconcile with backend in case we missed a WS message
reconcileFromBackend(activeConversation, controller.signal);
} else {
// Not cached — full fetch with spinner
fetchMessages(true, controller.signal);
}
}
// Cleanup: abort request if conversation changes or component unmounts
@@ -367,7 +521,7 @@ export function useConversationMessages(
// - activeConversation object identity changes on every render; we only care about id/type
// - We use fetchingConversationIdRef and AbortController to handle stale responses safely
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeConversation?.id, activeConversation?.type]);
}, [activeConversation?.id, activeConversation?.type, targetMessageId]);
// Add a message if it's new (deduplication)
// Returns true if the message was added, false if it was a duplicate
@@ -446,8 +600,13 @@ export function useConversationMessages(
messagesLoading,
loadingOlder,
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
setMessages,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
addMessageIfNew,
updateMessageAck,
triggerReconcile,

View File

@@ -82,6 +82,11 @@ export function useConversationRouter({
hasSetDefaultConversation.current = true;
return;
}
if (hashConv?.type === 'search') {
setActiveConversationState({ type: 'search', id: 'search', name: 'Message Search' });
hasSetDefaultConversation.current = true;
return;
}
// Handle channel hash (ID-first with legacy-name fallback)
if (hashConv?.type === 'channel') {
@@ -202,7 +207,7 @@ export function useConversationRouter({
if (hashSyncEnabledRef.current) {
updateUrlHash(activeConversation);
}
if (getReopenLastConversationEnabled()) {
if (getReopenLastConversationEnabled() && activeConversation.type !== 'search') {
saveLastViewedConversation(activeConversation);
}
}

View File

@@ -29,10 +29,36 @@ export function useUnreadCounts(
const [mentions, setMentions] = useState<Record<string, boolean>>({});
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
// Apply unreads data to state
// Track active conversation via ref so applyUnreads can filter without
// destabilizing the callback chain (avoids re-creating fetchUnreads on
// every conversation switch).
const activeConvRef = useRef(activeConversation);
activeConvRef.current = activeConversation;
// Apply unreads data to state, filtering out the active conversation
// (the user is already viewing it, so its count should stay at 0).
const applyUnreads = useCallback((data: UnreadCounts) => {
setUnreadCounts(data.counts);
setMentions(data.mentions);
const ac = activeConvRef.current;
const activeKey =
ac &&
ac.type !== 'raw' &&
ac.type !== 'map' &&
ac.type !== 'visualizer' &&
ac.type !== 'search'
? getStateKey(ac.type as 'channel' | 'contact', ac.id)
: null;
if (activeKey) {
const counts = { ...data.counts };
const mentionsData = { ...data.mentions };
delete counts[activeKey];
delete mentionsData[activeKey];
setUnreadCounts(counts);
setMentions(mentionsData);
} else {
setUnreadCounts(data.counts);
setMentions(data.mentions);
}
if (Object.keys(data.last_message_times).length > 0) {
for (const [key, ts] of Object.entries(data.last_message_times)) {
@@ -42,13 +68,21 @@ export function useUnreadCounts(
}
}, []);
// Fetch unreads from the server-side endpoint
// Fetch unreads from the server-side endpoint.
// Also re-marks the active conversation as read so the server's last_read_at
// stays current (otherwise subsequent fetches would re-report the same unreads).
const fetchUnreads = useCallback(async () => {
try {
applyUnreads(await api.getUnreads());
} catch (err) {
console.error('Failed to fetch unreads:', err);
}
const ac = activeConvRef.current;
if (ac?.type === 'channel') {
api.markChannelRead(ac.id).catch(() => {});
} else if (ac?.type === 'contact') {
api.markContactRead(ac.id).catch(() => {});
}
}, [applyUnreads]);
// On mount, consume the prefetched promise (started in index.html before

View File

@@ -15,19 +15,65 @@
--secondary: 224 12% 17%;
--secondary-foreground: 213 12% 85%;
--muted: 224 12% 15%;
--muted-foreground: 218 10% 60%;
--muted-foreground: 218 10% 65%;
--accent: 224 11% 19%;
--accent-foreground: 213 15% 90%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 224 11% 17%;
--input: 224 11% 17%;
--border: 224 11% 23%;
--input: 224 11% 23%;
--ring: 152 60% 38%;
--radius: 0.5rem;
/* Semantic colors for specific contexts */
--msg-outgoing: 152 30% 14%;
--msg-incoming: 224 12% 15%;
/* Status indicator pips */
--status-connected: 142 71% 45%;
--status-disconnected: 220 9% 46%;
/* Semantic feedback colors */
--warning: 38 92% 50%;
--warning-foreground: 0 0% 100%;
--success: 142 71% 45%;
--success-foreground: 0 0% 100%;
--info: 217 91% 60%;
--info-foreground: 0 0% 100%;
/* Favorites */
--favorite: 43 96% 56%;
/* Console / terminal */
--console: 142 69% 58%;
--console-command: 142 76% 73%;
--console-bg: 0 0% 0%;
/* Unread badges */
--badge-unread: var(--primary);
--badge-unread-foreground: var(--primary-foreground);
--badge-mention: var(--destructive);
--badge-mention-foreground: var(--destructive-foreground);
/* Error toast */
--toast-error: 0 30% 14%;
--toast-error-foreground: 0 56% 77%;
--toast-error-border: 0 27% 23%;
/* Code editor */
--code-editor-bg: 220 13% 18%;
/* Overlay / backdrop */
--overlay: 0 0% 0%;
/* Typography */
--font-sans: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono:
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
/* Scrollbar */
--scrollbar: 224 11% 22%;
--scrollbar-hover: 224 11% 28%;
}
}
@@ -37,12 +83,7 @@
}
body {
@apply bg-background text-foreground;
font-family:
system-ui,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
sans-serif;
font-family: var(--font-sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@@ -58,17 +99,30 @@
background: transparent;
}
::-webkit-scrollbar-thumb {
background: hsl(224 11% 22%);
background: hsl(var(--scrollbar));
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: hsl(224 11% 28%);
background: hsl(var(--scrollbar-hover));
}
/* Firefox scrollbar */
* {
scrollbar-width: thin;
scrollbar-color: hsl(224 11% 22%) transparent;
scrollbar-color: hsl(var(--scrollbar)) transparent;
}
/* Message highlight animation for jump-to-message */
@keyframes message-highlight {
0% {
box-shadow: 0 0 0 2px hsl(var(--primary));
}
100% {
box-shadow: 0 0 0 2px transparent;
}
}
.message-highlight {
animation: message-highlight 2s ease-out forwards;
}
/* Constrain CodeMirror editor width */

Some files were not shown because too many files have changed in this diff Show More