Compare commits
58 Commits
2.7.9
...
notificati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e17d1ba4b4 | ||
|
|
48a49ce48d | ||
|
|
9d1676818f | ||
|
|
b5edd00220 | ||
|
|
d3a7b7ce07 | ||
|
|
42ca242ee1 | ||
|
|
3e7e0669c5 | ||
|
|
bee273ab56 | ||
|
|
1842bcf43e | ||
|
|
7c68973e30 | ||
|
|
c9ede1f71f | ||
|
|
42e9628d98 | ||
|
|
1bf760121d | ||
|
|
bb4a601788 | ||
|
|
d0ed3484ce | ||
|
|
738e0b9815 | ||
|
|
97997e23e8 | ||
|
|
eaee66f836 | ||
|
|
9a99d3f17e | ||
|
|
73e717fbd8 | ||
|
|
dc87fa42b2 | ||
|
|
f650e0ab34 | ||
|
|
39b745f8b0 | ||
|
|
18e1408292 | ||
|
|
3e941a5b20 | ||
|
|
a000fc88a5 | ||
|
|
def7c8e29e | ||
|
|
9388e1f506 | ||
|
|
81bdfe09fa | ||
|
|
5e94b14b45 | ||
|
|
c3f1a43a80 | ||
|
|
3316f00271 | ||
|
|
319b84455b | ||
|
|
f107dce920 | ||
|
|
ec5b9663b2 | ||
|
|
19d7c3c98c | ||
|
|
ae0ef90fe2 | ||
|
|
56e5e0d278 | ||
|
|
5d509a88d9 | ||
|
|
946006bd7f | ||
|
|
344cee5508 | ||
|
|
0d671f361d | ||
|
|
2d781cad56 | ||
|
|
088dcb39d6 | ||
|
|
b1e3e71b68 | ||
|
|
557af55ee8 | ||
|
|
9421c10e8f | ||
|
|
b157ee14e4 | ||
|
|
e03ddcaaa7 | ||
|
|
6832516b40 | ||
|
|
5bfd9f4af2 | ||
|
|
463a0c9084 | ||
|
|
811c7e7349 | ||
|
|
0c5b37c07c | ||
|
|
7e384c12bb | ||
|
|
48bc8c6337 | ||
|
|
c3d7b8f79a | ||
|
|
b5c4413e63 |
56
AGENTS.md
@@ -28,28 +28,30 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Frontend (React) │
|
||||
│ Frontend (React) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │
|
||||
│ │ StatusBar│ │ Sidebar │ │MessageList│ │ MessageInput │ │
|
||||
│ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │
|
||||
│ ┌────────────────────────────────────────────────────────────┐ │
|
||||
│ │ CrackerPanel (global collapsible, WebGPU cracking) │ │
|
||||
│ └────────────────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ useWebSocket ←──── Real-time updates │
|
||||
│ │ │
|
||||
│ api.ts ←──── REST API calls │
|
||||
└───────────────────────────┼──────────────────────────────────────┘
|
||||
│ │ │
|
||||
│ useWebSocket ←──── Real-time updates │
|
||||
│ │ │
|
||||
│ api.ts ←──── REST API calls │
|
||||
└───────────────────────────┼─────────────────────────────────────┘
|
||||
│ HTTP + WebSocket (/api/*)
|
||||
┌───────────────────────────┼──────────────────────────────────────┐
|
||||
│ Backend (FastAPI) │
|
||||
│ ┌──────────┐ ┌──────────────┐ ┌────────────┐ ┌───────────┐ │
|
||||
│ │ Routers │→ │ Repositories │→ │ SQLite DB │ │ WebSocket │ │
|
||||
│ └──────────┘ └──────────────┘ └────────────┘ │ Manager │ │
|
||||
│ ↓ └───────────┘ │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ RadioManager + Event Handlers │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────────┐ ┌────────────┐ │
|
||||
│ │ Routers │→ │ Services │→ │ Repositories │→ │ SQLite DB │ │
|
||||
│ └──────────┘ └──────────┘ └──────────────┘ └────────────┘ │
|
||||
│ ↓ │ ┌───────────┐ │
|
||||
│ ┌──────────────────────────┐ └──────────────→ │ WebSocket │ │
|
||||
│ │ Radio runtime seam + │ │ Manager │ │
|
||||
│ │ RadioManager lifecycle │ └───────────┘ │
|
||||
│ │ / event adapters │ │
|
||||
│ └──────────────────────────┘ │
|
||||
└───────────────────────────┼──────────────────────────────────────┘
|
||||
│ Serial / TCP / BLE
|
||||
┌──────┴──────┐
|
||||
@@ -82,6 +84,8 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific
|
||||
|
||||
**Background tasks** (WebSocket broadcasts, periodic sync, contact auto-loading, etc.) use fire-and-forget `asyncio.create_task`. Exceptions in these tasks are logged to the backend logs, which is sufficient for debugging. There is no need to track task references or add done-callbacks purely for error visibility. If there's a convenient way to bubble an error to the frontend (e.g., via `broadcast_error` for user-actionable problems), do so, but this is minor and best-effort.
|
||||
|
||||
Radio startup/setup is one place where that frontend bubbling is intentional: if post-connect setup hangs past its timeout, the backend both logs the failure and pushes a toast instructing the operator to reboot the radio and restart the server.
|
||||
|
||||
## Key Design Principles
|
||||
|
||||
1. **Store-and-serve**: Backend stores all packets even when no client is connected
|
||||
@@ -91,6 +95,15 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific
|
||||
5. **Offline-capable**: Radio operates independently; server syncs when connected
|
||||
6. **Auto-reconnect**: Background monitor detects disconnection and attempts reconnection
|
||||
|
||||
## Code Ethos
|
||||
|
||||
- Prefer fewer, stronger modules over many tiny wrapper files.
|
||||
- Split code only when the new module owns a real invariant, workflow, or contract.
|
||||
- Avoid "enterprise" indirection layers whose main job is forwarding, renaming, or prop bundling.
|
||||
- For this repo, "locally dense but semantically obvious" is better than context scattered across many files.
|
||||
- Use typed contracts at important boundaries such as API payloads, WebSocket events, and repository writes.
|
||||
- Refactors should be behavior-preserving slices with tests around the moved seam, not aesthetic reshuffles.
|
||||
|
||||
## Intentional Security Design Decisions
|
||||
|
||||
The following are **deliberate design choices**, not bugs. They are documented in the README with appropriate warnings. Do not "fix" these or flag them as vulnerabilities.
|
||||
@@ -107,6 +120,8 @@ Raw packet handling uses two identities by design:
|
||||
|
||||
Frontend packet-feed consumers should treat `observation_id` as the dedup/render key, while `id` remains the storage reference.
|
||||
|
||||
Channel metadata updates may also fan out as `channel` WebSocket events (full `Channel` payload) so clients can reflect local-only channel state such as regional flood-scope overrides without a full refetch.
|
||||
|
||||
## Contact Advert Path Memory
|
||||
|
||||
To improve repeater disambiguation in the network visualizer, the backend stores recent unique advertisement paths per contact in a dedicated table (`contact_advert_paths`).
|
||||
@@ -124,6 +139,7 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
||||
- `GET /api/radio/config` exposes both the current `path_hash_mode` and `path_hash_mode_supported`.
|
||||
- `PATCH /api/radio/config` may update `path_hash_mode` only when the connected firmware supports it.
|
||||
- Contacts persist `out_path_hash_mode` separately from `last_path` so contact sync and DM send paths can round-trip correctly even when hop bytes are ambiguous.
|
||||
- Contacts may also persist an explicit routing override (`route_override_*`). When set, radio-bound operations use the override instead of the learned `last_path*`, but learned paths still keep updating from adverts.
|
||||
- `path_len` in API payloads is always hop count, not byte count. The actual path byte length is `hop_count * hash_size`.
|
||||
|
||||
## Data Flow
|
||||
@@ -139,7 +155,7 @@ MeshCore firmware can encode path hops as 1-byte, 2-byte, or 3-byte identifiers.
|
||||
|
||||
1. User types message → clicks send
|
||||
2. `api.sendChannelMessage()` → POST to backend
|
||||
3. Backend calls `radio_manager.meshcore.commands.send_chan_msg()`
|
||||
3. Backend route delegates to service-layer send orchestration, which acquires the radio lock and calls MeshCore commands
|
||||
4. Message stored in database with `outgoing=true`
|
||||
5. For direct messages: ACK tracked; for channel: repeat detection
|
||||
|
||||
@@ -159,6 +175,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
│ ├── AGENTS.md # Backend documentation
|
||||
│ ├── main.py # App entry, lifespan
|
||||
│ ├── routers/ # API endpoints
|
||||
│ ├── services/ # Shared backend orchestration/domain services, including radio_runtime access seam
|
||||
│ ├── packet_processor.py # Raw packet pipeline, dedup, path handling
|
||||
│ ├── repository/ # Database CRUD (contacts, channels, messages, raw_packets, settings, fanout)
|
||||
│ ├── event_handlers.py # Radio events
|
||||
@@ -168,7 +185,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
├── frontend/ # React frontend
|
||||
│ ├── AGENTS.md # Frontend documentation
|
||||
│ ├── src/
|
||||
│ │ ├── App.tsx # Main component
|
||||
│ │ ├── App.tsx # Frontend composition entry (hooks → AppShell)
|
||||
│ │ ├── api.ts # REST client
|
||||
│ │ ├── useWebSocket.ts # WebSocket hook
|
||||
│ │ └── components/
|
||||
@@ -247,6 +264,8 @@ Key test files:
|
||||
- `tests/test_messages_search.py` - Message search, around endpoint, forward pagination
|
||||
- `tests/test_rx_log_data.py` - on_rx_log_data event handler integration
|
||||
- `tests/test_ack_tracking_wiring.py` - DM ACK tracking extraction and wiring
|
||||
- `tests/test_radio_lifecycle_service.py` - Radio reconnect/setup orchestration helpers
|
||||
- `tests/test_radio_commands_service.py` - Radio config/private-key service workflows
|
||||
- `tests/test_health_mqtt_status.py` - Health endpoint MQTT status field
|
||||
- `tests/test_community_mqtt.py` - Community MQTT publisher (JWT, packet format, hash, broadcast)
|
||||
- `tests/test_radio_sync.py` - Radio sync, periodic tasks, and contact offload back to the radio
|
||||
@@ -289,7 +308,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| 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}/reset-path` | Reset contact path to flood |
|
||||
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
|
||||
| POST | `/api/contacts/{public_key}/trace` | Trace route to contact |
|
||||
| POST | `/api/contacts/{public_key}/repeater/login` | Log in to a repeater |
|
||||
| POST | `/api/contacts/{public_key}/repeater/status` | Fetch repeater status telemetry |
|
||||
@@ -306,6 +325,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| 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) |
|
||||
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
|
||||
@@ -351,6 +371,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
- Stored as 32-character hex string (TEXT PRIMARY KEY)
|
||||
- Hashtag channels: `SHA256("#name")[:16]` converted to hex
|
||||
- Custom channels: User-provided or generated
|
||||
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
|
||||
|
||||
### Message Types
|
||||
|
||||
@@ -421,8 +442,9 @@ 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 |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
|
||||
**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, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
|
||||
|
||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||
|
||||
|
||||
@@ -562,7 +562,7 @@ THE SOFTWARE.
|
||||
|
||||
</details>
|
||||
|
||||
### @michaelhart/meshcore-decoder (0.2.7) — MIT
|
||||
### @michaelhart/meshcore-decoder (0.3.0) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
@@ -25,7 +25,7 @@ If extending, have your LLM read the three `AGENTS.md` files: `./AGENTS.md`, `./
|
||||
## Requirements
|
||||
|
||||
- Python 3.10+
|
||||
- Node.js 18+
|
||||
- Node.js LTS or current (20, 22, 24, 25)
|
||||
- [UV](https://astral.sh/uv) package manager: `curl -LsSf https://astral.sh/uv/install.sh | sh`
|
||||
- MeshCore radio connected via USB serial, TCP, or BLE
|
||||
|
||||
@@ -224,9 +224,12 @@ 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 |
|
||||
|
||||
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
|
||||
|
||||
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.
|
||||
|
||||
## Additional Setup
|
||||
|
||||
<details>
|
||||
|
||||
@@ -11,6 +11,14 @@ Keep it aligned with `app/` source files and router behavior.
|
||||
- MeshCore Python library (`meshcore` from PyPI)
|
||||
- PyCryptodome
|
||||
|
||||
## Code Ethos
|
||||
|
||||
- Prefer strong domain modules over layers of pass-through helpers.
|
||||
- Split code when the new module owns real policy, not just a nicer name.
|
||||
- Avoid wrapper services around globals unless they materially improve testability or reduce coupling.
|
||||
- Keep workflows locally understandable; do not scatter one reasoning unit across several files without a clear contract.
|
||||
- Typed write/read contracts are preferred over loose dict-shaped repository inputs.
|
||||
|
||||
## Backend Map
|
||||
|
||||
```text
|
||||
@@ -19,13 +27,22 @@ app/
|
||||
├── config.py # Env-driven runtime settings
|
||||
├── database.py # SQLite connection + base schema + migration runner
|
||||
├── migrations.py # Schema migrations (SQLite user_version)
|
||||
├── models.py # Pydantic request/response models
|
||||
├── models.py # Pydantic request/response models and typed write contracts (for example ContactUpsert)
|
||||
├── repository/ # Data access layer (contacts, channels, messages, raw_packets, settings, fanout)
|
||||
├── radio.py # RadioManager + auto-reconnect monitor
|
||||
├── services/ # Shared orchestration/domain services
|
||||
│ ├── messages.py # Shared message creation, dedup, ACK application
|
||||
│ ├── message_send.py # Direct send, channel send, resend workflows
|
||||
│ ├── dm_ack_tracker.py # Pending DM ACK state
|
||||
│ ├── contact_reconciliation.py # Prefix-claim, sender-key backfill, name-history wiring
|
||||
│ ├── radio_lifecycle.py # Post-connect setup and reconnect/setup helpers
|
||||
│ ├── radio_commands.py # Radio config/private-key command workflows
|
||||
│ └── radio_runtime.py # Router/dependency seam over the global RadioManager
|
||||
├── radio.py # RadioManager transport/session state + lock management
|
||||
├── radio_sync.py # Polling, sync, periodic advertisement loop
|
||||
├── decoder.py # Packet parsing/decryption
|
||||
├── packet_processor.py # Raw packet pipeline, dedup, path handling
|
||||
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
|
||||
├── events.py # Typed WS event payload serialization
|
||||
├── websocket.py # WS manager + broadcast helpers
|
||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
@@ -53,13 +70,13 @@ app/
|
||||
|
||||
1. Radio emits events.
|
||||
2. `on_rx_log_data` stores raw packet and tries decrypt/pipeline handling.
|
||||
3. Decrypted messages are inserted into `messages` and broadcast over WS.
|
||||
3. Shared message-domain services create/update `messages` and shape WS payloads.
|
||||
4. `CONTACT_MSG_RECV` is a fallback DM path when packet pipeline cannot decrypt.
|
||||
|
||||
### Outgoing messages
|
||||
|
||||
1. Send endpoints in `routers/messages.py` call MeshCore commands.
|
||||
2. Message is persisted as outgoing.
|
||||
1. Send endpoints in `routers/messages.py` validate requests and delegate to `services/message_send.py`.
|
||||
2. Service-layer send workflows call MeshCore commands, persist outgoing messages, and wire ACK tracking.
|
||||
3. Endpoint broadcasts WS `message` event so all live clients update.
|
||||
4. ACK/repeat updates arrive later as `message_acked` events.
|
||||
5. Channel resend (`POST /messages/channel/{id}/resend`) strips the sender name prefix by exact match against the current radio name. This assumes the radio name hasn't changed between the original send and the resend. Name changes require an explicit radio config update and are rare, but the `new_timestamp=true` resend path has no time window, so a mismatch is possible if the name was changed between the original send and a later resend.
|
||||
@@ -67,9 +84,11 @@ app/
|
||||
### Connection lifecycle
|
||||
|
||||
- `RadioManager.start_connection_monitor()` checks health every 5s.
|
||||
- Monitor reconnect path runs `post_connect_setup()` before broadcasting healthy state.
|
||||
- Manual reconnect/reboot endpoints call `reconnect()` then `post_connect_setup()`.
|
||||
- Setup includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks.
|
||||
- `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.
|
||||
- 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
|
||||
|
||||
@@ -78,6 +97,7 @@ app/
|
||||
- Packet `path_len` values are hop counts, not byte counts.
|
||||
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- 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.
|
||||
|
||||
### Read/unread state
|
||||
@@ -144,7 +164,7 @@ app/
|
||||
- `POST /contacts/{public_key}/remove-from-radio`
|
||||
- `POST /contacts/{public_key}/mark-read`
|
||||
- `POST /contacts/{public_key}/command`
|
||||
- `POST /contacts/{public_key}/reset-path`
|
||||
- `POST /contacts/{public_key}/routing-override`
|
||||
- `POST /contacts/{public_key}/trace`
|
||||
- `POST /contacts/{public_key}/repeater/login`
|
||||
- `POST /contacts/{public_key}/repeater/status`
|
||||
@@ -162,6 +182,7 @@ app/
|
||||
- `POST /channels`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/sync`
|
||||
- `POST /channels/{key}/flood-scope-override`
|
||||
- `POST /channels/{key}/mark-read`
|
||||
|
||||
### Messages
|
||||
@@ -208,11 +229,12 @@ app/
|
||||
- `message_acked` — ACK/echo update for existing message (ack count + paths)
|
||||
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
|
||||
- `contact_deleted` — contact removed from database (payload: `{ public_key }`)
|
||||
- `channel` — single channel upsert/update (payload: full `Channel`)
|
||||
- `channel_deleted` — channel removed from database (payload: `{ key }`)
|
||||
- `error` — toast notification (reconnect failure, missing private key, etc.)
|
||||
- `error` — toast notification (reconnect failure, missing private key, stuck radio startup, etc.)
|
||||
- `success` — toast notification (historical decrypt complete, etc.)
|
||||
|
||||
Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
|
||||
Backend WS sends go through typed serialization in `events.py`. Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
|
||||
Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
|
||||
## Data Model Notes
|
||||
@@ -220,12 +242,17 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
|
||||
Main tables:
|
||||
- `contacts` (includes `first_seen` for contact age tracking and `out_path_hash_mode` for route round-tripping)
|
||||
- `channels`
|
||||
Includes optional `flood_scope_override` for channel-specific regional sends.
|
||||
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
|
||||
- `raw_packets`
|
||||
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
|
||||
- `contact_name_history` (tracks name changes over time)
|
||||
- `app_settings`
|
||||
|
||||
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
|
||||
|
||||
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
|
||||
|
||||
`app_settings` fields in active model:
|
||||
- `max_radio_contacts`
|
||||
- `favorites`
|
||||
@@ -285,6 +312,8 @@ tests/
|
||||
├── test_packet_pipeline.py # End-to-end packet processing
|
||||
├── test_packets_router.py # Packets router endpoints (decrypt, maintenance)
|
||||
├── test_radio.py # RadioManager, serial detection
|
||||
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
||||
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
||||
├── test_real_crypto.py # Real cryptographic operations
|
||||
├── test_radio_operation.py # radio_operation() context manager
|
||||
├── test_radio_router.py # Radio router endpoints
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import logging.config
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
@@ -17,6 +18,7 @@ class Settings(BaseSettings):
|
||||
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
database_path: str = "data/meshcore.db"
|
||||
disable_bots: bool = False
|
||||
enable_message_poll_fallback: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_transport_exclusivity(self) -> "Settings":
|
||||
@@ -84,10 +86,54 @@ class _RepeatSquelch(logging.Filter):
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Configure logging for the application."""
|
||||
logging.basicConfig(
|
||||
level=settings.log_level,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
logging.config.dictConfig(
|
||||
{
|
||||
"version": 1,
|
||||
"disable_existing_loggers": False,
|
||||
"formatters": {
|
||||
"default": {
|
||||
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
},
|
||||
"uvicorn_access": {
|
||||
"()": "uvicorn.logging.AccessFormatter",
|
||||
"fmt": '%(asctime)s - %(name)s - %(levelname)s - %(client_addr)s - "%(request_line)s" %(status_code)s',
|
||||
"datefmt": "%Y-%m-%d %H:%M:%S",
|
||||
"use_colors": None,
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"default": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "default",
|
||||
},
|
||||
"uvicorn_access": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "uvicorn_access",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["default"],
|
||||
},
|
||||
"loggers": {
|
||||
"uvicorn": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["default"],
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.error": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["default"],
|
||||
"propagate": False,
|
||||
},
|
||||
"uvicorn.access": {
|
||||
"level": settings.log_level,
|
||||
"handlers": ["uvicorn_access"],
|
||||
"propagate": False,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
|
||||
# "Serial Connection started" when the port is contended).
|
||||
|
||||
@@ -16,6 +16,9 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
last_path TEXT,
|
||||
last_path_len INTEGER DEFAULT -1,
|
||||
out_path_hash_mode INTEGER DEFAULT 0,
|
||||
route_override_path TEXT,
|
||||
route_override_len INTEGER,
|
||||
route_override_hash_mode INTEGER,
|
||||
last_advert INTEGER,
|
||||
lat REAL,
|
||||
lon REAL,
|
||||
@@ -29,7 +32,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
key TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
is_hashtag INTEGER DEFAULT 0,
|
||||
on_radio INTEGER DEFAULT 0
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
flood_scope_override TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
"""Shared dependencies for FastAPI routers."""
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
|
||||
def require_connected():
|
||||
"""Dependency that ensures radio is connected and returns meshcore instance.
|
||||
|
||||
Raises HTTPException 503 if radio is not connected.
|
||||
"""
|
||||
if getattr(radio_manager, "is_setup_in_progress", False) is True:
|
||||
raise HTTPException(status_code=503, detail="Radio is initializing")
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
||||
return radio_manager.meshcore
|
||||
"""Dependency that ensures radio is connected and returns meshcore instance."""
|
||||
return radio_manager.require_connected()
|
||||
|
||||
@@ -4,14 +4,18 @@ from typing import TYPE_CHECKING
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, Contact, Message, MessagePath
|
||||
from app.models import CONTACT_TYPE_REPEATER, Contact, ContactUpsert
|
||||
from app.packet_processor import process_raw_packet
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.contact_reconciliation import (
|
||||
claim_prefix_messages_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -22,33 +26,17 @@ logger = logging.getLogger(__name__)
|
||||
# Track active subscriptions so we can unsubscribe before re-registering
|
||||
# This prevents handler duplication after reconnects
|
||||
_active_subscriptions: list["Subscription"] = []
|
||||
|
||||
|
||||
# Track pending ACKs: expected_ack_code -> (message_id, timestamp, timeout_ms)
|
||||
_pending_acks: dict[str, tuple[int, float, int]] = {}
|
||||
_pending_acks = dm_ack_tracker._pending_acks
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
|
||||
"""Track a pending ACK for a direct message."""
|
||||
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
|
||||
logger.debug(
|
||||
"Tracking pending ACK %s for message %d (timeout %dms)",
|
||||
expected_ack,
|
||||
message_id,
|
||||
timeout_ms,
|
||||
)
|
||||
"""Compatibility wrapper for pending DM ACK tracking."""
|
||||
dm_ack_tracker.track_pending_ack(expected_ack, message_id, timeout_ms)
|
||||
|
||||
|
||||
def cleanup_expired_acks() -> None:
|
||||
"""Remove expired pending ACKs."""
|
||||
now = time.time()
|
||||
expired = []
|
||||
for code, (_msg_id, created_at, timeout_ms) in _pending_acks.items():
|
||||
if now - created_at > (timeout_ms / 1000) * 2: # 2x timeout as buffer
|
||||
expired.append(code)
|
||||
for code in expired:
|
||||
del _pending_acks[code]
|
||||
logger.debug("Expired pending ACK %s", code)
|
||||
"""Compatibility wrapper for expiring stale DM ACK entries."""
|
||||
dm_ack_tracker.cleanup_expired_acks()
|
||||
|
||||
|
||||
async def on_contact_message(event: "Event") -> None:
|
||||
@@ -90,7 +78,7 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_pubkey = contact.public_key.lower()
|
||||
|
||||
# Promote any prefix-stored messages to this full key
|
||||
await MessageRepository.claim_prefix_messages(sender_pubkey)
|
||||
await claim_prefix_messages_for_contact(public_key=sender_pubkey, log=logger)
|
||||
|
||||
# Skip messages from repeaters - they only send CLI responses, not chat messages.
|
||||
# CLI responses are handled by the command endpoint and txt_type filter above.
|
||||
@@ -108,21 +96,21 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_name = contact.name if contact else None
|
||||
path = payload.get("path")
|
||||
path_len = payload.get("path_len")
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=payload.get("text", ""),
|
||||
message = await create_fallback_direct_message(
|
||||
conversation_key=sender_pubkey,
|
||||
text=payload.get("text", ""),
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
sender_key=sender_pubkey,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_pubkey,
|
||||
broadcast_fn=broadcast_event,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
if message is None:
|
||||
# Already handled by packet processor (or exact duplicate) - nothing more to do
|
||||
logger.debug("DM from %s already processed by packet processor", sender_pubkey[:12])
|
||||
return
|
||||
@@ -131,31 +119,6 @@ async def on_contact_message(event: "Event") -> None:
|
||||
# (likely because private key export is not available)
|
||||
logger.debug("DM from %s handled by event handler (fallback path)", sender_pubkey[:12])
|
||||
|
||||
# Build paths array for broadcast
|
||||
paths = (
|
||||
[MessagePath(path=path or "", received_at=received_at, path_len=path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Broadcast the new message
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=msg_id,
|
||||
type="PRIV",
|
||||
conversation_key=sender_pubkey,
|
||||
text=payload.get("text", ""),
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=paths,
|
||||
txt_type=txt_type,
|
||||
signature=payload.get("signature"),
|
||||
sender_key=sender_pubkey,
|
||||
sender_name=sender_name,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
# Update contact last_contacted (contact was already fetched above)
|
||||
if contact:
|
||||
await ContactRepository.update_last_contacted(sender_pubkey, received_at)
|
||||
@@ -265,30 +228,29 @@ async def on_new_contact(event: "Event") -> None:
|
||||
|
||||
logger.debug("New contact: %s", public_key[:12])
|
||||
|
||||
contact_data = {
|
||||
**Contact.from_radio_dict(public_key.lower(), payload, on_radio=True),
|
||||
"last_seen": int(time.time()),
|
||||
}
|
||||
await ContactRepository.upsert(contact_data)
|
||||
contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=True)
|
||||
contact_upsert.last_seen = int(time.time())
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
|
||||
# Record name history if contact has a name
|
||||
adv_name = payload.get("adv_name")
|
||||
if adv_name:
|
||||
await ContactNameHistoryRepository.record_name(
|
||||
public_key.lower(), adv_name, int(time.time())
|
||||
)
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(public_key, adv_name)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
adv_name,
|
||||
)
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=public_key,
|
||||
contact_name=adv_name,
|
||||
timestamp=int(time.time()),
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# Read back from DB so the broadcast includes all fields (last_contacted,
|
||||
# last_read_at, etc.) matching the REST Contact shape exactly.
|
||||
db_contact = await ContactRepository.get_by_key(public_key)
|
||||
broadcast_event("contact", (db_contact.model_dump() if db_contact else contact_data))
|
||||
broadcast_event(
|
||||
"contact",
|
||||
(
|
||||
db_contact.model_dump()
|
||||
if db_contact
|
||||
else Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump()
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def on_ack(event: "Event") -> None:
|
||||
@@ -304,15 +266,13 @@ async def on_ack(event: "Event") -> None:
|
||||
|
||||
cleanup_expired_acks()
|
||||
|
||||
if ack_code in _pending_acks:
|
||||
message_id, _, _ = _pending_acks.pop(ack_code)
|
||||
message_id = dm_ack_tracker.pop_pending_ack(ack_code)
|
||||
if message_id is not None:
|
||||
logger.info("ACK received for message %d", message_id)
|
||||
|
||||
ack_count = await MessageRepository.increment_ack_count(message_id)
|
||||
# DM ACKs don't carry path data, so paths is intentionally omitted.
|
||||
# The frontend's mergePendingAck handles the missing field correctly,
|
||||
# preserving any previously known paths.
|
||||
broadcast_event("message_acked", {"message_id": message_id, "ack_count": ack_count})
|
||||
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event)
|
||||
else:
|
||||
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
||||
|
||||
|
||||
107
app/events.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Typed WebSocket event contracts and serialization helpers."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from app.models import Channel, Contact, Message, MessagePath, RawPacketBroadcast
|
||||
from app.routers.health import HealthResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
WsEventType = Literal[
|
||||
"health",
|
||||
"message",
|
||||
"contact",
|
||||
"channel",
|
||||
"contact_deleted",
|
||||
"channel_deleted",
|
||||
"raw_packet",
|
||||
"message_acked",
|
||||
"error",
|
||||
"success",
|
||||
]
|
||||
|
||||
|
||||
class ContactDeletedPayload(TypedDict):
|
||||
public_key: str
|
||||
|
||||
|
||||
class ChannelDeletedPayload(TypedDict):
|
||||
key: str
|
||||
|
||||
|
||||
class MessageAckedPayload(TypedDict):
|
||||
message_id: int
|
||||
ack_count: int
|
||||
paths: NotRequired[list[MessagePath]]
|
||||
|
||||
|
||||
class ToastPayload(TypedDict):
|
||||
message: str
|
||||
details: NotRequired[str]
|
||||
|
||||
|
||||
WsEventPayload = (
|
||||
HealthResponse
|
||||
| Message
|
||||
| Contact
|
||||
| Channel
|
||||
| ContactDeletedPayload
|
||||
| ChannelDeletedPayload
|
||||
| RawPacketBroadcast
|
||||
| MessageAckedPayload
|
||||
| ToastPayload
|
||||
)
|
||||
|
||||
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||
"health": TypeAdapter(HealthResponse),
|
||||
"message": TypeAdapter(Message),
|
||||
"contact": TypeAdapter(Contact),
|
||||
"channel": TypeAdapter(Channel),
|
||||
"contact_deleted": TypeAdapter(ContactDeletedPayload),
|
||||
"channel_deleted": TypeAdapter(ChannelDeletedPayload),
|
||||
"raw_packet": TypeAdapter(RawPacketBroadcast),
|
||||
"message_acked": TypeAdapter(MessageAckedPayload),
|
||||
"error": TypeAdapter(ToastPayload),
|
||||
"success": TypeAdapter(ToastPayload),
|
||||
}
|
||||
|
||||
|
||||
def validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any:
|
||||
"""Validate known WebSocket payloads; pass unknown events through unchanged."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
if adapter is None:
|
||||
return data
|
||||
return adapter.validate_python(data)
|
||||
|
||||
|
||||
def dump_ws_event(event_type: str, data: Any) -> str:
|
||||
"""Serialize a WebSocket event envelope with validation for known event types."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
if adapter is None:
|
||||
return json.dumps({"type": event_type, "data": data})
|
||||
|
||||
try:
|
||||
validated = adapter.validate_python(data)
|
||||
payload = adapter.dump_python(validated, mode="json")
|
||||
return json.dumps({"type": event_type, "data": payload})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to validate WebSocket payload for event %s; falling back to raw JSON envelope",
|
||||
event_type,
|
||||
)
|
||||
return json.dumps({"type": event_type, "data": data})
|
||||
|
||||
|
||||
def dump_ws_event_payload(event_type: str, data: Any) -> Any:
|
||||
"""Return the JSON-serializable payload for a WebSocket event."""
|
||||
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
|
||||
if adapter is None:
|
||||
return data
|
||||
|
||||
validated = adapter.validate_python(data)
|
||||
return adapter.dump_python(validated, mode="json")
|
||||
@@ -52,10 +52,17 @@ class BotModule(FanoutModule):
|
||||
|
||||
msg_type = data.get("type", "")
|
||||
is_dm = msg_type == "PRIV"
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
logger.debug(
|
||||
"Bot '%s' starting for type=%s conversation=%s outgoing=%s",
|
||||
self.name,
|
||||
msg_type or "unknown",
|
||||
conversation_key[:12] if conversation_key else "(none)",
|
||||
bool(data.get("outgoing", False)),
|
||||
)
|
||||
|
||||
# Extract bot parameters from broadcast data
|
||||
if is_dm:
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
sender_key = data.get("sender_key") or conversation_key
|
||||
is_outgoing = data.get("outgoing", False)
|
||||
message_text = data.get("text", "")
|
||||
@@ -73,7 +80,6 @@ class BotModule(FanoutModule):
|
||||
contact = await ContactRepository.get_by_key(conversation_key)
|
||||
sender_name = contact.name if contact else None
|
||||
else:
|
||||
conversation_key = data.get("conversation_key", "")
|
||||
sender_key = None
|
||||
is_outgoing = bool(data.get("outgoing", False))
|
||||
sender_name = data.get("sender_name")
|
||||
@@ -130,8 +136,8 @@ class BotModule(FanoutModule):
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Bot '%s' execution timed out", self.name)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning("Bot '%s' execution error: %s", self.name, e)
|
||||
except Exception:
|
||||
logger.exception("Bot '%s' execution error", self.name)
|
||||
return
|
||||
|
||||
if response and self._active:
|
||||
|
||||
@@ -90,8 +90,8 @@ def execute_bot_code(
|
||||
try:
|
||||
# Execute the user's code to define the bot function
|
||||
exec(code, namespace)
|
||||
except Exception as e:
|
||||
logger.warning("Bot code compilation failed: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Bot code compilation failed")
|
||||
return None
|
||||
|
||||
# Check if bot function was defined
|
||||
@@ -172,8 +172,8 @@ def execute_bot_code(
|
||||
logger.debug("Bot function returned unsupported type: %s", type(result))
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Bot function execution failed: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Bot function execution failed")
|
||||
return None
|
||||
|
||||
|
||||
@@ -249,10 +249,10 @@ async def _send_single_bot_message(
|
||||
logger.warning("Cannot send bot response: no destination")
|
||||
return # Don't update timestamp if we didn't send
|
||||
except HTTPException as e:
|
||||
logger.error("Bot failed to send response: %s", e.detail)
|
||||
logger.error("Bot failed to send response: %s", e.detail, exc_info=True)
|
||||
return # Don't update timestamp on failure
|
||||
except Exception as e:
|
||||
logger.error("Bot failed to send response: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Bot failed to send response")
|
||||
return # Don't update timestamp on failure
|
||||
|
||||
# Update last send time after successful send
|
||||
|
||||
@@ -244,7 +244,7 @@ def _build_radio_info() -> str:
|
||||
Matches the reference format: ``"freq,bw,sf,cr"`` (comma-separated raw
|
||||
values). Falls back to ``"0,0,0,0"`` when unavailable.
|
||||
"""
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
try:
|
||||
if radio_manager.meshcore and radio_manager.meshcore.self_info:
|
||||
@@ -329,7 +329,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
def _build_client_kwargs(self, settings: object) -> dict[str, Any]:
|
||||
s: CommunityMqttSettings = settings # type: ignore[assignment]
|
||||
from app.keystore import get_private_key, get_public_key
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
private_key = get_private_key()
|
||||
public_key = get_public_key()
|
||||
@@ -401,7 +401,8 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
if self._cached_device_info is not None:
|
||||
return self._cached_device_info
|
||||
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
fallback = {"model": "unknown", "firmware_version": "unknown"}
|
||||
try:
|
||||
@@ -448,7 +449,8 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
) < _STATS_MIN_CACHE_SECS and self._cached_stats is not None:
|
||||
return self._cached_stats
|
||||
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError, radio_manager
|
||||
from app.radio import RadioDisconnectedError, RadioOperationBusyError
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation("community_stats_fetch", blocking=False) as mc:
|
||||
@@ -489,7 +491,7 @@ class CommunityMqttPublisher(BaseMqttPublisher):
|
||||
) -> None:
|
||||
"""Build and publish the enriched retained status message."""
|
||||
from app.keystore import get_public_key
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
public_key = get_public_key()
|
||||
if public_key is None:
|
||||
|
||||
@@ -25,7 +25,7 @@ _BACKOFF_MIN = 5
|
||||
|
||||
def _broadcast_health() -> None:
|
||||
"""Push updated health (including MQTT status) to all WS clients."""
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(radio_manager.is_connected, radio_manager.connection_info)
|
||||
@@ -89,7 +89,13 @@ class BaseMqttPublisher(ABC):
|
||||
try:
|
||||
await self._client.publish(topic, json.dumps(payload), retain=retain)
|
||||
except Exception as e:
|
||||
logger.warning("%s publish failed on %s: %s", self._log_prefix, topic, e)
|
||||
logger.warning(
|
||||
"%s publish failed on %s: %s",
|
||||
self._log_prefix,
|
||||
topic,
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
self.connected = False
|
||||
# Wake the connection loop so it exits the wait and reconnects
|
||||
self._settings_version += 1
|
||||
@@ -223,6 +229,7 @@ class BaseMqttPublisher(ABC):
|
||||
self._log_prefix,
|
||||
e,
|
||||
backoff,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -109,7 +109,7 @@ async def _publish_community_packet(
|
||||
"""Format and publish a raw packet to the community broker."""
|
||||
try:
|
||||
from app.keystore import get_public_key
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
public_key = get_public_key()
|
||||
if public_key is None:
|
||||
@@ -135,4 +135,4 @@ async def _publish_community_packet(
|
||||
await publisher.publish(topic, packet)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Community MQTT broadcast error: %s", e)
|
||||
logger.warning("Community MQTT broadcast error: %s", e, exc_info=True)
|
||||
|
||||
17
app/main.py
@@ -10,7 +10,7 @@ from fastapi.responses import JSONResponse
|
||||
from app.config import setup_logging
|
||||
from app.database import db
|
||||
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
|
||||
from app.radio import RadioDisconnectedError, radio_manager
|
||||
from app.radio import RadioDisconnectedError
|
||||
from app.radio_sync import (
|
||||
stop_message_polling,
|
||||
stop_periodic_advert,
|
||||
@@ -30,6 +30,7 @@ from app.routers import (
|
||||
statistics,
|
||||
ws,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
setup_logging()
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,17 +39,13 @@ logger = logging.getLogger(__name__)
|
||||
async def _startup_radio_connect_and_setup() -> None:
|
||||
"""Connect/setup the radio in the background so HTTP serving can start immediately."""
|
||||
try:
|
||||
connected = await radio_manager.reconnect(broadcast_on_success=False)
|
||||
connected = await radio_manager.reconnect_and_prepare(broadcast_on_success=True)
|
||||
if connected:
|
||||
await radio_manager.post_connect_setup()
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
broadcast_health(True, radio_manager.connection_info)
|
||||
logger.info("Connected to radio")
|
||||
else:
|
||||
logger.warning("Failed to connect to radio on startup")
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to radio on startup: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Failed to connect to radio on startup")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -72,8 +69,8 @@ async def lifespan(app: FastAPI):
|
||||
|
||||
try:
|
||||
await fanout_manager.load_from_db()
|
||||
except Exception as e:
|
||||
logger.warning("Failed to start fanout modules: %s", e)
|
||||
except Exception:
|
||||
logger.exception("Failed to start fanout modules")
|
||||
|
||||
startup_radio_task = asyncio.create_task(_startup_radio_connect_and_setup())
|
||||
app.state.startup_radio_task = startup_radio_task
|
||||
|
||||
@@ -317,6 +317,20 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 40)
|
||||
applied += 1
|
||||
|
||||
# Migration 41: Persist optional routing overrides separately from learned paths
|
||||
if version < 41:
|
||||
logger.info("Applying migration 41: add contacts routing override columns")
|
||||
await _migrate_041_add_contact_routing_override_columns(conn)
|
||||
await set_version(conn, 41)
|
||||
applied += 1
|
||||
|
||||
# Migration 42: Persist optional per-channel flood-scope overrides
|
||||
if version < 42:
|
||||
logger.info("Applying migration 42: add channels flood_scope_override column")
|
||||
await _migrate_042_add_channel_flood_scope_override(conn)
|
||||
await set_version(conn, 42)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -764,7 +778,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
|
||||
Create app_settings table for persistent application preferences.
|
||||
|
||||
This table stores:
|
||||
- max_radio_contacts: Max non-repeater contacts to keep on radio for DM ACKs
|
||||
- max_radio_contacts: Configured radio contact capacity baseline for maintenance thresholds
|
||||
- favorites: JSON array of favorite conversations [{type, id}, ...]
|
||||
- auto_decrypt_dm_on_advert: Whether to attempt historical DM decryption on new contact
|
||||
- sidebar_sort_order: 'recent' or 'alpha' for sidebar sorting
|
||||
@@ -2382,3 +2396,50 @@ async def _migrate_040_rebuild_contact_advert_paths_identity(
|
||||
"ON contact_advert_paths(public_key, last_seen DESC)"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_041_add_contact_routing_override_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""Add nullable routing-override columns to contacts."""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='contacts'"
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
for column_name, column_type in (
|
||||
("route_override_path", "TEXT"),
|
||||
("route_override_len", "INTEGER"),
|
||||
("route_override_hash_mode", "INTEGER"),
|
||||
):
|
||||
try:
|
||||
await conn.execute(f"ALTER TABLE contacts ADD COLUMN {column_name} {column_type}")
|
||||
logger.debug("Added %s to contacts table", column_name)
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
logger.debug("contacts.%s already exists, skipping", column_name)
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_042_add_channel_flood_scope_override(conn: aiosqlite.Connection) -> None:
|
||||
"""Add nullable per-channel flood-scope override column."""
|
||||
cursor = await conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='channels'"
|
||||
)
|
||||
if await cursor.fetchone() is None:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
try:
|
||||
await conn.execute("ALTER TABLE channels ADD COLUMN flood_scope_override TEXT")
|
||||
logger.debug("Added flood_scope_override to channels table")
|
||||
except aiosqlite.OperationalError as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
logger.debug("channels.flood_scope_override already exists, skipping")
|
||||
else:
|
||||
raise
|
||||
|
||||
await conn.commit()
|
||||
|
||||
139
app/models.py
@@ -5,6 +5,64 @@ from pydantic import BaseModel, Field
|
||||
from app.path_utils import normalize_contact_route
|
||||
|
||||
|
||||
class ContactUpsert(BaseModel):
|
||||
"""Typed write contract for contacts persisted to SQLite."""
|
||||
|
||||
public_key: str = Field(description="Public key (64-char hex)")
|
||||
name: str | None = None
|
||||
type: int = 0
|
||||
flags: int = 0
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
out_path_hash_mode: int | None = None
|
||||
route_override_path: str | None = None
|
||||
route_override_len: int | None = None
|
||||
route_override_hash_mode: int | None = None
|
||||
last_advert: int | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
last_seen: int | None = None
|
||||
on_radio: bool | None = None
|
||||
last_contacted: int | None = None
|
||||
first_seen: int | None = None
|
||||
|
||||
@classmethod
|
||||
def from_contact(cls, contact: "Contact", **changes) -> "ContactUpsert":
|
||||
return cls.model_validate(
|
||||
{
|
||||
**contact.model_dump(exclude={"last_read_at"}),
|
||||
**changes,
|
||||
}
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_radio_dict(
|
||||
cls, public_key: str, radio_data: dict, on_radio: bool = False
|
||||
) -> "ContactUpsert":
|
||||
"""Convert radio contact data to the contact-row write shape."""
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
radio_data.get("out_path"),
|
||||
radio_data.get("out_path_len", -1),
|
||||
radio_data.get(
|
||||
"out_path_hash_mode",
|
||||
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
|
||||
),
|
||||
)
|
||||
return cls(
|
||||
public_key=public_key,
|
||||
name=radio_data.get("adv_name"),
|
||||
type=radio_data.get("type", 0),
|
||||
flags=radio_data.get("flags", 0),
|
||||
last_path=last_path,
|
||||
last_path_len=last_path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
lat=radio_data.get("adv_lat"),
|
||||
lon=radio_data.get("adv_lon"),
|
||||
last_advert=radio_data.get("last_advert"),
|
||||
on_radio=on_radio,
|
||||
)
|
||||
|
||||
|
||||
class Contact(BaseModel):
|
||||
public_key: str = Field(description="Public key (64-char hex)")
|
||||
name: str | None = None
|
||||
@@ -13,6 +71,9 @@ class Contact(BaseModel):
|
||||
last_path: str | None = None
|
||||
last_path_len: int = -1
|
||||
out_path_hash_mode: int = 0
|
||||
route_override_path: str | None = None
|
||||
route_override_len: int | None = None
|
||||
route_override_hash_mode: int | None = None
|
||||
last_advert: int | None = None
|
||||
lat: float | None = None
|
||||
lon: float | None = None
|
||||
@@ -22,17 +83,29 @@ class Contact(BaseModel):
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
first_seen: int | None = None
|
||||
|
||||
def has_route_override(self) -> bool:
|
||||
return self.route_override_len is not None
|
||||
|
||||
def effective_route(self) -> tuple[str, int, int]:
|
||||
if self.has_route_override():
|
||||
return normalize_contact_route(
|
||||
self.route_override_path,
|
||||
self.route_override_len,
|
||||
self.route_override_hash_mode,
|
||||
)
|
||||
return normalize_contact_route(
|
||||
self.last_path,
|
||||
self.last_path_len,
|
||||
self.out_path_hash_mode,
|
||||
)
|
||||
|
||||
def to_radio_dict(self) -> dict:
|
||||
"""Convert to the dict format expected by meshcore radio commands.
|
||||
|
||||
The radio API uses different field names (adv_name, out_path, etc.)
|
||||
than our database schema (name, last_path, etc.).
|
||||
"""
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
self.last_path,
|
||||
self.last_path_len,
|
||||
self.out_path_hash_mode,
|
||||
)
|
||||
last_path, last_path_len, out_path_hash_mode = self.effective_route()
|
||||
return {
|
||||
"public_key": self.public_key,
|
||||
"adv_name": self.name or "",
|
||||
@@ -46,34 +119,18 @@ class Contact(BaseModel):
|
||||
"last_advert": self.last_advert if self.last_advert is not None else 0,
|
||||
}
|
||||
|
||||
def to_upsert(self, **changes) -> ContactUpsert:
|
||||
"""Convert the stored contact to the repository's write contract."""
|
||||
return ContactUpsert.from_contact(self, **changes)
|
||||
|
||||
@staticmethod
|
||||
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
|
||||
"""Convert radio contact data to database format dict.
|
||||
|
||||
This is the inverse of to_radio_dict(), used when syncing contacts
|
||||
from radio to database.
|
||||
"""
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
radio_data.get("out_path"),
|
||||
radio_data.get("out_path_len", -1),
|
||||
radio_data.get(
|
||||
"out_path_hash_mode",
|
||||
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
|
||||
),
|
||||
)
|
||||
return {
|
||||
"public_key": public_key,
|
||||
"name": radio_data.get("adv_name"),
|
||||
"type": radio_data.get("type", 0),
|
||||
"flags": radio_data.get("flags", 0),
|
||||
"last_path": last_path,
|
||||
"last_path_len": last_path_len,
|
||||
"out_path_hash_mode": out_path_hash_mode,
|
||||
"lat": radio_data.get("adv_lat"),
|
||||
"lon": radio_data.get("adv_lon"),
|
||||
"last_advert": radio_data.get("last_advert"),
|
||||
"on_radio": on_radio,
|
||||
}
|
||||
"""Backward-compatible dict wrapper over ContactUpsert.from_radio_dict()."""
|
||||
return ContactUpsert.from_radio_dict(
|
||||
public_key,
|
||||
radio_data,
|
||||
on_radio=on_radio,
|
||||
).model_dump()
|
||||
|
||||
|
||||
class CreateContactRequest(BaseModel):
|
||||
@@ -87,6 +144,18 @@ class CreateContactRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ContactRoutingOverrideRequest(BaseModel):
|
||||
"""Request to set, force, or clear a contact routing override."""
|
||||
|
||||
route: str = Field(
|
||||
description=(
|
||||
"Blank clears the override and resets learned routing to flood, "
|
||||
'"-1" forces flood, "0" forces direct, and explicit routes are '
|
||||
"comma-separated 1/2/3-byte hop hex values"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# Contact type constants
|
||||
CONTACT_TYPE_REPEATER = 2
|
||||
|
||||
@@ -161,6 +230,10 @@ class Channel(BaseModel):
|
||||
name: str
|
||||
is_hashtag: bool = False
|
||||
on_radio: bool = False
|
||||
flood_scope_override: str | None = Field(
|
||||
default=None,
|
||||
description="Per-channel outbound flood scope override (null = use global app setting)",
|
||||
)
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
|
||||
|
||||
@@ -443,8 +516,8 @@ class AppSettings(BaseModel):
|
||||
max_radio_contacts: int = Field(
|
||||
default=200,
|
||||
description=(
|
||||
"Maximum contacts to keep on radio for DM ACKs "
|
||||
"(favorite contacts first, then recent non-repeaters)"
|
||||
"Configured radio contact capacity used for maintenance thresholds; "
|
||||
"favorites reload first, then background fill targets about 80% of this value"
|
||||
),
|
||||
)
|
||||
favorites: list[Favorite] = Field(
|
||||
|
||||
@@ -29,20 +29,24 @@ from app.decoder import (
|
||||
)
|
||||
from app.keystore import get_private_key, get_public_key, has_private_key
|
||||
from app.models import (
|
||||
CONTACT_TYPE_REPEATER,
|
||||
Message,
|
||||
MessagePath,
|
||||
Contact,
|
||||
ContactUpsert,
|
||||
RawPacketBroadcast,
|
||||
RawPacketDecryptedInfo,
|
||||
)
|
||||
from app.repository import (
|
||||
ChannelRepository,
|
||||
ContactAdvertPathRepository,
|
||||
ContactNameHistoryRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
RawPacketRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import record_contact_name_and_reconcile
|
||||
from app.services.messages import (
|
||||
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
|
||||
)
|
||||
from app.services.messages import (
|
||||
create_message_from_decrypted as _create_message_from_decrypted,
|
||||
)
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -50,77 +54,6 @@ logger = logging.getLogger(__name__)
|
||||
_raw_observation_counter = count(1)
|
||||
|
||||
|
||||
async def _handle_duplicate_message(
|
||||
packet_id: int,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
path: str | None,
|
||||
received: int,
|
||||
path_len: int | None = None,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record.
|
||||
|
||||
Called when MessageRepository.create returns None (INSERT OR IGNORE hit a duplicate).
|
||||
Looks up the existing message, adds the new path, increments ack count for outgoing
|
||||
messages, and broadcasts the update to clients.
|
||||
"""
|
||||
existing_msg = await MessageRepository.get_by_content(
|
||||
msg_type=msg_type,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
if not existing_msg:
|
||||
label = "message" if msg_type == "CHAN" else "DM"
|
||||
logger.warning(
|
||||
"Duplicate %s for %s but couldn't find existing",
|
||||
label,
|
||||
conversation_key[:12],
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path",
|
||||
msg_type,
|
||||
conversation_key[:12],
|
||||
existing_msg.id,
|
||||
existing_msg.outgoing,
|
||||
)
|
||||
|
||||
# Add path if provided
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received, path_len)
|
||||
else:
|
||||
# Get current paths for broadcast
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
# Increment ack count for outgoing messages (echo confirmation)
|
||||
if existing_msg.outgoing:
|
||||
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
|
||||
# Only broadcast when something actually changed:
|
||||
# - outgoing: ack count was incremented
|
||||
# - path provided: a new path entry was appended
|
||||
# The path=None case happens for direct-delivery DMs (0-hop, no routing bytes).
|
||||
# A non-outgoing duplicate with no new path changes nothing in the DB, so skip.
|
||||
if existing_msg.outgoing or path is not None:
|
||||
broadcast_event(
|
||||
"message_acked",
|
||||
{
|
||||
"message_id": existing_msg.id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [p.model_dump() for p in paths] if paths else [],
|
||||
},
|
||||
)
|
||||
|
||||
# Mark this packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
|
||||
|
||||
async def create_message_from_decrypted(
|
||||
packet_id: int,
|
||||
channel_key: str,
|
||||
@@ -133,95 +66,21 @@ async def create_message_from_decrypted(
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted channel packet content.
|
||||
|
||||
This is the shared logic for storing decrypted channel messages,
|
||||
used by both real-time packet processing and historical decryption.
|
||||
|
||||
Args:
|
||||
packet_id: ID of the raw packet being processed
|
||||
channel_key: Hex string channel key
|
||||
channel_name: Channel name (e.g. "#general"), for bot context
|
||||
sender: Sender name (will be prefixed to message) or None
|
||||
message_text: The decrypted message content
|
||||
timestamp: Sender timestamp from the packet
|
||||
received_at: When the packet was received (defaults to now)
|
||||
path: Hex-encoded routing path
|
||||
realtime: If False, skip fanout dispatch (used for historical decryption)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
received = received_at or int(time.time())
|
||||
|
||||
# Format the message text with sender prefix if present
|
||||
text = f"{sender}: {message_text}" if sender else message_text
|
||||
|
||||
# Normalize channel key to uppercase for consistency
|
||||
channel_key_normalized = channel_key.upper()
|
||||
|
||||
# Resolve sender_key: look up contact by exact name match
|
||||
resolved_sender_key: str | None = None
|
||||
if sender:
|
||||
candidates = await ContactRepository.get_by_name(sender)
|
||||
if len(candidates) == 1:
|
||||
resolved_sender_key = candidates[0].public_key
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=channel_key_normalized,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
"""Store a decrypted channel message via the shared message service."""
|
||||
return await _create_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
channel_key=channel_key,
|
||||
sender=sender,
|
||||
message_text=message_text,
|
||||
timestamp=timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
# Duplicate message detected - this happens when:
|
||||
# 1. Our own outgoing message echoes back (flood routing)
|
||||
# 2. Same message arrives via multiple paths before first is committed
|
||||
# In either case, add the path to the existing message.
|
||||
await _handle_duplicate_message(
|
||||
packet_id, "CHAN", channel_key_normalized, text, timestamp, path, received, path_len
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8])
|
||||
|
||||
# Mark the raw packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
# Build paths array for broadcast
|
||||
# Use "is not None" to include empty string (direct/0-hop messages)
|
||||
paths = (
|
||||
[MessagePath(path=path or "", received_at=received, path_len=path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Broadcast new message to connected clients (and fanout modules when realtime)
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=msg_id,
|
||||
type="CHAN",
|
||||
conversation_key=channel_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
paths=paths,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
).model_dump(),
|
||||
channel_name=channel_name,
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_dm_message_from_decrypted(
|
||||
packet_id: int,
|
||||
@@ -234,111 +93,20 @@ async def create_dm_message_from_decrypted(
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
) -> int | None:
|
||||
"""Create a message record from decrypted direct message packet content.
|
||||
|
||||
This is the shared logic for storing decrypted direct messages,
|
||||
used by both real-time packet processing and historical decryption.
|
||||
|
||||
Args:
|
||||
packet_id: ID of the raw packet being processed
|
||||
decrypted: DecryptedDirectMessage from decoder
|
||||
their_public_key: The contact's full 64-char public key (conversation_key)
|
||||
our_public_key: Our public key (to determine direction), or None
|
||||
received_at: When the packet was received (defaults to now)
|
||||
path: Hex-encoded routing path
|
||||
outgoing: Whether this is an outgoing message (we sent it)
|
||||
realtime: If False, skip fanout dispatch (used for historical decryption)
|
||||
|
||||
Returns the message ID if created, None if duplicate.
|
||||
"""
|
||||
# Check if sender is a repeater - repeaters only send CLI responses, not chat messages.
|
||||
# CLI responses are handled by the command endpoint, not stored in chat history.
|
||||
contact = await ContactRepository.get_by_key(their_public_key)
|
||||
if contact and contact.type == CONTACT_TYPE_REPEATER:
|
||||
logger.debug(
|
||||
"Skipping message from repeater %s (CLI responses not stored): %s",
|
||||
their_public_key[:12],
|
||||
(decrypted.message or "")[:50],
|
||||
)
|
||||
return None
|
||||
|
||||
received = received_at or int(time.time())
|
||||
|
||||
# conversation_key is always the other party's public key
|
||||
conversation_key = their_public_key.lower()
|
||||
|
||||
# Resolve sender name for incoming messages (used for name-based blocking)
|
||||
sender_name = contact.name if contact and not outgoing else None
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=decrypted.message,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
"""Store a decrypted direct message via the shared message service."""
|
||||
return await _create_dm_message_from_decrypted(
|
||||
packet_id=packet_id,
|
||||
decrypted=decrypted,
|
||||
their_public_key=their_public_key,
|
||||
our_public_key=our_public_key,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
outgoing=outgoing,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
# Duplicate message detected
|
||||
await _handle_duplicate_message(
|
||||
packet_id,
|
||||
"PRIV",
|
||||
conversation_key,
|
||||
decrypted.message,
|
||||
decrypted.timestamp,
|
||||
path,
|
||||
received,
|
||||
path_len,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Stored direct message %d for contact %s (outgoing=%s)",
|
||||
msg_id,
|
||||
conversation_key[:12],
|
||||
outgoing,
|
||||
)
|
||||
|
||||
# Mark the raw packet as decrypted
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
# Build paths array for broadcast
|
||||
paths = (
|
||||
[MessagePath(path=path or "", received_at=received, path_len=path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
# Broadcast new message to connected clients (and fanout modules when realtime)
|
||||
sender_name = contact.name if contact and not outgoing else None
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=msg_id,
|
||||
type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
paths=paths,
|
||||
outgoing=outgoing,
|
||||
sender_name=sender_name,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
).model_dump(),
|
||||
realtime=realtime,
|
||||
broadcast_fn=broadcast_event,
|
||||
)
|
||||
|
||||
# Update contact's last_contacted timestamp (for sorting)
|
||||
await ContactRepository.update_last_contacted(conversation_key, received)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
async def run_historical_dm_decryption(
|
||||
private_key_bytes: bytes,
|
||||
@@ -646,7 +414,6 @@ async def _process_advertisement(
|
||||
Process an advertisement packet.
|
||||
|
||||
Extracts contact info and updates the database/broadcasts to clients.
|
||||
For non-repeater contacts, triggers sync of recent contacts to radio for DM ACK support.
|
||||
"""
|
||||
# Parse packet to get path info if not already provided
|
||||
if packet_info is None:
|
||||
@@ -722,46 +489,27 @@ async def _process_advertisement(
|
||||
hop_count=new_path_len,
|
||||
)
|
||||
|
||||
# Record name history
|
||||
if advert.name:
|
||||
await ContactNameHistoryRepository.record_name(
|
||||
public_key=advert.public_key.lower(),
|
||||
name=advert.name,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=advert.public_key.lower(),
|
||||
name=advert.name,
|
||||
type=contact_type,
|
||||
lat=advert.lat,
|
||||
lon=advert.lon,
|
||||
last_advert=advert.timestamp if advert.timestamp > 0 else timestamp,
|
||||
last_seen=timestamp,
|
||||
last_path=path_hex,
|
||||
last_path_len=path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
first_seen=timestamp, # COALESCE in upsert preserves existing value
|
||||
)
|
||||
|
||||
contact_data = {
|
||||
"public_key": advert.public_key.lower(),
|
||||
"name": advert.name,
|
||||
"type": contact_type,
|
||||
"lat": advert.lat,
|
||||
"lon": advert.lon,
|
||||
"last_advert": advert.timestamp if advert.timestamp > 0 else timestamp,
|
||||
"last_seen": timestamp,
|
||||
"last_path": path_hex,
|
||||
"last_path_len": path_len,
|
||||
"out_path_hash_mode": out_path_hash_mode,
|
||||
"first_seen": timestamp, # COALESCE in upsert preserves existing value
|
||||
}
|
||||
|
||||
await ContactRepository.upsert(contact_data)
|
||||
claimed = await MessageRepository.claim_prefix_messages(advert.public_key.lower())
|
||||
if claimed > 0:
|
||||
logger.info(
|
||||
"Claimed %d prefix DM message(s) for contact %s",
|
||||
claimed,
|
||||
advert.public_key[:12],
|
||||
)
|
||||
if advert.name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(
|
||||
advert.public_key, advert.name
|
||||
)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
advert.name,
|
||||
)
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=advert.public_key,
|
||||
contact_name=advert.name,
|
||||
timestamp=timestamp,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# Read back from DB so the broadcast includes all fields (last_contacted,
|
||||
# last_read_at, flags, on_radio, etc.) matching the REST Contact shape exactly.
|
||||
@@ -769,7 +517,10 @@ async def _process_advertisement(
|
||||
if db_contact:
|
||||
broadcast_event("contact", db_contact.model_dump())
|
||||
else:
|
||||
broadcast_event("contact", contact_data)
|
||||
broadcast_event(
|
||||
"contact",
|
||||
Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump(),
|
||||
)
|
||||
|
||||
# For new contacts, optionally attempt to decrypt any historical DMs we may have stored
|
||||
# This is controlled by the auto_decrypt_dm_on_advert setting
|
||||
@@ -780,14 +531,6 @@ async def _process_advertisement(
|
||||
if settings.auto_decrypt_dm_on_advert:
|
||||
await start_historical_dm_decryption(None, advert.public_key.lower(), advert.name)
|
||||
|
||||
# If this is not a repeater, trigger recent contacts sync to radio
|
||||
# This ensures we can auto-ACK DMs from recent contacts
|
||||
if contact_type != CONTACT_TYPE_REPEATER:
|
||||
# Import here to avoid circular import
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
|
||||
asyncio.create_task(sync_recent_contacts_to_radio())
|
||||
|
||||
|
||||
async def _process_direct_message(
|
||||
raw_bytes: bytes,
|
||||
|
||||
@@ -202,3 +202,45 @@ def normalize_contact_route(
|
||||
normalized_len = actual_bytes // bytes_per_hop
|
||||
|
||||
return normalized_path, normalized_len, normalized_mode
|
||||
|
||||
|
||||
def normalize_route_override(
|
||||
path_hex: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None,
|
||||
) -> tuple[str | None, int | None, int | None]:
|
||||
"""Normalize optional route-override fields while preserving the unset state."""
|
||||
if path_len is None:
|
||||
return None, None, None
|
||||
|
||||
normalized_path, normalized_len, normalized_mode = normalize_contact_route(
|
||||
path_hex,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
)
|
||||
return normalized_path, normalized_len, normalized_mode
|
||||
|
||||
|
||||
def parse_explicit_hop_route(route_text: str) -> tuple[str, int, int]:
|
||||
"""Parse a comma-separated explicit hop route into stored contact fields."""
|
||||
hops = [hop.strip().lower() for hop in route_text.split(",") if hop.strip()]
|
||||
if not hops:
|
||||
raise ValueError("Explicit path must include at least one hop")
|
||||
|
||||
hop_chars = len(hops[0])
|
||||
if hop_chars not in (2, 4, 6):
|
||||
raise ValueError("Each hop must be 1, 2, or 3 bytes of hex")
|
||||
|
||||
for hop in hops:
|
||||
if len(hop) != hop_chars:
|
||||
raise ValueError("All hops must use the same width")
|
||||
try:
|
||||
bytes.fromhex(hop)
|
||||
except ValueError as exc:
|
||||
raise ValueError("Each hop must be valid hex") from exc
|
||||
|
||||
hash_size = hop_chars // 2
|
||||
if path_wire_len(len(hops), hash_size) > MAX_PATH_SIZE:
|
||||
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
|
||||
|
||||
return "".join(hops), len(hops), hash_size - 1
|
||||
|
||||
214
app/radio.py
@@ -217,145 +217,10 @@ class RadioManager:
|
||||
self._release_operation_lock(name)
|
||||
|
||||
async def post_connect_setup(self) -> None:
|
||||
"""Full post-connection setup: handlers, key export, sync, advertisements, polling.
|
||||
"""Run shared post-connection orchestration after transport setup succeeds."""
|
||||
from app.services.radio_lifecycle import run_post_connect_setup
|
||||
|
||||
Called after every successful connection or reconnection.
|
||||
Idempotent — safe to call repeatedly (periodic tasks have start guards).
|
||||
"""
|
||||
from app.event_handlers import register_event_handlers
|
||||
from app.keystore import export_and_store_private_key
|
||||
from app.radio_sync import (
|
||||
drain_pending_messages,
|
||||
send_advertisement,
|
||||
start_message_polling,
|
||||
start_periodic_advert,
|
||||
start_periodic_sync,
|
||||
sync_and_offload_all,
|
||||
sync_radio_time,
|
||||
)
|
||||
|
||||
if not self._meshcore:
|
||||
return
|
||||
|
||||
if self._setup_lock is None:
|
||||
self._setup_lock = asyncio.Lock()
|
||||
|
||||
async with self._setup_lock:
|
||||
if not self._meshcore:
|
||||
return
|
||||
self._setup_in_progress = True
|
||||
self._setup_complete = False
|
||||
mc = self._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 self._acquire_operation_lock("post_connect_setup", blocking=True)
|
||||
try:
|
||||
await export_and_store_private_key(mc)
|
||||
|
||||
# Sync radio clock with system time
|
||||
await sync_radio_time(mc)
|
||||
|
||||
# Apply flood scope from settings (best-effort; older firmware
|
||||
# may not support set_flood_scope)
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
scope = app_settings.flood_scope
|
||||
try:
|
||||
await mc.commands.set_flood_scope(scope if scope else "")
|
||||
logger.info("Applied flood_scope=%r", scope or "(disabled)")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"set_flood_scope failed (firmware may not support it): %s", exc
|
||||
)
|
||||
|
||||
# Query path hash mode support (best-effort; older firmware won't report it).
|
||||
# If the library's parsed payload is missing path_hash_mode (e.g. stale
|
||||
# .pyc on WSL2 Windows mounts), fall back to raw-frame extraction.
|
||||
reader = mc._reader
|
||||
_original_handle_rx = reader.handle_rx
|
||||
_captured_frame: list[bytes] = []
|
||||
|
||||
async def _capture_handle_rx(data: bytearray) -> None:
|
||||
from meshcore.packets import PacketType
|
||||
|
||||
if len(data) > 0 and data[0] == PacketType.DEVICE_INFO.value:
|
||||
_captured_frame.append(bytes(data))
|
||||
return await _original_handle_rx(data)
|
||||
|
||||
reader.handle_rx = _capture_handle_rx
|
||||
self.path_hash_mode = 0
|
||||
self.path_hash_mode_supported = False
|
||||
try:
|
||||
device_query = await mc.commands.send_device_query()
|
||||
if device_query and "path_hash_mode" in device_query.payload:
|
||||
self.path_hash_mode = device_query.payload["path_hash_mode"]
|
||||
self.path_hash_mode_supported = True
|
||||
elif _captured_frame:
|
||||
# Raw-frame fallback: byte 1 = fw_ver, byte 81 = path_hash_mode
|
||||
raw = _captured_frame[-1]
|
||||
fw_ver = raw[1] if len(raw) > 1 else 0
|
||||
if fw_ver >= 10 and len(raw) >= 82:
|
||||
self.path_hash_mode = raw[81]
|
||||
self.path_hash_mode_supported = True
|
||||
logger.warning(
|
||||
"path_hash_mode=%d extracted from raw frame "
|
||||
"(stale .pyc? try: rm %s)",
|
||||
self.path_hash_mode,
|
||||
getattr(
|
||||
__import__("meshcore.reader", fromlist=["reader"]),
|
||||
"__cached__",
|
||||
"meshcore __pycache__/reader.*.pyc",
|
||||
),
|
||||
)
|
||||
if self.path_hash_mode_supported:
|
||||
logger.info("Path hash mode: %d (supported)", self.path_hash_mode)
|
||||
else:
|
||||
logger.debug("Firmware does not report path_hash_mode")
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to query path_hash_mode: %s", exc)
|
||||
finally:
|
||||
reader.handle_rx = _original_handle_rx
|
||||
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
logger.info("Sync complete: %s", result)
|
||||
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
|
||||
# Drain any messages that were queued before we connected.
|
||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||
# compete on get_msg() with interleaved radio I/O.
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
|
||||
await mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
finally:
|
||||
self._release_operation_lock("post_connect_setup")
|
||||
|
||||
# Start background tasks AFTER releasing the operation lock.
|
||||
# These tasks acquire their own locks when they need radio access.
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
|
||||
self._setup_complete = True
|
||||
finally:
|
||||
self._setup_in_progress = False
|
||||
|
||||
logger.info("Post-connect setup complete")
|
||||
await run_post_connect_setup(self)
|
||||
|
||||
@property
|
||||
def meshcore(self) -> MeshCore | None:
|
||||
@@ -509,83 +374,18 @@ class RadioManager:
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
logger.warning("Reconnection failed: %s", e)
|
||||
logger.warning("Reconnection failed: %s", e, exc_info=True)
|
||||
broadcast_error("Reconnection failed", str(e))
|
||||
return False
|
||||
|
||||
async def start_connection_monitor(self) -> None:
|
||||
"""Start background task to monitor connection and auto-reconnect."""
|
||||
from app.services.radio_lifecycle import connection_monitor_loop
|
||||
|
||||
if self._reconnect_task is not None:
|
||||
return
|
||||
|
||||
async def monitor_loop():
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
CHECK_INTERVAL_SECONDS = 5
|
||||
UNRESPONSIVE_THRESHOLD = 3
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(CHECK_INTERVAL_SECONDS)
|
||||
|
||||
current_connected = self.is_connected
|
||||
|
||||
# Detect status change
|
||||
if self._last_connected and not current_connected:
|
||||
# Connection lost
|
||||
logger.warning("Radio connection lost, broadcasting status change")
|
||||
broadcast_health(False, self._connection_info)
|
||||
self._last_connected = False
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
if not current_connected:
|
||||
# Attempt reconnection on every loop while disconnected
|
||||
if not self.is_reconnecting and await self.reconnect(
|
||||
broadcast_on_success=False
|
||||
):
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
self._last_connected = True
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
elif not self._last_connected and current_connected:
|
||||
# Connection restored (might have reconnected automatically).
|
||||
# Always run setup before reporting healthy.
|
||||
logger.info("Radio connection restored")
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
self._last_connected = True
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
elif current_connected and not self._setup_complete:
|
||||
# Transport connected but setup incomplete — retry
|
||||
logger.info("Retrying post-connect setup...")
|
||||
await self.post_connect_setup()
|
||||
broadcast_health(True, self._connection_info)
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
except asyncio.CancelledError:
|
||||
# Task is being cancelled, exit cleanly
|
||||
break
|
||||
except Exception as e:
|
||||
consecutive_setup_failures += 1
|
||||
if consecutive_setup_failures == UNRESPONSIVE_THRESHOLD:
|
||||
logger.error(
|
||||
"Post-connect setup has failed %d times in a row. "
|
||||
"The radio port appears open but the radio is not "
|
||||
"responding to commands. Common causes: another "
|
||||
"process has the serial port open (check for other "
|
||||
"RemoteTerm instances, serial monitors, etc.), the "
|
||||
"firmware is in repeater mode (not client), or the "
|
||||
"radio needs a power cycle. Will keep retrying.",
|
||||
consecutive_setup_failures,
|
||||
)
|
||||
elif consecutive_setup_failures < UNRESPONSIVE_THRESHOLD:
|
||||
logger.exception("Error in connection monitor, continuing: %s", e)
|
||||
# After the threshold, silently retry (avoid log spam)
|
||||
|
||||
self._reconnect_task = asyncio.create_task(monitor_loop())
|
||||
self._reconnect_task = asyncio.create_task(connection_monitor_loop(self))
|
||||
logger.info("Radio connection monitor started")
|
||||
|
||||
async def stop_connection_monitor(self) -> None:
|
||||
|
||||
@@ -4,28 +4,32 @@ Radio sync and offload management.
|
||||
This module handles syncing contacts and channels from the radio to the database,
|
||||
then removing them from the radio to free up space for new discoveries.
|
||||
|
||||
Also handles loading recent non-repeater contacts TO the radio for DM ACK support.
|
||||
Also handles loading favorites plus recently active contacts TO the radio for DM ACK support.
|
||||
Also handles periodic message polling as a fallback for platforms where push events
|
||||
don't work reliably.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from meshcore import EventType, MeshCore
|
||||
|
||||
from app.config import settings
|
||||
from app.event_handlers import cleanup_expired_acks
|
||||
from app.models import Contact
|
||||
from app.radio import RadioOperationBusyError, radio_manager
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.radio import RadioOperationBusyError
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,6 +42,9 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
"last_path": contact.last_path,
|
||||
"last_path_len": contact.last_path_len,
|
||||
"out_path_hash_mode": contact.out_path_hash_mode,
|
||||
"route_override_path": contact.route_override_path,
|
||||
"route_override_len": contact.route_override_len,
|
||||
"route_override_hash_mode": contact.route_override_hash_mode,
|
||||
"last_advert": contact.last_advert,
|
||||
"lat": contact.lat,
|
||||
"lon": contact.lon,
|
||||
@@ -45,6 +52,26 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
async def _reconcile_contact_messages_background(
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
) -> None:
|
||||
"""Run contact/message reconciliation outside the radio critical path."""
|
||||
try:
|
||||
await reconcile_contact_messages(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
log=logger,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Background contact reconciliation failed for %s: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> str | None:
|
||||
"""Parse a radio channel-slot payload and upsert to the database.
|
||||
|
||||
@@ -74,9 +101,12 @@ async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> st
|
||||
# Message poll task handle
|
||||
_message_poll_task: asyncio.Task | None = None
|
||||
|
||||
# Message poll interval in seconds (10s gives DM ACKs plenty of time to arrive)
|
||||
# Message poll interval in seconds when aggressive fallback is enabled.
|
||||
MESSAGE_POLL_INTERVAL = 10
|
||||
|
||||
# Always-on audit interval when aggressive fallback is disabled.
|
||||
MESSAGE_POLL_AUDIT_INTERVAL = 3600
|
||||
|
||||
# Periodic advertisement task handle
|
||||
_advert_task: asyncio.Task | None = None
|
||||
|
||||
@@ -115,9 +145,58 @@ async def pause_polling():
|
||||
# Background task handle
|
||||
_sync_task: asyncio.Task | None = None
|
||||
|
||||
# Sync interval in seconds (5 minutes)
|
||||
# Periodic maintenance check interval in seconds (5 minutes)
|
||||
SYNC_INTERVAL = 300
|
||||
|
||||
# Reload non-favorite contacts up to 80% of configured radio capacity after offload.
|
||||
RADIO_CONTACT_REFILL_RATIO = 0.80
|
||||
|
||||
# Trigger a full offload/reload once occupancy reaches 95% of configured capacity.
|
||||
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
|
||||
|
||||
|
||||
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
||||
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
||||
capacity = max(1, max_contacts)
|
||||
refill_target = max(1, min(capacity, int((capacity * RADIO_CONTACT_REFILL_RATIO) + 0.5)))
|
||||
full_sync_trigger = max(
|
||||
refill_target,
|
||||
min(capacity, math.ceil(capacity * RADIO_CONTACT_FULL_SYNC_RATIO)),
|
||||
)
|
||||
return refill_target, full_sync_trigger
|
||||
|
||||
|
||||
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||
"""Check current radio occupancy and decide whether to offload/reload."""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
capacity = app_settings.max_radio_contacts
|
||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||
|
||||
result = await mc.commands.get_contacts()
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.warning("Periodic sync occupancy check failed: %s", result)
|
||||
return False
|
||||
|
||||
current_contacts = len(result.payload or {})
|
||||
if current_contacts >= full_sync_trigger:
|
||||
logger.info(
|
||||
"Running full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
|
||||
current_contacts,
|
||||
capacity,
|
||||
full_sync_trigger,
|
||||
refill_target,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
"Skipping full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
|
||||
current_contacts,
|
||||
capacity,
|
||||
full_sync_trigger,
|
||||
refill_target,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
"""
|
||||
@@ -151,26 +230,14 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
for public_key, contact_data in contacts.items():
|
||||
# Save to database
|
||||
await ContactRepository.upsert(
|
||||
Contact.from_radio_dict(public_key, contact_data, on_radio=False)
|
||||
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
|
||||
)
|
||||
claimed = await MessageRepository.claim_prefix_messages(public_key.lower())
|
||||
if claimed > 0:
|
||||
logger.info(
|
||||
"Claimed %d prefix DM message(s) for contact %s",
|
||||
claimed,
|
||||
public_key[:12],
|
||||
asyncio.create_task(
|
||||
_reconcile_contact_messages_background(
|
||||
public_key,
|
||||
contact_data.get("adv_name"),
|
||||
)
|
||||
adv_name = contact_data.get("adv_name")
|
||||
if adv_name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(
|
||||
public_key, adv_name
|
||||
)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
adv_name,
|
||||
)
|
||||
)
|
||||
synced += 1
|
||||
|
||||
# Remove from radio
|
||||
@@ -299,10 +366,10 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
# Ensure default channels exist
|
||||
await ensure_default_channels()
|
||||
|
||||
# Reload favorites and recent contacts back onto the radio immediately
|
||||
# so favorited contacts don't stay in the on_radio=False limbo until the
|
||||
# next advertisement arrives. 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
|
||||
# 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_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
|
||||
|
||||
return {
|
||||
@@ -383,11 +450,10 @@ async def _message_poll_loop():
|
||||
"""Background task that periodically polls for messages."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(MESSAGE_POLL_INTERVAL)
|
||||
|
||||
# Clean up expired pending ACKs every poll cycle so they don't
|
||||
# accumulate when no ACKs arrive (e.g. all recipients out of range).
|
||||
cleanup_expired_acks()
|
||||
aggressive_fallback = settings.enable_message_poll_fallback
|
||||
await asyncio.sleep(
|
||||
MESSAGE_POLL_INTERVAL if aggressive_fallback else MESSAGE_POLL_AUDIT_INTERVAL
|
||||
)
|
||||
|
||||
if radio_manager.is_connected and not is_polling_paused():
|
||||
try:
|
||||
@@ -398,10 +464,24 @@ async def _message_poll_loop():
|
||||
) as mc:
|
||||
count = await poll_for_messages(mc)
|
||||
if count > 0:
|
||||
logger.warning(
|
||||
"Poll loop caught %d message(s) missed by auto-fetch",
|
||||
count,
|
||||
)
|
||||
if aggressive_fallback:
|
||||
logger.warning(
|
||||
"Poll loop caught %d message(s) missed by auto-fetch",
|
||||
count,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Periodic radio audit caught %d message(s) that were not "
|
||||
"surfaced via event subscription. See README and consider "
|
||||
"setting MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true to "
|
||||
"enable more frequent polling.",
|
||||
count,
|
||||
)
|
||||
broadcast_error(
|
||||
"A periodic poll task has discovered radio inconsistencies.",
|
||||
"Please check the logs for recommendations (search "
|
||||
"'MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK').",
|
||||
)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping message poll: radio busy")
|
||||
|
||||
@@ -416,7 +496,16 @@ def start_message_polling():
|
||||
global _message_poll_task
|
||||
if _message_poll_task is None or _message_poll_task.done():
|
||||
_message_poll_task = asyncio.create_task(_message_poll_loop())
|
||||
logger.info("Started periodic message polling (interval: %ds)", MESSAGE_POLL_INTERVAL)
|
||||
if settings.enable_message_poll_fallback:
|
||||
logger.info(
|
||||
"Started periodic message polling task (aggressive fallback, interval: %ds)",
|
||||
MESSAGE_POLL_INTERVAL,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Started periodic message audit task (interval: %ds)",
|
||||
MESSAGE_POLL_AUDIT_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
async def stop_message_polling():
|
||||
@@ -483,7 +572,7 @@ async def send_advertisement(mc: MeshCore, *, force: bool = False) -> bool:
|
||||
logger.warning("Failed to send advertisement: %s", result.payload)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.warning("Error sending advertisement: %s", e)
|
||||
logger.warning("Error sending advertisement: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -514,7 +603,7 @@ async def _periodic_advert_loop():
|
||||
logger.info("Periodic advertisement task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in periodic advertisement loop: %s", e)
|
||||
logger.error("Error in periodic advertisement loop: %s", e, exc_info=True)
|
||||
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
|
||||
|
||||
|
||||
@@ -554,7 +643,7 @@ async def sync_radio_time(mc: MeshCore) -> bool:
|
||||
logger.debug("Synced radio time to %d", now)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning("Failed to sync radio time: %s", e)
|
||||
logger.warning("Failed to sync radio time: %s", e, exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@@ -563,6 +652,7 @@ async def _periodic_sync_loop():
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(SYNC_INTERVAL)
|
||||
cleanup_expired_acks()
|
||||
if not radio_manager.is_connected:
|
||||
continue
|
||||
|
||||
@@ -571,8 +661,8 @@ async def _periodic_sync_loop():
|
||||
"periodic_sync",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
logger.debug("Running periodic radio sync")
|
||||
await sync_and_offload_all(mc)
|
||||
if await should_run_full_periodic_sync(mc):
|
||||
await sync_and_offload_all(mc)
|
||||
await sync_radio_time(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping periodic sync: radio busy")
|
||||
@@ -580,7 +670,7 @@ async def _periodic_sync_loop():
|
||||
logger.info("Periodic sync task cancelled")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in periodic sync: %s", e)
|
||||
logger.error("Error in periodic sync: %s", e, exc_info=True)
|
||||
|
||||
|
||||
def start_periodic_sync():
|
||||
@@ -613,13 +703,19 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
"""
|
||||
Core logic for loading contacts onto the radio.
|
||||
|
||||
Favorite contacts are prioritized first, then recent non-repeater contacts
|
||||
fill remaining slots up to max_radio_contacts.
|
||||
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.
|
||||
"""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
max_contacts = app_settings.max_radio_contacts
|
||||
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||
selected_contacts: list[Contact] = []
|
||||
selected_keys: set[str] = set()
|
||||
|
||||
@@ -646,34 +742,101 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
if len(selected_contacts) >= max_contacts:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < max_contacts:
|
||||
recent_contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts)
|
||||
for contact in recent_contacts:
|
||||
if len(selected_contacts) < refill_target:
|
||||
for contact in await ContactRepository.get_recently_contacted_non_repeaters(
|
||||
limit=max_contacts
|
||||
):
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
if len(selected_contacts) >= max_contacts:
|
||||
if len(selected_contacts) >= refill_target:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < refill_target:
|
||||
for contact in await ContactRepository.get_recently_advertised_non_repeaters(
|
||||
limit=max_contacts
|
||||
):
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
if len(selected_contacts) >= refill_target:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"Selected %d contacts to sync (%d favorite contacts first, limit=%d)",
|
||||
"Selected %d contacts to sync (%d favorites, refill_target=%d, capacity=%d)",
|
||||
len(selected_contacts),
|
||||
favorite_contacts_loaded,
|
||||
refill_target,
|
||||
max_contacts,
|
||||
)
|
||||
return await _load_contacts_to_radio(mc, selected_contacts)
|
||||
|
||||
|
||||
async def ensure_contact_on_radio(
|
||||
public_key: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
mc: MeshCore | None = None,
|
||||
) -> dict:
|
||||
"""Ensure one contact is loaded on the radio for ACK/routing support."""
|
||||
global _last_contact_sync
|
||||
|
||||
now = time.time()
|
||||
if not force and (now - _last_contact_sync) < CONTACT_SYNC_THROTTLE_SECONDS:
|
||||
logger.debug(
|
||||
"Single-contact sync throttled (last sync %ds ago)",
|
||||
int(now - _last_contact_sync),
|
||||
)
|
||||
return {"loaded": 0, "throttled": True}
|
||||
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning("Cannot sync favorite contact '%s': ambiguous key prefix", public_key)
|
||||
return {"loaded": 0, "error": "Ambiguous contact key prefix"}
|
||||
|
||||
if not contact:
|
||||
logger.debug("Cannot sync favorite contact %s: not found", public_key[:12])
|
||||
return {"loaded": 0, "error": "Contact not found"}
|
||||
|
||||
if mc is not None:
|
||||
_last_contact_sync = now
|
||||
return await _load_contacts_to_radio(mc, [contact])
|
||||
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.debug("Cannot sync favorite contact to radio: not connected")
|
||||
return {"loaded": 0, "error": "Radio not connected"}
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"ensure_contact_on_radio",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
_last_contact_sync = now
|
||||
assert mc is not None
|
||||
return await _load_contacts_to_radio(mc, [contact])
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping favorite contact sync: radio busy")
|
||||
return {"loaded": 0, "busy": True}
|
||||
except Exception as e:
|
||||
logger.error("Error syncing favorite contact to radio: %s", e, exc_info=True)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
|
||||
|
||||
async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict:
|
||||
"""Load the provided contacts onto the radio."""
|
||||
loaded = 0
|
||||
already_on_radio = 0
|
||||
failed = 0
|
||||
|
||||
for contact in selected_contacts:
|
||||
# Check if already on radio
|
||||
for contact in contacts:
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
already_on_radio += 1
|
||||
# Update DB if not marked as on_radio
|
||||
if not contact.on_radio:
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
continue
|
||||
@@ -731,8 +894,10 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
"""
|
||||
Load contacts to the radio for DM ACK support.
|
||||
|
||||
Favorite contacts are prioritized first, then recent non-repeater contacts
|
||||
fill remaining slots up to max_radio_contacts.
|
||||
Fill order is favorites, then recently contacted non-repeaters,
|
||||
then recently advert-heard non-repeaters. Favorites are always reloaded
|
||||
up to the configured capacity; additional non-favorite fill stops at the
|
||||
80% refill target.
|
||||
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
|
||||
|
||||
Args:
|
||||
@@ -754,6 +919,7 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
# If caller provided a MeshCore instance, use it directly (caller holds the lock)
|
||||
if mc is not None:
|
||||
_last_contact_sync = now
|
||||
assert mc is not None
|
||||
return await _sync_contacts_to_radio_inner(mc)
|
||||
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
@@ -766,11 +932,12 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
blocking=False,
|
||||
) as mc:
|
||||
_last_contact_sync = now
|
||||
assert mc is not None
|
||||
return await _sync_contacts_to_radio_inner(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping contact sync to radio: radio busy")
|
||||
return {"loaded": 0, "busy": True}
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error syncing contacts to radio: %s", e)
|
||||
logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
|
||||
20
app/region_scope.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Helpers for normalizing MeshCore flood-scope / region names."""
|
||||
|
||||
|
||||
def normalize_region_scope(scope: str | None) -> str:
|
||||
"""Normalize a user-facing region scope into MeshCore's internal form.
|
||||
|
||||
Region names are now user-facing plain strings like ``Esperance``.
|
||||
Internally, MeshCore still expects hashtag-style names like ``#Esperance``.
|
||||
|
||||
Backward compatibility:
|
||||
- blank/None stays disabled (`""`)
|
||||
- existing leading ``#`` is preserved
|
||||
"""
|
||||
|
||||
stripped = (scope or "").strip()
|
||||
if not stripped:
|
||||
return ""
|
||||
if stripped.startswith("#"):
|
||||
return stripped
|
||||
return f"#{stripped}"
|
||||
@@ -10,8 +10,8 @@ class ChannelRepository:
|
||||
"""Upsert a channel. Key is 32-char hex string."""
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO channels (key, name, is_hashtag, on_radio)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO channels (key, name, is_hashtag, on_radio, flood_scope_override)
|
||||
VALUES (?, ?, ?, ?, NULL)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
is_hashtag = excluded.is_hashtag,
|
||||
@@ -25,7 +25,11 @@ class ChannelRepository:
|
||||
async def get_by_key(key: str) -> Channel | None:
|
||||
"""Get a channel by its key (32-char hex string)."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio, last_read_at FROM channels WHERE key = ?",
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
FROM channels
|
||||
WHERE key = ?
|
||||
""",
|
||||
(key.upper(),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
@@ -35,6 +39,7 @@ class ChannelRepository:
|
||||
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"],
|
||||
)
|
||||
return None
|
||||
@@ -42,7 +47,11 @@ class ChannelRepository:
|
||||
@staticmethod
|
||||
async def get_all() -> list[Channel]:
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT key, name, is_hashtag, on_radio, last_read_at FROM channels ORDER BY name"
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
FROM channels
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
@@ -51,6 +60,7 @@ class ChannelRepository:
|
||||
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
|
||||
@@ -79,6 +89,16 @@ class ChannelRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def update_flood_scope_override(key: str, flood_scope_override: str | None) -> bool:
|
||||
"""Set or clear a channel's flood-scope override."""
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET flood_scope_override = ? WHERE key = ?",
|
||||
(flood_scope_override, key.upper()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all channels as read at the given timestamp."""
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
@@ -7,8 +8,9 @@ from app.models import (
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactNameHistory,
|
||||
ContactUpsert,
|
||||
)
|
||||
from app.path_utils import first_hop_hex, normalize_contact_route
|
||||
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
|
||||
|
||||
|
||||
class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
@@ -22,20 +24,40 @@ class AmbiguousPublicKeyPrefixError(ValueError):
|
||||
|
||||
class ContactRepository:
|
||||
@staticmethod
|
||||
async def upsert(contact: dict[str, Any]) -> None:
|
||||
def _coerce_contact_upsert(
|
||||
contact: ContactUpsert | Contact | Mapping[str, Any],
|
||||
) -> ContactUpsert:
|
||||
if isinstance(contact, ContactUpsert):
|
||||
return contact
|
||||
if isinstance(contact, Contact):
|
||||
return contact.to_upsert()
|
||||
return ContactUpsert.model_validate(contact)
|
||||
|
||||
@staticmethod
|
||||
async def upsert(contact: ContactUpsert | Contact | Mapping[str, Any]) -> None:
|
||||
contact_row = ContactRepository._coerce_contact_upsert(contact)
|
||||
last_path, last_path_len, out_path_hash_mode = normalize_contact_route(
|
||||
contact.get("last_path"),
|
||||
contact.get("last_path_len", -1),
|
||||
contact.get("out_path_hash_mode"),
|
||||
contact_row.last_path,
|
||||
contact_row.last_path_len,
|
||||
contact_row.out_path_hash_mode,
|
||||
)
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
contact_row.route_override_path,
|
||||
contact_row.route_override_len,
|
||||
contact_row.route_override_hash_mode,
|
||||
)
|
||||
)
|
||||
|
||||
await db.conn.execute(
|
||||
"""
|
||||
INSERT INTO contacts (public_key, name, type, flags, last_path, last_path_len,
|
||||
out_path_hash_mode,
|
||||
route_override_path, route_override_len,
|
||||
route_override_hash_mode,
|
||||
last_advert, lat, lon, last_seen,
|
||||
on_radio, last_contacted, first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(public_key) DO UPDATE SET
|
||||
name = COALESCE(excluded.name, contacts.name),
|
||||
type = CASE WHEN excluded.type = 0 THEN contacts.type ELSE excluded.type END,
|
||||
@@ -43,6 +65,15 @@ class ContactRepository:
|
||||
last_path = COALESCE(excluded.last_path, contacts.last_path),
|
||||
last_path_len = excluded.last_path_len,
|
||||
out_path_hash_mode = excluded.out_path_hash_mode,
|
||||
route_override_path = COALESCE(
|
||||
excluded.route_override_path, contacts.route_override_path
|
||||
),
|
||||
route_override_len = COALESCE(
|
||||
excluded.route_override_len, contacts.route_override_len
|
||||
),
|
||||
route_override_hash_mode = COALESCE(
|
||||
excluded.route_override_hash_mode, contacts.route_override_hash_mode
|
||||
),
|
||||
last_advert = COALESCE(excluded.last_advert, contacts.last_advert),
|
||||
lat = COALESCE(excluded.lat, contacts.lat),
|
||||
lon = COALESCE(excluded.lon, contacts.lon),
|
||||
@@ -52,20 +83,23 @@ class ContactRepository:
|
||||
first_seen = COALESCE(contacts.first_seen, excluded.first_seen)
|
||||
""",
|
||||
(
|
||||
contact.get("public_key", "").lower(),
|
||||
contact.get("name"),
|
||||
contact.get("type", 0),
|
||||
contact.get("flags", 0),
|
||||
contact_row.public_key.lower(),
|
||||
contact_row.name,
|
||||
contact_row.type,
|
||||
contact_row.flags,
|
||||
last_path,
|
||||
last_path_len,
|
||||
out_path_hash_mode,
|
||||
contact.get("last_advert"),
|
||||
contact.get("lat"),
|
||||
contact.get("lon"),
|
||||
contact.get("last_seen", int(time.time())),
|
||||
contact.get("on_radio"),
|
||||
contact.get("last_contacted"),
|
||||
contact.get("first_seen"),
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode,
|
||||
contact_row.last_advert,
|
||||
contact_row.lat,
|
||||
contact_row.lon,
|
||||
contact_row.last_seen if contact_row.last_seen is not None else int(time.time()),
|
||||
contact_row.on_radio,
|
||||
contact_row.last_contacted,
|
||||
contact_row.first_seen,
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
@@ -78,6 +112,25 @@ class ContactRepository:
|
||||
row["last_path_len"],
|
||||
row["out_path_hash_mode"],
|
||||
)
|
||||
available_columns = set(row.keys())
|
||||
route_override_path = (
|
||||
row["route_override_path"] if "route_override_path" in available_columns else None
|
||||
)
|
||||
route_override_len = (
|
||||
row["route_override_len"] if "route_override_len" in available_columns else None
|
||||
)
|
||||
route_override_hash_mode = (
|
||||
row["route_override_hash_mode"]
|
||||
if "route_override_hash_mode" in available_columns
|
||||
else None
|
||||
)
|
||||
route_override_path, route_override_len, route_override_hash_mode = (
|
||||
normalize_route_override(
|
||||
route_override_path,
|
||||
route_override_len,
|
||||
route_override_hash_mode,
|
||||
)
|
||||
)
|
||||
return Contact(
|
||||
public_key=row["public_key"],
|
||||
name=row["name"],
|
||||
@@ -86,6 +139,9 @@ class ContactRepository:
|
||||
last_path=last_path,
|
||||
last_path_len=last_path_len,
|
||||
out_path_hash_mode=out_path_hash_mode,
|
||||
route_override_path=route_override_path,
|
||||
route_override_len=route_override_len,
|
||||
route_override_hash_mode=route_override_hash_mode,
|
||||
last_advert=row["last_advert"],
|
||||
lat=row["lat"],
|
||||
lon=row["lon"],
|
||||
@@ -197,17 +253,28 @@ class ContactRepository:
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get the most recently active non-repeater contacts.
|
||||
|
||||
Orders by most recent activity (last_contacted or last_advert),
|
||||
excluding repeaters (type=2).
|
||||
"""
|
||||
async def get_recently_contacted_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get recently interacted-with non-repeater contacts."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2
|
||||
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
|
||||
WHERE type != 2 AND last_contacted IS NOT NULL
|
||||
ORDER BY last_contacted DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get recently advert-heard non-repeater contacts."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2 AND last_advert IS NOT NULL
|
||||
ORDER BY last_advert DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
@@ -241,6 +308,47 @@ class ContactRepository:
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_routing_override(
|
||||
public_key: str,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
out_path_hash_mode: int | None = None,
|
||||
) -> None:
|
||||
normalized_path, normalized_len, normalized_hash_mode = normalize_route_override(
|
||||
path,
|
||||
path_len,
|
||||
out_path_hash_mode,
|
||||
)
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET route_override_path = ?, route_override_len = ?, route_override_hash_mode = ?
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
normalized_path,
|
||||
normalized_len,
|
||||
normalized_hash_mode,
|
||||
public_key.lower(),
|
||||
),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def clear_routing_override(public_key: str) -> None:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET route_override_path = NULL,
|
||||
route_override_len = NULL,
|
||||
route_override_hash_mode = NULL
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(public_key.lower(),),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_on_radio(public_key: str, on_radio: bool) -> None:
|
||||
await db.conn.execute(
|
||||
|
||||
@@ -7,9 +7,11 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import upsert_channel_from_radio_slot
|
||||
from app.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"])
|
||||
@@ -23,6 +25,12 @@ class CreateChannelRequest(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class ChannelFloodScopeOverrideRequest(BaseModel):
|
||||
flood_scope_override: str = Field(
|
||||
description="Blank clears the override; non-empty values temporarily override flood scope"
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Channel])
|
||||
async def list_channels() -> list[Channel]:
|
||||
"""List all channels from the database."""
|
||||
@@ -95,6 +103,7 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
name=request.name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
flood_scope_override=None,
|
||||
)
|
||||
|
||||
|
||||
@@ -136,6 +145,28 @@ async def mark_channel_read(key: str) -> dict:
|
||||
return {"status": "ok", "key": channel.key}
|
||||
|
||||
|
||||
@router.post("/{key}/flood-scope-override", response_model=Channel)
|
||||
async def set_channel_flood_scope_override(
|
||||
key: str, request: ChannelFloodScopeOverrideRequest
|
||||
) -> Channel:
|
||||
"""Set or clear a per-channel flood-scope override."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
|
||||
override = normalize_region_scope(request.flood_scope_override) or None
|
||||
updated = await ChannelRepository.update_flood_scope_override(channel.key, override)
|
||||
if not updated:
|
||||
raise HTTPException(status_code=500, detail="Failed to update flood-scope override")
|
||||
|
||||
refreshed = await ChannelRepository.get_by_key(channel.key)
|
||||
if refreshed is None:
|
||||
raise HTTPException(status_code=500, detail="Channel disappeared after update")
|
||||
|
||||
broadcast_event("channel", refreshed.model_dump())
|
||||
return refreshed
|
||||
|
||||
|
||||
@router.delete("/{key}")
|
||||
async def delete_channel(key: str) -> dict:
|
||||
"""Delete a channel from the database by key.
|
||||
@@ -146,8 +177,6 @@ async def delete_channel(key: str) -> dict:
|
||||
logger.info("Deleting channel %s from database", key)
|
||||
await ChannelRepository.delete(key)
|
||||
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
broadcast_event("channel_deleted", {"key": key})
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -11,12 +11,14 @@ from app.models import (
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactDetail,
|
||||
ContactRoutingOverrideRequest,
|
||||
ContactUpsert,
|
||||
CreateContactRequest,
|
||||
NearestRepeater,
|
||||
TraceResponse,
|
||||
)
|
||||
from app.packet_processor import start_historical_dm_decryption
|
||||
from app.radio import radio_manager
|
||||
from app.path_utils import parse_explicit_hop_route
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
ContactAdvertPathRepository,
|
||||
@@ -24,6 +26,8 @@ from app.repository import (
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -59,6 +63,34 @@ 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:
|
||||
return
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(operation_name) as mc:
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to push updated routing to radio for %s: %s",
|
||||
contact.public_key[:12],
|
||||
result.payload,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to push updated routing to radio for %s",
|
||||
contact.public_key[:12],
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _broadcast_contact_update(contact: Contact) -> None:
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
broadcast_event("contact", contact.model_dump())
|
||||
|
||||
|
||||
@router.get("", response_model=list[Contact])
|
||||
async def list_contacts(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
@@ -102,23 +134,7 @@ async def create_contact(
|
||||
if existing:
|
||||
# Update name if provided
|
||||
if request.name:
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": existing.public_key,
|
||||
"name": request.name,
|
||||
"type": existing.type,
|
||||
"flags": existing.flags,
|
||||
"last_path": existing.last_path,
|
||||
"last_path_len": existing.last_path_len,
|
||||
"out_path_hash_mode": existing.out_path_hash_mode,
|
||||
"last_advert": existing.last_advert,
|
||||
"lat": existing.lat,
|
||||
"lon": existing.lon,
|
||||
"last_seen": existing.last_seen,
|
||||
"on_radio": existing.on_radio,
|
||||
"last_contacted": existing.last_contacted,
|
||||
}
|
||||
)
|
||||
await ContactRepository.upsert(existing.to_upsert(name=request.name))
|
||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
@@ -133,42 +149,26 @@ async def create_contact(
|
||||
|
||||
# Create new contact
|
||||
lower_key = request.public_key.lower()
|
||||
contact_data = {
|
||||
"public_key": lower_key,
|
||||
"name": request.name,
|
||||
"type": 0, # Unknown
|
||||
"flags": 0,
|
||||
"last_path": None,
|
||||
"last_path_len": -1,
|
||||
"out_path_hash_mode": -1,
|
||||
"last_advert": None,
|
||||
"lat": None,
|
||||
"lon": None,
|
||||
"last_seen": None,
|
||||
"on_radio": False,
|
||||
"last_contacted": None,
|
||||
}
|
||||
await ContactRepository.upsert(contact_data)
|
||||
contact_upsert = ContactUpsert(
|
||||
public_key=lower_key,
|
||||
name=request.name,
|
||||
out_path_hash_mode=-1,
|
||||
on_radio=False,
|
||||
)
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
logger.info("Created contact %s", lower_key[:12])
|
||||
|
||||
# Promote any prefix-stored messages to this full key
|
||||
claimed = await MessageRepository.claim_prefix_messages(lower_key)
|
||||
if claimed > 0:
|
||||
logger.info("Claimed %d prefix messages for contact %s", claimed, lower_key[:12])
|
||||
|
||||
# Backfill sender_key on channel messages that match this contact's name
|
||||
if request.name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, request.name)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s", backfilled, request.name
|
||||
)
|
||||
await reconcile_contact_messages(
|
||||
public_key=lower_key,
|
||||
contact_name=request.name,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
# Trigger historical decryption if requested
|
||||
if request.try_historical:
|
||||
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
|
||||
|
||||
return Contact(**contact_data)
|
||||
return Contact(**contact_upsert.model_dump())
|
||||
|
||||
|
||||
@router.get("/{public_key}/detail", response_model=ContactDetail)
|
||||
@@ -285,21 +285,14 @@ async def sync_contacts_from_radio() -> dict:
|
||||
for public_key, contact_data in contacts.items():
|
||||
lower_key = public_key.lower()
|
||||
await ContactRepository.upsert(
|
||||
Contact.from_radio_dict(lower_key, contact_data, on_radio=True)
|
||||
ContactUpsert.from_radio_dict(lower_key, contact_data, on_radio=True)
|
||||
)
|
||||
synced_keys.append(lower_key)
|
||||
claimed = await MessageRepository.claim_prefix_messages(lower_key)
|
||||
if claimed > 0:
|
||||
logger.info("Claimed %d prefix DM message(s) for contact %s", claimed, public_key[:12])
|
||||
adv_name = contact_data.get("adv_name")
|
||||
if adv_name:
|
||||
backfilled = await MessageRepository.backfill_channel_sender_key(lower_key, adv_name)
|
||||
if backfilled > 0:
|
||||
logger.info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
adv_name,
|
||||
)
|
||||
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
|
||||
@@ -459,29 +452,49 @@ async def request_trace(public_key: str) -> TraceResponse:
|
||||
return TraceResponse(remote_snr=remote_snr, local_snr=local_snr, path_len=path_len)
|
||||
|
||||
|
||||
@router.post("/{public_key}/reset-path")
|
||||
async def reset_contact_path(public_key: str) -> dict:
|
||||
"""Reset a contact's routing path to flood."""
|
||||
@router.post("/{public_key}/routing-override")
|
||||
async def set_contact_routing_override(
|
||||
public_key: str, request: ContactRoutingOverrideRequest
|
||||
) -> dict:
|
||||
"""Set, force, or clear an explicit routing override for a contact."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
await ContactRepository.update_path(contact.public_key, "", -1, -1)
|
||||
logger.info("Reset path to flood for %s", contact.public_key[:12])
|
||||
|
||||
# Push the updated path to radio if connected and contact is on radio
|
||||
if radio_manager.is_connected and contact.on_radio:
|
||||
route_text = request.route.strip()
|
||||
if route_text == "":
|
||||
await ContactRepository.clear_routing_override(contact.public_key)
|
||||
await ContactRepository.update_path(contact.public_key, "", -1, -1)
|
||||
logger.info(
|
||||
"Cleared routing override and reset learned path to flood for %s",
|
||||
contact.public_key[:12],
|
||||
)
|
||||
elif route_text == "-1":
|
||||
await ContactRepository.set_routing_override(contact.public_key, "", -1, -1)
|
||||
logger.info("Set forced flood routing override for %s", contact.public_key[:12])
|
||||
elif route_text == "0":
|
||||
await ContactRepository.set_routing_override(contact.public_key, "", 0, 0)
|
||||
logger.info("Set forced direct routing override for %s", contact.public_key[:12])
|
||||
else:
|
||||
try:
|
||||
updated = await ContactRepository.get_by_key(contact.public_key)
|
||||
if updated:
|
||||
async with radio_manager.radio_operation("reset_path_on_radio") as mc:
|
||||
await mc.commands.add_contact(updated.to_radio_dict())
|
||||
except Exception:
|
||||
logger.warning("Failed to push flood path to radio for %s", contact.public_key[:12])
|
||||
path_hex, path_len, hash_mode = parse_explicit_hop_route(route_text)
|
||||
except ValueError as err:
|
||||
raise HTTPException(status_code=400, detail=str(err)) from err
|
||||
|
||||
# Broadcast updated contact so frontend refreshes
|
||||
from app.websocket import broadcast_event
|
||||
await ContactRepository.set_routing_override(
|
||||
contact.public_key,
|
||||
path_hex,
|
||||
path_len,
|
||||
hash_mode,
|
||||
)
|
||||
logger.info(
|
||||
"Set explicit routing override for %s: %d hop(s), %d-byte IDs",
|
||||
contact.public_key[:12],
|
||||
path_len,
|
||||
hash_mode + 1,
|
||||
)
|
||||
|
||||
updated_contact = await ContactRepository.get_by_key(contact.public_key)
|
||||
if updated_contact:
|
||||
broadcast_event("contact", updated_contact.model_dump())
|
||||
await _best_effort_push_contact_to_radio(updated_contact, "set_routing_override_on_radio")
|
||||
await _broadcast_contact_update(updated_contact)
|
||||
|
||||
return {"status": "ok", "public_key": contact.public_key}
|
||||
|
||||
@@ -5,8 +5,8 @@ from fastapi import APIRouter
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.config import settings
|
||||
from app.radio import radio_manager
|
||||
from app.repository import RawPacketRepository
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
router = APIRouter(tags=["health"])
|
||||
|
||||
@@ -53,6 +53,8 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
setup_complete = getattr(radio_manager, "is_setup_complete", radio_connected)
|
||||
if not isinstance(setup_complete, bool):
|
||||
setup_complete = radio_connected
|
||||
if not radio_connected:
|
||||
setup_complete = False
|
||||
|
||||
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import logging
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.event_handlers import track_pending_ack
|
||||
@@ -12,9 +11,14 @@ from app.models import (
|
||||
SendChannelMessageRequest,
|
||||
SendDirectMessageRequest,
|
||||
)
|
||||
from app.radio import radio_manager
|
||||
from app.repository import AmbiguousPublicKeyPrefixError, AppSettingsRepository, MessageRepository
|
||||
from app.websocket import broadcast_event
|
||||
from app.services.message_send import (
|
||||
resend_channel_message_record,
|
||||
send_channel_message_to_channel,
|
||||
send_direct_message_to_contact,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/messages", tags=["messages"])
|
||||
@@ -105,80 +109,16 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
status_code=404, detail=f"Contact not found in database: {request.destination}"
|
||||
)
|
||||
|
||||
# Always add/update the contact on radio before sending.
|
||||
# The library cache (get_contact_by_key_prefix) can be stale after radio reboot,
|
||||
# so we can't rely on it to know if the firmware has the contact.
|
||||
# add_contact is idempotent - updates if exists, adds if not.
|
||||
contact_data = db_contact.to_radio_dict()
|
||||
async with radio_manager.radio_operation("send_direct_message") as mc:
|
||||
logger.debug("Ensuring contact %s is on radio before sending", db_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)
|
||||
# Continue anyway - might still work if contact exists
|
||||
|
||||
# Get the contact from the library cache (may have updated info like path)
|
||||
contact = mc.get_contact_by_key_prefix(db_contact.public_key[:12])
|
||||
if not contact:
|
||||
contact = contact_data
|
||||
|
||||
logger.info("Sending direct message to %s", db_contact.public_key[:12])
|
||||
|
||||
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
|
||||
# and the database. This ensures consistency for deduplication.
|
||||
now = int(time.time())
|
||||
|
||||
result = await mc.commands.send_msg(
|
||||
dst=contact,
|
||||
msg=request.text,
|
||||
timestamp=now,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
# Store outgoing message
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
return await send_direct_message_to_contact(
|
||||
contact=db_contact,
|
||||
text=request.text,
|
||||
conversation_key=db_contact.public_key.lower(),
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_event,
|
||||
track_pending_ack_fn=track_pending_ack,
|
||||
now_fn=time.time,
|
||||
message_repository=MessageRepository,
|
||||
contact_repository=ContactRepository,
|
||||
)
|
||||
if message_id is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
# Update last_contacted for the contact
|
||||
await ContactRepository.update_last_contacted(db_contact.public_key.lower(), now)
|
||||
|
||||
# Track the expected ACK for this message
|
||||
expected_ack = result.payload.get("expected_ack")
|
||||
suggested_timeout: int = result.payload.get("suggested_timeout", 10000) # default 10s
|
||||
if expected_ack:
|
||||
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
|
||||
track_pending_ack(ack_code, message_id, suggested_timeout)
|
||||
logger.debug("Tracking ACK %s for message %d", ack_code, message_id)
|
||||
|
||||
message = Message(
|
||||
id=message_id,
|
||||
type="PRIV",
|
||||
conversation_key=db_contact.public_key.lower(),
|
||||
text=request.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
)
|
||||
|
||||
# Broadcast so all connected clients (not just sender) see the outgoing message immediately.
|
||||
# Fanout modules (including bots) are triggered via broadcast_event's realtime dispatch.
|
||||
broadcast_event("message", message.model_dump())
|
||||
|
||||
return message
|
||||
|
||||
|
||||
# Temporary radio slot used for sending channel messages
|
||||
@@ -216,112 +156,19 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
TEMP_RADIO_SLOT,
|
||||
expected_hash,
|
||||
)
|
||||
channel_key_upper = request.channel_key.upper()
|
||||
message_id: int | None = None
|
||||
now: int | None = None
|
||||
radio_name: str = ""
|
||||
text_with_sender: str = request.text
|
||||
|
||||
our_public_key: str | None = None
|
||||
|
||||
async with radio_manager.radio_operation("send_channel_message") as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
our_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
|
||||
text_with_sender = f"{radio_name}: {request.text}" if radio_name else request.text
|
||||
# Load the channel to a temporary radio slot before sending
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=TEMP_RADIO_SLOT,
|
||||
channel_name=db_channel.name,
|
||||
channel_secret=key_bytes,
|
||||
)
|
||||
if set_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to set channel on radio slot %d before sending: %s",
|
||||
TEMP_RADIO_SLOT,
|
||||
set_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to configure channel on radio before sending message",
|
||||
)
|
||||
|
||||
logger.info("Sending channel message to %s: %s", db_channel.name, request.text[:50])
|
||||
|
||||
# Capture timestamp BEFORE sending so we can pass the same value to both the radio
|
||||
# and the database. This ensures the echo's timestamp matches our stored message
|
||||
# for proper deduplication.
|
||||
now = int(time.time())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
|
||||
result = await mc.commands.send_chan_msg(
|
||||
chan=TEMP_RADIO_SLOT,
|
||||
msg=request.text,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
# Store outgoing immediately after send to avoid a race where
|
||||
# our own echo lands before persistence.
|
||||
message_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text_with_sender,
|
||||
conversation_key=channel_key_upper,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
)
|
||||
if message_id is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
# Broadcast immediately so all connected clients see the message promptly.
|
||||
# This ensures the message exists in frontend state when echo-driven
|
||||
# `message_acked` events arrive.
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=message_id,
|
||||
type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=db_channel.name,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
if message_id is None or now is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
acked_count, paths = await MessageRepository.get_ack_and_paths(message_id)
|
||||
|
||||
message = Message(
|
||||
id=message_id,
|
||||
type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
paths=paths,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=db_channel.name,
|
||||
return await send_channel_message_to_channel(
|
||||
channel=db_channel,
|
||||
channel_key_upper=request.channel_key.upper(),
|
||||
key_bytes=key_bytes,
|
||||
text=request.text,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_event,
|
||||
error_broadcast_fn=broadcast_error,
|
||||
now_fn=time.time,
|
||||
temp_radio_slot=TEMP_RADIO_SLOT,
|
||||
message_repository=MessageRepository,
|
||||
)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
@@ -366,97 +213,14 @@ async def resend_channel_message(
|
||||
if not db_channel:
|
||||
raise HTTPException(status_code=404, detail=f"Channel {msg.conversation_key} not found")
|
||||
|
||||
# Choose timestamp: original for byte-perfect, fresh for new-timestamp
|
||||
if new_timestamp:
|
||||
now = int(time.time())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
else:
|
||||
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
try:
|
||||
key_bytes = bytes.fromhex(msg.conversation_key)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Invalid channel key format: {msg.conversation_key}"
|
||||
) from None
|
||||
|
||||
resend_public_key: str | None = None
|
||||
|
||||
async with radio_manager.radio_operation("resend_channel_message") as mc:
|
||||
# Strip sender prefix: DB stores "RadioName: message" but radio needs "message"
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
resend_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
|
||||
text_to_send = msg.text
|
||||
if radio_name and text_to_send.startswith(f"{radio_name}: "):
|
||||
text_to_send = text_to_send[len(f"{radio_name}: ") :]
|
||||
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=TEMP_RADIO_SLOT,
|
||||
channel_name=db_channel.name,
|
||||
channel_secret=key_bytes,
|
||||
)
|
||||
if set_result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to configure channel on radio before resending",
|
||||
)
|
||||
|
||||
result = await mc.commands.send_chan_msg(
|
||||
chan=TEMP_RADIO_SLOT,
|
||||
msg=text_to_send,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to resend message: {result.payload}"
|
||||
)
|
||||
|
||||
# For new-timestamp resend, create a new message row and broadcast it
|
||||
if new_timestamp:
|
||||
new_msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=msg.text,
|
||||
conversation_key=msg.conversation_key,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
)
|
||||
if new_msg_id is None:
|
||||
# Timestamp-second collision (same text+channel within the same second).
|
||||
# The radio already transmitted, so log and return the original ID rather
|
||||
# than surfacing a 500 for a message that was successfully sent over the air.
|
||||
logger.warning(
|
||||
"Duplicate timestamp collision resending message %d — radio sent but DB row not created",
|
||||
message_id,
|
||||
)
|
||||
return {"status": "ok", "message_id": message_id}
|
||||
|
||||
broadcast_event(
|
||||
"message",
|
||||
Message(
|
||||
id=new_msg_id,
|
||||
type="CHAN",
|
||||
conversation_key=msg.conversation_key,
|
||||
text=msg.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
channel_name=db_channel.name,
|
||||
).model_dump(),
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Resent channel message %d as new message %d to %s",
|
||||
message_id,
|
||||
new_msg_id,
|
||||
db_channel.name,
|
||||
)
|
||||
return {"status": "ok", "message_id": new_msg_id}
|
||||
|
||||
logger.info("Resent channel message %d to %s", message_id, db_channel.name)
|
||||
return {"status": "ok", "message_id": message_id}
|
||||
return await resend_channel_message_record(
|
||||
message=msg,
|
||||
channel=db_channel,
|
||||
new_timestamp=new_timestamp,
|
||||
radio_manager=radio_manager,
|
||||
broadcast_fn=broadcast_event,
|
||||
error_broadcast_fn=broadcast_error,
|
||||
now_fn=time.time,
|
||||
temp_radio_slot=TEMP_RADIO_SLOT,
|
||||
message_repository=MessageRepository,
|
||||
)
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.radio import radio_manager
|
||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||
from app.radio_sync import sync_radio_time
|
||||
from app.services.radio_commands import (
|
||||
KeystoreRefreshError,
|
||||
PathHashModeUnsupportedError,
|
||||
RadioCommandRejectedError,
|
||||
apply_radio_config_update,
|
||||
import_private_key_and_refresh_keystore,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/radio", tags=["radio"])
|
||||
|
||||
|
||||
async def _prepare_connected(*, broadcast_on_success: bool) -> None:
|
||||
await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
|
||||
|
||||
|
||||
async def _reconnect_and_prepare(*, broadcast_on_success: bool) -> bool:
|
||||
return await radio_manager.reconnect_and_prepare(
|
||||
broadcast_on_success=broadcast_on_success,
|
||||
)
|
||||
|
||||
|
||||
class RadioSettings(BaseModel):
|
||||
freq: float = Field(description="Frequency in MHz")
|
||||
bw: float = Field(description="Bandwidth in kHz")
|
||||
@@ -87,57 +103,18 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
|
||||
require_connected()
|
||||
|
||||
async with radio_manager.radio_operation("update_radio_config") as mc:
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
|
||||
if update.lat is not None or update.lon is not None:
|
||||
current_info = mc.self_info
|
||||
lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0)
|
||||
lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0)
|
||||
logger.info("Setting radio coordinates to %f, %f", lat, lon)
|
||||
await mc.commands.set_coords(lat=lat, lon=lon)
|
||||
|
||||
if update.tx_power is not None:
|
||||
logger.info("Setting TX power to %d dBm", update.tx_power)
|
||||
await mc.commands.set_tx_power(val=update.tx_power)
|
||||
|
||||
if update.radio is not None:
|
||||
logger.info(
|
||||
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",
|
||||
update.radio.freq,
|
||||
update.radio.bw,
|
||||
update.radio.sf,
|
||||
update.radio.cr,
|
||||
try:
|
||||
await apply_radio_config_update(
|
||||
mc,
|
||||
update,
|
||||
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
|
||||
set_path_hash_mode=lambda mode: setattr(radio_manager, "path_hash_mode", mode),
|
||||
sync_radio_time_fn=sync_radio_time,
|
||||
)
|
||||
await mc.commands.set_radio(
|
||||
freq=update.radio.freq,
|
||||
bw=update.radio.bw,
|
||||
sf=update.radio.sf,
|
||||
cr=update.radio.cr,
|
||||
)
|
||||
|
||||
if update.path_hash_mode is not None:
|
||||
if not radio_manager.path_hash_mode_supported:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="Firmware does not support path hash mode setting"
|
||||
)
|
||||
logger.info("Setting path hash mode to %d", update.path_hash_mode)
|
||||
result = await mc.commands.set_path_hash_mode(update.path_hash_mode)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to set path hash mode: {result.payload}",
|
||||
)
|
||||
radio_manager.path_hash_mode = update.path_hash_mode
|
||||
|
||||
# Sync time with system clock
|
||||
await sync_radio_time(mc)
|
||||
|
||||
# Re-fetch self_info so the response reflects the changes we just made.
|
||||
# Commands like set_name() write to flash but don't update the cached
|
||||
# self_info — send_appstart() triggers a fresh SELF_INFO from the radio.
|
||||
await mc.commands.send_appstart()
|
||||
except PathHashModeUnsupportedError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except RadioCommandRejectedError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return await get_radio_config()
|
||||
|
||||
@@ -154,30 +131,16 @@ async def set_private_key(update: PrivateKeyUpdate) -> dict:
|
||||
|
||||
logger.info("Importing private key")
|
||||
async with radio_manager.radio_operation("import_private_key") as mc:
|
||||
result = await mc.commands.import_private_key(key_bytes)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to import private key: {result.payload}"
|
||||
)
|
||||
|
||||
# Re-export from radio so the server-side keystore uses the new key
|
||||
# for DM decryption immediately, rather than waiting for reconnect.
|
||||
from app.keystore import export_and_store_private_key
|
||||
|
||||
keystore_refreshed = await export_and_store_private_key(mc)
|
||||
if not keystore_refreshed:
|
||||
logger.warning("Keystore refresh failed after import, retrying once")
|
||||
keystore_refreshed = await export_and_store_private_key(mc)
|
||||
|
||||
if not keystore_refreshed:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
"Private key imported on radio, but server-side keystore "
|
||||
"refresh failed. Reconnect to apply the new key for DM decryption."
|
||||
),
|
||||
)
|
||||
try:
|
||||
await import_private_key_and_refresh_keystore(
|
||||
mc,
|
||||
key_bytes,
|
||||
export_and_store_private_key_fn=export_and_store_private_key,
|
||||
)
|
||||
except (RadioCommandRejectedError, KeystoreRefreshError) as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
|
||||
return {"status": "ok"}
|
||||
|
||||
@@ -214,14 +177,8 @@ async def _attempt_reconnect() -> dict:
|
||||
"connected": False,
|
||||
}
|
||||
|
||||
success = await radio_manager.reconnect()
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
success = await _reconnect_and_prepare(broadcast_on_success=True)
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed after reconnect")
|
||||
raise HTTPException(
|
||||
@@ -229,6 +186,11 @@ async def _attempt_reconnect() -> dict:
|
||||
detail=f"Radio connected but setup failed: {e}",
|
||||
) from e
|
||||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Failed to reconnect. Check radio connection and power."
|
||||
)
|
||||
|
||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||
|
||||
|
||||
@@ -266,7 +228,7 @@ async def reconnect_radio() -> dict:
|
||||
|
||||
logger.info("Radio connected but setup incomplete, retrying setup")
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
await _prepare_connected(broadcast_on_success=True)
|
||||
return {"status": "ok", "message": "Setup completed", "connected": True}
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed")
|
||||
|
||||
@@ -6,13 +6,13 @@ import time
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.models import UnreadCounts
|
||||
from app.radio import radio_manager
|
||||
from app.repository import (
|
||||
AppSettingsRepository,
|
||||
ChannelRepository,
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/read-state", tags=["read-state"])
|
||||
|
||||
@@ -25,9 +25,9 @@ from app.models import (
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
)
|
||||
from app.radio import radio_manager
|
||||
from app.repository import ContactRepository
|
||||
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from meshcore.events import Event
|
||||
@@ -451,7 +451,7 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
|
||||
- get radio, set radio <freq,bw,sf,cr>
|
||||
- tempradio <freq,bw,sf,cr,minutes>
|
||||
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
|
||||
- clock, clock sync
|
||||
- clock, clock sync, time <epoch_seconds>
|
||||
- reboot
|
||||
- ver
|
||||
"""
|
||||
|
||||
@@ -6,6 +6,7 @@ from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import AppSettings
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,7 +19,8 @@ class AppSettingsUpdate(BaseModel):
|
||||
ge=1,
|
||||
le=1000,
|
||||
description=(
|
||||
"Maximum contacts to keep on radio (favorites first, then recent non-repeaters)"
|
||||
"Configured radio contact capacity used for maintenance thresholds and "
|
||||
"background refill behavior"
|
||||
),
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool | None = Field(
|
||||
@@ -123,8 +125,7 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
# Flood scope
|
||||
flood_scope_changed = False
|
||||
if update.flood_scope is not None:
|
||||
stripped = update.flood_scope.strip()
|
||||
kwargs["flood_scope"] = stripped
|
||||
kwargs["flood_scope"] = normalize_region_scope(update.flood_scope)
|
||||
flood_scope_changed = True
|
||||
|
||||
if kwargs:
|
||||
@@ -132,7 +133,7 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
|
||||
# Apply flood scope to radio immediately if changed
|
||||
if flood_scope_changed:
|
||||
from app.radio import radio_manager
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
if radio_manager.is_connected:
|
||||
try:
|
||||
@@ -161,12 +162,12 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
|
||||
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
|
||||
result = await AppSettingsRepository.add_favorite(request.type, request.id)
|
||||
|
||||
# When a contact favorite changes, sync the radio so the contact is
|
||||
# loaded/unloaded immediately rather than waiting for the next advert.
|
||||
if request.type == "contact":
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
# When a contact is newly favorited, load just that contact to the radio
|
||||
# immediately so DM ACK support does not wait for the next maintenance cycle.
|
||||
if request.type == "contact" and not is_favorited:
|
||||
from app.radio_sync import ensure_contact_on_radio
|
||||
|
||||
asyncio.create_task(sync_recent_contacts_to_radio(force=True))
|
||||
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ import logging
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from app.radio import radio_manager
|
||||
from app.routers.health import build_health_data
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import ws_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
1
app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend service-layer helpers."""
|
||||
115
app/services/contact_reconciliation.py
Normal file
@@ -0,0 +1,115 @@
|
||||
"""Shared contact/message reconciliation helpers."""
|
||||
|
||||
import logging
|
||||
|
||||
from app.repository import ContactNameHistoryRepository, MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def claim_prefix_messages_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
message_repository=MessageRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> int:
|
||||
"""Promote prefix-key DMs to a resolved full public key."""
|
||||
normalized_key = public_key.lower()
|
||||
claimed = await message_repository.claim_prefix_messages(normalized_key)
|
||||
if claimed > 0:
|
||||
(log or logger).info(
|
||||
"Claimed %d prefix DM message(s) for contact %s",
|
||||
claimed,
|
||||
normalized_key[:12],
|
||||
)
|
||||
return claimed
|
||||
|
||||
|
||||
async def backfill_channel_sender_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
message_repository=MessageRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> int:
|
||||
"""Backfill channel sender attribution once a contact name is known."""
|
||||
if not contact_name:
|
||||
return 0
|
||||
|
||||
normalized_key = public_key.lower()
|
||||
backfilled = await message_repository.backfill_channel_sender_key(
|
||||
normalized_key,
|
||||
contact_name,
|
||||
)
|
||||
if backfilled > 0:
|
||||
(log or logger).info(
|
||||
"Backfilled sender_key on %d channel message(s) for %s",
|
||||
backfilled,
|
||||
contact_name,
|
||||
)
|
||||
return backfilled
|
||||
|
||||
|
||||
async def reconcile_contact_messages(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
message_repository=MessageRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Apply message reconciliation once a contact's identity is resolved."""
|
||||
claimed = await claim_prefix_messages_for_contact(
|
||||
public_key=public_key,
|
||||
message_repository=message_repository,
|
||||
log=log,
|
||||
)
|
||||
backfilled = await backfill_channel_sender_for_contact(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
message_repository=message_repository,
|
||||
log=log,
|
||||
)
|
||||
return claimed, backfilled
|
||||
|
||||
|
||||
async def record_contact_name(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
timestamp: int,
|
||||
contact_name_history_repository=ContactNameHistoryRepository,
|
||||
) -> bool:
|
||||
"""Record contact name history when a non-empty name is available."""
|
||||
if not contact_name:
|
||||
return False
|
||||
|
||||
await contact_name_history_repository.record_name(
|
||||
public_key.lower(),
|
||||
contact_name,
|
||||
timestamp,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def record_contact_name_and_reconcile(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
timestamp: int,
|
||||
message_repository=MessageRepository,
|
||||
contact_name_history_repository=ContactNameHistoryRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> tuple[int, int]:
|
||||
"""Record name history, then reconcile message identity for the contact."""
|
||||
await record_contact_name(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
timestamp=timestamp,
|
||||
contact_name_history_repository=contact_name_history_repository,
|
||||
)
|
||||
return await reconcile_contact_messages(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
message_repository=message_repository,
|
||||
log=log,
|
||||
)
|
||||
43
app/services/dm_ack_tracker.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Shared pending ACK tracking for outgoing direct messages."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PendingAck = tuple[int, float, int]
|
||||
|
||||
_pending_acks: dict[str, PendingAck] = {}
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
|
||||
"""Track an expected ACK code for an outgoing direct message."""
|
||||
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
|
||||
logger.debug(
|
||||
"Tracking pending ACK %s for message %d (timeout %dms)",
|
||||
expected_ack,
|
||||
message_id,
|
||||
timeout_ms,
|
||||
)
|
||||
|
||||
|
||||
def cleanup_expired_acks() -> None:
|
||||
"""Remove stale pending ACK entries."""
|
||||
now = time.time()
|
||||
expired_codes = [
|
||||
code
|
||||
for code, (_message_id, created_at, timeout_ms) in _pending_acks.items()
|
||||
if now - created_at > (timeout_ms / 1000) * 2
|
||||
]
|
||||
for code in expired_codes:
|
||||
del _pending_acks[code]
|
||||
logger.debug("Expired pending ACK %s", code)
|
||||
|
||||
|
||||
def pop_pending_ack(ack_code: str) -> int | None:
|
||||
"""Claim the tracked message ID for an ACK code if present."""
|
||||
pending = _pending_acks.pop(ack_code, None)
|
||||
if pending is None:
|
||||
return None
|
||||
message_id, _, _ = pending
|
||||
return message_id
|
||||
360
app/services/message_send.py
Normal file
@@ -0,0 +1,360 @@
|
||||
"""Shared send/resend orchestration for outgoing messages."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
|
||||
from app.services.messages import (
|
||||
build_message_model,
|
||||
create_outgoing_channel_message,
|
||||
create_outgoing_direct_message,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
TrackAckFn = Callable[[str, int, int], None]
|
||||
NowFn = Callable[[], float]
|
||||
|
||||
|
||||
async def send_channel_message_with_effective_scope(
|
||||
*,
|
||||
mc,
|
||||
channel,
|
||||
key_bytes: bytes,
|
||||
text: str,
|
||||
timestamp_bytes: bytes,
|
||||
action_label: str,
|
||||
temp_radio_slot: int,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
app_settings_repository=AppSettingsRepository,
|
||||
) -> Any:
|
||||
"""Send a channel message, temporarily overriding flood scope when configured."""
|
||||
override_scope = normalize_region_scope(channel.flood_scope_override)
|
||||
baseline_scope = ""
|
||||
|
||||
if override_scope:
|
||||
settings = await app_settings_repository.get()
|
||||
baseline_scope = normalize_region_scope(settings.flood_scope)
|
||||
|
||||
if override_scope and override_scope != baseline_scope:
|
||||
logger.info(
|
||||
"Temporarily applying channel flood_scope override for %s: %r",
|
||||
channel.name,
|
||||
override_scope,
|
||||
)
|
||||
override_result = await mc.commands.set_flood_scope(override_scope)
|
||||
if override_result is not None and override_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to apply channel flood_scope override for %s: %s",
|
||||
channel.name,
|
||||
override_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=(
|
||||
f"Failed to apply regional override {override_scope!r} before {action_label}: "
|
||||
f"{override_result.payload}"
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=temp_radio_slot,
|
||||
channel_name=channel.name,
|
||||
channel_secret=key_bytes,
|
||||
)
|
||||
if set_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to set channel on radio slot %d before %s: %s",
|
||||
temp_radio_slot,
|
||||
action_label,
|
||||
set_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to configure channel on radio before {action_label}",
|
||||
)
|
||||
|
||||
return await mc.commands.send_chan_msg(
|
||||
chan=temp_radio_slot,
|
||||
msg=text,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
finally:
|
||||
if override_scope and override_scope != baseline_scope:
|
||||
try:
|
||||
restore_result = await mc.commands.set_flood_scope(
|
||||
baseline_scope if baseline_scope else ""
|
||||
)
|
||||
if restore_result is not None and restore_result.type == EventType.ERROR:
|
||||
logger.error(
|
||||
"Failed to restore baseline flood_scope after sending to %s: %s",
|
||||
channel.name,
|
||||
restore_result.payload,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Regional override restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring flood scope failed. "
|
||||
"The radio may still be region-scoped. Consider rebooting the radio."
|
||||
),
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"Restored baseline flood_scope after channel send: %r",
|
||||
baseline_scope or "(disabled)",
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Failed to restore baseline flood_scope after sending to %s",
|
||||
channel.name,
|
||||
)
|
||||
error_broadcast_fn(
|
||||
"Regional override restore failed",
|
||||
(
|
||||
f"Sent to {channel.name}, but restoring flood scope failed. "
|
||||
"The radio may still be region-scoped. Consider rebooting the radio."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def send_direct_message_to_contact(
|
||||
*,
|
||||
contact,
|
||||
text: str,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
track_pending_ack_fn: TrackAckFn,
|
||||
now_fn: NowFn,
|
||||
message_repository=MessageRepository,
|
||||
contact_repository=ContactRepository,
|
||||
) -> 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())
|
||||
result = await mc.commands.send_msg(
|
||||
dst=cached_contact,
|
||||
msg=text,
|
||||
timestamp=now,
|
||||
)
|
||||
|
||||
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,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
broadcast_fn=broadcast_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
|
||||
await contact_repository.update_last_contacted(contact.public_key.lower(), now)
|
||||
|
||||
expected_ack = result.payload.get("expected_ack")
|
||||
suggested_timeout: int = result.payload.get("suggested_timeout", 10000)
|
||||
if expected_ack:
|
||||
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
|
||||
track_pending_ack_fn(ack_code, message.id, suggested_timeout)
|
||||
logger.debug("Tracking ACK %s for message %d", ack_code, message.id)
|
||||
|
||||
return message
|
||||
|
||||
|
||||
async def send_channel_message_to_channel(
|
||||
*,
|
||||
channel,
|
||||
channel_key_upper: str,
|
||||
key_bytes: bytes,
|
||||
text: str,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
now_fn: NowFn,
|
||||
temp_radio_slot: int,
|
||||
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
|
||||
text_with_sender = text
|
||||
|
||||
async with radio_manager.radio_operation("send_channel_message") as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
our_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
|
||||
text_with_sender = f"{radio_name}: {text}" if radio_name else text
|
||||
logger.info("Sending channel message to %s: %s", channel.name, text[:50])
|
||||
|
||||
now = int(now_fn())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
key_bytes=key_bytes,
|
||||
text=text,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="sending message",
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
|
||||
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:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
|
||||
return build_message_model(
|
||||
message_id=message_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
paths=paths,
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=channel.name,
|
||||
)
|
||||
|
||||
|
||||
async def resend_channel_message_record(
|
||||
*,
|
||||
message,
|
||||
channel,
|
||||
new_timestamp: bool,
|
||||
radio_manager,
|
||||
broadcast_fn: BroadcastFn,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
now_fn: NowFn,
|
||||
temp_radio_slot: int,
|
||||
message_repository=MessageRepository,
|
||||
) -> dict[str, Any]:
|
||||
"""Resend a stored outgoing channel message."""
|
||||
try:
|
||||
key_bytes = bytes.fromhex(message.conversation_key)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid channel key format: {message.conversation_key}",
|
||||
) from None
|
||||
|
||||
now: int | None = None
|
||||
if new_timestamp:
|
||||
now = int(now_fn())
|
||||
timestamp_bytes = now.to_bytes(4, "little")
|
||||
else:
|
||||
timestamp_bytes = message.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
resend_public_key: str | None = None
|
||||
radio_name = ""
|
||||
|
||||
async with radio_manager.radio_operation("resend_channel_message") as mc:
|
||||
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
|
||||
resend_public_key = (mc.self_info.get("public_key") or None) if mc.self_info else None
|
||||
text_to_send = message.text
|
||||
if radio_name and text_to_send.startswith(f"{radio_name}: "):
|
||||
text_to_send = text_to_send[len(f"{radio_name}: ") :]
|
||||
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
key_bytes=key_bytes,
|
||||
text=text_to_send,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="resending message",
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to resend message: {result.payload}",
|
||||
)
|
||||
|
||||
if new_timestamp:
|
||||
if now is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
||||
new_message = await create_outgoing_channel_message(
|
||||
conversation_key=message.conversation_key,
|
||||
text=message.text,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if new_message is None:
|
||||
logger.warning(
|
||||
"Duplicate timestamp collision resending message %d — radio sent but DB row not created",
|
||||
message.id,
|
||||
)
|
||||
return {"status": "ok", "message_id": message.id}
|
||||
|
||||
logger.info(
|
||||
"Resent channel message %d as new message %d to %s",
|
||||
message.id,
|
||||
new_message.id,
|
||||
channel.name,
|
||||
)
|
||||
return {"status": "ok", "message_id": new_message.id}
|
||||
|
||||
logger.info("Resent channel message %d to %s", message.id, channel.name)
|
||||
return {"status": "ok", "message_id": message.id}
|
||||
447
app/services/messages.py
Normal file
@@ -0,0 +1,447 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, Message, MessagePath
|
||||
from app.repository import ContactRepository, MessageRepository, RawPacketRepository
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.decoder import DecryptedDirectMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
|
||||
|
||||
def build_message_paths(
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
) -> list[MessagePath] | None:
|
||||
"""Build the single-path list used by message payloads."""
|
||||
return (
|
||||
[MessagePath(path=path or "", received_at=received_at, path_len=path_len)]
|
||||
if path is not None
|
||||
else None
|
||||
)
|
||||
|
||||
|
||||
def build_message_model(
|
||||
*,
|
||||
message_id: int,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int | None,
|
||||
received_at: int,
|
||||
paths: list[MessagePath] | None = None,
|
||||
txt_type: int = 0,
|
||||
signature: str | None = None,
|
||||
sender_key: str | None = None,
|
||||
outgoing: bool = False,
|
||||
acked: int = 0,
|
||||
sender_name: str | None = None,
|
||||
channel_name: str | None = None,
|
||||
) -> Message:
|
||||
"""Build a Message model with the canonical backend payload shape."""
|
||||
return Message(
|
||||
id=message_id,
|
||||
type=msg_type,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=paths,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
outgoing=outgoing,
|
||||
acked=acked,
|
||||
sender_name=sender_name,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
|
||||
|
||||
def broadcast_message(
|
||||
*,
|
||||
message: Message,
|
||||
broadcast_fn: BroadcastFn,
|
||||
realtime: bool | None = None,
|
||||
) -> None:
|
||||
"""Broadcast a message payload, preserving the caller's broadcast signature."""
|
||||
payload = message.model_dump()
|
||||
if realtime is None:
|
||||
broadcast_fn("message", payload)
|
||||
else:
|
||||
broadcast_fn("message", payload, realtime=realtime)
|
||||
|
||||
|
||||
def broadcast_message_acked(
|
||||
*,
|
||||
message_id: int,
|
||||
ack_count: int,
|
||||
paths: list[MessagePath] | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Broadcast a message_acked payload."""
|
||||
broadcast_fn(
|
||||
"message_acked",
|
||||
{
|
||||
"message_id": message_id,
|
||||
"ack_count": ack_count,
|
||||
"paths": [path.model_dump() for path in paths] if paths else [],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def increment_ack_and_broadcast(
|
||||
*,
|
||||
message_id: int,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> int:
|
||||
"""Increment a message's ACK count and broadcast the update."""
|
||||
ack_count = await MessageRepository.increment_ack_count(message_id)
|
||||
broadcast_fn("message_acked", {"message_id": message_id, "ack_count": ack_count})
|
||||
return ack_count
|
||||
|
||||
|
||||
async def handle_duplicate_message(
|
||||
*,
|
||||
packet_id: int,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
path: str | None,
|
||||
received_at: int,
|
||||
path_len: int | None = None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> None:
|
||||
"""Handle a duplicate message by updating paths/acks on the existing record."""
|
||||
existing_msg = await MessageRepository.get_by_content(
|
||||
msg_type=msg_type,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
if not existing_msg:
|
||||
label = "message" if msg_type == "CHAN" else "DM"
|
||||
logger.warning(
|
||||
"Duplicate %s for %s but couldn't find existing",
|
||||
label,
|
||||
conversation_key[:12],
|
||||
)
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
"Duplicate %s for %s (msg_id=%d, outgoing=%s) - adding path",
|
||||
msg_type,
|
||||
conversation_key[:12],
|
||||
existing_msg.id,
|
||||
existing_msg.outgoing,
|
||||
)
|
||||
|
||||
if path is not None:
|
||||
paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len)
|
||||
else:
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
if existing_msg.outgoing:
|
||||
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
|
||||
if existing_msg.outgoing or path is not None:
|
||||
broadcast_message_acked(
|
||||
message_id=existing_msg.id,
|
||||
ack_count=ack_count,
|
||||
paths=paths,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
|
||||
|
||||
async def create_message_from_decrypted(
|
||||
*,
|
||||
packet_id: int,
|
||||
channel_key: str,
|
||||
sender: str | None,
|
||||
message_text: str,
|
||||
timestamp: int,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
channel_name: str | None = None,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> int | None:
|
||||
"""Store and broadcast a decrypted channel message."""
|
||||
received = received_at or int(time.time())
|
||||
text = f"{sender}: {message_text}" if sender else message_text
|
||||
channel_key_normalized = channel_key.upper()
|
||||
|
||||
resolved_sender_key: str | None = None
|
||||
if sender:
|
||||
candidates = await ContactRepository.get_by_name(sender)
|
||||
if len(candidates) == 1:
|
||||
resolved_sender_key = candidates[0].public_key
|
||||
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=channel_key_normalized,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
await handle_duplicate_message(
|
||||
packet_id=packet_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
path=path,
|
||||
received_at=received,
|
||||
path_len=path_len,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8])
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
broadcast_message(
|
||||
message=build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=timestamp,
|
||||
received_at=received,
|
||||
paths=build_message_paths(path, received, path_len),
|
||||
sender_name=sender,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
),
|
||||
broadcast_fn=broadcast_fn,
|
||||
realtime=realtime,
|
||||
)
|
||||
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_dm_message_from_decrypted(
|
||||
*,
|
||||
packet_id: int,
|
||||
decrypted: "DecryptedDirectMessage",
|
||||
their_public_key: str,
|
||||
our_public_key: str | None,
|
||||
received_at: int | None = None,
|
||||
path: str | None = None,
|
||||
path_len: int | None = None,
|
||||
outgoing: bool = False,
|
||||
realtime: bool = True,
|
||||
broadcast_fn: BroadcastFn,
|
||||
) -> int | None:
|
||||
"""Store and broadcast a decrypted direct message."""
|
||||
contact = await ContactRepository.get_by_key(their_public_key)
|
||||
if contact and contact.type == CONTACT_TYPE_REPEATER:
|
||||
logger.debug(
|
||||
"Skipping message from repeater %s (CLI responses not stored): %s",
|
||||
their_public_key[:12],
|
||||
(decrypted.message or "")[:50],
|
||||
)
|
||||
return None
|
||||
|
||||
received = received_at or int(time.time())
|
||||
conversation_key = their_public_key.lower()
|
||||
sender_name = contact.name if contact and not outgoing else None
|
||||
|
||||
msg_id = await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text=decrypted.message,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
outgoing=outgoing,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
|
||||
if msg_id is None:
|
||||
await handle_duplicate_message(
|
||||
packet_id=packet_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
path=path,
|
||||
received_at=received,
|
||||
path_len=path_len,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Stored direct message %d for contact %s (outgoing=%s)",
|
||||
msg_id,
|
||||
conversation_key[:12],
|
||||
outgoing,
|
||||
)
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
broadcast_message(
|
||||
message=build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=decrypted.message,
|
||||
sender_timestamp=decrypted.timestamp,
|
||||
received_at=received,
|
||||
paths=build_message_paths(path, received, path_len),
|
||||
outgoing=outgoing,
|
||||
sender_name=sender_name,
|
||||
sender_key=conversation_key if not outgoing else None,
|
||||
),
|
||||
broadcast_fn=broadcast_fn,
|
||||
realtime=realtime,
|
||||
)
|
||||
|
||||
await ContactRepository.update_last_contacted(conversation_key, received)
|
||||
return msg_id
|
||||
|
||||
|
||||
async def create_fallback_direct_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
txt_type: int,
|
||||
signature: str | None,
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast a CONTACT_MSG_RECV fallback direct message."""
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="PRIV",
|
||||
text=text,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
if msg_id is None:
|
||||
return None
|
||||
|
||||
message = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=build_message_paths(path, received_at, path_len),
|
||||
txt_type=txt_type,
|
||||
signature=signature,
|
||||
sender_key=sender_key,
|
||||
sender_name=sender_name,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
|
||||
|
||||
async def create_outgoing_direct_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
broadcast_fn: BroadcastFn,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast an outgoing direct message."""
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="PRIV",
|
||||
text=text,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
)
|
||||
if msg_id is None:
|
||||
return None
|
||||
|
||||
message = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="PRIV",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
|
||||
|
||||
async def create_outgoing_channel_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
channel_name: str | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast an outgoing channel message."""
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=conversation_key,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
)
|
||||
if msg_id is None:
|
||||
return None
|
||||
|
||||
message = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
outgoing=True,
|
||||
acked=0,
|
||||
sender_name=sender_name,
|
||||
sender_key=sender_key,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
102
app/services/radio_commands.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
|
||||
from meshcore import EventType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RadioCommandServiceError(RuntimeError):
|
||||
"""Base error for reusable radio command workflows."""
|
||||
|
||||
|
||||
class PathHashModeUnsupportedError(RadioCommandServiceError):
|
||||
"""Raised when firmware does not support path hash mode updates."""
|
||||
|
||||
|
||||
class RadioCommandRejectedError(RadioCommandServiceError):
|
||||
"""Raised when the radio reports an error for a command."""
|
||||
|
||||
|
||||
class KeystoreRefreshError(RadioCommandServiceError):
|
||||
"""Raised when server-side keystore refresh fails after import."""
|
||||
|
||||
|
||||
async def apply_radio_config_update(
|
||||
mc,
|
||||
update,
|
||||
*,
|
||||
path_hash_mode_supported: bool,
|
||||
set_path_hash_mode: Callable[[int], None],
|
||||
sync_radio_time_fn: Callable[[Any], Awaitable[Any]],
|
||||
) -> None:
|
||||
"""Apply a validated radio-config update to the connected radio."""
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
|
||||
if update.lat is not None or update.lon is not None:
|
||||
current_info = mc.self_info
|
||||
lat = update.lat if update.lat is not None else current_info.get("adv_lat", 0.0)
|
||||
lon = update.lon if update.lon is not None else current_info.get("adv_lon", 0.0)
|
||||
logger.info("Setting radio coordinates to %f, %f", lat, lon)
|
||||
await mc.commands.set_coords(lat=lat, lon=lon)
|
||||
|
||||
if update.tx_power is not None:
|
||||
logger.info("Setting TX power to %d dBm", update.tx_power)
|
||||
await mc.commands.set_tx_power(val=update.tx_power)
|
||||
|
||||
if update.radio is not None:
|
||||
logger.info(
|
||||
"Setting radio params: freq=%f MHz, bw=%f kHz, sf=%d, cr=%d",
|
||||
update.radio.freq,
|
||||
update.radio.bw,
|
||||
update.radio.sf,
|
||||
update.radio.cr,
|
||||
)
|
||||
await mc.commands.set_radio(
|
||||
freq=update.radio.freq,
|
||||
bw=update.radio.bw,
|
||||
sf=update.radio.sf,
|
||||
cr=update.radio.cr,
|
||||
)
|
||||
|
||||
if update.path_hash_mode is not None:
|
||||
if not path_hash_mode_supported:
|
||||
raise PathHashModeUnsupportedError("Firmware does not support path hash mode setting")
|
||||
|
||||
logger.info("Setting path hash mode to %d", update.path_hash_mode)
|
||||
result = await mc.commands.set_path_hash_mode(update.path_hash_mode)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(f"Failed to set path hash mode: {result.payload}")
|
||||
set_path_hash_mode(update.path_hash_mode)
|
||||
|
||||
await sync_radio_time_fn(mc)
|
||||
|
||||
# Commands like set_name() write to flash but don't update cached self_info.
|
||||
# send_appstart() forces a fresh SELF_INFO so the response reflects changes.
|
||||
await mc.commands.send_appstart()
|
||||
|
||||
|
||||
async def import_private_key_and_refresh_keystore(
|
||||
mc,
|
||||
key_bytes: bytes,
|
||||
*,
|
||||
export_and_store_private_key_fn: Callable[[Any], Awaitable[bool]],
|
||||
) -> None:
|
||||
"""Import a private key and refresh the in-memory keystore immediately."""
|
||||
result = await mc.commands.import_private_key(key_bytes)
|
||||
if result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(f"Failed to import private key: {result.payload}")
|
||||
|
||||
keystore_refreshed = await export_and_store_private_key_fn(mc)
|
||||
if not keystore_refreshed:
|
||||
logger.warning("Keystore refresh failed after import, retrying once")
|
||||
keystore_refreshed = await export_and_store_private_key_fn(mc)
|
||||
|
||||
if not keystore_refreshed:
|
||||
raise KeystoreRefreshError(
|
||||
"Private key imported on radio, but server-side keystore refresh failed. "
|
||||
"Reconnect to apply the new key for DM decryption."
|
||||
)
|
||||
256
app/services/radio_lifecycle.py
Normal file
@@ -0,0 +1,256 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POST_CONNECT_SETUP_TIMEOUT_SECONDS = 300
|
||||
POST_CONNECT_SETUP_MAX_ATTEMPTS = 2
|
||||
|
||||
|
||||
async def run_post_connect_setup(radio_manager) -> None:
|
||||
"""Run shared radio initialization after a transport connection succeeds."""
|
||||
from app.event_handlers import register_event_handlers
|
||||
from app.keystore import export_and_store_private_key
|
||||
from app.radio_sync import (
|
||||
drain_pending_messages,
|
||||
send_advertisement,
|
||||
start_message_polling,
|
||||
start_periodic_advert,
|
||||
start_periodic_sync,
|
||||
sync_and_offload_all,
|
||||
sync_radio_time,
|
||||
)
|
||||
|
||||
if not radio_manager.meshcore:
|
||||
return
|
||||
|
||||
if radio_manager._setup_lock is None:
|
||||
radio_manager._setup_lock = asyncio.Lock()
|
||||
|
||||
async def _setup_body() -> None:
|
||||
if not radio_manager.meshcore:
|
||||
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:
|
||||
await export_and_store_private_key(mc)
|
||||
|
||||
# Sync radio clock with system time
|
||||
await sync_radio_time(mc)
|
||||
|
||||
# Apply flood scope from settings (best-effort; older firmware
|
||||
# may not support set_flood_scope)
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
scope = normalize_region_scope(app_settings.flood_scope)
|
||||
try:
|
||||
await mc.commands.set_flood_scope(scope if scope else "")
|
||||
logger.info("Applied flood_scope=%r", scope or "(disabled)")
|
||||
except Exception as exc:
|
||||
logger.warning("set_flood_scope failed (firmware may not support it): %s", exc)
|
||||
|
||||
# Query path hash mode support (best-effort; older firmware won't report it).
|
||||
# If the library's parsed payload is missing path_hash_mode (e.g. stale
|
||||
# .pyc on WSL2 Windows mounts), fall back to raw-frame extraction.
|
||||
reader = mc._reader
|
||||
_original_handle_rx = reader.handle_rx
|
||||
_captured_frame: list[bytes] = []
|
||||
|
||||
async def _capture_handle_rx(data: bytearray) -> None:
|
||||
from meshcore.packets import PacketType
|
||||
|
||||
if len(data) > 0 and data[0] == PacketType.DEVICE_INFO.value:
|
||||
_captured_frame.append(bytes(data))
|
||||
return await _original_handle_rx(data)
|
||||
|
||||
reader.handle_rx = _capture_handle_rx
|
||||
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 "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 = _captured_frame[-1]
|
||||
fw_ver = raw[1] if len(raw) > 1 else 0
|
||||
if fw_ver >= 10 and len(raw) >= 82:
|
||||
radio_manager.path_hash_mode = raw[81]
|
||||
radio_manager.path_hash_mode_supported = True
|
||||
logger.warning(
|
||||
"path_hash_mode=%d extracted from raw frame "
|
||||
"(stale .pyc? try: rm %s)",
|
||||
radio_manager.path_hash_mode,
|
||||
getattr(
|
||||
__import__("meshcore.reader", fromlist=["reader"]),
|
||||
"__cached__",
|
||||
"meshcore __pycache__/reader.*.pyc",
|
||||
),
|
||||
)
|
||||
if radio_manager.path_hash_mode_supported:
|
||||
logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode)
|
||||
else:
|
||||
logger.debug("Firmware does not report path_hash_mode")
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to query path_hash_mode: %s", exc)
|
||||
finally:
|
||||
reader.handle_rx = _original_handle_rx
|
||||
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
logger.info("Sync complete: %s", result)
|
||||
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
logger.info("Advertisement sent")
|
||||
else:
|
||||
logger.debug("Advertisement skipped (disabled or throttled)")
|
||||
|
||||
# Drain any messages that were queued before we connected.
|
||||
# This must happen BEFORE starting auto-fetch, otherwise both
|
||||
# compete on get_msg() with interleaved radio I/O.
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
|
||||
await mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
finally:
|
||||
radio_manager._release_operation_lock("post_connect_setup")
|
||||
|
||||
# Start background tasks AFTER releasing the operation lock.
|
||||
# These tasks acquire their own locks when they need radio access.
|
||||
start_periodic_sync()
|
||||
start_periodic_advert()
|
||||
start_message_polling()
|
||||
|
||||
radio_manager._setup_complete = True
|
||||
finally:
|
||||
radio_manager._setup_in_progress = False
|
||||
|
||||
async with radio_manager._setup_lock:
|
||||
await asyncio.wait_for(_setup_body(), timeout=POST_CONNECT_SETUP_TIMEOUT_SECONDS)
|
||||
|
||||
logger.info("Post-connect setup complete")
|
||||
|
||||
|
||||
async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> None:
|
||||
"""Finish setup for an already-connected radio and optionally broadcast health."""
|
||||
from app.websocket import broadcast_error, broadcast_health
|
||||
|
||||
for attempt in range(1, POST_CONNECT_SETUP_MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
break
|
||||
except asyncio.TimeoutError as exc:
|
||||
if attempt < POST_CONNECT_SETUP_MAX_ATTEMPTS:
|
||||
logger.warning(
|
||||
"Post-connect setup timed out after %ds on attempt %d/%d; retrying once",
|
||||
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
|
||||
attempt,
|
||||
POST_CONNECT_SETUP_MAX_ATTEMPTS,
|
||||
)
|
||||
continue
|
||||
|
||||
logger.error(
|
||||
"Post-connect setup timed out after %ds on %d attempts. Initial radio offload "
|
||||
"took too long; something is probably wrong.",
|
||||
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
|
||||
POST_CONNECT_SETUP_MAX_ATTEMPTS,
|
||||
)
|
||||
broadcast_error(
|
||||
"Radio startup appears stuck",
|
||||
"Initial radio offload took too long. Reboot the radio and restart the server.",
|
||||
)
|
||||
raise RuntimeError("Post-connect setup timed out") from exc
|
||||
|
||||
radio_manager._last_connected = True
|
||||
if broadcast_on_success:
|
||||
broadcast_health(True, radio_manager.connection_info)
|
||||
|
||||
|
||||
async def reconnect_and_prepare_radio(
|
||||
radio_manager,
|
||||
*,
|
||||
broadcast_on_success: bool = True,
|
||||
) -> bool:
|
||||
"""Reconnect the transport, then run post-connect setup before reporting healthy."""
|
||||
connected = await radio_manager.reconnect(broadcast_on_success=False)
|
||||
if not connected:
|
||||
return False
|
||||
|
||||
await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success)
|
||||
return True
|
||||
|
||||
|
||||
async def connection_monitor_loop(radio_manager) -> None:
|
||||
"""Monitor radio health and keep transport/setup state converged."""
|
||||
from app.websocket import broadcast_health
|
||||
|
||||
check_interval_seconds = 5
|
||||
unresponsive_threshold = 3
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(check_interval_seconds)
|
||||
|
||||
current_connected = radio_manager.is_connected
|
||||
|
||||
if radio_manager._last_connected and not current_connected:
|
||||
logger.warning("Radio connection lost, broadcasting status change")
|
||||
broadcast_health(False, radio_manager.connection_info)
|
||||
radio_manager._last_connected = False
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
if not current_connected:
|
||||
if not radio_manager.is_reconnecting and await reconnect_and_prepare_radio(
|
||||
radio_manager,
|
||||
broadcast_on_success=True,
|
||||
):
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
elif not radio_manager._last_connected and current_connected:
|
||||
logger.info("Radio connection restored")
|
||||
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
elif (
|
||||
current_connected
|
||||
and not radio_manager.is_setup_complete
|
||||
and not radio_manager.is_setup_in_progress
|
||||
):
|
||||
logger.info("Retrying post-connect setup...")
|
||||
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
consecutive_setup_failures += 1
|
||||
if consecutive_setup_failures == unresponsive_threshold:
|
||||
logger.error(
|
||||
"Post-connect setup has failed %d times in a row. "
|
||||
"The radio port appears open but the radio is not "
|
||||
"responding to commands. Common causes: another "
|
||||
"process has the serial port open (check for other "
|
||||
"RemoteTerm instances, serial monitors, etc.), the "
|
||||
"firmware is in repeater mode (not client), or the "
|
||||
"radio needs a power cycle. Will keep retrying.",
|
||||
consecutive_setup_failures,
|
||||
)
|
||||
elif consecutive_setup_failures < unresponsive_threshold:
|
||||
logger.exception("Error in connection monitor, continuing: %s", e)
|
||||
91
app/services/radio_runtime.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Shared access seam over the global RadioManager instance.
|
||||
|
||||
This module deliberately keeps behavior thin and forwarding-only. The goal is
|
||||
to reduce direct `app.radio.radio_manager` imports across routers and helpers
|
||||
without changing radio lifecycle, lock, or connection semantics.
|
||||
"""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
import app.radio as radio_module
|
||||
|
||||
|
||||
class RadioRuntime:
|
||||
"""Thin forwarding wrapper around the process-global RadioManager."""
|
||||
|
||||
def __init__(self, manager_or_getter=None):
|
||||
if manager_or_getter is None:
|
||||
self._manager_getter: Callable[[], Any] = lambda: radio_module.radio_manager
|
||||
elif callable(manager_or_getter):
|
||||
self._manager_getter = manager_or_getter
|
||||
else:
|
||||
self._manager_getter = lambda: manager_or_getter
|
||||
|
||||
@property
|
||||
def manager(self) -> Any:
|
||||
return self._manager_getter()
|
||||
|
||||
def __getattr__(self, name: str) -> Any:
|
||||
"""Forward unknown attributes to the current global manager."""
|
||||
return getattr(self.manager, name)
|
||||
|
||||
@staticmethod
|
||||
def _is_local_runtime_attr(name: str) -> bool:
|
||||
return name.startswith("_") or hasattr(RadioRuntime, name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any) -> None:
|
||||
if self._is_local_runtime_attr(name):
|
||||
object.__setattr__(self, name, value)
|
||||
return
|
||||
setattr(self.manager, name, value)
|
||||
|
||||
def __delattr__(self, name: str) -> None:
|
||||
if self._is_local_runtime_attr(name):
|
||||
object.__delattr__(self, name)
|
||||
return
|
||||
delattr(self.manager, name)
|
||||
|
||||
def require_connected(self):
|
||||
"""Return MeshCore when available, mirroring existing HTTP semantics."""
|
||||
if self.is_setup_in_progress:
|
||||
raise HTTPException(status_code=503, detail="Radio is initializing")
|
||||
if not self.is_connected:
|
||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
||||
mc = self.meshcore
|
||||
if mc is None:
|
||||
raise HTTPException(status_code=503, detail="Radio not connected")
|
||||
return mc
|
||||
|
||||
@asynccontextmanager
|
||||
async def radio_operation(self, name: str, **kwargs):
|
||||
async with self.manager.radio_operation(name, **kwargs) as mc:
|
||||
yield mc
|
||||
|
||||
async def start_connection_monitor(self) -> None:
|
||||
await self.manager.start_connection_monitor()
|
||||
|
||||
async def stop_connection_monitor(self) -> None:
|
||||
await self.manager.stop_connection_monitor()
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
await self.manager.disconnect()
|
||||
|
||||
async def prepare_connected(self, *, broadcast_on_success: bool = True) -> None:
|
||||
from app.services.radio_lifecycle import prepare_connected_radio
|
||||
|
||||
await prepare_connected_radio(self.manager, broadcast_on_success=broadcast_on_success)
|
||||
|
||||
async def reconnect_and_prepare(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
from app.services.radio_lifecycle import reconnect_and_prepare_radio
|
||||
|
||||
return await reconnect_and_prepare_radio(
|
||||
self.manager,
|
||||
broadcast_on_success=broadcast_on_success,
|
||||
)
|
||||
|
||||
|
||||
radio_runtime = RadioRuntime()
|
||||
@@ -1,12 +1,13 @@
|
||||
"""WebSocket manager for real-time updates."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
from app.events import dump_ws_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Timeout for individual WebSocket send operations (seconds)
|
||||
@@ -45,7 +46,7 @@ class WebSocketManager:
|
||||
if not self.active_connections:
|
||||
return
|
||||
|
||||
message = json.dumps({"type": event_type, "data": data})
|
||||
message = dump_ws_event(event_type, data)
|
||||
|
||||
# Copy connection list under lock to avoid holding lock during I/O
|
||||
async with self._lock:
|
||||
@@ -81,7 +82,7 @@ class WebSocketManager:
|
||||
|
||||
async def send_personal(self, websocket: WebSocket, event_type: str, data: Any) -> None:
|
||||
"""Send an event to a specific client."""
|
||||
message = json.dumps({"type": event_type, "data": data})
|
||||
message = dump_ws_event(event_type, data)
|
||||
try:
|
||||
await websocket.send_text(message)
|
||||
except Exception as e:
|
||||
|
||||
@@ -16,15 +16,25 @@ Keep it aligned with `frontend/src` source code.
|
||||
- `meshcore-hashtag-cracker` + `nosleep.js` (channel cracker)
|
||||
- Multibyte-aware decoder build published as `meshcore-decoder-multibyte-patch`
|
||||
|
||||
## Code Ethos
|
||||
|
||||
- Prefer fewer, stronger modules over many thin wrappers.
|
||||
- Split code only when the new hook/component owns a real invariant or workflow.
|
||||
- Keep one reasoning unit readable in one place, even if that file is moderately large.
|
||||
- Avoid dedicated files whose main job is pass-through, prop bundling, or renaming.
|
||||
- For this repo, "locally dense but semantically obvious" is better than indirection-heavy "clean architecture".
|
||||
- When refactoring, preserve behavior first and add tests around the seam being moved.
|
||||
|
||||
## Frontend Map
|
||||
|
||||
```text
|
||||
frontend/src/
|
||||
├── main.tsx # React entry point (StrictMode, root render)
|
||||
├── App.tsx # App shell and orchestration
|
||||
├── App.tsx # Data/orchestration entry that wires hooks into AppShell
|
||||
├── api.ts # Typed REST client
|
||||
├── types.ts # Shared TS contracts
|
||||
├── useWebSocket.ts # WS lifecycle + event dispatch
|
||||
├── wsEvents.ts # Typed WS event parsing / discriminated union
|
||||
├── messageCache.ts # Conversation-scoped cache
|
||||
├── prefetch.ts # Consumes prefetched API promises started in index.html
|
||||
├── index.css # Global styles/utilities
|
||||
@@ -34,13 +44,27 @@ frontend/src/
|
||||
│ └── utils.ts # cn() — clsx + tailwind-merge helper
|
||||
├── hooks/
|
||||
│ ├── index.ts # Central re-export of all hooks
|
||||
│ ├── useConversationMessages.ts # Fetch, pagination, dedup, ACK buffering
|
||||
│ ├── useConversationActions.ts # Send/resend/trace/block conversation actions
|
||||
│ ├── useConversationNavigation.ts # Search target, selection reset, and info-pane navigation state
|
||||
│ ├── useConversationMessages.ts # Conversation timeline loading, cache restore, jump-target loading, pagination, dedup, pending ACK buffering
|
||||
│ ├── useUnreadCounts.ts # Unread counters, mentions, recent-sort timestamps
|
||||
│ ├── useRealtimeAppState.ts # WebSocket event application and reconnect recovery
|
||||
│ ├── useAppShell.ts # App-shell view state (settings/sidebar/modals/cracker)
|
||||
│ ├── useRepeaterDashboard.ts # Repeater dashboard state (login, panes, console, retries)
|
||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection
|
||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
||||
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||
├── components/
|
||||
│ ├── AppShell.tsx # App-shell layout: status, sidebar, search/settings panes, cracker, modals
|
||||
│ ├── ConversationPane.tsx # Active conversation surface selection (map/raw/repeater/chat/empty)
|
||||
│ ├── visualizer/
|
||||
│ │ ├── useVisualizerData3D.ts # Packet→graph data pipeline, repeat aggregation, simulation state
|
||||
│ │ ├── useVisualizer3DScene.ts # Three.js scene lifecycle, buffers, hover/pin interaction
|
||||
│ │ ├── VisualizerControls.tsx # Visualizer legends and control panel overlay
|
||||
│ │ ├── VisualizerTooltip.tsx # Hover/pin node detail overlay
|
||||
│ │ └── shared.ts # Graph node/link types and shared rendering helpers
|
||||
│ └── ...
|
||||
├── utils/
|
||||
│ ├── urlHash.ts # Hash parsing and encoding
|
||||
│ ├── conversationState.ts # State keys, in-memory + localStorage helpers
|
||||
@@ -136,10 +160,15 @@ frontend/src/
|
||||
├── searchView.test.tsx
|
||||
├── useConversationMessages.test.ts
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useConversationNavigation.test.ts
|
||||
├── useAppShell.test.ts
|
||||
├── useRepeaterDashboard.test.ts
|
||||
├── useContactsAndChannels.test.ts
|
||||
├── useRealtimeAppState.test.ts
|
||||
├── useUnreadCounts.test.ts
|
||||
├── useWebSocket.dispatch.test.ts
|
||||
└── useWebSocket.lifecycle.test.ts
|
||||
├── useWebSocket.lifecycle.test.ts
|
||||
└── wsEvents.test.ts
|
||||
|
||||
```
|
||||
|
||||
@@ -147,19 +176,42 @@ frontend/src/
|
||||
|
||||
### State ownership
|
||||
|
||||
`App.tsx` orchestrates high-level state and delegates to hooks:
|
||||
`App.tsx` is now a thin composition entrypoint over the hook layer. `AppShell.tsx` owns shell layout/composition:
|
||||
- local label banner
|
||||
- status bar
|
||||
- desktop/mobile sidebar container
|
||||
- search/settings surface switching
|
||||
- global cracker mount/focus behavior
|
||||
- new-message modal and info panes
|
||||
|
||||
High-level state is delegated to hooks:
|
||||
- `useAppShell`: app-shell view state (settings section, sidebar, cracker, new-message modal)
|
||||
- `useRadioControl`: radio health/config state, reconnect/reboot polling
|
||||
- `useAppSettings`: settings CRUD, favorites, preferences migration
|
||||
- `useContactsAndChannels`: contact/channel lists, creation, deletion
|
||||
- `useConversationRouter`: URL hash → active conversation routing
|
||||
- `useConversationMessages`: fetch, pagination, dedup/update helpers
|
||||
- `useConversationNavigation`: search target, conversation selection reset, and info-pane state
|
||||
- `useConversationActions`: send/resend/trace/block handlers and channel override updates
|
||||
- `useConversationMessages`: conversation switch loading, cache restore, jump-target loading, pagination, dedup/update helpers, and pending ACK buffering
|
||||
- `useUnreadCounts`: unread counters, mention tracking, recent-sort timestamps
|
||||
- `useRealtimeAppState`: typed WS event application, reconnect recovery, cache/unread coordination
|
||||
- `useRepeaterDashboard`: repeater dashboard state (login, pane data/retries, console, actions)
|
||||
|
||||
`App.tsx` intentionally still does the final `AppShell` prop assembly. That composition layer is considered acceptable here because it keeps the shell contract visible in one place and avoids a prop-bundling hook with little original logic.
|
||||
|
||||
`ConversationPane.tsx` owns the main active-conversation surface branching:
|
||||
- empty state
|
||||
- map view
|
||||
- visualizer
|
||||
- raw packet feed
|
||||
- repeater dashboard
|
||||
- normal chat chrome (`ChatHeader` + `MessageList` + `MessageInput`)
|
||||
|
||||
### Initial load + realtime
|
||||
|
||||
- Initial data: REST fetches (`api.ts`) for config/settings/channels/contacts/unreads.
|
||||
- WebSocket: realtime deltas/events.
|
||||
- On reconnect, the app refetches channels and contacts, refreshes unread counts, and reconciles the active conversation to recover disconnect-window drift.
|
||||
- On WS connect, backend sends `health` only; contacts/channels still come from REST.
|
||||
|
||||
### New Message modal
|
||||
@@ -176,6 +228,7 @@ frontend/src/
|
||||
### Visualizer behavior
|
||||
|
||||
- `VisualizerView.tsx` hosts `PacketVisualizer3D.tsx` (desktop split-pane and mobile tabs).
|
||||
- `PacketVisualizer3D.tsx` is now a thin composition shell over visualizer-specific hooks/components in `components/visualizer/`.
|
||||
- `PacketVisualizer3D` uses persistent Three.js geometries for links/highlights/particles and updates typed-array buffers in-place per frame.
|
||||
- Packet repeat aggregation keys prefer decoder `messageHash` (path-insensitive), with hash fallback for malformed packets.
|
||||
- Raw-packet decoding in `RawPacketList.tsx` and `visualizerUtils.ts` relies on the multibyte-aware decoder fork; keep frontend packet parsing aligned with backend `path_utils.py`.
|
||||
@@ -193,6 +246,7 @@ frontend/src/
|
||||
|
||||
- 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).
|
||||
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.
|
||||
|
||||
@@ -276,7 +330,7 @@ Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInf
|
||||
- Nearest repeaters (resolved from first-hop path prefixes)
|
||||
- Recent advert paths
|
||||
|
||||
State: `infoPaneContactKey` in App.tsx controls open/close. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||
State: `useConversationNavigation` controls open/close via `infoPaneContactKey`. Live contact data from WebSocket updates is preferred over the initial detail snapshot.
|
||||
|
||||
## Channel Info Pane
|
||||
|
||||
@@ -288,11 +342,11 @@ Clicking a channel name in `ChatHeader` opens a `ChannelInfoPane` sheet (right d
|
||||
- First message date
|
||||
- Top senders in last 24h (name + count)
|
||||
|
||||
State: `infoPaneChannelKey` in App.tsx controls open/close. Live channel data from the `channels` array is preferred over the initial detail snapshot.
|
||||
State: `useConversationNavigation` controls open/close via `infoPaneChannelKey`. Live channel data from the `channels` array is preferred over the initial detail snapshot.
|
||||
|
||||
## Repeater Dashboard
|
||||
|
||||
For repeater contacts (`type=2`), App.tsx renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
||||
For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashboard` instead of the normal chat UI (ChatHeader + MessageList + MessageInput).
|
||||
|
||||
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
|
||||
|
||||
@@ -308,9 +362,10 @@ All state is managed by `useRepeaterDashboard` hook. State resets on conversatio
|
||||
|
||||
The `SearchView` component (`components/SearchView.tsx`) provides full-text search across all DMs and channel messages. Key behaviors:
|
||||
|
||||
- **State**: `targetMessageId` in `App.tsx` drives the jump-to-message flow. When a search result is clicked, `handleNavigateToMessage` sets `targetMessageId` and switches to the target conversation.
|
||||
- **State**: `targetMessageId` is shared between `useConversationNavigation` and `useConversationMessages`. When a search result is clicked, `handleNavigateToMessage` sets the target ID and switches to the target conversation.
|
||||
- **Same-conversation clear**: when `targetMessageId` is cleared after the target is reached, the hook preserves the around-loaded mid-history view instead of replacing it with the latest page.
|
||||
- **Persistence**: `SearchView` stays mounted after first open using the same `hidden` class pattern as `CrackerPanel`, preserving search state when navigating to results.
|
||||
- **Jump-to-message**: `useConversationMessages` accepts optional `targetMessageId`. When set, it calls `api.getMessagesAround()` instead of normal fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation.
|
||||
- **Jump-to-message**: `useConversationMessages` handles optional `targetMessageId` by calling `api.getMessagesAround()` instead of the normal latest-page fetch, loading context around the target message. `MessageList` scrolls to the target via `data-message-id` attribute and applies a `message-highlight` CSS animation.
|
||||
- **Bidirectional pagination**: After jumping mid-history, `hasNewerMessages` enables forward pagination via `fetchNewerMessages`. The scroll-to-bottom button calls `jumpToBottom` (re-fetches latest page) instead of just scrolling.
|
||||
- **WS message suppression**: When `hasNewerMessages` is true, incoming WS messages for the active conversation are not added to the message list (the user is viewing historical context, not the latest page).
|
||||
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "2.7.1",
|
||||
"version": "2.7.9",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@michaelhart/meshcore-decoder": "npm:meshcore-decoder-multibyte-patch@0.2.7",
|
||||
"@michaelhart/meshcore-decoder": "npm:meshcore-decoder-multibyte-patch@0.3.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
@@ -1345,9 +1345,9 @@
|
||||
},
|
||||
"node_modules/@michaelhart/meshcore-decoder": {
|
||||
"name": "meshcore-decoder-multibyte-patch",
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/meshcore-decoder-multibyte-patch/-/meshcore-decoder-multibyte-patch-0.2.7.tgz",
|
||||
"integrity": "sha512-sk0SIsIg3wSaQKAmdROTzZw7xebRXwaR1d5O+Z6l+Ya956hcSel+8AOmSJUBo7kVA7MmCsHWtTdb9majDmRl0w==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/meshcore-decoder-multibyte-patch/-/meshcore-decoder-multibyte-patch-0.3.0.tgz",
|
||||
"integrity": "sha512-qcGsi1/PbtCTQbZ7rf9G3iSk55eouiGaroeS1ZS+jiKWBELZPV3++xjRup2+xX0rjQtnX5Gg4SCcLvF7Y0M6Sg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ed25519": "^2.3.0",
|
||||
@@ -5000,6 +5000,22 @@
|
||||
"crypto-js": "^4.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/meshcore-hashtag-cracker/node_modules/@michaelhart/meshcore-decoder": {
|
||||
"name": "meshcore-decoder-multibyte-patch",
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/meshcore-decoder-multibyte-patch/-/meshcore-decoder-multibyte-patch-0.2.7.tgz",
|
||||
"integrity": "sha512-sk0SIsIg3wSaQKAmdROTzZw7xebRXwaR1d5O+Z6l+Ya956hcSel+8AOmSJUBo7kVA7MmCsHWtTdb9majDmRl0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/ed25519": "^2.3.0",
|
||||
"chalk": "^4.1.2",
|
||||
"commander": "^12.0.0",
|
||||
"crypto-js": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"meshcore-decoder": "dist/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/meshoptimizer": {
|
||||
"version": "0.22.0",
|
||||
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz",
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@michaelhart/meshcore-decoder": "npm:meshcore-decoder-multibyte-patch@0.2.7",
|
||||
"@michaelhart/meshcore-decoder": "npm:meshcore-decoder-multibyte-patch@0.3.0",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/favicon-256x256.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 4.3 KiB |
1054
frontend/src/App.tsx
@@ -137,9 +137,10 @@ export const api = {
|
||||
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
resetContactPath: (publicKey: string) =>
|
||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/reset-path`, {
|
||||
setContactRoutingOverride: (publicKey: string, route: string) =>
|
||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/routing-override`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ route }),
|
||||
}),
|
||||
|
||||
// Channels
|
||||
@@ -156,6 +157,11 @@ export const api = {
|
||||
fetchJson<{ status: string; key: string }>(`/channels/${key}/mark-read`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
setChannelFloodScopeOverride: (key: string, floodScopeOverride: string) =>
|
||||
fetchJson<Channel>(`/channels/${key}/flood-scope-override`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
|
||||
}),
|
||||
|
||||
// Messages
|
||||
getMessages: (
|
||||
|
||||
305
frontend/src/components/AppShell.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
|
||||
|
||||
import { StatusBar } from './StatusBar';
|
||||
import { Sidebar } from './Sidebar';
|
||||
import { ConversationPane } from './ConversationPane';
|
||||
import { NewMessageModal } from './NewMessageModal';
|
||||
import { ContactInfoPane } from './ContactInfoPane';
|
||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||
import { Toaster } from './ui/sonner';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import {
|
||||
SETTINGS_SECTION_ICONS,
|
||||
SETTINGS_SECTION_LABELS,
|
||||
SETTINGS_SECTION_ORDER,
|
||||
type SettingsSection,
|
||||
} from './settings/settingsConstants';
|
||||
import { getContrastTextColor, type LocalLabel } from '../utils/localLabel';
|
||||
import type { CrackerPanelProps } from './CrackerPanel';
|
||||
import type { SearchViewProps } from './SearchView';
|
||||
import type { SettingsModalProps } from './SettingsModal';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SettingsModal = lazy(() =>
|
||||
import('./SettingsModal').then((m) => ({ default: m.SettingsModal }))
|
||||
);
|
||||
const CrackerPanel = lazy(() =>
|
||||
import('./CrackerPanel').then((m) => ({ default: m.CrackerPanel }))
|
||||
);
|
||||
const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.SearchView })));
|
||||
|
||||
type SidebarProps = ComponentProps<typeof Sidebar>;
|
||||
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
|
||||
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
|
||||
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
|
||||
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
|
||||
|
||||
interface AppShellProps {
|
||||
localLabel: LocalLabel;
|
||||
showNewMessage: boolean;
|
||||
showSettings: boolean;
|
||||
settingsSection: SettingsSection;
|
||||
sidebarOpen: boolean;
|
||||
showCracker: boolean;
|
||||
onSettingsSectionChange: (section: SettingsSection) => void;
|
||||
onSidebarOpenChange: (open: boolean) => void;
|
||||
onCrackerRunningChange: (running: boolean) => void;
|
||||
onToggleSettingsView: () => void;
|
||||
onCloseSettingsView: () => void;
|
||||
onCloseNewMessage: () => void;
|
||||
onLocalLabelChange: (label: LocalLabel) => void;
|
||||
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
|
||||
sidebarProps: SidebarProps;
|
||||
conversationPaneProps: ConversationPaneProps;
|
||||
searchProps: SearchViewProps;
|
||||
settingsProps: Omit<
|
||||
SettingsModalProps,
|
||||
'open' | 'pageMode' | 'externalSidebarNav' | 'desktopSection' | 'onClose' | 'onLocalLabelChange'
|
||||
>;
|
||||
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
|
||||
newMessageModalProps: NewMessageModalProps;
|
||||
contactInfoPaneProps: ContactInfoPaneProps;
|
||||
channelInfoPaneProps: ChannelInfoPaneProps;
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
localLabel,
|
||||
showNewMessage,
|
||||
showSettings,
|
||||
settingsSection,
|
||||
sidebarOpen,
|
||||
showCracker,
|
||||
onSettingsSectionChange,
|
||||
onSidebarOpenChange,
|
||||
onCrackerRunningChange,
|
||||
onToggleSettingsView,
|
||||
onCloseSettingsView,
|
||||
onCloseNewMessage,
|
||||
onLocalLabelChange,
|
||||
statusProps,
|
||||
sidebarProps,
|
||||
conversationPaneProps,
|
||||
searchProps,
|
||||
settingsProps,
|
||||
crackerProps,
|
||||
newMessageModalProps,
|
||||
contactInfoPaneProps,
|
||||
channelInfoPaneProps,
|
||||
}: AppShellProps) {
|
||||
const searchMounted = useRef(false);
|
||||
if (conversationPaneProps.activeConversation?.type === 'search') {
|
||||
searchMounted.current = true;
|
||||
}
|
||||
|
||||
const crackerMounted = useRef(false);
|
||||
if (showCracker) {
|
||||
crackerMounted.current = true;
|
||||
}
|
||||
|
||||
const settingsSidebarContent = (
|
||||
<nav
|
||||
className="sidebar w-60 h-full min-h-0 overflow-hidden bg-card border-r border-border flex flex-col"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
|
||||
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Settings
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCloseSettingsView}
|
||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs bg-status-connected/15 border border-status-connected/30 text-status-connected hover:bg-status-connected/25 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
title="Back to conversations"
|
||||
aria-label="Back to conversations"
|
||||
>
|
||||
← Back to Chat
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
|
||||
{SETTINGS_SECTION_ORDER.map((section) => {
|
||||
const Icon = SETTINGS_SECTION_ICONS[section];
|
||||
return (
|
||||
<button
|
||||
key={section}
|
||||
type="button"
|
||||
className={cn(
|
||||
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
|
||||
settingsSection === section && 'bg-accent border-l-primary'
|
||||
)}
|
||||
aria-current={settingsSection === section ? 'true' : undefined}
|
||||
onClick={() => onSettingsSectionChange(section)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<span>{SETTINGS_SECTION_LABELS[section]}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
|
||||
const activeSidebarContent = showSettings ? (
|
||||
settingsSidebarContent
|
||||
) : (
|
||||
<Sidebar {...sidebarProps} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<a
|
||||
href="#main-content"
|
||||
className="sr-only focus:not-sr-only focus:absolute focus:z-50 focus:p-2 focus:bg-primary focus:text-primary-foreground"
|
||||
>
|
||||
Skip to content
|
||||
</a>
|
||||
{localLabel.text && (
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: localLabel.color,
|
||||
color: getContrastTextColor(localLabel.color),
|
||||
}}
|
||||
className="px-4 py-1 text-center text-sm font-medium"
|
||||
>
|
||||
{localLabel.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<StatusBar
|
||||
health={statusProps.health}
|
||||
config={statusProps.config}
|
||||
settingsMode={showSettings}
|
||||
onSettingsClick={onToggleSettingsView}
|
||||
onMenuClick={showSettings ? undefined : () => onSidebarOpenChange(true)}
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<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>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Sidebar navigation</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-hidden">{activeSidebarContent}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<main id="main-content" className="flex-1 flex flex-col bg-background min-w-0">
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex flex-col min-h-0',
|
||||
(showSettings || conversationPaneProps.activeConversation?.type === 'search') &&
|
||||
'hidden'
|
||||
)}
|
||||
>
|
||||
<ConversationPane {...conversationPaneProps} />
|
||||
</div>
|
||||
|
||||
{searchMounted.current && (
|
||||
<div
|
||||
className={cn(
|
||||
'flex-1 flex flex-col min-h-0',
|
||||
(conversationPaneProps.activeConversation?.type !== 'search' || showSettings) &&
|
||||
'hidden'
|
||||
)}
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Loading search...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SearchView {...searchProps} />
|
||||
</Suspense>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSettings && (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
|
||||
<span>Radio & Settings</span>
|
||||
<span className="text-sm text-muted-foreground hidden md:inline">
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{(() => {
|
||||
const Icon = SETTINGS_SECTION_ICONS[settingsSection];
|
||||
return <Icon className="h-4 w-4" aria-hidden="true" />;
|
||||
})()}
|
||||
<span>{SETTINGS_SECTION_LABELS[settingsSection]}</span>
|
||||
</span>
|
||||
</span>
|
||||
</h2>
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex-1 flex items-center justify-center p-8 text-muted-foreground">
|
||||
Loading settings...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsModal
|
||||
{...settingsProps}
|
||||
open={showSettings}
|
||||
pageMode
|
||||
externalSidebarNav
|
||||
desktopSection={settingsSection}
|
||||
onClose={onCloseSettingsView}
|
||||
onLocalLabelChange={onLocalLabelChange}
|
||||
/>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (showCracker && el) {
|
||||
const focusable = el.querySelector<HTMLElement>('input, button:not([disabled])');
|
||||
if (focusable) {
|
||||
setTimeout(() => focusable.focus(), 210);
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'border-t border-border bg-background transition-all duration-200 overflow-hidden',
|
||||
showCracker ? 'h-[275px]' : 'h-0'
|
||||
)}
|
||||
>
|
||||
{crackerMounted.current && (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading cracker...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CrackerPanel
|
||||
{...crackerProps}
|
||||
visible={showCracker}
|
||||
onRunningChange={onCrackerRunningChange}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<NewMessageModal
|
||||
{...newMessageModalProps}
|
||||
open={showNewMessage}
|
||||
onClose={onCloseNewMessage}
|
||||
onSelectConversation={(conv) => {
|
||||
newMessageModalProps.onSelectConversation(conv);
|
||||
onCloseNewMessage();
|
||||
}}
|
||||
/>
|
||||
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||
<Toaster position="top-right" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
@@ -125,12 +126,12 @@ export function ChannelInfoPane({
|
||||
>
|
||||
{isFavorite(favorites, 'channel', channel.key) ? (
|
||||
<>
|
||||
<span className="text-favorite text-lg">★</span>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">☆</span>
|
||||
<Star className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Add to favorites</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { stripRegionScopePrefix } from '../utils/regionScope';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types';
|
||||
@@ -12,8 +14,13 @@ interface ChatHeaderProps {
|
||||
channels: Channel[];
|
||||
config: RadioConfig | null;
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
onTrace: () => void;
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||
onDeleteChannel: (key: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
@@ -26,8 +33,13 @@ export function ChatHeader({
|
||||
channels,
|
||||
config,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
onTrace,
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onDeleteChannel,
|
||||
onDeleteContact,
|
||||
onOpenContactInfo,
|
||||
@@ -39,15 +51,43 @@ export function ChatHeader({
|
||||
setShowKey(false);
|
||||
}, [conversation.id]);
|
||||
|
||||
const isPrivateChannel =
|
||||
conversation.type === 'channel' && !channels.find((c) => c.key === conversation.id)?.is_hashtag;
|
||||
const activeChannel =
|
||||
conversation.type === 'channel'
|
||||
? channels.find((channel) => channel.key === conversation.id)
|
||||
: undefined;
|
||||
const activeFloodScopeOverride =
|
||||
conversation.type === 'channel' ? (activeChannel?.flood_scope_override ?? null) : null;
|
||||
const activeFloodScopeLabel = activeFloodScopeOverride
|
||||
? stripRegionScopePrefix(activeFloodScopeOverride)
|
||||
: null;
|
||||
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
|
||||
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
|
||||
|
||||
const titleClickable =
|
||||
(conversation.type === 'contact' && onOpenContactInfo) ||
|
||||
(conversation.type === 'channel' && onOpenChannelInfo);
|
||||
const favoriteTitle =
|
||||
conversation.type === 'contact'
|
||||
? isFavorite(favorites, 'contact', conversation.id)
|
||||
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
: isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites';
|
||||
|
||||
const handleEditFloodScopeOverride = () => {
|
||||
if (conversation.type !== 'channel' || !onSetChannelFloodScopeOverride) return;
|
||||
const nextValue = window.prompt(
|
||||
'Enter regional override flood scope for this room. This temporarily changes the radio flood scope before send and restores it after, which significantly slows room sends. Leave blank to clear.',
|
||||
activeFloodScopeLabel ?? ''
|
||||
);
|
||||
if (nextValue === null) return;
|
||||
onSetChannelFloodScopeOverride(conversation.id, nextValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
|
||||
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex min-w-0 flex-1 items-start gap-2">
|
||||
{conversation.type === 'contact' && onOpenContactInfo && (
|
||||
<span
|
||||
className="flex-shrink-0 cursor-pointer"
|
||||
@@ -67,98 +107,175 @@ export function ChatHeader({
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<h2
|
||||
className={`flex-shrink-0 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
|
||||
role={titleClickable ? 'button' : undefined}
|
||||
tabIndex={titleClickable ? 0 : undefined}
|
||||
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
|
||||
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
|
||||
onClick={
|
||||
titleClickable
|
||||
? () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
|
||||
onOpenChannelInfo(conversation.id);
|
||||
}
|
||||
<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
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
channels.find((c) => c.key === conversation.id)?.is_hashtag
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</h2>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
<button
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono hover:text-primary transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowKey(true);
|
||||
}}
|
||||
title="Reveal channel key"
|
||||
>
|
||||
Show Key
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success(
|
||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
||||
);
|
||||
}}
|
||||
title="Click to copy"
|
||||
aria-label={conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'}
|
||||
>
|
||||
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
|
||||
>
|
||||
<span 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"
|
||||
/>
|
||||
)}
|
||||
</h2>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
<button
|
||||
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowKey(true);
|
||||
}}
|
||||
title="Reveal channel key"
|
||||
>
|
||||
Show Key
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success(
|
||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
||||
);
|
||||
}}
|
||||
title="Click to copy"
|
||||
aria-label={
|
||||
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
|
||||
}
|
||||
>
|
||||
{conversation.type === 'channel'
|
||||
? conversation.id.toLowerCase()
|
||||
: conversation.id}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{conversation.type === 'contact' &&
|
||||
(() => {
|
||||
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>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
{conversation.type === 'contact' &&
|
||||
(() => {
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||
if (!contact) return null;
|
||||
return (
|
||||
<ContactStatusInfo
|
||||
contact={contact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
{conversation.type === 'channel' && activeFloodScopeDisplay && (
|
||||
<button
|
||||
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
|
||||
onClick={handleEditFloodScopeOverride}
|
||||
title="Set regional override"
|
||||
aria-label="Set regional override"
|
||||
>
|
||||
<Globe2
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
|
||||
{activeFloodScopeDisplay}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{/* Direct trace button (contacts only) */}
|
||||
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
|
||||
{conversation.type === 'contact' && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
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"
|
||||
onClick={onTrace}
|
||||
title="Direct Trace"
|
||||
aria-label="Direct Trace"
|
||||
>
|
||||
<span aria-hidden="true">🛎</span>
|
||||
<Route className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{notificationsSupported && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onToggleNotifications}
|
||||
title={
|
||||
notificationsEnabled
|
||||
? 'Disable desktop notifications for this conversation'
|
||||
: notificationsPermission === 'denied'
|
||||
? 'Notifications blocked by the browser'
|
||||
: 'Enable desktop notifications for this conversation'
|
||||
}
|
||||
aria-label={
|
||||
notificationsEnabled
|
||||
? 'Disable notifications for this conversation'
|
||||
: 'Enable notifications for this conversation'
|
||||
}
|
||||
>
|
||||
<Bell
|
||||
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||
fill={notificationsEnabled ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
||||
<button
|
||||
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={handleEditFloodScopeOverride}
|
||||
title="Set regional override"
|
||||
aria-label="Set regional override"
|
||||
>
|
||||
<Globe2
|
||||
className={`h-4 w-4 ${activeFloodScopeLabel ? 'text-[hsl(var(--region-override))]' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{activeFloodScopeDisplay && (
|
||||
<span className="hidden text-[11px] font-medium text-[hsl(var(--region-override))] sm:inline">
|
||||
{activeFloodScopeDisplay}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Favorite button */}
|
||||
{(conversation.type === 'channel' || conversation.type === 'contact') && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors 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 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() =>
|
||||
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
|
||||
}
|
||||
title={
|
||||
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites'
|
||||
}
|
||||
title={favoriteTitle}
|
||||
aria-label={
|
||||
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
? 'Remove from favorites'
|
||||
@@ -166,16 +283,15 @@ export function ChatHeader({
|
||||
}
|
||||
>
|
||||
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
|
||||
<span className="text-favorite">★</span>
|
||||
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</span>
|
||||
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{/* Delete button */}
|
||||
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => {
|
||||
if (conversation.type === 'channel') {
|
||||
onDeleteChannel(conversation.id);
|
||||
@@ -186,7 +302,7 @@ export function ChatHeader({
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -6,6 +6,35 @@ interface ContactAvatarProps {
|
||||
size?: number;
|
||||
contactType?: number;
|
||||
clickable?: boolean;
|
||||
variant?: 'default' | 'corrupt';
|
||||
}
|
||||
|
||||
function CorruptAvatarGraphic({ size }: { size: number }) {
|
||||
return (
|
||||
<svg
|
||||
data-testid="corrupt-avatar"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 56"
|
||||
width={Math.round(size * 0.6)}
|
||||
height={Math.round(size * 0.94)}
|
||||
shapeRendering="crispEdges"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path fill="#F8E8F8" d="M8 0v24H0v32h24V0" />
|
||||
<path
|
||||
fill="#807098"
|
||||
d="M12 0h2v1h1v1h1v1h-1v1h-2V3h-1V0zm3 0h1v1h-1V0zm2 0h2v1h-1v1h-1V0zm4 0h2v1h-2V0zm-2 1h2v1h-2V1zm4 0h1v3h-2v1h-1V3h1V2h1V1zm-6 2h1v2h-1V3zm2 0h1v2h-1V3zM9 4h4v1H9V4zm5 1h3v4h-1V6h-2V5zm4 0h1v1h-1V5zm2 0h1v1h1V5h2v1h-1v2h-1v1h-2v1h-1V9h-1V7h1V6h1V5zM8 7h1v1H8V7zm0 3h2v1h1v1H9v-1H8v-1zm13 0h1v1h1v1h-2v-2zm2 0h1v1h-1v-1zm-11 1h1v1h-1v-1zm2 0h1v1h-1v-1zm3 0h1v1h-1v-1zm2 0h1v1h-1v-1zM8 12h1v1H8v-1zm3 0h1v1h-1v-1zm2 0h1v1h-1v-1zm2 0h1v1h2v1h1v1h-2v-1h-3v-1h1v-1zm-5 1h1v1h-1v-1zm2 0h1v1h-1v-1zm7 0h2v1h-2v-1zm3 0h2v1h-2v-1zM8 15h1v1H8v-1zm11 0h2v1h1v1h-2v-1h-1v-1zm4 0h1v1h-1v-1zm-13 1h1v1h-1v-1zm2 0h1v1h-1v-1zm2 0h4v1h-4v-1zm-2 2h1v1h-1v-1zm3 0h1v1h1v1h-3v-1h1v-1zm4 0h4v1h-2v1h-3v-1h1v-1zM9 19h1v1H9v-1zm2 0h1v1h-1v-1zm10 1h3v2h-1v-1h-2v-1zm-11 1h2v2H9v-1h1v-1zm4 0h2v3h1v-1h1v2h1v1h-2v-1h-2v-1h-1v-1h-1v-1h1v-1zm6 0h1v1h-1v-1zm-8 2h1v1h-1v-1zm7 0h2v1h-1v1h-1v-2zm4 0h1v1h-1v-1zM0 24h8v3H7v2h3v1H8v1H7v-1H1v-1h5v-4H0v-1zm9 0h3v1H9v-1zm4 0h1v1h-1v-1zm8 0h1v1h-1v-1zm-1 1h1v1h-1v-1zm2 0h2v3h-1v-2h-1v-1zm-6 1h1v2h-1v-2zm3 0h1v2h-1v-2zm2 0h1v2h-1v-2zm-11 2h2v1h1v1h-2v-1h-1v-1zm7 0h2v1h-2v-1zm3 0h1v1h-1v-1zm2 0h1v1h-1v-1zm-6 1h1v2h-1v-2zm3 0h1v1h1v-1h1v2h1v1h-1v2h-1v-1h-1v1h-1v-1h-2v-1h2v-1h-1v-1h1v-1zm4 0h1v1h-1v-1zm-12 2h1v1h-1v-1zm2 0h1v1h-1v-1zm2 0h1v1h-1v-1zM5 32h1v1H5v-1zm3 1h2v1H8v-1zm3 0h1v1h-1v-1zm3 0h1v1h-1v-1zm9 0h1v1h-1v-1zM6 34h2v1H7v1H6v1h1v1H1v-1H0v-1h1v-1h1v1h1v-1h1v1h1v-1h1v-1zm11 0h2v1h-1v1h1v-1h1v1h1v1h-5v1h-1v-2h2v-2zm5 0h1v1h-1v-1zM8 35h4v1h1v2h-3v-1H7v-1h1v-1zm5 0h2v1h-2v-1zm9 1h1v1h-1v-1zm-1 1h1v1h-1v-1zM2 39h1v2H2v-2zm2 0h1v2H4v-2zm2 0h1v2H6v-2zm2 0h2v2H9v-1H8v-1zm3 0h1v1h-1v-1zm4 0h1v1h-1v-1zm7 0h1v1h-1v-1zm-5 1h1v1h-1v-1zm2 0h1v1h-1v-1zm2 0h1v1h-1v-1zM1 41h1v1H1v-1zm2 0h1v1H3v-1zm2 0h1v1H5v-1zm2 0h1v1H7v-1zm3 0h2v2h-1v-1h-1v-1zm4 0h2v1h7v1h-1v1h-1v-1h-1v3h-4v-1h1v-2h-2v-1h-1v-1zm9 0h1v1h-1v-1zm-10 2h2v1h1v1h-4v1h1v1h1v-1h1v1h1v1h-4v-1h-1v-3h2v-1zm10 0h1v1h-1v-1zM0 45h10v3H8v-2H0v-1zm22 0h1v1h-1v-1zm-1 1h1v1h-1v-1zm2 0h1v2h-1v-2zM4 48h4v2H7v-1H6v1H5v-1H4v-1zm6 0h2v3h-1v-1h-1v-2zm6 0h7v1h-7v-1zM0 49h4v1h1v1H0v-2zm13 0h1v1h-1v-1zm2 0h1v1h2v1h-3v-2zm-9 1h1v2H6v-2zm2 0h2v1h1v1h1v1H7v-1h1v-2zm11 0h5v1h-1v1h-1v-1h-1v1h-3v-1h1v-1zm-7 1h3v1h-3v-1zM0 52h6v1h1v1H5v-1H4v1h1v1h1v1H4v-1H0v-3zm12 1h1v1h-1v-1zm2 0h1v3h-2v-2h1v-1zm2 0h2v3h-1v-1h-1v-2zm6 0h2v1h-1v1h-2v-1h1v-1zM7 54h1v1H7v-1zm12 0h1v1h-1v-1zM8 55h4v1H8v-1zm15 0h1v1h-1v-1z"
|
||||
/>
|
||||
<path
|
||||
fill="#181010"
|
||||
d="M8 0h1v1H8V0zm0 6h1v1H8V6zm2 0h1v1h-1V6zm2 0h1v1h-1V6zm2 0h1v1h-1V6zM8 9h1v1H8V9zm3 0h1v1h-1V9zm2 0h1v1h-1V9zm2 0h1v1h-1V9zm6 0h1v1h-1V9zm2 0h1v1h-1V9zM8 11h1v1H8v-1zm9 1h5v1h-5v-1zm6 0h1v1h-1v-1zm-13 2h4v1h-4v-1zm9 0h4v1h-4v-1zm-4 1h1v1h-1v-1zm2 0h2v1h-2v-1zm-5 2h1v1h-1v-1zm4 0h2v1h-2v-1zm3 0h2v1h-2v-1zm3 0h1v1h-1v-1zM8 18h1v2H8v-2zm1 2h1v1H9v-1zm2 0h2v1h-2v-1zm4 0h1v1h-1v-1zm1 2h4v1h-4v-1zm6 0h2v1h-2v-1zM9 25h1v1H9v-1zm2 0h5v1h-1v1h-1v-1h-1v1h1v1h-2v-1h-1v-2zM0 26h1v1H0v-1zm8 0h1v1h1v1H8v-2zm7 1h1v1h-1v-1zm-8 1h1v1H7v-1zm-7 2h1v1H0v-1zm9 0h2v1H9v-1zm5 0h2v1h-2v-1zM1 31h5v1H1v-1zm6 0h1v1H7v-1zm-7 1h1v2H0v-2zm9 0h7v1H9v-1zm14 0h1v1h-1v-1zM2 33h1v1H2v-1zm2 0h1v1H4v-1zm3 0h1v1H7v-1zm1 1h2v1H8v-1zm4 0h1v1h-1v-1zM0 35h1v1H0v-1zm23 1h1v1h-1v-1zm-1 1h1v2h-1v-2zM2 38h1v1H2v-1zm2 0h1v1H4v-1zm2 0h1v1H6v-1zm2 0h4v1H8v-1zm6 0h1v1h-1v-1zm3 1h1v1h-1v-1zm2 0h1v1h-1v-1zm2 0h1v1h-1v-1zm2 0h1v1h-1v-1zm-10 1h1v1h-1v-1zm9 0h1v1h-1v-1zM8 42h1v1H8v-1zm10 4h3v2h-2v-1h-1v-1zm-1 1h1v1h-1v-1zm-1 2h1v1h-1v-1zm7 0h1v1h-1v-1zm-5 3h3v1h-3v-1zm4 0h1v1h-1v-1z"
|
||||
/>
|
||||
<path
|
||||
fill="#F0B088"
|
||||
d="M8 5h1v1H8V5zm1 1h1v1h1V6h1v1h1V6h1v1h1V6h1v2H9V6zM8 8h1v1H8V8zm1 1h2v1H9V9zm3 0h1v1h-1V9zm2 0h1v1h-1V9zm6 0h1v1h-1V9zm2 0h1v1h-1V9zm-6 3h1v1h-1v-1zm6 0h1v1h-1v-1zM9 14h1v1h4v-1h2v1h-1v1H9v-2zm14 0h1v1h-1v-1zm-7 1h1v1h-1v-1zm-8 1h1v1h3v1H8v-2zm5 1h3v1h-3v-1zm5 0h1v1h-1v-1zm3 0h1v1h-1v-1zm2 0h1v1h-1v-1zM8 20h1v1H8v-1zm2 0h1v1h-1v-1zm3 0h2v1h-2v-1zm-5 2h1v1H8v-1zm12 0h2v1h-2v-1zm-10 3h1v2h1v1h-2v-1H9v-1h1v-1zm3 1h1v1h-1v-1zm2 0h1v1h-1v-1zm-1 1h1v1h-1v-1zM0 29h1v1H0v-1zm11 1h3v1h-3v-1zm-5 1h1v1H6v-1zm-5 2h1v1H1v-1zm2 0h1v1H3v-1zm2 0h2v1H5v-1zm5 0h1v1h1v1h-2v-2zm3 1h3v1h-3v-1zM0 37h1v1H0v-1zm8 0h2v1H8v-1zm15 0h1v1h-1v-1zM1 38h1v1H1v-1zm2 0h1v1H3v-1zm2 0h1v1H5v-1zm2 0h1v1H7v-1zm5 0h2v1h-2v-1zm3 0h7v1h-1v1h-1v-1h-1v1h-1v-1h-1v1h-1v-1h-1v-1zM0 39h1v2H0v-2zm10 0h1v1h-1v-1zm-2 1h1v1H8v-1zm3 0h2v1h-2v-1zm3 0h2v1h-2v-1zm9 0h1v1h-1v-1zm-7 1h7v1h-7v-1zm7 1h1v1h-1v-1zm-3 3h1v1h-1v-1zm-4 1h2v1h-1v1h-1v-2zm2 1h1v1h-1v-1zm-1 2h6v1h-6v-1zm-1 3h2v1h-2v-1zm5 0h1v1h-1v-1zm2 0h1v1h-1v-1z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactAvatar({
|
||||
@@ -14,14 +43,30 @@ export function ContactAvatar({
|
||||
size = 28,
|
||||
contactType,
|
||||
clickable,
|
||||
variant = 'default',
|
||||
}: ContactAvatarProps) {
|
||||
if (variant === 'corrupt') {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-md flex-shrink-0 select-none bg-black/10${clickable ? ' cursor-pointer' : ''}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
}}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<CorruptAvatarGraphic size={size} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const avatar = getContactAvatar(name, publicKey, contactType);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-center rounded-full font-semibold flex-shrink-0 select-none${clickable ? ' cursor-pointer' : ''}`}
|
||||
style={{
|
||||
backgroundColor: avatar.background,
|
||||
background: avatar.background,
|
||||
color: avatar.textColor,
|
||||
width: size,
|
||||
height: size,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { Ban, Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
isValidLocation,
|
||||
calculateDistance,
|
||||
formatDistance,
|
||||
formatRouteLabel,
|
||||
getEffectiveContactRoute,
|
||||
hasRoutingOverride,
|
||||
parsePathHops,
|
||||
} from '../utils/pathUtils';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
@@ -106,7 +110,12 @@ export function ContactInfoPane({
|
||||
isValidLocation(contact.lat, contact.lon)
|
||||
? calculateDistance(config.lat, config.lon, contact.lat, contact.lon)
|
||||
: null;
|
||||
const pathHashModeLabel = contact ? formatPathHashMode(contact.out_path_hash_mode) : null;
|
||||
const effectiveRoute = contact ? getEffectiveContactRoute(contact) : null;
|
||||
const pathHashModeLabel =
|
||||
effectiveRoute && effectiveRoute.pathLen >= 0
|
||||
? formatPathHashMode(effectiveRoute.pathHashMode)
|
||||
: null;
|
||||
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
|
||||
|
||||
return (
|
||||
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
@@ -144,12 +153,12 @@ export function ContactInfoPane({
|
||||
>
|
||||
{blockedNames.includes(nameOnlyValue) ? (
|
||||
<>
|
||||
<span className="text-destructive text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
|
||||
<span>Unblock this name</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Block this name</span>
|
||||
</>
|
||||
)}
|
||||
@@ -220,17 +229,24 @@ export function ContactInfoPane({
|
||||
{distFromUs !== null && (
|
||||
<InfoItem label="Distance" value={formatDistance(distFromUs)} />
|
||||
)}
|
||||
{contact.last_path_len >= 0 && (
|
||||
{effectiveRoute && (
|
||||
<InfoItem
|
||||
label="Hops"
|
||||
label="Routing"
|
||||
value={
|
||||
contact.last_path_len === 0
|
||||
? 'Direct'
|
||||
: `${contact.last_path_len} hop${contact.last_path_len > 1 ? 's' : ''}`
|
||||
effectiveRoute.forced ? (
|
||||
<span>
|
||||
{formatRouteLabel(effectiveRoute.pathLen, true)}{' '}
|
||||
<span className="text-destructive">(forced)</span>
|
||||
</span>
|
||||
) : (
|
||||
formatRouteLabel(effectiveRoute.pathLen, true)
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{contact.last_path_len === -1 && <InfoItem label="Routing" value="Flood" />}
|
||||
{contact && hasRoutingOverride(contact) && learnedRouteLabel && (
|
||||
<InfoItem label="Learned Route" value={learnedRouteLabel} />
|
||||
)}
|
||||
{pathHashModeLabel && <InfoItem label="Hop Width" value={pathHashModeLabel} />}
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,15 +280,16 @@ export function ContactInfoPane({
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onToggleFavorite('contact', contact.public_key)}
|
||||
title="Favorite contacts stay loaded on the radio for ACK support"
|
||||
>
|
||||
{isFavorite(favorites, 'contact', contact.public_key) ? (
|
||||
<>
|
||||
<span className="text-favorite text-lg">★</span>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">☆</span>
|
||||
<Star className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Add to favorites</span>
|
||||
</>
|
||||
)}
|
||||
@@ -290,12 +307,12 @@ export function ContactInfoPane({
|
||||
>
|
||||
{blockedKeys.includes(contact.public_key.toLowerCase()) ? (
|
||||
<>
|
||||
<span className="text-destructive text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
|
||||
<span>Unblock this key</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Block this key</span>
|
||||
</>
|
||||
)}
|
||||
@@ -309,12 +326,12 @@ export function ContactInfoPane({
|
||||
>
|
||||
{blockedNames.includes(contact.name) ? (
|
||||
<>
|
||||
<span className="text-destructive text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
|
||||
<span>Unblock name “{contact.name}”</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Block name “{contact.name}”</span>
|
||||
</>
|
||||
)}
|
||||
@@ -468,7 +485,7 @@ function ChannelAttributionWarning() {
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
|
||||
@@ -2,7 +2,14 @@ import type { ReactNode } from 'react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../utils/pathUtils';
|
||||
import {
|
||||
isValidLocation,
|
||||
calculateDistance,
|
||||
formatDistance,
|
||||
formatRouteLabel,
|
||||
formatRoutingOverrideInput,
|
||||
getEffectiveContactRoute,
|
||||
} from '../utils/pathUtils';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import type { Contact } from '../types';
|
||||
@@ -19,58 +26,48 @@ interface ContactStatusInfoProps {
|
||||
*/
|
||||
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
|
||||
const parts: ReactNode[] = [];
|
||||
const effectiveRoute = getEffectiveContactRoute(contact);
|
||||
|
||||
const editRoutingOverride = () => {
|
||||
const route = window.prompt(
|
||||
'Enter explicit path as comma-separated 1, 2, or 3 byte hops (for example "ae,f1" or "ae92,f13e").\nEnter 0 to force direct always.\nEnter -1 to force flooding always.\nLeave blank to clear the override and reset to flood until a new path is heard.',
|
||||
formatRoutingOverrideInput(contact)
|
||||
);
|
||||
if (route === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
api.setContactRoutingOverride(contact.public_key, route).then(
|
||||
() =>
|
||||
toast.success(
|
||||
route.trim() === '' ? 'Routing override cleared' : 'Routing override updated'
|
||||
),
|
||||
(err: unknown) =>
|
||||
toast.error(err instanceof Error ? err.message : 'Failed to update routing override')
|
||||
);
|
||||
};
|
||||
|
||||
if (contact.last_seen) {
|
||||
parts.push(`Last heard: ${formatTime(contact.last_seen)}`);
|
||||
}
|
||||
|
||||
if (contact.last_path_len === -1) {
|
||||
parts.push('flood');
|
||||
} else if (contact.last_path_len === 0) {
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Reset path to flood?')) {
|
||||
api.resetContactPath(contact.public_key).then(
|
||||
() => toast.success('Path reset to flood'),
|
||||
() => toast.error('Failed to reset path')
|
||||
);
|
||||
}
|
||||
}}
|
||||
title="Click to reset path to flood"
|
||||
>
|
||||
direct
|
||||
</span>
|
||||
);
|
||||
} else if (contact.last_path_len > 0) {
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (window.confirm('Reset path to flood?')) {
|
||||
api.resetContactPath(contact.public_key).then(
|
||||
() => toast.success('Path reset to flood'),
|
||||
() => toast.error('Failed to reset path')
|
||||
);
|
||||
}
|
||||
}}
|
||||
title="Click to reset path to flood"
|
||||
>
|
||||
{contact.last_path_len} hop{contact.last_path_len > 1 ? 's' : ''}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
editRoutingOverride();
|
||||
}}
|
||||
title="Click to edit routing override"
|
||||
>
|
||||
{formatRouteLabel(effectiveRoute.pathLen)}
|
||||
{effectiveRoute.forced && <span className="text-destructive"> (forced)</span>}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isValidLocation(contact.lat, contact.lon)) {
|
||||
const distFromUs =
|
||||
|
||||
235
frontend/src/components/ConversationPane.tsx
Normal file
@@ -0,0 +1,235 @@
|
||||
import { lazy, Suspense, useMemo, type Ref } from 'react';
|
||||
|
||||
import { ChatHeader } from './ChatHeader';
|
||||
import { MessageInput, type MessageInputHandle } from './MessageInput';
|
||||
import { MessageList } from './MessageList';
|
||||
import { RawPacketList } from './RawPacketList';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
Message,
|
||||
RawPacket,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
|
||||
const RepeaterDashboard = lazy(() =>
|
||||
import('./RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
|
||||
);
|
||||
const MapView = lazy(() => import('./MapView').then((m) => ({ default: m.MapView })));
|
||||
const VisualizerView = lazy(() =>
|
||||
import('./VisualizerView').then((m) => ({ default: m.VisualizerView }))
|
||||
);
|
||||
|
||||
interface ConversationPaneProps {
|
||||
activeConversation: Conversation | null;
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
rawPackets: RawPacket[];
|
||||
config: RadioConfig | null;
|
||||
health: HealthStatus | null;
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
favorites: Favorite[];
|
||||
messages: Message[];
|
||||
messagesLoading: boolean;
|
||||
loadingOlder: boolean;
|
||||
hasOlderMessages: boolean;
|
||||
targetMessageId: number | null;
|
||||
hasNewerMessages: boolean;
|
||||
loadingNewer: boolean;
|
||||
messageInputRef: Ref<MessageInputHandle>;
|
||||
onTrace: () => Promise<void>;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||
onDeleteChannel: (key: string) => Promise<void>;
|
||||
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
|
||||
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||
onOpenChannelInfo: (channelKey: string) => void;
|
||||
onSenderClick: (sender: string) => void;
|
||||
onLoadOlder: () => Promise<void>;
|
||||
onResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
||||
onTargetReached: () => void;
|
||||
onLoadNewer: () => Promise<void>;
|
||||
onJumpToBottom: () => void;
|
||||
onSendMessage: (text: string) => Promise<void>;
|
||||
onToggleNotifications: () => void;
|
||||
}
|
||||
|
||||
function LoadingPane({ label }: { label: string }) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">{label}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationPane({
|
||||
activeConversation,
|
||||
contacts,
|
||||
channels,
|
||||
rawPackets,
|
||||
config,
|
||||
health,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
favorites,
|
||||
messages,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
targetMessageId,
|
||||
hasNewerMessages,
|
||||
loadingNewer,
|
||||
messageInputRef,
|
||||
onTrace,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
onDeleteChannel,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onOpenContactInfo,
|
||||
onOpenChannelInfo,
|
||||
onSenderClick,
|
||||
onLoadOlder,
|
||||
onResendChannelMessage,
|
||||
onTargetReached,
|
||||
onLoadNewer,
|
||||
onJumpToBottom,
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
}: ConversationPaneProps) {
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
||||
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
|
||||
return contact?.type === CONTACT_TYPE_REPEATER;
|
||||
}, [activeConversation, contacts]);
|
||||
|
||||
if (!activeConversation) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Select a conversation or start a new one
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeConversation.type === 'map') {
|
||||
return (
|
||||
<>
|
||||
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
|
||||
Node Map
|
||||
</h2>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<Suspense fallback={<LoadingPane label="Loading map..." />}>
|
||||
<MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
|
||||
</Suspense>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeConversation.type === 'visualizer') {
|
||||
return (
|
||||
<Suspense fallback={<LoadingPane label="Loading visualizer..." />}>
|
||||
<VisualizerView packets={rawPackets} contacts={contacts} config={config} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeConversation.type === 'raw') {
|
||||
return (
|
||||
<>
|
||||
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
|
||||
Raw Packet Feed
|
||||
</h2>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<RawPacketList packets={rawPackets} />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeConversation.type === 'search') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (activeContactIsRepeater) {
|
||||
return (
|
||||
<Suspense fallback={<LoadingPane label="Loading dashboard..." />}>
|
||||
<RepeaterDashboard
|
||||
key={activeConversation.id}
|
||||
conversation={activeConversation}
|
||||
contacts={contacts}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
radioLat={config?.lat ?? null}
|
||||
radioLon={config?.lon ?? null}
|
||||
radioName={config?.name ?? null}
|
||||
onTrace={onTrace}
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDeleteContact={onDeleteContact}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ChatHeader
|
||||
conversation={activeConversation}
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
config={config}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
onTrace={onTrace}
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||
onDeleteChannel={onDeleteChannel}
|
||||
onDeleteContact={onDeleteContact}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
onOpenChannelInfo={onOpenChannelInfo}
|
||||
/>
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
contacts={contacts}
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResendChannelMessage={
|
||||
activeConversation.type === 'channel' ? onResendChannelMessage : undefined
|
||||
}
|
||||
radioName={config?.name}
|
||||
config={config}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
targetMessageId={targetMessageId}
|
||||
onTargetReached={onTargetReached}
|
||||
hasNewerMessages={hasNewerMessages}
|
||||
loadingNewer={loadingNewer}
|
||||
onLoadNewer={onLoadNewer}
|
||||
onJumpToBottom={onJumpToBottom}
|
||||
/>
|
||||
<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}...`
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,7 @@ interface QueueItem {
|
||||
status: 'pending' | 'cracking' | 'cracked' | 'failed';
|
||||
}
|
||||
|
||||
interface CrackerPanelProps {
|
||||
export interface CrackerPanelProps {
|
||||
packets: RawPacket[];
|
||||
channels: Channel[];
|
||||
onChannelCreate: (name: string, key: string) => Promise<void>;
|
||||
|
||||
@@ -148,6 +148,23 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
|
||||
}
|
||||
|
||||
const RESEND_WINDOW_SECONDS = 30;
|
||||
const CORRUPT_SENDER_LABEL = '<No name -- corrupt packet?>';
|
||||
|
||||
function hasUnexpectedControlChars(text: string): boolean {
|
||||
for (const char of text) {
|
||||
const code = char.charCodeAt(0);
|
||||
if (
|
||||
(code >= 0 && code <= 8) ||
|
||||
code === 11 ||
|
||||
code === 12 ||
|
||||
(code >= 14 && code <= 31) ||
|
||||
code === 127
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
messages,
|
||||
@@ -400,6 +417,17 @@ export function MessageList({
|
||||
return contacts.find((c) => c.name === name) || null;
|
||||
};
|
||||
|
||||
const isCorruptUnnamedChannelMessage = (msg: Message, parsedSender: string | null): boolean => {
|
||||
return (
|
||||
msg.type === 'CHAN' &&
|
||||
!msg.outgoing &&
|
||||
!msg.sender_name &&
|
||||
!msg.sender_key &&
|
||||
!parsedSender &&
|
||||
hasUnexpectedControlChars(msg.text)
|
||||
);
|
||||
};
|
||||
|
||||
// Build sender info for path modal
|
||||
const getSenderInfo = (
|
||||
msg: Message,
|
||||
@@ -415,6 +443,32 @@ export function MessageList({
|
||||
pathHashMode: contact.out_path_hash_mode,
|
||||
};
|
||||
}
|
||||
if (msg.type === 'CHAN') {
|
||||
const senderName = msg.sender_name || parsedSender;
|
||||
const senderContact =
|
||||
(msg.sender_key
|
||||
? contacts.find((candidate) => candidate.public_key === msg.sender_key)
|
||||
: null) || (senderName ? getContactByName(senderName) : null);
|
||||
if (senderContact) {
|
||||
return {
|
||||
name: senderContact.name || senderName || senderContact.public_key.slice(0, 12),
|
||||
publicKeyOrPrefix: senderContact.public_key,
|
||||
lat: senderContact.lat,
|
||||
lon: senderContact.lon,
|
||||
pathHashMode: senderContact.out_path_hash_mode,
|
||||
};
|
||||
}
|
||||
if (senderName || msg.sender_key) {
|
||||
return {
|
||||
name: senderName || msg.sender_key || 'Unknown',
|
||||
publicKeyOrPrefix: msg.sender_key || msg.conversation_key || '',
|
||||
lat: null,
|
||||
lon: null,
|
||||
pathHashMode: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// For channel messages, try to find contact by parsed sender name
|
||||
if (parsedSender) {
|
||||
const senderContact = getContactByName(parsedSender);
|
||||
@@ -455,10 +509,17 @@ export function MessageList({
|
||||
}
|
||||
|
||||
// Helper to get a unique sender key for grouping messages
|
||||
const getSenderKey = (msg: Message, sender: string | null): string => {
|
||||
const getSenderKey = (
|
||||
msg: Message,
|
||||
senderName: string | null,
|
||||
isCorruptChannelMessage: boolean
|
||||
): string => {
|
||||
if (msg.outgoing) return '__outgoing__';
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) return msg.conversation_key;
|
||||
return sender || '__unknown__';
|
||||
if (msg.sender_key) return `key:${msg.sender_key}`;
|
||||
if (senderName) return `name:${senderName}`;
|
||||
if (isCorruptChannelMessage) return `corrupt:${msg.id}`;
|
||||
return '__unknown__';
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -487,17 +548,36 @@ export function MessageList({
|
||||
const { sender, content } = isRepeater
|
||||
? { sender: null, content: msg.text }
|
||||
: parseSenderFromText(msg.text);
|
||||
const channelSenderName = msg.type === 'CHAN' ? msg.sender_name || sender : null;
|
||||
const channelSenderContact =
|
||||
msg.type === 'CHAN' && channelSenderName ? getContactByName(channelSenderName) : null;
|
||||
const isCorruptChannelMessage = isCorruptUnnamedChannelMessage(msg, sender);
|
||||
const displaySender = msg.outgoing
|
||||
? 'You'
|
||||
: contact?.name || sender || msg.conversation_key?.slice(0, 8) || 'Unknown';
|
||||
: contact?.name ||
|
||||
channelSenderName ||
|
||||
(isCorruptChannelMessage
|
||||
? CORRUPT_SENDER_LABEL
|
||||
: msg.conversation_key?.slice(0, 8) || 'Unknown');
|
||||
|
||||
const canClickSender = !msg.outgoing && onSenderClick && displaySender !== 'Unknown';
|
||||
const canClickSender =
|
||||
!msg.outgoing &&
|
||||
onSenderClick &&
|
||||
displaySender !== 'Unknown' &&
|
||||
displaySender !== CORRUPT_SENDER_LABEL;
|
||||
|
||||
// Determine if we should show avatar (first message in a chunk from same sender)
|
||||
const currentSenderKey = getSenderKey(msg, sender);
|
||||
const currentSenderKey = getSenderKey(msg, channelSenderName, isCorruptChannelMessage);
|
||||
const prevMsg = sortedMessages[index - 1];
|
||||
const prevParsedSender = prevMsg ? parseSenderFromText(prevMsg.text).sender : null;
|
||||
const prevSenderKey = prevMsg
|
||||
? getSenderKey(prevMsg, parseSenderFromText(prevMsg.text).sender)
|
||||
? getSenderKey(
|
||||
prevMsg,
|
||||
prevMsg.type === 'CHAN'
|
||||
? prevMsg.sender_name || prevParsedSender
|
||||
: prevParsedSender,
|
||||
isCorruptUnnamedChannelMessage(prevMsg, prevParsedSender)
|
||||
)
|
||||
: null;
|
||||
const isFirstInGroup = currentSenderKey !== prevSenderKey;
|
||||
const showAvatar = !msg.outgoing && isFirstInGroup;
|
||||
@@ -506,16 +586,24 @@ export function MessageList({
|
||||
// Get avatar info for incoming messages
|
||||
let avatarName: string | null = null;
|
||||
let avatarKey: string = '';
|
||||
let avatarVariant: 'default' | 'corrupt' = 'default';
|
||||
if (!msg.outgoing) {
|
||||
if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
// DM: use conversation_key (sender's public key)
|
||||
avatarName = contact?.name || null;
|
||||
avatarKey = msg.conversation_key;
|
||||
} else if (sender) {
|
||||
// Channel message: try to find contact by name, or use sender name as pseudo-key
|
||||
const senderContact = getContactByName(sender);
|
||||
avatarName = sender;
|
||||
avatarKey = senderContact?.public_key || `name:${sender}`;
|
||||
} else if (isCorruptChannelMessage) {
|
||||
avatarName = CORRUPT_SENDER_LABEL;
|
||||
avatarKey = `corrupt:${msg.id}`;
|
||||
avatarVariant = 'corrupt';
|
||||
} else {
|
||||
// Channel message: use stored sender identity first, then parsed/fallback display name
|
||||
avatarName =
|
||||
channelSenderName || (displaySender !== 'Unknown' ? displaySender : null);
|
||||
avatarKey =
|
||||
msg.sender_key ||
|
||||
channelSenderContact?.public_key ||
|
||||
(avatarName ? `name:${avatarName}` : `message:${msg.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,6 +635,7 @@ export function MessageList({
|
||||
publicKey={avatarKey}
|
||||
size={32}
|
||||
clickable={!!onOpenContactInfo}
|
||||
variant={avatarVariant}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Dice5 } from 'lucide-react';
|
||||
import type { Contact, Conversation } from '../types';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
@@ -256,7 +257,7 @@ export function NewMessageModal({
|
||||
title="Generate random key"
|
||||
aria-label="Generate random key"
|
||||
>
|
||||
<span aria-hidden="true">🎲</span>
|
||||
<Dice5 className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,9 +49,14 @@ function decodePacketSummary(
|
||||
|
||||
const routeType = Utils.getRouteTypeName(decoded.routeType);
|
||||
const payloadTypeName = Utils.getPayloadTypeName(decoded.payloadType);
|
||||
const tracePayload =
|
||||
decoded.payloadType === PayloadType.Trace && decoded.payload.decoded
|
||||
? (decoded.payload.decoded as { pathHashes?: string[] })
|
||||
: null;
|
||||
const pathTokens = tracePayload?.pathHashes || decoded.path || [];
|
||||
|
||||
// Build path string if available
|
||||
const pathStr = decoded.path && decoded.path.length > 0 ? ` via ${decoded.path.join('-')}` : '';
|
||||
const pathStr = pathTokens.length > 0 ? ` via ${pathTokens.join('-')}` : '';
|
||||
|
||||
// Generate summary based on payload type
|
||||
let summary = payloadTypeName;
|
||||
@@ -152,9 +157,9 @@ function getRouteTypeColor(routeType: string): string {
|
||||
return 'bg-info/20 text-info';
|
||||
case 'Direct':
|
||||
return 'bg-success/20 text-success';
|
||||
case 'Transport Flood':
|
||||
case 'TransportFlood':
|
||||
return 'bg-purple-500/20 text-purple-400';
|
||||
case 'Transport Direct':
|
||||
case 'TransportDirect':
|
||||
return 'bg-orange-500/20 text-orange-400';
|
||||
default:
|
||||
return 'bg-muted text-muted-foreground';
|
||||
@@ -168,9 +173,9 @@ function getRouteTypeLabel(routeType: string): string {
|
||||
return 'F';
|
||||
case 'Direct':
|
||||
return 'D';
|
||||
case 'Transport Flood':
|
||||
case 'TransportFlood':
|
||||
return 'TF';
|
||||
case 'Transport Direct':
|
||||
case 'TransportDirect':
|
||||
return 'TD';
|
||||
default:
|
||||
return '?';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
@@ -24,10 +25,14 @@ interface RepeaterDashboardProps {
|
||||
conversation: Conversation;
|
||||
contacts: Contact[];
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
radioLat: number | null;
|
||||
radioLon: number | null;
|
||||
radioName: string | null;
|
||||
onTrace: () => void;
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
}
|
||||
@@ -36,10 +41,14 @@ export function RepeaterDashboard({
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
radioLat,
|
||||
radioLon,
|
||||
radioName,
|
||||
onTrace,
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
}: RepeaterDashboardProps) {
|
||||
@@ -56,7 +65,8 @@ export function RepeaterDashboard({
|
||||
refreshPane,
|
||||
loadAll,
|
||||
sendConsoleCommand,
|
||||
sendAdvert,
|
||||
sendZeroHopAdvert,
|
||||
sendFloodAdvert,
|
||||
rebootRepeater,
|
||||
syncClock,
|
||||
} = useRepeaterDashboard(conversation);
|
||||
@@ -70,23 +80,33 @@ export function RepeaterDashboard({
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
|
||||
<span className="flex-shrink-0 font-semibold text-base">{conversation.name}</span>
|
||||
<span
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success('Contact key copied!');
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{conversation.id}
|
||||
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
|
||||
{conversation.name}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success('Contact key copied!');
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{conversation.id}
|
||||
</span>
|
||||
</span>
|
||||
{contact && (
|
||||
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
|
||||
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{contact && <ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{loggedIn && (
|
||||
@@ -95,38 +115,71 @@ export function RepeaterDashboard({
|
||||
size="sm"
|
||||
onClick={loadAll}
|
||||
disabled={anyLoading}
|
||||
className="text-xs border-success text-success hover:bg-success/10 hover:text-success"
|
||||
className="h-7 px-2 text-[11px] leading-none border-success text-success hover:bg-success/10 hover:text-success sm:h-8 sm:px-3 sm:text-xs"
|
||||
>
|
||||
{anyLoading ? 'Loading...' : 'Load All'}
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
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"
|
||||
onClick={onTrace}
|
||||
title="Direct Trace"
|
||||
aria-label="Direct Trace"
|
||||
>
|
||||
<span aria-hidden="true">🛎</span>
|
||||
<Route className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
{notificationsSupported && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onToggleNotifications}
|
||||
title={
|
||||
notificationsEnabled
|
||||
? 'Disable desktop notifications for this conversation'
|
||||
: notificationsPermission === 'denied'
|
||||
? 'Notifications blocked by the browser'
|
||||
: 'Enable desktop notifications for this conversation'
|
||||
}
|
||||
aria-label={
|
||||
notificationsEnabled
|
||||
? 'Disable notifications for this conversation'
|
||||
: 'Enable notifications for this conversation'
|
||||
}
|
||||
>
|
||||
<Bell
|
||||
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||
fill={notificationsEnabled ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
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"
|
||||
onClick={() => onToggleFavorite('contact', conversation.id)}
|
||||
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={
|
||||
isFav
|
||||
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
}
|
||||
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFav ? (
|
||||
<span className="text-favorite">★</span>
|
||||
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</span>
|
||||
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onDeleteContact(conversation.id)}
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -196,7 +249,8 @@ export function RepeaterDashboard({
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
<ActionsPane
|
||||
onSendAdvert={sendAdvert}
|
||||
onSendZeroHopAdvert={sendZeroHopAdvert}
|
||||
onSendFloodAdvert={sendFloodAdvert}
|
||||
onSyncClock={syncClock}
|
||||
onReboot={rebootRepeater}
|
||||
consoleLoading={consoleLoading}
|
||||
|
||||
@@ -26,7 +26,7 @@ export interface SearchNavigateTarget {
|
||||
conversation_name: string;
|
||||
}
|
||||
|
||||
interface SearchViewProps {
|
||||
export interface SearchViewProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
onNavigateToMessage: (target: SearchNavigateTarget) => void;
|
||||
|
||||
@@ -7,7 +7,11 @@ import type {
|
||||
RadioConfigUpdate,
|
||||
} from '../types';
|
||||
import type { LocalLabel } from '../utils/localLabel';
|
||||
import { SETTINGS_SECTION_LABELS, type SettingsSection } from './settings/settingsConstants';
|
||||
import {
|
||||
SETTINGS_SECTION_ICONS,
|
||||
SETTINGS_SECTION_LABELS,
|
||||
type SettingsSection,
|
||||
} from './settings/settingsConstants';
|
||||
|
||||
import { SettingsRadioSection } from './settings/SettingsRadioSection';
|
||||
import { SettingsLocalSection } from './settings/SettingsLocalSection';
|
||||
@@ -37,7 +41,7 @@ interface SettingsModalBaseProps {
|
||||
onToggleBlockedName?: (name: string) => void;
|
||||
}
|
||||
|
||||
type SettingsModalProps = SettingsModalBaseProps &
|
||||
export type SettingsModalProps = SettingsModalBaseProps &
|
||||
(
|
||||
| { externalSidebarNav: true; desktopSection: SettingsSection }
|
||||
| { externalSidebarNav?: false; desktopSection?: never }
|
||||
@@ -138,6 +142,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
|
||||
const renderSectionHeader = (section: SettingsSection): ReactNode => {
|
||||
if (!showSectionButton) return null;
|
||||
const Icon = SETTINGS_SECTION_ICONS[section];
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
@@ -145,8 +150,9 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
aria-expanded={expandedSections[section]}
|
||||
onClick={() => toggleSection(section)}
|
||||
>
|
||||
<span className="font-medium" role="heading" aria-level={3}>
|
||||
{SETTINGS_SECTION_LABELS[section]}
|
||||
<span className="inline-flex items-center gap-2 font-medium" role="heading" aria-level={3}>
|
||||
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<span>{SETTINGS_SECTION_LABELS[section]}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground md:hidden" aria-hidden="true">
|
||||
{expandedSections[section] ? '−' : '+'}
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
LockOpen,
|
||||
Map,
|
||||
Search as SearchIcon,
|
||||
Sparkles,
|
||||
SquarePen,
|
||||
Waypoints,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
@@ -24,6 +37,7 @@ type ConversationRow = {
|
||||
name: string;
|
||||
unreadCount: number;
|
||||
isMention: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
contact?: Contact;
|
||||
};
|
||||
|
||||
@@ -81,6 +95,7 @@ interface SidebarProps {
|
||||
sortOrder?: SortOrder;
|
||||
/** Callback when sort order changes */
|
||||
onSortOrderChange?: (order: SortOrder) => void;
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -99,6 +114,7 @@ export function Sidebar({
|
||||
favorites,
|
||||
sortOrder: sortOrderProp = 'recent',
|
||||
onSortOrderChange,
|
||||
isConversationNotificationsEnabled,
|
||||
}: SidebarProps) {
|
||||
const sortOrder = sortOrderProp;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -393,6 +409,7 @@ export function Sidebar({
|
||||
name: channel.name,
|
||||
unreadCount: getUnreadCount('channel', channel.key),
|
||||
isMention: hasMention('channel', channel.key),
|
||||
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
|
||||
});
|
||||
|
||||
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
|
||||
@@ -402,6 +419,8 @@ export function Sidebar({
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
unreadCount: getUnreadCount('contact', contact.public_key),
|
||||
isMention: hasMention('contact', contact.public_key),
|
||||
notificationsEnabled:
|
||||
isConversationNotificationsEnabled?.('contact', contact.public_key) ?? false,
|
||||
contact,
|
||||
});
|
||||
|
||||
@@ -434,19 +453,26 @@ export function Sidebar({
|
||||
/>
|
||||
)}
|
||||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
row.isMention
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
row.isMention
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -459,7 +485,7 @@ export function Sidebar({
|
||||
}: {
|
||||
key: string;
|
||||
active?: boolean;
|
||||
icon: string;
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
@@ -475,16 +501,18 @@ export function Sidebar({
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={onClick}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs" aria-hidden="true">
|
||||
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
|
||||
{icon}
|
||||
</span>
|
||||
<span className="flex-1 truncate text-muted-foreground">{label}</span>
|
||||
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const getSectionUnreadCount = (rows: ConversationRow[]): number =>
|
||||
rows.reduce((total, row) => total + row.unreadCount, 0);
|
||||
|
||||
const sectionHasMention = (rows: ConversationRow[]): boolean => rows.some((row) => row.isMention);
|
||||
|
||||
const favoriteRows = favoriteItems.map((item) =>
|
||||
item.type === 'channel'
|
||||
? buildChannelRow(item.channel, 'fav-chan')
|
||||
@@ -498,12 +526,14 @@ export function Sidebar({
|
||||
const channelsUnreadCount = getSectionUnreadCount(channelRows);
|
||||
const contactsUnreadCount = getSectionUnreadCount(contactRows);
|
||||
const repeatersUnreadCount = getSectionUnreadCount(repeaterRows);
|
||||
const favoritesHasMention = sectionHasMention(favoriteRows);
|
||||
const channelsHasMention = sectionHasMention(channelRows);
|
||||
const toolRows = !query
|
||||
? [
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-raw',
|
||||
active: isActive('raw', 'raw'),
|
||||
icon: '📡',
|
||||
icon: <Waypoints className="h-4 w-4" />,
|
||||
label: 'Packet Feed',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
@@ -515,7 +545,7 @@ export function Sidebar({
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-map',
|
||||
active: isActive('map', 'map'),
|
||||
icon: '🗺️',
|
||||
icon: <Map className="h-4 w-4" />,
|
||||
label: 'Node Map',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
@@ -527,7 +557,7 @@ export function Sidebar({
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-visualizer',
|
||||
active: isActive('visualizer', 'visualizer'),
|
||||
icon: '✨',
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
label: 'Mesh Visualizer',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
@@ -539,7 +569,7 @@ export function Sidebar({
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-search',
|
||||
active: isActive('search', 'search'),
|
||||
icon: '🔍',
|
||||
icon: <SearchIcon className="h-4 w-4" />,
|
||||
label: 'Message Search',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
@@ -551,7 +581,7 @@ export function Sidebar({
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-cracker',
|
||||
active: showCracker,
|
||||
icon: '🔓',
|
||||
icon: <LockOpen className="h-4 w-4" />,
|
||||
label: (
|
||||
<>
|
||||
{showCracker ? 'Hide' : 'Show'} Room Finder
|
||||
@@ -575,7 +605,8 @@ export function Sidebar({
|
||||
collapsed: boolean,
|
||||
onToggle: () => void,
|
||||
showSortToggle = false,
|
||||
unreadCount = 0
|
||||
unreadCount = 0,
|
||||
highlightUnread = false
|
||||
) => {
|
||||
const effectiveCollapsed = isSearching ? false : collapsed;
|
||||
|
||||
@@ -592,9 +623,11 @@ export function Sidebar({
|
||||
}}
|
||||
title={effectiveCollapsed ? `Expand ${title}` : `Collapse ${title}`}
|
||||
>
|
||||
<span className="text-[9px]" aria-hidden="true">
|
||||
{effectiveCollapsed ? '▸' : '▾'}
|
||||
</span>
|
||||
{effectiveCollapsed ? (
|
||||
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
)}
|
||||
<span>{title}</span>
|
||||
</button>
|
||||
{(showSortToggle || unreadCount > 0) && (
|
||||
@@ -611,7 +644,12 @@ export function Sidebar({
|
||||
)}
|
||||
{unreadCount > 0 && (
|
||||
<span
|
||||
className="text-[10px] font-medium px-1.5 py-0.5 rounded-full bg-secondary text-muted-foreground"
|
||||
className={cn(
|
||||
'text-[10px] font-medium px-1.5 py-0.5 rounded-full',
|
||||
highlightUnread
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-secondary text-muted-foreground'
|
||||
)}
|
||||
aria-label={`${unreadCount} unread`}
|
||||
>
|
||||
{unreadCount}
|
||||
@@ -629,44 +667,39 @@ export function Sidebar({
|
||||
aria-label="Conversations"
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
|
||||
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
|
||||
Conversations
|
||||
</h2>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-7 text-[13px] pr-8 bg-background/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onNewMessage}
|
||||
title="New Message"
|
||||
aria-label="New message"
|
||||
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
+
|
||||
<SquarePen className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative px-3 py-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-7 text-[13px] pr-8 bg-background/50"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
|
||||
onClick={() => setSearchQuery('')}
|
||||
title="Clear search"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
|
||||
{/* Tools */}
|
||||
@@ -686,9 +719,7 @@ export function Sidebar({
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={onMarkAllRead}
|
||||
>
|
||||
<span className="text-muted-foreground text-xs" aria-hidden="true">
|
||||
✓
|
||||
</span>
|
||||
<CheckCheck className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
<span className="flex-1 truncate text-muted-foreground">Mark all as read</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -701,7 +732,8 @@ export function Sidebar({
|
||||
favoritesCollapsed,
|
||||
() => setFavoritesCollapsed((prev) => !prev),
|
||||
false,
|
||||
favoritesUnreadCount
|
||||
favoritesUnreadCount,
|
||||
favoritesHasMention
|
||||
)}
|
||||
{(isSearching || !favoritesCollapsed) &&
|
||||
favoriteRows.map((row) => renderConversationRow(row))}
|
||||
@@ -716,7 +748,8 @@ export function Sidebar({
|
||||
channelsCollapsed,
|
||||
() => setChannelsCollapsed((prev) => !prev),
|
||||
true,
|
||||
channelsUnreadCount
|
||||
channelsUnreadCount,
|
||||
channelsHasMention
|
||||
)}
|
||||
{(isSearching || !channelsCollapsed) &&
|
||||
channelRows.map((row) => renderConversationRow(row))}
|
||||
@@ -731,7 +764,8 @@ export function Sidebar({
|
||||
contactsCollapsed,
|
||||
() => setContactsCollapsed((prev) => !prev),
|
||||
true,
|
||||
contactsUnreadCount
|
||||
contactsUnreadCount,
|
||||
contactsUnreadCount > 0
|
||||
)}
|
||||
{(isSearching || !contactsCollapsed) &&
|
||||
contactRows.map((row) => renderConversationRow(row))}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import { Menu } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Menu, Moon, Sun } from 'lucide-react';
|
||||
import type { HealthStatus, RadioConfig } from '../types';
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { applyTheme, getSavedTheme, THEME_CHANGE_EVENT } from '../utils/theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatusBarProps {
|
||||
@@ -29,6 +30,19 @@ export function StatusBar({
|
||||
? 'Radio OK'
|
||||
: 'Radio Disconnected';
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState(getSavedTheme);
|
||||
|
||||
useEffect(() => {
|
||||
const handleThemeChange = (event: Event) => {
|
||||
const themeId = (event as CustomEvent<string>).detail;
|
||||
setCurrentTheme(typeof themeId === 'string' && themeId ? themeId : getSavedTheme());
|
||||
};
|
||||
|
||||
window.addEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener);
|
||||
return () => {
|
||||
window.removeEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleReconnect = async () => {
|
||||
setReconnecting(true);
|
||||
@@ -46,22 +60,28 @@ export function StatusBar({
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
const nextTheme = currentTheme === 'light' ? 'original' : 'light';
|
||||
applyTheme(nextTheme);
|
||||
setCurrentTheme(nextTheme);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex items-center gap-3 px-4 py-2.5 bg-card border-b border-border text-xs">
|
||||
{/* Mobile menu button - only visible on small screens */}
|
||||
{onMenuClick && (
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="md:hidden p-1 bg-transparent border-none text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
|
||||
className="md:hidden p-0.5 bg-transparent border-none text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
<Menu className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<h1 className="text-base font-semibold tracking-tight mr-auto text-foreground flex items-center gap-1.5">
|
||||
<svg
|
||||
className="h-5 w-5 shrink-0 text-white"
|
||||
className="h-4 w-4 shrink-0 text-white"
|
||||
viewBox="0 0 512 512"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
@@ -128,6 +148,18 @@ export function StatusBar({
|
||||
>
|
||||
{settingsMode ? 'Back to Chat' : 'Settings'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleThemeToggle}
|
||||
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
||||
title={currentTheme === 'light' ? 'Switch to classic theme' : 'Switch to light theme'}
|
||||
aria-label={currentTheme === 'light' ? 'Switch to classic theme' : 'Switch to light theme'}
|
||||
>
|
||||
{currentTheme === 'light' ? (
|
||||
<Moon className="h-4 w-4" aria-hidden="true" />
|
||||
) : (
|
||||
<Sun className="h-4 w-4" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function ActionsPane({
|
||||
onSendAdvert,
|
||||
onSendZeroHopAdvert,
|
||||
onSendFloodAdvert,
|
||||
onSyncClock,
|
||||
onReboot,
|
||||
consoleLoading,
|
||||
}: {
|
||||
onSendAdvert: () => void;
|
||||
onSendZeroHopAdvert: () => void;
|
||||
onSendFloodAdvert: () => void;
|
||||
onSyncClock: () => void;
|
||||
onReboot: () => void;
|
||||
consoleLoading: boolean;
|
||||
@@ -36,8 +38,16 @@ export function ActionsPane({
|
||||
<h3 className="text-sm font-medium">Actions</h3>
|
||||
</div>
|
||||
<div className="p-3 flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onSendAdvert} disabled={consoleLoading}>
|
||||
Send Advert
|
||||
<Button variant="outline" size="sm" onClick={onSendZeroHopAdvert} disabled={consoleLoading}>
|
||||
Zero Hop Advert
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onSendFloodAdvert}
|
||||
disabled={consoleLoading}
|
||||
>
|
||||
Flood Advert
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onSyncClock} disabled={consoleLoading}>
|
||||
Sync Clock
|
||||
|
||||
@@ -80,6 +80,28 @@ export function formatAdvertInterval(val: string | null): string {
|
||||
return `${trimmed}h`;
|
||||
}
|
||||
|
||||
function formatFetchedRelative(fetchedAt: number): string {
|
||||
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - fetchedAt) / 1000));
|
||||
|
||||
if (elapsedSeconds < 60) return 'Just now';
|
||||
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
||||
if (elapsedMinutes < 60) {
|
||||
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
||||
return `${elapsedHours} hour${elapsedHours === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
function formatFetchedTime(fetchedAt: number): string {
|
||||
return new Date(fetchedAt).toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// --- Generic Pane Wrapper ---
|
||||
|
||||
export function RepeaterPane({
|
||||
@@ -99,10 +121,22 @@ export function RepeaterPane({
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}) {
|
||||
const fetchedAt = state.fetched_at ?? null;
|
||||
|
||||
return (
|
||||
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{fetchedAt && (
|
||||
<p
|
||||
className="text-[11px] text-muted-foreground"
|
||||
title={new Date(fetchedAt).toLocaleString()}
|
||||
>
|
||||
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { MapPinned } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { toast } from '../ui/sonner';
|
||||
import { RADIO_PRESETS } from '../../utils/radioPresets';
|
||||
import { stripRegionScopePrefix } from '../../utils/regionScope';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
@@ -83,7 +85,7 @@ export function SettingsRadioSection({
|
||||
|
||||
useEffect(() => {
|
||||
setAdvertIntervalHours(String(Math.round(appSettings.advert_interval / 3600)));
|
||||
setFloodScope(appSettings.flood_scope);
|
||||
setFloodScope(stripRegionScopePrefix(appSettings.flood_scope));
|
||||
setMaxRadioContacts(String(appSettings.max_radio_contacts));
|
||||
}, [appSettings]);
|
||||
|
||||
@@ -256,7 +258,7 @@ export function SettingsRadioSection({
|
||||
if (newAdvertInterval !== appSettings.advert_interval) {
|
||||
update.advert_interval = newAdvertInterval;
|
||||
}
|
||||
if (floodScope !== appSettings.flood_scope) {
|
||||
if (floodScope !== stripRegionScopePrefix(appSettings.flood_scope)) {
|
||||
update.flood_scope = floodScope;
|
||||
}
|
||||
const newMaxRadioContacts = parseInt(maxRadioContacts, 10);
|
||||
@@ -405,7 +407,14 @@ export function SettingsRadioSection({
|
||||
onClick={handleGetLocation}
|
||||
disabled={gettingLocation}
|
||||
>
|
||||
{gettingLocation ? 'Getting...' : '📍 Use My Location'}
|
||||
{gettingLocation ? (
|
||||
'Getting...'
|
||||
) : (
|
||||
<>
|
||||
<MapPinned className="mr-1.5 h-4 w-4" aria-hidden="true" />
|
||||
Use My Location
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@@ -554,11 +563,12 @@ export function SettingsRadioSection({
|
||||
id="flood-scope"
|
||||
value={floodScope}
|
||||
onChange={(e) => setFloodScope(e.target.value)}
|
||||
placeholder="#MyRegion"
|
||||
placeholder="MyRegion"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tag outgoing flood messages with a region name (e.g. #MyRegion). Repeaters with this
|
||||
region configured will prioritize your traffic. Leave empty to disable.
|
||||
Tag outgoing flood messages with a region name (e.g. MyRegion). Repeaters configured for
|
||||
that region can forward the traffic, while repeaters configured to deny other regions may
|
||||
drop it. Leave empty to disable.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -573,8 +583,8 @@ export function SettingsRadioSection({
|
||||
onChange={(e) => setMaxRadioContacts(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Favorite contacts load first, then recent non-repeater contacts until this limit is
|
||||
reached (1-1000)
|
||||
Configured radio contact capacity. Favorites reload first, then background maintenance
|
||||
refills to about 80% of this value and offloads once occupancy reaches about 95%.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,13 @@
|
||||
import {
|
||||
BarChart3,
|
||||
Database,
|
||||
Info,
|
||||
MonitorCog,
|
||||
RadioTower,
|
||||
Share2,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export type SettingsSection = 'radio' | 'local' | 'database' | 'fanout' | 'statistics' | 'about';
|
||||
|
||||
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
@@ -10,10 +20,19 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
|
||||
];
|
||||
|
||||
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
|
||||
radio: '📻 Radio',
|
||||
local: '🖥️ Local Configuration',
|
||||
database: '🗄️ Database & Messaging',
|
||||
fanout: '📤 MQTT & Automation',
|
||||
statistics: '📊 Statistics',
|
||||
radio: 'Radio',
|
||||
local: 'Local Configuration',
|
||||
database: 'Database & Messaging',
|
||||
fanout: 'MQTT & Automation',
|
||||
statistics: 'Statistics',
|
||||
about: 'About',
|
||||
};
|
||||
|
||||
export const SETTINGS_SECTION_ICONS: Record<SettingsSection, LucideIcon> = {
|
||||
radio: RadioTower,
|
||||
local: MonitorCog,
|
||||
database: Database,
|
||||
fanout: Share2,
|
||||
statistics: BarChart3,
|
||||
about: Info,
|
||||
};
|
||||
|
||||
@@ -12,7 +12,7 @@ The visualizer displays:
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Layer (`useVisualizerData3D` hook)
|
||||
### Data Layer (`components/visualizer/useVisualizerData3D.ts`)
|
||||
|
||||
The custom hook manages all graph state and simulation logic:
|
||||
|
||||
@@ -39,6 +39,8 @@ Packets → Parse → Aggregate by key → Observation window → Publish → An
|
||||
|
||||
### Rendering Layer (Three.js)
|
||||
|
||||
Scene creation, render-loop updates, raycasting hover, and click-to-pin interaction live in `components/visualizer/useVisualizer3DScene.ts`.
|
||||
|
||||
- `THREE.WebGLRenderer` + `CSS2DRenderer` (text labels overlaid on 3D scene)
|
||||
- `OrbitControls` for camera interaction (orbit, pan, zoom)
|
||||
- `THREE.Mesh` with `SphereGeometry` per node + `CSS2DObject` labels
|
||||
@@ -46,15 +48,20 @@ Packets → Parse → Aggregate by key → Observation window → Publish → An
|
||||
- `THREE.Points` with vertex colors for particles (persistent geometry + circular sprite texture)
|
||||
- `THREE.Raycaster` for hover/click detection on node spheres
|
||||
|
||||
### Shared Utilities (`utils/visualizerUtils.ts`)
|
||||
### Shared Utilities
|
||||
|
||||
Types, constants, and pure functions shared across the codebase:
|
||||
- `components/visualizer/shared.ts`
|
||||
- Graph-specific types: `GraphNode`, `GraphLink`, `NodeMeshData`
|
||||
- Shared rendering helpers: node colors, relative-time formatting, typed-array growth helpers
|
||||
- `utils/visualizerUtils.ts`
|
||||
- Packet parsing, identity helpers, ambiguous repeater heuristics, constants shared across visualizer code
|
||||
|
||||
- Types: `NodeType`, `PacketLabel`, `Particle`, `ObservedPath`, `PendingPacket`, `ParsedPacket`, `TrafficObservation`, `RepeaterTrafficData`, `RepeaterSplitAnalysis`
|
||||
- Constants: `COLORS`, `PARTICLE_COLOR_MAP`, `PARTICLE_SPEED`, `DEFAULT_OBSERVATION_WINDOW_SEC`, traffic thresholds, `PACKET_LEGEND_ITEMS`
|
||||
- Functions: `hashString` (from `utils/contactAvatar.ts`), `parsePacket`, `getPacketLabel`, `generatePacketKey`, `getLinkId`, `getNodeType`, `dedupeConsecutive`, `analyzeRepeaterTraffic`, `recordTrafficObservation`
|
||||
### UI Overlays
|
||||
|
||||
`GraphNode` and `GraphLink` are defined locally in the component — they extend `SimulationNodeDatum3D` and `SimulationLinkDatum` from `d3-force-3d`.
|
||||
- `components/visualizer/VisualizerControls.tsx`
|
||||
- Legends, settings toggles, repulsion/speed controls, reset/stretch actions
|
||||
- `components/visualizer/VisualizerTooltip.tsx`
|
||||
- Hovered/pinned node metadata and neighbor list
|
||||
|
||||
### Type Declarations (`types/d3-force-3d.d.ts`)
|
||||
|
||||
317
frontend/src/components/visualizer/VisualizerControls.tsx
Normal file
@@ -0,0 +1,317 @@
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { PACKET_LEGEND_ITEMS } from '../../utils/visualizerUtils';
|
||||
import { NODE_LEGEND_ITEMS } from './shared';
|
||||
|
||||
interface VisualizerControlsProps {
|
||||
showControls: boolean;
|
||||
setShowControls: (value: boolean) => void;
|
||||
fullScreen?: boolean;
|
||||
onFullScreenChange?: (fullScreen: boolean) => void;
|
||||
showAmbiguousPaths: boolean;
|
||||
setShowAmbiguousPaths: (value: boolean) => void;
|
||||
showAmbiguousNodes: boolean;
|
||||
setShowAmbiguousNodes: (value: boolean) => void;
|
||||
useAdvertPathHints: boolean;
|
||||
setUseAdvertPathHints: (value: boolean) => void;
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
setSplitAmbiguousByTraffic: (value: boolean) => void;
|
||||
observationWindowSec: number;
|
||||
setObservationWindowSec: (value: number) => void;
|
||||
pruneStaleNodes: boolean;
|
||||
setPruneStaleNodes: (value: boolean) => void;
|
||||
pruneStaleMinutes: number;
|
||||
setPruneStaleMinutes: (value: number) => void;
|
||||
letEmDrift: boolean;
|
||||
setLetEmDrift: (value: boolean) => void;
|
||||
autoOrbit: boolean;
|
||||
setAutoOrbit: (value: boolean) => void;
|
||||
chargeStrength: number;
|
||||
setChargeStrength: (value: number) => void;
|
||||
particleSpeedMultiplier: number;
|
||||
setParticleSpeedMultiplier: (value: number) => void;
|
||||
nodeCount: number;
|
||||
linkCount: number;
|
||||
onExpandContract: () => void;
|
||||
onClearAndReset: () => void;
|
||||
}
|
||||
|
||||
export function VisualizerControls({
|
||||
showControls,
|
||||
setShowControls,
|
||||
fullScreen,
|
||||
onFullScreenChange,
|
||||
showAmbiguousPaths,
|
||||
setShowAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
setShowAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
setUseAdvertPathHints,
|
||||
splitAmbiguousByTraffic,
|
||||
setSplitAmbiguousByTraffic,
|
||||
observationWindowSec,
|
||||
setObservationWindowSec,
|
||||
pruneStaleNodes,
|
||||
setPruneStaleNodes,
|
||||
pruneStaleMinutes,
|
||||
setPruneStaleMinutes,
|
||||
letEmDrift,
|
||||
setLetEmDrift,
|
||||
autoOrbit,
|
||||
setAutoOrbit,
|
||||
chargeStrength,
|
||||
setChargeStrength,
|
||||
particleSpeedMultiplier,
|
||||
setParticleSpeedMultiplier,
|
||||
nodeCount,
|
||||
linkCount,
|
||||
onExpandContract,
|
||||
onClearAndReset,
|
||||
}: VisualizerControlsProps) {
|
||||
return (
|
||||
<>
|
||||
{showControls && (
|
||||
<div className="absolute bottom-4 left-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border z-10">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-muted-foreground font-medium mb-1">Packets</div>
|
||||
{PACKET_LEGEND_ITEMS.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full flex items-center justify-center text-[8px] font-bold text-white"
|
||||
style={{ backgroundColor: item.color }}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
<span>{item.description}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="text-muted-foreground font-medium mb-1">Nodes</div>
|
||||
{NODE_LEGEND_ITEMS.map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<div
|
||||
className="rounded-full"
|
||||
style={{
|
||||
width: item.size,
|
||||
height: item.size,
|
||||
backgroundColor: item.color,
|
||||
}}
|
||||
/>
|
||||
<span>{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`absolute top-4 left-4 bg-background/80 backdrop-blur-sm rounded-lg p-3 text-xs border border-border z-10 transition-opacity ${!showControls ? 'opacity-40 hover:opacity-100' : ''}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={showControls}
|
||||
onCheckedChange={(c) => setShowControls(c === true)}
|
||||
/>
|
||||
<span title="Toggle legends and controls visibility">Show controls</span>
|
||||
</label>
|
||||
{onFullScreenChange && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!fullScreen}
|
||||
onCheckedChange={(c) => onFullScreenChange(c !== true)}
|
||||
/>
|
||||
<span title="Show or hide the packet feed sidebar">Show packet feed sidebar</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{showControls && (
|
||||
<>
|
||||
<div className="border-t border-border pt-2 mt-1 flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={showAmbiguousPaths}
|
||||
onCheckedChange={(c) => setShowAmbiguousPaths(c === true)}
|
||||
/>
|
||||
<span title="Show placeholder nodes for repeaters when the 1-byte prefix matches multiple contacts">
|
||||
Show ambiguous repeaters
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={showAmbiguousNodes}
|
||||
onCheckedChange={(c) => setShowAmbiguousNodes(c === true)}
|
||||
/>
|
||||
<span title="Show placeholder nodes for senders/recipients when only a 1-byte prefix is known">
|
||||
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>
|
||||
<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
|
||||
checked={pruneStaleNodes}
|
||||
onCheckedChange={(c) => setPruneStaleNodes(c === true)}
|
||||
/>
|
||||
<span title="Automatically remove nodes with no traffic within the configured window to keep the mesh manageable">
|
||||
Only show recently heard/in-a-path nodes
|
||||
</span>
|
||||
</label>
|
||||
{pruneStaleNodes && (
|
||||
<div className="flex items-center gap-2 pl-6">
|
||||
<label
|
||||
htmlFor="prune-window"
|
||||
className="text-muted-foreground whitespace-nowrap"
|
||||
>
|
||||
Window:
|
||||
</label>
|
||||
<input
|
||||
id="prune-window"
|
||||
type="number"
|
||||
min={1}
|
||||
max={60}
|
||||
value={pruneStaleMinutes}
|
||||
onChange={(e) => {
|
||||
const v = parseInt(e.target.value, 10);
|
||||
if (!isNaN(v) && v >= 1 && v <= 60) setPruneStaleMinutes(v);
|
||||
}}
|
||||
className="w-14 rounded border border-border bg-background px-2 py-0.5 text-sm"
|
||||
/>
|
||||
<span className="text-muted-foreground" aria-hidden="true">
|
||||
min
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={letEmDrift}
|
||||
onCheckedChange={(c) => setLetEmDrift(c === true)}
|
||||
/>
|
||||
<span title="When enabled, the graph continuously reorganizes itself into a better layout">
|
||||
Let 'em drift
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={autoOrbit}
|
||||
onCheckedChange={(c) => setAutoOrbit(c === true)}
|
||||
/>
|
||||
<span title="Automatically orbit the camera around the scene">
|
||||
Orbit the mesh
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
<label
|
||||
htmlFor="viz-repulsion"
|
||||
className="text-muted-foreground"
|
||||
title="How strongly nodes repel each other. Higher values spread nodes out more."
|
||||
>
|
||||
Repulsion: {Math.abs(chargeStrength)}
|
||||
</label>
|
||||
<input
|
||||
id="viz-repulsion"
|
||||
type="range"
|
||||
min="50"
|
||||
max="2500"
|
||||
value={Math.abs(chargeStrength)}
|
||||
onChange={(e) => setChargeStrength(-parseInt(e.target.value, 10))}
|
||||
className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
<label
|
||||
htmlFor="viz-packet-speed"
|
||||
className="text-muted-foreground"
|
||||
title="How fast particles travel along links. Higher values make packets move faster."
|
||||
>
|
||||
Packet speed: {particleSpeedMultiplier}x
|
||||
</label>
|
||||
<input
|
||||
id="viz-packet-speed"
|
||||
type="range"
|
||||
min="1"
|
||||
max="5"
|
||||
step="0.5"
|
||||
value={particleSpeedMultiplier}
|
||||
onChange={(e) => setParticleSpeedMultiplier(parseFloat(e.target.value))}
|
||||
className="w-full h-2 bg-border rounded-lg appearance-none cursor-pointer accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onExpandContract}
|
||||
className="mt-1 px-3 py-1.5 bg-primary/20 hover:bg-primary/30 text-primary rounded text-xs transition-colors"
|
||||
title="Expand nodes apart then contract back - can help untangle the graph"
|
||||
>
|
||||
Oooh Big Stretch!
|
||||
</button>
|
||||
<button
|
||||
onClick={onClearAndReset}
|
||||
className="mt-1 px-3 py-1.5 bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-500 rounded text-xs transition-colors"
|
||||
title="Clear all nodes and links from the visualization - packets are preserved"
|
||||
>
|
||||
Clear & Reset
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-t border-border pt-2 mt-1">
|
||||
<div>Nodes: {nodeCount}</div>
|
||||
<div>Links: {linkCount}</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
73
frontend/src/components/visualizer/VisualizerTooltip.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
import type { GraphNode } from './shared';
|
||||
import { formatRelativeTime } from './shared';
|
||||
|
||||
interface VisualizerTooltipProps {
|
||||
activeNodeId: string | null;
|
||||
nodes: Map<string, GraphNode>;
|
||||
neighborIds: string[];
|
||||
}
|
||||
|
||||
export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: VisualizerTooltipProps) {
|
||||
if (!activeNodeId) return null;
|
||||
|
||||
const node = nodes.get(activeNodeId);
|
||||
if (!node) return null;
|
||||
|
||||
const neighbors = neighborIds
|
||||
.map((nid) => {
|
||||
const neighbor = nodes.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 };
|
||||
})
|
||||
.filter((neighbor): neighbor is NonNullable<typeof neighbor> => neighbor !== null);
|
||||
|
||||
return (
|
||||
<div className="absolute top-4 right-4 bg-background/90 backdrop-blur-sm rounded-lg p-3 text-xs border border-border z-10 max-w-72 max-h-[calc(100%-2rem)] overflow-y-auto">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="font-medium">
|
||||
{node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8))}
|
||||
</div>
|
||||
<div className="text-muted-foreground">ID: {node.id}</div>
|
||||
<div className="text-muted-foreground">
|
||||
Type: {node.type}
|
||||
{node.isAmbiguous ? ' (ambiguous)' : ''}
|
||||
</div>
|
||||
{node.probableIdentity && (
|
||||
<div className="text-muted-foreground">Probably: {node.probableIdentity}</div>
|
||||
)}
|
||||
{node.ambiguousNames && node.ambiguousNames.length > 0 && (
|
||||
<div className="text-muted-foreground">
|
||||
{node.probableIdentity ? 'Other possible: ' : 'Possible: '}
|
||||
{node.ambiguousNames.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
{node.type !== 'self' && (
|
||||
<div className="text-muted-foreground border-t border-border pt-1 mt-1">
|
||||
<div>Last active: {formatRelativeTime(node.lastActivity)}</div>
|
||||
{node.lastActivityReason && <div>Reason: {node.lastActivityReason}</div>}
|
||||
</div>
|
||||
)}
|
||||
{neighbors.length > 0 && (
|
||||
<div className="text-muted-foreground border-t border-border pt-1 mt-1">
|
||||
<div className="mb-0.5">Traffic exchanged with:</div>
|
||||
<ul className="pl-3 flex flex-col gap-0.5">
|
||||
{neighbors.map((neighbor) => (
|
||||
<li key={neighbor.id}>
|
||||
{neighbor.name}
|
||||
{neighbor.ambiguousNames && neighbor.ambiguousNames.length > 0 && (
|
||||
<span className="text-muted-foreground/60">
|
||||
{' '}
|
||||
({neighbor.ambiguousNames.join(', ')})
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
frontend/src/components/visualizer/shared.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import * as THREE from 'three';
|
||||
import type { SimulationLinkDatum } from 'd3-force';
|
||||
import type { SimulationNodeDatum3D } from 'd3-force-3d';
|
||||
import type { CSS2DObject } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
|
||||
|
||||
import type { NodeType } from '../../utils/visualizerUtils';
|
||||
|
||||
export interface GraphNode extends SimulationNodeDatum3D {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type: NodeType;
|
||||
isAmbiguous: boolean;
|
||||
lastActivity: number;
|
||||
lastActivityReason?: string;
|
||||
lastSeen?: number | null;
|
||||
probableIdentity?: string | null;
|
||||
ambiguousNames?: string[];
|
||||
}
|
||||
|
||||
export interface GraphLink extends SimulationLinkDatum<GraphNode> {
|
||||
source: string | GraphNode;
|
||||
target: string | GraphNode;
|
||||
lastActivity: number;
|
||||
}
|
||||
|
||||
export interface NodeMeshData {
|
||||
mesh: THREE.Mesh;
|
||||
label: CSS2DObject;
|
||||
labelDiv: HTMLDivElement;
|
||||
}
|
||||
|
||||
export const NODE_COLORS = {
|
||||
self: 0x22c55e,
|
||||
repeater: 0x3b82f6,
|
||||
client: 0xffffff,
|
||||
ambiguous: 0x9ca3af,
|
||||
} as const;
|
||||
|
||||
export const NODE_LEGEND_ITEMS = [
|
||||
{ color: '#22c55e', label: 'You', size: 14 },
|
||||
{ color: '#3b82f6', label: 'Repeater', size: 10 },
|
||||
{ color: '#ffffff', label: 'Node', size: 10 },
|
||||
{ color: '#9ca3af', label: 'Ambiguous', size: 10 },
|
||||
] as const;
|
||||
|
||||
export function getBaseNodeColor(node: Pick<GraphNode, 'type' | 'isAmbiguous'>): number {
|
||||
if (node.type === 'self') return NODE_COLORS.self;
|
||||
if (node.type === 'repeater') return NODE_COLORS.repeater;
|
||||
return node.isAmbiguous ? NODE_COLORS.ambiguous : NODE_COLORS.client;
|
||||
}
|
||||
|
||||
export function growFloat32Buffer(current: Float32Array, requiredLength: number): Float32Array {
|
||||
let nextLength = Math.max(12, current.length);
|
||||
while (nextLength < requiredLength) {
|
||||
nextLength *= 2;
|
||||
}
|
||||
return new Float32Array(nextLength);
|
||||
}
|
||||
|
||||
export function arraysEqual(a: string[], b: string[]): boolean {
|
||||
if (a.length !== b.length) return false;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
if (a[i] !== b[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function formatRelativeTime(timestamp: number): string {
|
||||
const seconds = Math.floor((Date.now() - timestamp) / 1000);
|
||||
if (seconds < 5) return 'just now';
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return secs > 0 ? `${minutes}m ${secs}s ago` : `${minutes}m ago`;
|
||||
}
|
||||
|
||||
export function normalizePacketTimestampMs(timestamp: number | null | undefined): number {
|
||||
if (!Number.isFinite(timestamp) || !timestamp || timestamp <= 0) {
|
||||
return Date.now();
|
||||
}
|
||||
const ts = Number(timestamp);
|
||||
return ts > 1_000_000_000_000 ? ts : ts * 1000;
|
||||
}
|
||||
578
frontend/src/components/visualizer/useVisualizer3DScene.ts
Normal file
@@ -0,0 +1,578 @@
|
||||
import { useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
|
||||
import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer.js';
|
||||
|
||||
import { COLORS, getLinkId } from '../../utils/visualizerUtils';
|
||||
import type { VisualizerData3D } from './useVisualizerData3D';
|
||||
import { arraysEqual, getBaseNodeColor, growFloat32Buffer, type NodeMeshData } from './shared';
|
||||
|
||||
interface UseVisualizer3DSceneArgs {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
data: VisualizerData3D;
|
||||
autoOrbit: boolean;
|
||||
}
|
||||
|
||||
interface UseVisualizer3DSceneResult {
|
||||
hoveredNodeId: string | null;
|
||||
hoveredNeighborIds: string[];
|
||||
pinnedNodeId: string | null;
|
||||
}
|
||||
|
||||
export function useVisualizer3DScene({
|
||||
containerRef,
|
||||
data,
|
||||
autoOrbit,
|
||||
}: UseVisualizer3DSceneArgs): UseVisualizer3DSceneResult {
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const cssRendererRef = useRef<CSS2DRenderer | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const nodeMeshesRef = useRef<Map<string, NodeMeshData>>(new Map());
|
||||
const raycastTargetsRef = useRef<THREE.Mesh[]>([]);
|
||||
const linkLineRef = 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 highlightPositionBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
const particlePositionBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
const particleColorBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
const raycasterRef = useRef(new THREE.Raycaster());
|
||||
const mouseRef = useRef(new THREE.Vector2());
|
||||
const dataRef = useRef(data);
|
||||
|
||||
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null);
|
||||
const hoveredNodeIdRef = useRef<string | null>(null);
|
||||
const [hoveredNeighborIds, setHoveredNeighborIds] = useState<string[]>([]);
|
||||
const hoveredNeighborIdsRef = useRef<string[]>([]);
|
||||
const pinnedNodeIdRef = useRef<string | null>(null);
|
||||
const [pinnedNodeId, setPinnedNodeId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
dataRef.current = data;
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(COLORS.background);
|
||||
sceneRef.current = scene;
|
||||
|
||||
const camera = new THREE.PerspectiveCamera(60, 1, 1, 5000);
|
||||
camera.position.set(0, 0, 400);
|
||||
cameraRef.current = camera;
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
container.appendChild(renderer.domElement);
|
||||
rendererRef.current = renderer;
|
||||
|
||||
const texSize = 64;
|
||||
const texCanvas = document.createElement('canvas');
|
||||
texCanvas.width = texSize;
|
||||
texCanvas.height = texSize;
|
||||
const texCtx = texCanvas.getContext('2d');
|
||||
if (!texCtx) {
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentNode) {
|
||||
renderer.domElement.parentNode.removeChild(renderer.domElement);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const gradient = texCtx.createRadialGradient(
|
||||
texSize / 2,
|
||||
texSize / 2,
|
||||
0,
|
||||
texSize / 2,
|
||||
texSize / 2,
|
||||
texSize / 2
|
||||
);
|
||||
gradient.addColorStop(0, 'rgba(255,255,255,1)');
|
||||
gradient.addColorStop(0.5, 'rgba(255,255,255,0.8)');
|
||||
gradient.addColorStop(1, 'rgba(255,255,255,0)');
|
||||
texCtx.fillStyle = gradient;
|
||||
texCtx.fillRect(0, 0, texSize, texSize);
|
||||
const particleTexture = new THREE.CanvasTexture(texCanvas);
|
||||
particleTextureRef.current = particleTexture;
|
||||
|
||||
const cssRenderer = new CSS2DRenderer();
|
||||
cssRenderer.domElement.style.position = 'absolute';
|
||||
cssRenderer.domElement.style.top = '0';
|
||||
cssRenderer.domElement.style.left = '0';
|
||||
cssRenderer.domElement.style.pointerEvents = 'none';
|
||||
cssRenderer.domElement.style.zIndex = '1';
|
||||
container.appendChild(cssRenderer.domElement);
|
||||
cssRendererRef.current = cssRenderer;
|
||||
|
||||
const controls = new OrbitControls(camera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.1;
|
||||
controls.minDistance = 50;
|
||||
controls.maxDistance = 2000;
|
||||
controlsRef.current = controls;
|
||||
|
||||
const linkGeometry = new THREE.BufferGeometry();
|
||||
const linkMaterial = new THREE.LineBasicMaterial({
|
||||
color: COLORS.link,
|
||||
transparent: true,
|
||||
opacity: 0.6,
|
||||
});
|
||||
const linkSegments = new THREE.LineSegments(linkGeometry, linkMaterial);
|
||||
linkSegments.visible = false;
|
||||
scene.add(linkSegments);
|
||||
linkLineRef.current = linkSegments;
|
||||
|
||||
const highlightGeometry = new THREE.BufferGeometry();
|
||||
const highlightMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0xffd700,
|
||||
transparent: true,
|
||||
opacity: 1,
|
||||
linewidth: 2,
|
||||
});
|
||||
const highlightSegments = new THREE.LineSegments(highlightGeometry, highlightMaterial);
|
||||
highlightSegments.visible = false;
|
||||
scene.add(highlightSegments);
|
||||
highlightLineRef.current = highlightSegments;
|
||||
|
||||
const particleGeometry = new THREE.BufferGeometry();
|
||||
const particleMaterial = new THREE.PointsMaterial({
|
||||
size: 20,
|
||||
map: particleTexture,
|
||||
vertexColors: true,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
opacity: 0.9,
|
||||
depthWrite: false,
|
||||
});
|
||||
const particlePoints = new THREE.Points(particleGeometry, particleMaterial);
|
||||
particlePoints.visible = false;
|
||||
scene.add(particlePoints);
|
||||
particlePointsRef.current = particlePoints;
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
renderer.setSize(rect.width, rect.height);
|
||||
cssRenderer.setSize(rect.width, rect.height);
|
||||
camera.aspect = rect.width / rect.height;
|
||||
camera.updateProjectionMatrix();
|
||||
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect;
|
||||
if (width === 0 || height === 0) continue;
|
||||
renderer.setSize(width, height);
|
||||
cssRenderer.setSize(width, height);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
});
|
||||
observer.observe(container);
|
||||
|
||||
const nodeMeshes = nodeMeshesRef.current;
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
if (renderer.domElement.parentNode) {
|
||||
renderer.domElement.parentNode.removeChild(renderer.domElement);
|
||||
}
|
||||
if (cssRenderer.domElement.parentNode) {
|
||||
cssRenderer.domElement.parentNode.removeChild(cssRenderer.domElement);
|
||||
}
|
||||
for (const nd of nodeMeshes.values()) {
|
||||
nd.mesh.remove(nd.label);
|
||||
nd.labelDiv.remove();
|
||||
scene.remove(nd.mesh);
|
||||
nd.mesh.geometry.dispose();
|
||||
(nd.mesh.material as THREE.Material).dispose();
|
||||
}
|
||||
nodeMeshes.clear();
|
||||
raycastTargetsRef.current = [];
|
||||
|
||||
if (linkLineRef.current) {
|
||||
scene.remove(linkLineRef.current);
|
||||
linkLineRef.current.geometry.dispose();
|
||||
(linkLineRef.current.material as THREE.Material).dispose();
|
||||
linkLineRef.current = null;
|
||||
}
|
||||
if (highlightLineRef.current) {
|
||||
scene.remove(highlightLineRef.current);
|
||||
highlightLineRef.current.geometry.dispose();
|
||||
(highlightLineRef.current.material as THREE.Material).dispose();
|
||||
highlightLineRef.current = null;
|
||||
}
|
||||
if (particlePointsRef.current) {
|
||||
scene.remove(particlePointsRef.current);
|
||||
particlePointsRef.current.geometry.dispose();
|
||||
(particlePointsRef.current.material as THREE.Material).dispose();
|
||||
particlePointsRef.current = null;
|
||||
}
|
||||
particleTexture.dispose();
|
||||
particleTextureRef.current = null;
|
||||
linkPositionBufferRef.current = new Float32Array(0);
|
||||
highlightPositionBufferRef.current = new Float32Array(0);
|
||||
particlePositionBufferRef.current = new Float32Array(0);
|
||||
particleColorBufferRef.current = new Float32Array(0);
|
||||
sceneRef.current = null;
|
||||
cameraRef.current = null;
|
||||
rendererRef.current = null;
|
||||
cssRendererRef.current = null;
|
||||
controlsRef.current = null;
|
||||
};
|
||||
}, [containerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
const controls = controlsRef.current;
|
||||
if (!controls) return;
|
||||
controls.autoRotate = autoOrbit;
|
||||
controls.autoRotateSpeed = -0.5;
|
||||
}, [autoOrbit]);
|
||||
|
||||
useEffect(() => {
|
||||
const renderer = rendererRef.current;
|
||||
const camera = cameraRef.current;
|
||||
if (!renderer || !camera) return;
|
||||
|
||||
const onMouseMove = (event: MouseEvent) => {
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
mouseRef.current.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
mouseRef.current.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
};
|
||||
|
||||
let mouseDownPos = { x: 0, y: 0 };
|
||||
|
||||
const onMouseDown = (event: MouseEvent) => {
|
||||
mouseDownPos = { x: event.clientX, y: event.clientY };
|
||||
};
|
||||
|
||||
const onMouseUp = (event: MouseEvent) => {
|
||||
const dx = event.clientX - mouseDownPos.x;
|
||||
const dy = event.clientY - mouseDownPos.y;
|
||||
if (dx * dx + dy * dy > 25) return;
|
||||
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
const clickMouse = new THREE.Vector2(
|
||||
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
||||
-((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||
);
|
||||
|
||||
const raycaster = raycasterRef.current;
|
||||
raycaster.setFromCamera(clickMouse, camera);
|
||||
const intersects = raycaster.intersectObjects(raycastTargetsRef.current, false);
|
||||
const clickedObject = intersects[0]?.object as THREE.Mesh | undefined;
|
||||
const clickedId = (clickedObject?.userData?.nodeId as string | undefined) ?? null;
|
||||
|
||||
if (clickedId === pinnedNodeIdRef.current) {
|
||||
pinnedNodeIdRef.current = null;
|
||||
setPinnedNodeId(null);
|
||||
} else if (clickedId) {
|
||||
pinnedNodeIdRef.current = clickedId;
|
||||
setPinnedNodeId(clickedId);
|
||||
} else {
|
||||
pinnedNodeIdRef.current = null;
|
||||
setPinnedNodeId(null);
|
||||
}
|
||||
};
|
||||
|
||||
renderer.domElement.addEventListener('mousemove', onMouseMove);
|
||||
renderer.domElement.addEventListener('mousedown', onMouseDown);
|
||||
renderer.domElement.addEventListener('mouseup', onMouseUp);
|
||||
return () => {
|
||||
renderer.domElement.removeEventListener('mousemove', onMouseMove);
|
||||
renderer.domElement.removeEventListener('mousedown', onMouseDown);
|
||||
renderer.domElement.removeEventListener('mouseup', onMouseUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const scene = sceneRef.current;
|
||||
const camera = cameraRef.current;
|
||||
const renderer = rendererRef.current;
|
||||
const cssRenderer = cssRendererRef.current;
|
||||
const controls = controlsRef.current;
|
||||
if (!scene || !camera || !renderer || !cssRenderer || !controls) return;
|
||||
|
||||
let running = true;
|
||||
|
||||
const animate = () => {
|
||||
if (!running) return;
|
||||
requestAnimationFrame(animate);
|
||||
|
||||
controls.update();
|
||||
|
||||
const { nodes, links, particles } = dataRef.current;
|
||||
const currentNodeIds = new Set<string>();
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
currentNodeIds.add(node.id);
|
||||
|
||||
let nd = nodeMeshesRef.current.get(node.id);
|
||||
if (!nd) {
|
||||
const isSelf = node.type === 'self';
|
||||
const radius = isSelf ? 12 : 6;
|
||||
const geometry = new THREE.SphereGeometry(radius, 16, 12);
|
||||
const material = new THREE.MeshBasicMaterial({ color: getBaseNodeColor(node) });
|
||||
const mesh = new THREE.Mesh(geometry, material);
|
||||
mesh.userData.nodeId = node.id;
|
||||
scene.add(mesh);
|
||||
|
||||
const labelDiv = document.createElement('div');
|
||||
labelDiv.style.color = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb';
|
||||
labelDiv.style.fontSize = '11px';
|
||||
labelDiv.style.fontFamily = 'sans-serif';
|
||||
labelDiv.style.textAlign = 'center';
|
||||
labelDiv.style.whiteSpace = 'nowrap';
|
||||
labelDiv.style.textShadow = '0 0 4px #000, 0 0 2px #000';
|
||||
const label = new CSS2DObject(labelDiv);
|
||||
label.position.set(0, -(radius + 6), 0);
|
||||
mesh.add(label);
|
||||
|
||||
nd = { mesh, label, labelDiv };
|
||||
nodeMeshesRef.current.set(node.id, nd);
|
||||
raycastTargetsRef.current.push(mesh);
|
||||
}
|
||||
|
||||
nd.mesh.position.set(node.x ?? 0, node.y ?? 0, node.z ?? 0);
|
||||
const labelColor = node.isAmbiguous ? COLORS.ambiguous : '#e5e7eb';
|
||||
if (nd.labelDiv.style.color !== labelColor) {
|
||||
nd.labelDiv.style.color = labelColor;
|
||||
}
|
||||
const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
|
||||
if (nd.labelDiv.textContent !== labelText) {
|
||||
nd.labelDiv.textContent = labelText;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [id, nd] of nodeMeshesRef.current) {
|
||||
if (!currentNodeIds.has(id)) {
|
||||
nd.mesh.remove(nd.label);
|
||||
nd.labelDiv.remove();
|
||||
scene.remove(nd.mesh);
|
||||
nd.mesh.geometry.dispose();
|
||||
(nd.mesh.material as THREE.Material).dispose();
|
||||
const meshIdx = raycastTargetsRef.current.indexOf(nd.mesh);
|
||||
if (meshIdx >= 0) raycastTargetsRef.current.splice(meshIdx, 1);
|
||||
nodeMeshesRef.current.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
raycasterRef.current.setFromCamera(mouseRef.current, camera);
|
||||
const intersects = raycasterRef.current.intersectObjects(raycastTargetsRef.current, false);
|
||||
const hitObject = intersects[0]?.object as THREE.Mesh | undefined;
|
||||
const hitId = (hitObject?.userData?.nodeId as string | undefined) ?? null;
|
||||
if (hitId !== hoveredNodeIdRef.current) {
|
||||
hoveredNodeIdRef.current = hitId;
|
||||
setHoveredNodeId(hitId);
|
||||
}
|
||||
const activeId = pinnedNodeIdRef.current ?? hoveredNodeIdRef.current;
|
||||
|
||||
const visibleLinks = [];
|
||||
for (const link of links.values()) {
|
||||
const { sourceId, targetId } = getLinkId(link);
|
||||
if (currentNodeIds.has(sourceId) && currentNodeIds.has(targetId)) {
|
||||
visibleLinks.push(link);
|
||||
}
|
||||
}
|
||||
|
||||
const connectedIds = activeId ? new Set<string>([activeId]) : null;
|
||||
|
||||
const linkLine = linkLineRef.current;
|
||||
if (linkLine) {
|
||||
const geometry = linkLine.geometry as THREE.BufferGeometry;
|
||||
const requiredLength = visibleLinks.length * 6;
|
||||
if (linkPositionBufferRef.current.length < requiredLength) {
|
||||
linkPositionBufferRef.current = growFloat32Buffer(
|
||||
linkPositionBufferRef.current,
|
||||
requiredLength
|
||||
);
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(linkPositionBufferRef.current, 3).setUsage(
|
||||
THREE.DynamicDrawUsage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const highlightLine = highlightLineRef.current;
|
||||
if (highlightLine && highlightPositionBufferRef.current.length < requiredLength) {
|
||||
highlightPositionBufferRef.current = growFloat32Buffer(
|
||||
highlightPositionBufferRef.current,
|
||||
requiredLength
|
||||
);
|
||||
(highlightLine.geometry as THREE.BufferGeometry).setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(highlightPositionBufferRef.current, 3).setUsage(
|
||||
THREE.DynamicDrawUsage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const positions = linkPositionBufferRef.current;
|
||||
const hlPositions = highlightPositionBufferRef.current;
|
||||
let idx = 0;
|
||||
let hlIdx = 0;
|
||||
|
||||
for (const link of visibleLinks) {
|
||||
const { sourceId, targetId } = getLinkId(link);
|
||||
const sNode = nodes.get(sourceId);
|
||||
const tNode = nodes.get(targetId);
|
||||
if (!sNode || !tNode) continue;
|
||||
|
||||
const sx = sNode.x ?? 0;
|
||||
const sy = sNode.y ?? 0;
|
||||
const sz = sNode.z ?? 0;
|
||||
const tx = tNode.x ?? 0;
|
||||
const ty = tNode.y ?? 0;
|
||||
const tz = tNode.z ?? 0;
|
||||
|
||||
positions[idx++] = sx;
|
||||
positions[idx++] = sy;
|
||||
positions[idx++] = sz;
|
||||
positions[idx++] = tx;
|
||||
positions[idx++] = ty;
|
||||
positions[idx++] = tz;
|
||||
|
||||
if (activeId && (sourceId === activeId || targetId === activeId)) {
|
||||
connectedIds?.add(sourceId === activeId ? targetId : sourceId);
|
||||
hlPositions[hlIdx++] = sx;
|
||||
hlPositions[hlIdx++] = sy;
|
||||
hlPositions[hlIdx++] = sz;
|
||||
hlPositions[hlIdx++] = tx;
|
||||
hlPositions[hlIdx++] = ty;
|
||||
hlPositions[hlIdx++] = tz;
|
||||
}
|
||||
}
|
||||
|
||||
const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
|
||||
if (positionAttr) {
|
||||
positionAttr.needsUpdate = true;
|
||||
}
|
||||
geometry.setDrawRange(0, idx / 3);
|
||||
linkLine.visible = idx > 0;
|
||||
|
||||
if (highlightLine) {
|
||||
const hlGeometry = highlightLine.geometry as THREE.BufferGeometry;
|
||||
const hlAttr = hlGeometry.getAttribute('position') as THREE.BufferAttribute | undefined;
|
||||
if (hlAttr) {
|
||||
hlAttr.needsUpdate = true;
|
||||
}
|
||||
hlGeometry.setDrawRange(0, hlIdx / 3);
|
||||
highlightLine.visible = hlIdx > 0;
|
||||
}
|
||||
}
|
||||
|
||||
let writeIdx = 0;
|
||||
for (let readIdx = 0; readIdx < particles.length; readIdx++) {
|
||||
const particle = particles[readIdx];
|
||||
particle.progress += particle.speed;
|
||||
if (particle.progress <= 1) {
|
||||
particles[writeIdx++] = particle;
|
||||
}
|
||||
}
|
||||
particles.length = writeIdx;
|
||||
|
||||
const particlePoints = particlePointsRef.current;
|
||||
if (particlePoints) {
|
||||
const geometry = particlePoints.geometry as THREE.BufferGeometry;
|
||||
const requiredLength = particles.length * 3;
|
||||
|
||||
if (particlePositionBufferRef.current.length < requiredLength) {
|
||||
particlePositionBufferRef.current = growFloat32Buffer(
|
||||
particlePositionBufferRef.current,
|
||||
requiredLength
|
||||
);
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(particlePositionBufferRef.current, 3).setUsage(
|
||||
THREE.DynamicDrawUsage
|
||||
)
|
||||
);
|
||||
}
|
||||
if (particleColorBufferRef.current.length < requiredLength) {
|
||||
particleColorBufferRef.current = growFloat32Buffer(
|
||||
particleColorBufferRef.current,
|
||||
requiredLength
|
||||
);
|
||||
geometry.setAttribute(
|
||||
'color',
|
||||
new THREE.BufferAttribute(particleColorBufferRef.current, 3).setUsage(
|
||||
THREE.DynamicDrawUsage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const pPositions = particlePositionBufferRef.current;
|
||||
const pColors = particleColorBufferRef.current;
|
||||
const color = new THREE.Color();
|
||||
let visibleCount = 0;
|
||||
|
||||
for (const p of particles) {
|
||||
if (p.progress < 0) continue;
|
||||
if (!currentNodeIds.has(p.fromNodeId) || !currentNodeIds.has(p.toNodeId)) continue;
|
||||
|
||||
const fromNode = nodes.get(p.fromNodeId);
|
||||
const toNode = nodes.get(p.toNodeId);
|
||||
if (!fromNode || !toNode) continue;
|
||||
|
||||
const t = p.progress;
|
||||
const x = (fromNode.x ?? 0) + ((toNode.x ?? 0) - (fromNode.x ?? 0)) * t;
|
||||
const y = (fromNode.y ?? 0) + ((toNode.y ?? 0) - (fromNode.y ?? 0)) * t;
|
||||
const z = (fromNode.z ?? 0) + ((toNode.z ?? 0) - (fromNode.z ?? 0)) * t;
|
||||
|
||||
pPositions[visibleCount * 3] = x;
|
||||
pPositions[visibleCount * 3 + 1] = y;
|
||||
pPositions[visibleCount * 3 + 2] = z;
|
||||
|
||||
color.set(p.color);
|
||||
pColors[visibleCount * 3] = color.r;
|
||||
pColors[visibleCount * 3 + 1] = color.g;
|
||||
pColors[visibleCount * 3 + 2] = color.b;
|
||||
visibleCount++;
|
||||
}
|
||||
|
||||
const posAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
|
||||
const colorAttr = geometry.getAttribute('color') as THREE.BufferAttribute | undefined;
|
||||
if (posAttr) posAttr.needsUpdate = true;
|
||||
if (colorAttr) colorAttr.needsUpdate = true;
|
||||
geometry.setDrawRange(0, visibleCount);
|
||||
particlePoints.visible = visibleCount > 0;
|
||||
}
|
||||
|
||||
const nextNeighbors = connectedIds
|
||||
? Array.from(connectedIds)
|
||||
.filter((id) => id !== activeId)
|
||||
.sort()
|
||||
: [];
|
||||
if (!arraysEqual(hoveredNeighborIdsRef.current, nextNeighbors)) {
|
||||
hoveredNeighborIdsRef.current = nextNeighbors;
|
||||
setHoveredNeighborIds(nextNeighbors);
|
||||
}
|
||||
|
||||
for (const [id, nd] of nodeMeshesRef.current) {
|
||||
const node = nodes.get(id);
|
||||
if (!node) continue;
|
||||
const mat = nd.mesh.material as THREE.MeshBasicMaterial;
|
||||
if (id === activeId) {
|
||||
mat.color.set(0xffd700);
|
||||
} else if (connectedIds?.has(id)) {
|
||||
mat.color.set(0xfff0b3);
|
||||
} else {
|
||||
mat.color.set(getBaseNodeColor(node));
|
||||
}
|
||||
}
|
||||
|
||||
renderer.render(scene, camera);
|
||||
cssRenderer.render(scene, camera);
|
||||
};
|
||||
|
||||
animate();
|
||||
return () => {
|
||||
running = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { hoveredNodeId, hoveredNeighborIds, pinnedNodeId };
|
||||
}
|
||||
922
frontend/src/components/visualizer/useVisualizerData3D.ts
Normal file
@@ -0,0 +1,922 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
forceCenter,
|
||||
forceLink,
|
||||
forceManyBody,
|
||||
forceSimulation,
|
||||
forceX,
|
||||
forceY,
|
||||
forceZ,
|
||||
type ForceLink3D,
|
||||
type Simulation3D,
|
||||
} from 'd3-force-3d';
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
type ContactAdvertPathSummary,
|
||||
type RadioConfig,
|
||||
type RawPacket,
|
||||
} from '../../types';
|
||||
import { getRawPacketObservationKey } from '../../utils/rawPacketIdentity';
|
||||
import {
|
||||
type Particle,
|
||||
type PendingPacket,
|
||||
type RepeaterTrafficData,
|
||||
PARTICLE_COLOR_MAP,
|
||||
PARTICLE_SPEED,
|
||||
analyzeRepeaterTraffic,
|
||||
buildAmbiguousRepeaterLabel,
|
||||
buildAmbiguousRepeaterNodeId,
|
||||
dedupeConsecutive,
|
||||
generatePacketKey,
|
||||
getNodeType,
|
||||
getPacketLabel,
|
||||
parsePacket,
|
||||
recordTrafficObservation,
|
||||
} from '../../utils/visualizerUtils';
|
||||
import { type GraphLink, type GraphNode, normalizePacketTimestampMs } from './shared';
|
||||
|
||||
export interface UseVisualizerData3DOptions {
|
||||
packets: RawPacket[];
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
repeaterAdvertPaths: ContactAdvertPathSummary[];
|
||||
showAmbiguousPaths: boolean;
|
||||
showAmbiguousNodes: boolean;
|
||||
useAdvertPathHints: boolean;
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
chargeStrength: number;
|
||||
letEmDrift: boolean;
|
||||
particleSpeedMultiplier: number;
|
||||
observationWindowSec: number;
|
||||
pruneStaleNodes: boolean;
|
||||
pruneStaleMinutes: number;
|
||||
}
|
||||
|
||||
export interface VisualizerData3D {
|
||||
nodes: Map<string, GraphNode>;
|
||||
links: Map<string, GraphLink>;
|
||||
particles: Particle[];
|
||||
stats: { processed: number; animated: number; nodes: number; links: number };
|
||||
expandContract: () => void;
|
||||
clearAndReset: () => void;
|
||||
}
|
||||
|
||||
export function useVisualizerData3D({
|
||||
packets,
|
||||
contacts,
|
||||
config,
|
||||
repeaterAdvertPaths,
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
splitAmbiguousByTraffic,
|
||||
chargeStrength,
|
||||
letEmDrift,
|
||||
particleSpeedMultiplier,
|
||||
observationWindowSec,
|
||||
pruneStaleNodes,
|
||||
pruneStaleMinutes,
|
||||
}: UseVisualizerData3DOptions): VisualizerData3D {
|
||||
const nodesRef = useRef<Map<string, GraphNode>>(new Map());
|
||||
const linksRef = useRef<Map<string, GraphLink>>(new Map());
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
const simulationRef = useRef<Simulation3D<GraphNode, GraphLink> | null>(null);
|
||||
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 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]);
|
||||
|
||||
useEffect(() => {
|
||||
speedMultiplierRef.current = particleSpeedMultiplier;
|
||||
}, [particleSpeedMultiplier]);
|
||||
|
||||
useEffect(() => {
|
||||
observationWindowRef.current = observationWindowSec * 1000;
|
||||
}, [observationWindowSec]);
|
||||
|
||||
useEffect(() => {
|
||||
const sim = forceSimulation<GraphNode, GraphLink>([])
|
||||
.numDimensions(3)
|
||||
.force(
|
||||
'link',
|
||||
forceLink<GraphNode, GraphLink>([])
|
||||
.id((d) => d.id)
|
||||
.distance(120)
|
||||
.strength(0.3)
|
||||
)
|
||||
.force(
|
||||
'charge',
|
||||
forceManyBody<GraphNode>()
|
||||
.strength((d) => (d.id === 'self' ? -1200 : -200))
|
||||
.distanceMax(800)
|
||||
)
|
||||
.force('center', forceCenter(0, 0, 0))
|
||||
.force(
|
||||
'selfX',
|
||||
forceX<GraphNode>(0).strength((d) => (d.id === 'self' ? 0.1 : 0))
|
||||
)
|
||||
.force(
|
||||
'selfY',
|
||||
forceY<GraphNode>(0).strength((d) => (d.id === 'self' ? 0.1 : 0))
|
||||
)
|
||||
.force(
|
||||
'selfZ',
|
||||
forceZ<GraphNode>(0).strength((d) => (d.id === 'self' ? 0.1 : 0))
|
||||
)
|
||||
.alphaDecay(0.02)
|
||||
.velocityDecay(0.5)
|
||||
.alphaTarget(0.03);
|
||||
|
||||
simulationRef.current = sim;
|
||||
return () => {
|
||||
sim.stop();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const sim = simulationRef.current;
|
||||
if (!sim) return;
|
||||
|
||||
sim.force(
|
||||
'charge',
|
||||
forceManyBody<GraphNode>()
|
||||
.strength((d) => (d.id === 'self' ? chargeStrength * 6 : chargeStrength))
|
||||
.distanceMax(800)
|
||||
);
|
||||
sim.alpha(0.3).restart();
|
||||
}, [chargeStrength]);
|
||||
|
||||
useEffect(() => {
|
||||
const sim = simulationRef.current;
|
||||
if (!sim) return;
|
||||
sim.alphaTarget(letEmDrift ? 0.05 : 0);
|
||||
}, [letEmDrift]);
|
||||
|
||||
const syncSimulation = useCallback(() => {
|
||||
const sim = simulationRef.current;
|
||||
if (!sim) return;
|
||||
|
||||
const nodes = Array.from(nodesRef.current.values());
|
||||
const links = Array.from(linksRef.current.values());
|
||||
|
||||
sim.nodes(nodes);
|
||||
const linkForce = sim.force('link') as ForceLink3D<GraphNode, GraphLink> | undefined;
|
||||
linkForce?.links(links);
|
||||
|
||||
sim.alpha(0.15).restart();
|
||||
|
||||
setStats((prev) =>
|
||||
prev.nodes === nodes.length && prev.links === links.length
|
||||
? prev
|
||||
: { ...prev, nodes: nodes.length, links: links.length }
|
||||
);
|
||||
}, []);
|
||||
|
||||
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 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 publishPacket = useCallback((packetKey: string) => {
|
||||
const pending = pendingRef.current.get(packetKey);
|
||||
if (!pending) return;
|
||||
|
||||
pendingRef.current.delete(packetKey);
|
||||
timersRef.current.delete(packetKey);
|
||||
|
||||
if (document.hidden) return;
|
||||
|
||||
for (const path of pending.paths) {
|
||||
const dedupedPath = dedupeConsecutive(path.nodes);
|
||||
if (dedupedPath.length < 2) continue;
|
||||
|
||||
for (let i = 0; i < dedupedPath.length - 1; i++) {
|
||||
particlesRef.current.push({
|
||||
linkKey: [dedupedPath[i], dedupedPath[i + 1]].sort().join('->'),
|
||||
progress: -i,
|
||||
speed: PARTICLE_SPEED * speedMultiplierRef.current,
|
||||
color: PARTICLE_COLOR_MAP[pending.label],
|
||||
label: pending.label,
|
||||
fromNodeId: dedupedPath[i],
|
||||
toNodeId: dedupedPath[i + 1],
|
||||
});
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
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;
|
||||
|
||||
for (const packet of packets) {
|
||||
const observationKey = getRawPacketObservationKey(packet);
|
||||
if (processedRef.current.has(observationKey)) continue;
|
||||
processedRef.current.add(observationKey);
|
||||
newProcessed++;
|
||||
|
||||
if (processedRef.current.size > 1000) {
|
||||
processedRef.current = new Set(Array.from(processedRef.current).slice(-500));
|
||||
}
|
||||
|
||||
const parsed = parsePacket(packet.data);
|
||||
if (!parsed) continue;
|
||||
|
||||
const packetActivityAt = normalizePacketTimestampMs(packet.timestamp);
|
||||
const path = buildPath(parsed, packet, myPrefix, packetActivityAt);
|
||||
if (path.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 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 });
|
||||
} else {
|
||||
const existingTimer = timersRef.current.get(packetKey);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
}
|
||||
const windowMs = observationWindowRef.current;
|
||||
pendingRef.current.set(packetKey, {
|
||||
key: packetKey,
|
||||
label: getPacketLabel(parsed.payloadType),
|
||||
paths: [{ nodes: path, snr: packet.snr ?? null, timestamp: now }],
|
||||
firstSeen: now,
|
||||
expiresAt: now + windowMs,
|
||||
});
|
||||
timersRef.current.set(
|
||||
packetKey,
|
||||
setTimeout(() => publishPacket(packetKey), windowMs)
|
||||
);
|
||||
}
|
||||
|
||||
if (pendingRef.current.size > 100) {
|
||||
const entries = Array.from(pendingRef.current.entries())
|
||||
.sort((a, b) => a[1].firstSeen - b[1].firstSeen)
|
||||
.slice(0, 50);
|
||||
for (const [key] of entries) {
|
||||
const timer = timersRef.current.get(key);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timersRef.current.delete(key);
|
||||
pendingRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
newAnimated++;
|
||||
}
|
||||
|
||||
if (needsUpdate) syncSimulation();
|
||||
if (newProcessed > 0) {
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
processed: prev.processed + newProcessed,
|
||||
animated: prev.animated + newAnimated,
|
||||
}));
|
||||
}
|
||||
}, [packets, config, buildPath, addLink, syncSimulation, publishPacket]);
|
||||
|
||||
const expandContract = useCallback(() => {
|
||||
const sim = simulationRef.current;
|
||||
if (!sim) return;
|
||||
|
||||
if (stretchRafRef.current !== null) {
|
||||
cancelAnimationFrame(stretchRafRef.current);
|
||||
stretchRafRef.current = null;
|
||||
}
|
||||
|
||||
const startChargeStrength = chargeStrength;
|
||||
const peakChargeStrength = -5000;
|
||||
const startLinkStrength = 0.3;
|
||||
const minLinkStrength = 0.02;
|
||||
const expandDuration = 1000;
|
||||
const holdDuration = 2000;
|
||||
const contractDuration = 1000;
|
||||
const startTime = performance.now();
|
||||
|
||||
const animate = (now: number) => {
|
||||
const elapsed = now - startTime;
|
||||
let currentChargeStrength: number;
|
||||
let currentLinkStrength: number;
|
||||
|
||||
if (elapsed < expandDuration) {
|
||||
const t = elapsed / expandDuration;
|
||||
currentChargeStrength =
|
||||
startChargeStrength + (peakChargeStrength - startChargeStrength) * t;
|
||||
currentLinkStrength = startLinkStrength + (minLinkStrength - startLinkStrength) * t;
|
||||
} else if (elapsed < expandDuration + holdDuration) {
|
||||
currentChargeStrength = peakChargeStrength;
|
||||
currentLinkStrength = minLinkStrength;
|
||||
} else if (elapsed < expandDuration + holdDuration + contractDuration) {
|
||||
const t = (elapsed - expandDuration - holdDuration) / contractDuration;
|
||||
currentChargeStrength = peakChargeStrength + (startChargeStrength - peakChargeStrength) * t;
|
||||
currentLinkStrength = minLinkStrength + (startLinkStrength - minLinkStrength) * t;
|
||||
} else {
|
||||
sim.force(
|
||||
'charge',
|
||||
forceManyBody<GraphNode>()
|
||||
.strength((d) => (d.id === 'self' ? startChargeStrength * 6 : startChargeStrength))
|
||||
.distanceMax(800)
|
||||
);
|
||||
sim.force(
|
||||
'link',
|
||||
forceLink<GraphNode, GraphLink>(Array.from(linksRef.current.values()))
|
||||
.id((d) => d.id)
|
||||
.distance(120)
|
||||
.strength(startLinkStrength)
|
||||
);
|
||||
sim.alpha(0.3).restart();
|
||||
stretchRafRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
sim.force(
|
||||
'charge',
|
||||
forceManyBody<GraphNode>()
|
||||
.strength((d) => (d.id === 'self' ? currentChargeStrength * 6 : currentChargeStrength))
|
||||
.distanceMax(800)
|
||||
);
|
||||
sim.force(
|
||||
'link',
|
||||
forceLink<GraphNode, GraphLink>(Array.from(linksRef.current.values()))
|
||||
.id((d) => d.id)
|
||||
.distance(120)
|
||||
.strength(currentLinkStrength)
|
||||
);
|
||||
sim.alpha(0.5).restart();
|
||||
|
||||
stretchRafRef.current = requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
stretchRafRef.current = requestAnimationFrame(animate);
|
||||
}, [chargeStrength]);
|
||||
|
||||
const clearAndReset = useCallback(() => {
|
||||
if (stretchRafRef.current !== null) {
|
||||
cancelAnimationFrame(stretchRafRef.current);
|
||||
stretchRafRef.current = null;
|
||||
}
|
||||
|
||||
for (const timer of timersRef.current.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timersRef.current.clear();
|
||||
pendingRef.current.clear();
|
||||
processedRef.current.clear();
|
||||
trafficPatternsRef.current.clear();
|
||||
particlesRef.current.length = 0;
|
||||
linksRef.current.clear();
|
||||
|
||||
const selfNode = nodesRef.current.get('self');
|
||||
nodesRef.current.clear();
|
||||
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);
|
||||
}
|
||||
|
||||
const sim = simulationRef.current;
|
||||
if (sim) {
|
||||
sim.nodes(Array.from(nodesRef.current.values()));
|
||||
const linkForce = sim.force('link') as ForceLink3D<GraphNode, GraphLink> | undefined;
|
||||
linkForce?.links([]);
|
||||
sim.alpha(0.3).restart();
|
||||
}
|
||||
|
||||
setStats({ processed: 0, animated: 0, nodes: 1, links: 0 });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const stretchRaf = stretchRafRef;
|
||||
const timers = timersRef.current;
|
||||
const pending = pendingRef.current;
|
||||
return () => {
|
||||
if (stretchRaf.current !== null) {
|
||||
cancelAnimationFrame(stretchRaf.current);
|
||||
}
|
||||
for (const timer of timers.values()) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
timers.clear();
|
||||
pending.clear();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pruneStaleNodes) return;
|
||||
|
||||
const staleMs = pruneStaleMinutes * 60 * 1000;
|
||||
const pruneIntervalMs = 1000;
|
||||
|
||||
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();
|
||||
}
|
||||
}, pruneIntervalMs);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [pruneStaleNodes, pruneStaleMinutes, syncSimulation]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
nodes: nodesRef.current,
|
||||
links: linksRef.current,
|
||||
particles: particlesRef.current,
|
||||
stats,
|
||||
expandContract,
|
||||
clearAndReset,
|
||||
}),
|
||||
[stats, expandContract, clearAndReset]
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,11 @@ export { useUnreadCounts } from './useUnreadCounts';
|
||||
export { useConversationMessages, getMessageContentKey } from './useConversationMessages';
|
||||
export { useRadioControl } from './useRadioControl';
|
||||
export { useRepeaterDashboard } from './useRepeaterDashboard';
|
||||
export { useAppShell } from './useAppShell';
|
||||
export { useAppSettings } from './useAppSettings';
|
||||
export { useConversationRouter } from './useConversationRouter';
|
||||
export { useContactsAndChannels } from './useContactsAndChannels';
|
||||
export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
|
||||
77
frontend/src/hooks/useAppShell.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { startTransition, useCallback, useState } from 'react';
|
||||
|
||||
import { getLocalLabel, type LocalLabel } from '../utils/localLabel';
|
||||
import type { SettingsSection } from '../components/settings/settingsConstants';
|
||||
|
||||
interface UseAppShellResult {
|
||||
showNewMessage: boolean;
|
||||
showSettings: boolean;
|
||||
settingsSection: SettingsSection;
|
||||
sidebarOpen: boolean;
|
||||
showCracker: boolean;
|
||||
crackerRunning: boolean;
|
||||
localLabel: LocalLabel;
|
||||
setSettingsSection: (section: SettingsSection) => void;
|
||||
setSidebarOpen: (open: boolean) => void;
|
||||
setCrackerRunning: (running: boolean) => void;
|
||||
setLocalLabel: (label: LocalLabel) => void;
|
||||
handleCloseSettingsView: () => void;
|
||||
handleToggleSettingsView: () => void;
|
||||
handleOpenNewMessage: () => void;
|
||||
handleCloseNewMessage: () => void;
|
||||
handleToggleCracker: () => void;
|
||||
}
|
||||
|
||||
export function useAppShell(): UseAppShellResult {
|
||||
const [showNewMessage, setShowNewMessage] = useState(false);
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [settingsSection, setSettingsSection] = useState<SettingsSection>('radio');
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [showCracker, setShowCracker] = useState(false);
|
||||
const [crackerRunning, setCrackerRunning] = useState(false);
|
||||
const [localLabel, setLocalLabel] = useState(getLocalLabel);
|
||||
|
||||
const handleCloseSettingsView = useCallback(() => {
|
||||
startTransition(() => setShowSettings(false));
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleSettingsView = useCallback(() => {
|
||||
startTransition(() => {
|
||||
setShowSettings((prev) => !prev);
|
||||
});
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOpenNewMessage = useCallback(() => {
|
||||
setShowNewMessage(true);
|
||||
setSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleCloseNewMessage = useCallback(() => {
|
||||
setShowNewMessage(false);
|
||||
}, []);
|
||||
|
||||
const handleToggleCracker = useCallback(() => {
|
||||
setShowCracker((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
showNewMessage,
|
||||
showSettings,
|
||||
settingsSection,
|
||||
sidebarOpen,
|
||||
showCracker,
|
||||
crackerRunning,
|
||||
localLabel,
|
||||
setSettingsSection,
|
||||
setSidebarOpen,
|
||||
setCrackerRunning,
|
||||
setLocalLabel,
|
||||
handleCloseSettingsView,
|
||||
handleToggleSettingsView,
|
||||
handleOpenNewMessage,
|
||||
handleCloseNewMessage,
|
||||
handleToggleCracker,
|
||||
};
|
||||
}
|
||||
207
frontend/src/hooks/useBrowserNotifications.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import type { Message } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation';
|
||||
const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
|
||||
|
||||
type NotificationPermissionState = NotificationPermission | 'unsupported';
|
||||
type ConversationNotificationMap = Record<string, boolean>;
|
||||
|
||||
function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string {
|
||||
return getStateKey(type, id);
|
||||
}
|
||||
|
||||
function readStoredEnabledMap(): ConversationNotificationMap {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key, value]) => typeof key === 'string' && value === true)
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredEnabledMap(enabledByConversation: ConversationNotificationMap) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(enabledByConversation));
|
||||
}
|
||||
|
||||
function getInitialPermission(): NotificationPermissionState {
|
||||
if (typeof window === 'undefined' || !('Notification' in window)) {
|
||||
return 'unsupported';
|
||||
}
|
||||
return window.Notification.permission;
|
||||
}
|
||||
|
||||
function shouldShowDesktopNotification(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return document.visibilityState !== 'visible' || !document.hasFocus();
|
||||
}
|
||||
|
||||
function getMessageConversationNotificationKey(message: Message): string | null {
|
||||
if (message.type === 'PRIV' && message.conversation_key) {
|
||||
return getConversationNotificationKey('contact', message.conversation_key);
|
||||
}
|
||||
if (message.type === 'CHAN' && message.conversation_key) {
|
||||
return getConversationNotificationKey('channel', message.conversation_key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildNotificationTitle(message: Message): string {
|
||||
if (message.type === 'PRIV') {
|
||||
return message.sender_name
|
||||
? `New message from ${message.sender_name}`
|
||||
: `New message from ${message.conversation_key.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
const roomName = message.channel_name || message.conversation_key.slice(0, 8);
|
||||
return `New message in ${roomName}`;
|
||||
}
|
||||
|
||||
function buildPreviewNotificationTitle(type: 'channel' | 'contact', label: string): string {
|
||||
return type === 'contact' ? `New message from ${label}` : `New message in ${label}`;
|
||||
}
|
||||
|
||||
function buildMessageNotificationHash(message: Message): string | null {
|
||||
if (message.type === 'PRIV' && message.conversation_key) {
|
||||
const label = message.sender_name || message.conversation_key.slice(0, 12);
|
||||
return `#contact/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
|
||||
}
|
||||
if (message.type === 'CHAN' && message.conversation_key) {
|
||||
const label = message.channel_name || message.conversation_key.slice(0, 8);
|
||||
return `#channel/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useBrowserNotifications() {
|
||||
const [permission, setPermission] = useState<NotificationPermissionState>(getInitialPermission);
|
||||
const [enabledByConversation, setEnabledByConversation] =
|
||||
useState<ConversationNotificationMap>(readStoredEnabledMap);
|
||||
|
||||
useEffect(() => {
|
||||
setPermission(getInitialPermission());
|
||||
}, []);
|
||||
|
||||
const isConversationNotificationsEnabled = useCallback(
|
||||
(type: 'channel' | 'contact', id: string) =>
|
||||
permission === 'granted' &&
|
||||
enabledByConversation[getConversationNotificationKey(type, id)] === true,
|
||||
[enabledByConversation, permission]
|
||||
);
|
||||
|
||||
const toggleConversationNotifications = useCallback(
|
||||
async (type: 'channel' | 'contact', id: string, label: string) => {
|
||||
const conversationKey = getConversationNotificationKey(type, id);
|
||||
if (enabledByConversation[conversationKey]) {
|
||||
setEnabledByConversation((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[conversationKey];
|
||||
writeStoredEnabledMap(next);
|
||||
return next;
|
||||
});
|
||||
toast.success(`${label} notifications disabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === 'unsupported') {
|
||||
toast.error('Browser notifications unavailable', {
|
||||
description: 'This browser does not support desktop notifications.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === 'denied') {
|
||||
toast.error('Browser notifications blocked', {
|
||||
description: 'Allow notifications in your browser settings, then try again.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPermission = await window.Notification.requestPermission();
|
||||
setPermission(nextPermission);
|
||||
|
||||
if (nextPermission === 'granted') {
|
||||
setEnabledByConversation((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
[conversationKey]: true,
|
||||
};
|
||||
writeStoredEnabledMap(next);
|
||||
return next;
|
||||
});
|
||||
new window.Notification(buildPreviewNotificationTitle(type, label), {
|
||||
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
|
||||
icon: NOTIFICATION_ICON_PATH,
|
||||
tag: `meshcore-notification-preview-${conversationKey}`,
|
||||
});
|
||||
toast.success(`${label} notifications enabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error('Browser notifications not enabled', {
|
||||
description:
|
||||
nextPermission === 'denied'
|
||||
? 'Permission was denied by the browser.'
|
||||
: 'Permission request was dismissed.',
|
||||
});
|
||||
},
|
||||
[enabledByConversation, permission]
|
||||
);
|
||||
|
||||
const notifyIncomingMessage = useCallback(
|
||||
(message: Message) => {
|
||||
const conversationKey = getMessageConversationNotificationKey(message);
|
||||
if (
|
||||
permission !== 'granted' ||
|
||||
!conversationKey ||
|
||||
enabledByConversation[conversationKey] !== true ||
|
||||
!shouldShowDesktopNotification()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new window.Notification(buildNotificationTitle(message), {
|
||||
body: message.text,
|
||||
icon: NOTIFICATION_ICON_PATH,
|
||||
tag: `meshcore-message-${message.id}`,
|
||||
});
|
||||
|
||||
notification.onclick = () => {
|
||||
const hash = buildMessageNotificationHash(message);
|
||||
if (hash) {
|
||||
window.open(`${window.location.origin}${window.location.pathname}${hash}`, '_self');
|
||||
}
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
},
|
||||
[enabledByConversation, permission]
|
||||
);
|
||||
|
||||
return {
|
||||
notificationsSupported: permission !== 'unsupported',
|
||||
notificationsPermission: permission,
|
||||
isConversationNotificationsEnabled,
|
||||
toggleConversationNotifications,
|
||||
notifyIncomingMessage,
|
||||
};
|
||||
}
|
||||
156
frontend/src/hooks/useConversationActions.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { useCallback, type MutableRefObject, type RefObject } from 'react';
|
||||
import { api } from '../api';
|
||||
import * as messageCache from '../messageCache';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import type { MessageInputHandle } from '../components/MessageInput';
|
||||
import type { Channel, Conversation, Message } from '../types';
|
||||
|
||||
interface UseConversationActionsArgs {
|
||||
activeConversation: Conversation | null;
|
||||
activeConversationRef: MutableRefObject<Conversation | null>;
|
||||
setChannels: React.Dispatch<React.SetStateAction<Channel[]>>;
|
||||
addMessageIfNew: (msg: Message) => boolean;
|
||||
jumpToBottom: () => void;
|
||||
handleToggleBlockedKey: (key: string) => Promise<void>;
|
||||
handleToggleBlockedName: (name: string) => Promise<void>;
|
||||
messageInputRef: RefObject<MessageInputHandle | null>;
|
||||
}
|
||||
|
||||
interface UseConversationActionsResult {
|
||||
handleSendMessage: (text: string) => Promise<void>;
|
||||
handleResendChannelMessage: (messageId: number, newTimestamp?: boolean) => Promise<void>;
|
||||
handleSetChannelFloodScopeOverride: (
|
||||
channelKey: string,
|
||||
floodScopeOverride: string
|
||||
) => Promise<void>;
|
||||
handleSenderClick: (sender: string) => void;
|
||||
handleTrace: () => Promise<void>;
|
||||
handleBlockKey: (key: string) => Promise<void>;
|
||||
handleBlockName: (name: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function useConversationActions({
|
||||
activeConversation,
|
||||
activeConversationRef,
|
||||
setChannels,
|
||||
addMessageIfNew,
|
||||
jumpToBottom,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
messageInputRef,
|
||||
}: UseConversationActionsArgs): UseConversationActionsResult {
|
||||
const mergeChannelIntoList = useCallback(
|
||||
(updated: Channel) => {
|
||||
setChannels((prev) => {
|
||||
const existingIndex = prev.findIndex((channel) => channel.key === updated.key);
|
||||
if (existingIndex === -1) {
|
||||
return [...prev, updated].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
const next = [...prev];
|
||||
next[existingIndex] = updated;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setChannels]
|
||||
);
|
||||
|
||||
const handleSendMessage = useCallback(
|
||||
async (text: string) => {
|
||||
if (!activeConversation) return;
|
||||
|
||||
const conversationId = activeConversation.id;
|
||||
const sent =
|
||||
activeConversation.type === 'channel'
|
||||
? await api.sendChannelMessage(activeConversation.id, text)
|
||||
: await api.sendDirectMessage(activeConversation.id, text);
|
||||
|
||||
if (activeConversationRef.current?.id === conversationId) {
|
||||
addMessageIfNew(sent);
|
||||
}
|
||||
},
|
||||
[activeConversation, activeConversationRef, addMessageIfNew]
|
||||
);
|
||||
|
||||
const handleResendChannelMessage = useCallback(
|
||||
async (messageId: number, newTimestamp?: boolean) => {
|
||||
try {
|
||||
await api.resendChannelMessage(messageId, newTimestamp);
|
||||
toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent');
|
||||
} catch (err) {
|
||||
toast.error('Failed to resend', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSetChannelFloodScopeOverride = useCallback(
|
||||
async (channelKey: string, floodScopeOverride: string) => {
|
||||
try {
|
||||
const updated = await api.setChannelFloodScopeOverride(channelKey, floodScopeOverride);
|
||||
mergeChannelIntoList(updated);
|
||||
toast.success(
|
||||
updated.flood_scope_override ? 'Regional override saved' : 'Regional override cleared'
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error('Failed to update regional override', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
},
|
||||
[mergeChannelIntoList]
|
||||
);
|
||||
|
||||
const handleSenderClick = useCallback(
|
||||
(sender: string) => {
|
||||
messageInputRef.current?.appendText(`@[${sender}] `);
|
||||
},
|
||||
[messageInputRef]
|
||||
);
|
||||
|
||||
const handleTrace = useCallback(async () => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return;
|
||||
toast('Trace started...');
|
||||
try {
|
||||
const result = await api.requestTrace(activeConversation.id);
|
||||
const parts: string[] = [];
|
||||
if (result.remote_snr !== null) parts.push(`Remote SNR: ${result.remote_snr.toFixed(1)} dB`);
|
||||
if (result.local_snr !== null) parts.push(`Local SNR: ${result.local_snr.toFixed(1)} dB`);
|
||||
const detail = parts.join(', ');
|
||||
toast.success(detail ? `Trace complete! ${detail}` : 'Trace complete!');
|
||||
} catch (err) {
|
||||
toast.error('Trace failed', {
|
||||
description: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
}
|
||||
}, [activeConversation]);
|
||||
|
||||
const handleBlockKey = useCallback(
|
||||
async (key: string) => {
|
||||
await handleToggleBlockedKey(key);
|
||||
messageCache.clear();
|
||||
jumpToBottom();
|
||||
},
|
||||
[handleToggleBlockedKey, jumpToBottom]
|
||||
);
|
||||
|
||||
const handleBlockName = useCallback(
|
||||
async (name: string) => {
|
||||
await handleToggleBlockedName(name);
|
||||
messageCache.clear();
|
||||
jumpToBottom();
|
||||
},
|
||||
[handleToggleBlockedName, jumpToBottom]
|
||||
);
|
||||
|
||||
return {
|
||||
handleSendMessage,
|
||||
handleResendChannelMessage,
|
||||
handleSetChannelFloodScopeOverride,
|
||||
handleSenderClick,
|
||||
handleTrace,
|
||||
handleBlockKey,
|
||||
handleBlockName,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import { api, isAbortError } from '../api';
|
||||
import * as messageCache from '../messageCache';
|
||||
import type { Conversation, Message, MessagePath } from '../types';
|
||||
|
||||
const MESSAGE_PAGE_SIZE = 200;
|
||||
const MAX_PENDING_ACKS = 500;
|
||||
const MESSAGE_PAGE_SIZE = 200;
|
||||
|
||||
interface PendingAckUpdate {
|
||||
ackCount: number;
|
||||
@@ -64,8 +72,8 @@ interface UseConversationMessagesResult {
|
||||
hasOlderMessages: boolean;
|
||||
hasNewerMessages: boolean;
|
||||
loadingNewer: boolean;
|
||||
hasNewerMessagesRef: React.MutableRefObject<boolean>;
|
||||
setMessages: React.Dispatch<React.SetStateAction<Message[]>>;
|
||||
hasNewerMessagesRef: MutableRefObject<boolean>;
|
||||
setMessages: Dispatch<SetStateAction<Message[]>>;
|
||||
fetchOlderMessages: () => Promise<void>;
|
||||
fetchNewerMessages: () => Promise<void>;
|
||||
jumpToBottom: () => void;
|
||||
@@ -74,17 +82,14 @@ interface UseConversationMessagesResult {
|
||||
triggerReconcile: () => void;
|
||||
}
|
||||
|
||||
function isMessageConversation(conversation: Conversation | null): conversation is Conversation {
|
||||
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
|
||||
}
|
||||
|
||||
export function useConversationMessages(
|
||||
activeConversation: Conversation | null,
|
||||
targetMessageId?: number | null
|
||||
): UseConversationMessagesResult {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [hasOlderMessages, setHasOlderMessages] = useState(false);
|
||||
const [hasNewerMessages, setHasNewerMessages] = useState(false);
|
||||
const [loadingNewer, setLoadingNewer] = useState(false);
|
||||
|
||||
// Track seen message content for deduplication
|
||||
const seenMessageContent = useRef<Set<string>>(new Set());
|
||||
|
||||
@@ -92,31 +97,6 @@ export function useConversationMessages(
|
||||
// Buffer latest ACK state by message_id and apply when the message arrives.
|
||||
const pendingAcksRef = useRef<Map<number, PendingAckUpdate>>(new Map());
|
||||
|
||||
// AbortController for cancelling in-flight requests on conversation change
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Ref to track the conversation ID being fetched to prevent stale responses
|
||||
const fetchingConversationIdRef = useRef<string | null>(null);
|
||||
|
||||
// --- Cache integration refs ---
|
||||
// Keep refs in sync with state so we can read current values in the switch effect
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
const hasOlderMessagesRef = useRef(false);
|
||||
const hasNewerMessagesRef = useRef(false);
|
||||
const prevConversationIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
hasOlderMessagesRef.current = hasOlderMessages;
|
||||
}, [hasOlderMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
hasNewerMessagesRef.current = hasNewerMessages;
|
||||
}, [hasNewerMessages]);
|
||||
|
||||
const setPendingAck = useCallback(
|
||||
(messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
const existing = pendingAcksRef.current.get(messageId);
|
||||
@@ -148,33 +128,57 @@ export function useConversationMessages(
|
||||
...(pending.paths !== undefined && { paths: pending.paths }),
|
||||
};
|
||||
}, []);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [messagesLoading, setMessagesLoading] = useState(false);
|
||||
const [loadingOlder, setLoadingOlder] = useState(false);
|
||||
const [hasOlderMessages, setHasOlderMessages] = useState(false);
|
||||
const [hasNewerMessages, setHasNewerMessages] = useState(false);
|
||||
const [loadingNewer, setLoadingNewer] = useState(false);
|
||||
|
||||
// Fetch messages for active conversation
|
||||
// Note: This is called manually and from the useEffect. The useEffect handles
|
||||
// cancellation via AbortController; manual calls (e.g., after sending a message)
|
||||
// don't need cancellation.
|
||||
const fetchMessages = useCallback(
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const fetchingConversationIdRef = useRef<string | null>(null);
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
const hasOlderMessagesRef = useRef(false);
|
||||
const hasNewerMessagesRef = useRef(false);
|
||||
const prevConversationIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
hasOlderMessagesRef.current = hasOlderMessages;
|
||||
}, [hasOlderMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
hasNewerMessagesRef.current = hasNewerMessages;
|
||||
}, [hasNewerMessages]);
|
||||
|
||||
const syncSeenContent = useCallback(
|
||||
(nextMessages: Message[]) => {
|
||||
seenMessageContent.current.clear();
|
||||
for (const msg of nextMessages) {
|
||||
seenMessageContent.current.add(getMessageContentKey(msg));
|
||||
}
|
||||
},
|
||||
[seenMessageContent]
|
||||
);
|
||||
|
||||
const fetchLatestMessages = useCallback(
|
||||
async (showLoading = false, signal?: AbortSignal) => {
|
||||
if (
|
||||
!activeConversation ||
|
||||
activeConversation.type === 'raw' ||
|
||||
activeConversation.type === 'map' ||
|
||||
activeConversation.type === 'visualizer' ||
|
||||
activeConversation.type === 'search'
|
||||
) {
|
||||
if (!isMessageConversation(activeConversation)) {
|
||||
setMessages([]);
|
||||
setHasOlderMessages(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Track which conversation we're fetching for
|
||||
const conversationId = activeConversation.id;
|
||||
|
||||
if (showLoading) {
|
||||
setMessagesLoading(true);
|
||||
// Clear messages first so MessageList resets scroll state for new conversation
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getMessages(
|
||||
{
|
||||
@@ -185,24 +189,15 @@ export function useConversationMessages(
|
||||
signal
|
||||
);
|
||||
|
||||
// Check if this response is still for the current conversation
|
||||
// This handles the race where the conversation changed while awaiting
|
||||
if (fetchingConversationIdRef.current !== conversationId) {
|
||||
// Stale response - conversation changed while we were fetching
|
||||
return;
|
||||
}
|
||||
|
||||
const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||
setMessages(messagesWithPendingAck);
|
||||
// Track seen content for new messages
|
||||
seenMessageContent.current.clear();
|
||||
for (const msg of messagesWithPendingAck) {
|
||||
seenMessageContent.current.add(getMessageContentKey(msg));
|
||||
}
|
||||
// If we got a full page, there might be more
|
||||
syncSeenContent(messagesWithPendingAck);
|
||||
setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
// Don't show error toast for aborted requests (user switched conversations)
|
||||
if (isAbortError(err)) {
|
||||
return;
|
||||
}
|
||||
@@ -216,22 +211,46 @@ export function useConversationMessages(
|
||||
}
|
||||
}
|
||||
},
|
||||
[activeConversation, applyPendingAck]
|
||||
[activeConversation, applyPendingAck, syncSeenContent]
|
||||
);
|
||||
|
||||
const reconcileFromBackend = useCallback(
|
||||
(conversation: Conversation, signal: AbortSignal) => {
|
||||
const conversationId = conversation.id;
|
||||
api
|
||||
.getMessages(
|
||||
{
|
||||
type: conversation.type === 'channel' ? 'CHAN' : 'PRIV',
|
||||
conversation_key: conversationId,
|
||||
limit: MESSAGE_PAGE_SIZE,
|
||||
},
|
||||
signal
|
||||
)
|
||||
.then((data) => {
|
||||
if (fetchingConversationIdRef.current !== conversationId) return;
|
||||
|
||||
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||
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;
|
||||
console.debug('Background reconciliation failed:', err);
|
||||
});
|
||||
},
|
||||
[applyPendingAck, syncSeenContent]
|
||||
);
|
||||
|
||||
// Fetch older messages (cursor-based pagination)
|
||||
const fetchOlderMessages = useCallback(async () => {
|
||||
if (
|
||||
!activeConversation ||
|
||||
activeConversation.type === 'raw' ||
|
||||
loadingOlder ||
|
||||
!hasOlderMessages
|
||||
)
|
||||
return;
|
||||
if (!isMessageConversation(activeConversation) || loadingOlder || !hasOlderMessages) return;
|
||||
|
||||
const conversationId = activeConversation.id;
|
||||
|
||||
// Get the true oldest message as cursor for the next page
|
||||
const oldestMessage = messages.reduce(
|
||||
(oldest, msg) => {
|
||||
if (!oldest) return msg;
|
||||
@@ -253,20 +272,16 @@ export function useConversationMessages(
|
||||
before_id: oldestMessage.id,
|
||||
});
|
||||
|
||||
// Guard against stale response if the user switched conversations mid-request
|
||||
if (fetchingConversationIdRef.current !== conversationId) return;
|
||||
|
||||
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||
|
||||
if (dataWithPendingAck.length > 0) {
|
||||
// Prepend older messages (they come sorted DESC, so older are at the end)
|
||||
setMessages((prev) => [...prev, ...dataWithPendingAck]);
|
||||
// Track seen content
|
||||
for (const msg of dataWithPendingAck) {
|
||||
seenMessageContent.current.add(getMessageContentKey(msg));
|
||||
}
|
||||
}
|
||||
// If we got less than a full page, no more messages
|
||||
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch older messages:', err);
|
||||
@@ -276,21 +291,12 @@ export function useConversationMessages(
|
||||
} finally {
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
}, [activeConversation, loadingOlder, hasOlderMessages, messages, applyPendingAck]);
|
||||
}, [activeConversation, applyPendingAck, hasOlderMessages, loadingOlder, messages]);
|
||||
|
||||
// Fetch newer messages (forward cursor pagination)
|
||||
const fetchNewerMessages = useCallback(async () => {
|
||||
if (
|
||||
!activeConversation ||
|
||||
activeConversation.type === 'raw' ||
|
||||
loadingNewer ||
|
||||
!hasNewerMessages
|
||||
)
|
||||
return;
|
||||
if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return;
|
||||
|
||||
const conversationId = activeConversation.id;
|
||||
|
||||
// Get the newest message as forward cursor
|
||||
const newestMessage = messages.reduce(
|
||||
(newest, msg) => {
|
||||
if (!newest) return msg;
|
||||
@@ -315,11 +321,10 @@ export function useConversationMessages(
|
||||
if (fetchingConversationIdRef.current !== conversationId) return;
|
||||
|
||||
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||
|
||||
// Deduplicate against already-seen messages (WS race)
|
||||
const newMessages = dataWithPendingAck.filter(
|
||||
(msg) => !seenMessageContent.current.has(getMessageContentKey(msg))
|
||||
);
|
||||
|
||||
if (newMessages.length > 0) {
|
||||
setMessages((prev) => [...prev, ...newMessages]);
|
||||
for (const msg of newMessages) {
|
||||
@@ -335,105 +340,43 @@ export function useConversationMessages(
|
||||
} finally {
|
||||
setLoadingNewer(false);
|
||||
}
|
||||
}, [activeConversation, loadingNewer, hasNewerMessages, messages, applyPendingAck]);
|
||||
}, [activeConversation, applyPendingAck, hasNewerMessages, loadingNewer, messages]);
|
||||
|
||||
// Jump to bottom: re-fetch latest page, clear hasNewerMessages
|
||||
const jumpToBottom = useCallback(() => {
|
||||
if (!activeConversation) return;
|
||||
setHasNewerMessages(false);
|
||||
// Invalidate cache so fetchMessages does a fresh load
|
||||
messageCache.remove(activeConversation.id);
|
||||
fetchMessages(true);
|
||||
}, [activeConversation, fetchMessages]);
|
||||
void fetchLatestMessages(true);
|
||||
}, [activeConversation, fetchLatestMessages]);
|
||||
|
||||
// Trigger a background reconciliation for the current conversation.
|
||||
// Used after WebSocket reconnects to silently recover any missed messages.
|
||||
const triggerReconcile = useCallback(() => {
|
||||
const conv = activeConversation;
|
||||
if (
|
||||
!conv ||
|
||||
conv.type === 'raw' ||
|
||||
conv.type === 'map' ||
|
||||
conv.type === 'visualizer' ||
|
||||
conv.type === 'search'
|
||||
)
|
||||
return;
|
||||
if (!isMessageConversation(activeConversation)) return;
|
||||
const controller = new AbortController();
|
||||
reconcileFromBackend(conv, controller.signal);
|
||||
}, [activeConversation]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
reconcileFromBackend(activeConversation, controller.signal);
|
||||
}, [activeConversation, reconcileFromBackend]);
|
||||
|
||||
// Background reconciliation: silently fetch from backend after a cache restore
|
||||
// and only update state if something differs (missed WS message, stale ack, etc.).
|
||||
// No-ops on the happy path — zero rerenders when cache is already consistent.
|
||||
function reconcileFromBackend(conversation: Conversation, signal: AbortSignal) {
|
||||
const conversationId = conversation.id;
|
||||
api
|
||||
.getMessages(
|
||||
{
|
||||
type: conversation.type === 'channel' ? 'CHAN' : 'PRIV',
|
||||
conversation_key: conversationId,
|
||||
limit: MESSAGE_PAGE_SIZE,
|
||||
},
|
||||
signal
|
||||
)
|
||||
.then((data) => {
|
||||
// Stale check — conversation may have changed while awaiting
|
||||
if (fetchingConversationIdRef.current !== conversationId) return;
|
||||
|
||||
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||
const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck);
|
||||
if (!merged) return; // Cache was consistent — no rerender
|
||||
|
||||
setMessages(merged);
|
||||
seenMessageContent.current.clear();
|
||||
for (const msg of merged) {
|
||||
seenMessageContent.current.add(getMessageContentKey(msg));
|
||||
}
|
||||
if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) {
|
||||
setHasOlderMessages(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isAbortError(err)) return;
|
||||
// Silent failure — we already have cached data
|
||||
console.debug('Background reconciliation failed:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch messages when conversation changes, with proper cancellation and caching
|
||||
useEffect(() => {
|
||||
// Abort any previous in-flight request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
const prevId = prevConversationIdRef.current;
|
||||
|
||||
// Track which conversation we're now on
|
||||
const newId = activeConversation?.id ?? null;
|
||||
const conversationChanged = prevId !== newId;
|
||||
fetchingConversationIdRef.current = newId;
|
||||
prevConversationIdRef.current = newId;
|
||||
|
||||
// When targetMessageId goes from a value to null (onTargetReached cleared it)
|
||||
// but the conversation hasn't changed, the around-loaded messages are already
|
||||
// displayed — do nothing. Without this guard the effect would re-enter the
|
||||
// normal fetch path and replace the mid-history view with the latest page.
|
||||
// Preserve around-loaded context on the same conversation when search clears targetMessageId.
|
||||
if (!conversationChanged && !targetMessageId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset loadingOlder/loadingNewer — the previous conversation's in-flight
|
||||
// fetch is irrelevant now (its stale-check will discard the response).
|
||||
setLoadingOlder(false);
|
||||
setLoadingNewer(false);
|
||||
if (conversationChanged) {
|
||||
setHasNewerMessages(false);
|
||||
}
|
||||
|
||||
// Save outgoing conversation to cache only when actually leaving it, and
|
||||
// only if we were on the latest page (mid-history views would restore stale
|
||||
// partial data on switch-back).
|
||||
if (
|
||||
conversationChanged &&
|
||||
prevId &&
|
||||
@@ -447,29 +390,20 @@ export function useConversationMessages(
|
||||
});
|
||||
}
|
||||
|
||||
// Clear state for non-message views
|
||||
if (
|
||||
!activeConversation ||
|
||||
activeConversation.type === 'raw' ||
|
||||
activeConversation.type === 'map' ||
|
||||
activeConversation.type === 'visualizer' ||
|
||||
activeConversation.type === 'search'
|
||||
) {
|
||||
if (!isMessageConversation(activeConversation)) {
|
||||
setMessages([]);
|
||||
setHasOlderMessages(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create AbortController for this conversation's fetch (cache reconcile or full fetch)
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
// Jump-to-message: skip cache and load messages around the target
|
||||
if (targetMessageId) {
|
||||
setMessagesLoading(true);
|
||||
setMessages([]);
|
||||
const msgType = activeConversation.type === 'channel' ? 'CHAN' : 'PRIV';
|
||||
api
|
||||
void api
|
||||
.getMessagesAround(
|
||||
targetMessageId,
|
||||
msgType as 'PRIV' | 'CHAN',
|
||||
@@ -480,10 +414,7 @@ export function useConversationMessages(
|
||||
if (fetchingConversationIdRef.current !== activeConversation.id) return;
|
||||
const withAcks = response.messages.map((msg) => applyPendingAck(msg));
|
||||
setMessages(withAcks);
|
||||
seenMessageContent.current.clear();
|
||||
for (const msg of withAcks) {
|
||||
seenMessageContent.current.add(getMessageContentKey(msg));
|
||||
}
|
||||
syncSeenContent(withAcks);
|
||||
setHasOlderMessages(response.has_older);
|
||||
setHasNewerMessages(response.has_newer);
|
||||
})
|
||||
@@ -496,30 +427,21 @@ export function useConversationMessages(
|
||||
setMessagesLoading(false);
|
||||
});
|
||||
} else {
|
||||
// Check cache for the new conversation
|
||||
const cached = messageCache.get(activeConversation.id);
|
||||
if (cached) {
|
||||
// Restore from cache instantly — no spinner
|
||||
setMessages(cached.messages);
|
||||
seenMessageContent.current = new Set(cached.seenContent);
|
||||
setHasOlderMessages(cached.hasOlderMessages);
|
||||
setMessagesLoading(false);
|
||||
// Silently reconcile with backend in case we missed a WS message
|
||||
reconcileFromBackend(activeConversation, controller.signal);
|
||||
} else {
|
||||
// Not cached — full fetch with spinner
|
||||
fetchMessages(true, controller.signal);
|
||||
void fetchLatestMessages(true, controller.signal);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup: abort request if conversation changes or component unmounts
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
// NOTE: Intentionally omitting fetchMessages and activeConversation from deps:
|
||||
// - fetchMessages is recreated when activeConversation changes, which would cause infinite loops
|
||||
// - activeConversation object identity changes on every render; we only care about id/type
|
||||
// - We use fetchingConversationIdRef and AbortController to handle stale responses safely
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeConversation?.id, activeConversation?.type, targetMessageId]);
|
||||
|
||||
@@ -555,7 +477,7 @@ export function useConversationMessages(
|
||||
|
||||
return true;
|
||||
},
|
||||
[applyPendingAck]
|
||||
[applyPendingAck, messagesRef, setMessages]
|
||||
);
|
||||
|
||||
// Update a message's ack count and paths
|
||||
@@ -592,7 +514,7 @@ export function useConversationMessages(
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[setPendingAck]
|
||||
[messagesRef, setMessages, setPendingAck]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
112
frontend/src/hooks/useConversationNavigation.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { useCallback, useState, type Dispatch, type SetStateAction } from 'react';
|
||||
|
||||
import type { SearchNavigateTarget } from '../components/SearchView';
|
||||
import type { Channel, Conversation } from '../types';
|
||||
|
||||
interface UseConversationNavigationArgs {
|
||||
channels: Channel[];
|
||||
handleSelectConversation: (conv: Conversation) => void;
|
||||
}
|
||||
|
||||
interface UseConversationNavigationResult {
|
||||
targetMessageId: number | null;
|
||||
setTargetMessageId: Dispatch<SetStateAction<number | null>>;
|
||||
infoPaneContactKey: string | null;
|
||||
infoPaneFromChannel: boolean;
|
||||
infoPaneChannelKey: string | null;
|
||||
handleOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||
handleCloseContactInfo: () => void;
|
||||
handleOpenChannelInfo: (channelKey: string) => void;
|
||||
handleCloseChannelInfo: () => void;
|
||||
handleSelectConversationWithTargetReset: (
|
||||
conv: Conversation,
|
||||
options?: { preserveTarget?: boolean }
|
||||
) => void;
|
||||
handleNavigateToChannel: (channelKey: string) => void;
|
||||
handleNavigateToMessage: (target: SearchNavigateTarget) => void;
|
||||
}
|
||||
|
||||
export function useConversationNavigation({
|
||||
channels,
|
||||
handleSelectConversation,
|
||||
}: UseConversationNavigationArgs): UseConversationNavigationResult {
|
||||
const [targetMessageId, setTargetMessageId] = useState<number | null>(null);
|
||||
const [infoPaneContactKey, setInfoPaneContactKey] = useState<string | null>(null);
|
||||
const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false);
|
||||
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(null);
|
||||
|
||||
const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => {
|
||||
setInfoPaneContactKey(publicKey);
|
||||
setInfoPaneFromChannel(fromChannel ?? false);
|
||||
}, []);
|
||||
|
||||
const handleCloseContactInfo = useCallback(() => {
|
||||
setInfoPaneContactKey(null);
|
||||
}, []);
|
||||
|
||||
const handleOpenChannelInfo = useCallback((channelKey: string) => {
|
||||
setInfoPaneChannelKey(channelKey);
|
||||
}, []);
|
||||
|
||||
const handleCloseChannelInfo = useCallback(() => {
|
||||
setInfoPaneChannelKey(null);
|
||||
}, []);
|
||||
|
||||
const handleSelectConversationWithTargetReset = useCallback(
|
||||
(conv: Conversation, options?: { preserveTarget?: boolean }) => {
|
||||
if (conv.type !== 'search' && !options?.preserveTarget) {
|
||||
setTargetMessageId(null);
|
||||
}
|
||||
handleSelectConversation(conv);
|
||||
},
|
||||
[handleSelectConversation]
|
||||
);
|
||||
|
||||
const handleNavigateToChannel = useCallback(
|
||||
(channelKey: string) => {
|
||||
const channel = channels.find((item) => item.key === channelKey);
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
handleSelectConversationWithTargetReset({
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
});
|
||||
setInfoPaneContactKey(null);
|
||||
},
|
||||
[channels, handleSelectConversationWithTargetReset]
|
||||
);
|
||||
|
||||
const handleNavigateToMessage = useCallback(
|
||||
(target: SearchNavigateTarget) => {
|
||||
const convType = target.type === 'CHAN' ? 'channel' : 'contact';
|
||||
setTargetMessageId(target.id);
|
||||
handleSelectConversationWithTargetReset(
|
||||
{
|
||||
type: convType,
|
||||
id: target.conversation_key,
|
||||
name: target.conversation_name,
|
||||
},
|
||||
{ preserveTarget: true }
|
||||
);
|
||||
},
|
||||
[handleSelectConversationWithTargetReset]
|
||||
);
|
||||
|
||||
return {
|
||||
targetMessageId,
|
||||
setTargetMessageId,
|
||||
infoPaneContactKey,
|
||||
infoPaneFromChannel,
|
||||
infoPaneChannelKey,
|
||||
handleOpenContactInfo,
|
||||
handleCloseContactInfo,
|
||||
handleOpenChannelInfo,
|
||||
handleCloseChannelInfo,
|
||||
handleSelectConversationWithTargetReset,
|
||||
handleNavigateToChannel,
|
||||
handleNavigateToMessage,
|
||||
};
|
||||
}
|
||||
272
frontend/src/hooks/useRealtimeAppState.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction,
|
||||
} from 'react';
|
||||
import { api } from '../api';
|
||||
import * as messageCache from '../messageCache';
|
||||
import type { UseWebSocketOptions } from '../useWebSocket';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
import { mergeContactIntoList } from '../utils/contactMerge';
|
||||
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
|
||||
import { getMessageContentKey } from './useConversationMessages';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
HealthStatus,
|
||||
Message,
|
||||
MessagePath,
|
||||
RawPacket,
|
||||
} from '../types';
|
||||
|
||||
interface UseRealtimeAppStateArgs {
|
||||
prevHealthRef: MutableRefObject<HealthStatus | null>;
|
||||
setHealth: Dispatch<SetStateAction<HealthStatus | null>>;
|
||||
fetchConfig: () => void | Promise<void>;
|
||||
setRawPackets: Dispatch<SetStateAction<RawPacket[]>>;
|
||||
triggerReconcile: () => void;
|
||||
refreshUnreads: () => Promise<void>;
|
||||
setChannels: Dispatch<SetStateAction<Channel[]>>;
|
||||
fetchAllContacts: () => Promise<Contact[]>;
|
||||
setContacts: Dispatch<SetStateAction<Contact[]>>;
|
||||
blockedKeysRef: MutableRefObject<string[]>;
|
||||
blockedNamesRef: MutableRefObject<string[]>;
|
||||
activeConversationRef: MutableRefObject<Conversation | null>;
|
||||
hasNewerMessagesRef: MutableRefObject<boolean>;
|
||||
addMessageIfNew: (msg: Message) => boolean;
|
||||
trackNewMessage: (msg: Message) => void;
|
||||
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
|
||||
checkMention: (text: string) => boolean;
|
||||
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||
setActiveConversation: (conv: Conversation | null) => void;
|
||||
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
notifyIncomingMessage?: (msg: Message) => void;
|
||||
maxRawPackets?: number;
|
||||
}
|
||||
|
||||
function isMessageBlocked(msg: Message, blockedKeys: string[], blockedNames: string[]): boolean {
|
||||
if (msg.outgoing) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (blockedKeys.length > 0) {
|
||||
if (msg.type === 'PRIV' && blockedKeys.includes(msg.conversation_key.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
msg.type === 'CHAN' &&
|
||||
msg.sender_key &&
|
||||
blockedKeys.includes(msg.sender_key.toLowerCase())
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return blockedNames.length > 0 && !!msg.sender_name && blockedNames.includes(msg.sender_name);
|
||||
}
|
||||
|
||||
function isActiveConversationMessage(
|
||||
activeConversation: Conversation | null,
|
||||
msg: Message
|
||||
): boolean {
|
||||
if (!activeConversation) return false;
|
||||
if (msg.type === 'CHAN' && activeConversation.type === 'channel') {
|
||||
return msg.conversation_key === activeConversation.id;
|
||||
}
|
||||
if (msg.type === 'PRIV' && activeConversation.type === 'contact') {
|
||||
return msg.conversation_key === activeConversation.id;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function useRealtimeAppState({
|
||||
prevHealthRef,
|
||||
setHealth,
|
||||
fetchConfig,
|
||||
setRawPackets,
|
||||
triggerReconcile,
|
||||
refreshUnreads,
|
||||
setChannels,
|
||||
fetchAllContacts,
|
||||
setContacts,
|
||||
blockedKeysRef,
|
||||
blockedNamesRef,
|
||||
activeConversationRef,
|
||||
hasNewerMessagesRef,
|
||||
addMessageIfNew,
|
||||
trackNewMessage,
|
||||
incrementUnread,
|
||||
checkMention,
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
updateMessageAck,
|
||||
notifyIncomingMessage,
|
||||
maxRawPackets = 500,
|
||||
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
|
||||
const mergeChannelIntoList = useCallback(
|
||||
(updated: Channel) => {
|
||||
setChannels((prev) => {
|
||||
const existingIndex = prev.findIndex((channel) => channel.key === updated.key);
|
||||
if (existingIndex === -1) {
|
||||
return [...prev, updated].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
const next = [...prev];
|
||||
next[existingIndex] = updated;
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[setChannels]
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
onHealth: (data: HealthStatus) => {
|
||||
const prev = prevHealthRef.current;
|
||||
prevHealthRef.current = data;
|
||||
setHealth(data);
|
||||
const initializationCompleted =
|
||||
prev !== null &&
|
||||
prev.radio_connected &&
|
||||
prev.radio_initializing &&
|
||||
data.radio_connected &&
|
||||
!data.radio_initializing;
|
||||
|
||||
if (prev !== null && prev.radio_connected !== data.radio_connected) {
|
||||
if (data.radio_connected) {
|
||||
toast.success('Radio connected', {
|
||||
description: data.connection_info
|
||||
? `Connected via ${data.connection_info}`
|
||||
: undefined,
|
||||
});
|
||||
fetchConfig();
|
||||
} else {
|
||||
toast.error('Radio disconnected', {
|
||||
description: 'Check radio connection and power',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (initializationCompleted) {
|
||||
fetchConfig();
|
||||
}
|
||||
},
|
||||
onError: (error: { message: string; details?: string }) => {
|
||||
toast.error(error.message, {
|
||||
description: error.details,
|
||||
});
|
||||
},
|
||||
onSuccess: (success: { message: string; details?: string }) => {
|
||||
toast.success(success.message, {
|
||||
description: success.details,
|
||||
});
|
||||
},
|
||||
onReconnect: () => {
|
||||
setRawPackets([]);
|
||||
triggerReconcile();
|
||||
refreshUnreads();
|
||||
api.getChannels().then(setChannels).catch(console.error);
|
||||
fetchAllContacts()
|
||||
.then((data) => setContacts(data))
|
||||
.catch(console.error);
|
||||
},
|
||||
onMessage: (msg: Message) => {
|
||||
if (isMessageBlocked(msg, blockedKeysRef.current, blockedNamesRef.current)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isForActiveConversation = isActiveConversationMessage(
|
||||
activeConversationRef.current,
|
||||
msg
|
||||
);
|
||||
let isNewMessage = false;
|
||||
|
||||
if (isForActiveConversation && !hasNewerMessagesRef.current) {
|
||||
isNewMessage = addMessageIfNew(msg);
|
||||
}
|
||||
|
||||
trackNewMessage(msg);
|
||||
|
||||
const contentKey = getMessageContentKey(msg);
|
||||
if (!isForActiveConversation) {
|
||||
isNewMessage = messageCache.addMessage(msg.conversation_key, msg, contentKey);
|
||||
|
||||
if (!msg.outgoing && isNewMessage) {
|
||||
let stateKey: string | null = null;
|
||||
if (msg.type === 'CHAN' && msg.conversation_key) {
|
||||
stateKey = getStateKey('channel', msg.conversation_key);
|
||||
} else if (msg.type === 'PRIV' && msg.conversation_key) {
|
||||
stateKey = getStateKey('contact', msg.conversation_key);
|
||||
}
|
||||
if (stateKey) {
|
||||
incrementUnread(stateKey, checkMention(msg.text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.outgoing && isNewMessage) {
|
||||
notifyIncomingMessage?.(msg);
|
||||
}
|
||||
},
|
||||
onContact: (contact: Contact) => {
|
||||
setContacts((prev) => mergeContactIntoList(prev, contact));
|
||||
},
|
||||
onChannel: (channel: Channel) => {
|
||||
mergeChannelIntoList(channel);
|
||||
},
|
||||
onContactDeleted: (publicKey: string) => {
|
||||
setContacts((prev) => prev.filter((c) => c.public_key !== publicKey));
|
||||
messageCache.remove(publicKey);
|
||||
const active = activeConversationRef.current;
|
||||
if (active?.type === 'contact' && active.id === publicKey) {
|
||||
pendingDeleteFallbackRef.current = true;
|
||||
setActiveConversation(null);
|
||||
}
|
||||
},
|
||||
onChannelDeleted: (key: string) => {
|
||||
setChannels((prev) => prev.filter((c) => c.key !== key));
|
||||
messageCache.remove(key);
|
||||
const active = activeConversationRef.current;
|
||||
if (active?.type === 'channel' && active.id === key) {
|
||||
pendingDeleteFallbackRef.current = true;
|
||||
setActiveConversation(null);
|
||||
}
|
||||
},
|
||||
onRawPacket: (packet: RawPacket) => {
|
||||
setRawPackets((prev) => appendRawPacketUnique(prev, packet, maxRawPackets));
|
||||
},
|
||||
onMessageAcked: (messageId: number, ackCount: number, paths?: MessagePath[]) => {
|
||||
updateMessageAck(messageId, ackCount, paths);
|
||||
messageCache.updateAck(messageId, ackCount, paths);
|
||||
},
|
||||
}),
|
||||
[
|
||||
activeConversationRef,
|
||||
addMessageIfNew,
|
||||
blockedKeysRef,
|
||||
blockedNamesRef,
|
||||
checkMention,
|
||||
fetchAllContacts,
|
||||
fetchConfig,
|
||||
hasNewerMessagesRef,
|
||||
incrementUnread,
|
||||
maxRawPackets,
|
||||
mergeChannelIntoList,
|
||||
pendingDeleteFallbackRef,
|
||||
prevHealthRef,
|
||||
refreshUnreads,
|
||||
setActiveConversation,
|
||||
setChannels,
|
||||
setContacts,
|
||||
setHealth,
|
||||
setRawPackets,
|
||||
trackNewMessage,
|
||||
triggerReconcile,
|
||||
updateMessageAck,
|
||||
notifyIncomingMessage,
|
||||
]
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
const MAX_CACHED_REPEATERS = 20;
|
||||
|
||||
interface ConsoleEntry {
|
||||
command: string;
|
||||
@@ -35,7 +36,15 @@ interface PaneData {
|
||||
lppTelemetry: RepeaterLppTelemetryResponse | null;
|
||||
}
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null };
|
||||
interface RepeaterDashboardCacheEntry {
|
||||
loggedIn: boolean;
|
||||
loginError: string | null;
|
||||
paneData: PaneData;
|
||||
paneStates: Record<PaneName, PaneState>;
|
||||
consoleHistory: ConsoleEntry[];
|
||||
}
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null, fetched_at: null };
|
||||
|
||||
function createInitialPaneStates(): Record<PaneName, PaneState> {
|
||||
return {
|
||||
@@ -61,6 +70,67 @@ function createInitialPaneData(): PaneData {
|
||||
};
|
||||
}
|
||||
|
||||
const repeaterDashboardCache = new Map<string, RepeaterDashboardCacheEntry>();
|
||||
|
||||
function clonePaneData(data: PaneData): PaneData {
|
||||
return { ...data };
|
||||
}
|
||||
|
||||
function normalizePaneStates(paneStates: Record<PaneName, PaneState>): Record<PaneName, PaneState> {
|
||||
return {
|
||||
status: { ...paneStates.status, loading: false },
|
||||
neighbors: { ...paneStates.neighbors, loading: false },
|
||||
acl: { ...paneStates.acl, loading: false },
|
||||
radioSettings: { ...paneStates.radioSettings, loading: false },
|
||||
advertIntervals: { ...paneStates.advertIntervals, loading: false },
|
||||
ownerInfo: { ...paneStates.ownerInfo, loading: false },
|
||||
lppTelemetry: { ...paneStates.lppTelemetry, loading: false },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneConsoleHistory(consoleHistory: ConsoleEntry[]): ConsoleEntry[] {
|
||||
return consoleHistory.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry | null {
|
||||
if (!publicKey) return null;
|
||||
const cached = repeaterDashboardCache.get(publicKey);
|
||||
if (!cached) return null;
|
||||
|
||||
repeaterDashboardCache.delete(publicKey);
|
||||
repeaterDashboardCache.set(publicKey, cached);
|
||||
|
||||
return {
|
||||
loggedIn: cached.loggedIn,
|
||||
loginError: cached.loginError,
|
||||
paneData: clonePaneData(cached.paneData),
|
||||
paneStates: normalizePaneStates(cached.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
|
||||
};
|
||||
}
|
||||
|
||||
function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
|
||||
repeaterDashboardCache.delete(publicKey);
|
||||
repeaterDashboardCache.set(publicKey, {
|
||||
loggedIn: entry.loggedIn,
|
||||
loginError: entry.loginError,
|
||||
paneData: clonePaneData(entry.paneData),
|
||||
paneStates: normalizePaneStates(entry.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
|
||||
});
|
||||
|
||||
if (repeaterDashboardCache.size > MAX_CACHED_REPEATERS) {
|
||||
const lruKey = repeaterDashboardCache.keys().next().value as string | undefined;
|
||||
if (lruKey) {
|
||||
repeaterDashboardCache.delete(lruKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRepeaterDashboardCacheForTests() {
|
||||
repeaterDashboardCache.clear();
|
||||
}
|
||||
|
||||
// Maps pane name to the API call
|
||||
function fetchPaneData(publicKey: string, pane: PaneName) {
|
||||
switch (pane) {
|
||||
@@ -94,7 +164,8 @@ export interface UseRepeaterDashboardResult {
|
||||
refreshPane: (pane: PaneName) => Promise<void>;
|
||||
loadAll: () => Promise<void>;
|
||||
sendConsoleCommand: (command: string) => Promise<void>;
|
||||
sendAdvert: () => Promise<void>;
|
||||
sendZeroHopAdvert: () => Promise<void>;
|
||||
sendFloodAdvert: () => Promise<void>;
|
||||
rebootRepeater: () => Promise<void>;
|
||||
syncClock: () => Promise<void>;
|
||||
}
|
||||
@@ -102,15 +173,24 @@ export interface UseRepeaterDashboardResult {
|
||||
export function useRepeaterDashboard(
|
||||
activeConversation: Conversation | null
|
||||
): UseRepeaterDashboardResult {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const conversationId =
|
||||
activeConversation && activeConversation.type === 'contact' ? activeConversation.id : null;
|
||||
const cachedState = getCachedState(conversationId);
|
||||
|
||||
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
|
||||
|
||||
const [paneData, setPaneData] = useState<PaneData>(createInitialPaneData);
|
||||
const [paneStates, setPaneStates] =
|
||||
useState<Record<PaneName, PaneState>>(createInitialPaneStates);
|
||||
const [paneData, setPaneData] = useState<PaneData>(
|
||||
cachedState?.paneData ?? createInitialPaneData
|
||||
);
|
||||
const [paneStates, setPaneStates] = useState<Record<PaneName, PaneState>>(
|
||||
cachedState?.paneStates ?? createInitialPaneStates
|
||||
);
|
||||
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
|
||||
cachedState?.consoleHistory ?? []
|
||||
);
|
||||
const [consoleLoading, setConsoleLoading] = useState(false);
|
||||
|
||||
// Track which conversation we're operating on to avoid stale updates after
|
||||
@@ -120,6 +200,10 @@ export function useRepeaterDashboard(
|
||||
|
||||
// Guard against setting state after unmount (retry timers firing late)
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
activeIdRef.current = conversationId;
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
@@ -127,6 +211,17 @@ export function useRepeaterDashboard(
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
cacheState(conversationId, {
|
||||
loggedIn,
|
||||
loginError,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
});
|
||||
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
|
||||
|
||||
const getPublicKey = useCallback((): string | null => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return activeConversation.id;
|
||||
@@ -172,7 +267,12 @@ export function useRepeaterDashboard(
|
||||
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: true, attempt, error: null },
|
||||
[pane]: {
|
||||
loading: true,
|
||||
attempt,
|
||||
error: null,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -182,7 +282,7 @@ export function useRepeaterDashboard(
|
||||
setPaneData((prev) => ({ ...prev, [pane]: data }));
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: null },
|
||||
[pane]: { loading: false, attempt, error: null, fetched_at: Date.now() },
|
||||
}));
|
||||
return; // Success
|
||||
} catch (err) {
|
||||
@@ -193,7 +293,12 @@ export function useRepeaterDashboard(
|
||||
if (attempt === MAX_RETRIES) {
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: msg },
|
||||
[pane]: {
|
||||
loading: false,
|
||||
attempt,
|
||||
error: msg,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
}));
|
||||
toast.error(`Failed to fetch ${pane}`, { description: msg });
|
||||
} else {
|
||||
@@ -266,7 +371,11 @@ export function useRepeaterDashboard(
|
||||
[getPublicKey]
|
||||
);
|
||||
|
||||
const sendAdvert = useCallback(async () => {
|
||||
const sendZeroHopAdvert = useCallback(async () => {
|
||||
await sendConsoleCommand('advert.zerohop');
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
const sendFloodAdvert = useCallback(async () => {
|
||||
await sendConsoleCommand('advert');
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
@@ -275,8 +384,8 @@ export function useRepeaterDashboard(
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
const syncClock = useCallback(async () => {
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
await sendConsoleCommand(`clock ${epoch}`);
|
||||
const epochSeconds = Math.floor(Date.now() / 1000);
|
||||
await sendConsoleCommand(`time ${epochSeconds}`);
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
return {
|
||||
@@ -292,7 +401,8 @@ export function useRepeaterDashboard(
|
||||
refreshPane,
|
||||
loadAll,
|
||||
sendConsoleCommand,
|
||||
sendAdvert,
|
||||
sendZeroHopAdvert,
|
||||
sendFloodAdvert,
|
||||
rebootRepeater,
|
||||
syncClock,
|
||||
};
|
||||
|
||||
@@ -88,10 +88,12 @@ export function useUnreadCounts(
|
||||
// On mount, consume the prefetched promise (started in index.html before
|
||||
// React loaded) or fall back to a fresh fetch.
|
||||
// Re-fetch when channel/contact count changes mid-session (new sync, cracker
|
||||
// channel created, etc.) but skip the initial 0→N load to avoid double calls.
|
||||
// channel created, etc.). Skip only the very first run of this effect; after
|
||||
// that, any count change should trigger a refresh, even if the other
|
||||
// collection is still empty.
|
||||
const channelsLen = channels.length;
|
||||
const contactsLen = contacts.length;
|
||||
const prevLens = useRef({ channels: 0, contacts: 0 });
|
||||
const hasObservedCountsRef = useRef(false);
|
||||
useEffect(() => {
|
||||
takePrefetchOrFetch('unreads', api.getUnreads)
|
||||
.then(applyUnreads)
|
||||
@@ -100,10 +102,10 @@ export function useUnreadCounts(
|
||||
});
|
||||
}, [applyUnreads]);
|
||||
useEffect(() => {
|
||||
const prev = prevLens.current;
|
||||
prevLens.current = { channels: channelsLen, contacts: contactsLen };
|
||||
// Skip the initial load (0→N); only refetch on mid-session count changes
|
||||
if (prev.channels === 0 || prev.contacts === 0) return;
|
||||
if (!hasObservedCountsRef.current) {
|
||||
hasObservedCountsRef.current = true;
|
||||
return;
|
||||
}
|
||||
fetchUnreads();
|
||||
}, [channelsLen, contactsLen, fetchUnreads]);
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
--success-foreground: 0 0% 100%;
|
||||
--info: 217 91% 60%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
--region-override: 270 80% 74%;
|
||||
|
||||
/* Favorites */
|
||||
--favorite: 43 96% 56%;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -36,9 +36,11 @@ const mocks = vi.hoisted(() => ({
|
||||
fetchOlderMessages: vi.fn(async () => {}),
|
||||
addMessageIfNew: vi.fn(),
|
||||
updateMessageAck: vi.fn(),
|
||||
triggerReconcile: vi.fn(),
|
||||
incrementUnread: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
trackNewMessage: vi.fn(),
|
||||
refreshUnreads: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -59,11 +61,17 @@ vi.mock('../hooks', async (importOriginal) => {
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: false,
|
||||
hasNewerMessages: false,
|
||||
loadingNewer: false,
|
||||
hasNewerMessagesRef: { current: false },
|
||||
setMessages: mocks.hookFns.setMessages,
|
||||
fetchMessages: mocks.hookFns.fetchMessages,
|
||||
fetchOlderMessages: mocks.hookFns.fetchOlderMessages,
|
||||
fetchNewerMessages: vi.fn(async () => {}),
|
||||
jumpToBottom: vi.fn(),
|
||||
addMessageIfNew: mocks.hookFns.addMessageIfNew,
|
||||
updateMessageAck: mocks.hookFns.updateMessageAck,
|
||||
triggerReconcile: mocks.hookFns.triggerReconcile,
|
||||
}),
|
||||
useUnreadCounts: () => ({
|
||||
unreadCounts: {},
|
||||
@@ -72,6 +80,7 @@ vi.mock('../hooks', async (importOriginal) => {
|
||||
incrementUnread: mocks.hookFns.incrementUnread,
|
||||
markAllRead: mocks.hookFns.markAllRead,
|
||||
trackNewMessage: mocks.hookFns.trackNewMessage,
|
||||
refreshUnreads: mocks.hookFns.refreshUnreads,
|
||||
}),
|
||||
getMessageContentKey: () => 'content-key',
|
||||
};
|
||||
@@ -165,6 +174,7 @@ vi.mock('../utils/urlHash', () => ({
|
||||
}));
|
||||
|
||||
import { App } from '../App';
|
||||
import { useWebSocket } from '../useWebSocket';
|
||||
|
||||
const baseConfig = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
@@ -264,6 +274,28 @@ describe('App favorite toggle flow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('re-fetches channels after WebSocket reconnect', async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.api.getChannels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const wsHandlers = vi.mocked(useWebSocket).mock.calls[0]?.[0];
|
||||
expect(wsHandlers?.onReconnect).toBeTypeOf('function');
|
||||
|
||||
await act(async () => {
|
||||
wsHandlers?.onReconnect?.();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.api.getChannels).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
expect(mocks.hookFns.triggerReconcile).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.hookFns.refreshUnreads).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('toggles settings page mode and syncs selected section into SettingsModal', async () => {
|
||||
render(<App />);
|
||||
|
||||
|
||||
@@ -14,8 +14,13 @@ const baseProps = {
|
||||
contacts: [],
|
||||
config: null,
|
||||
favorites: [] as Favorite[],
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
onTrace: noop,
|
||||
onToggleNotifications: noop,
|
||||
onToggleFavorite: noop,
|
||||
onSetChannelFloodScopeOverride: noop,
|
||||
onDeleteChannel: noop,
|
||||
onDeleteContact: noop,
|
||||
};
|
||||
@@ -105,4 +110,60 @@ describe('ChatHeader key visibility', () => {
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(key);
|
||||
});
|
||||
|
||||
it('shows active regional override badge for channels', () => {
|
||||
const key = 'AB'.repeat(16);
|
||||
const channel = {
|
||||
...makeChannel(key, '#flightless', true),
|
||||
flood_scope_override: '#Esperance',
|
||||
};
|
||||
const conversation: Conversation = { type: 'channel', id: key, name: '#flightless' };
|
||||
|
||||
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
|
||||
|
||||
expect(screen.getAllByText('#Esperance')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('shows enabled notification state and toggles when clicked', () => {
|
||||
const conversation: Conversation = { type: 'contact', id: '11'.repeat(32), name: 'Alice' };
|
||||
const onToggleNotifications = vi.fn();
|
||||
|
||||
render(
|
||||
<ChatHeader
|
||||
{...baseProps}
|
||||
conversation={conversation}
|
||||
channels={[]}
|
||||
notificationsEnabled
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Notifications On'));
|
||||
|
||||
expect(screen.getByText('Notifications On')).toBeInTheDocument();
|
||||
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prompts for regional override when globe button is clicked', () => {
|
||||
const key = 'CD'.repeat(16);
|
||||
const channel = makeChannel(key, '#flightless', true);
|
||||
const conversation: Conversation = { type: 'channel', id: key, name: '#flightless' };
|
||||
const onSetChannelFloodScopeOverride = vi.fn();
|
||||
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('Esperance');
|
||||
|
||||
render(
|
||||
<ChatHeader
|
||||
{...baseProps}
|
||||
conversation={conversation}
|
||||
channels={[channel]}
|
||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Set regional override'));
|
||||
|
||||
expect(promptSpy).toHaveBeenCalled();
|
||||
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, 'Esperance');
|
||||
promptSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -106,4 +106,25 @@ describe('ContactInfoPane', () => {
|
||||
expect(screen.getByText('Flood')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('shows forced routing override and learned route separately', async () => {
|
||||
const contact = createContact({
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
route_override_path: 'ae92f13e',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
});
|
||||
getContactDetail.mockResolvedValue(createDetail(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
|
||||
await screen.findByText('Alice');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Routing')).toBeInTheDocument();
|
||||
expect(screen.getByText('(forced)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Learned Route')).toBeInTheDocument();
|
||||
expect(screen.getByText('1 hop')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
199
frontend/src/test/conversationPane.test.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConversationPane } from '../components/ConversationPane';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
Message,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
|
||||
vi.mock('../components/ChatHeader', () => ({
|
||||
ChatHeader: () => <div data-testid="chat-header" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageList', () => ({
|
||||
MessageList: () => <div data-testid="message-list" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageInput', () => ({
|
||||
MessageInput: React.forwardRef((_props, ref) => {
|
||||
React.useImperativeHandle(ref, () => ({ appendText: vi.fn() }));
|
||||
return <div data-testid="message-input" />;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../components/RawPacketList', () => ({
|
||||
RawPacketList: () => <div data-testid="raw-packet-list" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/RepeaterDashboard', () => ({
|
||||
RepeaterDashboard: () => <div data-testid="repeater-dashboard" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MapView', () => ({
|
||||
MapView: () => <div data-testid="map-view" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/VisualizerView', () => ({
|
||||
VisualizerView: () => <div data-testid="visualizer-view" />,
|
||||
}));
|
||||
|
||||
const config: RadioConfig = {
|
||||
public_key: 'aa'.repeat(32),
|
||||
name: 'Radio',
|
||||
lat: 1,
|
||||
lon: 2,
|
||||
tx_power: 17,
|
||||
max_tx_power: 22,
|
||||
radio: { freq: 910.525, bw: 62.5, sf: 7, cr: 5 },
|
||||
path_hash_mode: 0,
|
||||
path_hash_mode_supported: true,
|
||||
};
|
||||
|
||||
const health: HealthStatus = {
|
||||
status: 'ok',
|
||||
radio_connected: true,
|
||||
radio_initializing: false,
|
||||
connection_info: 'serial',
|
||||
database_size_mb: 1,
|
||||
oldest_undecrypted_timestamp: null,
|
||||
fanout_statuses: {},
|
||||
bots_disabled: false,
|
||||
};
|
||||
|
||||
const channel: Channel = {
|
||||
key: '8B3387E9C5CDEA6AC9E5EDBAA115CD72',
|
||||
name: 'Public',
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
};
|
||||
|
||||
const message: Message = {
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: channel.key,
|
||||
text: 'hello',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000001,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
sender_key: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
};
|
||||
|
||||
function createProps(overrides: Partial<React.ComponentProps<typeof ConversationPane>> = {}) {
|
||||
return {
|
||||
activeConversation: null as Conversation | null,
|
||||
contacts: [] as Contact[],
|
||||
channels: [channel],
|
||||
rawPackets: [],
|
||||
config,
|
||||
health,
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
favorites: [] as Favorite[],
|
||||
messages: [message],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: false,
|
||||
targetMessageId: null,
|
||||
hasNewerMessages: false,
|
||||
loadingNewer: false,
|
||||
messageInputRef: { current: null },
|
||||
onTrace: vi.fn(async () => {}),
|
||||
onToggleFavorite: vi.fn(async () => {}),
|
||||
onDeleteContact: vi.fn(async () => {}),
|
||||
onDeleteChannel: vi.fn(async () => {}),
|
||||
onSetChannelFloodScopeOverride: vi.fn(async () => {}),
|
||||
onOpenContactInfo: vi.fn(),
|
||||
onOpenChannelInfo: vi.fn(),
|
||||
onSenderClick: vi.fn(),
|
||||
onLoadOlder: vi.fn(async () => {}),
|
||||
onResendChannelMessage: vi.fn(async () => {}),
|
||||
onTargetReached: vi.fn(),
|
||||
onLoadNewer: vi.fn(async () => {}),
|
||||
onJumpToBottom: vi.fn(),
|
||||
onSendMessage: vi.fn(async () => {}),
|
||||
onToggleNotifications: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ConversationPane', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders the empty state when no conversation is active', () => {
|
||||
render(<ConversationPane {...createProps()} />);
|
||||
|
||||
expect(screen.getByText('Select a conversation or start a new one')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders repeater dashboard instead of chat chrome for repeater contacts', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'contact',
|
||||
id: 'bb'.repeat(32),
|
||||
name: 'Repeater',
|
||||
},
|
||||
contacts: [
|
||||
{
|
||||
public_key: 'bb'.repeat(32),
|
||||
name: 'Repeater',
|
||||
type: 2,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: 0,
|
||||
out_path_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(await screen.findByTestId('repeater-dashboard')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('message-list')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders chat chrome for normal channel conversations', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chat-header')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-list')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-input')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
64
frontend/src/test/messageList.test.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MessageList } from '../components/MessageList';
|
||||
import type { Message } from '../types';
|
||||
|
||||
function createMessage(overrides: Partial<Message> = {}): Message {
|
||||
return {
|
||||
id: 1,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'C3B889530D4F02DB5662EA13C417F530',
|
||||
text: 'Alice: hello world',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000001,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
sender_key: null,
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('MessageList channel sender rendering', () => {
|
||||
it('renders explicit corrupt placeholder and warning avatar for unnamed corrupt channel packets', () => {
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
text: "Nv\x0ek\x16ɩ'\x7fg:",
|
||||
sender_name: null,
|
||||
sender_key: null,
|
||||
}),
|
||||
]}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('<No name -- corrupt packet?>')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('corrupt-avatar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('prefers stored sender_name for channel messages even when text is not sender-prefixed', () => {
|
||||
render(
|
||||
<MessageList
|
||||
messages={[
|
||||
createMessage({
|
||||
text: 'garbled payload with no sender prefix',
|
||||
sender_name: 'Alice',
|
||||
sender_key: 'ab'.repeat(32),
|
||||
}),
|
||||
]}
|
||||
contacts={[]}
|
||||
loading={false}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,9 @@ import {
|
||||
extractPacketPayloadHex,
|
||||
findContactsByPrefix,
|
||||
calculateDistance,
|
||||
formatRouteLabel,
|
||||
formatRoutingOverrideInput,
|
||||
getEffectiveContactRoute,
|
||||
resolvePath,
|
||||
formatDistance,
|
||||
formatHopCounts,
|
||||
@@ -131,6 +134,42 @@ describe('extractPacketPayloadHex', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('contact routing helpers', () => {
|
||||
it('prefers routing override over learned route', () => {
|
||||
const effective = getEffectiveContactRoute(
|
||||
createContact({
|
||||
last_path: 'AABB',
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
route_override_path: 'AE92F13E',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
})
|
||||
);
|
||||
|
||||
expect(effective.path).toBe('AE92F13E');
|
||||
expect(effective.pathLen).toBe(2);
|
||||
expect(effective.pathHashMode).toBe(1);
|
||||
expect(effective.forced).toBe(true);
|
||||
});
|
||||
|
||||
it('formats route labels and override input', () => {
|
||||
expect(formatRouteLabel(-1)).toBe('flood');
|
||||
expect(formatRouteLabel(0)).toBe('direct');
|
||||
expect(formatRouteLabel(2, true)).toBe('2 hops');
|
||||
|
||||
expect(
|
||||
formatRoutingOverrideInput(
|
||||
createContact({
|
||||
route_override_path: 'AE92F13E',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
})
|
||||
)
|
||||
).toBe('ae92,f13e');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findContactsByPrefix', () => {
|
||||
const contacts: Contact[] = [
|
||||
createContact({
|
||||
|
||||
27
frontend/src/test/rawPacketList.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { RawPacketList } from '../components/RawPacketList';
|
||||
import type { RawPacket } from '../types';
|
||||
|
||||
function createPacket(overrides: Partial<RawPacket> = {}): RawPacket {
|
||||
return {
|
||||
id: 1,
|
||||
timestamp: 1700000000,
|
||||
data: '000000000000',
|
||||
payload_type: 'REQ',
|
||||
snr: null,
|
||||
rssi: null,
|
||||
decrypted: false,
|
||||
decrypted_info: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('RawPacketList', () => {
|
||||
it('renders TF badge for transport-flood packets', () => {
|
||||
render(<RawPacketList packets={[createPacket()]} />);
|
||||
|
||||
expect(screen.getByText('TF')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -38,7 +38,8 @@ const mockHook: {
|
||||
refreshPane: vi.fn(),
|
||||
loadAll: vi.fn(),
|
||||
sendConsoleCommand: vi.fn(),
|
||||
sendAdvert: vi.fn(),
|
||||
sendZeroHopAdvert: vi.fn(),
|
||||
sendFloodAdvert: vi.fn(),
|
||||
rebootRepeater: vi.fn(),
|
||||
syncClock: vi.fn(),
|
||||
};
|
||||
@@ -98,10 +99,14 @@ const defaultProps = {
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
radioLat: null,
|
||||
radioLon: null,
|
||||
radioName: null,
|
||||
onTrace: vi.fn(),
|
||||
onToggleNotifications: vi.fn(),
|
||||
onToggleFavorite: vi.fn(),
|
||||
onDeleteContact: vi.fn(),
|
||||
};
|
||||
@@ -189,6 +194,21 @@ describe('RepeaterDashboard', () => {
|
||||
expect(mockHook.loadAll).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows enabled notification state and toggles when clicked', () => {
|
||||
render(
|
||||
<RepeaterDashboard
|
||||
{...defaultProps}
|
||||
notificationsEnabled
|
||||
onToggleNotifications={defaultProps.onToggleNotifications}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Notifications On'));
|
||||
|
||||
expect(screen.getByText('Notifications On')).toBeInTheDocument();
|
||||
expect(defaultProps.onToggleNotifications).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows login error when present', () => {
|
||||
mockHook.loginError = 'Invalid password';
|
||||
|
||||
@@ -244,12 +264,27 @@ describe('RepeaterDashboard', () => {
|
||||
expect(screen.getByText('7.5 dB')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows fetched time and relative age when pane data has been loaded', () => {
|
||||
mockHook.loggedIn = true;
|
||||
mockHook.paneStates.status = {
|
||||
loading: false,
|
||||
attempt: 1,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
};
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText(/Fetched .*Just now/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders action buttons', () => {
|
||||
mockHook.loggedIn = true;
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Send Advert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Zero Hop Advert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Flood Advert')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sync Clock')).toBeInTheDocument();
|
||||
expect(screen.getByText('Reboot')).toBeInTheDocument();
|
||||
});
|
||||
@@ -307,67 +342,82 @@ describe('RepeaterDashboard', () => {
|
||||
expect(screen.getByText('1 hop')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('direct path is clickable with reset title', () => {
|
||||
it('direct path is clickable with routing override title', () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
|
||||
const directEl = screen.getByTitle('Click to reset path to flood');
|
||||
const directEl = screen.getByTitle('Click to edit routing override');
|
||||
expect(directEl).toBeInTheDocument();
|
||||
expect(directEl.textContent).toBe('direct');
|
||||
});
|
||||
|
||||
it('clicking direct path calls resetContactPath on confirm', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
it('shows forced decorator when a routing override is active', () => {
|
||||
const forcedContacts: Contact[] = [
|
||||
{
|
||||
...contacts[0],
|
||||
last_path_len: 1,
|
||||
last_seen: 1700000000,
|
||||
route_override_path: 'ae92f13e',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
},
|
||||
];
|
||||
|
||||
// Mock window.confirm to return true
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={forcedContacts} />);
|
||||
|
||||
// Mock the api module
|
||||
const { api } = await import('../api');
|
||||
const resetSpy = vi.spyOn(api, 'resetContactPath').mockResolvedValue({
|
||||
status: 'ok',
|
||||
public_key: REPEATER_KEY,
|
||||
});
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Click to reset path to flood'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Reset path to flood?');
|
||||
expect(resetSpy).toHaveBeenCalledWith(REPEATER_KEY);
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
resetSpy.mockRestore();
|
||||
expect(screen.getByText('2 hops')).toBeInTheDocument();
|
||||
expect(screen.getByText('(forced)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking path does not call API when confirm is cancelled', async () => {
|
||||
it('clicking direct path opens prompt and updates routing override', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
// Mock window.confirm to return false
|
||||
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue('0');
|
||||
|
||||
const { api } = await import('../api');
|
||||
const resetSpy = vi.spyOn(api, 'resetContactPath').mockResolvedValue({
|
||||
const overrideSpy = vi.spyOn(api, 'setContactRoutingOverride').mockResolvedValue({
|
||||
status: 'ok',
|
||||
public_key: REPEATER_KEY,
|
||||
});
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Click to reset path to flood'));
|
||||
fireEvent.click(screen.getByTitle('Click to edit routing override'));
|
||||
|
||||
expect(confirmSpy).toHaveBeenCalledWith('Reset path to flood?');
|
||||
expect(resetSpy).not.toHaveBeenCalled();
|
||||
expect(promptSpy).toHaveBeenCalled();
|
||||
expect(overrideSpy).toHaveBeenCalledWith(REPEATER_KEY, '0');
|
||||
|
||||
confirmSpy.mockRestore();
|
||||
resetSpy.mockRestore();
|
||||
promptSpy.mockRestore();
|
||||
overrideSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('clicking path does not call API when prompt is cancelled', async () => {
|
||||
const directContacts: Contact[] = [
|
||||
{ ...contacts[0], last_path_len: 0, last_seen: 1700000000 },
|
||||
];
|
||||
|
||||
const promptSpy = vi.spyOn(window, 'prompt').mockReturnValue(null);
|
||||
|
||||
const { api } = await import('../api');
|
||||
const overrideSpy = vi.spyOn(api, 'setContactRoutingOverride').mockResolvedValue({
|
||||
status: 'ok',
|
||||
public_key: REPEATER_KEY,
|
||||
});
|
||||
|
||||
render(<RepeaterDashboard {...defaultProps} contacts={directContacts} />);
|
||||
|
||||
fireEvent.click(screen.getByTitle('Click to edit routing override'));
|
||||
|
||||
expect(promptSpy).toHaveBeenCalled();
|
||||
expect(overrideSpy).not.toHaveBeenCalled();
|
||||
|
||||
promptSpy.mockRestore();
|
||||
overrideSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -179,15 +179,11 @@ describe('SettingsModal', () => {
|
||||
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows favorite-first contact sync helper text in radio tab', async () => {
|
||||
it('shows favorite-contact radio sync helper text in radio tab', async () => {
|
||||
renderModal();
|
||||
openRadioSection();
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Favorite contacts load first, then recent non-repeater contacts until this\s+limit is reached/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('saves changed max contacts value through onSaveAppSettings', async () => {
|
||||
|
||||
@@ -37,9 +37,11 @@ function makeContact(public_key: string, name: string, type = 1): Contact {
|
||||
|
||||
function renderSidebar(overrides?: {
|
||||
unreadCounts?: Record<string, number>;
|
||||
mentions?: Record<string, boolean>;
|
||||
favorites?: Favorite[];
|
||||
lastMessageTimes?: ConversationTimes;
|
||||
channels?: Channel[];
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
}) {
|
||||
const aliceName = 'Alice';
|
||||
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
||||
@@ -67,7 +69,7 @@ function renderSidebar(overrides?: {
|
||||
onNewMessage={vi.fn()}
|
||||
lastMessageTimes={overrides?.lastMessageTimes ?? {}}
|
||||
unreadCounts={unreadCounts}
|
||||
mentions={{}}
|
||||
mentions={overrides?.mentions ?? {}}
|
||||
showCracker={false}
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
@@ -75,6 +77,7 @@ function renderSidebar(overrides?: {
|
||||
favorites={favorites}
|
||||
sortOrder="recent"
|
||||
onSortOrderChange={vi.fn()}
|
||||
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -102,6 +105,40 @@ describe('Sidebar section summaries', () => {
|
||||
expect(within(getSectionHeaderContainer('Repeaters')).getByText('4')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('turns favorites and channels rollups red when they contain a mention', () => {
|
||||
renderSidebar({
|
||||
mentions: {
|
||||
[getStateKey('channel', 'BB'.repeat(16))]: true,
|
||||
[getStateKey('channel', 'CC'.repeat(16))]: true,
|
||||
},
|
||||
});
|
||||
|
||||
expect(within(getSectionHeaderContainer('Favorites')).getByText('2')).toHaveClass(
|
||||
'bg-badge-mention',
|
||||
'text-badge-mention-foreground'
|
||||
);
|
||||
expect(within(getSectionHeaderContainer('Channels')).getByText('1')).toHaveClass(
|
||||
'bg-badge-mention',
|
||||
'text-badge-mention-foreground'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps contact row badges normal while the contacts rollup is always red', () => {
|
||||
const { aliceName } = renderSidebar();
|
||||
|
||||
expect(within(getSectionHeaderContainer('Contacts')).getByText('3')).toHaveClass(
|
||||
'bg-badge-mention',
|
||||
'text-badge-mention-foreground'
|
||||
);
|
||||
|
||||
const aliceRow = screen.getByText(aliceName).closest('div');
|
||||
if (!aliceRow) throw new Error('Missing Alice row');
|
||||
expect(within(aliceRow).getByText('3')).toHaveClass(
|
||||
'bg-badge-unread/90',
|
||||
'text-badge-unread-foreground'
|
||||
);
|
||||
});
|
||||
|
||||
it('expands collapsed sections during search and restores collapse state after clearing search', async () => {
|
||||
const { opsChannel, aliceName } = renderSidebar();
|
||||
|
||||
@@ -183,4 +220,37 @@ describe('Sidebar section summaries', () => {
|
||||
const selectedIds = onSelectConversation.mock.calls.map(([conv]) => conv.id);
|
||||
expect(new Set(selectedIds)).toEqual(new Set([channelA.key, channelB.key]));
|
||||
});
|
||||
|
||||
it('shows a notification bell for conversations with notifications enabled', () => {
|
||||
const { aliceName } = renderSidebar({
|
||||
unreadCounts: {},
|
||||
isConversationNotificationsEnabled: (type, id) =>
|
||||
(type === 'contact' && id === '11'.repeat(32)) ||
|
||||
(type === 'channel' && id === 'BB'.repeat(16)),
|
||||
});
|
||||
|
||||
const aliceRow = screen.getByText(aliceName).closest('div');
|
||||
const flightRow = screen.getByText('#flight').closest('div');
|
||||
if (!aliceRow || !flightRow) throw new Error('Missing sidebar rows');
|
||||
|
||||
expect(within(aliceRow).getByLabelText('Notifications enabled')).toBeInTheDocument();
|
||||
expect(within(flightRow).getByLabelText('Notifications enabled')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps the notification bell to the left of the unread pill when both are present', () => {
|
||||
const { aliceName } = renderSidebar({
|
||||
unreadCounts: {
|
||||
[getStateKey('contact', '11'.repeat(32))]: 3,
|
||||
},
|
||||
isConversationNotificationsEnabled: (type, id) =>
|
||||
type === 'contact' && id === '11'.repeat(32),
|
||||
});
|
||||
|
||||
const aliceRow = screen.getByText(aliceName).closest('div');
|
||||
if (!aliceRow) throw new Error('Missing Alice row');
|
||||
|
||||
const bell = within(aliceRow).getByLabelText('Notifications enabled');
|
||||
const unread = within(aliceRow).getByText('3');
|
||||
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||