mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
65 Commits
better-pat
...
richer-ins
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
094058bad7 | ||
|
|
88c99e0983 | ||
|
|
983a37f68f | ||
|
|
fd8bc4b56a | ||
|
|
4ead2ffcde | ||
|
|
caf4bf4eff | ||
|
|
74e1f49db8 | ||
|
|
3b28ebfa49 | ||
|
|
d36c63f6b1 | ||
|
|
e8a4f5c349 | ||
|
|
b022aea71f | ||
|
|
5225a1c766 | ||
|
|
41400c0528 | ||
|
|
07928d930c | ||
|
|
26742d0c88 | ||
|
|
8b73bef30b | ||
|
|
4b583fe337 | ||
|
|
e6e7267eb1 | ||
|
|
36eeeae64d | ||
|
|
7c988ae3d0 | ||
|
|
1a0c4833d5 | ||
|
|
84c500d018 | ||
|
|
1960a16fb0 | ||
|
|
3580aeda5a | ||
|
|
bb97b983bb | ||
|
|
da31b67d54 | ||
|
|
d840159f9c | ||
|
|
9de4158a6c | ||
|
|
1e21644d74 | ||
|
|
df0ed8452b | ||
|
|
d4a5f0f728 | ||
|
|
3e2c48457d | ||
|
|
d4f518df0c | ||
|
|
5213c8c84c | ||
|
|
33c2b0c948 | ||
|
|
b021a4a8ac | ||
|
|
c74fdec10b | ||
|
|
cf314e02ff | ||
|
|
8ae600d010 | ||
|
|
fdd82e1f77 | ||
|
|
9d129260fd | ||
|
|
2b80760696 | ||
|
|
c2655c1809 | ||
|
|
cee7103ec6 | ||
|
|
d05312c157 | ||
|
|
5b166c4b66 | ||
|
|
dbe2915635 | ||
|
|
2337d7b592 | ||
|
|
62080424bb | ||
|
|
1ae76848fe | ||
|
|
45ed430580 | ||
|
|
5f8ce16855 | ||
|
|
b79249c4a0 | ||
|
|
85d1a940dc | ||
|
|
b85d451e26 | ||
|
|
41a297c944 | ||
|
|
41d64d86d4 | ||
|
|
bd336e3ee2 | ||
|
|
cf585cdf87 | ||
|
|
417a583696 | ||
|
|
541dba6a75 | ||
|
|
720b8be64f | ||
|
|
2b5083e889 | ||
|
|
5975006cf7 | ||
|
|
69e09378f5 |
18
AGENTS.md
18
AGENTS.md
@@ -165,7 +165,8 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
||||
|
||||
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`).
|
||||
- If the initial `MSG_SENT` result includes an expected ACK code, background retries are armed.
|
||||
- Non-final retry 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`.
|
||||
@@ -174,7 +175,7 @@ Direct-message send behavior intentionally mirrors the firmware/library `send_ms
|
||||
|
||||
**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.
|
||||
Outgoing DMs send once immediately, then may retry up to 2 more times in the background only when the initial `MSG_SENT` result includes an expected ACK code and the message remains 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.
|
||||
|
||||
@@ -295,9 +296,9 @@ cd frontend
|
||||
npm run test:run
|
||||
```
|
||||
|
||||
### Before Completing Changes
|
||||
### Before Completing Major 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.
|
||||
**Run `./scripts/all_quality.sh` before finishing major 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. For minor changes (like wording, color, spacing, etc.), wait until prompted to run the quality gate.
|
||||
|
||||
## API Summary
|
||||
|
||||
@@ -307,8 +308,8 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
|
||||
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
||||
| 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 |
|
||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled` |
|
||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, `multi_acks_enabled`, radio params, and `path_hash_mode` when supported |
|
||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||
| 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 |
|
||||
@@ -324,6 +325,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
||||
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
|
||||
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
|
||||
| POST | `/api/contacts/{public_key}/path-discovery` | Discover forward/return paths and persist the learned direct route |
|
||||
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
|
||||
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
|
||||
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
|
||||
@@ -348,7 +350,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| GET | `/api/packets/undecrypted/count` | Count of undecrypted packets |
|
||||
| POST | `/api/packets/decrypt/historical` | Decrypt stored packets |
|
||||
| POST | `/api/packets/maintenance` | Delete old packets and vacuum |
|
||||
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times |
|
||||
| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times, and `last_read_ats` boundaries |
|
||||
| POST | `/api/read-state/mark-all-read` | Mark all conversations as read |
|
||||
| GET | `/api/settings` | Get app settings |
|
||||
| PATCH | `/api/settings` | Update app settings |
|
||||
@@ -398,7 +400,7 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
|
||||
- Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at`
|
||||
- Updated via `POST /api/contacts/{public_key}/mark-read` and `POST /api/channels/{key}/mark-read`
|
||||
- Bulk update via `POST /api/read-state/mark-all-read`
|
||||
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation)
|
||||
- Aggregated counts via `GET /api/read-state/unreads` (server-side computation of counts, mention flags, `last_message_times`, and `last_read_ats`)
|
||||
|
||||
**State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting):
|
||||
- Channels: `channel-{channel_key}`
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -1,3 +1,36 @@
|
||||
## [3.6.0] - 2026-03-22
|
||||
|
||||
Feature: Add incoming-packet analytics
|
||||
Feature: BYOPacket for analysis
|
||||
Feature: Add room activity to stats view
|
||||
Bugfix: Handle Heltec v3 serial noise
|
||||
Misc: Swap repeaters and room servers for better ordering
|
||||
|
||||
## [3.5.0] - 2026-03-19
|
||||
|
||||
Feature: Add room server alpha support
|
||||
Feature: Add option to force-reset node clock when it's too far ahead
|
||||
Feature: DMs auto-retry before resorting to flood
|
||||
Feature: Add impulse zero-hop advert
|
||||
Feature: Utilize PATH packets to correctly source a contact's route
|
||||
Feature: Metrics view on raw packet pane
|
||||
Feature: Metric, Imperial, and Smoots are now selectable for distance display
|
||||
Feature: Allow favorites to be sorted
|
||||
Feature: Add multi-ack support
|
||||
Feature: Password-remember checkbox on repeaters + room servers
|
||||
Bugfix: Serialize radio disconnect in a lock
|
||||
Bugfix: Fix contact bar layout issues
|
||||
Bugfix: Fix sidebar ordering for contacts by advert recency
|
||||
Bugfix: Fix version reporting in community MQTT
|
||||
Bugfix: Fix Apprise duplicate names
|
||||
Bugfix: Be better about identity resolution in the stats pane
|
||||
Misc: Docs, test, and performance enhancements
|
||||
Misc: Don't prompt "Are you sure" when leaving an unedited interation
|
||||
Misc: Log node time on startup
|
||||
Misc: Improve community MQTT error bubble-up
|
||||
Misc: Unread DMs always have a red unread counter
|
||||
Misc: Improve information in the debug view to show DB status
|
||||
|
||||
## [3.4.1] - 2026-03-16
|
||||
|
||||
Bugfix: Improve handling of version information on prebuilt bundles
|
||||
|
||||
@@ -330,7 +330,7 @@ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
|
||||
</details>
|
||||
|
||||
### meshcore (2.2.29) — MIT
|
||||
### meshcore (2.3.2) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
10
README.md
10
README.md
@@ -7,7 +7,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Run multiple Python bots that can analyze messages and respond to DMs and channels
|
||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Search for hashtag room names for channels you don't have keys for yet
|
||||
* Search for hashtag channel names for channels you don't have keys for yet
|
||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||
@@ -41,8 +41,6 @@ If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
- [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>
|
||||
|
||||
@@ -97,6 +95,8 @@ Access the app at http://localhost:8000.
|
||||
|
||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
|
||||
On Linux, if you want this installed as a persistent `systemd` service that starts on boot and restarts automatically on failure, run `bash scripts/install_service.sh` from the repo root.
|
||||
|
||||
## 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.
|
||||
@@ -111,6 +111,8 @@ uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||
|
||||
The release bundle includes `frontend/prebuilt`, so it does not require a local frontend build.
|
||||
|
||||
Alternatively, if you have already cloned the repo, you can fetch just the prebuilt frontend into your working tree without downloading the full release zip via `python3 scripts/fetch_prebuilt_frontend.py`.
|
||||
|
||||
## Path 2: Docker
|
||||
|
||||
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||
@@ -192,7 +194,7 @@ $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.
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
|
||||
|
||||
## Where To Go Next
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
|
||||
|
||||
## HTTPS
|
||||
|
||||
WebGPU room-finding requires a secure context when you are not on `localhost`.
|
||||
WebGPU channel-finding requires a secure context when you are not on `localhost`.
|
||||
|
||||
Generate a local cert and start the backend with TLS:
|
||||
|
||||
@@ -46,59 +46,37 @@ Accept the browser warning, or use [mkcert](https://github.com/FiloSottile/mkcer
|
||||
|
||||
## Systemd Service
|
||||
|
||||
Assumes you are running from `/opt/remoteterm`; adjust paths if you deploy elsewhere.
|
||||
Two paths are available depending on your comfort level with Linux system administration.
|
||||
|
||||
### Simple install (recommended for most users)
|
||||
|
||||
On Linux systems, this is the recommended installation method if you want RemoteTerm set up as a persistent systemd service that starts automatically on boot and restarts automatically if it crashes. Run the installer script from the repo root. It runs as your current user, installs from wherever you cloned the repo, and prints a quick-reference cheatsheet when done — no separate service account or path juggling required.
|
||||
|
||||
```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
|
||||
bash scripts/install_service.sh
|
||||
```
|
||||
|
||||
Create `/etc/systemd/system/remoteterm.service` with:
|
||||
The script interactively asks which transport to use (serial auto-detect, serial with explicit port, TCP, or BLE), whether to build the frontend locally or download a prebuilt copy, whether to enable the bot system, and whether to set up HTTP Basic Auth. It handles dependency installation (`uv sync`), validates `node`/`npm` for local builds, adds your user to the `dialout` group if needed, writes the systemd unit file, and enables the service. After installation, normal operations work without any `sudo -u` gymnastics:
|
||||
|
||||
```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:
|
||||
You can also rerun the script later to change transport, bot, or auth settings. If the service is already running, the installer stops it, rewrites the unit file, reloads systemd, and starts it again with the new configuration.
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now remoteterm
|
||||
sudo systemctl status remoteterm
|
||||
# Update to latest and restart
|
||||
cd /path/to/repo
|
||||
git pull
|
||||
uv sync
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
sudo systemctl restart remoteterm
|
||||
|
||||
# Refresh prebuilt frontend only (skips local build)
|
||||
python3 scripts/fetch_prebuilt_frontend.py
|
||||
sudo systemctl restart remoteterm
|
||||
|
||||
# View live logs
|
||||
sudo journalctl -u remoteterm -f
|
||||
|
||||
# Service control
|
||||
sudo systemctl start|stop|restart|status remoteterm
|
||||
```
|
||||
|
||||
## Debug Logging And Bug Reports
|
||||
|
||||
@@ -101,7 +101,7 @@ app/
|
||||
- Packet `path_len` values are hop counts, not byte counts.
|
||||
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
|
||||
- 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.
|
||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same channel reuse the loaded slot; new channels fill free slots up to the discovered channel capacity, then evict the least recently used cached channel.
|
||||
- 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 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.
|
||||
@@ -113,16 +113,16 @@ app/
|
||||
### Read/unread state
|
||||
|
||||
- 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`.
|
||||
- `GET /api/read-state/unreads` returns counts, mention flags, `last_message_times`, and `last_read_ats`.
|
||||
|
||||
### 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.
|
||||
- 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 only when the initial `MSG_SENT` result includes an expected ACK code and the message remains 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(...)`.
|
||||
- Direct-message send behavior is intended to emulate `meshcore_py.commands.send_msg_with_retry(...)` when the radio provides an expected ACK code: 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.
|
||||
@@ -169,8 +169,8 @@ app/
|
||||
- `GET /debug` — support snapshot with recent logs, live radio probe, slot/contact audits, and version/git info
|
||||
|
||||
### Radio
|
||||
- `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
|
||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, advert-location on/off, and `multi_acks_enabled`
|
||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it, and `multi_acks_enabled`
|
||||
- `PUT /radio/private-key`
|
||||
- `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
|
||||
@@ -188,6 +188,7 @@ app/
|
||||
- `POST /contacts/{public_key}/command`
|
||||
- `POST /contacts/{public_key}/routing-override`
|
||||
- `POST /contacts/{public_key}/trace`
|
||||
- `POST /contacts/{public_key}/path-discovery` — discover forward/return paths, persist the learned direct route, and sync it back to the radio best-effort
|
||||
- `POST /contacts/{public_key}/repeater/login`
|
||||
- `POST /contacts/{public_key}/repeater/status`
|
||||
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
|
||||
@@ -219,7 +220,7 @@ app/
|
||||
- `POST /packets/maintenance`
|
||||
|
||||
### Read state
|
||||
- `GET /read-state/unreads`
|
||||
- `GET /read-state/unreads` — counts, mention flags, `last_message_times`, and `last_read_ats`
|
||||
- `POST /read-state/mark-all-read`
|
||||
|
||||
### Settings
|
||||
|
||||
@@ -58,6 +58,15 @@ class DecryptedDirectMessage:
|
||||
message: str
|
||||
dest_hash: str # First byte of destination pubkey as hex
|
||||
src_hash: str # First byte of sender pubkey as hex
|
||||
signed_sender_prefix: str | None = None
|
||||
|
||||
@property
|
||||
def txt_type(self) -> int:
|
||||
return self.flags >> 2
|
||||
|
||||
@property
|
||||
def attempt(self) -> int:
|
||||
return self.flags & 0x03
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -498,6 +507,13 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
||||
|
||||
# Extract message text (UTF-8, null-padded)
|
||||
message_bytes = decrypted[5:]
|
||||
signed_sender_prefix: str | None = None
|
||||
txt_type = flags >> 2
|
||||
if txt_type == 2:
|
||||
if len(message_bytes) < 4:
|
||||
return None
|
||||
signed_sender_prefix = message_bytes[:4].hex()
|
||||
message_bytes = message_bytes[4:]
|
||||
try:
|
||||
message_text = message_bytes.decode("utf-8")
|
||||
# Truncate at first null terminator (consistent with channel message handling)
|
||||
@@ -513,6 +529,7 @@ def decrypt_direct_message(payload: bytes, shared_secret: bytes) -> DecryptedDir
|
||||
message=message_text,
|
||||
dest_hash=dest_hash,
|
||||
src_hash=src_hash,
|
||||
signed_sender_prefix=signed_sender_prefix,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.models import CONTACT_TYPE_ROOM, Contact, ContactUpsert
|
||||
from app.packet_processor import process_raw_packet
|
||||
from app.repository import (
|
||||
ContactRepository,
|
||||
@@ -17,6 +17,7 @@ from app.services.contact_reconciliation import (
|
||||
from app.services.dm_ack_apply import apply_dm_ack_code
|
||||
from app.services.dm_ingest import (
|
||||
ingest_fallback_direct_message,
|
||||
resolve_direct_message_sender_metadata,
|
||||
resolve_fallback_direct_message_context,
|
||||
)
|
||||
from app.websocket import broadcast_event
|
||||
@@ -87,6 +88,23 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_timestamp = ts if ts is not None else received_at
|
||||
path = payload.get("path")
|
||||
path_len = payload.get("path_len")
|
||||
sender_name = context.sender_name
|
||||
sender_key = context.sender_key
|
||||
signature = payload.get("signature")
|
||||
if (
|
||||
context.contact is not None
|
||||
and context.contact.type == CONTACT_TYPE_ROOM
|
||||
and txt_type == 2
|
||||
and isinstance(signature, str)
|
||||
and signature
|
||||
):
|
||||
sender_name, sender_key = await resolve_direct_message_sender_metadata(
|
||||
sender_public_key=signature,
|
||||
received_at=received_at,
|
||||
broadcast_fn=broadcast_event,
|
||||
contact_repository=ContactRepository,
|
||||
log=logger,
|
||||
)
|
||||
message = await ingest_fallback_direct_message(
|
||||
conversation_key=context.conversation_key,
|
||||
text=payload.get("text", ""),
|
||||
@@ -95,9 +113,9 @@ async def on_contact_message(event: "Event") -> None:
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
sender_name=context.sender_name,
|
||||
sender_key=context.sender_key,
|
||||
signature=signature,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
broadcast_fn=broadcast_event,
|
||||
update_last_contacted_key=context.contact.public_key.lower() if context.contact else None,
|
||||
)
|
||||
|
||||
@@ -44,6 +44,7 @@ class MessageAckedPayload(TypedDict):
|
||||
message_id: int
|
||||
ack_count: int
|
||||
paths: NotRequired[list[MessagePath]]
|
||||
packet_id: NotRequired[int | None]
|
||||
|
||||
|
||||
class ToastPayload(TypedDict):
|
||||
|
||||
@@ -65,6 +65,7 @@ Wraps bot code execution via `app/fanout/bot_exec.py`. Config blob:
|
||||
- `code` — Python bot function source code
|
||||
- Executes in a thread pool with timeout and semaphore concurrency control
|
||||
- Rate-limits outgoing messages for repeater compatibility
|
||||
- Channel `message_text` passed to bot code is normalized for human readability by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender.
|
||||
|
||||
### webhook (webhook.py)
|
||||
HTTP webhook delivery. Config blob:
|
||||
@@ -78,6 +79,7 @@ Push notifications via Apprise library. Config blob:
|
||||
- `urls` — newline-separated Apprise notification service URLs
|
||||
- `preserve_identity` — suppress Discord webhook name/avatar override
|
||||
- `include_path` — include routing path in notification body
|
||||
- Channel notifications normalize stored message text by stripping a leading `"{sender_name}: "` prefix when it matches the payload sender so alerts do not duplicate the name.
|
||||
|
||||
### sqs (sqs.py)
|
||||
Amazon SQS delivery. Config blob:
|
||||
|
||||
@@ -6,7 +6,7 @@ import asyncio
|
||||
import logging
|
||||
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
from app.fanout.base import FanoutModule, get_fanout_message_text
|
||||
from app.path_utils import split_path_hex
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -39,7 +39,7 @@ def _normalize_discord_url(url: str) -> str:
|
||||
def _format_body(data: dict, *, include_path: bool) -> str:
|
||||
"""Build a human-readable notification body from message data."""
|
||||
msg_type = data.get("type", "")
|
||||
text = data.get("text", "")
|
||||
text = get_fanout_message_text(data)
|
||||
sender_name = data.get("sender_name") or "Unknown"
|
||||
|
||||
via = ""
|
||||
|
||||
@@ -33,3 +33,30 @@ class FanoutModule:
|
||||
def status(self) -> str:
|
||||
"""Return 'connected', 'disconnected', or 'error'."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
def get_fanout_message_text(data: dict) -> str:
|
||||
"""Return the best human-readable message body for fanout consumers.
|
||||
|
||||
Channel messages are stored with the rendered sender label embedded in the
|
||||
text (for example ``"Alice: hello"``). Human-facing integrations that also
|
||||
carry ``sender_name`` should strip that duplicated prefix when it matches
|
||||
the payload sender exactly.
|
||||
"""
|
||||
|
||||
text = data.get("text", "")
|
||||
if not isinstance(text, str):
|
||||
return ""
|
||||
|
||||
if data.get("type") != "CHAN":
|
||||
return text
|
||||
|
||||
sender_name = data.get("sender_name")
|
||||
if not isinstance(sender_name, str) or not sender_name:
|
||||
return text
|
||||
|
||||
prefix, separator, remainder = text.partition(": ")
|
||||
if separator and prefix == sender_name:
|
||||
return remainder
|
||||
|
||||
return text
|
||||
|
||||
@@ -115,7 +115,7 @@ def _generate_jwt_token(
|
||||
"exp": now + _TOKEN_LIFETIME,
|
||||
"aud": audience,
|
||||
"owner": pubkey_hex,
|
||||
"client": _CLIENT_ID,
|
||||
"client": _get_client_version(),
|
||||
}
|
||||
if email:
|
||||
payload["email"] = email
|
||||
@@ -260,8 +260,10 @@ def _build_radio_info() -> str:
|
||||
|
||||
|
||||
def _get_client_version() -> str:
|
||||
"""Return the app version string for community MQTT payloads."""
|
||||
return get_app_build_info().version
|
||||
"""Return the canonical client/version identifier for community MQTT."""
|
||||
build = get_app_build_info()
|
||||
commit_hash = build.commit_hash or "unknown"
|
||||
return f"{_CLIENT_ID}/{build.version}-{commit_hash}"
|
||||
|
||||
|
||||
class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
|
||||
@@ -82,6 +82,21 @@ class FanoutManager:
|
||||
def __init__(self) -> None:
|
||||
self._modules: dict[str, tuple[FanoutModule, dict]] = {} # id -> (module, scope)
|
||||
self._restart_locks: dict[str, asyncio.Lock] = {}
|
||||
self._bots_disabled_until_restart = False
|
||||
|
||||
def get_bots_disabled_source(self) -> str | None:
|
||||
"""Return why bot modules are unavailable, if at all."""
|
||||
from app.config import settings as server_settings
|
||||
|
||||
if server_settings.disable_bots:
|
||||
return "env"
|
||||
if self._bots_disabled_until_restart:
|
||||
return "until_restart"
|
||||
return None
|
||||
|
||||
def bots_disabled_effective(self) -> bool:
|
||||
"""Return True when bot modules should be treated as unavailable."""
|
||||
return self.get_bots_disabled_source() is not None
|
||||
|
||||
async def load_from_db(self) -> None:
|
||||
"""Read enabled fanout_configs and instantiate modules."""
|
||||
@@ -99,13 +114,14 @@ class FanoutManager:
|
||||
config_blob = cfg["config"]
|
||||
scope = cfg["scope"]
|
||||
|
||||
# Skip bot modules when bots are disabled server-wide
|
||||
if config_type == "bot":
|
||||
from app.config import settings as server_settings
|
||||
|
||||
if server_settings.disable_bots:
|
||||
logger.info("Skipping bot module %s (bots disabled by server config)", config_id)
|
||||
return
|
||||
# Skip bot modules when bots are disabled server-wide or until restart.
|
||||
if config_type == "bot" and self.bots_disabled_effective():
|
||||
logger.info(
|
||||
"Skipping bot module %s (bots disabled: %s)",
|
||||
config_id,
|
||||
self.get_bots_disabled_source(),
|
||||
)
|
||||
return
|
||||
|
||||
cls = _MODULE_TYPES.get(config_type)
|
||||
if cls is None:
|
||||
@@ -240,6 +256,26 @@ class FanoutManager:
|
||||
}
|
||||
return result
|
||||
|
||||
async def disable_bots_until_restart(self) -> str:
|
||||
"""Stop active bot modules and prevent them from starting again until restart."""
|
||||
source = self.get_bots_disabled_source()
|
||||
if source == "env":
|
||||
return source
|
||||
|
||||
self._bots_disabled_until_restart = True
|
||||
|
||||
from app.repository.fanout import _configs_cache
|
||||
|
||||
bot_ids = [
|
||||
config_id
|
||||
for config_id in list(self._modules)
|
||||
if _configs_cache.get(config_id, {}).get("type") == "bot"
|
||||
]
|
||||
for config_id in bot_ids:
|
||||
await self.remove_config(config_id)
|
||||
|
||||
return "until_restart"
|
||||
|
||||
|
||||
# Module-level singleton
|
||||
fanout_manager = FanoutManager()
|
||||
|
||||
@@ -139,6 +139,18 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
@app.get("/{path:path}")
|
||||
async def serve_frontend(path: str):
|
||||
"""Serve frontend files, falling back to index.html for SPA routing."""
|
||||
if path == "api" or path.startswith("api/"):
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={
|
||||
"detail": (
|
||||
"API endpoint not found. If you are seeing this in response to a "
|
||||
"frontend request, you may be running a newer frontend with an older "
|
||||
"backend or vice versa. A full update is suggested."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
file_path = (frontend_dir / path).resolve()
|
||||
try:
|
||||
file_path.relative_to(frontend_dir)
|
||||
|
||||
@@ -32,6 +32,7 @@ from app.routers import (
|
||||
radio,
|
||||
read_state,
|
||||
repeaters,
|
||||
rooms,
|
||||
settings,
|
||||
statistics,
|
||||
ws,
|
||||
@@ -134,6 +135,7 @@ app.include_router(fanout.router, prefix="/api")
|
||||
app.include_router(radio.router, prefix="/api")
|
||||
app.include_router(contacts.router, prefix="/api")
|
||||
app.include_router(repeaters.router, prefix="/api")
|
||||
app.include_router(rooms.router, prefix="/api")
|
||||
app.include_router(channels.router, prefix="/api")
|
||||
app.include_router(messages.router, prefix="/api")
|
||||
app.include_router(packets.router, prefix="/api")
|
||||
|
||||
@@ -353,6 +353,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 45)
|
||||
applied += 1
|
||||
|
||||
# Migration 46: Clean orphaned contact child rows left by old prefix promotion
|
||||
if version < 46:
|
||||
logger.info("Applying migration 46: clean orphaned contact child rows")
|
||||
await _migrate_046_cleanup_orphaned_contact_child_rows(conn)
|
||||
await set_version(conn, 46)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -2773,3 +2780,91 @@ async def _migrate_045_rebuild_contacts_direct_route_columns(conn: aiosqlite.Con
|
||||
await conn.execute("DROP TABLE contacts")
|
||||
await conn.execute("ALTER TABLE contacts_new RENAME TO contacts")
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_046_cleanup_orphaned_contact_child_rows(conn: aiosqlite.Connection) -> None:
|
||||
"""Move uniquely resolvable orphan contact child rows onto full contacts, drop the rest."""
|
||||
existing_tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
existing_tables = {row[0] for row in await existing_tables_cursor.fetchall()}
|
||||
if "contacts" not in existing_tables:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
child_tables = [
|
||||
table
|
||||
for table in ("contact_name_history", "contact_advert_paths")
|
||||
if table in existing_tables
|
||||
]
|
||||
if not child_tables:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
orphan_keys: set[str] = set()
|
||||
|
||||
for table in child_tables:
|
||||
cursor = await conn.execute(
|
||||
f"""
|
||||
SELECT DISTINCT child.public_key
|
||||
FROM {table} child
|
||||
LEFT JOIN contacts c ON c.public_key = child.public_key
|
||||
WHERE c.public_key IS NULL
|
||||
"""
|
||||
)
|
||||
orphan_keys.update(row[0] for row in await cursor.fetchall())
|
||||
|
||||
for orphan_key in sorted(orphan_keys, key=len, reverse=True):
|
||||
match_cursor = await conn.execute(
|
||||
"""
|
||||
SELECT public_key
|
||||
FROM contacts
|
||||
WHERE length(public_key) = 64
|
||||
AND public_key LIKE ? || '%'
|
||||
ORDER BY public_key
|
||||
""",
|
||||
(orphan_key.lower(),),
|
||||
)
|
||||
matches = [row[0] for row in await match_cursor.fetchall()]
|
||||
resolved_key = matches[0] if len(matches) == 1 else None
|
||||
|
||||
if resolved_key is not None:
|
||||
if "contact_name_history" in child_tables:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
|
||||
SELECT ?, name, first_seen, last_seen
|
||||
FROM contact_name_history
|
||||
WHERE public_key = ?
|
||||
ON CONFLICT(public_key, name) DO UPDATE SET
|
||||
first_seen = MIN(contact_name_history.first_seen, excluded.first_seen),
|
||||
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
|
||||
""",
|
||||
(resolved_key, orphan_key),
|
||||
)
|
||||
if "contact_advert_paths" in child_tables:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_advert_paths
|
||||
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
|
||||
SELECT ?, path_hex, path_len, first_seen, last_seen, heard_count
|
||||
FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
|
||||
first_seen = MIN(contact_advert_paths.first_seen, excluded.first_seen),
|
||||
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
|
||||
heard_count = contact_advert_paths.heard_count + excluded.heard_count
|
||||
""",
|
||||
(resolved_key, orphan_key),
|
||||
)
|
||||
|
||||
if "contact_name_history" in child_tables:
|
||||
await conn.execute(
|
||||
"DELETE FROM contact_name_history WHERE public_key = ?",
|
||||
(orphan_key,),
|
||||
)
|
||||
if "contact_advert_paths" in child_tables:
|
||||
await conn.execute(
|
||||
"DELETE FROM contact_advert_paths WHERE public_key = ?",
|
||||
(orphan_key,),
|
||||
)
|
||||
|
||||
await conn.commit()
|
||||
|
||||
@@ -231,6 +231,7 @@ class ContactRoutingOverrideRequest(BaseModel):
|
||||
|
||||
# Contact type constants
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
CONTACT_TYPE_ROOM = 3
|
||||
|
||||
|
||||
class ContactAdvertPath(BaseModel):
|
||||
@@ -265,7 +266,7 @@ class ContactNameHistory(BaseModel):
|
||||
|
||||
|
||||
class ContactActiveRoom(BaseModel):
|
||||
"""A channel/room where a contact has been active."""
|
||||
"""A channel where a contact has been active."""
|
||||
|
||||
channel_key: str
|
||||
channel_name: str
|
||||
@@ -412,6 +413,10 @@ class Message(BaseModel):
|
||||
acked: int = 0
|
||||
sender_name: str | None = None
|
||||
channel_name: str | None = None
|
||||
packet_id: int | None = Field(
|
||||
default=None,
|
||||
description="Representative raw packet row ID when archival raw bytes exist",
|
||||
)
|
||||
|
||||
|
||||
class MessagesAroundResponse(BaseModel):
|
||||
@@ -457,6 +462,21 @@ class RawPacketBroadcast(BaseModel):
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
|
||||
|
||||
class RawPacketDetail(BaseModel):
|
||||
"""Stored raw-packet detail returned by the packet API."""
|
||||
|
||||
id: int
|
||||
timestamp: int
|
||||
data: str = Field(description="Hex-encoded packet data")
|
||||
payload_type: str = Field(description="Packet type name (e.g. GROUP_TEXT, ADVERT)")
|
||||
snr: float | None = Field(default=None, description="Signal-to-noise ratio in dB if available")
|
||||
rssi: int | None = Field(
|
||||
default=None, description="Received signal strength in dBm if available"
|
||||
)
|
||||
decrypted: bool = False
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
text: str = Field(min_length=1)
|
||||
|
||||
@@ -813,4 +833,5 @@ class StatisticsResponse(BaseModel):
|
||||
total_outgoing: int
|
||||
contacts_heard: ContactActivityCounts
|
||||
repeaters_heard: ContactActivityCounts
|
||||
known_channels_active: ContactActivityCounts
|
||||
path_hash_width_24h: PathHashWidthStats
|
||||
|
||||
@@ -431,6 +431,43 @@ class ContactRepository:
|
||||
|
||||
Returns the placeholder public keys that were merged into the full key.
|
||||
"""
|
||||
|
||||
async def migrate_child_rows(old_key: str, new_key: str) -> None:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_name_history (public_key, name, first_seen, last_seen)
|
||||
SELECT ?, name, first_seen, last_seen
|
||||
FROM contact_name_history
|
||||
WHERE public_key = ?
|
||||
ON CONFLICT(public_key, name) DO UPDATE SET
|
||||
first_seen = MIN(contact_name_history.first_seen, excluded.first_seen),
|
||||
last_seen = MAX(contact_name_history.last_seen, excluded.last_seen)
|
||||
""",
|
||||
(new_key, old_key),
|
||||
)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contact_advert_paths
|
||||
(public_key, path_hex, path_len, first_seen, last_seen, heard_count)
|
||||
SELECT ?, path_hex, path_len, first_seen, last_seen, heard_count
|
||||
FROM contact_advert_paths
|
||||
WHERE public_key = ?
|
||||
ON CONFLICT(public_key, path_hex, path_len) DO UPDATE SET
|
||||
first_seen = MIN(contact_advert_paths.first_seen, excluded.first_seen),
|
||||
last_seen = MAX(contact_advert_paths.last_seen, excluded.last_seen),
|
||||
heard_count = contact_advert_paths.heard_count + excluded.heard_count
|
||||
""",
|
||||
(new_key, old_key),
|
||||
)
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_name_history WHERE public_key = ?",
|
||||
(old_key,),
|
||||
)
|
||||
await db.conn.execute(
|
||||
"DELETE FROM contact_advert_paths WHERE public_key = ?",
|
||||
(old_key,),
|
||||
)
|
||||
|
||||
normalized_full_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
@@ -467,6 +504,8 @@ class ContactRepository:
|
||||
if (match_row["match_count"] if match_row is not None else 0) != 1:
|
||||
continue
|
||||
|
||||
await migrate_child_rows(old_key, normalized_full_key)
|
||||
|
||||
if full_exists:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
|
||||
@@ -331,6 +331,12 @@ class MessageRepository:
|
||||
@staticmethod
|
||||
def _row_to_message(row: Any) -> Message:
|
||||
"""Convert a database row to a Message model."""
|
||||
packet_id = None
|
||||
if hasattr(row, "keys"):
|
||||
row_keys = row.keys()
|
||||
if "packet_id" in row_keys:
|
||||
packet_id = row["packet_id"]
|
||||
|
||||
return Message(
|
||||
id=row["id"],
|
||||
type=row["type"],
|
||||
@@ -345,6 +351,14 @@ class MessageRepository:
|
||||
outgoing=bool(row["outgoing"]),
|
||||
acked=row["acked"],
|
||||
sender_name=row["sender_name"],
|
||||
packet_id=packet_id,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _message_select(message_alias: str = "messages") -> str:
|
||||
return (
|
||||
f"{message_alias}.*, "
|
||||
f"(SELECT MIN(id) FROM raw_packets WHERE message_id = {message_alias}.id) AS packet_id"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -363,7 +377,7 @@ class MessageRepository:
|
||||
) -> list[Message]:
|
||||
search_query = MessageRepository._parse_search_query(q) if q else None
|
||||
query = (
|
||||
"SELECT messages.* FROM messages "
|
||||
f"SELECT {MessageRepository._message_select('messages')} FROM messages "
|
||||
"LEFT JOIN contacts ON messages.type = 'PRIV' "
|
||||
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
|
||||
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
||||
@@ -470,7 +484,8 @@ class MessageRepository:
|
||||
|
||||
# 1. Get the target message (must satisfy filters if provided)
|
||||
target_cursor = await db.conn.execute(
|
||||
f"SELECT * FROM messages WHERE id = ? AND {where_sql}",
|
||||
f"SELECT {MessageRepository._message_select('messages')} "
|
||||
f"FROM messages WHERE id = ? AND {where_sql}",
|
||||
(message_id, *base_params),
|
||||
)
|
||||
target_row = await target_cursor.fetchone()
|
||||
@@ -481,7 +496,7 @@ class MessageRepository:
|
||||
|
||||
# 2. Get context_size+1 messages before target (DESC)
|
||||
before_query = f"""
|
||||
SELECT * FROM messages WHERE {where_sql}
|
||||
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
|
||||
AND (received_at < ? OR (received_at = ? AND id < ?))
|
||||
ORDER BY received_at DESC, id DESC LIMIT ?
|
||||
"""
|
||||
@@ -500,7 +515,7 @@ class MessageRepository:
|
||||
|
||||
# 3. Get context_size+1 messages after target (ASC)
|
||||
after_query = f"""
|
||||
SELECT * FROM messages WHERE {where_sql}
|
||||
SELECT {MessageRepository._message_select("messages")} FROM messages WHERE {where_sql}
|
||||
AND (received_at > ? OR (received_at = ? AND id > ?))
|
||||
ORDER BY received_at ASC, id ASC LIMIT ?
|
||||
"""
|
||||
@@ -545,7 +560,7 @@ class MessageRepository:
|
||||
async def get_by_id(message_id: int) -> "Message | None":
|
||||
"""Look up a message by its ID."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM messages WHERE id = ?",
|
||||
f"SELECT {MessageRepository._message_select('messages')} FROM messages WHERE id = ?",
|
||||
(message_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
@@ -554,6 +569,12 @@ class MessageRepository:
|
||||
|
||||
return MessageRepository._row_to_message(row)
|
||||
|
||||
@staticmethod
|
||||
async def delete_by_id(message_id: int) -> None:
|
||||
"""Delete a message row by ID."""
|
||||
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_by_content(
|
||||
msg_type: str,
|
||||
@@ -564,7 +585,9 @@ class MessageRepository:
|
||||
) -> "Message | None":
|
||||
"""Look up a message by its unique content fields."""
|
||||
query = """
|
||||
SELECT * FROM messages
|
||||
SELECT messages.*,
|
||||
(SELECT MIN(id) FROM raw_packets WHERE message_id = messages.id) AS packet_id
|
||||
FROM messages
|
||||
WHERE type = ? AND conversation_key = ? AND text = ?
|
||||
AND (sender_timestamp = ? OR (sender_timestamp IS NULL AND ? IS NULL))
|
||||
"""
|
||||
|
||||
@@ -121,6 +121,18 @@ class RawPacketRepository:
|
||||
return None
|
||||
return row["message_id"]
|
||||
|
||||
@staticmethod
|
||||
async def get_by_id(packet_id: int) -> tuple[int, bytes, int, int | None] | None:
|
||||
"""Return a raw packet row as (id, data, timestamp, message_id)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT id, data, timestamp, message_id FROM raw_packets WHERE id = ?",
|
||||
(packet_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
return (row["id"], bytes(row["data"]), row["timestamp"], 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."""
|
||||
|
||||
@@ -270,6 +270,30 @@ class StatisticsRepository:
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _known_channels_active() -> dict[str, int]:
|
||||
"""Count distinct known channel keys with channel traffic in each time window."""
|
||||
now = int(time.time())
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_hour,
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_24_hours,
|
||||
COUNT(DISTINCT CASE WHEN m.received_at >= ? THEN m.conversation_key END) AS last_week
|
||||
FROM messages m
|
||||
INNER JOIN channels c ON UPPER(m.conversation_key) = UPPER(c.key)
|
||||
WHERE m.type = 'CHAN'
|
||||
""",
|
||||
(now - SECONDS_1H, now - SECONDS_24H, now - SECONDS_7D),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
assert row is not None
|
||||
return {
|
||||
"last_hour": row["last_hour"] or 0,
|
||||
"last_24_hours": row["last_24_hours"] or 0,
|
||||
"last_week": row["last_week"] or 0,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def _path_hash_width_24h() -> dict[str, int | float]:
|
||||
"""Count parsed raw packets from the last 24h by hop hash width."""
|
||||
@@ -396,6 +420,7 @@ class StatisticsRepository:
|
||||
# Activity windows
|
||||
contacts_heard = await StatisticsRepository._activity_counts(contact_type=2, exclude=True)
|
||||
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
|
||||
known_channels_active = await StatisticsRepository._known_channels_active()
|
||||
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
|
||||
|
||||
return {
|
||||
@@ -411,5 +436,6 @@ class StatisticsRepository:
|
||||
"total_outgoing": total_outgoing,
|
||||
"contacts_heard": contacts_heard,
|
||||
"repeaters_heard": repeaters_heard,
|
||||
"known_channels_active": known_channels_active,
|
||||
"path_hash_width_24h": path_hash_width_24h,
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
requested_name = request.name
|
||||
is_hashtag = requested_name.startswith("#")
|
||||
|
||||
# Reserve the canonical Public room so it cannot drift to another key,
|
||||
# Reserve the canonical Public channel 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:
|
||||
|
||||
@@ -10,7 +10,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import get_recent_log_lines, settings
|
||||
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
|
||||
from app.repository import MessageRepository
|
||||
from app.repository import MessageRepository, StatisticsRepository
|
||||
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
|
||||
@@ -78,6 +78,7 @@ class DebugChannelAudit(BaseModel):
|
||||
class DebugRadioProbe(BaseModel):
|
||||
performed: bool
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
multi_acks_enabled: bool | None = None
|
||||
self_info: dict[str, Any] | None = None
|
||||
device_info: dict[str, Any] | None = None
|
||||
stats_core: dict[str, Any] | None = None
|
||||
@@ -86,11 +87,18 @@ class DebugRadioProbe(BaseModel):
|
||||
channels: DebugChannelAudit | None = None
|
||||
|
||||
|
||||
class DebugDatabaseInfo(BaseModel):
|
||||
total_dms: int
|
||||
total_channel_messages: int
|
||||
total_outgoing: int
|
||||
|
||||
|
||||
class DebugSnapshotResponse(BaseModel):
|
||||
captured_at: str
|
||||
application: DebugApplicationInfo
|
||||
health: HealthResponse
|
||||
runtime: DebugRuntimeInfo
|
||||
database: DebugDatabaseInfo
|
||||
radio_probe: DebugRadioProbe
|
||||
logs: list[str]
|
||||
|
||||
@@ -234,6 +242,9 @@ async def _probe_radio() -> DebugRadioProbe:
|
||||
return DebugRadioProbe(
|
||||
performed=True,
|
||||
errors=errors,
|
||||
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
|
||||
if mc.self_info is not None
|
||||
else None,
|
||||
self_info=dict(mc.self_info or {}),
|
||||
device_info=device_info,
|
||||
stats_core=stats_core,
|
||||
@@ -254,6 +265,7 @@ async def _probe_radio() -> DebugRadioProbe:
|
||||
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
"""Return a support/debug snapshot with recent logs and live radio state."""
|
||||
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
|
||||
statistics = await StatisticsRepository.get_all()
|
||||
radio_probe = await _probe_radio()
|
||||
channels_with_incoming_messages = (
|
||||
await MessageRepository.count_channels_with_incoming_messages()
|
||||
@@ -278,6 +290,11 @@ async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
|
||||
},
|
||||
),
|
||||
database=DebugDatabaseInfo(
|
||||
total_dms=statistics["total_dms"],
|
||||
total_channel_messages=statistics["total_channel_messages"],
|
||||
total_outgoing=statistics["total_outgoing"],
|
||||
),
|
||||
radio_probe=radio_probe,
|
||||
logs=[*LOG_COPY_BOUNDARY_PREFIX, *get_recent_log_lines(limit=1000)],
|
||||
)
|
||||
|
||||
@@ -9,8 +9,8 @@ import string
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings as server_settings
|
||||
from app.fanout.bot_exec import _analyze_bot_signature
|
||||
from app.fanout.manager import fanout_manager
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -325,6 +325,15 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
return {"messages": messages, "raw_packets": raw_packets}
|
||||
|
||||
|
||||
def _bot_system_disabled_detail() -> str | None:
|
||||
source = fanout_manager.get_bots_disabled_source()
|
||||
if source == "env":
|
||||
return "Bot system disabled by server configuration (MESHCORE_DISABLE_BOTS)"
|
||||
if source == "until_restart":
|
||||
return "Bot system disabled until the server restarts"
|
||||
return None
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_fanout_configs() -> list[dict]:
|
||||
"""List all fanout configs."""
|
||||
@@ -340,8 +349,10 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
detail=f"Invalid type '{body.type}'. Must be one of: {', '.join(sorted(_VALID_TYPES))}",
|
||||
)
|
||||
|
||||
if body.type == "bot" and server_settings.disable_bots:
|
||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
||||
if body.type == "bot":
|
||||
disabled_detail = _bot_system_disabled_detail()
|
||||
if disabled_detail:
|
||||
raise HTTPException(status_code=403, detail=disabled_detail)
|
||||
|
||||
normalized_config = _validate_and_normalize_config(body.type, body.config)
|
||||
scope = _enforce_scope(body.type, body.scope)
|
||||
@@ -356,8 +367,6 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
|
||||
# Start the module if enabled
|
||||
if cfg["enabled"]:
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.reload_config(cfg["id"])
|
||||
|
||||
logger.info("Created fanout config %s (type=%s, name=%s)", cfg["id"], body.type, body.name)
|
||||
@@ -371,8 +380,10 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
if existing["type"] == "bot" and server_settings.disable_bots:
|
||||
raise HTTPException(status_code=403, detail="Bot system disabled by server configuration")
|
||||
if existing["type"] == "bot":
|
||||
disabled_detail = _bot_system_disabled_detail()
|
||||
if disabled_detail:
|
||||
raise HTTPException(status_code=403, detail=disabled_detail)
|
||||
|
||||
kwargs = {}
|
||||
if body.name is not None:
|
||||
@@ -390,8 +401,6 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
# Reload the module to pick up changes
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.reload_config(config_id)
|
||||
|
||||
logger.info("Updated fanout config %s", config_id)
|
||||
@@ -406,10 +415,24 @@ async def delete_fanout_config(config_id: str) -> dict:
|
||||
raise HTTPException(status_code=404, detail="Fanout config not found")
|
||||
|
||||
# Stop the module first
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
await fanout_manager.remove_config(config_id)
|
||||
await FanoutConfigRepository.delete(config_id)
|
||||
|
||||
logger.info("Deleted fanout config %s", config_id)
|
||||
return {"deleted": True}
|
||||
|
||||
|
||||
@router.post("/bots/disable-until-restart")
|
||||
async def disable_bots_until_restart() -> dict:
|
||||
"""Stop active bot modules and prevent them from running again until restart."""
|
||||
source = await fanout_manager.disable_bots_until_restart()
|
||||
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
return {
|
||||
"status": "ok",
|
||||
"bots_disabled": True,
|
||||
"bots_disabled_source": source,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import os
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
@@ -37,6 +37,8 @@ class HealthResponse(BaseModel):
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
fanout_statuses: dict[str, dict[str, str]] = {}
|
||||
bots_disabled: bool = False
|
||||
bots_disabled_source: Literal["env", "until_restart"] | None = None
|
||||
basic_auth_enabled: bool = False
|
||||
|
||||
|
||||
def _clean_optional_str(value: object) -> str | None:
|
||||
@@ -46,6 +48,11 @@ def _clean_optional_str(value: object) -> str | None:
|
||||
return cleaned or None
|
||||
|
||||
|
||||
def _read_optional_bool_setting(name: str) -> bool:
|
||||
value = getattr(settings, name, False)
|
||||
return value if isinstance(value, bool) else False
|
||||
|
||||
|
||||
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()
|
||||
@@ -64,10 +71,14 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
|
||||
# Fanout module statuses
|
||||
fanout_statuses: dict[str, Any] = {}
|
||||
bots_disabled_source = "env" if _read_optional_bool_setting("disable_bots") else None
|
||||
try:
|
||||
from app.fanout.manager import fanout_manager
|
||||
|
||||
fanout_statuses = fanout_manager.get_statuses()
|
||||
manager_bots_disabled_source = fanout_manager.get_bots_disabled_source()
|
||||
if manager_bots_disabled_source is not None:
|
||||
bots_disabled_source = manager_bots_disabled_source
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
@@ -118,7 +129,9 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
"fanout_statuses": fanout_statuses,
|
||||
"bots_disabled": settings.disable_bots,
|
||||
"bots_disabled": bots_disabled_source is not None,
|
||||
"bots_disabled_source": bots_disabled_source,
|
||||
"basic_auth_enabled": _read_optional_bool_setting("basic_auth_enabled"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -8,8 +8,9 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.database import db
|
||||
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
|
||||
from app.models import RawPacketDecryptedInfo, RawPacketDetail
|
||||
from app.packet_processor import create_message_from_decrypted, run_historical_dm_decryption
|
||||
from app.repository import ChannelRepository, RawPacketRepository
|
||||
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
|
||||
from app.websocket import broadcast_success
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -102,6 +103,45 @@ async def get_undecrypted_count() -> dict:
|
||||
return {"count": count}
|
||||
|
||||
|
||||
@router.get("/{packet_id}", response_model=RawPacketDetail)
|
||||
async def get_raw_packet(packet_id: int) -> RawPacketDetail:
|
||||
"""Fetch one stored raw packet by row ID for on-demand inspection."""
|
||||
packet_row = await RawPacketRepository.get_by_id(packet_id)
|
||||
if packet_row is None:
|
||||
raise HTTPException(status_code=404, detail="Raw packet not found")
|
||||
|
||||
stored_packet_id, packet_data, packet_timestamp, message_id = packet_row
|
||||
packet_info = parse_packet(packet_data)
|
||||
payload_type_name = packet_info.payload_type.name if packet_info else "Unknown"
|
||||
|
||||
decrypted_info: RawPacketDecryptedInfo | None = None
|
||||
if message_id is not None:
|
||||
message = await MessageRepository.get_by_id(message_id)
|
||||
if message is not None:
|
||||
if message.type == "CHAN":
|
||||
channel = await ChannelRepository.get_by_key(message.conversation_key)
|
||||
decrypted_info = RawPacketDecryptedInfo(
|
||||
channel_name=channel.name if channel else None,
|
||||
sender=message.sender_name,
|
||||
channel_key=message.conversation_key,
|
||||
contact_key=message.sender_key,
|
||||
)
|
||||
else:
|
||||
decrypted_info = RawPacketDecryptedInfo(
|
||||
sender=message.sender_name,
|
||||
contact_key=message.conversation_key,
|
||||
)
|
||||
|
||||
return RawPacketDetail(
|
||||
id=stored_packet_id,
|
||||
timestamp=packet_timestamp,
|
||||
data=packet_data.hex(),
|
||||
payload_type=payload_type_name,
|
||||
decrypted=message_id is not None,
|
||||
decrypted_info=decrypted_info,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/decrypt/historical", response_model=DecryptResult)
|
||||
async def decrypt_historical_packets(
|
||||
request: DecryptRequest, background_tasks: BackgroundTasks, response: Response
|
||||
|
||||
@@ -81,6 +81,10 @@ class RadioConfigResponse(BaseModel):
|
||||
default="current",
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
multi_acks_enabled: bool = Field(
|
||||
default=False,
|
||||
description="Whether the radio sends an extra direct ACK transmission",
|
||||
)
|
||||
|
||||
|
||||
class RadioConfigUpdate(BaseModel):
|
||||
@@ -99,6 +103,10 @@ class RadioConfigUpdate(BaseModel):
|
||||
default=None,
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
multi_acks_enabled: bool | None = Field(
|
||||
default=None,
|
||||
description="Whether the radio sends an extra direct ACK transmission",
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyUpdate(BaseModel):
|
||||
@@ -222,6 +230,7 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
path_hash_mode=radio_manager.path_hash_mode,
|
||||
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
|
||||
advert_location_source=advert_location_source,
|
||||
multi_acks_enabled=bool(info.get("multi_acks", 0)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
@@ -28,6 +27,14 @@ from app.models import (
|
||||
)
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
_monotonic,
|
||||
batch_cli_fetch,
|
||||
extract_response_text,
|
||||
prepare_authenticated_contact_connection,
|
||||
require_server_capable_contact,
|
||||
send_contact_cli_command,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -43,39 +50,11 @@ ACL_PERMISSION_NAMES = {
|
||||
3: "Admin",
|
||||
}
|
||||
router = APIRouter(prefix="/contacts", tags=["repeaters"])
|
||||
|
||||
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:
|
||||
"""Wrapper around time.monotonic() for testability.
|
||||
|
||||
Patching time.monotonic directly breaks the asyncio event loop which also
|
||||
uses it. This indirection allows tests to control the clock safely.
|
||||
"""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _extract_response_text(event) -> str:
|
||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||
text = event.payload.get("text", str(event.payload))
|
||||
if text.startswith("> "):
|
||||
text = text[2:]
|
||||
return text
|
||||
return extract_response_text(event)
|
||||
|
||||
|
||||
async def _fetch_repeater_response(
|
||||
@@ -83,21 +62,6 @@ async def _fetch_repeater_response(
|
||||
target_pubkey_prefix: str,
|
||||
timeout: float = 20.0,
|
||||
) -> "Event | None":
|
||||
"""Fetch a CLI response from a specific repeater via a validated get_msg() loop.
|
||||
|
||||
Calls get_msg() repeatedly until a matching CLI response (txt_type=1) from the
|
||||
target repeater arrives or the wall-clock deadline expires. Unrelated messages
|
||||
are safe to skip — meshcore's event dispatcher already delivers them to the
|
||||
normal subscription handlers (on_contact_message, etc.) when get_msg() returns.
|
||||
|
||||
Args:
|
||||
mc: MeshCore instance
|
||||
target_pubkey_prefix: 12-char hex prefix of the repeater's public key
|
||||
timeout: Wall-clock seconds before giving up
|
||||
|
||||
Returns:
|
||||
The matching Event, or None if no response arrived before the deadline.
|
||||
"""
|
||||
deadline = _monotonic() + timeout
|
||||
|
||||
while _monotonic() < deadline:
|
||||
@@ -105,13 +69,12 @@ async def _fetch_repeater_response(
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.debug("get_msg() exception: %s", e)
|
||||
except Exception as exc:
|
||||
logger.debug("get_msg() exception: %s", exc)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.NO_MORE_MSGS:
|
||||
# No messages queued yet — wait and retry
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
@@ -125,8 +88,6 @@ async def _fetch_repeater_response(
|
||||
txt_type = result.payload.get("txt_type", 0)
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
# Not our target — already dispatched to subscribers by meshcore,
|
||||
# so just continue draining the queue.
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
msg_prefix,
|
||||
@@ -136,7 +97,6 @@ async def _fetch_repeater_response(
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
# Already dispatched to subscribers by meshcore; skip.
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during repeater fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
@@ -150,87 +110,13 @@ async def _fetch_repeater_response(
|
||||
|
||||
|
||||
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)
|
||||
"""
|
||||
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},
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact,
|
||||
password,
|
||||
label="repeater",
|
||||
response_timeout=REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
)
|
||||
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)
|
||||
try:
|
||||
logger.info("Adding repeater %s to radio", contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
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:
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{REPEATER_LOGIN_SEND_FAILED_MESSAGE} ({login_result.payload})",
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -403,43 +289,7 @@ async def _batch_cli_fetch(
|
||||
operation_name: str,
|
||||
commands: list[tuple[str, str]],
|
||||
) -> dict[str, str | None]:
|
||||
"""Send a batch of CLI commands to a repeater and collect responses.
|
||||
|
||||
Opens a radio operation with polling paused and auto-fetch suspended (since
|
||||
we call get_msg() directly via _fetch_repeater_response), adds the contact
|
||||
to the radio for routing, then sends each command sequentially with a 1-second
|
||||
gap between them.
|
||||
|
||||
Returns a dict mapping field names to response strings (or None on timeout).
|
||||
"""
|
||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
for i, (cmd, field) in enumerate(commands):
|
||||
if i > 0:
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
||||
continue
|
||||
|
||||
response_event = await _fetch_repeater_response(
|
||||
mc, contact.public_key[:12], timeout=10.0
|
||||
)
|
||||
if response_event is not None:
|
||||
results[field] = _extract_response_text(response_event)
|
||||
else:
|
||||
logger.warning("No response for command '%s' (%s)", cmd, field)
|
||||
|
||||
return results
|
||||
return await batch_cli_fetch(contact, operation_name, commands)
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||
@@ -524,72 +374,13 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
|
||||
|
||||
@router.post("/{public_key}/command", response_model=CommandResponse)
|
||||
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
|
||||
"""Send a CLI command to a repeater.
|
||||
|
||||
The contact must be a repeater (type=2). The user must have already logged in
|
||||
via the repeater/login endpoint. This endpoint ensures the contact is on the
|
||||
radio before sending commands (the repeater remembers ACL permissions after login).
|
||||
|
||||
Common commands:
|
||||
- get name, set name <value>
|
||||
- get tx, set tx <dbm>
|
||||
- get radio, set radio <freq,bw,sf,cr>
|
||||
- tempradio <freq,bw,sf,cr,minutes>
|
||||
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
|
||||
- clock, clock sync, time <epoch_seconds>
|
||||
- reboot
|
||||
- ver
|
||||
"""
|
||||
"""Send a CLI command to a repeater or room server."""
|
||||
require_connected()
|
||||
|
||||
# Get contact from database
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"send_repeater_command",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
# 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)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
# Send the command
|
||||
logger.info("Sending command to repeater %s: %s", contact.public_key[:12], request.command)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, request.command)
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
# Wait for response using validated fetch loop
|
||||
response_event = await _fetch_repeater_response(mc, contact.public_key[:12])
|
||||
|
||||
if response_event is None:
|
||||
logger.warning(
|
||||
"No response from repeater %s for command: %s",
|
||||
contact.public_key[:12],
|
||||
request.command,
|
||||
)
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response="(no response - command may have been processed)",
|
||||
)
|
||||
|
||||
# CONTACT_MSG_RECV payloads use sender_timestamp in meshcore.
|
||||
response_text = _extract_response_text(response_event)
|
||||
sender_timestamp = response_event.payload.get(
|
||||
"sender_timestamp",
|
||||
response_event.payload.get("timestamp"),
|
||||
)
|
||||
logger.info("Received response from %s: %s", contact.public_key[:12], response_text)
|
||||
|
||||
return CommandResponse(
|
||||
command=request.command,
|
||||
response=response_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
require_server_capable_contact(contact)
|
||||
return await send_contact_cli_command(
|
||||
contact,
|
||||
request.command,
|
||||
operation_name="send_repeater_command",
|
||||
)
|
||||
|
||||
145
app/routers/rooms.py
Normal file
145
app/routers/rooms.py
Normal file
@@ -0,0 +1,145 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
CONTACT_TYPE_ROOM,
|
||||
AclEntry,
|
||||
LppSensor,
|
||||
RepeaterAclResponse,
|
||||
RepeaterLoginRequest,
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterStatusResponse,
|
||||
)
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.routers.server_control import (
|
||||
prepare_authenticated_contact_connection,
|
||||
require_server_capable_contact,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
router = APIRouter(prefix="/contacts", tags=["rooms"])
|
||||
|
||||
|
||||
def _require_room(contact) -> None:
|
||||
require_server_capable_contact(contact, allowed_types=(CONTACT_TYPE_ROOM,))
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
|
||||
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
|
||||
"""Attempt room-server login and report whether auth was confirmed."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_login",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
return await prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact,
|
||||
request.password,
|
||||
label="room server",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
|
||||
async def room_status(public_key: str) -> RepeaterStatusResponse:
|
||||
"""Fetch status telemetry from a room server."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_status", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
|
||||
if status is None:
|
||||
raise HTTPException(status_code=504, detail="No status response from room server")
|
||||
|
||||
return RepeaterStatusResponse(
|
||||
battery_volts=status.get("bat", 0) / 1000.0,
|
||||
tx_queue_len=status.get("tx_queue_len", 0),
|
||||
noise_floor_dbm=status.get("noise_floor", 0),
|
||||
last_rssi_dbm=status.get("last_rssi", 0),
|
||||
last_snr_db=status.get("last_snr", 0.0),
|
||||
packets_received=status.get("nb_recv", 0),
|
||||
packets_sent=status.get("nb_sent", 0),
|
||||
airtime_seconds=status.get("airtime", 0),
|
||||
rx_airtime_seconds=status.get("rx_airtime", 0),
|
||||
uptime_seconds=status.get("uptime", 0),
|
||||
sent_flood=status.get("sent_flood", 0),
|
||||
sent_direct=status.get("sent_direct", 0),
|
||||
recv_flood=status.get("recv_flood", 0),
|
||||
recv_direct=status.get("recv_direct", 0),
|
||||
flood_dups=status.get("flood_dups", 0),
|
||||
direct_dups=status.get("direct_dups", 0),
|
||||
full_events=status.get("full_evts", 0),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
|
||||
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
|
||||
"""Fetch CayenneLPP telemetry from a room server."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_lpp_telemetry", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
telemetry = await mc.commands.req_telemetry_sync(
|
||||
contact.public_key, timeout=10, min_timeout=5
|
||||
)
|
||||
|
||||
if telemetry is None:
|
||||
raise HTTPException(status_code=504, detail="No telemetry response from room server")
|
||||
|
||||
sensors = [
|
||||
LppSensor(
|
||||
channel=entry.get("channel", 0),
|
||||
type_name=str(entry.get("type", "unknown")),
|
||||
value=entry.get("value", 0),
|
||||
)
|
||||
for entry in telemetry
|
||||
]
|
||||
return RepeaterLppTelemetryResponse(sensors=sensors)
|
||||
|
||||
|
||||
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
|
||||
async def room_acl(public_key: str) -> RepeaterAclResponse:
|
||||
"""Fetch ACL entries from a room server."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_room(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"room_acl", pause_polling=True, suspend_auto_fetch=True
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
acl_data = await mc.commands.req_acl_sync(contact.public_key, timeout=10, min_timeout=5)
|
||||
|
||||
acl_entries = []
|
||||
if acl_data and isinstance(acl_data, list):
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.repeaters import ACL_PERMISSION_NAMES
|
||||
|
||||
for entry in acl_data:
|
||||
pubkey_prefix = entry.get("key", "")
|
||||
perm = entry.get("perm", 0)
|
||||
resolved_contact = await ContactRepository.get_by_key_prefix(pubkey_prefix)
|
||||
acl_entries.append(
|
||||
AclEntry(
|
||||
pubkey_prefix=pubkey_prefix,
|
||||
name=resolved_contact.name if resolved_contact else None,
|
||||
permission=perm,
|
||||
permission_name=ACL_PERMISSION_NAMES.get(perm, f"Unknown({perm})"),
|
||||
)
|
||||
)
|
||||
|
||||
return RepeaterAclResponse(acl=acl_entries)
|
||||
317
app/routers/server_control.py
Normal file
317
app/routers/server_control.py
Normal file
@@ -0,0 +1,317 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
CONTACT_TYPE_ROOM,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
RepeaterLoginResponse,
|
||||
)
|
||||
from app.routers.contacts import _ensure_on_radio
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
"""Wrapper around time.monotonic() for testability."""
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def get_server_contact_label(contact: Contact) -> str:
|
||||
"""Return a user-facing label for server-capable contacts."""
|
||||
if contact.type == CONTACT_TYPE_REPEATER:
|
||||
return "repeater"
|
||||
if contact.type == CONTACT_TYPE_ROOM:
|
||||
return "room server"
|
||||
return "server"
|
||||
|
||||
|
||||
def require_server_capable_contact(
|
||||
contact: Contact,
|
||||
*,
|
||||
allowed_types: tuple[int, ...] = (CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM),
|
||||
) -> None:
|
||||
"""Raise 400 if the contact does not support server control/login features."""
|
||||
if contact.type not in allowed_types:
|
||||
expected = ", ".join(str(value) for value in allowed_types)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Contact is not a supported server contact (type={contact.type}, expected one of {expected})",
|
||||
)
|
||||
|
||||
|
||||
def _login_rejected_message(label: str) -> str:
|
||||
return (
|
||||
f"The {label} replied but did not confirm this login. "
|
||||
f"Existing access may still allow some {label} operations, but privileged actions may fail."
|
||||
)
|
||||
|
||||
|
||||
def _login_send_failed_message(label: str) -> str:
|
||||
return (
|
||||
f"The login request could not be sent to the {label}. "
|
||||
f"The control panel is still available, but authenticated actions may fail until a login succeeds."
|
||||
)
|
||||
|
||||
|
||||
def _login_timeout_message(label: str) -> str:
|
||||
return (
|
||||
f"No login confirmation was heard from the {label}. "
|
||||
"That can mean the password was wrong or the reply was missed in transit. "
|
||||
"The control panel is still available; try logging in again if authenticated actions fail."
|
||||
)
|
||||
|
||||
|
||||
def extract_response_text(event) -> str:
|
||||
"""Extract text from a CLI response event, stripping the firmware '> ' prefix."""
|
||||
text = event.payload.get("text", str(event.payload))
|
||||
if text.startswith("> "):
|
||||
text = text[2:]
|
||||
return text
|
||||
|
||||
|
||||
async def fetch_contact_cli_response(
|
||||
mc,
|
||||
target_pubkey_prefix: str,
|
||||
timeout: float = 20.0,
|
||||
) -> "Event | None":
|
||||
"""Fetch a CLI response from a specific contact via a validated get_msg() loop."""
|
||||
deadline = _monotonic() + timeout
|
||||
|
||||
while _monotonic() < deadline:
|
||||
try:
|
||||
result = await mc.commands.get_msg(timeout=2.0)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as exc:
|
||||
logger.debug("get_msg() exception: %s", exc)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.NO_MORE_MSGS:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
logger.debug("get_msg() error: %s", result.payload)
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CONTACT_MSG_RECV:
|
||||
msg_prefix = result.payload.get("pubkey_prefix", "")
|
||||
txt_type = result.payload.get("txt_type", 0)
|
||||
if msg_prefix == target_pubkey_prefix and txt_type == 1:
|
||||
return result
|
||||
logger.debug(
|
||||
"Skipping non-target message (from=%s, txt_type=%d) while waiting for %s",
|
||||
msg_prefix,
|
||||
txt_type,
|
||||
target_pubkey_prefix,
|
||||
)
|
||||
continue
|
||||
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
logger.debug(
|
||||
"Skipping channel message (channel_idx=%s) during CLI fetch",
|
||||
result.payload.get("channel_idx"),
|
||||
)
|
||||
continue
|
||||
|
||||
logger.debug("Unexpected event type %s during CLI fetch, skipping", result.type)
|
||||
|
||||
logger.warning("No CLI response from contact %s within %.1fs", target_pubkey_prefix, timeout)
|
||||
return None
|
||||
|
||||
|
||||
async def prepare_authenticated_contact_connection(
|
||||
mc,
|
||||
contact: Contact,
|
||||
password: str,
|
||||
*,
|
||||
label: str | None = None,
|
||||
response_timeout: float = SERVER_LOGIN_RESPONSE_TIMEOUT_SECONDS,
|
||||
) -> RepeaterLoginResponse:
|
||||
"""Prepare connection to a server-capable contact by adding it to the radio and logging in."""
|
||||
pubkey_prefix = contact.public_key[:12].lower()
|
||||
contact_label = label or get_server_contact_label(contact)
|
||||
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,
|
||||
_login_rejected_message(contact_label),
|
||||
),
|
||||
attribute_filters={"pubkey_prefix": pubkey_prefix},
|
||||
)
|
||||
|
||||
try:
|
||||
logger.info("Adding %s %s to radio", contact_label, contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
logger.info("Sending login to %s %s", contact_label, contact.public_key[:12])
|
||||
login_result = await mc.commands.send_login(contact.public_key, password)
|
||||
|
||||
if login_result.type == EventType.ERROR:
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{_login_send_failed_message(contact_label)} ({login_result.payload})",
|
||||
)
|
||||
|
||||
try:
|
||||
return await asyncio.wait_for(
|
||||
login_future,
|
||||
timeout=response_timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"No login response from %s %s within %.1fs",
|
||||
contact_label,
|
||||
contact.public_key[:12],
|
||||
response_timeout,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="timeout",
|
||||
authenticated=False,
|
||||
message=_login_timeout_message(contact_label),
|
||||
)
|
||||
except HTTPException as exc:
|
||||
logger.warning(
|
||||
"%s login setup failed for %s: %s",
|
||||
contact_label.capitalize(),
|
||||
contact.public_key[:12],
|
||||
exc.detail,
|
||||
)
|
||||
return RepeaterLoginResponse(
|
||||
status="error",
|
||||
authenticated=False,
|
||||
message=f"{_login_send_failed_message(contact_label)} ({exc.detail})",
|
||||
)
|
||||
finally:
|
||||
success_subscription.unsubscribe()
|
||||
failed_subscription.unsubscribe()
|
||||
|
||||
|
||||
async def batch_cli_fetch(
|
||||
contact: Contact,
|
||||
operation_name: str,
|
||||
commands: list[tuple[str, str]],
|
||||
) -> dict[str, str | None]:
|
||||
"""Send a batch of CLI commands to a server-capable contact and collect responses."""
|
||||
results: dict[str, str | None] = {field: None for _, field in commands}
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
for index, (cmd, field) in enumerate(commands):
|
||||
if index > 0:
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, cmd)
|
||||
if send_result.type == EventType.ERROR:
|
||||
logger.debug("Command '%s' send error: %s", cmd, send_result.payload)
|
||||
continue
|
||||
|
||||
response_event = await fetch_contact_cli_response(
|
||||
mc, contact.public_key[:12], timeout=10.0
|
||||
)
|
||||
if response_event is not None:
|
||||
results[field] = extract_response_text(response_event)
|
||||
else:
|
||||
logger.warning("No response for command '%s' (%s)", cmd, field)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def send_contact_cli_command(
|
||||
contact: Contact,
|
||||
command: str,
|
||||
*,
|
||||
operation_name: str,
|
||||
) -> CommandResponse:
|
||||
"""Send a CLI command to a server-capable contact and return the text response."""
|
||||
label = get_server_contact_label(contact)
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
operation_name,
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
logger.info("Adding %s %s to radio", label, contact.public_key[:12])
|
||||
await _ensure_on_radio(mc, contact)
|
||||
await asyncio.sleep(1.0)
|
||||
|
||||
logger.info("Sending command to %s %s: %s", label, contact.public_key[:12], command)
|
||||
send_result = await mc.commands.send_cmd(contact.public_key, command)
|
||||
|
||||
if send_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send command: {send_result.payload}"
|
||||
)
|
||||
|
||||
response_event = await fetch_contact_cli_response(mc, contact.public_key[:12])
|
||||
|
||||
if response_event is None:
|
||||
logger.warning(
|
||||
"No response from %s %s for command: %s",
|
||||
label,
|
||||
contact.public_key[:12],
|
||||
command,
|
||||
)
|
||||
return CommandResponse(
|
||||
command=command,
|
||||
response="(no response - command may have been processed)",
|
||||
)
|
||||
|
||||
response_text = extract_response_text(response_event)
|
||||
sender_timestamp = response_event.payload.get(
|
||||
"sender_timestamp",
|
||||
response_event.payload.get("timestamp"),
|
||||
)
|
||||
logger.info(
|
||||
"Received response from %s %s: %s",
|
||||
label,
|
||||
contact.public_key[:12],
|
||||
response_text,
|
||||
)
|
||||
|
||||
return CommandResponse(
|
||||
command=command,
|
||||
response=response_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
@@ -5,7 +5,7 @@ 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.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactRepository,
|
||||
@@ -106,6 +106,35 @@ async def resolve_fallback_direct_message_context(
|
||||
)
|
||||
|
||||
|
||||
async def resolve_direct_message_sender_metadata(
|
||||
*,
|
||||
sender_public_key: str,
|
||||
received_at: int,
|
||||
broadcast_fn: BroadcastFn,
|
||||
contact_repository=ContactRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Resolve sender attribution for direct-message variants such as room-server posts."""
|
||||
normalized_sender = sender_public_key.lower()
|
||||
|
||||
try:
|
||||
contact = await contact_repository.get_by_key_or_prefix(normalized_sender)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
(log or logger).warning(
|
||||
"Sender prefix '%s' is ambiguous; preserving prefix-only attribution",
|
||||
sender_public_key,
|
||||
)
|
||||
contact = None
|
||||
|
||||
if contact is not None:
|
||||
await claim_prefix_messages_for_contact(
|
||||
public_key=contact.public_key.lower(), log=log or logger
|
||||
)
|
||||
return contact.name, contact.public_key.lower()
|
||||
|
||||
return None, normalized_sender or None
|
||||
|
||||
|
||||
async def _store_direct_message(
|
||||
*,
|
||||
packet_id: int | None,
|
||||
@@ -209,6 +238,7 @@ async def _store_direct_message(
|
||||
sender_key=sender_key,
|
||||
outgoing=outgoing,
|
||||
sender_name=sender_name,
|
||||
packet_id=packet_id,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn, realtime=realtime)
|
||||
|
||||
@@ -237,8 +267,19 @@ async def ingest_decrypted_direct_message(
|
||||
contact_repository=ContactRepository,
|
||||
) -> Message | None:
|
||||
conversation_key = their_public_key.lower()
|
||||
|
||||
if not outgoing and decrypted.txt_type == 1:
|
||||
logger.debug(
|
||||
"Skipping CLI response from %s (txt_type=1): %s",
|
||||
conversation_key[:12],
|
||||
(decrypted.message or "")[:50],
|
||||
)
|
||||
return None
|
||||
|
||||
contact = await contact_repository.get_by_key(conversation_key)
|
||||
sender_name: str | None = None
|
||||
sender_key: str | None = conversation_key if not outgoing else None
|
||||
signature: str | None = None
|
||||
if contact is not None:
|
||||
conversation_key, skip_storage = await _prepare_resolved_contact(contact, log=logger)
|
||||
if skip_storage:
|
||||
@@ -249,7 +290,17 @@ async def ingest_decrypted_direct_message(
|
||||
)
|
||||
return None
|
||||
if not outgoing:
|
||||
sender_name = contact.name
|
||||
if contact.type == CONTACT_TYPE_ROOM and decrypted.signed_sender_prefix:
|
||||
sender_name, sender_key = await resolve_direct_message_sender_metadata(
|
||||
sender_public_key=decrypted.signed_sender_prefix,
|
||||
received_at=received_at or int(time.time()),
|
||||
broadcast_fn=broadcast_fn,
|
||||
contact_repository=contact_repository,
|
||||
log=logger,
|
||||
)
|
||||
signature = decrypted.signed_sender_prefix
|
||||
else:
|
||||
sender_name = contact.name
|
||||
|
||||
received = received_at or int(time.time())
|
||||
message = await _store_direct_message(
|
||||
@@ -261,10 +312,10 @@ async def ingest_decrypted_direct_message(
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
outgoing=outgoing,
|
||||
txt_type=0,
|
||||
signature=None,
|
||||
txt_type=decrypted.txt_type,
|
||||
signature=signature,
|
||||
sender_name=sender_name,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
sender_key=sender_key,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_fn,
|
||||
update_last_contacted_key=conversation_key,
|
||||
|
||||
@@ -13,7 +13,8 @@ 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,
|
||||
broadcast_message,
|
||||
build_stored_outgoing_channel_message,
|
||||
create_outgoing_channel_message,
|
||||
create_outgoing_direct_message,
|
||||
increment_ack_and_broadcast,
|
||||
@@ -586,6 +587,23 @@ async def send_channel_message_to_channel(
|
||||
requested_timestamp=sent_at,
|
||||
)
|
||||
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
|
||||
outgoing_message = await create_outgoing_channel_message(
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
broadcast=False,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if outgoing_message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
@@ -611,23 +629,11 @@ async def send_channel_message_to_channel(
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to send message: {result.payload}"
|
||||
)
|
||||
|
||||
outgoing_message = await create_outgoing_channel_message(
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if outgoing_message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
except Exception:
|
||||
if outgoing_message is not None:
|
||||
await message_repository.delete_by_id(outgoing_message.id)
|
||||
outgoing_message = None
|
||||
raise
|
||||
finally:
|
||||
if sender_timestamp is not None:
|
||||
await release_outgoing_sender_timestamp(
|
||||
@@ -640,22 +646,19 @@ async def send_channel_message_to_channel(
|
||||
if sent_at is None or sender_timestamp is None or outgoing_message is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
message_id = outgoing_message.id
|
||||
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
|
||||
return build_message_model(
|
||||
message_id=message_id,
|
||||
msg_type="CHAN",
|
||||
outgoing_message = await build_stored_outgoing_channel_message(
|
||||
message_id=outgoing_message.id,
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
paths=paths,
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=channel.name,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
|
||||
return outgoing_message
|
||||
|
||||
|
||||
async def resend_channel_message_record(
|
||||
@@ -705,6 +708,23 @@ async def resend_channel_message_record(
|
||||
requested_timestamp=sent_at,
|
||||
)
|
||||
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
|
||||
new_message = await create_outgoing_channel_message(
|
||||
conversation_key=message.conversation_key,
|
||||
text=message.text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
broadcast=False,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if new_message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store resent message - unexpected duplicate",
|
||||
)
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
@@ -729,26 +749,11 @@ async def resend_channel_message_record(
|
||||
status_code=500,
|
||||
detail=f"Failed to resend message: {result.payload}",
|
||||
)
|
||||
|
||||
if new_timestamp:
|
||||
if sent_at is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
||||
new_message = await create_outgoing_channel_message(
|
||||
conversation_key=message.conversation_key,
|
||||
text=message.text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if new_message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store resent message - unexpected duplicate",
|
||||
)
|
||||
except Exception:
|
||||
if new_message is not None:
|
||||
await message_repository.delete_by_id(new_message.id)
|
||||
new_message = None
|
||||
raise
|
||||
finally:
|
||||
if new_timestamp and sent_at is not None:
|
||||
await release_outgoing_sender_timestamp(
|
||||
@@ -762,6 +767,19 @@ async def resend_channel_message_record(
|
||||
if sent_at is None or new_message is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
||||
|
||||
new_message = await build_stored_outgoing_channel_message(
|
||||
message_id=new_message.id,
|
||||
conversation_key=message.conversation_key,
|
||||
text=message.text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
channel_name=channel.name,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
broadcast_message(message=new_message, broadcast_fn=broadcast_fn)
|
||||
|
||||
logger.info(
|
||||
"Resent channel message %d as new message %d to %s",
|
||||
message.id,
|
||||
|
||||
@@ -62,6 +62,7 @@ def build_message_model(
|
||||
acked: int = 0,
|
||||
sender_name: str | None = None,
|
||||
channel_name: str | None = None,
|
||||
packet_id: int | None = None,
|
||||
) -> Message:
|
||||
"""Build a Message model with the canonical backend payload shape."""
|
||||
return Message(
|
||||
@@ -79,6 +80,7 @@ def build_message_model(
|
||||
acked=acked,
|
||||
sender_name=sender_name,
|
||||
channel_name=channel_name,
|
||||
packet_id=packet_id,
|
||||
)
|
||||
|
||||
|
||||
@@ -96,11 +98,42 @@ def broadcast_message(
|
||||
broadcast_fn("message", payload, realtime=realtime)
|
||||
|
||||
|
||||
async def build_stored_outgoing_channel_message(
|
||||
*,
|
||||
message_id: int,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
channel_name: str | None,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message:
|
||||
"""Build the current payload for a stored outgoing channel message."""
|
||||
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
|
||||
return build_message_model(
|
||||
message_id=message_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=paths,
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
|
||||
|
||||
def broadcast_message_acked(
|
||||
*,
|
||||
message_id: int,
|
||||
ack_count: int,
|
||||
paths: list[MessagePath] | None,
|
||||
packet_id: int | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Broadcast a message_acked payload."""
|
||||
@@ -110,6 +143,7 @@ def broadcast_message_acked(
|
||||
"message_id": message_id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [path.model_dump() for path in paths] if paths else [],
|
||||
"packet_id": packet_id,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -152,11 +186,16 @@ async def reconcile_duplicate_message(
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
|
||||
representative_packet_id = (
|
||||
existing_msg.packet_id if existing_msg.packet_id is not None else packet_id
|
||||
)
|
||||
|
||||
if existing_msg.outgoing or path is not None:
|
||||
broadcast_message_acked(
|
||||
message_id=existing_msg.id,
|
||||
ack_count=ack_count,
|
||||
paths=paths,
|
||||
packet_id=representative_packet_id,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
@@ -277,6 +316,7 @@ async def create_message_from_decrypted(
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
packet_id=packet_id,
|
||||
),
|
||||
broadcast_fn=broadcast_fn,
|
||||
realtime=realtime,
|
||||
@@ -428,6 +468,7 @@ async def create_outgoing_channel_message(
|
||||
sender_key: str | None,
|
||||
channel_name: str | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
broadcast: bool = True,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast an outgoing channel message."""
|
||||
@@ -444,18 +485,17 @@ async def create_outgoing_channel_message(
|
||||
if msg_id is None:
|
||||
return None
|
||||
|
||||
message = build_message_model(
|
||||
message = await build_stored_outgoing_channel_message(
|
||||
message_id=msg_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
channel_name=channel_name,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
if broadcast:
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
|
||||
@@ -44,6 +44,13 @@ async def apply_radio_config_update(
|
||||
f"Failed to set advert location policy: {result.payload}"
|
||||
)
|
||||
|
||||
if update.multi_acks_enabled is not None:
|
||||
multi_acks = 1 if update.multi_acks_enabled else 0
|
||||
logger.info("Setting multi ACKs to %d", multi_acks)
|
||||
result = await mc.commands.set_multi_acks(multi_acks)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(f"Failed to set multi ACKs: {result.payload}")
|
||||
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
|
||||
@@ -18,15 +18,18 @@ services:
|
||||
environment:
|
||||
MESHCORE_DATABASE_PATH: data/meshcore.db
|
||||
# Radio connection -- optional if you map just a single serial device above, as the app will autodetect
|
||||
|
||||
# Serial (USB)
|
||||
# MESHCORE_SERIAL_PORT: /dev/ttyUSB0
|
||||
# MESHCORE_SERIAL_BAUDRATE: 115200
|
||||
|
||||
# TCP
|
||||
# MESHCORE_TCP_HOST: 192.168.1.100
|
||||
# MESHCORE_TCP_PORT: 4000
|
||||
|
||||
# Security
|
||||
# MESHCORE_DISABLE_BOTS: "true"
|
||||
# MESHCORE_BASIC_AUTH_USERNAME: changeme
|
||||
# MESHCORE_BASIC_AUTH_PASSWORD: changeme
|
||||
|
||||
# Logging
|
||||
# MESHCORE_LOG_LEVEL: INFO
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -198,9 +198,9 @@ High-level state is delegated to hooks:
|
||||
- `useContactsAndChannels`: contact/channel lists, creation, deletion
|
||||
- `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
|
||||
- `useConversationActions`: send/resend/trace/path-discovery/block handlers and channel override updates
|
||||
- `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
|
||||
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps, and server `last_read_ats` boundaries
|
||||
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
|
||||
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
|
||||
|
||||
@@ -245,10 +245,12 @@ High-level state is delegated to hooks:
|
||||
- `id`: backend storage row identity (payload-level dedup)
|
||||
- `observation_id`: realtime per-arrival identity (session fidelity)
|
||||
- Packet feed/visualizer render keys and dedup logic should use `observation_id` (fallback to `id` only for older payloads).
|
||||
- The dedicated raw packet feed view now includes a frontend-only stats drawer. It tracks a separate lightweight per-observation session history for charts/rankings, so its windows are not limited by the visible packet list cap. Coverage messaging should stay honest when detailed in-memory stats history has been trimmed or the selected window predates the current browser session.
|
||||
|
||||
### Radio settings behavior
|
||||
|
||||
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
|
||||
- `SettingsRadioSection.tsx` also exposes `multi_acks_enabled` as a checkbox for the radio's extra direct-ACK transmission behavior.
|
||||
- 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.
|
||||
@@ -329,6 +331,8 @@ Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_config
|
||||
|
||||
`RawPacket.decrypted_info` includes `channel_key` and `contact_key` for MQTT topic routing.
|
||||
|
||||
`UnreadCounts` includes `counts`, `mentions`, `last_message_times`, and `last_read_ats`. The unread-boundary/jump-to-unread behavior uses the server-provided `last_read_ats` map keyed by `getStateKey(...)`.
|
||||
|
||||
## Contact Info Pane
|
||||
|
||||
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/analytics` using either `?public_key=...` or `?name=...`:
|
||||
@@ -423,9 +427,9 @@ PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
## Errata & Known Non-Issues
|
||||
|
||||
### Contacts rollup uses mention styling for unread DMs
|
||||
### Contacts use 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.
|
||||
This is intentional. In the sidebar, unread direct messages for actual contact conversations are treated as mention-equivalent for badge styling. That means both the Contacts section header and contact unread badges themselves use the highlighted mention-style colors for unread DMs, including when those contacts appear in Favorites. Repeaters do not inherit this rule, and channel badges still use mention styling only for real `@[name]` mentions.
|
||||
|
||||
### RawPacketList always scrolls to bottom
|
||||
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
<meta name="theme-color" content="#111419" />
|
||||
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.4.1",
|
||||
"version": "3.6.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -14,9 +14,13 @@ import {
|
||||
useConversationNavigation,
|
||||
useRealtimeAppState,
|
||||
useBrowserNotifications,
|
||||
useFaviconBadge,
|
||||
useUnreadTitle,
|
||||
useRawPacketStatsSession,
|
||||
} from './hooks';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import type { MessageInputHandle } from './components/MessageInput';
|
||||
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { Conversation, Message, RawPacket } from './types';
|
||||
@@ -81,6 +85,7 @@ export function App() {
|
||||
toggleConversationNotifications,
|
||||
notifyIncomingMessage,
|
||||
} = useBrowserNotifications();
|
||||
const { rawPacketStatsSession, recordRawPacketObservation } = useRawPacketStatsSession();
|
||||
const {
|
||||
showNewMessage,
|
||||
showSettings,
|
||||
@@ -89,10 +94,12 @@ export function App() {
|
||||
showCracker,
|
||||
crackerRunning,
|
||||
localLabel,
|
||||
distanceUnit,
|
||||
setSettingsSection,
|
||||
setSidebarOpen,
|
||||
setCrackerRunning,
|
||||
setLocalLabel,
|
||||
setDistanceUnit,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage,
|
||||
@@ -254,6 +261,8 @@ export function App() {
|
||||
markAllRead,
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
useFaviconBadge(unreadCounts, mentions, favorites);
|
||||
useUnreadTitle(unreadCounts, favorites);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeConversation?.type !== 'channel') {
|
||||
@@ -331,6 +340,7 @@ export function App() {
|
||||
removeConversationMessages,
|
||||
receiveMessageAck,
|
||||
notifyIncomingMessage,
|
||||
recordRawPacketObservation,
|
||||
});
|
||||
const handleVisibilityPolicyChanged = useCallback(() => {
|
||||
clearConversationMessages();
|
||||
@@ -413,6 +423,7 @@ export function App() {
|
||||
contacts,
|
||||
channels,
|
||||
rawPackets,
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
favorites,
|
||||
@@ -495,9 +506,7 @@ export function App() {
|
||||
onChannelCreate: handleCreateCrackedChannel,
|
||||
};
|
||||
const newMessageModalProps = {
|
||||
contacts,
|
||||
undecryptedCount,
|
||||
onSelectConversation: handleSelectConversationWithTargetReset,
|
||||
onCreateContact: handleCreateContact,
|
||||
onCreateChannel: handleCreateChannel,
|
||||
onCreateHashtagChannel: handleCreateHashtagChannel,
|
||||
@@ -560,29 +569,31 @@ export function App() {
|
||||
setContactsLoaded,
|
||||
]);
|
||||
return (
|
||||
<AppShell
|
||||
localLabel={localLabel}
|
||||
showNewMessage={showNewMessage}
|
||||
showSettings={showSettings}
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
showCracker={showCracker}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
onSidebarOpenChange={setSidebarOpen}
|
||||
onCrackerRunningChange={setCrackerRunning}
|
||||
onToggleSettingsView={handleToggleSettingsView}
|
||||
onCloseSettingsView={handleCloseSettingsView}
|
||||
onCloseNewMessage={handleCloseNewMessage}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
statusProps={statusProps}
|
||||
sidebarProps={sidebarProps}
|
||||
conversationPaneProps={conversationPaneProps}
|
||||
searchProps={searchProps}
|
||||
settingsProps={settingsProps}
|
||||
crackerProps={crackerProps}
|
||||
newMessageModalProps={newMessageModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
/>
|
||||
<DistanceUnitProvider distanceUnit={distanceUnit} setDistanceUnit={setDistanceUnit}>
|
||||
<AppShell
|
||||
localLabel={localLabel}
|
||||
showNewMessage={showNewMessage}
|
||||
showSettings={showSettings}
|
||||
settingsSection={settingsSection}
|
||||
sidebarOpen={sidebarOpen}
|
||||
showCracker={showCracker}
|
||||
onSettingsSectionChange={setSettingsSection}
|
||||
onSidebarOpenChange={setSidebarOpen}
|
||||
onCrackerRunningChange={setCrackerRunning}
|
||||
onToggleSettingsView={handleToggleSettingsView}
|
||||
onCloseSettingsView={handleCloseSettingsView}
|
||||
onCloseNewMessage={handleCloseNewMessage}
|
||||
onLocalLabelChange={setLocalLabel}
|
||||
statusProps={statusProps}
|
||||
sidebarProps={sidebarProps}
|
||||
conversationPaneProps={conversationPaneProps}
|
||||
searchProps={searchProps}
|
||||
settingsProps={settingsProps}
|
||||
crackerProps={crackerProps}
|
||||
newMessageModalProps={newMessageModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
/>
|
||||
</DistanceUnitProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
MessagesAroundResponse,
|
||||
MigratePreferencesRequest,
|
||||
MigratePreferencesResponse,
|
||||
RawPacket,
|
||||
RadioAdvertMode,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
@@ -247,6 +248,7 @@ export const api = {
|
||||
),
|
||||
|
||||
// Packets
|
||||
getPacket: (packetId: number) => fetchJson<RawPacket>(`/packets/${packetId}`),
|
||||
getUndecryptedPacketCount: () => fetchJson<{ count: number }>('/packets/undecrypted/count'),
|
||||
decryptHistoricalPackets: (params: {
|
||||
key_type: 'channel' | 'contact';
|
||||
@@ -341,6 +343,14 @@ export const api = {
|
||||
fetchJson<{ deleted: boolean }>(`/fanout/${id}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
disableBotsUntilRestart: () =>
|
||||
fetchJson<{
|
||||
status: string;
|
||||
bots_disabled: boolean;
|
||||
bots_disabled_source: 'env' | 'until_restart';
|
||||
}>('/fanout/bots/disable-until-restart', {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
// Statistics
|
||||
getStatistics: () => fetchJson<StatisticsResponse>('/statistics'),
|
||||
@@ -383,4 +393,21 @@ export const api = {
|
||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
roomLogin: (publicKey: string, password: string) =>
|
||||
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
}),
|
||||
roomStatus: (publicKey: string) =>
|
||||
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/room/status`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
roomAcl: (publicKey: string) =>
|
||||
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/room/acl`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
roomLppTelemetry: (publicKey: string) =>
|
||||
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/room/lpp-telemetry`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ConversationPane } from './ConversationPane';
|
||||
import { NewMessageModal } from './NewMessageModal';
|
||||
import { ContactInfoPane } from './ContactInfoPane';
|
||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||
import { Toaster } from './ui/sonner';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import {
|
||||
@@ -283,12 +284,9 @@ export function AppShell({
|
||||
{...newMessageModalProps}
|
||||
open={showNewMessage}
|
||||
onClose={onCloseNewMessage}
|
||||
onSelectConversation={(conv) => {
|
||||
newMessageModalProps.onSelectConversation(conv);
|
||||
onCloseNewMessage();
|
||||
}}
|
||||
/>
|
||||
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||
<Toaster position="top-right" />
|
||||
|
||||
@@ -45,8 +45,8 @@ export function ChannelFloodScopeOverrideModal({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regional Override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Room-level regional routing temporarily changes the radio flood scope before send and
|
||||
restores it after. This can noticeably slow room sends.
|
||||
Channel-level regional routing temporarily changes the radio flood scope before send and
|
||||
restores it after. This can noticeably slow channel sends.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { stripRegionScopePrefix } from '../utils/regionScope';
|
||||
import { isPrefixOnlyContact } from '../utils/pubkey';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type {
|
||||
@@ -19,6 +20,7 @@ import type {
|
||||
PathDiscoveryResponse,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
conversation: Conversation;
|
||||
@@ -84,6 +86,7 @@ export function ChatHeader({
|
||||
conversation.type === 'contact'
|
||||
? contacts.find((contact) => contact.public_key === conversation.id)
|
||||
: null;
|
||||
const activeContactIsRoomServer = activeContact?.type === CONTACT_TYPE_ROOM;
|
||||
const activeContactIsPrefixOnly = activeContact
|
||||
? isPrefixOnlyContact(activeContact.public_key)
|
||||
: false;
|
||||
@@ -116,8 +119,15 @@ export function ChatHeader({
|
||||
};
|
||||
|
||||
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">
|
||||
<header
|
||||
className={cn(
|
||||
'conversation-header grid items-start gap-x-2 gap-y-0.5 border-b border-border px-4 py-2.5',
|
||||
conversation.type === 'contact' && activeContact
|
||||
? 'grid-cols-[minmax(0,1fr)_auto] min-[1100px]:grid-cols-[minmax(0,1fr)_auto_auto]'
|
||||
: 'grid-cols-[minmax(0,1fr)_auto]'
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 items-start gap-2">
|
||||
{conversation.type === 'contact' && onOpenContactInfo && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -135,16 +145,31 @@ export function ChatHeader({
|
||||
/>
|
||||
</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="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 flex-shrink font-semibold text-base">
|
||||
{titleClickable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
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="truncate">
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
@@ -153,84 +178,75 @@ export function ChatHeader({
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</span>
|
||||
<Info
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</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="truncate">
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
activeChannel?.is_hashtag
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
<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'
|
||||
? 'Channel 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>
|
||||
)}
|
||||
</h2>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
</span>
|
||||
{conversation.type === 'channel' && activeFloodScopeDisplay && (
|
||||
<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"
|
||||
className="mt-0.5 flex basis-full items-center gap-1 text-left sm:hidden"
|
||||
onClick={handleEditFloodScopeOverride}
|
||||
title="Set regional override"
|
||||
aria-label="Set regional override"
|
||||
>
|
||||
Show Key
|
||||
<Globe2
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
|
||||
{activeFloodScopeDisplay}
|
||||
</span>
|
||||
</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 && (
|
||||
<span className="min-w-0 text-[11px] text-muted-foreground min-[1200px]:justify-self-end">
|
||||
<ContactStatusInfo
|
||||
contact={activeContact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{conversation.type === 'channel' && activeFloodScopeDisplay && (
|
||||
<button
|
||||
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
|
||||
onClick={handleEditFloodScopeOverride}
|
||||
title="Set regional override"
|
||||
aria-label="Set regional override"
|
||||
>
|
||||
<Globe2
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
|
||||
{activeFloodScopeDisplay}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
|
||||
{conversation.type === 'contact' && (
|
||||
{conversation.type === 'contact' && activeContact && (
|
||||
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||
<ContactStatusInfo
|
||||
contact={activeContact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{conversation.type === 'contact' && !activeContactIsRoomServer && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setPathDiscoveryOpen(true)}
|
||||
@@ -245,7 +261,7 @@ export function ChatHeader({
|
||||
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{conversation.type === 'contact' && (
|
||||
{conversation.type === 'contact' && !activeContactIsRoomServer && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTrace}
|
||||
@@ -260,7 +276,7 @@ export function ChatHeader({
|
||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{notificationsSupported && (
|
||||
{notificationsSupported && !activeContactIsRoomServer && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onToggleNotifications}
|
||||
|
||||
@@ -24,6 +24,7 @@ import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
import type {
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
@@ -82,6 +83,7 @@ export function ContactInfoPane({
|
||||
onToggleBlockedKey,
|
||||
onToggleBlockedName,
|
||||
}: ContactInfoPaneProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const isNameOnly = contactKey?.startsWith('name:') ?? false;
|
||||
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
|
||||
|
||||
@@ -240,8 +242,8 @@ export function ContactInfoPane({
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={analytics?.most_active_rooms ?? []}
|
||||
<MostActiveChannelsSection
|
||||
channels={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
@@ -315,7 +317,7 @@ export function ContactInfoPane({
|
||||
<InfoItem label="Last Contacted" value={formatTime(contact.last_contacted)} />
|
||||
)}
|
||||
{distFromUs !== null && (
|
||||
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
|
||||
<InfoItem label="Distance" value={formatDistance(distFromUs, distanceUnit)} />
|
||||
)}
|
||||
{effectiveRoute && (
|
||||
<InfoItem
|
||||
@@ -513,8 +515,8 @@ export function ContactInfoPane({
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={analytics?.most_active_rooms ?? []}
|
||||
<MostActiveChannelsSection
|
||||
channels={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
@@ -586,23 +588,23 @@ function MessageStatsSection({
|
||||
);
|
||||
}
|
||||
|
||||
function MostActiveRoomsSection({
|
||||
rooms,
|
||||
function MostActiveChannelsSection({
|
||||
channels,
|
||||
onNavigateToChannel,
|
||||
}: {
|
||||
rooms: ContactActiveRoom[];
|
||||
channels: ContactActiveRoom[];
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
}) {
|
||||
if (rooms.length === 0) {
|
||||
if (channels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
||||
<SectionLabel>Most Active Channels</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{rooms.map((room) => (
|
||||
<div key={room.channel_key} className="flex justify-between items-center text-sm">
|
||||
{channels.map((channel) => (
|
||||
<div key={channel.channel_key} className="flex justify-between items-center text-sm">
|
||||
<span
|
||||
className={
|
||||
onNavigateToChannel
|
||||
@@ -612,15 +614,15 @@ function MostActiveRoomsSection({
|
||||
role={onNavigateToChannel ? 'button' : undefined}
|
||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
||||
onClick={() => onNavigateToChannel?.(channel.channel_key)}
|
||||
>
|
||||
{room.channel_name.startsWith('#') || isPublicChannelKey(room.channel_key)
|
||||
? room.channel_name
|
||||
: `#${room.channel_name}`}
|
||||
{channel.channel_name.startsWith('#') || isPublicChannelKey(channel.channel_key)
|
||||
? channel.channel_name
|
||||
: `#${channel.channel_name}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{room.message_count.toLocaleString()} msg
|
||||
{room.message_count !== 1 ? 's' : ''}
|
||||
{channel.message_count.toLocaleString()} msg
|
||||
{channel.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import type { Contact } from '../types';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
import { ContactRoutingOverrideModal } from './ContactRoutingOverrideModal';
|
||||
|
||||
interface ContactStatusInfoProps {
|
||||
@@ -24,6 +25,7 @@ interface ContactStatusInfoProps {
|
||||
* shared between ChatHeader and RepeaterDashboard.
|
||||
*/
|
||||
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [routingModalOpen, setRoutingModalOpen] = useState(false);
|
||||
const parts: ReactNode[] = [];
|
||||
const effectiveRoute = getEffectiveContactRoute(contact);
|
||||
@@ -74,7 +76,7 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
|
||||
>
|
||||
{contact.lat!.toFixed(3)}, {contact.lon!.toFixed(3)}
|
||||
</span>
|
||||
{distFromUs !== null && ` (${formatDistance(distFromUs)})`}
|
||||
{distFromUs !== null && ` (${formatDistance(distFromUs, distanceUnit)})`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { lazy, Suspense, useMemo, type Ref } from 'react';
|
||||
import { lazy, Suspense, useEffect, useMemo, useState, type Ref } from 'react';
|
||||
|
||||
import { ChatHeader } from './ChatHeader';
|
||||
import { MessageInput, type MessageInputHandle } from './MessageInput';
|
||||
import { MessageList } from './MessageList';
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { RawPacketFeedView } from './RawPacketFeedView';
|
||||
import { RoomServerPanel } from './RoomServerPanel';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
@@ -15,7 +16,8 @@ import type {
|
||||
RawPacket,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
|
||||
|
||||
const RepeaterDashboard = lazy(() =>
|
||||
@@ -31,6 +33,7 @@ interface ConversationPaneProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
rawPackets: RawPacket[];
|
||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||
config: RadioConfig | null;
|
||||
health: HealthStatus | null;
|
||||
notificationsSupported: boolean;
|
||||
@@ -95,6 +98,7 @@ export function ConversationPane({
|
||||
contacts,
|
||||
channels,
|
||||
rawPackets,
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
notificationsSupported,
|
||||
@@ -128,6 +132,7 @@ export function ConversationPane({
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
}: ConversationPaneProps) {
|
||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
||||
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
|
||||
@@ -137,6 +142,10 @@ export function ConversationPane({
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
|
||||
}, [activeConversation, contacts]);
|
||||
const activeContactIsRoom = activeContact?.type === CONTACT_TYPE_ROOM;
|
||||
useEffect(() => {
|
||||
setRoomAuthenticated(false);
|
||||
}, [activeConversation?.id]);
|
||||
const isPrefixOnlyActiveContact = activeContact
|
||||
? isPrefixOnlyContact(activeContact.public_key)
|
||||
: false;
|
||||
@@ -178,14 +187,12 @@ export function ConversationPane({
|
||||
|
||||
if (activeConversation.type === 'raw') {
|
||||
return (
|
||||
<>
|
||||
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
|
||||
Raw Packet Feed
|
||||
</h2>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<RawPacketList packets={rawPackets} />
|
||||
</div>
|
||||
</>
|
||||
<RawPacketFeedView
|
||||
packets={rawPackets}
|
||||
rawPacketStatsSession={rawPacketStatsSession}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -217,6 +224,8 @@ export function ConversationPane({
|
||||
);
|
||||
}
|
||||
|
||||
const showRoomChat = !activeContactIsRoom || roomAuthenticated;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
@@ -244,35 +253,41 @@ export function ConversationPane({
|
||||
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
|
||||
<ContactResolutionBanner variant="unknown-full-key" />
|
||||
)}
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
contacts={contacts}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
unreadMarkerLastReadAt={
|
||||
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
|
||||
}
|
||||
onDismissUnreadMarker={
|
||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||
}
|
||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResendChannelMessage={
|
||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||
}
|
||||
radioName={config?.name}
|
||||
config={config}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
targetMessageId={targetMessageId}
|
||||
onTargetReached={onTargetReached}
|
||||
hasNewerMessages={hasNewerMessages}
|
||||
loadingNewer={loadingNewer}
|
||||
onLoadNewer={onLoadNewer}
|
||||
onJumpToBottom={onJumpToBottom}
|
||||
/>
|
||||
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : (
|
||||
{activeContactIsRoom && activeContact && (
|
||||
<RoomServerPanel contact={activeContact} onAuthenticatedChange={setRoomAuthenticated} />
|
||||
)}
|
||||
{showRoomChat && (
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
unreadMarkerLastReadAt={
|
||||
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
|
||||
}
|
||||
onDismissUnreadMarker={
|
||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||
}
|
||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResendChannelMessage={
|
||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||
}
|
||||
radioName={config?.name}
|
||||
config={config}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
targetMessageId={targetMessageId}
|
||||
onTargetReached={onTargetReached}
|
||||
hasNewerMessages={hasNewerMessages}
|
||||
loadingNewer={loadingNewer}
|
||||
onLoadNewer={onLoadNewer}
|
||||
onJumpToBottom={onJumpToBottom}
|
||||
/>
|
||||
)}
|
||||
{showRoomChat && !(activeConversation.type === 'contact' && isPrefixOnlyActiveContact) ? (
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
onSend={onSendMessage}
|
||||
@@ -285,7 +300,7 @@ export function ConversationPane({
|
||||
: `Message ${activeConversation.name}...`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,8 @@ import { toast } from './ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { extractPacketPayloadHex } from '../utils/pathUtils';
|
||||
|
||||
interface CrackedRoom {
|
||||
roomName: string;
|
||||
interface CrackedChannel {
|
||||
channelName: string;
|
||||
key: string;
|
||||
packetId: number;
|
||||
message: string;
|
||||
@@ -45,7 +45,7 @@ export function CrackerPanel({
|
||||
const [twoWordMode, setTwoWordMode] = useState(false);
|
||||
const [progress, setProgress] = useState<ProgressReport | null>(null);
|
||||
const [queue, setQueue] = useState<Map<number, QueueItem>>(new Map());
|
||||
const [crackedRooms, setCrackedRooms] = useState<CrackedRoom[]>([]);
|
||||
const [crackedChannels, setCrackedChannels] = useState<CrackedChannel[]>([]);
|
||||
const [wordlistLoaded, setWordlistLoaded] = useState(false);
|
||||
const [gpuAvailable, setGpuAvailable] = useState<boolean | null>(null);
|
||||
const [undecryptedPacketCount, setUndecryptedPacketCount] = useState<number | null>(null);
|
||||
@@ -325,14 +325,14 @@ export function CrackerPanel({
|
||||
return updated;
|
||||
});
|
||||
|
||||
const newRoom: CrackedRoom = {
|
||||
roomName: result.roomName,
|
||||
const newCracked: CrackedChannel = {
|
||||
channelName: result.roomName,
|
||||
key: result.key,
|
||||
packetId: nextId!,
|
||||
message: result.decryptedMessage || '',
|
||||
crackedAt: Date.now(),
|
||||
};
|
||||
setCrackedRooms((prev) => [...prev, newRoom]);
|
||||
setCrackedChannels((prev) => [...prev, newCracked]);
|
||||
|
||||
// Auto-add channel if not already exists
|
||||
const keyUpper = result.key.toUpperCase();
|
||||
@@ -580,20 +580,20 @@ export function CrackerPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cracked rooms list */}
|
||||
{crackedRooms.length > 0 && (
|
||||
{/* Cracked channels list */}
|
||||
{crackedChannels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Cracked Rooms:</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
|
||||
<div className="space-y-1">
|
||||
{crackedRooms.map((room, i) => (
|
||||
{crackedChannels.map((channel, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="text-sm bg-success/10 border border-success/20 rounded px-2 py-1"
|
||||
>
|
||||
<span className="text-success font-medium">#{room.roomName}</span>
|
||||
<span className="text-success font-medium">#{channel.channelName}</span>
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
"{room.message.slice(0, 50)}
|
||||
{room.message.length > 50 ? '...' : ''}"
|
||||
"{channel.message.slice(0, 50)}
|
||||
{channel.message.length > 50 ? '...' : ''}"
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
@@ -604,16 +604,17 @@ export function CrackerPanel({
|
||||
<hr className="border-border" />
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
For unknown-keyed GroupText packets, this will attempt to dictionary attack, then brute
|
||||
force payloads as they arrive, testing room names up to the specified length to discover
|
||||
active rooms on the local mesh (GroupText packets may not be hashtag messages; we have no
|
||||
force payloads as they arrive, testing channel names up to the specified length to discover
|
||||
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
|
||||
way of knowing but try as if they are).
|
||||
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
||||
pick up messages it couldn't crack, attempting them at one longer length.
|
||||
<strong> Try word pairs</strong> will also try every combination of two dictionary words
|
||||
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
||||
dictionary pass; this can substantially increase search time.
|
||||
<strong> Decrypt historical</strong> will run an async job on any room name it finds to see
|
||||
if any historically captured packets will decrypt with that key.
|
||||
dictionary pass; this can substantially increase search time and also result in
|
||||
false-positives.
|
||||
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to
|
||||
see if any historically captured packets will decrypt with that key.
|
||||
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
||||
may allow accelerated cracking and/or system instability.
|
||||
</p>
|
||||
|
||||
@@ -8,19 +8,23 @@ import {
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import type { Contact, Message, MessagePath, RadioConfig } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import type { Channel, Contact, Message, MessagePath, RadioConfig, RawPacket } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
import { api } from '../api';
|
||||
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 { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
import { toast } from './ui/sonner';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface MessageListProps {
|
||||
messages: Message[];
|
||||
contacts: Contact[];
|
||||
channels?: Channel[];
|
||||
loading: boolean;
|
||||
loadingOlder?: boolean;
|
||||
hasOlderMessages?: boolean;
|
||||
@@ -153,6 +157,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
|
||||
const RESEND_WINDOW_SECONDS = 30;
|
||||
const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>';
|
||||
const ANALYZE_PACKET_NOTICE =
|
||||
'This analyzer shows one stored full packet copy only. When multiple receives have identical payloads, the backend deduplicates them to a single stored packet and appends any additional receive paths onto the message path history instead of storing multiple full packet copies.';
|
||||
|
||||
function hasUnexpectedControlChars(text: string): boolean {
|
||||
for (const char of text) {
|
||||
@@ -173,6 +179,7 @@ function hasUnexpectedControlChars(text: string): boolean {
|
||||
export function MessageList({
|
||||
messages,
|
||||
contacts,
|
||||
channels = [],
|
||||
loading,
|
||||
loadingOlder = false,
|
||||
hasOlderMessages = false,
|
||||
@@ -199,10 +206,18 @@ export function MessageList({
|
||||
paths: MessagePath[];
|
||||
senderInfo: SenderInfo;
|
||||
messageId?: number;
|
||||
packetId?: number | null;
|
||||
isOutgoingChan?: boolean;
|
||||
} | null>(null);
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
|
||||
const [packetInspectorSource, setPacketInspectorSource] = useState<
|
||||
| { kind: 'packet'; packet: RawPacket }
|
||||
| { kind: 'loading'; message: string }
|
||||
| { kind: 'unavailable'; message: string }
|
||||
| null
|
||||
>(null);
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
|
||||
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
|
||||
const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false);
|
||||
@@ -221,6 +236,43 @@ export function MessageList({
|
||||
// Track conversation key to detect when entire message set changes
|
||||
const prevConvKeyRef = useRef<string | null>(null);
|
||||
|
||||
const handleAnalyzePacket = useCallback(async (message: Message) => {
|
||||
if (message.packet_id == null) {
|
||||
setPacketInspectorSource({
|
||||
kind: 'unavailable',
|
||||
message:
|
||||
'No archival raw packet is available for this message, so packet analysis cannot be shown.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = packetCacheRef.current.get(message.packet_id);
|
||||
if (cached) {
|
||||
setPacketInspectorSource({ kind: 'packet', packet: cached });
|
||||
return;
|
||||
}
|
||||
|
||||
setPacketInspectorSource({ kind: 'loading', message: 'Loading packet analysis...' });
|
||||
|
||||
try {
|
||||
const packet = await api.getPacket(message.packet_id);
|
||||
packetCacheRef.current.set(message.packet_id, packet);
|
||||
setPacketInspectorSource({ kind: 'packet', packet });
|
||||
} catch (error) {
|
||||
const description = error instanceof Error ? error.message : 'Unknown error';
|
||||
const isMissing = error instanceof Error && /not found/i.test(error.message);
|
||||
if (!isMissing) {
|
||||
toast.error('Failed to load raw packet', { description });
|
||||
}
|
||||
setPacketInspectorSource({
|
||||
kind: 'unavailable',
|
||||
message: isMissing
|
||||
? 'The archival raw packet for this message is no longer available. It may have been purged from Settings > Database, so only the stored message and merged route history remain.'
|
||||
: `Could not load the archival raw packet for this message: ${description}`,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle scroll position AFTER render
|
||||
useLayoutEffect(() => {
|
||||
if (!listRef.current) return;
|
||||
@@ -500,6 +552,33 @@ export function MessageList({
|
||||
contact: Contact | null,
|
||||
parsedSender: string | null
|
||||
): SenderInfo => {
|
||||
if (
|
||||
msg.type === 'PRIV' &&
|
||||
contact?.type === CONTACT_TYPE_ROOM &&
|
||||
(msg.sender_key || msg.sender_name)
|
||||
) {
|
||||
const authorContact =
|
||||
(msg.sender_key
|
||||
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
|
||||
: null) || (msg.sender_name ? getContactByName(msg.sender_name) : null);
|
||||
if (authorContact) {
|
||||
const directRoute = getDirectContactRoute(authorContact);
|
||||
return {
|
||||
name: authorContact.name || msg.sender_name || authorContact.public_key.slice(0, 12),
|
||||
publicKeyOrPrefix: authorContact.public_key,
|
||||
lat: authorContact.lat,
|
||||
lon: authorContact.lon,
|
||||
pathHashMode: directRoute?.path_hash_mode ?? null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: msg.sender_name || msg.sender_key || 'Unknown',
|
||||
publicKeyOrPrefix: msg.sender_key || '',
|
||||
lat: null,
|
||||
lon: null,
|
||||
pathHashMode: null,
|
||||
};
|
||||
}
|
||||
if (msg.type === 'PRIV' && contact) {
|
||||
const directRoute = getDirectContactRoute(contact);
|
||||
return {
|
||||
@@ -584,6 +663,8 @@ export function MessageList({
|
||||
isCorruptChannelMessage: boolean
|
||||
): string => {
|
||||
if (msg.outgoing) return '__outgoing__';
|
||||
if (msg.type === 'PRIV' && msg.sender_key) return `key:${msg.sender_key}`;
|
||||
if (msg.type === 'PRIV' && senderName) return `name:${senderName}`;
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
|
||||
if (msg.sender_key) return `key:${msg.sender_key}`;
|
||||
if (senderName) return `name:${senderName}`;
|
||||
@@ -612,18 +693,24 @@ export function MessageList({
|
||||
// For DMs, look up contact; for channel messages, use parsed sender
|
||||
const contact = msg.type === 'PRIV' ? getContact(msg.conversation_key) : null;
|
||||
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
|
||||
const isRoomServer = contact?.type === CONTACT_TYPE_ROOM;
|
||||
|
||||
// Skip sender parsing for repeater messages (CLI responses often have colons)
|
||||
const { sender, content } = isRepeater
|
||||
? { sender: null, content: msg.text }
|
||||
: parseSenderFromText(msg.text);
|
||||
const { sender, content } =
|
||||
isRepeater || (isRoomServer && msg.type === 'PRIV')
|
||||
? { sender: null, content: msg.text }
|
||||
: parseSenderFromText(msg.text);
|
||||
const directSenderName =
|
||||
msg.type === 'PRIV' && isRoomServer ? msg.sender_name || null : null;
|
||||
const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null;
|
||||
const channelSenderContact =
|
||||
msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null;
|
||||
const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender);
|
||||
const displaySender = msg.outgoing
|
||||
? 'You'
|
||||
: contact?.name ||
|
||||
: directSenderName ||
|
||||
(isRoomServer && msg.sender_key ? msg.sender_key.slice(0, 8) : null) ||
|
||||
contact?.name ||
|
||||
channelSenderName ||
|
||||
(isCorruptChannelMessage
|
||||
? CORRUPT_SENDER_LABEL
|
||||
@@ -636,15 +723,22 @@ export function MessageList({
|
||||
displaySender !== CORRUPT_SENDER_LABEL;
|
||||
|
||||
// Determine if we should show avatar (first message in a chunk from same sender)
|
||||
const currentSenderKey = getSenderKey(msg, channelSenderName, isCorruptChannelMessage);
|
||||
const currentSenderKey = getSenderKey(
|
||||
msg,
|
||||
directSenderName || channelSenderName,
|
||||
isCorruptChannelMessage
|
||||
);
|
||||
const prevMsg = sortedMessages[index - 1];
|
||||
const prevParsedSender = prevMsg ? parseSenderFromText(prevMsg.text).sender : null;
|
||||
const prevSenderKey = prevMsg
|
||||
? getSenderKey(
|
||||
prevMsg,
|
||||
prevMsg.type === 'CHAN'
|
||||
? prevMsg.sender_name || prevParsedSender
|
||||
: prevParsedSender,
|
||||
prevMsg.type === 'PRIV' &&
|
||||
getContact(prevMsg.conversation_key)?.type === CONTACT_TYPE_ROOM
|
||||
? prevMsg.sender_name
|
||||
: prevMsg.type === 'CHAN'
|
||||
? prevMsg.sender_name || prevParsedSender
|
||||
: prevParsedSender,
|
||||
isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender)
|
||||
)
|
||||
: null;
|
||||
@@ -658,9 +752,14 @@ export function MessageList({
|
||||
let avatarVariant: 'default' | 'corrupt' = 'default';
|
||||
if (!msg.outgoing) {
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
// DM: use conversation_key (sender's public key)
|
||||
avatarName = contact?.name || null;
|
||||
avatarKey = msg.conversation_key;
|
||||
if (isRoomServer) {
|
||||
avatarName = directSenderName;
|
||||
avatarKey =
|
||||
msg.sender_key || (avatarName ? `name:${avatarName}` : msg.conversation_key);
|
||||
} else {
|
||||
avatarName = contact?.name || null;
|
||||
avatarKey = msg.conversation_key;
|
||||
}
|
||||
} else if (isCorruptChannelMessage) {
|
||||
avatarName = CORRUPT_SENDER_LABEL;
|
||||
avatarKey = `corrupt:${msg.id}`;
|
||||
@@ -725,7 +824,12 @@ export function MessageList({
|
||||
type="button"
|
||||
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={avatarActionLabel}
|
||||
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
|
||||
onClick={() =>
|
||||
onOpenContactInfo(
|
||||
avatarKey,
|
||||
msg.type === 'CHAN' || (msg.type === 'PRIV' && isRoomServer)
|
||||
)
|
||||
}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
@@ -780,7 +884,9 @@ export function MessageList({
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, sender),
|
||||
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -806,7 +912,9 @@ export function MessageList({
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, sender),
|
||||
senderInfo: getSenderInfo(msg, contact, directSenderName || sender),
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
@@ -827,6 +935,7 @@ export function MessageList({
|
||||
paths: msg.paths!,
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
|
||||
});
|
||||
}}
|
||||
@@ -848,6 +957,7 @@ export function MessageList({
|
||||
paths: [],
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
packetId: msg.packet_id,
|
||||
isOutgoingChan: true,
|
||||
});
|
||||
}}
|
||||
@@ -945,9 +1055,31 @@ export function MessageList({
|
||||
contacts={contacts}
|
||||
config={config ?? null}
|
||||
messageId={selectedPath.messageId}
|
||||
packetId={selectedPath.packetId}
|
||||
isOutgoingChan={selectedPath.isOutgoingChan}
|
||||
isResendable={isSelectedMessageResendable}
|
||||
onResend={onResendChannelMessage}
|
||||
onAnalyzePacket={
|
||||
selectedPath.packetId != null
|
||||
? () => {
|
||||
const message = messages.find((entry) => entry.id === selectedPath.messageId);
|
||||
if (message) {
|
||||
void handleAnalyzePacket(message);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{packetInspectorSource && (
|
||||
<RawPacketInspectorDialog
|
||||
open={packetInspectorSource !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
|
||||
channels={channels}
|
||||
source={packetInspectorSource}
|
||||
title="Analyze Packet"
|
||||
description="On-demand raw packet analysis for a message-backed archival packet."
|
||||
notice={ANALYZE_PACKET_NOTICE}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Dice5 } from 'lucide-react';
|
||||
import type { Contact, Conversation } from '../types';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,14 +15,12 @@ import { Checkbox } from './ui/checkbox';
|
||||
import { Button } from './ui/button';
|
||||
import { toast } from './ui/sonner';
|
||||
|
||||
type Tab = 'existing' | 'new-contact' | 'new-room' | 'hashtag';
|
||||
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
|
||||
|
||||
interface NewMessageModalProps {
|
||||
open: boolean;
|
||||
contacts: Contact[];
|
||||
undecryptedCount: number;
|
||||
onClose: () => void;
|
||||
onSelectConversation: (conversation: Conversation) => void;
|
||||
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
|
||||
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
|
||||
@@ -32,18 +28,16 @@ interface NewMessageModalProps {
|
||||
|
||||
export function NewMessageModal({
|
||||
open,
|
||||
contacts,
|
||||
undecryptedCount,
|
||||
onClose,
|
||||
onSelectConversation,
|
||||
onCreateContact,
|
||||
onCreateChannel,
|
||||
onCreateHashtagChannel,
|
||||
}: NewMessageModalProps) {
|
||||
const [tab, setTab] = useState<Tab>('existing');
|
||||
const [tab, setTab] = useState<Tab>('new-contact');
|
||||
const [name, setName] = useState('');
|
||||
const [contactKey, setContactKey] = useState('');
|
||||
const [roomKey, setRoomKey] = useState('');
|
||||
const [channelKey, setChannelKey] = useState('');
|
||||
const [tryHistorical, setTryHistorical] = useState(false);
|
||||
const [permitCapitals, setPermitCapitals] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
@@ -53,7 +47,7 @@ export function NewMessageModal({
|
||||
const resetForm = () => {
|
||||
setName('');
|
||||
setContactKey('');
|
||||
setRoomKey('');
|
||||
setChannelKey('');
|
||||
setTryHistorical(false);
|
||||
setPermitCapitals(false);
|
||||
setError('');
|
||||
@@ -71,12 +65,12 @@ export function NewMessageModal({
|
||||
}
|
||||
// handleCreateContact sets activeConversation with the backend-normalized key
|
||||
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
|
||||
} else if (tab === 'new-room') {
|
||||
if (!name.trim() || !roomKey.trim()) {
|
||||
setError('Room name and key are required');
|
||||
} else if (tab === 'new-channel') {
|
||||
if (!name.trim() || !channelKey.trim()) {
|
||||
setError('Channel name and key are required');
|
||||
return;
|
||||
}
|
||||
await onCreateChannel(name.trim(), roomKey.trim(), tryHistorical);
|
||||
await onCreateChannel(name.trim(), channelKey.trim(), tryHistorical);
|
||||
} else if (tab === 'hashtag') {
|
||||
const channelName = name.trim();
|
||||
const validationError = validateHashtagName(channelName);
|
||||
@@ -136,7 +130,7 @@ export function NewMessageModal({
|
||||
}
|
||||
};
|
||||
|
||||
const showHistoricalOption = tab !== 'existing' && undecryptedCount > 0;
|
||||
const showHistoricalOption = undecryptedCount > 0;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -152,9 +146,8 @@ export function NewMessageModal({
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{tab === 'existing' && 'Select an existing contact to start a conversation'}
|
||||
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
|
||||
{tab === 'new-room' && 'Create a private room with a shared encryption key'}
|
||||
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
|
||||
{tab === 'hashtag' && 'Join a public hashtag channel'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -167,53 +160,12 @@ export function NewMessageModal({
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="existing">Existing</TabsTrigger>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="new-contact">Contact</TabsTrigger>
|
||||
<TabsTrigger value="new-room">Room</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag</TabsTrigger>
|
||||
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
|
||||
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="existing" className="mt-4">
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-md border">
|
||||
{contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
|
||||
) : (
|
||||
contacts
|
||||
.filter((contact) => contact.public_key.length === 64)
|
||||
.map((contact) => (
|
||||
<div
|
||||
key={contact.public_key}
|
||||
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).click();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
onSelectConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(
|
||||
contact.name,
|
||||
contact.public_key,
|
||||
contact.last_advert
|
||||
),
|
||||
});
|
||||
resetForm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new-contact" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="contact-name">Name</Label>
|
||||
@@ -235,23 +187,23 @@ export function NewMessageModal({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="new-room" className="mt-4 space-y-4">
|
||||
<TabsContent value="new-channel" className="mt-4 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room-name">Room Name</Label>
|
||||
<Label htmlFor="channel-name">Channel Name</Label>
|
||||
<Input
|
||||
id="room-name"
|
||||
id="channel-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="Room name"
|
||||
placeholder="Channel name"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="room-key">Room Key</Label>
|
||||
<Label htmlFor="channel-key">Channel Key</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="room-key"
|
||||
value={roomKey}
|
||||
onChange={(e) => setRoomKey(e.target.value)}
|
||||
id="channel-key"
|
||||
value={channelKey}
|
||||
onChange={(e) => setChannelKey(e.target.value)}
|
||||
placeholder="Pre-shared key (hex)"
|
||||
className="flex-1"
|
||||
/>
|
||||
@@ -265,7 +217,7 @@ export function NewMessageModal({
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
setRoomKey(hex);
|
||||
setChannelKey(hex);
|
||||
}}
|
||||
title="Generate random key"
|
||||
aria-label="Generate random key"
|
||||
@@ -299,7 +251,7 @@ export function NewMessageModal({
|
||||
onChange={(e) => setPermitCapitals(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Permit capitals in room key derivation</span>
|
||||
<span className="text-sm">Permit capitals in channel key derivation</span>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground pl-7">
|
||||
Not recommended; most companions normalize to lowercase
|
||||
@@ -353,11 +305,9 @@ export function NewMessageModal({
|
||||
{loading ? 'Creating...' : 'Create & Add Another'}
|
||||
</Button>
|
||||
)}
|
||||
{tab !== 'existing' && (
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={handleCreate} disabled={loading}>
|
||||
{loading ? 'Creating...' : 'Create'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
} from '../utils/pathUtils';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
|
||||
import type { DistanceUnit } from '../utils/distanceUnits';
|
||||
|
||||
const PathRouteMap = lazy(() =>
|
||||
import('./PathRouteMap').then((m) => ({ default: m.PathRouteMap }))
|
||||
@@ -27,9 +29,11 @@ interface PathModalProps {
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
messageId?: number;
|
||||
packetId?: number | null;
|
||||
isOutgoingChan?: boolean;
|
||||
isResendable?: boolean;
|
||||
onResend?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
onAnalyzePacket?: () => void;
|
||||
}
|
||||
|
||||
export function PathModal({
|
||||
@@ -40,13 +44,17 @@ export function PathModal({
|
||||
contacts,
|
||||
config,
|
||||
messageId,
|
||||
packetId,
|
||||
isOutgoingChan,
|
||||
isResendable,
|
||||
onResend,
|
||||
onAnalyzePacket,
|
||||
}: PathModalProps) {
|
||||
const [expandedMaps, setExpandedMaps] = useState<Set<number>>(new Set());
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const [mapModalIndex, setMapModalIndex] = useState<number | null>(null);
|
||||
const hasResendActions = isOutgoingChan && messageId !== undefined && onResend;
|
||||
const hasPaths = paths.length > 0;
|
||||
const showAnalyzePacket = hasPaths && packetId != null && onAnalyzePacket;
|
||||
|
||||
// Resolve all paths
|
||||
const resolvedPaths = hasPaths
|
||||
@@ -87,6 +95,12 @@ export function PathModal({
|
||||
|
||||
{hasPaths && (
|
||||
<div className="flex-1 overflow-y-auto py-2 space-y-4">
|
||||
{showAnalyzePacket ? (
|
||||
<Button type="button" variant="outline" className="w-full" onClick={onAnalyzePacket}>
|
||||
Analyze Packet
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{/* Raw path summary */}
|
||||
<div className="text-sm">
|
||||
{paths.map((p, index) => {
|
||||
@@ -120,61 +134,75 @@ export function PathModal({
|
||||
resolvedPaths[0].resolved.sender.lon,
|
||||
resolvedPaths[0].resolved.receiver.lat,
|
||||
resolvedPaths[0].resolved.receiver.lon
|
||||
)!
|
||||
)!,
|
||||
distanceUnit
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{resolvedPaths.map((pathData, index) => {
|
||||
const mapExpanded = expandedMaps.has(index);
|
||||
const toggleMap = () =>
|
||||
setExpandedMaps((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(index)) next.delete(index);
|
||||
else next.add(index);
|
||||
return next;
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between mb-2 pb-1 border-b border-border">
|
||||
{!hasSinglePath ? (
|
||||
<div className="text-sm text-foreground/70 font-semibold">
|
||||
Path {index + 1}{' '}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
— received {formatTime(pathData.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<button
|
||||
onClick={toggleMap}
|
||||
aria-expanded={mapExpanded}
|
||||
className="text-xs text-primary hover:underline cursor-pointer shrink-0 ml-2"
|
||||
>
|
||||
{mapExpanded ? 'Hide map' : 'Map route'}
|
||||
</button>
|
||||
</div>
|
||||
{mapExpanded && (
|
||||
<div className="mb-2">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className="rounded border border-border bg-muted/30 animate-pulse"
|
||||
style={{ height: 220 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PathRouteMap resolved={pathData.resolved} senderInfo={senderInfo} />
|
||||
</Suspense>
|
||||
{resolvedPaths.map((pathData, index) => (
|
||||
<div key={index}>
|
||||
<div className="flex items-center justify-between mb-2 pb-1 border-b border-border">
|
||||
{!hasSinglePath ? (
|
||||
<div className="text-sm text-foreground/70 font-semibold">
|
||||
Path {index + 1}{' '}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
— received {formatTime(pathData.received_at)}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div />
|
||||
)}
|
||||
<PathVisualization resolved={pathData.resolved} senderInfo={senderInfo} />
|
||||
<button
|
||||
onClick={() => setMapModalIndex(index)}
|
||||
className="text-xs text-primary hover:underline cursor-pointer shrink-0 ml-2"
|
||||
>
|
||||
Map route
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<PathVisualization
|
||||
resolved={pathData.resolved}
|
||||
senderInfo={senderInfo}
|
||||
distanceUnit={distanceUnit}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Map modal — opens when a "Map route" button is clicked */}
|
||||
<Dialog
|
||||
open={mapModalIndex !== null}
|
||||
onOpenChange={(open) => !open && setMapModalIndex(null)}
|
||||
>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mapModalIndex !== null && !hasSinglePath
|
||||
? `Path ${mapModalIndex + 1} Route Map`
|
||||
: 'Route Map'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Map of known node locations along this message route.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{mapModalIndex !== null && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div
|
||||
className="rounded border border-border bg-muted/30 animate-pulse"
|
||||
style={{ height: 400 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<PathRouteMap
|
||||
resolved={resolvedPaths[mapModalIndex].resolved}
|
||||
senderInfo={senderInfo}
|
||||
height={400}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -227,9 +255,10 @@ export function PathModal({
|
||||
interface PathVisualizationProps {
|
||||
resolved: ResolvedPath;
|
||||
senderInfo: SenderInfo;
|
||||
distanceUnit: DistanceUnit;
|
||||
}
|
||||
|
||||
function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
function PathVisualization({ resolved, senderInfo, distanceUnit }: PathVisualizationProps) {
|
||||
// Track previous location for each hop to calculate distances
|
||||
// Returns null if previous hop was ambiguous or has invalid location
|
||||
const getPrevLocation = (hopIndex: number): { lat: number | null; lon: number | null } | null => {
|
||||
@@ -264,6 +293,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
name={resolved.sender.name}
|
||||
prefix={resolved.sender.prefix}
|
||||
distance={null}
|
||||
distanceUnit={distanceUnit}
|
||||
isFirst
|
||||
lat={resolved.sender.lat}
|
||||
lon={resolved.sender.lon}
|
||||
@@ -277,6 +307,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
hop={hop}
|
||||
hopNumber={index + 1}
|
||||
prevLocation={getPrevLocation(index)}
|
||||
distanceUnit={distanceUnit}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -286,6 +317,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
name={resolved.receiver.name}
|
||||
prefix={resolved.receiver.prefix}
|
||||
distance={calculateReceiverDistance(resolved)}
|
||||
distanceUnit={distanceUnit}
|
||||
isLast
|
||||
lat={resolved.receiver.lat}
|
||||
lon={resolved.receiver.lon}
|
||||
@@ -300,7 +332,7 @@ function PathVisualization({ resolved, senderInfo }: PathVisualizationProps) {
|
||||
</span>
|
||||
<span className="text-sm font-medium">
|
||||
{resolved.hasGaps ? '>' : ''}
|
||||
{formatDistance(resolved.totalDistances[0])}
|
||||
{formatDistance(resolved.totalDistances[0], distanceUnit)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -313,6 +345,7 @@ interface PathNodeProps {
|
||||
name: string;
|
||||
prefix: string;
|
||||
distance: number | null;
|
||||
distanceUnit: DistanceUnit;
|
||||
isFirst?: boolean;
|
||||
isLast?: boolean;
|
||||
/** Optional coordinates for map link */
|
||||
@@ -327,6 +360,7 @@ function PathNode({
|
||||
name,
|
||||
prefix,
|
||||
distance,
|
||||
distanceUnit,
|
||||
isFirst,
|
||||
isLast,
|
||||
lat,
|
||||
@@ -353,7 +387,9 @@ function PathNode({
|
||||
<div className="font-medium truncate">
|
||||
{name}
|
||||
{distance !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">- {formatDistance(distance)}</span>
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(distance, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{hasLocation && <CoordinateLink lat={lat!} lon={lon!} publicKey={publicKey!} />}
|
||||
</div>
|
||||
@@ -366,9 +402,10 @@ interface HopNodeProps {
|
||||
hop: PathHop;
|
||||
hopNumber: number;
|
||||
prevLocation: { lat: number | null; lon: number | null } | null;
|
||||
distanceUnit: DistanceUnit;
|
||||
}
|
||||
|
||||
function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
|
||||
const isAmbiguous = hop.matches.length > 1;
|
||||
const isUnknown = hop.matches.length === 0;
|
||||
|
||||
@@ -417,7 +454,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
{dist !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(dist)}
|
||||
- {formatDistance(dist, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{hasLocation && (
|
||||
@@ -436,7 +473,7 @@ function HopNode({ hop, hopNumber, prevLocation }: HopNodeProps) {
|
||||
{hop.matches[0].name || hop.matches[0].public_key.slice(0, 12)}
|
||||
{hop.distanceFromPrev !== null && (
|
||||
<span className="text-xs text-muted-foreground ml-1">
|
||||
- {formatDistance(hop.distanceFromPrev)}
|
||||
- {formatDistance(hop.distanceFromPrev, distanceUnit)}
|
||||
</span>
|
||||
)}
|
||||
{isValidLocation(hop.matches[0].lat, hop.matches[0].lon) && (
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { ResolvedPath, SenderInfo } from '../utils/pathUtils';
|
||||
interface PathRouteMapProps {
|
||||
resolved: ResolvedPath;
|
||||
senderInfo: SenderInfo;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// Colors for hop markers (indexed by hop number - 1)
|
||||
@@ -82,7 +83,7 @@ function RouteMapBounds({ points }: { points: [number, number][] }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
||||
export function PathRouteMap({ resolved, senderInfo, height = 220 }: PathRouteMapProps) {
|
||||
const points = collectPoints(resolved);
|
||||
const hasAnyGps = points.length > 0;
|
||||
|
||||
@@ -117,7 +118,7 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
||||
className="rounded border border-border overflow-hidden"
|
||||
role="img"
|
||||
aria-label="Map showing message route between nodes"
|
||||
style={{ height: 220 }}
|
||||
style={{ height }}
|
||||
>
|
||||
<MapContainer
|
||||
center={center}
|
||||
@@ -138,6 +139,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
||||
icon={makeIcon('S', SENDER_COLOR)}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -14]}>
|
||||
<span className="font-mono">{resolved.sender.prefix}</span>
|
||||
{' · '}
|
||||
{senderInfo.name || 'Sender'}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
@@ -154,6 +157,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
||||
icon={makeIcon(String(hopIdx + 1), getHopColor(hopIdx))}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -14]}>
|
||||
<span className="font-mono">{hop.prefix}</span>
|
||||
{' · '}
|
||||
{m.name || m.public_key.slice(0, 12)}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
@@ -167,6 +172,8 @@ export function PathRouteMap({ resolved, senderInfo }: PathRouteMapProps) {
|
||||
icon={makeIcon('R', RECEIVER_COLOR)}
|
||||
>
|
||||
<Tooltip direction="top" offset={[0, -14]}>
|
||||
<span className="font-mono">{resolved.receiver.prefix}</span>
|
||||
{' · '}
|
||||
{resolved.receiver.name || 'Receiver'}
|
||||
</Tooltip>
|
||||
</Marker>
|
||||
|
||||
820
frontend/src/components/RawPacketDetailModal.tsx
Normal file
820
frontend/src/components/RawPacketDetailModal.tsx
Normal file
@@ -0,0 +1,820 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { ChannelCrypto, PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
createDecoderOptions,
|
||||
inspectRawPacketWithOptions,
|
||||
type PacketByteField,
|
||||
} from '../utils/rawPacketInspector';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
|
||||
interface RawPacketDetailModalProps {
|
||||
packet: RawPacket | null;
|
||||
channels: Channel[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type RawPacketInspectorDialogSource =
|
||||
| {
|
||||
kind: 'packet';
|
||||
packet: RawPacket;
|
||||
}
|
||||
| {
|
||||
kind: 'paste';
|
||||
}
|
||||
| {
|
||||
kind: 'loading';
|
||||
message: string;
|
||||
}
|
||||
| {
|
||||
kind: 'unavailable';
|
||||
message: string;
|
||||
};
|
||||
|
||||
interface RawPacketInspectorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
channels: Channel[];
|
||||
source: RawPacketInspectorDialogSource;
|
||||
title: string;
|
||||
description: string;
|
||||
notice?: ReactNode;
|
||||
}
|
||||
|
||||
interface RawPacketInspectionPanelProps {
|
||||
packet: RawPacket;
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
interface FieldPaletteEntry {
|
||||
box: string;
|
||||
boxActive: string;
|
||||
hex: string;
|
||||
hexActive: string;
|
||||
}
|
||||
|
||||
interface GroupTextResolutionCandidate {
|
||||
key: string;
|
||||
name: string;
|
||||
hash: string;
|
||||
}
|
||||
|
||||
const FIELD_PALETTE: FieldPaletteEntry[] = [
|
||||
{
|
||||
box: 'border-sky-500/30 bg-sky-500/10',
|
||||
boxActive: 'border-sky-600 bg-sky-500/20 shadow-sm shadow-sky-500/20',
|
||||
hex: 'bg-sky-500/20 ring-1 ring-inset ring-sky-500/35',
|
||||
hexActive: 'bg-sky-500/40 ring-1 ring-inset ring-sky-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-emerald-500/30 bg-emerald-500/10',
|
||||
boxActive: 'border-emerald-600 bg-emerald-500/20 shadow-sm shadow-emerald-500/20',
|
||||
hex: 'bg-emerald-500/20 ring-1 ring-inset ring-emerald-500/35',
|
||||
hexActive: 'bg-emerald-500/40 ring-1 ring-inset ring-emerald-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-amber-500/30 bg-amber-500/10',
|
||||
boxActive: 'border-amber-600 bg-amber-500/20 shadow-sm shadow-amber-500/20',
|
||||
hex: 'bg-amber-500/20 ring-1 ring-inset ring-amber-500/35',
|
||||
hexActive: 'bg-amber-500/40 ring-1 ring-inset ring-amber-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-rose-500/30 bg-rose-500/10',
|
||||
boxActive: 'border-rose-600 bg-rose-500/20 shadow-sm shadow-rose-500/20',
|
||||
hex: 'bg-rose-500/20 ring-1 ring-inset ring-rose-500/35',
|
||||
hexActive: 'bg-rose-500/40 ring-1 ring-inset ring-rose-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-violet-500/30 bg-violet-500/10',
|
||||
boxActive: 'border-violet-600 bg-violet-500/20 shadow-sm shadow-violet-500/20',
|
||||
hex: 'bg-violet-500/20 ring-1 ring-inset ring-violet-500/35',
|
||||
hexActive: 'bg-violet-500/40 ring-1 ring-inset ring-violet-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-cyan-500/30 bg-cyan-500/10',
|
||||
boxActive: 'border-cyan-600 bg-cyan-500/20 shadow-sm shadow-cyan-500/20',
|
||||
hex: 'bg-cyan-500/20 ring-1 ring-inset ring-cyan-500/35',
|
||||
hexActive: 'bg-cyan-500/40 ring-1 ring-inset ring-cyan-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-lime-500/30 bg-lime-500/10',
|
||||
boxActive: 'border-lime-600 bg-lime-500/20 shadow-sm shadow-lime-500/20',
|
||||
hex: 'bg-lime-500/20 ring-1 ring-inset ring-lime-500/35',
|
||||
hexActive: 'bg-lime-500/40 ring-1 ring-inset ring-lime-600/70',
|
||||
},
|
||||
{
|
||||
box: 'border-fuchsia-500/30 bg-fuchsia-500/10',
|
||||
boxActive: 'border-fuchsia-600 bg-fuchsia-500/20 shadow-sm shadow-fuchsia-500/20',
|
||||
hex: 'bg-fuchsia-500/20 ring-1 ring-inset ring-fuchsia-500/35',
|
||||
hexActive: 'bg-fuchsia-500/40 ring-1 ring-inset ring-fuchsia-600/70',
|
||||
},
|
||||
];
|
||||
|
||||
function formatTimestamp(timestamp: number): string {
|
||||
return new Date(timestamp * 1000).toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatSignal(packet: RawPacket): string {
|
||||
const parts: string[] = [];
|
||||
if (packet.rssi !== null) {
|
||||
parts.push(`${packet.rssi} dBm RSSI`);
|
||||
}
|
||||
if (packet.snr !== null) {
|
||||
parts.push(`${packet.snr.toFixed(1)} dB SNR`);
|
||||
}
|
||||
return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
|
||||
}
|
||||
|
||||
function formatByteRange(field: PacketByteField): string {
|
||||
if (field.absoluteStartByte === field.absoluteEndByte) {
|
||||
return `Byte ${field.absoluteStartByte}`;
|
||||
}
|
||||
return `Bytes ${field.absoluteStartByte}-${field.absoluteEndByte}`;
|
||||
}
|
||||
|
||||
function formatPathMode(hashSize: number | undefined, hopCount: number): string {
|
||||
if (hopCount === 0) {
|
||||
return 'No path hops';
|
||||
}
|
||||
if (!hashSize) {
|
||||
return `${hopCount} hop${hopCount === 1 ? '' : 's'}`;
|
||||
}
|
||||
return `${hopCount} hop${hopCount === 1 ? '' : 's'} · ${hashSize} byte hash${hashSize === 1 ? '' : 'es'}`;
|
||||
}
|
||||
|
||||
function buildGroupTextResolutionCandidates(channels: Channel[]): GroupTextResolutionCandidate[] {
|
||||
return channels.map((channel) => ({
|
||||
key: channel.key,
|
||||
name: channel.name,
|
||||
hash: ChannelCrypto.calculateChannelHash(channel.key).toUpperCase(),
|
||||
}));
|
||||
}
|
||||
|
||||
function resolveGroupTextChannelName(
|
||||
payload: {
|
||||
channelHash?: string;
|
||||
cipherMac?: string;
|
||||
ciphertext?: string;
|
||||
decrypted?: { message?: string };
|
||||
},
|
||||
candidates: GroupTextResolutionCandidate[]
|
||||
): string | null {
|
||||
if (!payload.channelHash) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hashMatches = candidates.filter(
|
||||
(candidate) => candidate.hash === payload.channelHash?.toUpperCase()
|
||||
);
|
||||
if (hashMatches.length === 1) {
|
||||
return hashMatches[0].name;
|
||||
}
|
||||
if (
|
||||
hashMatches.length <= 1 ||
|
||||
!payload.cipherMac ||
|
||||
!payload.ciphertext ||
|
||||
!payload.decrypted?.message
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decryptMatches = hashMatches.filter(
|
||||
(candidate) =>
|
||||
ChannelCrypto.decryptGroupTextMessage(payload.ciphertext!, payload.cipherMac!, candidate.key)
|
||||
.success
|
||||
);
|
||||
return decryptMatches.length === 1 ? decryptMatches[0].name : null;
|
||||
}
|
||||
|
||||
function packetShowsDecryptedState(
|
||||
packet: RawPacket,
|
||||
inspection: ReturnType<typeof inspectRawPacketWithOptions>
|
||||
): boolean {
|
||||
const payload = inspection.decoded?.payload.decoded as { decrypted?: unknown } | null | undefined;
|
||||
return packet.decrypted || Boolean(packet.decrypted_info) || Boolean(payload?.decrypted);
|
||||
}
|
||||
|
||||
function getPacketContext(
|
||||
packet: RawPacket,
|
||||
inspection: ReturnType<typeof inspectRawPacketWithOptions>,
|
||||
groupTextCandidates: GroupTextResolutionCandidate[]
|
||||
) {
|
||||
const fallbackSender = packet.decrypted_info?.sender ?? null;
|
||||
const fallbackChannel = packet.decrypted_info?.channel_name ?? null;
|
||||
|
||||
if (!inspection.decoded?.payload.decoded) {
|
||||
if (!fallbackSender && !fallbackChannel) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
title: fallbackChannel ? 'Channel' : 'Context',
|
||||
primary: fallbackChannel ?? 'Sender metadata available',
|
||||
secondary: fallbackSender ? `Sender: ${fallbackSender}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
if (inspection.decoded.payloadType === PayloadType.GroupText) {
|
||||
const payload = inspection.decoded.payload.decoded as {
|
||||
channelHash?: string;
|
||||
cipherMac?: string;
|
||||
ciphertext?: string;
|
||||
decrypted?: { sender?: string; message?: string };
|
||||
};
|
||||
const channelName =
|
||||
fallbackChannel ?? resolveGroupTextChannelName(payload, groupTextCandidates);
|
||||
return {
|
||||
title: 'Channel',
|
||||
primary:
|
||||
channelName ?? (payload.channelHash ? `Channel hash ${payload.channelHash}` : 'GroupText'),
|
||||
secondary: payload.decrypted?.sender
|
||||
? `Sender: ${payload.decrypted.sender}`
|
||||
: fallbackSender
|
||||
? `Sender: ${fallbackSender}`
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (fallbackSender) {
|
||||
return {
|
||||
title: 'Context',
|
||||
primary: fallbackSender,
|
||||
secondary: null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDisplayFields(inspection: ReturnType<typeof inspectRawPacketWithOptions>) {
|
||||
return [
|
||||
...inspection.packetFields.filter((field) => field.name !== 'Payload'),
|
||||
...inspection.payloadFields,
|
||||
];
|
||||
}
|
||||
|
||||
function buildFieldColorMap(fields: PacketByteField[]) {
|
||||
return new Map(
|
||||
fields.map((field, index) => [field.id, FIELD_PALETTE[index % FIELD_PALETTE.length]])
|
||||
);
|
||||
}
|
||||
|
||||
function buildByteOwners(totalBytes: number, fields: PacketByteField[]) {
|
||||
const owners = new Array<string | null>(totalBytes).fill(null);
|
||||
for (const field of fields) {
|
||||
for (let index = field.absoluteStartByte; index <= field.absoluteEndByte; index += 1) {
|
||||
if (index >= 0 && index < owners.length) {
|
||||
owners[index] = field.id;
|
||||
}
|
||||
}
|
||||
}
|
||||
return owners;
|
||||
}
|
||||
|
||||
function buildByteRuns(bytes: string[], owners: Array<string | null>) {
|
||||
const runs: Array<{ fieldId: string | null; text: string }> = [];
|
||||
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
const fieldId = owners[index];
|
||||
const lastRun = runs[runs.length - 1];
|
||||
if (lastRun && lastRun.fieldId === fieldId) {
|
||||
lastRun.text += ` ${bytes[index]}`;
|
||||
continue;
|
||||
}
|
||||
|
||||
runs.push({
|
||||
fieldId,
|
||||
text: bytes[index],
|
||||
});
|
||||
}
|
||||
|
||||
return runs;
|
||||
}
|
||||
|
||||
function CompactMetaCard({
|
||||
label,
|
||||
primary,
|
||||
secondary,
|
||||
}: {
|
||||
label: string;
|
||||
primary: string;
|
||||
secondary?: string | null;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
|
||||
{secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FullPacketHex({
|
||||
packetHex,
|
||||
fields,
|
||||
colorMap,
|
||||
hoveredFieldId,
|
||||
onHoverField,
|
||||
}: {
|
||||
packetHex: string;
|
||||
fields: PacketByteField[];
|
||||
colorMap: Map<string, FieldPaletteEntry>;
|
||||
hoveredFieldId: string | null;
|
||||
onHoverField: (fieldId: string | null) => void;
|
||||
}) {
|
||||
const normalized = packetHex.toUpperCase();
|
||||
const bytes = useMemo(() => normalized.match(/.{1,2}/g) ?? [], [normalized]);
|
||||
const byteOwners = useMemo(() => buildByteOwners(bytes.length, fields), [bytes.length, fields]);
|
||||
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
|
||||
|
||||
return (
|
||||
<div className="font-mono text-[15px] leading-7 text-foreground">
|
||||
{byteRuns.map((run, index) => {
|
||||
const fieldId = run.fieldId;
|
||||
const palette = fieldId ? colorMap.get(fieldId) : null;
|
||||
const active = fieldId !== null && hoveredFieldId === fieldId;
|
||||
return (
|
||||
<span key={`${fieldId ?? 'plain'}-${index}`}>
|
||||
<span
|
||||
onMouseEnter={() => onHoverField(fieldId)}
|
||||
onMouseLeave={() => onHoverField(null)}
|
||||
className={cn(
|
||||
'inline rounded-sm px-0.5 py-0.5 transition-colors',
|
||||
palette ? (active ? palette.hexActive : palette.hex) : ''
|
||||
)}
|
||||
>
|
||||
{run.text}
|
||||
</span>
|
||||
{index < byteRuns.length - 1 ? ' ' : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderFieldValue(field: PacketByteField) {
|
||||
if (field.name !== 'Path Data') {
|
||||
return field.value.toUpperCase();
|
||||
}
|
||||
|
||||
const parts = field.value
|
||||
.toUpperCase()
|
||||
.split(' → ')
|
||||
.filter((part) => part.length > 0);
|
||||
|
||||
if (parts.length <= 1) {
|
||||
return field.value.toUpperCase();
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="inline-flex flex-wrap justify-start gap-x-1 sm:justify-end">
|
||||
{parts.map((part, index) => {
|
||||
const isLast = index === parts.length - 1;
|
||||
return (
|
||||
<span key={`${field.id}-${part}-${index}`} className="whitespace-nowrap">
|
||||
{isLast ? part : `${part} →`}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePacketHex(input: string): string {
|
||||
return input.replace(/\s+/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
function validatePacketHex(input: string): string | null {
|
||||
if (!input) {
|
||||
return 'Paste a packet hex string to analyze.';
|
||||
}
|
||||
if (!/^[0-9A-F]+$/.test(input)) {
|
||||
return 'Packet hex may only contain 0-9 and A-F characters.';
|
||||
}
|
||||
if (input.length % 2 !== 0) {
|
||||
return 'Packet hex must contain an even number of characters.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildPastedRawPacket(packetHex: string): RawPacket {
|
||||
return {
|
||||
id: -1,
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
data: packetHex,
|
||||
payload_type: 'Unknown',
|
||||
snr: null,
|
||||
rssi: null,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
};
|
||||
}
|
||||
|
||||
function FieldBox({
|
||||
field,
|
||||
palette,
|
||||
active,
|
||||
onHoverField,
|
||||
}: {
|
||||
field: PacketByteField;
|
||||
palette: FieldPaletteEntry;
|
||||
active: boolean;
|
||||
onHoverField: (fieldId: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
onMouseEnter={() => onHoverField(field.id)}
|
||||
onMouseLeave={() => onHoverField(null)}
|
||||
className={cn(
|
||||
'rounded-lg border p-2.5 transition-colors',
|
||||
active ? palette.boxActive : palette.box
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'w-full font-mono text-sm leading-5 text-foreground sm:max-w-[14rem] sm:text-right',
|
||||
field.name === 'Path Data' ? 'break-normal' : 'break-all'
|
||||
)}
|
||||
>
|
||||
{renderFieldValue(field)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 whitespace-pre-wrap text-sm leading-5 text-foreground">
|
||||
{field.description}
|
||||
</div>
|
||||
|
||||
{field.decryptedMessage ? (
|
||||
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
|
||||
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
|
||||
</div>
|
||||
<PlaintextContent text={field.decryptedMessage} />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{field.headerBreakdown ? (
|
||||
<div className="mt-2 space-y-1.5">
|
||||
<div className="font-mono text-xs tracking-[0.16em] text-muted-foreground">
|
||||
{field.headerBreakdown.fullBinary}
|
||||
</div>
|
||||
{field.headerBreakdown.fields.map((part) => (
|
||||
<div
|
||||
key={`${field.id}-${part.bits}-${part.field}`}
|
||||
className="rounded border border-border/50 bg-background/40 p-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<div className="text-sm font-medium leading-tight text-foreground">
|
||||
{part.field}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="font-mono text-sm text-foreground">{part.binary}</div>
|
||||
<div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PlaintextContent({ text }: { text: string }) {
|
||||
const lines = text.split('\n');
|
||||
|
||||
return (
|
||||
<div className="mt-1 space-y-1 text-sm leading-5 text-foreground">
|
||||
{lines.map((line, index) => {
|
||||
const separatorIndex = line.indexOf(': ');
|
||||
if (separatorIndex === -1) {
|
||||
return (
|
||||
<div key={`${line}-${index}`} className="font-mono">
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const label = line.slice(0, separatorIndex + 1);
|
||||
const value = line.slice(separatorIndex + 2);
|
||||
|
||||
return (
|
||||
<div key={`${line}-${index}`}>
|
||||
<span>{label} </span>
|
||||
<span className="font-mono">{value}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldSection({
|
||||
title,
|
||||
fields,
|
||||
colorMap,
|
||||
hoveredFieldId,
|
||||
onHoverField,
|
||||
}: {
|
||||
title: string;
|
||||
fields: PacketByteField[];
|
||||
colorMap: Map<string, FieldPaletteEntry>;
|
||||
hoveredFieldId: string | null;
|
||||
onHoverField: (fieldId: string | null) => void;
|
||||
}) {
|
||||
return (
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="mb-2 text-sm font-semibold text-foreground">{title}</div>
|
||||
{fields.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">No decoded fields available.</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
{fields.map((field) => (
|
||||
<FieldBox
|
||||
key={field.id}
|
||||
field={field}
|
||||
palette={colorMap.get(field.id) ?? FIELD_PALETTE[0]}
|
||||
active={hoveredFieldId === field.id}
|
||||
onHoverField={onHoverField}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
const groupTextCandidates = useMemo(
|
||||
() => buildGroupTextResolutionCandidates(channels),
|
||||
[channels]
|
||||
);
|
||||
const inspection = useMemo(
|
||||
() => inspectRawPacketWithOptions(packet, decoderOptions),
|
||||
[decoderOptions, packet]
|
||||
);
|
||||
const [hoveredFieldId, setHoveredFieldId] = useState<string | null>(null);
|
||||
|
||||
const packetDisplayFields = useMemo(
|
||||
() => inspection.packetFields.filter((field) => field.name !== 'Payload'),
|
||||
[inspection]
|
||||
);
|
||||
const fullPacketFields = useMemo(() => buildDisplayFields(inspection), [inspection]);
|
||||
const colorMap = useMemo(() => buildFieldColorMap(fullPacketFields), [fullPacketFields]);
|
||||
const packetContext = useMemo(
|
||||
() => getPacketContext(packet, inspection, groupTextCandidates),
|
||||
[groupTextCandidates, inspection, packet]
|
||||
);
|
||||
const packetIsDecrypted = useMemo(
|
||||
() => packetShowsDecryptedState(packet, inspection),
|
||||
[inspection, packet]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-3">
|
||||
<div className="grid gap-2 lg:grid-cols-[minmax(0,1.45fr)_minmax(0,1fr)]">
|
||||
<section className="rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Summary
|
||||
</div>
|
||||
<div className="mt-1 text-base font-semibold leading-tight text-foreground">
|
||||
{inspection.summary.summary}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatTimestamp(packet.timestamp)}
|
||||
</div>
|
||||
</div>
|
||||
{packetContext ? (
|
||||
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
|
||||
<div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{packetContext.title}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium leading-tight text-foreground">
|
||||
{packetContext.primary}
|
||||
</div>
|
||||
{packetContext.secondary ? (
|
||||
<div className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
{packetContext.secondary}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="grid gap-2 sm:grid-cols-3 lg:grid-cols-1 xl:grid-cols-3">
|
||||
<CompactMetaCard
|
||||
label="Packet"
|
||||
primary={`${packet.data.length / 2} bytes · ${packetIsDecrypted ? 'Decrypted' : 'Encrypted'}`}
|
||||
secondary={`Storage #${packet.id}${packet.observation_id !== undefined ? ` · Observation #${packet.observation_id}` : ''}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Transport"
|
||||
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
|
||||
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
|
||||
/>
|
||||
<CompactMetaCard
|
||||
label="Signal"
|
||||
primary={formatSignal(packet)}
|
||||
secondary={packetContext ? null : undefined}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{inspection.validationErrors.length > 0 ? (
|
||||
<div className="mt-3 rounded-lg border border-warning/40 bg-warning/10 p-2.5">
|
||||
<div className="text-sm font-semibold text-foreground">Validation notes</div>
|
||||
<div className="mt-1.5 space-y-1 text-sm text-foreground">
|
||||
{inspection.validationErrors.map((error) => (
|
||||
<div key={error}>{error}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-xl font-semibold text-foreground">Full packet hex</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(packet.data);
|
||||
toast.success('Packet hex copied!');
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-2.5">
|
||||
<FullPacketHex
|
||||
packetHex={packet.data}
|
||||
fields={fullPacketFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 grid gap-3 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]">
|
||||
<FieldSection
|
||||
title="Packet fields"
|
||||
fields={packetDisplayFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
|
||||
<FieldSection
|
||||
title="Payload fields"
|
||||
fields={inspection.payloadFields}
|
||||
colorMap={colorMap}
|
||||
hoveredFieldId={hoveredFieldId}
|
||||
onHoverField={setHoveredFieldId}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketInspectorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
channels,
|
||||
source,
|
||||
title,
|
||||
description,
|
||||
notice,
|
||||
}: RawPacketInspectorDialogProps) {
|
||||
const [packetInput, setPacketInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || source.kind !== 'paste') {
|
||||
setPacketInput('');
|
||||
}
|
||||
}, [open, source.kind]);
|
||||
|
||||
const normalizedPacketInput = useMemo(() => normalizePacketHex(packetInput), [packetInput]);
|
||||
const packetInputError = useMemo(
|
||||
() => (normalizedPacketInput.length > 0 ? validatePacketHex(normalizedPacketInput) : null),
|
||||
[normalizedPacketInput]
|
||||
);
|
||||
const analyzedPacket = useMemo(
|
||||
() =>
|
||||
normalizedPacketInput.length > 0 && packetInputError === null
|
||||
? buildPastedRawPacket(normalizedPacketInput)
|
||||
: null,
|
||||
[normalizedPacketInput, packetInputError]
|
||||
);
|
||||
|
||||
let body: ReactNode;
|
||||
if (source.kind === 'packet') {
|
||||
body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
|
||||
} else if (source.kind === 'paste') {
|
||||
body = (
|
||||
<>
|
||||
<div className="border-b border-border px-4 py-3 pr-14">
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="text-sm font-medium text-foreground" htmlFor="raw-packet-input">
|
||||
Packet Hex
|
||||
</label>
|
||||
<textarea
|
||||
id="raw-packet-input"
|
||||
value={packetInput}
|
||||
onChange={(event) => setPacketInput(event.target.value)}
|
||||
placeholder="Paste raw packet hex here..."
|
||||
className="min-h-14 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-sm text-foreground outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{packetInputError ? (
|
||||
<div className="text-sm text-destructive">{packetInputError}</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{analyzedPacket ? (
|
||||
<RawPacketInspectionPanel packet={analyzedPacket} channels={channels} />
|
||||
) : (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
Paste a packet above to inspect it.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
} else if (source.kind === 'loading') {
|
||||
body = (
|
||||
<div className="flex flex-1 items-center justify-center p-6 text-sm text-muted-foreground">
|
||||
{source.message}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div className="max-w-xl rounded-lg border border-warning/40 bg-warning/10 p-4 text-sm text-foreground">
|
||||
{source.message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex h-[92vh] max-w-[min(96vw,82rem)] flex-col gap-0 overflow-hidden p-0">
|
||||
<DialogHeader className="border-b border-border px-5 py-3">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{notice ? (
|
||||
<div className="border-b border-border px-3 py-3 text-sm text-foreground">
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-destructive">
|
||||
{notice}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{body}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketDetailModal({ packet, channels, onClose }: RawPacketDetailModalProps) {
|
||||
if (!packet) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<RawPacketInspectorDialog
|
||||
open={packet !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && onClose()}
|
||||
channels={channels}
|
||||
source={{ kind: 'packet', packet }}
|
||||
title="Packet Details"
|
||||
description="Detailed byte and field breakdown for the selected raw packet."
|
||||
/>
|
||||
);
|
||||
}
|
||||
637
frontend/src/components/RawPacketFeedView.tsx
Normal file
637
frontend/src/components/RawPacketFeedView.tsx
Normal file
@@ -0,0 +1,637 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import { RawPacketInspectorDialog } from './RawPacketDetailModal';
|
||||
import { Button } from './ui/button';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
import {
|
||||
RAW_PACKET_STATS_WINDOWS,
|
||||
buildRawPacketStatsSnapshot,
|
||||
type NeighborStat,
|
||||
type PacketTimelineBin,
|
||||
type RankedPacketStat,
|
||||
type RawPacketStatsSessionState,
|
||||
type RawPacketStatsWindow,
|
||||
} from '../utils/rawPacketStats';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RawPacketFeedViewProps {
|
||||
packets: RawPacket[];
|
||||
rawPacketStatsSession: RawPacketStatsSessionState;
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
}
|
||||
|
||||
const WINDOW_LABELS: Record<RawPacketStatsWindow, string> = {
|
||||
'1m': '1 min',
|
||||
'5m': '5 min',
|
||||
'10m': '10 min',
|
||||
'30m': '30 min',
|
||||
session: 'Session',
|
||||
};
|
||||
|
||||
const TIMELINE_COLORS = [
|
||||
'bg-sky-500/80',
|
||||
'bg-emerald-500/80',
|
||||
'bg-amber-500/80',
|
||||
'bg-rose-500/80',
|
||||
'bg-violet-500/80',
|
||||
];
|
||||
|
||||
function formatTimestamp(timestampMs: number): string {
|
||||
return new Date(timestampMs).toLocaleString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (seconds < 60) {
|
||||
return `${Math.max(1, Math.round(seconds))} sec`;
|
||||
}
|
||||
if (seconds < 3600) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remainder = Math.round(seconds % 60);
|
||||
return remainder > 0 ? `${minutes}m ${remainder}s` : `${minutes}m`;
|
||||
}
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.round((seconds % 3600) / 60);
|
||||
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
|
||||
}
|
||||
|
||||
function formatRate(value: number): string {
|
||||
if (value >= 100) return value.toFixed(0);
|
||||
if (value >= 10) return value.toFixed(1);
|
||||
return value.toFixed(2);
|
||||
}
|
||||
|
||||
function formatPercent(value: number): string {
|
||||
return `${Math.round(value * 100)}%`;
|
||||
}
|
||||
|
||||
function formatRssi(value: number | null): string {
|
||||
return value === null ? '-' : `${Math.round(value)} dBm`;
|
||||
}
|
||||
|
||||
function normalizeResolvableSourceKey(sourceKey: string): string {
|
||||
return sourceKey.startsWith('hash1:') ? sourceKey.slice(6) : sourceKey;
|
||||
}
|
||||
|
||||
function resolveContact(sourceKey: string | null, contacts: Contact[]): Contact | null {
|
||||
if (!sourceKey || sourceKey.startsWith('name:')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalizedSourceKey = normalizeResolvableSourceKey(sourceKey).toLowerCase();
|
||||
const matches = contacts.filter((contact) =>
|
||||
contact.public_key.toLowerCase().startsWith(normalizedSourceKey)
|
||||
);
|
||||
if (matches.length !== 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function resolveContactLabel(sourceKey: string | null, contacts: Contact[]): string | null {
|
||||
const contact = resolveContact(sourceKey, contacts);
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
return getContactDisplayName(contact.name, contact.public_key, contact.last_advert);
|
||||
}
|
||||
|
||||
function resolveNeighbor(item: NeighborStat, contacts: Contact[]): NeighborStat {
|
||||
return {
|
||||
...item,
|
||||
label: resolveContactLabel(item.key, contacts) ?? item.label,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeResolvedNeighbors(items: NeighborStat[], contacts: Contact[]): NeighborStat[] {
|
||||
const merged = new Map<string, NeighborStat>();
|
||||
|
||||
for (const item of items) {
|
||||
const contact = resolveContact(item.key, contacts);
|
||||
const canonicalKey = contact?.public_key ?? item.key;
|
||||
const resolvedLabel =
|
||||
contact != null
|
||||
? getContactDisplayName(contact.name, contact.public_key, contact.last_advert)
|
||||
: item.label;
|
||||
const existing = merged.get(canonicalKey);
|
||||
|
||||
if (!existing) {
|
||||
merged.set(canonicalKey, {
|
||||
...item,
|
||||
key: canonicalKey,
|
||||
label: resolvedLabel,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
existing.count += item.count;
|
||||
existing.lastSeen = Math.max(existing.lastSeen, item.lastSeen);
|
||||
existing.bestRssi =
|
||||
existing.bestRssi === null
|
||||
? item.bestRssi
|
||||
: item.bestRssi === null
|
||||
? existing.bestRssi
|
||||
: Math.max(existing.bestRssi, item.bestRssi);
|
||||
existing.label = resolvedLabel;
|
||||
}
|
||||
|
||||
return Array.from(merged.values());
|
||||
}
|
||||
|
||||
function isNeighborIdentityResolvable(item: NeighborStat, contacts: Contact[]): boolean {
|
||||
if (item.key.startsWith('name:')) {
|
||||
return true;
|
||||
}
|
||||
return resolveContact(item.key, contacts) !== null;
|
||||
}
|
||||
|
||||
function formatStrongestPacketDetail(
|
||||
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
||||
contacts: Contact[]
|
||||
): string | undefined {
|
||||
if (!stats.strongestPacketPayloadType) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const resolvedLabel =
|
||||
resolveContactLabel(stats.strongestPacketSourceKey, contacts) ??
|
||||
stats.strongestPacketSourceLabel;
|
||||
if (resolvedLabel) {
|
||||
return `${resolvedLabel} · ${stats.strongestPacketPayloadType}`;
|
||||
}
|
||||
if (stats.strongestPacketPayloadType === 'GroupText') {
|
||||
return '<unknown sender> · GroupText';
|
||||
}
|
||||
return stats.strongestPacketPayloadType;
|
||||
}
|
||||
|
||||
function getCoverageMessage(
|
||||
stats: ReturnType<typeof buildRawPacketStatsSnapshot>,
|
||||
session: RawPacketStatsSessionState
|
||||
): { tone: 'default' | 'warning'; message: string } {
|
||||
if (session.trimmedObservationCount > 0 && stats.window === 'session') {
|
||||
return {
|
||||
tone: 'warning',
|
||||
message: `Detailed session history was trimmed after ${session.totalObservedPackets.toLocaleString()} observations.`,
|
||||
};
|
||||
}
|
||||
|
||||
if (!stats.windowFullyCovered) {
|
||||
return {
|
||||
tone: 'warning',
|
||||
message: `This window is only covered for ${formatDuration(stats.coverageSeconds)} of frontend-collected history.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
tone: 'default',
|
||||
message: `Tracking ${session.observations.length.toLocaleString()} detailed observations from this browser session.`,
|
||||
};
|
||||
}
|
||||
|
||||
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
|
||||
return (
|
||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
|
||||
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RankedBars({
|
||||
title,
|
||||
items,
|
||||
emptyLabel,
|
||||
formatter,
|
||||
}: {
|
||||
title: string;
|
||||
items: RankedPacketStat[];
|
||||
emptyLabel: string;
|
||||
formatter?: (item: RankedPacketStat) => string;
|
||||
}) {
|
||||
const maxCount = Math.max(...items.map((item) => item.count), 1);
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{items.map((item) => (
|
||||
<div key={item.label}>
|
||||
<div className="mb-1 flex items-center justify-between gap-3 text-xs">
|
||||
<span className="truncate text-foreground">{item.label}</span>
|
||||
<span className="shrink-0 tabular-nums text-muted-foreground">
|
||||
{formatter
|
||||
? formatter(item)
|
||||
: `${item.count.toLocaleString()} · ${formatPercent(item.share)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary/80"
|
||||
style={{ width: `${(item.count / maxCount) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function NeighborList({
|
||||
title,
|
||||
items,
|
||||
emptyLabel,
|
||||
mode,
|
||||
contacts,
|
||||
}: {
|
||||
title: string;
|
||||
items: NeighborStat[];
|
||||
emptyLabel: string;
|
||||
mode: 'heard' | 'signal' | 'recent';
|
||||
contacts: Contact[];
|
||||
}) {
|
||||
const mergedItems = mergeResolvedNeighbors(items, contacts);
|
||||
const sortedItems = [...mergedItems].sort((a, b) => {
|
||||
if (mode === 'heard') {
|
||||
return b.count - a.count || b.lastSeen - a.lastSeen || a.label.localeCompare(b.label);
|
||||
}
|
||||
if (mode === 'signal') {
|
||||
return (
|
||||
(b.bestRssi ?? Number.NEGATIVE_INFINITY) - (a.bestRssi ?? Number.NEGATIVE_INFINITY) ||
|
||||
b.count - a.count ||
|
||||
a.label.localeCompare(b.label)
|
||||
);
|
||||
}
|
||||
return b.lastSeen - a.lastSeen || b.count - a.count || a.label.localeCompare(b.label);
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
|
||||
{sortedItems.length === 0 ? (
|
||||
<p className="mt-3 text-sm text-muted-foreground">{emptyLabel}</p>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
{sortedItems.map((item) => (
|
||||
<div
|
||||
key={item.key}
|
||||
className="flex items-center justify-between gap-3 rounded-md bg-background/70 px-2 py-1.5"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm text-foreground">{item.label}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{mode === 'heard'
|
||||
? `${item.count.toLocaleString()} packets`
|
||||
: mode === 'signal'
|
||||
? `${formatRssi(item.bestRssi)} best`
|
||||
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
|
||||
</div>
|
||||
{!isNeighborIdentityResolvable(item, contacts) ? (
|
||||
<div className="text-[11px] text-warning">Identity not resolvable</div>
|
||||
) : null}
|
||||
</div>
|
||||
{mode !== 'signal' ? (
|
||||
<div className="shrink-0 text-xs tabular-nums text-muted-foreground">
|
||||
{mode === 'recent' ? formatRssi(item.bestRssi) : formatRssi(item.bestRssi)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
|
||||
const maxTotal = Math.max(...bins.map((bin) => bin.total), 1);
|
||||
const typeOrder = Array.from(new Set(bins.flatMap((bin) => Object.keys(bin.countsByType)))).slice(
|
||||
0,
|
||||
TIMELINE_COLORS.length
|
||||
);
|
||||
|
||||
return (
|
||||
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
|
||||
<div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
|
||||
{typeOrder.map((type, index) => (
|
||||
<span key={type} className="inline-flex items-center gap-1">
|
||||
<span className={cn('h-2 w-2 rounded-full', TIMELINE_COLORS[index])} />
|
||||
<span>{type}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-start gap-1">
|
||||
{bins.map((bin, index) => (
|
||||
<div
|
||||
key={`${bin.label}-${index}`}
|
||||
className="flex min-w-0 flex-1 flex-col items-center gap-1"
|
||||
>
|
||||
<div className="flex h-24 w-full items-end overflow-hidden rounded-sm bg-muted/60">
|
||||
<div className="flex h-full w-full flex-col justify-end">
|
||||
{typeOrder.map((type, index) => {
|
||||
const count = bin.countsByType[type] ?? 0;
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className={cn('w-full', TIMELINE_COLORS[index])}
|
||||
style={{
|
||||
height: `${(count / maxTotal) * 100}%`,
|
||||
}}
|
||||
title={`${bin.label}: ${type} ${count.toLocaleString()}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-muted-foreground">{bin.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function RawPacketFeedView({
|
||||
packets,
|
||||
rawPacketStatsSession,
|
||||
contacts,
|
||||
channels,
|
||||
}: RawPacketFeedViewProps) {
|
||||
const [statsOpen, setStatsOpen] = useState(() =>
|
||||
typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
||||
? window.matchMedia('(min-width: 768px)').matches
|
||||
: false
|
||||
);
|
||||
const [selectedWindow, setSelectedWindow] = useState<RawPacketStatsWindow>('10m');
|
||||
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
|
||||
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
|
||||
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => {
|
||||
setNowSec(Math.floor(Date.now() / 1000));
|
||||
}, 30000);
|
||||
return () => window.clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setNowSec(Math.floor(Date.now() / 1000));
|
||||
}, [packets, rawPacketStatsSession]);
|
||||
|
||||
const stats = useMemo(
|
||||
() => buildRawPacketStatsSnapshot(rawPacketStatsSession, selectedWindow, nowSec),
|
||||
[nowSec, rawPacketStatsSession, selectedWindow]
|
||||
);
|
||||
const coverageMessage = getCoverageMessage(stats, rawPacketStatsSession);
|
||||
const strongestPacketDetail = useMemo(
|
||||
() => formatStrongestPacketDetail(stats, contacts),
|
||||
[contacts, stats]
|
||||
);
|
||||
const strongestNeighbors = useMemo(
|
||||
() => stats.strongestNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||
[contacts, stats.strongestNeighbors]
|
||||
);
|
||||
const mostActiveNeighbors = useMemo(
|
||||
() => stats.mostActiveNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||
[contacts, stats.mostActiveNeighbors]
|
||||
);
|
||||
const newestNeighbors = useMemo(
|
||||
() => stats.newestNeighbors.map((item) => resolveNeighbor(item, contacts)),
|
||||
[contacts, stats.newestNeighbors]
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
|
||||
<div>
|
||||
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAnalyzeModalOpen(true)}
|
||||
>
|
||||
Analyze Packet
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setStatsOpen((current) => !current)}
|
||||
aria-expanded={statsOpen}
|
||||
>
|
||||
{statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
|
||||
{statsOpen ? 'Hide Stats' : 'Show Stats'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
|
||||
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
|
||||
<RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
|
||||
</div>
|
||||
|
||||
<aside
|
||||
className={cn(
|
||||
'shrink-0 overflow-hidden border-t border-border transition-all duration-300 md:border-l md:border-t-0',
|
||||
statsOpen
|
||||
? 'max-h-[42rem] md:max-h-none md:w-1/2 md:min-w-[30rem]'
|
||||
: 'max-h-0 md:w-0 md:min-w-0 border-transparent'
|
||||
)}
|
||||
>
|
||||
{statsOpen ? (
|
||||
<div className="h-full overflow-y-auto bg-background p-4 [contain:layout_paint]">
|
||||
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
Coverage
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 text-sm',
|
||||
coverageMessage.tone === 'warning'
|
||||
? 'text-warning'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
{coverageMessage.message}
|
||||
</div>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<span className="text-muted-foreground">Window</span>
|
||||
<select
|
||||
value={selectedWindow}
|
||||
onChange={(event) =>
|
||||
setSelectedWindow(event.target.value as RawPacketStatsWindow)
|
||||
}
|
||||
className="rounded-md border border-input bg-background px-2 py-1 text-sm"
|
||||
aria-label="Stats window"
|
||||
>
|
||||
{RAW_PACKET_STATS_WINDOWS.map((option) => (
|
||||
<option key={option} value={option}>
|
||||
{WINDOW_LABELS[option]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{stats.packetCount.toLocaleString()} packets in{' '}
|
||||
{WINDOW_LABELS[selectedWindow].toLowerCase()} window
|
||||
{' · '}
|
||||
{rawPacketStatsSession.totalObservedPackets.toLocaleString()} observed this
|
||||
session
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<StatTile
|
||||
label="Packets / min"
|
||||
value={formatRate(stats.packetsPerMinute)}
|
||||
detail={`${stats.packetCount.toLocaleString()} total in window`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Unique Sources"
|
||||
value={stats.uniqueSources.toLocaleString()}
|
||||
detail="Distinct identified senders"
|
||||
/>
|
||||
<StatTile
|
||||
label="Decrypt Rate"
|
||||
value={formatPercent(stats.decryptRate)}
|
||||
detail={`${stats.decryptedCount.toLocaleString()} decrypted / ${stats.undecryptedCount.toLocaleString()} locked`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Path Diversity"
|
||||
value={stats.distinctPaths.toLocaleString()}
|
||||
detail={`${formatPercent(stats.pathBearingRate)} path-bearing packets`}
|
||||
/>
|
||||
<StatTile
|
||||
label="Best RSSI"
|
||||
value={formatRssi(stats.bestRssi)}
|
||||
detail={strongestPacketDetail ?? 'No signal sample in window'}
|
||||
/>
|
||||
<StatTile
|
||||
label="Median RSSI"
|
||||
value={formatRssi(stats.medianRssi)}
|
||||
detail={
|
||||
stats.averageRssi === null
|
||||
? 'No signal sample in window'
|
||||
: `Average ${formatRssi(stats.averageRssi)}`
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<TimelineChart bins={stats.timeline} />
|
||||
</div>
|
||||
|
||||
<div className="md:columns-2 md:gap-4">
|
||||
<RankedBars
|
||||
title="Packet Types"
|
||||
items={stats.payloadBreakdown}
|
||||
emptyLabel="No packets in this window yet."
|
||||
/>
|
||||
|
||||
<RankedBars
|
||||
title="Route Mix"
|
||||
items={stats.routeBreakdown}
|
||||
emptyLabel="No packets in this window yet."
|
||||
/>
|
||||
|
||||
<RankedBars
|
||||
title="Hop Profile"
|
||||
items={stats.hopProfile}
|
||||
emptyLabel="No packets in this window yet."
|
||||
/>
|
||||
|
||||
<RankedBars
|
||||
title="Hop Byte Width"
|
||||
items={stats.hopByteWidthProfile}
|
||||
emptyLabel="No packets in this window yet."
|
||||
/>
|
||||
|
||||
<RankedBars
|
||||
title="Signal Distribution"
|
||||
items={stats.rssiBuckets}
|
||||
emptyLabel="No RSSI samples in this window yet."
|
||||
/>
|
||||
|
||||
<NeighborList
|
||||
title="Most-Heard Neighbors"
|
||||
items={mostActiveNeighbors}
|
||||
emptyLabel="No sender identities resolved in this window yet."
|
||||
mode="heard"
|
||||
contacts={contacts}
|
||||
/>
|
||||
|
||||
<NeighborList
|
||||
title="Strongest Recent Neighbors"
|
||||
items={strongestNeighbors}
|
||||
emptyLabel="No RSSI-tagged neighbors in this window yet."
|
||||
mode="signal"
|
||||
contacts={contacts}
|
||||
/>
|
||||
|
||||
<NeighborList
|
||||
title="Newest Heard Neighbors"
|
||||
items={newestNeighbors}
|
||||
emptyLabel="No newly identified neighbors in this window yet."
|
||||
mode="recent"
|
||||
contacts={contacts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<RawPacketInspectorDialog
|
||||
open={selectedPacket !== null}
|
||||
onOpenChange={(isOpen) => !isOpen && setSelectedPacket(null)}
|
||||
channels={channels}
|
||||
source={
|
||||
selectedPacket
|
||||
? { kind: 'packet', packet: selectedPacket }
|
||||
: { kind: 'loading', message: 'Loading packet...' }
|
||||
}
|
||||
title="Packet Details"
|
||||
description="Detailed byte and field breakdown for the selected raw packet."
|
||||
/>
|
||||
|
||||
<RawPacketInspectorDialog
|
||||
open={analyzeModalOpen}
|
||||
onOpenChange={setAnalyzeModalOpen}
|
||||
channels={channels}
|
||||
source={{ kind: 'paste' }}
|
||||
title="Analyze Packet"
|
||||
description="Paste and inspect a raw packet hex string."
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { MeshCoreDecoder, PayloadType, Utils } from '@michaelhart/meshcore-decoder';
|
||||
import type { RawPacket } from '../types';
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
|
||||
import { createDecoderOptions, decodePacketSummary } from '../utils/rawPacketInspector';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface RawPacketListProps {
|
||||
packets: RawPacket[];
|
||||
channels?: Channel[];
|
||||
onPacketClick?: (packet: RawPacket) => void;
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number): string {
|
||||
@@ -24,132 +26,6 @@ function formatSignalInfo(packet: RawPacket): string {
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
// Decrypted info from the packet (validated by backend)
|
||||
interface DecryptedInfo {
|
||||
channel_name: string | null;
|
||||
sender: string | null;
|
||||
}
|
||||
|
||||
// Decode a packet and generate a human-readable summary
|
||||
// Uses backend's decrypted_info when available (validated), falls back to decoder
|
||||
function decodePacketSummary(
|
||||
hexData: string,
|
||||
decryptedInfo: DecryptedInfo | null
|
||||
): {
|
||||
summary: string;
|
||||
routeType: string;
|
||||
details?: string;
|
||||
} {
|
||||
try {
|
||||
const decoded = MeshCoreDecoder.decode(hexData);
|
||||
|
||||
if (!decoded.isValid) {
|
||||
return { summary: 'Invalid packet', routeType: 'Unknown' };
|
||||
}
|
||||
|
||||
const routeType = Utils.getRouteTypeName(decoded.routeType);
|
||||
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
|
||||
const tracePayload =
|
||||
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
|
||||
? (decoded.payload.decoded as { pathHashes?: string[] })
|
||||
: null;
|
||||
const pathTokens = tracePayload?.pathHashes || decoded.path || [];
|
||||
|
||||
// Build path string if available
|
||||
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join('-')}` : '';
|
||||
|
||||
// Generate summary based on payload type
|
||||
let summary = payloadTypeName;
|
||||
let details: string | undefined;
|
||||
|
||||
switch (decoded.payloadType) {
|
||||
case PayloadType.TextMessage: {
|
||||
const payload = decoded.payload.decoded as {
|
||||
destinationHash?: string;
|
||||
sourceHash?: string;
|
||||
} | null;
|
||||
if (payload?.sourceHash && payload?.destinationHash) {
|
||||
summary = `DM from ${payload.sourceHash} to ${payload.destinationHash}${pathStr}`;
|
||||
} else {
|
||||
summary = `DM${pathStr}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.GroupText: {
|
||||
const payload = decoded.payload.decoded as {
|
||||
channelHash?: string;
|
||||
} | null;
|
||||
// Use backend's validated decrypted_info when available
|
||||
if (decryptedInfo?.channel_name) {
|
||||
if (decryptedInfo.sender) {
|
||||
summary = `GT from ${decryptedInfo.sender} in ${decryptedInfo.channel_name}${pathStr}`;
|
||||
} else {
|
||||
summary = `GT in ${decryptedInfo.channel_name}${pathStr}`;
|
||||
}
|
||||
} else if (payload?.channelHash) {
|
||||
// Fallback to showing channel hash when not decrypted
|
||||
summary = `GT ch:${payload.channelHash}${pathStr}`;
|
||||
} else {
|
||||
summary = `GroupText${pathStr}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Advert: {
|
||||
const payload = decoded.payload.decoded as {
|
||||
publicKey?: string;
|
||||
appData?: { name?: string; deviceRole?: number };
|
||||
} | null;
|
||||
if (payload?.appData?.name) {
|
||||
const role =
|
||||
payload.appData.deviceRole !== undefined
|
||||
? Utils.getDeviceRoleName(payload.appData.deviceRole)
|
||||
: '';
|
||||
summary = `Advert: ${payload.appData.name}${role ? ` (${role})` : ''}${pathStr}`;
|
||||
} else if (payload?.publicKey) {
|
||||
summary = `Advert: ${payload.publicKey.slice(0, 8)}...${pathStr}`;
|
||||
} else {
|
||||
summary = `Advert${pathStr}`;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Ack: {
|
||||
summary = `ACK${pathStr}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Request: {
|
||||
summary = `Request${pathStr}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Response: {
|
||||
summary = `Response${pathStr}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Trace: {
|
||||
summary = `Trace${pathStr}`;
|
||||
break;
|
||||
}
|
||||
|
||||
case PayloadType.Path: {
|
||||
summary = `Path${pathStr}`;
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
summary = `${payloadTypeName}${pathStr}`;
|
||||
}
|
||||
|
||||
return { summary, routeType, details };
|
||||
} catch {
|
||||
return { summary: 'Decode error', routeType: 'Unknown' };
|
||||
}
|
||||
}
|
||||
|
||||
// Get route type badge color
|
||||
function getRouteTypeColor(routeType: string): string {
|
||||
switch (routeType) {
|
||||
@@ -182,16 +58,17 @@ function getRouteTypeLabel(routeType: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function RawPacketList({ packets }: RawPacketListProps) {
|
||||
export function RawPacketList({ packets, channels, onPacketClick }: RawPacketListProps) {
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
|
||||
|
||||
// Decode all packets (memoized to avoid re-decoding on every render)
|
||||
const decodedPackets = useMemo(() => {
|
||||
return packets.map((packet) => ({
|
||||
packet,
|
||||
decoded: decodePacketSummary(packet.data, packet.decrypted_info),
|
||||
decoded: decodePacketSummary(packet, decoderOptions),
|
||||
}));
|
||||
}, [packets]);
|
||||
}, [decoderOptions, packets]);
|
||||
|
||||
// Sort packets by timestamp ascending (oldest first)
|
||||
const sortedPackets = useMemo(
|
||||
@@ -218,54 +95,78 @@ export function RawPacketList({ packets }: RawPacketListProps) {
|
||||
className="h-full overflow-y-auto p-4 flex flex-col gap-2 [contain:layout_paint]"
|
||||
ref={listRef}
|
||||
>
|
||||
{sortedPackets.map(({ packet, decoded }) => (
|
||||
<div
|
||||
key={getRawPacketObservationKey(packet)}
|
||||
className="py-2 px-3 bg-card rounded-md border border-border/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Route type badge */}
|
||||
<span
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||
title={decoded.routeType}
|
||||
>
|
||||
{getRouteTypeLabel(decoded.routeType)}
|
||||
</span>
|
||||
{sortedPackets.map(({ packet, decoded }) => {
|
||||
const cardContent = (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Route type badge */}
|
||||
<span
|
||||
className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
|
||||
title={decoded.routeType}
|
||||
>
|
||||
{getRouteTypeLabel(decoded.routeType)}
|
||||
</span>
|
||||
|
||||
{/* Encryption status */}
|
||||
{!packet.decrypted && (
|
||||
<>
|
||||
<span aria-hidden="true">🔒</span>
|
||||
<span className="sr-only">Encrypted</span>
|
||||
</>
|
||||
{/* Encryption status */}
|
||||
{!packet.decrypted && (
|
||||
<>
|
||||
<span aria-hidden="true">🔒</span>
|
||||
<span className="sr-only">Encrypted</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<span
|
||||
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
||||
>
|
||||
{decoded.summary}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
|
||||
{formatTime(packet.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Signal info */}
|
||||
{(packet.snr !== null || packet.rssi !== null) && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
||||
{formatSignalInfo(packet)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
<span
|
||||
className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
|
||||
>
|
||||
{decoded.summary}
|
||||
</span>
|
||||
|
||||
{/* Time */}
|
||||
<span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
|
||||
{formatTime(packet.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Signal info */}
|
||||
{(packet.snr !== null || packet.rssi !== null) && (
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
|
||||
{formatSignalInfo(packet)}
|
||||
{/* Raw hex data (always visible) */}
|
||||
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||
{packet.data.toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
{/* Raw hex data (always visible) */}
|
||||
<div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
|
||||
{packet.data.toUpperCase()}
|
||||
const className = cn(
|
||||
'rounded-md border border-border/50 bg-card px-3 py-2 text-left',
|
||||
onPacketClick &&
|
||||
'cursor-pointer transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'
|
||||
);
|
||||
|
||||
if (onPacketClick) {
|
||||
return (
|
||||
<button
|
||||
key={getRawPacketObservationKey(packet)}
|
||||
type="button"
|
||||
onClick={() => onPacketClick(packet)}
|
||||
className={className}
|
||||
>
|
||||
{cardContent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={getRawPacketObservationKey(packet)} className={className}>
|
||||
{cardContent}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,14 @@ import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
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 { cn } from '../lib/utils';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||
import { AclPane } from './repeater/RepeaterAclPane';
|
||||
@@ -81,8 +83,18 @@ export function RepeaterDashboard({
|
||||
rebootRepeater,
|
||||
syncClock,
|
||||
} = useRepeaterDashboard(conversation, { hasAdvertLocation });
|
||||
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||
useRememberedServerPassword('repeater', conversation.id);
|
||||
|
||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||
await login(nextPassword);
|
||||
persistAfterLogin(nextPassword);
|
||||
};
|
||||
const handleRepeaterGuestLogin = async () => {
|
||||
await loginAsGuest();
|
||||
persistAfterLogin('');
|
||||
};
|
||||
|
||||
// Loading all panes indicator
|
||||
const anyLoading = Object.values(paneStates).some((s) => s.loading);
|
||||
@@ -90,8 +102,15 @@ export function RepeaterDashboard({
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<header
|
||||
className={cn(
|
||||
'grid items-start gap-x-2 gap-y-0.5 border-b border-border px-4 py-2.5',
|
||||
contact
|
||||
? 'grid-cols-[minmax(0,1fr)_auto] min-[1100px]:grid-cols-[minmax(0,1fr)_auto_auto]'
|
||||
: 'grid-cols-[minmax(0,1fr)_auto]'
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 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">
|
||||
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
|
||||
@@ -111,14 +130,14 @@ export function RepeaterDashboard({
|
||||
{conversation.id}
|
||||
</span>
|
||||
</span>
|
||||
{contact && (
|
||||
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
|
||||
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{contact && (
|
||||
<div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
|
||||
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-0.5">
|
||||
{loggedIn && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -221,8 +240,12 @@ export function RepeaterDashboard({
|
||||
repeaterName={conversation.name}
|
||||
loading={loginLoading}
|
||||
error={loginError}
|
||||
onLogin={login}
|
||||
onLoginAsGuest={loginAsGuest}
|
||||
password={password}
|
||||
onPasswordChange={setPassword}
|
||||
rememberPassword={rememberPassword}
|
||||
onRememberPasswordChange={setRememberPassword}
|
||||
onLogin={handleRepeaterLogin}
|
||||
onLoginAsGuest={handleRepeaterGuestLogin}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -1,24 +1,39 @@
|
||||
import { useState, useCallback, type FormEvent } from 'react';
|
||||
import { useCallback, type FormEvent } from 'react';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
|
||||
interface RepeaterLoginProps {
|
||||
repeaterName: string;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
password: string;
|
||||
onPasswordChange: (password: string) => void;
|
||||
rememberPassword: boolean;
|
||||
onRememberPasswordChange: (checked: boolean) => void;
|
||||
onLogin: (password: string) => Promise<void>;
|
||||
onLoginAsGuest: () => Promise<void>;
|
||||
description?: string;
|
||||
passwordPlaceholder?: string;
|
||||
loginLabel?: string;
|
||||
guestLabel?: string;
|
||||
}
|
||||
|
||||
export function RepeaterLogin({
|
||||
repeaterName,
|
||||
loading,
|
||||
error,
|
||||
password,
|
||||
onPasswordChange,
|
||||
rememberPassword,
|
||||
onRememberPasswordChange,
|
||||
onLogin,
|
||||
onLoginAsGuest,
|
||||
description = 'Log in to access repeater dashboard',
|
||||
passwordPlaceholder = 'Repeater password...',
|
||||
loginLabel = 'Login with Password',
|
||||
guestLabel = 'Login as Guest / ACLs',
|
||||
}: RepeaterLoginProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -33,7 +48,7 @@ export function RepeaterLogin({
|
||||
<div className="w-full max-w-sm space-y-6">
|
||||
<div className="text-center space-y-1">
|
||||
<h2 className="text-lg font-semibold">{repeaterName}</h2>
|
||||
<p className="text-sm text-muted-foreground">Log in to access repeater dashboard</p>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4" autoComplete="off">
|
||||
@@ -45,13 +60,34 @@ export function RepeaterLogin({
|
||||
data-1p-ignore="true"
|
||||
data-bwignore="true"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Repeater password..."
|
||||
onChange={(e) => onPasswordChange(e.target.value)}
|
||||
placeholder={passwordPlaceholder}
|
||||
aria-label="Repeater password"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<label
|
||||
htmlFor="remember-server-password"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Checkbox
|
||||
id="remember-server-password"
|
||||
checked={rememberPassword}
|
||||
disabled={loading}
|
||||
onCheckedChange={(checked) => onRememberPasswordChange(checked === true)}
|
||||
/>
|
||||
<span>Remember password</span>
|
||||
</label>
|
||||
|
||||
{rememberPassword && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Passwords are stored unencrypted in local browser storage for this domain. It is
|
||||
highly recommended to login via ACLs after your first successful login; saving the
|
||||
password is not recommended.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-sm text-destructive text-center" role="alert">
|
||||
{error}
|
||||
@@ -60,7 +96,7 @@ export function RepeaterLogin({
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button type="submit" disabled={loading} className="w-full">
|
||||
{loading ? 'Logging in...' : 'Login with Password'}
|
||||
{loading ? 'Logging in...' : loginLabel}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -69,7 +105,7 @@ export function RepeaterLogin({
|
||||
className="w-full"
|
||||
onClick={onLoginAsGuest}
|
||||
>
|
||||
Login as Guest / ACLs
|
||||
{guestLabel}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
313
frontend/src/components/RoomServerPanel.tsx
Normal file
313
frontend/src/components/RoomServerPanel.tsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import type {
|
||||
Contact,
|
||||
PaneState,
|
||||
RepeaterAclResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterStatusResponse,
|
||||
} from '../types';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { AclPane } from './repeater/RepeaterAclPane';
|
||||
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
|
||||
interface RoomServerPanelProps {
|
||||
contact: Contact;
|
||||
onAuthenticatedChange?: (authenticated: boolean) => void;
|
||||
}
|
||||
|
||||
type RoomPaneKey = 'status' | 'acl' | 'lppTelemetry';
|
||||
|
||||
type RoomPaneData = {
|
||||
status: RepeaterStatusResponse | null;
|
||||
acl: RepeaterAclResponse | null;
|
||||
lppTelemetry: RepeaterLppTelemetryResponse | null;
|
||||
};
|
||||
|
||||
type RoomPaneStates = Record<RoomPaneKey, PaneState>;
|
||||
|
||||
type ConsoleEntry = {
|
||||
command: string;
|
||||
response: string;
|
||||
timestamp: number;
|
||||
outgoing: boolean;
|
||||
};
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = {
|
||||
loading: false,
|
||||
attempt: 0,
|
||||
error: null,
|
||||
fetched_at: null,
|
||||
};
|
||||
|
||||
function createInitialPaneStates(): RoomPaneStates {
|
||||
return {
|
||||
status: { ...INITIAL_PANE_STATE },
|
||||
acl: { ...INITIAL_PANE_STATE },
|
||||
lppTelemetry: { ...INITIAL_PANE_STATE },
|
||||
};
|
||||
}
|
||||
|
||||
export function RoomServerPanel({ contact, onAuthenticatedChange }: RoomServerPanelProps) {
|
||||
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
|
||||
useRememberedServerPassword('room', contact.public_key);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [authenticated, setAuthenticated] = useState(false);
|
||||
const [advancedOpen, setAdvancedOpen] = useState(false);
|
||||
const [paneData, setPaneData] = useState<RoomPaneData>({
|
||||
status: null,
|
||||
acl: null,
|
||||
lppTelemetry: null,
|
||||
});
|
||||
const [paneStates, setPaneStates] = useState<RoomPaneStates>(createInitialPaneStates);
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
|
||||
const [consoleLoading, setConsoleLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setLoginLoading(false);
|
||||
setLoginError(null);
|
||||
setAuthenticated(false);
|
||||
setAdvancedOpen(false);
|
||||
setPaneData({
|
||||
status: null,
|
||||
acl: null,
|
||||
lppTelemetry: null,
|
||||
});
|
||||
setPaneStates(createInitialPaneStates());
|
||||
setConsoleHistory([]);
|
||||
setConsoleLoading(false);
|
||||
}, [contact.public_key]);
|
||||
|
||||
useEffect(() => {
|
||||
onAuthenticatedChange?.(authenticated);
|
||||
}, [authenticated, onAuthenticatedChange]);
|
||||
|
||||
const refreshPane = useCallback(
|
||||
async <K extends RoomPaneKey>(pane: K, loader: () => Promise<RoomPaneData[K]>) => {
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
...prev[pane],
|
||||
loading: true,
|
||||
attempt: prev[pane].attempt + 1,
|
||||
error: null,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await loader();
|
||||
setPaneData((prev) => ({ ...prev, [pane]: data }));
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
loading: false,
|
||||
attempt: prev[pane].attempt,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
},
|
||||
}));
|
||||
} catch (err) {
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
...prev[pane],
|
||||
loading: false,
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
},
|
||||
}));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const performLogin = useCallback(
|
||||
async (password: string) => {
|
||||
if (loginLoading) return;
|
||||
|
||||
setLoginLoading(true);
|
||||
setLoginError(null);
|
||||
try {
|
||||
const result = await api.roomLogin(contact.public_key, password);
|
||||
setAuthenticated(true);
|
||||
if (result.authenticated) {
|
||||
toast.success('Room login confirmed');
|
||||
} else {
|
||||
toast.warning('Room login not confirmed', {
|
||||
description: result.message ?? 'Room login was not confirmed',
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setAuthenticated(true);
|
||||
setLoginError(message);
|
||||
toast.error('Room login failed', { description: message });
|
||||
} finally {
|
||||
setLoginLoading(false);
|
||||
}
|
||||
},
|
||||
[contact.public_key, loginLoading]
|
||||
);
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async (password: string) => {
|
||||
await performLogin(password);
|
||||
persistAfterLogin(password);
|
||||
},
|
||||
[performLogin, persistAfterLogin]
|
||||
);
|
||||
|
||||
const handleLoginAsGuest = useCallback(async () => {
|
||||
await performLogin('');
|
||||
persistAfterLogin('');
|
||||
}, [performLogin, persistAfterLogin]);
|
||||
|
||||
const handleConsoleCommand = useCallback(
|
||||
async (command: string) => {
|
||||
setConsoleLoading(true);
|
||||
const timestamp = Date.now();
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{ command, response: command, timestamp, outgoing: true },
|
||||
]);
|
||||
try {
|
||||
const response = await api.sendRepeaterCommand(contact.public_key, command);
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
command,
|
||||
response: response.response,
|
||||
timestamp: Date.now(),
|
||||
outgoing: false,
|
||||
},
|
||||
]);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setConsoleHistory((prev) => [
|
||||
...prev,
|
||||
{
|
||||
command,
|
||||
response: `(error) ${message}`,
|
||||
timestamp: Date.now(),
|
||||
outgoing: false,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setConsoleLoading(false);
|
||||
}
|
||||
},
|
||||
[contact.public_key]
|
||||
);
|
||||
|
||||
const panelTitle = useMemo(() => contact.name || contact.public_key.slice(0, 12), [contact]);
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="mx-auto flex w-full max-w-sm flex-col gap-4">
|
||||
<div className="rounded-md border border-warning/30 bg-warning/10 px-4 py-3 text-sm text-warning">
|
||||
Room server access is experimental and in public alpha. Please report any issues on{' '}
|
||||
<a
|
||||
href="https://github.com/jkingsman/Remote-Terminal-for-MeshCore/issues"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="font-medium underline underline-offset-2 hover:text-warning/80"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
.
|
||||
</div>
|
||||
<RepeaterLogin
|
||||
repeaterName={panelTitle}
|
||||
loading={loginLoading}
|
||||
error={loginError}
|
||||
password={password}
|
||||
onPasswordChange={setPassword}
|
||||
rememberPassword={rememberPassword}
|
||||
onRememberPasswordChange={setRememberPassword}
|
||||
onLogin={handleLogin}
|
||||
onLoginAsGuest={handleLoginAsGuest}
|
||||
description="Log in with the room password or use ACL/guest access to enter this room server"
|
||||
passwordPlaceholder="Room server password..."
|
||||
guestLabel="Login with ACL / Guest"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="border-b border-border bg-muted/20 px-4 py-3">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAdvancedOpen((prev) => !prev)}
|
||||
>
|
||||
{advancedOpen ? 'Hide Tools' : 'Show Tools'}
|
||||
</Button>
|
||||
</div>
|
||||
<Sheet open={advancedOpen} onOpenChange={setAdvancedOpen}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-4xl p-0 flex flex-col">
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Room Server Tools</SheetTitle>
|
||||
<SheetDescription>
|
||||
Room server telemetry, ACL tools, sensor data, and CLI console
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="border-b border-border px-4 py-3 pr-14">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-base font-semibold">Room Server Tools</h2>
|
||||
<p className="text-sm text-muted-foreground">{panelTitle}</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLoginAsGuest}
|
||||
disabled={loginLoading}
|
||||
className="self-start sm:self-auto"
|
||||
>
|
||||
Refresh ACL Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto p-4">
|
||||
<div className="grid gap-3 xl:grid-cols-2">
|
||||
<TelemetryPane
|
||||
data={paneData.status}
|
||||
state={paneStates.status}
|
||||
onRefresh={() => refreshPane('status', () => api.roomStatus(contact.public_key))}
|
||||
/>
|
||||
<AclPane
|
||||
data={paneData.acl}
|
||||
state={paneStates.acl}
|
||||
onRefresh={() => refreshPane('acl', () => api.roomAcl(contact.public_key))}
|
||||
/>
|
||||
<LppTelemetryPane
|
||||
data={paneData.lppTelemetry}
|
||||
state={paneStates.lppTelemetry}
|
||||
onRefresh={() =>
|
||||
refreshPane('lppTelemetry', () => api.roomLppTelemetry(contact.public_key))
|
||||
}
|
||||
/>
|
||||
<ConsolePane
|
||||
history={consoleHistory}
|
||||
loading={consoleLoading}
|
||||
onSend={handleConsoleCommand}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
181
frontend/src/components/SecurityWarningModal.tsx
Normal file
181
frontend/src/components/SecurityWarningModal.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
import { api } from '../api';
|
||||
import type { HealthStatus } from '../types';
|
||||
import { Button } from './ui/button';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
import { toast } from './ui/sonner';
|
||||
|
||||
const STORAGE_KEY = 'meshcore_security_warning_acknowledged';
|
||||
|
||||
function readAcknowledgedState(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeAcknowledgedState(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(STORAGE_KEY, 'true');
|
||||
} catch {
|
||||
// Best effort only; the warning will continue to show if localStorage is unavailable.
|
||||
}
|
||||
}
|
||||
|
||||
interface SecurityWarningModalProps {
|
||||
health: HealthStatus | null;
|
||||
}
|
||||
|
||||
export function SecurityWarningModal({ health }: SecurityWarningModalProps) {
|
||||
const [acknowledged, setAcknowledged] = useState(readAcknowledgedState);
|
||||
const [confirmedRisk, setConfirmedRisk] = useState(false);
|
||||
const [disablingBots, setDisablingBots] = useState(false);
|
||||
const [botsDisabledLocally, setBotsDisabledLocally] = useState(false);
|
||||
|
||||
const shouldWarn =
|
||||
health !== null &&
|
||||
health.bots_disabled !== true &&
|
||||
health.basic_auth_enabled !== true &&
|
||||
!botsDisabledLocally &&
|
||||
!acknowledged;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldWarn) {
|
||||
setConfirmedRisk(false);
|
||||
}
|
||||
}, [shouldWarn]);
|
||||
|
||||
useEffect(() => {
|
||||
if (health?.bots_disabled !== true) {
|
||||
setBotsDisabledLocally(false);
|
||||
}
|
||||
}, [health?.bots_disabled, health?.bots_disabled_source]);
|
||||
|
||||
if (!shouldWarn) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open>
|
||||
<DialogContent
|
||||
hideCloseButton
|
||||
className="top-3 w-[calc(100vw-1rem)] max-w-[42rem] translate-y-0 gap-5 overflow-y-auto px-4 py-5 max-h-[calc(100vh-1.5rem)] sm:top-[50%] sm:w-full sm:max-h-[min(90vh,48rem)] sm:translate-y-[-50%] sm:px-6"
|
||||
onEscapeKeyDown={(event) => event.preventDefault()}
|
||||
onInteractOutside={(event) => event.preventDefault()}
|
||||
>
|
||||
<DialogHeader className="space-y-0 text-left">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="inline-flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-destructive/30 bg-destructive/10 text-destructive">
|
||||
<AlertTriangle className="h-5 w-5" aria-hidden="true" />
|
||||
</div>
|
||||
<DialogTitle className="leading-tight">
|
||||
Unprotected bot execution is enabled
|
||||
</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<hr className="border-border" />
|
||||
|
||||
<div className="space-y-3 break-words text-sm leading-6 text-muted-foreground">
|
||||
<DialogDescription>
|
||||
Bots are not disabled, and app-wide Basic Auth is not configured.
|
||||
</DialogDescription>
|
||||
<p>
|
||||
Without one of those protections, or another access-control layer in front of
|
||||
RemoteTerm, anyone on your local network who can reach this app can run Python code on
|
||||
the computer hosting this instance via the bot system.
|
||||
</p>
|
||||
<p className="font-semibold text-foreground">
|
||||
This is only safe on protected or isolated networks with appropriate access control. If
|
||||
your network is untrusted or later compromised, this setup may expose the host system to
|
||||
arbitrary code execution.
|
||||
</p>
|
||||
<p>
|
||||
To reduce that risk, run the server with environment variables to either disable bots
|
||||
with{' '}
|
||||
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_DISABLE_BOTS=true
|
||||
</code>{' '}
|
||||
or enable the built-in login with{' '}
|
||||
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_BASIC_AUTH_USERNAME
|
||||
</code>{' '}
|
||||
/{' '}
|
||||
<code className="break-all rounded bg-muted px-1 py-0.5 text-foreground">
|
||||
MESHCORE_BASIC_AUTH_PASSWORD
|
||||
</code>
|
||||
. Another external auth or access-control system is also acceptable.
|
||||
</p>
|
||||
<p>
|
||||
If you just want a temporary safety measure while you learn the system, you can use the
|
||||
button below to disable bots until the server restarts. That is only a temporary guard;
|
||||
permanent protection through Basic Auth or env-based bot disablement is still
|
||||
encouraged.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="h-auto w-full whitespace-normal py-3 text-center"
|
||||
disabled={disablingBots}
|
||||
onClick={async () => {
|
||||
setDisablingBots(true);
|
||||
try {
|
||||
await api.disableBotsUntilRestart();
|
||||
setBotsDisabledLocally(true);
|
||||
toast.success('Bots disabled until restart');
|
||||
} catch (err) {
|
||||
toast.error('Failed to disable bots', {
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
});
|
||||
} finally {
|
||||
setDisablingBots(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{disablingBots ? 'Disabling Bots...' : 'Disable Bots Until Server Restart'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 rounded-md border border-input bg-muted/20 p-4">
|
||||
<label className="flex items-start gap-3">
|
||||
<Checkbox
|
||||
checked={confirmedRisk}
|
||||
onCheckedChange={(checked) => setConfirmedRisk(checked === true)}
|
||||
aria-label="Acknowledge bot security risk"
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span className="text-sm leading-6 text-foreground">
|
||||
I understand that continuing with my existing security setup may put me at risk on
|
||||
untrusted networks or if my home network is compromised.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
className="h-auto w-full whitespace-normal py-3 text-center"
|
||||
variant="outline"
|
||||
disabled={!confirmedRisk || disablingBots}
|
||||
onClick={() => {
|
||||
writeAcknowledgedState();
|
||||
setAcknowledged(true);
|
||||
}}
|
||||
>
|
||||
Do Not Warn Me On This Device Again
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
CONTACT_TYPE_ROOM,
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
type Channel,
|
||||
@@ -57,6 +58,7 @@ type CollapseState = {
|
||||
favorites: boolean;
|
||||
channels: boolean;
|
||||
contacts: boolean;
|
||||
rooms: boolean;
|
||||
repeaters: boolean;
|
||||
};
|
||||
|
||||
@@ -67,6 +69,7 @@ const DEFAULT_COLLAPSE_STATE: CollapseState = {
|
||||
favorites: false,
|
||||
channels: false,
|
||||
contacts: false,
|
||||
rooms: false,
|
||||
repeaters: false,
|
||||
};
|
||||
|
||||
@@ -80,6 +83,7 @@ function loadCollapsedState(): CollapseState {
|
||||
favorites: parsed.favorites ?? DEFAULT_COLLAPSE_STATE.favorites,
|
||||
channels: parsed.channels ?? DEFAULT_COLLAPSE_STATE.channels,
|
||||
contacts: parsed.contacts ?? DEFAULT_COLLAPSE_STATE.contacts,
|
||||
rooms: parsed.rooms ?? DEFAULT_COLLAPSE_STATE.rooms,
|
||||
repeaters: parsed.repeaters ?? DEFAULT_COLLAPSE_STATE.repeaters,
|
||||
};
|
||||
} catch {
|
||||
@@ -157,6 +161,7 @@ export function Sidebar({
|
||||
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
|
||||
const [channelsCollapsed, setChannelsCollapsed] = useState(initialCollapsedState.channels);
|
||||
const [contactsCollapsed, setContactsCollapsed] = useState(initialCollapsedState.contacts);
|
||||
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
|
||||
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
|
||||
const collapseSnapshotRef = useRef<CollapseState | null>(null);
|
||||
const sectionSortSourceRef = useRef(initialSectionSortState.source);
|
||||
@@ -314,16 +319,61 @@ export function Sidebar({
|
||||
[getContactHeardTime]
|
||||
);
|
||||
|
||||
const getFavoriteItemName = useCallback(
|
||||
(item: FavoriteItem) =>
|
||||
item.type === 'channel'
|
||||
? item.channel.name
|
||||
: getContactDisplayName(
|
||||
item.contact.name,
|
||||
item.contact.public_key,
|
||||
item.contact.last_advert
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
const sortFavoriteItemsByOrder = useCallback(
|
||||
(items: FavoriteItem[], order: SortOrder) =>
|
||||
[...items].sort((a, b) => {
|
||||
if (order === 'recent') {
|
||||
const timeA =
|
||||
a.type === 'channel'
|
||||
? getLastMessageTime('channel', a.channel.key)
|
||||
: getContactRecentTime(a.contact);
|
||||
const timeB =
|
||||
b.type === 'channel'
|
||||
? getLastMessageTime('channel', b.channel.key)
|
||||
: getContactRecentTime(b.contact);
|
||||
if (timeA && timeB) return timeB - timeA;
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
}
|
||||
|
||||
return getFavoriteItemName(a).localeCompare(getFavoriteItemName(b));
|
||||
}),
|
||||
[getContactRecentTime, getFavoriteItemName, getLastMessageTime]
|
||||
);
|
||||
|
||||
// Split non-repeater contacts and repeater contacts into separate sorted lists
|
||||
const sortedNonRepeaterContacts = useMemo(
|
||||
() =>
|
||||
sortContactsByOrder(
|
||||
uniqueContacts.filter((c) => c.type !== CONTACT_TYPE_REPEATER),
|
||||
uniqueContacts.filter(
|
||||
(c) => c.type !== CONTACT_TYPE_REPEATER && c.type !== CONTACT_TYPE_ROOM
|
||||
),
|
||||
sectionSortOrders.contacts
|
||||
),
|
||||
[uniqueContacts, sectionSortOrders.contacts, sortContactsByOrder]
|
||||
);
|
||||
|
||||
const sortedRooms = useMemo(
|
||||
() =>
|
||||
sortContactsByOrder(
|
||||
uniqueContacts.filter((c) => c.type === CONTACT_TYPE_ROOM),
|
||||
sectionSortOrders.rooms
|
||||
),
|
||||
[uniqueContacts, sectionSortOrders.rooms, sortContactsByOrder]
|
||||
);
|
||||
|
||||
const sortedRepeaters = useMemo(
|
||||
() =>
|
||||
sortRepeatersByOrder(
|
||||
@@ -358,6 +408,17 @@ export function Sidebar({
|
||||
[sortedNonRepeaterContacts, query]
|
||||
);
|
||||
|
||||
const filteredRooms = useMemo(
|
||||
() =>
|
||||
query
|
||||
? sortedRooms.filter(
|
||||
(c) =>
|
||||
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
|
||||
)
|
||||
: sortedRooms,
|
||||
[sortedRooms, query]
|
||||
);
|
||||
|
||||
const filteredRepeaters = useMemo(
|
||||
() =>
|
||||
query
|
||||
@@ -378,6 +439,7 @@ export function Sidebar({
|
||||
favorites: favoritesCollapsed,
|
||||
channels: channelsCollapsed,
|
||||
contacts: contactsCollapsed,
|
||||
rooms: roomsCollapsed,
|
||||
repeaters: repeatersCollapsed,
|
||||
};
|
||||
}
|
||||
@@ -387,12 +449,14 @@ export function Sidebar({
|
||||
favoritesCollapsed ||
|
||||
channelsCollapsed ||
|
||||
contactsCollapsed ||
|
||||
roomsCollapsed ||
|
||||
repeatersCollapsed
|
||||
) {
|
||||
setToolsCollapsed(false);
|
||||
setFavoritesCollapsed(false);
|
||||
setChannelsCollapsed(false);
|
||||
setContactsCollapsed(false);
|
||||
setRoomsCollapsed(false);
|
||||
setRepeatersCollapsed(false);
|
||||
}
|
||||
return;
|
||||
@@ -405,6 +469,7 @@ export function Sidebar({
|
||||
setFavoritesCollapsed(prev.favorites);
|
||||
setChannelsCollapsed(prev.channels);
|
||||
setContactsCollapsed(prev.contacts);
|
||||
setRoomsCollapsed(prev.rooms);
|
||||
setRepeatersCollapsed(prev.repeaters);
|
||||
}
|
||||
}, [
|
||||
@@ -413,6 +478,7 @@ export function Sidebar({
|
||||
favoritesCollapsed,
|
||||
channelsCollapsed,
|
||||
contactsCollapsed,
|
||||
roomsCollapsed,
|
||||
repeatersCollapsed,
|
||||
]);
|
||||
|
||||
@@ -424,6 +490,7 @@ export function Sidebar({
|
||||
favorites: favoritesCollapsed,
|
||||
channels: channelsCollapsed,
|
||||
contacts: contactsCollapsed,
|
||||
rooms: roomsCollapsed,
|
||||
repeaters: repeatersCollapsed,
|
||||
};
|
||||
|
||||
@@ -438,62 +505,56 @@ export function Sidebar({
|
||||
favoritesCollapsed,
|
||||
channelsCollapsed,
|
||||
contactsCollapsed,
|
||||
roomsCollapsed,
|
||||
repeatersCollapsed,
|
||||
]);
|
||||
|
||||
// Separate favorites from regular items, and build combined favorites list
|
||||
const { favoriteItems, nonFavoriteChannels, nonFavoriteContacts, nonFavoriteRepeaters } =
|
||||
useMemo(() => {
|
||||
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||
const favContacts = [...filteredNonRepeaterContacts, ...filteredRepeaters].filter((c) =>
|
||||
isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavChannels = filteredChannels.filter(
|
||||
(c) => !isFavorite(favorites, 'channel', c.key)
|
||||
);
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRepeaters = filteredRepeaters.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const {
|
||||
favoriteItems,
|
||||
nonFavoriteChannels,
|
||||
nonFavoriteContacts,
|
||||
nonFavoriteRooms,
|
||||
nonFavoriteRepeaters,
|
||||
} = useMemo(() => {
|
||||
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||
const favContacts = [
|
||||
...filteredNonRepeaterContacts,
|
||||
...filteredRooms,
|
||||
...filteredRepeaters,
|
||||
].filter((c) => isFavorite(favorites, 'contact', c.public_key));
|
||||
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRooms = filteredRooms.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRepeaters = filteredRepeaters.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
|
||||
const items: FavoriteItem[] = [
|
||||
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
||||
].sort((a, b) => {
|
||||
const timeA =
|
||||
a.type === 'channel'
|
||||
? getLastMessageTime('channel', a.channel.key)
|
||||
: getContactRecentTime(a.contact);
|
||||
const timeB =
|
||||
b.type === 'channel'
|
||||
? getLastMessageTime('channel', b.channel.key)
|
||||
: getContactRecentTime(b.contact);
|
||||
if (timeA && timeB) return timeB - timeA;
|
||||
if (timeA && !timeB) return -1;
|
||||
if (!timeA && timeB) return 1;
|
||||
const nameA =
|
||||
a.type === 'channel' ? a.channel.name : a.contact.name || a.contact.public_key;
|
||||
const nameB =
|
||||
b.type === 'channel' ? b.channel.name : b.contact.name || b.contact.public_key;
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
const items: FavoriteItem[] = [
|
||||
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||
...favContacts.map((contact) => ({ type: 'contact' as const, contact })),
|
||||
];
|
||||
|
||||
return {
|
||||
favoriteItems: items,
|
||||
nonFavoriteChannels: nonFavChannels,
|
||||
nonFavoriteContacts: nonFavContacts,
|
||||
nonFavoriteRepeaters: nonFavRepeaters,
|
||||
};
|
||||
}, [
|
||||
filteredChannels,
|
||||
filteredNonRepeaterContacts,
|
||||
filteredRepeaters,
|
||||
favorites,
|
||||
getContactRecentTime,
|
||||
getLastMessageTime,
|
||||
]);
|
||||
return {
|
||||
favoriteItems: sortFavoriteItemsByOrder(items, sectionSortOrders.favorites),
|
||||
nonFavoriteChannels: nonFavChannels,
|
||||
nonFavoriteContacts: nonFavContacts,
|
||||
nonFavoriteRooms: nonFavRooms,
|
||||
nonFavoriteRepeaters: nonFavRepeaters,
|
||||
};
|
||||
}, [
|
||||
filteredChannels,
|
||||
filteredNonRepeaterContacts,
|
||||
filteredRooms,
|
||||
filteredRepeaters,
|
||||
favorites,
|
||||
sectionSortOrders.favorites,
|
||||
sortFavoriteItemsByOrder,
|
||||
]);
|
||||
|
||||
const buildChannelRow = (channel: Channel, keyPrefix: string): ConversationRow => ({
|
||||
key: `${keyPrefix}-${channel.key}`,
|
||||
@@ -517,57 +578,65 @@ export function Sidebar({
|
||||
contact,
|
||||
});
|
||||
|
||||
const renderConversationRow = (row: ConversationRow) => (
|
||||
<div
|
||||
key={row.key}
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive(row.type, row.id) && 'bg-accent border-l-primary',
|
||||
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
|
||||
)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: row.type,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
{row.type === 'contact' && row.contact && (
|
||||
<ContactAvatar
|
||||
name={row.contact.name}
|
||||
publicKey={row.contact.public_key}
|
||||
size={24}
|
||||
contactType={row.contact.type}
|
||||
/>
|
||||
)}
|
||||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
const renderConversationRow = (row: ConversationRow) => {
|
||||
const highlightUnread =
|
||||
row.isMention ||
|
||||
(row.type === 'contact' &&
|
||||
row.contact?.type !== CONTACT_TYPE_REPEATER &&
|
||||
row.unreadCount > 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={row.key}
|
||||
className={cn(
|
||||
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
|
||||
isActive(row.type, row.id) && 'bg-accent border-l-primary',
|
||||
row.unreadCount > 0 && '[&_.name]:font-semibold [&_.name]:text-foreground'
|
||||
)}
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
row.isMention
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-current={isActive(row.type, row.id) ? 'page' : undefined}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() =>
|
||||
handleSelectConversation({
|
||||
type: row.type,
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
})
|
||||
}
|
||||
>
|
||||
{row.type === 'contact' && row.contact && (
|
||||
<ContactAvatar
|
||||
name={row.contact.name}
|
||||
publicKey={row.contact.public_key}
|
||||
size={24}
|
||||
contactType={row.contact.type}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
highlightUnread
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSidebarActionRow = ({
|
||||
key,
|
||||
@@ -613,11 +682,13 @@ export function Sidebar({
|
||||
);
|
||||
const channelRows = nonFavoriteChannels.map((channel) => buildChannelRow(channel, 'chan'));
|
||||
const contactRows = nonFavoriteContacts.map((contact) => buildContactRow(contact, 'contact'));
|
||||
const roomRows = nonFavoriteRooms.map((contact) => buildContactRow(contact, 'room'));
|
||||
const repeaterRows = nonFavoriteRepeaters.map((contact) => buildContactRow(contact, 'repeater'));
|
||||
|
||||
const favoritesUnreadCount = getSectionUnreadCount(favoriteRows);
|
||||
const channelsUnreadCount = getSectionUnreadCount(channelRows);
|
||||
const contactsUnreadCount = getSectionUnreadCount(contactRows);
|
||||
const roomsUnreadCount = getSectionUnreadCount(roomRows);
|
||||
const repeatersUnreadCount = getSectionUnreadCount(repeaterRows);
|
||||
const favoritesHasMention = sectionHasMention(favoriteRows);
|
||||
const channelsHasMention = sectionHasMention(channelRows);
|
||||
@@ -677,7 +748,7 @@ export function Sidebar({
|
||||
icon: <LockOpen className="h-4 w-4" />,
|
||||
label: (
|
||||
<>
|
||||
{showCracker ? 'Hide' : 'Show'} Room Finder
|
||||
{showCracker ? 'Hide' : 'Show'} Channel Finder
|
||||
<span
|
||||
className={cn(
|
||||
'ml-1 text-[11px]',
|
||||
@@ -833,7 +904,7 @@ export function Sidebar({
|
||||
'Favorites',
|
||||
favoritesCollapsed,
|
||||
() => setFavoritesCollapsed((prev) => !prev),
|
||||
null,
|
||||
'favorites',
|
||||
favoritesUnreadCount,
|
||||
favoritesHasMention
|
||||
)}
|
||||
@@ -889,8 +960,24 @@ export function Sidebar({
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Room Servers */}
|
||||
{nonFavoriteRooms.length > 0 && (
|
||||
<>
|
||||
{renderSectionHeader(
|
||||
'Room Servers',
|
||||
roomsCollapsed,
|
||||
() => setRoomsCollapsed((prev) => !prev),
|
||||
'rooms',
|
||||
roomsUnreadCount,
|
||||
roomsUnreadCount > 0
|
||||
)}
|
||||
{(isSearching || !roomsCollapsed) && roomRows.map((row) => renderConversationRow(row))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Empty state */}
|
||||
{nonFavoriteContacts.length === 0 &&
|
||||
nonFavoriteRooms.length === 0 &&
|
||||
nonFavoriteChannels.length === 0 &&
|
||||
nonFavoriteRepeaters.length === 0 &&
|
||||
favoriteItems.length === 0 && (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useMemo, lazy, Suspense } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
import type {
|
||||
Contact,
|
||||
RepeaterNeighborsResponse,
|
||||
@@ -35,6 +36,7 @@ export function NeighborsPane({
|
||||
nodeInfoState: PaneState;
|
||||
repeaterName: string | null;
|
||||
}) {
|
||||
const { distanceUnit } = useDistanceUnit();
|
||||
const advertLat = repeaterContact?.lat ?? null;
|
||||
const advertLon = repeaterContact?.lon ?? null;
|
||||
|
||||
@@ -93,7 +95,7 @@ export function NeighborsPane({
|
||||
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
|
||||
const distKm = calculateDistance(positionSource.lat, positionSource.lon, nLat, nLon);
|
||||
if (distKm != null) {
|
||||
dist = formatDistance(distKm);
|
||||
dist = formatDistance(distKm, distanceUnit);
|
||||
anyDist = true;
|
||||
}
|
||||
}
|
||||
@@ -111,7 +113,7 @@ export function NeighborsPane({
|
||||
sorted: enriched,
|
||||
hasDistances: anyDist,
|
||||
};
|
||||
}, [contacts, data, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
||||
}, [contacts, data, distanceUnit, hasValidRepeaterGps, positionSource.lat, positionSource.lon]);
|
||||
|
||||
return (
|
||||
<RepeaterPane
|
||||
|
||||
@@ -173,8 +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, nor impact your
|
||||
ability to do historical decryption.
|
||||
This will not affect any displayed messages or your ability to do historical decryption,
|
||||
but it will remove packet-analysis availability for those historical messages.
|
||||
</em>{' '}
|
||||
The raw bytes are only useful for manual packet analysis.
|
||||
</p>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useRef, lazy, Suspense } from 'react';
|
||||
import { useState, useEffect, useCallback, useMemo, useRef, lazy, Suspense } from 'react';
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api';
|
||||
@@ -15,21 +17,12 @@ const BotCodeEditor = lazy(() =>
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
mqtt_private: 'Private MQTT',
|
||||
mqtt_community: 'Community MQTT',
|
||||
bot: 'Bot',
|
||||
bot: 'Python Bot',
|
||||
webhook: 'Webhook',
|
||||
apprise: 'Apprise',
|
||||
sqs: 'Amazon SQS',
|
||||
};
|
||||
|
||||
const LIST_TYPE_OPTIONS = [
|
||||
{ value: 'mqtt_private', label: 'Private MQTT' },
|
||||
{ value: 'mqtt_community', label: 'Community MQTT' },
|
||||
{ value: 'bot', label: 'Bot' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'apprise', label: 'Apprise' },
|
||||
{ value: 'sqs', label: 'Amazon SQS' },
|
||||
];
|
||||
|
||||
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';
|
||||
@@ -42,30 +35,6 @@ 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> {
|
||||
@@ -122,11 +91,41 @@ const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
|
||||
return "[BOT] Plong!"
|
||||
return None`;
|
||||
|
||||
const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
mqtt_private: {
|
||||
type DraftType =
|
||||
| 'mqtt_private'
|
||||
| 'mqtt_community'
|
||||
| 'mqtt_community_meshrank'
|
||||
| 'mqtt_community_letsmesh_us'
|
||||
| 'mqtt_community_letsmesh_eu'
|
||||
| 'webhook'
|
||||
| 'apprise'
|
||||
| 'sqs'
|
||||
| 'bot';
|
||||
|
||||
type CreateIntegrationDefinition = {
|
||||
value: DraftType;
|
||||
savedType: string;
|
||||
label: string;
|
||||
section: string;
|
||||
description: string;
|
||||
defaultName: string;
|
||||
nameMode: 'counted' | 'fixed';
|
||||
defaults: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
const CREATE_INTEGRATION_DEFINITIONS: readonly CreateIntegrationDefinition[] = [
|
||||
{
|
||||
value: 'mqtt_private',
|
||||
savedType: 'mqtt_private',
|
||||
detailLabel: 'Private MQTT',
|
||||
label: 'Private MQTT',
|
||||
section: 'Bulk Forwarding',
|
||||
description:
|
||||
'Customizable-scope forwarding of all or some messages to an MQTT broker of your choosing, in raw and/or decrypted form.',
|
||||
defaultName: 'Private MQTT',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
broker_host: '',
|
||||
@@ -140,10 +139,29 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_meshrank: {
|
||||
{
|
||||
value: 'mqtt_community',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'MeshRank',
|
||||
label: 'Community MQTT/meshcoretomqtt',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'MeshcoreToMQTT-compatible raw-packet feed publishing, compatible with community aggregators (in other words, make your companion radio also serve as an observer node). Superset of other Community MQTT presets.',
|
||||
defaultName: 'Community MQTT',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults(),
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'mqtt_community_meshrank',
|
||||
savedType: 'mqtt_community',
|
||||
label: 'MeshRank',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'A community MQTT config preconfigured for MeshRank, requiring only the provided topic from your MeshRank configuration. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'MeshRank',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_MESHRANK_BROKER_HOST,
|
||||
@@ -158,10 +176,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_letsmesh_us: {
|
||||
{
|
||||
value: 'mqtt_community_letsmesh_us',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'LetsMesh (US)',
|
||||
label: 'LetsMesh (US)',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'A community MQTT config preconfigured for the LetsMesh US-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional EU configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'LetsMesh (US)',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_COMMUNITY_BROKER_HOST,
|
||||
@@ -170,10 +193,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'none', raw_packets: 'all' },
|
||||
},
|
||||
},
|
||||
mqtt_community_letsmesh_eu: {
|
||||
{
|
||||
value: 'mqtt_community_letsmesh_eu',
|
||||
savedType: 'mqtt_community',
|
||||
detailLabel: 'LetsMesh (EU)',
|
||||
label: 'LetsMesh (EU)',
|
||||
section: 'Community MQTT',
|
||||
description:
|
||||
'A community MQTT config preconfigured for the LetsMesh EU-ingest endpoint, requiring only your email and IATA region code. Good to use with an additional US configuration for redundancy. A subset of the primary Community MQTT/meshcoretomqtt configuration; you are free to edit all configuration after creation.',
|
||||
defaultName: 'LetsMesh (EU)',
|
||||
nameMode: 'fixed',
|
||||
defaults: {
|
||||
config: createCommunityConfigDefaults({
|
||||
broker_host: DEFAULT_COMMUNITY_BROKER_HOST_EU,
|
||||
@@ -182,30 +210,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
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: {
|
||||
{
|
||||
value: 'webhook',
|
||||
savedType: 'webhook',
|
||||
detailLabel: 'Webhook',
|
||||
label: 'Webhook',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'Generic webhook for decrypted channel/DM messages with customizable verb, method, and optional HMAC signature.',
|
||||
defaultName: 'Webhook',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
url: '',
|
||||
@@ -217,10 +230,15 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
apprise: {
|
||||
{
|
||||
value: 'apprise',
|
||||
savedType: 'apprise',
|
||||
detailLabel: 'Apprise',
|
||||
label: 'Apprise',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'A wide-ranging generic fanout, capable of forwarding decrypted channel/DM messages to Discord, Telegram, email, SMS, and many others.',
|
||||
defaultName: 'Apprise',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
urls: '',
|
||||
@@ -230,10 +248,14 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
sqs: {
|
||||
{
|
||||
value: 'sqs',
|
||||
savedType: 'sqs',
|
||||
detailLabel: 'Amazon SQS',
|
||||
label: 'Amazon SQS',
|
||||
section: 'Bulk Forwarding',
|
||||
description: 'Send full or scope-customized raw or decrypted packets to an SQS',
|
||||
defaultName: 'Amazon SQS',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
queue_url: '',
|
||||
@@ -246,15 +268,41 @@ const DRAFT_RECIPES: Record<DraftType, DraftRecipe> = {
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
};
|
||||
{
|
||||
value: 'bot',
|
||||
savedType: 'bot',
|
||||
label: 'Python Bot',
|
||||
section: 'Automation',
|
||||
description:
|
||||
'A simple, Python-based interface for basic bots that can respond to DM and channel messages.',
|
||||
defaultName: 'Bot',
|
||||
nameMode: 'counted',
|
||||
defaults: {
|
||||
config: {
|
||||
code: DEFAULT_BOT_CODE,
|
||||
},
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const CREATE_INTEGRATION_DEFINITIONS_BY_VALUE = Object.fromEntries(
|
||||
CREATE_INTEGRATION_DEFINITIONS.map((definition) => [definition.value, definition])
|
||||
) as Record<DraftType, CreateIntegrationDefinition>;
|
||||
|
||||
function isDraftType(value: string): value is DraftType {
|
||||
return value in DRAFT_RECIPES;
|
||||
return value in CREATE_INTEGRATION_DEFINITIONS_BY_VALUE;
|
||||
}
|
||||
|
||||
function getCreateIntegrationDefinition(draftType: DraftType) {
|
||||
return CREATE_INTEGRATION_DEFINITIONS_BY_VALUE[draftType];
|
||||
}
|
||||
|
||||
function normalizeDraftName(draftType: DraftType, name: string, configs: FanoutConfig[]) {
|
||||
const recipe = DRAFT_RECIPES[draftType];
|
||||
return name || getDefaultIntegrationName(recipe.savedType, configs);
|
||||
const definition = getCreateIntegrationDefinition(draftType);
|
||||
if (name) return name;
|
||||
if (definition.nameMode === 'fixed') return definition.defaultName;
|
||||
return getDefaultIntegrationName(definition.savedType, configs);
|
||||
}
|
||||
|
||||
function normalizeDraftConfig(draftType: DraftType, config: Record<string, unknown>) {
|
||||
@@ -305,22 +353,160 @@ function normalizeDraftConfig(draftType: DraftType, config: Record<string, unkno
|
||||
}
|
||||
|
||||
function normalizeDraftScope(draftType: DraftType, scope: Record<string, unknown>) {
|
||||
if (draftType.startsWith('mqtt_community_')) {
|
||||
if (getCreateIntegrationDefinition(draftType).savedType === 'mqtt_community') {
|
||||
return { messages: 'none', raw_packets: 'all' };
|
||||
}
|
||||
return scope;
|
||||
}
|
||||
|
||||
function cloneDraftDefaults(draftType: DraftType) {
|
||||
const recipe = DRAFT_RECIPES[draftType];
|
||||
const recipe = getCreateIntegrationDefinition(draftType);
|
||||
return {
|
||||
config: structuredClone(recipe.defaults.config),
|
||||
scope: structuredClone(recipe.defaults.scope),
|
||||
};
|
||||
}
|
||||
|
||||
function CreateIntegrationDialog({
|
||||
open,
|
||||
options,
|
||||
selectedType,
|
||||
onOpenChange,
|
||||
onSelect,
|
||||
onCreate,
|
||||
}: {
|
||||
open: boolean;
|
||||
options: readonly CreateIntegrationDefinition[];
|
||||
selectedType: DraftType | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSelect: (type: DraftType) => void;
|
||||
onCreate: () => void;
|
||||
}) {
|
||||
const selectedOption =
|
||||
options.find((option) => option.value === selectedType) ?? options[0] ?? null;
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const [showScrollHint, setShowScrollHint] = useState(false);
|
||||
|
||||
const updateScrollHint = useCallback(() => {
|
||||
const container = listRef.current;
|
||||
if (!container) {
|
||||
setShowScrollHint(false);
|
||||
return;
|
||||
}
|
||||
setShowScrollHint(container.scrollTop + container.clientHeight < container.scrollHeight - 8);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const frame = window.requestAnimationFrame(updateScrollHint);
|
||||
window.addEventListener('resize', updateScrollHint);
|
||||
return () => {
|
||||
window.cancelAnimationFrame(frame);
|
||||
window.removeEventListener('resize', updateScrollHint);
|
||||
};
|
||||
}, [open, options, updateScrollHint]);
|
||||
|
||||
const sectionedOptions = [...new Set(options.map((o) => o.section))]
|
||||
.map((section) => ({
|
||||
section,
|
||||
options: options.filter((option) => option.section === section),
|
||||
}))
|
||||
.filter((group) => group.options.length > 0);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
aria-describedby={undefined}
|
||||
hideCloseButton
|
||||
className="flex max-h-[calc(100dvh-2rem)] w-[96vw] max-w-[960px] flex-col overflow-hidden p-0 sm:rounded-xl"
|
||||
>
|
||||
<DialogHeader className="border-b border-border px-5 py-4">
|
||||
<DialogTitle>Create Integration</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 overflow-hidden md:grid-cols-[240px_minmax(0,1fr)]">
|
||||
<div className="relative border-b border-border bg-muted/20 md:border-b-0 md:border-r">
|
||||
<div
|
||||
ref={listRef}
|
||||
onScroll={updateScrollHint}
|
||||
className="max-h-56 overflow-y-auto p-2 md:max-h-[420px]"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{sectionedOptions.map((group) => (
|
||||
<div key={group.section} className="space-y-1.5">
|
||||
<div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{group.section}
|
||||
</div>
|
||||
{group.options.map((option) => {
|
||||
const selected = option.value === selectedOption?.value;
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full rounded-md border px-3 py-2 text-left transition-colors',
|
||||
selected
|
||||
? 'border-primary bg-accent text-foreground'
|
||||
: 'border-transparent bg-transparent hover:bg-accent/70'
|
||||
)}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(option.value)}
|
||||
>
|
||||
<div className="text-sm font-medium">{option.label}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showScrollHint && (
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-gradient-to-t from-background via-background/85 to-transparent px-4 pb-2 pt-8">
|
||||
<div className="rounded-full border border-border/80 bg-background/95 px-2 py-1 text-muted-foreground shadow-sm">
|
||||
<ChevronDown className="h-4 w-4" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto px-5 py-5 md:min-h-[280px] md:max-h-[420px]">
|
||||
{selectedOption ? (
|
||||
<>
|
||||
<div className="space-y-1.5">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
|
||||
{selectedOption.section}
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold">{selectedOption.label}</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-sm leading-6 text-muted-foreground">
|
||||
{selectedOption.description}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
|
||||
No integration types are currently available.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 border-t border-border px-5 py-4 sm:justify-end">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={onCreate} disabled={!selectedOption}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function getDetailTypeLabel(detailType: string) {
|
||||
if (isDraftType(detailType)) return DRAFT_RECIPES[detailType].detailLabel;
|
||||
if (isDraftType(detailType)) return getCreateIntegrationDefinition(detailType).label;
|
||||
return TYPE_LABELS[detailType] || detailType;
|
||||
}
|
||||
|
||||
@@ -1499,9 +1685,9 @@ export function SettingsFanoutSection({
|
||||
const [editName, setEditName] = useState('');
|
||||
const [inlineEditingId, setInlineEditingId] = useState<string | null>(null);
|
||||
const [inlineEditName, setInlineEditName] = useState('');
|
||||
const [addMenuOpen, setAddMenuOpen] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [selectedCreateType, setSelectedCreateType] = useState<DraftType | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const addMenuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const loadConfigs = useCallback(async () => {
|
||||
try {
|
||||
@@ -1516,18 +1702,28 @@ export function SettingsFanoutSection({
|
||||
loadConfigs();
|
||||
}, [loadConfigs]);
|
||||
|
||||
const availableCreateOptions = useMemo(
|
||||
() =>
|
||||
CREATE_INTEGRATION_DEFINITIONS.filter(
|
||||
(definition) => definition.savedType !== 'bot' || !health?.bots_disabled
|
||||
),
|
||||
[health?.bots_disabled]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!addMenuOpen) return;
|
||||
|
||||
const handlePointerDown = (event: MouseEvent) => {
|
||||
if (!addMenuRef.current?.contains(event.target as Node)) {
|
||||
setAddMenuOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handlePointerDown);
|
||||
return () => document.removeEventListener('mousedown', handlePointerDown);
|
||||
}, [addMenuOpen]);
|
||||
if (!createDialogOpen) return;
|
||||
if (availableCreateOptions.length === 0) {
|
||||
setSelectedCreateType(null);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedCreateType &&
|
||||
availableCreateOptions.some((option) => option.value === selectedCreateType)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setSelectedCreateType(availableCreateOptions[0].value);
|
||||
}, [createDialogOpen, availableCreateOptions, selectedCreateType]);
|
||||
|
||||
const handleToggleEnabled = async (cfg: FanoutConfig) => {
|
||||
try {
|
||||
@@ -1541,7 +1737,7 @@ export function SettingsFanoutSection({
|
||||
};
|
||||
|
||||
const handleEdit = (cfg: FanoutConfig) => {
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setInlineEditingId(null);
|
||||
setInlineEditName('');
|
||||
setDraftType(null);
|
||||
@@ -1552,7 +1748,7 @@ export function SettingsFanoutSection({
|
||||
};
|
||||
|
||||
const handleStartInlineEdit = (cfg: FanoutConfig) => {
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setInlineEditingId(cfg.id);
|
||||
setInlineEditName(cfg.name);
|
||||
};
|
||||
@@ -1611,7 +1807,7 @@ export function SettingsFanoutSection({
|
||||
setBusy(true);
|
||||
try {
|
||||
if (currentDraftType) {
|
||||
const recipe = DRAFT_RECIPES[currentDraftType];
|
||||
const recipe = getCreateIntegrationDefinition(currentDraftType);
|
||||
await api.createFanoutConfig({
|
||||
type: recipe.savedType,
|
||||
name: normalizeDraftName(currentDraftType, editName.trim(), configs),
|
||||
@@ -1663,18 +1859,16 @@ export function SettingsFanoutSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddCreate = async (type: string) => {
|
||||
if (!isDraftType(type)) return;
|
||||
const handleAddCreate = (type: DraftType) => {
|
||||
const definition = getCreateIntegrationDefinition(type);
|
||||
const defaults = cloneDraftDefaults(type);
|
||||
setAddMenuOpen(false);
|
||||
setCreateDialogOpen(false);
|
||||
setEditingId(null);
|
||||
setDraftType(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)
|
||||
definition.nameMode === 'fixed'
|
||||
? definition.defaultName
|
||||
: getDefaultIntegrationName(definition.savedType, configs)
|
||||
);
|
||||
setEditConfig(defaults.config);
|
||||
setEditScope(defaults.scope);
|
||||
@@ -1683,13 +1877,15 @@ export function SettingsFanoutSection({
|
||||
const editingConfig = editingId ? configs.find((c) => c.id === editingId) : null;
|
||||
const detailType = draftType ?? editingConfig?.type ?? null;
|
||||
const isDraft = draftType !== null;
|
||||
const configGroups = LIST_TYPE_OPTIONS.map((opt) => ({
|
||||
type: opt.value,
|
||||
label: opt.label,
|
||||
configs: configs
|
||||
.filter((cfg) => cfg.type === opt.value)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
|
||||
})).filter((group) => group.configs.length > 0);
|
||||
const configGroups = Object.entries(TYPE_LABELS)
|
||||
.map(([type, label]) => ({
|
||||
type,
|
||||
label,
|
||||
configs: configs
|
||||
.filter((cfg) => cfg.type === type)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })),
|
||||
}))
|
||||
.filter((group) => group.configs.length > 0);
|
||||
|
||||
// Detail view
|
||||
if (detailType) {
|
||||
@@ -1817,42 +2013,28 @@ export function SettingsFanoutSection({
|
||||
|
||||
{health?.bots_disabled && (
|
||||
<div className="rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations
|
||||
cannot be created or modified.
|
||||
{health.bots_disabled_source === 'until_restart'
|
||||
? 'Bot system is disabled until the server restarts. Bot integrations cannot run, be created, or be modified right now.'
|
||||
: 'Bot system is disabled by server configuration (MESHCORE_DISABLE_BOTS). Bot integrations cannot run, be created, or be modified.'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative inline-block" ref={addMenuRef}>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={addMenuOpen}
|
||||
onClick={() => setAddMenuOpen((open) => !open)}
|
||||
>
|
||||
Add Integration
|
||||
</Button>
|
||||
{addMenuOpen && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute left-0 top-full z-10 mt-2 min-w-72 rounded-md border border-input bg-background p-1 shadow-md"
|
||||
>
|
||||
{CREATE_TYPE_OPTIONS.filter((opt) => opt.value !== 'bot' || !health?.bots_disabled).map(
|
||||
(opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="flex w-full rounded-sm px-3 py-2 text-left text-sm hover:bg-muted"
|
||||
onClick={() => handleAddCreate(opt.value)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button type="button" size="sm" onClick={() => setCreateDialogOpen(true)}>
|
||||
Add Integration
|
||||
</Button>
|
||||
|
||||
<CreateIntegrationDialog
|
||||
open={createDialogOpen}
|
||||
options={availableCreateOptions}
|
||||
selectedType={selectedCreateType}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
onSelect={setSelectedCreateType}
|
||||
onCreate={() => {
|
||||
if (selectedCreateType) {
|
||||
handleAddCreate(selectedCreateType);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
{configGroups.length > 0 && (
|
||||
<div className="columns-1 gap-4 md:columns-2">
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
} from '../../utils/lastViewedConversation';
|
||||
import { ThemeSelector } from './ThemeSelector';
|
||||
import { getLocalLabel, setLocalLabel, type LocalLabel } from '../../utils/localLabel';
|
||||
import {
|
||||
DISTANCE_UNIT_LABELS,
|
||||
DISTANCE_UNITS,
|
||||
setSavedDistanceUnit,
|
||||
} from '../../utils/distanceUnits';
|
||||
import { useDistanceUnit } from '../../contexts/DistanceUnitContext';
|
||||
|
||||
export function SettingsLocalSection({
|
||||
onLocalLabelChange,
|
||||
@@ -19,6 +25,7 @@ export function SettingsLocalSection({
|
||||
onLocalLabelChange?: (label: LocalLabel) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { distanceUnit, setDistanceUnit } = useDistanceUnit();
|
||||
const [reopenLastConversation, setReopenLastConversation] = useState(
|
||||
getReopenLastConversationEnabled
|
||||
);
|
||||
@@ -82,6 +89,31 @@ export function SettingsLocalSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="distance-units">Distance Units</Label>
|
||||
<select
|
||||
id="distance-units"
|
||||
value={distanceUnit}
|
||||
onChange={(event) => {
|
||||
const nextUnit = event.target.value as (typeof DISTANCE_UNITS)[number];
|
||||
setSavedDistanceUnit(nextUnit);
|
||||
setDistanceUnit(nextUnit);
|
||||
}}
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
{DISTANCE_UNITS.map((unit) => (
|
||||
<option key={unit} value={unit}>
|
||||
{DISTANCE_UNIT_LABELS[unit]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Controls how distances are shown throughout the app.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
||||
import { stripRegionScopePrefix } from '../../utils/regionScope';
|
||||
import type {
|
||||
@@ -64,6 +65,7 @@ export function SettingsRadioSection({
|
||||
const [cr, setCr] = useState('');
|
||||
const [pathHashMode, setPathHashMode] = useState('0');
|
||||
const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
|
||||
const [multiAcksEnabled, setMultiAcksEnabled] = useState(false);
|
||||
const [gettingLocation, setGettingLocation] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [rebooting, setRebooting] = useState(false);
|
||||
@@ -98,6 +100,7 @@ export function SettingsRadioSection({
|
||||
setCr(String(config.radio.cr));
|
||||
setPathHashMode(String(config.path_hash_mode));
|
||||
setAdvertLocationSource(config.advert_location_source ?? 'current');
|
||||
setMultiAcksEnabled(config.multi_acks_enabled ?? false);
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -190,6 +193,9 @@ export function SettingsRadioSection({
|
||||
...(advertLocationSource !== (config.advert_location_source ?? 'current')
|
||||
? { advert_location_source: advertLocationSource }
|
||||
: {}),
|
||||
...(multiAcksEnabled !== (config.multi_acks_enabled ?? false)
|
||||
? { multi_acks_enabled: multiAcksEnabled }
|
||||
: {}),
|
||||
radio: {
|
||||
freq: parsedFreq,
|
||||
bw: parsedBw,
|
||||
@@ -579,6 +585,24 @@ export function SettingsRadioSection({
|
||||
library.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
|
||||
<Checkbox
|
||||
id="multi-acks-enabled"
|
||||
checked={multiAcksEnabled}
|
||||
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the radio sends one extra direct ACK transmission before the normal
|
||||
ACK for received direct messages. This is a firmware-level receive behavior, not a
|
||||
RemoteTerm retry setting.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.path_hash_mode_supported && (
|
||||
|
||||
@@ -164,6 +164,12 @@ export function SettingsStatisticsSection({ className }: { className?: string })
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_24_hours}</td>
|
||||
<td className="text-right py-1">{stats.repeaters_heard.last_week}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-1">Known-channels active</td>
|
||||
<td className="text-right py-1">{stats.known_channels_active.last_hour}</td>
|
||||
<td className="text-right py-1">{stats.known_channels_active.last_24_hours}</td>
|
||||
<td className="text-right py-1">{stats.known_channels_active.last_week}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -29,10 +29,16 @@ const DialogOverlay = React.forwardRef<
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
interface DialogContentProps extends React.ComponentPropsWithoutRef<
|
||||
typeof DialogPrimitive.Content
|
||||
> {
|
||||
hideCloseButton?: boolean;
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
DialogContentProps
|
||||
>(({ className, children, hideCloseButton = false, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
@@ -44,10 +50,12 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
{!hideCloseButton && (
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
|
||||
31
frontend/src/contexts/DistanceUnitContext.tsx
Normal file
31
frontend/src/contexts/DistanceUnitContext.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { createContext, useContext, type ReactNode } from 'react';
|
||||
|
||||
import type { DistanceUnit } from '../utils/distanceUnits';
|
||||
|
||||
interface DistanceUnitContextValue {
|
||||
distanceUnit: DistanceUnit;
|
||||
setDistanceUnit: (unit: DistanceUnit) => void;
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
const DistanceUnitContext = createContext<DistanceUnitContextValue>({
|
||||
distanceUnit: 'metric',
|
||||
setDistanceUnit: noop,
|
||||
});
|
||||
|
||||
export function DistanceUnitProvider({
|
||||
distanceUnit,
|
||||
setDistanceUnit,
|
||||
children,
|
||||
}: DistanceUnitContextValue & { children: ReactNode }) {
|
||||
return (
|
||||
<DistanceUnitContext.Provider value={{ distanceUnit, setDistanceUnit }}>
|
||||
{children}
|
||||
</DistanceUnitContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useDistanceUnit() {
|
||||
return useContext(DistanceUnitContext);
|
||||
}
|
||||
@@ -10,3 +10,5 @@ export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
export { useFaviconBadge, useUnreadTitle } from './useFaviconBadge';
|
||||
export { useRawPacketStatsSession } from './useRawPacketStatsSession';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { startTransition, useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
|
||||
import { getSavedDistanceUnit, type DistanceUnit } from '../utils/distanceUnits';
|
||||
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||
import { parseHashSettingsSection, updateSettingsHash } from '../utils/urlHash';
|
||||
|
||||
@@ -12,10 +13,12 @@ interface UseAppShellResult {
|
||||
showCracker: boolean;
|
||||
crackerRunning: boolean;
|
||||
localLabel: LocalLabel;
|
||||
distanceUnit: DistanceUnit;
|
||||
setSettingsSection: (section: SettingsSection) => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setCrackerRunning: (running: boolean) => void;
|
||||
setLocalLabel: (label: LocalLabel) => void;
|
||||
setDistanceUnit: (unit: DistanceUnit) => void;
|
||||
handleCloseSettingsView: () => void;
|
||||
handleToggleSettingsView: () => void;
|
||||
handleOpenNewMessage: () => void;
|
||||
@@ -34,6 +37,7 @@ export function useAppShell(): UseAppShellResult {
|
||||
const [showCracker, setShowCracker] = useState(false);
|
||||
const [crackerRunning, setCrackerRunning] = useState(false);
|
||||
const [localLabel, setLocalLabel] = useState(getLocalLabel);
|
||||
const [distanceUnit, setDistanceUnit] = useState(getSavedDistanceUnit);
|
||||
const previousHashRef = useRef('');
|
||||
|
||||
useEffect(() => {
|
||||
@@ -87,10 +91,12 @@ export function useAppShell(): UseAppShellResult {
|
||||
showCracker,
|
||||
crackerRunning,
|
||||
localLabel,
|
||||
distanceUnit,
|
||||
setSettingsSection,
|
||||
setSidebarOpen,
|
||||
setCrackerRunning,
|
||||
setLocalLabel,
|
||||
setDistanceUnit,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage,
|
||||
|
||||
@@ -110,6 +110,8 @@ export function useBrowserNotifications() {
|
||||
|
||||
const toggleConversationNotifications = useCallback(
|
||||
async (type: 'channel' | 'contact', id: string, label: string) => {
|
||||
const blockedDescription =
|
||||
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.';
|
||||
const conversationKey = getConversationNotificationKey(type, id);
|
||||
if (enabledByConversation[conversationKey]) {
|
||||
setEnabledByConversation((prev) => {
|
||||
@@ -131,7 +133,7 @@ export function useBrowserNotifications() {
|
||||
|
||||
if (permission === 'denied') {
|
||||
toast.error('Browser notifications blocked', {
|
||||
description: 'Allow notifications in your browser settings, then try again.',
|
||||
description: blockedDescription,
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -159,9 +161,7 @@ export function useBrowserNotifications() {
|
||||
|
||||
toast.error('Browser notifications not enabled', {
|
||||
description:
|
||||
nextPermission === 'denied'
|
||||
? 'Permission was denied by the browser.'
|
||||
: 'Permission request was dismissed.',
|
||||
nextPermission === 'denied' ? blockedDescription : 'Permission request was dismissed.',
|
||||
});
|
||||
},
|
||||
[enabledByConversation, permission]
|
||||
|
||||
@@ -21,6 +21,24 @@ interface InternalCachedConversationEntry extends CachedConversationEntry {
|
||||
export class ConversationMessageCache {
|
||||
private readonly cache = new Map<string, InternalCachedConversationEntry>();
|
||||
|
||||
private normalizeEntry(entry: CachedConversationEntry): InternalCachedConversationEntry {
|
||||
let messages = entry.messages;
|
||||
let hasOlderMessages = entry.hasOlderMessages;
|
||||
|
||||
if (messages.length > MAX_MESSAGES_PER_ENTRY) {
|
||||
messages = [...messages]
|
||||
.sort((a, b) => b.received_at - a.received_at)
|
||||
.slice(0, MAX_MESSAGES_PER_ENTRY);
|
||||
hasOlderMessages = true;
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
hasOlderMessages,
|
||||
contentKeys: new Set(messages.map((message) => getMessageContentKey(message))),
|
||||
};
|
||||
}
|
||||
|
||||
get(id: string): CachedConversationEntry | undefined {
|
||||
const entry = this.cache.get(id);
|
||||
if (!entry) return undefined;
|
||||
@@ -33,17 +51,7 @@ export class ConversationMessageCache {
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
const internalEntry = this.normalizeEntry(entry);
|
||||
this.cache.delete(id);
|
||||
this.cache.set(id, internalEntry);
|
||||
if (this.cache.size > MAX_CACHED_CONVERSATIONS) {
|
||||
@@ -69,19 +77,21 @@ export class ConversationMessageCache {
|
||||
}
|
||||
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);
|
||||
}
|
||||
const nextEntry = this.normalizeEntry({
|
||||
messages: [...entry.messages, msg],
|
||||
hasOlderMessages: entry.hasOlderMessages,
|
||||
});
|
||||
this.cache.delete(id);
|
||||
this.cache.set(id, entry);
|
||||
this.cache.set(id, nextEntry);
|
||||
return true;
|
||||
}
|
||||
|
||||
updateAck(messageId: number, ackCount: number, paths?: MessagePath[]): void {
|
||||
updateAck(
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
): void {
|
||||
for (const entry of this.cache.values()) {
|
||||
const index = entry.messages.findIndex((message) => message.id === messageId);
|
||||
if (index < 0) continue;
|
||||
@@ -91,6 +101,7 @@ export class ConversationMessageCache {
|
||||
...current,
|
||||
acked: Math.max(current.acked, ackCount),
|
||||
...(paths !== undefined && paths.length >= (current.paths?.length ?? 0) && { paths }),
|
||||
...(packetId !== undefined && { packet_id: packetId }),
|
||||
};
|
||||
entry.messages = updated;
|
||||
return;
|
||||
@@ -123,11 +134,13 @@ export class ConversationMessageCache {
|
||||
}
|
||||
|
||||
this.cache.delete(oldId);
|
||||
this.cache.set(newId, {
|
||||
messages: mergedMessages,
|
||||
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
|
||||
contentKeys: new Set([...newEntry.contentKeys, ...oldEntry.contentKeys]),
|
||||
});
|
||||
this.cache.set(
|
||||
newId,
|
||||
this.normalizeEntry({
|
||||
messages: mergedMessages,
|
||||
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
@@ -139,12 +152,16 @@ export function reconcileConversationMessages(
|
||||
current: Message[],
|
||||
fetched: Message[]
|
||||
): Message[] | null {
|
||||
const currentById = new Map<number, { acked: number; pathsLen: number; text: string }>();
|
||||
const currentById = new Map<
|
||||
number,
|
||||
{ acked: number; pathsLen: number; text: string; packetId: number | null | undefined }
|
||||
>();
|
||||
for (const message of current) {
|
||||
currentById.set(message.id, {
|
||||
acked: message.acked,
|
||||
pathsLen: message.paths?.length ?? 0,
|
||||
text: message.text,
|
||||
packetId: message.packet_id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -155,7 +172,8 @@ export function reconcileConversationMessages(
|
||||
!currentMessage ||
|
||||
currentMessage.acked !== message.acked ||
|
||||
currentMessage.pathsLen !== (message.paths?.length ?? 0) ||
|
||||
currentMessage.text !== message.text
|
||||
currentMessage.text !== message.text ||
|
||||
currentMessage.packetId !== message.packet_id
|
||||
) {
|
||||
needsUpdate = true;
|
||||
break;
|
||||
@@ -173,17 +191,20 @@ export const conversationMessageCache = new ConversationMessageCache();
|
||||
interface PendingAckUpdate {
|
||||
ackCount: number;
|
||||
paths?: MessagePath[];
|
||||
packetId?: number | null;
|
||||
}
|
||||
|
||||
export function mergePendingAck(
|
||||
existing: PendingAckUpdate | undefined,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[]
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
): PendingAckUpdate {
|
||||
if (!existing) {
|
||||
return {
|
||||
ackCount,
|
||||
...(paths !== undefined && { paths }),
|
||||
...(packetId !== undefined && { packetId }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,6 +213,9 @@ export function mergePendingAck(
|
||||
ackCount,
|
||||
...(paths !== undefined && { paths }),
|
||||
...(paths === undefined && existing.paths !== undefined && { paths: existing.paths }),
|
||||
...(packetId !== undefined && { packetId }),
|
||||
...(packetId === undefined &&
|
||||
existing.packetId !== undefined && { packetId: existing.packetId }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -199,16 +223,31 @@ export function mergePendingAck(
|
||||
return existing;
|
||||
}
|
||||
|
||||
const packetIdChanged = packetId !== undefined && packetId !== existing.packetId;
|
||||
|
||||
if (paths === undefined) {
|
||||
return existing;
|
||||
if (!packetIdChanged) {
|
||||
return existing;
|
||||
}
|
||||
return {
|
||||
...existing,
|
||||
packetId,
|
||||
};
|
||||
}
|
||||
|
||||
const existingPathCount = existing.paths?.length ?? -1;
|
||||
if (paths.length >= existingPathCount) {
|
||||
return { ackCount, paths };
|
||||
return { ackCount, paths, ...(packetId !== undefined && { packetId }) };
|
||||
}
|
||||
|
||||
return existing;
|
||||
if (!packetIdChanged) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
return {
|
||||
...existing,
|
||||
packetId,
|
||||
};
|
||||
}
|
||||
|
||||
interface UseConversationMessagesResult {
|
||||
@@ -223,7 +262,12 @@ interface UseConversationMessagesResult {
|
||||
jumpToBottom: () => void;
|
||||
reloadCurrentConversation: () => void;
|
||||
observeMessage: (msg: Message) => { added: boolean; activeConversation: boolean };
|
||||
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
receiveMessageAck: (
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
) => void;
|
||||
reconcileOnReconnect: () => void;
|
||||
renameConversationMessages: (oldId: string, newId: string) => void;
|
||||
removeConversationMessages: (conversationId: string) => void;
|
||||
@@ -284,9 +328,9 @@ export function useConversationMessages(
|
||||
const pendingAcksRef = useRef<Map<number, PendingAckUpdate>>(new Map());
|
||||
|
||||
const setPendingAck = useCallback(
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
|
||||
const existing = pendingAcksRef.current.get(messageId);
|
||||
const merged = mergePendingAck(existing, ackCount, paths);
|
||||
const merged = mergePendingAck(existing, ackCount, paths, packetId);
|
||||
|
||||
// Update insertion order so most recent updates remain in the buffer longest.
|
||||
pendingAcksRef.current.delete(messageId);
|
||||
@@ -312,6 +356,7 @@ export function useConversationMessages(
|
||||
...msg,
|
||||
acked: Math.max(msg.acked, pending.ackCount),
|
||||
...(pending.paths !== undefined && { paths: pending.paths }),
|
||||
...(pending.packetId !== undefined && { packet_id: pending.packetId }),
|
||||
};
|
||||
}, []);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
@@ -775,10 +820,10 @@ export function useConversationMessages(
|
||||
|
||||
// Update a message's ack count and paths
|
||||
const updateMessageAck = useCallback(
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
|
||||
const hasMessageLoaded = messagesRef.current.some((m) => m.id === messageId);
|
||||
if (!hasMessageLoaded) {
|
||||
setPendingAck(messageId, ackCount, paths);
|
||||
setPendingAck(messageId, ackCount, paths, packetId);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -800,10 +845,11 @@ export function useConversationMessages(
|
||||
...current,
|
||||
acked: nextAck,
|
||||
...(paths !== undefined && { paths: nextPaths }),
|
||||
...(packetId !== undefined && { packet_id: packetId }),
|
||||
};
|
||||
return updated;
|
||||
}
|
||||
setPendingAck(messageId, ackCount, paths);
|
||||
setPendingAck(messageId, ackCount, paths, packetId);
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
@@ -811,9 +857,9 @@ export function useConversationMessages(
|
||||
);
|
||||
|
||||
const receiveMessageAck = useCallback(
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
updateMessageAck(messageId, ackCount, paths);
|
||||
conversationMessageCache.updateAck(messageId, ackCount, paths);
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[], packetId?: number | null) => {
|
||||
updateMessageAck(messageId, ackCount, paths, packetId);
|
||||
conversationMessageCache.updateAck(messageId, ackCount, paths, packetId);
|
||||
},
|
||||
[updateMessageAck]
|
||||
);
|
||||
|
||||
196
frontend/src/hooks/useFaviconBadge.ts
Normal file
196
frontend/src/hooks/useFaviconBadge.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import type { Favorite } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const APP_TITLE = 'RemoteTerm for MeshCore';
|
||||
const UNREAD_APP_TITLE = 'RemoteTerm';
|
||||
const BASE_FAVICON_PATH = '/favicon.svg';
|
||||
const GREEN_BADGE_FILL = '#16a34a';
|
||||
const RED_BADGE_FILL = '#dc2626';
|
||||
const BADGE_CENTER = 750;
|
||||
const BADGE_OUTER_RADIUS = 220;
|
||||
const BADGE_INNER_RADIUS = 180;
|
||||
|
||||
let baseFaviconSvgPromise: Promise<string> | null = null;
|
||||
|
||||
export type FaviconBadgeState = 'none' | 'green' | 'red';
|
||||
|
||||
function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): number {
|
||||
return Object.entries(unreadCounts).reduce(
|
||||
(sum, [stateKey, count]) => sum + (stateKey.startsWith('contact-') ? count : 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function getUnreadFavoriteChannelCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
): number {
|
||||
return favorites.reduce(
|
||||
(sum, favorite) =>
|
||||
sum +
|
||||
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
export function getTotalUnreadCount(unreadCounts: Record<string, number>): number {
|
||||
return Object.values(unreadCounts).reduce((sum, count) => sum + count, 0);
|
||||
}
|
||||
|
||||
export function getFavoriteUnreadCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
): number {
|
||||
return favorites.reduce((sum, favorite) => {
|
||||
const stateKey = getStateKey(favorite.type, favorite.id);
|
||||
return sum + (unreadCounts[stateKey] || 0);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function getUnreadTitle(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
): string {
|
||||
const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
|
||||
if (unreadCount <= 0) {
|
||||
return APP_TITLE;
|
||||
}
|
||||
|
||||
const label = unreadCount > 99 ? '99+' : String(unreadCount);
|
||||
return `(${label}) ${UNREAD_APP_TITLE}`;
|
||||
}
|
||||
|
||||
export function deriveFaviconBadgeState(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
): FaviconBadgeState {
|
||||
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
|
||||
return 'red';
|
||||
}
|
||||
|
||||
if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
}
|
||||
|
||||
export function buildBadgedFaviconSvg(baseSvg: string, badgeFill: string): string {
|
||||
const closingTagIndex = baseSvg.lastIndexOf('</svg>');
|
||||
if (closingTagIndex === -1) {
|
||||
return baseSvg;
|
||||
}
|
||||
|
||||
const badge = `
|
||||
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_OUTER_RADIUS}" fill="#ffffff"/>
|
||||
<circle cx="${BADGE_CENTER}" cy="${BADGE_CENTER}" r="${BADGE_INNER_RADIUS}" fill="${badgeFill}"/>
|
||||
`;
|
||||
return `${baseSvg.slice(0, closingTagIndex)}${badge}</svg>`;
|
||||
}
|
||||
|
||||
async function loadBaseFaviconSvg(): Promise<string> {
|
||||
if (!baseFaviconSvgPromise) {
|
||||
baseFaviconSvgPromise = fetch(BASE_FAVICON_PATH, { cache: 'force-cache' })
|
||||
.then(async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load favicon SVG: ${response.status}`);
|
||||
}
|
||||
return response.text();
|
||||
})
|
||||
.catch((error) => {
|
||||
baseFaviconSvgPromise = null;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
return baseFaviconSvgPromise;
|
||||
}
|
||||
|
||||
function upsertFaviconLinks(rel: 'icon' | 'shortcut icon', href: string): void {
|
||||
const links = Array.from(document.head.querySelectorAll<HTMLLinkElement>(`link[rel="${rel}"]`));
|
||||
const targets = links.length > 0 ? links : [document.createElement('link')];
|
||||
|
||||
for (const link of targets) {
|
||||
if (!link.parentNode) {
|
||||
link.rel = rel;
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.type = 'image/svg+xml';
|
||||
link.href = href;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFaviconHref(href: string): void {
|
||||
upsertFaviconLinks('icon', href);
|
||||
upsertFaviconLinks('shortcut icon', href);
|
||||
}
|
||||
|
||||
export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
|
||||
const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
|
||||
return () => {
|
||||
document.title = APP_TITLE;
|
||||
};
|
||||
}, [title]);
|
||||
}
|
||||
|
||||
export function useFaviconBadge(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
): void {
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const badgeState = useMemo(
|
||||
() => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
|
||||
[favorites, mentions, unreadCounts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
|
||||
if (badgeState === 'none') {
|
||||
applyFaviconHref(BASE_FAVICON_PATH);
|
||||
return;
|
||||
}
|
||||
|
||||
const badgeFill = badgeState === 'red' ? RED_BADGE_FILL : GREEN_BADGE_FILL;
|
||||
let cancelled = false;
|
||||
|
||||
void loadBaseFaviconSvg()
|
||||
.then((baseSvg) => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const objectUrl = URL.createObjectURL(
|
||||
new Blob([buildBadgedFaviconSvg(baseSvg, badgeFill)], {
|
||||
type: 'image/svg+xml',
|
||||
})
|
||||
);
|
||||
objectUrlRef.current = objectUrl;
|
||||
applyFaviconHref(objectUrl);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
applyFaviconHref(BASE_FAVICON_PATH);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (objectUrlRef.current) {
|
||||
URL.revokeObjectURL(objectUrlRef.current);
|
||||
objectUrlRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [badgeState]);
|
||||
}
|
||||
52
frontend/src/hooks/useRawPacketStatsSession.ts
Normal file
52
frontend/src/hooks/useRawPacketStatsSession.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
import type { RawPacket } from '../types';
|
||||
import {
|
||||
MAX_RAW_PACKET_STATS_OBSERVATIONS,
|
||||
summarizeRawPacketForStats,
|
||||
type RawPacketStatsSessionState,
|
||||
} from '../utils/rawPacketStats';
|
||||
|
||||
export function useRawPacketStatsSession() {
|
||||
const [session, setSession] = useState<RawPacketStatsSessionState>(() => ({
|
||||
sessionStartedAt: Date.now(),
|
||||
totalObservedPackets: 0,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [],
|
||||
}));
|
||||
|
||||
const recordRawPacketObservation = useCallback((packet: RawPacket) => {
|
||||
setSession((prev) => {
|
||||
const observation = summarizeRawPacketForStats(packet);
|
||||
if (
|
||||
prev.observations.some(
|
||||
(candidate) => candidate.observationKey === observation.observationKey
|
||||
)
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const observations = [...prev.observations, observation];
|
||||
if (observations.length <= MAX_RAW_PACKET_STATS_OBSERVATIONS) {
|
||||
return {
|
||||
...prev,
|
||||
totalObservedPackets: prev.totalObservedPackets + 1,
|
||||
observations,
|
||||
};
|
||||
}
|
||||
|
||||
const overflow = observations.length - MAX_RAW_PACKET_STATS_OBSERVATIONS;
|
||||
return {
|
||||
...prev,
|
||||
totalObservedPackets: prev.totalObservedPackets + 1,
|
||||
trimmedObservationCount: prev.trimmedObservationCount + overflow,
|
||||
observations: observations.slice(overflow),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
return {
|
||||
rawPacketStatsSession: session,
|
||||
recordRawPacketObservation,
|
||||
};
|
||||
}
|
||||
@@ -48,8 +48,14 @@ interface UseRealtimeAppStateArgs {
|
||||
setActiveConversation: (conv: Conversation | null) => void;
|
||||
renameConversationMessages: (oldId: string, newId: string) => void;
|
||||
removeConversationMessages: (conversationId: string) => void;
|
||||
receiveMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
receiveMessageAck: (
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
) => void;
|
||||
notifyIncomingMessage?: (msg: Message) => void;
|
||||
recordRawPacketObservation?: (packet: RawPacket) => void;
|
||||
maxRawPackets?: number;
|
||||
}
|
||||
|
||||
@@ -97,6 +103,7 @@ export function useRealtimeAppState({
|
||||
removeConversationMessages,
|
||||
receiveMessageAck,
|
||||
notifyIncomingMessage,
|
||||
recordRawPacketObservation,
|
||||
maxRawPackets = 500,
|
||||
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
|
||||
const mergeChannelIntoList = useCallback(
|
||||
@@ -241,10 +248,16 @@ export function useRealtimeAppState({
|
||||
}
|
||||
},
|
||||
onRawPacket: (packet: RawPacket) => {
|
||||
recordRawPacketObservation?.(packet);
|
||||
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
|
||||
},
|
||||
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
receiveMessageAck(messageId, ackCount, paths);
|
||||
onMessageAcked: (
|
||||
messageId: number,
|
||||
ackCount: number,
|
||||
paths?: MessagePath[],
|
||||
packetId?: number | null
|
||||
) => {
|
||||
receiveMessageAck(messageId, ackCount, paths, packetId);
|
||||
},
|
||||
}),
|
||||
[
|
||||
@@ -261,6 +274,7 @@ export function useRealtimeAppState({
|
||||
pendingDeleteFallbackRef,
|
||||
prevHealthRef,
|
||||
recordMessageEvent,
|
||||
recordRawPacketObservation,
|
||||
receiveMessageAck,
|
||||
observeMessage,
|
||||
refreshUnreads,
|
||||
|
||||
79
frontend/src/hooks/useRememberedServerPassword.ts
Normal file
79
frontend/src/hooks/useRememberedServerPassword.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
type ServerLoginKind = 'repeater' | 'room';
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'remoteterm-server-password';
|
||||
|
||||
type StoredPassword = {
|
||||
password: string;
|
||||
};
|
||||
|
||||
function getStorageKey(kind: ServerLoginKind, publicKey: string): string {
|
||||
return `${STORAGE_KEY_PREFIX}:${kind}:${publicKey}`;
|
||||
}
|
||||
|
||||
function loadStoredPassword(kind: ServerLoginKind, publicKey: string): StoredPassword | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(getStorageKey(kind, publicKey));
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<StoredPassword>;
|
||||
if (typeof parsed.password !== 'string' || parsed.password.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return { password: parsed.password };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function useRememberedServerPassword(kind: ServerLoginKind, publicKey: string) {
|
||||
const storageKey = useMemo(() => getStorageKey(kind, publicKey), [kind, publicKey]);
|
||||
const [password, setPassword] = useState('');
|
||||
const [rememberPassword, setRememberPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const stored = loadStoredPassword(kind, publicKey);
|
||||
if (!stored) {
|
||||
setPassword('');
|
||||
setRememberPassword(false);
|
||||
return;
|
||||
}
|
||||
setPassword(stored.password);
|
||||
setRememberPassword(true);
|
||||
}, [kind, publicKey]);
|
||||
|
||||
const persistAfterLogin = useCallback(
|
||||
(submittedPassword: string) => {
|
||||
if (!rememberPassword) {
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
setPassword('');
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedPassword = submittedPassword.trim();
|
||||
if (!trimmedPassword) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify({ password: trimmedPassword }));
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
setPassword(trimmedPassword);
|
||||
},
|
||||
[rememberPassword, storageKey]
|
||||
);
|
||||
|
||||
return {
|
||||
password,
|
||||
setPassword,
|
||||
rememberPassword,
|
||||
setRememberPassword,
|
||||
persistAfterLogin,
|
||||
};
|
||||
}
|
||||
@@ -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 { CONTACT_TYPE_ROOM } from '../types';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
@@ -170,6 +171,38 @@ describe('ChatHeader key visibility', () => {
|
||||
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('hides trace and notification controls for room-server contacts', () => {
|
||||
const pubKey = '41'.repeat(32);
|
||||
const contact: Contact = {
|
||||
public_key: pubKey,
|
||||
name: 'Ops Board',
|
||||
type: CONTACT_TYPE_ROOM,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Ops Board' };
|
||||
|
||||
render(
|
||||
<ChatHeader {...baseProps} conversation={conversation} channels={[]} contacts={[contact]} />
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Path Discovery' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Direct Trace' })).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Enable notifications for this conversation' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
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' };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { getContactAvatar } from '../utils/contactAvatar';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
describe('getContactAvatar', () => {
|
||||
it('returns complete avatar info', () => {
|
||||
@@ -30,6 +30,13 @@ describe('getContactAvatar', () => {
|
||||
expect(avatar1.background).toBe(avatar2.background);
|
||||
});
|
||||
|
||||
it('returns room avatar for type=3', () => {
|
||||
const avatar = getContactAvatar('Ops Board', 'abc123def456', CONTACT_TYPE_ROOM);
|
||||
expect(avatar.text).toBe('🛖');
|
||||
expect(avatar.background).toBe('#6b4f2a');
|
||||
expect(avatar.textColor).toBe('#ffffff');
|
||||
});
|
||||
|
||||
it('non-repeater types use normal avatar', () => {
|
||||
const avatar0 = getContactAvatar('John', 'abc123', 0);
|
||||
const avatar1 = getContactAvatar('John', 'abc123', 1);
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('ContactInfoPane', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('loads name-only channel stats and most active rooms', async () => {
|
||||
it('loads name-only channel stats and most active channels', async () => {
|
||||
getContactAnalytics.mockResolvedValue(
|
||||
createAnalytics(null, {
|
||||
lookup_type: 'name',
|
||||
@@ -188,7 +188,7 @@ describe('ContactInfoPane', () => {
|
||||
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
|
||||
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
|
||||
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most Active Rooms')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most Active Channels')).toBeInTheDocument();
|
||||
expect(screen.getByText('#ops')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Name-only analytics include channel messages only/i)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConversationPane } from '../components/ConversationPane';
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
Message,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
messageList: vi.fn(() => <div data-testid="message-list" />),
|
||||
@@ -40,6 +41,21 @@ vi.mock('../components/RepeaterDashboard', () => ({
|
||||
RepeaterDashboard: () => <div data-testid="repeater-dashboard" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/RoomServerPanel', () => ({
|
||||
RoomServerPanel: ({
|
||||
onAuthenticatedChange,
|
||||
}: {
|
||||
onAuthenticatedChange?: (value: boolean) => void;
|
||||
}) => (
|
||||
<div>
|
||||
<div data-testid="room-server-panel" />
|
||||
<button type="button" onClick={() => onAuthenticatedChange?.(true)}>
|
||||
Authenticate room
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/MapView', () => ({
|
||||
MapView: () => <div data-testid="map-view" />,
|
||||
}));
|
||||
@@ -95,12 +111,20 @@ const message: Message = {
|
||||
sender_name: null,
|
||||
};
|
||||
|
||||
const rawPacketStatsSession: RawPacketStatsSessionState = {
|
||||
sessionStartedAt: 1_700_000_000_000,
|
||||
totalObservedPackets: 0,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [],
|
||||
};
|
||||
|
||||
function createProps(overrides: Partial<React.ComponentProps<typeof ConversationPane>> = {}) {
|
||||
return {
|
||||
activeConversation: null as Conversation | null,
|
||||
contacts: [] as Contact[],
|
||||
channels: [channel],
|
||||
rawPackets: [],
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
notificationsSupported: true,
|
||||
@@ -207,6 +231,54 @@ describe('ConversationPane', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('gates room chat behind room login controls until authenticated', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'contact',
|
||||
id: 'cc'.repeat(32),
|
||||
name: 'Ops Board',
|
||||
},
|
||||
contacts: [
|
||||
{
|
||||
public_key: 'cc'.repeat(32),
|
||||
name: 'Ops Board',
|
||||
type: 3,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('room-server-panel')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('chat-header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Authenticate room' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-input')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes unread marker props to MessageList only for channel conversations', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
|
||||
32
frontend/src/test/distanceUnits.test.ts
Normal file
32
frontend/src/test/distanceUnits.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
DISTANCE_UNIT_KEY,
|
||||
getSavedDistanceUnit,
|
||||
setSavedDistanceUnit,
|
||||
} from '../utils/distanceUnits';
|
||||
|
||||
describe('distanceUnits utilities', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults to metric when unset', () => {
|
||||
expect(getSavedDistanceUnit()).toBe('metric');
|
||||
});
|
||||
|
||||
it('returns the stored unit when valid', () => {
|
||||
localStorage.setItem(DISTANCE_UNIT_KEY, 'metric');
|
||||
expect(getSavedDistanceUnit()).toBe('metric');
|
||||
});
|
||||
|
||||
it('falls back to metric for invalid stored values', () => {
|
||||
localStorage.setItem(DISTANCE_UNIT_KEY, 'parsecs');
|
||||
expect(getSavedDistanceUnit()).toBe('metric');
|
||||
});
|
||||
|
||||
it('stores the selected distance unit', () => {
|
||||
setSavedDistanceUnit('smoots');
|
||||
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
|
||||
});
|
||||
});
|
||||
@@ -67,6 +67,29 @@ function renderSectionWithRefresh(
|
||||
);
|
||||
}
|
||||
|
||||
function startsWithAccessibleName(name: string) {
|
||||
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`^${escaped}(?:\\s|$)`);
|
||||
}
|
||||
|
||||
async function openCreateIntegrationDialog() {
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
return screen.findByRole('dialog', { name: 'Create Integration' });
|
||||
}
|
||||
|
||||
function selectCreateIntegration(name: string) {
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: startsWithAccessibleName(name) }));
|
||||
}
|
||||
|
||||
function confirmCreateIntegration() {
|
||||
const dialog = screen.getByRole('dialog', { name: 'Create Integration' });
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Create' }));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
@@ -76,35 +99,64 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe('SettingsFanoutSection', () => {
|
||||
it('shows add integration menu with all integration types', async () => {
|
||||
it('shows add integration dialog with all integration types', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
|
||||
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();
|
||||
const optionButtons = within(dialog)
|
||||
.getAllByRole('button')
|
||||
.filter((button) => button.hasAttribute('aria-pressed'));
|
||||
expect(optionButtons).toHaveLength(9);
|
||||
expect(within(dialog).getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('button', { name: 'Create' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('menuitem', { name: 'Community MQTT/meshcoretomqtt' })
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Private MQTT') })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Amazon SQS' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('MeshRank') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (US)') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('LetsMesh (EU)') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', {
|
||||
name: startsWithAccessibleName('Community MQTT/meshcoretomqtt'),
|
||||
})
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Webhook') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Apprise') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Amazon SQS') })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
|
||||
const genericCommunityIndex = optionButtons.findIndex((button) =>
|
||||
button.textContent?.startsWith('Community MQTT/meshcoretomqtt')
|
||||
);
|
||||
const meshRankIndex = optionButtons.findIndex((button) =>
|
||||
button.textContent?.startsWith('MeshRank')
|
||||
);
|
||||
expect(genericCommunityIndex).toBeGreaterThan(-1);
|
||||
expect(meshRankIndex).toBeGreaterThan(-1);
|
||||
expect(genericCommunityIndex).toBeLessThan(meshRankIndex);
|
||||
});
|
||||
|
||||
it('shows bot option in add integration menu when bots are enabled', async () => {
|
||||
it('shows bot option in add integration dialog when bots are enabled', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
expect(
|
||||
within(dialog).getByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows bots disabled banner when bots_disabled', async () => {
|
||||
@@ -114,14 +166,21 @@ describe('SettingsFanoutSection', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('hides bot option from add integration menu when bots_disabled', async () => {
|
||||
renderSection({ health: { ...baseHealth, bots_disabled: true } });
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
it('shows restart-scoped bots disabled messaging when disabled until restart', async () => {
|
||||
renderSection({
|
||||
health: { ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' },
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/disabled until the server restarts/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
expect(screen.queryByRole('menuitem', { name: 'Bot' })).not.toBeInTheDocument();
|
||||
it('hides bot option from add integration dialog when bots_disabled', async () => {
|
||||
renderSection({ health: { ...baseHealth, bots_disabled: true } });
|
||||
const dialog = await openCreateIntegrationDialog();
|
||||
expect(
|
||||
within(dialog).queryByRole('button', { name: startsWithAccessibleName('Python Bot') })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists existing configs after load', async () => {
|
||||
@@ -296,12 +355,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('navigates to create view when clicking add button', 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: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
||||
@@ -315,12 +371,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('new SQS draft shows queue url fields and sensible defaults', 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: 'Amazon SQS' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Amazon SQS');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
||||
@@ -332,12 +385,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
it('backing out of a new draft does not create an integration', 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: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByText('← Back to list'));
|
||||
@@ -411,12 +461,9 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValueOnce([]).mockResolvedValueOnce([createdWebhook]);
|
||||
|
||||
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: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save as Disabled' }));
|
||||
@@ -444,8 +491,9 @@ describe('SettingsFanoutSection', () => {
|
||||
renderSection();
|
||||
await waitFor(() => expect(screen.getByText('Test Hook')).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Webhook' }));
|
||||
await openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Webhook');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByLabelText('Name')).toHaveValue('Webhook #3'));
|
||||
});
|
||||
|
||||
@@ -647,21 +695,21 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([communityConfig]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText('Broker: mqtt-us-v1.letsmesh.net:443')).toBeInTheDocument()
|
||||
);
|
||||
expect(screen.getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
|
||||
const group = await screen.findByRole('group', { name: 'Integration Community Feed' });
|
||||
expect(
|
||||
within(group).getByText(
|
||||
(_, element) => element?.textContent === 'Broker: mqtt-us-v1.letsmesh.net:443'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(within(group).getByText('mesh2mqtt/{IATA}/node/{PUBLIC_KEY}')).toBeInTheDocument();
|
||||
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 openCreateIntegrationDialog();
|
||||
selectCreateIntegration('MeshRank');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
@@ -698,12 +746,9 @@ describe('SettingsFanoutSection', () => {
|
||||
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 openCreateIntegrationDialog();
|
||||
selectCreateIntegration('MeshRank');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Packet Topic Template'), {
|
||||
@@ -765,12 +810,9 @@ describe('SettingsFanoutSection', () => {
|
||||
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 openCreateIntegrationDialog();
|
||||
selectCreateIntegration('LetsMesh (US)');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('LetsMesh (US)');
|
||||
@@ -833,12 +875,9 @@ describe('SettingsFanoutSection', () => {
|
||||
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 openCreateIntegrationDialog();
|
||||
selectCreateIntegration('LetsMesh (EU)');
|
||||
confirmCreateIntegration();
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Email'), { target: { value: 'user@example.com' } });
|
||||
@@ -871,12 +910,9 @@ describe('SettingsFanoutSection', () => {
|
||||
|
||||
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 openCreateIntegrationDialog();
|
||||
selectCreateIntegration('Community MQTT/meshcoretomqtt');
|
||||
confirmCreateIntegration();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('← Back to list')).toBeInTheDocument());
|
||||
|
||||
@@ -900,9 +936,12 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([privateConfig]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('Broker: broker.local:1883')).toBeInTheDocument());
|
||||
const group = await screen.findByRole('group', { name: 'Integration Private Broker' });
|
||||
expect(
|
||||
screen.getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
|
||||
within(group).getByText((_, element) => element?.textContent === 'Broker: broker.local:1883')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
within(group).getByText('meshcore/dm:<pubkey>, meshcore/gm:<channel>, meshcore/raw/...')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -920,7 +959,8 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() => expect(screen.getByText('https://example.com/hook')).toBeInTheDocument());
|
||||
const group = await screen.findByRole('group', { name: 'Integration Webhook Feed' });
|
||||
expect(within(group).getByText('https://example.com/hook')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('apprise list shows compact target summary', async () => {
|
||||
@@ -941,9 +981,10 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)).toBeInTheDocument()
|
||||
);
|
||||
const group = await screen.findByRole('group', { name: 'Integration Apprise Feed' });
|
||||
expect(
|
||||
within(group).getByText(/discord:\/\/abc, mailto:\/\/one@example.com/)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sqs list shows queue url summary', async () => {
|
||||
@@ -963,11 +1004,10 @@ describe('SettingsFanoutSection', () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
|
||||
).toBeInTheDocument()
|
||||
);
|
||||
const group = await screen.findByRole('group', { name: 'Integration Queue Feed' });
|
||||
expect(
|
||||
within(group).getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('groups integrations by type and sorts entries alphabetically within each group', async () => {
|
||||
|
||||
@@ -167,17 +167,15 @@ describe('Integration: Duplicate Message Handling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regression)', () => {
|
||||
it('does not increment unread when a mesh echo arrives after many unique messages', () => {
|
||||
describe('Integration: Trimmed cache entries can reappear (hitlist #7 regression)', () => {
|
||||
it('increments unread when an evicted inactive-conversation message arrives again', () => {
|
||||
const state = createMockState();
|
||||
const convKey = 'channel_busy';
|
||||
|
||||
// Deliver 1001 unique messages — exceeding the old global
|
||||
// seenMessageContentRef prune threshold (1000→500). Under the old
|
||||
// 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
|
||||
// Cached messages remain the source of truth for inactive-conversation dedup.
|
||||
// Deliver enough unique messages to evict msg-0 from the inactive
|
||||
// conversation cache. Once it falls out of that window, a later arrival
|
||||
// with the same content should be allowed back in instead of being
|
||||
// suppressed forever by a stale content key.
|
||||
const MESSAGE_COUNT = 1001;
|
||||
for (let i = 0; i < MESSAGE_COUNT; i++) {
|
||||
const msg: Message = {
|
||||
@@ -219,9 +217,8 @@ describe('Integration: No phantom unreads from mesh echoes (hitlist #8 regressio
|
||||
};
|
||||
const result = handleMessageEvent(state, echo, 'other_active_conv');
|
||||
|
||||
// Must NOT increment unread — the echo is a duplicate
|
||||
expect(result.unreadIncremented).toBe(false);
|
||||
expect(state.unreadCounts[stateKey]).toBe(MESSAGE_COUNT);
|
||||
expect(result.unreadIncremented).toBe(true);
|
||||
expect(state.unreadCounts[stateKey]).toBe(MESSAGE_COUNT + 1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -214,6 +214,76 @@ describe('messageCache', () => {
|
||||
expect(entry!.messages.some((m) => m.id === 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('allows a trimmed-out message to be re-added after set() trimming', () => {
|
||||
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY + 1 }, (_, i) =>
|
||||
createMessage({
|
||||
id: i,
|
||||
text: `message-${i}`,
|
||||
received_at: 1700000000 + i,
|
||||
sender_timestamp: 1700000000 + i,
|
||||
})
|
||||
);
|
||||
|
||||
messageCache.set('conv1', createEntry(messages));
|
||||
|
||||
const trimmedOut = createMessage({
|
||||
id: 10_000,
|
||||
text: 'message-0',
|
||||
received_at: 1800000000,
|
||||
sender_timestamp: 1700000000,
|
||||
});
|
||||
|
||||
expect(messageCache.addMessage('conv1', trimmedOut)).toBe(true);
|
||||
const entry = messageCache.get('conv1');
|
||||
expect(entry!.messages.some((m) => m.id === 10_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('allows a trimmed-out message to be re-added after addMessage() trimming', () => {
|
||||
const messages = Array.from({ length: MAX_MESSAGES_PER_ENTRY - 1 }, (_, i) =>
|
||||
createMessage({
|
||||
id: i,
|
||||
text: `message-${i}`,
|
||||
received_at: 1700000000 + i,
|
||||
sender_timestamp: 1700000000 + i,
|
||||
})
|
||||
);
|
||||
messageCache.set('conv1', createEntry(messages));
|
||||
|
||||
expect(
|
||||
messageCache.addMessage(
|
||||
'conv1',
|
||||
createMessage({
|
||||
id: MAX_MESSAGES_PER_ENTRY,
|
||||
text: 'newest-a',
|
||||
received_at: 1800000000,
|
||||
sender_timestamp: 1800000000,
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
messageCache.addMessage(
|
||||
'conv1',
|
||||
createMessage({
|
||||
id: MAX_MESSAGES_PER_ENTRY + 1,
|
||||
text: 'newest-b',
|
||||
received_at: 1800000001,
|
||||
sender_timestamp: 1800000001,
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
|
||||
const readdedTrimmedMessage = createMessage({
|
||||
id: 10_001,
|
||||
text: 'message-0',
|
||||
received_at: 1900000000,
|
||||
sender_timestamp: 1700000000,
|
||||
});
|
||||
|
||||
expect(messageCache.addMessage('conv1', readdedTrimmedMessage)).toBe(true);
|
||||
const entry = messageCache.get('conv1');
|
||||
expect(entry!.messages.some((m) => m.id === 10_001)).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MessageList } from '../components/MessageList';
|
||||
import type { Message } from '../types';
|
||||
import { CONTACT_TYPE_ROOM, type Contact, type Message } from '../types';
|
||||
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
@@ -81,6 +81,46 @@ describe('MessageList channel sender rendering', () => {
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders room-server DM messages using stored sender attribution instead of the room contact', () => {
|
||||
const roomContact: Contact = {
|
||||
public_key: 'ab'.repeat(32),
|
||||
name: 'Ops Board',
|
||||
type: CONTACT_TYPE_ROOM,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
type: 'PRIV',
|
||||
conversation_key: roomContact.public_key,
|
||||
text: 'status update: ready',
|
||||
sender_name: 'Alice',
|
||||
sender_key: '12'.repeat(32),
|
||||
}),
|
||||
]}
|
||||
contacts={[roomContact]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Ops Board')).not.toBeInTheDocument();
|
||||
expect(screen.getByText('status update: ready')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives clickable sender avatars an accessible label', () => {
|
||||
render(
|
||||
<MessageList
|
||||
|
||||
@@ -10,7 +10,6 @@ import userEvent from '@testing-library/user-event';
|
||||
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)
|
||||
@@ -18,24 +17,6 @@ vi.mock('../components/ui/sonner', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
const mockContact: Contact = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
|
||||
const mockToast = toast as unknown as {
|
||||
success: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
@@ -43,7 +24,6 @@ const mockToast = toast as unknown as {
|
||||
|
||||
describe('NewMessageModal form reset', () => {
|
||||
const onClose = vi.fn();
|
||||
const onSelectConversation = vi.fn();
|
||||
const onCreateContact = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
|
||||
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
|
||||
@@ -56,10 +36,8 @@ describe('NewMessageModal form reset', () => {
|
||||
return render(
|
||||
<NewMessageModal
|
||||
open={open}
|
||||
contacts={[mockContact]}
|
||||
undecryptedCount={5}
|
||||
onClose={onClose}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onCreateContact={onCreateContact}
|
||||
onCreateChannel={onCreateChannel}
|
||||
onCreateHashtagChannel={onCreateHashtagChannel}
|
||||
@@ -75,7 +53,7 @@ describe('NewMessageModal form reset', () => {
|
||||
it('clears name after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { unmount } = renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
||||
await user.type(input, 'testchan');
|
||||
@@ -91,14 +69,14 @@ describe('NewMessageModal form reset', () => {
|
||||
|
||||
// Re-render to simulate reopening — state should be reset
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
|
||||
it('clears name when Cancel is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
const input = screen.getByPlaceholderText('channel-name') as HTMLInputElement;
|
||||
await user.type(input, 'mychannel');
|
||||
@@ -127,13 +105,13 @@ describe('NewMessageModal form reset', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('new-room tab', () => {
|
||||
describe('new-channel tab', () => {
|
||||
it('clears name and key after successful Create', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Room');
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
@@ -148,9 +126,9 @@ describe('NewMessageModal form reset', () => {
|
||||
const user = userEvent.setup();
|
||||
onCreateChannel.mockRejectedValueOnce(new Error('Bad key'));
|
||||
renderModal();
|
||||
await switchToTab(user, 'Room');
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Room name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Channel name'), 'MyRoom');
|
||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'cc'.repeat(16));
|
||||
await user.click(screen.getByRole('button', { name: 'Create' }));
|
||||
|
||||
@@ -164,7 +142,7 @@ describe('NewMessageModal form reset', () => {
|
||||
});
|
||||
|
||||
describe('tab switching resets form', () => {
|
||||
it('clears contact fields when switching to room tab', async () => {
|
||||
it('clears contact fields when switching to channel tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Contact');
|
||||
@@ -172,24 +150,24 @@ describe('NewMessageModal form reset', () => {
|
||||
await user.type(screen.getByPlaceholderText('Contact name'), 'Bob');
|
||||
await user.type(screen.getByPlaceholderText('64-character hex public key'), 'deadbeef');
|
||||
|
||||
// Switch to Room tab — fields should reset
|
||||
await switchToTab(user, 'Room');
|
||||
// Switch to Private Channel tab — fields should reset
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
expect((screen.getByPlaceholderText('Room name') as HTMLInputElement).value).toBe('');
|
||||
expect((screen.getByPlaceholderText('Channel name') as HTMLInputElement).value).toBe('');
|
||||
expect((screen.getByPlaceholderText('Pre-shared key (hex)') as HTMLInputElement).value).toBe(
|
||||
''
|
||||
);
|
||||
});
|
||||
|
||||
it('clears room fields when switching to hashtag tab', async () => {
|
||||
it('clears channel fields when switching to hashtag tab', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Room');
|
||||
await switchToTab(user, 'Private Channel');
|
||||
|
||||
await user.type(screen.getByPlaceholderText('Room name'), 'SecretRoom');
|
||||
await user.type(screen.getByPlaceholderText('Channel name'), 'SecretRoom');
|
||||
await user.type(screen.getByPlaceholderText('Pre-shared key (hex)'), 'ff'.repeat(16));
|
||||
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
expect((screen.getByPlaceholderText('channel-name') as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
@@ -199,7 +177,7 @@ describe('NewMessageModal form reset', () => {
|
||||
it('resets tryHistorical when switching tabs', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderModal();
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
// Check the "Try decrypting" checkbox
|
||||
const checkbox = screen.getByRole('checkbox', { name: /Try decrypting/ });
|
||||
@@ -210,7 +188,7 @@ describe('NewMessageModal form reset', () => {
|
||||
|
||||
// Switch tab and come back
|
||||
await switchToTab(user, 'Contact');
|
||||
await switchToTab(user, 'Hashtag');
|
||||
await switchToTab(user, 'Hashtag Channel');
|
||||
|
||||
// The streaming message should be gone (tryHistorical was reset)
|
||||
expect(screen.queryByText(/Messages will stream in/)).toBeNull();
|
||||
|
||||
@@ -685,22 +685,39 @@ describe('isValidLocation', () => {
|
||||
});
|
||||
|
||||
describe('formatDistance', () => {
|
||||
it('formats distances under 1km in meters', () => {
|
||||
expect(formatDistance(0.5)).toBe('500m');
|
||||
expect(formatDistance(0.123)).toBe('123m');
|
||||
expect(formatDistance(0.9999)).toBe('1000m');
|
||||
const formatInteger = (value: number) => value.toLocaleString();
|
||||
const formatOneDecimal = (value: number) =>
|
||||
value.toLocaleString(undefined, {
|
||||
minimumFractionDigits: 1,
|
||||
maximumFractionDigits: 1,
|
||||
});
|
||||
|
||||
it('defaults to imperial formatting', () => {
|
||||
expect(formatDistance(0.01)).toBe(`${formatInteger(33)}ft`);
|
||||
expect(formatDistance(0.5)).toBe(`${formatOneDecimal(0.5 * 0.621371)}mi`);
|
||||
expect(formatDistance(1)).toBe(`${formatOneDecimal(0.621371)}mi`);
|
||||
});
|
||||
|
||||
it('formats distances at or above 1km with one decimal', () => {
|
||||
expect(formatDistance(1)).toBe('1.0km');
|
||||
expect(formatDistance(1.5)).toBe('1.5km');
|
||||
expect(formatDistance(12.34)).toBe('12.3km');
|
||||
expect(formatDistance(100)).toBe('100.0km');
|
||||
it('formats metric distances in meters and kilometers', () => {
|
||||
expect(formatDistance(0.5, 'metric')).toBe(`${formatInteger(500)}m`);
|
||||
expect(formatDistance(0.123, 'metric')).toBe(`${formatInteger(123)}m`);
|
||||
expect(formatDistance(0.9999, 'metric')).toBe(`${formatInteger(1000)}m`);
|
||||
expect(formatDistance(1, 'metric')).toBe(`${formatOneDecimal(1)}km`);
|
||||
expect(formatDistance(12.34, 'metric')).toBe(`${formatOneDecimal(12.34)}km`);
|
||||
});
|
||||
|
||||
it('rounds meters to nearest integer', () => {
|
||||
expect(formatDistance(0.4567)).toBe('457m');
|
||||
expect(formatDistance(0.001)).toBe('1m');
|
||||
it('formats smoot distances using 1.7018 meters per smoot', () => {
|
||||
expect(formatDistance(0.0017018, 'smoots')).toBe(`${formatOneDecimal(1)} smoot`);
|
||||
expect(formatDistance(0.001, 'smoots')).toBe(`${formatOneDecimal(0.6)} smoots`);
|
||||
expect(formatDistance(1, 'smoots')).toBe(`${formatInteger(588)} smoots`);
|
||||
});
|
||||
|
||||
it('applies locale separators to large values', () => {
|
||||
expect(formatDistance(1.234, 'metric')).toBe(`${formatOneDecimal(1.234)}km`);
|
||||
expect(formatDistance(1234, 'metric')).toBe(`${formatOneDecimal(1234)}km`);
|
||||
expect(formatDistance(2.1, 'smoots')).toContain(
|
||||
formatInteger(Math.round((2.1 * 1000) / 1.7018))
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
78
frontend/src/test/rawPacketDetailModal.test.tsx
Normal file
78
frontend/src/test/rawPacketDetailModal.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RawPacketDetailModal } from '../components/RawPacketDetailModal';
|
||||
import type { Channel, RawPacket } from '../types';
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { toast } = await import('../components/ui/sonner');
|
||||
const mockToast = toast as unknown as {
|
||||
success: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
const BOT_CHANNEL: Channel = {
|
||||
key: 'eb50a1bcb3e4e5d7bf69a57c9dada211',
|
||||
name: '#bot',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
|
||||
const BOT_PACKET: RawPacket = {
|
||||
id: 1,
|
||||
observation_id: 10,
|
||||
timestamp: 1_700_000_000,
|
||||
data: '15833fa002860ccae0eed9ca78b9ab0775d477c1f6490a398bf4edc75240',
|
||||
decrypted: false,
|
||||
payload_type: 'GroupText',
|
||||
rssi: -72,
|
||||
snr: 5.5,
|
||||
decrypted_info: null,
|
||||
};
|
||||
|
||||
describe('RawPacketDetailModal', () => {
|
||||
it('copies the full packet hex to the clipboard', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.assign(navigator, {
|
||||
clipboard: { writeText },
|
||||
});
|
||||
|
||||
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy' }));
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(BOT_PACKET.data);
|
||||
expect(mockToast.success).toHaveBeenCalledWith('Packet hex copied!');
|
||||
});
|
||||
|
||||
it('renders path hops as nowrap arrow-delimited groups and links hover state to the full packet hex', () => {
|
||||
render(<RawPacketDetailModal packet={BOT_PACKET} channels={[BOT_CHANNEL]} onClose={vi.fn()} />);
|
||||
|
||||
const pathDescription = screen.getByText(
|
||||
'Historical route taken (3-byte hashes added as packet floods through network)'
|
||||
);
|
||||
const pathFieldBox = pathDescription.closest('[class*="rounded-lg"]');
|
||||
expect(pathFieldBox).not.toBeNull();
|
||||
|
||||
const pathField = within(pathFieldBox as HTMLElement);
|
||||
expect(pathField.getByText('3FA002 →')).toHaveClass('whitespace-nowrap');
|
||||
expect(pathField.getByText('860CCA →')).toHaveClass('whitespace-nowrap');
|
||||
expect(pathField.getByText('E0EED9')).toHaveClass('whitespace-nowrap');
|
||||
|
||||
const pathRun = screen.getByText('3F A0 02 86 0C CA E0 EE D9');
|
||||
const idleClassName = pathRun.className;
|
||||
|
||||
fireEvent.mouseEnter(pathFieldBox as HTMLElement);
|
||||
expect(pathRun.className).not.toBe(idleClassName);
|
||||
|
||||
fireEvent.mouseLeave(pathFieldBox as HTMLElement);
|
||||
expect(pathRun.className).toBe(idleClassName);
|
||||
});
|
||||
});
|
||||
419
frontend/src/test/rawPacketFeedView.test.tsx
Normal file
419
frontend/src/test/rawPacketFeedView.test.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RawPacketFeedView } from '../components/RawPacketFeedView';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
import type { Channel, Contact, RawPacket } from '../types';
|
||||
|
||||
const GROUP_TEXT_PACKET_HEX =
|
||||
'1500E69C7A89DD0AF6A2D69F5823B88F9720731E4B887C56932BF889255D8D926D99195927144323A42DD8A158F878B518B8304DF55E80501C7D02A9FFD578D3518283156BBA257BF8413E80A237393B2E4149BBBC864371140A9BBC4E23EB9BF203EF0D029214B3E3AAC3C0295690ACDB89A28619E7E5F22C83E16073AD679D25FA904D07E5ACF1DB5A7C77D7E1719FB9AE5BF55541EE0D7F59ED890E12CF0FEED6700818';
|
||||
|
||||
const TEST_CHANNEL: Channel = {
|
||||
key: '7ABA109EDCF304A84433CB71D0F3AB73',
|
||||
name: '#six77',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
|
||||
const COLLIDING_TEST_CHANNEL: Channel = {
|
||||
...TEST_CHANNEL,
|
||||
name: '#collision',
|
||||
};
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<RawPacketStatsSessionState> = {}
|
||||
): RawPacketStatsSessionState {
|
||||
return {
|
||||
sessionStartedAt: 1_700_000_000_000,
|
||||
totalObservedPackets: 3,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_700_000_000,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'AA11',
|
||||
sourceLabel: 'AA11',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-2',
|
||||
timestamp: 1_700_000_030,
|
||||
payloadType: 'TextMessage',
|
||||
routeType: 'Direct',
|
||||
decrypted: true,
|
||||
rssi: -66,
|
||||
snr: 7,
|
||||
sourceKey: 'BB22',
|
||||
sourceLabel: 'BB22',
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-3',
|
||||
timestamp: 1_700_000_050,
|
||||
payloadType: 'Ack',
|
||||
routeType: 'Direct',
|
||||
decrypted: true,
|
||||
rssi: -80,
|
||||
snr: 4,
|
||||
sourceKey: 'BB22',
|
||||
sourceLabel: 'BB22',
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
return {
|
||||
public_key: 'aa11bb22cc33' + '0'.repeat(52),
|
||||
name: 'Alpha',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: 0,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: 1_700_000_000,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderView({
|
||||
packets = [],
|
||||
contacts = [],
|
||||
channels = [],
|
||||
rawPacketStatsSession = createSession(),
|
||||
}: {
|
||||
packets?: RawPacket[];
|
||||
contacts?: Contact[];
|
||||
channels?: Channel[];
|
||||
rawPacketStatsSession?: RawPacketStatsSessionState;
|
||||
} = {}) {
|
||||
return render(
|
||||
<RawPacketFeedView
|
||||
packets={packets}
|
||||
rawPacketStatsSession={rawPacketStatsSession}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('RawPacketFeedView', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('opens a stats drawer with window controls and grouped summaries', () => {
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText('Raw Packet Feed')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Packet Types')).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
|
||||
expect(screen.getByLabelText('Stats window')).toBeInTheDocument();
|
||||
expect(screen.getByText('Packet Types')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hop Byte Width')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most-Heard Neighbors')).toBeInTheDocument();
|
||||
expect(screen.getByText('Traffic Timeline')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('analyzes a pasted raw packet without adding it to the live feed', () => {
|
||||
renderView({ channels: [TEST_CHANNEL] });
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Analyze Packet' }));
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Analyze Packet' })).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Packet Hex'), {
|
||||
target: { value: GROUP_TEXT_PACKET_HEX },
|
||||
});
|
||||
|
||||
expect(screen.getByText('Full packet hex')).toBeInTheDocument();
|
||||
expect(screen.getByText('Packet fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('Payload fields')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows stats by default on desktop', () => {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
vi.fn().mockImplementation((query: string) => ({
|
||||
matches: query === '(min-width: 768px)',
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}))
|
||||
);
|
||||
|
||||
renderView();
|
||||
|
||||
expect(screen.getByText('Packet Types')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hop Byte Width')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /hide stats/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('refreshes coverage when packet or session props update without counter deltas', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2024-01-01T00:00:30Z'));
|
||||
|
||||
const initialPackets: RawPacket[] = [];
|
||||
const nextPackets: RawPacket[] = [
|
||||
{
|
||||
id: 1,
|
||||
timestamp: 1_704_067_255,
|
||||
data: '00',
|
||||
decrypted: false,
|
||||
payload_type: 'Unknown',
|
||||
rssi: null,
|
||||
snr: null,
|
||||
observation_id: 1,
|
||||
decrypted_info: null,
|
||||
},
|
||||
];
|
||||
const initialSession = createSession({
|
||||
sessionStartedAt: Date.parse('2024-01-01T00:00:00Z'),
|
||||
totalObservedPackets: 10,
|
||||
trimmedObservationCount: 1,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_704_067_220,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'AA11',
|
||||
sourceLabel: 'AA11',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = renderView({
|
||||
packets: initialPackets,
|
||||
rawPacketStatsSession: initialSession,
|
||||
contacts: [],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: '1m' } });
|
||||
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
|
||||
|
||||
vi.setSystemTime(new Date('2024-01-01T00:01:10Z'));
|
||||
rerender(
|
||||
<RawPacketFeedView
|
||||
packets={nextPackets}
|
||||
rawPacketStatsSession={initialSession}
|
||||
contacts={[]}
|
||||
channels={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/only covered for 50 sec/i)).toBeInTheDocument();
|
||||
|
||||
vi.setSystemTime(new Date('2024-01-01T00:01:30Z'));
|
||||
const nextSession = {
|
||||
...initialSession,
|
||||
sessionStartedAt: Date.parse('2024-01-01T00:01:00Z'),
|
||||
observations: [
|
||||
{
|
||||
...initialSession.observations[0],
|
||||
timestamp: 1_704_067_280,
|
||||
},
|
||||
],
|
||||
};
|
||||
rerender(
|
||||
<RawPacketFeedView
|
||||
packets={nextPackets}
|
||||
rawPacketStatsSession={nextSession}
|
||||
contacts={[]}
|
||||
channels={[]}
|
||||
/>
|
||||
);
|
||||
expect(screen.getByText(/only covered for 10 sec/i)).toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('resolves neighbor labels from matching contacts when identity is available', () => {
|
||||
renderView({
|
||||
rawPacketStatsSession: createSession({
|
||||
totalObservedPackets: 1,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_700_000_000,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'AA11BB22CC33',
|
||||
sourceLabel: 'AA11BB22CC33',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
],
|
||||
}),
|
||||
contacts: [createContact()],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
||||
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('marks unresolved neighbor identities explicitly', () => {
|
||||
renderView({
|
||||
rawPacketStatsSession: createSession({
|
||||
totalObservedPackets: 1,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_700_000_000,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'DEADBEEF1234',
|
||||
sourceLabel: 'DEADBEEF1234',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '01',
|
||||
},
|
||||
],
|
||||
}),
|
||||
contacts: [],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
||||
expect(screen.getAllByText('Identity not resolvable').length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('collapses uniquely resolved hash buckets into the same visible contact row', () => {
|
||||
const alphaContact = createContact({
|
||||
public_key: 'aa11bb22cc33' + '0'.repeat(52),
|
||||
name: 'Alpha',
|
||||
});
|
||||
|
||||
renderView({
|
||||
rawPacketStatsSession: createSession({
|
||||
totalObservedPackets: 2,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 1_700_000_000,
|
||||
payloadType: 'TextMessage',
|
||||
routeType: 'Direct',
|
||||
decrypted: true,
|
||||
rssi: -70,
|
||||
snr: 6,
|
||||
sourceKey: 'hash1:AA',
|
||||
sourceLabel: 'AA',
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-2',
|
||||
timestamp: 1_700_000_030,
|
||||
payloadType: 'TextMessage',
|
||||
routeType: 'Direct',
|
||||
decrypted: true,
|
||||
rssi: -67,
|
||||
snr: 7,
|
||||
sourceKey: alphaContact.public_key.toUpperCase(),
|
||||
sourceLabel: alphaContact.public_key.slice(0, 12).toUpperCase(),
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
contacts: [alphaContact],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /show stats/i }));
|
||||
fireEvent.change(screen.getByLabelText('Stats window'), { target: { value: 'session' } });
|
||||
|
||||
expect(screen.getAllByText('Alpha').length).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Identity not resolvable')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens a packet detail modal from the raw feed and decrypts channel messages when a key is loaded', () => {
|
||||
renderView({
|
||||
packets: [
|
||||
{
|
||||
id: 1,
|
||||
observation_id: 10,
|
||||
timestamp: 1_700_000_000,
|
||||
data: GROUP_TEXT_PACKET_HEX,
|
||||
decrypted: false,
|
||||
payload_type: 'GroupText',
|
||||
rssi: -72,
|
||||
snr: 5.5,
|
||||
decrypted_info: null,
|
||||
},
|
||||
],
|
||||
channels: [TEST_CHANNEL],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i }));
|
||||
|
||||
expect(screen.getByText('Packet Details')).toBeInTheDocument();
|
||||
expect(screen.getByText('Payload fields')).toBeInTheDocument();
|
||||
expect(screen.getByText('Full packet hex')).toBeInTheDocument();
|
||||
expect(screen.getByText('#six77')).toBeInTheDocument();
|
||||
expect(screen.getByText(/bytes · decrypted/i)).toBeInTheDocument();
|
||||
expect(screen.getAllByText(/sender: flightless/i).length).toBeGreaterThan(0);
|
||||
expect(
|
||||
screen.getByText(/hello there; this hashtag room is essentially public/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not guess a channel name when multiple loaded channels collide on the group hash', () => {
|
||||
renderView({
|
||||
packets: [
|
||||
{
|
||||
id: 1,
|
||||
observation_id: 10,
|
||||
timestamp: 1_700_000_000,
|
||||
data: GROUP_TEXT_PACKET_HEX,
|
||||
decrypted: false,
|
||||
payload_type: 'GroupText',
|
||||
rssi: -72,
|
||||
snr: 5.5,
|
||||
decrypted_info: null,
|
||||
},
|
||||
],
|
||||
channels: [TEST_CHANNEL, COLLIDING_TEST_CHANNEL],
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /gt from flightless/i }));
|
||||
|
||||
expect(screen.getByText(/channel hash e6/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText('#six77')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('#collision')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
28
frontend/src/test/rawPacketInspector.test.ts
Normal file
28
frontend/src/test/rawPacketInspector.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import { describeCiphertextStructure, formatHexByHop } from '../utils/rawPacketInspector';
|
||||
|
||||
describe('rawPacketInspector helpers', () => {
|
||||
it('formats path hex as hop-delimited groups', () => {
|
||||
expect(formatHexByHop('A1B2C3D4E5F6', 2)).toBe('A1B2 → C3D4 → E5F6');
|
||||
expect(formatHexByHop('AABBCC', 1)).toBe('AA → BB → CC');
|
||||
});
|
||||
|
||||
it('leaves non-hop-aligned hex unchanged', () => {
|
||||
expect(formatHexByHop('A1B2C3', 2)).toBe('A1B2C3');
|
||||
expect(formatHexByHop('A1B2', null)).toBe('A1B2');
|
||||
});
|
||||
|
||||
it('describes undecryptable ciphertext with multiline bullets', () => {
|
||||
expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain(
|
||||
'\n• Timestamp (4 bytes)'
|
||||
);
|
||||
expect(describeCiphertextStructure(PayloadType.GroupText, 9, 'fallback')).toContain(
|
||||
'\n• Flags (1 byte)'
|
||||
);
|
||||
expect(describeCiphertextStructure(PayloadType.TextMessage, 12, 'fallback')).toContain(
|
||||
'\n• Message (remaining bytes)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { RawPacketList } from '../components/RawPacketList';
|
||||
import type { RawPacket } from '../types';
|
||||
@@ -23,5 +23,17 @@ describe('RawPacketList', () => {
|
||||
render(<RawPacketList packets={[createPacket()]} />);
|
||||
|
||||
expect(screen.getByText('TF')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('makes packet cards clickable only when an inspector handler is provided', () => {
|
||||
const packet = createPacket({ id: 9, observation_id: 22 });
|
||||
const onPacketClick = vi.fn();
|
||||
|
||||
render(<RawPacketList packets={[packet]} onPacketClick={onPacketClick} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button'));
|
||||
|
||||
expect(onPacketClick).toHaveBeenCalledWith(packet);
|
||||
});
|
||||
});
|
||||
|
||||
184
frontend/src/test/rawPacketStats.test.ts
Normal file
184
frontend/src/test/rawPacketStats.test.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildRawPacketStatsSnapshot,
|
||||
summarizeRawPacketForStats,
|
||||
type RawPacketStatsSessionState,
|
||||
} from '../utils/rawPacketStats';
|
||||
import type { RawPacket } from '../types';
|
||||
|
||||
const TEXT_MESSAGE_PACKET = '09046F17C47ED00A13E16AB5B94B1CC2D1A5059C6E5A6253C60D';
|
||||
|
||||
function createSession(
|
||||
overrides: Partial<RawPacketStatsSessionState> = {}
|
||||
): RawPacketStatsSessionState {
|
||||
return {
|
||||
sessionStartedAt: 700_000,
|
||||
totalObservedPackets: 4,
|
||||
trimmedObservationCount: 0,
|
||||
observations: [
|
||||
{
|
||||
observationKey: 'obs-1',
|
||||
timestamp: 850,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -68,
|
||||
snr: 7,
|
||||
sourceKey: 'AA11',
|
||||
sourceLabel: 'AA11',
|
||||
pathTokenCount: 2,
|
||||
pathSignature: '01>02',
|
||||
hopByteWidth: 1,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-2',
|
||||
timestamp: 910,
|
||||
payloadType: 'TextMessage',
|
||||
routeType: 'Direct',
|
||||
decrypted: true,
|
||||
rssi: -74,
|
||||
snr: 5,
|
||||
sourceKey: 'BB22',
|
||||
sourceLabel: 'BB22',
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
hopByteWidth: null,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-3',
|
||||
timestamp: 960,
|
||||
payloadType: 'Advert',
|
||||
routeType: 'Flood',
|
||||
decrypted: false,
|
||||
rssi: -64,
|
||||
snr: 8,
|
||||
sourceKey: 'AA11',
|
||||
sourceLabel: 'AA11',
|
||||
pathTokenCount: 1,
|
||||
pathSignature: '02',
|
||||
hopByteWidth: 2,
|
||||
},
|
||||
{
|
||||
observationKey: 'obs-4',
|
||||
timestamp: 990,
|
||||
payloadType: 'Ack',
|
||||
routeType: 'Direct',
|
||||
decrypted: true,
|
||||
rssi: -88,
|
||||
snr: 3,
|
||||
sourceKey: null,
|
||||
sourceLabel: null,
|
||||
pathTokenCount: 0,
|
||||
pathSignature: null,
|
||||
hopByteWidth: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildRawPacketStatsSnapshot', () => {
|
||||
it('prefers decrypted contact identity over one-byte sourceHash for stats bucketing', () => {
|
||||
const packet: RawPacket = {
|
||||
id: 1,
|
||||
observation_id: 10,
|
||||
timestamp: 1_700_000_000,
|
||||
data: TEXT_MESSAGE_PACKET,
|
||||
payload_type: 'TextMessage',
|
||||
snr: 4,
|
||||
rssi: -72,
|
||||
decrypted: true,
|
||||
decrypted_info: {
|
||||
channel_name: null,
|
||||
sender: 'Alpha',
|
||||
channel_key: null,
|
||||
contact_key: '0a'.repeat(32),
|
||||
},
|
||||
};
|
||||
|
||||
const summary = summarizeRawPacketForStats(packet);
|
||||
|
||||
expect(summary.sourceKey).toBe('0A'.repeat(32));
|
||||
expect(summary.sourceLabel).toBe('Alpha');
|
||||
});
|
||||
|
||||
it('tags unresolved one-byte source hashes so they do not collide with full contact keys', () => {
|
||||
const packet: RawPacket = {
|
||||
id: 2,
|
||||
observation_id: 11,
|
||||
timestamp: 1_700_000_000,
|
||||
data: TEXT_MESSAGE_PACKET,
|
||||
payload_type: 'TextMessage',
|
||||
snr: 4,
|
||||
rssi: -72,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
};
|
||||
|
||||
const summary = summarizeRawPacketForStats(packet);
|
||||
|
||||
expect(summary.sourceKey).toBe('hash1:0A');
|
||||
expect(summary.sourceLabel).toBe('0A');
|
||||
});
|
||||
|
||||
it('computes counts, rankings, and rolling-window coverage from session observations', () => {
|
||||
const stats = buildRawPacketStatsSnapshot(createSession(), '5m', 1_000);
|
||||
|
||||
expect(stats.packetCount).toBe(4);
|
||||
expect(stats.uniqueSources).toBe(2);
|
||||
expect(stats.pathBearingCount).toBe(2);
|
||||
expect(stats.payloadBreakdown.slice(0, 3).map((item) => item.label)).toEqual([
|
||||
'Advert',
|
||||
'Ack',
|
||||
'TextMessage',
|
||||
]);
|
||||
expect(stats.payloadBreakdown).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'GroupText', count: 0 }),
|
||||
expect.objectContaining({ label: 'Control', count: 0 }),
|
||||
])
|
||||
);
|
||||
expect(stats.hopProfile.map((item) => item.label)).toEqual([
|
||||
'0',
|
||||
'1',
|
||||
'2-5',
|
||||
'6-10',
|
||||
'11-15',
|
||||
'16+',
|
||||
]);
|
||||
expect(stats.hopProfile).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: '0', count: 2 }),
|
||||
expect.objectContaining({ label: '1', count: 1 }),
|
||||
expect.objectContaining({ label: '2-5', count: 1 }),
|
||||
expect.objectContaining({ label: '6-10', count: 0 }),
|
||||
expect.objectContaining({ label: '11-15', count: 0 }),
|
||||
expect.objectContaining({ label: '16+', count: 0 }),
|
||||
])
|
||||
);
|
||||
expect(stats.hopByteWidthProfile).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'No path', count: 2 }),
|
||||
expect.objectContaining({ label: '1 byte / hop', count: 1 }),
|
||||
expect.objectContaining({ label: '2 bytes / hop', count: 1 }),
|
||||
])
|
||||
);
|
||||
expect(stats.strongestNeighbors[0]).toMatchObject({ label: 'AA11', bestRssi: -64 });
|
||||
expect(stats.mostActiveNeighbors[0]).toMatchObject({ label: 'AA11', count: 2 });
|
||||
expect(stats.windowFullyCovered).toBe(true);
|
||||
});
|
||||
|
||||
it('flags incomplete session coverage when detailed history has been trimmed', () => {
|
||||
const stats = buildRawPacketStatsSnapshot(
|
||||
createSession({
|
||||
trimmedObservationCount: 25,
|
||||
}),
|
||||
'session',
|
||||
1_000
|
||||
);
|
||||
|
||||
expect(stats.windowFullyCovered).toBe(false);
|
||||
expect(stats.packetCount).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,10 @@ describe('RepeaterLogin', () => {
|
||||
repeaterName: 'TestRepeater',
|
||||
loading: false,
|
||||
error: null as string | null,
|
||||
password: '',
|
||||
onPasswordChange: vi.fn(),
|
||||
rememberPassword: false,
|
||||
onRememberPasswordChange: vi.fn(),
|
||||
onLogin: vi.fn(),
|
||||
onLoginAsGuest: vi.fn(),
|
||||
};
|
||||
@@ -26,20 +30,45 @@ describe('RepeaterLogin', () => {
|
||||
render(<RepeaterLogin {...defaultProps} />);
|
||||
|
||||
expect(screen.getByPlaceholderText('Repeater password...')).toBeInTheDocument();
|
||||
expect(screen.getByText('Remember password')).toBeInTheDocument();
|
||||
expect(screen.getByText('Login with Password')).toBeInTheDocument();
|
||||
expect(screen.getByText('Login as Guest / ACLs')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onLogin with trimmed password on submit', () => {
|
||||
render(<RepeaterLogin {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Repeater password...');
|
||||
fireEvent.change(input, { target: { value: ' secret ' } });
|
||||
render(<RepeaterLogin {...defaultProps} password=" secret " />);
|
||||
fireEvent.submit(screen.getByText('Login with Password').closest('form')!);
|
||||
|
||||
expect(defaultProps.onLogin).toHaveBeenCalledWith('secret');
|
||||
});
|
||||
|
||||
it('propagates password changes', () => {
|
||||
render(<RepeaterLogin {...defaultProps} />);
|
||||
|
||||
const input = screen.getByPlaceholderText('Repeater password...');
|
||||
fireEvent.change(input, { target: { value: 'new secret' } });
|
||||
|
||||
expect(defaultProps.onPasswordChange).toHaveBeenCalledWith('new secret');
|
||||
});
|
||||
|
||||
it('toggles remember password checkbox', () => {
|
||||
render(<RepeaterLogin {...defaultProps} />);
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Remember password'));
|
||||
|
||||
expect(defaultProps.onRememberPasswordChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('shows storage warning when remember password is enabled', () => {
|
||||
render(<RepeaterLogin {...defaultProps} rememberPassword={true} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Passwords are stored unencrypted in local browser storage for this domain\./
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onLoginAsGuest when guest button clicked', () => {
|
||||
render(<RepeaterLogin {...defaultProps} />);
|
||||
|
||||
|
||||
77
frontend/src/test/roomServerPanel.test.tsx
Normal file
77
frontend/src/test/roomServerPanel.test.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
import { RoomServerPanel } from '../components/RoomServerPanel';
|
||||
import type { Contact } from '../types';
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
roomLogin: vi.fn(),
|
||||
roomStatus: vi.fn(),
|
||||
roomAcl: vi.fn(),
|
||||
roomLppTelemetry: vi.fn(),
|
||||
sendRepeaterCommand: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: Object.assign(vi.fn(), {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const { api: _rawApi } = await import('../api');
|
||||
const mockApi = _rawApi as unknown as Record<string, Mock>;
|
||||
const { toast } = await import('../components/ui/sonner');
|
||||
const mockToast = toast as unknown as Record<string, Mock>;
|
||||
|
||||
const roomContact: Contact = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Ops Board',
|
||||
type: 3,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
|
||||
describe('RoomServerPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('keeps room controls available when login is not confirmed', async () => {
|
||||
mockApi.roomLogin.mockResolvedValueOnce({
|
||||
status: 'timeout',
|
||||
authenticated: false,
|
||||
message:
|
||||
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
|
||||
});
|
||||
const onAuthenticatedChange = vi.fn();
|
||||
|
||||
render(<RoomServerPanel contact={roomContact} onAuthenticatedChange={onAuthenticatedChange} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Login with ACL / Guest'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('Show Tools')).toBeInTheDocument();
|
||||
expect(mockToast.warning).toHaveBeenCalledWith('Room login not confirmed', {
|
||||
description:
|
||||
'No login confirmation was heard from the room server. The control panel is still available; try logging in again if authenticated actions fail.',
|
||||
});
|
||||
expect(onAuthenticatedChange).toHaveBeenLastCalledWith(true);
|
||||
});
|
||||
});
|
||||
119
frontend/src/test/securityWarningModal.test.tsx
Normal file
119
frontend/src/test/securityWarningModal.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
disableBotsUntilRestart: vi.fn(),
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
disableBotsUntilRestart: mocks.disableBotsUntilRestart,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: mocks.toast,
|
||||
}));
|
||||
|
||||
import { SecurityWarningModal } from '../components/SecurityWarningModal';
|
||||
import type { HealthStatus } from '../types';
|
||||
|
||||
const baseHealth: HealthStatus = {
|
||||
status: 'degraded',
|
||||
radio_connected: false,
|
||||
radio_initializing: false,
|
||||
connection_info: null,
|
||||
database_size_mb: 1.2,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
fanout_statuses: {},
|
||||
bots_disabled: false,
|
||||
basic_auth_enabled: false,
|
||||
};
|
||||
|
||||
describe('SecurityWarningModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
mocks.disableBotsUntilRestart.mockResolvedValue({
|
||||
status: 'ok',
|
||||
bots_disabled: true,
|
||||
bots_disabled_source: 'until_restart',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows the warning when bots are enabled and basic auth is off', () => {
|
||||
render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Do Not Warn Me On This Device Again' })
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not show when bots are disabled', () => {
|
||||
render(<SecurityWarningModal health={{ ...baseHealth, bots_disabled: true }} />);
|
||||
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show when basic auth is enabled', () => {
|
||||
render(<SecurityWarningModal health={{ ...baseHealth, basic_auth_enabled: true }} />);
|
||||
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('persists dismissal only after the checkbox is acknowledged', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
const dismissButton = screen.getByRole('button', {
|
||||
name: 'Do Not Warn Me On This Device Again',
|
||||
});
|
||||
await user.click(screen.getByLabelText('Acknowledge bot security risk'));
|
||||
expect(dismissButton).toBeEnabled();
|
||||
|
||||
await user.click(dismissButton);
|
||||
|
||||
expect(window.localStorage.getItem('meshcore_security_warning_acknowledged')).toBe('true');
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables bots until restart from the warning modal', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
|
||||
|
||||
expect(mocks.disableBotsUntilRestart).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.toast.success).toHaveBeenCalledWith('Bots disabled until restart');
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the warning again after temporary bot disable disappears on a later health update', async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = render(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Disable Bots Until Server Restart' }));
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
|
||||
rerender(
|
||||
<SecurityWarningModal
|
||||
health={{ ...baseHealth, bots_disabled: true, bots_disabled_source: 'until_restart' }}
|
||||
/>
|
||||
);
|
||||
expect(screen.queryByText('Unprotected bot execution is enabled')).not.toBeInTheDocument();
|
||||
|
||||
rerender(<SecurityWarningModal health={baseHealth} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Unprotected bot execution is enabled')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SettingsModal } from '../components/SettingsModal';
|
||||
import type {
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
REOPEN_LAST_CONVERSATION_KEY,
|
||||
} from '../utils/lastViewedConversation';
|
||||
import { api } from '../api';
|
||||
import { DISTANCE_UNIT_KEY } from '../utils/distanceUnits';
|
||||
|
||||
const baseConfig: RadioConfig = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
@@ -36,6 +37,7 @@ const baseConfig: RadioConfig = {
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: false,
|
||||
advert_location_source: 'current',
|
||||
multi_acks_enabled: false,
|
||||
};
|
||||
|
||||
const baseHealth: HealthStatus = {
|
||||
@@ -176,6 +178,10 @@ function openDatabaseSection() {
|
||||
}
|
||||
|
||||
describe('SettingsModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, 'getFanoutConfigs').mockResolvedValue([]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
localStorage.clear();
|
||||
@@ -327,6 +333,18 @@ describe('SettingsModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('saves multi-acks through radio config save', async () => {
|
||||
const { onSave } = renderModal();
|
||||
openRadioSection();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Extra Direct ACK Transmission'));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ multi_acks_enabled: true }));
|
||||
});
|
||||
});
|
||||
|
||||
it('saves changed max contacts value through onSaveAppSettings', async () => {
|
||||
const { onSaveAppSettings } = renderModal();
|
||||
openRadioSection();
|
||||
@@ -364,17 +382,21 @@ describe('SettingsModal', () => {
|
||||
desktopSection: 'fanout',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(api.getFanoutConfigs).toHaveBeenCalled();
|
||||
});
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /Local Configuration/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not clip the fanout add-integration menu in external desktop mode', () => {
|
||||
it('does not clip the fanout add-integration menu in external desktop mode', async () => {
|
||||
renderModal({
|
||||
externalSidebarNav: true,
|
||||
desktopSection: 'fanout',
|
||||
});
|
||||
|
||||
const addIntegrationButton = screen.getByRole('button', { name: 'Add Integration' });
|
||||
const addIntegrationButton = await screen.findByRole('button', { name: 'Add Integration' });
|
||||
const wrapperSection = addIntegrationButton.closest('section');
|
||||
expect(wrapperSection).not.toHaveClass('overflow-hidden');
|
||||
});
|
||||
@@ -427,30 +449,35 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByText('Save failed')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
view.rerender(
|
||||
<SettingsModal
|
||||
open
|
||||
externalSidebarNav
|
||||
desktopSection="fanout"
|
||||
config={baseConfig}
|
||||
health={baseHealth}
|
||||
appSettings={baseSettings}
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn(async () => {})}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onSetPrivateKey={vi.fn(async () => {})}
|
||||
onReboot={vi.fn(async () => {})}
|
||||
onDisconnect={vi.fn(async () => {})}
|
||||
onReconnect={vi.fn(async () => {})}
|
||||
onAdvertise={vi.fn(async () => {})}
|
||||
meshDiscovery={null}
|
||||
meshDiscoveryLoadingTarget={null}
|
||||
onDiscoverMesh={vi.fn(async () => {})}
|
||||
onHealthRefresh={vi.fn(async () => {})}
|
||||
onRefreshAppSettings={vi.fn(async () => {})}
|
||||
/>
|
||||
);
|
||||
await act(async () => {
|
||||
view.rerender(
|
||||
<SettingsModal
|
||||
open
|
||||
externalSidebarNav
|
||||
desktopSection="fanout"
|
||||
config={baseConfig}
|
||||
health={baseHealth}
|
||||
appSettings={baseSettings}
|
||||
onClose={vi.fn()}
|
||||
onSave={vi.fn(async () => {})}
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onSetPrivateKey={vi.fn(async () => {})}
|
||||
onReboot={vi.fn(async () => {})}
|
||||
onDisconnect={vi.fn(async () => {})}
|
||||
onReconnect={vi.fn(async () => {})}
|
||||
onAdvertise={vi.fn(async () => {})}
|
||||
meshDiscovery={null}
|
||||
meshDiscoveryLoadingTarget={null}
|
||||
onDiscoverMesh={vi.fn(async () => {})}
|
||||
onHealthRefresh={vi.fn(async () => {})}
|
||||
onRefreshAppSettings={vi.fn(async () => {})}
|
||||
/>
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(api.getFanoutConfigs).toHaveBeenCalled();
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Save failed')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -509,6 +536,18 @@ describe('SettingsModal', () => {
|
||||
expect(localStorage.getItem(LAST_VIEWED_CONVERSATION_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('defaults distance units to metric and stores local changes', () => {
|
||||
renderModal();
|
||||
openLocalSection();
|
||||
|
||||
const select = screen.getByLabelText('Distance Units');
|
||||
expect(select).toHaveValue('metric');
|
||||
|
||||
fireEvent.change(select, { target: { value: 'smoots' } });
|
||||
|
||||
expect(localStorage.getItem(DISTANCE_UNIT_KEY)).toBe('smoots');
|
||||
});
|
||||
|
||||
it('purges decrypted raw packets via maintenance endpoint action', async () => {
|
||||
const runMaintenanceSpy = vi.spyOn(api, 'runMaintenance').mockResolvedValue({
|
||||
packets_deleted: 12,
|
||||
@@ -518,6 +557,10 @@ describe('SettingsModal', () => {
|
||||
renderModal();
|
||||
openDatabaseSection();
|
||||
|
||||
expect(
|
||||
screen.getByText(/remove packet-analysis availability for those historical messages/i)
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -541,6 +584,7 @@ describe('SettingsModal', () => {
|
||||
total_outgoing: 30,
|
||||
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
|
||||
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
|
||||
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
|
||||
path_hash_width_24h: {
|
||||
total_packets: 120,
|
||||
single_byte: 60,
|
||||
@@ -587,6 +631,7 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByText('24 (20.0%)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Contacts heard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Repeaters heard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Known-channels active')).toBeInTheDocument();
|
||||
|
||||
// Busiest channels
|
||||
expect(screen.getByText('general')).toBeInTheDocument();
|
||||
@@ -607,6 +652,7 @@ describe('SettingsModal', () => {
|
||||
total_outgoing: 30,
|
||||
contacts_heard: { last_hour: 2, last_24_hours: 7, last_week: 10 },
|
||||
repeaters_heard: { last_hour: 1, last_24_hours: 3, last_week: 3 },
|
||||
known_channels_active: { last_hour: 1, last_24_hours: 4, last_week: 6 },
|
||||
path_hash_width_24h: {
|
||||
total_packets: 120,
|
||||
single_byte: 60,
|
||||
|
||||
@@ -1 +1,9 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
class ResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
globalThis.ResizeObserver = ResizeObserver;
|
||||
|
||||
@@ -2,7 +2,13 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
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 {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
CONTACT_TYPE_ROOM,
|
||||
type Channel,
|
||||
type Contact,
|
||||
type Favorite,
|
||||
} from '../types';
|
||||
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
@@ -51,16 +57,19 @@ function renderSidebar(overrides?: {
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
}) {
|
||||
const aliceName = 'Alice';
|
||||
const roomName = 'Ops Board';
|
||||
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
||||
const flightChannel = makeChannel('BB'.repeat(16), '#flight');
|
||||
const opsChannel = makeChannel('CC'.repeat(16), '#ops');
|
||||
const alice = makeContact('11'.repeat(32), aliceName);
|
||||
const board = makeContact('33'.repeat(32), roomName, CONTACT_TYPE_ROOM);
|
||||
const relay = makeContact('22'.repeat(32), 'Relay', CONTACT_TYPE_REPEATER);
|
||||
|
||||
const unreadCounts = overrides?.unreadCounts ?? {
|
||||
[getStateKey('channel', flightChannel.key)]: 2,
|
||||
[getStateKey('channel', opsChannel.key)]: 1,
|
||||
[getStateKey('contact', alice.public_key)]: 3,
|
||||
[getStateKey('contact', board.public_key)]: 5,
|
||||
[getStateKey('contact', relay.public_key)]: 4,
|
||||
};
|
||||
|
||||
@@ -69,7 +78,7 @@ function renderSidebar(overrides?: {
|
||||
|
||||
const view = render(
|
||||
<Sidebar
|
||||
contacts={[alice, relay]}
|
||||
contacts={[alice, board, relay]}
|
||||
channels={channels}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
@@ -87,7 +96,7 @@ function renderSidebar(overrides?: {
|
||||
/>
|
||||
);
|
||||
|
||||
return { ...view, flightChannel, opsChannel, aliceName };
|
||||
return { ...view, flightChannel, opsChannel, aliceName, roomName };
|
||||
}
|
||||
|
||||
function getSectionHeaderContainer(title: string): HTMLElement {
|
||||
@@ -108,6 +117,7 @@ describe('Sidebar section summaries', () => {
|
||||
expect(within(getSectionHeaderContainer('Favorites')).getByText('2')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Channels')).getByText('1')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Room Servers')).getByText('5')).toBeInTheDocument();
|
||||
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -129,7 +139,7 @@ describe('Sidebar section summaries', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps contact row badges normal while the contacts rollup is always red', () => {
|
||||
it('turns contact row badges red while the contacts rollup remains red', () => {
|
||||
const { aliceName } = renderSidebar();
|
||||
|
||||
expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toHaveClass(
|
||||
@@ -140,21 +150,54 @@ describe('Sidebar section summaries', () => {
|
||||
const aliceRow = screen.getByText(aliceName).closest('div');
|
||||
if (!aliceRow) throw new Error('Missing Alice row');
|
||||
expect(within(aliceRow).getByText('3')).toHaveClass(
|
||||
'bg-badge-mention',
|
||||
'text-badge-mention-foreground'
|
||||
);
|
||||
});
|
||||
|
||||
it('turns favorite contact row badges red', () => {
|
||||
const { aliceName } = renderSidebar({
|
||||
favorites: [{ type: 'contact', id: '11'.repeat(32) }],
|
||||
});
|
||||
|
||||
const aliceRow = screen.getByText(aliceName).closest('div');
|
||||
if (!aliceRow) throw new Error('Missing Alice row');
|
||||
expect(within(aliceRow).getByText('3')).toHaveClass(
|
||||
'bg-badge-mention',
|
||||
'text-badge-mention-foreground'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps repeater row badges neutral', () => {
|
||||
renderSidebar();
|
||||
|
||||
const relayRow = screen.getByText('Relay').closest('div');
|
||||
if (!relayRow) throw new Error('Missing Relay row');
|
||||
expect(within(relayRow).getByText('4')).toHaveClass(
|
||||
'bg-badge-unread/90',
|
||||
'text-badge-unread-foreground'
|
||||
);
|
||||
});
|
||||
|
||||
it('renders room servers in their own section', () => {
|
||||
const { roomName } = renderSidebar();
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Room Servers' })).toBeInTheDocument();
|
||||
expect(screen.getByText(roomName)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('expands collapsed sections during search and restores collapse state after clearing search', async () => {
|
||||
const { opsChannel, aliceName } = renderSidebar();
|
||||
const { opsChannel, aliceName, roomName } = renderSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));
|
||||
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
|
||||
const search = screen.getByLabelText('Search conversations');
|
||||
fireEvent.change(search, { target: { value: 'alice' } });
|
||||
@@ -169,19 +212,22 @@ describe('Sidebar section summaries', () => {
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('persists collapsed section state across unmount and remount', () => {
|
||||
const { opsChannel, aliceName, unmount } = renderSidebar();
|
||||
const { opsChannel, aliceName, roomName, unmount } = renderSidebar();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Tools' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Channels' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Contacts' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Room Servers' }));
|
||||
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
|
||||
unmount();
|
||||
renderSidebar();
|
||||
@@ -189,6 +235,7 @@ describe('Sidebar section summaries', () => {
|
||||
expect(screen.queryByText('Packet Feed')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(roomName)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders same-name channels when keys differ and allows selecting both', () => {
|
||||
@@ -265,6 +312,12 @@ describe('Sidebar section summaries', () => {
|
||||
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 zebraRoom = makeContact('55'.repeat(32), 'Zebra Room', CONTACT_TYPE_ROOM, {
|
||||
last_seen: 100,
|
||||
});
|
||||
const alphaRoom = makeContact('66'.repeat(32), 'Alpha Room', CONTACT_TYPE_ROOM, {
|
||||
last_advert: 300,
|
||||
});
|
||||
const relayZulu = makeContact('33'.repeat(32), 'Zulu Relay', CONTACT_TYPE_REPEATER, {
|
||||
last_seen: 100,
|
||||
});
|
||||
@@ -273,7 +326,7 @@ describe('Sidebar section summaries', () => {
|
||||
});
|
||||
|
||||
const props = {
|
||||
contacts: [zed, amy, relayZulu, relayAlpha],
|
||||
contacts: [zed, amy, zebraRoom, alphaRoom, relayZulu, relayAlpha],
|
||||
channels: [publicChannel, zebraChannel, alphaChannel],
|
||||
activeConversation: null,
|
||||
onSelectConversation: vi.fn(),
|
||||
@@ -282,6 +335,7 @@ describe('Sidebar section summaries', () => {
|
||||
[getStateKey('channel', zebraChannel.key)]: 300,
|
||||
[getStateKey('channel', alphaChannel.key)]: 100,
|
||||
[getStateKey('contact', zed.public_key)]: 200,
|
||||
[getStateKey('contact', zebraRoom.public_key)]: 350,
|
||||
},
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
@@ -304,27 +358,76 @@ describe('Sidebar section summaries', () => {
|
||||
.getAllByText(/Relay$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
const getRoomsOrder = () =>
|
||||
screen
|
||||
.getAllByText(/Room$/)
|
||||
.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(getRoomsOrder()).toEqual(['Zebra Room', 'Alpha Room']);
|
||||
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort Channels alphabetically' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort Contacts alphabetically' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort Room Servers alphabetically' }));
|
||||
|
||||
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
|
||||
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
|
||||
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
|
||||
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
|
||||
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
|
||||
|
||||
unmount();
|
||||
render(<Sidebar {...props} />);
|
||||
|
||||
expect(getChannelsOrder()).toEqual(['#alpha', '#zebra']);
|
||||
expect(getContactsOrder()).toEqual(['Zed', 'Amy']);
|
||||
expect(getContactsOrder()).toEqual(['Amy', 'Zed']);
|
||||
expect(getRoomsOrder()).toEqual(['Alpha Room', 'Zebra Room']);
|
||||
expect(getRepeatersOrder()).toEqual(['Alpha Relay', 'Zulu Relay']);
|
||||
});
|
||||
|
||||
it('sorts room servers like contacts by DM recency first, then advert recency', () => {
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const dmRecentRoom = makeContact('77'.repeat(32), 'DM Recent Room', CONTACT_TYPE_ROOM, {
|
||||
last_advert: 100,
|
||||
});
|
||||
const advertOnlyRoom = makeContact('88'.repeat(32), 'Advert Only Room', CONTACT_TYPE_ROOM, {
|
||||
last_seen: 300,
|
||||
});
|
||||
const noRecencyRoom = makeContact('99'.repeat(32), 'No Recency Room', CONTACT_TYPE_ROOM);
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
contacts={[noRecencyRoom, advertOnlyRoom, dmRecentRoom]}
|
||||
channels={[publicChannel]}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onNewMessage={vi.fn()}
|
||||
lastMessageTimes={{
|
||||
[getStateKey('contact', dmRecentRoom.public_key)]: 400,
|
||||
}}
|
||||
unreadCounts={{}}
|
||||
mentions={{}}
|
||||
showCracker={false}
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
legacySortOrder="recent"
|
||||
/>
|
||||
);
|
||||
|
||||
const roomRows = screen
|
||||
.getAllByText(/Room$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
expect(roomRows).toEqual(['DM Recent Room', 'Advert Only Room', 'No Recency Room']);
|
||||
});
|
||||
|
||||
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 });
|
||||
@@ -442,4 +545,90 @@ describe('Sidebar section summaries', () => {
|
||||
name: 'Public',
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts favorites independently and persists the favorites sort preference', () => {
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy');
|
||||
|
||||
const props = {
|
||||
contacts: [zed, amy],
|
||||
channels: [publicChannel],
|
||||
activeConversation: null,
|
||||
onSelectConversation: vi.fn(),
|
||||
onNewMessage: vi.fn(),
|
||||
lastMessageTimes: {
|
||||
[getStateKey('contact', zed.public_key)]: 200,
|
||||
},
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
showCracker: false,
|
||||
crackerRunning: false,
|
||||
onToggleCracker: vi.fn(),
|
||||
onMarkAllRead: vi.fn(),
|
||||
favorites: [
|
||||
{ type: 'contact', id: zed.public_key },
|
||||
{ type: 'contact', id: amy.public_key },
|
||||
] satisfies Favorite[],
|
||||
legacySortOrder: 'recent' as const,
|
||||
};
|
||||
|
||||
const getFavoritesOrder = () =>
|
||||
screen
|
||||
.getAllByText(/^(Amy|Zed)$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
const { unmount } = render(<Sidebar {...props} />);
|
||||
|
||||
expect(getFavoritesOrder()).toEqual(['Zed', 'Amy']);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Sort Favorites alphabetically' }));
|
||||
|
||||
expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']);
|
||||
|
||||
unmount();
|
||||
render(<Sidebar {...props} />);
|
||||
|
||||
expect(getFavoritesOrder()).toEqual(['Amy', 'Zed']);
|
||||
});
|
||||
|
||||
it('seeds favorites sort from the legacy global sort order when section prefs are missing', () => {
|
||||
localStorage.setItem('remoteterm-sortOrder', 'alpha');
|
||||
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy');
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
contacts={[zed, amy]}
|
||||
channels={[publicChannel]}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onNewMessage={vi.fn()}
|
||||
lastMessageTimes={{
|
||||
[getStateKey('contact', zed.public_key)]: 200,
|
||||
}}
|
||||
unreadCounts={{}}
|
||||
mentions={{}}
|
||||
showCracker={false}
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[
|
||||
{ type: 'contact', id: zed.public_key },
|
||||
{ type: 'contact', id: amy.public_key },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
const favoriteRows = screen
|
||||
.getAllByText(/^(Amy|Zed)$/)
|
||||
.map((node) => node.textContent)
|
||||
.filter((text): text is string => Boolean(text));
|
||||
|
||||
expect(favoriteRows).toEqual(['Amy', 'Zed']);
|
||||
expect(screen.getByRole('button', { name: 'Sort Favorites by recent' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -148,4 +148,25 @@ describe('useBrowserNotifications', () => {
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
expect(notificationInstance.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows the browser guidance toast when notifications are blocked', async () => {
|
||||
Object.assign(window.Notification, {
|
||||
permission: 'denied',
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useBrowserNotifications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith('Browser notifications blocked', {
|
||||
description:
|
||||
'Allow notifications in your browser settings, then try again. Some browsers may refuse notifications on non-HTTPS or self-signed HTTPS origins. Check your browser documentation for how to trust an insecure origin and the associated risks before doing so.',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -217,7 +217,9 @@ describe('useConversationMessages conversation switch', () => {
|
||||
|
||||
// Switch to conv B while older-messages fetch is still pending
|
||||
mockGetMessages.mockResolvedValueOnce([createMessage({ id: 999, conversation_key: 'conv_b' })]);
|
||||
rerender({ conv: convB });
|
||||
await act(async () => {
|
||||
rerender({ conv: convB });
|
||||
});
|
||||
|
||||
// loadingOlder must reset immediately — no phantom spinner in conv B
|
||||
await waitFor(() => expect(result.current.loadingOlder).toBe(false));
|
||||
@@ -226,12 +228,13 @@ describe('useConversationMessages conversation switch', () => {
|
||||
expect(result.current.messages[0].conversation_key).toBe('conv_b');
|
||||
|
||||
// Resolve the stale older-messages fetch — should not affect conv B's state
|
||||
olderDeferred.resolve([
|
||||
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
|
||||
]);
|
||||
await act(async () => {
|
||||
olderDeferred.resolve([
|
||||
createMessage({ id: 500, conversation_key: 'conv_a', text: 'stale-old' }),
|
||||
]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
// Give the stale response time to be processed (it should be discarded)
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].conversation_key).toBe('conv_b');
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user