51 Commits

Author SHA1 Message Date
Jack Kingsman
5b98198c60 Fix migration to not import historical advert path 2026-03-18 20:41:19 -07:00
Jack Kingsman
29a76cef96 Add e2e test 2026-03-18 20:15:56 -07:00
Jack Kingsman
0768b59bcc Doc updates 2026-03-18 19:59:32 -07:00
Jack Kingsman
2c6ab31202 Dupe code cleanup 2026-03-18 19:59:32 -07:00
Jack Kingsman
7895671309 Pass 1 on PATH integration 2026-03-18 19:59:31 -07:00
Jack Kingsman
b832239e22 Add zero-hop impulse advert. Closes #83. 2026-03-18 19:59:08 -07:00
Jack Kingsman
d8e22ef4af Stop autofocus stealing of the cracker panel. Closes #80. 2026-03-18 17:53:42 -07:00
Jack Kingsman
ffc5d75a58 Use the actual remoteterm version for the client_version tag so letsmesh rollups work better 2026-03-18 17:46:10 -07:00
Jack Kingsman
350c85ca6d Behave better around DM dedupe/storage. Closes #77. 2026-03-18 17:40:11 -07:00
Jack Kingsman
4d5f0087cc Fix sidebar ordering for contacts by advert. Closes #69. 2026-03-17 21:04:57 -07:00
Jack Kingsman
e33bc553f5 improve MQTT error bubble up and massage communitymqtt + debug etc. for version management. Closes #70 2026-03-17 20:33:09 -07:00
Jack Kingsman
020acbda02 Do better DM retry to align with stndard firmware retry (but so that we can track the acks). Closes #73. 2026-03-17 18:12:07 -07:00
Jack Kingsman
d5b8f7d462 Prevent contact status bar jitter. Closes #68. 2026-03-17 16:28:05 -07:00
Jack Kingsman
bc16d804e9 Log the node time on startup 2026-03-16 22:44:53 -07:00
Jack Kingsman
a0459edf62 Add clowntown clock rollover trick 2026-03-16 22:37:13 -07:00
Jack Kingsman
86170766eb Better no-connection errors 2026-03-16 22:17:54 -07:00
Jack Kingsman
33e1b527bd Don't force save-prompt on unedited integrations 2026-03-16 21:47:04 -07:00
Jack Kingsman
23f9bd216c Add pre-filled letsmesh/meshrank MQTT 2026-03-16 21:43:45 -07:00
Jack Kingsman
35b592d2a7 Auto-reset node if clock is too far ahead to change 2026-03-16 21:24:07 -07:00
Jack Kingsman
c215aedc0d Let the radio settings pane open but not be used, rather than disabled 2026-03-16 20:30:34 -07:00
Jack Kingsman
9cd567895b Update the docs 2026-03-16 19:29:09 -07:00
Jack Kingsman
c469633a30 Serialize radio disconnect in a lock 2026-03-16 19:25:00 -07:00
Jack Kingsman
f8f0b3a8cf Updating changelog + build for 3.4.1 2026-03-16 18:43:34 -07:00
Jack Kingsman
47276dcb6c Improve handling of version info in pre-built bundles 2026-03-16 18:41:19 -07:00
Jack Kingsman
9c06ed62a4 Fix reopen-last behavior with new settings hash 2026-03-16 18:17:21 -07:00
Jack Kingsman
e19a8d3395 Update our docs and README.md. Closes #65. 2026-03-16 18:14:31 -07:00
Jack Kingsman
b68bfc41d6 Use better behavior on disconnected radio and allow deeplinking into settings. Closes #66. 2026-03-16 17:46:12 -07:00
Jack Kingsman
ffb5fa51c1 Finish frontend phase 3 2026-03-16 17:32:27 -07:00
Jack Kingsman
0e4828bf72 Frontend optimization part 2 2026-03-16 17:32:27 -07:00
Jack Kingsman
a5d9632a67 Phase 1 of frontend fixup 2026-03-16 17:32:27 -07:00
Jack Kingsman
24747ecd17 Unify our DM ingest 2026-03-16 17:32:27 -07:00
Jack Kingsman
dbb8dd4c43 Updating changelog + build for 3.4.0 2026-03-16 15:41:43 -07:00
Jack Kingsman
6c003069d4 Move to pre-built frontend on release only. Closes #62 2026-03-16 15:40:01 -07:00
Jack Kingsman
ea5ba3b2a3 Add radio model and stats display. Closes #64 2026-03-16 15:29:21 -07:00
Jack Kingsman
58b34a6a2f Make pagination requests abortable 2026-03-16 15:18:38 -07:00
Jack Kingsman
4277e0c924 Clear keys on radio disconnect and add better error for channel send non-radio response 2026-03-16 15:11:02 -07:00
Jack Kingsman
2f562ce682 Don't reconcile mid history view 2026-03-16 15:03:55 -07:00
Jack Kingsman
370ff115b4 Fix DM collapse on same second send 2026-03-16 14:53:48 -07:00
Jack Kingsman
04733b6a02 Use advert position if we don't have a from-repeater-stats lat/lon 2026-03-16 14:33:04 -07:00
Jack Kingsman
749fb43fd0 Ditch garbage data ingest for lat/lon and extend map. Closes #63 2026-03-16 14:24:58 -07:00
Jack Kingsman
8d7d926762 Fix repeater clock drift-drift on nav-away-come-back 2026-03-16 11:32:40 -07:00
Jack Kingsman
c809dad05d Polish off all our gross edges around frontend/backend Public name management 2026-03-15 18:20:43 -07:00
Jack Kingsman
c76f230c9f Reduce memo thrash on map update 2026-03-15 18:07:30 -07:00
Jack Kingsman
226dc4f59e use server time for advert freshness 2026-03-15 17:55:47 -07:00
Jack Kingsman
3f50a2ef07 fix e2e sort order test 2026-03-15 17:15:14 -07:00
Jack Kingsman
4a7ea9eb29 Always valodate fanout configs on save 2026-03-15 16:27:43 -07:00
Jack Kingsman
29368961fc Tweak send no-response handling 2026-03-15 16:12:17 -07:00
Jack Kingsman
7cb84ea6c7 Fix sidebar sort order. Closes #61 2026-03-15 16:02:09 -07:00
Jack Kingsman
0b1a19164a Fix typo and add better ops for decrypt API calls 2026-03-15 15:47:09 -07:00
Jack Kingsman
cf1a55e258 Add prebuilt frontend 2026-03-14 23:05:57 -07:00
Jack Kingsman
0881998e5b Overhaul repeater interaction to better deal with login failure clearly 2026-03-14 22:58:14 -07:00
157 changed files with 8585 additions and 2470 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
frontend/prebuilt/** -diff

4
.gitignore vendored
View File

@@ -12,8 +12,12 @@ frontend/test-results/
# Frontend build output (built from source by end users)
frontend/dist/
frontend/prebuilt/
frontend/.eslintcache
# Release artifacts
remoteterm-prebuilt-frontend-v*.zip
# reference libraries
references/

View File

@@ -10,7 +10,7 @@ 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 sequentially. All checks must pass green.
This is the repo's end-to-end quality gate. It runs backend/frontend autofixers first, then type checking, tests, and the standard frontend build. All checks must pass green, and the script may leave formatting/lint edits behind.
## Overview
@@ -138,8 +138,12 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
- `path_hash_mode` values are `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous.
- Contacts may also persist an explicit routing override (`route_override_*`). When set, radio-bound operations use the override instead of the learned `last_path*`, but learned paths still keep updating from adverts.
- Contact routing now uses canonical route fields: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, plus optional `route_override_*`.
- The contact/API surface also exposes backend-computed `effective_route`, `effective_route_source`, `direct_route`, and `route_override` so send logic and UI do not reimplement precedence rules independently.
- Legacy `last_path`, `last_path_len`, and `out_path_hash_mode` are no longer part of the contact model or API contract.
- Route precedence for direct-message sends is: explicit override, then learned direct route, then flood.
- The learned direct route is sourced from radio contact sync (`out_path`) and PATH/path-discovery updates, matching how firmware updates `ContactInfo.out_path`.
- Advertisement paths are informational only. They are retained in `contact_advert_paths` for the contact pane and visualizer, but they are not used as DM send routes.
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
## Data Flow
@@ -159,10 +163,21 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
4. Message stored in database with `outgoing=true`
5. For direct messages: ACK tracked; for channel: repeat detection
Direct-message send behavior intentionally mirrors the firmware/library `send_msg_with_retry(...)` flow:
- We push the contact's effective route to the radio via `add_contact(...)` before sending.
- Non-final attempts use the effective route (`override > direct > flood`).
- Retry timing follows the radio's `suggested_timeout`.
- The final retry is sent as flood by resetting the path on the radio first, even if an override or direct route exists.
- Path math is always hop-count based; hop bytes are interpreted using the stored `path_hash_mode`.
### ACK and Repeat Detection
**Direct messages**: Expected ACK code is tracked. When ACK event arrives, message marked as acked.
Outgoing DMs send once immediately, then may retry up to 2 more times in the background if still unacked. Retry timing follows the radio's `suggested_timeout` from `PACKET_MSG_SENT`, and the final retry is sent as flood even when a routing override is configured. DM ACK state is terminal on first ACK: sibling retry ACK codes are cleared so one DM should not accumulate multiple delivery confirmations from different retry attempts.
ACKs are not a contact-route source. They drive message delivery state and may appear in analytics/detail surfaces, but they do not update `direct_path*` or otherwise influence route selection for future sends.
**Channel messages**: Flood messages echo back through repeaters. Repeats are identified by the database UNIQUE constraint on `(type, conversation_key, text, sender_timestamp)` — when an INSERT hits a duplicate, `_handle_duplicate_message()` in `packet_processor.py` adds the new path and, for outgoing messages only, increments the ack count. Incoming repeats add path data but do not change the ack count. There is no timestamp-windowed matching; deduplication is exact-match only.
This message-layer echo/path handling is independent of raw-packet storage deduplication.
@@ -193,12 +208,13 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ └── ...
│ └── vite.config.ts
├── scripts/
│ ├── all_quality.sh # Run all lint, format, typecheck, tests, build (sequential)
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
│ ├── all_quality.sh # Repo-standard autofix + validate gate
│ ├── 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
├── README_ADVANCED.md # Advanced setup, troubleshooting, and service guidance
├── CONTRIBUTING.md # Contributor workflow and testing guidance
├── tests/ # Backend tests (pytest)
├── data/ # SQLite database (runtime)
└── pyproject.toml # Python dependencies
@@ -243,7 +259,7 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
Access at `http://localhost:8000`. All API routes are prefixed with `/api`.
If `frontend/dist` (or `frontend/dist/index.html`) is missing, backend startup now logs an explicit error and continues serving API routes. In that case, frontend static routes are not mounted until a frontend build is present.
If `frontend/dist` is missing, the backend falls back to `frontend/prebuilt` when present (for example from the release zip artifact). If neither build directory is available, startup logs an explicit error and continues serving API routes without frontend static routes mounted.
## Testing
@@ -281,7 +297,7 @@ npm run test:run
### Before Completing Changes
**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.
**Always run `./scripts/all_quality.sh` before finishing any changes that have modified code or tests.** It is the standard repo gate: autofix first, then type checks, tests, and the standard frontend build. This is not necessary for docs-only changes.
## API Summary
@@ -294,7 +310,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
| PUT | `/api/radio/private-key` | Import private key to radio |
| POST | `/api/radio/advertise` | Send advertisement |
| POST | `/api/radio/advertise` | Send advertisement (`mode`: `flood` or `zero_hop`, default `flood`) |
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
@@ -444,7 +460,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
**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`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
**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`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. The backend still carries `sidebar_sort_order` for compatibility and migration, but the current frontend sidebar stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in localStorage rather than treating it as one shared server-backed preference. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
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,21 @@
## [3.4.1] - 2026-03-16
Bugfix: Improve handling of version information on prebuilt bundles
Bugfix: Improve frontend usability on disconnected radio
Misc: Docs and readme updates
Misc: Overhaul DM ingest and frontend state handling
## [3.4.0] - 2026-03-16
Feature: Add radio model and stats display
Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
Bugfix: Misc. frontend performance and correctness fixes
Bugfix: Fix same-second same-content DM send collition
Bugfix: Discard clearly-wrong GPS data
Bugfix: Prevent repeater clock skew drift on page nav
Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
Misc: Don't permit invalid fanout configs to be saved ever`
## [3.3.0] - 2026-03-13
Feature: Use dashed lines to show collapsed ambiguous router results

108
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,108 @@
# Contributing
## Guiding Principles
- In all your interactions with developers, maintainers, and users, be kind.
- Prefer small, comprehensible changes over large sweeping ones. Individual commits should be meaningful atomic chunks of work. Pull requests with many, many commits instead of a phased approach may be declined.
- Pull requests must be fully understood and explicitly endorsed by a human before merge. AI assistance is great, and this repo is optimized for it, but we keep quality by keeping our agents on track to write clear code, useful (not useless) tests, good architecture, and big-picture thinking.
- No pull request should introduce new failing lint, typecheck, test, or build results.
- Every pull request should have an associated issue or discussion thread; a brand new feature appearing first in a PR is an antipattern.
- No truly automated radio traffic. Bot replies are already the practical edge of what this project wants to automate; any kind of traffic that would be intervalized or automated is not what this project is about.
- No ingestion from the internet onto the mesh. This project is a radio client, not a bridge for outside traffic to enter the network. The mesh is strong because it is a radio mesh, not the internet with some weird wireless links.
## Local Development
### Backend
```bash
uv sync
uv run uvicorn app.main:app --reload
```
With an explicit serial port:
```bash
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --reload
```
On Windows (PowerShell):
```powershell
uv sync
$env:MESHCORE_SERIAL_PORT="COM8"
uv run uvicorn app.main:app --reload
```
### Frontend
```bash
cd frontend
npm install
npm run dev
```
Run both the backend and `npm run dev` for hot-reloading frontend development.
## Quality Checks
Run the full quality suite before proposing or handing off code changes:
```bash
./scripts/all_quality.sh
```
That runs linting, formatting, type checking, tests, and builds for both backend and frontend.
If you need targeted commands while iterating:
```bash
# backend
uv run ruff check app/ tests/ --fix
uv run ruff format app/ tests/
uv run pyright app/
PYTHONPATH=. uv run pytest tests/ -v
# frontend
cd frontend
npm run lint:fix
npm run format
npm run test:run
npm run build
```
## E2E Testing
E2E coverage exists, but it is intentionally not part of the normal development path.
These tests are only guaranteed to run correctly in a narrow subset of environments; they require a busy mesh with messages arriving constantly, an available autodetect-able radio, and 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
npx playwright test # headless
npx playwright test --headed # you can probably guess
```
## Pull Request Expectations
- Keep scope tight.
- Explain why the change is needed.
- Link the issue or discussion where the behavior was agreed on.
- Call out any follow-up work left intentionally undone.
- Do not treat code review as the place where the app's direction is first introduced or debated
## Notes For Agent-Assisted Work
Before making non-trivial changes, read:
- `./AGENTS.md`
- `./app/AGENTS.md`
- `./frontend/AGENTS.md`
Read these only when working in those areas:
- `./app/fanout/AGENTS_fanout.md`
- `./frontend/src/components/visualizer/AGENTS_packet_visualizer.md`
- Agent output is welcome, but human review is mandatory.
- Agents should start with the AGENTS files above before making architectural changes.
- If a change touches advanced areas like fanout or the visualizer, read the area-specific AGENTS file before editing.

View File

@@ -15,8 +15,12 @@ RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
# Stage 2: Python runtime
FROM python:3.12-slim
ARG COMMIT_HASH=unknown
WORKDIR /app
ENV COMMIT_HASH=${COMMIT_HASH}
# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

279
README.md
View File

@@ -22,13 +22,27 @@ This is developed with very heavy agentic assistance -- there is no warranty of
If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./frontend/AGENTS.md`, and `./app/AGENTS.md`.
## Start Here
Most users should choose one of these paths:
1. Clone and build from source.
2. Download the prebuilt release zip if you are on a resource-constrained system and do not want to build the frontend locally.
3. Use Docker if that better matches how you deploy.
For advanced setup, troubleshooting, HTTPS, systemd service setup, and remediation environment variables, see [README_ADVANCED.md](README_ADVANCED.md).
If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
## Requirements
- Python 3.10+
- Node.js LTS or current (20, 22, 24, 25)
- Node.js LTS or current (20, 22, 24, 25) if you're not using a prebuilt release
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
- MeshCore radio connected via USB serial, TCP, or BLE
If you are on a low-resource system and do not want to build the frontend locally, download the release zip named `remoteterm-prebuilt-frontend-vX.X.X-<short hash>.zip`. That bundle includes `frontend/prebuilt`, so you can run the app without doing a frontend build from source.
<details>
<summary>Finding your serial port</summary>
@@ -65,7 +79,7 @@ usbipd attach --wsl --busid 3-8
```
</details>
## Quick Start
## Path 1: Clone And Build
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
@@ -73,43 +87,33 @@ usbipd attach --wsl --busid 3-8
git clone https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
cd Remote-Terminal-for-MeshCore
# Install backend dependencies
uv sync
# Build frontend
cd frontend && npm install && npm run build && cd ..
# Run server
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
The server auto-detects the serial port. To specify a transport manually:
Access the app at http://localhost:8000.
Source checkouts expect a normal frontend build in `frontend/dist`.
## Path 1.5: Use The Prebuilt Release Zip
Release zips can be found as an asset within the [releases listed here](https://github.com/jkingsman/Remote-Terminal-for-MeshCore/releases). This can be beneficial on resource constrained systems that cannot cope with the RAM-hungry frontend build process.
If you downloaded the release zip instead of cloning the repo, unpack it and run:
```bash
# Serial (explicit port)
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# TCP (e.g. via wifi-enabled firmware)
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# BLE (address and PIN both required)
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
cd Remote-Terminal-for-MeshCore
uv sync
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Access at http://localhost:8000
The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build.
> **Note:** WebGPU cracking requires HTTPS when not on localhost. See the HTTPS section under Additional Setup.
## Path 2: Docker
## Docker Compose
> **Warning:** Docker has intermittent issues with serial event subscriptions. The native method above is more reliable.
> **Note:** BLE-in-docker is outside the scope of this README, but the env vars should all still work.
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
Edit `docker-compose.yaml` to set a serial device for passthrough, or uncomment your transport (serial or TCP). Then:
@@ -142,7 +146,7 @@ docker compose pull
docker compose up -d
```
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yaml` to keep ownership aligned with your host user.
The container runs as root by default for maximum serial passthrough compatibility across host setups. On Linux, if you switch between native and Docker runs, `./data` can end up root-owned. If you do not need that serial compatibility behavior, you can enable the optional `user: "${UID:-1000}:${GID:-1000}"` line in `docker-compose.yaml` to keep ownership aligned with your host user.
To stop:
@@ -150,68 +154,9 @@ To stop:
docker compose down
```
## Development
## Standard Environment Variables
### Backend
```bash
uv sync
uv run uvicorn app.main:app --reload # autodetects serial port
# Or with explicit 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
cd frontend
npm install
npm run dev # Dev server at http://localhost:5173 (proxies API to :8000)
npm run build # Production build to dist/
```
Run both the backend and `npm run dev` for hot-reloading frontend development.
### Code Quality & Tests
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:
```bash
./scripts/all_quality.sh
```
<details>
<summary>Or run individual checks</summary>
```bash
# python
uv run ruff check app/ tests/ --fix # lint + auto-fix
uv run ruff format app/ tests/ # format (always writes)
uv run pyright app/ # type checking
PYTHONPATH=. uv run pytest tests/ -v # backend tests
# frontend
cd frontend
npm run lint:fix # esLint + auto-fix
npm run test:run # run tests
npm run format # prettier (always writes)
npm run build # build the frontend
```
</details>
## Configuration
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
| Variable | Default | Description |
|----------|---------|-------------|
@@ -221,140 +166,36 @@ npm run build # build the frontend
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
| `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) |
| `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; an intermediate security precaution, but not as good as basic auth) |
| `MESHCORE_BASIC_AUTH_USERNAME` | | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
| `MESHCORE_BASIC_AUTH_PASSWORD` | | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
Common launch patterns:
```bash
# Serial (explicit port)
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# TCP
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# BLE
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
```
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
### Remediation Environment Variables
## Where To Go Next
These are intended for diagnosing or working around radios that behave oddly.
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly sanity check |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
- whether messages were left on the radio without reaching the app through event subscription
- whether the app's channel-slot expectations still match the radio's actual channel listing
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
## Additional Setup
<details>
<summary>HTTPS (Required for WebGPU room-finding outside localhost)</summary>
WebGPU requires a secure context. When not on `localhost`, serve over HTTPS:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=key.pem --ssl-certfile=cert.pem
```
For Docker Compose, generate the cert and add the volume mounts and command override to `docker-compose.yaml`:
```bash
# generate snakeoil TLS cert
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
```
Then add the key and cert to the `remoteterm` service in `docker-compose.yaml`, and add an explicit launch command that uses them:
```yaml
volumes:
- ./data:/app/data
- ./cert.pem:/app/cert.pem:ro
- ./key.pem:/app/key.pem:ro
command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/app/key.pem --ssl-certfile=/app/cert.pem
```
Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcert) for locally-trusted certs.
</details>
<details>
<summary>Systemd Service (Linux)</summary>
Assumes you're running from `/opt/remoteterm`; update commands and `remoteterm.service` if you're running elsewhere.
```bash
# Create service user
sudo useradd -r -m -s /bin/false remoteterm
# Install to /opt/remoteterm
sudo mkdir -p /opt/remoteterm
sudo cp -r . /opt/remoteterm/
sudo chown -R remoteterm:remoteterm /opt/remoteterm
# Install dependencies
cd /opt/remoteterm
sudo -u remoteterm uv venv
sudo -u remoteterm uv sync
# Build frontend (required for the backend to serve the web UI)
cd /opt/remoteterm/frontend
sudo -u remoteterm npm install
sudo -u remoteterm npm run build
# Install and start service
sudo cp /opt/remoteterm/remoteterm.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now remoteterm
# Check status
sudo systemctl status remoteterm
sudo journalctl -u remoteterm -f
```
Edit `/etc/systemd/system/remoteterm.service` to set `MESHCORE_SERIAL_PORT` if needed.
</details>
<details>
<summary>Testing</summary>
**Backend:**
```bash
PYTHONPATH=. uv run pytest tests/ -v
```
**Frontend:**
```bash
cd frontend
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 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
npx playwright test # headless
npx playwright test --headed # show the browser window
```
</details>
## API Documentation
With the backend running: http://localhost:8000/docs
## Debugging & Bug Reports
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
To start the server with debug logging:
```bash
MESHCORE_LOG_LEVEL=DEBUG uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
Please include the relevant debug log output when filing an issue on GitHub.
- Advanced setup, troubleshooting, HTTPS, systemd, remediation variables, and debug logging: [README_ADVANCED.md](README_ADVANCED.md)
- Contributing, tests, linting, E2E notes, and important AGENTS files: [CONTRIBUTING.md](CONTRIBUTING.md)
- Live API docs after the backend is running: http://localhost:8000/docs

118
README_ADVANCED.md Normal file
View File

@@ -0,0 +1,118 @@
# Advanced Setup And Troubleshooting
## Remediation Environment Variables
These are intended for diagnosing or working around radios that behave oddly.
| Variable | Default | Description |
|----------|---------|-------------|
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling to check for messages |
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
| `__CLOWNTOWN_DO_CLOCK_WRAPAROUND` | false | Highly experimental: if the radio clock is ahead of system time, try forcing the clock to `0xFFFFFFFF`, wait for uint32 wraparound, and then retry normal time sync before falling back to reboot |
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
- whether messages were left on the radio without reaching the app through event subscription
- whether the app's channel-slot expectations still match the radio's actual channel listing
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
## HTTPS
WebGPU room-finding requires a secure context when you are not on `localhost`.
Generate a local cert and start the backend with TLS:
```bash
openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes -subj '/CN=localhost'
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=key.pem --ssl-certfile=cert.pem
```
For Docker Compose, generate the cert, mount it into the container, and override the launch command:
```yaml
services:
remoteterm:
volumes:
- ./data:/app/data
- ./cert.pem:/app/cert.pem:ro
- ./key.pem:/app/key.pem:ro
command: uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --ssl-keyfile=/app/key.pem --ssl-certfile=/app/cert.pem
```
Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcert) for locally-trusted certs.
## Systemd Service
Assumes you are running from `/opt/remoteterm`; adjust paths if you deploy elsewhere.
```bash
# Create service user
sudo useradd -r -m -s /bin/false remoteterm
# Install to /opt/remoteterm
sudo mkdir -p /opt/remoteterm
sudo cp -r . /opt/remoteterm/
sudo chown -R remoteterm:remoteterm /opt/remoteterm
# Install dependencies
cd /opt/remoteterm
sudo -u remoteterm uv venv
sudo -u remoteterm uv sync
# If deploying from a source checkout, build the frontend first
sudo -u remoteterm bash -lc 'cd /opt/remoteterm/frontend && npm install && npm run build'
# If deploying from the release zip artifact, frontend/prebuilt is already present
```
Create `/etc/systemd/system/remoteterm.service` with:
```ini
[Unit]
Description=RemoteTerm for MeshCore
After=network.target
[Service]
Type=simple
User=remoteterm
Group=remoteterm
WorkingDirectory=/opt/remoteterm
ExecStart=/opt/remoteterm/.venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000
Restart=always
RestartSec=5
Environment=MESHCORE_DATABASE_PATH=/opt/remoteterm/data/meshcore.db
# Uncomment and set if auto-detection doesn't work:
# Environment=MESHCORE_SERIAL_PORT=/dev/ttyUSB0
SupplementaryGroups=dialout
[Install]
WantedBy=multi-user.target
```
Then install and start it:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now remoteterm
sudo systemctl status remoteterm
sudo journalctl -u remoteterm -f
```
## Debug Logging And Bug Reports
If you're experiencing issues or opening a bug report, please start the backend with debug logging enabled. Debug mode provides a much more detailed breakdown of radio communication, packet processing, and other internal operations, which makes it significantly easier to diagnose problems.
```bash
MESHCORE_LOG_LEVEL=DEBUG uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
You can also navigate to `/api/debug` (or go to Settings -> About -> "Open debug support snapshot" at the bottom). This debug block contains information about the operating environment, expectations around keys and channels, and radio status. It also includes the most recent logs. **Non-log information reveals no keys, channel names, or other privilege information beyond the names of your bots. The logs, however, may contain channel names or keys (but never your private key).** If you do not wish to include this information, copy up to the `STOP COPYING HERE` marker in the debug body.
## Development Notes
For day-to-day development, see [CONTRIBUTING.md](CONTRIBUTING.md).
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.

View File

@@ -32,6 +32,7 @@ app/
├── services/ # Shared orchestration/domain services
│ ├── messages.py # Shared message creation, dedup, ACK application
│ ├── message_send.py # Direct send, channel send, resend workflows
│ ├── dm_ingest.py # Shared direct-message ingest / dedup seam for packet + fallback paths
│ ├── dm_ack_tracker.py # Pending DM ACK state
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
@@ -74,7 +75,7 @@ app/
1. Radio emits events.
2. `on_rx_log_data` stores raw packet and tries decrypt/pipeline handling.
3. Shared message-domain services create/update `messages` and shape WS payloads.
4. `CONTACT_MSG_RECV` is a fallback DM path when packet pipeline cannot decrypt.
4. Direct-message storage is centralized in `services/dm_ingest.py`; packet-processor DMs and `CONTACT_MSG_RECV` fallback events both route through that seam.
### Outgoing messages
@@ -103,8 +104,10 @@ app/
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes.
- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback.
- Contacts persist canonical direct-route fields (`direct_path`, `direct_path_len`, `direct_path_hash_mode`) so contact sync and outbound DM routing reuse the exact stored hop width instead of inferring from path bytes.
- Direct-route sources are limited to radio contact sync (`out_path`) and PATH/path-discovery updates. This mirrors firmware `onContactPathRecv(...)`, which replaces `ContactInfo.out_path` when a new returned path is heard.
- `route_override_path`, `route_override_len`, and `route_override_hash_mode` take precedence over the learned direct route for radio-bound sends.
- Advertisement paths are stored only in `contact_advert_paths` for analytics/visualization. They are not part of `Contact.to_radio_dict()` or DM route selection.
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
### Read/unread state
@@ -112,10 +115,22 @@ app/
- Server is source of truth (`contacts.last_read_at`, `channels.last_read_at`).
- `GET /api/read-state/unreads` returns counts, mention flags, and `last_message_times`.
### DM ingest + ACKs
- `services/dm_ingest.py` is the one place that should decide fallback-context resolution, DM dedup/reconciliation, and packet-linked vs. content-based storage behavior.
- `CONTACT_MSG_RECV` is a fallback path, not a parallel source of truth. If you change DM storage behavior, trace both `event_handlers.py` and `packet_processor.py`.
- DM ACK tracking is an in-memory pending/buffered map in `services/dm_ack_tracker.py`, with periodic expiry from `radio_sync.py`.
- Outgoing DMs send once inline, store/broadcast immediately after the first successful `MSG_SENT`, then may retry up to 2 more times in the background if still unacked.
- DM retry timing follows the firmware-provided `suggested_timeout` from `PACKET_MSG_SENT`; do not replace it with a fixed app timeout unless you intentionally want more aggressive duplicate-prone retries.
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)`: stage the effective contact route on the radio, send, wait for ACK, and on the final retry force flood via `reset_path(...)`.
- Non-final DM attempts use the contact's effective route (`override > direct > flood`). The final retry is intentionally sent as flood even when a routing override exists.
- DM ACK state is terminal on first ACK. Retry attempts may register multiple expected ACK codes for the same message, but sibling pending codes are cleared once one ACK wins so a DM should not accrue multiple delivery confirmations from retries.
- ACKs are delivery state, not routing state. Bundled ACKs inside PATH packets still satisfy pending DM sends, but ACK history does not feed contact route learning.
### Echo/repeat dedup
- Message uniqueness: `(type, conversation_key, text, sender_timestamp)`.
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented only for outgoing channel messages. Incoming repeats and direct-message duplicates may still add path data, but DM delivery state advances only from real ACK events.
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented only for outgoing channel messages. Incoming direct messages with the same conversation/text/sender timestamp also collapse onto one stored row, with later observations merging path data instead of creating a second DM.
### Raw packet dedup policy
@@ -157,7 +172,7 @@ app/
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
- `PUT /radio/private-key`
- `POST /radio/advertise`
- `POST /radio/advertise` — manual advert send; request body may set `mode` to `flood` or `zero_hop` (defaults to `flood`)
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
- `POST /radio/disconnect`
- `POST /radio/reboot`
@@ -247,7 +262,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
## Data Model Notes
Main tables:
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
- `channels`
Includes optional `flood_scope_override` for channel-specific regional sends.
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
@@ -256,6 +271,13 @@ Main tables:
- `contact_name_history` (tracks name changes over time)
- `app_settings`
Contact route state is canonicalized on the backend:
- stored route inputs: `direct_path`, `direct_path_len`, `direct_path_hash_mode`, `direct_path_updated_at`, plus optional `route_override_*`
- computed route surface: `effective_route`, `effective_route_source`, `direct_route`, `route_override`
- removed legacy names: `last_path`, `last_path_len`, `out_path_hash_mode`
Frontend and send paths should consume the canonical route surface rather than reconstructing precedence from raw fields.
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
@@ -272,11 +294,13 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
- `flood_scope`
- `blocked_keys`, `blocked_names`
Note: `sidebar_sort_order` remains in the backend model for compatibility and migration, but the current frontend sidebar uses per-section localStorage sort preferences instead of a single shared server-backed sort mode.
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
## Security Posture (intentional)
- No authn/authz.
- No per-user authn/authz model; optionally, operators may enable app-wide HTTP Basic auth for both HTTP and WS entrypoints.
- No CORS restriction (`*`).
- Bot code executes user-provided Python via `exec()`.
@@ -351,7 +375,7 @@ tests/
The MeshCore radio protocol encodes `sender_timestamp` as a 4-byte little-endian integer (Unix seconds). This is a firmware-level wire format — the radio, the Python library (`commands/messaging.py`), and the decoder (`decoder.py`) all read/write exactly 4 bytes. Millisecond Unix timestamps would overflow 4 bytes, so higher resolution is not possible without a firmware change.
**Consequence:** The dedup index `(type, conversation_key, text, COALESCE(sender_timestamp, 0))` operates at 1-second granularity. Sending identical text to the same conversation twice within one second will hit the UNIQUE constraint on the second insert, returning HTTP 500 *after* the radio has already transmitted. The message is sent over the air but not stored in the database. Do not attempt to fix this by switching to millisecond timestamps — it will break echo dedup (the echo's 4-byte timestamp won't match the stored value) and overflow `to_bytes(4, "little")`.
**Consequence:** Message dedup still operates at 1-second granularity because the radio protocol only provides second-resolution `sender_timestamp`. Do not attempt to fix this by switching to millisecond timestamps — it will break echo dedup (the echo's 4-byte timestamp won't match the stored value) and overflow `to_bytes(4, "little")`. Incoming DMs now share the same second-resolution content identity tradeoff as channel echoes: same-contact same-text same-second observations collapse onto one stored row.
### Outgoing DM echoes remain undecrypted

10
app/channel_constants.py Normal file
View File

@@ -0,0 +1,10 @@
PUBLIC_CHANNEL_KEY = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
PUBLIC_CHANNEL_NAME = "Public"
def is_public_channel_key(key: str) -> bool:
return key.upper() == PUBLIC_CHANNEL_KEY
def is_public_channel_name(name: str) -> bool:
return name.casefold() == PUBLIC_CHANNEL_NAME.casefold()

View File

@@ -4,7 +4,7 @@ from collections import deque
from threading import Lock
from typing import Literal
from pydantic import model_validator
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -22,6 +22,10 @@ class Settings(BaseSettings):
disable_bots: bool = False
enable_message_poll_fallback: bool = False
force_channel_slot_reconfigure: bool = False
clowntown_do_clock_wraparound: bool = Field(
default=False,
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
)
basic_auth_username: str = ""
basic_auth_password: str = ""

View File

@@ -13,9 +13,10 @@ CREATE TABLE IF NOT EXISTS contacts (
name TEXT,
type INTEGER DEFAULT 0,
flags INTEGER DEFAULT 0,
last_path TEXT,
last_path_len INTEGER DEFAULT -1,
out_path_hash_mode INTEGER DEFAULT 0,
direct_path TEXT,
direct_path_len INTEGER,
direct_path_hash_mode INTEGER,
direct_path_updated_at INTEGER,
route_override_path TEXT,
route_override_len INTEGER,
route_override_hash_mode INTEGER,
@@ -25,7 +26,8 @@ CREATE TABLE IF NOT EXISTS contacts (
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER
first_seen INTEGER,
last_read_at INTEGER
);
CREATE TABLE IF NOT EXISTS channels (
@@ -33,7 +35,8 @@ CREATE TABLE IF NOT EXISTS channels (
name TEXT NOT NULL,
is_hashtag INTEGER DEFAULT 0,
on_radio INTEGER DEFAULT 0,
flood_scope_override TEXT
flood_scope_override TEXT,
last_read_at INTEGER
);
CREATE TABLE IF NOT EXISTS messages (
@@ -50,9 +53,9 @@ CREATE TABLE IF NOT EXISTS messages (
acked INTEGER DEFAULT 0,
sender_name TEXT,
sender_key TEXT
-- Deduplication: identical text + timestamp in the same conversation is treated as a
-- mesh echo/repeat. Outgoing sends allocate a collision-free sender_timestamp before
-- transmit so legitimate repeat sends do not collide with this index.
-- Deduplication: channel echoes/repeats use a content/time unique index so
-- duplicate observations reconcile onto a single stored row. Legacy
-- databases may also gain an incoming-DM content index via migration 44.
-- Enforced via idx_messages_dedup_null_safe (unique index) rather than a table constraint
-- to avoid the storage overhead of SQLite's autoindex duplicating every message text.
);
@@ -90,11 +93,13 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0));
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'CHAN';
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
ON contact_advert_paths(public_key, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key

View File

@@ -60,6 +60,19 @@ class DecryptedDirectMessage:
src_hash: str # First byte of sender pubkey as hex
@dataclass
class DecryptedPathPayload:
"""Result of decrypting a PATH payload."""
dest_hash: str
src_hash: str
returned_path: bytes
returned_path_len: int
returned_path_hash_mode: int
extra_type: int
extra: bytes
@dataclass
class ParsedAdvertisement:
"""Result of parsing an advertisement packet."""
@@ -85,6 +98,10 @@ class PacketInfo:
path_hash_size: int = 1 # Bytes per hop: 1, 2, or 3
def _is_valid_advert_location(lat: float, lon: float) -> bool:
return -90 <= lat <= 90 and -180 <= lon <= 180
def extract_payload(raw_packet: bytes) -> bytes | None:
"""
Extract just the payload from a raw packet, skipping header and path.
@@ -243,7 +260,9 @@ def get_packet_payload_type(raw_packet: bytes) -> PayloadType | None:
return None
def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
def parse_advertisement(
payload: bytes, raw_packet: bytes | None = None
) -> ParsedAdvertisement | None:
"""
Parse an advertisement payload.
@@ -299,6 +318,16 @@ def parse_advertisement(payload: bytes) -> ParsedAdvertisement | None:
lon_raw = int.from_bytes(payload[offset + 4 : offset + 8], byteorder="little", signed=True)
lat = lat_raw / 1_000_000
lon = lon_raw / 1_000_000
if not _is_valid_advert_location(lat, lon):
packet_hex = (raw_packet if raw_packet is not None else payload).hex().upper()
logger.warning(
"Dropping location data for nonsensical packet -- packet %s implies lat/lon %s/%s. Outta this world!",
packet_hex,
lat,
lon,
)
lat = None
lon = None
offset += 8
# Skip feature fields if present
@@ -547,3 +576,88 @@ def try_decrypt_dm(
return None
return decrypt_direct_message(packet_info.payload, shared_secret)
def decrypt_path_payload(payload: bytes, shared_secret: bytes) -> DecryptedPathPayload | None:
"""Decrypt a PATH payload using the ECDH shared secret."""
if len(payload) < 4:
return None
dest_hash = format(payload[0], "02x")
src_hash = format(payload[1], "02x")
mac = payload[2:4]
ciphertext = payload[4:]
if len(ciphertext) == 0 or len(ciphertext) % 16 != 0:
return None
calculated_mac = hmac.new(shared_secret, ciphertext, hashlib.sha256).digest()[:2]
if calculated_mac != mac:
return None
try:
cipher = AES.new(shared_secret[:16], AES.MODE_ECB)
decrypted = cipher.decrypt(ciphertext)
except Exception as e:
logger.debug("AES decryption failed for PATH payload: %s", e)
return None
if len(decrypted) < 2:
return None
from app.path_utils import decode_path_byte
packed_len = decrypted[0]
try:
returned_path_len, hash_size = decode_path_byte(packed_len)
except ValueError:
return None
path_byte_len = returned_path_len * hash_size
if len(decrypted) < 1 + path_byte_len + 1:
return None
offset = 1
returned_path = decrypted[offset : offset + path_byte_len]
offset += path_byte_len
extra_type = decrypted[offset] & 0x0F
offset += 1
extra = decrypted[offset:]
return DecryptedPathPayload(
dest_hash=dest_hash,
src_hash=src_hash,
returned_path=returned_path,
returned_path_len=returned_path_len,
returned_path_hash_mode=hash_size - 1,
extra_type=extra_type,
extra=extra,
)
def try_decrypt_path(
raw_packet: bytes,
our_private_key: bytes,
their_public_key: bytes,
our_public_key: bytes,
) -> DecryptedPathPayload | None:
"""Try to decrypt a raw packet as a PATH packet."""
packet_info = parse_packet(raw_packet)
if packet_info is None or packet_info.payload_type != PayloadType.PATH:
return None
if len(packet_info.payload) < 4:
return None
dest_hash = packet_info.payload[0]
src_hash = packet_info.payload[1]
if dest_hash != our_public_key[0] or src_hash != their_public_key[0]:
return None
try:
shared_secret = derive_shared_secret(our_private_key, their_public_key)
except Exception as e:
logger.debug("Failed to derive shared secret for PATH payload: %s", e)
return None
return decrypt_path_payload(packet_info.payload, shared_secret)

View File

@@ -4,19 +4,21 @@ from typing import TYPE_CHECKING
from meshcore import EventType
from app.models import CONTACT_TYPE_REPEATER, Contact, ContactUpsert
from app.models import Contact, ContactUpsert
from app.packet_processor import process_raw_packet
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactRepository,
)
from app.services import dm_ack_tracker
from app.services.contact_reconciliation import (
claim_prefix_messages_for_contact,
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast
from app.services.dm_ack_apply import apply_dm_ack_code
from app.services.dm_ingest import (
ingest_fallback_direct_message,
resolve_fallback_direct_message_context,
)
from app.websocket import broadcast_event
if TYPE_CHECKING:
@@ -51,8 +53,8 @@ async def on_contact_message(event: "Event") -> None:
2. The packet processor couldn't match the sender to a known contact
The packet processor handles: decryption, storage, broadcast, bot trigger.
This handler only stores if the packet processor didn't already handle it
(detected via INSERT OR IGNORE returning None for duplicates).
This handler adapts CONTACT_MSG_RECV payloads into the shared DM ingest
workflow, which reconciles duplicates against the packet pipeline when possible.
"""
payload = event.payload
@@ -66,54 +68,27 @@ async def on_contact_message(event: "Event") -> None:
sender_pubkey = payload.get("public_key") or payload.get("pubkey_prefix", "")
received_at = int(time.time())
# Look up contact from database - use prefix lookup only if needed
# (get_by_key_or_prefix does exact match first, then prefix fallback)
try:
contact = await ContactRepository.get_by_key_or_prefix(sender_pubkey)
except AmbiguousPublicKeyPrefixError:
logger.warning(
"DM sender prefix '%s' is ambiguous; storing under prefix until full key is known",
sender_pubkey,
context = await resolve_fallback_direct_message_context(
sender_public_key=sender_pubkey,
received_at=received_at,
broadcast_fn=broadcast_event,
contact_repository=ContactRepository,
log=logger,
)
if context.skip_storage:
logger.debug(
"Skipping message from repeater %s (not stored in chat history)",
context.conversation_key[:12],
)
contact = None
if contact:
sender_pubkey = contact.public_key.lower()
return
# Promote any prefix-stored messages to this full key
await claim_prefix_messages_for_contact(public_key=sender_pubkey, log=logger)
# Skip messages from repeaters - they only send CLI responses, not chat messages.
# CLI responses are handled by the command endpoint and txt_type filter above.
if contact.type == CONTACT_TYPE_REPEATER:
logger.debug(
"Skipping message from repeater %s (not stored in chat history)",
sender_pubkey[:12],
)
return
elif sender_pubkey:
placeholder_upsert = ContactUpsert(
public_key=sender_pubkey.lower(),
type=0,
last_seen=received_at,
last_contacted=received_at,
first_seen=received_at,
on_radio=False,
out_path_hash_mode=-1,
)
await ContactRepository.upsert(placeholder_upsert)
contact = await ContactRepository.get_by_key(sender_pubkey.lower())
if contact:
broadcast_event("contact", contact.model_dump())
# Try to create message - INSERT OR IGNORE handles duplicates atomically
# If the packet processor already stored this message, this returns None
# Try to create or reconcile the message via the shared DM ingest service.
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")
path_len = payload.get("path_len")
message = await create_fallback_direct_message(
conversation_key=sender_pubkey,
message = await ingest_fallback_direct_message(
conversation_key=context.conversation_key,
text=payload.get("text", ""),
sender_timestamp=sender_timestamp,
received_at=received_at,
@@ -121,23 +96,24 @@ async def on_contact_message(event: "Event") -> None:
path_len=path_len,
txt_type=txt_type,
signature=payload.get("signature"),
sender_name=sender_name,
sender_key=sender_pubkey,
sender_name=context.sender_name,
sender_key=context.sender_key,
broadcast_fn=broadcast_event,
update_last_contacted_key=context.contact.public_key.lower() if context.contact else None,
)
if message is None:
# Already handled by packet processor (or exact duplicate) - nothing more to do
logger.debug("DM from %s already processed by packet processor", sender_pubkey[:12])
logger.debug(
"DM from %s already processed by packet processor", context.conversation_key[:12]
)
return
# If we get here, the packet processor didn't handle this message
# (likely because private key export is not available)
logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12])
# Update contact last_contacted (contact was already fetched above)
if contact:
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
logger.debug(
"DM from %s handled by event handler (fallback path)", context.conversation_key[:12]
)
async def on_rx_log_data(event: "Event") -> None:
@@ -221,11 +197,12 @@ async def on_path_update(event: "Event") -> None:
)
normalized_path_hash_mode = None
await ContactRepository.update_path(
await ContactRepository.update_direct_path(
contact.public_key,
str(path),
normalized_path_len,
normalized_path_hash_mode,
updated_at=int(time.time()),
)
@@ -292,18 +269,10 @@ async def on_ack(event: "Event") -> None:
return
logger.debug("Received ACK with code %s", ack_code)
cleanup_expired_acks()
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
if message_id is not None:
logger.info("ACK received for message %d", message_id)
# DM ACKs don't carry path data, so paths is intentionally omitted.
# The frontend's mergePendingAck handles the missing field correctly,
# preserving any previously known paths.
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event)
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
if matched:
logger.info("ACK received for code %s", ack_code)
else:
dm_ack_tracker.buffer_unmatched_ack(ack_code)
logger.debug("ACK code %s does not match any pending messages", ack_code)

View File

@@ -12,7 +12,6 @@ from __future__ import annotations
import asyncio
import base64
import hashlib
import importlib.metadata
import json
import logging
import ssl
@@ -25,12 +24,13 @@ import nacl.bindings
from app.fanout.mqtt_base import BaseMqttPublisher
from app.path_utils import parse_packet_envelope, split_path_hex
from app.version_info import get_app_build_info
logger = logging.getLogger(__name__)
_DEFAULT_BROKER = "mqtt-us-v1.letsmesh.net"
_DEFAULT_PORT = 443 # Community protocol uses WSS on port 443 by default
_CLIENT_ID = "RemoteTerm (github.com/jkingsman/Remote-Terminal-for-MeshCore)"
_CLIENT_ID = "RemoteTerm"
# Proactive JWT renewal: reconnect 1 hour before the 24h token expires
_TOKEN_LIFETIME = 86400 # 24 hours (must match _generate_jwt_token exp)
@@ -260,12 +260,8 @@ def _build_radio_info() -> str:
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"
"""Return the app version string for community MQTT payloads."""
return get_app_build_info().version
class CommunityMqttPublisher(BaseMqttPublisher):

View File

@@ -54,6 +54,17 @@ class BaseMqttPublisher(ABC):
self._settings_version: int = 0
self._version_event: asyncio.Event = asyncio.Event()
self.connected: bool = False
self.integration_name: str = ""
def set_integration_name(self, name: str) -> None:
"""Attach the configured fanout-module name for operator-facing logs."""
self.integration_name = name.strip()
def _integration_label(self) -> str:
"""Return a concise label for logs, including the configured module name."""
if self.integration_name:
return f"{self._log_prefix} [{self.integration_name}]"
return self._log_prefix
# ── Lifecycle ──────────────────────────────────────────────────────
@@ -90,8 +101,9 @@ class BaseMqttPublisher(ABC):
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,
"%s publish failed on %s. This is usually transient network noise; "
"if it self-resolves and reconnects, it is generally not a concern: %s",
self._integration_label(),
topic,
e,
exc_info=True,
@@ -225,8 +237,10 @@ class BaseMqttPublisher(ABC):
broadcast_error(title, detail)
_broadcast_health()
logger.warning(
"%s connection error: %s (reconnecting in %ds)",
self._log_prefix,
"%s connection error. This is usually transient network noise; "
"if it self-resolves, it is generally not a concern: %s "
"(reconnecting in %ds)",
self._integration_label(),
e,
backoff,
exc_info=True,

View File

@@ -77,6 +77,7 @@ class MqttCommunityModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = CommunityMqttPublisher()
self._publisher.set_integration_name(name or config_id)
async def start(self) -> None:
settings = _config_to_settings(self.config)

View File

@@ -32,6 +32,7 @@ class MqttPrivateModule(FanoutModule):
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._publisher = MqttPublisher()
self._publisher.set_integration_name(name or config_id)
async def start(self) -> None:
settings = _config_to_settings(self.config)

View File

@@ -10,6 +10,10 @@ logger = logging.getLogger(__name__)
INDEX_CACHE_CONTROL = "no-store"
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
FRONTEND_BUILD_INSTRUCTIONS = (
"Run 'cd frontend && npm install && npm run build', "
"or use a release zip that includes frontend/prebuilt."
)
class CacheControlStaticFiles(StaticFiles):
@@ -48,40 +52,38 @@ def _resolve_request_origin(request: Request) -> str:
return str(request.base_url).rstrip("/")
def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
"""Register frontend static file routes if a built frontend is available.
Returns True when routes are registered, False when frontend files are
missing/incomplete. Missing frontend files are logged but are not fatal.
"""
def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
"""Resolve and validate a built frontend directory."""
frontend_dir = frontend_dir.resolve()
index_file = frontend_dir / "index.html"
assets_dir = frontend_dir / "assets"
if not frontend_dir.exists():
logger.error(
"Frontend build directory not found at %s. "
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
frontend_dir,
)
return False
if log_failures:
logger.error("Frontend build directory not found at %s.", frontend_dir)
return False, frontend_dir
if not frontend_dir.is_dir():
logger.error(
"Frontend build path is not a directory: %s. "
"API will continue without frontend routes.",
frontend_dir,
)
return False
if log_failures:
logger.error("Frontend build path is not a directory: %s.", frontend_dir)
return False, frontend_dir
if not index_file.exists():
logger.error(
"Frontend index file not found at %s. "
"Run 'cd frontend && npm run build'. API will continue without frontend routes.",
index_file,
)
if log_failures:
logger.error("Frontend index file not found at %s.", index_file)
return False, frontend_dir
return True, frontend_dir
def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
"""Register frontend static file routes if a built frontend is available."""
valid, frontend_dir = _validate_frontend_dir(frontend_dir)
if not valid:
return False
index_file = frontend_dir / "index.html"
assets_dir = frontend_dir / "assets"
if assets_dir.exists() and assets_dir.is_dir():
app.mount(
"/assets",
@@ -157,6 +159,30 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
return True
def register_first_available_frontend_static_routes(
app: FastAPI, frontend_dirs: list[Path]
) -> Path | None:
"""Register frontend routes from the first valid build directory."""
for i, candidate in enumerate(frontend_dirs):
valid, resolved_candidate = _validate_frontend_dir(candidate, log_failures=False)
if not valid:
continue
if register_frontend_static_routes(app, resolved_candidate):
logger.info("Selected frontend build directory %s", resolved_candidate)
return resolved_candidate
if i < len(frontend_dirs) - 1:
logger.warning("Frontend build at %s was unusable; trying fallback", resolved_candidate)
logger.error(
"No usable frontend build found. Searched: %s. %s API will continue without frontend routes.",
", ".join(str(path.resolve()) for path in frontend_dirs),
FRONTEND_BUILD_INSTRUCTIONS,
)
return None
def register_frontend_missing_fallback(app: FastAPI) -> None:
"""Register a fallback route that tells the user to build the frontend."""
@@ -164,7 +190,5 @@ def register_frontend_missing_fallback(app: FastAPI) -> None:
async def frontend_not_built():
return JSONResponse(
status_code=404,
content={
"detail": "Frontend not built. Run: cd frontend && npm install && npm run build"
},
content={"detail": f"Frontend not built. {FRONTEND_BUILD_INSTRUCTIONS}"},
)

View File

@@ -30,6 +30,16 @@ _private_key: bytes | None = None
_public_key: bytes | None = None
def clear_keys() -> None:
"""Clear any stored private/public key material from memory."""
global _private_key, _public_key
had_key = _private_key is not None or _public_key is not None
_private_key = None
_public_key = None
if had_key:
logger.info("Cleared in-memory keystore")
def set_private_key(key: bytes) -> None:
"""Store the private key in memory and derive the public key.

View File

@@ -11,7 +11,10 @@ from fastapi.responses import JSONResponse
from app.config import settings as server_settings
from app.config import setup_logging
from app.database import db
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
from app.frontend_static import (
register_first_available_frontend_static_routes,
register_frontend_missing_fallback,
)
from app.radio import RadioDisconnectedError
from app.radio_sync import (
stop_message_polling,
@@ -35,6 +38,7 @@ from app.routers import (
)
from app.security import add_optional_basic_auth_middleware
from app.services.radio_runtime import radio_runtime as radio_manager
from app.version_info import get_app_build_info
setup_logging()
logger = logging.getLogger(__name__)
@@ -99,22 +103,10 @@ async def lifespan(app: FastAPI):
await db.disconnect()
def _get_version() -> str:
"""Read version from pyproject.toml so it stays in sync automatically."""
try:
pyproject = Path(__file__).resolve().parent.parent / "pyproject.toml"
for line in pyproject.read_text().splitlines():
if line.startswith("version = "):
return line.split('"')[1]
except Exception:
pass
return "0.0.0"
app = FastAPI(
title="RemoteTerm for MeshCore API",
description="API for interacting with MeshCore mesh radio networks",
version=_get_version(),
version=get_app_build_info().version,
lifespan=lifespan,
)
@@ -151,6 +143,9 @@ app.include_router(statistics.router, prefix="/api")
app.include_router(ws.router, prefix="/api")
# Serve frontend static files in production
FRONTEND_DIR = Path(__file__).parent.parent / "frontend" / "dist"
if not register_frontend_static_routes(app, FRONTEND_DIR):
FRONTEND_DIST_DIR = Path(__file__).parent.parent / "frontend" / "dist"
FRONTEND_PREBUILT_DIR = Path(__file__).parent.parent / "frontend" / "prebuilt"
if not register_first_available_frontend_static_routes(
app, [FRONTEND_DIST_DIR, FRONTEND_PREBUILT_DIR]
):
register_frontend_missing_fallback(app)

View File

@@ -8,6 +8,7 @@ This approach is safe for existing users - their databases have user_version=0,
so all migrations run in order on first startup after upgrade.
"""
import json
import logging
from hashlib import sha256
@@ -331,6 +332,27 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 42)
applied += 1
# Migration 43: Limit message dedup index to channel messages only
if version < 43:
logger.info("Applying migration 43: narrow message dedup index to channels")
await _migrate_043_split_message_dedup_by_type(conn)
await set_version(conn, 43)
applied += 1
# Migration 44: Deduplicate incoming direct messages by content/timestamp
if version < 44:
logger.info("Applying migration 44: dedupe incoming direct messages")
await _migrate_044_dedupe_incoming_direct_messages(conn)
await set_version(conn, 44)
applied += 1
# Migration 45: Replace legacy contact route columns with direct-route columns
if version < 45:
logger.info("Applying migration 45: rebuild contacts direct-route columns")
await _migrate_045_rebuild_contacts_direct_route_columns(conn)
await set_version(conn, 45)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -2443,3 +2465,311 @@ async def _migrate_042_add_channel_flood_scope_override(conn: aiosqlite.Connecti
raise
await conn.commit()
async def _migrate_043_split_message_dedup_by_type(conn: aiosqlite.Connection) -> None:
"""Restrict the message dedup index to channel messages."""
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='messages'"
)
if await cursor.fetchone() is None:
await conn.commit()
return
cursor = await conn.execute("PRAGMA table_info(messages)")
columns = {row[1] for row in await cursor.fetchall()}
required_columns = {"type", "conversation_key", "text", "sender_timestamp"}
if not required_columns.issubset(columns):
logger.debug("messages table missing dedup-index columns, skipping migration 43")
await conn.commit()
return
await conn.execute("DROP INDEX IF EXISTS idx_messages_dedup_null_safe")
await conn.execute(
"""CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'CHAN'"""
)
await conn.commit()
def _merge_message_paths(paths_json_values: list[str | None]) -> str | None:
"""Merge multiple message path arrays into one exact-observation list."""
merged: list[dict[str, object]] = []
seen: set[tuple[object | None, object | None, object | None]] = set()
for paths_json in paths_json_values:
if not paths_json:
continue
try:
parsed = json.loads(paths_json)
except (TypeError, json.JSONDecodeError):
continue
if not isinstance(parsed, list):
continue
for entry in parsed:
if not isinstance(entry, dict):
continue
key = (
entry.get("path"),
entry.get("received_at"),
entry.get("path_len"),
)
if key in seen:
continue
seen.add(key)
merged.append(entry)
return json.dumps(merged) if merged else None
async def _migrate_044_dedupe_incoming_direct_messages(conn: aiosqlite.Connection) -> None:
"""Collapse same-contact same-text same-second incoming DMs into one row."""
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='messages'"
)
if await cursor.fetchone() is None:
await conn.commit()
return
cursor = await conn.execute("PRAGMA table_info(messages)")
columns = {row[1] for row in await cursor.fetchall()}
required_columns = {
"id",
"type",
"conversation_key",
"text",
"sender_timestamp",
"received_at",
"paths",
"txt_type",
"signature",
"outgoing",
"acked",
"sender_name",
"sender_key",
}
if not required_columns.issubset(columns):
logger.debug("messages table missing incoming-DM dedup columns, skipping migration 44")
await conn.commit()
return
raw_packets_cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='raw_packets'"
)
raw_packets_exists = await raw_packets_cursor.fetchone() is not None
duplicate_groups_cursor = await conn.execute(
"""
SELECT conversation_key, text,
COALESCE(sender_timestamp, 0) AS normalized_sender_timestamp,
COUNT(*) AS duplicate_count
FROM messages
WHERE type = 'PRIV' AND outgoing = 0
GROUP BY conversation_key, text, COALESCE(sender_timestamp, 0)
HAVING COUNT(*) > 1
"""
)
duplicate_groups = await duplicate_groups_cursor.fetchall()
for group in duplicate_groups:
normalized_sender_timestamp = group["normalized_sender_timestamp"]
rows_cursor = await conn.execute(
"""
SELECT *
FROM messages
WHERE type = 'PRIV' AND outgoing = 0
AND conversation_key = ? AND text = ?
AND COALESCE(sender_timestamp, 0) = ?
ORDER BY id ASC
""",
(
group["conversation_key"],
group["text"],
normalized_sender_timestamp,
),
)
rows = list(await rows_cursor.fetchall())
if len(rows) < 2:
continue
keeper = rows[0]
duplicate_ids = [row["id"] for row in rows[1:]]
merged_paths = _merge_message_paths([row["paths"] for row in rows])
merged_received_at = min(row["received_at"] for row in rows)
merged_txt_type = next((row["txt_type"] for row in rows if row["txt_type"] != 0), 0)
merged_signature = next((row["signature"] for row in rows if row["signature"]), None)
merged_sender_name = next((row["sender_name"] for row in rows if row["sender_name"]), None)
merged_sender_key = next((row["sender_key"] for row in rows if row["sender_key"]), None)
merged_acked = max(int(row["acked"] or 0) for row in rows)
await conn.execute(
"""
UPDATE messages
SET received_at = ?, paths = ?, txt_type = ?, signature = ?,
acked = ?, sender_name = ?, sender_key = ?
WHERE id = ?
""",
(
merged_received_at,
merged_paths,
merged_txt_type,
merged_signature,
merged_acked,
merged_sender_name,
merged_sender_key,
keeper["id"],
),
)
if raw_packets_exists:
for duplicate_id in duplicate_ids:
await conn.execute(
"UPDATE raw_packets SET message_id = ? WHERE message_id = ?",
(keeper["id"], duplicate_id),
)
placeholders = ",".join("?" for _ in duplicate_ids)
await conn.execute(
f"DELETE FROM messages WHERE id IN ({placeholders})",
duplicate_ids,
)
await conn.execute("DROP INDEX IF EXISTS idx_messages_incoming_priv_dedup")
await conn.execute(
"""CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'PRIV' AND outgoing = 0"""
)
await conn.commit()
async def _migrate_045_rebuild_contacts_direct_route_columns(conn: aiosqlite.Connection) -> None:
"""Replace legacy contact route columns with canonical direct-route columns."""
cursor = await conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
)
if await cursor.fetchone() is None:
await conn.commit()
return
cursor = await conn.execute("PRAGMA table_info(contacts)")
columns = {row[1] for row in await cursor.fetchall()}
target_columns = {
"public_key",
"name",
"type",
"flags",
"direct_path",
"direct_path_len",
"direct_path_hash_mode",
"direct_path_updated_at",
"route_override_path",
"route_override_len",
"route_override_hash_mode",
"last_advert",
"lat",
"lon",
"last_seen",
"on_radio",
"last_contacted",
"first_seen",
"last_read_at",
}
if (
target_columns.issubset(columns)
and "last_path" not in columns
and "out_path_hash_mode" not in columns
):
await conn.commit()
return
await conn.execute(
"""
CREATE TABLE contacts_new (
public_key TEXT PRIMARY KEY,
name TEXT,
type INTEGER DEFAULT 0,
flags INTEGER DEFAULT 0,
direct_path TEXT,
direct_path_len INTEGER,
direct_path_hash_mode INTEGER,
direct_path_updated_at INTEGER,
route_override_path TEXT,
route_override_len INTEGER,
route_override_hash_mode INTEGER,
last_advert INTEGER,
lat REAL,
lon REAL,
last_seen INTEGER,
on_radio INTEGER DEFAULT 0,
last_contacted INTEGER,
first_seen INTEGER,
last_read_at INTEGER
)
"""
)
select_expr = {
"public_key": "public_key",
"name": "NULL",
"type": "0",
"flags": "0",
"direct_path": "NULL",
"direct_path_len": "NULL",
"direct_path_hash_mode": "NULL",
"direct_path_updated_at": "NULL",
"route_override_path": "NULL",
"route_override_len": "NULL",
"route_override_hash_mode": "NULL",
"last_advert": "NULL",
"lat": "NULL",
"lon": "NULL",
"last_seen": "NULL",
"on_radio": "0",
"last_contacted": "NULL",
"first_seen": "NULL",
"last_read_at": "NULL",
}
for name in ("name", "type", "flags"):
if name in columns:
select_expr[name] = name
if "direct_path" in columns:
select_expr["direct_path"] = "direct_path"
if "direct_path_len" in columns:
select_expr["direct_path_len"] = "direct_path_len"
if "direct_path_hash_mode" in columns:
select_expr["direct_path_hash_mode"] = "direct_path_hash_mode"
for name in (
"route_override_path",
"route_override_len",
"route_override_hash_mode",
"last_advert",
"lat",
"lon",
"last_seen",
"on_radio",
"last_contacted",
"first_seen",
"last_read_at",
):
if name in columns:
select_expr[name] = name
ordered_columns = list(select_expr.keys())
await conn.execute(
f"""
INSERT INTO contacts_new ({", ".join(ordered_columns)})
SELECT {", ".join(select_expr[name] for name in ordered_columns)}
FROM contacts
"""
)
await conn.execute("DROP TABLE contacts")
await conn.execute("ALTER TABLE contacts_new RENAME TO contacts")
await conn.commit()

View File

@@ -2,7 +2,17 @@ from typing import Literal
from pydantic import BaseModel, Field
from app.path_utils import normalize_contact_route
from app.path_utils import normalize_contact_route, normalize_route_override
class ContactRoute(BaseModel):
"""A normalized contact route."""
path: str = Field(description="Hex-encoded path bytes (empty string for direct/flood)")
path_len: int = Field(description="Hop count (-1=flood, 0=direct, >0=explicit route)")
path_hash_mode: int = Field(
description="Path hash mode (-1=flood, 0=1-byte, 1=2-byte, 2=3-byte hop identifiers)"
)
class ContactUpsert(BaseModel):
@@ -12,9 +22,10 @@ class ContactUpsert(BaseModel):
name: str | None = None
type: int = 0
flags: int = 0
last_path: str | None = None
last_path_len: int = -1
out_path_hash_mode: int | None = None
direct_path: str | None = None
direct_path_len: int | None = None
direct_path_hash_mode: int | None = None
direct_path_updated_at: int | None = None
route_override_path: str | None = None
route_override_len: int | None = None
route_override_hash_mode: int | None = None
@@ -40,7 +51,7 @@ class ContactUpsert(BaseModel):
cls, public_key: str, radio_data: dict, on_radio: bool = False
) -> "ContactUpsert":
"""Convert radio contact data to the contact-row write shape."""
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
radio_data.get("out_path"),
radio_data.get("out_path_len", -1),
radio_data.get(
@@ -53,9 +64,9 @@ class ContactUpsert(BaseModel):
name=radio_data.get("adv_name"),
type=radio_data.get("type", 0),
flags=radio_data.get("flags", 0),
last_path=last_path,
last_path_len=last_path_len,
out_path_hash_mode=out_path_hash_mode,
direct_path=direct_path,
direct_path_len=direct_path_len,
direct_path_hash_mode=direct_path_hash_mode,
lat=radio_data.get("adv_lat"),
lon=radio_data.get("adv_lon"),
last_advert=radio_data.get("last_advert"),
@@ -68,9 +79,10 @@ class Contact(BaseModel):
name: str | None = None
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 = 0
direct_path: str | None = None
direct_path_len: int = -1
direct_path_hash_mode: int = -1
direct_path_updated_at: int | None = None
route_override_path: str | None = None
route_override_len: int | None = None
route_override_hash_mode: int | None = None
@@ -82,38 +94,99 @@ class Contact(BaseModel):
last_contacted: int | None = None # Last time we sent/received a message
last_read_at: int | None = None # Server-side read state tracking
first_seen: int | None = None
effective_route: ContactRoute | None = None
effective_route_source: Literal["override", "direct", "flood"] = "flood"
direct_route: ContactRoute | None = None
route_override: ContactRoute | None = None
def model_post_init(self, __context) -> None:
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
self.direct_path,
self.direct_path_len,
self.direct_path_hash_mode,
)
self.direct_path = direct_path or None
self.direct_path_len = direct_path_len
self.direct_path_hash_mode = direct_path_hash_mode
route_override_path, route_override_len, route_override_hash_mode = (
normalize_route_override(
self.route_override_path,
self.route_override_len,
self.route_override_hash_mode,
)
)
self.route_override_path = route_override_path or None
self.route_override_len = route_override_len
self.route_override_hash_mode = route_override_hash_mode
if (
route_override_path is not None
and route_override_len is not None
and route_override_hash_mode is not None
):
self.route_override = ContactRoute(
path=route_override_path,
path_len=route_override_len,
path_hash_mode=route_override_hash_mode,
)
else:
self.route_override = None
if direct_path_len >= 0:
self.direct_route = ContactRoute(
path=direct_path,
path_len=direct_path_len,
path_hash_mode=direct_path_hash_mode,
)
else:
self.direct_route = None
path, path_len, path_hash_mode = self.effective_route_tuple()
if self.has_route_override():
self.effective_route_source = "override"
elif self.direct_route is not None:
self.effective_route_source = "direct"
else:
self.effective_route_source = "flood"
self.effective_route = ContactRoute(
path=path,
path_len=path_len,
path_hash_mode=path_hash_mode,
)
def has_route_override(self) -> bool:
return self.route_override_len is not None
def effective_route(self) -> tuple[str, int, int]:
def effective_route_tuple(self) -> tuple[str, int, int]:
if self.has_route_override():
return normalize_contact_route(
self.route_override_path,
self.route_override_len,
self.route_override_hash_mode,
)
return normalize_contact_route(
self.last_path,
self.last_path_len,
self.out_path_hash_mode,
)
if self.direct_path_len >= 0:
return normalize_contact_route(
self.direct_path,
self.direct_path_len,
self.direct_path_hash_mode,
)
return "", -1, -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.).
than our database schema (name, direct_path, etc.).
"""
last_path, last_path_len, out_path_hash_mode = self.effective_route()
effective_path, effective_path_len, effective_path_hash_mode = self.effective_route_tuple()
return {
"public_key": self.public_key,
"adv_name": self.name or "",
"type": self.type,
"flags": self.flags,
"out_path": last_path,
"out_path_len": last_path_len,
"out_path_hash_mode": out_path_hash_mode,
"out_path": effective_path,
"out_path_len": effective_path_len,
"out_path_hash_mode": effective_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,
@@ -149,7 +222,7 @@ class ContactRoutingOverrideRequest(BaseModel):
route: str = Field(
description=(
"Blank clears the override and resets learned routing to flood, "
"Blank clears the override, "
'"-1" forces flood, "0" forces direct, and explicit routes are '
"comma-separated 1/2/3-byte hop hex values"
)
@@ -410,6 +483,11 @@ class RepeaterLoginResponse(BaseModel):
"""Response from repeater login."""
status: str = Field(description="Login result status")
authenticated: bool = Field(description="Whether repeater authentication was confirmed")
message: str | None = Field(
default=None,
description="Optional warning or error message when authentication was not confirmed",
)
class RepeaterStatusResponse(BaseModel):

View File

@@ -26,6 +26,7 @@ from app.decoder import (
parse_packet,
try_decrypt_dm,
try_decrypt_packet_with_channel_key,
try_decrypt_path,
)
from app.keystore import get_private_key, get_public_key, has_private_key
from app.models import (
@@ -44,6 +45,7 @@ from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.dm_ack_apply import apply_dm_ack_code
from app.services.messages import (
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
)
@@ -318,8 +320,7 @@ async def process_raw_packet(
elif payload_type == PayloadType.ADVERT:
# Process all advert arrivals (even payload-hash duplicates) so the
# path-freshness logic in _process_advertisement can pick the shortest
# path heard within the freshness window.
# advert-history table retains recent path observations.
await _process_advertisement(raw_bytes, ts, packet_info)
elif payload_type == PayloadType.TEXT_MESSAGE:
@@ -328,6 +329,9 @@ async def process_raw_packet(
if decrypt_result:
result.update(decrypt_result)
elif payload_type == PayloadType.PATH:
await _process_path_packet(raw_bytes, ts, packet_info)
# Always broadcast raw packet for the packet feed UI (even duplicates)
# This enables the frontend cracker to see all incoming packets in real-time
broadcast_payload = RawPacketBroadcast(
@@ -425,60 +429,30 @@ async def _process_advertisement(
logger.debug("Failed to parse advertisement packet")
return
advert = parse_advertisement(packet_info.payload)
advert = parse_advertisement(packet_info.payload, raw_packet=raw_bytes)
if not advert:
logger.debug("Failed to parse advertisement payload")
return
# Extract path info from packet
new_path_len = packet_info.path_length
new_path_hex = packet_info.path.hex() if packet_info.path else ""
# Try to find existing contact
existing = await ContactRepository.get_by_key(advert.public_key.lower())
# Determine which path to use: keep shorter path if heard recently (within 60s)
# This handles advertisement echoes through different routes
PATH_FRESHNESS_SECONDS = 60
use_existing_path = False
if existing and existing.last_advert:
path_age = timestamp - existing.last_advert
existing_path_len = existing.last_path_len if existing.last_path_len >= 0 else float("inf")
# Keep existing path if it's fresh and shorter (or equal)
if path_age <= PATH_FRESHNESS_SECONDS and existing_path_len <= new_path_len:
use_existing_path = True
logger.debug(
"Keeping existing shorter path for %s (existing=%d, new=%d, age=%ds)",
advert.public_key[:12],
existing_path_len,
new_path_len,
path_age,
)
if use_existing_path:
assert existing is not None # Guaranteed by the conditions that set use_existing_path
path_len = existing.last_path_len if existing.last_path_len is not None else -1
path_hex = existing.last_path or ""
out_path_hash_mode = existing.out_path_hash_mode
else:
path_len = new_path_len
path_hex = new_path_hex
out_path_hash_mode = packet_info.path_hash_size - 1
logger.debug(
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, path_len=%d)",
"Parsed advertisement from %s: %s (role=%d, lat=%s, lon=%s, advert_path_len=%d)",
advert.public_key[:12],
advert.name,
advert.device_role,
advert.lat,
advert.lon,
path_len,
new_path_len,
)
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor)
# Use advert.timestamp for last_advert (sender's timestamp), receive timestamp for last_seen
# Use device_role from advertisement for contact type (1=Chat, 2=Repeater, 3=Room, 4=Sensor).
# Persist advert freshness fields using the server receive wall clock so
# route selection is not affected by sender clock skew.
contact_type = (
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
)
@@ -498,11 +472,8 @@ async def _process_advertisement(
type=contact_type,
lat=advert.lat,
lon=advert.lon,
last_advert=advert.timestamp if advert.timestamp > 0 else timestamp,
last_advert=timestamp,
last_seen=timestamp,
last_path=path_hex,
last_path_len=path_len,
out_path_hash_mode=out_path_hash_mode,
first_seen=timestamp, # COALESCE in upsert preserves existing value
)
@@ -666,3 +637,90 @@ async def _process_direct_message(
# Couldn't decrypt with any known contact
logger.debug("Could not decrypt DM with any of %d candidate contacts", len(candidate_contacts))
return None
async def _process_path_packet(
raw_bytes: bytes,
timestamp: int,
packet_info: PacketInfo | None,
) -> None:
"""Process a PATH packet and update the learned direct route."""
if not has_private_key():
return
private_key = get_private_key()
our_public_key = get_public_key()
if private_key is None or our_public_key is None:
return
if packet_info is None:
packet_info = parse_packet(raw_bytes)
if packet_info is None or packet_info.payload is None or len(packet_info.payload) < 4:
return
dest_hash = format(packet_info.payload[0], "02x").lower()
src_hash = format(packet_info.payload[1], "02x").lower()
our_first_byte = format(our_public_key[0], "02x").lower()
if dest_hash != our_first_byte:
return
candidate_contacts = await ContactRepository.get_by_pubkey_first_byte(src_hash)
if not candidate_contacts:
logger.debug("No contacts found matching hash %s for PATH decryption", src_hash)
return
for contact in candidate_contacts:
if len(contact.public_key) != 64:
continue
try:
contact_public_key = bytes.fromhex(contact.public_key)
except ValueError:
continue
result = try_decrypt_path(
raw_packet=raw_bytes,
our_private_key=private_key,
their_public_key=contact_public_key,
our_public_key=our_public_key,
)
if result is None:
continue
await ContactRepository.update_direct_path(
contact.public_key,
result.returned_path.hex(),
result.returned_path_len,
result.returned_path_hash_mode,
updated_at=timestamp,
)
if result.extra_type == PayloadType.ACK and len(result.extra) >= 4:
ack_code = result.extra[:4].hex()
matched = await apply_dm_ack_code(ack_code, broadcast_fn=broadcast_event)
if matched:
logger.info(
"Applied bundled PATH ACK for %s via contact %s",
ack_code,
contact.public_key[:12],
)
else:
logger.debug(
"Buffered bundled PATH ACK %s via contact %s",
ack_code,
contact.public_key[:12],
)
elif result.extra_type == PayloadType.RESPONSE and len(result.extra) > 0:
logger.debug(
"Observed bundled PATH RESPONSE from %s (%d bytes)",
contact.public_key[:12],
len(result.extra),
)
refreshed_contact = await ContactRepository.get_by_key(contact.public_key)
if refreshed_contact is not None:
broadcast_event("contact", refreshed_contact.model_dump())
return
logger.debug(
"Could not decrypt PATH packet with any of %d candidate contacts", len(candidate_contacts)
)

View File

@@ -153,12 +153,12 @@ def first_hop_hex(path_hex: str, hop_count: int) -> str | None:
def normalize_contact_route(
path_hex: str | None,
path_len: int | None,
out_path_hash_mode: int | None,
path_hash_mode: int | None,
) -> tuple[str, int, int]:
"""Normalize stored contact route fields.
Handles legacy/bad rows where the packed wire path byte was stored directly
in `last_path_len` (sometimes as a signed byte, e.g. `-125` for `0x83`).
in the hop-count column (sometimes as a signed byte, e.g. `-125` for `0x83`).
Returns `(path_hex, hop_count, hash_mode)`.
"""
normalized_path = path_hex or ""
@@ -169,7 +169,7 @@ def normalize_contact_route(
normalized_len = -1
try:
normalized_mode = int(out_path_hash_mode) if out_path_hash_mode is not None else None
normalized_mode = int(path_hash_mode) if path_hash_mode is not None else None
except (TypeError, ValueError):
normalized_mode = None
@@ -207,7 +207,7 @@ def normalize_contact_route(
def normalize_route_override(
path_hex: str | None,
path_len: int | None,
out_path_hash_mode: int | None,
path_hash_mode: int | None,
) -> tuple[str | None, int | None, int | None]:
"""Normalize optional route-override fields while preserving the unset state."""
if path_len is None:
@@ -216,7 +216,7 @@ def normalize_route_override(
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
path_hex,
path_len,
out_path_hash_mode,
path_hash_mode,
)
return normalized_path, normalized_len, normalized_mode

View File

@@ -2,15 +2,20 @@ import asyncio
import glob
import logging
import platform
import re
from collections import OrderedDict
from contextlib import asynccontextmanager, nullcontext
from pathlib import Path
from meshcore import MeshCore
from serial.serialutil import SerialException
from app.config import settings
from app.keystore import clear_keys
logger = logging.getLogger(__name__)
MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS = 3
_SERIAL_PORT_ERROR_RE = re.compile(r"could not open port (?P<port>.+?):")
class RadioOperationError(RuntimeError):
@@ -67,6 +72,36 @@ def detect_serial_devices() -> list[str]:
return devices
def _extract_serial_port_from_error(exc: Exception) -> str | None:
"""Best-effort extraction of a serial port path from a pyserial error."""
message = str(exc)
match = _SERIAL_PORT_ERROR_RE.search(message)
if match:
return match.group("port")
return None
def _format_reconnect_failure(exc: Exception) -> tuple[str, str, bool]:
"""Return log message, frontend detail, and whether to log a traceback."""
if settings.connection_type == "serial":
if isinstance(exc, RuntimeError) and str(exc).startswith("No MeshCore radio found"):
message = (
"Could not find a MeshCore radio on any serial port. "
"Did the radio get disconnected or change serial ports?"
)
return (message, message, False)
if isinstance(exc, SerialException):
port = settings.serial_port or _extract_serial_port_from_error(exc) or "the serial port"
message = (
f"Could not connect to serial port {port}. "
"Did the radio get disconnected or change serial ports?"
)
return (message, message, False)
return (f"Reconnection failed: {exc}", str(exc), True)
async def test_serial_device(port: str, baudrate: int, timeout: float = 3.0) -> bool:
"""Test if a MeshCore radio responds on the given serial port."""
mc = None
@@ -130,6 +165,12 @@ class RadioManager:
self._setup_lock: asyncio.Lock | None = None
self._setup_in_progress: bool = False
self._setup_complete: bool = False
self._frontend_reconnect_error_broadcasts: int = 0
self.device_info_loaded: bool = False
self.max_contacts: int | None = None
self.device_model: str | None = None
self.firmware_build: str | None = None
self.firmware_version: str | None = None
self.max_channels: int = 40
self.path_hash_mode: int = 0
self.path_hash_mode_supported: bool = False
@@ -165,6 +206,20 @@ class RadioManager:
else:
logger.error("Attempted to release unlocked radio operation lock (%s)", name)
def _reset_connected_runtime_state(self) -> None:
"""Clear cached runtime state after a transport teardown completes."""
self._setup_complete = False
self.device_info_loaded = False
self.max_contacts = None
self.device_model = None
self.firmware_build = None
self.firmware_version = None
self.max_channels = 40
self.path_hash_mode = 0
self.path_hash_mode_supported = False
self.reset_channel_send_cache()
self.clear_pending_message_channel_slots()
@asynccontextmanager
async def radio_operation(
self,
@@ -381,6 +436,21 @@ class RadioManager:
self._last_connected = False
await self.disconnect()
def _reset_reconnect_error_broadcasts(self) -> None:
self._frontend_reconnect_error_broadcasts = 0
def _broadcast_reconnect_error_if_needed(self, details: str) -> None:
from app.websocket import broadcast_error
self._frontend_reconnect_error_broadcasts += 1
if self._frontend_reconnect_error_broadcasts > MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
return
if self._frontend_reconnect_error_broadcasts == MAX_FRONTEND_RECONNECT_ERROR_BROADCASTS:
details = f"{details} Further reconnect failures will be logged only until a connection succeeds."
broadcast_error("Reconnection failed", details)
async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None:
"""Disable library-managed reconnects so manual teardown fully releases transport."""
connection_manager = getattr(mc, "connection_manager", None)
@@ -478,20 +548,30 @@ class RadioManager:
async def disconnect(self) -> None:
"""Disconnect from the radio."""
if self._meshcore is not None:
logger.debug("Disconnecting from radio")
clear_keys()
self._reset_reconnect_error_broadcasts()
if self._meshcore is None:
return
await self._acquire_operation_lock("disconnect", blocking=True)
try:
mc = self._meshcore
if mc is None:
return
logger.debug("Disconnecting from radio")
await self._disable_meshcore_auto_reconnect(mc)
await mc.disconnect()
await self._disable_meshcore_auto_reconnect(mc)
self._meshcore = None
self._setup_complete = False
self.max_channels = 40
self.path_hash_mode = 0
self.path_hash_mode_supported = False
self.reset_channel_send_cache()
self.clear_pending_message_channel_slots()
try:
await mc.disconnect()
finally:
await self._disable_meshcore_auto_reconnect(mc)
if self._meshcore is mc:
self._meshcore = None
self._reset_connected_runtime_state()
logger.debug("Radio disconnected")
finally:
self._release_operation_lock("disconnect")
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
"""Attempt to reconnect to the radio.
@@ -499,7 +579,7 @@ class RadioManager:
Returns True if reconnection was successful, False otherwise.
Uses a lock to prevent concurrent reconnection attempts.
"""
from app.websocket import broadcast_error, broadcast_health
from app.websocket import broadcast_health
# Lazily initialize lock (can't create in __init__ before event loop exists)
if self._reconnect_lock is None:
@@ -522,10 +602,9 @@ class RadioManager:
# Disconnect if we have a stale connection
if self._meshcore is not None:
try:
await self._meshcore.disconnect()
await self.disconnect()
except Exception:
pass
self._meshcore = None
# Try to connect (will auto-detect if no port specified)
await self.connect()
@@ -537,6 +616,7 @@ class RadioManager:
if self.is_connected:
logger.info("Radio reconnected successfully at %s", self._connection_info)
self._reset_reconnect_error_broadcasts()
if broadcast_on_success:
broadcast_health(True, self._connection_info)
return True
@@ -545,8 +625,9 @@ class RadioManager:
return False
except Exception as e:
logger.warning("Reconnection failed: %s", e, exc_info=True)
broadcast_error("Reconnection failed", str(e))
log_message, frontend_detail, include_traceback = _format_reconnect_failure(e)
logger.warning(log_message, exc_info=include_traceback)
self._broadcast_reconnect_error_if_needed(frontend_detail)
return False
async def start_connection_monitor(self) -> None:

View File

@@ -14,9 +14,11 @@ import logging
import math
import time
from contextlib import asynccontextmanager
from typing import Literal
from meshcore import EventType, MeshCore
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
from app.config import settings
from app.event_handlers import cleanup_expired_acks
from app.models import Contact, ContactUpsert
@@ -36,15 +38,18 @@ logger = logging.getLogger(__name__)
DEFAULT_MAX_CHANNELS = 40
AdvertMode = Literal["flood", "zero_hop"]
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
"""Return key contact fields for sync failure diagnostics."""
return {
"type": contact.type,
"flags": contact.flags,
"last_path": contact.last_path,
"last_path_len": contact.last_path_len,
"out_path_hash_mode": contact.out_path_hash_mode,
"direct_path": contact.direct_path,
"direct_path_len": contact.direct_path_len,
"direct_path_hash_mode": contact.direct_path_hash_mode,
"direct_path_updated_at": contact.direct_path_updated_at,
"route_override_path": contact.route_override_path,
"route_override_len": contact.route_override_len,
"route_override_hash_mode": contact.route_override_hash_mode,
@@ -443,16 +448,13 @@ async def ensure_default_channels() -> None:
This seeds the canonical Public channel row in the database if it is missing
or misnamed. It does not make the channel undeletable through the router.
"""
# Public channel - no hashtag, specific well-known key
PUBLIC_CHANNEL_KEY_HEX = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
# Check by KEY (not name) since that's what's fixed
existing = await ChannelRepository.get_by_key(PUBLIC_CHANNEL_KEY_HEX)
if not existing or existing.name != "Public":
existing = await ChannelRepository.get_by_key(PUBLIC_CHANNEL_KEY)
if not existing or existing.name != PUBLIC_CHANNEL_NAME:
logger.info("Ensuring default Public channel exists with correct name")
await ChannelRepository.upsert(
key=PUBLIC_CHANNEL_KEY_HEX,
name="Public",
key=PUBLIC_CHANNEL_KEY,
name=PUBLIC_CHANNEL_NAME,
is_hashtag=False,
on_radio=existing.on_radio if existing else False,
)
@@ -688,7 +690,12 @@ async def stop_message_polling():
logger.info("Stopped periodic message polling")
async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
async def send_advertisement(
mc: MeshCore,
*,
force: bool = False,
mode: AdvertMode = "flood",
) -> bool:
"""Send an advertisement to announce presence on the mesh.
Respects the configured advert_interval - won't send if not enough time
@@ -697,11 +704,15 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
Args:
mc: The MeshCore instance to use for the advertisement.
force: If True, send immediately regardless of interval.
mode: Advertisement mode. Flood adverts use the persisted flood-advert
throttle state; zero-hop adverts currently send immediately.
Returns True if successful, False otherwise (including if throttled).
"""
# Check if enough time has elapsed (unless forced)
if not force:
use_flood = mode == "flood"
# Only flood adverts currently participate in persisted throttle state.
if use_flood and not force:
settings = await AppSettingsRepository.get()
interval = settings.advert_interval
last_time = settings.last_advert_time
@@ -728,18 +739,19 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
return False
try:
result = await mc.commands.send_advert(flood=True)
result = await mc.commands.send_advert(flood=use_flood)
if result.type == EventType.OK:
# Update last_advert_time in database
now = int(time.time())
await AppSettingsRepository.update(last_advert_time=now)
logger.info("Advertisement sent successfully")
if use_flood:
# Track flood advert timing for periodic/startup throttling.
now = int(time.time())
await AppSettingsRepository.update(last_advert_time=now)
logger.info("%s advertisement sent successfully", mode.replace("_", "-"))
return True
else:
logger.warning("Failed to send advertisement: %s", result.payload)
logger.warning("Failed to send %s advertisement: %s", mode, result.payload)
return False
except Exception as e:
logger.warning("Error sending advertisement: %s", e, exc_info=True)
logger.warning("Error sending %s advertisement: %s", mode, e, exc_info=True)
return False
@@ -798,16 +810,177 @@ async def stop_periodic_advert():
logger.info("Stopped periodic advertisement")
# Prevents reboot-loop: once we've rebooted to fix clock skew this session,
# don't do it again (the hardware RTC case can't be fixed by reboot).
_clock_reboot_attempted: bool = False
_CLOCK_WRAP_TARGET = 0xFFFFFFFF
_CLOCK_WRAP_POLL_INTERVAL = 0.2
_CLOCK_WRAP_TIMEOUT = 3.0
async def _query_radio_time(mc: MeshCore) -> int | None:
"""Return the radio's current epoch, or None if it can't be read."""
try:
result = await mc.commands.get_time()
except Exception:
return None
if result.payload is None:
return None
value = result.payload.get("time")
if isinstance(value, int):
return value
return None
async def _attempt_clock_wraparound(mc: MeshCore, *, now: int, observed_radio_time: int) -> bool:
"""Try the experimental uint32 wraparound trick, then retry normal time sync."""
logger.warning(
"Experimental __CLOWNTOWN_DO_CLOCK_WRAPAROUND enabled: attempting uint32 "
"clock wraparound before normal time sync (radio=%d, system=%d).",
observed_radio_time,
now,
)
result = await mc.commands.set_time(_CLOCK_WRAP_TARGET)
if result.type != EventType.OK:
logger.warning(
"Clock wraparound pre-step failed: set_time(%d) returned %s.",
_CLOCK_WRAP_TARGET,
result.type,
)
return False
deadline = time.monotonic() + _CLOCK_WRAP_TIMEOUT
wrapped_time: int | None = None
while time.monotonic() < deadline:
await asyncio.sleep(_CLOCK_WRAP_POLL_INTERVAL)
wrapped_time = await _query_radio_time(mc)
if wrapped_time is not None and wrapped_time < 60:
break
else:
wrapped_time = None
if wrapped_time is None:
logger.warning(
"Clock wraparound experiment did not observe a wrapped epoch within %.1f "
"seconds; falling back to normal recovery.",
_CLOCK_WRAP_TIMEOUT,
)
return False
logger.warning(
"Clock wraparound experiment observed wrapped epoch %d; retrying normal time sync.",
wrapped_time,
)
retry = await mc.commands.set_time(now)
if retry.type == EventType.OK:
logger.warning("Clock sync succeeded after experimental wraparound.")
return True
logger.warning(
"Clock sync still failed after experimental wraparound: set_time(%d) returned %s.",
now,
retry.type,
)
return False
async def sync_radio_time(mc: MeshCore) -> bool:
"""Sync the radio's clock with the system time.
Returns True if successful, False otherwise.
The firmware only accepts forward time adjustments (new >= current).
If the radio's clock is already ahead, set_time is silently rejected
with an ERROR response. We detect this by checking the response and,
on failure, querying the radio's actual time so we can log the skew.
When significant forward skew is detected for the first time in a
session, the radio is rebooted so that boards with a volatile clock
(most companion radios) reset to their default epoch and accept the
correct time on the next connection setup. The reboot is attempted
only once; if it doesn't help (hardware RTC persists the wrong time),
the skew is logged as a warning on subsequent syncs.
Returns True if the radio accepted the new time, False otherwise.
"""
global _clock_reboot_attempted # noqa: PLW0603
try:
now = int(time.time())
await mc.commands.set_time(now)
logger.debug("Synced radio time to %d", now)
return True
preflight_radio_time: int | None = None
wraparound_attempted = False
if settings.clowntown_do_clock_wraparound:
preflight_radio_time = await _query_radio_time(mc)
if preflight_radio_time is not None and preflight_radio_time > now:
wraparound_attempted = True
if await _attempt_clock_wraparound(
mc,
now=now,
observed_radio_time=preflight_radio_time,
):
return True
result = await mc.commands.set_time(now)
if result.type == EventType.OK:
logger.debug("Synced radio time to %d", now)
return True
# Firmware rejected the time (most likely radio clock is ahead).
# Query actual radio time so we can report the delta.
radio_time = await _query_radio_time(mc)
if radio_time is not None:
delta = radio_time - now
logger.warning(
"Radio rejected time sync: radio clock is %+d seconds "
"(%+.1f hours) from system time (radio=%d, system=%d).",
delta,
delta / 3600.0,
radio_time,
now,
)
else:
delta = None
logger.warning(
"Radio rejected time sync (set_time returned %s) "
"and get_time query failed; cannot determine clock skew.",
result.type,
)
if (
settings.clowntown_do_clock_wraparound
and not wraparound_attempted
and radio_time is not None
and radio_time > now
and await _attempt_clock_wraparound(
mc,
now=now,
observed_radio_time=radio_time,
)
):
return True
# If the clock is significantly ahead and we haven't already tried
# a corrective reboot this session, reboot the radio. Boards with
# a volatile RTC (most companion radios) will reset their clock on
# reboot, allowing the next post-connect sync to succeed.
if not _clock_reboot_attempted and (delta is None or delta > 30):
_clock_reboot_attempted = True
logger.warning(
"Rebooting radio to reset clock skew. Boards with a "
"volatile RTC will accept the correct time after restart."
)
try:
await mc.commands.reboot()
except Exception:
logger.warning("Reboot command failed", exc_info=True)
elif _clock_reboot_attempted:
logger.warning(
"Clock skew persists after reboot — the radio likely has a "
"hardware RTC that preserved the wrong time. A manual "
"'clkreboot' CLI command is needed to reset it."
)
return False
except Exception as e:
logger.warning("Failed to sync radio time: %s", e, exc_info=True)
return False

View File

@@ -36,11 +36,20 @@ class ContactRepository:
@staticmethod
async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None:
contact_row = ContactRepository._coerce_contact_upsert(contact)
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
contact_row.last_path,
contact_row.last_path_len,
contact_row.out_path_hash_mode,
)
if (
contact_row.direct_path is None
and contact_row.direct_path_len is None
and contact_row.direct_path_hash_mode is None
):
direct_path = None
direct_path_len = None
direct_path_hash_mode = None
else:
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
contact_row.direct_path,
contact_row.direct_path_len,
contact_row.direct_path_hash_mode,
)
route_override_path, route_override_len, route_override_hash_mode = (
normalize_route_override(
contact_row.route_override_path,
@@ -51,20 +60,25 @@ class ContactRepository:
await db.conn.execute(
"""
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
out_path_hash_mode,
INSERT INTO contacts (public_key, name, type, flags, direct_path, direct_path_len,
direct_path_hash_mode, direct_path_updated_at,
route_override_path, route_override_len,
route_override_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 = excluded.out_path_hash_mode,
direct_path = COALESCE(excluded.direct_path, contacts.direct_path),
direct_path_len = COALESCE(excluded.direct_path_len, contacts.direct_path_len),
direct_path_hash_mode = COALESCE(
excluded.direct_path_hash_mode, contacts.direct_path_hash_mode
),
direct_path_updated_at = COALESCE(
excluded.direct_path_updated_at, contacts.direct_path_updated_at
),
route_override_path = COALESCE(
excluded.route_override_path, contacts.route_override_path
),
@@ -87,9 +101,10 @@ class ContactRepository:
contact_row.name,
contact_row.type,
contact_row.flags,
last_path,
last_path_len,
out_path_hash_mode,
direct_path,
direct_path_len,
direct_path_hash_mode,
contact_row.direct_path_updated_at,
route_override_path,
route_override_len,
route_override_hash_mode,
@@ -107,12 +122,12 @@ class ContactRepository:
@staticmethod
def _row_to_contact(row) -> Contact:
"""Convert a database row to a Contact model."""
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
row["last_path"],
row["last_path_len"],
row["out_path_hash_mode"],
)
available_columns = set(row.keys())
direct_path, direct_path_len, direct_path_hash_mode = normalize_contact_route(
row["direct_path"] if "direct_path" in available_columns else None,
row["direct_path_len"] if "direct_path_len" in available_columns else None,
row["direct_path_hash_mode"] if "direct_path_hash_mode" in available_columns else None,
)
route_override_path = (
row["route_override_path"] if "route_override_path" in available_columns else None
)
@@ -136,9 +151,14 @@ class ContactRepository:
name=row["name"],
type=row["type"],
flags=row["flags"],
last_path=last_path,
last_path_len=last_path_len,
out_path_hash_mode=out_path_hash_mode,
direct_path=direct_path,
direct_path_len=direct_path_len,
direct_path_hash_mode=direct_path_hash_mode,
direct_path_updated_at=(
row["direct_path_updated_at"]
if "direct_path_updated_at" in available_columns
else None
),
route_override_path=route_override_path,
route_override_len=route_override_len,
route_override_hash_mode=route_override_hash_mode,
@@ -286,26 +306,30 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def update_path(
async def update_direct_path(
public_key: str,
path: str,
path_len: int,
out_path_hash_mode: int | None = None,
path_hash_mode: int | None = None,
updated_at: int | None = None,
) -> None:
normalized_path, normalized_path_len, normalized_hash_mode = normalize_contact_route(
path,
path_len,
out_path_hash_mode,
path_hash_mode,
)
ts = updated_at if updated_at is not None else int(time.time())
await db.conn.execute(
"""UPDATE contacts SET last_path = ?, last_path_len = ?,
out_path_hash_mode = COALESCE(?, out_path_hash_mode),
"""UPDATE contacts SET direct_path = ?, direct_path_len = ?,
direct_path_hash_mode = COALESCE(?, direct_path_hash_mode),
direct_path_updated_at = ?,
last_seen = ? WHERE public_key = ?""",
(
normalized_path,
normalized_path_len,
normalized_hash_mode,
int(time.time()),
ts,
ts,
public_key.lower(),
),
)
@@ -316,12 +340,12 @@ class ContactRepository:
public_key: str,
path: str | None,
path_len: int | None,
out_path_hash_mode: int | None = None,
path_hash_mode: int | None = None,
) -> None:
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
path,
path_len,
out_path_hash_mode,
path_hash_mode,
)
await db.conn.execute(
"""

View File

@@ -66,9 +66,10 @@ class MessageRepository:
) -> int | None:
"""Create a message, returning the ID or None if duplicate.
Uses INSERT OR IGNORE to handle the UNIQUE constraint on
(type, conversation_key, text, sender_timestamp). This prevents
duplicate messages when the same message arrives via multiple RF paths.
Uses INSERT OR IGNORE to handle the message dedup indexes:
- channel messages dedupe by content/timestamp for echo reconciliation
- incoming direct messages dedupe by conversation/text/timestamp so
raw-packet and fallback observations merge onto one row
The path parameter is converted to the paths JSON array format.
"""
@@ -559,16 +560,20 @@ class MessageRepository:
conversation_key: str,
text: str,
sender_timestamp: int | None,
outgoing: bool | None = None,
) -> "Message | None":
"""Look up a message by its unique content fields."""
cursor = await db.conn.execute(
"""
query = """
SELECT * FROM messages
WHERE type = ? AND conversation_key = ? AND text = ?
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
""",
(msg_type, conversation_key, text, sender_timestamp, sender_timestamp),
)
"""
params: list[Any] = [msg_type, conversation_key, text, sender_timestamp, sender_timestamp]
if outgoing is not None:
query += " AND outgoing = ?"
params.append(1 if outgoing else 0)
query += " ORDER BY id ASC"
cursor = await db.conn.execute(query, params)
row = await cursor.fetchone()
if not row:
return None

View File

@@ -109,6 +109,18 @@ class RawPacketRepository:
)
await db.conn.commit()
@staticmethod
async def get_linked_message_id(packet_id: int) -> int | None:
"""Return the linked message ID for a raw packet, if any."""
cursor = await db.conn.execute(
"SELECT message_id FROM raw_packets WHERE id = ?",
(packet_id,),
)
row = await cursor.fetchone()
if not row:
return None
return row["message_id"]
@staticmethod
async def prune_old_undecrypted(max_age_days: int) -> int:
"""Delete undecrypted packets older than max_age_days. Returns count deleted."""

View File

@@ -4,6 +4,12 @@ from hashlib import sha256
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.channel_constants import (
PUBLIC_CHANNEL_KEY,
PUBLIC_CHANNEL_NAME,
is_public_channel_key,
is_public_channel_name,
)
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.region_scope import normalize_region_scope
from app.repository import ChannelRepository, MessageRepository
@@ -62,10 +68,31 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
Channels are NOT pushed to radio on creation. They are loaded to the radio
automatically when sending a message (see messages.py send_channel_message).
"""
is_hashtag = request.name.startswith("#")
requested_name = request.name
is_hashtag = requested_name.startswith("#")
# Determine the channel secret
if request.key and not is_hashtag:
# Reserve the canonical Public room so it cannot drift to another key,
# and the well-known Public key cannot be renamed to something else.
if is_public_channel_name(requested_name):
if request.key:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)",
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
raise HTTPException(
status_code=400,
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
)
key_hex = PUBLIC_CHANNEL_KEY
channel_name = PUBLIC_CHANNEL_NAME
is_hashtag = False
elif request.key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
@@ -74,17 +101,25 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
key_hex = key_bytes.hex().upper()
if is_public_channel_key(key_hex):
raise HTTPException(
status_code=400,
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
)
channel_name = requested_name
else:
# Derive key from name hash (same as meshcore library does)
key_bytes = sha256(request.name.encode("utf-8")).digest()[:16]
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
key_hex = key_bytes.hex().upper()
channel_name = requested_name
key_hex = key_bytes.hex().upper()
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, request.name, is_hashtag)
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
# Store in database only - radio sync happens at send time
await ChannelRepository.upsert(
key=key_hex,
name=request.name,
name=channel_name,
is_hashtag=is_hashtag,
on_radio=False,
)
@@ -140,6 +175,11 @@ async def delete_channel(key: str) -> dict:
Note: This does not clear the channel from the radio. The radio's channel
slots are managed separately (channels are loaded temporarily when sending).
"""
if is_public_channel_key(key):
raise HTTPException(
status_code=400, detail="The canonical Public channel cannot be deleted"
)
logger.info("Deleting channel %s from database", key)
await ChannelRepository.delete(key)

View File

@@ -304,7 +304,6 @@ async def create_contact(
contact_upsert = ContactUpsert(
public_key=lower_key,
name=request.name,
out_path_hash_mode=-1,
on_radio=False,
)
await ContactRepository.upsert(contact_upsert)
@@ -474,7 +473,7 @@ async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
return_len = int(payload.get("in_path_len") or 0)
return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len"))
await ContactRepository.update_path(
await ContactRepository.update_direct_path(
contact.public_key,
forward_path,
forward_len,
@@ -524,9 +523,8 @@ async def set_contact_routing_override(
route_text = request.route.strip()
if route_text == "":
await ContactRepository.clear_routing_override(contact.public_key)
await ContactRepository.update_path(contact.public_key, "", -1, -1)
logger.info(
"Cleared routing override and reset learned path to flood for %s",
"Cleared routing override for %s",
contact.public_key[:12],
)
elif route_text == "-1":

View File

@@ -1,10 +1,7 @@
import hashlib
import importlib.metadata
import logging
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from fastapi import APIRouter
@@ -16,15 +13,32 @@ from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_chann
from app.repository import MessageRepository
from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output
logger = logging.getLogger(__name__)
router = APIRouter(tags=["debug"])
LOG_COPY_BOUNDARY_MESSAGE = "STOP COPYING HERE IF YOU DO NOT WANT TO INCLUDE LOGS BELOW"
LOG_COPY_BOUNDARY_LINE = "-" * 64
LOG_COPY_BOUNDARY_PREFIX = [
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_MESSAGE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
LOG_COPY_BOUNDARY_LINE,
]
class DebugApplicationInfo(BaseModel):
version: str
version_source: str
commit_hash: str | None = None
commit_source: str | None = None
git_branch: str | None = None
git_dirty: bool | None = None
python_version: str
@@ -81,45 +95,15 @@ class DebugSnapshotResponse(BaseModel):
logs: list[str]
def _repo_root() -> Path:
return Path(__file__).resolve().parents[2]
def _get_app_version() -> str:
try:
return importlib.metadata.version("remoteterm-meshcore")
except Exception:
pyproject = _repo_root() / "pyproject.toml"
try:
for line in pyproject.read_text().splitlines():
if line.startswith("version = "):
return line.split('"')[1]
except Exception:
pass
return "0.0.0"
def _git_output(*args: str) -> str | None:
try:
result = subprocess.run(
["git", *args],
cwd=_repo_root(),
check=True,
capture_output=True,
text=True,
)
except Exception:
return None
output = result.stdout.strip()
return output or None
def _build_application_info() -> DebugApplicationInfo:
dirty_output = _git_output("status", "--porcelain")
build_info = get_app_build_info()
dirty_output = git_output("status", "--porcelain")
return DebugApplicationInfo(
version=_get_app_version(),
commit_hash=_git_output("rev-parse", "HEAD"),
git_branch=_git_output("rev-parse", "--abbrev-ref", "HEAD"),
version=build_info.version,
version_source=build_info.version_source,
commit_hash=build_info.commit_hash,
commit_source=build_info.commit_source,
git_branch=git_output("rev-parse", "--abbrev-ref", "HEAD"),
git_dirty=(dirty_output is not None and dirty_output != ""),
python_version=sys.version.split()[0],
)
@@ -295,5 +279,5 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
},
),
radio_probe=radio_probe,
logs=get_recent_log_lines(limit=1000),
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
)

View File

@@ -78,6 +78,26 @@ class FanoutConfigUpdate(BaseModel):
enabled: bool | None = Field(default=None, description="Enable/disable toggle")
def _validate_and_normalize_config(config_type: str, config: dict) -> dict:
"""Validate a config blob and return the canonical persisted form."""
normalized = dict(config)
if config_type == "mqtt_private":
_validate_mqtt_private_config(normalized)
elif config_type == "mqtt_community":
_validate_mqtt_community_config(normalized)
elif config_type == "bot":
_validate_bot_config(normalized)
elif config_type == "webhook":
_validate_webhook_config(normalized)
elif config_type == "apprise":
_validate_apprise_config(normalized)
elif config_type == "sqs":
_validate_sqs_config(normalized)
return normalized
def _validate_mqtt_private_config(config: dict) -> None:
"""Validate mqtt_private config blob."""
if not config.get("broker_host"):
@@ -323,28 +343,13 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
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)
elif body.type == "sqs":
_validate_sqs_config(body.config)
normalized_config = _validate_and_normalize_config(body.type, body.config)
scope = _enforce_scope(body.type, body.scope)
cfg = await FanoutConfigRepository.create(
config_type=body.type,
name=body.name,
config=body.config,
config=normalized_config,
scope=scope,
enabled=body.enabled,
)
@@ -374,27 +379,11 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
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)
elif existing["type"] == "sqs":
_validate_sqs_config(config_to_validate)
config_to_validate = body.config if body.config is not None else existing["config"]
kwargs["config"] = _validate_and_normalize_config(existing["type"], config_to_validate)
updated = await FanoutConfigRepository.update(config_id, **kwargs)
if updated is None:

View File

@@ -7,24 +7,48 @@ from pydantic import BaseModel
from app.config import settings
from app.repository import RawPacketRepository
from app.services.radio_runtime import radio_runtime as radio_manager
from app.version_info import get_app_build_info
router = APIRouter(tags=["health"])
class RadioDeviceInfoResponse(BaseModel):
model: str | None = None
firmware_build: str | None = None
firmware_version: str | None = None
max_contacts: int | None = None
max_channels: int | None = None
class AppInfoResponse(BaseModel):
version: str
commit_hash: str | None = None
class HealthResponse(BaseModel):
status: str
radio_connected: bool
radio_initializing: bool = False
radio_state: str = "disconnected"
connection_info: str | None
app_info: AppInfoResponse | None = None
radio_device_info: RadioDeviceInfoResponse | None = None
database_size_mb: float
oldest_undecrypted_timestamp: int | None
fanout_statuses: dict[str, dict[str, str]] = {}
bots_disabled: bool = False
def _clean_optional_str(value: object) -> str | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
return cleaned or None
async def build_health_data(radio_connected: bool, connection_info: str | None) -> dict:
"""Build the health status payload used by REST endpoint and WebSocket broadcasts."""
app_build_info = get_app_build_info()
db_size_mb = 0.0
try:
db_size_bytes = os.path.getsize(settings.database_path)
@@ -48,22 +72,12 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
pass
setup_in_progress = getattr(radio_manager, "is_setup_in_progress", False)
if not isinstance(setup_in_progress, bool):
setup_in_progress = False
setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected)
if not isinstance(setup_complete, bool):
setup_complete = radio_connected
if not radio_connected:
setup_complete = False
connection_desired = getattr(radio_manager, "connection_desired", True)
if not isinstance(connection_desired, bool):
connection_desired = True
is_reconnecting = getattr(radio_manager, "is_reconnecting", False)
if not isinstance(is_reconnecting, bool):
is_reconnecting = False
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
if not connection_desired:
@@ -77,12 +91,30 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
else:
radio_state = "disconnected"
radio_device_info = None
device_info_loaded = getattr(radio_manager, "device_info_loaded", False)
if radio_connected and device_info_loaded:
radio_device_info = {
"model": _clean_optional_str(getattr(radio_manager, "device_model", None)),
"firmware_build": _clean_optional_str(getattr(radio_manager, "firmware_build", None)),
"firmware_version": _clean_optional_str(
getattr(radio_manager, "firmware_version", None)
),
"max_contacts": getattr(radio_manager, "max_contacts", None),
"max_channels": getattr(radio_manager, "max_channels", None),
}
return {
"status": "ok" if radio_connected and not radio_initializing else "degraded",
"radio_connected": radio_connected,
"radio_initializing": radio_initializing,
"radio_state": radio_state,
"connection_info": connection_info,
"app_info": {
"version": app_build_info.version,
"commit_hash": app_build_info.commit_hash,
},
"radio_device_info": radio_device_info,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,
"fanout_statuses": fanout_statuses,

View File

@@ -3,7 +3,7 @@ from hashlib import sha256
from sqlite3 import OperationalError
import aiosqlite
from fastapi import APIRouter, BackgroundTasks
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
from pydantic import BaseModel, Field
from app.database import db
@@ -40,6 +40,10 @@ class DecryptResult(BaseModel):
message: str
def _bad_request(detail: str) -> HTTPException:
return HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=detail)
async def _run_historical_channel_decryption(
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
) -> None:
@@ -100,7 +104,7 @@ async def get_undecrypted_count() -> dict:
@router.post("/decrypt/historical", response_model=DecryptResult)
async def decrypt_historical_packets(
request: DecryptRequest, background_tasks: BackgroundTasks
request: DecryptRequest, background_tasks: BackgroundTasks, response: Response
) -> DecryptResult:
"""
Attempt to decrypt historical packets with the provided key.
@@ -112,27 +116,15 @@ async def decrypt_historical_packets(
try:
channel_key_bytes = bytes.fromhex(request.channel_key)
if len(channel_key_bytes) != 16:
return DecryptResult(
started=False,
total_packets=0,
message="Channel key must be 16 bytes (32 hex chars)",
)
raise _bad_request("Channel key must be 16 bytes (32 hex chars)")
channel_key_hex = request.channel_key.upper()
except ValueError:
return DecryptResult(
started=False,
total_packets=0,
message="Invalid hex string for channel key",
)
raise _bad_request("Invalid hex string for channel key") from None
elif request.channel_name:
channel_key_bytes = sha256(request.channel_name.encode("utf-8")).digest()[:16]
channel_key_hex = channel_key_bytes.hex().upper()
else:
return DecryptResult(
started=False,
total_packets=0,
message="Must provide channel_key or channel_name",
)
raise _bad_request("Must provide channel_key or channel_name")
# Get count and lookup channel name for display
count = await RawPacketRepository.get_undecrypted_count()
@@ -148,6 +140,7 @@ async def decrypt_historical_packets(
background_tasks.add_task(
_run_historical_channel_decryption, channel_key_bytes, channel_key_hex, display_name
)
response.status_code = status.HTTP_202_ACCEPTED
return DecryptResult(
started=True,
@@ -158,48 +151,24 @@ async def decrypt_historical_packets(
elif request.key_type == "contact":
# DM decryption
if not request.private_key:
return DecryptResult(
started=False,
total_packets=0,
message="Must provide private_key for contact decryption",
)
raise _bad_request("Must provide private_key for contact decryption")
if not request.contact_public_key:
return DecryptResult(
started=False,
total_packets=0,
message="Must provide contact_public_key for contact decryption",
)
raise _bad_request("Must provide contact_public_key for contact decryption")
try:
private_key_bytes = bytes.fromhex(request.private_key)
if len(private_key_bytes) != 64:
return DecryptResult(
started=False,
total_packets=0,
message="Private key must be 64 bytes (128 hex chars)",
)
raise _bad_request("Private key must be 64 bytes (128 hex chars)")
except ValueError:
return DecryptResult(
started=False,
total_packets=0,
message="Invalid hex string for private key",
)
raise _bad_request("Invalid hex string for private key") from None
try:
contact_public_key_bytes = bytes.fromhex(request.contact_public_key)
if len(contact_public_key_bytes) != 32:
return DecryptResult(
started=False,
total_packets=0,
message="Contact public key must be 32 bytes (64 hex chars)",
)
raise _bad_request("Contact public key must be 32 bytes (64 hex chars)")
contact_public_key_hex = request.contact_public_key.lower()
except ValueError:
return DecryptResult(
started=False,
total_packets=0,
message="Invalid hex string for contact public key",
)
raise _bad_request("Invalid hex string for contact public key") from None
packets = await RawPacketRepository.get_undecrypted_text_messages()
count = len(packets)
@@ -223,6 +192,7 @@ async def decrypt_historical_packets(
contact_public_key_hex,
display_name,
)
response.status_code = status.HTTP_202_ACCEPTED
return DecryptResult(
started=True,
@@ -230,11 +200,7 @@ async def decrypt_historical_packets(
message=f"Started DM decryption of {count} TEXT_MESSAGE packets in background",
)
return DecryptResult(
started=False,
total_packets=0,
message="key_type must be 'channel' or 'contact'",
)
raise _bad_request("key_type must be 'channel' or 'contact'")
class MaintenanceRequest(BaseModel):

View File

@@ -32,6 +32,7 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/radio", tags=["radio"])
AdvertLocationSource = Literal["off", "current"]
RadioAdvertMode = Literal["flood", "zero_hop"]
DiscoveryNodeType: TypeAlias = Literal["repeater", "sensor"]
DISCOVERY_WINDOW_SECONDS = 8.0
_DISCOVERY_TARGET_BITS = {
@@ -104,6 +105,13 @@ class PrivateKeyUpdate(BaseModel):
private_key: str = Field(description="Private key as hex string")
class RadioAdvertiseRequest(BaseModel):
mode: RadioAdvertMode = Field(
default="flood",
description="Advertisement mode: flood through repeaters or zero-hop local only",
)
def _monotonic() -> float:
return time.monotonic()
@@ -266,24 +274,25 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
@router.post("/advertise")
async def send_advertisement() -> dict:
"""Send a flood advertisement to announce presence on the mesh.
async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> dict:
"""Send an advertisement to announce presence on the mesh.
Manual advertisement requests always send immediately, updating the
last_advert_time which affects when the next periodic/startup advert
can occur.
Manual advertisement requests always send immediately. Flood adverts update
the shared flood-advert timing state used by periodic/startup advertising;
zero-hop adverts currently do not.
Returns:
status: "ok" if sent successfully
"""
require_connected()
mode: RadioAdvertMode = request.mode if request is not None else "flood"
logger.info("Sending flood advertisement")
logger.info("Sending %s advertisement", mode.replace("_", "-"))
async with radio_manager.radio_operation("manual_advertisement") as mc:
success = await do_send_advertisement(mc, force=True)
success = await do_send_advertisement(mc, force=True, mode=mode)
if not success:
raise HTTPException(status_code=500, detail="Failed to send advertisement")
raise HTTPException(status_code=500, detail=f"Failed to send {mode} advertisement")
return {"status": "ok"}

View File

@@ -44,8 +44,21 @@ ACL_PERMISSION_NAMES = {
}
router = APIRouter(prefix="/contacts", tags=["repeaters"])
# Delay between repeater radio operations to allow key exchange and path establishment
REPEATER_OP_DELAY_SECONDS = 2.0
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
REPEATER_LOGIN_REJECTED_MESSAGE = (
"The repeater replied but did not confirm this login. "
"Existing access may still allow some repeater operations, but admin actions may fail."
)
REPEATER_LOGIN_SEND_FAILED_MESSAGE = (
"The login request could not be sent to the repeater. "
"The dashboard is still available, but repeater operations may fail until a login succeeds."
)
REPEATER_LOGIN_TIMEOUT_MESSAGE = (
"No login confirmation was heard from the repeater. "
"On current repeater firmware, that can mean the password was wrong, "
"blank-password login was not allowed by the ACL, or the reply was missed in transit. "
"The dashboard is still available; try logging in again if admin actions fail."
)
def _monotonic() -> float:
@@ -136,31 +149,88 @@ async def _fetch_repeater_response(
return None
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> None:
"""Prepare connection to a repeater by adding to radio and logging in.
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
"""Prepare connection to a repeater by adding to radio and attempting login.
Args:
mc: MeshCore instance
contact: The repeater contact
password: Password for login (empty string for no password)
Raises:
HTTPException: If login fails
"""
pubkey_prefix = contact.public_key[:12].lower()
loop = asyncio.get_running_loop()
login_future = loop.create_future()
def _resolve_login(event_type: EventType, message: str | None = None) -> None:
if login_future.done():
return
login_future.set_result(
RepeaterLoginResponse(
status="ok" if event_type == EventType.LOGIN_SUCCESS else "error",
authenticated=event_type == EventType.LOGIN_SUCCESS,
message=message,
)
)
success_subscription = mc.subscribe(
EventType.LOGIN_SUCCESS,
lambda _event: _resolve_login(EventType.LOGIN_SUCCESS),
attribute_filters={"pubkey_prefix": pubkey_prefix},
)
failed_subscription = mc.subscribe(
EventType.LOGIN_FAILED,
lambda _event: _resolve_login(
EventType.LOGIN_FAILED,
REPEATER_LOGIN_REJECTED_MESSAGE,
),
attribute_filters={"pubkey_prefix": pubkey_prefix},
)
# Add contact to radio with path from DB (non-fatal — contact may already be loaded)
logger.info("Adding repeater %s to radio", contact.public_key[:12])
await _ensure_on_radio(mc, contact)
try:
logger.info("Adding repeater %s to radio", contact.public_key[:12])
await _ensure_on_radio(mc, contact)
# Send login with password
logger.info("Sending login to repeater %s", contact.public_key[:12])
login_result = await mc.commands.send_login(contact.public_key, password)
logger.info("Sending login to repeater %s", contact.public_key[:12])
login_result = await mc.commands.send_login(contact.public_key, password)
if login_result.type == EventType.ERROR:
raise HTTPException(status_code=401, detail=f"Login failed: {login_result.payload}")
if login_result.type == EventType.ERROR:
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({login_result.payload})",
)
# Wait for key exchange to complete before sending requests
logger.debug("Waiting %.1fs for key exchange to complete", REPEATER_OP_DELAY_SECONDS)
await asyncio.sleep(REPEATER_OP_DELAY_SECONDS)
try:
return await asyncio.wait_for(
login_future,
timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
)
except asyncio.TimeoutError:
logger.warning(
"No login response from repeater %s within %.1fs",
contact.public_key[:12],
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
)
return RepeaterLoginResponse(
status="timeout",
authenticated=False,
message=REPEATER_LOGIN_TIMEOUT_MESSAGE,
)
except HTTPException as exc:
logger.warning(
"Repeater login setup failed for %s: %s",
contact.public_key[:12],
exc.detail,
)
return RepeaterLoginResponse(
status="error",
authenticated=False,
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({exc.detail})",
)
finally:
success_subscription.unsubscribe()
failed_subscription.unsubscribe()
def _require_repeater(contact: Contact) -> None:
@@ -180,7 +250,7 @@ def _require_repeater(contact: Contact) -> None:
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
"""Log in to a repeater. Adds contact to radio, sends login, waits for key exchange."""
"""Attempt repeater login and report whether auth was confirmed."""
require_connected()
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
@@ -190,9 +260,7 @@ async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> Repe
pause_polling=True,
suspend_auto_fetch=True,
) as mc:
await prepare_repeater_connection(mc, contact, request.password)
return RepeaterLoginResponse(status="ok")
return await prepare_repeater_connection(mc, contact, request.password)
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)

View File

@@ -0,0 +1,26 @@
"""Shared direct-message ACK application logic."""
from collections.abc import Callable
from typing import Any
from app.services import dm_ack_tracker
from app.services.messages import increment_ack_and_broadcast
BroadcastFn = Callable[..., Any]
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
"""Apply a DM ACK code using the shared pending/buffered state machine.
Returns True when the ACK matched a pending message, False when it was buffered.
"""
dm_ack_tracker.cleanup_expired_acks()
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
if message_id is None:
dm_ack_tracker.buffer_unmatched_ack(ack_code)
return False
dm_ack_tracker.clear_pending_acks_for_message(message_id)
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_fn)
return True

View File

@@ -71,3 +71,15 @@ def pop_pending_ack(ack_code: str) -> int | None:
return None
message_id, _, _ = pending
return message_id
def clear_pending_acks_for_message(message_id: int) -> None:
"""Remove any still-pending ACK codes for a message once one ACK wins."""
sibling_codes = [
code
for code, (pending_message_id, _created_at, _timeout_ms) in _pending_acks.items()
if pending_message_id == message_id
]
for code in sibling_codes:
del _pending_acks[code]
logger.debug("Cleared sibling pending ACK %s for message %d", code, message_id)

321
app/services/dm_ingest.py Normal file
View File

@@ -0,0 +1,321 @@
import asyncio
import logging
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from app.models import CONTACT_TYPE_REPEATER, Contact, ContactUpsert, Message
from app.repository import (
AmbiguousPublicKeyPrefixError,
ContactRepository,
MessageRepository,
RawPacketRepository,
)
from app.services.contact_reconciliation import claim_prefix_messages_for_contact
from app.services.messages import (
broadcast_message,
build_message_model,
build_message_paths,
format_contact_log_target,
handle_duplicate_message,
reconcile_duplicate_message,
truncate_for_log,
)
if TYPE_CHECKING:
from app.decoder import DecryptedDirectMessage
logger = logging.getLogger(__name__)
BroadcastFn = Callable[..., Any]
_decrypted_dm_store_lock = asyncio.Lock()
@dataclass(frozen=True)
class FallbackDirectMessageContext:
conversation_key: str
contact: Contact | None
sender_name: str | None
sender_key: str | None
skip_storage: bool = False
async def _prepare_resolved_contact(
contact: Contact,
*,
log: logging.Logger | None = None,
) -> tuple[str, bool]:
conversation_key = contact.public_key.lower()
await claim_prefix_messages_for_contact(public_key=conversation_key, log=log or logger)
if contact.type == CONTACT_TYPE_REPEATER:
return conversation_key, True
return conversation_key, False
async def resolve_fallback_direct_message_context(
*,
sender_public_key: str,
received_at: int,
broadcast_fn: BroadcastFn,
contact_repository=ContactRepository,
log: logging.Logger | None = None,
) -> FallbackDirectMessageContext:
normalized_sender = sender_public_key.lower()
try:
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
except AmbiguousPublicKeyPrefixError:
(log or logger).warning(
"DM sender prefix '%s' is ambiguous; storing under prefix until full key is known",
sender_public_key,
)
contact = None
if contact is not None:
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=log)
return FallbackDirectMessageContext(
conversation_key=conversation_key,
contact=contact,
sender_name=contact.name,
sender_key=conversation_key,
skip_storage=skip_storage,
)
if normalized_sender:
placeholder_upsert = ContactUpsert(
public_key=normalized_sender,
type=0,
last_seen=received_at,
last_contacted=received_at,
first_seen=received_at,
on_radio=False,
)
await contact_repository.upsert(placeholder_upsert)
contact = await contact_repository.get_by_key(normalized_sender)
if contact is not None:
broadcast_fn("contact", contact.model_dump())
return FallbackDirectMessageContext(
conversation_key=normalized_sender,
contact=contact,
sender_name=contact.name if contact else None,
sender_key=normalized_sender or None,
)
async def _store_direct_message(
*,
packet_id: int | None,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
path: str | None,
path_len: int | None,
outgoing: bool,
txt_type: int,
signature: str | None,
sender_name: str | None,
sender_key: str | None,
realtime: bool,
broadcast_fn: BroadcastFn,
update_last_contacted_key: str | None,
best_effort_content_dedup: bool,
linked_packet_dedup: bool,
message_repository=MessageRepository,
contact_repository=ContactRepository,
raw_packet_repository=RawPacketRepository,
) -> Message | None:
async def store() -> Message | None:
if linked_packet_dedup and packet_id is not None:
linked_message_id = await raw_packet_repository.get_linked_message_id(packet_id)
if linked_message_id is not None:
existing_msg = await message_repository.get_by_id(linked_message_id)
if existing_msg is not None:
await reconcile_duplicate_message(
existing_msg=existing_msg,
packet_id=packet_id,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
if best_effort_content_dedup:
existing_msg = await message_repository.get_by_content(
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
outgoing=outgoing,
)
if existing_msg is not None:
await reconcile_duplicate_message(
existing_msg=existing_msg,
packet_id=packet_id,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
msg_id = await message_repository.create(
msg_type="PRIV",
text=text,
conversation_key=conversation_key,
sender_timestamp=sender_timestamp,
received_at=received_at,
path=path,
path_len=path_len,
txt_type=txt_type,
signature=signature,
outgoing=outgoing,
sender_key=sender_key,
sender_name=sender_name,
)
if msg_id is None:
await handle_duplicate_message(
packet_id=packet_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
outgoing=outgoing,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
if packet_id is not None:
await raw_packet_repository.mark_decrypted(packet_id, msg_id)
message = build_message_model(
message_id=msg_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=build_message_paths(path, received_at, path_len),
txt_type=txt_type,
signature=signature,
sender_key=sender_key,
outgoing=outgoing,
sender_name=sender_name,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)
if update_last_contacted_key:
await contact_repository.update_last_contacted(update_last_contacted_key, received_at)
return message
if linked_packet_dedup:
async with _decrypted_dm_store_lock:
return await store()
return await store()
async def ingest_decrypted_direct_message(
*,
packet_id: int,
decrypted: "DecryptedDirectMessage",
their_public_key: str,
received_at: int | None = None,
path: str | None = None,
path_len: int | None = None,
outgoing: bool = False,
realtime: bool = True,
broadcast_fn: BroadcastFn,
contact_repository=ContactRepository,
) -> Message | None:
conversation_key = their_public_key.lower()
contact = await contact_repository.get_by_key(conversation_key)
sender_name: str | None = None
if contact is not None:
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=logger)
if skip_storage:
logger.debug(
"Skipping message from repeater %s (CLI responses not stored): %s",
conversation_key[:12],
(decrypted.message or "")[:50],
)
return None
if not outgoing:
sender_name = contact.name
received = received_at or int(time.time())
message = await _store_direct_message(
packet_id=packet_id,
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
received_at=received,
path=path,
path_len=path_len,
outgoing=outgoing,
txt_type=0,
signature=None,
sender_name=sender_name,
sender_key=conversation_key if not outgoing else None,
realtime=realtime,
broadcast_fn=broadcast_fn,
update_last_contacted_key=conversation_key,
best_effort_content_dedup=outgoing,
linked_packet_dedup=True,
)
if message is None:
return None
logger.info(
'Stored direct message "%s" for %r (msg ID %d in contact ID %s, outgoing=%s)',
truncate_for_log(decrypted.message),
format_contact_log_target(contact.name if contact else None, conversation_key),
message.id,
conversation_key,
outgoing,
)
return message
async def ingest_fallback_direct_message(
*,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
path: str | None,
path_len: int | None,
txt_type: int,
signature: str | None,
sender_name: str | None,
sender_key: str | None,
broadcast_fn: BroadcastFn,
update_last_contacted_key: str | None = None,
) -> Message | None:
return await _store_direct_message(
packet_id=None,
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
path=path,
path_len=path_len,
outgoing=False,
txt_type=txt_type,
signature=signature,
sender_name=sender_name,
sender_key=sender_key,
realtime=True,
broadcast_fn=broadcast_fn,
update_last_contacted_key=update_last_contacted_key,
best_effort_content_dedup=True,
linked_packet_dedup=False,
)

View File

@@ -11,6 +11,7 @@ from meshcore import EventType
from app.models import ResendChannelMessageResponse
from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
from app.services import dm_ack_tracker
from app.services.messages import (
build_message_model,
create_outgoing_channel_message,
@@ -20,14 +21,24 @@ from app.services.messages import (
logger = logging.getLogger(__name__)
NO_RADIO_RESPONSE_AFTER_SEND_DETAIL = (
"Send command was issued to the radio, but no response was heard back. "
"The message may or may not have sent successfully."
)
BroadcastFn = Callable[..., Any]
TrackAckFn = Callable[[str, int, int], bool]
NowFn = Callable[[], float]
OutgoingReservationKey = tuple[str, str, str]
RetryTaskScheduler = Callable[[Any], Any]
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
_outgoing_timestamp_reservations_lock = asyncio.Lock()
DM_SEND_MAX_ATTEMPTS = 3
DEFAULT_DM_ACK_TIMEOUT_MS = 10000
DM_RETRY_WAIT_MARGIN = 1.2
async def allocate_outgoing_sender_timestamp(
*,
@@ -193,6 +204,13 @@ async def send_channel_message_with_effective_scope(
msg=text,
timestamp=timestamp_bytes,
)
if send_result is None:
logger.warning(
"No response from radio after %s for channel %s; send outcome is unknown",
action_label,
channel.name,
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if send_result.type == EventType.ERROR:
radio_manager.invalidate_cached_channel_slot(channel_key)
else:
@@ -236,6 +254,183 @@ async def send_channel_message_with_effective_scope(
)
def _extract_expected_ack_code(result: Any) -> str | None:
if result is None or result.type == EventType.ERROR:
return None
payload = result.payload or {}
expected_ack = payload.get("expected_ack")
if not expected_ack:
return None
return expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
def _get_ack_tracking_timeout_ms(result: Any) -> int:
if result is None or result.type == EventType.ERROR:
return DEFAULT_DM_ACK_TIMEOUT_MS
payload = result.payload or {}
suggested_timeout = payload.get("suggested_timeout")
if suggested_timeout is None:
return DEFAULT_DM_ACK_TIMEOUT_MS
try:
return max(1, int(suggested_timeout))
except (TypeError, ValueError):
return DEFAULT_DM_ACK_TIMEOUT_MS
def _get_direct_message_retry_timeout_ms(result: Any) -> int:
"""Return the ACK window to wait before retrying a DM.
The MeshCore firmware already computes and returns `suggested_timeout` in
`PACKET_MSG_SENT`, derived from estimated packet airtime and route mode.
We use that firmware-supplied window directly so retries do not fire before
the radio's own ACK timeout expires.
Sources:
- https://github.com/meshcore-dev/MeshCore/blob/main/src/helpers/BaseChatMesh.cpp
- https://github.com/meshcore-dev/MeshCore/blob/main/examples/companion_radio/MyMesh.cpp
- https://github.com/meshcore-dev/MeshCore/blob/main/docs/companion_protocol.md
"""
return _get_ack_tracking_timeout_ms(result)
async def _apply_direct_message_ack_tracking(
*,
result: Any,
message_id: int,
track_pending_ack_fn: TrackAckFn,
broadcast_fn: BroadcastFn,
) -> int:
ack_code = _extract_expected_ack_code(result)
if not ack_code:
return 0
timeout_ms = _get_ack_tracking_timeout_ms(result)
matched_immediately = track_pending_ack_fn(ack_code, message_id, timeout_ms) is True
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
if matched_immediately:
dm_ack_tracker.clear_pending_acks_for_message(message_id)
return await increment_ack_and_broadcast(
message_id=message_id,
broadcast_fn=broadcast_fn,
)
return 0
async def _is_message_acked(*, message_id: int, message_repository) -> bool:
acked_count, _paths = await message_repository.get_ack_and_paths(message_id)
return acked_count > 0
async def _retry_direct_message_until_acked(
*,
contact,
text: str,
message_id: int,
sender_timestamp: int,
radio_manager,
track_pending_ack_fn: TrackAckFn,
broadcast_fn: BroadcastFn,
wait_timeout_ms: int,
sleep_fn,
message_repository,
) -> None:
next_wait_timeout_ms = wait_timeout_ms
for attempt in range(1, DM_SEND_MAX_ATTEMPTS):
await sleep_fn((next_wait_timeout_ms / 1000) * DM_RETRY_WAIT_MARGIN)
if await _is_message_acked(message_id=message_id, message_repository=message_repository):
return
try:
async with radio_manager.radio_operation("retry_direct_message") as mc:
contact_data = contact.to_radio_dict()
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
logger.warning(
"Failed to reload contact %s on radio before DM retry: %s",
contact.public_key[:12],
add_result.payload,
)
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not cached_contact:
cached_contact = contact_data
if attempt == DM_SEND_MAX_ATTEMPTS - 1:
reset_result = await mc.commands.reset_path(contact.public_key)
if reset_result is None:
logger.warning(
"No response from radio for reset_path to %s before final DM retry",
contact.public_key[:12],
)
elif reset_result.type == EventType.ERROR:
logger.warning(
"Failed to reset path before final DM retry to %s: %s",
contact.public_key[:12],
reset_result.payload,
)
refreshed_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if refreshed_contact:
cached_contact = refreshed_contact
result = await mc.commands.send_msg(
dst=cached_contact,
msg=text,
timestamp=sender_timestamp,
attempt=attempt,
)
except Exception:
logger.exception(
"Background DM retry attempt %d/%d failed for %s",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
continue
if result is None:
logger.warning(
"No response from radio after background DM retry attempt %d/%d to %s",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
continue
if result.type == EventType.ERROR:
logger.warning(
"Background DM retry attempt %d/%d failed for %s: %s",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
result.payload,
)
continue
if await _is_message_acked(message_id=message_id, message_repository=message_repository):
return
ack_code = _extract_expected_ack_code(result)
if not ack_code:
logger.warning(
"Background DM retry attempt %d/%d for %s returned no expected_ack; "
"stopping retries to avoid duplicate sends",
attempt + 1,
DM_SEND_MAX_ATTEMPTS,
contact.public_key[:12],
)
return
next_wait_timeout_ms = _get_direct_message_retry_timeout_ms(result)
ack_count = await _apply_direct_message_ack_tracking(
result=result,
message_id=message_id,
track_pending_ack_fn=track_pending_ack_fn,
broadcast_fn=broadcast_fn,
)
if ack_count > 0:
return
async def send_direct_message_to_contact(
*,
contact,
@@ -244,10 +439,17 @@ async def send_direct_message_to_contact(
broadcast_fn: BroadcastFn,
track_pending_ack_fn: TrackAckFn,
now_fn: NowFn,
retry_task_scheduler: RetryTaskScheduler | None = None,
retry_sleep_fn=None,
message_repository=MessageRepository,
contact_repository=ContactRepository,
) -> Any:
"""Send a direct message and persist/broadcast the outgoing row."""
if retry_task_scheduler is None:
retry_task_scheduler = asyncio.create_task
if retry_sleep_fn is None:
retry_sleep_fn = asyncio.sleep
contact_data = contact.to_radio_dict()
sent_at: int | None = None
sender_timestamp: int | None = None
@@ -279,7 +481,14 @@ async def send_direct_message_to_contact(
timestamp=sender_timestamp,
)
if result is None or result.type == EventType.ERROR:
if result is None:
logger.warning(
"No response from radio after direct send to %s; send outcome is unknown",
contact.public_key[:12],
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
message = await create_outgoing_direct_message(
@@ -309,18 +518,33 @@ async def send_direct_message_to_contact(
await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at)
expected_ack = result.payload.get("expected_ack")
suggested_timeout: int = result.payload.get("suggested_timeout", 10000)
if expected_ack:
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
matched_immediately = track_pending_ack_fn(ack_code, message.id, suggested_timeout) is True
logger.debug("Tracking ACK %s for message %d", ack_code, message.id)
if matched_immediately:
ack_count = await increment_ack_and_broadcast(
ack_code = _extract_expected_ack_code(result)
retry_timeout_ms = _get_direct_message_retry_timeout_ms(result)
ack_count = await _apply_direct_message_ack_tracking(
result=result,
message_id=message.id,
track_pending_ack_fn=track_pending_ack_fn,
broadcast_fn=broadcast_fn,
)
if ack_count > 0:
message.acked = ack_count
return message
if DM_SEND_MAX_ATTEMPTS > 1 and ack_code:
retry_task_scheduler(
_retry_direct_message_until_acked(
contact=contact,
text=text,
message_id=message.id,
sender_timestamp=sender_timestamp,
radio_manager=radio_manager,
track_pending_ack_fn=track_pending_ack_fn,
broadcast_fn=broadcast_fn,
wait_timeout_ms=retry_timeout_ms,
sleep_fn=retry_sleep_fn,
message_repository=message_repository,
)
message.acked = ack_count
)
return message
@@ -376,6 +600,13 @@ async def send_channel_message_to_channel(
error_broadcast_fn=error_broadcast_fn,
)
if result is None:
logger.warning(
"No response from radio after channel send to %s; send outcome is unknown",
channel.name,
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500, detail=f"Failed to send message: {result.payload}"
@@ -487,6 +718,12 @@ async def resend_channel_message_record(
temp_radio_slot=temp_radio_slot,
error_broadcast_fn=error_broadcast_fn,
)
if result is None:
logger.warning(
"No response from radio after channel resend to %s; send outcome is unknown",
channel.name,
)
raise HTTPException(status_code=504, detail=NO_RADIO_RESPONSE_AFTER_SEND_DETAIL)
if result.type == EventType.ERROR:
raise HTTPException(
status_code=500,

View File

@@ -3,7 +3,7 @@ import time
from collections.abc import Callable
from typing import TYPE_CHECKING, Any
from app.models import CONTACT_TYPE_REPEATER, Message, MessagePath
from app.models import Message, MessagePath
from app.repository import ContactRepository, MessageRepository, RawPacketRepository
if TYPE_CHECKING:
@@ -15,7 +15,7 @@ BroadcastFn = Callable[..., Any]
LOG_MESSAGE_PREVIEW_LEN = 32
def _truncate_for_log(text: str, max_chars: int = LOG_MESSAGE_PREVIEW_LEN) -> str:
def truncate_for_log(text: str, max_chars: int = LOG_MESSAGE_PREVIEW_LEN) -> str:
"""Return a compact single-line message preview for log output."""
normalized = " ".join(text.split())
if len(normalized) <= max_chars:
@@ -28,7 +28,7 @@ def _format_channel_log_target(channel_name: str | None, channel_key: str) -> st
return channel_name or channel_key
def _format_contact_log_target(contact_name: str | None, public_key: str) -> str:
def format_contact_log_target(contact_name: str | None, public_key: str) -> str:
"""Return a human-friendly DM target label for logs."""
return contact_name or public_key[:12]
@@ -125,38 +125,19 @@ async def increment_ack_and_broadcast(
return ack_count
async def handle_duplicate_message(
async def reconcile_duplicate_message(
*,
existing_msg: Message,
packet_id: int | None,
msg_type: str,
conversation_key: str,
text: str,
sender_timestamp: int,
path: str | None,
received_at: int,
path_len: int | None = None,
path_len: int | None,
broadcast_fn: BroadcastFn,
) -> None:
"""Handle a duplicate message by updating paths/acks on the existing record."""
existing_msg = await MessageRepository.get_by_content(
msg_type=msg_type,
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
)
if not existing_msg:
label = "message" if msg_type == "CHAN" else "DM"
logger.warning(
"Duplicate %s for %s but couldn't find existing",
label,
conversation_key[:12],
)
return
logger.debug(
"Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path",
msg_type,
conversation_key[:12],
existing_msg.type,
existing_msg.conversation_key[:12],
existing_msg.id,
existing_msg.outgoing,
)
@@ -183,6 +164,46 @@ async def handle_duplicate_message(
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
async def handle_duplicate_message(
*,
packet_id: int | None,
msg_type: str,
conversation_key: str,
text: str,
sender_timestamp: int,
outgoing: bool | None = None,
path: str | None,
received_at: int,
path_len: int | None = None,
broadcast_fn: BroadcastFn,
) -> None:
"""Handle a duplicate message by updating paths/acks on the existing record."""
existing_msg = await MessageRepository.get_by_content(
msg_type=msg_type,
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
outgoing=outgoing,
)
if not existing_msg:
label = "message" if msg_type == "CHAN" else "DM"
logger.warning(
"Duplicate %s for %s but couldn't find existing",
label,
conversation_key[:12],
)
return
await reconcile_duplicate_message(
existing_msg=existing_msg,
packet_id=packet_id,
path=path,
received_at=received_at,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
async def create_message_from_decrypted(
*,
packet_id: int,
@@ -227,6 +248,7 @@ async def create_message_from_decrypted(
conversation_key=channel_key_normalized,
text=text,
sender_timestamp=timestamp,
outgoing=None,
path=path,
received_at=received,
path_len=path_len,
@@ -236,7 +258,7 @@ async def create_message_from_decrypted(
logger.info(
'Stored channel message "%s" for %r (msg ID %d in chan ID %s)',
_truncate_for_log(text),
truncate_for_log(text),
_format_channel_log_target(channel_name, channel_key_normalized),
msg_id,
channel_key_normalized,
@@ -277,124 +299,20 @@ async def create_dm_message_from_decrypted(
broadcast_fn: BroadcastFn,
) -> int | None:
"""Store and broadcast a decrypted direct message."""
contact = await ContactRepository.get_by_key(their_public_key)
if contact and contact.type == CONTACT_TYPE_REPEATER:
logger.debug(
"Skipping message from repeater %s (CLI responses not stored): %s",
their_public_key[:12],
(decrypted.message or "")[:50],
)
return None
from app.services.dm_ingest import ingest_decrypted_direct_message
received = received_at or int(time.time())
conversation_key = their_public_key.lower()
sender_name = contact.name if contact and not outgoing else None
msg_id = await MessageRepository.create(
msg_type="PRIV",
text=decrypted.message,
conversation_key=conversation_key,
sender_timestamp=decrypted.timestamp,
received_at=received,
message = await ingest_decrypted_direct_message(
packet_id=packet_id,
decrypted=decrypted,
their_public_key=their_public_key,
received_at=received_at,
path=path,
path_len=path_len,
outgoing=outgoing,
sender_key=conversation_key if not outgoing else None,
sender_name=sender_name,
)
if msg_id is None:
await handle_duplicate_message(
packet_id=packet_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
path=path,
received_at=received,
path_len=path_len,
broadcast_fn=broadcast_fn,
)
return None
logger.info(
'Stored direct message "%s" for %r (msg ID %d in contact ID %s, outgoing=%s)',
_truncate_for_log(decrypted.message),
_format_contact_log_target(contact.name if contact else None, conversation_key),
msg_id,
conversation_key,
outgoing,
)
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
broadcast_message(
message=build_message_model(
message_id=msg_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=decrypted.message,
sender_timestamp=decrypted.timestamp,
received_at=received,
paths=build_message_paths(path, received, path_len),
outgoing=outgoing,
sender_name=sender_name,
sender_key=conversation_key if not outgoing else None,
),
broadcast_fn=broadcast_fn,
realtime=realtime,
broadcast_fn=broadcast_fn,
)
await ContactRepository.update_last_contacted(conversation_key, received)
return msg_id
async def create_fallback_direct_message(
*,
conversation_key: str,
text: str,
sender_timestamp: int,
received_at: int,
path: str | None,
path_len: int | None,
txt_type: int,
signature: str | None,
sender_name: str | None,
sender_key: str | None,
broadcast_fn: BroadcastFn,
message_repository=MessageRepository,
) -> Message | None:
"""Store and broadcast a CONTACT_MSG_RECV fallback direct message."""
msg_id = await message_repository.create(
msg_type="PRIV",
text=text,
conversation_key=conversation_key,
sender_timestamp=sender_timestamp,
received_at=received_at,
path=path,
path_len=path_len,
txt_type=txt_type,
signature=signature,
sender_key=sender_key,
sender_name=sender_name,
)
if msg_id is None:
return None
message = build_message_model(
message_id=msg_id,
msg_type="PRIV",
conversation_key=conversation_key,
text=text,
sender_timestamp=sender_timestamp,
received_at=received_at,
paths=build_message_paths(path, received_at, path_len),
txt_type=txt_type,
signature=signature,
sender_key=sender_key,
sender_name=sender_name,
)
broadcast_message(message=message, broadcast_fn=broadcast_fn)
return message
return message.id if message is not None else None
async def create_fallback_channel_message(
@@ -440,6 +358,7 @@ async def create_fallback_channel_message(
conversation_key=conversation_key_normalized,
text=text,
sender_timestamp=sender_timestamp,
outgoing=None,
path=path,
received_at=received_at,
path_len=path_len,

View File

@@ -1,5 +1,6 @@
import asyncio
import logging
from datetime import datetime, timezone
logger = logging.getLogger(__name__)
@@ -7,6 +8,21 @@ POST_CONNECT_SETUP_TIMEOUT_SECONDS = 300
POST_CONNECT_SETUP_MAX_ATTEMPTS = 2
def _clean_device_string(value: object) -> str | None:
if not isinstance(value, str):
return None
cleaned = value.strip()
return cleaned or None
def _decode_fixed_string(raw: bytes, start: int, length: int) -> str | None:
if len(raw) < start:
return None
return _clean_device_string(
raw[start : start + length].decode("utf-8", "ignore").replace("\0", "")
)
async def run_post_connect_setup(radio_manager) -> None:
"""Run shared radio initialization after a transport connection succeeds."""
from app.event_handlers import register_event_handlers
@@ -78,26 +94,66 @@ async def run_post_connect_setup(radio_manager) -> None:
return await _original_handle_rx(data)
reader.handle_rx = _capture_handle_rx
radio_manager.device_info_loaded = False
radio_manager.max_contacts = None
radio_manager.device_model = None
radio_manager.firmware_build = None
radio_manager.firmware_version = None
radio_manager.max_channels = 40
radio_manager.path_hash_mode = 0
radio_manager.path_hash_mode_supported = False
try:
device_query = await mc.commands.send_device_query()
if device_query and "max_channels" in device_query.payload:
radio_manager.max_channels = max(
1, int(device_query.payload["max_channels"])
)
if device_query and "path_hash_mode" in device_query.payload:
radio_manager.path_hash_mode = device_query.payload["path_hash_mode"]
payload = (
device_query.payload
if device_query is not None and isinstance(device_query.payload, dict)
else {}
)
payload_max_contacts = payload.get("max_contacts")
if isinstance(payload_max_contacts, int):
radio_manager.max_contacts = max(1, payload_max_contacts)
payload_max_channels = payload.get("max_channels")
if isinstance(payload_max_channels, int):
radio_manager.max_channels = max(1, payload_max_channels)
radio_manager.device_model = _clean_device_string(payload.get("model"))
radio_manager.firmware_build = _clean_device_string(payload.get("fw_build"))
radio_manager.firmware_version = _clean_device_string(payload.get("ver"))
fw_ver = payload.get("fw ver")
payload_reports_device_info = isinstance(fw_ver, int) and fw_ver >= 3
if payload_reports_device_info:
radio_manager.device_info_loaded = True
if "path_hash_mode" in payload and isinstance(payload["path_hash_mode"], int):
radio_manager.path_hash_mode = payload["path_hash_mode"]
radio_manager.path_hash_mode_supported = True
elif _captured_frame:
# Raw-frame fallback:
# byte 1 = fw_ver, byte 3 = max_channels, byte 81 = path_hash_mode
if _captured_frame:
# Raw-frame fallback / completion:
# byte 1 = fw_ver, byte 2 = max_contacts/2, byte 3 = max_channels,
# bytes 8:20 = fw_build, 20:60 = model, 60:80 = ver, byte 81 = path_hash_mode
raw = _captured_frame[-1]
fw_ver = raw[1] if len(raw) > 1 else 0
if fw_ver >= 3 and len(raw) >= 4:
radio_manager.max_channels = max(1, raw[3])
if fw_ver >= 10 and len(raw) >= 82:
if fw_ver >= 3:
radio_manager.device_info_loaded = True
if radio_manager.max_contacts is None and len(raw) >= 3:
radio_manager.max_contacts = max(1, raw[2] * 2)
if len(raw) >= 4 and not isinstance(payload_max_channels, int):
radio_manager.max_channels = max(1, raw[3])
if radio_manager.firmware_build is None:
radio_manager.firmware_build = _decode_fixed_string(raw, 8, 12)
if radio_manager.device_model is None:
radio_manager.device_model = _decode_fixed_string(raw, 20, 40)
if radio_manager.firmware_version is None:
radio_manager.firmware_version = _decode_fixed_string(raw, 60, 20)
if (
not radio_manager.path_hash_mode_supported
and fw_ver >= 10
and len(raw) >= 82
):
radio_manager.path_hash_mode = raw[81]
radio_manager.path_hash_mode_supported = True
logger.warning(
@@ -114,6 +170,34 @@ async def run_post_connect_setup(radio_manager) -> None:
logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode)
else:
logger.debug("Firmware does not report path_hash_mode")
if radio_manager.device_info_loaded:
logger.info(
"Radio device info: model=%s build=%s version=%s max_contacts=%s max_channels=%d",
radio_manager.device_model or "unknown",
radio_manager.firmware_build or "unknown",
radio_manager.firmware_version or "unknown",
radio_manager.max_contacts
if radio_manager.max_contacts is not None
else "unknown",
radio_manager.max_channels,
)
try:
time_result = await mc.commands.get_time()
radio_time = (
time_result.payload.get("time")
if time_result is not None and time_result.payload
else None
)
if isinstance(radio_time, int):
logger.info(
"Radio clock at connect: epoch=%d utc=%s",
radio_time,
datetime.fromtimestamp(radio_time, timezone.utc).strftime(
"%Y-%m-%d %H:%M:%S UTC"
),
)
except Exception as exc:
logger.debug("Failed to query radio clock after device info: %s", exc)
logger.info("Max channel slots: %d", radio_manager.max_channels)
except Exception as exc:
logger.debug("Failed to query device info capabilities: %s", exc)

149
app/version_info.py Normal file
View File

@@ -0,0 +1,149 @@
"""Unified application version/build metadata resolution.
Resolution order:
- version: installed package metadata, ``APP_VERSION`` env, ``build_info.json``, ``pyproject.toml``
- commit: local git, ``COMMIT_HASH``/``VITE_COMMIT_HASH`` env, ``build_info.json``
This keeps backend surfaces, release bundles, and Docker builds aligned.
"""
from __future__ import annotations
import importlib.metadata
import json
import os
import subprocess
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
import tomllib
RELEASE_BUILD_INFO_FILENAME = "build_info.json"
PROJECT_NAME = "remoteterm-meshcore"
@dataclass(frozen=True)
class AppBuildInfo:
version: str
version_source: str
commit_hash: str | None
commit_source: str | None
def repo_root() -> Path:
return Path(__file__).resolve().parents[1]
def _read_build_info(root: Path) -> dict[str, Any] | None:
build_info_path = root / RELEASE_BUILD_INFO_FILENAME
try:
data = json.loads(build_info_path.read_text())
except Exception:
return None
return data if isinstance(data, dict) else None
def _package_metadata_version() -> str | None:
try:
return importlib.metadata.version(PROJECT_NAME)
except Exception:
return None
def _env_version() -> str | None:
value = os.getenv("APP_VERSION")
return value.strip() if value and value.strip() else None
def _build_info_version(build_info: dict[str, Any] | None) -> str | None:
if not build_info:
return None
value = build_info.get("version")
return value.strip() if isinstance(value, str) and value.strip() else None
def _pyproject_version(root: Path) -> str | None:
try:
pyproject = tomllib.loads((root / "pyproject.toml").read_text())
project = pyproject.get("project")
if isinstance(project, dict):
version = project.get("version")
if isinstance(version, str) and version.strip():
return version.strip()
except Exception:
return None
return None
def _git_output(root: Path, *args: str) -> str | None:
try:
result = subprocess.run(
["git", *args],
cwd=root,
check=True,
capture_output=True,
text=True,
)
except Exception:
return None
output = result.stdout.strip()
return output or None
def _env_commit_hash() -> str | None:
for name in ("COMMIT_HASH", "VITE_COMMIT_HASH"):
value = os.getenv(name)
if value and value.strip():
return value.strip()[:8]
return None
def _build_info_commit_hash(build_info: dict[str, Any] | None) -> str | None:
if not build_info:
return None
value = build_info.get("commit_hash")
return value.strip()[:8] if isinstance(value, str) and value.strip() else None
@lru_cache(maxsize=1)
def get_app_build_info() -> AppBuildInfo:
root = repo_root()
build_info = _read_build_info(root)
version = _package_metadata_version()
version_source = "package_metadata"
if version is None:
version = _env_version()
version_source = "env"
if version is None:
version = _build_info_version(build_info)
version_source = "build_info"
if version is None:
version = _pyproject_version(root)
version_source = "pyproject"
if version is None:
version = "0.0.0"
version_source = "fallback"
commit_hash = _git_output(root, "rev-parse", "--short", "HEAD")
commit_source: str | None = "git" if commit_hash else None
if commit_hash is None:
commit_hash = _env_commit_hash()
commit_source = "env" if commit_hash else None
if commit_hash is None:
commit_hash = _build_info_commit_hash(build_info)
commit_source = "build_info" if commit_hash else None
return AppBuildInfo(
version=version,
version_source=version_source,
commit_hash=commit_hash,
commit_source=commit_source,
)
def git_output(*args: str) -> str | None:
"""Shared git helper for debug surfaces that still need live repo state."""
return _git_output(repo_root(), *args)

View File

@@ -35,7 +35,6 @@ frontend/src/
├── types.ts # Shared TS contracts
├── useWebSocket.ts # WS lifecycle + event dispatch
├── wsEvents.ts # Typed WS event parsing / discriminated union
├── messageCache.ts # Conversation-scoped cache
├── prefetch.ts # Consumes prefetched API promises started in index.html
├── index.css # Global styles/utilities
├── styles.css # Additional global app styles
@@ -130,8 +129,7 @@ frontend/src/
│ │ └── RepeaterConsolePane.tsx # CLI console with history
│ └── ui/ # shadcn/ui primitives
├── types/
── d3-force-3d.d.ts # Type declarations for d3-force-3d
│ └── globals.d.ts # Global type declarations (__APP_VERSION__, __COMMIT_HASH__)
── d3-force-3d.d.ts # Type declarations for d3-force-3d
└── test/
├── setup.ts
├── fixtures/websocket_events.json
@@ -201,7 +199,7 @@ High-level state is delegated to hooks:
- `useConversationRouter`: URL hash → active conversation routing
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
- `useConversationActions`: send/resend/trace/block handlers and channel override updates
- `useConversationMessages`: conversation switch loading, cache restore, jump-target loading, pagination, dedup/update helpers, and pending ACK buffering
- `useConversationMessages`: conversation switch loading, embedded conversation-scoped cache, jump-target loading, pagination, dedup/update helpers, reconnect reconciliation, and pending ACK buffering
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
@@ -233,6 +231,8 @@ High-level state is delegated to hooks:
- Backend also emits WS `message` for outgoing sends so other clients stay in sync.
- ACK/repeat updates arrive as `message_acked` events.
- Outgoing channel messages show a 30-second resend control; resend calls `POST /api/messages/channel/{message_id}/resend`.
- Conversation-scoped message caching now lives inside `useConversationMessages.ts` rather than a standalone `messageCache.ts` module. If you touch message timeline restore/dedup/reconnect behavior, start there.
- `contact_resolved` is a real-time identity migration event, not just a contact-list update. Changes in that area need to consider active conversation state, cached messages, unread state keys, and reconnect reconciliation together.
### Visualizer behavior
@@ -250,6 +250,7 @@ High-level state is delegated to hooks:
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
- The advert action is mode-aware: the radio settings section exposes both flood and zero-hop manual advert buttons, both routed through the same `onAdvertise(mode)` seam.
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
@@ -318,6 +319,8 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `flood_scope`
- `blocked_keys`, `blocked_names`
The backend still carries `sidebar_sort_order` for compatibility and old preference migration, but the current sidebar UI stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in frontend localStorage rather than treating it as one global server-backed setting.
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`.
@@ -337,9 +340,10 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
- Name history ("Also Known As") — shown only when the contact has used multiple names
- Message stats: DM count, channel message count
- Most active rooms (clickable → navigate to channel)
- Route details from the canonical backend surface (`effective_route`, `effective_route_source`, `direct_route`, `route_override`)
- Advert observation rate
- Nearest repeaters (resolved from first-hop path prefixes)
- Recent advert paths
- Recent advert paths (informational only; not part of DM route selection)
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
@@ -407,6 +411,10 @@ npm run test:run
npm run build
```
`npm run packaged-build` is release-only. It writes the fallback `frontend/prebuilt`
directory used by the downloadable prebuilt release zip; normal development and
validation should stick to `npm run build`.
When touching cross-layer contracts, also run backend tests from repo root:
```bash
@@ -415,6 +423,10 @@ PYTHONPATH=. uv run pytest tests/ -v
## Errata & Known Non-Issues
### Contacts rollup uses mention styling for unread DMs
This is intentional. In the sidebar section headers, unread direct messages are treated as mention-equivalent, so the Contacts rollup uses the highlighted mention-style badge for any unread DM. Row-level mention detection remains separate; this note is only about the section summary styling.
### RawPacketList always scrolls to bottom
`RawPacketList` unconditionally scrolls to the latest packet on every update. This is intentional — the packet feed is a live status display, not an interactive log meant for lingering or long-term analysis. Users watching it want to see the newest packet, not hold a scroll position.

View File

@@ -1,11 +1,12 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.3.0",
"version": "3.4.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"packaged-build": "vite build --outDir prebuilt",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",

View File

@@ -1,6 +1,5 @@
import { useEffect, useCallback, useRef, useState } from 'react';
import { api } from './api';
import * as messageCache from './messageCache';
import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
import {
@@ -109,6 +108,7 @@ export function App() {
// useConversationRouter, but useConversationRouter needs channels/contacts from
// useContactsAndChannels. We break the cycle with a ref-based indirection.
const setActiveConversationRef = useRef<(conv: Conversation | null) => void>(() => {});
const removeConversationMessagesRef = useRef<(conversationId: string) => void>(() => {});
// --- Extracted hooks ---
@@ -135,7 +135,6 @@ export function App() {
favorites,
fetchAppSettings,
handleSaveAppSettings,
handleSortOrderChange,
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,
@@ -181,6 +180,8 @@ export function App() {
setActiveConversation: (conv) => setActiveConversationRef.current(conv),
pendingDeleteFallbackRef,
hasSetDefaultConversation,
removeConversationMessages: (conversationId) =>
removeConversationMessagesRef.current(conversationId),
});
// useConversationRouter is called second — it receives channels/contacts as inputs
@@ -193,6 +194,7 @@ export function App() {
channels,
contacts,
contactsLoaded,
suspendHashSync: showSettings,
setSidebarOpen,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
@@ -229,25 +231,27 @@ export function App() {
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
reloadCurrentConversation,
addMessageIfNew,
updateMessageAck,
triggerReconcile,
observeMessage,
receiveMessageAck,
reconcileOnReconnect,
renameConversationMessages,
removeConversationMessages,
clearConversationMessages,
} = useConversationMessages(activeConversation, targetMessageId);
removeConversationMessagesRef.current = removeConversationMessages;
const {
unreadCounts,
mentions,
lastMessageTimes,
unreadLastReadAts,
incrementUnread,
recordMessageEvent,
renameConversationState,
markAllRead,
trackNewMessage,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
@@ -309,7 +313,7 @@ export function App() {
setHealth,
fetchConfig,
setRawPackets,
triggerReconcile,
reconcileOnReconnect,
refreshUnreads,
setChannels,
fetchAllContacts,
@@ -317,23 +321,23 @@ export function App() {
blockedKeysRef,
blockedNamesRef,
activeConversationRef,
hasNewerMessagesRef,
addMessageIfNew,
trackNewMessage,
incrementUnread,
observeMessage,
recordMessageEvent,
renameConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
renameConversationMessages,
removeConversationMessages,
receiveMessageAck,
notifyIncomingMessage,
});
const handleVisibilityPolicyChanged = useCallback(() => {
messageCache.clear();
clearConversationMessages();
reloadCurrentConversation();
void refreshUnreads();
setVisibilityVersion((current) => current + 1);
}, [refreshUnreads, reloadCurrentConversation]);
}, [clearConversationMessages, refreshUnreads, reloadCurrentConversation]);
const handleBlockKey = useCallback(
async (key: string) => {
@@ -362,7 +366,7 @@ export function App() {
activeConversationRef,
setContacts,
setChannels,
addMessageIfNew,
observeMessage,
messageInputRef,
});
const handleCreateCrackedChannel = useCallback(
@@ -401,10 +405,7 @@ export function App() {
void markAllRead();
},
favorites,
sortOrder: appSettings?.sidebar_sort_order ?? 'recent',
onSortOrderChange: (sortOrder: 'recent' | 'alpha') => {
void handleSortOrderChange(sortOrder);
},
legacySortOrder: appSettings?.sidebar_sort_order,
isConversationNotificationsEnabled,
};
const conversationPaneProps = {

View File

@@ -15,6 +15,7 @@ import type {
MessagesAroundResponse,
MigratePreferencesRequest,
MigratePreferencesResponse,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -95,9 +96,10 @@ export const api = {
method: 'PUT',
body: JSON.stringify({ private_key: privateKey }),
}),
sendAdvertisement: () =>
sendAdvertisement: (mode: RadioAdvertMode = 'flood') =>
fetchJson<{ status: string }>('/radio/advertise', {
method: 'POST',
body: JSON.stringify({ mode }),
}),
discoverMesh: (target: RadioDiscoveryTarget) =>
fetchJson<RadioDiscoveryResponse>('/radio/discover', {

View File

@@ -41,6 +41,7 @@ interface AppShellProps {
settingsSection: SettingsSection;
sidebarOpen: boolean;
showCracker: boolean;
disabledSettingsSections?: SettingsSection[];
onSettingsSectionChange: (section: SettingsSection) => void;
onSidebarOpenChange: (open: boolean) => void;
onCrackerRunningChange: (running: boolean) => void;
@@ -69,6 +70,7 @@ export function AppShell({
settingsSection,
sidebarOpen,
showCracker,
disabledSettingsSections = [],
onSettingsSectionChange,
onSidebarOpenChange,
onCrackerRunningChange,
@@ -118,13 +120,16 @@ export function AppShell({
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
{SETTINGS_SECTION_ORDER.map((section) => {
const Icon = SETTINGS_SECTION_ICONS[section];
const disabled = disabledSettingsSections.includes(section);
return (
<button
key={section}
type="button"
disabled={disabled}
className={cn(
'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'
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
!disabled && 'hover:bg-accent',
settingsSection === section && !disabled && 'bg-accent border-l-primary'
)}
aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => onSettingsSectionChange(section)}
@@ -252,14 +257,6 @@ export function AppShell({
</div>
<div
ref={(el) => {
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'

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { DirectTraceIcon } from './DirectTraceIcon';
@@ -6,6 +6,7 @@ import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isPublicChannelKey } from '../utils/publicChannel';
import { stripRegionScopePrefix } from '../utils/regionScope';
import { isPrefixOnlyContact } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
@@ -59,10 +60,8 @@ export function ChatHeader({
onOpenChannelInfo,
}: ChatHeaderProps) {
const [showKey, setShowKey] = useState(false);
const [contactStatusInline, setContactStatusInline] = useState(true);
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
const keyTextRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
setShowKey(false);
@@ -116,43 +115,6 @@ export function ChatHeader({
}
};
useEffect(() => {
if (conversation.type !== 'contact') {
setContactStatusInline(true);
return;
}
const measure = () => {
const keyElement = keyTextRef.current;
if (!keyElement) return;
const isTruncated = keyElement.scrollWidth > keyElement.clientWidth + 1;
setContactStatusInline(!isTruncated);
};
measure();
const onResize = () => {
window.requestAnimationFrame(measure);
};
window.addEventListener('resize', onResize);
let observer: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(() => {
window.requestAnimationFrame(measure);
});
if (keyTextRef.current?.parentElement) {
observer.observe(keyTextRef.current.parentElement);
}
}
return () => {
window.removeEventListener('resize', onResize);
observer?.disconnect();
};
}, [conversation.id, conversation.type, showKey]);
return (
<header className="conversation-header flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 items-start gap-2">
@@ -173,31 +135,16 @@ export function ChatHeader({
/>
</button>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2 whitespace-nowrap">
<h2 className="min-w-0 shrink font-semibold text-base">
{titleClickable ? (
<button
type="button"
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
aria-label={`View info for ${conversation.name}`}
onClick={handleOpenConversationInfo}
>
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="grid min-w-0 flex-1 grid-cols-1 gap-y-0.5 min-[1200px]:grid-cols-[minmax(0,1fr)_auto] min-[1200px]:items-baseline min-[1200px]:gap-x-2">
<span className="flex min-w-0 items-baseline gap-2 whitespace-nowrap">
<h2 className="min-w-0 shrink font-semibold text-base">
{titleClickable ? (
<button
type="button"
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
aria-label={`View info for ${conversation.name}`}
onClick={handleOpenConversationInfo}
>
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
@@ -206,56 +153,57 @@ export function ChatHeader({
: ''}
{conversation.name}
</span>
)}
</h2>
{isPrivateChannel && !showKey ? (
<button
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
>
Show Key
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span
ref={keyTextRef}
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
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 className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
)}
</span>
{conversation.type === 'contact' && activeContact && contactStatusInline && (
<span className="min-w-0 flex-none text-[11px] text-muted-foreground">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</h2>
{isPrivateChannel && !showKey ? (
<button
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
>
Show Key
</button>
) : (
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
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>
)}
</span>
{conversation.type === 'contact' && activeContact && !contactStatusInline && (
<span className="mt-0.5 min-w-0 text-[11px] text-muted-foreground">
{conversation.type === 'contact' && activeContact && (
<span className="min-w-0 text-[11px] text-muted-foreground min-[1200px]:justify-self-end">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
@@ -304,7 +252,7 @@ export function ChatHeader({
title={
activeContactIsPrefixOnly
? 'Direct Trace unavailable until the full contact key is known'
: 'Direct Trace. Send a zero-hop packet to thie contact and display out and back SNR'
: 'Direct Trace. Send a zero-hop packet to this contact and display out and back SNR'
}
aria-label="Direct Trace"
disabled={activeContactIsPrefixOnly}
@@ -379,7 +327,7 @@ export function ChatHeader({
)}
</button>
)}
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
{!(conversation.type === 'channel' && isPublicChannelKey(conversation.id)) && (
<button
className="p-1 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={() => {

View File

@@ -12,10 +12,12 @@ import {
calculateDistance,
formatDistance,
formatRouteLabel,
getDirectContactRoute,
getEffectiveContactRoute,
hasRoutingOverride,
parsePathHops,
} from '../utils/pathUtils';
import { isPublicChannelKey } from '../utils/publicChannel';
import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
@@ -133,11 +135,12 @@ export function ContactInfoPane({
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
: null;
const effectiveRoute = contact ? getEffectiveContactRoute(contact) : null;
const directRoute = contact ? getDirectContactRoute(contact) : null;
const pathHashModeLabel =
effectiveRoute && effectiveRoute.pathLen >= 0
? formatPathHashMode(effectiveRoute.pathHashMode)
: null;
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
const learnedRouteLabel = directRoute ? formatRouteLabel(directRoute.path_len, true) : null;
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
const isUnknownFullKeyResolvedContact =
contact !== null &&
@@ -329,7 +332,7 @@ export function ContactInfoPane({
}
/>
)}
{contact && hasRoutingOverride(contact) && learnedRouteLabel && (
{hasRoutingOverride(contact) && learnedRouteLabel && (
<InfoItem label="Learned Route" value={learnedRouteLabel} />
)}
{pathHashModeLabel && <InfoItem label="Hop Width" value={pathHashModeLabel} />}
@@ -611,7 +614,7 @@ function MostActiveRoomsSection({
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
onClick={() => onNavigateToChannel?.(room.channel_key)}
>
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
{room.channel_name.startsWith('#') || isPublicChannelKey(room.channel_key)
? room.channel_name
: `#${room.channel_name}`}
</span>

View File

@@ -4,6 +4,7 @@ import type { Contact, PathDiscoveryResponse, PathDiscoveryRoute } from '../type
import {
findContactsByPrefix,
formatRouteLabel,
getDirectContactRoute,
getEffectiveContactRoute,
hasRoutingOverride,
parsePathHops,
@@ -99,16 +100,17 @@ export function ContactPathDiscoveryModal({
const [result, setResult] = useState<PathDiscoveryResponse | null>(null);
const effectiveRoute = useMemo(() => getEffectiveContactRoute(contact), [contact]);
const directRoute = useMemo(() => getDirectContactRoute(contact), [contact]);
const hasForcedRoute = hasRoutingOverride(contact);
const learnedRouteSummary = useMemo(() => {
if (contact.last_path_len === -1) {
if (!directRoute) {
return 'Flood';
}
const hops = parsePathHops(contact.last_path, contact.last_path_len);
const hops = parsePathHops(directRoute.path, directRoute.path_len);
return hops.length > 0
? `${formatRouteLabel(contact.last_path_len, true)} (${hops.join(' -> ')})`
: formatRouteLabel(contact.last_path_len, true);
}, [contact.last_path, contact.last_path_len]);
? `${formatRouteLabel(directRoute.path_len, true)} (${hops.join(' -> ')})`
: formatRouteLabel(directRoute.path_len, true);
}, [directRoute]);
const forcedRouteSummary = useMemo(() => {
if (!hasForcedRoute) {
return null;

View File

@@ -5,6 +5,7 @@ import type { Contact } from '../types';
import {
formatRouteLabel,
formatRoutingOverrideInput,
getDirectContactRoute,
hasRoutingOverride,
} from '../utils/pathUtils';
import { Button } from './ui/button';
@@ -28,7 +29,7 @@ interface ContactRoutingOverrideModalProps {
}
function summarizeLearnedRoute(contact: Contact): string {
return formatRouteLabel(contact.last_path_len, true);
return formatRouteLabel(getDirectContactRoute(contact)?.path_len ?? -1, true);
}
function summarizeForcedRoute(contact: Contact): string | null {
@@ -118,6 +119,10 @@ export function ContactRoutingOverrideModal({
/>
<div className="space-y-1 text-xs text-muted-foreground">
<p>Use comma-separated 1, 2, or 3 byte hop IDs for an explicit path.</p>
<p>
Note: direct messages that do not see an ACK retry up to 3 times. The final retry is
sent as flood, even when forced routing is configured.
</p>
</div>
</div>

View File

@@ -104,7 +104,7 @@ function MapBoundsHandler({
}
export function MapView({ contacts, focusedKey }: MapViewProps) {
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
// 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.

View File

@@ -24,6 +24,7 @@ const CHANNEL_WARNING_THRESHOLD = 120; // Conservative for multi-hop
const CHANNEL_DANGER_BUFFER = 8; // Red zone starts this many bytes before hard limit
const textEncoder = new TextEncoder();
const RADIO_NO_RESPONSE_SNIPPET = 'no response was heard back';
/** Get UTF-8 byte length of a string (LoRa packets are byte-constrained, not character-constrained). */
function byteLen(s: string): number {
return textEncoder.encode(s).length;
@@ -118,8 +119,11 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
setText('');
} catch (err) {
console.error('Failed to send message:', err);
toast.error('Failed to send message', {
description: err instanceof Error ? err.message : 'Check radio connection',
const description = err instanceof Error ? err.message : 'Check radio connection';
const isRadioNoResponse =
err instanceof Error && err.message.toLowerCase().includes(RADIO_NO_RESPONSE_SNIPPET);
toast.error(isRadioNoResponse ? 'Radio did not confirm send' : 'Failed to send message', {
description,
});
return;
} finally {

View File

@@ -12,6 +12,7 @@ import type { Contact, Message, MessagePath, RadioConfig } from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { formatTime, parseSenderFromText } from '../utils/messageParser';
import { formatHopCounts, type SenderInfo } from '../utils/pathUtils';
import { getDirectContactRoute } from '../utils/pathUtils';
import { ContactAvatar } from './ContactAvatar';
import { PathModal } from './PathModal';
import { handleKeyboardActivate } from '../utils/a11y';
@@ -329,7 +330,7 @@ export function MessageList({
}, [messages, onResendChannelMessage]);
// Sort messages by received_at ascending (oldest first)
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
// Note: Deduplication is handled by useConversationMessages.observeMessage()
// 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 || a.id - b.id),
@@ -500,12 +501,13 @@ export function MessageList({
parsedSender: string | null
): SenderInfo => {
if (msg.type === 'PRIV' && contact) {
const directRoute = getDirectContactRoute(contact);
return {
name: contact.name || contact.public_key.slice(0, 12),
publicKeyOrPrefix: contact.public_key,
lat: contact.lat,
lon: contact.lon,
pathHashMode: contact.out_path_hash_mode,
pathHashMode: directRoute?.path_hash_mode ?? null,
};
}
if (msg.type === 'CHAN') {
@@ -515,12 +517,13 @@ export function MessageList({
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
: null) || (senderName ? getContactByName(senderName) : null);
if (senderContact) {
const directRoute = getDirectContactRoute(senderContact);
return {
name: senderContact.name || senderName || senderContact.public_key.slice(0, 12),
publicKeyOrPrefix: senderContact.public_key,
lat: senderContact.lat,
lon: senderContact.lon,
pathHashMode: senderContact.out_path_hash_mode,
pathHashMode: directRoute?.path_hash_mode ?? null,
};
}
if (senderName || msg.sender_key) {
@@ -538,12 +541,13 @@ export function MessageList({
if (parsedSender) {
const senderContact = getContactByName(parsedSender);
if (senderContact) {
const directRoute = getDirectContactRoute(senderContact);
return {
name: parsedSender,
publicKeyOrPrefix: senderContact.public_key,
lat: senderContact.lat,
lon: senderContact.lon,
pathHashMode: senderContact.out_path_hash_mode,
pathHashMode: directRoute?.path_hash_mode ?? null,
};
}
}

View File

@@ -15,6 +15,7 @@ import { Input } from './ui/input';
import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
type Tab = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
@@ -90,6 +91,9 @@ export function NewMessageModal({
resetForm();
onClose();
} catch (err) {
toast.error('Failed to create conversation', {
description: err instanceof Error ? err.message : undefined,
});
setError(err instanceof Error ? err.message : 'Failed to create');
} finally {
setLoading(false);
@@ -123,6 +127,9 @@ export function NewMessageModal({
setName('');
hashtagInputRef.current?.focus();
} catch (err) {
toast.error('Failed to create conversation', {
description: err instanceof Error ? err.message : undefined,
});
setError(err instanceof Error ? err.message : 'Failed to create');
} finally {
setLoading(false);

View File

@@ -8,6 +8,7 @@ import { RepeaterLogin } from './RepeaterLogin';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
@@ -60,6 +61,8 @@ export function RepeaterDashboard({
onDeleteContact,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
const hasAdvertLocation = isValidLocation(contact?.lat ?? null, contact?.lon ?? null);
const {
loggedIn,
loginLoading,
@@ -77,9 +80,8 @@ export function RepeaterDashboard({
sendFloodAdvert,
rebootRepeater,
syncClock,
} = useRepeaterDashboard(conversation);
} = useRepeaterDashboard(conversation, { hasAdvertLocation });
const contact = contacts.find((c) => c.public_key === conversation.id);
const isFav = isFavorite(favorites, 'contact', conversation.id);
// Loading all panes indicator
@@ -225,7 +227,7 @@ export function RepeaterDashboard({
) : (
<div className="space-y-4">
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 md:items-stretch">
<div className="flex flex-col gap-4">
<NodeInfoPane
data={paneData.nodeInfo}
@@ -255,12 +257,13 @@ export function RepeaterDashboard({
disabled={anyLoading}
/>
</div>
<div className="flex flex-col gap-4">
<div className="flex min-h-0 flex-col gap-4">
<NeighborsPane
data={paneData.neighbors}
state={paneStates.neighbors}
onRefresh={() => refreshPane('neighbors')}
disabled={anyLoading}
repeaterContact={contact}
contacts={contacts}
nodeInfo={paneData.nodeInfo}
nodeInfoState={paneStates.nodeInfo}

View File

@@ -3,6 +3,7 @@ import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -35,7 +36,7 @@ interface SettingsModalBaseProps {
onReboot: () => Promise<void>;
onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>;
onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
@@ -177,33 +178,38 @@ export function SettingsModal(props: SettingsModalProps) {
return null;
}
return !config ? (
<div className="py-8 text-center text-muted-foreground">Loading configuration...</div>
) : (
return (
<div className={settingsContainerClass}>
{shouldRenderSection('radio') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('radio')}
{isSectionVisible('radio') && appSettings && (
<SettingsRadioSection
config={config}
health={health}
appSettings={appSettings}
pageMode={pageMode}
onSave={onSave}
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={onSetPrivateKey}
onReboot={onReboot}
onDisconnect={onDisconnect}
onReconnect={onReconnect}
onAdvertise={onAdvertise}
meshDiscovery={meshDiscovery}
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
onDiscoverMesh={onDiscoverMesh}
onClose={onClose}
className={sectionContentClass}
/>
)}
{isSectionVisible('radio') &&
(config && appSettings ? (
<SettingsRadioSection
config={config}
health={health}
appSettings={appSettings}
pageMode={pageMode}
onSave={onSave}
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={onSetPrivateKey}
onReboot={onReboot}
onDisconnect={onDisconnect}
onReconnect={onReconnect}
onAdvertise={onAdvertise}
meshDiscovery={meshDiscovery}
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
onDiscoverMesh={onDiscoverMesh}
onClose={onClose}
className={sectionContentClass}
/>
) : (
<div className={sectionContentClass}>
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
Radio is not available.
</div>
</div>
))}
</section>
)}
@@ -222,19 +228,26 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('database') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('database')}
{isSectionVisible('database') && appSettings && (
<SettingsDatabaseSection
appSettings={appSettings}
health={health}
onSaveAppSettings={onSaveAppSettings}
onHealthRefresh={onHealthRefresh}
blockedKeys={blockedKeys}
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
className={sectionContentClass}
/>
)}
{isSectionVisible('database') &&
(appSettings ? (
<SettingsDatabaseSection
appSettings={appSettings}
health={health}
onSaveAppSettings={onSaveAppSettings}
onHealthRefresh={onHealthRefresh}
blockedKeys={blockedKeys}
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
className={sectionContentClass}
/>
) : (
<div className={sectionContentClass}>
<div className="rounded-md border border-input bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
Loading app settings...
</div>
</div>
))}
</section>
)}
@@ -263,7 +276,9 @@ export function SettingsModal(props: SettingsModalProps) {
{shouldRenderSection('about') && (
<section className={sectionWrapperClass}>
{renderSectionHeader('about')}
{isSectionVisible('about') && <SettingsAboutSection className={sectionContentClass} />}
{isSectionVisible('about') && (
<SettingsAboutSection health={health} className={sectionContentClass} />
)}
</section>
)}
</div>

View File

@@ -19,7 +19,18 @@ import {
type Conversation,
type Favorite,
} from '../types';
import { getStateKey, type ConversationTimes, type SortOrder } from '../utils/conversationState';
import {
buildSidebarSectionSortOrders,
getStateKey,
loadLegacyLocalStorageSortOrder,
loadLocalStorageSidebarSectionSortOrders,
saveLocalStorageSidebarSectionSortOrders,
type ConversationTimes,
type SidebarSectionSortOrders,
type SidebarSortableSection,
type SortOrder,
} from '../utils/conversationState';
import { isPublicChannelKey } from '../utils/publicChannel';
import { getContactDisplayName } from '../utils/pubkey';
import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
@@ -91,13 +102,36 @@ interface SidebarProps {
onToggleCracker: () => void;
onMarkAllRead: () => void;
favorites: Favorite[];
/** Sort order from server settings */
sortOrder?: SortOrder;
/** Callback when sort order changes */
onSortOrderChange?: (order: SortOrder) => void;
/** Legacy global sort order, used only to seed per-section local preferences. */
legacySortOrder?: SortOrder;
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
}
type InitialSectionSortState = {
orders: SidebarSectionSortOrders;
source: 'section' | 'legacy' | 'none';
};
function loadInitialSectionSortOrders(): InitialSectionSortState {
const storedOrders = loadLocalStorageSidebarSectionSortOrders();
if (storedOrders) {
return { orders: storedOrders, source: 'section' };
}
const legacyOrder = loadLegacyLocalStorageSortOrder();
if (legacyOrder) {
return {
orders: buildSidebarSectionSortOrders(legacyOrder),
source: 'legacy',
};
}
return {
orders: buildSidebarSectionSortOrders(),
source: 'none',
};
}
export function Sidebar({
contacts,
channels,
@@ -112,12 +146,12 @@ export function Sidebar({
onToggleCracker,
onMarkAllRead,
favorites,
sortOrder: sortOrderProp = 'recent',
onSortOrderChange,
legacySortOrder,
isConversationNotificationsEnabled,
}: SidebarProps) {
const sortOrder = sortOrderProp;
const [searchQuery, setSearchQuery] = useState('');
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
const initialCollapsedState = useMemo(loadCollapsedState, []);
const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
@@ -125,10 +159,31 @@ export function Sidebar({
const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts);
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
const collapseSnapshotRef = useRef<CollapseState | null>(null);
const sectionSortSourceRef = useRef(initialSectionSortState.source);
const handleSortToggle = () => {
const newOrder = sortOrder === 'alpha' ? 'recent' : 'alpha';
onSortOrderChange?.(newOrder);
useEffect(() => {
if (sectionSortSourceRef.current === 'legacy') {
saveLocalStorageSidebarSectionSortOrders(sectionSortOrders);
sectionSortSourceRef.current = 'section';
return;
}
if (sectionSortSourceRef.current !== 'none' || legacySortOrder === undefined) return;
const seededOrders = buildSidebarSectionSortOrders(legacySortOrder);
setSectionSortOrders(seededOrders);
saveLocalStorageSidebarSectionSortOrders(seededOrders);
sectionSortSourceRef.current = 'section';
}, [legacySortOrder, sectionSortOrders]);
const handleSortToggle = (section: SidebarSortableSection) => {
setSectionSortOrders((prev) => {
const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha';
const updated = { ...prev, [section]: nextOrder };
saveLocalStorageSidebarSectionSortOrders(updated);
sectionSortSourceRef.current = 'section';
return updated;
});
};
const handleSelectConversation = (conversation: Conversation) => {
@@ -161,6 +216,20 @@ export function Sidebar({
[lastMessageTimes]
);
const getContactHeardTime = useCallback((contact: Contact): number => {
return Math.max(contact.last_seen ?? 0, contact.last_advert ?? 0);
}, []);
const getContactRecentTime = useCallback(
(contact: Contact): number => {
if (contact.type === CONTACT_TYPE_REPEATER) {
return getContactHeardTime(contact);
}
return getLastMessageTime('contact', contact.public_key) || getContactHeardTime(contact);
},
[getContactHeardTime, getLastMessageTime]
);
// Deduplicate channels by key only.
// Channel names are not unique; distinct keys must remain visible.
const uniqueChannels = useMemo(
@@ -200,10 +269,10 @@ export function Sidebar({
() =>
[...uniqueChannels].sort((a, b) => {
// Public channel always sorts to the top
if (a.name === 'Public') return -1;
if (b.name === 'Public') return 1;
if (isPublicChannelKey(a.key)) return -1;
if (isPublicChannelKey(b.key)) return 1;
if (sortOrder === 'recent') {
if (sectionSortOrders.channels === 'recent') {
const timeA = getLastMessageTime('channel', a.key);
const timeB = getLastMessageTime('channel', b.key);
if (timeA && timeB) return timeB - timeA;
@@ -212,33 +281,56 @@ export function Sidebar({
}
return a.name.localeCompare(b.name);
}),
[uniqueChannels, sortOrder, getLastMessageTime]
[uniqueChannels, sectionSortOrders.channels, getLastMessageTime]
);
const sortContactsByOrder = useCallback(
(items: Contact[]) =>
(items: Contact[], order: SortOrder) =>
[...items].sort((a, b) => {
if (sortOrder === 'recent') {
const timeA = getLastMessageTime('contact', a.public_key);
const timeB = getLastMessageTime('contact', b.public_key);
if (order === 'recent') {
const timeA = getContactRecentTime(a);
const timeB = getContactRecentTime(b);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
}
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
}),
[sortOrder, getLastMessageTime]
[getContactRecentTime]
);
const sortRepeatersByOrder = useCallback(
(items: Contact[], order: SortOrder) =>
[...items].sort((a, b) => {
if (order === 'recent') {
const timeA = getContactHeardTime(a);
const timeB = getContactHeardTime(b);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
}
return (a.name || a.public_key).localeCompare(b.name || b.public_key);
}),
[getContactHeardTime]
);
// Split non-repeater contacts and repeater contacts into separate sorted lists
const sortedNonRepeaterContacts = useMemo(
() => sortContactsByOrder(uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER)),
[uniqueContacts, sortContactsByOrder]
() =>
sortContactsByOrder(
uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER),
sectionSortOrders.contacts
),
[uniqueContacts, sectionSortOrders.contacts, sortContactsByOrder]
);
const sortedRepeaters = useMemo(
() => sortContactsByOrder(uniqueContacts.filter((c) => c.type === CONTACT_TYPE_REPEATER)),
[uniqueContacts, sortContactsByOrder]
() =>
sortRepeatersByOrder(
uniqueContacts.filter((c) => c.type === CONTACT_TYPE_REPEATER),
sectionSortOrders.repeaters
),
[uniqueContacts, sectionSortOrders.repeaters, sortRepeatersByOrder]
);
// Filter by search query
@@ -373,11 +465,11 @@ export function Sidebar({
const timeA =
a.type === 'channel'
? getLastMessageTime('channel', a.channel.key)
: getLastMessageTime('contact', a.contact.public_key);
: getContactRecentTime(a.contact);
const timeB =
b.type === 'channel'
? getLastMessageTime('channel', b.channel.key)
: getLastMessageTime('contact', b.contact.public_key);
: getContactRecentTime(b.contact);
if (timeA && timeB) return timeB - timeA;
if (timeA && !timeB) return -1;
if (!timeA && timeB) return 1;
@@ -399,6 +491,7 @@ export function Sidebar({
filteredNonRepeaterContacts,
filteredRepeaters,
favorites,
getContactRecentTime,
getLastMessageTime,
]);
@@ -604,11 +697,12 @@ export function Sidebar({
title: string,
collapsed: boolean,
onToggle: () => void,
showSortToggle = false,
sortSection: SidebarSortableSection | null = null,
unreadCount = 0,
highlightUnread = false
) => {
const effectiveCollapsed = isSearching ? false : collapsed;
const sectionSortOrder = sortSection ? sectionSortOrders[sortSection] : null;
return (
<div className="flex justify-between items-center px-3 py-2 pt-3.5">
@@ -630,16 +724,24 @@ export function Sidebar({
)}
<span>{title}</span>
</button>
{(showSortToggle || unreadCount > 0) && (
{(sortSection || unreadCount > 0) && (
<div className="ml-auto flex items-center gap-1.5">
{showSortToggle && (
{sortSection && sectionSortOrder && (
<button
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'}
onClick={() => handleSortToggle(sortSection)}
aria-label={
sectionSortOrder === 'alpha'
? `Sort ${title} by recent`
: `Sort ${title} alphabetically`
}
title={
sectionSortOrder === 'alpha'
? `Sort ${title} by recent`
: `Sort ${title} alphabetically`
}
>
{sortOrder === 'alpha' ? 'A-Z' : '⏱'}
{sectionSortOrder === 'alpha' ? 'A-Z' : '⏱'}
</button>
)}
{unreadCount > 0 && (
@@ -731,7 +833,7 @@ export function Sidebar({
'Favorites',
favoritesCollapsed,
() => setFavoritesCollapsed((prev) => !prev),
false,
null,
favoritesUnreadCount,
favoritesHasMention
)}
@@ -747,7 +849,7 @@ export function Sidebar({
'Channels',
channelsCollapsed,
() => setChannelsCollapsed((prev) => !prev),
true,
'channels',
channelsUnreadCount,
channelsHasMention
)}
@@ -763,7 +865,7 @@ export function Sidebar({
'Contacts',
contactsCollapsed,
() => setContactsCollapsed((prev) => !prev),
true,
'contacts',
contactsUnreadCount,
contactsUnreadCount > 0
)}
@@ -779,7 +881,7 @@ export function Sidebar({
'Repeaters',
repeatersCollapsed,
() => setRepeatersCollapsed((prev) => !prev),
true,
'repeaters',
repeatersUnreadCount
)}
{(isSearching || !repeatersCollapsed) &&

View File

@@ -19,6 +19,7 @@ export function NeighborsPane({
state,
onRefresh,
disabled,
repeaterContact,
contacts,
nodeInfo,
nodeInfoState,
@@ -28,11 +29,15 @@ export function NeighborsPane({
state: PaneState;
onRefresh: () => void;
disabled?: boolean;
repeaterContact: Contact | null;
contacts: Contact[];
nodeInfo: RepeaterNodeInfoResponse | null;
nodeInfoState: PaneState;
repeaterName: string | null;
}) {
const advertLat = repeaterContact?.lat ?? null;
const advertLon = repeaterContact?.lon ?? null;
const radioLat = useMemo(() => {
const parsed = nodeInfo?.lat != null ? parseFloat(nodeInfo.lat) : null;
return Number.isFinite(parsed) ? parsed : null;
@@ -43,11 +48,26 @@ export function NeighborsPane({
return Number.isFinite(parsed) ? parsed : null;
}, [nodeInfo?.lon]);
const radioName = nodeInfo?.name || repeaterName;
const hasValidRepeaterGps = isValidLocation(radioLat, radioLon);
const showGpsUnavailableMessage =
!hasValidRepeaterGps &&
(nodeInfoState.error !== null || nodeInfoState.fetched_at != null || nodeInfo !== null);
const positionSource = useMemo(() => {
if (isValidLocation(radioLat, radioLon)) {
return { lat: radioLat, lon: radioLon, source: 'reported' as const };
}
if (isValidLocation(advertLat, advertLon)) {
return { lat: advertLat, lon: advertLon, source: 'advert' as const };
}
return { lat: null, lon: null, source: null };
}, [advertLat, advertLon, radioLat, radioLon]);
const radioName = nodeInfo?.name || repeaterContact?.name || repeaterName;
const hasValidRepeaterGps = positionSource.source !== null;
const headerNote =
positionSource.source === 'reported'
? 'Using repeater-reported position'
: positionSource.source === 'advert'
? 'Using advert position'
: nodeInfoState.loading
? 'Waiting for repeater position'
: 'No repeater position available';
// Resolve contact data for each neighbor in a single pass — used for
// coords (mini-map), distances (table column), and sorted display order.
@@ -71,7 +91,7 @@ export function NeighborsPane({
let dist: string | null = null;
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
const distKm = calculateDistance(radioLat, radioLon, nLat, nLon);
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
if (distKm != null) {
dist = formatDistance(distKm);
anyDist = true;
@@ -91,24 +111,25 @@ export function NeighborsPane({
sorted: enriched,
hasDistances: anyDist,
};
}, [contacts, data, hasValidRepeaterGps, radioLat, radioLon]);
}, [contacts, data, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
return (
<RepeaterPane
title="Neighbors"
headerNote={headerNote}
state={state}
onRefresh={onRefresh}
disabled={disabled}
className="flex flex-col"
contentClassName="flex-1 flex flex-col"
className="flex min-h-0 flex-1 flex-col"
contentClassName="flex min-h-0 flex-1 flex-col"
>
{!data ? (
<NotFetched />
) : sorted.length === 0 ? (
<p className="text-sm text-muted-foreground">No neighbors reported</p>
) : (
<div className="flex-1 flex flex-col gap-2">
<div className="overflow-x-auto">
<div className="flex min-h-0 flex-1 flex-col gap-2">
<div className="shrink-0 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-muted-foreground text-xs">
@@ -145,7 +166,7 @@ export function NeighborsPane({
{hasValidRepeaterGps && (neighborsWithCoords.length > 0 || hasValidRepeaterGps) ? (
<Suspense
fallback={
<div className="h-48 flex items-center justify-center text-xs text-muted-foreground">
<div className="flex min-h-48 flex-1 items-center justify-center text-xs text-muted-foreground">
Loading map...
</div>
}
@@ -153,18 +174,17 @@ export function NeighborsPane({
<NeighborsMiniMap
key={neighborsWithCoords.map((n) => n.pubkey_prefix).join(',')}
neighbors={neighborsWithCoords}
radioLat={radioLat}
radioLon={radioLon}
radioLat={positionSource.lat}
radioLon={positionSource.lon}
radioName={radioName}
/>
</Suspense>
) : showGpsUnavailableMessage ? (
) : (
<div className="rounded border border-border/70 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
GPS info failed to fetch; map and distance data not available. This may be due to
missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try
refreshing.
Map and distance data are unavailable until this repeater has a valid position from
either its advert or a Node Info fetch.
</div>
) : null}
)}
</div>
)}
</RepeaterPane>

View File

@@ -16,8 +16,8 @@ export function NodeInfoPane({
}) {
const clockDrift = useMemo(() => {
if (!data?.clock_utc) return null;
return formatClockDrift(data.clock_utc);
}, [data?.clock_utc]);
return formatClockDrift(data.clock_utc, state.fetched_at ?? undefined);
}, [data?.clock_utc, state.fetched_at]);
return (
<RepeaterPane title="Node Info" state={state} onRefresh={onRefresh} disabled={disabled}>

View File

@@ -39,7 +39,10 @@ export function formatDuration(seconds: number): string {
return `${mins}m`;
}
export function formatClockDrift(clockUtc: string): { text: string; isLarge: boolean } {
export function formatClockDrift(
clockUtc: string,
referenceTimeMs: number = Date.now()
): { text: string; isLarge: boolean } {
// Firmware format: "HH:MM - D/M/YYYY UTC" or "HH:MM:SS - D/M/YYYY UTC"
// Also handle ISO-like: "YYYY-MM-DD HH:MM:SS"
let parsed: Date;
@@ -56,7 +59,7 @@ export function formatClockDrift(clockUtc: string): { text: string; isLarge: boo
}
if (isNaN(parsed.getTime())) return { text: '(invalid)', isLarge: false };
const driftMs = Math.abs(Date.now() - parsed.getTime());
const driftMs = Math.abs(referenceTimeMs - parsed.getTime());
const driftSec = Math.floor(driftMs / 1000);
if (driftSec >= 86400) return { text: '>24 hours!', isLarge: true };
@@ -106,6 +109,7 @@ function formatFetchedTime(fetchedAt: number): string {
export function RepeaterPane({
title,
headerNote,
state,
onRefresh,
disabled,
@@ -114,6 +118,7 @@ export function RepeaterPane({
contentClassName,
}: {
title: string;
headerNote?: ReactNode;
state: PaneState;
onRefresh?: () => void;
disabled?: boolean;
@@ -128,6 +133,7 @@ export function RepeaterPane({
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<div className="min-w-0">
<h3 className="text-sm font-medium">{title}</h3>
{headerNote && <p className="text-[11px] text-muted-foreground">{headerNote}</p>}
{fetchedAt && (
<p
className="text-[11px] text-muted-foreground"

View File

@@ -1,10 +1,17 @@
import type { HealthStatus } from '../../types';
import { Separator } from '../ui/separator';
const GITHUB_URL = 'https://github.com/jkingsman/Remote-Terminal-for-MeshCore';
export function SettingsAboutSection({ className }: { className?: string }) {
const version = __APP_VERSION__;
const commit = __COMMIT_HASH__;
export function SettingsAboutSection({
health,
className,
}: {
health?: HealthStatus | null;
className?: string;
}) {
const version = health?.app_info?.version ?? 'unknown';
const commit = health?.app_info?.commit_hash;
return (
<div className={className}>
@@ -14,8 +21,14 @@ export function SettingsAboutSection({ className }: { className?: string }) {
<h3 className="text-lg font-semibold">RemoteTerm for MeshCore</h3>
<div className="text-sm text-muted-foreground">
v{version}
<span className="mx-1.5">·</span>
<span className="font-mono text-xs">{commit}</span>
{commit ? (
<>
<span className="mx-1.5">·</span>
<span className="font-mono text-xs" title={commit}>
{commit}
</span>
</>
) : null}
</div>
</div>

View File

@@ -173,7 +173,8 @@ export function SettingsDatabaseSection({
Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or app functionality.
This will not affect any displayed messages or app functionality, nor impact your
ability to do historical decryption.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>

View File

@@ -14,16 +14,16 @@ const BotCodeEditor = lazy(() =>
const TYPE_LABELS: Record<string, string> = {
mqtt_private: 'Private MQTT',
mqtt_community: 'meshcoretomqtt/LetsMesh/MeshRank',
mqtt_community: 'Community MQTT',
bot: 'Bot',
webhook: 'Webhook',
apprise: 'Apprise',
sqs: 'Amazon SQS',
};
const TYPE_OPTIONS = [
const LIST_TYPE_OPTIONS = [
{ value: 'mqtt_private', label: 'Private MQTT' },
{ value: 'mqtt_community', label: 'meshcoretomqtt/LetsMesh/MeshRank' },
{ value: 'mqtt_community', label: 'Community MQTT' },
{ value: 'bot', label: 'Bot' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'apprise', label: 'Apprise' },
@@ -32,9 +32,313 @@ const TYPE_OPTIONS = [
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
const DEFAULT_COMMUNITY_BROKER_HOST = 'mqtt-us-v1.letsmesh.net';
const DEFAULT_COMMUNITY_BROKER_HOST_EU = 'mqtt-eu-v1.letsmesh.net';
const DEFAULT_COMMUNITY_BROKER_PORT = 443;
const DEFAULT_COMMUNITY_TRANSPORT = 'websockets';
const DEFAULT_COMMUNITY_AUTH_MODE = 'token';
const DEFAULT_MESHRANK_BROKER_HOST = 'meshrank.net';
const DEFAULT_MESHRANK_BROKER_PORT = 8883;
const DEFAULT_MESHRANK_TRANSPORT = 'tcp';
const DEFAULT_MESHRANK_AUTH_MODE = 'none';
const DEFAULT_MESHRANK_IATA = 'XYZ';
const CREATE_TYPE_OPTIONS = [
{ value: 'mqtt_private', label: 'Private MQTT' },
{ value: 'mqtt_community_meshrank', label: 'MeshRank' },
{ value: 'mqtt_community_letsmesh_us', label: 'LetsMesh (US)' },
{ value: 'mqtt_community_letsmesh_eu', label: 'LetsMesh (EU)' },
{ value: 'mqtt_community', label: 'Community MQTT/meshcoretomqtt' },
{ value: 'bot', label: 'Bot' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'apprise', label: 'Apprise' },
{ value: 'sqs', label: 'Amazon SQS' },
] as const;
type DraftType = (typeof CREATE_TYPE_OPTIONS)[number]['value'];
type DraftRecipe = {
savedType: string;
detailLabel: string;
defaultName: string;
defaults: {
config: Record<string, unknown>;
scope: Record<string, unknown>;
};
};
function createCommunityConfigDefaults(
overrides: Partial<Record<string, unknown>> = {}
): Record<string, unknown> {
return {
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
transport: DEFAULT_COMMUNITY_TRANSPORT,
use_tls: true,
tls_verify: true,
auth_mode: DEFAULT_COMMUNITY_AUTH_MODE,
username: '',
password: '',
iata: '',
email: '',
token_audience: '',
topic_template: DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
...overrides,
};
}
const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
"""
Process messages and optionally return a reply.
Args:
kwargs keys currently provided:
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
path_bytes_per_hop: Bytes per hop in path (1, 2, or 3) when known
Returns:
None for no reply, a string for a single reply,
or a list of strings to send multiple messages in order
"""
sender_name = kwargs.get("sender_name")
message_text = kwargs.get("message_text", "")
channel_name = kwargs.get("channel_name")
is_outgoing = kwargs.get("is_outgoing", False)
path_bytes_per_hop = kwargs.get("path_bytes_per_hop")
# 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`;
const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
mqtt_private: {
savedType: 'mqtt_private',
detailLabel: 'Private MQTT',
defaultName: 'Private MQTT',
defaults: {
config: {
broker_host: '',
broker_port: 1883,
username: '',
password: '',
use_tls: false,
tls_insecure: false,
topic_prefix: 'meshcore',
},
scope: { messages: 'all', raw_packets: 'all' },
},
},
mqtt_community_meshrank: {
savedType: 'mqtt_community',
detailLabel: 'MeshRank',
defaultName: 'MeshRank',
defaults: {
config: createCommunityConfigDefaults({
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
transport: DEFAULT_MESHRANK_TRANSPORT,
auth_mode: DEFAULT_MESHRANK_AUTH_MODE,
iata: DEFAULT_MESHRANK_IATA,
email: '',
token_audience: '',
topic_template: '',
}),
scope: { messages: 'none', raw_packets: 'all' },
},
},
mqtt_community_letsmesh_us: {
savedType: 'mqtt_community',
detailLabel: 'LetsMesh (US)',
defaultName: 'LetsMesh (US)',
defaults: {
config: createCommunityConfigDefaults({
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
token_audience: DEFAULT_COMMUNITY_BROKER_HOST,
}),
scope: { messages: 'none', raw_packets: 'all' },
},
},
mqtt_community_letsmesh_eu: {
savedType: 'mqtt_community',
detailLabel: 'LetsMesh (EU)',
defaultName: 'LetsMesh (EU)',
defaults: {
config: createCommunityConfigDefaults({
broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU,
token_audience: DEFAULT_COMMUNITY_BROKER_HOST_EU,
}),
scope: { messages: 'none', raw_packets: 'all' },
},
},
mqtt_community: {
savedType: 'mqtt_community',
detailLabel: 'Community MQTT/meshcoretomqtt',
defaultName: 'Community MQTT',
defaults: {
config: createCommunityConfigDefaults(),
scope: { messages: 'none', raw_packets: 'all' },
},
},
bot: {
savedType: 'bot',
detailLabel: 'Bot',
defaultName: 'Bot',
defaults: {
config: {
code: DEFAULT_BOT_CODE,
},
scope: { messages: 'all', raw_packets: 'none' },
},
},
webhook: {
savedType: 'webhook',
detailLabel: 'Webhook',
defaultName: 'Webhook',
defaults: {
config: {
url: '',
method: 'POST',
headers: {},
hmac_secret: '',
hmac_header: '',
},
scope: { messages: 'all', raw_packets: 'none' },
},
},
apprise: {
savedType: 'apprise',
detailLabel: 'Apprise',
defaultName: 'Apprise',
defaults: {
config: {
urls: '',
preserve_identity: true,
include_path: true,
},
scope: { messages: 'all', raw_packets: 'none' },
},
},
sqs: {
savedType: 'sqs',
detailLabel: 'Amazon SQS',
defaultName: 'Amazon SQS',
defaults: {
config: {
queue_url: '',
region_name: '',
endpoint_url: '',
access_key_id: '',
secret_access_key: '',
session_token: '',
},
scope: { messages: 'all', raw_packets: 'none' },
},
},
};
function isDraftType(value: string): value is DraftType {
return value in DRAFT_RECIPES;
}
function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) {
const recipe = DRAFT_RECIPES[draftType];
return name || getDefaultIntegrationName(recipe.savedType, configs);
}
function normalizeDraftConfig(draftType: DraftType, config: Record<string, unknown>) {
if (draftType === 'mqtt_community_meshrank') {
const topicTemplate = String(config.topic_template || '').trim();
if (!topicTemplate) {
throw new Error('MeshRank packet topic is required');
}
return {
...config,
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
broker_port: DEFAULT_MESHRANK_BROKER_PORT,
transport: DEFAULT_MESHRANK_TRANSPORT,
auth_mode: DEFAULT_MESHRANK_AUTH_MODE,
use_tls: true,
tls_verify: true,
iata: DEFAULT_MESHRANK_IATA,
email: '',
token_audience: '',
topic_template: topicTemplate,
username: '',
password: '',
};
}
if (draftType === 'mqtt_community_letsmesh_us' || draftType === 'mqtt_community_letsmesh_eu') {
const brokerHost =
draftType === 'mqtt_community_letsmesh_eu'
? DEFAULT_COMMUNITY_BROKER_HOST_EU
: DEFAULT_COMMUNITY_BROKER_HOST;
return {
...config,
broker_host: brokerHost,
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
transport: DEFAULT_COMMUNITY_TRANSPORT,
auth_mode: DEFAULT_COMMUNITY_AUTH_MODE,
use_tls: true,
tls_verify: true,
token_audience: brokerHost,
topic_template: (config.topic_template as string) || DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
username: '',
password: '',
};
}
return config;
}
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
if (draftType.startsWith('mqtt_community_')) {
return { messages: 'none', raw_packets: 'all' };
}
return scope;
}
function cloneDraftDefaults(draftType: DraftType) {
const recipe = DRAFT_RECIPES[draftType];
return {
config: structuredClone(recipe.defaults.config),
scope: structuredClone(recipe.defaults.scope),
};
}
function getDetailTypeLabel(detailType: string) {
if (isDraftType(detailType)) return DRAFT_RECIPES[detailType].detailLabel;
return TYPE_LABELS[detailType] || detailType;
}
function fanoutDraftHasUnsavedChanges(
original: FanoutConfig | null,
current: {
name: string;
config: Record<string, unknown>;
scope: Record<string, unknown>;
}
) {
if (!original) return false;
return (
current.name !== original.name ||
JSON.stringify(current.config) !== JSON.stringify(original.config) ||
JSON.stringify(current.scope) !== JSON.stringify(original.scope)
);
}
function formatBrokerSummary(
config: Record<string, unknown>,
@@ -74,42 +378,6 @@ function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) {
return `${label} #${nextIndex}`;
}
const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
"""
Process messages and optionally return a reply.
Args:
kwargs keys currently provided:
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
path_bytes_per_hop: Bytes per hop in path (1, 2, or 3) when known
Returns:
None for no reply, a string for a single reply,
or a list of strings to send multiple messages in order
"""
sender_name = kwargs.get("sender_name")
message_text = kwargs.get("message_text", "")
channel_name = kwargs.get("channel_name")
is_outgoing = kwargs.get("is_outgoing", False)
path_bytes_per_hop = kwargs.get("path_bytes_per_hop")
# 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`;
function getStatusLabel(status: string | undefined, type?: string) {
if (status === 'connected')
return type === 'bot' || type === 'webhook' || type === 'apprise' ? 'Active' : 'Connected';
@@ -251,8 +519,9 @@ function MqttCommunityConfigEditor({
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Share raw packet data with the MeshCore community for coverage mapping and network analysis.
Only raw RF packets are shared &mdash; never decrypted messages.
Advanced community MQTT editor. Use this for manual meshcoretomqtt-compatible setups or for
modifying a saved preset after creation. Only raw RF packets are shared &mdash; never
decrypted messages.
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
@@ -424,6 +693,110 @@ function MqttCommunityConfigEditor({
);
}
function MeshRankConfigEditor({
config,
onChange,
}: {
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Pre-filled MeshRank setup. This saves as a regular Community MQTT integration once created,
but only asks for the MeshRank packet topic you were given.
</p>
<div className="rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Broker <code>{DEFAULT_MESHRANK_BROKER_HOST}</code> on port{' '}
<code>{DEFAULT_MESHRANK_BROKER_PORT}</code> via <code>{DEFAULT_MESHRANK_TRANSPORT}</code>,
auth <code>{DEFAULT_MESHRANK_AUTH_MODE}</code>, TLS on, certificate verification on, region
code fixed to <code>{DEFAULT_MESHRANK_IATA}</code>.
</div>
<div className="space-y-2">
<Label htmlFor="fanout-meshrank-topic-template">Packet Topic Template</Label>
<Input
id="fanout-meshrank-topic-template"
type="text"
placeholder="meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{PUBLIC_KEY}/packets"
value={(config.topic_template as string) || ''}
onChange={(e) =>
onChange({
...config,
iata: DEFAULT_MESHRANK_IATA,
topic_template: e.target.value,
})
}
/>
<p className="text-xs text-muted-foreground">
Paste the full topic template from your MeshRank config, for example{' '}
<code>meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{'{PUBLIC_KEY}'}/packets</code>.
</p>
</div>
</div>
);
}
function LetsMeshConfigEditor({
config,
onChange,
brokerHost,
}: {
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
brokerHost: string;
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Pre-filled LetsMesh setup. This saves as a regular Community MQTT integration once created,
but only asks for the values LetsMesh expects from you.
</p>
<div className="rounded-md border border-input bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
Broker <code>{brokerHost}</code> on port <code>{DEFAULT_COMMUNITY_BROKER_PORT}</code> via{' '}
<code>{DEFAULT_COMMUNITY_TRANSPORT}</code>, auth <code>{DEFAULT_COMMUNITY_AUTH_MODE}</code>,
TLS on, certificate verification on, token audience fixed to <code>{brokerHost}</code>.
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fanout-letsmesh-email">Email</Label>
<Input
id="fanout-letsmesh-email"
type="email"
placeholder="you@example.com"
value={(config.email as string) || ''}
onChange={(e) =>
onChange({ ...config, email: e.target.value, broker_host: brokerHost })
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="fanout-letsmesh-iata">Region Code (IATA)</Label>
<Input
id="fanout-letsmesh-iata"
type="text"
maxLength={3}
placeholder="e.g. DEN, LAX, NYC"
value={(config.iata as string) || ''}
onChange={(e) =>
onChange({
...config,
broker_host: brokerHost,
token_audience: brokerHost,
iata: e.target.value.toUpperCase(),
})
}
className="w-32"
/>
</div>
</div>
</div>
);
}
function BotConfigEditor({
config,
onChange,
@@ -1120,7 +1493,7 @@ export function SettingsFanoutSection({
}) {
const [configs, setConfigs] = useState<FanoutConfig[]>([]);
const [editingId, setEditingId] = useState<string | null>(null);
const [draftType, setDraftType] = useState<string | null>(null);
const [draftType, setDraftType] = useState<DraftType | null>(null);
const [editConfig, setEditConfig] = useState<Record<string, unknown>>({});
const [editScope, setEditScope] = useState<Record<string, unknown>>({});
const [editName, setEditName] = useState('');
@@ -1190,7 +1563,17 @@ export function SettingsFanoutSection({
};
const handleBackToList = () => {
if (!confirm('Leave without saving?')) return;
const shouldConfirm =
draftType !== null ||
fanoutDraftHasUnsavedChanges(
editingId ? (configs.find((c) => c.id === editingId) ?? null) : null,
{
name: editName,
config: editConfig,
scope: editScope,
}
);
if (shouldConfirm && !confirm('Leave without saving?')) return;
setEditingId(null);
setDraftType(null);
};
@@ -1228,11 +1611,12 @@ export function SettingsFanoutSection({
setBusy(true);
try {
if (currentDraftType) {
const recipe = DRAFT_RECIPES[currentDraftType];
await api.createFanoutConfig({
type: currentDraftType,
name: editName,
config: editConfig,
scope: editScope,
type: recipe.savedType,
name: normalizeDraftName(currentDraftType, editName.trim(), configs),
config: normalizeDraftConfig(currentDraftType, editConfig),
scope: normalizeDraftScope(currentDraftType, editScope),
enabled: enabled ?? true,
});
} else {
@@ -1280,74 +1664,26 @@ export function SettingsFanoutSection({
};
const handleAddCreate = async (type: string) => {
const defaults: Record<string, Record<string, unknown>> = {
mqtt_private: {
broker_host: '',
broker_port: 1883,
username: '',
password: '',
use_tls: false,
tls_insecure: false,
topic_prefix: 'meshcore',
},
mqtt_community: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: DEFAULT_COMMUNITY_BROKER_PORT,
transport: DEFAULT_COMMUNITY_TRANSPORT,
use_tls: true,
tls_verify: true,
auth_mode: DEFAULT_COMMUNITY_AUTH_MODE,
username: '',
password: '',
iata: '',
email: '',
token_audience: '',
topic_template: DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE,
},
bot: {
code: DEFAULT_BOT_CODE,
},
webhook: {
url: '',
method: 'POST',
headers: {},
hmac_secret: '',
hmac_header: '',
},
apprise: {
urls: '',
preserve_identity: true,
include_path: true,
},
sqs: {
queue_url: '',
region_name: '',
endpoint_url: '',
access_key_id: '',
secret_access_key: '',
session_token: '',
},
};
const defaultScopes: Record<string, Record<string, unknown>> = {
mqtt_private: { messages: 'all', raw_packets: 'all' },
mqtt_community: { messages: 'none', raw_packets: 'all' },
bot: { messages: 'all', raw_packets: 'none' },
webhook: { messages: 'all', raw_packets: 'none' },
apprise: { messages: 'all', raw_packets: 'none' },
sqs: { messages: 'all', raw_packets: 'none' },
};
if (!isDraftType(type)) return;
const defaults = cloneDraftDefaults(type);
setAddMenuOpen(false);
setEditingId(null);
setDraftType(type);
setEditName(getDefaultIntegrationName(type, configs));
setEditConfig(defaults[type] || {});
setEditScope(defaultScopes[type] || {});
setEditName(
type === 'mqtt_community_meshrank' ||
type === 'mqtt_community_letsmesh_us' ||
type === 'mqtt_community_letsmesh_eu'
? DRAFT_RECIPES[type].defaultName
: getDefaultIntegrationName(DRAFT_RECIPES[type].savedType, configs)
);
setEditConfig(defaults.config);
setEditScope(defaults.scope);
};
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
const detailType = draftType ?? editingConfig?.type ?? null;
const isDraft = draftType !== null;
const configGroups = TYPE_OPTIONS.map((opt) => ({
const configGroups = LIST_TYPE_OPTIONS.map((opt) => ({
type: opt.value,
label: opt.label,
configs: configs
@@ -1377,9 +1713,7 @@ export function SettingsFanoutSection({
/>
</div>
<div className="text-xs text-muted-foreground">
Type: {TYPE_LABELS[detailType] || detailType}
</div>
<div className="text-xs text-muted-foreground">Type: {getDetailTypeLabel(detailType)}</div>
<Separator />
@@ -1396,6 +1730,26 @@ export function SettingsFanoutSection({
<MqttCommunityConfigEditor config={editConfig} onChange={setEditConfig} />
)}
{detailType === 'mqtt_community_meshrank' && (
<MeshRankConfigEditor config={editConfig} onChange={setEditConfig} />
)}
{detailType === 'mqtt_community_letsmesh_us' && (
<LetsMeshConfigEditor
config={editConfig}
onChange={setEditConfig}
brokerHost={DEFAULT_COMMUNITY_BROKER_HOST}
/>
)}
{detailType === 'mqtt_community_letsmesh_eu' && (
<LetsMeshConfigEditor
config={editConfig}
onChange={setEditConfig}
brokerHost={DEFAULT_COMMUNITY_BROKER_HOST_EU}
/>
)}
{detailType === 'bot' && <BotConfigEditor config={editConfig} onChange={setEditConfig} />}
{detailType === 'apprise' && (
@@ -1481,9 +1835,9 @@ export function SettingsFanoutSection({
{addMenuOpen && (
<div
role="menu"
className="absolute left-0 top-full z-10 mt-2 min-w-56 rounded-md border border-input bg-background p-1 shadow-md"
className="absolute left-0 top-full z-10 mt-2 min-w-72 rounded-md border border-input bg-background p-1 shadow-md"
>
{TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
{CREATE_TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
(opt) => (
<button
key={opt.value}

View File

@@ -11,6 +11,7 @@ import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -45,7 +46,7 @@ export function SettingsRadioSection({
onReboot: () => Promise<void>;
onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>;
onAdvertise: (mode: RadioAdvertMode) => Promise<void>;
meshDiscovery: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
@@ -82,7 +83,7 @@ export function SettingsRadioSection({
const [floodError, setFloodError] = useState<string | null>(null);
// Advertise state
const [advertising, setAdvertising] = useState(false);
const [advertisingMode, setAdvertisingMode] = useState<RadioAdvertMode | null>(null);
const [discoverError, setDiscoverError] = useState<string | null>(null);
const [connectionBusy, setConnectionBusy] = useState(false);
@@ -295,12 +296,12 @@ export function SettingsRadioSection({
}
};
const handleAdvertise = async () => {
setAdvertising(true);
const handleAdvertise = async (mode: RadioAdvertMode) => {
setAdvertisingMode(mode);
try {
await onAdvertise();
await onAdvertise(mode);
} finally {
setAdvertising(false);
setAdvertisingMode(null);
}
};
@@ -333,6 +334,35 @@ export function SettingsRadioSection({
? `Connection paused${health?.connection_info ? ` (${health.connection_info})` : ''}`
: 'Not connected';
const deviceInfoLabel = useMemo(() => {
const info = health?.radio_device_info;
if (!info) {
return null;
}
const model = info.model?.trim() || null;
const firmwareParts = [info.firmware_build?.trim(), info.firmware_version?.trim()].filter(
(value): value is string => Boolean(value)
);
const capacityParts = [
typeof info.max_contacts === 'number' ? `${info.max_contacts} contacts` : null,
typeof info.max_channels === 'number' ? `${info.max_channels} channels` : null,
].filter((value): value is string => value !== null);
if (!model && firmwareParts.length === 0 && capacityParts.length === 0) {
return null;
}
let label = model ?? 'Radio';
if (firmwareParts.length > 0) {
label += ` running ${firmwareParts.join('/')}`;
}
if (capacityParts.length > 0) {
label += ` (max: ${capacityParts.join(', ')})`;
}
return label;
}, [health?.radio_device_info]);
const handleConnectionAction = async () => {
setConnectionBusy(true);
try {
@@ -377,6 +407,7 @@ export function SettingsRadioSection({
{connectionStatusLabel}
</span>
</div>
{deviceInfoLabel && <p className="text-sm text-muted-foreground">{deviceInfoLabel}</p>}
<Button
type="button"
variant="outline"
@@ -712,15 +743,25 @@ export function SettingsRadioSection({
<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.
Flood adverts propagate through repeaters. Zero-hop adverts are local-only and use less
airtime.
</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>
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button
onClick={() => handleAdvertise('flood')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full bg-warning hover:bg-warning/90 text-warning-foreground"
>
{advertisingMode === 'flood' ? 'Sending...' : 'Send Flood Advertisement'}
</Button>
<Button
onClick={() => handleAdvertise('zero_hop')}
disabled={advertisingMode !== null || !health?.radio_connected}
className="w-full"
>
{advertisingMode === 'zero_hop' ? 'Sending...' : 'Send Zero-Hop Advertisement'}
</Button>
</div>
{!health?.radio_connected && (
<p className="text-sm text-destructive">Radio not connected</p>
)}

View File

@@ -1,5 +1,5 @@
export { useUnreadCounts } from './useUnreadCounts';
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
export { useConversationMessages } from './useConversationMessages';
export { useRadioControl } from './useRadioControl';
export { useRepeaterDashboard } from './useRepeaterDashboard';
export { useAppShell } from './useAppShell';

View File

@@ -43,25 +43,6 @@ export function useAppSettings() {
[fetchAppSettings]
);
const handleSortOrderChange = useCallback(
async (order: 'recent' | 'alpha') => {
const previousOrder = appSettings?.sidebar_sort_order ?? 'recent';
// Optimistic update for responsive UI
setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: order } : prev));
try {
const updatedSettings = await api.updateSettings({ sidebar_sort_order: order });
setAppSettings(updatedSettings);
} catch (err) {
console.error('Failed to update sort order:', err);
setAppSettings((prev) => (prev ? { ...prev, sidebar_sort_order: previousOrder } : prev));
toast.error('Failed to save sort preference');
}
},
[appSettings?.sidebar_sort_order]
);
const handleToggleBlockedKey = useCallback(async (key: string) => {
const normalizedKey = key.toLowerCase();
setAppSettings((prev) => {
@@ -198,7 +179,6 @@ export function useAppSettings() {
favorites,
fetchAppSettings,
handleSaveAppSettings,
handleSortOrderChange,
handleToggleFavorite,
handleToggleBlockedKey,
handleToggleBlockedName,

View File

@@ -1,7 +1,8 @@
import { startTransition, useCallback, useState } from 'react';
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
import type { SettingsSection } from '../components/settings/settingsConstants';
import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash';
interface UseAppShellResult {
showNewMessage: boolean;
@@ -23,25 +24,47 @@ interface UseAppShellResult {
}
export function useAppShell(): UseAppShellResult {
const initialSettingsSection = typeof window === 'undefined' ? null : parseHashSettingsSection();
const [showNewMessage, setShowNewMessage] = useState(false);
const [showSettings, setShowSettings] = useState(false);
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio');
const [showSettings, setShowSettings] = useState(() => initialSettingsSection !== null);
const [settingsSection, setSettingsSection] = useState<SettingsSection>(
() => initialSettingsSection ?? 'radio'
);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [showCracker, setShowCracker] = useState(false);
const [crackerRunning, setCrackerRunning] = useState(false);
const [localLabel, setLocalLabel] = useState(getLocalLabel);
const previousHashRef = useRef('');
useEffect(() => {
if (showSettings) {
updateSettingsHash(settingsSection);
}
}, [settingsSection, showSettings]);
const handleCloseSettingsView = useCallback(() => {
if (typeof window !== 'undefined' && parseHashSettingsSection() !== null) {
window.history.replaceState(null, '', previousHashRef.current || window.location.pathname);
}
startTransition(() => setShowSettings(false));
setSidebarOpen(false);
}, []);
const handleToggleSettingsView = useCallback(() => {
if (showSettings) {
handleCloseSettingsView();
return;
}
if (typeof window !== 'undefined') {
previousHashRef.current =
parseHashSettingsSection() === null ? window.location.hash : previousHashRef.current;
}
startTransition(() => {
setShowSettings((prev) => !prev);
setShowSettings(true);
});
setSidebarOpen(false);
}, []);
}, [handleCloseSettingsView, showSettings]);
const handleOpenNewMessage = useCallback(() => {
setShowNewMessage(true);

View File

@@ -2,22 +2,22 @@ import { useState, useCallback, type MutableRefObject } from 'react';
import { api } from '../api';
import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import * as messageCache from '../messageCache';
import { getContactDisplayName } from '../utils/pubkey';
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
import type { Channel, Contact, Conversation } from '../types';
const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72';
interface UseContactsAndChannelsArgs {
setActiveConversation: (conv: Conversation | null) => void;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
hasSetDefaultConversation: MutableRefObject<boolean>;
removeConversationMessages: (conversationId: string) => void;
}
export function useContactsAndChannels({
setActiveConversation,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
removeConversationMessages,
}: UseContactsAndChannelsArgs) {
const [contacts, setContacts] = useState<Contact[]>([]);
const [contactsLoaded, setContactsLoaded] = useState(false);
@@ -118,17 +118,15 @@ export function useContactsAndChannels({
try {
pendingDeleteFallbackRef.current = true;
await api.deleteChannel(key);
messageCache.remove(key);
removeConversationMessages(key);
const refreshedChannels = await api.getChannels();
setChannels(refreshedChannels);
const publicChannel =
refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) ||
refreshedChannels.find((c) => c.name === 'Public');
const publicChannel = findPublicChannel(refreshedChannels);
hasSetDefaultConversation.current = true;
setActiveConversation({
type: 'channel',
id: publicChannel?.key || PUBLIC_CHANNEL_KEY,
name: publicChannel?.name || 'Public',
name: publicChannel?.name || PUBLIC_CHANNEL_NAME,
});
toast.success('Channel deleted');
} catch (err) {
@@ -138,7 +136,12 @@ export function useContactsAndChannels({
});
}
},
[setActiveConversation, pendingDeleteFallbackRef, hasSetDefaultConversation]
[
hasSetDefaultConversation,
pendingDeleteFallbackRef,
removeConversationMessages,
setActiveConversation,
]
);
const handleDeleteContact = useCallback(
@@ -147,18 +150,16 @@ export function useContactsAndChannels({
try {
pendingDeleteFallbackRef.current = true;
await api.deleteContact(publicKey);
messageCache.remove(publicKey);
removeConversationMessages(publicKey);
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
const refreshedChannels = await api.getChannels();
setChannels(refreshedChannels);
const publicChannel =
refreshedChannels.find((c) => c.key === PUBLIC_CHANNEL_KEY) ||
refreshedChannels.find((c) => c.name === 'Public');
const publicChannel = findPublicChannel(refreshedChannels);
hasSetDefaultConversation.current = true;
setActiveConversation({
type: 'channel',
id: publicChannel?.key || PUBLIC_CHANNEL_KEY,
name: publicChannel?.name || 'Public',
name: publicChannel?.name || PUBLIC_CHANNEL_NAME,
});
toast.success('Contact deleted');
} catch (err) {
@@ -168,7 +169,12 @@ export function useContactsAndChannels({
});
}
},
[setActiveConversation, pendingDeleteFallbackRef, hasSetDefaultConversation]
[
hasSetDefaultConversation,
pendingDeleteFallbackRef,
removeConversationMessages,
setActiveConversation,
]
);
return {

View File

@@ -10,7 +10,7 @@ interface UseConversationActionsArgs {
activeConversationRef: MutableRefObject<Conversation | null>;
setContacts: React.Dispatch<React.SetStateAction<Contact[]>>;
setChannels: React.Dispatch<React.SetStateAction<Channel[]>>;
addMessageIfNew: (msg: Message) => boolean;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
messageInputRef: RefObject<MessageInputHandle | null>;
}
@@ -31,7 +31,7 @@ export function useConversationActions({
activeConversationRef,
setContacts,
setChannels,
addMessageIfNew,
observeMessage,
messageInputRef,
}: UseConversationActionsArgs): UseConversationActionsResult {
const mergeChannelIntoList = useCallback(
@@ -60,10 +60,10 @@ export function useConversationActions({
: await api.sendDirectMessage(activeConversation.id, text);
if (activeConversationRef.current?.id === conversationId) {
addMessageIfNew(sent);
observeMessage(sent);
}
},
[activeConversation, activeConversationRef, addMessageIfNew]
[activeConversation, activeConversationRef, observeMessage]
);
const handleResendChannelMessage = useCallback(
@@ -77,7 +77,7 @@ export function useConversationActions({
activeConversationRef.current?.type === 'channel' &&
activeConversationRef.current.id === resentMessage.conversation_key
) {
addMessageIfNew(resentMessage);
observeMessage(resentMessage);
}
toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent');
} catch (err) {
@@ -86,7 +86,7 @@ export function useConversationActions({
});
}
},
[activeConversationRef, addMessageIfNew]
[activeConversationRef, observeMessage]
);
const handleSetChannelFloodScopeOverride = useCallback(

View File

@@ -1,19 +1,174 @@
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction,
} from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from '../components/ui/sonner';
import { api, isAbortError } from '../api';
import * as messageCache from '../messageCache';
import type { Conversation, Message, MessagePath } from '../types';
import { getMessageContentKey } from '../utils/messageIdentity';
const MAX_PENDING_ACKS = 500;
const MESSAGE_PAGE_SIZE = 200;
export const MAX_CACHED_CONVERSATIONS = 20;
export const MAX_MESSAGES_PER_ENTRY = 200;
interface CachedConversationEntry {
messages: Message[];
hasOlderMessages: boolean;
}
interface InternalCachedConversationEntry extends CachedConversationEntry {
contentKeys: Set<string>;
}
export class ConversationMessageCache {
private readonly cache = new Map<string, InternalCachedConversationEntry>();
get(id: string): CachedConversationEntry | undefined {
const entry = this.cache.get(id);
if (!entry) return undefined;
this.cache.delete(id);
this.cache.set(id, entry);
return {
messages: entry.messages,
hasOlderMessages: entry.hasOlderMessages,
};
}
set(id: string, entry: CachedConversationEntry): void {
const contentKeys = new Set(entry.messages.map((message) => getMessageContentKey(message)));
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
const trimmed = [...entry.messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
entry = { ...entry, messages: trimmed, hasOlderMessages: true };
}
const internalEntry: InternalCachedConversationEntry = {
...entry,
contentKeys,
};
this.cache.delete(id);
this.cache.set(id, internalEntry);
if (this.cache.size > MAX_CACHED_CONVERSATIONS) {
const lruKey = this.cache.keys().next().value as string;
this.cache.delete(lruKey);
}
}
addMessage(id: string, msg: Message): boolean {
const entry = this.cache.get(id);
const contentKey = getMessageContentKey(msg);
if (!entry) {
this.cache.set(id, {
messages: [msg],
hasOlderMessages: true,
contentKeys: new Set([contentKey]),
});
if (this.cache.size > MAX_CACHED_CONVERSATIONS) {
const lruKey = this.cache.keys().next().value as string;
this.cache.delete(lruKey);
}
return true;
}
if (entry.contentKeys.has(contentKey)) return false;
if (entry.messages.some((message) => message.id === msg.id)) return false;
entry.contentKeys.add(contentKey);
entry.messages = [...entry.messages, msg];
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
entry.messages = [...entry.messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
}
this.cache.delete(id);
this.cache.set(id, entry);
return true;
}
updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void {
for (const entry of this.cache.values()) {
const index = entry.messages.findIndex((message) => message.id === messageId);
if (index < 0) continue;
const current = entry.messages[index];
const updated = [...entry.messages];
updated[index] = {
...current,
acked: Math.max(current.acked, ackCount),
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
};
entry.messages = updated;
return;
}
}
remove(id: string): void {
this.cache.delete(id);
}
rename(oldId: string, newId: string): void {
if (oldId === newId) return;
const oldEntry = this.cache.get(oldId);
if (!oldEntry) return;
const newEntry = this.cache.get(newId);
if (!newEntry) {
this.cache.delete(oldId);
this.cache.set(newId, oldEntry);
return;
}
const mergedMessages = [...newEntry.messages];
const seenIds = new Set(mergedMessages.map((message) => message.id));
for (const message of oldEntry.messages) {
if (!seenIds.has(message.id)) {
mergedMessages.push(message);
seenIds.add(message.id);
}
}
this.cache.delete(oldId);
this.cache.set(newId, {
messages: mergedMessages,
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
contentKeys: new Set([...newEntry.contentKeys, ...oldEntry.contentKeys]),
});
}
clear(): void {
this.cache.clear();
}
}
export function reconcileConversationMessages(
current: Message[],
fetched: Message[]
): Message[] | null {
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>();
for (const message of current) {
currentById.set(message.id, {
acked: message.acked,
pathsLen: message.paths?.length ?? 0,
text: message.text,
});
}
let needsUpdate = false;
for (const message of fetched) {
const currentMessage = currentById.get(message.id);
if (
!currentMessage ||
currentMessage.acked !== message.acked ||
currentMessage.pathsLen !== (message.paths?.length ?? 0) ||
currentMessage.text !== message.text
) {
needsUpdate = true;
break;
}
}
if (!needsUpdate) return null;
const fetchedIds = new Set(fetched.map((message) => message.id));
const olderMessages = current.filter((message) => !fetchedIds.has(message.id));
return [...fetched, ...olderMessages];
}
export const conversationMessageCache = new ConversationMessageCache();
interface PendingAckUpdate {
ackCount: number;
@@ -56,15 +211,6 @@ export function mergePendingAck(
return existing;
}
// Generate a key for deduplicating messages by content
export function getMessageContentKey(msg: Message): string {
// When sender_timestamp exists, dedup by content (catches radio-path duplicates with different IDs).
// When null, include msg.id so each message gets a unique key — avoids silently dropping
// different messages that share the same text and received_at second.
const ts = msg.sender_timestamp ?? `r${msg.received_at}-${msg.id}`;
return `${msg.type}-${msg.conversation_key}-${msg.text}-${ts}`;
}
interface UseConversationMessagesResult {
messages: Message[];
messagesLoading: boolean;
@@ -72,21 +218,36 @@ interface UseConversationMessagesResult {
hasOlderMessages: boolean;
hasNewerMessages: boolean;
loadingNewer: boolean;
hasNewerMessagesRef: MutableRefObject<boolean>;
setMessages: Dispatch<SetStateAction<Message[]>>;
fetchOlderMessages: () => Promise<void>;
fetchNewerMessages: () => Promise<void>;
jumpToBottom: () => void;
reloadCurrentConversation: () => void;
addMessageIfNew: (msg: Message) => boolean;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
triggerReconcile: () => void;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
reconcileOnReconnect: () => void;
renameConversationMessages: (oldId: string, newId: string) => void;
removeConversationMessages: (conversationId: string) => void;
clearConversationMessages: () => void;
}
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
}
function isActiveConversationMessage(
activeConversation: Conversation | null,
msg: Message
): boolean {
if (!activeConversation) return false;
if (msg.type === 'CHAN' && activeConversation.type === 'channel') {
return msg.conversation_key === activeConversation.id;
}
if (msg.type === 'PRIV' && activeConversation.type === 'contact') {
return msg.conversation_key === activeConversation.id;
}
return false;
}
function appendUniqueMessages(current: Message[], incoming: Message[]): Message[] {
if (incoming.length === 0) return current;
@@ -161,10 +322,14 @@ export function useConversationMessages(
const [loadingNewer, setLoadingNewer] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const olderAbortControllerRef = useRef<AbortController | null>(null);
const newerAbortControllerRef = useRef<AbortController | null>(null);
const fetchingConversationIdRef = useRef<string | null>(null);
const latestReconcileRequestIdRef = useRef(0);
const pendingReconnectReconcileRef = useRef(false);
const messagesRef = useRef<Message[]>([]);
const loadingOlderRef = useRef(false);
const loadingNewerRef = useRef(false);
const hasOlderMessagesRef = useRef(false);
const hasNewerMessagesRef = useRef(false);
const prevConversationIdRef = useRef<string | null>(null);
@@ -179,6 +344,10 @@ export function useConversationMessages(
loadingOlderRef.current = loadingOlder;
}, [loadingOlder]);
useEffect(() => {
loadingNewerRef.current = loadingNewer;
}, [loadingNewer]);
useEffect(() => {
hasOlderMessagesRef.current = hasOlderMessages;
}, [hasOlderMessages]);
@@ -206,6 +375,7 @@ export function useConversationMessages(
}
const conversationId = activeConversation.id;
pendingReconnectReconcileRef.current = false;
if (showLoading) {
setMessagesLoading(true);
@@ -227,7 +397,7 @@ export function useConversationMessages(
}
const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg));
const merged = messageCache.reconcile(messagesRef.current, messagesWithPendingAck);
const merged = reconcileConversationMessages(messagesRef.current, messagesWithPendingAck);
const nextMessages = merged ?? messagesRef.current;
if (merged) {
setMessages(merged);
@@ -269,7 +439,7 @@ export function useConversationMessages(
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck);
const merged = reconcileConversationMessages(messagesRef.current, dataWithPendingAck);
if (!merged) return;
setMessages(merged);
@@ -293,7 +463,7 @@ export function useConversationMessages(
}
const conversationId = activeConversation.id;
const oldestMessage = messages.reduce(
const oldestMessage = messagesRef.current.reduce(
(oldest, msg) => {
if (!oldest) return msg;
if (msg.received_at < oldest.received_at) return msg;
@@ -306,14 +476,19 @@ export function useConversationMessages(
loadingOlderRef.current = true;
setLoadingOlder(true);
const controller = new AbortController();
olderAbortControllerRef.current = controller;
try {
const data = await api.getMessages({
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: conversationId,
limit: MESSAGE_PAGE_SIZE,
before: oldestMessage.received_at,
before_id: oldestMessage.id,
});
const data = await api.getMessages(
{
type: activeConversation.type === 'channel' ? 'CHAN' : 'PRIV',
conversation_key: conversationId,
limit: MESSAGE_PAGE_SIZE,
before: oldestMessage.received_at,
before_id: oldestMessage.id,
},
controller.signal
);
if (fetchingConversationIdRef.current !== conversationId) return;
@@ -335,21 +510,33 @@ export function useConversationMessages(
}
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
if (isAbortError(err)) {
return;
}
console.error('Failed to fetch older messages:', err);
toast.error('Failed to load older messages', {
description: err instanceof Error ? err.message : 'Check your connection',
});
} finally {
if (olderAbortControllerRef.current === controller) {
olderAbortControllerRef.current = null;
}
loadingOlderRef.current = false;
setLoadingOlder(false);
}
}, [activeConversation, applyPendingAck, messages, syncSeenContent]);
}, [activeConversation, applyPendingAck, syncSeenContent]);
const fetchNewerMessages = useCallback(async () => {
if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return;
if (
!isMessageConversation(activeConversation) ||
loadingNewerRef.current ||
!hasNewerMessagesRef.current
) {
return;
}
const conversationId = activeConversation.id;
const newestMessage = messages.reduce(
const newestMessage = messagesRef.current.reduce(
(newest, msg) => {
if (!newest) return msg;
if (msg.received_at > newest.received_at) return msg;
@@ -360,15 +547,21 @@ export function useConversationMessages(
);
if (!newestMessage) return;
loadingNewerRef.current = true;
setLoadingNewer(true);
const controller = new AbortController();
newerAbortControllerRef.current = controller;
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,
});
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,
},
controller.signal
);
if (fetchingConversationIdRef.current !== conversationId) return;
@@ -383,33 +576,57 @@ export function useConversationMessages(
seenMessageContent.current.add(getMessageContentKey(msg));
}
}
setHasNewerMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
const stillHasNewerMessages = dataWithPendingAck.length >= MESSAGE_PAGE_SIZE;
setHasNewerMessages(stillHasNewerMessages);
if (!stillHasNewerMessages && pendingReconnectReconcileRef.current) {
pendingReconnectReconcileRef.current = false;
const requestId = latestReconcileRequestIdRef.current + 1;
latestReconcileRequestIdRef.current = requestId;
const reconcileController = new AbortController();
reconcileFromBackend(activeConversation, reconcileController.signal, requestId);
}
} catch (err) {
if (isAbortError(err)) {
return;
}
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 {
if (newerAbortControllerRef.current === controller) {
newerAbortControllerRef.current = null;
}
loadingNewerRef.current = false;
setLoadingNewer(false);
}
}, [activeConversation, applyPendingAck, hasNewerMessages, loadingNewer, messages]);
}, [activeConversation, applyPendingAck, reconcileFromBackend]);
const jumpToBottom = useCallback(() => {
if (!activeConversation) return;
setHasNewerMessages(false);
messageCache.remove(activeConversation.id);
conversationMessageCache.remove(activeConversation.id);
void fetchLatestMessages(true);
}, [activeConversation, fetchLatestMessages]);
const reloadCurrentConversation = useCallback(() => {
if (!isMessageConversation(activeConversation)) return;
setHasNewerMessages(false);
messageCache.remove(activeConversation.id);
conversationMessageCache.remove(activeConversation.id);
setReloadVersion((current) => current + 1);
}, [activeConversation]);
const triggerReconcile = useCallback(() => {
if (!isMessageConversation(activeConversation)) return;
const reconcileOnReconnect = useCallback(() => {
if (!isMessageConversation(activeConversation)) {
return;
}
if (hasNewerMessagesRef.current) {
pendingReconnectReconcileRef.current = true;
return;
}
pendingReconnectReconcileRef.current = false;
const controller = new AbortController();
const requestId = latestReconcileRequestIdRef.current + 1;
latestReconcileRequestIdRef.current = requestId;
@@ -420,6 +637,14 @@ export function useConversationMessages(
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
if (olderAbortControllerRef.current) {
olderAbortControllerRef.current.abort();
olderAbortControllerRef.current = null;
}
if (newerAbortControllerRef.current) {
newerAbortControllerRef.current.abort();
newerAbortControllerRef.current = null;
}
const prevId = prevConversationIdRef.current;
const newId = activeConversation?.id ?? null;
@@ -429,6 +654,7 @@ export function useConversationMessages(
prevConversationIdRef.current = newId;
prevReloadVersionRef.current = reloadVersion;
latestReconcileRequestIdRef.current = 0;
pendingReconnectReconcileRef.current = false;
// Preserve around-loaded context on the same conversation when search clears targetMessageId.
if (!conversationChanged && !targetMessageId && !reloadRequested) {
@@ -448,9 +674,8 @@ export function useConversationMessages(
messagesRef.current.length > 0 &&
!hasNewerMessagesRef.current
) {
messageCache.set(prevId, {
conversationMessageCache.set(prevId, {
messages: messagesRef.current,
seenContent: new Set(seenMessageContent.current),
hasOlderMessages: hasOlderMessagesRef.current,
});
}
@@ -492,10 +717,12 @@ export function useConversationMessages(
setMessagesLoading(false);
});
} else {
const cached = messageCache.get(activeConversation.id);
const cached = conversationMessageCache.get(activeConversation.id);
if (cached) {
setMessages(cached.messages);
seenMessageContent.current = new Set(cached.seenContent);
seenMessageContent.current = new Set(
cached.messages.map((message) => getMessageContentKey(message))
);
setHasOlderMessages(cached.hasOlderMessages);
setMessagesLoading(false);
const requestId = latestReconcileRequestIdRef.current + 1;
@@ -512,9 +739,8 @@ export function useConversationMessages(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeConversation?.id, activeConversation?.type, targetMessageId, reloadVersion]);
// Add a message if it's new (deduplication)
// Returns true if the message was added, false if it was a duplicate
const addMessageIfNew = useCallback(
// Add a message to the active conversation if it is new.
const appendActiveMessageIfNew = useCallback(
(msg: Message): boolean => {
const msgWithPendingAck = applyPendingAck(msg);
const contentKey = getMessageContentKey(msgWithPendingAck);
@@ -584,6 +810,56 @@ export function useConversationMessages(
[messagesRef, setMessages, setPendingAck]
);
const receiveMessageAck = useCallback(
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
updateMessageAck(messageId, ackCount, paths);
conversationMessageCache.updateAck(messageId, ackCount, paths);
},
[updateMessageAck]
);
const observeMessage = useCallback(
(msg: Message): { added: boolean; activeConversation: boolean } => {
const msgWithPendingAck = applyPendingAck(msg);
const activeConversationMessage = isActiveConversationMessage(
activeConversation,
msgWithPendingAck
);
if (activeConversationMessage) {
if (hasNewerMessagesRef.current) {
return { added: false, activeConversation: true };
}
return {
added: appendActiveMessageIfNew(msgWithPendingAck),
activeConversation: true,
};
}
return {
added: conversationMessageCache.addMessage(
msgWithPendingAck.conversation_key,
msgWithPendingAck
),
activeConversation: false,
};
},
[activeConversation, appendActiveMessageIfNew, applyPendingAck, hasNewerMessagesRef]
);
const renameConversationMessages = useCallback((oldId: string, newId: string) => {
conversationMessageCache.rename(oldId, newId);
}, []);
const removeConversationMessages = useCallback((conversationId: string) => {
conversationMessageCache.remove(conversationId);
}, []);
const clearConversationMessages = useCallback(() => {
conversationMessageCache.clear();
}, []);
return {
messages,
messagesLoading,
@@ -591,14 +867,15 @@ export function useConversationMessages(
hasOlderMessages,
hasNewerMessages,
loadingNewer,
hasNewerMessagesRef,
setMessages,
fetchOlderMessages,
fetchNewerMessages,
jumpToBottom,
reloadCurrentConversation,
addMessageIfNew,
updateMessageAck,
triggerReconcile,
observeMessage,
receiveMessageAck,
reconcileOnReconnect,
renameConversationMessages,
removeConversationMessages,
clearConversationMessages,
};
}

View File

@@ -1,6 +1,7 @@
import { useState, useCallback, useEffect, useRef, type MutableRefObject } from 'react';
import {
parseHashConversation,
parseHashSettingsSection,
updateUrlHash,
resolveChannelFromHashToken,
resolveContactFromHashToken,
@@ -10,15 +11,15 @@ import {
getReopenLastConversationEnabled,
saveLastViewedConversation,
} from '../utils/lastViewedConversation';
import { findPublicChannel } from '../utils/publicChannel';
import { getContactDisplayName } from '../utils/pubkey';
import type { Channel, Contact, Conversation } from '../types';
const PUBLIC_CHANNEL_KEY = '8B3387E9C5CDEA6AC9E5EDBAA115CD72';
interface UseConversationRouterArgs {
channels: Channel[];
contacts: Contact[];
contactsLoaded: boolean;
suspendHashSync: boolean;
setSidebarOpen: (open: boolean) => void;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
hasSetDefaultConversation: MutableRefObject<boolean>;
@@ -28,6 +29,7 @@ export function useConversationRouter({
channels,
contacts,
contactsLoaded,
suspendHashSync,
setSidebarOpen,
pendingDeleteFallbackRef,
hasSetDefaultConversation,
@@ -35,7 +37,9 @@ export function useConversationRouter({
const [activeConversation, setActiveConversationState] = useState<Conversation | null>(null);
const activeConversationRef = useRef<Conversation | null>(null);
const hashSyncEnabledRef = useRef(
typeof window !== 'undefined' ? window.location.hash.length > 0 : false
typeof window !== 'undefined'
? window.location.hash.length > 0 && parseHashSettingsSection() === null
: false
);
const setActiveConversation = useCallback((conv: Conversation | null) => {
@@ -44,7 +48,7 @@ export function useConversationRouter({
}, []);
const getPublicChannelConversation = useCallback((): Conversation | null => {
const publicChannel = channels.find((c) => c.name === 'Public');
const publicChannel = findPublicChannel(channels);
if (!publicChannel) return null;
return {
type: 'channel',
@@ -59,7 +63,7 @@ export function useConversationRouter({
if (hasSetDefaultConversation.current || activeConversation) return;
if (channels.length === 0) return;
const hashConv = parseHashConversation();
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
// Handle non-data views immediately
if (hashConv?.type === 'raw') {
@@ -142,7 +146,7 @@ export function useConversationRouter({
useEffect(() => {
if (hasSetDefaultConversation.current || activeConversation) return;
const hashConv = parseHashConversation();
const hashConv = parseHashSettingsSection() ? null : parseHashConversation();
if (hashConv?.type === 'contact') {
if (!contactsLoaded) return;
@@ -204,14 +208,14 @@ export function useConversationRouter({
useEffect(() => {
activeConversationRef.current = activeConversation;
if (activeConversation) {
if (hashSyncEnabledRef.current) {
if (hashSyncEnabledRef.current && !suspendHashSync) {
updateUrlHash(activeConversation);
}
if (getReopenLastConversationEnabled() && activeConversation.type !== 'search') {
if (activeConversation.type !== 'search') {
saveLastViewedConversation(activeConversation);
}
}
}, [activeConversation]);
}, [activeConversation, suspendHashSync]);
// If a delete action left us without an active conversation, recover to Public
useEffect(() => {
@@ -221,9 +225,7 @@ export function useConversationRouter({
return;
}
const publicChannel =
channels.find((c) => c.key === PUBLIC_CHANNEL_KEY) ||
channels.find((c) => c.name === 'Public');
const publicChannel = findPublicChannel(channels);
if (!publicChannel) return;
hasSetDefaultConversation.current = true;

View File

@@ -4,6 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import type {
HealthStatus,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -93,13 +94,14 @@ export function useRadioControl() {
}
}, [fetchConfig]);
const handleAdvertise = useCallback(async () => {
const handleAdvertise = useCallback(async (mode: RadioAdvertMode = 'flood') => {
try {
await api.sendAdvertisement();
toast.success('Advertisement sent');
await api.sendAdvertisement(mode);
toast.success(mode === 'zero_hop' ? 'Zero-hop advertisement sent' : 'Advertisement sent');
} catch (err) {
console.error('Failed to send advertisement:', err);
toast.error('Failed to send advertisement', {
const label = mode === 'zero_hop' ? 'zero-hop advertisement' : 'advertisement';
console.error(`Failed to send ${label}:`, err);
toast.error(`Failed to send ${label}`, {
description: err instanceof Error ? err.message : 'Check radio connection',
});
}

View File

@@ -6,14 +6,12 @@ import {
type SetStateAction,
} from 'react';
import { api } from '../api';
import * as messageCache from '../messageCache';
import type { UseWebSocketOptions } from '../useWebSocket';
import { toast } from '../components/ui/sonner';
import { getStateKey } from '../utils/conversationState';
import { mergeContactIntoList } from '../utils/contactMerge';
import { getContactDisplayName } from '../utils/pubkey';
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
import { getMessageContentKey } from './useConversationMessages';
import type {
Channel,
Contact,
@@ -29,7 +27,7 @@ interface UseRealtimeAppStateArgs {
setHealth: Dispatch<SetStateAction<HealthStatus | null>>;
fetchConfig: () => void | Promise<void>;
setRawPackets: Dispatch<SetStateAction<RawPacket[]>>;
triggerReconcile: () => void;
reconcileOnReconnect: () => void;
refreshUnreads: () => Promise<void>;
setChannels: Dispatch<SetStateAction<Channel[]>>;
fetchAllContacts: () => Promise<Contact[]>;
@@ -37,15 +35,20 @@ interface UseRealtimeAppStateArgs {
blockedKeysRef: MutableRefObject<string[]>;
blockedNamesRef: MutableRefObject<string[]>;
activeConversationRef: MutableRefObject<Conversation | null>;
hasNewerMessagesRef: MutableRefObject<boolean>;
addMessageIfNew: (msg: Message) => boolean;
trackNewMessage: (msg: Message) => void;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
recordMessageEvent: (args: {
msg: Message;
activeConversation: boolean;
isNewMessage: boolean;
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
checkMention: (text: string) => boolean;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
setActiveConversation: (conv: Conversation | null) => void;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
renameConversationMessages: (oldId: string, newId: string) => void;
removeConversationMessages: (conversationId: string) => void;
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
notifyIncomingMessage?: (msg: Message) => void;
maxRawPackets?: number;
}
@@ -71,26 +74,12 @@ function isMessageBlocked(msg: Message, blockedKeys: string[], blockedNames: str
return blockedNames.length > 0 && !!msg.sender_name && blockedNames.includes(msg.sender_name);
}
function isActiveConversationMessage(
activeConversation: Conversation | null,
msg: Message
): boolean {
if (!activeConversation) return false;
if (msg.type === 'CHAN' && activeConversation.type === 'channel') {
return msg.conversation_key === activeConversation.id;
}
if (msg.type === 'PRIV' && activeConversation.type === 'contact') {
return msg.conversation_key === activeConversation.id;
}
return false;
}
export function useRealtimeAppState({
prevHealthRef,
setHealth,
fetchConfig,
setRawPackets,
triggerReconcile,
reconcileOnReconnect,
refreshUnreads,
setChannels,
fetchAllContacts,
@@ -98,15 +87,15 @@ export function useRealtimeAppState({
blockedKeysRef,
blockedNamesRef,
activeConversationRef,
hasNewerMessagesRef,
addMessageIfNew,
trackNewMessage,
incrementUnread,
observeMessage,
recordMessageEvent,
renameConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
renameConversationMessages,
removeConversationMessages,
receiveMessageAck,
notifyIncomingMessage,
maxRawPackets = 500,
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
@@ -180,7 +169,7 @@ export function useRealtimeAppState({
},
onReconnect: () => {
setRawPackets([]);
triggerReconcile();
reconcileOnReconnect();
refreshUnreads();
api.getChannels().then(setChannels).catch(console.error);
fetchAllContacts()
@@ -192,34 +181,14 @@ export function useRealtimeAppState({
return;
}
const isForActiveConversation = isActiveConversationMessage(
activeConversationRef.current,
msg
);
let isNewMessage = false;
if (isForActiveConversation && !hasNewerMessagesRef.current) {
isNewMessage = addMessageIfNew(msg);
}
trackNewMessage(msg);
const contentKey = getMessageContentKey(msg);
if (!isForActiveConversation) {
isNewMessage = messageCache.addMessage(msg.conversation_key, msg, contentKey);
if (!msg.outgoing && isNewMessage) {
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
stateKey = getStateKey('contact', msg.conversation_key);
}
if (stateKey) {
incrementUnread(stateKey, checkMention(msg.text));
}
}
}
const { added: isNewMessage, activeConversation: isForActiveConversation } =
observeMessage(msg);
recordMessageEvent({
msg,
activeConversation: isForActiveConversation,
isNewMessage,
hasMention: checkMention(msg.text),
});
if (!msg.outgoing && isNewMessage) {
notifyIncomingMessage?.(msg);
@@ -235,7 +204,7 @@ export function useRealtimeAppState({
contact
)
);
messageCache.rename(previousPublicKey, contact.public_key);
renameConversationMessages(previousPublicKey, contact.public_key);
renameConversationState(
getStateKey('contact', previousPublicKey),
getStateKey('contact', contact.public_key)
@@ -255,7 +224,7 @@ export function useRealtimeAppState({
},
onContactDeleted: (publicKey: string) => {
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
messageCache.remove(publicKey);
removeConversationMessages(publicKey);
const active = activeConversationRef.current;
if (active?.type === 'contact' && active.id === publicKey) {
pendingDeleteFallbackRef.current = true;
@@ -264,7 +233,7 @@ export function useRealtimeAppState({
},
onChannelDeleted: (key: string) => {
setChannels((prev) => prev.filter((c) => c.key !== key));
messageCache.remove(key);
removeConversationMessages(key);
const active = activeConversationRef.current;
if (active?.type === 'channel' && active.id === key) {
pendingDeleteFallbackRef.current = true;
@@ -275,34 +244,33 @@ export function useRealtimeAppState({
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
},
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
updateMessageAck(messageId, ackCount, paths);
messageCache.updateAck(messageId, ackCount, paths);
receiveMessageAck(messageId, ackCount, paths);
},
}),
[
activeConversationRef,
addMessageIfNew,
blockedKeysRef,
blockedNamesRef,
checkMention,
fetchAllContacts,
fetchConfig,
hasNewerMessagesRef,
incrementUnread,
renameConversationState,
renameConversationMessages,
maxRawPackets,
mergeChannelIntoList,
pendingDeleteFallbackRef,
prevHealthRef,
recordMessageEvent,
receiveMessageAck,
observeMessage,
refreshUnreads,
reconcileOnReconnect,
removeConversationMessages,
setActiveConversation,
setChannels,
setContacts,
setHealth,
setRawPackets,
trackNewMessage,
triggerReconcile,
updateMessageAck,
notifyIncomingMessage,
]
);

View File

@@ -76,6 +76,17 @@ function createInitialPaneData(): PaneData {
const repeaterDashboardCache = new Map<string, RepeaterDashboardCacheEntry>();
function getLoginToastTitle(status: string): string {
switch (status) {
case 'timeout':
return 'Login confirmation not heard';
case 'error':
return 'Login not confirmed';
default:
return 'Repeater login not confirmed';
}
}
function clonePaneData(data: PaneData): PaneData {
return { ...data };
}
@@ -177,8 +188,13 @@ export interface UseRepeaterDashboardResult {
syncClock: () => Promise<void>;
}
interface UseRepeaterDashboardOptions {
hasAdvertLocation?: boolean;
}
export function useRepeaterDashboard(
activeConversation: Conversation | null
activeConversation: Conversation | null,
options: UseRepeaterDashboardOptions = {}
): UseRepeaterDashboardResult {
const conversationId =
activeConversation && activeConversation.type === 'contact' ? activeConversation.id : null;
@@ -255,13 +271,22 @@ export function useRepeaterDashboard(
setLoginLoading(true);
setLoginError(null);
try {
await api.repeaterLogin(publicKey, password);
const result = await api.repeaterLogin(publicKey, password);
if (activeIdRef.current !== conversationId) return;
setLoggedIn(true);
if (!result.authenticated) {
const msg = result.message ?? 'Repeater login was not confirmed';
setLoginError(msg);
toast.error(getLoginToastTitle(result.status), { description: msg });
}
} catch (err) {
if (activeIdRef.current !== conversationId) return;
const msg = err instanceof Error ? err.message : 'Login failed';
setLoggedIn(true);
setLoginError(msg);
toast.error('Login request failed', {
description: `${msg}. The dashboard is still available, but repeater operations may fail until a login succeeds.`,
});
} finally {
if (activeIdRef.current === conversationId) {
setLoginLoading(false);
@@ -281,7 +306,7 @@ export function useRepeaterDashboard(
if (!publicKey) return;
const conversationId = publicKey;
if (pane === 'neighbors') {
if (pane === 'neighbors' && !options.hasAdvertLocation) {
const nodeInfoState = paneStatesRef.current.nodeInfo;
const nodeInfoData = paneDataRef.current.nodeInfo;
const needsNodeInfoPrefetch =
@@ -365,7 +390,7 @@ export function useRepeaterDashboard(
}
}
},
[getPublicKey]
[getPublicKey, options.hasAdvertLocation]
);
const loadAll = useCallback(async () => {

View File

@@ -16,10 +16,14 @@ interface UseUnreadCountsResult {
mentions: Record<string, boolean>;
lastMessageTimes: ConversationTimes;
unreadLastReadAts: Record<string, number | null>;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
recordMessageEvent: (args: {
msg: Message;
activeConversation: boolean;
isNewMessage: boolean;
hasMention?: boolean;
}) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
markAllRead: () => void;
trackNewMessage: (msg: Message) => void;
refreshUnreads: () => Promise<void>;
}
@@ -162,7 +166,6 @@ export function useUnreadCounts(
}
}, [activeConversation]);
// Increment unread count for a conversation
const incrementUnread = useCallback((stateKey: string, hasMention?: boolean) => {
setUnreadCounts((prev) => ({
...prev,
@@ -176,6 +179,40 @@ export function useUnreadCounts(
}
}, []);
const recordMessageEvent = useCallback(
({
msg,
activeConversation: isActiveConversation,
isNewMessage,
hasMention,
}: {
msg: Message;
activeConversation: boolean;
isNewMessage: boolean;
hasMention?: boolean;
}) => {
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
stateKey = getStateKey('contact', msg.conversation_key);
}
if (!stateKey) {
return;
}
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
const updated = setLastMessageTime(stateKey, timestamp);
setLastMessageTimes(updated);
if (!isActiveConversation && !msg.outgoing && isNewMessage) {
incrementUnread(stateKey, hasMention);
}
},
[incrementUnread]
);
const renameConversationState = useCallback((oldStateKey: string, newStateKey: string) => {
if (oldStateKey === newStateKey) return;
@@ -212,31 +249,14 @@ export function useUnreadCounts(
});
}, []);
// Track a new incoming message for unread counts
const trackNewMessage = useCallback((msg: Message) => {
let conversationKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
conversationKey = getStateKey('channel', msg.conversation_key);
} else if (msg.type === 'PRIV' && msg.conversation_key) {
conversationKey = getStateKey('contact', msg.conversation_key);
}
if (conversationKey) {
const timestamp = msg.received_at || Math.floor(Date.now() / 1000);
const updated = setLastMessageTime(conversationKey, timestamp);
setLastMessageTimes(updated);
}
}, []);
return {
unreadCounts,
mentions,
lastMessageTimes,
unreadLastReadAts,
incrementUnread,
recordMessageEvent,
renameConversationState,
markAllRead,
trackNewMessage,
refreshUnreads: fetchUnreads,
};
}

View File

@@ -1,174 +0,0 @@
/**
* LRU message cache for recently-visited conversations.
*
* Uses Map insertion-order semantics: the most recently used entry
* is always at the end. Eviction removes the first (least-recently-used) entry.
*
* Cache size: 20 conversations, 200 messages each (~2.4MB worst case).
*/
import type { Message, MessagePath } from './types';
export const MAX_CACHED_CONVERSATIONS = 20;
export const MAX_MESSAGES_PER_ENTRY = 200;
interface CacheEntry {
messages: Message[];
seenContent: Set<string>;
hasOlderMessages: boolean;
}
const cache = new Map<string, CacheEntry>();
/** Get a cached entry and promote it to most-recently-used. */
export function get(id: string): CacheEntry | undefined {
const entry = cache.get(id);
if (!entry) return undefined;
// Promote to MRU: delete and re-insert
cache.delete(id);
cache.set(id, entry);
return entry;
}
/** Insert or update an entry at MRU position, evicting LRU if over capacity. */
export function set(id: string, entry: CacheEntry): void {
// Trim to most recent messages to bound memory
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
const trimmed = [...entry.messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
entry = { ...entry, messages: trimmed, hasOlderMessages: true };
}
// Remove first so re-insert moves to end
cache.delete(id);
cache.set(id, entry);
// Evict LRU (first entry) if over capacity
if (cache.size > MAX_CACHED_CONVERSATIONS) {
const lruKey = cache.keys().next().value as string;
cache.delete(lruKey);
}
}
/** Add a message to a cached conversation with dedup. Returns true if new, false if duplicate. */
export function addMessage(id: string, msg: Message, contentKey: string): boolean {
const entry = cache.get(id);
if (!entry) {
// Auto-create a minimal entry for never-visited conversations
cache.set(id, {
messages: [msg],
seenContent: new Set([contentKey]),
hasOlderMessages: true,
});
// Evict LRU if over capacity
if (cache.size > MAX_CACHED_CONVERSATIONS) {
const lruKey = cache.keys().next().value as string;
cache.delete(lruKey);
}
return true;
}
if (entry.seenContent.has(contentKey)) return false;
if (entry.messages.some((m) => m.id === msg.id)) return false;
entry.seenContent.add(contentKey);
entry.messages = [...entry.messages, msg];
// Trim if over limit (drop oldest by received_at)
if (entry.messages.length > MAX_MESSAGES_PER_ENTRY) {
entry.messages = [...entry.messages]
.sort((a, b) => b.received_at - a.received_at)
.slice(0, MAX_MESSAGES_PER_ENTRY);
}
// Promote to MRU so actively-messaged conversations aren't evicted
cache.delete(id);
cache.set(id, entry);
return true;
}
/** Scan all cached entries for a message ID and update its ack/paths. */
export function updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void {
for (const entry of cache.values()) {
const idx = entry.messages.findIndex((m) => m.id === messageId);
if (idx >= 0) {
const current = entry.messages[idx];
const updated = [...entry.messages];
updated[idx] = {
...current,
acked: Math.max(current.acked, ackCount),
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
};
entry.messages = updated;
return; // Message IDs are unique, stop after first match
}
}
}
/**
* Compare fetched messages against current state.
* Returns merged array if there are differences (new messages or ack changes),
* or null if the cache is already consistent (happy path — no rerender needed).
* Preserves any older paginated messages not present in the fetched page.
*/
export function reconcile(current: Message[], fetched: Message[]): Message[] | null {
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>();
for (const m of current) {
currentById.set(m.id, { acked: m.acked, pathsLen: m.paths?.length ?? 0, text: m.text });
}
let needsUpdate = false;
for (const m of fetched) {
const cur = currentById.get(m.id);
if (
!cur ||
cur.acked !== m.acked ||
cur.pathsLen !== (m.paths?.length ?? 0) ||
cur.text !== m.text
) {
needsUpdate = true;
break;
}
}
if (!needsUpdate) return null;
// Merge: fresh recent page + any older paginated messages not in the fetch
const fetchedIds = new Set(fetched.map((m) => m.id));
const olderMessages = current.filter((m) => !fetchedIds.has(m.id));
return [...fetched, ...olderMessages];
}
/** Evict a specific conversation from the cache. */
export function remove(id: string): void {
cache.delete(id);
}
/** Move cached conversation state to a new conversation id. */
export function rename(oldId: string, newId: string): void {
if (oldId === newId) return;
const oldEntry = cache.get(oldId);
if (!oldEntry) return;
const newEntry = cache.get(newId);
if (!newEntry) {
cache.delete(oldId);
cache.set(newId, oldEntry);
return;
}
const mergedMessages = [...newEntry.messages];
const seenIds = new Set(mergedMessages.map((message) => message.id));
for (const message of oldEntry.messages) {
if (!seenIds.has(message.id)) {
mergedMessages.push(message);
seenIds.add(message.id);
}
}
cache.delete(oldId);
cache.set(newId, {
messages: mergedMessages,
seenContent: new Set([...newEntry.seenContent, ...oldEntry.seenContent]),
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
});
}
/** Clear the entire cache. */
export function clear(): void {
cache.clear();
}

View File

@@ -305,7 +305,7 @@ describe('fetchJson (via api methods)', () => {
expect(options.method).toBe('DELETE');
});
it('sends POST without body for sendAdvertisement', async () => {
it('sends POST with flood mode for sendAdvertisement', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
@@ -317,7 +317,22 @@ describe('fetchJson (via api methods)', () => {
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/advertise');
expect(options.method).toBe('POST');
expect(options.body).toBeUndefined();
expect(options.body).toBe(JSON.stringify({ mode: 'flood' }));
});
it('sends POST with zero-hop mode for sendAdvertisement', async () => {
installMockFetch();
mockFetch.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ status: 'ok' }),
});
await api.sendAdvertisement('zero_hop');
const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('/api/radio/advertise');
expect(options.method).toBe('POST');
expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' }));
});
});

View File

@@ -31,15 +31,15 @@ const mocks = vi.hoisted(() => ({
error: vi.fn(),
},
hookFns: {
setMessages: vi.fn(),
fetchMessages: vi.fn(async () => {}),
fetchOlderMessages: vi.fn(async () => {}),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
incrementUnread: vi.fn(),
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
removeConversationMessages: vi.fn(),
clearConversationMessages: vi.fn(),
recordMessageEvent: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(async () => {}),
},
}));
@@ -63,38 +63,30 @@ vi.mock('../hooks', async (importOriginal) => {
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: mocks.hookFns.setMessages,
fetchMessages: mocks.hookFns.fetchMessages,
fetchOlderMessages: mocks.hookFns.fetchOlderMessages,
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: mocks.hookFns.addMessageIfNew,
updateMessageAck: mocks.hookFns.updateMessageAck,
triggerReconcile: mocks.hookFns.triggerReconcile,
observeMessage: mocks.hookFns.observeMessage,
receiveMessageAck: mocks.hookFns.receiveMessageAck,
reconcileOnReconnect: mocks.hookFns.reconcileOnReconnect,
renameConversationMessages: mocks.hookFns.renameConversationMessages,
removeConversationMessages: mocks.hookFns.removeConversationMessages,
clearConversationMessages: mocks.hookFns.clearConversationMessages,
}),
useUnreadCounts: () => ({
unreadCounts: {},
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
incrementUnread: mocks.hookFns.incrementUnread,
recordMessageEvent: mocks.hookFns.recordMessageEvent,
renameConversationState: vi.fn(),
markAllRead: mocks.hookFns.markAllRead,
trackNewMessage: mocks.hookFns.trackNewMessage,
refreshUnreads: mocks.hookFns.refreshUnreads,
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: ({
settingsMode,
@@ -172,7 +164,10 @@ vi.mock('../components/ui/sonner', () => ({
vi.mock('../utils/urlHash', () => ({
parseHashConversation: () => null,
parseHashSettingsSection: () => null,
updateUrlHash: vi.fn(),
updateSettingsHash: vi.fn(),
getSettingsHash: (section: string) => `#settings/${section}`,
getMapFocusHash: () => '#map',
}));
@@ -295,7 +290,7 @@ describe('App favorite toggle flow', () => {
await waitFor(() => {
expect(mocks.api.getChannels).toHaveBeenCalledTimes(2);
});
expect(mocks.hookFns.triggerReconcile).toHaveBeenCalledTimes(1);
expect(mocks.hookFns.reconcileOnReconnect).toHaveBeenCalledTimes(1);
expect(mocks.hookFns.refreshUnreads).toHaveBeenCalledTimes(1);
});

View File

@@ -37,15 +37,16 @@ vi.mock('../hooks', async (importOriginal) => {
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: vi.fn(),
fetchOlderMessages: vi.fn(async () => {}),
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
removeConversationMessages: vi.fn(),
clearConversationMessages: vi.fn(),
};
},
useUnreadCounts: () => ({
@@ -53,22 +54,14 @@ vi.mock('../hooks', async (importOriginal) => {
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
incrementUnread: vi.fn(),
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(),
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />,
}));

View File

@@ -32,38 +32,30 @@ vi.mock('../hooks', async (importOriginal) => {
hasOlderMessages: false,
hasNewerMessages: false,
loadingNewer: false,
hasNewerMessagesRef: { current: false },
setMessages: vi.fn(),
fetchMessages: vi.fn(async () => {}),
fetchOlderMessages: vi.fn(async () => {}),
fetchNewerMessages: vi.fn(async () => {}),
jumpToBottom: vi.fn(),
reloadCurrentConversation: vi.fn(),
addMessageIfNew: vi.fn(),
updateMessageAck: vi.fn(),
triggerReconcile: vi.fn(),
observeMessage: vi.fn(() => ({ added: false, activeConversation: false })),
receiveMessageAck: vi.fn(),
reconcileOnReconnect: vi.fn(),
renameConversationMessages: vi.fn(),
removeConversationMessages: vi.fn(),
clearConversationMessages: vi.fn(),
}),
useUnreadCounts: () => ({
unreadCounts: {},
mentions: {},
lastMessageTimes: {},
unreadLastReadAts: {},
incrementUnread: vi.fn(),
recordMessageEvent: vi.fn(),
renameConversationState: vi.fn(),
markAllRead: vi.fn(),
trackNewMessage: vi.fn(),
refreshUnreads: vi.fn(async () => {}),
}),
getMessageContentKey: () => 'content-key',
};
});
vi.mock('../messageCache', () => ({
addMessage: vi.fn(),
updateAck: vi.fn(),
remove: vi.fn(),
}));
vi.mock('../components/StatusBar', () => ({
StatusBar: () => <div data-testid="status-bar" />,
}));
@@ -98,7 +90,9 @@ vi.mock('../components/NewMessageModal', () => ({
}));
vi.mock('../components/SettingsModal', () => ({
SettingsModal: () => null,
SettingsModal: ({ desktopSection }: { desktopSection?: string }) => (
<div data-testid="settings-modal-section">{desktopSection ?? 'none'}</div>
),
SETTINGS_SECTION_ORDER: ['radio', 'local', 'database', 'bot'],
SETTINGS_SECTION_LABELS: {
radio: 'Radio',
@@ -262,14 +256,45 @@ describe('App startup hash resolution', () => {
expect(window.location.hash).toBe('');
});
it('tracks the current conversation in local storage even before reopen is enabled', async () => {
const chatChannel = {
key: '11111111111111111111111111111111',
name: 'Ops',
is_hashtag: false,
on_radio: false,
last_read_at: null,
};
window.location.hash = '';
mocks.api.getChannels.mockResolvedValue([publicChannel, chatChannel]);
localStorage.setItem(
LAST_VIEWED_CONVERSATION_KEY,
JSON.stringify({
type: 'channel',
id: chatChannel.key,
name: chatChannel.name,
})
);
render(<App />);
await waitFor(() => {
for (const node of screen.getAllByTestId('active-conversation')) {
expect(node).toHaveTextContent(`channel:${publicChannel.key}:Public`);
}
});
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toContain(publicChannel.key);
});
it('restores last viewed contact from legacy name token when hash is empty and reopen is enabled', async () => {
const aliceContact = {
public_key: 'b'.repeat(64),
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
direct_path: null,
direct_path_len: -1,
last_advert: null,
lat: null,
lon: null,
@@ -301,4 +326,18 @@ describe('App startup hash resolution', () => {
});
expect(window.location.hash).toBe('');
});
it('stays on radio settings section even when radio is disconnected', async () => {
window.location.hash = '#settings/radio';
mocks.api.getRadioConfig.mockRejectedValue(new Error('radio offline'));
render(<App />);
await waitFor(() => {
expect(screen.getByTestId('settings-modal-section')).toHaveTextContent('radio');
});
// Section stays on radio (no redirect to local) and hash is preserved
expect(window.location.hash).toBe('#settings/radio');
});
});

View File

@@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
@@ -169,6 +170,25 @@ describe('ChatHeader key visibility', () => {
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('hides the delete button for the canonical Public channel', () => {
const channel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public', false);
const conversation: Conversation = { type: 'channel', id: PUBLIC_CHANNEL_KEY, name: 'Public' };
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
});
it('still shows the delete button for non-canonical channels named Public', () => {
const key = 'AB'.repeat(16);
const channel = makeChannel(key, 'Public', false);
const conversation: Conversation = { type: 'channel', id: key, name: 'Public' };
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
expect(screen.getByRole('button', { name: 'Delete' })).toBeInTheDocument();
});
it('opens path discovery modal for contacts and runs the request on demand', async () => {
const pubKey = '21'.repeat(32);
const contact: Contact = {
@@ -176,9 +196,9 @@ describe('ChatHeader key visibility', () => {
name: 'Alice',
type: 1,
flags: 0,
last_path: 'AA',
last_path_len: 1,
out_path_hash_mode: 0,
direct_path: 'AA',
direct_path_len: 1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -222,9 +242,9 @@ describe('ChatHeader key visibility', () => {
name: 'Alice',
type: 1,
flags: 0,
last_path: 'AA',
last_path_len: 1,
out_path_hash_mode: 0,
direct_path: 'AA',
direct_path_len: 1,
direct_path_hash_mode: 0,
route_override_path: 'BBDD',
route_override_len: 2,
route_override_hash_mode: 0,

View File

@@ -39,9 +39,9 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -104,7 +104,7 @@ describe('ContactInfoPane', () => {
});
it('shows hop width when contact has a stored path hash mode', async () => {
const contact = createContact({ out_path_hash_mode: 1 });
const contact = createContact({ direct_path_hash_mode: 1, direct_path_len: 1 });
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
@@ -117,7 +117,7 @@ describe('ContactInfoPane', () => {
});
it('does not show hop width for flood-routed contacts', async () => {
const contact = createContact({ last_path_len: -1, out_path_hash_mode: -1 });
const contact = createContact({ direct_path_len: -1, direct_path_hash_mode: -1 });
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
@@ -131,8 +131,8 @@ describe('ContactInfoPane', () => {
it('shows forced routing override and learned route separately', async () => {
const contact = createContact({
last_path_len: 1,
out_path_hash_mode: 0,
direct_path_len: 1,
direct_path_hash_mode: 0,
route_override_path: 'ae92f13e',
route_override_len: 2,
route_override_hash_mode: 1,

View File

@@ -166,9 +166,9 @@ describe('ConversationPane', () => {
name: 'Repeater',
type: 2,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -268,9 +268,9 @@ describe('ConversationPane', () => {
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
@@ -304,9 +304,9 @@ describe('ConversationPane', () => {
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,

View File

@@ -85,8 +85,11 @@ describe('SettingsFanoutSection', () => {
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
expect(screen.getByRole('menuitem', { name: 'Private MQTT' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'MeshRank' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'LetsMesh (US)' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' })).toBeInTheDocument();
expect(
screen.getByRole('menuitem', { name: 'meshcoretomqtt/LetsMesh/MeshRank' })
screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' })
).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument();
@@ -339,11 +342,12 @@ describe('SettingsFanoutSection', () => {
fireEvent.click(screen.getByText('← Back to list'));
expect(window.confirm).toHaveBeenCalledWith('Leave without saving?');
await waitFor(() => expect(screen.queryByText('← Back to list')).not.toBeInTheDocument());
expect(mockedApi.createFanoutConfig).not.toHaveBeenCalled();
});
it('back to list asks for confirmation before leaving', async () => {
it('back to list does not ask for confirmation when an existing integration is unchanged', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
@@ -353,11 +357,28 @@ describe('SettingsFanoutSection', () => {
fireEvent.click(screen.getByText('← Back to list'));
expect(window.confirm).not.toHaveBeenCalled();
await waitFor(() => expect(screen.queryByText('← Back to list')).not.toBeInTheDocument());
});
it('back to list asks for confirmation after editing an existing integration', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('URL'), {
target: { value: 'https://example.com/new' },
});
fireEvent.click(screen.getByText('← Back to list'));
expect(window.confirm).toHaveBeenCalledWith('Leave without saving?');
await waitFor(() => expect(screen.queryByText('← Back to list')).not.toBeInTheDocument());
});
it('back to list stays on the edit screen when confirmation is cancelled', async () => {
it('back to list stays on the edit screen when confirmation is cancelled after edits', async () => {
vi.mocked(window.confirm).mockReturnValue(false);
mockedApi.getFanoutConfigs.mockResolvedValue([webhookConfig]);
renderSection();
@@ -366,6 +387,9 @@ describe('SettingsFanoutSection', () => {
fireEvent.click(screen.getByRole('button', { name: 'Edit' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('URL'), {
target: { value: 'https://example.com/new' },
});
fireEvent.click(screen.getByText('← Back to list'));
expect(window.confirm).toHaveBeenCalledWith('Leave without saving?');
@@ -630,6 +654,238 @@ describe('SettingsFanoutSection', () => {
expect(screen.queryByText('Region: LAX')).not.toBeInTheDocument();
});
it('MeshRank preset pre-fills the broker settings and asks for the topic template', async () => {
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
expect(screen.getByLabelText('Name')).toHaveValue('MeshRank');
expect(screen.getByLabelText('Packet Topic Template')).toHaveValue('');
expect(screen.queryByLabelText('Broker Host')).not.toBeInTheDocument();
});
it('creates MeshRank preset as a regular mqtt_community config', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-meshrank',
type: 'mqtt_community',
name: 'MeshRank',
enabled: true,
config: {
broker_host: 'meshrank.net',
broker_port: 8883,
transport: 'tcp',
use_tls: true,
tls_verify: true,
auth_mode: 'none',
username: '',
password: '',
iata: 'XYZ',
email: '',
token_audience: '',
topic_template: 'meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'MeshRank' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('Packet Topic Template'), {
target: {
value: 'meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{PUBLIC_KEY}/packets',
},
});
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'mqtt_community',
name: 'MeshRank',
config: {
broker_host: 'meshrank.net',
broker_port: 8883,
transport: 'tcp',
use_tls: true,
tls_verify: true,
auth_mode: 'none',
username: '',
password: '',
iata: 'XYZ',
email: '',
token_audience: '',
topic_template: 'meshrank/uplink/B435F6D5F7896B74C6B995FE221C2C1F/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: true,
})
);
});
it('LetsMesh (US) preset pre-fills the expected broker defaults', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-letsmesh-us',
type: 'mqtt_community',
name: 'LetsMesh (US)',
enabled: false,
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
username: '',
password: '',
iata: 'LAX',
email: 'user@example.com',
token_audience: 'mqtt-us-v1.letsmesh.net',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (US)' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
expect(screen.getByLabelText('Name')).toHaveValue('LetsMesh (US)');
expect(screen.queryByLabelText('Authentication')).not.toBeInTheDocument();
expect(screen.queryByLabelText('Packet Topic Template')).not.toBeInTheDocument();
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } });
fireEvent.change(screen.getByLabelText('Region Code (IATA)'), { target: { value: 'lax' } });
fireEvent.click(screen.getByRole('button', { name: 'Save as Disabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'mqtt_community',
name: 'LetsMesh (US)',
config: {
broker_host: 'mqtt-us-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
username: '',
password: '',
iata: 'LAX',
email: 'user@example.com',
token_audience: 'mqtt-us-v1.letsmesh.net',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: false,
})
);
});
it('LetsMesh (EU) preset saves the EU broker defaults', async () => {
const createdConfig: FanoutConfig = {
id: 'comm-letsmesh-eu',
type: 'mqtt_community',
name: 'LetsMesh (EU)',
enabled: true,
config: {
broker_host: 'mqtt-eu-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
username: '',
password: '',
iata: 'AMS',
email: 'user@example.com',
token_audience: 'mqtt-eu-v1.letsmesh.net',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
sort_order: 0,
created_at: 2000,
};
mockedApi.createFanoutConfig.mockResolvedValue(createdConfig);
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdConfig]);
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'LetsMesh (EU)' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } });
fireEvent.change(screen.getByLabelText('Region Code (IATA)'), { target: { value: 'ams' } });
fireEvent.click(screen.getByRole('button', { name: 'Save as Enabled' }));
await waitFor(() =>
expect(mockedApi.createFanoutConfig).toHaveBeenCalledWith({
type: 'mqtt_community',
name: 'LetsMesh (EU)',
config: {
broker_host: 'mqtt-eu-v1.letsmesh.net',
broker_port: 443,
transport: 'websockets',
use_tls: true,
tls_verify: true,
auth_mode: 'token',
username: '',
password: '',
iata: 'AMS',
email: 'user@example.com',
token_audience: 'mqtt-eu-v1.letsmesh.net',
topic_template: 'meshcore/{IATA}/{PUBLIC_KEY}/packets',
},
scope: { messages: 'none', raw_packets: 'all' },
enabled: true,
})
);
});
it('generic Community MQTT entry still opens the full editor', async () => {
renderSection();
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument()
);
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' }));
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
expect(screen.getByLabelText('Name')).toHaveValue('Community MQTT #1');
expect(screen.getByLabelText('Broker Host')).toBeInTheDocument();
expect(screen.getByLabelText('Authentication')).toBeInTheDocument();
expect(screen.getByLabelText('Packet Topic Template')).toBeInTheDocument();
});
it('private MQTT list shows broker and topic summary', async () => {
const privateConfig: FanoutConfig = {
id: 'mqtt-1',

View File

@@ -8,12 +8,12 @@
* between backend and frontend - both sides test against the same data.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect } from 'vitest';
import fixtures from './fixtures/websocket_events.json';
import { getMessageContentKey } from '../hooks/useConversationMessages';
import { getStateKey } from '../utils/conversationState';
import { mergeContactIntoList } from '../utils/contactMerge';
import * as messageCache from '../messageCache';
import { ConversationMessageCache } from '../hooks/useConversationMessages';
import { getMessageContentKey } from '../utils/messageIdentity';
import type { Contact, Message } from '../types';
/**
@@ -25,6 +25,7 @@ interface MockState {
unreadCounts: Record<string, number>;
lastMessageTimes: Record<string, number>;
seenActiveContent: Set<string>;
messageCache: ConversationMessageCache;
}
function createMockState(): MockState {
@@ -33,6 +34,7 @@ function createMockState(): MockState {
unreadCounts: {},
lastMessageTimes: {},
seenActiveContent: new Set(),
messageCache: new ConversationMessageCache(),
};
}
@@ -68,7 +70,7 @@ function handleMessageEvent(
state.lastMessageTimes[stateKey] = msg.received_at;
if (!isForActiveConversation) {
const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey);
const isNew = state.messageCache.addMessage(msg.conversation_key, msg);
if (!msg.outgoing && isNew) {
state.unreadCounts[stateKey] = (state.unreadCounts[stateKey] || 0) + 1;
unreadIncremented = true;
@@ -78,11 +80,6 @@ function handleMessageEvent(
return { added, unreadIncremented };
}
// Clear messageCache between tests to avoid cross-test contamination
beforeEach(() => {
messageCache.clear();
});
describe('Integration: Channel Message Events', () => {
const fixture = fixtures.channel_message;
@@ -180,7 +177,7 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
// dual-set design the global set would drop msg-0's key during pruning,
// so a later mesh echo of msg-0 would pass the global check and
// phantom-increment unread. With the fix, messageCache's per-conversation
// seenContent is the single source of truth and is never pruned.
// Cached messages remain the source of truth for inactive-conversation dedup.
const MESSAGE_COUNT = 1001;
for (let i = 0; i < MESSAGE_COUNT; i++) {
const msg: Message = {
@@ -277,8 +274,8 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
name: 'TestNode',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
direct_path: null,
direct_path_len: 0,
last_advert: null,
lat: null,
lon: null,
@@ -288,7 +285,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
last_read_at: null,
first_seen: null,
...overrides,
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
direct_path_hash_mode: overrides.direct_path_hash_mode ?? 0,
};
}
@@ -342,11 +339,8 @@ describe('Integration: Contact Merge', () => {
// --- ACK + messageCache propagation tests ---
describe('Integration: ACK + messageCache propagation', () => {
beforeEach(() => {
messageCache.clear();
});
it('updateAck updates acked count on cached message', () => {
const messageCache = new ConversationMessageCache();
const msg: Message = {
id: 100,
type: 'PRIV',
@@ -362,7 +356,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 0,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-100');
messageCache.addMessage('pk_abc', msg);
messageCache.updateAck(100, 1);
@@ -372,6 +366,7 @@ describe('Integration: ACK + messageCache propagation', () => {
});
it('updateAck updates paths when longer', () => {
const messageCache = new ConversationMessageCache();
const msg: Message = {
id: 101,
type: 'PRIV',
@@ -387,7 +382,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 1,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-101');
messageCache.addMessage('pk_abc', msg);
const longerPaths = [
{ path: 'aa', received_at: 1700000001 },
@@ -401,6 +396,7 @@ describe('Integration: ACK + messageCache propagation', () => {
});
it('preserves higher existing ack count (max semantics)', () => {
const messageCache = new ConversationMessageCache();
const msg: Message = {
id: 102,
type: 'PRIV',
@@ -416,7 +412,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 5,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-102');
messageCache.addMessage('pk_abc', msg);
// Try to update with a lower ack count
messageCache.updateAck(102, 3);
@@ -426,6 +422,7 @@ describe('Integration: ACK + messageCache propagation', () => {
});
it('is a no-op for unknown message ID', () => {
const messageCache = new ConversationMessageCache();
const msg: Message = {
id: 103,
type: 'PRIV',
@@ -441,7 +438,7 @@ describe('Integration: ACK + messageCache propagation', () => {
acked: 0,
sender_name: null,
};
messageCache.addMessage('pk_abc', msg, 'key-103');
messageCache.addMessage('pk_abc', msg);
// Update a non-existent message ID — should not throw or modify anything
messageCache.updateAck(999, 1);

View File

@@ -29,9 +29,9 @@ describe('MapView', () => {
name: 'Mystery Node',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
@@ -52,4 +52,43 @@ describe('MapView', () => {
).toBeInTheDocument();
expect(screen.getByText('Last heard: Never heard by this server')).toBeInTheDocument();
});
it('keeps the 7-day cutoff stable for the lifetime of the mounted map', () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
const contact: Contact = {
public_key: 'bb'.repeat(32),
name: 'Almost Stale',
type: 1,
flags: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
last_advert: null,
lat: 41,
lon: -73,
last_seen: Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60 + 60,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
const { rerender } = render(<MapView contacts={[contact]} focusedKey={null} />);
expect(screen.getByText(/showing 1 contact heard in the last 7 days/i)).toBeInTheDocument();
vi.advanceTimersByTime(2 * 60 * 1000);
rerender(<MapView contacts={[contact]} focusedKey={null} />);
expect(screen.getByText(/showing 1 contact heard in the last 7 days/i)).toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
});

View File

@@ -3,8 +3,12 @@
*/
import { describe, it, expect, beforeEach } from 'vitest';
import * as messageCache from '../messageCache';
import { MAX_CACHED_CONVERSATIONS, MAX_MESSAGES_PER_ENTRY } from '../messageCache';
import {
ConversationMessageCache,
MAX_CACHED_CONVERSATIONS,
MAX_MESSAGES_PER_ENTRY,
reconcileConversationMessages,
} from '../hooks/useConversationMessages';
import type { Message } from '../types';
function createMessage(overrides: Partial<Message> = {}): Message {
@@ -27,16 +31,14 @@ function createMessage(overrides: Partial<Message> = {}): Message {
}
function createEntry(messages: Message[] = [], hasOlderMessages = false) {
const seenContent = new Set<string>();
for (const msg of messages) {
seenContent.add(`${msg.type}-${msg.conversation_key}-${msg.text}-${msg.sender_timestamp}`);
}
return { messages, seenContent, hasOlderMessages };
return { messages, hasOlderMessages };
}
describe('messageCache', () => {
let messageCache: ConversationMessageCache;
beforeEach(() => {
messageCache.clear();
messageCache = new ConversationMessageCache();
});
describe('get/set', () => {
@@ -155,11 +157,7 @@ describe('messageCache', () => {
messageCache.set('conv1', createEntry([]));
const msg = createMessage({ id: 10, text: 'New message' });
const result = messageCache.addMessage(
'conv1',
msg,
'CHAN-channel123-New message-1700000000'
);
const result = messageCache.addMessage('conv1', msg);
expect(result).toBe(true);
const entry = messageCache.get('conv1');
@@ -171,12 +169,11 @@ describe('messageCache', () => {
messageCache.set('conv1', createEntry([]));
const msg1 = createMessage({ id: 10, text: 'Hello' });
const contentKey = 'CHAN-channel123-Hello-1700000000';
expect(messageCache.addMessage('conv1', msg1, contentKey)).toBe(true);
expect(messageCache.addMessage('conv1', msg1)).toBe(true);
// Same content key, different message id
const msg2 = createMessage({ id: 11, text: 'Hello' });
expect(messageCache.addMessage('conv1', msg2, contentKey)).toBe(false);
expect(messageCache.addMessage('conv1', msg2)).toBe(false);
const entry = messageCache.get('conv1');
expect(entry!.messages).toHaveLength(1);
@@ -187,9 +184,7 @@ describe('messageCache', () => {
// Same id, different content key
const msg = createMessage({ id: 10, text: 'Different' });
expect(messageCache.addMessage('conv1', msg, 'CHAN-channel123-Different-1700000000')).toBe(
false
);
expect(messageCache.addMessage('conv1', msg)).toBe(false);
const entry = messageCache.get('conv1');
expect(entry!.messages).toHaveLength(1);
@@ -208,11 +203,7 @@ describe('messageCache', () => {
text: 'newest',
received_at: 1700000000 + MAX_MESSAGES_PER_ENTRY,
});
const result = messageCache.addMessage(
'conv1',
newMsg,
`CHAN-channel123-newest-${newMsg.sender_timestamp}`
);
const result = messageCache.addMessage('conv1', newMsg);
expect(result).toBe(true);
const entry = messageCache.get('conv1');
@@ -225,11 +216,7 @@ describe('messageCache', () => {
it('auto-creates a minimal entry for never-visited conversations and returns true', () => {
const msg = createMessage({ id: 10, text: 'First contact' });
const result = messageCache.addMessage(
'new_conv',
msg,
'CHAN-channel123-First contact-1700000000'
);
const result = messageCache.addMessage('new_conv', msg);
expect(result).toBe(true);
const entry = messageCache.get('new_conv');
@@ -237,7 +224,6 @@ describe('messageCache', () => {
expect(entry!.messages).toHaveLength(1);
expect(entry!.messages[0].text).toBe('First contact');
expect(entry!.hasOlderMessages).toBe(true);
expect(entry!.seenContent.has('CHAN-channel123-First contact-1700000000')).toBe(true);
});
it('promotes entry to MRU on addMessage', () => {
@@ -248,7 +234,7 @@ describe('messageCache', () => {
// addMessage to conv0 (currently LRU) should promote it
const msg = createMessage({ id: 999, text: 'Incoming WS message' });
messageCache.addMessage('conv0', msg, 'CHAN-channel123-Incoming WS message-1700000000');
messageCache.addMessage('conv0', msg);
// Add one more — conv1 should now be LRU and get evicted, not conv0
messageCache.set('conv_new', createEntry());
@@ -259,11 +245,10 @@ describe('messageCache', () => {
it('returns false for duplicate delivery to auto-created entry', () => {
const msg = createMessage({ id: 10, text: 'Echo' });
const contentKey = 'CHAN-channel123-Echo-1700000000';
expect(messageCache.addMessage('new_conv', msg, contentKey)).toBe(true);
expect(messageCache.addMessage('new_conv', msg)).toBe(true);
// Duplicate via mesh echo
expect(messageCache.addMessage('new_conv', msg, contentKey)).toBe(false);
expect(messageCache.addMessage('new_conv', msg)).toBe(false);
const entry = messageCache.get('new_conv');
expect(entry!.messages).toHaveLength(1);
@@ -358,7 +343,7 @@ describe('messageCache', () => {
createMessage({ id: 3, acked: 1 }),
];
expect(messageCache.reconcile(msgs, fetched)).toBeNull();
expect(reconcileConversationMessages(msgs, fetched)).toBeNull();
});
it('detects new messages missing from cache', () => {
@@ -369,7 +354,7 @@ describe('messageCache', () => {
createMessage({ id: 3, text: 'missed via WS' }),
];
const merged = messageCache.reconcile(current, fetched);
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged!.map((m) => m.id)).toEqual([1, 2, 3]);
});
@@ -378,7 +363,7 @@ describe('messageCache', () => {
const current = [createMessage({ id: 1, acked: 0 })];
const fetched = [createMessage({ id: 1, acked: 3 })];
const merged = messageCache.reconcile(current, fetched);
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged![0].acked).toBe(3);
});
@@ -397,20 +382,20 @@ describe('messageCache', () => {
createMessage({ id: 2 }),
];
const merged = messageCache.reconcile(current, fetched);
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
// Should have fetched page + older paginated message
expect(merged!.map((m) => m.id)).toEqual([4, 3, 2, 1]);
});
it('returns null for empty fetched and empty current', () => {
expect(messageCache.reconcile([], [])).toBeNull();
expect(reconcileConversationMessages([], [])).toBeNull();
});
it('detects difference when current is empty but fetch has messages', () => {
const fetched = [createMessage({ id: 1 })];
const merged = messageCache.reconcile([], fetched);
const merged = reconcileConversationMessages([], fetched);
expect(merged).not.toBeNull();
expect(merged!).toHaveLength(1);
});
@@ -430,7 +415,7 @@ describe('messageCache', () => {
}),
];
const merged = messageCache.reconcile(current, fetched);
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged![0].paths).toHaveLength(2);
});
@@ -439,7 +424,7 @@ describe('messageCache', () => {
const current = [createMessage({ id: 1, text: '[encrypted]' })];
const fetched = [createMessage({ id: 1, text: 'Hello world' })];
const merged = messageCache.reconcile(current, fetched);
const merged = reconcileConversationMessages(current, fetched);
expect(merged).not.toBeNull();
expect(merged![0].text).toBe('Hello world');
});
@@ -449,7 +434,7 @@ describe('messageCache', () => {
const current = [createMessage({ id: 1, acked: 2, paths, text: 'Hello' })];
const fetched = [createMessage({ id: 1, acked: 2, paths, text: 'Hello' })];
expect(messageCache.reconcile(current, fetched)).toBeNull();
expect(reconcileConversationMessages(current, fetched)).toBeNull();
});
});
});

View File

@@ -9,12 +9,18 @@ import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { MessageInput } from '../components/MessageInput';
import { toast } from '../components/ui/sonner';
// Mock sonner (toast)
vi.mock('../components/ui/sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
const mockToast = toast as unknown as {
success: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
const textEncoder = new TextEncoder();
function byteLen(s: string): number {
@@ -182,4 +188,24 @@ describe('MessageInput', () => {
expect(getSendButton()).toBeEnabled();
});
});
describe('send failure toasts', () => {
it('shows the radio no-response toast when the send outcome is unknown', async () => {
onSend.mockRejectedValueOnce(
new Error(
'Send command was issued to the radio, but no response was heard back. The message may or may not have sent successfully.'
)
);
renderInput({ conversationType: 'contact' });
fireEvent.change(getInput(), { target: { value: 'Hello' } });
fireEvent.click(getSendButton());
expect(await screen.findByDisplayValue('Hello')).toBeTruthy();
expect(mockToast.error).toHaveBeenCalledWith('Radio did not confirm send', {
description:
'Send command was issued to the radio, but no response was heard back. The message may or may not have sent successfully.',
});
});
});
});

View File

@@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { NewMessageModal } from '../components/NewMessageModal';
import type { Contact } from '../types';
import { toast } from '../components/ui/sonner';
// Mock sonner (toast)
vi.mock('../components/ui/sonner', () => ({
@@ -22,9 +23,9 @@ const mockContact: Contact = {
name: 'Alice',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -35,6 +36,11 @@ const mockContact: Contact = {
first_seen: null,
};
const mockToast = toast as unknown as {
success: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
};
describe('NewMessageModal form reset', () => {
const onClose = vi.fn();
const onSelectConversation = vi.fn();
@@ -137,6 +143,24 @@ describe('NewMessageModal form reset', () => {
});
expect(onClose).toHaveBeenCalled();
});
it('toasts when creation fails', async () => {
const user = userEvent.setup();
onCreateChannel.mockRejectedValueOnce(new Error('Bad key'));
renderModal();
await switchToTab(user, 'Room');
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
await user.click(screen.getByRole('button', { name: 'Create' }));
await waitFor(() => {
expect(mockToast.error).toHaveBeenCalledWith('Failed to create conversation', {
description: 'Bad key',
});
});
expect(screen.getByText('Bad key')).toBeTruthy();
});
});
describe('tab switching resets form', () => {

View File

@@ -51,9 +51,9 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
name,
type,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,

View File

@@ -7,6 +7,7 @@ import {
formatRouteLabel,
formatRoutingOverrideInput,
getEffectiveContactRoute,
isValidLocation,
resolvePath,
formatDistance,
formatHopCounts,
@@ -21,8 +22,9 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
name: 'Test Contact',
type: CONTACT_TYPE_REPEATER,
flags: 0,
last_path: null,
last_path_len: -1,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
@@ -32,7 +34,6 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
last_read_at: null,
first_seen: null,
...overrides,
out_path_hash_mode: overrides.out_path_hash_mode ?? 0,
};
}
@@ -138,9 +139,9 @@ describe('contact routing helpers', () => {
it('prefers routing override over learned route', () => {
const effective = getEffectiveContactRoute(
createContact({
last_path: 'AABB',
last_path_len: 1,
out_path_hash_mode: 0,
direct_path: 'AABB',
direct_path_len: 1,
direct_path_hash_mode: 0,
route_override_path: 'AE92F13E',
route_override_len: 2,
route_override_hash_mode: 1,
@@ -665,6 +666,24 @@ describe('resolvePath', () => {
});
});
describe('isValidLocation', () => {
it('rejects null and unset coordinates', () => {
expect(isValidLocation(null, -122.3)).toBe(false);
expect(isValidLocation(47.6, null)).toBe(false);
expect(isValidLocation(0, 0)).toBe(false);
});
it('rejects out-of-range coordinates', () => {
expect(isValidLocation(-593.497573, -1659.939204)).toBe(false);
expect(isValidLocation(91, 0)).toBe(false);
expect(isValidLocation(0, 181)).toBe(false);
});
it('accepts sane coordinates', () => {
expect(isValidLocation(47.6062, -122.3321)).toBe(true);
});
});
describe('formatDistance', () => {
it('formats distances under 1km in meters', () => {
expect(formatDistance(0.5)).toBe('500m');

View File

@@ -82,9 +82,9 @@ const contacts: Contact[] = [
name: 'TestRepeater',
type: 2,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -265,13 +265,14 @@ describe('RepeaterDashboard', () => {
expect(
screen.getByText(
'GPS info failed to fetch; map and distance data not available. This may be due to missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try refreshing.'
'Map and distance data are unavailable until this repeater has a valid position from either its advert or a Node Info fetch.'
)
).toBeInTheDocument();
expect(screen.getByText('No repeater position available')).toBeInTheDocument();
expect(screen.queryByText('Dist')).not.toBeInTheDocument();
});
it('shows neighbor distance when repeater radio settings include valid coords', () => {
it('shows neighbor distance when repeater node info includes valid coords', () => {
mockHook.loggedIn = true;
mockHook.paneData.neighbors = {
neighbors: [
@@ -304,9 +305,9 @@ describe('RepeaterDashboard', () => {
name: 'Neighbor',
type: 1,
flags: 0,
last_path: null,
last_path_len: 0,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
@@ -324,13 +325,69 @@ describe('RepeaterDashboard', () => {
render(<RepeaterDashboard {...defaultProps} contacts={contactsWithNeighbor} />);
expect(screen.getByText('Dist')).toBeInTheDocument();
expect(screen.getByText('Using repeater-reported position')).toBeInTheDocument();
expect(
screen.queryByText(
'GPS info failed to fetch; map and distance data not available. This may be due to missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try refreshing.'
'Map and distance data are unavailable until this repeater has a valid position from either its advert or a Node Info fetch.'
)
).not.toBeInTheDocument();
});
it('uses advert coords for neighbor distance when node info is unavailable', () => {
mockHook.loggedIn = true;
mockHook.paneData.neighbors = {
neighbors: [
{ pubkey_prefix: 'bbbbbbbbbbbb', name: 'Neighbor', snr: 7.2, last_heard_seconds: 9 },
],
};
mockHook.paneData.nodeInfo = null;
mockHook.paneStates.neighbors = {
loading: false,
attempt: 1,
error: null,
fetched_at: Date.now(),
};
mockHook.paneStates.nodeInfo = {
loading: false,
attempt: 0,
error: null,
fetched_at: null,
};
const contactsWithAdvertAndNeighbor = [
{
...contacts[0],
lat: -31.95,
lon: 115.86,
},
{
public_key: 'bbbbbbbbbbbb0000000000000000000000000000000000000000000000000000',
name: 'Neighbor',
type: 1,
flags: 0,
direct_path: null,
direct_path_len: 0,
direct_path_hash_mode: 0,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
last_advert: null,
lat: -31.94,
lon: 115.87,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
},
];
render(<RepeaterDashboard {...defaultProps} contacts={contactsWithAdvertAndNeighbor} />);
expect(screen.getByText('Dist')).toBeInTheDocument();
expect(screen.getByText('Using advert position')).toBeInTheDocument();
});
it('shows fetching state with attempt counter', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = { loading: true, attempt: 2, error: null };
@@ -401,6 +458,40 @@ describe('RepeaterDashboard', () => {
expect(screen.getByText(/Fetched .*Just now/)).toBeInTheDocument();
});
it('keeps repeater clock drift anchored to fetch time across remounts', () => {
vi.useFakeTimers();
try {
const fetchedAt = Date.UTC(2024, 0, 1, 12, 0, 0);
vi.setSystemTime(fetchedAt);
mockHook.loggedIn = true;
mockHook.paneData.nodeInfo = {
name: 'TestRepeater',
lat: null,
lon: null,
clock_utc: '11:59:30 - 1/1/2024 UTC',
};
mockHook.paneStates.nodeInfo = {
loading: false,
attempt: 1,
error: null,
fetched_at: fetchedAt,
};
const firstRender = render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText(/\(drift: 30s\)/)).toBeInTheDocument();
vi.setSystemTime(fetchedAt + 10 * 60 * 1000);
firstRender.unmount();
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText(/\(drift: 30s\)/)).toBeInTheDocument();
expect(screen.queryByText(/\(drift: 10m30s\)/)).not.toBeInTheDocument();
} finally {
vi.useRealTimers();
}
});
it('renders action buttons', () => {
mockHook.loggedIn = true;
@@ -429,15 +520,15 @@ describe('RepeaterDashboard', () => {
});
describe('path type display and reset', () => {
it('shows flood when last_path_len is -1', () => {
it('shows flood when direct_path_len is -1', () => {
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('flood')).toBeInTheDocument();
});
it('shows direct when last_path_len is 0', () => {
it('shows direct when direct_path_len is 0', () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
];
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
@@ -445,9 +536,9 @@ describe('RepeaterDashboard', () => {
expect(screen.getByText('direct')).toBeInTheDocument();
});
it('shows N hops when last_path_len > 0', () => {
it('shows N hops when direct_path_len > 0', () => {
const hoppedContacts: Contact[] = [
{ ...contacts[0], last_path_len: 3, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 3, last_seen: 1700000000 },
];
render(<RepeaterDashboard {...defaultProps} contacts={hoppedContacts} />);
@@ -457,7 +548,7 @@ describe('RepeaterDashboard', () => {
it('shows 1 hop (singular) for single hop', () => {
const oneHopContacts: Contact[] = [
{ ...contacts[0], last_path_len: 1, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 1, last_seen: 1700000000 },
];
render(<RepeaterDashboard {...defaultProps} contacts={oneHopContacts} />);
@@ -467,7 +558,7 @@ describe('RepeaterDashboard', () => {
it('direct path is clickable, underlined, and marked as editable', () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
];
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
@@ -482,7 +573,7 @@ describe('RepeaterDashboard', () => {
const forcedContacts: Contact[] = [
{
...contacts[0],
last_path_len: 1,
direct_path_len: 1,
last_seen: 1700000000,
route_override_path: 'ae92f13e',
route_override_len: 2,
@@ -498,7 +589,7 @@ describe('RepeaterDashboard', () => {
it('clicking direct path opens modal and can force direct routing', async () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
];
const { api } = await import('../api');
@@ -522,7 +613,7 @@ describe('RepeaterDashboard', () => {
it('closing the routing override modal does not call the API', async () => {
const directContacts: Contact[] = [
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
{ ...contacts[0], direct_path_len: 0, last_seen: 1700000000 },
];
const { api } = await import('../api');

View File

@@ -231,9 +231,9 @@ describe('SearchView', () => {
name: 'Bob',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,

View File

@@ -1,20 +1,28 @@
import { render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { describe, expect, it } from 'vitest';
import { SettingsAboutSection } from '../components/settings/SettingsAboutSection';
describe('SettingsAboutSection', () => {
beforeEach(() => {
vi.stubGlobal('__APP_VERSION__', '3.2.0-test');
vi.stubGlobal('__COMMIT_HASH__', 'deadbeef');
});
afterEach(() => {
vi.unstubAllGlobals();
});
it('renders the debug support snapshot link', () => {
render(<SettingsAboutSection />);
render(
<SettingsAboutSection
health={{
status: 'ok',
radio_connected: true,
radio_initializing: false,
connection_info: 'Serial: /dev/ttyUSB0',
app_info: {
version: '3.2.0-test',
commit_hash: 'deadbeef',
},
database_size_mb: 1.2,
oldest_undecrypted_timestamp: null,
fanout_statuses: {},
bots_disabled: false,
}}
/>
);
const link = screen.getByRole('link', { name: /Open debug support snapshot/i });
expect(link).toHaveAttribute('href', '/api/debug');

View File

@@ -6,6 +6,7 @@ import type {
AppSettings,
AppSettingsUpdate,
HealthStatus,
RadioAdvertMode,
RadioConfig,
RadioConfigUpdate,
RadioDiscoveryResponse,
@@ -63,6 +64,7 @@ const baseSettings: AppSettings = {
};
function renderModal(overrides?: {
config?: RadioConfig | null;
appSettings?: AppSettings;
health?: HealthStatus;
onSaveAppSettings?: (update: AppSettingsUpdate) => Promise<void>;
@@ -73,6 +75,7 @@ function renderModal(overrides?: {
onReboot?: () => Promise<void>;
onDisconnect?: () => Promise<void>;
onReconnect?: () => Promise<void>;
onAdvertise?: (mode: RadioAdvertMode) => Promise<void>;
meshDiscovery?: RadioDiscoveryResponse | null;
meshDiscoveryLoadingTarget?: RadioDiscoveryTarget | null;
onDiscoverMesh?: (target: RadioDiscoveryTarget) => Promise<void>;
@@ -92,12 +95,13 @@ function renderModal(overrides?: {
const onReboot = overrides?.onReboot ?? vi.fn(async () => {});
const onDisconnect = overrides?.onDisconnect ?? vi.fn(async () => {});
const onReconnect = overrides?.onReconnect ?? vi.fn(async () => {});
const onAdvertise = overrides?.onAdvertise ?? vi.fn(async (_mode: RadioAdvertMode) => {});
const onDiscoverMesh = overrides?.onDiscoverMesh ?? vi.fn(async () => {});
const commonProps = {
open: overrides?.open ?? true,
pageMode: overrides?.pageMode,
config: baseConfig,
config: overrides?.config === undefined ? baseConfig : overrides.config,
health: overrides?.health ?? baseHealth,
appSettings: overrides?.appSettings ?? baseSettings,
onClose,
@@ -107,7 +111,7 @@ function renderModal(overrides?: {
onReboot,
onDisconnect,
onReconnect,
onAdvertise: vi.fn(async () => {}),
onAdvertise,
meshDiscovery: overrides?.meshDiscovery ?? null,
meshDiscoveryLoadingTarget: overrides?.meshDiscoveryLoadingTarget ?? null,
onDiscoverMesh,
@@ -134,6 +138,7 @@ function renderModal(overrides?: {
onReboot,
onDisconnect,
onReconnect,
onAdvertise,
onDiscoverMesh,
view,
};
@@ -205,6 +210,62 @@ describe('SettingsModal', () => {
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
});
it('renders flood and zero-hop advert buttons and passes the selected mode', async () => {
const onAdvertise = vi.fn(async (_mode: RadioAdvertMode) => {});
renderModal({ onAdvertise });
openRadioSection();
fireEvent.click(screen.getByRole('button', { name: 'Send Flood Advertisement' }));
await waitFor(() => {
expect(onAdvertise).toHaveBeenCalledWith('flood');
});
fireEvent.click(screen.getByRole('button', { name: 'Send Zero-Hop Advertisement' }));
await waitFor(() => {
expect(onAdvertise).toHaveBeenCalledWith('zero_hop');
});
});
it('shows radio-unavailable message when config is null', () => {
renderModal({ config: null });
const radioToggle = screen.getByRole('button', { name: /Radio/i });
expect(radioToggle).not.toBeDisabled();
fireEvent.click(radioToggle);
expect(screen.getByText('Radio is not available.')).toBeInTheDocument();
});
it('shows radio-unavailable message in sidebar-nav mode when config is null', () => {
renderModal({
config: null,
externalSidebarNav: true,
desktopSection: 'radio',
});
expect(screen.getByText('Radio is not available.')).toBeInTheDocument();
});
it('shows cached radio firmware and capacity info under the connection status', () => {
renderModal({
health: {
...baseHealth,
radio_device_info: {
model: 'T-Echo',
firmware_build: '2025-02-01',
firmware_version: '1.2.3',
max_contacts: 350,
max_channels: 64,
},
},
});
openRadioSection();
expect(
screen.getByText('T-Echo running 2025-02-01/1.2.3 (max: 350 contacts, 64 channels)')
).toBeInTheDocument();
});
it('shows reconnect action when radio connection is paused', () => {
renderModal({
health: { ...baseHealth, radio_state: 'paused' },

View File

@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Sidebar } from '../components/Sidebar';
import { CONTACT_TYPE_REPEATER, type Channel, type Contact, type Favorite } from '../types';
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
function makeChannel(key: string, name: string): Channel {
return {
@@ -15,15 +16,20 @@ function makeChannel(key: string, name: string): Channel {
};
}
function makeContact(public_key: string, name: string, type = 1): Contact {
function makeContact(
public_key: string,
name: string,
type = 1,
overrides: Partial<Contact> = {}
): Contact {
return {
public_key,
name,
type,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: 0,
direct_path: null,
direct_path_len: -1,
direct_path_hash_mode: 0,
last_advert: null,
lat: null,
lon: null,
@@ -32,6 +38,7 @@ function makeContact(public_key: string, name: string, type = 1): Contact {
last_contacted: null,
last_read_at: null,
first_seen: null,
...overrides,
};
}
@@ -75,8 +82,7 @@ function renderSidebar(overrides?: {
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={favorites}
sortOrder="recent"
onSortOrderChange={vi.fn()}
legacySortOrder="recent"
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
/>
);
@@ -85,7 +91,7 @@ function renderSidebar(overrides?: {
}
function getSectionHeaderContainer(title: string): HTMLElement {
const btn = screen.getByRole('button', { name: new RegExp(title, 'i') });
const btn = screen.getByRole('button', { name: title });
const container = btn.closest('div');
if (!container) throw new Error(`Missing header container for section ${title}`);
return container;
@@ -142,9 +148,9 @@ describe('Sidebar section summaries', () => {
it('expands collapsed sections during search and restores collapse state after clearing search', async () => {
const { opsChannel, aliceName } = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: /Tools/i }));
fireEvent.click(screen.getByRole('button', { name: /Channels/i }));
fireEvent.click(screen.getByRole('button', { name: /Contacts/i }));
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
@@ -169,9 +175,9 @@ describe('Sidebar section summaries', () => {
it('persists collapsed section state across unmount and remount', () => {
const { opsChannel, aliceName, unmount } = renderSidebar();
fireEvent.click(screen.getByRole('button', { name: /Tools/i }));
fireEvent.click(screen.getByRole('button', { name: /Channels/i }));
fireEvent.click(screen.getByRole('button', { name: /Contacts/i }));
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
@@ -206,8 +212,7 @@ describe('Sidebar section summaries', () => {
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
sortOrder="recent"
onSortOrderChange={vi.fn()}
legacySortOrder="recent"
/>
);
@@ -253,4 +258,188 @@ describe('Sidebar section summaries', () => {
const unread = within(aliceRow).getByText('3');
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it('sorts each section independently and persists per-section sort preferences', () => {
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
const zebraChannel = makeChannel('BB'.repeat(16), '#zebra');
const alphaChannel = makeChannel('CC'.repeat(16), '#alpha');
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
const amy = makeContact('22'.repeat(32), 'Amy');
const relayZulu = makeContact('33'.repeat(32), 'Zulu Relay', CONTACT_TYPE_REPEATER, {
last_seen: 100,
});
const relayAlpha = makeContact('44'.repeat(32), 'Alpha Relay', CONTACT_TYPE_REPEATER, {
last_seen: 300,
});
const props = {
contacts: [zed, amy, relayZulu, relayAlpha],
channels: [publicChannel, zebraChannel, alphaChannel],
activeConversation: null,
onSelectConversation: vi.fn(),
onNewMessage: vi.fn(),
lastMessageTimes: {
[getStateKey('channel', zebraChannel.key)]: 300,
[getStateKey('channel', alphaChannel.key)]: 100,
[getStateKey('contact', zed.public_key)]: 200,
},
unreadCounts: {},
mentions: {},
showCracker: false,
crackerRunning: false,
onToggleCracker: vi.fn(),
onMarkAllRead: vi.fn(),
favorites: [],
legacySortOrder: 'recent' as const,
};
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
const getContactsOrder = () =>
screen
.getAllByText(/^(Amy|Zed)$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
const getRepeatersOrder = () =>
screen
.getAllByText(/Relay$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
const { unmount } = render(<Sidebar {...props} />);
expect(getChannelsOrder()).toEqual(['#zebra', '#alpha']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' }));
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
unmount();
render(<Sidebar {...props} />);
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
});
it('sorts contacts by DM recency first, then advert recency, then no-recency at the bottom', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const dmRecent = makeContact('11'.repeat(32), 'DM Recent', 1, { last_advert: 100 });
const advertOnly = makeContact('22'.repeat(32), 'Advert Only', 1, { last_seen: 300 });
const noRecency = makeContact('33'.repeat(32), 'No Recency');
render(
<Sidebar
contacts={[noRecency, advertOnly, dmRecent]}
channels={[publicChannel]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={{
[getStateKey('contact', dmRecent.public_key)]: 400,
}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
const contactRows = screen
.getAllByText(/^(DM Recent|Advert Only|No Recency)$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
expect(contactRows).toEqual(['DM Recent', 'Advert Only', 'No Recency']);
});
it('sorts repeaters by heard recency even when message times disagree', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const staleMessageRelay = makeContact(
'44'.repeat(32),
'Stale Message Relay',
CONTACT_TYPE_REPEATER,
{
last_seen: 100,
}
);
const freshAdvertRelay = makeContact(
'55'.repeat(32),
'Fresh Advert Relay',
CONTACT_TYPE_REPEATER,
{
last_advert: 500,
}
);
render(
<Sidebar
contacts={[staleMessageRelay, freshAdvertRelay]}
channels={[publicChannel]}
activeConversation={null}
onSelectConversation={vi.fn()}
onNewMessage={vi.fn()}
lastMessageTimes={{
[getStateKey('contact', staleMessageRelay.public_key)]: 1000,
[getStateKey('contact', freshAdvertRelay.public_key)]: 50,
}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="recent"
/>
);
const repeaterRows = screen
.getAllByText(/Relay$/)
.map((node) => node.textContent)
.filter((text): text is string => Boolean(text));
expect(repeaterRows).toEqual(['Fresh Advert Relay', 'Stale Message Relay']);
});
it('pins only the canonical Public channel to the top of channel sorting', () => {
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
const fakePublic = makeChannel('DD'.repeat(16), 'Public');
const alphaChannel = makeChannel('CC'.repeat(16), '#alpha');
const onSelectConversation = vi.fn();
render(
<Sidebar
contacts={[]}
channels={[fakePublic, alphaChannel, publicChannel]}
activeConversation={null}
onSelectConversation={onSelectConversation}
onNewMessage={vi.fn()}
lastMessageTimes={{}}
unreadCounts={{}}
mentions={{}}
showCracker={false}
crackerRunning={false}
onToggleCracker={vi.fn()}
onMarkAllRead={vi.fn()}
favorites={[]}
legacySortOrder="alpha"
/>
);
fireEvent.click(screen.getAllByText('Public')[0]);
expect(onSelectConversation).toHaveBeenCalledWith({
type: 'channel',
id: PUBLIC_CHANNEL_KEY,
name: 'Public',
});
});
});

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