mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-15 22:06:13 +02:00
Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb9c9ae289 | |||
| 2369e69e0a | |||
| 9c2b6f0744 | |||
| 70d28e53a9 | |||
| 96d8d1dc64 | |||
| a7ff041a48 | |||
| 5a580b9c01 | |||
| 0834414ba4 | |||
| df538b3aaf | |||
| 2710cafb21 | |||
| 338f632514 | |||
| 7e1f941760 | |||
| 87ea2b4675 | |||
| 5c85a432c8 | |||
| 22ca5410ee | |||
| 276e0e09b3 | |||
| 1c57e35ba5 | |||
| 358589bd66 | |||
| 74c13d194c | |||
| 07fd88a4d6 | |||
| 07934093e6 | |||
| 3ee4f9d7a2 | |||
| b81f6ef89e | |||
| 489950a2f7 | |||
| 08e00373aa | |||
| 2f0d35748a | |||
| 7db2974481 | |||
| 0a20929df6 | |||
| 30f6f95d8e | |||
| 9e8cf56b31 | |||
| fb535298be | |||
| 1f2903fc2d | |||
| 6466a5c355 | |||
| f8e88b3737 | |||
| a13e241636 | |||
| 8c1a58b293 | |||
| bf53e8a4cb | |||
| c6cd209192 | |||
| e5c7ebb388 | |||
| e37632de3f | |||
| d36c5e3e32 | |||
| bc7506b0d9 | |||
| 38c7277c9d | |||
| 20d0bd92bb | |||
| e0df30b5f0 | |||
| 83635845b6 | |||
| 2e705538fd | |||
| 4363fd2a73 | |||
| 5bd3205de5 | |||
| bcde3bd9d5 | |||
| 15a8c637e4 | |||
| d38efc0421 |
@@ -22,7 +22,7 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
|
||||
|
||||
Ancillary AGENTS.md files which should generally not be reviewed unless specific work is being performed on those features include:
|
||||
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise, SQS)
|
||||
- `frontend/src/components/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
|
||||
- `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
@@ -129,7 +129,7 @@ To improve repeater disambiguation in the network visualizer, the backend stores
|
||||
- This is independent of raw-packet payload deduplication.
|
||||
- Paths are keyed per contact + path + hop count, with `heard_count`, `first_seen`, and `last_seen`.
|
||||
- Only the N most recent unique paths are retained per contact (currently 10).
|
||||
- See `frontend/src/components/AGENTS_packet_visualizer.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
|
||||
- See `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
|
||||
|
||||
## Path Hash Modes
|
||||
|
||||
@@ -290,22 +290,19 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
|
||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode` and `path_hash_mode_supported` |
|
||||
| PATCH | `/api/radio/config` | Update name, location, radio params, and `path_hash_mode` when supported |
|
||||
| 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 |
|
||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||
| POST | `/api/radio/advertise` | Send advertisement |
|
||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||
| GET | `/api/contacts` | List contacts |
|
||||
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
|
||||
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
|
||||
| GET | `/api/contacts/{public_key}` | Get contact by public key or prefix |
|
||||
| GET | `/api/contacts/{public_key}/detail` | Comprehensive contact profile (stats, name history, paths) |
|
||||
| GET | `/api/contacts/{public_key}/advert-paths` | List recent unique advert paths for a contact |
|
||||
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
||||
| DELETE | `/api/contacts/{public_key}` | Delete contact |
|
||||
| POST | `/api/contacts/sync` | Pull from radio |
|
||||
| POST | `/api/contacts/{public_key}/add-to-radio` | Push contact to radio |
|
||||
| POST | `/api/contacts/{public_key}/remove-from-radio` | Remove contact from radio |
|
||||
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
|
||||
| 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 |
|
||||
@@ -315,16 +312,15 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
|
||||
| POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors |
|
||||
| POST | `/api/contacts/{public_key}/repeater/acl` | Fetch repeater ACL |
|
||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch radio settings via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/node-info` | Fetch repeater name, location, and clock via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||
|
||||
| GET | `/api/channels` | List channels |
|
||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||
| GET | `/api/channels/{key}` | Get channel by key |
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
||||
| POST | `/api/channels/sync` | Pull from radio |
|
||||
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
|
||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
|
||||
@@ -442,11 +438,12 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
|
||||
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on message audit task from hourly checks to aggressive 10-second `get_msg()` fallback polling |
|
||||
| `MESHCORE_BASIC_AUTH_USERNAME` | *(none)* | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
|
||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||
|
||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||
|
||||
@@ -459,3 +456,9 @@ Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/
|
||||
The vendored MeshCore Python reader's `LOG_DATA` advert path assumes the decoded advert payload always contains at least 101 bytes of advert body and reads the flags byte with `pk_buf.read(1)[0]` without a length guard. If a malformed or truncated RF log frame slips through, `MessageReader.handle_rx()` can fail with `IndexError: index out of range` from `meshcore/reader.py` while parsing payload type `0x04` (advert).
|
||||
|
||||
This does not indicate database corruption or a message-store bug. It is a parser-hardening gap in `meshcore_py`: the reader does not fully mirror firmware-side packet/path validation before attempting advert decode. The practical effect is usually a one-off asyncio task failure for that packet while later packets continue processing normally.
|
||||
|
||||
### Channel-message dedup intentionally treats same-name/same-text/same-second channel sends as indistinguishable because they are
|
||||
|
||||
Channel message storage deduplicates on `(type, conversation_key, text, sender_timestamp)`. Reviewers often flag this as "missing sender identity," but for channel messages the stored `text` already includes the displayed sender label (for example `Alice: hello`). That means two different users only collide when they produce the same rendered sender name, the same body text, and the same sender timestamp.
|
||||
|
||||
In that case, RemoteTerm usually does not have enough information to distinguish "two independent same-name sends" from "one message observed again as an echo/repeat." Without a reliable sender identity at ingest, treating those packets as the same message is an accepted limitation of the observable data model, not an obvious correctness bug.
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
## [3.2.0] - 2026-03-12
|
||||
|
||||
Feature: Improve ambiguous-sender DM handling and visibility
|
||||
Feature: Allow for toggling of node GPS broadcast
|
||||
Feature: Add path width to bot and move example to full kwargs
|
||||
Feature: Improve node map color contrast
|
||||
Bugfix: More accurate tracking of contact data
|
||||
Bugfix: Misc. frontend performance and bugfixes
|
||||
Misc: Clearer warnings on user-key linkage
|
||||
Misc: Documentation improvements
|
||||
|
||||
## [3.1.1] - 2026-03-11
|
||||
|
||||
Feature: Add basic auth
|
||||
|
||||
@@ -8,8 +8,8 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Search for hashtag room names for channels you don't have keys for yet
|
||||
* Forward packets to MQTT brokers (private: decrypted messages and/or raw packets; community aggregators like LetsMesh.net: raw packets only)
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing in all traffic and display systems within the app
|
||||
* 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!
|
||||
|
||||
**Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
|
||||
@@ -224,7 +224,6 @@ npm run build # build the frontend
|
||||
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
|
||||
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
|
||||
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config) |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly audit task |
|
||||
| `MESHCORE_BASIC_AUTH_USERNAME` | | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
|
||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||
|
||||
@@ -232,7 +231,21 @@ Only one transport may be active at a time. If multiple are set, the server will
|
||||
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
|
||||
|
||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. If that audit ever finds radio data that was not surfaced through event subscription, the backend logs an error and the UI shows a toast telling the operator to check the logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second `get_msg()` safety net.
|
||||
### Remediation Environment Variables
|
||||
|
||||
These are intended for diagnosing or working around radios that behave oddly.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly sanity check |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
|
||||
|
||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
||||
|
||||
- whether messages were left on the radio without reaching the app through event subscription
|
||||
- whether the app's channel-slot expectations still match the radio's actual channel listing
|
||||
|
||||
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
|
||||
|
||||
## Additional Setup
|
||||
|
||||
|
||||
+21
-12
@@ -48,10 +48,12 @@ app/
|
||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
├── path_utils.py # Path hex rendering and hop-width helpers
|
||||
├── region_scope.py # Normalize/validate regional flood-scope values
|
||||
├── keystore.py # Ephemeral private/public key storage for DM decryption
|
||||
├── frontend_static.py # Mount/serve built frontend (production)
|
||||
└── routers/
|
||||
├── health.py
|
||||
├── debug.py
|
||||
├── radio.py
|
||||
├── contacts.py
|
||||
├── channels.py
|
||||
@@ -88,7 +90,7 @@ app/
|
||||
- `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`.
|
||||
- Routers, startup/lifespan code, fanout helpers, and `radio_sync.py` should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly.
|
||||
- Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state.
|
||||
- Setup still includes handler registration, key export, time sync, contact/channel sync, and advertisement tasks. The message-poll task always starts: by default it runs as a low-frequency hourly audit, and `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` switches it to aggressive 10-second polling.
|
||||
- Setup still includes handler registration, key export, time sync, contact/channel sync, and advertisement tasks. The message-poll task always starts: by default it runs as a low-frequency hourly audit, and `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` switches it to aggressive 10-second polling. That audit checks both missed-radio-message drift and channel-slot cache drift; cache mismatches are logged, toasted, and the send-slot cache is reset.
|
||||
- Post-connect setup is timeout-bounded. If initial radio offload/setup hangs too long, the backend logs the failure and broadcasts an `error` toast telling the operator to reboot the radio and restart the server.
|
||||
|
||||
## Important Behaviors
|
||||
@@ -97,6 +99,10 @@ 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.
|
||||
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
||||
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
||||
- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes.
|
||||
- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback.
|
||||
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
|
||||
@@ -144,25 +150,24 @@ app/
|
||||
### Health
|
||||
- `GET /health`
|
||||
|
||||
### Debug
|
||||
- `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` and `path_hash_mode_supported`
|
||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
|
||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
|
||||
- `PUT /radio/private-key`
|
||||
- `POST /radio/advertise`
|
||||
- `POST /radio/disconnect`
|
||||
- `POST /radio/reboot`
|
||||
- `POST /radio/reconnect`
|
||||
|
||||
### Contacts
|
||||
- `GET /contacts`
|
||||
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
|
||||
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
|
||||
- `GET /contacts/{public_key}`
|
||||
- `GET /contacts/{public_key}/detail` — comprehensive contact profile (stats, name history, paths, nearest repeaters)
|
||||
- `GET /contacts/{public_key}/advert-paths` — recent advert paths for one contact
|
||||
- `POST /contacts`
|
||||
- `DELETE /contacts/{public_key}`
|
||||
- `POST /contacts/sync`
|
||||
- `POST /contacts/{public_key}/add-to-radio`
|
||||
- `POST /contacts/{public_key}/remove-from-radio`
|
||||
- `POST /contacts/{public_key}/mark-read`
|
||||
- `POST /contacts/{public_key}/command`
|
||||
- `POST /contacts/{public_key}/routing-override`
|
||||
@@ -172,6 +177,7 @@ app/
|
||||
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
|
||||
- `POST /contacts/{public_key}/repeater/neighbors`
|
||||
- `POST /contacts/{public_key}/repeater/acl`
|
||||
- `POST /contacts/{public_key}/repeater/node-info`
|
||||
- `POST /contacts/{public_key}/repeater/radio-settings`
|
||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||
@@ -179,10 +185,8 @@ app/
|
||||
### Channels
|
||||
- `GET /channels`
|
||||
- `GET /channels/{key}/detail`
|
||||
- `GET /channels/{key}`
|
||||
- `POST /channels`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/sync`
|
||||
- `POST /channels/{key}/flood-scope-override`
|
||||
- `POST /channels/{key}/mark-read`
|
||||
|
||||
@@ -226,6 +230,7 @@ app/
|
||||
|
||||
- `health` — radio connection status (broadcast on change, personal on connect)
|
||||
- `contact` — single contact upsert (from advertisements and radio sync)
|
||||
- `contact_resolved` — prefix contact reconciled to a full contact row (payload: `{ previous_public_key, contact }`)
|
||||
- `message` — new message (channel or DM, from packet processor or send endpoints)
|
||||
- `message_acked` — ACK/echo update for existing message (ack count + paths)
|
||||
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
|
||||
@@ -293,16 +298,20 @@ tests/
|
||||
├── test_api.py # REST endpoint integration tests
|
||||
├── test_bot.py # Bot execution and sandboxing
|
||||
├── test_channels_router.py # Channels router endpoints
|
||||
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
||||
├── test_config.py # Configuration validation
|
||||
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
||||
├── test_contacts_router.py # Contacts router endpoints
|
||||
├── test_decoder.py # Packet parsing/decryption
|
||||
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
||||
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
||||
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
||||
├── test_fanout_integration.py # Fanout integration tests
|
||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
||||
├── test_frontend_static.py # Frontend static file serving
|
||||
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
||||
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
||||
├── test_key_normalization.py # Public key normalization
|
||||
├── test_keystore.py # Ephemeral keystore
|
||||
├── test_message_pagination.py # Cursor-based message pagination
|
||||
@@ -315,6 +324,7 @@ tests/
|
||||
├── test_radio.py # RadioManager, serial detection
|
||||
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
||||
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
||||
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
||||
├── test_real_crypto.py # Real cryptographic operations
|
||||
├── test_radio_operation.py # radio_operation() context manager
|
||||
├── test_radio_router.py # Radio router endpoints
|
||||
@@ -324,11 +334,10 @@ tests/
|
||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||
├── test_messages_search.py # Message search, around, forward pagination
|
||||
├── test_block_lists.py # Blocked keys/names filtering
|
||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||
├── test_settings_router.py # Settings endpoints, advert validation
|
||||
├── test_statistics.py # Statistics aggregation
|
||||
├── test_channel_sender_backfill.py # Sender key backfill for channel messages
|
||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||
├── test_main_startup.py # App startup and lifespan
|
||||
├── test_path_utils.py # Path hex rendering helpers
|
||||
├── test_websocket.py # WS manager broadcast/cleanup
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
import logging.config
|
||||
from collections import deque
|
||||
from threading import Lock
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
@@ -19,6 +21,7 @@ class Settings(BaseSettings):
|
||||
database_path: str = "data/meshcore.db"
|
||||
disable_bots: bool = False
|
||||
enable_message_poll_fallback: bool = False
|
||||
force_channel_slot_reconfigure: bool = False
|
||||
basic_auth_username: str = ""
|
||||
basic_auth_password: str = ""
|
||||
|
||||
@@ -66,6 +69,47 @@ class Settings(BaseSettings):
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class _RingBufferLogHandler(logging.Handler):
|
||||
"""Keep a bounded in-memory tail of formatted log lines."""
|
||||
|
||||
def __init__(self, max_lines: int = 1000) -> None:
|
||||
super().__init__()
|
||||
self._buffer: deque[str] = deque(maxlen=max_lines)
|
||||
self._lock = Lock()
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
line = self.format(record)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
return
|
||||
with self._lock:
|
||||
self._buffer.append(line)
|
||||
|
||||
def get_lines(self, limit: int = 1000) -> list[str]:
|
||||
with self._lock:
|
||||
if limit <= 0:
|
||||
return []
|
||||
return list(self._buffer)[-limit:]
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._buffer.clear()
|
||||
|
||||
|
||||
_recent_log_handler = _RingBufferLogHandler(max_lines=1000)
|
||||
|
||||
|
||||
def get_recent_log_lines(limit: int = 1000) -> list[str]:
|
||||
"""Return recent formatted log lines from the in-memory ring buffer."""
|
||||
return _recent_log_handler.get_lines(limit)
|
||||
|
||||
|
||||
def clear_recent_log_lines() -> None:
|
||||
"""Clear the in-memory log ring buffer."""
|
||||
_recent_log_handler.clear()
|
||||
|
||||
|
||||
class _RepeatSquelch(logging.Filter):
|
||||
"""Suppress rapid-fire identical messages and emit a summary instead.
|
||||
|
||||
@@ -151,6 +195,19 @@ def setup_logging() -> None:
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
_recent_log_handler.setLevel(logging.DEBUG)
|
||||
_recent_log_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
for logger_name in ("", "uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
target = logging.getLogger(logger_name)
|
||||
if _recent_log_handler not in target.handlers:
|
||||
target.addHandler(_recent_log_handler)
|
||||
|
||||
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
|
||||
# "Serial Connection started" when the port is contended).
|
||||
logging.getLogger("meshcore").addFilter(_RepeatSquelch())
|
||||
|
||||
+1
-10
@@ -85,15 +85,6 @@ class PacketInfo:
|
||||
path_hash_size: int = 1 # Bytes per hop: 1, 2, or 3
|
||||
|
||||
|
||||
def calculate_channel_hash(channel_key: bytes) -> str:
|
||||
"""
|
||||
Calculate the channel hash from a 16-byte channel key.
|
||||
Returns the first byte of SHA256(key) as hex.
|
||||
"""
|
||||
hash_bytes = hashlib.sha256(channel_key).digest()
|
||||
return format(hash_bytes[0], "02x")
|
||||
|
||||
|
||||
def extract_payload(raw_packet: bytes) -> bytes | None:
|
||||
"""
|
||||
Extract just the payload from a raw packet, skipping header and path.
|
||||
@@ -233,7 +224,7 @@ def try_decrypt_packet_with_channel_key(
|
||||
return None
|
||||
|
||||
packet_channel_hash = format(packet_info.payload[0], "02x")
|
||||
expected_hash = calculate_channel_hash(channel_key)
|
||||
expected_hash = format(hashlib.sha256(channel_key).digest()[0], "02x")
|
||||
|
||||
if packet_channel_hash != expected_hash:
|
||||
return None
|
||||
|
||||
+29
-1
@@ -13,6 +13,7 @@ from app.repository import (
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.contact_reconciliation import (
|
||||
claim_prefix_messages_for_contact,
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast
|
||||
@@ -88,6 +89,20 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_pubkey[:12],
|
||||
)
|
||||
return
|
||||
elif sender_pubkey:
|
||||
placeholder_upsert = ContactUpsert(
|
||||
public_key=sender_pubkey.lower(),
|
||||
type=0,
|
||||
last_seen=received_at,
|
||||
last_contacted=received_at,
|
||||
first_seen=received_at,
|
||||
on_radio=False,
|
||||
out_path_hash_mode=-1,
|
||||
)
|
||||
await ContactRepository.upsert(placeholder_upsert)
|
||||
contact = await ContactRepository.get_by_key(sender_pubkey.lower())
|
||||
if contact:
|
||||
broadcast_event("contact", contact.model_dump())
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
# If the packet processor already stored this message, this returns None
|
||||
@@ -228,9 +243,13 @@ async def on_new_contact(event: "Event") -> None:
|
||||
|
||||
logger.debug("New contact: %s", public_key[:12])
|
||||
|
||||
contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=True)
|
||||
contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=False)
|
||||
contact_upsert.last_seen = int(time.time())
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=public_key,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
adv_name = payload.get("adv_name")
|
||||
await record_contact_name_and_reconcile(
|
||||
@@ -251,6 +270,15 @@ async def on_new_contact(event: "Event") -> None:
|
||||
else Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump()
|
||||
),
|
||||
)
|
||||
if db_contact:
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": db_contact.model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def on_ack(event: "Event") -> None:
|
||||
|
||||
@@ -16,6 +16,7 @@ WsEventType = Literal[
|
||||
"health",
|
||||
"message",
|
||||
"contact",
|
||||
"contact_resolved",
|
||||
"channel",
|
||||
"contact_deleted",
|
||||
"channel_deleted",
|
||||
@@ -30,6 +31,11 @@ class ContactDeletedPayload(TypedDict):
|
||||
public_key: str
|
||||
|
||||
|
||||
class ContactResolvedPayload(TypedDict):
|
||||
previous_public_key: str
|
||||
contact: Contact
|
||||
|
||||
|
||||
class ChannelDeletedPayload(TypedDict):
|
||||
key: str
|
||||
|
||||
@@ -49,6 +55,7 @@ WsEventPayload = (
|
||||
HealthResponse
|
||||
| Message
|
||||
| Contact
|
||||
| ContactResolvedPayload
|
||||
| Channel
|
||||
| ContactDeletedPayload
|
||||
| ChannelDeletedPayload
|
||||
@@ -61,6 +68,7 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||
"health": TypeAdapter(HealthResponse),
|
||||
"message": TypeAdapter(Message),
|
||||
"contact": TypeAdapter(Contact),
|
||||
"contact_resolved": TypeAdapter(ContactResolvedPayload),
|
||||
"channel": TypeAdapter(Channel),
|
||||
"contact_deleted": TypeAdapter(ContactDeletedPayload),
|
||||
"channel_deleted": TypeAdapter(ChannelDeletedPayload),
|
||||
|
||||
@@ -82,7 +82,7 @@ Push notifications via Apprise library. Config blob:
|
||||
### sqs (sqs.py)
|
||||
Amazon SQS delivery. Config blob:
|
||||
- `queue_url` — target queue URL
|
||||
- `region_name` (optional), `endpoint_url` (optional)
|
||||
- `region_name` (optional; inferred from standard AWS SQS queue URLs when omitted), `endpoint_url` (optional)
|
||||
- `access_key_id`, `secret_access_key`, `session_token` (all optional; blank uses the normal AWS credential chain)
|
||||
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
|
||||
- Supports both decoded messages and raw packets via normal scope selection
|
||||
|
||||
+35
-4
@@ -10,6 +10,36 @@ from app.fanout.base import FanoutModule
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _derive_path_bytes_per_hop(paths: object, path_value: str | None) -> int | None:
|
||||
"""Derive hop width from the first serialized message path when possible."""
|
||||
if not isinstance(path_value, str) or not path_value:
|
||||
return None
|
||||
if not isinstance(paths, list) or not paths:
|
||||
return None
|
||||
|
||||
first_path = paths[0]
|
||||
if not isinstance(first_path, dict):
|
||||
return None
|
||||
|
||||
path_hops = first_path.get("path_len")
|
||||
if not isinstance(path_hops, int) or path_hops <= 0:
|
||||
return None
|
||||
|
||||
path_hex_chars = len(path_value)
|
||||
if path_hex_chars % 2 != 0:
|
||||
return None
|
||||
|
||||
path_bytes = path_hex_chars // 2
|
||||
if path_bytes % path_hops != 0:
|
||||
return None
|
||||
|
||||
hop_width = path_bytes // path_hops
|
||||
if hop_width not in (1, 2, 3):
|
||||
return None
|
||||
|
||||
return hop_width
|
||||
|
||||
|
||||
class BotModule(FanoutModule):
|
||||
"""Wraps a single bot's code execution and response routing.
|
||||
|
||||
@@ -101,11 +131,11 @@ class BotModule(FanoutModule):
|
||||
|
||||
sender_timestamp = data.get("sender_timestamp")
|
||||
path_value = data.get("path")
|
||||
paths = data.get("paths")
|
||||
# Message model serializes paths as list of dicts; extract first path string
|
||||
if path_value is None:
|
||||
paths = data.get("paths")
|
||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
|
||||
if path_value is None and paths and isinstance(paths, list) and len(paths) > 0:
|
||||
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
|
||||
path_bytes_per_hop = _derive_path_bytes_per_hop(paths, path_value)
|
||||
|
||||
# Wait for message to settle (allows retransmissions to be deduped)
|
||||
await asyncio.sleep(2)
|
||||
@@ -130,6 +160,7 @@ class BotModule(FanoutModule):
|
||||
sender_timestamp,
|
||||
path_value,
|
||||
is_outgoing,
|
||||
path_bytes_per_hop,
|
||||
),
|
||||
timeout=BOT_EXECUTION_TIMEOUT,
|
||||
)
|
||||
|
||||
+144
-35
@@ -15,6 +15,7 @@ import inspect
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -39,6 +40,102 @@ _bot_send_lock = asyncio.Lock()
|
||||
_last_bot_send_time: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BotCallPlan:
|
||||
"""How to call a validated bot() function."""
|
||||
|
||||
call_style: str
|
||||
keyword_args: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
|
||||
"""Validate bot() signature and return a supported call plan."""
|
||||
try:
|
||||
sig = (
|
||||
bot_func_or_sig
|
||||
if isinstance(bot_func_or_sig, inspect.Signature)
|
||||
else inspect.signature(bot_func_or_sig)
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("Bot function signature could not be inspected") from exc
|
||||
|
||||
params = sig.parameters
|
||||
param_values = tuple(params.values())
|
||||
positional_params = [
|
||||
p
|
||||
for p in param_values
|
||||
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||||
]
|
||||
has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in param_values)
|
||||
has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in param_values)
|
||||
explicit_optional_names = tuple(
|
||||
name for name in ("is_outgoing", "path_bytes_per_hop") if name in params
|
||||
)
|
||||
unsupported_required_kwonly = [
|
||||
p.name
|
||||
for p in param_values
|
||||
if p.kind == inspect.Parameter.KEYWORD_ONLY
|
||||
and p.default is inspect.Parameter.empty
|
||||
and p.name not in {"is_outgoing", "path_bytes_per_hop"}
|
||||
]
|
||||
if unsupported_required_kwonly:
|
||||
raise ValueError(
|
||||
"Bot function signature is not supported. Unsupported required keyword-only "
|
||||
"parameters: " + ", ".join(unsupported_required_kwonly)
|
||||
)
|
||||
|
||||
positional_capacity = len(positional_params)
|
||||
base_args = [object()] * 8
|
||||
base_keyword_args: dict[str, object] = {
|
||||
"sender_name": object(),
|
||||
"sender_key": object(),
|
||||
"message_text": object(),
|
||||
"is_dm": object(),
|
||||
"channel_key": object(),
|
||||
"channel_name": object(),
|
||||
"sender_timestamp": object(),
|
||||
"path": object(),
|
||||
}
|
||||
candidate_specs: list[tuple[str, list[object], dict[str, object]]] = []
|
||||
keyword_args = dict(base_keyword_args)
|
||||
if has_kwargs or "is_outgoing" in params:
|
||||
keyword_args["is_outgoing"] = False
|
||||
if has_kwargs or "path_bytes_per_hop" in params:
|
||||
keyword_args["path_bytes_per_hop"] = 1
|
||||
candidate_specs.append(("keyword", [], keyword_args))
|
||||
|
||||
if not has_kwargs and explicit_optional_names:
|
||||
kwargs: dict[str, object] = {}
|
||||
if has_kwargs or "is_outgoing" in params:
|
||||
kwargs["is_outgoing"] = False
|
||||
if has_kwargs or "path_bytes_per_hop" in params:
|
||||
kwargs["path_bytes_per_hop"] = 1
|
||||
candidate_specs.append(("mixed_keyword", base_args, kwargs))
|
||||
|
||||
if has_varargs or positional_capacity >= 10:
|
||||
candidate_specs.append(("positional_10", base_args + [False, 1], {}))
|
||||
if has_varargs or positional_capacity >= 9:
|
||||
candidate_specs.append(("positional_9", base_args + [False], {}))
|
||||
if has_varargs or positional_capacity >= 8:
|
||||
candidate_specs.append(("legacy", base_args, {}))
|
||||
|
||||
for call_style, args, kwargs in candidate_specs:
|
||||
try:
|
||||
sig.bind(*args, **kwargs)
|
||||
except TypeError:
|
||||
continue
|
||||
if call_style in {"keyword", "mixed_keyword"}:
|
||||
return BotCallPlan(call_style="keyword", keyword_args=tuple(kwargs.keys()))
|
||||
return BotCallPlan(call_style=call_style)
|
||||
|
||||
raise ValueError(
|
||||
"Bot function signature is not supported. Use the default bot template as a reference. "
|
||||
"Supported trailing parameters are: path; path + is_outgoing; "
|
||||
"path + path_bytes_per_hop; path + is_outgoing + path_bytes_per_hop; "
|
||||
"or use **kwargs for forward compatibility."
|
||||
)
|
||||
|
||||
|
||||
def execute_bot_code(
|
||||
code: str,
|
||||
sender_name: str | None,
|
||||
@@ -50,17 +147,19 @@ def execute_bot_code(
|
||||
sender_timestamp: int | None,
|
||||
path: str | None,
|
||||
is_outgoing: bool = False,
|
||||
path_bytes_per_hop: int | None = None,
|
||||
) -> str | list[str] | None:
|
||||
"""
|
||||
Execute user-provided bot code with message context.
|
||||
|
||||
The code should define a function:
|
||||
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing)`
|
||||
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing, path_bytes_per_hop)`
|
||||
or use named parameters / `**kwargs`.
|
||||
that returns either None (no response), a string (single response message),
|
||||
or a list of strings (multiple messages sent in order).
|
||||
|
||||
Legacy bot functions with 8 parameters (without is_outgoing) are detected
|
||||
via inspect and called without the new parameter for backward compatibility.
|
||||
Legacy bot functions with older signatures are detected via inspect and
|
||||
called without the newer parameters for backward compatibility.
|
||||
|
||||
Args:
|
||||
code: Python code defining the bot function
|
||||
@@ -73,6 +172,7 @@ def execute_bot_code(
|
||||
sender_timestamp: Sender's timestamp from the message (may be None)
|
||||
path: Hex-encoded routing path (may be None)
|
||||
is_outgoing: True if this is our own outgoing message
|
||||
path_bytes_per_hop: Number of bytes per routing hop (1, 2, or 3), if known
|
||||
|
||||
Returns:
|
||||
Response string, list of strings, or None.
|
||||
@@ -100,30 +200,28 @@ def execute_bot_code(
|
||||
return None
|
||||
|
||||
bot_func = namespace["bot"]
|
||||
|
||||
# Detect whether the bot function accepts is_outgoing (new 9-param signature)
|
||||
# or uses the legacy 8-param signature, for backward compatibility.
|
||||
# Three cases: explicit is_outgoing param or 9+ params (positional),
|
||||
# **kwargs (pass as keyword), or legacy 8-param (omit).
|
||||
call_style = "legacy" # "positional", "keyword", or "legacy"
|
||||
try:
|
||||
sig = inspect.signature(bot_func)
|
||||
params = sig.parameters
|
||||
non_variadic = [
|
||||
p
|
||||
for p in params.values()
|
||||
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
||||
]
|
||||
if "is_outgoing" in params or len(non_variadic) >= 9:
|
||||
call_style = "positional"
|
||||
elif any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
||||
call_style = "keyword"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
call_plan = _analyze_bot_signature(bot_func)
|
||||
except ValueError as exc:
|
||||
logger.error("%s", exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Call the bot function with appropriate signature
|
||||
if call_style == "positional":
|
||||
if call_plan.call_style == "positional_10":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing,
|
||||
path_bytes_per_hop,
|
||||
)
|
||||
elif call_plan.call_style == "positional_9":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
@@ -135,18 +233,29 @@ def execute_bot_code(
|
||||
path,
|
||||
is_outgoing,
|
||||
)
|
||||
elif call_style == "keyword":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing=is_outgoing,
|
||||
)
|
||||
elif call_plan.call_style == "keyword":
|
||||
keyword_args: dict[str, Any] = {}
|
||||
if "sender_name" in call_plan.keyword_args:
|
||||
keyword_args["sender_name"] = sender_name
|
||||
if "sender_key" in call_plan.keyword_args:
|
||||
keyword_args["sender_key"] = sender_key
|
||||
if "message_text" in call_plan.keyword_args:
|
||||
keyword_args["message_text"] = message_text
|
||||
if "is_dm" in call_plan.keyword_args:
|
||||
keyword_args["is_dm"] = is_dm
|
||||
if "channel_key" in call_plan.keyword_args:
|
||||
keyword_args["channel_key"] = channel_key
|
||||
if "channel_name" in call_plan.keyword_args:
|
||||
keyword_args["channel_name"] = channel_name
|
||||
if "sender_timestamp" in call_plan.keyword_args:
|
||||
keyword_args["sender_timestamp"] = sender_timestamp
|
||||
if "path" in call_plan.keyword_args:
|
||||
keyword_args["path"] = path
|
||||
if "is_outgoing" in call_plan.keyword_args:
|
||||
keyword_args["is_outgoing"] = is_outgoing
|
||||
if "path_bytes_per_hop" in call_plan.keyword_args:
|
||||
keyword_args["path_bytes_per_hop"] = path_bytes_per_hop
|
||||
result = bot_func(**keyword_args)
|
||||
else:
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
|
||||
@@ -7,6 +7,7 @@ import hashlib
|
||||
import json
|
||||
import logging
|
||||
from functools import partial
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
@@ -28,6 +29,24 @@ def _build_payload(data: dict, *, event_type: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _infer_region_from_queue_url(queue_url: str) -> str | None:
|
||||
"""Infer AWS region from a standard SQS queue URL host when possible."""
|
||||
host = urlparse(queue_url).hostname or ""
|
||||
if not host:
|
||||
return None
|
||||
|
||||
parts = host.split(".")
|
||||
if len(parts) < 4 or parts[0] != "sqs":
|
||||
return None
|
||||
if parts[2] != "amazonaws":
|
||||
return None
|
||||
if parts[3] not in {"com", "com.cn"}:
|
||||
return None
|
||||
|
||||
region = parts[1].strip()
|
||||
return region or None
|
||||
|
||||
|
||||
def _is_fifo_queue(queue_url: str) -> bool:
|
||||
"""Return True when the configured queue URL points at an SQS FIFO queue."""
|
||||
return queue_url.rstrip("/").endswith(".fifo")
|
||||
@@ -69,12 +88,15 @@ class SqsModule(FanoutModule):
|
||||
|
||||
async def start(self) -> None:
|
||||
kwargs: dict[str, str] = {}
|
||||
queue_url = str(self.config.get("queue_url", "")).strip()
|
||||
region_name = str(self.config.get("region_name", "")).strip()
|
||||
endpoint_url = str(self.config.get("endpoint_url", "")).strip()
|
||||
access_key_id = str(self.config.get("access_key_id", "")).strip()
|
||||
secret_access_key = str(self.config.get("secret_access_key", "")).strip()
|
||||
session_token = str(self.config.get("session_token", "")).strip()
|
||||
|
||||
if not region_name:
|
||||
region_name = _infer_region_from_queue_url(queue_url) or ""
|
||||
if region_name:
|
||||
kwargs["region_name"] = region_name
|
||||
if endpoint_url:
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.radio_sync import (
|
||||
from app.routers import (
|
||||
channels,
|
||||
contacts,
|
||||
debug,
|
||||
fanout,
|
||||
health,
|
||||
messages,
|
||||
@@ -136,6 +137,7 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
|
||||
|
||||
# API routes - all prefixed with /api for production compatibility
|
||||
app.include_router(health.router, prefix="/api")
|
||||
app.include_router(debug.router, prefix="/api")
|
||||
app.include_router(fanout.router, prefix="/api")
|
||||
app.include_router(radio.router, prefix="/api")
|
||||
app.include_router(contacts.router, prefix="/api")
|
||||
|
||||
+13
-5
@@ -428,8 +428,17 @@ class RepeaterStatusResponse(BaseModel):
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
|
||||
|
||||
class RepeaterNodeInfoResponse(BaseModel):
|
||||
"""Identity/location info from a repeater (small CLI batch)."""
|
||||
|
||||
name: str | None = Field(default=None, description="Repeater name")
|
||||
lat: str | None = Field(default=None, description="Latitude")
|
||||
lon: str | None = Field(default=None, description="Longitude")
|
||||
clock_utc: str | None = Field(default=None, description="Repeater clock in UTC")
|
||||
|
||||
|
||||
class RepeaterRadioSettingsResponse(BaseModel):
|
||||
"""Radio settings from a repeater (batch CLI get commands)."""
|
||||
"""Radio settings from a repeater (radio/config CLI batch)."""
|
||||
|
||||
firmware_version: str | None = Field(default=None, description="Firmware version string")
|
||||
radio: str | None = Field(default=None, description="Radio settings (freq,bw,sf,cr)")
|
||||
@@ -437,10 +446,6 @@ class RepeaterRadioSettingsResponse(BaseModel):
|
||||
airtime_factor: str | None = Field(default=None, description="Airtime factor")
|
||||
repeat_enabled: str | None = Field(default=None, description="Repeat mode enabled")
|
||||
flood_max: str | None = Field(default=None, description="Max flood hops")
|
||||
name: str | None = Field(default=None, description="Repeater name")
|
||||
lat: str | None = Field(default=None, description="Latitude")
|
||||
lon: str | None = Field(default=None, description="Longitude")
|
||||
clock_utc: str | None = Field(default=None, description="Repeater clock in UTC")
|
||||
|
||||
|
||||
class RepeaterAdvertIntervalsResponse(BaseModel):
|
||||
@@ -554,6 +559,9 @@ class UnreadCounts(BaseModel):
|
||||
last_message_times: dict[str, int] = Field(
|
||||
default_factory=dict, description="Map of stateKey -> last message timestamp"
|
||||
)
|
||||
last_read_ats: dict[str, int | None] = Field(
|
||||
default_factory=dict, description="Map of stateKey -> server-side last_read_at boundary"
|
||||
)
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
|
||||
+16
-1
@@ -40,7 +40,10 @@ from app.repository import (
|
||||
ContactRepository,
|
||||
RawPacketRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import record_contact_name_and_reconcile
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.messages import (
|
||||
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
|
||||
)
|
||||
@@ -504,6 +507,10 @@ async def _process_advertisement(
|
||||
)
|
||||
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=advert.public_key,
|
||||
log=logger,
|
||||
)
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=advert.public_key,
|
||||
contact_name=advert.name,
|
||||
@@ -516,6 +523,14 @@ async def _process_advertisement(
|
||||
db_contact = await ContactRepository.get_by_key(advert.public_key.lower())
|
||||
if db_contact:
|
||||
broadcast_event("contact", db_contact.model_dump())
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": db_contact.model_dump(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
broadcast_event(
|
||||
"contact",
|
||||
|
||||
+123
@@ -2,6 +2,7 @@ import asyncio
|
||||
import glob
|
||||
import logging
|
||||
import platform
|
||||
from collections import OrderedDict
|
||||
from contextlib import asynccontextmanager, nullcontext
|
||||
from pathlib import Path
|
||||
|
||||
@@ -129,8 +130,12 @@ class RadioManager:
|
||||
self._setup_lock: asyncio.Lock | None = None
|
||||
self._setup_in_progress: bool = False
|
||||
self._setup_complete: bool = False
|
||||
self.max_channels: int = 40
|
||||
self.path_hash_mode: int = 0
|
||||
self.path_hash_mode_supported: bool = False
|
||||
self._channel_slot_by_key: OrderedDict[str, int] = OrderedDict()
|
||||
self._channel_key_by_slot: dict[int, str] = {}
|
||||
self._pending_message_channel_key_by_slot: dict[int, str] = {}
|
||||
|
||||
async def _acquire_operation_lock(
|
||||
self,
|
||||
@@ -223,6 +228,121 @@ class RadioManager:
|
||||
|
||||
await run_post_connect_setup(self)
|
||||
|
||||
def reset_channel_send_cache(self) -> None:
|
||||
"""Forget any session-local channel-slot reuse state."""
|
||||
self._channel_slot_by_key.clear()
|
||||
self._channel_key_by_slot.clear()
|
||||
|
||||
def remember_pending_message_channel_slot(self, channel_key: str, slot: int) -> None:
|
||||
"""Remember a channel key for later queued-message recovery."""
|
||||
self._pending_message_channel_key_by_slot[slot] = channel_key.upper()
|
||||
|
||||
def get_pending_message_channel_key(self, slot: int) -> str | None:
|
||||
"""Return the last remembered channel key for a radio slot."""
|
||||
return self._pending_message_channel_key_by_slot.get(slot)
|
||||
|
||||
def clear_pending_message_channel_slots(self) -> None:
|
||||
"""Drop any queued-message recovery slot metadata."""
|
||||
self._pending_message_channel_key_by_slot.clear()
|
||||
|
||||
def channel_slot_reuse_enabled(self) -> bool:
|
||||
"""Return whether this transport can safely reuse cached channel slots."""
|
||||
if settings.force_channel_slot_reconfigure:
|
||||
return False
|
||||
if self._connection_info:
|
||||
return not self._connection_info.startswith("TCP:")
|
||||
return settings.connection_type != "tcp"
|
||||
|
||||
def get_channel_send_cache_capacity(self) -> int:
|
||||
"""Return the app-managed channel cache capacity for the current session."""
|
||||
try:
|
||||
return max(1, int(self.max_channels))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
def get_cached_channel_slot(self, channel_key: str) -> int | None:
|
||||
"""Return the cached radio slot for a channel key, if present."""
|
||||
return self._channel_slot_by_key.get(channel_key.upper())
|
||||
|
||||
def plan_channel_send_slot(
|
||||
self,
|
||||
channel_key: str,
|
||||
*,
|
||||
preferred_slot: int = 0,
|
||||
) -> tuple[int, bool, str | None]:
|
||||
"""Choose a radio slot for a channel send.
|
||||
|
||||
Returns `(slot, needs_configure, evicted_channel_key)`.
|
||||
"""
|
||||
if not self.channel_slot_reuse_enabled():
|
||||
return preferred_slot, True, None
|
||||
|
||||
normalized_key = channel_key.upper()
|
||||
cached_slot = self._channel_slot_by_key.get(normalized_key)
|
||||
if cached_slot is not None:
|
||||
return cached_slot, False, None
|
||||
|
||||
capacity = self.get_channel_send_cache_capacity()
|
||||
if len(self._channel_slot_by_key) < capacity:
|
||||
slot = self._find_first_free_channel_slot(capacity, preferred_slot)
|
||||
return slot, True, None
|
||||
|
||||
evicted_key, slot = next(iter(self._channel_slot_by_key.items()))
|
||||
return slot, True, evicted_key
|
||||
|
||||
def note_channel_slot_loaded(self, channel_key: str, slot: int) -> None:
|
||||
"""Record that a channel is now resident in the given radio slot."""
|
||||
if not self.channel_slot_reuse_enabled():
|
||||
return
|
||||
|
||||
normalized_key = channel_key.upper()
|
||||
previous_slot = self._channel_slot_by_key.pop(normalized_key, None)
|
||||
if previous_slot is not None and previous_slot != slot:
|
||||
self._channel_key_by_slot.pop(previous_slot, None)
|
||||
|
||||
displaced_key = self._channel_key_by_slot.get(slot)
|
||||
if displaced_key is not None and displaced_key != normalized_key:
|
||||
self._channel_slot_by_key.pop(displaced_key, None)
|
||||
|
||||
self._channel_key_by_slot[slot] = normalized_key
|
||||
self._channel_slot_by_key[normalized_key] = slot
|
||||
|
||||
def note_channel_slot_used(self, channel_key: str) -> None:
|
||||
"""Refresh LRU order for a previously loaded channel slot."""
|
||||
if not self.channel_slot_reuse_enabled():
|
||||
return
|
||||
|
||||
normalized_key = channel_key.upper()
|
||||
slot = self._channel_slot_by_key.get(normalized_key)
|
||||
if slot is None:
|
||||
return
|
||||
self._channel_slot_by_key.move_to_end(normalized_key)
|
||||
self._channel_key_by_slot[slot] = normalized_key
|
||||
|
||||
def invalidate_cached_channel_slot(self, channel_key: str) -> None:
|
||||
"""Drop any cached slot assignment for a channel key."""
|
||||
normalized_key = channel_key.upper()
|
||||
slot = self._channel_slot_by_key.pop(normalized_key, None)
|
||||
if slot is None:
|
||||
return
|
||||
if self._channel_key_by_slot.get(slot) == normalized_key:
|
||||
self._channel_key_by_slot.pop(slot, None)
|
||||
|
||||
def get_channel_send_cache_snapshot(self) -> list[tuple[str, int]]:
|
||||
"""Return the current channel send cache contents in LRU order."""
|
||||
return list(self._channel_slot_by_key.items())
|
||||
|
||||
def _find_first_free_channel_slot(self, capacity: int, preferred_slot: int) -> int:
|
||||
"""Pick the first unclaimed app-managed slot, preferring the requested slot."""
|
||||
if preferred_slot < capacity and preferred_slot not in self._channel_key_by_slot:
|
||||
return preferred_slot
|
||||
|
||||
for slot in range(capacity):
|
||||
if slot not in self._channel_key_by_slot:
|
||||
return slot
|
||||
|
||||
return preferred_slot
|
||||
|
||||
@property
|
||||
def meshcore(self) -> MeshCore | None:
|
||||
return self._meshcore
|
||||
@@ -366,8 +486,11 @@ class RadioManager:
|
||||
await self._disable_meshcore_auto_reconnect(mc)
|
||||
self._meshcore = None
|
||||
self._setup_complete = False
|
||||
self.max_channels = 40
|
||||
self.path_hash_mode = 0
|
||||
self.path_hash_mode_supported = False
|
||||
self.reset_channel_send_cache()
|
||||
self.clear_pending_message_channel_slots()
|
||||
logger.debug("Radio disconnected")
|
||||
|
||||
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
|
||||
+214
-31
@@ -28,11 +28,14 @@ from app.repository import (
|
||||
ContactRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.messages import create_fallback_channel_message
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_error
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_CHANNELS = 40
|
||||
|
||||
|
||||
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
"""Return key contact fields for sync failure diagnostics."""
|
||||
@@ -48,7 +51,6 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
"last_advert": contact.last_advert,
|
||||
"lat": contact.lat,
|
||||
"lon": contact.lon,
|
||||
"on_radio": contact.on_radio,
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +100,20 @@ async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> st
|
||||
return key_hex
|
||||
|
||||
|
||||
def get_radio_channel_limit(max_channels: int | None = None) -> int:
|
||||
"""Return the effective channel-slot limit for the connected firmware."""
|
||||
discovered = getattr(radio_manager, "max_channels", DEFAULT_MAX_CHANNELS)
|
||||
try:
|
||||
limit = max(1, int(discovered))
|
||||
except (TypeError, ValueError):
|
||||
limit = DEFAULT_MAX_CHANNELS
|
||||
|
||||
if max_channels is not None:
|
||||
return min(limit, max(1, int(max_channels)))
|
||||
|
||||
return limit
|
||||
|
||||
|
||||
# Message poll task handle
|
||||
_message_poll_task: asyncio.Task | None = None
|
||||
|
||||
@@ -285,7 +301,7 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
return {"synced": synced, "removed": removed}
|
||||
|
||||
|
||||
async def sync_and_offload_channels(mc: MeshCore) -> dict:
|
||||
async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict:
|
||||
"""
|
||||
Sync channels from radio to database, then clear them from radio.
|
||||
Returns counts of synced and cleared channels.
|
||||
@@ -294,8 +310,11 @@ async def sync_and_offload_channels(mc: MeshCore) -> dict:
|
||||
cleared = 0
|
||||
|
||||
try:
|
||||
# Check all 40 channel slots
|
||||
for idx in range(40):
|
||||
radio_manager.reset_channel_send_cache()
|
||||
channel_limit = get_radio_channel_limit(max_channels)
|
||||
|
||||
# Check all available channel slots for this firmware variant
|
||||
for idx in range(channel_limit):
|
||||
result = await mc.commands.get_channel(idx)
|
||||
|
||||
if result.type != EventType.CHANNEL_INFO:
|
||||
@@ -308,6 +327,7 @@ async def sync_and_offload_channels(mc: MeshCore) -> dict:
|
||||
if key_hex is None:
|
||||
continue
|
||||
|
||||
radio_manager.remember_pending_message_channel_slot(key_hex, idx)
|
||||
synced += 1
|
||||
logger.debug("Synced channel %s: %s", key_hex[:8], result.payload.get("channel_name"))
|
||||
|
||||
@@ -334,12 +354,94 @@ async def sync_and_offload_channels(mc: MeshCore) -> dict:
|
||||
return {"synced": synced, "cleared": cleared}
|
||||
|
||||
|
||||
def _split_channel_sender_and_text(text: str) -> tuple[str | None, str]:
|
||||
"""Parse the canonical MeshCore "<sender>: <message>" channel text format."""
|
||||
sender = None
|
||||
message_text = text
|
||||
colon_idx = text.find(": ")
|
||||
if 0 < colon_idx < 50:
|
||||
potential_sender = text[:colon_idx]
|
||||
if not any(char in potential_sender for char in ":[]\x00"):
|
||||
sender = potential_sender
|
||||
message_text = text[colon_idx + 2 :]
|
||||
return sender, message_text
|
||||
|
||||
|
||||
async def _resolve_channel_for_pending_message(
|
||||
mc: MeshCore,
|
||||
channel_idx: int,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Resolve a pending channel message's slot to a channel key and name."""
|
||||
try:
|
||||
result = await mc.commands.get_channel(channel_idx)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to fetch channel slot %s for pending message: %s", channel_idx, exc)
|
||||
else:
|
||||
if result.type == EventType.CHANNEL_INFO:
|
||||
key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=False)
|
||||
if key_hex is not None:
|
||||
radio_manager.remember_pending_message_channel_slot(key_hex, channel_idx)
|
||||
return key_hex, result.payload.get("channel_name") or None
|
||||
|
||||
current_slot_map = getattr(radio_manager, "_channel_key_by_slot", {})
|
||||
cached_key = current_slot_map.get(channel_idx)
|
||||
if cached_key is None:
|
||||
cached_key = radio_manager.get_pending_message_channel_key(channel_idx)
|
||||
if cached_key is None:
|
||||
return None, None
|
||||
|
||||
channel = await ChannelRepository.get_by_key(cached_key)
|
||||
return cached_key, channel.name if channel else None
|
||||
|
||||
|
||||
async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
||||
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
|
||||
channel_idx = payload.get("channel_idx")
|
||||
if channel_idx is None:
|
||||
logger.warning("Pending channel message missing channel_idx; dropping payload")
|
||||
return
|
||||
|
||||
try:
|
||||
normalized_channel_idx = int(channel_idx)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Pending channel message had invalid channel_idx=%r", channel_idx)
|
||||
return
|
||||
|
||||
channel_key, channel_name = await _resolve_channel_for_pending_message(
|
||||
mc, normalized_channel_idx
|
||||
)
|
||||
if channel_key is None:
|
||||
logger.warning(
|
||||
"Could not resolve channel slot %d for pending message; message cannot be stored",
|
||||
normalized_channel_idx,
|
||||
)
|
||||
return
|
||||
|
||||
received_at = int(time.time())
|
||||
sender_timestamp = payload.get("sender_timestamp") or received_at
|
||||
sender_name, message_text = _split_channel_sender_and_text(payload.get("text", ""))
|
||||
|
||||
await create_fallback_channel_message(
|
||||
conversation_key=channel_key,
|
||||
message_text=message_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=payload.get("path"),
|
||||
path_len=payload.get("path_len"),
|
||||
txt_type=payload.get("txt_type", 0),
|
||||
sender_name=sender_name,
|
||||
channel_name=channel_name,
|
||||
broadcast_fn=broadcast_event,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_default_channels() -> None:
|
||||
"""
|
||||
Ensure default channels exist in the database.
|
||||
These will be configured on the radio when needed for sending.
|
||||
|
||||
The Public channel is protected - it always exists with the canonical name.
|
||||
This seeds the canonical Public channel row in the database if it is missing
|
||||
or misnamed. It does not make the channel undeletable through the router.
|
||||
"""
|
||||
# Public channel - no hashtag, specific well-known key
|
||||
PUBLIC_CHANNEL_KEY_HEX = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
|
||||
@@ -360,16 +462,19 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
"""Sync and offload both contacts and channels, then ensure defaults exist."""
|
||||
logger.info("Starting full radio sync and offload")
|
||||
|
||||
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
||||
# cycle so old rows stop claiming radio residency we do not actively track.
|
||||
await ContactRepository.clear_on_radio_except([])
|
||||
|
||||
contacts_result = await sync_and_offload_contacts(mc)
|
||||
channels_result = await sync_and_offload_channels(mc)
|
||||
|
||||
# Ensure default channels exist
|
||||
await ensure_default_channels()
|
||||
|
||||
# Reload favorites plus a working-set fill back onto the radio immediately
|
||||
# so they do not stay in on_radio=False limbo after offload. Pass mc directly
|
||||
# since the caller already holds the radio operation lock (asyncio.Lock is not
|
||||
# reentrant).
|
||||
# Reload favorites plus a working-set fill back onto the radio immediately.
|
||||
# Pass mc directly since the caller already holds the radio operation lock
|
||||
# (asyncio.Lock is not reentrant).
|
||||
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
|
||||
|
||||
return {
|
||||
@@ -399,6 +504,8 @@ async def drain_pending_messages(mc: MeshCore) -> int:
|
||||
logger.debug("Error during message drain: %s", result.payload)
|
||||
break
|
||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
await _store_pending_channel_message(mc, result.payload)
|
||||
count += 1
|
||||
|
||||
# Small delay between fetches
|
||||
@@ -434,6 +541,8 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
||||
elif result.type == EventType.ERROR:
|
||||
return 0
|
||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
await _store_pending_channel_message(mc, result.payload)
|
||||
count += 1
|
||||
# If we got a message, there might be more - drain them
|
||||
count += await drain_pending_messages(mc)
|
||||
@@ -446,6 +555,63 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def _normalize_channel_secret(payload: dict) -> bytes:
|
||||
"""Return a normalized bytes representation of a radio channel secret."""
|
||||
secret = payload.get("channel_secret", b"")
|
||||
if isinstance(secret, bytes):
|
||||
return secret
|
||||
return bytes(secret)
|
||||
|
||||
|
||||
async def audit_channel_send_cache(mc: MeshCore) -> bool:
|
||||
"""Verify cached send-slot expectations still match radio channel contents.
|
||||
|
||||
If a mismatch is detected, the app's send-slot cache is reset so future sends
|
||||
fall back to reloading channels before reuse resumes.
|
||||
"""
|
||||
if not radio_manager.channel_slot_reuse_enabled():
|
||||
return True
|
||||
|
||||
cached_slots = radio_manager.get_channel_send_cache_snapshot()
|
||||
if not cached_slots:
|
||||
return True
|
||||
|
||||
mismatches: list[str] = []
|
||||
for channel_key, slot in cached_slots:
|
||||
result = await mc.commands.get_channel(slot)
|
||||
if result.type != EventType.CHANNEL_INFO:
|
||||
mismatches.append(
|
||||
f"slot {slot}: expected {channel_key[:8]} but radio returned {result.type}"
|
||||
)
|
||||
continue
|
||||
|
||||
observed_name = result.payload.get("channel_name") or ""
|
||||
observed_key = _normalize_channel_secret(result.payload).hex().upper()
|
||||
expected_channel = await ChannelRepository.get_by_key(channel_key)
|
||||
expected_name = expected_channel.name if expected_channel is not None else None
|
||||
|
||||
if observed_key != channel_key or expected_name is None or observed_name != expected_name:
|
||||
mismatches.append(
|
||||
f"slot {slot}: expected {expected_name or '(missing db row)'} "
|
||||
f"{channel_key[:8]}, got {observed_name or '(empty)'} {observed_key[:8]}"
|
||||
)
|
||||
|
||||
if not mismatches:
|
||||
return True
|
||||
|
||||
logger.error(
|
||||
"[RADIO SYNC ERROR] A periodic radio audit discovered that the channel send-slot cache fell out of sync with radio state. This indicates that some other system, internal or external to the radio, has updated the channel slots on the radio (which the app assumes it has exclusive rights to, except on TCP-linked devices). The cache is resetting now, but you should review the README.md and consider using the environment variable MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true to make the radio use non-optimistic channel management and force-write the channel to radio before each send. This is a minor performance hit, but guarantees consistency. Mismatches found: %s",
|
||||
"; ".join(mismatches),
|
||||
)
|
||||
radio_manager.reset_channel_send_cache()
|
||||
broadcast_error(
|
||||
"A periodic poll task has discovered radio inconsistencies.",
|
||||
"Please check the logs for recommendations (search "
|
||||
"'MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE').",
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _message_poll_loop():
|
||||
"""Background task that periodically polls for messages."""
|
||||
while True:
|
||||
@@ -463,6 +629,7 @@ async def _message_poll_loop():
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
count = await poll_for_messages(mc)
|
||||
await audit_channel_send_cache(mc)
|
||||
if count > 0:
|
||||
if aggressive_fallback:
|
||||
logger.warning(
|
||||
@@ -471,10 +638,10 @@ async def _message_poll_loop():
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Periodic radio audit caught %d message(s) that were not "
|
||||
"surfaced via event subscription. See README and consider "
|
||||
"[RADIO SYNC ERROR] Periodic radio audit caught %d message(s) that were not "
|
||||
"surfaced via event subscription. This means that the method of event (new contacts, messages, etc.) awareness we want isn't giving us everything. There is a fallback method available; see README.md and consider "
|
||||
"setting MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true to "
|
||||
"enable more frequent polling.",
|
||||
"enable active radio polling every few seconds.",
|
||||
count,
|
||||
)
|
||||
broadcast_error(
|
||||
@@ -604,7 +771,6 @@ async def _periodic_advert_loop():
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in periodic advertisement loop: %s", e, exc_info=True)
|
||||
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
|
||||
|
||||
|
||||
def start_periodic_advert():
|
||||
@@ -699,20 +865,8 @@ _last_contact_sync: float = 0.0
|
||||
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||
|
||||
|
||||
async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
"""
|
||||
Core logic for loading contacts onto the radio.
|
||||
|
||||
Fill order is:
|
||||
1. Favorite contacts
|
||||
2. Most recently interacted-with non-repeaters
|
||||
3. Most recently advert-heard non-repeaters without interaction history
|
||||
|
||||
Favorite contacts are always reloaded first, up to the configured capacity.
|
||||
Additional non-favorite fill stops at the refill target (80% of capacity).
|
||||
|
||||
Caller must hold the radio operation lock and pass a valid MeshCore instance.
|
||||
"""
|
||||
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||
"""Return the contacts that would be loaded onto the radio right now."""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
max_contacts = app_settings.max_radio_contacts
|
||||
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||
@@ -733,6 +887,12 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
continue
|
||||
if not contact:
|
||||
continue
|
||||
if len(contact.public_key) < 64:
|
||||
logger.debug(
|
||||
"Skipping unresolved prefix-only favorite contact '%s' for radio sync",
|
||||
favorite.id,
|
||||
)
|
||||
continue
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
@@ -773,6 +933,24 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
refill_target,
|
||||
max_contacts,
|
||||
)
|
||||
return selected_contacts
|
||||
|
||||
|
||||
async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
"""
|
||||
Core logic for loading contacts onto the radio.
|
||||
|
||||
Fill order is:
|
||||
1. Favorite contacts
|
||||
2. Most recently interacted-with non-repeaters
|
||||
3. Most recently advert-heard non-repeaters without interaction history
|
||||
|
||||
Favorite contacts are always reloaded first, up to the configured capacity.
|
||||
Additional non-favorite fill stops at the refill target (80% of capacity).
|
||||
|
||||
Caller must hold the radio operation lock and pass a valid MeshCore instance.
|
||||
"""
|
||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||
return await _load_contacts_to_radio(mc, selected_contacts)
|
||||
|
||||
|
||||
@@ -802,6 +980,9 @@ async def ensure_contact_on_radio(
|
||||
if not contact:
|
||||
logger.debug("Cannot sync favorite contact %s: not found", public_key[:12])
|
||||
return {"loaded": 0, "error": "Contact not found"}
|
||||
if len(contact.public_key) < 64:
|
||||
logger.debug("Cannot sync unresolved prefix-only contact %s to radio", public_key)
|
||||
return {"loaded": 0, "error": "Full contact key not yet known"}
|
||||
|
||||
if mc is not None:
|
||||
_last_contact_sync = now
|
||||
@@ -834,11 +1015,14 @@ async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict
|
||||
failed = 0
|
||||
|
||||
for contact in contacts:
|
||||
if len(contact.public_key) < 64:
|
||||
logger.debug(
|
||||
"Skipping unresolved prefix-only contact %s during radio load", contact.public_key
|
||||
)
|
||||
continue
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
already_on_radio += 1
|
||||
if not contact.on_radio:
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -846,7 +1030,6 @@ async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict
|
||||
result = await mc.commands.add_contact(radio_contact_payload)
|
||||
if result.type == EventType.OK:
|
||||
loaded += 1
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
@@ -66,6 +66,30 @@ class ChannelRepository:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_on_radio() -> list[Channel]:
|
||||
"""Return channels currently marked as resident on the radio in the database."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
FROM channels
|
||||
WHERE on_radio = 1
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
Channel(
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> None:
|
||||
"""Delete a channel by key."""
|
||||
|
||||
+102
-10
@@ -168,6 +168,9 @@ class ContactRepository:
|
||||
the prefix (to avoid silently selecting the wrong contact).
|
||||
"""
|
||||
normalized_prefix = prefix.lower()
|
||||
exact = await ContactRepository.get_by_key(normalized_prefix)
|
||||
if exact:
|
||||
return exact
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT 2",
|
||||
(f"{normalized_prefix}%",),
|
||||
@@ -258,7 +261,7 @@ class ContactRepository:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2 AND last_contacted IS NOT NULL
|
||||
WHERE type != 2 AND last_contacted IS NOT NULL AND length(public_key) = 64
|
||||
ORDER BY last_contacted DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
@@ -273,7 +276,7 @@ class ContactRepository:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2 AND last_advert IS NOT NULL
|
||||
WHERE type != 2 AND last_advert IS NOT NULL AND length(public_key) = 64
|
||||
ORDER BY last_advert DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
@@ -349,14 +352,6 @@ class ContactRepository:
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_on_radio(public_key: str, on_radio: bool) -> None:
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET on_radio = ? WHERE public_key = ?",
|
||||
(on_radio, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def clear_on_radio_except(keep_keys: list[str]) -> None:
|
||||
"""Set on_radio=False for all contacts NOT in keep_keys."""
|
||||
@@ -406,6 +401,103 @@ class ContactRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def promote_prefix_placeholders(full_key: str) -> list[str]:
|
||||
"""Promote prefix-only placeholder contacts to a resolved full key.
|
||||
|
||||
Returns the placeholder public keys that were merged into the full key.
|
||||
"""
|
||||
normalized_full_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT public_key, last_seen, last_contacted, first_seen, last_read_at
|
||||
FROM contacts
|
||||
WHERE length(public_key) < 64
|
||||
AND ? LIKE public_key || '%'
|
||||
ORDER BY length(public_key) DESC, public_key
|
||||
""",
|
||||
(normalized_full_key,),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
promoted_keys: list[str] = []
|
||||
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
|
||||
|
||||
for row in rows:
|
||||
old_key = row["public_key"]
|
||||
if old_key == normalized_full_key:
|
||||
continue
|
||||
|
||||
match_cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS match_count
|
||||
FROM contacts
|
||||
WHERE length(public_key) = 64
|
||||
AND public_key LIKE ? || '%'
|
||||
""",
|
||||
(old_key,),
|
||||
)
|
||||
match_row = await match_cursor.fetchone()
|
||||
if (match_row["match_count"] if match_row is not None else 0) != 1:
|
||||
continue
|
||||
|
||||
if full_exists:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET last_seen = CASE
|
||||
WHEN contacts.last_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_seen
|
||||
WHEN ? > contacts.last_seen THEN ?
|
||||
ELSE contacts.last_seen
|
||||
END,
|
||||
last_contacted = CASE
|
||||
WHEN contacts.last_contacted IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_contacted
|
||||
WHEN ? > contacts.last_contacted THEN ?
|
||||
ELSE contacts.last_contacted
|
||||
END,
|
||||
first_seen = CASE
|
||||
WHEN contacts.first_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.first_seen
|
||||
WHEN ? < contacts.first_seen THEN ?
|
||||
ELSE contacts.first_seen
|
||||
END,
|
||||
last_read_at = COALESCE(contacts.last_read_at, ?)
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["last_read_at"],
|
||||
normalized_full_key,
|
||||
),
|
||||
)
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||
else:
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
|
||||
(normalized_full_key, old_key),
|
||||
)
|
||||
full_exists = True
|
||||
|
||||
promoted_keys.append(old_key)
|
||||
|
||||
await db.conn.commit()
|
||||
return promoted_keys
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all contacts as read at the given timestamp."""
|
||||
|
||||
@@ -162,7 +162,8 @@ class MessageRepository:
|
||||
AND ? LIKE conversation_key || '%'
|
||||
AND (
|
||||
SELECT COUNT(*) FROM contacts
|
||||
WHERE public_key LIKE messages.conversation_key || '%'
|
||||
WHERE length(public_key) = 64
|
||||
AND public_key LIKE messages.conversation_key || '%'
|
||||
) = 1""",
|
||||
(lower_key, lower_key),
|
||||
)
|
||||
@@ -179,8 +180,16 @@ class MessageRepository:
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET sender_key = ?
|
||||
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL""",
|
||||
(public_key.lower(), name),
|
||||
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL
|
||||
AND (
|
||||
SELECT COUNT(*) FROM contacts
|
||||
WHERE name = ?
|
||||
) = 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM contacts
|
||||
WHERE public_key = ? AND name = ?
|
||||
)""",
|
||||
(public_key.lower(), name, name, public_key.lower(), name),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
@@ -570,11 +579,12 @@ class MessageRepository:
|
||||
blocked_names: Display names whose messages should be excluded from counts.
|
||||
|
||||
Returns:
|
||||
Dict with 'counts', 'mentions', and 'last_message_times' keys.
|
||||
Dict with 'counts', 'mentions', 'last_message_times', and 'last_read_ats' keys.
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
mention_flags: dict[str, bool] = {}
|
||||
last_message_times: dict[str, int] = {}
|
||||
last_read_ats: dict[str, int | None] = {}
|
||||
|
||||
mention_token = f"@[{name}]" if name else None
|
||||
|
||||
@@ -652,13 +662,81 @@ class MessageRepository:
|
||||
if mention_token and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Last message times for all conversations (including read ones)
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, last_read_at
|
||||
FROM channels
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
last_read_ats[f"channel-{row['key']}"] = row["last_read_at"]
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT public_key, last_read_at
|
||||
FROM contacts
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
last_read_ats[f"contact-{row['public_key']}"] = row["last_read_at"]
|
||||
|
||||
# Last message times for all conversations (including read ones),
|
||||
# excluding blocked incoming traffic so refresh matches live WS behavior.
|
||||
last_time_filters: list[str] = []
|
||||
last_time_params: list[Any] = []
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
last_time_filters.append(
|
||||
f"""
|
||||
NOT (
|
||||
type = 'PRIV'
|
||||
AND outgoing = 0
|
||||
AND LOWER(conversation_key) IN ({placeholders})
|
||||
)
|
||||
"""
|
||||
)
|
||||
last_time_params.extend(blocked_keys)
|
||||
last_time_filters.append(
|
||||
f"""
|
||||
NOT (
|
||||
type = 'CHAN'
|
||||
AND outgoing = 0
|
||||
AND sender_key IS NOT NULL
|
||||
AND LOWER(sender_key) IN ({placeholders})
|
||||
)
|
||||
"""
|
||||
)
|
||||
last_time_params.extend(blocked_keys)
|
||||
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
last_time_filters.append(
|
||||
f"""
|
||||
NOT (
|
||||
type = 'CHAN'
|
||||
AND outgoing = 0
|
||||
AND sender_name IS NOT NULL
|
||||
AND sender_name IN ({placeholders})
|
||||
)
|
||||
"""
|
||||
)
|
||||
last_time_params.extend(blocked_names)
|
||||
|
||||
last_time_where_sql = (
|
||||
f"WHERE {' AND '.join(last_time_filters)}" if last_time_filters else ""
|
||||
)
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
SELECT type, conversation_key, MAX(received_at) as last_message_time
|
||||
FROM messages
|
||||
{last_time_where_sql}
|
||||
GROUP BY type, conversation_key
|
||||
"""
|
||||
""",
|
||||
last_time_params,
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
@@ -670,6 +748,7 @@ class MessageRepository:
|
||||
"counts": counts,
|
||||
"mentions": mention_flags,
|
||||
"last_message_times": last_message_times,
|
||||
"last_read_ats": last_read_ats,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -778,6 +857,19 @@ class MessageRepository:
|
||||
"top_senders_24h": top_senders,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def count_channels_with_incoming_messages() -> int:
|
||||
"""Count distinct channel conversations with at least one incoming message."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT conversation_key) AS cnt
|
||||
FROM messages
|
||||
WHERE type = 'CHAN' AND outgoing = 0
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row["cnt"]) if row and row["cnt"] is not None else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_most_active_rooms(sender_key: str, limit: int = 5) -> list[tuple[str, str, int]]:
|
||||
"""Get channels where a contact has sent the most messages.
|
||||
|
||||
+10
-44
@@ -1,22 +1,22 @@
|
||||
import logging
|
||||
from hashlib import sha256
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||
from app.radio_sync import upsert_channel_from_radio_slot
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import ChannelRepository, MessageRepository
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
|
||||
|
||||
def _broadcast_channel_update(channel: Channel) -> None:
|
||||
broadcast_event("channel", channel.model_dump())
|
||||
|
||||
|
||||
class CreateChannelRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=32)
|
||||
key: str | None = Field(
|
||||
@@ -55,15 +55,6 @@ async def get_channel_detail(key: str) -> ChannelDetail:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=Channel)
|
||||
async def get_channel(key: str) -> Channel:
|
||||
"""Get a specific channel by key (32-char hex string)."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return channel
|
||||
|
||||
|
||||
@router.post("", response_model=Channel)
|
||||
async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
"""Create a channel in the database.
|
||||
@@ -98,37 +89,12 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
on_radio=False,
|
||||
)
|
||||
|
||||
return Channel(
|
||||
key=key_hex,
|
||||
name=request.name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
flood_scope_override=None,
|
||||
)
|
||||
stored = await ChannelRepository.get_by_key(key_hex)
|
||||
if stored is None:
|
||||
raise HTTPException(status_code=500, detail="Channel was created but could not be reloaded")
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_channels_from_radio(max_channels: int = Query(default=40, ge=1, le=40)) -> dict:
|
||||
"""Sync channels from the radio to the database."""
|
||||
require_connected()
|
||||
|
||||
logger.info("Syncing channels from radio (checking %d slots)", max_channels)
|
||||
count = 0
|
||||
|
||||
async with radio_manager.radio_operation("sync_channels_from_radio") as mc:
|
||||
for idx in range(max_channels):
|
||||
result = await mc.commands.get_channel(idx)
|
||||
|
||||
if result.type == EventType.CHANNEL_INFO:
|
||||
key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=True)
|
||||
if key_hex is not None:
|
||||
count += 1
|
||||
logger.debug(
|
||||
"Synced channel %s: %s", key_hex, result.payload.get("channel_name")
|
||||
)
|
||||
|
||||
logger.info("Synced %d channels from radio", count)
|
||||
return {"synced": count}
|
||||
_broadcast_channel_update(stored)
|
||||
return stored
|
||||
|
||||
|
||||
@router.post("/{key}/mark-read")
|
||||
|
||||
+40
-150
@@ -8,14 +8,11 @@ from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactAnalytics,
|
||||
ContactDetail,
|
||||
ContactRoutingOverrideRequest,
|
||||
ContactUpsert,
|
||||
CreateContactRequest,
|
||||
NameOnlyContactDetail,
|
||||
NearestRepeater,
|
||||
TraceResponse,
|
||||
)
|
||||
@@ -28,7 +25,10 @@ from app.repository import (
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -66,8 +66,8 @@ async def _ensure_on_radio(mc, contact: Contact) -> None:
|
||||
|
||||
|
||||
async def _best_effort_push_contact_to_radio(contact: Contact, operation_name: str) -> None:
|
||||
"""Push the current effective route to the radio when the contact is already loaded."""
|
||||
if not radio_manager.is_connected or not contact.on_radio:
|
||||
"""Best-effort push the current effective route to the radio when connected."""
|
||||
if not radio_manager.is_connected:
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -93,6 +93,19 @@ async def _broadcast_contact_update(contact: Contact) -> None:
|
||||
broadcast_event("contact", contact.model_dump())
|
||||
|
||||
|
||||
async def _broadcast_contact_resolution(previous_public_keys: list[str], contact: Contact) -> None:
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
for old_key in previous_public_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": contact.model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _build_keyed_contact_analytics(contact: Contact) -> ContactAnalytics:
|
||||
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
|
||||
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
|
||||
@@ -257,12 +270,23 @@ async def create_contact(
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=request.public_key,
|
||||
log=logger,
|
||||
)
|
||||
if promoted_keys:
|
||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
await _broadcast_contact_resolution(promoted_keys, existing)
|
||||
|
||||
# Trigger historical decryption if requested (even for existing contacts)
|
||||
if request.try_historical:
|
||||
await start_historical_dm_decryption(
|
||||
background_tasks, request.public_key, request.name or existing.name
|
||||
)
|
||||
|
||||
await _broadcast_contact_update(existing)
|
||||
return existing
|
||||
|
||||
# Create new contact
|
||||
@@ -275,6 +299,10 @@ async def create_contact(
|
||||
)
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
logger.info("Created contact %s", lower_key[:12])
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=lower_key,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
await reconcile_contact_messages(
|
||||
public_key=lower_key,
|
||||
@@ -286,150 +314,12 @@ async def create_contact(
|
||||
if request.try_historical:
|
||||
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
|
||||
|
||||
return Contact(**contact_upsert.model_dump())
|
||||
|
||||
|
||||
@router.get("/{public_key}/detail", response_model=ContactDetail)
|
||||
async def get_contact_detail(public_key: str) -> ContactDetail:
|
||||
"""Get comprehensive contact profile data.
|
||||
|
||||
Returns contact info, name history, message counts, most active rooms,
|
||||
advertisement paths, advert frequency, and nearest repeaters.
|
||||
"""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
analytics = await _build_keyed_contact_analytics(contact)
|
||||
assert analytics.contact is not None
|
||||
return ContactDetail(
|
||||
contact=analytics.contact,
|
||||
name_history=analytics.name_history,
|
||||
dm_message_count=analytics.dm_message_count,
|
||||
channel_message_count=analytics.channel_message_count,
|
||||
most_active_rooms=analytics.most_active_rooms,
|
||||
advert_paths=analytics.advert_paths,
|
||||
advert_frequency=analytics.advert_frequency,
|
||||
nearest_repeaters=analytics.nearest_repeaters,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/name-detail", response_model=NameOnlyContactDetail)
|
||||
async def get_name_only_contact_detail(
|
||||
name: str = Query(min_length=1, max_length=200),
|
||||
) -> NameOnlyContactDetail:
|
||||
"""Get channel activity summary for a sender name without a resolved key."""
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
analytics = await _build_name_only_contact_analytics(normalized_name)
|
||||
return NameOnlyContactDetail(
|
||||
name=analytics.name,
|
||||
channel_message_count=analytics.channel_message_count,
|
||||
most_active_rooms=analytics.most_active_rooms,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=Contact)
|
||||
async def get_contact(public_key: str) -> Contact:
|
||||
"""Get a specific contact by public key or prefix."""
|
||||
return await _resolve_contact_or_404(public_key)
|
||||
|
||||
|
||||
@router.get("/{public_key}/advert-paths", response_model=list[ContactAdvertPath])
|
||||
async def get_contact_advert_paths(
|
||||
public_key: str,
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
) -> list[ContactAdvertPath]:
|
||||
"""List recent unique advert paths for a contact."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
return await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key, limit)
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_contacts_from_radio() -> dict:
|
||||
"""Sync contacts from the radio to the database."""
|
||||
require_connected()
|
||||
|
||||
logger.info("Syncing contacts from radio")
|
||||
|
||||
async with radio_manager.radio_operation("sync_contacts_from_radio") as mc:
|
||||
result = await mc.commands.get_contacts()
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get contacts: {result.payload}")
|
||||
|
||||
contacts = result.payload
|
||||
count = 0
|
||||
|
||||
synced_keys: list[str] = []
|
||||
for public_key, contact_data in contacts.items():
|
||||
lower_key = public_key.lower()
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert.from_radio_dict(lower_key, contact_data, on_radio=True)
|
||||
)
|
||||
synced_keys.append(lower_key)
|
||||
await reconcile_contact_messages(
|
||||
public_key=lower_key,
|
||||
contact_name=contact_data.get("adv_name"),
|
||||
log=logger,
|
||||
)
|
||||
count += 1
|
||||
|
||||
# Clear on_radio for contacts not found on the radio
|
||||
await ContactRepository.clear_on_radio_except(synced_keys)
|
||||
|
||||
logger.info("Synced %d contacts from radio", count)
|
||||
return {"synced": count}
|
||||
|
||||
|
||||
@router.post("/{public_key}/remove-from-radio")
|
||||
async def remove_contact_from_radio(public_key: str) -> dict:
|
||||
"""Remove a contact from the radio (keeps it in database)."""
|
||||
require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
async with radio_manager.radio_operation("remove_contact_from_radio") as mc:
|
||||
# Get the contact from radio
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not radio_contact:
|
||||
# Already not on radio
|
||||
await ContactRepository.set_on_radio(contact.public_key, False)
|
||||
return {"status": "ok", "message": "Contact was not on radio"}
|
||||
|
||||
logger.info("Removing contact %s from radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.remove_contact(radio_contact)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to remove contact: {result.payload}"
|
||||
)
|
||||
|
||||
await ContactRepository.set_on_radio(contact.public_key, False)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/{public_key}/add-to-radio")
|
||||
async def add_contact_to_radio(public_key: str) -> dict:
|
||||
"""Add a contact from the database to the radio."""
|
||||
require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key, "Contact not found in database")
|
||||
|
||||
async with radio_manager.radio_operation("add_contact_to_radio") as mc:
|
||||
# Check if already on radio
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
return {"status": "ok", "message": "Contact already on radio"}
|
||||
|
||||
logger.info("Adding contact %s to radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add contact: {result.payload}")
|
||||
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
return {"status": "ok"}
|
||||
stored = await ContactRepository.get_by_key(lower_key)
|
||||
if stored is None:
|
||||
raise HTTPException(status_code=500, detail="Contact was created but could not be reloaded")
|
||||
await _broadcast_contact_update(stored)
|
||||
await _broadcast_contact_resolution(promoted_keys, stored)
|
||||
return stored
|
||||
|
||||
|
||||
@router.post("/{public_key}/mark-read")
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from meshcore import EventType
|
||||
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.routers.health import HealthResponse, build_health_data
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["debug"])
|
||||
|
||||
|
||||
class DebugApplicationInfo(BaseModel):
|
||||
version: str
|
||||
commit_hash: str | None = None
|
||||
git_branch: str | None = None
|
||||
git_dirty: bool | None = None
|
||||
python_version: str
|
||||
|
||||
|
||||
class DebugRuntimeInfo(BaseModel):
|
||||
connection_info: str | None = None
|
||||
connection_desired: bool
|
||||
setup_in_progress: bool
|
||||
setup_complete: bool
|
||||
channels_with_incoming_messages: int
|
||||
max_channels: int
|
||||
path_hash_mode: int
|
||||
path_hash_mode_supported: bool
|
||||
channel_slot_reuse_enabled: bool
|
||||
channel_send_cache_capacity: int
|
||||
remediation_flags: dict[str, bool]
|
||||
|
||||
|
||||
class DebugContactAudit(BaseModel):
|
||||
expected_and_found: int
|
||||
expected_but_not_found: list[str]
|
||||
found_but_not_expected: list[str]
|
||||
|
||||
|
||||
class DebugChannelSlotMismatch(BaseModel):
|
||||
slot_number: int
|
||||
expected_sha256_of_room_key: str | None = None
|
||||
actual_sha256_of_room_key: str | None = None
|
||||
|
||||
|
||||
class DebugChannelAudit(BaseModel):
|
||||
matched_slots: int
|
||||
wrong_slots: list[DebugChannelSlotMismatch]
|
||||
|
||||
|
||||
class DebugRadioProbe(BaseModel):
|
||||
performed: bool
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
self_info: dict[str, Any] | None = None
|
||||
device_info: dict[str, Any] | None = None
|
||||
stats_core: dict[str, Any] | None = None
|
||||
stats_radio: dict[str, Any] | None = None
|
||||
contacts: DebugContactAudit | None = None
|
||||
channels: DebugChannelAudit | None = None
|
||||
|
||||
|
||||
class DebugSnapshotResponse(BaseModel):
|
||||
captured_at: str
|
||||
application: DebugApplicationInfo
|
||||
health: HealthResponse
|
||||
runtime: DebugRuntimeInfo
|
||||
radio_probe: DebugRadioProbe
|
||||
logs: list[str]
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _get_app_version() -> str:
|
||||
try:
|
||||
return importlib.metadata.version("remoteterm-meshcore")
|
||||
except Exception:
|
||||
pyproject = _repo_root() / "pyproject.toml"
|
||||
try:
|
||||
for line in pyproject.read_text().splitlines():
|
||||
if line.startswith("version = "):
|
||||
return line.split('"')[1]
|
||||
except Exception:
|
||||
pass
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def _git_output(*args: str) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=_repo_root(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
output = result.stdout.strip()
|
||||
return output or None
|
||||
|
||||
|
||||
def _build_application_info() -> DebugApplicationInfo:
|
||||
dirty_output = _git_output("status", "--porcelain")
|
||||
return DebugApplicationInfo(
|
||||
version=_get_app_version(),
|
||||
commit_hash=_git_output("rev-parse", "HEAD"),
|
||||
git_branch=_git_output("rev-parse", "--abbrev-ref", "HEAD"),
|
||||
git_dirty=(dirty_output is not None and dirty_output != ""),
|
||||
python_version=sys.version.split()[0],
|
||||
)
|
||||
|
||||
|
||||
def _event_type_name(event: Any) -> str:
|
||||
event_type = getattr(event, "type", None)
|
||||
return getattr(event_type, "name", str(event_type))
|
||||
|
||||
|
||||
def _sha256_hex(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _normalize_channel_secret(payload: dict[str, Any]) -> bytes:
|
||||
secret = payload.get("channel_secret", b"")
|
||||
if isinstance(secret, bytes):
|
||||
return secret
|
||||
return bytes(secret)
|
||||
|
||||
|
||||
def _is_empty_channel_payload(payload: dict[str, Any]) -> bool:
|
||||
name = payload.get("channel_name", "")
|
||||
return not name or name == "\x00" * len(name)
|
||||
|
||||
|
||||
def _observed_channel_key(event: Any) -> str | None:
|
||||
if getattr(event, "type", None) != EventType.CHANNEL_INFO:
|
||||
return None
|
||||
|
||||
payload = event.payload or {}
|
||||
if _is_empty_channel_payload(payload):
|
||||
return None
|
||||
|
||||
return _normalize_channel_secret(payload).hex().upper()
|
||||
|
||||
|
||||
def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
||||
if not device_info or "max_channels" not in device_info:
|
||||
return None
|
||||
try:
|
||||
return int(device_info["max_channels"])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def _build_contact_audit(
|
||||
observed_contacts_payload: dict[str, dict[str, Any]],
|
||||
) -> DebugContactAudit:
|
||||
expected_contacts = await get_contacts_selected_for_radio_sync()
|
||||
expected_keys = {contact.public_key.lower() for contact in expected_contacts}
|
||||
observed_keys = {public_key.lower() for public_key in observed_contacts_payload}
|
||||
|
||||
return DebugContactAudit(
|
||||
expected_and_found=len(expected_keys & observed_keys),
|
||||
expected_but_not_found=sorted(_sha256_hex(key) for key in (expected_keys - observed_keys)),
|
||||
found_but_not_expected=sorted(_sha256_hex(key) for key in (observed_keys - expected_keys)),
|
||||
)
|
||||
|
||||
|
||||
async def _build_channel_audit(mc: Any, max_channels: int | None = None) -> DebugChannelAudit:
|
||||
cache_key_by_slot = {
|
||||
slot: channel_key for channel_key, slot in radio_runtime.get_channel_send_cache_snapshot()
|
||||
}
|
||||
|
||||
matched_slots = 0
|
||||
wrong_slots: list[DebugChannelSlotMismatch] = []
|
||||
for slot in range(get_radio_channel_limit(max_channels)):
|
||||
event = await mc.commands.get_channel(slot)
|
||||
expected_key = cache_key_by_slot.get(slot)
|
||||
observed_key = _observed_channel_key(event)
|
||||
if expected_key == observed_key:
|
||||
matched_slots += 1
|
||||
continue
|
||||
wrong_slots.append(
|
||||
DebugChannelSlotMismatch(
|
||||
slot_number=slot,
|
||||
expected_sha256_of_room_key=_sha256_hex(expected_key) if expected_key else None,
|
||||
actual_sha256_of_room_key=_sha256_hex(observed_key) if observed_key else None,
|
||||
)
|
||||
)
|
||||
|
||||
return DebugChannelAudit(
|
||||
matched_slots=matched_slots,
|
||||
wrong_slots=wrong_slots,
|
||||
)
|
||||
|
||||
|
||||
async def _probe_radio() -> DebugRadioProbe:
|
||||
if not radio_runtime.is_connected:
|
||||
return DebugRadioProbe(performed=False, errors=["Radio not connected"])
|
||||
|
||||
errors: list[str] = []
|
||||
try:
|
||||
async with radio_runtime.radio_operation(
|
||||
"debug_support_snapshot",
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
device_info = None
|
||||
stats_core = None
|
||||
stats_radio = None
|
||||
|
||||
device_event = await mc.commands.send_device_query()
|
||||
if getattr(device_event, "type", None) == EventType.DEVICE_INFO:
|
||||
device_info = device_event.payload
|
||||
else:
|
||||
errors.append(f"send_device_query returned {_event_type_name(device_event)}")
|
||||
|
||||
core_event = await mc.commands.get_stats_core()
|
||||
if getattr(core_event, "type", None) == EventType.STATS_CORE:
|
||||
stats_core = core_event.payload
|
||||
else:
|
||||
errors.append(f"get_stats_core returned {_event_type_name(core_event)}")
|
||||
|
||||
radio_event = await mc.commands.get_stats_radio()
|
||||
if getattr(radio_event, "type", None) == EventType.STATS_RADIO:
|
||||
stats_radio = radio_event.payload
|
||||
else:
|
||||
errors.append(f"get_stats_radio returned {_event_type_name(radio_event)}")
|
||||
|
||||
contacts_event = await mc.commands.get_contacts()
|
||||
observed_contacts_payload: dict[str, dict[str, Any]] = {}
|
||||
if getattr(contacts_event, "type", None) != EventType.ERROR:
|
||||
observed_contacts_payload = contacts_event.payload or {}
|
||||
else:
|
||||
errors.append(f"get_contacts returned {_event_type_name(contacts_event)}")
|
||||
|
||||
return DebugRadioProbe(
|
||||
performed=True,
|
||||
errors=errors,
|
||||
self_info=dict(mc.self_info or {}),
|
||||
device_info=device_info,
|
||||
stats_core=stats_core,
|
||||
stats_radio=stats_radio,
|
||||
contacts=await _build_contact_audit(observed_contacts_payload),
|
||||
channels=await _build_channel_audit(
|
||||
mc,
|
||||
max_channels=_coerce_live_max_channels(device_info),
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Debug support snapshot radio probe failed: %s", exc, exc_info=True)
|
||||
errors.append(str(exc))
|
||||
return DebugRadioProbe(performed=False, errors=errors)
|
||||
|
||||
|
||||
@router.get("/debug", response_model=DebugSnapshotResponse)
|
||||
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)
|
||||
radio_probe = await _probe_radio()
|
||||
channels_with_incoming_messages = (
|
||||
await MessageRepository.count_channels_with_incoming_messages()
|
||||
)
|
||||
return DebugSnapshotResponse(
|
||||
captured_at=datetime.now(timezone.utc).isoformat(),
|
||||
application=_build_application_info(),
|
||||
health=HealthResponse(**health_data),
|
||||
runtime=DebugRuntimeInfo(
|
||||
connection_info=radio_runtime.connection_info,
|
||||
connection_desired=radio_runtime.connection_desired,
|
||||
setup_in_progress=radio_runtime.is_setup_in_progress,
|
||||
setup_complete=radio_runtime.is_setup_complete,
|
||||
channels_with_incoming_messages=channels_with_incoming_messages,
|
||||
max_channels=radio_runtime.max_channels,
|
||||
path_hash_mode=radio_runtime.path_hash_mode,
|
||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||
remediation_flags={
|
||||
"enable_message_poll_fallback": settings.enable_message_poll_fallback,
|
||||
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
|
||||
},
|
||||
),
|
||||
radio_probe=radio_probe,
|
||||
logs=get_recent_log_lines(limit=1000),
|
||||
)
|
||||
+65
-2
@@ -1,5 +1,7 @@
|
||||
"""REST API for fanout config CRUD."""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import string
|
||||
@@ -8,6 +10,7 @@ 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.repository.fanout import FanoutConfigRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -144,18 +147,78 @@ def _validate_mqtt_community_config(config: dict) -> None:
|
||||
|
||||
|
||||
def _validate_bot_config(config: dict) -> None:
|
||||
"""Validate bot config blob (syntax-check the code)."""
|
||||
"""Validate bot config blob (syntax-check the code and supported signature)."""
|
||||
code = config.get("code", "")
|
||||
if not code or not code.strip():
|
||||
raise HTTPException(status_code=400, detail="Bot code cannot be empty")
|
||||
try:
|
||||
compile(code, "<bot_code>", "exec")
|
||||
tree = ast.parse(code, filename="<bot_code>", mode="exec")
|
||||
except SyntaxError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
|
||||
) from None
|
||||
|
||||
bot_def = next(
|
||||
(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "bot"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if bot_def is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Bot code must define a callable bot() function. "
|
||||
"Use the default bot template as a reference."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
parameters: list[inspect.Parameter] = []
|
||||
positional_args = [
|
||||
*((arg, inspect.Parameter.POSITIONAL_ONLY) for arg in bot_def.args.posonlyargs),
|
||||
*((arg, inspect.Parameter.POSITIONAL_OR_KEYWORD) for arg in bot_def.args.args),
|
||||
]
|
||||
positional_defaults_start = len(positional_args) - len(bot_def.args.defaults)
|
||||
sentinel_default = object()
|
||||
|
||||
for index, (arg, kind) in enumerate(positional_args):
|
||||
has_default = index >= positional_defaults_start
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
arg.arg,
|
||||
kind=kind,
|
||||
default=sentinel_default if has_default else inspect.Parameter.empty,
|
||||
)
|
||||
)
|
||||
if bot_def.args.vararg is not None:
|
||||
parameters.append(
|
||||
inspect.Parameter(bot_def.args.vararg.arg, kind=inspect.Parameter.VAR_POSITIONAL)
|
||||
)
|
||||
for kwonly_arg, kw_default in zip(
|
||||
bot_def.args.kwonlyargs, bot_def.args.kw_defaults, strict=True
|
||||
):
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
kwonly_arg.arg,
|
||||
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||
default=(
|
||||
sentinel_default if kw_default is not None else inspect.Parameter.empty
|
||||
),
|
||||
)
|
||||
)
|
||||
if bot_def.args.kwarg is not None:
|
||||
parameters.append(
|
||||
inspect.Parameter(bot_def.args.kwarg.arg, kind=inspect.Parameter.VAR_KEYWORD)
|
||||
)
|
||||
|
||||
_analyze_bot_signature(inspect.Signature(parameters))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
|
||||
|
||||
def _validate_apprise_config(config: dict) -> None:
|
||||
"""Validate apprise config blob."""
|
||||
|
||||
+8
-10
@@ -108,6 +108,11 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Contact not found in database: {request.destination}"
|
||||
)
|
||||
if len(db_contact.public_key) < 64:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot send to an unresolved prefix-only contact until a full key is known",
|
||||
)
|
||||
|
||||
return await send_direct_message_to_contact(
|
||||
contact=db_contact,
|
||||
@@ -121,7 +126,9 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
)
|
||||
|
||||
|
||||
# Temporary radio slot used for sending channel messages
|
||||
# Preferred first radio slot used for sending channel messages.
|
||||
# The send service may reuse/load other app-managed slots depending on transport
|
||||
# and session cache state.
|
||||
TEMP_RADIO_SLOT = 0
|
||||
|
||||
|
||||
@@ -131,7 +138,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
require_connected()
|
||||
|
||||
# Get channel info from our database
|
||||
from app.decoder import calculate_channel_hash
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
db_channel = await ChannelRepository.get_by_key(request.channel_key)
|
||||
@@ -148,14 +154,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
status_code=400, detail=f"Invalid channel key format: {request.channel_key}"
|
||||
) from None
|
||||
|
||||
expected_hash = calculate_channel_hash(key_bytes)
|
||||
logger.info(
|
||||
"Sending to channel %s (%s) via radio slot %d, key hash: %s",
|
||||
request.channel_key,
|
||||
db_channel.name,
|
||||
TEMP_RADIO_SLOT,
|
||||
expected_hash,
|
||||
)
|
||||
return await send_channel_message_to_channel(
|
||||
channel=db_channel,
|
||||
channel_key_upper=request.channel_key.upper(),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -19,6 +20,8 @@ from app.websocket import broadcast_health
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/radio", tags=["radio"])
|
||||
|
||||
AdvertLocationSource = Literal["off", "current"]
|
||||
|
||||
|
||||
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
||||
return await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
|
||||
@@ -51,6 +54,10 @@ class RadioConfigResponse(BaseModel):
|
||||
path_hash_mode_supported: bool = Field(
|
||||
default=False, description="Whether firmware supports path hash mode setting"
|
||||
)
|
||||
advert_location_source: AdvertLocationSource = Field(
|
||||
default="current",
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
|
||||
|
||||
class RadioConfigUpdate(BaseModel):
|
||||
@@ -65,6 +72,10 @@ class RadioConfigUpdate(BaseModel):
|
||||
le=2,
|
||||
description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)",
|
||||
)
|
||||
advert_location_source: AdvertLocationSource | None = Field(
|
||||
default=None,
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyUpdate(BaseModel):
|
||||
@@ -80,6 +91,9 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
if not info:
|
||||
raise HTTPException(status_code=503, detail="Radio info not available")
|
||||
|
||||
adv_loc_policy = info.get("adv_loc_policy", 1)
|
||||
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
|
||||
|
||||
return RadioConfigResponse(
|
||||
public_key=info.get("public_key", ""),
|
||||
name=info.get("name", ""),
|
||||
@@ -95,6 +109,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.models import (
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterNeighborsResponse,
|
||||
RepeaterNodeInfoResponse,
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
@@ -373,9 +374,29 @@ async def _batch_cli_fetch(
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
||||
"""Fetch repeater identity/location info via a small CLI batch."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
results = await _batch_cli_fetch(
|
||||
contact,
|
||||
"repeater_node_info",
|
||||
[
|
||||
("get name", "name"),
|
||||
("get lat", "lat"),
|
||||
("get lon", "lon"),
|
||||
("clock", "clock_utc"),
|
||||
],
|
||||
)
|
||||
return RepeaterNodeInfoResponse(**results)
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
|
||||
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
|
||||
"""Fetch radio settings from a repeater via batch CLI commands."""
|
||||
"""Fetch radio settings from a repeater via radio/config CLI commands."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
@@ -390,10 +411,6 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
|
||||
("get af", "airtime_factor"),
|
||||
("get repeat", "repeat_enabled"),
|
||||
("get flood.max", "flood_max"),
|
||||
("get name", "name"),
|
||||
("get lat", "lat"),
|
||||
("get lon", "lon"),
|
||||
("clock", "clock_utc"),
|
||||
],
|
||||
)
|
||||
return RepeaterRadioSettingsResponse(**results)
|
||||
|
||||
@@ -2,11 +2,29 @@
|
||||
|
||||
import logging
|
||||
|
||||
from app.repository import ContactNameHistoryRepository, MessageRepository
|
||||
from app.repository import ContactNameHistoryRepository, ContactRepository, MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def promote_prefix_contacts_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_repository=ContactRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> list[str]:
|
||||
"""Promote prefix-only placeholder contacts once a full key is known."""
|
||||
normalized_key = public_key.lower()
|
||||
promoted = await contact_repository.promote_prefix_placeholders(normalized_key)
|
||||
if promoted:
|
||||
(log or logger).info(
|
||||
"Promoted %d prefix contact placeholder(s) for %s",
|
||||
len(promoted),
|
||||
normalized_key[:12],
|
||||
)
|
||||
return promoted
|
||||
|
||||
|
||||
async def claim_prefix_messages_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
|
||||
@@ -26,10 +26,12 @@ async def send_channel_message_with_effective_scope(
|
||||
*,
|
||||
mc,
|
||||
channel,
|
||||
channel_key: str,
|
||||
key_bytes: bytes,
|
||||
text: str,
|
||||
timestamp_bytes: bytes,
|
||||
action_label: str,
|
||||
radio_manager,
|
||||
temp_radio_slot: int,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
app_settings_repository=AppSettingsRepository,
|
||||
@@ -64,28 +66,64 @@ async def send_channel_message_with_effective_scope(
|
||||
)
|
||||
|
||||
try:
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=temp_radio_slot,
|
||||
channel_name=channel.name,
|
||||
channel_secret=key_bytes,
|
||||
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
|
||||
channel_key,
|
||||
preferred_slot=temp_radio_slot,
|
||||
)
|
||||
if set_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to set channel on radio slot %d before %s: %s",
|
||||
temp_radio_slot,
|
||||
if needs_configure:
|
||||
logger.debug(
|
||||
"Loading channel %s into radio slot %d before %s%s",
|
||||
channel.name,
|
||||
channel_slot,
|
||||
action_label,
|
||||
set_result.payload,
|
||||
(
|
||||
f" (evicting cached {evicted_channel_key[:8]})"
|
||||
if evicted_channel_key is not None
|
||||
else ""
|
||||
),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to configure channel on radio before {action_label}",
|
||||
try:
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=channel_slot,
|
||||
channel_name=channel.name,
|
||||
channel_secret=key_bytes,
|
||||
)
|
||||
except Exception:
|
||||
if evicted_channel_key is not None:
|
||||
radio_manager.invalidate_cached_channel_slot(evicted_channel_key)
|
||||
raise
|
||||
if set_result.type == EventType.ERROR:
|
||||
if evicted_channel_key is not None:
|
||||
radio_manager.invalidate_cached_channel_slot(evicted_channel_key)
|
||||
logger.warning(
|
||||
"Failed to set channel on radio slot %d before %s: %s",
|
||||
channel_slot,
|
||||
action_label,
|
||||
set_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to configure channel on radio before {action_label}",
|
||||
)
|
||||
radio_manager.note_channel_slot_loaded(channel_key, channel_slot)
|
||||
else:
|
||||
logger.debug(
|
||||
"Reusing cached radio slot %d for channel %s before %s",
|
||||
channel_slot,
|
||||
channel.name,
|
||||
action_label,
|
||||
)
|
||||
|
||||
return await mc.commands.send_chan_msg(
|
||||
chan=temp_radio_slot,
|
||||
send_result = await mc.commands.send_chan_msg(
|
||||
chan=channel_slot,
|
||||
msg=text,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
if send_result.type == EventType.ERROR:
|
||||
radio_manager.invalidate_cached_channel_slot(channel_key)
|
||||
else:
|
||||
radio_manager.note_channel_slot_used(channel_key)
|
||||
return send_result
|
||||
finally:
|
||||
if override_scope and override_scope != baseline_scope:
|
||||
try:
|
||||
@@ -137,20 +175,15 @@ async def send_direct_message_to_contact(
|
||||
) -> Any:
|
||||
"""Send a direct message and persist/broadcast the outgoing row."""
|
||||
contact_data = contact.to_radio_dict()
|
||||
contact_ensured_on_radio = False
|
||||
async with radio_manager.radio_operation("send_direct_message") as mc:
|
||||
logger.debug("Ensuring contact %s is on radio before sending", contact.public_key[:12])
|
||||
add_result = await mc.commands.add_contact(contact_data)
|
||||
if add_result.type == EventType.ERROR:
|
||||
logger.warning("Failed to add contact to radio: %s", add_result.payload)
|
||||
else:
|
||||
contact_ensured_on_radio = True
|
||||
|
||||
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not cached_contact:
|
||||
cached_contact = contact_data
|
||||
else:
|
||||
contact_ensured_on_radio = True
|
||||
|
||||
logger.info("Sending direct message to %s", contact.public_key[:12])
|
||||
now = int(now_fn())
|
||||
@@ -163,9 +196,6 @@ async def send_direct_message_to_contact(
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
if contact_ensured_on_radio and not contact.on_radio:
|
||||
await contact_repository.set_on_radio(contact.public_key.lower(), True)
|
||||
|
||||
message = await create_outgoing_direct_message(
|
||||
conversation_key=contact.public_key.lower(),
|
||||
text=text,
|
||||
@@ -206,7 +236,6 @@ async def send_channel_message_to_channel(
|
||||
message_repository=MessageRepository,
|
||||
) -> Any:
|
||||
"""Send a channel message and persist/broadcast the outgoing row."""
|
||||
message_id: int | None = None
|
||||
now: int | None = None
|
||||
radio_name = ""
|
||||
our_public_key: str | None = None
|
||||
@@ -224,10 +253,12 @@ async def send_channel_message_to_channel(
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
channel_key=channel_key_upper,
|
||||
key_bytes=key_bytes,
|
||||
text=text,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="sending message",
|
||||
radio_manager=radio_manager,
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
@@ -235,27 +266,27 @@ async def send_channel_message_to_channel(
|
||||
if result.type == EventType.ERROR:
|
||||
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=now,
|
||||
received_at=now,
|
||||
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",
|
||||
)
|
||||
message_id = outgoing_message.id
|
||||
|
||||
if message_id is None or now is None:
|
||||
if now is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
outgoing_message = await create_outgoing_channel_message(
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
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",
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -314,10 +345,12 @@ async def resend_channel_message_record(
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
channel_key=message.conversation_key,
|
||||
key_bytes=key_bytes,
|
||||
text=text_to_send,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="resending message",
|
||||
radio_manager=radio_manager,
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
|
||||
@@ -127,7 +127,7 @@ async def increment_ack_and_broadcast(
|
||||
|
||||
async def handle_duplicate_message(
|
||||
*,
|
||||
packet_id: int,
|
||||
packet_id: int | None,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
@@ -179,7 +179,8 @@ async def handle_duplicate_message(
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
if packet_id is not None:
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
|
||||
|
||||
async def create_message_from_decrypted(
|
||||
@@ -396,6 +397,73 @@ async def create_fallback_direct_message(
|
||||
return message
|
||||
|
||||
|
||||
async def create_fallback_channel_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
message_text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
txt_type: int,
|
||||
sender_name: str | None,
|
||||
channel_name: str | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast a CHANNEL_MSG_RECV fallback channel message."""
|
||||
conversation_key_normalized = conversation_key.upper()
|
||||
text = f"{sender_name}: {message_text}" if sender_name else message_text
|
||||
|
||||
resolved_sender_key: str | None = None
|
||||
if sender_name:
|
||||
candidates = await ContactRepository.get_by_name(sender_name)
|
||||
if len(candidates) == 1:
|
||||
resolved_sender_key = candidates[0].public_key
|
||||
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=conversation_key_normalized,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
sender_name=sender_name,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
if msg_id is None:
|
||||
await handle_duplicate_message(
|
||||
packet_id=None,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
message = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=build_message_paths(path, received_at, path_len),
|
||||
txt_type=txt_type,
|
||||
sender_name=sender_name,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
|
||||
|
||||
async def create_outgoing_direct_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
|
||||
@@ -32,6 +32,18 @@ async def apply_radio_config_update(
|
||||
sync_radio_time_fn: Callable[[Any], Awaitable[Any]],
|
||||
) -> None:
|
||||
"""Apply a validated radio-config update to the connected radio."""
|
||||
if update.advert_location_source is not None:
|
||||
advert_loc_policy = 0 if update.advert_location_source == "off" else 1
|
||||
logger.info(
|
||||
"Setting advert location policy to %s",
|
||||
update.advert_location_source,
|
||||
)
|
||||
result = await mc.commands.set_advert_loc_policy(advert_loc_policy)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(
|
||||
f"Failed to set advert location policy: {result.payload}"
|
||||
)
|
||||
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
|
||||
@@ -32,16 +32,19 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
return
|
||||
radio_manager._setup_in_progress = True
|
||||
radio_manager._setup_complete = False
|
||||
mc = radio_manager.meshcore
|
||||
try:
|
||||
# Register event handlers (no radio I/O, just callback setup)
|
||||
register_event_handlers(mc)
|
||||
|
||||
# Hold the operation lock for all radio I/O during setup.
|
||||
# This prevents user-initiated operations (send message, etc.)
|
||||
# from interleaving commands on the serial link.
|
||||
await radio_manager._acquire_operation_lock("post_connect_setup", blocking=True)
|
||||
try:
|
||||
mc = radio_manager.meshcore
|
||||
if not mc:
|
||||
return
|
||||
|
||||
# Register event handlers against the locked, current transport.
|
||||
register_event_handlers(mc)
|
||||
|
||||
await export_and_store_private_key(mc)
|
||||
|
||||
# Sync radio clock with system time
|
||||
@@ -75,17 +78,25 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
return await _original_handle_rx(data)
|
||||
|
||||
reader.handle_rx = _capture_handle_rx
|
||||
radio_manager.max_channels = 40
|
||||
radio_manager.path_hash_mode = 0
|
||||
radio_manager.path_hash_mode_supported = False
|
||||
try:
|
||||
device_query = await mc.commands.send_device_query()
|
||||
if device_query and "max_channels" in device_query.payload:
|
||||
radio_manager.max_channels = max(
|
||||
1, int(device_query.payload["max_channels"])
|
||||
)
|
||||
if device_query and "path_hash_mode" in device_query.payload:
|
||||
radio_manager.path_hash_mode = device_query.payload["path_hash_mode"]
|
||||
radio_manager.path_hash_mode_supported = True
|
||||
elif _captured_frame:
|
||||
# Raw-frame fallback: byte 1 = fw_ver, byte 81 = path_hash_mode
|
||||
# Raw-frame fallback:
|
||||
# byte 1 = fw_ver, byte 3 = max_channels, byte 81 = path_hash_mode
|
||||
raw = _captured_frame[-1]
|
||||
fw_ver = raw[1] if len(raw) > 1 else 0
|
||||
if fw_ver >= 3 and len(raw) >= 4:
|
||||
radio_manager.max_channels = max(1, raw[3])
|
||||
if fw_ver >= 10 and len(raw) >= 82:
|
||||
radio_manager.path_hash_mode = raw[81]
|
||||
radio_manager.path_hash_mode_supported = True
|
||||
@@ -103,8 +114,9 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode)
|
||||
else:
|
||||
logger.debug("Firmware does not report path_hash_mode")
|
||||
logger.info("Max channel slots: %d", radio_manager.max_channels)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to query path_hash_mode: %s", exc)
|
||||
logger.debug("Failed to query device info capabilities: %s", exc)
|
||||
finally:
|
||||
reader.handle_rx = _original_handle_rx
|
||||
|
||||
@@ -125,6 +137,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
radio_manager.clear_pending_message_channel_slots()
|
||||
|
||||
await mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
|
||||
+15
-5
@@ -74,6 +74,7 @@ frontend/src/
|
||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||
│ ├── a11y.ts # Keyboard accessibility helper
|
||||
@@ -105,6 +106,7 @@ frontend/src/
|
||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||
│ ├── settings/
|
||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||
@@ -120,7 +122,8 @@ frontend/src/
|
||||
│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
|
||||
│ │ ├── RepeaterNeighborsPane.tsx # Neighbor table + lazy mini-map
|
||||
│ │ ├── RepeaterAclPane.tsx # Permission table
|
||||
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio settings + advert intervals
|
||||
│ │ ├── RepeaterNodeInfoPane.tsx # Repeater name, coords, clock drift
|
||||
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio config + advert intervals
|
||||
│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
|
||||
│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
|
||||
│ │ ├── RepeaterActionsPane.tsx # Send Advert, Sync Clock, Reboot
|
||||
@@ -136,9 +139,13 @@ frontend/src/
|
||||
├── appFavorites.test.tsx
|
||||
├── appStartupHash.test.tsx
|
||||
├── contactAvatar.test.ts
|
||||
├── contactInfoPane.test.tsx
|
||||
├── integration.test.ts
|
||||
├── mapView.test.tsx
|
||||
├── messageCache.test.ts
|
||||
├── messageList.test.tsx
|
||||
├── messageParser.test.ts
|
||||
├── rawPacketList.test.tsx
|
||||
├── pathUtils.test.ts
|
||||
├── prefetch.test.ts
|
||||
├── radioPresets.test.ts
|
||||
@@ -152,12 +159,14 @@ frontend/src/
|
||||
├── newMessageModal.test.tsx
|
||||
├── settingsModal.test.tsx
|
||||
├── sidebar.test.tsx
|
||||
├── statusBar.test.tsx
|
||||
├── unreadCounts.test.ts
|
||||
├── urlHash.test.ts
|
||||
├── appSearchJump.test.tsx
|
||||
├── channelInfoKeyVisibility.test.tsx
|
||||
├── chatHeaderKeyVisibility.test.tsx
|
||||
├── searchView.test.tsx
|
||||
├── useConversationActions.test.ts
|
||||
├── useConversationMessages.test.ts
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useConversationNavigation.test.ts
|
||||
@@ -240,14 +249,15 @@ High-level state is delegated to hooks:
|
||||
### Radio settings behavior
|
||||
|
||||
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
|
||||
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
|
||||
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
|
||||
|
||||
## WebSocket (`useWebSocket.ts`)
|
||||
|
||||
- Auto reconnect (3s) with cleanup guard on unmount.
|
||||
- Heartbeat ping every 30s.
|
||||
- Incoming JSON is parsed through `wsEvents.ts`, which returns a typed discriminated union for known events and a centralized `unknown` fallback.
|
||||
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
|
||||
- Incoming JSON is parsed through `wsEvents.ts`, which validates the top-level envelope and known event type strings, then casts payloads at the handler boundary. It does not schema-validate per-event payload shapes.
|
||||
- Event handlers: `health`, `message`, `contact`, `contact_resolved`, `channel`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
|
||||
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.
|
||||
|
||||
## URL Hash Navigation (`utils/urlHash.ts`)
|
||||
@@ -317,7 +327,7 @@ Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_config
|
||||
|
||||
## 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/{key}/detail`:
|
||||
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=...`:
|
||||
|
||||
- Header: avatar, name, public key, type badge, on-radio badge
|
||||
- Info grid: last seen, first heard, last contacted, distance, hops
|
||||
@@ -350,7 +360,7 @@ For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashbo
|
||||
|
||||
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
|
||||
|
||||
**Dashboard panes** (after login): Telemetry, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
|
||||
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
|
||||
|
||||
**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.1.1",
|
||||
"version": "3.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
+109
-1
@@ -18,7 +18,51 @@ import {
|
||||
import { AppShell } from './components/AppShell';
|
||||
import type { MessageInputHandle } from './components/MessageInput';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import type { Conversation, RawPacket } from './types';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { Conversation, Message, RawPacket } from './types';
|
||||
|
||||
interface ChannelUnreadMarker {
|
||||
channelId: string;
|
||||
lastReadAt: number | null;
|
||||
}
|
||||
|
||||
interface UnreadBoundaryBackfillParams {
|
||||
activeConversation: Conversation | null;
|
||||
unreadMarker: ChannelUnreadMarker | null;
|
||||
messages: Message[];
|
||||
messagesLoading: boolean;
|
||||
loadingOlder: boolean;
|
||||
hasOlderMessages: boolean;
|
||||
}
|
||||
|
||||
export function getUnreadBoundaryBackfillKey({
|
||||
activeConversation,
|
||||
unreadMarker,
|
||||
messages,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
}: UnreadBoundaryBackfillParams): string | null {
|
||||
if (activeConversation?.type !== 'channel') return null;
|
||||
if (!unreadMarker || unreadMarker.channelId !== activeConversation.id) return null;
|
||||
if (unreadMarker.lastReadAt === null) return null;
|
||||
if (messagesLoading || loadingOlder || !hasOlderMessages || messages.length === 0) return null;
|
||||
|
||||
const oldestLoadedMessage = messages.reduce(
|
||||
(oldest, msg) => {
|
||||
if (!oldest) return msg;
|
||||
if (msg.received_at < oldest.received_at) return msg;
|
||||
if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg;
|
||||
return oldest;
|
||||
},
|
||||
null as Message | null
|
||||
);
|
||||
|
||||
if (!oldestLoadedMessage) return null;
|
||||
if (oldestLoadedMessage.received_at <= unreadMarker.lastReadAt) return null;
|
||||
|
||||
return `${activeConversation.id}:${unreadMarker.lastReadAt}:${oldestLoadedMessage.id}`;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const quoteSearchOperatorValue = useCallback((value: string) => {
|
||||
@@ -27,6 +71,8 @@ export function App() {
|
||||
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||
const {
|
||||
notificationsSupported,
|
||||
notificationsPermission,
|
||||
@@ -191,12 +237,67 @@ export function App() {
|
||||
unreadCounts,
|
||||
mentions,
|
||||
lastMessageTimes,
|
||||
unreadLastReadAts,
|
||||
incrementUnread,
|
||||
renameConversationState,
|
||||
markAllRead,
|
||||
trackNewMessage,
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeConversation?.type !== 'channel') {
|
||||
setChannelUnreadMarker(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeChannelId = activeConversation.id;
|
||||
const activeChannelUnreadCount = unreadCounts[getStateKey('channel', activeChannelId)] ?? 0;
|
||||
|
||||
setChannelUnreadMarker((prev) => {
|
||||
if (prev?.channelId === activeChannelId) {
|
||||
return prev;
|
||||
}
|
||||
if (activeChannelUnreadCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channelId: activeChannelId,
|
||||
lastReadAt: unreadLastReadAts[getStateKey('channel', activeChannelId)] ?? null,
|
||||
};
|
||||
});
|
||||
}, [activeConversation, unreadCounts, unreadLastReadAts]);
|
||||
|
||||
useEffect(() => {
|
||||
lastUnreadBackfillAttemptRef.current = null;
|
||||
}, [activeConversation?.id, channelUnreadMarker?.channelId, channelUnreadMarker?.lastReadAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const backfillKey = getUnreadBoundaryBackfillKey({
|
||||
activeConversation,
|
||||
unreadMarker: channelUnreadMarker,
|
||||
messages,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
});
|
||||
|
||||
if (!backfillKey || lastUnreadBackfillAttemptRef.current === backfillKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastUnreadBackfillAttemptRef.current = backfillKey;
|
||||
void fetchOlderMessages();
|
||||
}, [
|
||||
activeConversation,
|
||||
channelUnreadMarker,
|
||||
messages,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
fetchOlderMessages,
|
||||
]);
|
||||
|
||||
const wsHandlers = useRealtimeAppState({
|
||||
prevHealthRef,
|
||||
setHealth,
|
||||
@@ -214,6 +315,7 @@ export function App() {
|
||||
addMessageIfNew,
|
||||
trackNewMessage,
|
||||
incrementUnread,
|
||||
renameConversationState,
|
||||
checkMention,
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
@@ -292,6 +394,11 @@ export function App() {
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
unreadMarkerLastReadAt:
|
||||
activeConversation?.type === 'channel' &&
|
||||
channelUnreadMarker?.channelId === activeConversation.id
|
||||
? channelUnreadMarker.lastReadAt
|
||||
: undefined,
|
||||
targetMessageId,
|
||||
hasNewerMessages,
|
||||
loadingNewer,
|
||||
@@ -310,6 +417,7 @@ export function App() {
|
||||
onLoadNewer: fetchNewerMessages,
|
||||
onJumpToBottom: jumpToBottom,
|
||||
onSendMessage: handleSendMessage,
|
||||
onDismissUnreadMarker: () => setChannelUnreadMarker(null),
|
||||
notificationsSupported,
|
||||
notificationsPermission,
|
||||
notificationsEnabled:
|
||||
|
||||
+5
-9
@@ -6,9 +6,7 @@ import type {
|
||||
CommandResponse,
|
||||
Contact,
|
||||
ContactAnalytics,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactDetail,
|
||||
FanoutConfig,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
@@ -17,7 +15,6 @@ import type {
|
||||
MessagesAroundResponse,
|
||||
MigratePreferencesRequest,
|
||||
MigratePreferencesResponse,
|
||||
NameOnlyContactDetail,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RepeaterAclResponse,
|
||||
@@ -25,6 +22,7 @@ import type {
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterNeighborsResponse,
|
||||
RepeaterNodeInfoResponse,
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
@@ -120,18 +118,12 @@ export const api = {
|
||||
fetchJson<ContactAdvertPathSummary[]>(
|
||||
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
||||
),
|
||||
getContactAdvertPaths: (publicKey: string, limit = 10) =>
|
||||
fetchJson<ContactAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
|
||||
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
||||
if (params.name) searchParams.set('name', params.name);
|
||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
|
||||
},
|
||||
getContactDetail: (publicKey: string) =>
|
||||
fetchJson<ContactDetail>(`/contacts/${publicKey}/detail`),
|
||||
getNameOnlyContactDetail: (name: string) =>
|
||||
fetchJson<NameOnlyContactDetail>(`/contacts/name-detail?name=${encodeURIComponent(name)}`),
|
||||
deleteContact: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
method: 'DELETE',
|
||||
@@ -352,6 +344,10 @@ export const api = {
|
||||
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterNodeInfo: (publicKey: string) =>
|
||||
fetchJson<RepeaterNodeInfoResponse>(`/contacts/${publicKey}/repeater/node-info`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterAcl: (publicKey: string) =>
|
||||
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/repeater/acl`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -178,7 +178,14 @@ export function AppShell({
|
||||
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
||||
|
||||
<Sheet open={sidebarOpen} onOpenChange={onSidebarOpenChange}>
|
||||
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="w-[280px] p-0 flex flex-col"
|
||||
hideCloseButton
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Sidebar navigation</SheetDescription>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bell, Globe2, Info, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { stripRegionScopePrefix } from '../utils/regionScope';
|
||||
import { isPrefixOnlyContact } from '../utils/pubkey';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types';
|
||||
@@ -46,6 +48,8 @@ export function ChatHeader({
|
||||
onOpenChannelInfo,
|
||||
}: ChatHeaderProps) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [contactStatusInline, setContactStatusInline] = useState(true);
|
||||
const keyTextRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setShowKey(false);
|
||||
@@ -62,6 +66,13 @@ export function ChatHeader({
|
||||
: null;
|
||||
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
|
||||
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
|
||||
const activeContact =
|
||||
conversation.type === 'contact'
|
||||
? contacts.find((contact) => contact.public_key === conversation.id)
|
||||
: null;
|
||||
const activeContactIsPrefixOnly = activeContact
|
||||
? isPrefixOnlyContact(activeContact.public_key)
|
||||
: false;
|
||||
|
||||
const titleClickable =
|
||||
(conversation.type === 'contact' && onOpenContactInfo) ||
|
||||
@@ -85,15 +96,60 @@ export function ChatHeader({
|
||||
onSetChannelFloodScopeOverride(conversation.id, nextValue);
|
||||
};
|
||||
|
||||
const handleOpenConversationInfo = () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
return;
|
||||
}
|
||||
if (conversation.type === 'channel' && onOpenChannelInfo) {
|
||||
onOpenChannelInfo(conversation.id);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (conversation.type !== 'contact') {
|
||||
setContactStatusInline(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
const keyElement = keyTextRef.current;
|
||||
if (!keyElement) return;
|
||||
const isTruncated = keyElement.scrollWidth > keyElement.clientWidth + 1;
|
||||
setContactStatusInline(!isTruncated);
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
const onResize = () => {
|
||||
window.requestAnimationFrame(measure);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
observer = new ResizeObserver(() => {
|
||||
window.requestAnimationFrame(measure);
|
||||
});
|
||||
if (keyTextRef.current?.parentElement) {
|
||||
observer.observe(keyTextRef.current.parentElement);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [conversation.id, conversation.type, showKey]);
|
||||
|
||||
return (
|
||||
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
|
||||
<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">
|
||||
{conversation.type === 'contact' && onOpenContactInfo && (
|
||||
<span
|
||||
className="flex-shrink-0 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-action-button flex-shrink-0 cursor-pointer rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onOpenContactInfo(conversation.id)}
|
||||
title="View contact info"
|
||||
aria-label={`View info for ${conversation.name}`}
|
||||
@@ -105,42 +161,41 @@ export function ChatHeader({
|
||||
contactType={contacts.find((c) => c.public_key === conversation.id)?.type}
|
||||
clickable
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<h2
|
||||
className={`flex shrink min-w-0 items-center gap-1.5 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
|
||||
role={titleClickable ? 'button' : undefined}
|
||||
tabIndex={titleClickable ? 0 : undefined}
|
||||
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
|
||||
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
|
||||
onClick={
|
||||
titleClickable
|
||||
? () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
|
||||
onOpenChannelInfo(conversation.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
activeChannel?.is_hashtag
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</span>
|
||||
{titleClickable && (
|
||||
<Info
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2 whitespace-nowrap">
|
||||
<h2 className="min-w-0 shrink font-semibold text-base">
|
||||
{titleClickable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
||||
aria-label={`View info for ${conversation.name}`}
|
||||
onClick={handleOpenConversationInfo}
|
||||
>
|
||||
<span className="truncate">
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
activeChannel?.is_hashtag
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</span>
|
||||
<Info
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="truncate">
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
activeChannel?.is_hashtag
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
@@ -156,6 +211,7 @@ export function ChatHeader({
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
ref={keyTextRef}
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -178,21 +234,25 @@ export function ChatHeader({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{conversation.type === 'contact' &&
|
||||
(() => {
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||
if (!contact) return null;
|
||||
return (
|
||||
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
|
||||
<ContactStatusInfo
|
||||
contact={contact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{conversation.type === 'contact' && activeContact && contactStatusInline && (
|
||||
<span className="min-w-0 flex-none text-[11px] text-muted-foreground">
|
||||
<ContactStatusInfo
|
||||
contact={activeContact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{conversation.type === 'contact' && activeContact && !contactStatusInline && (
|
||||
<span className="mt-0.5 min-w-0 text-[11px] text-muted-foreground">
|
||||
<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"
|
||||
@@ -214,12 +274,17 @@ export function ChatHeader({
|
||||
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
|
||||
{conversation.type === 'contact' && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
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}
|
||||
title="Direct Trace"
|
||||
title={
|
||||
activeContactIsPrefixOnly
|
||||
? 'Direct Trace unavailable until the full contact key is known'
|
||||
: 'Direct Trace'
|
||||
}
|
||||
aria-label="Direct Trace"
|
||||
disabled={activeContactIsPrefixOnly}
|
||||
>
|
||||
<Route className="h-4 w-4" aria-hidden="true" />
|
||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{notificationsSupported && (
|
||||
|
||||
@@ -2,6 +2,11 @@ import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { Ban, Search, Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
getContactDisplayName,
|
||||
isPrefixOnlyContact,
|
||||
isUnknownFullKeyContact,
|
||||
} from '../utils/pubkey';
|
||||
import {
|
||||
isValidLocation,
|
||||
calculateDistance,
|
||||
@@ -133,6 +138,11 @@ export function ContactInfoPane({
|
||||
? formatPathHashMode(effectiveRoute.pathHashMode)
|
||||
: null;
|
||||
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
|
||||
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
|
||||
const isUnknownFullKeyResolvedContact =
|
||||
contact !== null &&
|
||||
!isPrefixOnlyResolvedContact &&
|
||||
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
|
||||
|
||||
return (
|
||||
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
@@ -249,7 +259,7 @@ export function ContactInfoPane({
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
|
||||
</h2>
|
||||
<span
|
||||
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
|
||||
@@ -268,16 +278,27 @@ export function ContactInfoPane({
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
|
||||
</span>
|
||||
{contact.on_radio && (
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
On Radio
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isPrefixOnlyResolvedContact && (
|
||||
<div className="mx-5 mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
We only know a key prefix for this sender, which can happen when a fallback DM
|
||||
arrives before we hear an advertisement. This contact stays read-only until the full
|
||||
key resolves from a later advertisement.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUnknownFullKeyResolvedContact && (
|
||||
<div className="mx-5 mt-4 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
We know this sender's full key, but we have not yet heard an advertisement that
|
||||
fills in their identity details. Those details will appear automatically when an
|
||||
advertisement arrives.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
|
||||
|
||||
const RepeaterDashboard = lazy(() =>
|
||||
import('./RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
|
||||
@@ -39,6 +40,7 @@ interface ConversationPaneProps {
|
||||
messagesLoading: boolean;
|
||||
loadingOlder: boolean;
|
||||
hasOlderMessages: boolean;
|
||||
unreadMarkerLastReadAt?: number | null;
|
||||
targetMessageId: number | null;
|
||||
hasNewerMessages: boolean;
|
||||
loadingNewer: boolean;
|
||||
@@ -56,6 +58,7 @@ interface ConversationPaneProps {
|
||||
onTargetReached: () => void;
|
||||
onLoadNewer: () => Promise<void>;
|
||||
onJumpToBottom: () => void;
|
||||
onDismissUnreadMarker: () => void;
|
||||
onSendMessage: (text: string) => Promise<void>;
|
||||
onToggleNotifications: () => void;
|
||||
}
|
||||
@@ -66,6 +69,25 @@ function LoadingPane({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ContactResolutionBanner({ variant }: { variant: 'unknown-full-key' | 'prefix-only' }) {
|
||||
if (variant === 'prefix-only') {
|
||||
return (
|
||||
<div className="mx-4 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
We only know a key prefix for this sender, which can happen when a fallback DM arrives
|
||||
before we learn their full identity. This conversation is read-only until we hear an
|
||||
advertisement that resolves the full key.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 mt-3 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
A full identity profile is not yet available because we have not heard an advertisement from
|
||||
this sender. The contact will fill in automatically when an advertisement arrives.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationPane({
|
||||
activeConversation,
|
||||
contacts,
|
||||
@@ -81,6 +103,7 @@ export function ConversationPane({
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
unreadMarkerLastReadAt,
|
||||
targetMessageId,
|
||||
hasNewerMessages,
|
||||
loadingNewer,
|
||||
@@ -98,6 +121,7 @@ export function ConversationPane({
|
||||
onTargetReached,
|
||||
onLoadNewer,
|
||||
onJumpToBottom,
|
||||
onDismissUnreadMarker,
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
}: ConversationPaneProps) {
|
||||
@@ -106,6 +130,17 @@ export function ConversationPane({
|
||||
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
|
||||
return contact?.type === CONTACT_TYPE_REPEATER;
|
||||
}, [activeConversation, contacts]);
|
||||
const activeContact = useMemo(() => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
|
||||
}, [activeConversation, contacts]);
|
||||
const isPrefixOnlyActiveContact = activeContact
|
||||
? isPrefixOnlyContact(activeContact.public_key)
|
||||
: false;
|
||||
const isUnknownFullKeyActiveContact =
|
||||
activeContact !== null &&
|
||||
!isPrefixOnlyActiveContact &&
|
||||
isUnknownFullKeyContact(activeContact.public_key, activeContact.last_advert);
|
||||
|
||||
if (!activeConversation) {
|
||||
return (
|
||||
@@ -198,6 +233,12 @@ export function ConversationPane({
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
onOpenChannelInfo={onOpenChannelInfo}
|
||||
/>
|
||||
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact && (
|
||||
<ContactResolutionBanner variant="prefix-only" />
|
||||
)}
|
||||
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
|
||||
<ContactResolutionBanner variant="unknown-full-key" />
|
||||
)}
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
@@ -205,6 +246,12 @@ export function ConversationPane({
|
||||
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={
|
||||
@@ -220,16 +267,20 @@ export function ConversationPane({
|
||||
onLoadNewer={onLoadNewer}
|
||||
onJumpToBottom={onJumpToBottom}
|
||||
/>
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
onSend={onSendMessage}
|
||||
disabled={!health?.radio_connected}
|
||||
conversationType={activeConversation.type}
|
||||
senderName={config?.name}
|
||||
placeholder={
|
||||
!health?.radio_connected ? 'Radio not connected' : `Message ${activeConversation.name}...`
|
||||
}
|
||||
/>
|
||||
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : (
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
onSend={onSendMessage}
|
||||
disabled={!health?.radio_connected}
|
||||
conversationType={activeConversation.type}
|
||||
senderName={config?.name}
|
||||
placeholder={
|
||||
!health?.radio_connected
|
||||
? 'Radio not connected'
|
||||
: `Message ${activeConversation.name}...`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
interface DirectTraceIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DirectTraceIcon({ className }: DirectTraceIconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 12h12" />
|
||||
<circle cx="18" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
|
||||
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
@@ -13,17 +13,27 @@ interface MapViewProps {
|
||||
focusedKey?: string | null;
|
||||
}
|
||||
|
||||
const MAP_RECENCY_COLORS = {
|
||||
recent: '#06b6d4',
|
||||
today: '#2563eb',
|
||||
stale: '#f59e0b',
|
||||
old: '#64748b',
|
||||
} as const;
|
||||
const MAP_MARKER_STROKE = '#0f172a';
|
||||
const MAP_REPEATER_RING = '#f8fafc';
|
||||
|
||||
// Calculate marker color based on how recently the contact was heard
|
||||
function getMarkerColor(lastSeen: number): string {
|
||||
function getMarkerColor(lastSeen: number | null | undefined): string {
|
||||
if (lastSeen == null) return MAP_RECENCY_COLORS.old;
|
||||
const now = Date.now() / 1000;
|
||||
const age = now - lastSeen;
|
||||
const hour = 3600;
|
||||
const day = 86400;
|
||||
|
||||
if (age < hour) return '#22c55e'; // Bright green - less than 1 hour
|
||||
if (age < day) return '#4ade80'; // Light green - less than 1 day
|
||||
if (age < 3 * day) return '#a3e635'; // Yellow-green - less than 3 days
|
||||
return '#9ca3af'; // Gray - older (up to 7 days)
|
||||
if (age < hour) return MAP_RECENCY_COLORS.recent;
|
||||
if (age < day) return MAP_RECENCY_COLORS.today;
|
||||
if (age < 3 * day) return MAP_RECENCY_COLORS.stale;
|
||||
return MAP_RECENCY_COLORS.old;
|
||||
}
|
||||
|
||||
// Component to handle map bounds fitting
|
||||
@@ -94,16 +104,17 @@ function MapBoundsHandler({
|
||||
}
|
||||
|
||||
export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
|
||||
|
||||
// Filter to contacts with GPS coordinates, heard within the last 7 days.
|
||||
// Always include the focused contact so "view on map" links work for older nodes.
|
||||
const mappableContacts = useMemo(() => {
|
||||
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
|
||||
return contacts.filter(
|
||||
(c) =>
|
||||
isValidLocation(c.lat, c.lon) &&
|
||||
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
|
||||
);
|
||||
}, [contacts, focusedKey]);
|
||||
}, [contacts, focusedKey, sevenDaysAgo]);
|
||||
|
||||
// Find the focused contact by key
|
||||
const focusedContact = useMemo(() => {
|
||||
@@ -111,6 +122,10 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
return mappableContacts.find((c) => c.public_key === focusedKey) || null;
|
||||
}, [focusedKey, mappableContacts]);
|
||||
|
||||
const includesFocusedOutsideWindow =
|
||||
focusedContact != null &&
|
||||
(focusedContact.last_seen == null || focusedContact.last_seen <= sevenDaysAgo);
|
||||
|
||||
// Track marker refs to open popup programmatically
|
||||
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
|
||||
|
||||
@@ -137,19 +152,48 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
<span>
|
||||
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard
|
||||
in the last 7 days
|
||||
{includesFocusedOutsideWindow ? ' plus the focused contact' : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#22c55e]" aria-hidden="true" /> <1h
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1h
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#4ade80]" aria-hidden="true" /> <1d
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#a3e635]" aria-hidden="true" /> <3d
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<3d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#9ca3af]" aria-hidden="true" /> older
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
older
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border-2"
|
||||
style={{ borderColor: MAP_REPEATER_RING, backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
repeater
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,41 +219,46 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
|
||||
{mappableContacts.map((contact) => {
|
||||
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
|
||||
const color = getMarkerColor(contact.last_seen!);
|
||||
const color = getMarkerColor(contact.last_seen);
|
||||
const displayName = contact.name || contact.public_key.slice(0, 12);
|
||||
const lastHeardLabel =
|
||||
contact.last_seen != null
|
||||
? formatTime(contact.last_seen)
|
||||
: 'Never heard by this server';
|
||||
const radius = isRepeater ? 10 : 7;
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={contact.public_key}
|
||||
ref={(ref) => setMarkerRef(contact.public_key, ref)}
|
||||
center={[contact.lat!, contact.lon!]}
|
||||
radius={isRepeater ? 10 : 7}
|
||||
pathOptions={{
|
||||
color: isRepeater ? color : '#000',
|
||||
fillColor: color,
|
||||
fillOpacity: 0.8,
|
||||
weight: isRepeater ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{isRepeater && (
|
||||
<span title="Repeater" aria-hidden="true">
|
||||
🛜
|
||||
</span>
|
||||
)}
|
||||
{displayName}
|
||||
<Fragment key={contact.public_key}>
|
||||
<CircleMarker
|
||||
key={contact.public_key}
|
||||
ref={(ref) => setMarkerRef(contact.public_key, ref)}
|
||||
center={[contact.lat!, contact.lon!]}
|
||||
radius={radius}
|
||||
pathOptions={{
|
||||
color: isRepeater ? MAP_REPEATER_RING : MAP_MARKER_STROKE,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.9,
|
||||
weight: isRepeater ? 3 : 2,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{isRepeater && (
|
||||
<span title="Repeater" aria-hidden="true">
|
||||
🛜
|
||||
</span>
|
||||
)}
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Last heard: {lastHeardLabel}</div>
|
||||
<div className="text-xs text-gray-400 mt-1 font-mono">
|
||||
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Last heard: {formatTime(contact.last_seen!)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1 font-mono">
|
||||
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
|
||||
@@ -150,7 +150,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
|
||||
return (
|
||||
<form
|
||||
className="px-4 py-2.5 border-t border-border flex flex-col gap-1"
|
||||
className="message-input-shell px-4 py-2.5 border-t border-border flex flex-col gap-1"
|
||||
onSubmit={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Fragment,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
@@ -22,6 +23,8 @@ interface MessageListProps {
|
||||
loading: boolean;
|
||||
loadingOlder?: boolean;
|
||||
hasOlderMessages?: boolean;
|
||||
unreadMarkerLastReadAt?: number | null;
|
||||
onDismissUnreadMarker?: () => void;
|
||||
onSenderClick?: (sender: string) => void;
|
||||
onLoadOlder?: () => void;
|
||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
@@ -172,6 +175,8 @@ export function MessageList({
|
||||
loading,
|
||||
loadingOlder = false,
|
||||
hasOlderMessages = false,
|
||||
unreadMarkerLastReadAt,
|
||||
onDismissUnreadMarker,
|
||||
onSenderClick,
|
||||
onLoadOlder,
|
||||
onResendChannelMessage,
|
||||
@@ -198,7 +203,10 @@ export function MessageList({
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
|
||||
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
|
||||
const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false);
|
||||
const targetScrolledRef = useRef(false);
|
||||
const unreadMarkerRef = useRef<HTMLButtonElement | HTMLDivElement | null>(null);
|
||||
|
||||
// Capture scroll state in the scroll handler BEFORE any state updates
|
||||
const scrollStateRef = useRef({
|
||||
@@ -320,6 +328,57 @@ export function MessageList({
|
||||
};
|
||||
}, [messages, onResendChannelMessage]);
|
||||
|
||||
// Sort messages by received_at ascending (oldest first)
|
||||
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
|
||||
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
||||
const sortedMessages = useMemo(
|
||||
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
|
||||
[messages]
|
||||
);
|
||||
const unreadMarkerIndex = useMemo(() => {
|
||||
if (unreadMarkerLastReadAt === undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const boundary = unreadMarkerLastReadAt ?? 0;
|
||||
return sortedMessages.findIndex((msg) => !msg.outgoing && msg.received_at > boundary);
|
||||
}, [sortedMessages, unreadMarkerLastReadAt]);
|
||||
|
||||
const syncJumpToUnreadVisibility = useCallback(() => {
|
||||
if (unreadMarkerIndex === -1 || jumpToUnreadDismissed) {
|
||||
setShowJumpToUnread(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = unreadMarkerRef.current;
|
||||
const list = listRef.current;
|
||||
if (!marker || !list) {
|
||||
setShowJumpToUnread(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
const listRect = list.getBoundingClientRect();
|
||||
|
||||
if (
|
||||
markerRect.width === 0 ||
|
||||
markerRect.height === 0 ||
|
||||
listRect.width === 0 ||
|
||||
listRect.height === 0
|
||||
) {
|
||||
setShowJumpToUnread(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const markerVisible =
|
||||
markerRect.top >= listRect.top &&
|
||||
markerRect.bottom <= listRect.bottom &&
|
||||
markerRect.left >= listRect.left &&
|
||||
markerRect.right <= listRect.right;
|
||||
|
||||
setShowJumpToUnread(!markerVisible);
|
||||
}, [jumpToUnreadDismissed, unreadMarkerIndex]);
|
||||
|
||||
// Refs for scroll handler to read without causing callback recreation
|
||||
const onLoadOlderRef = useRef(onLoadOlder);
|
||||
const loadingOlderRef = useRef(loadingOlder);
|
||||
@@ -334,6 +393,22 @@ export function MessageList({
|
||||
loadingNewerRef.current = loadingNewer;
|
||||
hasNewerMessagesRef.current = hasNewerMessages;
|
||||
|
||||
const setUnreadMarkerElement = useCallback(
|
||||
(node: HTMLButtonElement | HTMLDivElement | null) => {
|
||||
unreadMarkerRef.current = node;
|
||||
syncJumpToUnreadVisibility();
|
||||
},
|
||||
[syncJumpToUnreadVisibility]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setJumpToUnreadDismissed(false);
|
||||
}, [unreadMarkerIndex]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncJumpToUnreadVisibility();
|
||||
}, [messages, syncJumpToUnreadVisibility]);
|
||||
|
||||
// Handle scroll - capture state and detect when user is near top/bottom
|
||||
// Stable callback: reads changing values from refs, never recreated.
|
||||
const handleScroll = useCallback(() => {
|
||||
@@ -342,7 +417,6 @@ export function MessageList({
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
// Always capture current scroll state (needed for scroll preservation)
|
||||
scrollStateRef.current = {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
@@ -351,7 +425,6 @@ export function MessageList({
|
||||
wasNearBottom: distanceFromBottom < 100,
|
||||
};
|
||||
|
||||
// Show scroll-to-bottom button when not near the bottom (more than 100px away)
|
||||
setShowScrollToBottom(distanceFromBottom > 100);
|
||||
|
||||
if (!onLoadOlderRef.current || loadingOlderRef.current || !hasOlderMessagesRef.current) {
|
||||
@@ -360,7 +433,6 @@ export function MessageList({
|
||||
onLoadOlderRef.current();
|
||||
}
|
||||
|
||||
// Trigger load newer when within 100px of bottom
|
||||
if (
|
||||
onLoadNewerRef.current &&
|
||||
!loadingNewerRef.current &&
|
||||
@@ -369,7 +441,8 @@ export function MessageList({
|
||||
) {
|
||||
onLoadNewerRef.current();
|
||||
}
|
||||
}, []);
|
||||
syncJumpToUnreadVisibility();
|
||||
}, [syncJumpToUnreadVisibility]);
|
||||
|
||||
// Scroll to bottom handler (or jump to bottom if viewing historical messages)
|
||||
const scrollToBottom = useCallback(() => {
|
||||
@@ -382,14 +455,6 @@ export function MessageList({
|
||||
}
|
||||
}, [hasNewerMessages, onJumpToBottom]);
|
||||
|
||||
// Sort messages by received_at ascending (oldest first)
|
||||
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
|
||||
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
||||
const sortedMessages = useMemo(
|
||||
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
|
||||
[messages]
|
||||
);
|
||||
|
||||
// Sender info for outgoing messages (used by path modal on own messages)
|
||||
const selfSenderInfo = useMemo<SenderInfo>(
|
||||
() => ({
|
||||
@@ -606,97 +671,108 @@ export function MessageList({
|
||||
(avatarName ? `name:${avatarName}` : `message:${msg.id}`);
|
||||
}
|
||||
}
|
||||
const avatarActionLabel =
|
||||
avatarName && avatarName !== 'Unknown'
|
||||
? `View info for ${avatarName}`
|
||||
: `View info for ${avatarKey.slice(0, 12)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
data-message-id={msg.id}
|
||||
className={cn(
|
||||
'flex items-start max-w-[85%]',
|
||||
msg.outgoing && 'flex-row-reverse self-end',
|
||||
isFirstInGroup && !isFirstMessage && 'mt-3'
|
||||
)}
|
||||
>
|
||||
{!msg.outgoing && (
|
||||
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
|
||||
{showAvatar && avatarKey && (
|
||||
<span
|
||||
role={onOpenContactInfo ? 'button' : undefined}
|
||||
tabIndex={onOpenContactInfo ? 0 : undefined}
|
||||
onKeyDown={onOpenContactInfo ? handleKeyboardActivate : undefined}
|
||||
onClick={
|
||||
onOpenContactInfo
|
||||
? () => onOpenContactInfo(avatarKey, msg.type === 'CHAN')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
publicKey={avatarKey}
|
||||
size={32}
|
||||
clickable={!!onOpenContactInfo}
|
||||
variant={avatarVariant}
|
||||
/>
|
||||
<Fragment key={msg.id}>
|
||||
{unreadMarkerIndex === index &&
|
||||
(onDismissUnreadMarker ? (
|
||||
<button
|
||||
ref={setUnreadMarkerElement}
|
||||
type="button"
|
||||
className="my-2 flex w-full items-center gap-3 text-left text-xs font-medium text-primary transition-colors hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onDismissUnreadMarker}
|
||||
>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
|
||||
Unread messages
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
ref={setUnreadMarkerElement}
|
||||
className="my-2 flex w-full items-center gap-3 text-xs font-medium text-primary"
|
||||
>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
|
||||
Unread messages
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
data-message-id={msg.id}
|
||||
className={cn(
|
||||
'py-1.5 px-3 rounded-lg min-w-0',
|
||||
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
|
||||
highlightedMessageId === msg.id && 'message-highlight'
|
||||
'flex items-start max-w-[85%]',
|
||||
msg.outgoing && 'flex-row-reverse self-end',
|
||||
isFirstInGroup && !isFirstMessage && 'mt-3'
|
||||
)}
|
||||
>
|
||||
{showAvatar && (
|
||||
<div className="text-[13px] font-semibold text-foreground mb-0.5">
|
||||
{canClickSender ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => onSenderClick(displaySender)}
|
||||
title={`Mention ${displaySender}`}
|
||||
>
|
||||
{displaySender}
|
||||
</span>
|
||||
) : (
|
||||
displaySender
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
paths={msg.paths}
|
||||
variant="header"
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, sender),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!msg.outgoing && (
|
||||
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
|
||||
{showAvatar &&
|
||||
avatarKey &&
|
||||
(onOpenContactInfo ? (
|
||||
<button
|
||||
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')}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
publicKey={avatarKey}
|
||||
size={32}
|
||||
clickable
|
||||
variant={avatarVariant}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
publicKey={avatarKey}
|
||||
size={32}
|
||||
variant={avatarVariant}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{content.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>
|
||||
{renderTextWithMentions(line, radioName)}
|
||||
{i < arr.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
{!showAvatar && (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground ml-2">
|
||||
<div
|
||||
className={cn(
|
||||
'py-1.5 px-3 rounded-lg min-w-0',
|
||||
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
|
||||
highlightedMessageId === msg.id && 'message-highlight'
|
||||
)}
|
||||
>
|
||||
{showAvatar && (
|
||||
<div className="text-[13px] font-semibold text-foreground mb-0.5">
|
||||
{canClickSender ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => onSenderClick(displaySender)}
|
||||
title={`Mention ${displaySender}`}
|
||||
>
|
||||
{displaySender}
|
||||
</span>
|
||||
) : (
|
||||
displaySender
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
paths={msg.paths}
|
||||
variant="inline"
|
||||
variant="header"
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
@@ -705,11 +781,58 @@ export function MessageList({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{msg.outgoing &&
|
||||
(msg.acked > 0 ? (
|
||||
msg.paths && msg.paths.length > 0 ? (
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{content.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>
|
||||
{renderTextWithMentions(line, radioName)}
|
||||
{i < arr.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
{!showAvatar && (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground ml-2">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
paths={msg.paths}
|
||||
variant="inline"
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, sender),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{msg.outgoing &&
|
||||
(msg.acked > 0 ? (
|
||||
msg.paths && msg.paths.length > 0 ? (
|
||||
<span
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
|
||||
});
|
||||
}}
|
||||
title="View echo paths"
|
||||
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
|
||||
>{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
)
|
||||
) : onResendChannelMessage && msg.type === 'CHAN' ? (
|
||||
<span
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
role="button"
|
||||
@@ -718,48 +841,28 @@ export function MessageList({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
paths: [],
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
|
||||
isOutgoingChan: true,
|
||||
});
|
||||
}}
|
||||
title="View echo paths"
|
||||
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
|
||||
>{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
title="Message status"
|
||||
aria-label="No echoes yet — view message status"
|
||||
>
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
)
|
||||
) : onResendChannelMessage && msg.type === 'CHAN' ? (
|
||||
<span
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
paths: [],
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
isOutgoingChan: true,
|
||||
});
|
||||
}}
|
||||
title="Message status"
|
||||
aria-label="No echoes yet — view message status"
|
||||
>
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground" title="No repeats heard yet">
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
))}
|
||||
<span className="text-muted-foreground" title="No repeats heard yet">
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{loadingNewer && (
|
||||
@@ -775,6 +878,35 @@ export function MessageList({
|
||||
</div>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{showJumpToUnread && (
|
||||
<div className="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
<div className="pointer-events-auto flex h-9 items-center overflow-hidden rounded-full border border-border bg-card shadow-lg transition-all hover:scale-105">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
unreadMarkerRef.current?.scrollIntoView?.({ block: 'center' });
|
||||
setJumpToUnreadDismissed(true);
|
||||
setShowJumpToUnread(false);
|
||||
}}
|
||||
className="h-full px-3 text-sm font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Jump to unread
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setJumpToUnreadDismissed(true);
|
||||
setShowJumpToUnread(false);
|
||||
}}
|
||||
className="flex h-full w-9 items-center justify-center border-l border-border text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Dismiss jump to unread"
|
||||
title="Dismiss jump to unread"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showScrollToBottom && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
|
||||
@@ -169,34 +169,40 @@ export function NewMessageModal({
|
||||
|
||||
<TabsContent value="existing" className="mt-4">
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-md border">
|
||||
{contacts.length === 0 ? (
|
||||
{contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
|
||||
) : (
|
||||
contacts.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),
|
||||
});
|
||||
resetForm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{getContactDisplayName(contact.name, contact.public_key)}
|
||||
</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>
|
||||
|
||||
@@ -29,6 +29,9 @@ export function PacketVisualizer3D({
|
||||
const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(savedSettings.showAmbiguousPaths);
|
||||
const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(savedSettings.showAmbiguousNodes);
|
||||
const [useAdvertPathHints, setUseAdvertPathHints] = useState(savedSettings.useAdvertPathHints);
|
||||
const [collapseLikelyKnownSiblingRepeaters, setCollapseLikelyKnownSiblingRepeaters] = useState(
|
||||
savedSettings.collapseLikelyKnownSiblingRepeaters
|
||||
);
|
||||
const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(
|
||||
savedSettings.splitAmbiguousByTraffic
|
||||
);
|
||||
@@ -52,6 +55,7 @@ export function PacketVisualizer3D({
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic,
|
||||
chargeStrength,
|
||||
observationWindowSec,
|
||||
@@ -66,6 +70,7 @@ export function PacketVisualizer3D({
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic,
|
||||
chargeStrength,
|
||||
observationWindowSec,
|
||||
@@ -108,6 +113,7 @@ export function PacketVisualizer3D({
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic,
|
||||
chargeStrength,
|
||||
letEmDrift,
|
||||
@@ -117,7 +123,7 @@ export function PacketVisualizer3D({
|
||||
pruneStaleMinutes,
|
||||
});
|
||||
|
||||
const { hoveredNodeId, hoveredNeighborIds, pinnedNodeId } = useVisualizer3DScene({
|
||||
const { hoveredNodeId, pinnedNodeId } = useVisualizer3DScene({
|
||||
containerRef,
|
||||
data,
|
||||
autoOrbit,
|
||||
@@ -143,6 +149,8 @@ export function PacketVisualizer3D({
|
||||
setShowAmbiguousNodes={setShowAmbiguousNodes}
|
||||
useAdvertPathHints={useAdvertPathHints}
|
||||
setUseAdvertPathHints={setUseAdvertPathHints}
|
||||
collapseLikelyKnownSiblingRepeaters={collapseLikelyKnownSiblingRepeaters}
|
||||
setCollapseLikelyKnownSiblingRepeaters={setCollapseLikelyKnownSiblingRepeaters}
|
||||
splitAmbiguousByTraffic={splitAmbiguousByTraffic}
|
||||
setSplitAmbiguousByTraffic={setSplitAmbiguousByTraffic}
|
||||
observationWindowSec={observationWindowSec}
|
||||
@@ -167,8 +175,9 @@ export function PacketVisualizer3D({
|
||||
|
||||
<VisualizerTooltip
|
||||
activeNodeId={tooltipNodeId}
|
||||
nodes={data.nodes}
|
||||
neighborIds={hoveredNeighborIds}
|
||||
canonicalNodes={data.canonicalNodes}
|
||||
canonicalNeighborIds={data.canonicalNeighborIds}
|
||||
renderedNodeIds={data.renderedNodeIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { Bell, Star, Trash2 } from 'lucide-react';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
@@ -10,6 +11,7 @@ import type { Contact, Conversation, Favorite } from '../types';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||
import { AclPane } from './repeater/RepeaterAclPane';
|
||||
import { NodeInfoPane } from './repeater/RepeaterNodeInfoPane';
|
||||
import { RadioSettingsPane } from './repeater/RepeaterRadioSettingsPane';
|
||||
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
||||
@@ -46,7 +48,7 @@ export function RepeaterDashboard({
|
||||
notificationsPermission,
|
||||
radioLat,
|
||||
radioLon,
|
||||
radioName,
|
||||
radioName: _radioName,
|
||||
onTrace,
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
@@ -126,7 +128,7 @@ export function RepeaterDashboard({
|
||||
title="Direct Trace"
|
||||
aria-label="Direct Trace"
|
||||
>
|
||||
<Route className="h-4 w-4" aria-hidden="true" />
|
||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
{notificationsSupported && (
|
||||
<button
|
||||
@@ -196,9 +198,15 @@ export function RepeaterDashboard({
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Top row: Telemetry + Radio Settings | Neighbors (with expanding map) */}
|
||||
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<NodeInfoPane
|
||||
data={paneData.nodeInfo}
|
||||
state={paneStates.nodeInfo}
|
||||
onRefresh={() => refreshPane('nodeInfo')}
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
<TelemetryPane
|
||||
data={paneData.status}
|
||||
state={paneStates.status}
|
||||
@@ -221,16 +229,18 @@ export function RepeaterDashboard({
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
</div>
|
||||
<NeighborsPane
|
||||
data={paneData.neighbors}
|
||||
state={paneStates.neighbors}
|
||||
onRefresh={() => refreshPane('neighbors')}
|
||||
disabled={anyLoading}
|
||||
contacts={contacts}
|
||||
radioLat={radioLat}
|
||||
radioLon={radioLon}
|
||||
radioName={radioName}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<NeighborsPane
|
||||
data={paneData.neighbors}
|
||||
state={paneStates.neighbors}
|
||||
onRefresh={() => refreshPane('neighbors')}
|
||||
disabled={anyLoading}
|
||||
contacts={contacts}
|
||||
nodeInfo={paneData.nodeInfo}
|
||||
nodeInfoState={paneStates.nodeInfo}
|
||||
repeaterName={conversation.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remaining panes: ACL | Owner Info + Actions */}
|
||||
|
||||
@@ -123,6 +123,12 @@ export function SearchView({
|
||||
inputRef.current?.focus();
|
||||
}, [prefillRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch search results
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery) {
|
||||
@@ -243,6 +249,11 @@ export function SearchView({
|
||||
Tip: use <code>user:</code> or <code>channel:</code> for keys or names, and wrap names
|
||||
with spaces in them in quotes.
|
||||
</p>
|
||||
<p className="mt-2 text-xs">
|
||||
Warning: User-key linkage for group messages is best-effort and based on correlation
|
||||
at advertise time. It does not account for multiple users with the same name, and
|
||||
should be considered unreliable.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
LockOpen,
|
||||
Logs,
|
||||
Map,
|
||||
Search as SearchIcon,
|
||||
Sparkles,
|
||||
SquarePen,
|
||||
Waypoints,
|
||||
X,
|
||||
@@ -416,7 +416,7 @@ export function Sidebar({
|
||||
key: `${keyPrefix}-${contact.public_key}`,
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
|
||||
unreadCount: getUnreadCount('contact', contact.public_key),
|
||||
isMention: hasMention('contact', contact.public_key),
|
||||
notificationsEnabled:
|
||||
@@ -533,7 +533,7 @@ export function Sidebar({
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-raw',
|
||||
active: isActive('raw', 'raw'),
|
||||
icon: <Waypoints className="h-4 w-4" />,
|
||||
icon: <Logs className="h-4 w-4" />,
|
||||
label: 'Packet Feed',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
@@ -557,7 +557,7 @@ export function Sidebar({
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-visualizer',
|
||||
active: isActive('visualizer', 'visualizer'),
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
icon: <Waypoints className="h-4 w-4" />,
|
||||
label: 'Mesh Visualizer',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
|
||||
@@ -2,7 +2,13 @@ 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 type { Contact, RepeaterNeighborsResponse, PaneState, NeighborInfo } from '../../types';
|
||||
import type {
|
||||
Contact,
|
||||
RepeaterNeighborsResponse,
|
||||
PaneState,
|
||||
NeighborInfo,
|
||||
RepeaterNodeInfoResponse,
|
||||
} from '../../types';
|
||||
|
||||
const NeighborsMiniMap = lazy(() =>
|
||||
import('../NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
|
||||
@@ -14,19 +20,35 @@ export function NeighborsPane({
|
||||
onRefresh,
|
||||
disabled,
|
||||
contacts,
|
||||
radioLat,
|
||||
radioLon,
|
||||
radioName,
|
||||
nodeInfo,
|
||||
nodeInfoState,
|
||||
repeaterName,
|
||||
}: {
|
||||
data: RepeaterNeighborsResponse | null;
|
||||
state: PaneState;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
contacts: Contact[];
|
||||
radioLat: number | null;
|
||||
radioLon: number | null;
|
||||
radioName: string | null;
|
||||
nodeInfo: RepeaterNodeInfoResponse | null;
|
||||
nodeInfoState: PaneState;
|
||||
repeaterName: string | null;
|
||||
}) {
|
||||
const radioLat = useMemo(() => {
|
||||
const parsed = nodeInfo?.lat != null ? parseFloat(nodeInfo.lat) : null;
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}, [nodeInfo?.lat]);
|
||||
|
||||
const radioLon = useMemo(() => {
|
||||
const parsed = nodeInfo?.lon != null ? parseFloat(nodeInfo.lon) : null;
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}, [nodeInfo?.lon]);
|
||||
|
||||
const radioName = nodeInfo?.name || repeaterName;
|
||||
const hasValidRepeaterGps = isValidLocation(radioLat, radioLon);
|
||||
const showGpsUnavailableMessage =
|
||||
!hasValidRepeaterGps &&
|
||||
(nodeInfoState.error !== null || nodeInfoState.fetched_at != null || nodeInfo !== null);
|
||||
|
||||
// Resolve contact data for each neighbor in a single pass — used for
|
||||
// coords (mini-map), distances (table column), and sorted display order.
|
||||
const { neighborsWithCoords, sorted, hasDistances } = useMemo(() => {
|
||||
@@ -48,7 +70,7 @@ export function NeighborsPane({
|
||||
const nLon = contact?.lon ?? null;
|
||||
|
||||
let dist: string | null = null;
|
||||
if (isValidLocation(radioLat, radioLon) && isValidLocation(nLat, nLon)) {
|
||||
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
|
||||
const distKm = calculateDistance(radioLat, radioLon, nLat, nLon);
|
||||
if (distKm != null) {
|
||||
dist = formatDistance(distKm);
|
||||
@@ -69,7 +91,7 @@ export function NeighborsPane({
|
||||
sorted: enriched,
|
||||
hasDistances: anyDist,
|
||||
};
|
||||
}, [data, contacts, radioLat, radioLon]);
|
||||
}, [contacts, data, hasValidRepeaterGps, radioLat, radioLon]);
|
||||
|
||||
return (
|
||||
<RepeaterPane
|
||||
@@ -120,7 +142,7 @@ export function NeighborsPane({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && (
|
||||
{hasValidRepeaterGps && (neighborsWithCoords.length > 0 || hasValidRepeaterGps) ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-48 flex items-center justify-center text-xs text-muted-foreground">
|
||||
@@ -136,7 +158,13 @@ export function NeighborsPane({
|
||||
radioName={radioName}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
) : showGpsUnavailableMessage ? (
|
||||
<div className="rounded border border-border/70 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
GPS info failed to fetch; map and distance data not available. This may be due to
|
||||
missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try
|
||||
refreshing.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</RepeaterPane>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RepeaterPane, NotFetched, KvRow, formatClockDrift } from './repeaterPaneShared';
|
||||
import type { RepeaterNodeInfoResponse, PaneState } from '../../types';
|
||||
|
||||
export function NodeInfoPane({
|
||||
data,
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
}: {
|
||||
data: RepeaterNodeInfoResponse | null;
|
||||
state: PaneState;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const clockDrift = useMemo(() => {
|
||||
if (!data?.clock_utc) return null;
|
||||
return formatClockDrift(data.clock_utc);
|
||||
}, [data?.clock_utc]);
|
||||
|
||||
return (
|
||||
<RepeaterPane title="Node Info" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
{!data ? (
|
||||
<NotFetched />
|
||||
) : (
|
||||
<div>
|
||||
<KvRow label="Name" value={data.name ?? '—'} />
|
||||
<KvRow
|
||||
label="Lat / Lon"
|
||||
value={
|
||||
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-between text-sm py-0.5">
|
||||
<span className="text-muted-foreground">Clock (UTC)</span>
|
||||
<span>
|
||||
{data.clock_utc ?? '—'}
|
||||
{clockDrift && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-2 text-xs',
|
||||
clockDrift.isLarge ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
(drift: {clockDrift.text})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</RepeaterPane>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '../ui/separator';
|
||||
import {
|
||||
@@ -6,7 +5,6 @@ import {
|
||||
RefreshIcon,
|
||||
NotFetched,
|
||||
KvRow,
|
||||
formatClockDrift,
|
||||
formatAdvertInterval,
|
||||
} from './repeaterPaneShared';
|
||||
import type {
|
||||
@@ -15,6 +13,35 @@ import type {
|
||||
PaneState,
|
||||
} from '../../types';
|
||||
|
||||
function formatRadioTuple(radio: string | null): { display: string; raw: string | null } {
|
||||
if (radio == null) {
|
||||
return { display: '—', raw: null };
|
||||
}
|
||||
|
||||
const trimmed = radio.trim();
|
||||
const parts = trimmed.split(',').map((part) => part.trim());
|
||||
if (parts.length !== 4) {
|
||||
return { display: trimmed || '—', raw: trimmed || null };
|
||||
}
|
||||
|
||||
const [freqRaw, bwRaw, sfRaw, crRaw] = parts;
|
||||
const freq = Number.parseFloat(freqRaw);
|
||||
const bw = Number.parseFloat(bwRaw);
|
||||
const sf = Number.parseInt(sfRaw, 10);
|
||||
const cr = Number.parseInt(crRaw, 10);
|
||||
|
||||
if (![freq, bw, sf, cr].every(Number.isFinite)) {
|
||||
return { display: trimmed || '—', raw: trimmed || null };
|
||||
}
|
||||
|
||||
const formattedFreq = Number(freq.toFixed(3)).toString();
|
||||
const formattedBw = Number(bw.toFixed(3)).toString();
|
||||
return {
|
||||
display: `${formattedFreq} MHz, BW ${formattedBw} kHz, SF${sf}, CR${cr}`,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
export function RadioSettingsPane({
|
||||
data,
|
||||
state,
|
||||
@@ -32,10 +59,7 @@ export function RadioSettingsPane({
|
||||
advertState: PaneState;
|
||||
onRefreshAdvert: () => void;
|
||||
}) {
|
||||
const clockDrift = useMemo(() => {
|
||||
if (!data?.clock_utc) return null;
|
||||
return formatClockDrift(data.clock_utc);
|
||||
}, [data?.clock_utc]);
|
||||
const formattedRadio = formatRadioTuple(data?.radio ?? null);
|
||||
|
||||
return (
|
||||
<RepeaterPane title="Radio Settings" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
@@ -44,36 +68,14 @@ export function RadioSettingsPane({
|
||||
) : (
|
||||
<div>
|
||||
<KvRow label="Firmware" value={data.firmware_version ?? '—'} />
|
||||
<KvRow label="Radio" value={data.radio ?? '—'} />
|
||||
<KvRow
|
||||
label="Radio"
|
||||
value={<span title={formattedRadio.raw ?? undefined}>{formattedRadio.display}</span>}
|
||||
/>
|
||||
<KvRow label="TX Power" value={data.tx_power != null ? `${data.tx_power} dBm` : '—'} />
|
||||
<KvRow label="Airtime Factor" value={data.airtime_factor ?? '—'} />
|
||||
<KvRow label="Repeat Mode" value={data.repeat_enabled ?? '—'} />
|
||||
<KvRow label="Max Flood Hops" value={data.flood_max ?? '—'} />
|
||||
<Separator className="my-1" />
|
||||
<KvRow label="Name" value={data.name ?? '—'} />
|
||||
<KvRow
|
||||
label="Lat / Lon"
|
||||
value={
|
||||
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
|
||||
}
|
||||
/>
|
||||
<Separator className="my-1" />
|
||||
<div className="flex justify-between text-sm py-0.5">
|
||||
<span className="text-muted-foreground">Clock (UTC)</span>
|
||||
<span>
|
||||
{data.clock_utc ?? '—'}
|
||||
{clockDrift && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-2 text-xs',
|
||||
clockDrift.isLarge ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
(drift: {clockDrift.text})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Advert Intervals sub-section */}
|
||||
|
||||
@@ -113,6 +113,19 @@ export function SettingsAboutSection({ className }: { className?: string }) {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="/api/debug"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-primary hover:underline"
|
||||
>
|
||||
Open debug support snapshot
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -74,35 +74,33 @@ function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) {
|
||||
return `${label} #${nextIndex}`;
|
||||
}
|
||||
|
||||
const DEFAULT_BOT_CODE = `def bot(
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
message_text: str,
|
||||
is_dm: bool,
|
||||
channel_key: str | None,
|
||||
channel_name: str | None,
|
||||
sender_timestamp: int | None,
|
||||
path: str | None,
|
||||
is_outgoing: bool = False,
|
||||
) -> str | list[str] | None:
|
||||
const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
|
||||
"""
|
||||
Process messages and optionally return a reply.
|
||||
|
||||
Args:
|
||||
sender_name: Display name of sender (may be None)
|
||||
sender_key: 64-char hex public key (None for channel msgs)
|
||||
message_text: The message content
|
||||
is_dm: True for direct messages, False for channel
|
||||
channel_key: 32-char hex key for channels, None for DMs
|
||||
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
|
||||
sender_timestamp: Sender's timestamp (unix seconds, may be None)
|
||||
path: Hex-encoded routing path (may be None)
|
||||
is_outgoing: True if this is our own outgoing message
|
||||
kwargs keys currently provided:
|
||||
sender_name: Display name of sender (may be None)
|
||||
sender_key: 64-char hex public key (None for channel msgs)
|
||||
message_text: The message content
|
||||
is_dm: True for direct messages, False for channel
|
||||
channel_key: 32-char hex key for channels, None for DMs
|
||||
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
|
||||
sender_timestamp: Sender's timestamp (unix seconds, may be None)
|
||||
path: Hex-encoded routing path (may be None)
|
||||
is_outgoing: True if this is our own outgoing message
|
||||
path_bytes_per_hop: Bytes per hop in path (1, 2, or 3) when known
|
||||
|
||||
Returns:
|
||||
None for no reply, a string for a single reply,
|
||||
or a list of strings to send multiple messages in order
|
||||
"""
|
||||
sender_name = kwargs.get("sender_name")
|
||||
message_text = kwargs.get("message_text", "")
|
||||
channel_name = kwargs.get("channel_name")
|
||||
is_outgoing = kwargs.get("is_outgoing", False)
|
||||
path_bytes_per_hop = kwargs.get("path_bytes_per_hop")
|
||||
|
||||
# Don't reply to our own outgoing messages
|
||||
if is_outgoing:
|
||||
return None
|
||||
@@ -1459,7 +1457,8 @@ export function SettingsFanoutSection({
|
||||
return (
|
||||
<div className={cn('mx-auto w-full max-w-[800px] space-y-4', className)}>
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 px-4 py-3 text-sm text-warning">
|
||||
Integrations are an experimental feature in open beta.
|
||||
Integrations are an experimental feature in open beta, and allow you to fanout raw and
|
||||
decrypted messages across multiple services for automation, analysis, or archiving.
|
||||
</div>
|
||||
|
||||
{health?.bots_disabled && (
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Logs, MessageSquare } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { ContactAvatar } from '../ContactAvatar';
|
||||
import {
|
||||
captureLastViewedConversationFromHash,
|
||||
getReopenLastConversationEnabled,
|
||||
@@ -97,7 +99,7 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Preview alert and message contrast for the selected theme.
|
||||
Preview alert, message, sidebar, and badge contrast for the selected theme.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -125,6 +127,42 @@ function ThemePreview({ className }: { className?: string }) {
|
||||
text="Hi there! I'm using RemoteTerm."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border bg-background p-2">
|
||||
<p className="mb-2 text-[11px] font-medium text-muted-foreground">Sidebar preview</p>
|
||||
<div className="space-y-1">
|
||||
<PreviewSidebarRow
|
||||
active
|
||||
leading={
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Logs className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
}
|
||||
label="Packet Feed"
|
||||
/>
|
||||
<PreviewSidebarRow
|
||||
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
|
||||
label="Alice"
|
||||
badge={
|
||||
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[10px] font-semibold text-badge-unread-foreground">
|
||||
3
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<PreviewSidebarRow
|
||||
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
|
||||
label="Mesh Ops"
|
||||
badge={
|
||||
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[10px] font-semibold text-badge-mention-foreground">
|
||||
@2
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -153,3 +191,34 @@ function PreviewMessage({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewSidebarRow({
|
||||
leading,
|
||||
label,
|
||||
badge,
|
||||
active = false,
|
||||
}: {
|
||||
leading: React.ReactNode;
|
||||
label: string;
|
||||
badge?: React.ReactNode;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
|
||||
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
{leading}
|
||||
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
|
||||
{label}
|
||||
</span>
|
||||
{badge}
|
||||
{!badge && (
|
||||
<span className="text-muted-foreground" aria-hidden="true">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export function SettingsRadioSection({
|
||||
const [sf, setSf] = useState('');
|
||||
const [cr, setCr] = useState('');
|
||||
const [pathHashMode, setPathHashMode] = useState('0');
|
||||
const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
|
||||
const [gettingLocation, setGettingLocation] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [rebooting, setRebooting] = useState(false);
|
||||
@@ -86,6 +87,7 @@ export function SettingsRadioSection({
|
||||
setSf(String(config.radio.sf));
|
||||
setCr(String(config.radio.cr));
|
||||
setPathHashMode(String(config.path_hash_mode));
|
||||
setAdvertLocationSource(config.advert_location_source ?? 'current');
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -175,6 +177,9 @@ export function SettingsRadioSection({
|
||||
lat: parsedLat,
|
||||
lon: parsedLon,
|
||||
tx_power: parsedTxPower,
|
||||
...(advertLocationSource !== (config.advert_location_source ?? 'current')
|
||||
? { advert_location_source: advertLocationSource }
|
||||
: {}),
|
||||
radio: {
|
||||
freq: parsedFreq,
|
||||
bw: parsedBw,
|
||||
@@ -506,6 +511,25 @@ export function SettingsRadioSection({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="advert-location-source">Advert Location Source</Label>
|
||||
<select
|
||||
id="advert-location-source"
|
||||
value={advertLocationSource}
|
||||
onChange={(e) => setAdvertLocationSource(e.target.value as 'off' | 'current')}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="current">Include Node Location</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Companion-radio firmware does not distinguish between saved coordinates and live GPS
|
||||
here. When enabled, adverts include the node's current location state. That may be
|
||||
the last coordinates you set from RemoteTerm or live GPS coordinates if the node itself
|
||||
is already updating them. RemoteTerm cannot enable GPS on the node through the interface
|
||||
library.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.path_hash_mode_supported && (
|
||||
|
||||
@@ -12,24 +12,41 @@ The visualizer displays:
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Layer (`components/visualizer/useVisualizerData3D.ts`)
|
||||
### Semantic Data Layer (`networkGraph/packetNetworkGraph.ts`)
|
||||
|
||||
The custom hook manages all graph state and simulation logic:
|
||||
The packet-network module owns the canonical mesh representation and the visibility-aware projection logic:
|
||||
|
||||
```
|
||||
Packets → Parse → Aggregate by key → Observation window → Publish → Animate
|
||||
Packets → Parse → Canonical observations/adjacency → Projection by settings
|
||||
```
|
||||
|
||||
**Key responsibilities:**
|
||||
|
||||
- Maintains node and link maps (`nodesRef`, `linksRef`)
|
||||
- Resolves packet source / repeater / destination nodes into a canonical path
|
||||
- Maintains canonical node, link, observation, and neighbor state independent of UI toggles
|
||||
- Applies ambiguous repeater heuristics and advert-path hints while building canonical data
|
||||
- Projects canonical paths into rendered links, including dashed bridges over hidden ambiguous runs
|
||||
- Exposes a reusable semantic surface for other consumers besides the 3D visualizer
|
||||
|
||||
### Visualizer Data Hook (`components/visualizer/useVisualizerData3D.ts`)
|
||||
|
||||
The hook manages render-specific state and animation timing on top of the shared packet-network data layer:
|
||||
|
||||
```
|
||||
Canonical projection → Aggregate by key → Observation window → Publish → Animate
|
||||
```
|
||||
|
||||
**Key responsibilities:**
|
||||
|
||||
- Adapts semantic packet-network nodes/links into `GraphNode` / `GraphLink` render objects
|
||||
- Runs `d3-force-3d` simulation for 3D layout (`.numDimensions(3)`)
|
||||
- Processes incoming packets with deduplication
|
||||
- Aggregates packet repeats across multiple paths
|
||||
- Processes incoming packets with deduplication and feeds them into the semantic layer
|
||||
- Aggregates packet repeats across multiple projected paths
|
||||
- Manages particle queue and animation timing
|
||||
|
||||
**State:**
|
||||
|
||||
- `networkStateRef`: Canonical packet-network state (nodes, links, observations, neighbors)
|
||||
- `nodesRef`: Map of node ID → GraphNode
|
||||
- `linksRef`: Map of link key → GraphLink
|
||||
- `particlesRef`: Array of active Particle objects
|
||||
@@ -50,6 +67,8 @@ Scene creation, render-loop updates, raycasting hover, and click-to-pin interact
|
||||
|
||||
### Shared Utilities
|
||||
|
||||
- `networkGraph/packetNetworkGraph.ts`
|
||||
- Canonical packet-network types and replay/projection logic
|
||||
- `components/visualizer/shared.ts`
|
||||
- Graph-specific types: `GraphNode`, `GraphLink`, `NodeMeshData`
|
||||
- Shared rendering helpers: node colors, relative-time formatting, typed-array growth helpers
|
||||
@@ -75,8 +94,9 @@ When a new packet arrives from the WebSocket:
|
||||
|
||||
```typescript
|
||||
packets.forEach((packet) => {
|
||||
if (processedRef.current.has(packet.id)) return; // Skip duplicates
|
||||
processedRef.current.add(packet.id);
|
||||
const observationKey = getRawPacketObservationKey(packet);
|
||||
if (processedRef.current.has(observationKey)) return; // Skip duplicates
|
||||
processedRef.current.add(observationKey);
|
||||
|
||||
const parsed = parsePacket(packet.data);
|
||||
const key = generatePacketKey(parsed, packet);
|
||||
@@ -196,6 +216,8 @@ When a winner is found, the ambiguous node gets a `probableIdentity` label (the
|
||||
|
||||
**Interaction with traffic splitting:** Advert-path hints run first. If a probable identity is found, the display name is set. Traffic splitting can still produce separate node IDs (`?XX:>YY`), but won't overwrite the advert-path display name.
|
||||
|
||||
**Sibling collapse projection:** When an ambiguous repeater has a high-confidence likely identity and that likely repeater also appears as a definitely-known sibling connecting to the same next hop, the projection layer can collapse the ambiguous node into the known repeater. This is projection-only: canonical observations and canonical neighbor truth remain unchanged.
|
||||
|
||||
**Toggle:** "Use repeater advert-path identity hints" checkbox (enabled by default, disabled when ambiguous repeaters are hidden).
|
||||
|
||||
### Traffic Pattern Splitting (Experimental)
|
||||
@@ -308,40 +330,44 @@ function buildPath(parsed, packet, myPrefix): string[] {
|
||||
| Pan (right-drag) | Pan the camera |
|
||||
| Scroll wheel | Zoom in/out |
|
||||
|
||||
**Click-to-pin:** When a node is pinned, hovering other nodes does not change the highlight. The tooltip shows "Traffic exchanged with:" listing all connected neighbors with their possible names.
|
||||
**Click-to-pin:** When a node is pinned, hovering other nodes does not change the highlight. The tooltip shows "Traffic exchanged with:" using canonical packet-network adjacency, not rendered-link adjacency, so hidden repeaters still appear truthfully as hidden neighbors.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------------------- | ------- | --------------------------------------------------------- |
|
||||
| Ambiguous repeaters | On | Show nodes when only partial prefix known |
|
||||
| Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders |
|
||||
| Advert-path identity hints | On | Use stored advert paths to label ambiguous repeaters |
|
||||
| Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) |
|
||||
| Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) |
|
||||
| Let 'em drift | On | Continuous layout optimization |
|
||||
| Repulsion | 200 | Force strength (50-2500) |
|
||||
| Packet speed | 2x | Particle animation speed multiplier (1x-5x) |
|
||||
| Shuffle layout | - | Button to randomize node positions and reheat sim |
|
||||
| Oooh Big Stretch! | - | Button to temporarily increase repulsion then relax |
|
||||
| Clear & Reset | - | Button to clear all nodes, links, and packets |
|
||||
| Hide UI | Off | Hide legends and most controls for cleaner view |
|
||||
| Full screen | Off | Hide the packet feed panel (desktop only) |
|
||||
| Option | Default | Description |
|
||||
| -------------------------- | ------- | ----------------------------------------------------------- |
|
||||
| Ambiguous repeaters | On | Show nodes when only partial prefix known |
|
||||
| Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders |
|
||||
| Advert-path identity hints | On | Use stored advert paths to label ambiguous repeaters |
|
||||
| Collapse sibling repeaters | On | Merge likely ambiguous repeater with known sibling repeater |
|
||||
| Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) |
|
||||
| Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) |
|
||||
| Let 'em drift | On | Continuous layout optimization |
|
||||
| Repulsion | 200 | Force strength (50-2500) |
|
||||
| Packet speed | 2x | Particle animation speed multiplier (1x-5x) |
|
||||
| Shuffle layout | - | Button to randomize node positions and reheat sim |
|
||||
| Oooh Big Stretch! | - | Button to temporarily increase repulsion then relax |
|
||||
| Clear & Reset | - | Button to clear all nodes, links, and packets |
|
||||
| Hide UI | Off | Hide legends and most controls for cleaner view |
|
||||
| Full screen | Off | Hide the packet feed panel (desktop only) |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
PacketVisualizer3D.tsx
|
||||
├── TYPES (GraphNode extends SimulationNodeDatum3D, GraphLink)
|
||||
├── CONSTANTS (NODE_COLORS, NODE_LEGEND_ITEMS)
|
||||
├── DATA LAYER HOOK (useVisualizerData3D)
|
||||
│ ├── Refs (nodes, links, particles, simulation, pending, timers, trafficPatterns, stretchRaf)
|
||||
│ ├── d3-force-3d simulation initialization (.numDimensions(3))
|
||||
│ ├── Contact indexing (byPrefix12 / byName / byPrefix)
|
||||
│ ├── Node/link management (addNode, addLink, syncSimulation)
|
||||
│ ├── Path building (resolveNode, buildPath)
|
||||
├── SEMANTIC DATA LAYER (networkGraph/packetNetworkGraph.ts)
|
||||
│ ├── Contact/advert indexes
|
||||
│ ├── Canonical node/link/neighbor/observation state
|
||||
│ ├── Path building (resolveNode, buildCanonicalPathForPacket)
|
||||
│ ├── Traffic pattern analysis (for repeater disambiguation)
|
||||
│ └── Packet processing & publishing
|
||||
│ └── Projection (projectCanonicalPath, projectPacketNetwork)
|
||||
├── DATA HOOK (useVisualizerData3D)
|
||||
│ ├── Refs (network state, render nodes, links, particles, simulation, pending, timers, stretchRaf)
|
||||
│ ├── d3-force-3d simulation initialization (.numDimensions(3))
|
||||
│ ├── Semantic→render adaptation
|
||||
│ ├── Observation-window packet aggregation
|
||||
│ └── Particle publishing
|
||||
└── MAIN COMPONENT (PacketVisualizer3D)
|
||||
├── Three.js scene setup (WebGLRenderer, CSS2DRenderer, OrbitControls)
|
||||
├── Node mesh management (SphereGeometry + CSS2DObject labels)
|
||||
@@ -356,6 +382,13 @@ utils/visualizerUtils.ts
|
||||
├── Constants (COLORS, PARTICLE_COLOR_MAP, PARTICLE_SPEED, PACKET_LEGEND_ITEMS)
|
||||
└── Functions (parsePacket, generatePacketKey, analyzeRepeaterTraffic, etc.)
|
||||
|
||||
networkGraph/packetNetworkGraph.ts
|
||||
├── Types (PacketNetworkNode, PacketNetworkLink, PacketNetworkObservation, projection types)
|
||||
├── Context builders (contact and advert-path indexes)
|
||||
├── Canonical replay (ingestPacketIntoPacketNetwork)
|
||||
├── Projection helpers (projectCanonicalPath, projectPacketNetwork)
|
||||
└── State maintenance (clear, prune, neighbor snapshots)
|
||||
|
||||
types/d3-force-3d.d.ts
|
||||
└── Type declarations for d3-force-3d (SimulationNodeDatum3D, Simulation3D, forces)
|
||||
```
|
||||
|
||||
@@ -13,6 +13,8 @@ interface VisualizerControlsProps {
|
||||
setShowAmbiguousNodes: (value: boolean) => void;
|
||||
useAdvertPathHints: boolean;
|
||||
setUseAdvertPathHints: (value: boolean) => void;
|
||||
collapseLikelyKnownSiblingRepeaters: boolean;
|
||||
setCollapseLikelyKnownSiblingRepeaters: (value: boolean) => void;
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
setSplitAmbiguousByTraffic: (value: boolean) => void;
|
||||
observationWindowSec: number;
|
||||
@@ -46,6 +48,8 @@ export function VisualizerControls({
|
||||
setShowAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
setUseAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
setCollapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic,
|
||||
setSplitAmbiguousByTraffic,
|
||||
observationWindowSec,
|
||||
@@ -149,55 +153,77 @@ export function VisualizerControls({
|
||||
Show ambiguous sender/recipient
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={useAdvertPathHints}
|
||||
onCheckedChange={(c) => setUseAdvertPathHints(c === true)}
|
||||
disabled={!showAmbiguousPaths}
|
||||
/>
|
||||
<span
|
||||
title="Use stored repeater advert paths to assign likely identity labels for ambiguous repeater nodes"
|
||||
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
|
||||
>
|
||||
Use repeater advert-path identity hints
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={splitAmbiguousByTraffic}
|
||||
onCheckedChange={(c) => setSplitAmbiguousByTraffic(c === true)}
|
||||
disabled={!showAmbiguousPaths}
|
||||
/>
|
||||
<span
|
||||
title="Split ambiguous repeaters into separate nodes based on traffic patterns (prev→next). Helps identify colliding prefixes representing different physical nodes, but requires enough traffic to disambiguate."
|
||||
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
|
||||
>
|
||||
Heuristically group repeaters by traffic pattern
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="observation-window-3d"
|
||||
className="text-muted-foreground"
|
||||
title="How long to wait for duplicate packets via different paths before animating"
|
||||
>
|
||||
Ack/echo listen window:
|
||||
</label>
|
||||
<input
|
||||
id="observation-window-3d"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={observationWindowSec}
|
||||
onChange={(e) =>
|
||||
setObservationWindowSec(
|
||||
Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
|
||||
)
|
||||
}
|
||||
className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground">sec</span>
|
||||
</div>
|
||||
<details className="rounded border border-border/60 px-2 py-1">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground">
|
||||
Advanced
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={useAdvertPathHints}
|
||||
onCheckedChange={(c) => setUseAdvertPathHints(c === true)}
|
||||
disabled={!showAmbiguousPaths}
|
||||
/>
|
||||
<span
|
||||
title="Use stored repeater advert paths to assign likely identity labels for ambiguous repeater nodes."
|
||||
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
|
||||
>
|
||||
Use repeater advert-path identity hints
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={collapseLikelyKnownSiblingRepeaters}
|
||||
onCheckedChange={(c) => setCollapseLikelyKnownSiblingRepeaters(c === true)}
|
||||
disabled={!showAmbiguousPaths || !useAdvertPathHints}
|
||||
/>
|
||||
<span
|
||||
title="When an ambiguous repeater has a high-confidence likely-identity that matches a sibling definitely-known repeater, and they both connect to the same next hop, collapse them into the known repeater. This should resolve more ambiguity as the mesh navigates the 1.14 upgrade."
|
||||
className={
|
||||
!showAmbiguousPaths || !useAdvertPathHints ? 'text-muted-foreground' : ''
|
||||
}
|
||||
>
|
||||
Collapse likely sibling repeaters
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={splitAmbiguousByTraffic}
|
||||
onCheckedChange={(c) => setSplitAmbiguousByTraffic(c === true)}
|
||||
disabled={!showAmbiguousPaths}
|
||||
/>
|
||||
<span
|
||||
title="Split ambiguous repeaters into separate nodes based on traffic patterns (prev→next). Helps identify colliding prefixes representing different physical nodes, but requires enough traffic to disambiguate."
|
||||
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
|
||||
>
|
||||
Heuristically group repeaters by traffic pattern
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="observation-window-3d"
|
||||
className="text-muted-foreground"
|
||||
title="How long to wait for duplicate packets via different paths before animating"
|
||||
>
|
||||
Ack/echo listen window:
|
||||
</label>
|
||||
<input
|
||||
id="observation-window-3d"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={observationWindowSec}
|
||||
onChange={(e) =>
|
||||
setObservationWindowSec(
|
||||
Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
|
||||
)
|
||||
}
|
||||
className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground">sec</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div className="border-t border-border pt-2 mt-1 flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import type { GraphNode } from './shared';
|
||||
import type { PacketNetworkNode } from '../../networkGraph/packetNetworkGraph';
|
||||
import { formatRelativeTime } from './shared';
|
||||
|
||||
interface VisualizerTooltipProps {
|
||||
activeNodeId: string | null;
|
||||
nodes: Map<string, GraphNode>;
|
||||
neighborIds: string[];
|
||||
canonicalNodes: Map<string, PacketNetworkNode>;
|
||||
canonicalNeighborIds: Map<string, string[]>;
|
||||
renderedNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: VisualizerTooltipProps) {
|
||||
export function VisualizerTooltip({
|
||||
activeNodeId,
|
||||
canonicalNodes,
|
||||
canonicalNeighborIds,
|
||||
renderedNodeIds,
|
||||
}: VisualizerTooltipProps) {
|
||||
if (!activeNodeId) return null;
|
||||
|
||||
const node = nodes.get(activeNodeId);
|
||||
const node = canonicalNodes.get(activeNodeId);
|
||||
if (!node) return null;
|
||||
|
||||
const neighborIds = canonicalNeighborIds.get(activeNodeId) ?? [];
|
||||
const neighbors = neighborIds
|
||||
.map((nid) => {
|
||||
const neighbor = nodes.get(nid);
|
||||
const neighbor = canonicalNodes.get(nid);
|
||||
if (!neighbor) return null;
|
||||
const displayName =
|
||||
neighbor.name || (neighbor.type === 'self' ? 'Me' : neighbor.id.slice(0, 8));
|
||||
return { id: nid, name: displayName, ambiguousNames: neighbor.ambiguousNames };
|
||||
return {
|
||||
id: nid,
|
||||
name: displayName,
|
||||
ambiguousNames: neighbor.ambiguousNames,
|
||||
hidden: !renderedNodeIds.has(nid),
|
||||
};
|
||||
})
|
||||
.filter((neighbor): neighbor is NonNullable<typeof neighbor> => neighbor !== null);
|
||||
|
||||
@@ -56,6 +68,7 @@ export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: Visualiz
|
||||
{neighbors.map((neighbor) => (
|
||||
<li key={neighbor.id}>
|
||||
{neighbor.name}
|
||||
{neighbor.hidden && <span className="text-muted-foreground/60"> (hidden)</span>}
|
||||
{neighbor.ambiguousNames && neighbor.ambiguousNames.length > 0 && (
|
||||
<span className="text-muted-foreground/60">
|
||||
{' '}
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface GraphLink extends SimulationLinkDatum<GraphNode> {
|
||||
source: string | GraphNode;
|
||||
target: string | GraphNode;
|
||||
lastActivity: number;
|
||||
hasDirectObservation: boolean;
|
||||
hasHiddenIntermediate: boolean;
|
||||
hiddenHopLabels: string[];
|
||||
}
|
||||
|
||||
export interface NodeMeshData {
|
||||
@@ -74,6 +77,11 @@ export function formatRelativeTime(timestamp: number): string {
|
||||
return secs > 0 ? `${minutes}m ${secs}s ago` : `${minutes}m ago`;
|
||||
}
|
||||
|
||||
export function getSceneNodeLabel(node: Pick<GraphNode, 'id' | 'name' | 'type' | 'isAmbiguous'>) {
|
||||
const baseLabel = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
|
||||
return node.isAmbiguous ? `${baseLabel} (?)` : baseLabel;
|
||||
}
|
||||
|
||||
export function normalizePacketTimestampMs(timestamp: number | null | undefined): number {
|
||||
if (!Number.isFinite(timestamp) || !timestamp || timestamp <= 0) {
|
||||
return Date.now();
|
||||
|
||||
@@ -5,7 +5,13 @@ import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRe
|
||||
|
||||
import { COLORS, getLinkId } from '../../utils/visualizerUtils';
|
||||
import type { VisualizerData3D } from './useVisualizerData3D';
|
||||
import { arraysEqual, getBaseNodeColor, growFloat32Buffer, type NodeMeshData } from './shared';
|
||||
import {
|
||||
arraysEqual,
|
||||
getBaseNodeColor,
|
||||
getSceneNodeLabel,
|
||||
growFloat32Buffer,
|
||||
type NodeMeshData,
|
||||
} from './shared';
|
||||
|
||||
interface UseVisualizer3DSceneArgs {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
@@ -32,10 +38,12 @@ export function useVisualizer3DScene({
|
||||
const nodeMeshesRef = useRef<Map<string, NodeMeshData>>(new Map());
|
||||
const raycastTargetsRef = useRef<THREE.Mesh[]>([]);
|
||||
const linkLineRef = useRef<THREE.LineSegments | null>(null);
|
||||
const dashedLinkLineRef = useRef<THREE.LineSegments | null>(null);
|
||||
const highlightLineRef = useRef<THREE.LineSegments | null>(null);
|
||||
const particlePointsRef = useRef<THREE.Points | null>(null);
|
||||
const particleTextureRef = useRef<THREE.Texture | null>(null);
|
||||
const linkPositionBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
const dashedLinkPositionBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
const highlightPositionBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
const particlePositionBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
const particleColorBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
@@ -126,6 +134,19 @@ export function useVisualizer3DScene({
|
||||
scene.add(linkSegments);
|
||||
linkLineRef.current = linkSegments;
|
||||
|
||||
const dashedLinkGeometry = new THREE.BufferGeometry();
|
||||
const dashedLinkMaterial = new THREE.LineDashedMaterial({
|
||||
color: 0x94a3b8,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
dashSize: 16,
|
||||
gapSize: 10,
|
||||
});
|
||||
const dashedLinkSegments = new THREE.LineSegments(dashedLinkGeometry, dashedLinkMaterial);
|
||||
dashedLinkSegments.visible = false;
|
||||
scene.add(dashedLinkSegments);
|
||||
dashedLinkLineRef.current = dashedLinkSegments;
|
||||
|
||||
const highlightGeometry = new THREE.BufferGeometry();
|
||||
const highlightMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0xffd700,
|
||||
@@ -198,6 +219,12 @@ export function useVisualizer3DScene({
|
||||
(linkLineRef.current.material as THREE.Material).dispose();
|
||||
linkLineRef.current = null;
|
||||
}
|
||||
if (dashedLinkLineRef.current) {
|
||||
scene.remove(dashedLinkLineRef.current);
|
||||
dashedLinkLineRef.current.geometry.dispose();
|
||||
(dashedLinkLineRef.current.material as THREE.Material).dispose();
|
||||
dashedLinkLineRef.current = null;
|
||||
}
|
||||
if (highlightLineRef.current) {
|
||||
scene.remove(highlightLineRef.current);
|
||||
highlightLineRef.current.geometry.dispose();
|
||||
@@ -213,6 +240,7 @@ export function useVisualizer3DScene({
|
||||
particleTexture.dispose();
|
||||
particleTextureRef.current = null;
|
||||
linkPositionBufferRef.current = new Float32Array(0);
|
||||
dashedLinkPositionBufferRef.current = new Float32Array(0);
|
||||
highlightPositionBufferRef.current = new Float32Array(0);
|
||||
particlePositionBufferRef.current = new Float32Array(0);
|
||||
particleColorBufferRef.current = new Float32Array(0);
|
||||
@@ -340,7 +368,7 @@ export function useVisualizer3DScene({
|
||||
if (nd.labelDiv.style.color !== labelColor) {
|
||||
nd.labelDiv.style.color = labelColor;
|
||||
}
|
||||
const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
|
||||
const labelText = getSceneNodeLabel(node);
|
||||
if (nd.labelDiv.textContent !== labelText) {
|
||||
nd.labelDiv.textContent = labelText;
|
||||
}
|
||||
@@ -369,11 +397,16 @@ export function useVisualizer3DScene({
|
||||
}
|
||||
const activeId = pinnedNodeIdRef.current ?? hoveredNodeIdRef.current;
|
||||
|
||||
const visibleLinks = [];
|
||||
const solidLinks = [];
|
||||
const dashedLinks = [];
|
||||
for (const link of links.values()) {
|
||||
const { sourceId, targetId } = getLinkId(link);
|
||||
if (currentNodeIds.has(sourceId) && currentNodeIds.has(targetId)) {
|
||||
visibleLinks.push(link);
|
||||
if (link.hasDirectObservation || !link.hasHiddenIntermediate) {
|
||||
solidLinks.push(link);
|
||||
} else {
|
||||
dashedLinks.push(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +415,8 @@ export function useVisualizer3DScene({
|
||||
const linkLine = linkLineRef.current;
|
||||
if (linkLine) {
|
||||
const geometry = linkLine.geometry as THREE.BufferGeometry;
|
||||
const requiredLength = visibleLinks.length * 6;
|
||||
const requiredLength = solidLinks.length * 6;
|
||||
const highlightRequiredLength = (solidLinks.length + dashedLinks.length) * 6;
|
||||
if (linkPositionBufferRef.current.length < requiredLength) {
|
||||
linkPositionBufferRef.current = growFloat32Buffer(
|
||||
linkPositionBufferRef.current,
|
||||
@@ -397,10 +431,10 @@ export function useVisualizer3DScene({
|
||||
}
|
||||
|
||||
const highlightLine = highlightLineRef.current;
|
||||
if (highlightLine && highlightPositionBufferRef.current.length < requiredLength) {
|
||||
if (highlightLine && highlightPositionBufferRef.current.length < highlightRequiredLength) {
|
||||
highlightPositionBufferRef.current = growFloat32Buffer(
|
||||
highlightPositionBufferRef.current,
|
||||
requiredLength
|
||||
highlightRequiredLength
|
||||
);
|
||||
(highlightLine.geometry as THREE.BufferGeometry).setAttribute(
|
||||
'position',
|
||||
@@ -415,7 +449,7 @@ export function useVisualizer3DScene({
|
||||
let idx = 0;
|
||||
let hlIdx = 0;
|
||||
|
||||
for (const link of visibleLinks) {
|
||||
for (const link of solidLinks) {
|
||||
const { sourceId, targetId } = getLinkId(link);
|
||||
const sNode = nodes.get(sourceId);
|
||||
const tNode = nodes.get(targetId);
|
||||
@@ -446,6 +480,23 @@ export function useVisualizer3DScene({
|
||||
}
|
||||
}
|
||||
|
||||
for (const link of dashedLinks) {
|
||||
const { sourceId, targetId } = getLinkId(link);
|
||||
if (activeId && (sourceId === activeId || targetId === activeId)) {
|
||||
const sNode = nodes.get(sourceId);
|
||||
const tNode = nodes.get(targetId);
|
||||
if (!sNode || !tNode) continue;
|
||||
|
||||
connectedIds?.add(sourceId === activeId ? targetId : sourceId);
|
||||
hlPositions[hlIdx++] = sNode.x ?? 0;
|
||||
hlPositions[hlIdx++] = sNode.y ?? 0;
|
||||
hlPositions[hlIdx++] = sNode.z ?? 0;
|
||||
hlPositions[hlIdx++] = tNode.x ?? 0;
|
||||
hlPositions[hlIdx++] = tNode.y ?? 0;
|
||||
hlPositions[hlIdx++] = tNode.z ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
|
||||
if (positionAttr) {
|
||||
positionAttr.needsUpdate = true;
|
||||
@@ -464,6 +515,51 @@ export function useVisualizer3DScene({
|
||||
}
|
||||
}
|
||||
|
||||
const dashedLinkLine = dashedLinkLineRef.current;
|
||||
if (dashedLinkLine) {
|
||||
const geometry = dashedLinkLine.geometry as THREE.BufferGeometry;
|
||||
const requiredLength = dashedLinks.length * 6;
|
||||
if (dashedLinkPositionBufferRef.current.length < requiredLength) {
|
||||
dashedLinkPositionBufferRef.current = growFloat32Buffer(
|
||||
dashedLinkPositionBufferRef.current,
|
||||
requiredLength
|
||||
);
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(dashedLinkPositionBufferRef.current, 3).setUsage(
|
||||
THREE.DynamicDrawUsage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const positions = dashedLinkPositionBufferRef.current;
|
||||
let idx = 0;
|
||||
|
||||
for (const link of dashedLinks) {
|
||||
const { sourceId, targetId } = getLinkId(link);
|
||||
const sNode = nodes.get(sourceId);
|
||||
const tNode = nodes.get(targetId);
|
||||
if (!sNode || !tNode) continue;
|
||||
|
||||
positions[idx++] = sNode.x ?? 0;
|
||||
positions[idx++] = sNode.y ?? 0;
|
||||
positions[idx++] = sNode.z ?? 0;
|
||||
positions[idx++] = tNode.x ?? 0;
|
||||
positions[idx++] = tNode.y ?? 0;
|
||||
positions[idx++] = tNode.z ?? 0;
|
||||
}
|
||||
|
||||
const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
|
||||
if (positionAttr) {
|
||||
positionAttr.needsUpdate = true;
|
||||
}
|
||||
geometry.setDrawRange(0, idx / 3);
|
||||
dashedLinkLine.visible = idx > 0;
|
||||
if (idx > 0 && positionAttr) {
|
||||
dashedLinkLine.computeLineDistances();
|
||||
}
|
||||
}
|
||||
|
||||
let writeIdx = 0;
|
||||
for (let readIdx = 0; readIdx < particles.length; readIdx++) {
|
||||
const particle = particles[readIdx];
|
||||
|
||||
@@ -10,10 +10,20 @@ import {
|
||||
type ForceLink3D,
|
||||
type Simulation3D,
|
||||
} from 'd3-force-3d';
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import type { PacketNetworkNode } from '../../networkGraph/packetNetworkGraph';
|
||||
import {
|
||||
buildPacketNetworkContext,
|
||||
clearPacketNetworkState,
|
||||
createPacketNetworkState,
|
||||
ensureSelfNode,
|
||||
ingestPacketIntoPacketNetwork,
|
||||
projectCanonicalPath,
|
||||
projectPacketNetwork,
|
||||
prunePacketNetworkState,
|
||||
snapshotNeighborIds,
|
||||
} from '../../networkGraph/packetNetworkGraph';
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
type ContactAdvertPathSummary,
|
||||
type RadioConfig,
|
||||
@@ -21,22 +31,15 @@ import {
|
||||
} from '../../types';
|
||||
import { getRawPacketObservationKey } from '../../utils/rawPacketIdentity';
|
||||
import {
|
||||
type Particle,
|
||||
type PendingPacket,
|
||||
type RepeaterTrafficData,
|
||||
PARTICLE_COLOR_MAP,
|
||||
PARTICLE_SPEED,
|
||||
analyzeRepeaterTraffic,
|
||||
buildAmbiguousRepeaterLabel,
|
||||
buildAmbiguousRepeaterNodeId,
|
||||
buildLinkKey,
|
||||
dedupeConsecutive,
|
||||
generatePacketKey,
|
||||
getNodeType,
|
||||
getPacketLabel,
|
||||
parsePacket,
|
||||
recordTrafficObservation,
|
||||
type Particle,
|
||||
PARTICLE_COLOR_MAP,
|
||||
PARTICLE_SPEED,
|
||||
type PendingPacket,
|
||||
} from '../../utils/visualizerUtils';
|
||||
import { type GraphLink, type GraphNode, normalizePacketTimestampMs } from './shared';
|
||||
import { type GraphLink, type GraphNode } from './shared';
|
||||
|
||||
export interface UseVisualizerData3DOptions {
|
||||
packets: RawPacket[];
|
||||
@@ -46,6 +49,7 @@ export interface UseVisualizerData3DOptions {
|
||||
showAmbiguousPaths: boolean;
|
||||
showAmbiguousNodes: boolean;
|
||||
useAdvertPathHints: boolean;
|
||||
collapseLikelyKnownSiblingRepeaters: boolean;
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
chargeStrength: number;
|
||||
letEmDrift: boolean;
|
||||
@@ -58,12 +62,42 @@ export interface UseVisualizerData3DOptions {
|
||||
export interface VisualizerData3D {
|
||||
nodes: Map<string, GraphNode>;
|
||||
links: Map<string, GraphLink>;
|
||||
canonicalNodes: Map<string, PacketNetworkNode>;
|
||||
canonicalNeighborIds: Map<string, string[]>;
|
||||
renderedNodeIds: Set<string>;
|
||||
particles: Particle[];
|
||||
stats: { processed: number; animated: number; nodes: number; links: number };
|
||||
expandContract: () => void;
|
||||
clearAndReset: () => void;
|
||||
}
|
||||
|
||||
function buildInitialRenderNode(node: PacketNetworkNode): GraphNode {
|
||||
if (node.id === 'self') {
|
||||
return {
|
||||
...node,
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
fx: 0,
|
||||
fy: 0,
|
||||
fz: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
vz: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const r = 80 + Math.random() * 100;
|
||||
return {
|
||||
...node,
|
||||
x: r * Math.sin(phi) * Math.cos(theta),
|
||||
y: r * Math.sin(phi) * Math.sin(theta),
|
||||
z: r * Math.cos(phi),
|
||||
};
|
||||
}
|
||||
|
||||
export function useVisualizerData3D({
|
||||
packets,
|
||||
contacts,
|
||||
@@ -72,6 +106,7 @@ export function useVisualizerData3D({
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic,
|
||||
chargeStrength,
|
||||
letEmDrift,
|
||||
@@ -80,6 +115,7 @@ export function useVisualizerData3D({
|
||||
pruneStaleNodes,
|
||||
pruneStaleMinutes,
|
||||
}: UseVisualizerData3DOptions): VisualizerData3D {
|
||||
const networkStateRef = useRef(createPacketNetworkState(config?.name || 'Me'));
|
||||
const nodesRef = useRef<Map<string, GraphNode>>(new Map());
|
||||
const linksRef = useRef<Map<string, GraphLink>>(new Map());
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
@@ -87,47 +123,23 @@ export function useVisualizerData3D({
|
||||
const processedRef = useRef<Set<string>>(new Set());
|
||||
const pendingRef = useRef<Map<string, PendingPacket>>(new Map());
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const trafficPatternsRef = useRef<Map<string, RepeaterTrafficData>>(new Map());
|
||||
const speedMultiplierRef = useRef(particleSpeedMultiplier);
|
||||
const observationWindowRef = useRef(observationWindowSec * 1000);
|
||||
const stretchRafRef = useRef<number | null>(null);
|
||||
const [stats, setStats] = useState({ processed: 0, animated: 0, nodes: 0, links: 0 });
|
||||
const [, setProjectionVersion] = useState(0);
|
||||
|
||||
const contactIndex = useMemo(() => {
|
||||
const byPrefix12 = new Map<string, Contact>();
|
||||
const byName = new Map<string, Contact>();
|
||||
const byPrefix = new Map<string, Contact[]>();
|
||||
|
||||
for (const contact of contacts) {
|
||||
const prefix12 = contact.public_key.slice(0, 12).toLowerCase();
|
||||
byPrefix12.set(prefix12, contact);
|
||||
|
||||
if (contact.name && !byName.has(contact.name)) {
|
||||
byName.set(contact.name, contact);
|
||||
}
|
||||
|
||||
for (let len = 1; len <= 12; len++) {
|
||||
const prefix = prefix12.slice(0, len);
|
||||
const matches = byPrefix.get(prefix);
|
||||
if (matches) {
|
||||
matches.push(contact);
|
||||
} else {
|
||||
byPrefix.set(prefix, [contact]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { byPrefix12, byName, byPrefix };
|
||||
}, [contacts]);
|
||||
|
||||
const advertPathIndex = useMemo(() => {
|
||||
const byRepeater = new Map<string, ContactAdvertPathSummary['paths']>();
|
||||
for (const summary of repeaterAdvertPaths) {
|
||||
const key = summary.public_key.slice(0, 12).toLowerCase();
|
||||
byRepeater.set(key, summary.paths);
|
||||
}
|
||||
return { byRepeater };
|
||||
}, [repeaterAdvertPaths]);
|
||||
const packetNetworkContext = useMemo(
|
||||
() =>
|
||||
buildPacketNetworkContext({
|
||||
contacts,
|
||||
config,
|
||||
repeaterAdvertPaths,
|
||||
splitAmbiguousByTraffic,
|
||||
useAdvertPathHints,
|
||||
}),
|
||||
[contacts, config, repeaterAdvertPaths, splitAmbiguousByTraffic, useAdvertPathHints]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
speedMultiplierRef.current = particleSpeedMultiplier;
|
||||
@@ -213,96 +225,108 @@ export function useVisualizerData3D({
|
||||
? prev
|
||||
: { ...prev, nodes: nodes.length, links: links.length }
|
||||
);
|
||||
setProjectionVersion((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodesRef.current.has('self')) {
|
||||
nodesRef.current.set('self', {
|
||||
id: 'self',
|
||||
name: config?.name || 'Me',
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: Date.now(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
});
|
||||
syncSimulation();
|
||||
}
|
||||
}, [config, syncSimulation]);
|
||||
|
||||
useEffect(() => {
|
||||
processedRef.current.clear();
|
||||
const selfNode = nodesRef.current.get('self');
|
||||
nodesRef.current.clear();
|
||||
if (selfNode) nodesRef.current.set('self', selfNode);
|
||||
linksRef.current.clear();
|
||||
particlesRef.current = [];
|
||||
pendingRef.current.clear();
|
||||
timersRef.current.forEach((t) => clearTimeout(t));
|
||||
timersRef.current.clear();
|
||||
trafficPatternsRef.current.clear();
|
||||
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
||||
syncSimulation();
|
||||
}, [
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
splitAmbiguousByTraffic,
|
||||
syncSimulation,
|
||||
]);
|
||||
|
||||
const addNode = useCallback(
|
||||
(
|
||||
id: string,
|
||||
name: string | null,
|
||||
type: GraphNode['type'],
|
||||
isAmbiguous: boolean,
|
||||
probableIdentity?: string | null,
|
||||
ambiguousNames?: string[],
|
||||
lastSeen?: number | null,
|
||||
activityAtMs?: number
|
||||
) => {
|
||||
const activityAt = activityAtMs ?? Date.now();
|
||||
const existing = nodesRef.current.get(id);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAt);
|
||||
if (name) existing.name = name;
|
||||
if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity;
|
||||
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
|
||||
if (lastSeen !== undefined) existing.lastSeen = lastSeen;
|
||||
} else {
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const r = 80 + Math.random() * 100;
|
||||
nodesRef.current.set(id, {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
isAmbiguous,
|
||||
lastActivity: activityAt,
|
||||
probableIdentity,
|
||||
lastSeen,
|
||||
ambiguousNames,
|
||||
x: r * Math.sin(phi) * Math.cos(theta),
|
||||
y: r * Math.sin(phi) * Math.sin(theta),
|
||||
z: r * Math.cos(phi),
|
||||
});
|
||||
const upsertRenderNode = useCallback(
|
||||
(node: PacketNetworkNode, existing?: GraphNode): GraphNode => {
|
||||
if (!existing) {
|
||||
return buildInitialRenderNode(node);
|
||||
}
|
||||
|
||||
existing.name = node.name;
|
||||
existing.type = node.type;
|
||||
existing.isAmbiguous = node.isAmbiguous;
|
||||
existing.lastActivity = node.lastActivity;
|
||||
existing.lastActivityReason = node.lastActivityReason;
|
||||
existing.lastSeen = node.lastSeen;
|
||||
existing.probableIdentity = node.probableIdentity;
|
||||
existing.ambiguousNames = node.ambiguousNames;
|
||||
|
||||
if (node.id === 'self') {
|
||||
existing.x = 0;
|
||||
existing.y = 0;
|
||||
existing.z = 0;
|
||||
existing.fx = 0;
|
||||
existing.fy = 0;
|
||||
existing.fz = 0;
|
||||
existing.vx = 0;
|
||||
existing.vy = 0;
|
||||
existing.vz = 0;
|
||||
}
|
||||
|
||||
return existing;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const addLink = useCallback((sourceId: string, targetId: string, activityAtMs?: number) => {
|
||||
const activityAt = activityAtMs ?? Date.now();
|
||||
const key = [sourceId, targetId].sort().join('->');
|
||||
const existing = linksRef.current.get(key);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAt);
|
||||
} else {
|
||||
linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: activityAt });
|
||||
const rebuildRenderProjection = useCallback(() => {
|
||||
const projection = projectPacketNetwork(networkStateRef.current, {
|
||||
showAmbiguousNodes,
|
||||
showAmbiguousPaths,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
});
|
||||
const previousNodes = nodesRef.current;
|
||||
const nextNodes = new Map<string, GraphNode>();
|
||||
|
||||
for (const [nodeId, node] of projection.nodes) {
|
||||
nextNodes.set(nodeId, upsertRenderNode(node, previousNodes.get(nodeId)));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const nextLinks = new Map<string, GraphLink>();
|
||||
for (const [key, link] of projection.links) {
|
||||
nextLinks.set(key, {
|
||||
source: link.sourceId,
|
||||
target: link.targetId,
|
||||
lastActivity: link.lastActivity,
|
||||
hasDirectObservation: link.hasDirectObservation,
|
||||
hasHiddenIntermediate: link.hasHiddenIntermediate,
|
||||
hiddenHopLabels: [...link.hiddenHopLabels],
|
||||
});
|
||||
}
|
||||
|
||||
nodesRef.current = nextNodes;
|
||||
linksRef.current = nextLinks;
|
||||
syncSimulation();
|
||||
}, [
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
showAmbiguousNodes,
|
||||
showAmbiguousPaths,
|
||||
syncSimulation,
|
||||
upsertRenderNode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureSelfNode(networkStateRef.current, config?.name || 'Me');
|
||||
const selfNode = networkStateRef.current.nodes.get('self');
|
||||
if (selfNode) {
|
||||
nodesRef.current.set('self', upsertRenderNode(selfNode, nodesRef.current.get('self')));
|
||||
}
|
||||
syncSimulation();
|
||||
}, [config?.name, syncSimulation, upsertRenderNode]);
|
||||
|
||||
useEffect(() => {
|
||||
processedRef.current.clear();
|
||||
clearPacketNetworkState(networkStateRef.current, { selfName: config?.name || 'Me' });
|
||||
nodesRef.current.clear();
|
||||
linksRef.current.clear();
|
||||
particlesRef.current = [];
|
||||
pendingRef.current.clear();
|
||||
timersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
timersRef.current.clear();
|
||||
|
||||
const selfNode = networkStateRef.current.nodes.get('self');
|
||||
if (selfNode) {
|
||||
nodesRef.current.set('self', upsertRenderNode(selfNode));
|
||||
}
|
||||
|
||||
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
||||
syncSimulation();
|
||||
}, [config?.name, splitAmbiguousByTraffic, syncSimulation, upsertRenderNode, useAdvertPathHints]);
|
||||
|
||||
useEffect(() => {
|
||||
rebuildRenderProjection();
|
||||
}, [rebuildRenderProjection]);
|
||||
|
||||
const publishPacket = useCallback((packetKey: string) => {
|
||||
const pending = pendingRef.current.get(packetKey);
|
||||
@@ -319,7 +343,7 @@ export function useVisualizerData3D({
|
||||
|
||||
for (let i = 0; i < dedupedPath.length - 1; i++) {
|
||||
particlesRef.current.push({
|
||||
linkKey: [dedupedPath[i], dedupedPath[i + 1]].sort().join('->'),
|
||||
linkKey: buildLinkKey(dedupedPath[i], dedupedPath[i + 1]),
|
||||
progress: -i,
|
||||
speed: PARTICLE_SPEED * speedMultiplierRef.current,
|
||||
color: PARTICLE_COLOR_MAP[pending.label],
|
||||
@@ -331,334 +355,10 @@ export function useVisualizerData3D({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pickLikelyRepeaterByAdvertPath = useCallback(
|
||||
(candidates: Contact[], nextPrefix: string | null) => {
|
||||
const nextHop = nextPrefix?.toLowerCase() ?? null;
|
||||
const scored = candidates
|
||||
.map((candidate) => {
|
||||
const prefix12 = candidate.public_key.slice(0, 12).toLowerCase();
|
||||
const paths = advertPathIndex.byRepeater.get(prefix12) ?? [];
|
||||
let matchScore = 0;
|
||||
let totalScore = 0;
|
||||
|
||||
for (const path of paths) {
|
||||
totalScore += path.heard_count;
|
||||
const pathNextHop = path.next_hop?.toLowerCase() ?? null;
|
||||
if (pathNextHop === nextHop) {
|
||||
matchScore += path.heard_count;
|
||||
}
|
||||
}
|
||||
|
||||
return { candidate, matchScore, totalScore };
|
||||
})
|
||||
.filter((entry) => entry.totalScore > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.matchScore - a.matchScore ||
|
||||
b.totalScore - a.totalScore ||
|
||||
a.candidate.public_key.localeCompare(b.candidate.public_key)
|
||||
);
|
||||
|
||||
if (scored.length === 0) return null;
|
||||
|
||||
const top = scored[0];
|
||||
const second = scored[1] ?? null;
|
||||
|
||||
if (top.matchScore < 2) return null;
|
||||
if (second && top.matchScore < second.matchScore * 2) return null;
|
||||
|
||||
return top.candidate;
|
||||
},
|
||||
[advertPathIndex]
|
||||
);
|
||||
|
||||
const resolveNode = useCallback(
|
||||
(
|
||||
source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
|
||||
isRepeater: boolean,
|
||||
showAmbiguous: boolean,
|
||||
myPrefix: string | null,
|
||||
activityAtMs: number,
|
||||
trafficContext?: { packetSource: string | null; nextPrefix: string | null }
|
||||
): string | null => {
|
||||
if (source.type === 'pubkey') {
|
||||
if (source.value.length < 12) return null;
|
||||
const nodeId = source.value.slice(0, 12).toLowerCase();
|
||||
if (myPrefix && nodeId === myPrefix) return 'self';
|
||||
const contact = contactIndex.byPrefix12.get(nodeId);
|
||||
addNode(
|
||||
nodeId,
|
||||
contact?.name || null,
|
||||
getNodeType(contact),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
contact?.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (source.type === 'name') {
|
||||
const contact = contactIndex.byName.get(source.value) ?? null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (myPrefix && nodeId === myPrefix) return 'self';
|
||||
addNode(
|
||||
nodeId,
|
||||
contact.name,
|
||||
getNodeType(contact),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
contact.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
const nodeId = `name:${source.value}`;
|
||||
addNode(
|
||||
nodeId,
|
||||
source.value,
|
||||
'client',
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
const lookupValue = source.value.toLowerCase();
|
||||
const matches = contactIndex.byPrefix.get(lookupValue) ?? [];
|
||||
const contact = matches.length === 1 ? matches[0] : null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (myPrefix && nodeId === myPrefix) return 'self';
|
||||
addNode(
|
||||
nodeId,
|
||||
contact.name,
|
||||
getNodeType(contact),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
contact.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (showAmbiguous) {
|
||||
const filtered = isRepeater
|
||||
? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER)
|
||||
: matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER);
|
||||
|
||||
if (filtered.length === 1) {
|
||||
const c = filtered[0];
|
||||
const nodeId = c.public_key.slice(0, 12).toLowerCase();
|
||||
addNode(
|
||||
nodeId,
|
||||
c.name,
|
||||
getNodeType(c),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
c.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (filtered.length > 1 || (filtered.length === 0 && isRepeater)) {
|
||||
const names = filtered.map((c) => c.name || c.public_key.slice(0, 8));
|
||||
const lastSeen = filtered.reduce(
|
||||
(max, c) => (c.last_seen && (!max || c.last_seen > max) ? c.last_seen : max),
|
||||
null as number | null
|
||||
);
|
||||
|
||||
let nodeId = buildAmbiguousRepeaterNodeId(lookupValue);
|
||||
let displayName = buildAmbiguousRepeaterLabel(lookupValue);
|
||||
let probableIdentity: string | null = null;
|
||||
let ambiguousNames = names.length > 0 ? names : undefined;
|
||||
|
||||
if (useAdvertPathHints && isRepeater && trafficContext) {
|
||||
const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null;
|
||||
const likely = pickLikelyRepeaterByAdvertPath(filtered, normalizedNext);
|
||||
if (likely) {
|
||||
const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase();
|
||||
probableIdentity = likelyName;
|
||||
displayName = likelyName;
|
||||
ambiguousNames = filtered
|
||||
.filter((c) => c.public_key !== likely.public_key)
|
||||
.map((c) => c.name || c.public_key.slice(0, 8));
|
||||
}
|
||||
}
|
||||
|
||||
if (splitAmbiguousByTraffic && isRepeater && trafficContext) {
|
||||
const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null;
|
||||
|
||||
if (trafficContext.packetSource) {
|
||||
recordTrafficObservation(
|
||||
trafficPatternsRef.current,
|
||||
lookupValue,
|
||||
trafficContext.packetSource,
|
||||
normalizedNext
|
||||
);
|
||||
}
|
||||
|
||||
const trafficData = trafficPatternsRef.current.get(lookupValue);
|
||||
if (trafficData) {
|
||||
const analysis = analyzeRepeaterTraffic(trafficData);
|
||||
if (analysis.shouldSplit && normalizedNext) {
|
||||
nodeId = buildAmbiguousRepeaterNodeId(lookupValue, normalizedNext);
|
||||
if (!probableIdentity) {
|
||||
displayName = buildAmbiguousRepeaterLabel(lookupValue, normalizedNext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addNode(
|
||||
nodeId,
|
||||
displayName,
|
||||
isRepeater ? 'repeater' : 'client',
|
||||
true,
|
||||
probableIdentity,
|
||||
ambiguousNames,
|
||||
lastSeen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[
|
||||
contactIndex,
|
||||
addNode,
|
||||
useAdvertPathHints,
|
||||
pickLikelyRepeaterByAdvertPath,
|
||||
splitAmbiguousByTraffic,
|
||||
]
|
||||
);
|
||||
|
||||
const buildPath = useCallback(
|
||||
(
|
||||
parsed: ReturnType<typeof parsePacket>,
|
||||
packet: RawPacket,
|
||||
myPrefix: string | null,
|
||||
activityAtMs: number
|
||||
): string[] => {
|
||||
if (!parsed) return [];
|
||||
const path: string[] = [];
|
||||
let packetSource: string | null = null;
|
||||
|
||||
if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'pubkey', value: parsed.advertPubkey },
|
||||
false,
|
||||
false,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'pubkey', value: parsed.anonRequestPubkey },
|
||||
false,
|
||||
false,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) {
|
||||
if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) {
|
||||
path.push('self');
|
||||
packetSource = 'self';
|
||||
} else {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'prefix', value: parsed.srcHash },
|
||||
false,
|
||||
showAmbiguousNodes,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.GroupText) {
|
||||
const senderName = parsed.groupTextSender || packet.decrypted_info?.sender;
|
||||
if (senderName) {
|
||||
const resolved = resolveNode(
|
||||
{ type: 'name', value: senderName },
|
||||
false,
|
||||
false,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (resolved) {
|
||||
path.push(resolved);
|
||||
packetSource = resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < parsed.pathBytes.length; i++) {
|
||||
const hexPrefix = parsed.pathBytes[i];
|
||||
const nextPrefix = parsed.pathBytes[i + 1] || null;
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'prefix', value: hexPrefix },
|
||||
true,
|
||||
showAmbiguousPaths,
|
||||
myPrefix,
|
||||
activityAtMs,
|
||||
{ packetSource, nextPrefix }
|
||||
);
|
||||
if (nodeId) path.push(nodeId);
|
||||
}
|
||||
|
||||
if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) {
|
||||
if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) {
|
||||
path.push('self');
|
||||
} else {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'prefix', value: parsed.dstHash },
|
||||
false,
|
||||
showAmbiguousNodes,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) path.push(nodeId);
|
||||
else path.push('self');
|
||||
}
|
||||
} else if (path.length > 0) {
|
||||
path.push('self');
|
||||
}
|
||||
|
||||
if (path.length > 0 && path[path.length - 1] !== 'self') {
|
||||
path.push('self');
|
||||
}
|
||||
|
||||
return dedupeConsecutive(path);
|
||||
},
|
||||
[resolveNode, showAmbiguousPaths, showAmbiguousNodes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let newProcessed = 0;
|
||||
let newAnimated = 0;
|
||||
let needsUpdate = false;
|
||||
const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null;
|
||||
let needsProjectionRebuild = false;
|
||||
|
||||
for (const packet of packets) {
|
||||
const observationKey = getRawPacketObservationKey(packet);
|
||||
@@ -670,34 +370,31 @@ export function useVisualizerData3D({
|
||||
processedRef.current = new Set(Array.from(processedRef.current).slice(-500));
|
||||
}
|
||||
|
||||
const parsed = parsePacket(packet.data);
|
||||
if (!parsed) continue;
|
||||
const ingested = ingestPacketIntoPacketNetwork(
|
||||
networkStateRef.current,
|
||||
packetNetworkContext,
|
||||
packet
|
||||
);
|
||||
if (!ingested) continue;
|
||||
needsProjectionRebuild = true;
|
||||
|
||||
const packetActivityAt = normalizePacketTimestampMs(packet.timestamp);
|
||||
const path = buildPath(parsed, packet, myPrefix, packetActivityAt);
|
||||
if (path.length < 2) continue;
|
||||
const projectedPath = projectCanonicalPath(networkStateRef.current, ingested.canonicalPath, {
|
||||
showAmbiguousNodes,
|
||||
showAmbiguousPaths,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
});
|
||||
if (projectedPath.nodes.length < 2) continue;
|
||||
|
||||
const label = getPacketLabel(parsed.payloadType);
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const n = nodesRef.current.get(path[i]);
|
||||
if (n && n.id !== 'self') {
|
||||
n.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
if (path[i] !== path[i + 1]) {
|
||||
addLink(path[i], path[i + 1], packetActivityAt);
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
const packetKey = generatePacketKey(parsed, packet);
|
||||
const packetKey = generatePacketKey(ingested.parsed, packet);
|
||||
const now = Date.now();
|
||||
const existing = pendingRef.current.get(packetKey);
|
||||
|
||||
if (existing && now < existing.expiresAt) {
|
||||
existing.paths.push({ nodes: path, snr: packet.snr ?? null, timestamp: now });
|
||||
existing.paths.push({
|
||||
nodes: projectedPath.nodes,
|
||||
snr: packet.snr ?? null,
|
||||
timestamp: now,
|
||||
});
|
||||
} else {
|
||||
const existingTimer = timersRef.current.get(packetKey);
|
||||
if (existingTimer) {
|
||||
@@ -706,8 +403,8 @@ export function useVisualizerData3D({
|
||||
const windowMs = observationWindowRef.current;
|
||||
pendingRef.current.set(packetKey, {
|
||||
key: packetKey,
|
||||
label: getPacketLabel(parsed.payloadType),
|
||||
paths: [{ nodes: path, snr: packet.snr ?? null, timestamp: now }],
|
||||
label: ingested.label,
|
||||
paths: [{ nodes: projectedPath.nodes, snr: packet.snr ?? null, timestamp: now }],
|
||||
firstSeen: now,
|
||||
expiresAt: now + windowMs,
|
||||
});
|
||||
@@ -734,7 +431,9 @@ export function useVisualizerData3D({
|
||||
newAnimated++;
|
||||
}
|
||||
|
||||
if (needsUpdate) syncSimulation();
|
||||
if (needsProjectionRebuild) {
|
||||
rebuildRenderProjection();
|
||||
}
|
||||
if (newProcessed > 0) {
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
@@ -742,7 +441,15 @@ export function useVisualizerData3D({
|
||||
animated: prev.animated + newAnimated,
|
||||
}));
|
||||
}
|
||||
}, [packets, config, buildPath, addLink, syncSimulation, publishPacket]);
|
||||
}, [
|
||||
packets,
|
||||
packetNetworkContext,
|
||||
publishPacket,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
rebuildRenderProjection,
|
||||
showAmbiguousNodes,
|
||||
showAmbiguousPaths,
|
||||
]);
|
||||
|
||||
const expandContract = useCallback(() => {
|
||||
const sim = simulationRef.current;
|
||||
@@ -831,21 +538,14 @@ export function useVisualizerData3D({
|
||||
timersRef.current.clear();
|
||||
pendingRef.current.clear();
|
||||
processedRef.current.clear();
|
||||
trafficPatternsRef.current.clear();
|
||||
particlesRef.current.length = 0;
|
||||
linksRef.current.clear();
|
||||
clearPacketNetworkState(networkStateRef.current, { selfName: config?.name || 'Me' });
|
||||
|
||||
const selfNode = nodesRef.current.get('self');
|
||||
linksRef.current.clear();
|
||||
nodesRef.current.clear();
|
||||
const selfNode = networkStateRef.current.nodes.get('self');
|
||||
if (selfNode) {
|
||||
selfNode.x = 0;
|
||||
selfNode.y = 0;
|
||||
selfNode.z = 0;
|
||||
selfNode.vx = 0;
|
||||
selfNode.vy = 0;
|
||||
selfNode.vz = 0;
|
||||
selfNode.lastActivity = Date.now();
|
||||
nodesRef.current.set('self', selfNode);
|
||||
nodesRef.current.set('self', upsertRenderNode(selfNode));
|
||||
}
|
||||
|
||||
const sim = simulationRef.current;
|
||||
@@ -856,8 +556,8 @@ export function useVisualizerData3D({
|
||||
sim.alpha(0.3).restart();
|
||||
}
|
||||
|
||||
setStats({ processed: 0, animated: 0, nodes: 1, links: 0 });
|
||||
}, []);
|
||||
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
||||
}, [config?.name, upsertRenderNode]);
|
||||
|
||||
useEffect(() => {
|
||||
const stretchRaf = stretchRafRef;
|
||||
@@ -883,40 +583,23 @@ export function useVisualizerData3D({
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const cutoff = Date.now() - staleMs;
|
||||
let pruned = false;
|
||||
|
||||
for (const [id, node] of nodesRef.current) {
|
||||
if (id === 'self') continue;
|
||||
if (node.lastActivity < cutoff) {
|
||||
nodesRef.current.delete(id);
|
||||
pruned = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (pruned) {
|
||||
for (const [key, link] of linksRef.current) {
|
||||
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
|
||||
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
|
||||
if (!nodesRef.current.has(sourceId) || !nodesRef.current.has(targetId)) {
|
||||
linksRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
syncSimulation();
|
||||
if (prunePacketNetworkState(networkStateRef.current, cutoff)) {
|
||||
rebuildRenderProjection();
|
||||
}
|
||||
}, pruneIntervalMs);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [pruneStaleNodes, pruneStaleMinutes, syncSimulation]);
|
||||
}, [pruneStaleMinutes, pruneStaleNodes, rebuildRenderProjection]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
nodes: nodesRef.current,
|
||||
links: linksRef.current,
|
||||
particles: particlesRef.current,
|
||||
stats,
|
||||
expandContract,
|
||||
clearAndReset,
|
||||
}),
|
||||
[stats, expandContract, clearAndReset]
|
||||
);
|
||||
return {
|
||||
nodes: nodesRef.current,
|
||||
links: linksRef.current,
|
||||
canonicalNodes: networkStateRef.current.nodes,
|
||||
canonicalNeighborIds: snapshotNeighborIds(networkStateRef.current),
|
||||
renderedNodeIds: new Set(nodesRef.current.keys()),
|
||||
particles: particlesRef.current,
|
||||
stats,
|
||||
expandContract,
|
||||
clearAndReset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function useContactsAndChannels({
|
||||
setActiveConversation({
|
||||
type: 'contact',
|
||||
id: created.public_key,
|
||||
name: getContactDisplayName(created.name, created.public_key),
|
||||
name: getContactDisplayName(created.name, created.public_key, created.last_advert),
|
||||
});
|
||||
},
|
||||
[fetchAllContacts, setActiveConversation]
|
||||
|
||||
@@ -86,6 +86,30 @@ function isMessageConversation(conversation: Conversation | null): conversation
|
||||
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
|
||||
}
|
||||
|
||||
function appendUniqueMessages(current: Message[], incoming: Message[]): Message[] {
|
||||
if (incoming.length === 0) return current;
|
||||
|
||||
const seenIds = new Set(current.map((msg) => msg.id));
|
||||
const seenContent = new Set(current.map((msg) => getMessageContentKey(msg)));
|
||||
const additions: Message[] = [];
|
||||
|
||||
for (const msg of incoming) {
|
||||
const contentKey = getMessageContentKey(msg);
|
||||
if (seenIds.has(msg.id) || seenContent.has(contentKey)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(msg.id);
|
||||
seenContent.add(contentKey);
|
||||
additions.push(msg);
|
||||
}
|
||||
|
||||
if (additions.length === 0) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, ...additions];
|
||||
}
|
||||
|
||||
export function useConversationMessages(
|
||||
activeConversation: Conversation | null,
|
||||
targetMessageId?: number | null
|
||||
@@ -137,7 +161,9 @@ export function useConversationMessages(
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const fetchingConversationIdRef = useRef<string | null>(null);
|
||||
const latestReconcileRequestIdRef = useRef(0);
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
const loadingOlderRef = useRef(false);
|
||||
const hasOlderMessagesRef = useRef(false);
|
||||
const hasNewerMessagesRef = useRef(false);
|
||||
const prevConversationIdRef = useRef<string | null>(null);
|
||||
@@ -146,6 +172,10 @@ export function useConversationMessages(
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
loadingOlderRef.current = loadingOlder;
|
||||
}, [loadingOlder]);
|
||||
|
||||
useEffect(() => {
|
||||
hasOlderMessagesRef.current = hasOlderMessages;
|
||||
}, [hasOlderMessages]);
|
||||
@@ -194,8 +224,12 @@ export function useConversationMessages(
|
||||
}
|
||||
|
||||
const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||
setMessages(messagesWithPendingAck);
|
||||
syncSeenContent(messagesWithPendingAck);
|
||||
const merged = messageCache.reconcile(messagesRef.current, messagesWithPendingAck);
|
||||
const nextMessages = merged ?? messagesRef.current;
|
||||
if (merged) {
|
||||
setMessages(merged);
|
||||
}
|
||||
syncSeenContent(nextMessages);
|
||||
setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
@@ -215,7 +249,7 @@ export function useConversationMessages(
|
||||
);
|
||||
|
||||
const reconcileFromBackend = useCallback(
|
||||
(conversation: Conversation, signal: AbortSignal) => {
|
||||
(conversation: Conversation, signal: AbortSignal, requestId: number) => {
|
||||
const conversationId = conversation.id;
|
||||
api
|
||||
.getMessages(
|
||||
@@ -228,16 +262,15 @@ export function useConversationMessages(
|
||||
)
|
||||
.then((data) => {
|
||||
if (fetchingConversationIdRef.current !== conversationId) return;
|
||||
if (latestReconcileRequestIdRef.current !== requestId) return;
|
||||
|
||||
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
|
||||
const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck);
|
||||
if (!merged) return;
|
||||
|
||||
setMessages(merged);
|
||||
syncSeenContent(merged);
|
||||
if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) {
|
||||
setHasOlderMessages(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isAbortError(err)) return;
|
||||
@@ -248,7 +281,13 @@ export function useConversationMessages(
|
||||
);
|
||||
|
||||
const fetchOlderMessages = useCallback(async () => {
|
||||
if (!isMessageConversation(activeConversation) || loadingOlder || !hasOlderMessages) return;
|
||||
if (
|
||||
!isMessageConversation(activeConversation) ||
|
||||
loadingOlderRef.current ||
|
||||
!hasOlderMessagesRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationId = activeConversation.id;
|
||||
const oldestMessage = messages.reduce(
|
||||
@@ -262,6 +301,7 @@ export function useConversationMessages(
|
||||
);
|
||||
if (!oldestMessage) return;
|
||||
|
||||
loadingOlderRef.current = true;
|
||||
setLoadingOlder(true);
|
||||
try {
|
||||
const data = await api.getMessages({
|
||||
@@ -277,9 +317,17 @@ export function useConversationMessages(
|
||||
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||
|
||||
if (dataWithPendingAck.length > 0) {
|
||||
setMessages((prev) => [...prev, ...dataWithPendingAck]);
|
||||
for (const msg of dataWithPendingAck) {
|
||||
seenMessageContent.current.add(getMessageContentKey(msg));
|
||||
let nextMessages: Message[] | null = null;
|
||||
setMessages((prev) => {
|
||||
const merged = appendUniqueMessages(prev, dataWithPendingAck);
|
||||
if (merged !== prev) {
|
||||
nextMessages = merged;
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
if (nextMessages) {
|
||||
messagesRef.current = nextMessages;
|
||||
syncSeenContent(nextMessages);
|
||||
}
|
||||
}
|
||||
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
|
||||
@@ -289,9 +337,10 @@ export function useConversationMessages(
|
||||
description: err instanceof Error ? err.message : 'Check your connection',
|
||||
});
|
||||
} finally {
|
||||
loadingOlderRef.current = false;
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
}, [activeConversation, applyPendingAck, hasOlderMessages, loadingOlder, messages]);
|
||||
}, [activeConversation, applyPendingAck, messages, syncSeenContent]);
|
||||
|
||||
const fetchNewerMessages = useCallback(async () => {
|
||||
if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return;
|
||||
@@ -352,7 +401,9 @@ export function useConversationMessages(
|
||||
const triggerReconcile = useCallback(() => {
|
||||
if (!isMessageConversation(activeConversation)) return;
|
||||
const controller = new AbortController();
|
||||
reconcileFromBackend(activeConversation, controller.signal);
|
||||
const requestId = latestReconcileRequestIdRef.current + 1;
|
||||
latestReconcileRequestIdRef.current = requestId;
|
||||
reconcileFromBackend(activeConversation, controller.signal, requestId);
|
||||
}, [activeConversation, reconcileFromBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -365,6 +416,7 @@ export function useConversationMessages(
|
||||
const conversationChanged = prevId !== newId;
|
||||
fetchingConversationIdRef.current = newId;
|
||||
prevConversationIdRef.current = newId;
|
||||
latestReconcileRequestIdRef.current = 0;
|
||||
|
||||
// Preserve around-loaded context on the same conversation when search clears targetMessageId.
|
||||
if (!conversationChanged && !targetMessageId) {
|
||||
@@ -372,6 +424,7 @@ export function useConversationMessages(
|
||||
}
|
||||
|
||||
setLoadingOlder(false);
|
||||
loadingOlderRef.current = false;
|
||||
setLoadingNewer(false);
|
||||
if (conversationChanged) {
|
||||
setHasNewerMessages(false);
|
||||
@@ -433,7 +486,9 @@ export function useConversationMessages(
|
||||
seenMessageContent.current = new Set(cached.seenContent);
|
||||
setHasOlderMessages(cached.hasOlderMessages);
|
||||
setMessagesLoading(false);
|
||||
reconcileFromBackend(activeConversation, controller.signal);
|
||||
const requestId = latestReconcileRequestIdRef.current + 1;
|
||||
latestReconcileRequestIdRef.current = requestId;
|
||||
reconcileFromBackend(activeConversation, controller.signal, requestId);
|
||||
} else {
|
||||
void fetchLatestMessages(true, controller.signal);
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function useConversationRouter({
|
||||
setActiveConversationState({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
|
||||
});
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
@@ -179,7 +179,7 @@ export function useConversationRouter({
|
||||
setActiveConversationState({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
|
||||
});
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { UseWebSocketOptions } from '../useWebSocket';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
import { mergeContactIntoList } from '../utils/contactMerge';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
|
||||
import { getMessageContentKey } from './useConversationMessages';
|
||||
import type {
|
||||
@@ -40,6 +41,7 @@ interface UseRealtimeAppStateArgs {
|
||||
addMessageIfNew: (msg: Message) => boolean;
|
||||
trackNewMessage: (msg: Message) => void;
|
||||
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
|
||||
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
||||
checkMention: (text: string) => boolean;
|
||||
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||
setActiveConversation: (conv: Conversation | null) => void;
|
||||
@@ -100,6 +102,7 @@ export function useRealtimeAppState({
|
||||
addMessageIfNew,
|
||||
trackNewMessage,
|
||||
incrementUnread,
|
||||
renameConversationState,
|
||||
checkMention,
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
@@ -225,6 +228,28 @@ export function useRealtimeAppState({
|
||||
onContact: (contact: Contact) => {
|
||||
setContacts((prev) => mergeContactIntoList(prev, contact));
|
||||
},
|
||||
onContactResolved: (previousPublicKey: string, contact: Contact) => {
|
||||
setContacts((prev) =>
|
||||
mergeContactIntoList(
|
||||
prev.filter((candidate) => candidate.public_key !== previousPublicKey),
|
||||
contact
|
||||
)
|
||||
);
|
||||
messageCache.rename(previousPublicKey, contact.public_key);
|
||||
renameConversationState(
|
||||
getStateKey('contact', previousPublicKey),
|
||||
getStateKey('contact', contact.public_key)
|
||||
);
|
||||
|
||||
const active = activeConversationRef.current;
|
||||
if (active?.type === 'contact' && active.id === previousPublicKey) {
|
||||
setActiveConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
|
||||
});
|
||||
}
|
||||
},
|
||||
onChannel: (channel: Channel) => {
|
||||
mergeChannelIntoList(channel);
|
||||
},
|
||||
@@ -264,6 +289,7 @@ export function useRealtimeAppState({
|
||||
fetchConfig,
|
||||
hasNewerMessagesRef,
|
||||
incrementUnread,
|
||||
renameConversationState,
|
||||
maxRawPackets,
|
||||
mergeChannelIntoList,
|
||||
pendingDeleteFallbackRef,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RepeaterStatusResponse,
|
||||
RepeaterNeighborsResponse,
|
||||
RepeaterAclResponse,
|
||||
RepeaterNodeInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterAdvertIntervalsResponse,
|
||||
RepeaterOwnerInfoResponse,
|
||||
@@ -28,6 +29,7 @@ interface ConsoleEntry {
|
||||
|
||||
interface PaneData {
|
||||
status: RepeaterStatusResponse | null;
|
||||
nodeInfo: RepeaterNodeInfoResponse | null;
|
||||
neighbors: RepeaterNeighborsResponse | null;
|
||||
acl: RepeaterAclResponse | null;
|
||||
radioSettings: RepeaterRadioSettingsResponse | null;
|
||||
@@ -49,6 +51,7 @@ const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null,
|
||||
function createInitialPaneStates(): Record<PaneName, PaneState> {
|
||||
return {
|
||||
status: { ...INITIAL_PANE_STATE },
|
||||
nodeInfo: { ...INITIAL_PANE_STATE },
|
||||
neighbors: { ...INITIAL_PANE_STATE },
|
||||
acl: { ...INITIAL_PANE_STATE },
|
||||
radioSettings: { ...INITIAL_PANE_STATE },
|
||||
@@ -61,6 +64,7 @@ function createInitialPaneStates(): Record<PaneName, PaneState> {
|
||||
function createInitialPaneData(): PaneData {
|
||||
return {
|
||||
status: null,
|
||||
nodeInfo: null,
|
||||
neighbors: null,
|
||||
acl: null,
|
||||
radioSettings: null,
|
||||
@@ -79,6 +83,7 @@ function clonePaneData(data: PaneData): PaneData {
|
||||
function normalizePaneStates(paneStates: Record<PaneName, PaneState>): Record<PaneName, PaneState> {
|
||||
return {
|
||||
status: { ...paneStates.status, loading: false },
|
||||
nodeInfo: { ...paneStates.nodeInfo, loading: false },
|
||||
neighbors: { ...paneStates.neighbors, loading: false },
|
||||
acl: { ...paneStates.acl, loading: false },
|
||||
radioSettings: { ...paneStates.radioSettings, loading: false },
|
||||
@@ -136,6 +141,8 @@ function fetchPaneData(publicKey: string, pane: PaneName) {
|
||||
switch (pane) {
|
||||
case 'status':
|
||||
return api.repeaterStatus(publicKey);
|
||||
case 'nodeInfo':
|
||||
return api.repeaterNodeInfo(publicKey);
|
||||
case 'neighbors':
|
||||
return api.repeaterNeighbors(publicKey);
|
||||
case 'acl':
|
||||
@@ -187,6 +194,10 @@ export function useRepeaterDashboard(
|
||||
const [paneStates, setPaneStates] = useState<Record<PaneName, PaneState>>(
|
||||
cachedState?.paneStates ?? createInitialPaneStates
|
||||
);
|
||||
const paneDataRef = useRef<PaneData>(cachedState?.paneData ?? createInitialPaneData());
|
||||
const paneStatesRef = useRef<Record<PaneName, PaneState>>(
|
||||
cachedState?.paneStates ?? createInitialPaneStates()
|
||||
);
|
||||
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
|
||||
cachedState?.consoleHistory ?? []
|
||||
@@ -222,6 +233,14 @@ export function useRepeaterDashboard(
|
||||
});
|
||||
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
|
||||
|
||||
useEffect(() => {
|
||||
paneDataRef.current = paneData;
|
||||
}, [paneData]);
|
||||
|
||||
useEffect(() => {
|
||||
paneStatesRef.current = paneStates;
|
||||
}, [paneStates]);
|
||||
|
||||
const getPublicKey = useCallback((): string | null => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return activeConversation.id;
|
||||
@@ -262,27 +281,60 @@ export function useRepeaterDashboard(
|
||||
if (!publicKey) return;
|
||||
const conversationId = publicKey;
|
||||
|
||||
if (pane === 'neighbors') {
|
||||
const nodeInfoState = paneStatesRef.current.nodeInfo;
|
||||
const nodeInfoData = paneDataRef.current.nodeInfo;
|
||||
const needsNodeInfoPrefetch =
|
||||
nodeInfoState.error !== null ||
|
||||
(nodeInfoState.fetched_at == null && nodeInfoData == null);
|
||||
|
||||
if (needsNodeInfoPrefetch) {
|
||||
await refreshPane('nodeInfo');
|
||||
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
|
||||
|
||||
const loadingState = {
|
||||
loading: true,
|
||||
attempt,
|
||||
error: null,
|
||||
fetched_at: paneStatesRef.current[pane].fetched_at ?? null,
|
||||
};
|
||||
paneStatesRef.current = {
|
||||
...paneStatesRef.current,
|
||||
[pane]: loadingState,
|
||||
};
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
loading: true,
|
||||
attempt,
|
||||
error: null,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
[pane]: loadingState,
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await fetchPaneData(publicKey, pane);
|
||||
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
|
||||
|
||||
paneDataRef.current = {
|
||||
...paneDataRef.current,
|
||||
[pane]: data,
|
||||
};
|
||||
const successState = {
|
||||
loading: false,
|
||||
attempt,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
};
|
||||
paneStatesRef.current = {
|
||||
...paneStatesRef.current,
|
||||
[pane]: successState,
|
||||
};
|
||||
|
||||
setPaneData((prev) => ({ ...prev, [pane]: data }));
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: null, fetched_at: Date.now() },
|
||||
[pane]: successState,
|
||||
}));
|
||||
return; // Success
|
||||
} catch (err) {
|
||||
@@ -291,14 +343,19 @@ export function useRepeaterDashboard(
|
||||
const msg = err instanceof Error ? err.message : 'Request failed';
|
||||
|
||||
if (attempt === MAX_RETRIES) {
|
||||
const errorState = {
|
||||
loading: false,
|
||||
attempt,
|
||||
error: msg,
|
||||
fetched_at: paneStatesRef.current[pane].fetched_at ?? null,
|
||||
};
|
||||
paneStatesRef.current = {
|
||||
...paneStatesRef.current,
|
||||
[pane]: errorState,
|
||||
};
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
loading: false,
|
||||
attempt,
|
||||
error: msg,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
[pane]: errorState,
|
||||
}));
|
||||
toast.error(`Failed to fetch ${pane}`, { description: msg });
|
||||
} else {
|
||||
@@ -314,9 +371,10 @@ export function useRepeaterDashboard(
|
||||
const loadAll = useCallback(async () => {
|
||||
const panes: PaneName[] = [
|
||||
'status',
|
||||
'nodeInfo',
|
||||
'neighbors',
|
||||
'acl',
|
||||
'radioSettings',
|
||||
'acl',
|
||||
'advertIntervals',
|
||||
'ownerInfo',
|
||||
'lppTelemetry',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from '../api';
|
||||
import {
|
||||
getLastMessageTimes,
|
||||
setLastMessageTime,
|
||||
renameConversationTimeKey,
|
||||
getStateKey,
|
||||
type ConversationTimes,
|
||||
} from '../utils/conversationState';
|
||||
@@ -14,7 +15,9 @@ interface UseUnreadCountsResult {
|
||||
/** Tracks which conversations have unread messages that mention the user */
|
||||
mentions: Record<string, boolean>;
|
||||
lastMessageTimes: ConversationTimes;
|
||||
unreadLastReadAts: Record<string, number | null>;
|
||||
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
|
||||
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
||||
markAllRead: () => void;
|
||||
trackNewMessage: (msg: Message) => void;
|
||||
refreshUnreads: () => Promise<void>;
|
||||
@@ -28,6 +31,7 @@ export function useUnreadCounts(
|
||||
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
|
||||
const [mentions, setMentions] = useState<Record<string, boolean>>({});
|
||||
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
|
||||
const [unreadLastReadAts, setUnreadLastReadAts] = useState<Record<string, number | null>>({});
|
||||
|
||||
// Track active conversation via ref so applyUnreads can filter without
|
||||
// destabilizing the callback chain (avoids re-creating fetchUnreads on
|
||||
@@ -60,6 +64,8 @@ export function useUnreadCounts(
|
||||
setMentions(data.mentions);
|
||||
}
|
||||
|
||||
setUnreadLastReadAts(data.last_read_ats);
|
||||
|
||||
if (Object.keys(data.last_message_times).length > 0) {
|
||||
for (const [key, ts] of Object.entries(data.last_message_times)) {
|
||||
setLastMessageTime(key, ts);
|
||||
@@ -170,12 +176,35 @@ export function useUnreadCounts(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renameConversationState = useCallback((oldStateKey: string, newStateKey: string) => {
|
||||
if (oldStateKey === newStateKey) return;
|
||||
|
||||
setUnreadCounts((prev) => {
|
||||
if (!(oldStateKey in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
next[newStateKey] = (next[newStateKey] || 0) + next[oldStateKey];
|
||||
delete next[oldStateKey];
|
||||
return next;
|
||||
});
|
||||
|
||||
setMentions((prev) => {
|
||||
if (!(oldStateKey in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
next[newStateKey] = next[newStateKey] || next[oldStateKey];
|
||||
delete next[oldStateKey];
|
||||
return next;
|
||||
});
|
||||
|
||||
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
|
||||
}, []);
|
||||
|
||||
// Mark all conversations as read
|
||||
// Calls single bulk API endpoint to persist read state
|
||||
const markAllRead = useCallback(() => {
|
||||
// Update local state immediately
|
||||
setUnreadCounts({});
|
||||
setMentions({});
|
||||
setUnreadLastReadAts({});
|
||||
|
||||
// Persist to server with single bulk request
|
||||
api.markAllRead().catch((err) => {
|
||||
@@ -203,7 +232,9 @@ export function useUnreadCounts(
|
||||
unreadCounts,
|
||||
mentions,
|
||||
lastMessageTimes,
|
||||
unreadLastReadAts,
|
||||
incrementUnread,
|
||||
renameConversationState,
|
||||
markAllRead,
|
||||
trackNewMessage,
|
||||
refreshUnreads: fetchUnreads,
|
||||
|
||||
@@ -138,6 +138,36 @@ export function remove(id: string): void {
|
||||
cache.delete(id);
|
||||
}
|
||||
|
||||
/** Move cached conversation state to a new conversation id. */
|
||||
export function rename(oldId: string, newId: string): void {
|
||||
if (oldId === newId) return;
|
||||
const oldEntry = cache.get(oldId);
|
||||
if (!oldEntry) return;
|
||||
|
||||
const newEntry = cache.get(newId);
|
||||
if (!newEntry) {
|
||||
cache.delete(oldId);
|
||||
cache.set(newId, oldEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedMessages = [...newEntry.messages];
|
||||
const seenIds = new Set(mergedMessages.map((message) => message.id));
|
||||
for (const message of oldEntry.messages) {
|
||||
if (!seenIds.has(message.id)) {
|
||||
mergedMessages.push(message);
|
||||
seenIds.add(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
cache.delete(oldId);
|
||||
cache.set(newId, {
|
||||
messages: mergedMessages,
|
||||
seenContent: new Set([...newEntry.seenContent, ...oldEntry.seenContent]),
|
||||
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the entire cache. */
|
||||
export function clear(): void {
|
||||
cache.clear();
|
||||
|
||||
@@ -0,0 +1,861 @@
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
type ContactAdvertPathSummary,
|
||||
type RadioConfig,
|
||||
type RawPacket,
|
||||
} from '../types';
|
||||
import {
|
||||
analyzeRepeaterTraffic,
|
||||
buildAmbiguousRepeaterLabel,
|
||||
buildAmbiguousRepeaterNodeId,
|
||||
buildLinkKey,
|
||||
compactPathSteps,
|
||||
dedupeConsecutive,
|
||||
getNodeType,
|
||||
getPacketLabel,
|
||||
parsePacket,
|
||||
recordTrafficObservation,
|
||||
type NodeType,
|
||||
type ParsedPacket,
|
||||
type RepeaterTrafficData,
|
||||
} from '../utils/visualizerUtils';
|
||||
import { normalizePacketTimestampMs } from '../components/visualizer/shared';
|
||||
|
||||
interface ContactIndex {
|
||||
byPrefix12: Map<string, Contact>;
|
||||
byName: Map<string, Contact>;
|
||||
byPrefix: Map<string, Contact[]>;
|
||||
}
|
||||
|
||||
interface AdvertPathIndex {
|
||||
byRepeater: Map<string, ContactAdvertPathSummary['paths']>;
|
||||
}
|
||||
|
||||
export interface PacketNetworkContext {
|
||||
advertPathIndex: AdvertPathIndex;
|
||||
contactIndex: ContactIndex;
|
||||
myPrefix: string | null;
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
useAdvertPathHints: boolean;
|
||||
}
|
||||
|
||||
export interface PacketNetworkVisibilityOptions {
|
||||
showAmbiguousNodes: boolean;
|
||||
showAmbiguousPaths: boolean;
|
||||
collapseLikelyKnownSiblingRepeaters: boolean;
|
||||
}
|
||||
|
||||
export interface PacketNetworkNode {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type: NodeType;
|
||||
isAmbiguous: boolean;
|
||||
lastActivity: number;
|
||||
lastActivityReason?: string;
|
||||
lastSeen?: number | null;
|
||||
probableIdentity?: string | null;
|
||||
probableIdentityNodeId?: string | null;
|
||||
ambiguousNames?: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkLink {
|
||||
lastActivity: number;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
export interface ProjectedPacketNetworkLink extends PacketNetworkLink {
|
||||
hasDirectObservation: boolean;
|
||||
hasHiddenIntermediate: boolean;
|
||||
hiddenHopLabels: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkObservation {
|
||||
activityAtMs: number;
|
||||
nodes: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkState {
|
||||
links: Map<string, PacketNetworkLink>;
|
||||
neighborIds: Map<string, Set<string>>;
|
||||
nodes: Map<string, PacketNetworkNode>;
|
||||
observations: PacketNetworkObservation[];
|
||||
trafficPatterns: Map<string, RepeaterTrafficData>;
|
||||
}
|
||||
|
||||
export interface PacketNetworkIngestResult {
|
||||
activityAtMs: number;
|
||||
canonicalPath: string[];
|
||||
label: ReturnType<typeof getPacketLabel>;
|
||||
parsed: ParsedPacket;
|
||||
}
|
||||
|
||||
export interface ProjectedPacketNetworkPath {
|
||||
dashedLinkDetails: Map<string, string[]>;
|
||||
nodes: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkProjection {
|
||||
links: Map<string, ProjectedPacketNetworkLink>;
|
||||
nodes: Map<string, PacketNetworkNode>;
|
||||
renderedNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
export function buildPacketNetworkContext({
|
||||
config,
|
||||
contacts,
|
||||
repeaterAdvertPaths,
|
||||
splitAmbiguousByTraffic,
|
||||
useAdvertPathHints,
|
||||
}: {
|
||||
config: RadioConfig | null;
|
||||
contacts: Contact[];
|
||||
repeaterAdvertPaths: ContactAdvertPathSummary[];
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
useAdvertPathHints: boolean;
|
||||
}): PacketNetworkContext {
|
||||
const byPrefix12 = new Map<string, Contact>();
|
||||
const byName = new Map<string, Contact>();
|
||||
const byPrefix = new Map<string, Contact[]>();
|
||||
|
||||
for (const contact of contacts) {
|
||||
const prefix12 = contact.public_key.slice(0, 12).toLowerCase();
|
||||
byPrefix12.set(prefix12, contact);
|
||||
|
||||
if (contact.name && !byName.has(contact.name)) {
|
||||
byName.set(contact.name, contact);
|
||||
}
|
||||
|
||||
for (let len = 1; len <= 12; len++) {
|
||||
const prefix = prefix12.slice(0, len);
|
||||
const matches = byPrefix.get(prefix);
|
||||
if (matches) {
|
||||
matches.push(contact);
|
||||
} else {
|
||||
byPrefix.set(prefix, [contact]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const byRepeater = new Map<string, ContactAdvertPathSummary['paths']>();
|
||||
for (const summary of repeaterAdvertPaths) {
|
||||
const key = summary.public_key.slice(0, 12).toLowerCase();
|
||||
byRepeater.set(key, summary.paths);
|
||||
}
|
||||
|
||||
return {
|
||||
contactIndex: { byPrefix12, byName, byPrefix },
|
||||
advertPathIndex: { byRepeater },
|
||||
myPrefix: config?.public_key?.slice(0, 12).toLowerCase() || null,
|
||||
splitAmbiguousByTraffic,
|
||||
useAdvertPathHints,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPacketNetworkState(selfName: string = 'Me'): PacketNetworkState {
|
||||
const now = Date.now();
|
||||
return {
|
||||
nodes: new Map([
|
||||
[
|
||||
'self',
|
||||
{
|
||||
id: 'self',
|
||||
name: selfName,
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: now,
|
||||
},
|
||||
],
|
||||
]),
|
||||
links: new Map(),
|
||||
neighborIds: new Map(),
|
||||
observations: [],
|
||||
trafficPatterns: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureSelfNode(state: PacketNetworkState, selfName: string = 'Me'): void {
|
||||
const existing = state.nodes.get('self');
|
||||
if (existing) {
|
||||
existing.name = selfName;
|
||||
return;
|
||||
}
|
||||
state.nodes.set('self', {
|
||||
id: 'self',
|
||||
name: selfName,
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearPacketNetworkState(
|
||||
state: PacketNetworkState,
|
||||
{ selfName = 'Me' }: { selfName?: string } = {}
|
||||
): void {
|
||||
state.links.clear();
|
||||
state.neighborIds.clear();
|
||||
state.observations = [];
|
||||
state.trafficPatterns.clear();
|
||||
|
||||
const selfNode = state.nodes.get('self');
|
||||
state.nodes.clear();
|
||||
state.nodes.set('self', {
|
||||
id: 'self',
|
||||
name: selfName,
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: Date.now(),
|
||||
lastActivityReason: undefined,
|
||||
lastSeen: null,
|
||||
probableIdentity: undefined,
|
||||
probableIdentityNodeId: undefined,
|
||||
ambiguousNames: undefined,
|
||||
});
|
||||
|
||||
if (selfNode?.name && selfNode.name !== selfName) {
|
||||
state.nodes.get('self')!.name = selfName;
|
||||
}
|
||||
}
|
||||
|
||||
function addOrUpdateNode(
|
||||
state: PacketNetworkState,
|
||||
{
|
||||
activityAtMs,
|
||||
ambiguousNames,
|
||||
id,
|
||||
isAmbiguous,
|
||||
lastSeen,
|
||||
name,
|
||||
probableIdentity,
|
||||
probableIdentityNodeId,
|
||||
type,
|
||||
}: {
|
||||
activityAtMs: number;
|
||||
ambiguousNames?: string[];
|
||||
id: string;
|
||||
isAmbiguous: boolean;
|
||||
lastSeen?: number | null;
|
||||
name: string | null;
|
||||
probableIdentity?: string | null;
|
||||
probableIdentityNodeId?: string | null;
|
||||
type: NodeType;
|
||||
}
|
||||
): void {
|
||||
const existing = state.nodes.get(id);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAtMs);
|
||||
if (name) existing.name = name;
|
||||
if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity;
|
||||
if (probableIdentityNodeId !== undefined) {
|
||||
existing.probableIdentityNodeId = probableIdentityNodeId;
|
||||
}
|
||||
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
|
||||
if (lastSeen !== undefined) existing.lastSeen = lastSeen;
|
||||
return;
|
||||
}
|
||||
|
||||
state.nodes.set(id, {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
isAmbiguous,
|
||||
lastActivity: activityAtMs,
|
||||
probableIdentity,
|
||||
probableIdentityNodeId,
|
||||
ambiguousNames,
|
||||
lastSeen,
|
||||
});
|
||||
}
|
||||
|
||||
function addCanonicalLink(
|
||||
state: PacketNetworkState,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
activityAtMs: number
|
||||
): void {
|
||||
const key = buildLinkKey(sourceId, targetId);
|
||||
const existing = state.links.get(key);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAtMs);
|
||||
} else {
|
||||
state.links.set(key, { sourceId, targetId, lastActivity: activityAtMs });
|
||||
}
|
||||
}
|
||||
|
||||
function upsertNeighbor(state: PacketNetworkState, sourceId: string, targetId: string): void {
|
||||
const ensureSet = (id: string) => {
|
||||
const existing = state.neighborIds.get(id);
|
||||
if (existing) return existing;
|
||||
const created = new Set<string>();
|
||||
state.neighborIds.set(id, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
ensureSet(sourceId).add(targetId);
|
||||
ensureSet(targetId).add(sourceId);
|
||||
}
|
||||
|
||||
function pickLikelyRepeaterByAdvertPath(
|
||||
context: PacketNetworkContext,
|
||||
candidates: Contact[],
|
||||
nextPrefix: string | null
|
||||
): Contact | null {
|
||||
const nextHop = nextPrefix?.toLowerCase() ?? null;
|
||||
const scored = candidates
|
||||
.map((candidate) => {
|
||||
const prefix12 = candidate.public_key.slice(0, 12).toLowerCase();
|
||||
const paths = context.advertPathIndex.byRepeater.get(prefix12) ?? [];
|
||||
let matchScore = 0;
|
||||
let totalScore = 0;
|
||||
|
||||
for (const path of paths) {
|
||||
totalScore += path.heard_count;
|
||||
const pathNextHop = path.next_hop?.toLowerCase() ?? null;
|
||||
if (pathNextHop === nextHop) {
|
||||
matchScore += path.heard_count;
|
||||
}
|
||||
}
|
||||
|
||||
return { candidate, matchScore, totalScore };
|
||||
})
|
||||
.filter((entry) => entry.totalScore > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.matchScore - a.matchScore ||
|
||||
b.totalScore - a.totalScore ||
|
||||
a.candidate.public_key.localeCompare(b.candidate.public_key)
|
||||
);
|
||||
|
||||
if (scored.length === 0) return null;
|
||||
|
||||
const top = scored[0];
|
||||
const second = scored[1] ?? null;
|
||||
|
||||
if (top.matchScore < 2) return null;
|
||||
if (second && top.matchScore < second.matchScore * 2) return null;
|
||||
|
||||
return top.candidate;
|
||||
}
|
||||
|
||||
function resolveNode(
|
||||
state: PacketNetworkState,
|
||||
context: PacketNetworkContext,
|
||||
source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
|
||||
isRepeater: boolean,
|
||||
showAmbiguous: boolean,
|
||||
activityAtMs: number,
|
||||
trafficContext?: { packetSource: string | null; nextPrefix: string | null }
|
||||
): string | null {
|
||||
if (source.type === 'pubkey') {
|
||||
if (source.value.length < 12) return null;
|
||||
const nodeId = source.value.slice(0, 12).toLowerCase();
|
||||
if (context.myPrefix && nodeId === context.myPrefix) return 'self';
|
||||
const contact = context.contactIndex.byPrefix12.get(nodeId);
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: contact?.name || null,
|
||||
type: getNodeType(contact),
|
||||
isAmbiguous: false,
|
||||
lastSeen: contact?.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (source.type === 'name') {
|
||||
const contact = context.contactIndex.byName.get(source.value) ?? null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (context.myPrefix && nodeId === context.myPrefix) return 'self';
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: contact.name,
|
||||
type: getNodeType(contact),
|
||||
isAmbiguous: false,
|
||||
lastSeen: contact.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
const nodeId = `name:${source.value}`;
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: source.value,
|
||||
type: 'client',
|
||||
isAmbiguous: false,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
const lookupValue = source.value.toLowerCase();
|
||||
const matches = context.contactIndex.byPrefix.get(lookupValue) ?? [];
|
||||
const contact = matches.length === 1 ? matches[0] : null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (context.myPrefix && nodeId === context.myPrefix) return 'self';
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: contact.name,
|
||||
type: getNodeType(contact),
|
||||
isAmbiguous: false,
|
||||
lastSeen: contact.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (!showAmbiguous) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filtered = isRepeater
|
||||
? matches.filter((candidate) => candidate.type === CONTACT_TYPE_REPEATER)
|
||||
: matches.filter((candidate) => candidate.type !== CONTACT_TYPE_REPEATER);
|
||||
|
||||
if (filtered.length === 1) {
|
||||
const only = filtered[0];
|
||||
const nodeId = only.public_key.slice(0, 12).toLowerCase();
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: only.name,
|
||||
type: getNodeType(only),
|
||||
isAmbiguous: false,
|
||||
lastSeen: only.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (filtered.length === 0 && !isRepeater) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const names = filtered.map((candidate) => candidate.name || candidate.public_key.slice(0, 8));
|
||||
const lastSeen = filtered.reduce(
|
||||
(max, candidate) =>
|
||||
candidate.last_seen && (!max || candidate.last_seen > max) ? candidate.last_seen : max,
|
||||
null as number | null
|
||||
);
|
||||
|
||||
let nodeId = buildAmbiguousRepeaterNodeId(lookupValue);
|
||||
let displayName = buildAmbiguousRepeaterLabel(lookupValue);
|
||||
let probableIdentity: string | null = null;
|
||||
let probableIdentityNodeId: string | null = null;
|
||||
let ambiguousNames = names.length > 0 ? names : undefined;
|
||||
|
||||
if (context.useAdvertPathHints && isRepeater && trafficContext) {
|
||||
const likely = pickLikelyRepeaterByAdvertPath(context, filtered, trafficContext.nextPrefix);
|
||||
if (likely) {
|
||||
const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase();
|
||||
probableIdentity = likelyName;
|
||||
probableIdentityNodeId = likely.public_key.slice(0, 12).toLowerCase();
|
||||
displayName = likelyName;
|
||||
ambiguousNames = filtered
|
||||
.filter((candidate) => candidate.public_key !== likely.public_key)
|
||||
.map((candidate) => candidate.name || candidate.public_key.slice(0, 8));
|
||||
}
|
||||
}
|
||||
|
||||
if (context.splitAmbiguousByTraffic && isRepeater && trafficContext) {
|
||||
const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null;
|
||||
|
||||
if (trafficContext.packetSource) {
|
||||
recordTrafficObservation(
|
||||
state.trafficPatterns,
|
||||
lookupValue,
|
||||
trafficContext.packetSource,
|
||||
normalizedNext
|
||||
);
|
||||
}
|
||||
|
||||
const trafficData = state.trafficPatterns.get(lookupValue);
|
||||
if (trafficData) {
|
||||
const analysis = analyzeRepeaterTraffic(trafficData);
|
||||
if (analysis.shouldSplit && normalizedNext) {
|
||||
nodeId = buildAmbiguousRepeaterNodeId(lookupValue, normalizedNext);
|
||||
if (!probableIdentity) {
|
||||
displayName = buildAmbiguousRepeaterLabel(lookupValue, normalizedNext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: displayName,
|
||||
type: isRepeater ? 'repeater' : 'client',
|
||||
isAmbiguous: true,
|
||||
probableIdentity,
|
||||
probableIdentityNodeId,
|
||||
ambiguousNames,
|
||||
lastSeen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
export function buildCanonicalPathForPacket(
|
||||
state: PacketNetworkState,
|
||||
context: PacketNetworkContext,
|
||||
parsed: ParsedPacket,
|
||||
packet: RawPacket,
|
||||
activityAtMs: number
|
||||
): string[] {
|
||||
const path: string[] = [];
|
||||
let packetSource: string | null = null;
|
||||
const isDm = parsed.payloadType === PayloadType.TextMessage;
|
||||
const isOutgoingDm =
|
||||
isDm && !!context.myPrefix && parsed.srcHash?.toLowerCase() === context.myPrefix;
|
||||
|
||||
if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'pubkey', value: parsed.advertPubkey },
|
||||
false,
|
||||
false,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'pubkey', value: parsed.anonRequestPubkey },
|
||||
false,
|
||||
false,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) {
|
||||
if (context.myPrefix && parsed.srcHash.toLowerCase() === context.myPrefix) {
|
||||
path.push('self');
|
||||
packetSource = 'self';
|
||||
} else {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'prefix', value: parsed.srcHash },
|
||||
false,
|
||||
true,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.GroupText) {
|
||||
const senderName = parsed.groupTextSender || packet.decrypted_info?.sender;
|
||||
if (senderName) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'name', value: senderName },
|
||||
false,
|
||||
false,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < parsed.pathBytes.length; i++) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'prefix', value: parsed.pathBytes[i] },
|
||||
true,
|
||||
true,
|
||||
activityAtMs,
|
||||
{ packetSource, nextPrefix: parsed.pathBytes[i + 1] || null }
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) {
|
||||
if (context.myPrefix && parsed.dstHash.toLowerCase() === context.myPrefix) {
|
||||
path.push('self');
|
||||
} else if (!isOutgoingDm && path.length > 0) {
|
||||
path.push('self');
|
||||
}
|
||||
} else if (path.length > 0) {
|
||||
path.push('self');
|
||||
}
|
||||
|
||||
return dedupeConsecutive(path);
|
||||
}
|
||||
|
||||
export function ingestPacketIntoPacketNetwork(
|
||||
state: PacketNetworkState,
|
||||
context: PacketNetworkContext,
|
||||
packet: RawPacket
|
||||
): PacketNetworkIngestResult | null {
|
||||
const parsed = parsePacket(packet.data);
|
||||
if (!parsed) return null;
|
||||
|
||||
const activityAtMs = normalizePacketTimestampMs(packet.timestamp);
|
||||
const canonicalPath = buildCanonicalPathForPacket(state, context, parsed, packet, activityAtMs);
|
||||
if (canonicalPath.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = getPacketLabel(parsed.payloadType);
|
||||
for (let i = 0; i < canonicalPath.length; i++) {
|
||||
const node = state.nodes.get(canonicalPath[i]);
|
||||
if (node && node.id !== 'self') {
|
||||
node.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`;
|
||||
}
|
||||
}
|
||||
|
||||
state.observations.push({ nodes: canonicalPath, activityAtMs });
|
||||
|
||||
for (let i = 0; i < canonicalPath.length - 1; i++) {
|
||||
if (canonicalPath[i] !== canonicalPath[i + 1]) {
|
||||
addCanonicalLink(state, canonicalPath[i], canonicalPath[i + 1], activityAtMs);
|
||||
upsertNeighbor(state, canonicalPath[i], canonicalPath[i + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return { parsed, label, canonicalPath, activityAtMs };
|
||||
}
|
||||
|
||||
export function isPacketNetworkNodeVisible(
|
||||
node: PacketNetworkNode | undefined,
|
||||
visibility: PacketNetworkVisibilityOptions
|
||||
): boolean {
|
||||
if (!node) return false;
|
||||
if (node.id === 'self') return true;
|
||||
if (!node.isAmbiguous) return true;
|
||||
return node.type === 'repeater' ? visibility.showAmbiguousPaths : visibility.showAmbiguousNodes;
|
||||
}
|
||||
|
||||
function buildKnownSiblingRepeaterAliasMap(
|
||||
state: PacketNetworkState,
|
||||
visibility: PacketNetworkVisibilityOptions
|
||||
): Map<string, string> {
|
||||
if (!visibility.collapseLikelyKnownSiblingRepeaters || !visibility.showAmbiguousPaths) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const knownRepeaterNextHops = new Map<string, Set<string>>();
|
||||
for (const observation of state.observations) {
|
||||
for (let i = 0; i < observation.nodes.length - 1; i++) {
|
||||
const currentNode = state.nodes.get(observation.nodes[i]);
|
||||
if (!currentNode || currentNode.type !== 'repeater' || currentNode.isAmbiguous) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextNodeId = observation.nodes[i + 1];
|
||||
const existing = knownRepeaterNextHops.get(currentNode.id);
|
||||
if (existing) {
|
||||
existing.add(nextNodeId);
|
||||
} else {
|
||||
knownRepeaterNextHops.set(currentNode.id, new Set([nextNodeId]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const aliases = new Map<string, string>();
|
||||
for (const observation of state.observations) {
|
||||
for (let i = 0; i < observation.nodes.length - 1; i++) {
|
||||
const currentNodeId = observation.nodes[i];
|
||||
const currentNode = state.nodes.get(currentNodeId);
|
||||
if (
|
||||
!currentNode ||
|
||||
currentNode.type !== 'repeater' ||
|
||||
!currentNode.isAmbiguous ||
|
||||
!currentNode.probableIdentityNodeId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const probableNode = state.nodes.get(currentNode.probableIdentityNodeId);
|
||||
if (!probableNode || probableNode.type !== 'repeater' || probableNode.isAmbiguous) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextNodeId = observation.nodes[i + 1];
|
||||
const probableNextHops = knownRepeaterNextHops.get(probableNode.id);
|
||||
if (probableNextHops?.has(nextNodeId)) {
|
||||
aliases.set(currentNodeId, probableNode.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function projectCanonicalPathWithAliases(
|
||||
state: PacketNetworkState,
|
||||
canonicalPath: string[],
|
||||
visibility: PacketNetworkVisibilityOptions,
|
||||
repeaterAliases: Map<string, string>
|
||||
): ProjectedPacketNetworkPath {
|
||||
const projected = compactPathSteps(
|
||||
canonicalPath.map((nodeId, index) => {
|
||||
const node = state.nodes.get(nodeId);
|
||||
const visible = isPacketNetworkNodeVisible(node, visibility);
|
||||
return {
|
||||
nodeId: visible ? (repeaterAliases.get(nodeId) ?? nodeId) : null,
|
||||
// Only hidden repeater hops should imply a bridged dashed segment.
|
||||
// Hidden sender/recipient endpoints should disappear with their own edge.
|
||||
markHiddenLinkWhenOmitted:
|
||||
!visible &&
|
||||
!!node &&
|
||||
node.type === 'repeater' &&
|
||||
index > 0 &&
|
||||
index < canonicalPath.length - 1,
|
||||
hiddenLabel: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
nodes: dedupeConsecutive(projected.nodes),
|
||||
dashedLinkDetails: projected.dashedLinkDetails,
|
||||
};
|
||||
}
|
||||
|
||||
export function projectCanonicalPath(
|
||||
state: PacketNetworkState,
|
||||
canonicalPath: string[],
|
||||
visibility: PacketNetworkVisibilityOptions
|
||||
): ProjectedPacketNetworkPath {
|
||||
return projectCanonicalPathWithAliases(
|
||||
state,
|
||||
canonicalPath,
|
||||
visibility,
|
||||
buildKnownSiblingRepeaterAliasMap(state, visibility)
|
||||
);
|
||||
}
|
||||
|
||||
export function projectPacketNetwork(
|
||||
state: PacketNetworkState,
|
||||
visibility: PacketNetworkVisibilityOptions
|
||||
): PacketNetworkProjection {
|
||||
const repeaterAliases = buildKnownSiblingRepeaterAliasMap(state, visibility);
|
||||
const nodes = new Map<string, PacketNetworkNode>();
|
||||
const selfNode = state.nodes.get('self');
|
||||
if (selfNode) {
|
||||
nodes.set('self', selfNode);
|
||||
}
|
||||
|
||||
const links = new Map<string, ProjectedPacketNetworkLink>();
|
||||
|
||||
for (const observation of state.observations) {
|
||||
const projected = projectCanonicalPathWithAliases(
|
||||
state,
|
||||
observation.nodes,
|
||||
visibility,
|
||||
repeaterAliases
|
||||
);
|
||||
if (projected.nodes.length < 2) continue;
|
||||
|
||||
for (const nodeId of projected.nodes) {
|
||||
const node = state.nodes.get(nodeId);
|
||||
if (node) {
|
||||
nodes.set(nodeId, node);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < projected.nodes.length - 1; i++) {
|
||||
const sourceId = projected.nodes[i];
|
||||
const targetId = projected.nodes[i + 1];
|
||||
if (sourceId === targetId) continue;
|
||||
|
||||
const key = buildLinkKey(sourceId, targetId);
|
||||
const hiddenIntermediate = projected.dashedLinkDetails.has(key);
|
||||
const existing = links.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, observation.activityAtMs);
|
||||
if (hiddenIntermediate) {
|
||||
existing.hasHiddenIntermediate = true;
|
||||
for (const label of projected.dashedLinkDetails.get(key) ?? []) {
|
||||
if (!existing.hiddenHopLabels.includes(label)) {
|
||||
existing.hiddenHopLabels.push(label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
existing.hasDirectObservation = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
links.set(key, {
|
||||
sourceId,
|
||||
targetId,
|
||||
lastActivity: observation.activityAtMs,
|
||||
hasDirectObservation: !hiddenIntermediate,
|
||||
hasHiddenIntermediate: hiddenIntermediate,
|
||||
hiddenHopLabels: [...(projected.dashedLinkDetails.get(key) ?? [])],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
links,
|
||||
renderedNodeIds: new Set(nodes.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
export function prunePacketNetworkState(state: PacketNetworkState, cutoff: number): boolean {
|
||||
let pruned = false;
|
||||
|
||||
for (const [id, node] of state.nodes) {
|
||||
if (id === 'self') continue;
|
||||
if (node.lastActivity < cutoff) {
|
||||
state.nodes.delete(id);
|
||||
pruned = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pruned) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [key, link] of state.links) {
|
||||
if (!state.nodes.has(link.sourceId) || !state.nodes.has(link.targetId)) {
|
||||
state.links.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
state.observations = state.observations.filter((observation) =>
|
||||
observation.nodes.every((nodeId) => state.nodes.has(nodeId))
|
||||
);
|
||||
|
||||
state.neighborIds.clear();
|
||||
for (const link of state.links.values()) {
|
||||
upsertNeighbor(state, link.sourceId, link.targetId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function snapshotNeighborIds(state: PacketNetworkState): Map<string, string[]> {
|
||||
return new Map(
|
||||
Array.from(state.neighborIds.entries()).map(([nodeId, neighborIds]) => [
|
||||
nodeId,
|
||||
Array.from(neighborIds).sort(),
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,9 @@ vi.mock('../hooks', async (importOriginal) => {
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
lastMessageTimes: {},
|
||||
unreadLastReadAts: {},
|
||||
incrementUnread: mocks.hookFns.incrementUnread,
|
||||
renameConversationState: vi.fn(),
|
||||
markAllRead: mocks.hookFns.markAllRead,
|
||||
trackNewMessage: mocks.hookFns.trackNewMessage,
|
||||
refreshUnreads: mocks.hookFns.refreshUnreads,
|
||||
|
||||
@@ -51,7 +51,9 @@ vi.mock('../hooks', async (importOriginal) => {
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
lastMessageTimes: {},
|
||||
unreadLastReadAts: {},
|
||||
incrementUnread: vi.fn(),
|
||||
renameConversationState: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
trackNewMessage: vi.fn(),
|
||||
refreshUnreads: vi.fn(),
|
||||
|
||||
@@ -40,7 +40,9 @@ vi.mock('../hooks', async (importOriginal) => {
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
lastMessageTimes: {},
|
||||
unreadLastReadAts: {},
|
||||
incrementUnread: vi.fn(),
|
||||
renameConversationState: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
trackNewMessage: vi.fn(),
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatHeader } from '../components/ChatHeader';
|
||||
@@ -94,6 +94,28 @@ describe('ChatHeader key visibility', () => {
|
||||
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the clickable conversation title as a real button inside the heading', () => {
|
||||
const pubKey = '12'.repeat(32);
|
||||
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' };
|
||||
const onOpenContactInfo = vi.fn();
|
||||
|
||||
render(
|
||||
<ChatHeader
|
||||
{...baseProps}
|
||||
conversation={conversation}
|
||||
channels={[]}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
/>
|
||||
);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /alice/i });
|
||||
const titleButton = within(heading).getByRole('button', { name: 'View info for Alice' });
|
||||
|
||||
expect(heading).toContainElement(titleButton);
|
||||
fireEvent.click(titleButton);
|
||||
expect(onOpenContactInfo).toHaveBeenCalledWith(pubKey);
|
||||
});
|
||||
|
||||
it('copies key to clipboard when revealed key is clicked', async () => {
|
||||
const key = 'FF'.repeat(16);
|
||||
const channel = makeChannel(key, 'Priv', false);
|
||||
|
||||
@@ -13,12 +13,16 @@ import type {
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
messageList: vi.fn(() => <div data-testid="message-list" />),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ChatHeader', () => ({
|
||||
ChatHeader: () => <div data-testid="chat-header" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageList', () => ({
|
||||
MessageList: () => <div data-testid="message-list" />,
|
||||
MessageList: mocks.messageList,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageInput', () => ({
|
||||
@@ -107,6 +111,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: false,
|
||||
unreadMarkerLastReadAt: undefined,
|
||||
targetMessageId: null,
|
||||
hasNewerMessages: false,
|
||||
loadingNewer: false,
|
||||
@@ -124,6 +129,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
onTargetReached: vi.fn(),
|
||||
onLoadNewer: vi.fn(async () => {}),
|
||||
onJumpToBottom: vi.fn(),
|
||||
onDismissUnreadMarker: vi.fn(),
|
||||
onSendMessage: vi.fn(async () => {}),
|
||||
onToggleNotifications: vi.fn(),
|
||||
...overrides,
|
||||
@@ -133,6 +139,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
describe('ConversationPane', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.messageList.mockImplementation(() => <div data-testid="message-list" />);
|
||||
});
|
||||
|
||||
it('renders the empty state when no conversation is active', () => {
|
||||
@@ -196,4 +203,122 @@ describe('ConversationPane', () => {
|
||||
expect(screen.getByTestId('message-input')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes unread marker props to MessageList only for channel conversations', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
},
|
||||
unreadMarkerLastReadAt: 1700000000,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.messageList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const channelCallArgs = mocks.messageList.mock.calls[
|
||||
mocks.messageList.mock.calls.length - 1
|
||||
] as unknown[] | undefined;
|
||||
const channelCall = channelCallArgs?.[0] as Record<string, unknown> | undefined;
|
||||
expect(channelCall?.unreadMarkerLastReadAt).toBe(1700000000);
|
||||
expect(channelCall?.onDismissUnreadMarker).toBeTypeOf('function');
|
||||
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'contact',
|
||||
id: 'cc'.repeat(32),
|
||||
name: 'Alice',
|
||||
},
|
||||
unreadMarkerLastReadAt: 1700000000,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
const contactCallArgs = mocks.messageList.mock.calls[
|
||||
mocks.messageList.mock.calls.length - 1
|
||||
] as unknown[] | undefined;
|
||||
const contactCall = contactCallArgs?.[0] as Record<string, unknown> | undefined;
|
||||
expect(contactCall?.unreadMarkerLastReadAt).toBeUndefined();
|
||||
expect(contactCall?.onDismissUnreadMarker).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows a warning but keeps input for full-key contacts without an advert', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'contact',
|
||||
id: 'cc'.repeat(32),
|
||||
name: '[unknown sender]',
|
||||
},
|
||||
contacts: [
|
||||
{
|
||||
public_key: 'cc'.repeat(32),
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/A full identity profile is not yet available/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides input and shows a read-only warning for prefix-only contacts', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'contact',
|
||||
id: 'abc123def456',
|
||||
name: 'abc123def456',
|
||||
},
|
||||
contacts: [
|
||||
{
|
||||
public_key: 'abc123def456',
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/This conversation is read-only/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { MapView } from '../components/MapView';
|
||||
import type { Contact } from '../types';
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
TileLayer: () => null,
|
||||
CircleMarker: forwardRef<
|
||||
HTMLDivElement,
|
||||
{ children: React.ReactNode; pathOptions?: { fillColor?: string } }
|
||||
>(({ children, pathOptions }, ref) => (
|
||||
<div ref={ref} data-fill-color={pathOptions?.fillColor}>
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
Popup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
useMap: () => ({
|
||||
setView: vi.fn(),
|
||||
fitBounds: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('MapView', () => {
|
||||
it('renders a never-heard fallback for a focused contact without last_seen', () => {
|
||||
const contact: Contact = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Mystery Node',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
last_advert: null,
|
||||
lat: 40,
|
||||
lon: -74,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
|
||||
render(<MapView contacts={[contact]} focusedKey={contact.public_key} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(/showing 1 contact heard in the last 7 days plus the focused contact/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Last heard: Never heard by this server')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { useState } from 'react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { MessageList } from '../components/MessageList';
|
||||
import type { Message } from '../types';
|
||||
|
||||
const scrollIntoViewMock = vi.fn();
|
||||
const originalGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
|
||||
|
||||
function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -24,6 +29,20 @@ function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
}
|
||||
|
||||
describe('MessageList channel sender rendering', () => {
|
||||
beforeEach(() => {
|
||||
scrollIntoViewMock.mockReset();
|
||||
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
|
||||
configurable: true,
|
||||
value: scrollIntoViewMock,
|
||||
writable: true,
|
||||
});
|
||||
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: originalGetBoundingClientRect,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('renders explicit corrupt placeholder and warning avatar for unnamed corrupt channel packets', () => {
|
||||
render(
|
||||
<MessageList
|
||||
@@ -61,4 +80,172 @@ describe('MessageList channel sender rendering', () => {
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('gives clickable sender avatars an accessible label', () => {
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
text: 'garbled payload with no sender prefix',
|
||||
sender_name: 'Alice',
|
||||
sender_key: 'ab'.repeat(32),
|
||||
}),
|
||||
]}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
onOpenContactInfo={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
|
||||
const user = userEvent.setup();
|
||||
const messages = [
|
||||
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
|
||||
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
|
||||
];
|
||||
|
||||
function DismissibleUnreadMarkerList() {
|
||||
const [unreadMarkerLastReadAt, setUnreadMarkerLastReadAt] = useState<number | undefined>(
|
||||
1700000005
|
||||
);
|
||||
|
||||
return (
|
||||
<MessageList
|
||||
messages={messages}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
unreadMarkerLastReadAt={unreadMarkerLastReadAt}
|
||||
onDismissUnreadMarker={() => setUnreadMarkerLastReadAt(undefined)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<DismissibleUnreadMarkerList />);
|
||||
|
||||
const marker = screen.getByRole('button', { name: /Unread messages/i });
|
||||
expect(marker).toBeInTheDocument();
|
||||
expect(screen.getByText('older')).toBeInTheDocument();
|
||||
expect(screen.getByText('newer')).toBeInTheDocument();
|
||||
|
||||
await user.click(marker);
|
||||
|
||||
expect(screen.queryByRole('button', { name: /Unread messages/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a jump-to-unread button and dismisses it after use without hiding the marker', async () => {
|
||||
const user = userEvent.setup();
|
||||
const messages = [
|
||||
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
|
||||
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
|
||||
];
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={messages}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
unreadMarkerLastReadAt={1700000005}
|
||||
/>
|
||||
);
|
||||
|
||||
const jumpButton = screen.getByRole('button', { name: 'Jump to unread' });
|
||||
expect(jumpButton).toBeInTheDocument();
|
||||
expect(screen.getByText('Unread messages')).toBeInTheDocument();
|
||||
|
||||
await user.click(jumpButton);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Jump to unread' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Unread messages')).toBeInTheDocument();
|
||||
expect(scrollIntoViewMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets the user dismiss the jump-to-unread button without scrolling or hiding the marker', async () => {
|
||||
const user = userEvent.setup();
|
||||
const messages = [
|
||||
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
|
||||
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
|
||||
];
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={messages}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
unreadMarkerLastReadAt={1700000005}
|
||||
/>
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Dismiss jump to unread' }));
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Jump to unread' })).not.toBeInTheDocument();
|
||||
expect(screen.getByText('Unread messages')).toBeInTheDocument();
|
||||
expect(scrollIntoViewMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides the jump-to-unread button when the unread marker is already visible', () => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: function () {
|
||||
const element = this as HTMLElement;
|
||||
if (element.textContent?.includes('Unread messages')) {
|
||||
return {
|
||||
top: 200,
|
||||
bottom: 240,
|
||||
left: 0,
|
||||
right: 300,
|
||||
width: 300,
|
||||
height: 40,
|
||||
x: 0,
|
||||
y: 200,
|
||||
toJSON: () => '',
|
||||
};
|
||||
}
|
||||
if (element.className.includes('overflow-y-auto')) {
|
||||
return {
|
||||
top: 100,
|
||||
bottom: 500,
|
||||
left: 0,
|
||||
right: 400,
|
||||
width: 400,
|
||||
height: 400,
|
||||
x: 0,
|
||||
y: 100,
|
||||
toJSON: () => '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => '',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const messages = [
|
||||
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
|
||||
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
|
||||
];
|
||||
|
||||
render(
|
||||
<MessageList
|
||||
messages={messages}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
unreadMarkerLastReadAt={1700000005}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Unread messages')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Jump to unread' })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import {
|
||||
buildPacketNetworkContext,
|
||||
createPacketNetworkState,
|
||||
ingestPacketIntoPacketNetwork,
|
||||
projectCanonicalPath,
|
||||
projectPacketNetwork,
|
||||
snapshotNeighborIds,
|
||||
} from '../networkGraph/packetNetworkGraph';
|
||||
import { buildLinkKey } from '../utils/visualizerUtils';
|
||||
import type { Contact, RadioConfig, RawPacket } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
|
||||
const { packetFixtures } = vi.hoisted(() => ({
|
||||
packetFixtures: new Map<string, unknown>(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/visualizerUtils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/visualizerUtils')>(
|
||||
'../utils/visualizerUtils'
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
parsePacket: vi.fn(
|
||||
(hexData: string) => packetFixtures.get(hexData) ?? actual.parsePacket(hexData)
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
function createConfig(publicKey: string): RadioConfig {
|
||||
return {
|
||||
public_key: publicKey,
|
||||
name: 'Me',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
tx_power: 0,
|
||||
max_tx_power: 0,
|
||||
radio: { freq: 0, bw: 0, sf: 0, cr: 0 },
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: true,
|
||||
advert_location_source: 'off',
|
||||
};
|
||||
}
|
||||
|
||||
function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
return {
|
||||
public_key: publicKey,
|
||||
name,
|
||||
type,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createPacket(data: string): RawPacket {
|
||||
return {
|
||||
id: 1,
|
||||
observation_id: 1,
|
||||
timestamp: 1_700_000_000,
|
||||
data,
|
||||
payload_type: 'TEXT',
|
||||
snr: null,
|
||||
rssi: null,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe('packetNetworkGraph', () => {
|
||||
it('preserves canonical adjacency while projection hides ambiguous repeaters', () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
packetFixtures.set('dm-semantic-hide', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-semantic-hide',
|
||||
pathBytes: ['32'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const state = createPacketNetworkState('Me');
|
||||
const context = buildPacketNetworkContext({
|
||||
contacts: [createContact(aliceKey, 'Alice')],
|
||||
config: createConfig(selfKey),
|
||||
repeaterAdvertPaths: [],
|
||||
splitAmbiguousByTraffic: false,
|
||||
useAdvertPathHints: false,
|
||||
});
|
||||
|
||||
ingestPacketIntoPacketNetwork(state, context, createPacket('dm-semantic-hide'));
|
||||
|
||||
const hiddenProjection = projectPacketNetwork(state, {
|
||||
showAmbiguousNodes: false,
|
||||
showAmbiguousPaths: false,
|
||||
collapseLikelyKnownSiblingRepeaters: true,
|
||||
});
|
||||
const shownProjection = projectPacketNetwork(state, {
|
||||
showAmbiguousNodes: false,
|
||||
showAmbiguousPaths: true,
|
||||
collapseLikelyKnownSiblingRepeaters: true,
|
||||
});
|
||||
|
||||
expect(snapshotNeighborIds(state)).toEqual(
|
||||
new Map([
|
||||
['?32', ['aaaaaaaaaaaa', 'self']],
|
||||
['aaaaaaaaaaaa', ['?32']],
|
||||
['self', ['?32']],
|
||||
])
|
||||
);
|
||||
expect(hiddenProjection.links.has('aaaaaaaaaaaa->self')).toBe(true);
|
||||
expect(shownProjection.links.has('?32->aaaaaaaaaaaa')).toBe(true);
|
||||
expect(shownProjection.links.has('?32->self')).toBe(true);
|
||||
});
|
||||
|
||||
it('projects hidden ambiguous runs as dashed bridges but keeps later known repeaters visible', () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
const repeaterKey = '5656565656560000000000000000000000000000000000000000000000000000';
|
||||
|
||||
packetFixtures.set('dm-hidden-chain', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-hidden-chain',
|
||||
pathBytes: ['32', '565656565656'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const state = createPacketNetworkState('Me');
|
||||
const context = buildPacketNetworkContext({
|
||||
contacts: [
|
||||
createContact(aliceKey, 'Alice'),
|
||||
createContact(repeaterKey, 'Relay B', CONTACT_TYPE_REPEATER),
|
||||
],
|
||||
config: createConfig(selfKey),
|
||||
repeaterAdvertPaths: [],
|
||||
splitAmbiguousByTraffic: false,
|
||||
useAdvertPathHints: false,
|
||||
});
|
||||
|
||||
const ingested = ingestPacketIntoPacketNetwork(state, context, createPacket('dm-hidden-chain'));
|
||||
|
||||
expect(ingested?.canonicalPath).toEqual(['aaaaaaaaaaaa', '?32', '565656565656', 'self']);
|
||||
|
||||
const projectedPath = projectCanonicalPath(state, ingested!.canonicalPath, {
|
||||
showAmbiguousNodes: false,
|
||||
showAmbiguousPaths: false,
|
||||
collapseLikelyKnownSiblingRepeaters: true,
|
||||
});
|
||||
const projection = projectPacketNetwork(state, {
|
||||
showAmbiguousNodes: false,
|
||||
showAmbiguousPaths: false,
|
||||
collapseLikelyKnownSiblingRepeaters: true,
|
||||
});
|
||||
|
||||
expect(projectedPath.nodes).toEqual(['aaaaaaaaaaaa', '565656565656', 'self']);
|
||||
expect(Array.from(projectedPath.dashedLinkDetails.keys())).toEqual([
|
||||
'565656565656->aaaaaaaaaaaa',
|
||||
]);
|
||||
expect(projection.links.get('565656565656->aaaaaaaaaaaa')?.hasHiddenIntermediate).toBe(true);
|
||||
expect(projection.links.get('565656565656->self')?.hasDirectObservation).toBe(true);
|
||||
});
|
||||
|
||||
it('does not bridge across hidden ambiguous sender endpoints', () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const repeaterOneKey = '1111111111110000000000000000000000000000000000000000000000000000';
|
||||
const repeaterTwoKey = '2222222222220000000000000000000000000000000000000000000000000000';
|
||||
const repeaterThreeKey = '3333333333330000000000000000000000000000000000000000000000000000';
|
||||
const repeaterFourKey = '4444444444440000000000000000000000000000000000000000000000000000';
|
||||
|
||||
packetFixtures.set('dm-hidden-ambiguous-sender-a', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-hidden-ambiguous-sender-a',
|
||||
pathBytes: ['111111111111', '222222222222'],
|
||||
srcHash: '32',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
packetFixtures.set('dm-hidden-ambiguous-sender-b', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-hidden-ambiguous-sender-b',
|
||||
pathBytes: ['333333333333', '444444444444'],
|
||||
srcHash: '32',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const state = createPacketNetworkState('Me');
|
||||
const context = buildPacketNetworkContext({
|
||||
contacts: [
|
||||
createContact(repeaterOneKey, 'Relay 1', CONTACT_TYPE_REPEATER),
|
||||
createContact(repeaterTwoKey, 'Relay 2', CONTACT_TYPE_REPEATER),
|
||||
createContact(repeaterThreeKey, 'Relay 3', CONTACT_TYPE_REPEATER),
|
||||
createContact(repeaterFourKey, 'Relay 4', CONTACT_TYPE_REPEATER),
|
||||
],
|
||||
config: createConfig(selfKey),
|
||||
repeaterAdvertPaths: [],
|
||||
splitAmbiguousByTraffic: false,
|
||||
useAdvertPathHints: false,
|
||||
});
|
||||
|
||||
ingestPacketIntoPacketNetwork(state, context, createPacket('dm-hidden-ambiguous-sender-a'));
|
||||
ingestPacketIntoPacketNetwork(state, context, createPacket('dm-hidden-ambiguous-sender-b'));
|
||||
|
||||
const projection = projectPacketNetwork(state, {
|
||||
showAmbiguousNodes: false,
|
||||
showAmbiguousPaths: true,
|
||||
collapseLikelyKnownSiblingRepeaters: true,
|
||||
});
|
||||
|
||||
expect(projection.links.has(buildLinkKey('111111111111', '222222222222'))).toBe(true);
|
||||
expect(projection.links.has(buildLinkKey('333333333333', '444444444444'))).toBe(true);
|
||||
expect(projection.links.has(buildLinkKey('111111111111', '333333333333'))).toBe(false);
|
||||
expect(projection.links.has(buildLinkKey('222222222222', '444444444444'))).toBe(false);
|
||||
});
|
||||
|
||||
it('does not add a DM recipient node from destination metadata alone', () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
const bobKey = 'bbbbbbbbbbbb0000000000000000000000000000000000000000000000000000';
|
||||
const repeaterKey = '5656565656560000000000000000000000000000000000000000000000000000';
|
||||
|
||||
packetFixtures.set('dm-third-party-no-dst-node', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-third-party-no-dst-node',
|
||||
pathBytes: ['565656565656'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'bbbbbbbbbbbb',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const state = createPacketNetworkState('Me');
|
||||
const context = buildPacketNetworkContext({
|
||||
contacts: [
|
||||
createContact(aliceKey, 'Alice'),
|
||||
createContact(bobKey, 'Bob'),
|
||||
createContact(repeaterKey, 'Relay', CONTACT_TYPE_REPEATER),
|
||||
],
|
||||
config: createConfig(selfKey),
|
||||
repeaterAdvertPaths: [],
|
||||
splitAmbiguousByTraffic: false,
|
||||
useAdvertPathHints: false,
|
||||
});
|
||||
|
||||
const ingested = ingestPacketIntoPacketNetwork(
|
||||
state,
|
||||
context,
|
||||
createPacket('dm-third-party-no-dst-node')
|
||||
);
|
||||
|
||||
expect(ingested?.canonicalPath).toEqual(['aaaaaaaaaaaa', '565656565656', 'self']);
|
||||
expect(state.nodes.has('bbbbbbbbbbbb')).toBe(false);
|
||||
expect(snapshotNeighborIds(state)).toEqual(
|
||||
new Map([
|
||||
['565656565656', ['aaaaaaaaaaaa', 'self']],
|
||||
['aaaaaaaaaaaa', ['565656565656']],
|
||||
['self', ['565656565656']],
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('replays real advert packets through the semantic layer', () => {
|
||||
const state = createPacketNetworkState('Me');
|
||||
const context = buildPacketNetworkContext({
|
||||
contacts: [],
|
||||
config: createConfig('ffffffffffff0000000000000000000000000000000000000000000000000000'),
|
||||
repeaterAdvertPaths: [],
|
||||
splitAmbiguousByTraffic: false,
|
||||
useAdvertPathHints: false,
|
||||
});
|
||||
|
||||
const packet = createPacket(
|
||||
'1106538B1CD273868576DC7F679B493F9AB5AC316173E1A56D3388BC3BA75F583F63AB0D1BA2A8ABD0BC6669DBF719E67E4C8517BA4E0D6F8C96A323E9D13A77F2630DED965A5C17C3EC6ED1601EEFE857749DA24E9F39CBEACD722C3708F433DB5FA9BAF0BAF9BC5B1241069290FEEB029A839EF843616E204F204D657368203220F09FA5AB'
|
||||
);
|
||||
packet.payload_type = 'ADVERT';
|
||||
|
||||
const ingested = ingestPacketIntoPacketNetwork(state, context, packet);
|
||||
|
||||
expect(ingested?.canonicalPath).toEqual([
|
||||
'8576dc7f679b',
|
||||
'?53',
|
||||
'?8b',
|
||||
'?1c',
|
||||
'?d2',
|
||||
'?73',
|
||||
'?86',
|
||||
'self',
|
||||
]);
|
||||
expect(snapshotNeighborIds(state).get('?73')).toEqual(['?86', '?d2']);
|
||||
});
|
||||
|
||||
it('collapses a likely ambiguous repeater into its known sibling when both share the same next hop', () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const state = createPacketNetworkState('Me');
|
||||
const context = buildPacketNetworkContext({
|
||||
contacts: [
|
||||
createContact('aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000', 'Alice'),
|
||||
createContact('cccccccccccc0000000000000000000000000000000000000000000000000000', 'Carol'),
|
||||
createContact(
|
||||
'3232323232320000000000000000000000000000000000000000000000000000',
|
||||
'Relay A',
|
||||
CONTACT_TYPE_REPEATER
|
||||
),
|
||||
createContact(
|
||||
'32ababababab0000000000000000000000000000000000000000000000000000',
|
||||
'Relay B',
|
||||
CONTACT_TYPE_REPEATER
|
||||
),
|
||||
createContact(
|
||||
'5656565656560000000000000000000000000000000000000000000000000000',
|
||||
'Relay Next',
|
||||
CONTACT_TYPE_REPEATER
|
||||
),
|
||||
],
|
||||
config: createConfig(selfKey),
|
||||
repeaterAdvertPaths: [
|
||||
{
|
||||
public_key: '3232323232320000000000000000000000000000000000000000000000000000',
|
||||
paths: [
|
||||
{
|
||||
path: '',
|
||||
path_len: 1,
|
||||
next_hop: '565656565656',
|
||||
first_seen: 1,
|
||||
last_seen: 2,
|
||||
heard_count: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
splitAmbiguousByTraffic: false,
|
||||
useAdvertPathHints: true,
|
||||
});
|
||||
|
||||
packetFixtures.set('graph-ambiguous-sibling', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'graph-ambiguous-sibling',
|
||||
pathBytes: ['32', '565656565656'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
packetFixtures.set('graph-known-sibling', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'graph-known-sibling',
|
||||
pathBytes: ['323232323232', '565656565656'],
|
||||
srcHash: 'cccccccccccc',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
ingestPacketIntoPacketNetwork(state, context, createPacket('graph-ambiguous-sibling'));
|
||||
ingestPacketIntoPacketNetwork(state, context, createPacket('graph-known-sibling'));
|
||||
|
||||
const collapsed = projectPacketNetwork(state, {
|
||||
showAmbiguousNodes: false,
|
||||
showAmbiguousPaths: true,
|
||||
collapseLikelyKnownSiblingRepeaters: true,
|
||||
});
|
||||
const separated = projectPacketNetwork(state, {
|
||||
showAmbiguousNodes: false,
|
||||
showAmbiguousPaths: true,
|
||||
collapseLikelyKnownSiblingRepeaters: false,
|
||||
});
|
||||
|
||||
expect(collapsed.renderedNodeIds.has('?32')).toBe(false);
|
||||
expect(collapsed.renderedNodeIds.has('323232323232')).toBe(true);
|
||||
expect(collapsed.links.has('323232323232->aaaaaaaaaaaa')).toBe(true);
|
||||
expect(separated.renderedNodeIds.has('?32')).toBe(true);
|
||||
expect(separated.links.has('?32->aaaaaaaaaaaa')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ const mockHook: {
|
||||
loginError: null,
|
||||
paneData: {
|
||||
status: null,
|
||||
nodeInfo: null,
|
||||
neighbors: null,
|
||||
acl: null,
|
||||
radioSettings: null,
|
||||
@@ -23,6 +24,7 @@ const mockHook: {
|
||||
},
|
||||
paneStates: {
|
||||
status: { loading: false, attempt: 0, error: null },
|
||||
nodeInfo: { loading: false, attempt: 0, error: null },
|
||||
neighbors: { loading: false, attempt: 0, error: null },
|
||||
acl: { loading: false, attempt: 0, error: null },
|
||||
radioSettings: { loading: false, attempt: 0, error: null },
|
||||
@@ -63,6 +65,7 @@ vi.mock('react-leaflet', () => ({
|
||||
TileLayer: () => null,
|
||||
CircleMarker: () => null,
|
||||
Popup: () => null,
|
||||
Polyline: () => null,
|
||||
}));
|
||||
|
||||
const REPEATER_KEY = 'aa'.repeat(32);
|
||||
@@ -120,6 +123,7 @@ describe('RepeaterDashboard', () => {
|
||||
mockHook.loginError = null;
|
||||
mockHook.paneData = {
|
||||
status: null,
|
||||
nodeInfo: null,
|
||||
neighbors: null,
|
||||
acl: null,
|
||||
radioSettings: null,
|
||||
@@ -130,6 +134,7 @@ describe('RepeaterDashboard', () => {
|
||||
};
|
||||
mockHook.paneStates = {
|
||||
status: { loading: false, attempt: 0, error: null },
|
||||
nodeInfo: { loading: false, attempt: 0, error: null },
|
||||
neighbors: { loading: false, attempt: 0, error: null },
|
||||
acl: { loading: false, attempt: 0, error: null },
|
||||
radioSettings: { loading: false, attempt: 0, error: null },
|
||||
@@ -157,6 +162,7 @@ describe('RepeaterDashboard', () => {
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Telemetry')).toBeInTheDocument();
|
||||
expect(screen.getByText('Node Info')).toBeInTheDocument();
|
||||
expect(screen.getByText('Neighbors')).toBeInTheDocument();
|
||||
expect(screen.getByText('ACL')).toBeInTheDocument();
|
||||
expect(screen.getByText('Radio Settings')).toBeInTheDocument();
|
||||
@@ -226,6 +232,102 @@ describe('RepeaterDashboard', () => {
|
||||
expect(screen.getByText('Timeout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows GPS unavailable message for neighbors when repeater coords are missing', () => {
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneData.neighbors = {
|
||||
neighbors: [
|
||||
{ pubkey_prefix: 'bbbbbbbbbbbb', name: 'Neighbor', snr: 7.2, last_heard_seconds: 9 },
|
||||
],
|
||||
};
|
||||
mockHook.paneData.nodeInfo = {
|
||||
name: 'TestRepeater',
|
||||
lat: '0',
|
||||
lon: '0',
|
||||
clock_utc: null,
|
||||
};
|
||||
mockHook.paneStates.neighbors = {
|
||||
loading: false,
|
||||
attempt: 1,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
};
|
||||
mockHook.paneStates.nodeInfo = {
|
||||
loading: false,
|
||||
attempt: 1,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
};
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'GPS info failed to fetch; map and distance data not available. This may be due to missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try refreshing.'
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Dist')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows neighbor distance when repeater radio settings include valid coords', () => {
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneData.neighbors = {
|
||||
neighbors: [
|
||||
{ pubkey_prefix: 'bbbbbbbbbbbb', name: 'Neighbor', snr: 7.2, last_heard_seconds: 9 },
|
||||
],
|
||||
};
|
||||
mockHook.paneData.nodeInfo = {
|
||||
name: 'TestRepeater',
|
||||
lat: '-31.9500',
|
||||
lon: '115.8600',
|
||||
clock_utc: null,
|
||||
};
|
||||
mockHook.paneStates.neighbors = {
|
||||
loading: false,
|
||||
attempt: 1,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
};
|
||||
mockHook.paneStates.nodeInfo = {
|
||||
loading: false,
|
||||
attempt: 1,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
};
|
||||
|
||||
const contactsWithNeighbor = [
|
||||
...contacts,
|
||||
{
|
||||
public_key: 'bbbbbbbbbbbb0000000000000000000000000000000000000000000000000000',
|
||||
name: 'Neighbor',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
last_advert: null,
|
||||
lat: -31.94,
|
||||
lon: 115.87,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
},
|
||||
];
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={contactsWithNeighbor} />);
|
||||
|
||||
expect(screen.getByText('Dist')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
'GPS info failed to fetch; map and distance data not available. This may be due to missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try refreshing.'
|
||||
)
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fetching state with attempt counter', () => {
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneStates.status = { loading: true, attempt: 2, error: null };
|
||||
@@ -264,6 +366,24 @@ describe('RepeaterDashboard', () => {
|
||||
expect(screen.getByText('7.5 dB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats the radio tuple and preserves the raw tuple in a tooltip', () => {
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneData.radioSettings = {
|
||||
firmware_version: 'v1.0',
|
||||
radio: '910.5250244,62.5,7,5',
|
||||
tx_power: '20',
|
||||
airtime_factor: '0',
|
||||
repeat_enabled: '1',
|
||||
flood_max: '3',
|
||||
};
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
const formatted = screen.getByText('910.525 MHz, BW 62.5 kHz, SF7, CR5');
|
||||
expect(formatted).toBeInTheDocument();
|
||||
expect(formatted).toHaveAttribute('title', '910.5250244,62.5,7,5');
|
||||
});
|
||||
|
||||
it('shows fetched time and relative age when pane data has been loaded', () => {
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneStates.status = {
|
||||
|
||||
@@ -71,6 +71,9 @@ describe('SearchView', () => {
|
||||
render(<SearchView {...defaultProps} />);
|
||||
expect(screen.getByText('Type to search across all messages')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Tip: use/i)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/User-key linkage for group messages is best-effort/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('focuses input on mount', () => {
|
||||
@@ -280,4 +283,35 @@ describe('SearchView', () => {
|
||||
expect.any(AbortSignal)
|
||||
);
|
||||
});
|
||||
|
||||
it('aborts the load-more request on unmount', async () => {
|
||||
const pageResults = Array.from({ length: 50 }, (_, i) =>
|
||||
createSearchResult({ id: i + 1, text: `result ${i}` })
|
||||
);
|
||||
let resolveLoadMore: ((value: Message[]) => void) | null = null;
|
||||
mockGetMessages.mockResolvedValueOnce(pageResults).mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<Message[]>((resolve) => {
|
||||
resolveLoadMore = resolve;
|
||||
})
|
||||
);
|
||||
|
||||
const { unmount } = render(<SearchView {...defaultProps} />);
|
||||
|
||||
await typeAndWaitForResults('result');
|
||||
fireEvent.click(screen.getByText('Load more results'));
|
||||
|
||||
const loadMoreSignal = mockGetMessages.mock.calls[1]?.[1] as AbortSignal | undefined;
|
||||
expect(loadMoreSignal).toBeInstanceOf(AbortSignal);
|
||||
expect(loadMoreSignal?.aborted).toBe(false);
|
||||
|
||||
unmount();
|
||||
|
||||
expect(loadMoreSignal?.aborted).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
resolveLoadMore?.([createSearchResult({ id: 99, text: 'late result' })]);
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { SettingsAboutSection } from '../components/settings/SettingsAboutSection';
|
||||
|
||||
describe('SettingsAboutSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('__APP_VERSION__', '3.2.0-test');
|
||||
vi.stubGlobal('__COMMIT_HASH__', 'deadbeef');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('renders the debug support snapshot link', () => {
|
||||
render(<SettingsAboutSection />);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Open debug support snapshot/i });
|
||||
expect(link).toHaveAttribute('href', '/api/debug');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ const baseConfig: RadioConfig = {
|
||||
},
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: false,
|
||||
advert_location_source: 'current',
|
||||
};
|
||||
|
||||
const baseHealth: HealthStatus = {
|
||||
@@ -203,6 +204,22 @@ describe('SettingsModal', () => {
|
||||
expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves advert location source through radio config save', async () => {
|
||||
const { onSave } = renderModal();
|
||||
openRadioSection();
|
||||
|
||||
fireEvent.change(screen.getByLabelText('Advert Location Source'), {
|
||||
target: { value: 'off' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSave).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ advert_location_source: 'off' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('saves changed max contacts value through onSaveAppSettings', async () => {
|
||||
const { onSaveAppSettings } = renderModal();
|
||||
openRadioSection();
|
||||
@@ -279,24 +296,6 @@ describe('SettingsModal', () => {
|
||||
expect(screen.queryByLabelText('Local label text')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the theme contrast preview in local settings', () => {
|
||||
renderModal();
|
||||
openLocalSection();
|
||||
|
||||
expect(
|
||||
screen.getByText('Preview alert and message contrast for the selected theme.')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Connected preview: radio link healthy and syncing.')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Warning preview: packet audit suggests missing history.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Error preview: radio reconnect failed.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hello, mesh!')).toBeInTheDocument();
|
||||
expect(screen.getByText("Hi there! I'm using RemoteTerm.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists the new Windows 95 and iPhone themes', () => {
|
||||
renderModal();
|
||||
openLocalSection();
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getUnreadBoundaryBackfillKey } from '../App';
|
||||
import type { Conversation, Message } from '../types';
|
||||
|
||||
function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'channel-1',
|
||||
text: 'Alice: hello',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000001,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
sender_key: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: 'Alice',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const channelConversation: Conversation = {
|
||||
type: 'channel',
|
||||
id: 'channel-1',
|
||||
name: 'Busy room',
|
||||
};
|
||||
|
||||
describe('getUnreadBoundaryBackfillKey', () => {
|
||||
it('returns a fetch key when the unread boundary is older than the loaded window', () => {
|
||||
expect(
|
||||
getUnreadBoundaryBackfillKey({
|
||||
activeConversation: channelConversation,
|
||||
unreadMarker: {
|
||||
channelId: 'channel-1',
|
||||
lastReadAt: 1700000000,
|
||||
},
|
||||
messages: [
|
||||
createMessage({ id: 20, received_at: 1700000200 }),
|
||||
createMessage({ id: 21, received_at: 1700000300 }),
|
||||
],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: true,
|
||||
})
|
||||
).toBe('channel-1:1700000000:20');
|
||||
});
|
||||
|
||||
it('does not backfill when the loaded window already reaches the unread boundary', () => {
|
||||
expect(
|
||||
getUnreadBoundaryBackfillKey({
|
||||
activeConversation: channelConversation,
|
||||
unreadMarker: {
|
||||
channelId: 'channel-1',
|
||||
lastReadAt: 1700000200,
|
||||
},
|
||||
messages: [
|
||||
createMessage({ id: 20, received_at: 1700000200 }),
|
||||
createMessage({ id: 21, received_at: 1700000300 }),
|
||||
],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: true,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not backfill when there is no older history to fetch', () => {
|
||||
expect(
|
||||
getUnreadBoundaryBackfillKey({
|
||||
activeConversation: channelConversation,
|
||||
unreadMarker: {
|
||||
channelId: 'channel-1',
|
||||
lastReadAt: 1700000000,
|
||||
},
|
||||
messages: [createMessage({ id: 20, received_at: 1700000200 })],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: false,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('does not backfill for channels where everything is unread', () => {
|
||||
expect(
|
||||
getUnreadBoundaryBackfillKey({
|
||||
activeConversation: channelConversation,
|
||||
unreadMarker: {
|
||||
channelId: 'channel-1',
|
||||
lastReadAt: null,
|
||||
},
|
||||
messages: [createMessage({ id: 20, received_at: 1700000200 })],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: true,
|
||||
})
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -101,6 +101,40 @@ describe('useConversationMessages ACK ordering', () => {
|
||||
expect(result.current.messages[0].paths).toEqual(paths);
|
||||
});
|
||||
|
||||
it('preserves a WebSocket-arrived message when latest fetch resolves afterward', async () => {
|
||||
const deferred = createDeferred<Message[]>();
|
||||
mockGetMessages.mockReturnValueOnce(deferred.promise);
|
||||
|
||||
const { result } = renderHook(() => useConversationMessages(createConversation()));
|
||||
await waitFor(() => expect(mockGetMessages).toHaveBeenCalledTimes(1));
|
||||
|
||||
act(() => {
|
||||
const added = result.current.addMessageIfNew(
|
||||
createMessage({
|
||||
id: 99,
|
||||
text: 'ws-arrived',
|
||||
sender_timestamp: 1700000099,
|
||||
received_at: 1700000099,
|
||||
})
|
||||
);
|
||||
expect(added).toBe(true);
|
||||
});
|
||||
|
||||
deferred.resolve([
|
||||
createMessage({
|
||||
id: 42,
|
||||
text: 'rest-fetched',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000001,
|
||||
}),
|
||||
]);
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages.some((msg) => msg.text === 'rest-fetched')).toBe(true);
|
||||
expect(result.current.messages.some((msg) => msg.text === 'ws-arrived')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps highest ACK state when out-of-order ACK updates arrive', async () => {
|
||||
mockGetMessages.mockResolvedValueOnce([]);
|
||||
|
||||
@@ -224,6 +258,167 @@ describe('useConversationMessages conversation switch', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('useConversationMessages background reconcile ordering', () => {
|
||||
beforeEach(() => {
|
||||
mockGetMessages.mockReset();
|
||||
messageCache.clear();
|
||||
});
|
||||
|
||||
it('ignores stale reconnect reconcile responses that finish after newer ones', async () => {
|
||||
const conv = createConversation();
|
||||
mockGetMessages.mockResolvedValueOnce([
|
||||
createMessage({ id: 42, text: 'initial snapshot', acked: 0 }),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useConversationMessages(conv));
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.messages[0].text).toBe('initial snapshot');
|
||||
|
||||
const firstReconcile = createDeferred<Message[]>();
|
||||
const secondReconcile = createDeferred<Message[]>();
|
||||
mockGetMessages
|
||||
.mockReturnValueOnce(firstReconcile.promise)
|
||||
.mockReturnValueOnce(secondReconcile.promise);
|
||||
|
||||
act(() => {
|
||||
result.current.triggerReconcile();
|
||||
result.current.triggerReconcile();
|
||||
});
|
||||
|
||||
secondReconcile.resolve([createMessage({ id: 42, text: 'newer snapshot', acked: 2 })]);
|
||||
await waitFor(() => expect(result.current.messages[0].text).toBe('newer snapshot'));
|
||||
expect(result.current.messages[0].acked).toBe(2);
|
||||
|
||||
firstReconcile.resolve([createMessage({ id: 42, text: 'stale snapshot', acked: 1 })]);
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
|
||||
expect(result.current.messages[0].text).toBe('newer snapshot');
|
||||
expect(result.current.messages[0].acked).toBe(2);
|
||||
});
|
||||
|
||||
it('clears stale hasOlderMessages when cached conversations reconcile to a short latest page', async () => {
|
||||
const conv = createConversation();
|
||||
const cachedMessage = createMessage({ id: 42, text: 'cached snapshot' });
|
||||
|
||||
messageCache.set(conv.id, {
|
||||
messages: [cachedMessage],
|
||||
seenContent: new Set([
|
||||
`PRIV-${cachedMessage.conversation_key}-${cachedMessage.text}-${cachedMessage.sender_timestamp}`,
|
||||
]),
|
||||
hasOlderMessages: true,
|
||||
});
|
||||
|
||||
mockGetMessages.mockResolvedValueOnce([cachedMessage]);
|
||||
|
||||
const { result } = renderHook(() => useConversationMessages(conv));
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.hasOlderMessages).toBe(true);
|
||||
|
||||
await waitFor(() => expect(mockGetMessages).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() => expect(result.current.hasOlderMessages).toBe(false));
|
||||
});
|
||||
});
|
||||
|
||||
describe('useConversationMessages older-page dedup and reentry', () => {
|
||||
beforeEach(() => {
|
||||
mockGetMessages.mockReset();
|
||||
messageCache.clear();
|
||||
});
|
||||
|
||||
it('prevents duplicate overlapping older-page fetches in the same tick', async () => {
|
||||
const conv: Conversation = { type: 'contact', id: 'conv_a', name: 'Contact A' };
|
||||
|
||||
const fullPage = Array.from({ length: 200 }, (_, i) =>
|
||||
createMessage({
|
||||
id: i + 1,
|
||||
conversation_key: 'conv_a',
|
||||
text: `msg-${i + 1}`,
|
||||
sender_timestamp: 1700000000 + i,
|
||||
received_at: 1700000000 + i,
|
||||
})
|
||||
);
|
||||
mockGetMessages.mockResolvedValueOnce(fullPage);
|
||||
|
||||
const olderDeferred = createDeferred<Message[]>();
|
||||
mockGetMessages.mockReturnValueOnce(olderDeferred.promise);
|
||||
|
||||
const { result } = renderHook(() => useConversationMessages(conv));
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.messages).toHaveLength(200);
|
||||
expect(result.current.hasOlderMessages).toBe(true);
|
||||
|
||||
act(() => {
|
||||
void result.current.fetchOlderMessages();
|
||||
void result.current.fetchOlderMessages();
|
||||
});
|
||||
|
||||
expect(mockGetMessages).toHaveBeenCalledTimes(2); // initial page + one older fetch
|
||||
|
||||
olderDeferred.resolve([
|
||||
createMessage({
|
||||
id: 0,
|
||||
conversation_key: 'conv_a',
|
||||
text: 'older-msg',
|
||||
sender_timestamp: 1699999999,
|
||||
received_at: 1699999999,
|
||||
}),
|
||||
]);
|
||||
|
||||
await waitFor(() => expect(result.current.loadingOlder).toBe(false));
|
||||
expect(result.current.messages).toHaveLength(201);
|
||||
expect(result.current.messages.filter((msg) => msg.id === 0)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not append duplicate messages from an overlapping older page', async () => {
|
||||
const conv: Conversation = { type: 'contact', id: 'conv_a', name: 'Contact A' };
|
||||
|
||||
const fullPage = Array.from({ length: 200 }, (_, i) =>
|
||||
createMessage({
|
||||
id: i + 1,
|
||||
conversation_key: 'conv_a',
|
||||
text: `msg-${i + 1}`,
|
||||
sender_timestamp: 1700000000 + i,
|
||||
received_at: 1700000000 + i,
|
||||
})
|
||||
);
|
||||
mockGetMessages.mockResolvedValueOnce(fullPage);
|
||||
mockGetMessages.mockResolvedValueOnce([
|
||||
createMessage({
|
||||
id: 1,
|
||||
conversation_key: 'conv_a',
|
||||
text: 'msg-1',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000000,
|
||||
}),
|
||||
createMessage({
|
||||
id: 0,
|
||||
conversation_key: 'conv_a',
|
||||
text: 'older-msg',
|
||||
sender_timestamp: 1699999999,
|
||||
received_at: 1699999999,
|
||||
}),
|
||||
]);
|
||||
|
||||
const { result } = renderHook(() => useConversationMessages(conv));
|
||||
|
||||
await waitFor(() => expect(result.current.messagesLoading).toBe(false));
|
||||
expect(result.current.messages).toHaveLength(200);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchOlderMessages();
|
||||
});
|
||||
|
||||
expect(result.current.messages.filter((msg) => msg.id === 1)).toHaveLength(1);
|
||||
expect(result.current.messages.filter((msg) => msg.id === 0)).toHaveLength(1);
|
||||
expect(result.current.messages).toHaveLength(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useConversationMessages forward pagination', () => {
|
||||
beforeEach(() => {
|
||||
mockGetMessages.mockReset();
|
||||
|
||||
@@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => ({
|
||||
messageCache: {
|
||||
addMessage: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
rename: vi.fn(),
|
||||
updateAck: vi.fn(),
|
||||
},
|
||||
}));
|
||||
@@ -77,6 +78,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
addMessageIfNew: vi.fn(),
|
||||
trackNewMessage: vi.fn(),
|
||||
incrementUnread: vi.fn(),
|
||||
renameConversationState: vi.fn(),
|
||||
checkMention: vi.fn(() => false),
|
||||
pendingDeleteFallbackRef: { current: false },
|
||||
setActiveConversation: vi.fn(),
|
||||
@@ -193,6 +195,58 @@ describe('useRealtimeAppState', () => {
|
||||
expect(pendingDeleteFallbackRef.current).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves a prefix-only contact into a full key and updates active conversation state', () => {
|
||||
const previousPublicKey = 'abc123def456';
|
||||
const resolvedContact: Contact = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
};
|
||||
const activeConversationRef = {
|
||||
current: {
|
||||
type: 'contact',
|
||||
id: previousPublicKey,
|
||||
name: 'abc123def456',
|
||||
} satisfies Conversation,
|
||||
};
|
||||
const { args, fns } = createRealtimeArgs({
|
||||
activeConversationRef,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRealtimeAppState(args));
|
||||
|
||||
act(() => {
|
||||
result.current.onContactResolved?.(previousPublicKey, resolvedContact);
|
||||
});
|
||||
|
||||
expect(fns.setContacts).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(mocks.messageCache.rename).toHaveBeenCalledWith(
|
||||
previousPublicKey,
|
||||
resolvedContact.public_key
|
||||
);
|
||||
expect(args.renameConversationState).toHaveBeenCalledWith(
|
||||
`contact-${previousPublicKey}`,
|
||||
`contact-${resolvedContact.public_key}`
|
||||
);
|
||||
expect(args.setActiveConversation).toHaveBeenCalledWith({
|
||||
type: 'contact',
|
||||
id: resolvedContact.public_key,
|
||||
name: '[unknown sender]',
|
||||
});
|
||||
});
|
||||
|
||||
it('appends raw packets using observation identity dedup', () => {
|
||||
const { args, fns } = createRealtimeArgs();
|
||||
const packet: RawPacket = {
|
||||
|
||||
@@ -12,6 +12,7 @@ vi.mock('../api', () => ({
|
||||
api: {
|
||||
repeaterLogin: vi.fn(),
|
||||
repeaterStatus: vi.fn(),
|
||||
repeaterNodeInfo: vi.fn(),
|
||||
repeaterNeighbors: vi.fn(),
|
||||
repeaterAcl: vi.fn(),
|
||||
repeaterRadioSettings: vi.fn(),
|
||||
@@ -284,8 +285,12 @@ describe('useRepeaterDashboard', () => {
|
||||
|
||||
it('loadAll calls refreshPane for all panes serially', async () => {
|
||||
mockApi.repeaterStatus.mockResolvedValueOnce({ battery_volts: 4.0 });
|
||||
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
|
||||
mockApi.repeaterAcl.mockResolvedValueOnce({ acl: [] });
|
||||
mockApi.repeaterNodeInfo.mockResolvedValueOnce({
|
||||
name: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
clock_utc: null,
|
||||
});
|
||||
mockApi.repeaterRadioSettings.mockResolvedValueOnce({
|
||||
firmware_version: 'v1.0',
|
||||
radio: null,
|
||||
@@ -293,11 +298,9 @@ describe('useRepeaterDashboard', () => {
|
||||
airtime_factor: null,
|
||||
repeat_enabled: null,
|
||||
flood_max: null,
|
||||
name: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
clock_utc: null,
|
||||
});
|
||||
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
|
||||
mockApi.repeaterAcl.mockResolvedValueOnce({ acl: [] });
|
||||
mockApi.repeaterAdvertIntervals.mockResolvedValueOnce({
|
||||
advert_interval: null,
|
||||
flood_advert_interval: null,
|
||||
@@ -315,6 +318,7 @@ describe('useRepeaterDashboard', () => {
|
||||
});
|
||||
|
||||
expect(mockApi.repeaterStatus).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi.repeaterNodeInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi.repeaterAcl).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi.repeaterRadioSettings).toHaveBeenCalledTimes(1);
|
||||
@@ -323,6 +327,53 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('refreshing neighbors fetches node info first', async () => {
|
||||
mockApi.repeaterNodeInfo.mockResolvedValueOnce({
|
||||
name: 'Repeater',
|
||||
lat: '-31.9523',
|
||||
lon: '115.8613',
|
||||
clock_utc: null,
|
||||
});
|
||||
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
|
||||
|
||||
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshPane('neighbors');
|
||||
});
|
||||
|
||||
expect(mockApi.repeaterNodeInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi.repeaterNodeInfo.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockApi.repeaterNeighbors.mock.invocationCallOrder[0]
|
||||
);
|
||||
expect(result.current.paneData.nodeInfo?.lat).toBe('-31.9523');
|
||||
expect(result.current.paneData.neighbors).toEqual({ neighbors: [] });
|
||||
});
|
||||
|
||||
it('refreshing neighbors reuses already-fetched node info', async () => {
|
||||
mockApi.repeaterNodeInfo.mockResolvedValueOnce({
|
||||
name: 'Repeater',
|
||||
lat: '-31.9523',
|
||||
lon: '115.8613',
|
||||
clock_utc: null,
|
||||
});
|
||||
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
|
||||
mockApi.repeaterNeighbors.mockResolvedValueOnce({ neighbors: [] });
|
||||
|
||||
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.refreshPane('neighbors');
|
||||
});
|
||||
await act(async () => {
|
||||
await result.current.refreshPane('neighbors');
|
||||
});
|
||||
|
||||
expect(mockApi.repeaterNodeInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi.repeaterNeighbors).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('restores dashboard state when navigating away and back to the same repeater', async () => {
|
||||
const statusData = { battery_volts: 4.2 };
|
||||
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('useUnreadCounts', () => {
|
||||
counts: {},
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
last_read_ats: {},
|
||||
});
|
||||
mocks.markChannelRead.mockResolvedValue({ status: 'ok', key: '' });
|
||||
mocks.markContactRead.mockResolvedValue({ status: 'ok', public_key: '' });
|
||||
@@ -110,6 +111,7 @@ describe('useUnreadCounts', () => {
|
||||
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
|
||||
mentions: { [`channel-${CHANNEL_KEY}`]: true },
|
||||
last_message_times: {},
|
||||
last_read_ats: { [`channel-${CHANNEL_KEY}`]: 1234 },
|
||||
});
|
||||
|
||||
const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Test' };
|
||||
@@ -123,6 +125,7 @@ describe('useUnreadCounts', () => {
|
||||
// The active conversation should NOT have unreads
|
||||
expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined();
|
||||
expect(result.current.mentions[`channel-${CHANNEL_KEY}`]).toBeUndefined();
|
||||
expect(result.current.unreadLastReadAts[`channel-${CHANNEL_KEY}`]).toBe(1234);
|
||||
});
|
||||
|
||||
it('filters out active contact conversation from server unreads', async () => {
|
||||
@@ -133,6 +136,7 @@ describe('useUnreadCounts', () => {
|
||||
counts: { [`contact-${CONTACT_KEY}`]: 3 },
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
last_read_ats: { [`contact-${CONTACT_KEY}`]: 2345 },
|
||||
});
|
||||
|
||||
const activeConv: Conversation = { type: 'contact', id: CONTACT_KEY, name: 'Test' };
|
||||
@@ -143,6 +147,7 @@ describe('useUnreadCounts', () => {
|
||||
});
|
||||
|
||||
expect(result.current.unreadCounts[`contact-${CONTACT_KEY}`]).toBeUndefined();
|
||||
expect(result.current.unreadLastReadAts[`contact-${CONTACT_KEY}`]).toBe(2345);
|
||||
});
|
||||
|
||||
it('preserves unreads for non-active conversations', async () => {
|
||||
@@ -157,6 +162,7 @@ describe('useUnreadCounts', () => {
|
||||
},
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
last_read_ats: {},
|
||||
});
|
||||
|
||||
const activeConv: Conversation = { type: 'channel', id: CHANNEL_KEY, name: 'Active' };
|
||||
@@ -205,6 +211,7 @@ describe('useUnreadCounts', () => {
|
||||
counts: {},
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
last_read_ats: {},
|
||||
});
|
||||
|
||||
const { result } = renderWith({ channels, activeConversation: activeConv });
|
||||
@@ -218,6 +225,7 @@ describe('useUnreadCounts', () => {
|
||||
counts: { [`channel-${CHANNEL_KEY}`]: 7 },
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
last_read_ats: { [`channel-${CHANNEL_KEY}`]: 3456 },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -226,6 +234,7 @@ describe('useUnreadCounts', () => {
|
||||
|
||||
// Should still be filtered out
|
||||
expect(result.current.unreadCounts[`channel-${CHANNEL_KEY}`]).toBeUndefined();
|
||||
expect(result.current.unreadLastReadAts[`channel-${CHANNEL_KEY}`]).toBe(3456);
|
||||
});
|
||||
|
||||
it('re-fetches when channels change while contacts remain empty', async () => {
|
||||
@@ -243,6 +252,7 @@ describe('useUnreadCounts', () => {
|
||||
counts: { [`channel-${addedChannelKey}`]: 2 },
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
last_read_ats: {},
|
||||
});
|
||||
|
||||
rerender({
|
||||
@@ -271,6 +281,7 @@ describe('useUnreadCounts', () => {
|
||||
counts: { [`contact-${addedContactKey}`]: 1 },
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
last_read_ats: {},
|
||||
});
|
||||
|
||||
rerender({
|
||||
@@ -290,6 +301,7 @@ describe('useUnreadCounts', () => {
|
||||
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
last_read_ats: {},
|
||||
});
|
||||
|
||||
const { result } = renderWith({});
|
||||
@@ -307,6 +319,7 @@ describe('useUnreadCounts', () => {
|
||||
counts: { [`channel-${CHANNEL_KEY}`]: 5 },
|
||||
mentions: {},
|
||||
last_message_times: {},
|
||||
last_read_ats: {},
|
||||
});
|
||||
|
||||
const activeConv: Conversation = { type: 'raw', id: 'raw', name: 'Raw Packet Feed' };
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import type { Contact, ContactAdvertPathSummary, RadioConfig, RawPacket } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { buildLinkKey } from '../utils/visualizerUtils';
|
||||
|
||||
const { packetFixtures } = vi.hoisted(() => ({
|
||||
packetFixtures: new Map<string, unknown>(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/visualizerUtils', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/visualizerUtils')>(
|
||||
'../utils/visualizerUtils'
|
||||
);
|
||||
|
||||
return {
|
||||
...actual,
|
||||
parsePacket: vi.fn((hexData: string) => packetFixtures.get(hexData) ?? null),
|
||||
};
|
||||
});
|
||||
|
||||
import { useVisualizerData3D } from '../components/visualizer/useVisualizerData3D';
|
||||
|
||||
function createConfig(publicKey: string): RadioConfig {
|
||||
return {
|
||||
public_key: publicKey,
|
||||
name: 'Me',
|
||||
lat: 0,
|
||||
lon: 0,
|
||||
tx_power: 0,
|
||||
max_tx_power: 0,
|
||||
radio: { freq: 0, bw: 0, sf: 0, cr: 0 },
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: true,
|
||||
advert_location_source: 'off',
|
||||
};
|
||||
}
|
||||
|
||||
function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
return {
|
||||
public_key: publicKey,
|
||||
name,
|
||||
type,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
route_override_path: null,
|
||||
route_override_len: null,
|
||||
route_override_hash_mode: null,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
}
|
||||
|
||||
function createPacket(
|
||||
data: string,
|
||||
{ id = 1, observationId = id }: { id?: number; observationId?: number } = {}
|
||||
): RawPacket {
|
||||
return {
|
||||
id,
|
||||
observation_id: observationId,
|
||||
timestamp: 1_700_000_000,
|
||||
data,
|
||||
payload_type: 'TEXT',
|
||||
snr: null,
|
||||
rssi: null,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderVisualizerData({
|
||||
packets,
|
||||
contacts,
|
||||
config,
|
||||
showAmbiguousPaths = false,
|
||||
showAmbiguousNodes = false,
|
||||
collapseLikelyKnownSiblingRepeaters = true,
|
||||
repeaterAdvertPaths = [],
|
||||
useAdvertPathHints = false,
|
||||
}: {
|
||||
packets: RawPacket[];
|
||||
contacts: Contact[];
|
||||
config: RadioConfig;
|
||||
showAmbiguousPaths?: boolean;
|
||||
showAmbiguousNodes?: boolean;
|
||||
collapseLikelyKnownSiblingRepeaters?: boolean;
|
||||
repeaterAdvertPaths?: ContactAdvertPathSummary[];
|
||||
useAdvertPathHints?: boolean;
|
||||
}) {
|
||||
return renderHook(() =>
|
||||
useVisualizerData3D({
|
||||
packets,
|
||||
contacts,
|
||||
config,
|
||||
repeaterAdvertPaths,
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic: false,
|
||||
chargeStrength: -200,
|
||||
letEmDrift: false,
|
||||
particleSpeedMultiplier: 1,
|
||||
observationWindowSec: 15,
|
||||
pruneStaleNodes: false,
|
||||
pruneStaleMinutes: 5,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
packetFixtures.clear();
|
||||
});
|
||||
|
||||
describe('useVisualizerData3D', () => {
|
||||
it('hard-pins the self node at the origin', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
|
||||
const { result } = renderVisualizerData({
|
||||
packets: [],
|
||||
contacts: [],
|
||||
config: createConfig(selfKey),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.nodes.get('self')).toBeDefined());
|
||||
|
||||
const selfNode = result.current.nodes.get('self');
|
||||
expect(selfNode).toMatchObject({
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
fx: 0,
|
||||
fy: 0,
|
||||
fz: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
vz: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps canonical adjacency stable when ambiguous repeaters are shown or hidden', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
packetFixtures.set('dm-canonical-stable', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-canonical-stable',
|
||||
pathBytes: ['32'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const packets = [createPacket('dm-canonical-stable')];
|
||||
const contacts = [createContact(aliceKey, 'Alice')];
|
||||
|
||||
const hidden = renderVisualizerData({
|
||||
packets,
|
||||
contacts,
|
||||
config: createConfig(selfKey),
|
||||
showAmbiguousPaths: false,
|
||||
});
|
||||
const shown = renderVisualizerData({
|
||||
packets,
|
||||
contacts,
|
||||
config: createConfig(selfKey),
|
||||
showAmbiguousPaths: true,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(hidden.result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32'])
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(shown.result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32'])
|
||||
);
|
||||
|
||||
expect(hidden.result.current.canonicalNeighborIds).toEqual(
|
||||
shown.result.current.canonicalNeighborIds
|
||||
);
|
||||
expect(hidden.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', 'self'))).toBe(true);
|
||||
expect(hidden.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '?32'))).toBe(false);
|
||||
expect(shown.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '?32'))).toBe(true);
|
||||
});
|
||||
|
||||
it('marks compressed hidden-repeater routes as dashed links instead of direct solid links', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
|
||||
packetFixtures.set('dm-hidden-hop', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-hidden-hop',
|
||||
pathBytes: ['32'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const { result } = renderVisualizerData({
|
||||
packets: [createPacket('dm-hidden-hop')],
|
||||
contacts: [createContact(aliceKey, 'Alice')],
|
||||
config: createConfig(selfKey),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.links.size).toBe(1));
|
||||
|
||||
const link = result.current.links.get(buildLinkKey('aaaaaaaaaaaa', 'self'));
|
||||
expect(link).toBeDefined();
|
||||
expect(link?.hasHiddenIntermediate).toBe(true);
|
||||
expect(link?.hasDirectObservation).toBe(false);
|
||||
expect(result.current.canonicalNeighborIds.get('aaaaaaaaaaaa')).toEqual(['?32']);
|
||||
expect(result.current.canonicalNeighborIds.get('self')).toEqual(['?32']);
|
||||
expect(result.current.renderedNodeIds.has('?32')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not place a resolved outgoing DM destination into topology', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const bobKey = 'bbbbbbbbbbbb0000000000000000000000000000000000000000000000000000';
|
||||
const repeaterKey = '3232323232320000000000000000000000000000000000000000000000000000';
|
||||
|
||||
packetFixtures.set('dm-outgoing-known-dst', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-outgoing-known-dst',
|
||||
pathBytes: ['323232323232'],
|
||||
srcHash: 'ffffffffffff',
|
||||
dstHash: 'bbbbbbbbbbbb',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const { result } = renderVisualizerData({
|
||||
packets: [createPacket('dm-outgoing-known-dst')],
|
||||
contacts: [
|
||||
createContact(bobKey, 'Bob'),
|
||||
createContact(repeaterKey, 'Relay', CONTACT_TYPE_REPEATER),
|
||||
],
|
||||
config: createConfig(selfKey),
|
||||
showAmbiguousPaths: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.links.size).toBe(1));
|
||||
|
||||
expect(result.current.links.has(buildLinkKey('self', '323232323232'))).toBe(true);
|
||||
expect(result.current.links.has(buildLinkKey('323232323232', 'bbbbbbbbbbbb'))).toBe(false);
|
||||
expect(result.current.links.has(buildLinkKey('self', 'bbbbbbbbbbbb'))).toBe(false);
|
||||
expect(result.current.renderedNodeIds.has('bbbbbbbbbbbb')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not place a third-party DM destination into topology', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
const bobKey = 'bbbbbbbbbbbb0000000000000000000000000000000000000000000000000000';
|
||||
const repeaterKey = '3232323232320000000000000000000000000000000000000000000000000000';
|
||||
|
||||
packetFixtures.set('dm-third-party-known-dst', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-third-party-known-dst',
|
||||
pathBytes: ['323232323232'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'bbbbbbbbbbbb',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const { result } = renderVisualizerData({
|
||||
packets: [createPacket('dm-third-party-known-dst')],
|
||||
contacts: [
|
||||
createContact(aliceKey, 'Alice'),
|
||||
createContact(bobKey, 'Bob'),
|
||||
createContact(repeaterKey, 'Relay', CONTACT_TYPE_REPEATER),
|
||||
],
|
||||
config: createConfig(selfKey),
|
||||
showAmbiguousPaths: true,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.links.size).toBe(2));
|
||||
|
||||
expect(result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '323232323232'))).toBe(true);
|
||||
expect(result.current.links.has(buildLinkKey('323232323232', 'self'))).toBe(true);
|
||||
expect(result.current.links.has(buildLinkKey('323232323232', 'bbbbbbbbbbbb'))).toBe(false);
|
||||
expect(result.current.renderedNodeIds.has('bbbbbbbbbbbb')).toBe(false);
|
||||
});
|
||||
|
||||
it('collapses a high-confidence ambiguous repeater into its known sibling when both share the same next hop', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
const carolKey = 'cccccccccccc0000000000000000000000000000000000000000000000000000';
|
||||
const knownRelayKey = '3232323232320000000000000000000000000000000000000000000000000000';
|
||||
const otherRelayKey = '32ababababab0000000000000000000000000000000000000000000000000000';
|
||||
const nextRelayKey = '5656565656560000000000000000000000000000000000000000000000000000';
|
||||
|
||||
packetFixtures.set('dm-ambiguous-sibling', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-ambiguous-sibling',
|
||||
pathBytes: ['32', '565656565656'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
packetFixtures.set('dm-known-sibling', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-known-sibling',
|
||||
pathBytes: ['323232323232', '565656565656'],
|
||||
srcHash: 'cccccccccccc',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const sharedArgs = {
|
||||
packets: [
|
||||
createPacket('dm-ambiguous-sibling', { id: 1, observationId: 1 }),
|
||||
createPacket('dm-known-sibling', { id: 2, observationId: 2 }),
|
||||
],
|
||||
contacts: [
|
||||
createContact(aliceKey, 'Alice'),
|
||||
createContact(carolKey, 'Carol'),
|
||||
createContact(knownRelayKey, 'Relay A', CONTACT_TYPE_REPEATER),
|
||||
createContact(otherRelayKey, 'Relay B', CONTACT_TYPE_REPEATER),
|
||||
createContact(nextRelayKey, 'Relay Next', CONTACT_TYPE_REPEATER),
|
||||
],
|
||||
config: createConfig(selfKey),
|
||||
showAmbiguousPaths: true,
|
||||
useAdvertPathHints: true,
|
||||
repeaterAdvertPaths: [
|
||||
{
|
||||
public_key: knownRelayKey,
|
||||
paths: [
|
||||
{
|
||||
path: '',
|
||||
path_len: 1,
|
||||
next_hop: '565656565656',
|
||||
first_seen: 1,
|
||||
last_seen: 2,
|
||||
heard_count: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const collapsed = renderVisualizerData({
|
||||
...sharedArgs,
|
||||
collapseLikelyKnownSiblingRepeaters: true,
|
||||
});
|
||||
const separated = renderVisualizerData({
|
||||
...sharedArgs,
|
||||
collapseLikelyKnownSiblingRepeaters: false,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(collapsed.result.current.renderedNodeIds.has('323232323232')).toBe(true)
|
||||
);
|
||||
await waitFor(() => expect(separated.result.current.renderedNodeIds.has('?32')).toBe(true));
|
||||
|
||||
expect(collapsed.result.current.renderedNodeIds.has('?32')).toBe(false);
|
||||
expect(collapsed.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '323232323232'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(collapsed.result.current.links.has(buildLinkKey('323232323232', '565656565656'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(separated.result.current.renderedNodeIds.has('?32')).toBe(true);
|
||||
expect(separated.result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '?32'))).toBe(true);
|
||||
});
|
||||
|
||||
it('picks back up with known repeaters after hiding ambiguous repeater segments', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
const aliceKey = 'aaaaaaaaaaaa0000000000000000000000000000000000000000000000000000';
|
||||
const repeaterKey = '5656565656560000000000000000000000000000000000000000000000000000';
|
||||
|
||||
packetFixtures.set('dm-hidden-then-known', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-hidden-then-known',
|
||||
pathBytes: ['32', '565656565656'],
|
||||
srcHash: 'aaaaaaaaaaaa',
|
||||
dstHash: 'ffffffffffff',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const { result } = renderVisualizerData({
|
||||
packets: [createPacket('dm-hidden-then-known')],
|
||||
contacts: [
|
||||
createContact(aliceKey, 'Alice'),
|
||||
createContact(repeaterKey, 'Relay B', CONTACT_TYPE_REPEATER),
|
||||
],
|
||||
config: createConfig(selfKey),
|
||||
showAmbiguousPaths: false,
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.links.size).toBe(2));
|
||||
|
||||
expect(result.current.links.has(buildLinkKey('aaaaaaaaaaaa', '565656565656'))).toBe(true);
|
||||
expect(result.current.links.has(buildLinkKey('565656565656', 'self'))).toBe(true);
|
||||
expect(result.current.links.has(buildLinkKey('aaaaaaaaaaaa', 'self'))).toBe(false);
|
||||
expect(result.current.renderedNodeIds.has('565656565656')).toBe(true);
|
||||
expect(result.current.renderedNodeIds.has('?32')).toBe(false);
|
||||
expect(result.current.canonicalNeighborIds.get('?32')).toEqual(
|
||||
expect.arrayContaining(['aaaaaaaaaaaa', '565656565656'])
|
||||
);
|
||||
});
|
||||
|
||||
it('does not create a fake self edge for an unresolved outgoing direct DM', async () => {
|
||||
const selfKey = 'ffffffffffff0000000000000000000000000000000000000000000000000000';
|
||||
|
||||
packetFixtures.set('dm-outgoing-unknown-dst', {
|
||||
payloadType: PayloadType.TextMessage,
|
||||
messageHash: 'dm-outgoing-unknown-dst',
|
||||
pathBytes: [],
|
||||
srcHash: 'ffffffffffff',
|
||||
dstHash: 'cccccccccccc',
|
||||
advertPubkey: null,
|
||||
groupTextSender: null,
|
||||
anonRequestPubkey: null,
|
||||
});
|
||||
|
||||
const { result } = renderVisualizerData({
|
||||
packets: [createPacket('dm-outgoing-unknown-dst')],
|
||||
contacts: [],
|
||||
config: createConfig(selfKey),
|
||||
});
|
||||
|
||||
await waitFor(() => expect(result.current.stats.processed).toBe(1));
|
||||
|
||||
expect(result.current.links.size).toBe(0);
|
||||
expect(Array.from(result.current.nodes.keys())).toEqual(['self']);
|
||||
});
|
||||
});
|
||||
@@ -103,6 +103,39 @@ describe('useWebSocket dispatch', () => {
|
||||
expect(onContact.mock.calls[0][0]).toHaveProperty('name');
|
||||
});
|
||||
|
||||
it('routes contact_resolved event to onContactResolved', () => {
|
||||
const onContactResolved = vi.fn();
|
||||
renderHook(() => useWebSocket({ onContactResolved }));
|
||||
|
||||
const contact = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_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,
|
||||
};
|
||||
fireMessage({
|
||||
type: 'contact_resolved',
|
||||
data: {
|
||||
previous_public_key: 'abc123def456',
|
||||
contact,
|
||||
},
|
||||
});
|
||||
|
||||
expect(onContactResolved).toHaveBeenCalledOnce();
|
||||
expect(onContactResolved).toHaveBeenCalledWith('abc123def456', contact);
|
||||
});
|
||||
|
||||
it('routes message_acked to onMessageAcked with (messageId, ackCount, paths)', () => {
|
||||
const onMessageAcked = vi.fn();
|
||||
renderHook(() => useWebSocket({ onMessageAcked }));
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getSceneNodeLabel } from '../components/visualizer/shared';
|
||||
|
||||
describe('visualizer shared label helpers', () => {
|
||||
it('adds an ambiguity suffix to in-graph labels for ambiguous nodes', () => {
|
||||
expect(
|
||||
getSceneNodeLabel({
|
||||
id: '?32',
|
||||
name: 'Likely Relay',
|
||||
type: 'repeater',
|
||||
isAmbiguous: true,
|
||||
})
|
||||
).toBe('Likely Relay (?)');
|
||||
});
|
||||
|
||||
it('does not add an ambiguity suffix to unambiguous nodes', () => {
|
||||
expect(
|
||||
getSceneNodeLabel({
|
||||
id: 'aaaaaaaaaaaa',
|
||||
name: 'Alice',
|
||||
type: 'client',
|
||||
isAmbiguous: false,
|
||||
})
|
||||
).toBe('Alice');
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,17 @@ import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { VisualizerTooltip } from '../components/visualizer/VisualizerTooltip';
|
||||
import type { GraphNode } from '../components/visualizer/shared';
|
||||
import type { PacketNetworkNode } from '../networkGraph/packetNetworkGraph';
|
||||
|
||||
function createNode(overrides: Partial<GraphNode> & Pick<GraphNode, 'id' | 'type'>): GraphNode {
|
||||
function createNode(
|
||||
overrides: Partial<PacketNetworkNode> & Pick<PacketNetworkNode, 'id' | 'type'>
|
||||
): PacketNetworkNode {
|
||||
return {
|
||||
id: overrides.id,
|
||||
type: overrides.type,
|
||||
name: overrides.name ?? null,
|
||||
isAmbiguous: overrides.isAmbiguous ?? false,
|
||||
lastActivity: overrides.lastActivity ?? Date.now(),
|
||||
x: overrides.x ?? 0,
|
||||
y: overrides.y ?? 0,
|
||||
z: overrides.z ?? 0,
|
||||
probableIdentity: overrides.probableIdentity,
|
||||
ambiguousNames: overrides.ambiguousNames,
|
||||
lastActivityReason: overrides.lastActivityReason,
|
||||
@@ -23,7 +22,12 @@ function createNode(overrides: Partial<GraphNode> & Pick<GraphNode, 'id' | 'type
|
||||
describe('VisualizerTooltip', () => {
|
||||
it('renders nothing without an active node', () => {
|
||||
const { container } = render(
|
||||
<VisualizerTooltip activeNodeId={null} nodes={new Map()} neighborIds={[]} />
|
||||
<VisualizerTooltip
|
||||
activeNodeId={null}
|
||||
canonicalNodes={new Map()}
|
||||
canonicalNeighborIds={new Map()}
|
||||
renderedNodeIds={new Set()}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
@@ -49,17 +53,25 @@ describe('VisualizerTooltip', () => {
|
||||
name: 'Neighbor Node',
|
||||
ambiguousNames: ['Alt Neighbor'],
|
||||
});
|
||||
const hiddenRepeater = createNode({
|
||||
id: '?44',
|
||||
type: 'repeater',
|
||||
name: '44',
|
||||
isAmbiguous: true,
|
||||
});
|
||||
|
||||
render(
|
||||
<VisualizerTooltip
|
||||
activeNodeId={node.id}
|
||||
nodes={
|
||||
canonicalNodes={
|
||||
new Map([
|
||||
[node.id, node],
|
||||
[neighbor.id, neighbor],
|
||||
[hiddenRepeater.id, hiddenRepeater],
|
||||
])
|
||||
}
|
||||
neighborIds={[neighbor.id]}
|
||||
canonicalNeighborIds={new Map([[node.id, [neighbor.id, hiddenRepeater.id]]])}
|
||||
renderedNodeIds={new Set([node.id, neighbor.id])}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -72,6 +84,8 @@ describe('VisualizerTooltip', () => {
|
||||
expect(screen.getByText('Reason: Relayed GT')).toBeInTheDocument();
|
||||
expect(screen.getByText('Neighbor Node')).toBeInTheDocument();
|
||||
expect(screen.getByText('(Alt Neighbor)')).toBeInTheDocument();
|
||||
expect(screen.getByText('44')).toBeInTheDocument();
|
||||
expect(screen.getByText('(hidden)')).toBeInTheDocument();
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
@@ -14,6 +14,58 @@ describe('wsEvents', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('parses contact_resolved events', () => {
|
||||
const event = parseWsEvent(
|
||||
JSON.stringify({
|
||||
type: 'contact_resolved',
|
||||
data: {
|
||||
previous_public_key: 'abc123def456',
|
||||
contact: {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_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,
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(event).toEqual({
|
||||
type: 'contact_resolved',
|
||||
data: {
|
||||
previous_public_key: 'abc123def456',
|
||||
contact: {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('parses channel_deleted events', () => {
|
||||
const event = parseWsEvent(JSON.stringify({ type: 'channel_deleted', data: { key: 'bb' } }));
|
||||
|
||||
|
||||
+92
-6
@@ -79,7 +79,7 @@
|
||||
--msg-incoming: 0 0% 92%;
|
||||
--status-connected: 142 75% 32%;
|
||||
--status-disconnected: 0 0% 35%;
|
||||
--warning: 45 100% 45%;
|
||||
--warning: 45 100% 38%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--success: 142 75% 28%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
@@ -130,6 +130,12 @@
|
||||
inset -1px -1px 0 hsl(0 0% 34%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] .avatar-action-button {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] button:active,
|
||||
[data-theme='windows-95'] button:active {
|
||||
box-shadow:
|
||||
@@ -153,6 +159,86 @@
|
||||
inset -1px -1px 0 hsl(0 0% 34%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] .conversation-header {
|
||||
background: hsl(240 100% 25%);
|
||||
color: hsl(0 0% 100%);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 hsl(0 0% 100% / 0.35),
|
||||
inset -1px -1px 0 hsl(240 100% 14%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] .conversation-header .text-muted-foreground,
|
||||
[data-theme='windows-95'] .conversation-header .text-foreground,
|
||||
[data-theme='windows-95'] .conversation-header .text-primary,
|
||||
[data-theme='windows-95'] .conversation-header button,
|
||||
[data-theme='windows-95'] .conversation-header [role='button'] {
|
||||
color: hsl(0 0% 100%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] .conversation-header button {
|
||||
background: hsl(240 100% 25%);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] .conversation-header .lucide-info {
|
||||
color: hsl(0 0% 100%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] .message-input-shell {
|
||||
background: hsl(0 0% 75%);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 hsl(0 0% 100%),
|
||||
inset -1px -1px 0 hsl(0 0% 34%);
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
[data-theme='windows-95'] * {
|
||||
scrollbar-width: auto;
|
||||
scrollbar-color: hsl(0 0% 75%) hsl(0 0% 84%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] ::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] ::-webkit-scrollbar-track,
|
||||
[data-theme='windows-95'] ::-webkit-scrollbar-corner {
|
||||
background: hsl(0 0% 84%);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 hsl(0 0% 100%),
|
||||
inset -1px -1px 0 hsl(0 0% 34%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] ::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 75%);
|
||||
border-radius: 0;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 hsl(0 0% 100%),
|
||||
inset -1px -1px 0 hsl(0 0% 34%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 80%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] ::-webkit-scrollbar-thumb:active {
|
||||
box-shadow:
|
||||
inset -1px -1px 0 hsl(0 0% 100%),
|
||||
inset 1px 1px 0 hsl(0 0% 34%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] ::-webkit-scrollbar-button:single-button {
|
||||
display: block;
|
||||
background: hsl(0 0% 75%);
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 hsl(0 0% 100%),
|
||||
inset -1px -1px 0 hsl(0 0% 34%);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── iPhone / iOS ─────────────────────────────────────────── */
|
||||
:root[data-theme='ios'] {
|
||||
--background: 240 18% 96%;
|
||||
@@ -255,7 +341,7 @@
|
||||
--accent: 150 14% 12%;
|
||||
--accent-foreground: 120 8% 82%;
|
||||
--destructive: 340 100% 59%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--destructive-foreground: 340 100% 6%;
|
||||
--border: 135 30% 14%;
|
||||
--input: 135 30% 14%;
|
||||
--ring: 135 100% 50%;
|
||||
@@ -294,7 +380,7 @@
|
||||
--popover: 0 0% 8%;
|
||||
--popover-foreground: 0 0% 100%;
|
||||
--primary: 212 100% 62%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--primary-foreground: 0 0% 0%;
|
||||
--secondary: 0 0% 12%;
|
||||
--secondary-foreground: 0 0% 92%;
|
||||
--muted: 0 0% 10%;
|
||||
@@ -302,7 +388,7 @@
|
||||
--accent: 0 0% 14%;
|
||||
--accent-foreground: 0 0% 100%;
|
||||
--destructive: 355 100% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--destructive-foreground: 0 0% 0%;
|
||||
--border: 0 0% 22%;
|
||||
--input: 0 0% 22%;
|
||||
--ring: 212 100% 62%;
|
||||
@@ -585,7 +671,7 @@
|
||||
--accent: 205 46% 22%;
|
||||
--accent-foreground: 42 33% 92%;
|
||||
--destructive: 8 88% 61%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--destructive-foreground: 196 60% 9%;
|
||||
--border: 191 34% 24%;
|
||||
--input: 191 34% 24%;
|
||||
--ring: 175 72% 49%;
|
||||
@@ -686,7 +772,7 @@
|
||||
--accent: 251 28% 24%;
|
||||
--accent-foreground: 302 30% 93%;
|
||||
--destructive: 9 88% 66%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--destructive-foreground: 258 38% 12%;
|
||||
--border: 256 24% 28%;
|
||||
--input: 256 24% 28%;
|
||||
--ring: 325 100% 74%;
|
||||
|
||||
+11
-4
@@ -15,6 +15,7 @@ export interface RadioConfig {
|
||||
radio: RadioSettings;
|
||||
path_hash_mode: number;
|
||||
path_hash_mode_supported: boolean;
|
||||
advert_location_source?: 'off' | 'current';
|
||||
}
|
||||
|
||||
export interface RadioConfigUpdate {
|
||||
@@ -24,6 +25,7 @@ export interface RadioConfigUpdate {
|
||||
tx_power?: number;
|
||||
radio?: RadioSettings;
|
||||
path_hash_mode?: number;
|
||||
advert_location_source?: 'off' | 'current';
|
||||
}
|
||||
|
||||
export interface FanoutStatusEntry {
|
||||
@@ -354,6 +356,13 @@ export interface RepeaterAclResponse {
|
||||
acl: AclEntry[];
|
||||
}
|
||||
|
||||
export interface RepeaterNodeInfoResponse {
|
||||
name: string | null;
|
||||
lat: string | null;
|
||||
lon: string | null;
|
||||
clock_utc: string | null;
|
||||
}
|
||||
|
||||
export interface RepeaterRadioSettingsResponse {
|
||||
firmware_version: string | null;
|
||||
radio: string | null;
|
||||
@@ -361,10 +370,6 @@ export interface RepeaterRadioSettingsResponse {
|
||||
airtime_factor: string | null;
|
||||
repeat_enabled: string | null;
|
||||
flood_max: string | null;
|
||||
name: string | null;
|
||||
lat: string | null;
|
||||
lon: string | null;
|
||||
clock_utc: string | null;
|
||||
}
|
||||
|
||||
export interface RepeaterAdvertIntervalsResponse {
|
||||
@@ -389,6 +394,7 @@ export interface RepeaterLppTelemetryResponse {
|
||||
|
||||
export type PaneName =
|
||||
| 'status'
|
||||
| 'nodeInfo'
|
||||
| 'neighbors'
|
||||
| 'acl'
|
||||
| 'radioSettings'
|
||||
@@ -413,6 +419,7 @@ export interface UnreadCounts {
|
||||
counts: Record<string, number>;
|
||||
mentions: Record<string, boolean>;
|
||||
last_message_times: Record<string, number>;
|
||||
last_read_ats: Record<string, number | null>;
|
||||
}
|
||||
|
||||
interface BusyChannel {
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface UseWebSocketOptions {
|
||||
onHealth?: (health: HealthStatus) => void;
|
||||
onMessage?: (message: Message) => void;
|
||||
onContact?: (contact: Contact) => void;
|
||||
onContactResolved?: (previousPublicKey: string, contact: Contact) => void;
|
||||
onContactDeleted?: (publicKey: string) => void;
|
||||
onChannel?: (channel: Channel) => void;
|
||||
onChannelDeleted?: (key: string) => void;
|
||||
@@ -102,6 +103,14 @@ export function useWebSocket(options: UseWebSocketOptions) {
|
||||
case 'contact':
|
||||
handlers.onContact?.(msg.data as Contact);
|
||||
break;
|
||||
case 'contact_resolved': {
|
||||
const resolved = msg.data as {
|
||||
previous_public_key: string;
|
||||
contact: Contact;
|
||||
};
|
||||
handlers.onContactResolved?.(resolved.previous_public_key, resolved.contact);
|
||||
break;
|
||||
}
|
||||
case 'channel':
|
||||
handlers.onChannel?.(msg.data as Channel);
|
||||
break;
|
||||
|
||||
@@ -41,6 +41,22 @@ export function setLastMessageTime(key: string, timestamp: number): Conversation
|
||||
return { ...lastMessageTimesCache };
|
||||
}
|
||||
|
||||
/**
|
||||
* Move conversation timing state to a new key, preserving the most recent timestamp.
|
||||
*/
|
||||
export function renameConversationTimeKey(oldKey: string, newKey: string): ConversationTimes {
|
||||
if (oldKey === newKey) return { ...lastMessageTimesCache };
|
||||
|
||||
const oldTimestamp = lastMessageTimesCache[oldKey];
|
||||
const newTimestamp = lastMessageTimesCache[newKey];
|
||||
if (oldTimestamp !== undefined) {
|
||||
lastMessageTimesCache[newKey] =
|
||||
newTimestamp === undefined ? oldTimestamp : Math.max(newTimestamp, oldTimestamp);
|
||||
delete lastMessageTimesCache[oldKey];
|
||||
}
|
||||
return { ...lastMessageTimesCache };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a state tracking key for message times.
|
||||
*
|
||||
|
||||
@@ -21,6 +21,20 @@ function getPubkeyPrefix(key: string): string {
|
||||
/**
|
||||
* Get a display name for a contact, falling back to pubkey prefix.
|
||||
*/
|
||||
export function getContactDisplayName(name: string | null | undefined, pubkey: string): string {
|
||||
return name || getPubkeyPrefix(pubkey);
|
||||
export function getContactDisplayName(
|
||||
name: string | null | undefined,
|
||||
pubkey: string,
|
||||
lastAdvert?: number | null
|
||||
): string {
|
||||
if (name) return name;
|
||||
if (isUnknownFullKeyContact(pubkey, lastAdvert)) return '[unknown sender]';
|
||||
return getPubkeyPrefix(pubkey);
|
||||
}
|
||||
|
||||
export function isPrefixOnlyContact(pubkey: string): boolean {
|
||||
return pubkey.length < 64;
|
||||
}
|
||||
|
||||
export function isUnknownFullKeyContact(pubkey: string, lastAdvert?: number | null): boolean {
|
||||
return pubkey.length === 64 && !lastAdvert;
|
||||
}
|
||||
|
||||
@@ -86,14 +86,19 @@ export function resolveChannelFromHashToken(token: string, channels: Channel[]):
|
||||
export function resolveContactFromHashToken(token: string, contacts: Contact[]): Contact | null {
|
||||
const normalizedToken = token.trim();
|
||||
if (!normalizedToken) return null;
|
||||
const lowerToken = normalizedToken.toLowerCase();
|
||||
|
||||
// Preferred path: stable identity by full public key.
|
||||
const byKey = contacts.find((c) => c.public_key.toLowerCase() === normalizedToken.toLowerCase());
|
||||
const byKey = contacts.find((c) => c.public_key.toLowerCase() === lowerToken);
|
||||
if (byKey) return byKey;
|
||||
|
||||
// Backward compatibility for legacy name/prefix-based hashes.
|
||||
return (
|
||||
contacts.find((c) => getContactDisplayName(c.name, c.public_key) === normalizedToken) || null
|
||||
contacts.find(
|
||||
(c) =>
|
||||
getContactDisplayName(c.name, c.public_key, c.last_advert) === normalizedToken ||
|
||||
c.public_key.toLowerCase().startsWith(lowerToken)
|
||||
) || null
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface VisualizerSettings {
|
||||
showAmbiguousPaths: boolean;
|
||||
showAmbiguousNodes: boolean;
|
||||
useAdvertPathHints: boolean;
|
||||
collapseLikelyKnownSiblingRepeaters: boolean;
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
chargeStrength: number;
|
||||
observationWindowSec: number;
|
||||
@@ -20,6 +21,7 @@ export const VISUALIZER_DEFAULTS: VisualizerSettings = {
|
||||
showAmbiguousPaths: true,
|
||||
showAmbiguousNodes: false,
|
||||
useAdvertPathHints: true,
|
||||
collapseLikelyKnownSiblingRepeaters: true,
|
||||
splitAmbiguousByTraffic: true,
|
||||
chargeStrength: -200,
|
||||
observationWindowSec: 15,
|
||||
@@ -50,6 +52,10 @@ export function getVisualizerSettings(): VisualizerSettings {
|
||||
typeof parsed.useAdvertPathHints === 'boolean'
|
||||
? parsed.useAdvertPathHints
|
||||
: VISUALIZER_DEFAULTS.useAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters:
|
||||
typeof parsed.collapseLikelyKnownSiblingRepeaters === 'boolean'
|
||||
? parsed.collapseLikelyKnownSiblingRepeaters
|
||||
: VISUALIZER_DEFAULTS.collapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic:
|
||||
typeof parsed.splitAmbiguousByTraffic === 'boolean'
|
||||
? parsed.splitAmbiguousByTraffic
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface PendingPacket {
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
interface ParsedPacket {
|
||||
export interface ParsedPacket {
|
||||
payloadType: number;
|
||||
messageHash: string | null;
|
||||
pathBytes: string[];
|
||||
@@ -110,6 +110,12 @@ export const PACKET_LEGEND_ITEMS = [
|
||||
{ label: '?', color: COLORS.particleUnknown, description: 'Other' },
|
||||
] as const;
|
||||
|
||||
export interface PathStep {
|
||||
nodeId: string | null;
|
||||
markHiddenLinkWhenOmitted?: boolean;
|
||||
hiddenLabel?: string | null;
|
||||
}
|
||||
|
||||
export function normalizeHopToken(hop: string | null | undefined): string | null {
|
||||
const normalized = hop?.trim().toLowerCase() ?? '';
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
@@ -234,6 +240,10 @@ export function getLinkId<
|
||||
};
|
||||
}
|
||||
|
||||
export function buildLinkKey(sourceId: string, targetId: string): string {
|
||||
return [sourceId, targetId].sort().join('->');
|
||||
}
|
||||
|
||||
export function getNodeType(contact: Contact | null | undefined): NodeType {
|
||||
return contact?.type === CONTACT_TYPE_REPEATER ? 'repeater' : 'client';
|
||||
}
|
||||
@@ -242,6 +252,47 @@ export function dedupeConsecutive<T>(arr: T[]): T[] {
|
||||
return arr.filter((item, i) => i === 0 || item !== arr[i - 1]);
|
||||
}
|
||||
|
||||
export function compactPathSteps(steps: PathStep[]): {
|
||||
nodes: string[];
|
||||
dashedLinkDetails: Map<string, string[]>;
|
||||
} {
|
||||
const nodes: string[] = [];
|
||||
const dashedLinkDetails = new Map<string, string[]>();
|
||||
let pendingHiddenLink = false;
|
||||
let pendingHiddenLabels: string[] = [];
|
||||
|
||||
for (const step of steps) {
|
||||
if (step.nodeId) {
|
||||
const previousNodeId = nodes.length > 0 ? nodes[nodes.length - 1] : null;
|
||||
if (previousNodeId && pendingHiddenLink && previousNodeId !== step.nodeId) {
|
||||
const key = buildLinkKey(previousNodeId, step.nodeId);
|
||||
const existing = dashedLinkDetails.get(key) ?? [];
|
||||
for (const label of pendingHiddenLabels) {
|
||||
if (!existing.includes(label)) {
|
||||
existing.push(label);
|
||||
}
|
||||
}
|
||||
dashedLinkDetails.set(key, existing);
|
||||
}
|
||||
if (previousNodeId !== step.nodeId) {
|
||||
nodes.push(step.nodeId);
|
||||
}
|
||||
pendingHiddenLink = false;
|
||||
pendingHiddenLabels = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
if (step.markHiddenLinkWhenOmitted && nodes.length > 0) {
|
||||
pendingHiddenLink = true;
|
||||
if (step.hiddenLabel && !pendingHiddenLabels.includes(step.hiddenLabel)) {
|
||||
pendingHiddenLabels.push(step.hiddenLabel);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, dashedLinkDetails };
|
||||
}
|
||||
|
||||
/**
|
||||
* Analyze traffic patterns for an ambiguous repeater prefix to determine if it
|
||||
* should be split into multiple nodes.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user