mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-03-28 17:43:05 +01:00
Compare commits
87 Commits
notificati
...
3.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac65943263 | ||
|
|
04b324b711 | ||
|
|
5512f9e677 | ||
|
|
b4962d39f0 | ||
|
|
39a687da58 | ||
|
|
f41c7756d3 | ||
|
|
bafea6a172 | ||
|
|
68f05075ca | ||
|
|
adfb8c930c | ||
|
|
1299a301c1 | ||
|
|
3a4ea8022b | ||
|
|
bd19015693 | ||
|
|
cb9c9ae289 | ||
|
|
2369e69e0a | ||
|
|
9c2b6f0744 | ||
|
|
70d28e53a9 | ||
|
|
96d8d1dc64 | ||
|
|
a7ff041a48 | ||
|
|
5a580b9c01 | ||
|
|
0834414ba4 | ||
|
|
df538b3aaf | ||
|
|
2710cafb21 | ||
|
|
338f632514 | ||
|
|
7e1f941760 | ||
|
|
87ea2b4675 | ||
|
|
5c85a432c8 | ||
|
|
22ca5410ee | ||
|
|
276e0e09b3 | ||
|
|
1c57e35ba5 | ||
|
|
358589bd66 | ||
|
|
74c13d194c | ||
|
|
07fd88a4d6 | ||
|
|
07934093e6 | ||
|
|
3ee4f9d7a2 | ||
|
|
b81f6ef89e | ||
|
|
489950a2f7 | ||
|
|
08e00373aa | ||
|
|
2f0d35748a | ||
|
|
7db2974481 | ||
|
|
0a20929df6 | ||
|
|
30f6f95d8e | ||
|
|
9e8cf56b31 | ||
|
|
fb535298be | ||
|
|
1f2903fc2d | ||
|
|
6466a5c355 | ||
|
|
f8e88b3737 | ||
|
|
a13e241636 | ||
|
|
8c1a58b293 | ||
|
|
bf53e8a4cb | ||
|
|
c6cd209192 | ||
|
|
e5c7ebb388 | ||
|
|
e37632de3f | ||
|
|
d36c5e3e32 | ||
|
|
bc7506b0d9 | ||
|
|
38c7277c9d | ||
|
|
20d0bd92bb | ||
|
|
e0df30b5f0 | ||
|
|
83635845b6 | ||
|
|
2e705538fd | ||
|
|
4363fd2a73 | ||
|
|
5bd3205de5 | ||
|
|
bcde3bd9d5 | ||
|
|
15a8c637e4 | ||
|
|
d38efc0421 | ||
|
|
b311c406da | ||
|
|
b5e9e4d04c | ||
|
|
ce87dd9376 | ||
|
|
5273d9139d | ||
|
|
04ac3d6ed4 | ||
|
|
1a1d3059db | ||
|
|
633510b7de | ||
|
|
7f4c1e94fd | ||
|
|
a06fefb34e | ||
|
|
4e0b6a49b0 | ||
|
|
e993009782 | ||
|
|
ad7028e508 | ||
|
|
ce9bbd1059 | ||
|
|
0c35601af3 | ||
|
|
93369f8d64 | ||
|
|
e7d1f28076 | ||
|
|
472b4a5ed2 | ||
|
|
314e4c7fff | ||
|
|
528a94d2bd | ||
|
|
fa1c086f5f | ||
|
|
d8bb747152 | ||
|
|
18a465fde8 | ||
|
|
c52e00d2b7 |
74
.github/workflows/all-quality.yml
vendored
Normal file
74
.github/workflows/all-quality.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: All Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
backend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: uv sync --dev
|
||||
|
||||
- name: Backend lint
|
||||
run: uv run ruff check app/ tests/
|
||||
|
||||
- name: Backend format check
|
||||
run: uv run ruff format --check app/ tests/
|
||||
|
||||
- name: Backend typecheck
|
||||
run: uv run pyright app/
|
||||
|
||||
- name: Backend tests
|
||||
run: PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
frontend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend format check
|
||||
run: npm run format:check
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend tests
|
||||
run: npm run test:run
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend build
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -8,7 +8,6 @@ wheels/
|
||||
# Virtual environments
|
||||
.venv
|
||||
frontend/node_modules/
|
||||
frontend/package-lock.json
|
||||
frontend/test-results/
|
||||
|
||||
# Frontend build output (built from source by end users)
|
||||
|
||||
46
AGENTS.md
46
AGENTS.md
@@ -21,8 +21,8 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
|
||||
- `frontend/AGENTS.md` - Frontend (React, state management, WebSocket, components)
|
||||
|
||||
Ancillary AGENTS.md files which should generally not be reviewed unless specific work is being performed on those features include:
|
||||
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise)
|
||||
- `frontend/src/components/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
|
||||
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise, SQS)
|
||||
- `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
@@ -77,7 +77,7 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific
|
||||
- Raw packet feed — a debug/observation tool ("radio aquarium"); interesting to watch or copy packets from, but not critical infrastructure
|
||||
- Map view — visual display of node locations from advertisements
|
||||
- Network visualizer — force-directed graph of mesh topology
|
||||
- Fanout integrations (MQTT, bots, webhooks, Apprise) — see `app/fanout/AGENTS_fanout.md`
|
||||
- Fanout integrations (MQTT, bots, webhooks, Apprise, SQS) — see `app/fanout/AGENTS_fanout.md`
|
||||
- Read state tracking / mark-all-read — convenience feature for unread badges; no need for transactional atomicity or race-condition hardening
|
||||
|
||||
## Error Handling Philosophy
|
||||
@@ -109,7 +109,7 @@ Radio startup/setup is one place where that frontend bubbling is intentional: if
|
||||
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.
|
||||
|
||||
1. **No CORS restrictions**: The backend allows all origins (`allow_origins=["*"]`). This lets users access their radio from any device/origin on their network without configuration hassle.
|
||||
2. **No authentication or authorization**: There is no login, no API keys, no session management. The app is designed for trusted networks (home LAN, VPN). The README warns users not to expose it to untrusted networks.
|
||||
2. **Minimal optional access control only**: The app has no user accounts, sessions, authorization model, or per-feature permissions. Operators may optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but this is only a coarse gate and still requires HTTPS plus a trusted network posture.
|
||||
3. **Arbitrary bot code execution**: The bot system (`app/fanout/bot_exec.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this. Operators can set `MESHCORE_DISABLE_BOTS=true` to completely disable the bot system at startup — this skips all bot execution, returns 403 on bot settings updates, and shows a disabled message in the frontend.
|
||||
|
||||
## Intentional Packet Handling Decision
|
||||
@@ -129,7 +129,7 @@ To improve repeater disambiguation in the network visualizer, the backend stores
|
||||
- This is independent of raw-packet payload deduplication.
|
||||
- Paths are keyed per contact + path + hop count, with `heard_count`, `first_seen`, and `last_seen`.
|
||||
- Only the N most recent unique paths are retained per contact (currently 10).
|
||||
- See `frontend/src/components/AGENTS_packet_visualizer.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
|
||||
- See `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` § "Advert-Path Identity Hints" for how the visualizer consumes this data.
|
||||
|
||||
## Path Hash Modes
|
||||
|
||||
@@ -181,7 +181,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
|
||||
│ ├── event_handlers.py # Radio events
|
||||
│ ├── decoder.py # Packet decryption
|
||||
│ ├── websocket.py # Real-time broadcasts
|
||||
│ └── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
|
||||
│ └── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
|
||||
├── frontend/ # React frontend
|
||||
│ ├── AGENTS.md # Frontend documentation
|
||||
│ ├── src/
|
||||
@@ -290,22 +290,20 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
|
||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode` and `path_hash_mode_supported` |
|
||||
| PATCH | `/api/radio/config` | Update name, location, radio params, and `path_hash_mode` when supported |
|
||||
| GET | `/api/debug` | Support snapshot: recent logs, live radio probe, contact/channel drift audit, and running version/git info |
|
||||
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
|
||||
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
|
||||
| PUT | `/api/radio/private-key` | Import private key to radio |
|
||||
| POST | `/api/radio/advertise` | Send advertisement |
|
||||
| POST | `/api/radio/discover` | Run a short mesh discovery sweep for nearby repeaters/sensors |
|
||||
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
|
||||
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
|
||||
| POST | `/api/radio/reconnect` | Manual radio reconnection |
|
||||
| GET | `/api/contacts` | List contacts |
|
||||
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
|
||||
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
|
||||
| GET | `/api/contacts/{public_key}` | Get contact by public key or prefix |
|
||||
| GET | `/api/contacts/{public_key}/detail` | Comprehensive contact profile (stats, name history, paths) |
|
||||
| GET | `/api/contacts/{public_key}/advert-paths` | List recent unique advert paths for a contact |
|
||||
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
|
||||
| DELETE | `/api/contacts/{public_key}` | Delete contact |
|
||||
| POST | `/api/contacts/sync` | Pull from radio |
|
||||
| POST | `/api/contacts/{public_key}/add-to-radio` | Push contact to radio |
|
||||
| POST | `/api/contacts/{public_key}/remove-from-radio` | Remove contact from radio |
|
||||
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read |
|
||||
| POST | `/api/contacts/{public_key}/command` | Send CLI command to repeater |
|
||||
| POST | `/api/contacts/{public_key}/routing-override` | Set or clear a forced routing override |
|
||||
@@ -315,16 +313,15 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
|
||||
| POST | `/api/contacts/{public_key}/repeater/lpp-telemetry` | Fetch CayenneLPP sensor data |
|
||||
| POST | `/api/contacts/{public_key}/repeater/neighbors` | Fetch repeater neighbors |
|
||||
| POST | `/api/contacts/{public_key}/repeater/acl` | Fetch repeater ACL |
|
||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch radio settings via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/node-info` | Fetch repeater name, location, and clock via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/radio-settings` | Fetch repeater radio config via CLI |
|
||||
| POST | `/api/contacts/{public_key}/repeater/advert-intervals` | Fetch advert intervals |
|
||||
| POST | `/api/contacts/{public_key}/repeater/owner-info` | Fetch owner info |
|
||||
|
||||
| GET | `/api/channels` | List channels |
|
||||
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
|
||||
| GET | `/api/channels/{key}` | Get channel by key |
|
||||
| POST | `/api/channels` | Create channel |
|
||||
| DELETE | `/api/channels/{key}` | Delete channel |
|
||||
| POST | `/api/channels/sync` | Pull from radio |
|
||||
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
|
||||
| POST | `/api/channels/{key}/mark-read` | Mark channel as read |
|
||||
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
|
||||
@@ -393,7 +390,7 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
|
||||
|
||||
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
|
||||
|
||||
### Fanout Bus (MQTT, Bots, Webhooks, Apprise)
|
||||
### Fanout Bus (MQTT, Bots, Webhooks, Apprise, SQS)
|
||||
|
||||
All external integrations are managed through the fanout bus (`app/fanout/`). Each integration is a `FanoutModule` with scope-based event filtering, stored in the `fanout_configs` table and managed via `GET/POST/PATCH/DELETE /api/fanout`.
|
||||
|
||||
@@ -442,9 +439,12 @@ mc.subscribe(EventType.ACK, handler)
|
||||
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
|
||||
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
|
||||
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on message audit task from hourly checks to aggressive 10-second `get_msg()` fallback polling |
|
||||
| `MESHCORE_BASIC_AUTH_USERNAME` | *(none)* | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
|
||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | `false` | Disable channel-slot reuse and force `set_channel(...)` before every channel send, even on serial/BLE |
|
||||
|
||||
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, 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, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
|
||||
|
||||
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
|
||||
|
||||
@@ -457,3 +457,9 @@ Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/
|
||||
The vendored MeshCore Python reader's `LOG_DATA` advert path assumes the decoded advert payload always contains at least 101 bytes of advert body and reads the flags byte with `pk_buf.read(1)[0]` without a length guard. If a malformed or truncated RF log frame slips through, `MessageReader.handle_rx()` can fail with `IndexError: index out of range` from `meshcore/reader.py` while parsing payload type `0x04` (advert).
|
||||
|
||||
This does not indicate database corruption or a message-store bug. It is a parser-hardening gap in `meshcore_py`: the reader does not fully mirror firmware-side packet/path validation before attempting advert decode. The practical effect is usually a one-off asyncio task failure for that packet while later packets continue processing normally.
|
||||
|
||||
### Channel-message dedup intentionally treats same-name/same-text/same-second channel sends as indistinguishable because they are
|
||||
|
||||
Channel message storage deduplicates on `(type, conversation_key, text, sender_timestamp)`. Reviewers often flag this as "missing sender identity," but for channel messages the stored `text` already includes the displayed sender label (for example `Alice: hello`). That means two different users only collide when they produce the same rendered sender name, the same body text, and the same sender timestamp.
|
||||
|
||||
In that case, RemoteTerm usually does not have enough information to distinguish "two independent same-name sends" from "one message observed again as an echo/repeat." Without a reliable sender identity at ingest, treating those packets as the same message is an accepted limitation of the observable data model, not an obvious correctness bug.
|
||||
|
||||
79
CHANGELOG.md
79
CHANGELOG.md
@@ -1,3 +1,82 @@
|
||||
## [3.3.0] - 2026-03-13
|
||||
|
||||
Feature: Use dashed lines to show collapsed ambiguous router results
|
||||
Feature: Jump to unred
|
||||
Feature: Local channel management to prevent need to reload channel every time
|
||||
Feature: Debug endpoint
|
||||
Feature: Force-singleton channel management
|
||||
Feature: Local node discovery
|
||||
Feature: Node routing discovery
|
||||
Bugfix: Don't tell users to us npm ci
|
||||
Bugfix: Fallback polling dm message persistence
|
||||
Bugfix: All native-JS inputs are now modals
|
||||
Bugfix: Same-second send collision resolution
|
||||
Bugfix: Proper browser updates on resend
|
||||
Bugfix: Don't use last-heard when we actually want last-advert for path discovery for nodes
|
||||
Bugfix: Don't treat prefix-matching DM echoes as acks like we do for channel messages
|
||||
Misc: Visualizer data layer overhaul for future map work
|
||||
Misc: Parallelize docker tests
|
||||
|
||||
## [3.2.0] - 2026-03-12
|
||||
|
||||
Feature: Improve ambiguous-sender DM handling and visibility
|
||||
Feature: Allow for toggling of node GPS broadcast
|
||||
Feature: Add path width to bot and move example to full kwargs
|
||||
Feature: Improve node map color contrast
|
||||
Bugfix: More accurate tracking of contact data
|
||||
Bugfix: Misc. frontend performance and bugfixes
|
||||
Misc: Clearer warnings on user-key linkage
|
||||
Misc: Documentation improvements
|
||||
|
||||
## [3.1.1] - 2026-03-11
|
||||
|
||||
Feature: Add basic auth
|
||||
Feature: SQS fanout
|
||||
Feature: Enrich contact info pane
|
||||
Feature: Search operators for node and channel
|
||||
Feature: Pause radio connection attempts from Radio settings
|
||||
Feature: New themes! What a great use of time!
|
||||
Feature: Github workflows runs for validation
|
||||
Bugfix: More consistent log format with times
|
||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
|
||||
## [3.1.0] - 2026-03-11
|
||||
|
||||
Feature: Add basic auth
|
||||
Feature: SQS fanout
|
||||
Feature: Enrich contact info pane
|
||||
Feature: Search operators for node and channel
|
||||
Feature: Pause radio connection attempts from Radio settings
|
||||
Feature: New themes! What a great use of time!
|
||||
Feature: Github workflows runs for validation
|
||||
Bugfix: More consistent log format with times
|
||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
|
||||
## [3.0.0] - 2026-03-10
|
||||
|
||||
Feature: Custom regions per-channel
|
||||
Feature: Add custom contact pathing
|
||||
Feature: Corrupt packets are more clear that they're corrupt
|
||||
Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
|
||||
Feature: More consistent icons
|
||||
Feature: Add per-channel local notifications
|
||||
Feature: New themes
|
||||
Feature: Massive codebase refactor and overhaul
|
||||
Bugfix: Fix packet parsing for trace packets
|
||||
Bugfix: Refetch channels on reconnect
|
||||
Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
|
||||
Bugfix: Timestamps in logs
|
||||
Bugfix: Correct wrong clock sync command
|
||||
Misc: Improve bot error bubble up
|
||||
Misc: Update to non-lib-included meshcore-decoder version
|
||||
Misc: Revise refactors to be more LLM friendly
|
||||
Misc: Fix script executability
|
||||
Misc: Better logging format with timestamp
|
||||
Misc: Repeater advert buttons separate flood and one-hop
|
||||
Misc: Preserve repeater pane on navigation away
|
||||
Misc: Clearer iconography and coloring for status bar buttons
|
||||
Misc: Search bar to top bar
|
||||
|
||||
## [2.7.9] - 2026-03-08
|
||||
|
||||
Bugfix: Don't obscure new integration dropdown on session boundary
|
||||
|
||||
@@ -5,8 +5,8 @@ ARG COMMIT_HASH=unknown
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY frontend/package.json frontend/.npmrc ./
|
||||
RUN npm install
|
||||
COPY frontend/package.json frontend/package-lock.json frontend/.npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
|
||||
|
||||
186
LICENSES.md
186
LICENSES.md
@@ -91,6 +91,192 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
</details>
|
||||
|
||||
### boto3 (1.42.66) — Apache-2.0
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### fastapi (0.128.0) — MIT
|
||||
|
||||
<details>
|
||||
|
||||
27
README.md
27
README.md
@@ -8,11 +8,11 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Monitor unlimited contacts and channels (radio limits don't apply -- packets are decrypted server-side)
|
||||
* Access your radio remotely over your network or VPN
|
||||
* Search for hashtag room names for channels you don't have keys for yet
|
||||
* Forward packets to MQTT brokers (private: decrypted messages and/or raw packets; community aggregators like LetsMesh.net: raw packets only)
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing in all traffic and display systems within the app
|
||||
* Forward packets to MQTT, LetsMesh, MeshRank, SQS, Apprise, etc.
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||
|
||||
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
|
||||
**Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
|
||||
|
||||

|
||||
|
||||
@@ -224,11 +224,28 @@ npm run build # build the frontend
|
||||
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
|
||||
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
|
||||
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config) |
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly audit task |
|
||||
| `MESHCORE_BASIC_AUTH_USERNAME` | | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
|
||||
| `MESHCORE_BASIC_AUTH_PASSWORD` | | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
|
||||
|
||||
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.
|
||||
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
|
||||
|
||||
### Remediation Environment Variables
|
||||
|
||||
These are intended for diagnosing or working around radios that behave oddly.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly sanity check |
|
||||
| `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE` | false | Disable channel-slot reuse and force `set_channel(...)` before every channel send |
|
||||
|
||||
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. That audit checks both:
|
||||
|
||||
- whether messages were left on the radio without reaching the app through event subscription
|
||||
- whether the app's channel-slot expectations still match the radio's actual channel listing
|
||||
|
||||
If the audit finds a mismatch, you'll see an error in the application UI and your logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second safety net. If room sends appear to be using the wrong channel slot or another client is changing slots underneath this app, try `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` to force the radio to validate the channel slot is valid before sending (will delay sending by ~500ms).
|
||||
|
||||
## Additional Setup
|
||||
|
||||
|
||||
@@ -44,13 +44,16 @@ app/
|
||||
├── 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)
|
||||
├── security.py # Optional app-wide HTTP Basic auth middleware for HTTP + WS
|
||||
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
|
||||
├── dependencies.py # Shared FastAPI dependency providers
|
||||
├── path_utils.py # Path hex rendering and hop-width helpers
|
||||
├── region_scope.py # Normalize/validate regional flood-scope values
|
||||
├── keystore.py # Ephemeral private/public key storage for DM decryption
|
||||
├── frontend_static.py # Mount/serve built frontend (production)
|
||||
└── routers/
|
||||
├── health.py
|
||||
├── debug.py
|
||||
├── radio.py
|
||||
├── contacts.py
|
||||
├── channels.py
|
||||
@@ -87,7 +90,7 @@ app/
|
||||
- `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`.
|
||||
- Routers, startup/lifespan code, fanout helpers, and `radio_sync.py` should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly.
|
||||
- Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state.
|
||||
- Setup still includes handler registration, key export, time sync, contact/channel sync, and advertisement tasks. The message-poll task always starts: by default it runs as a low-frequency hourly audit, and `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` switches it to aggressive 10-second polling.
|
||||
- Setup still includes handler registration, key export, time sync, contact/channel sync, and advertisement tasks. The message-poll task always starts: by default it runs as a low-frequency hourly audit, and `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` switches it to aggressive 10-second polling. That audit checks both missed-radio-message drift and channel-slot cache drift; cache mismatches are logged, toasted, and the send-slot cache is reset.
|
||||
- Post-connect setup is timeout-bounded. If initial radio offload/setup hangs too long, the backend logs the failure and broadcasts an `error` toast telling the operator to reboot the radio and restart the server.
|
||||
|
||||
## Important Behaviors
|
||||
@@ -96,6 +99,10 @@ app/
|
||||
|
||||
- Packet `path_len` values are hop counts, not byte counts.
|
||||
- Hop width comes from the packet or radio `path_hash_mode`: `0` = 1-byte, `1` = 2-byte, `2` = 3-byte.
|
||||
- Channel slot count comes from firmware-reported `DEVICE_INFO.max_channels`; do not hardcode `40` when scanning/offloading channel slots.
|
||||
- Channel sends use a session-local LRU slot cache after startup channel offload clears the radio. Repeated sends to the same room reuse the loaded slot; new rooms fill free slots up to the discovered channel capacity, then evict the least recently used cached room.
|
||||
- TCP radios do not reuse cached slot contents. For TCP, channel sends still force `set_channel(...)` before every send because this backend does not have exclusive device access.
|
||||
- `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true` disables slot reuse on all transports and forces the old always-`set_channel(...)` behavior before every channel send.
|
||||
- Contacts persist `out_path_hash_mode` in the database so contact sync and outbound DM routing reuse the exact stored mode instead of inferring from path bytes.
|
||||
- Contacts may also persist `route_override_path`, `route_override_len`, and `route_override_hash_mode`. `Contact.to_radio_dict()` gives these override fields precedence over learned `last_path*`, while advert processing still updates the learned route for telemetry/fallback.
|
||||
- `contact_advert_paths` identity is `(public_key, path_hex, path_len)` because the same hex bytes can represent different routes at different hop widths.
|
||||
@@ -108,7 +115,7 @@ app/
|
||||
### Echo/repeat dedup
|
||||
|
||||
- Message uniqueness: `(type, conversation_key, text, sender_timestamp)`.
|
||||
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented **only for outgoing messages**. Incoming repeats add path data but do not change the ACK count.
|
||||
- Duplicate insert is treated as an echo/repeat: the new path (if any) is appended, and the ACK count is incremented only for outgoing channel messages. Incoming repeats and direct-message duplicates may still add path data, but DM delivery state advances only from real ACK events.
|
||||
|
||||
### Raw packet dedup policy
|
||||
|
||||
@@ -131,7 +138,7 @@ app/
|
||||
|
||||
### Fanout bus
|
||||
|
||||
- All external integrations (MQTT, bots, webhooks, Apprise) are managed through the fanout bus (`app/fanout/`).
|
||||
- All external integrations (MQTT, bots, webhooks, Apprise, SQS) are managed through the fanout bus (`app/fanout/`).
|
||||
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
|
||||
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message` and `raw_packet` events.
|
||||
- Each integration is a `FanoutModule` with scope-based filtering.
|
||||
@@ -143,25 +150,25 @@ app/
|
||||
### Health
|
||||
- `GET /health`
|
||||
|
||||
### Debug
|
||||
- `GET /debug` — support snapshot with recent logs, live radio probe, slot/contact audits, and version/git info
|
||||
|
||||
### Radio
|
||||
- `GET /radio/config` — includes `path_hash_mode` and `path_hash_mode_supported`
|
||||
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
|
||||
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
|
||||
- `PUT /radio/private-key`
|
||||
- `POST /radio/advertise`
|
||||
- `POST /radio/discover` — short mesh discovery sweep for nearby repeaters/sensors
|
||||
- `POST /radio/disconnect`
|
||||
- `POST /radio/reboot`
|
||||
- `POST /radio/reconnect`
|
||||
|
||||
### Contacts
|
||||
- `GET /contacts`
|
||||
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
|
||||
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
|
||||
- `GET /contacts/{public_key}`
|
||||
- `GET /contacts/{public_key}/detail` — comprehensive contact profile (stats, name history, paths, nearest repeaters)
|
||||
- `GET /contacts/{public_key}/advert-paths` — recent advert paths for one contact
|
||||
- `POST /contacts`
|
||||
- `DELETE /contacts/{public_key}`
|
||||
- `POST /contacts/sync`
|
||||
- `POST /contacts/{public_key}/add-to-radio`
|
||||
- `POST /contacts/{public_key}/remove-from-radio`
|
||||
- `POST /contacts/{public_key}/mark-read`
|
||||
- `POST /contacts/{public_key}/command`
|
||||
- `POST /contacts/{public_key}/routing-override`
|
||||
@@ -171,6 +178,7 @@ app/
|
||||
- `POST /contacts/{public_key}/repeater/lpp-telemetry`
|
||||
- `POST /contacts/{public_key}/repeater/neighbors`
|
||||
- `POST /contacts/{public_key}/repeater/acl`
|
||||
- `POST /contacts/{public_key}/repeater/node-info`
|
||||
- `POST /contacts/{public_key}/repeater/radio-settings`
|
||||
- `POST /contacts/{public_key}/repeater/advert-intervals`
|
||||
- `POST /contacts/{public_key}/repeater/owner-info`
|
||||
@@ -178,10 +186,8 @@ app/
|
||||
### Channels
|
||||
- `GET /channels`
|
||||
- `GET /channels/{key}/detail`
|
||||
- `GET /channels/{key}`
|
||||
- `POST /channels`
|
||||
- `DELETE /channels/{key}`
|
||||
- `POST /channels/sync`
|
||||
- `POST /channels/{key}/flood-scope-override`
|
||||
- `POST /channels/{key}/mark-read`
|
||||
|
||||
@@ -225,6 +231,7 @@ app/
|
||||
|
||||
- `health` — radio connection status (broadcast on change, personal on connect)
|
||||
- `contact` — single contact upsert (from advertisements and radio sync)
|
||||
- `contact_resolved` — prefix contact reconciled to a full contact row (payload: `{ previous_public_key, contact }`)
|
||||
- `message` — new message (channel or DM, from packet processor or send endpoints)
|
||||
- `message_acked` — ACK/echo update for existing message (ack count + paths)
|
||||
- `raw_packet` — every incoming RF packet (for real-time packet feed UI)
|
||||
@@ -292,16 +299,20 @@ tests/
|
||||
├── test_api.py # REST endpoint integration tests
|
||||
├── test_bot.py # Bot execution and sandboxing
|
||||
├── test_channels_router.py # Channels router endpoints
|
||||
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
|
||||
├── test_config.py # Configuration validation
|
||||
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
|
||||
├── test_contacts_router.py # Contacts router endpoints
|
||||
├── test_decoder.py # Packet parsing/decryption
|
||||
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
|
||||
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
|
||||
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
|
||||
├── test_fanout_integration.py # Fanout integration tests
|
||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||
├── test_event_handlers.py # ACK tracking, event registration, cleanup
|
||||
├── test_frontend_static.py # Frontend static file serving
|
||||
├── test_health_mqtt_status.py # Health endpoint MQTT status field
|
||||
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
|
||||
├── test_key_normalization.py # Public key normalization
|
||||
├── test_keystore.py # Ephemeral keystore
|
||||
├── test_message_pagination.py # Cursor-based message pagination
|
||||
@@ -314,6 +325,7 @@ tests/
|
||||
├── test_radio.py # RadioManager, serial detection
|
||||
├── test_radio_commands_service.py # Radio config/private-key service workflows
|
||||
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
|
||||
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
|
||||
├── test_real_crypto.py # Real cryptographic operations
|
||||
├── test_radio_operation.py # radio_operation() context manager
|
||||
├── test_radio_router.py # Radio router endpoints
|
||||
@@ -323,11 +335,10 @@ tests/
|
||||
├── test_rx_log_data.py # on_rx_log_data event handler integration
|
||||
├── test_messages_search.py # Message search, around, forward pagination
|
||||
├── test_block_lists.py # Blocked keys/names filtering
|
||||
├── test_security.py # Optional Basic Auth middleware / config behavior
|
||||
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
|
||||
├── test_settings_router.py # Settings endpoints, advert validation
|
||||
├── test_statistics.py # Statistics aggregation
|
||||
├── test_channel_sender_backfill.py # Sender key backfill for channel messages
|
||||
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
|
||||
├── test_main_startup.py # App startup and lifespan
|
||||
├── test_path_utils.py # Path hex rendering helpers
|
||||
├── test_websocket.py # WS manager broadcast/cleanup
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
import logging.config
|
||||
from collections import deque
|
||||
from threading import Lock
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
@@ -19,6 +21,9 @@ class Settings(BaseSettings):
|
||||
database_path: str = "data/meshcore.db"
|
||||
disable_bots: bool = False
|
||||
enable_message_poll_fallback: bool = False
|
||||
force_channel_slot_reconfigure: bool = False
|
||||
basic_auth_username: str = ""
|
||||
basic_auth_password: str = ""
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_transport_exclusivity(self) -> "Settings":
|
||||
@@ -36,6 +41,11 @@ class Settings(BaseSettings):
|
||||
)
|
||||
if self.ble_address and not self.ble_pin:
|
||||
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
|
||||
if self.basic_auth_partially_configured:
|
||||
raise ValueError(
|
||||
"MESHCORE_BASIC_AUTH_USERNAME and MESHCORE_BASIC_AUTH_PASSWORD "
|
||||
"must be set together."
|
||||
)
|
||||
return self
|
||||
|
||||
@property
|
||||
@@ -46,10 +56,60 @@ class Settings(BaseSettings):
|
||||
return "ble"
|
||||
return "serial"
|
||||
|
||||
@property
|
||||
def basic_auth_enabled(self) -> bool:
|
||||
return bool(self.basic_auth_username and self.basic_auth_password)
|
||||
|
||||
@property
|
||||
def basic_auth_partially_configured(self) -> bool:
|
||||
any_credentials_set = bool(self.basic_auth_username or self.basic_auth_password)
|
||||
return any_credentials_set and not self.basic_auth_enabled
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
||||
|
||||
class _RingBufferLogHandler(logging.Handler):
|
||||
"""Keep a bounded in-memory tail of formatted log lines."""
|
||||
|
||||
def __init__(self, max_lines: int = 1000) -> None:
|
||||
super().__init__()
|
||||
self._buffer: deque[str] = deque(maxlen=max_lines)
|
||||
self._lock = Lock()
|
||||
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
try:
|
||||
line = self.format(record)
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
return
|
||||
with self._lock:
|
||||
self._buffer.append(line)
|
||||
|
||||
def get_lines(self, limit: int = 1000) -> list[str]:
|
||||
with self._lock:
|
||||
if limit <= 0:
|
||||
return []
|
||||
return list(self._buffer)[-limit:]
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._buffer.clear()
|
||||
|
||||
|
||||
_recent_log_handler = _RingBufferLogHandler(max_lines=1000)
|
||||
|
||||
|
||||
def get_recent_log_lines(limit: int = 1000) -> list[str]:
|
||||
"""Return recent formatted log lines from the in-memory ring buffer."""
|
||||
return _recent_log_handler.get_lines(limit)
|
||||
|
||||
|
||||
def clear_recent_log_lines() -> None:
|
||||
"""Clear the in-memory log ring buffer."""
|
||||
_recent_log_handler.clear()
|
||||
|
||||
|
||||
class _RepeatSquelch(logging.Filter):
|
||||
"""Suppress rapid-fire identical messages and emit a summary instead.
|
||||
|
||||
@@ -135,6 +195,19 @@ def setup_logging() -> None:
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
_recent_log_handler.setLevel(logging.DEBUG)
|
||||
_recent_log_handler.setFormatter(
|
||||
logging.Formatter(
|
||||
fmt="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
)
|
||||
for logger_name in ("", "uvicorn", "uvicorn.error", "uvicorn.access"):
|
||||
target = logging.getLogger(logger_name)
|
||||
if _recent_log_handler not in target.handlers:
|
||||
target.addHandler(_recent_log_handler)
|
||||
|
||||
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
|
||||
# "Serial Connection started" when the port is contended).
|
||||
logging.getLogger("meshcore").addFilter(_RepeatSquelch())
|
||||
|
||||
@@ -51,9 +51,8 @@ CREATE TABLE IF NOT EXISTS messages (
|
||||
sender_name TEXT,
|
||||
sender_key TEXT
|
||||
-- Deduplication: identical text + timestamp in the same conversation is treated as a
|
||||
-- mesh echo/repeat. Second-precision timestamps mean two intentional identical messages
|
||||
-- within the same second would collide, but this is not feasible in practice — LoRa
|
||||
-- transmission takes several seconds per message, and the UI clears the input on send.
|
||||
-- mesh echo/repeat. Outgoing sends allocate a collision-free sender_timestamp before
|
||||
-- transmit so legitimate repeat sends do not collide with this index.
|
||||
-- Enforced via idx_messages_dedup_null_safe (unique index) rather than a table constraint
|
||||
-- to avoid the storage overhead of SQLite's autoindex duplicating every message text.
|
||||
);
|
||||
|
||||
@@ -85,15 +85,6 @@ class PacketInfo:
|
||||
path_hash_size: int = 1 # Bytes per hop: 1, 2, or 3
|
||||
|
||||
|
||||
def calculate_channel_hash(channel_key: bytes) -> str:
|
||||
"""
|
||||
Calculate the channel hash from a 16-byte channel key.
|
||||
Returns the first byte of SHA256(key) as hex.
|
||||
"""
|
||||
hash_bytes = hashlib.sha256(channel_key).digest()
|
||||
return format(hash_bytes[0], "02x")
|
||||
|
||||
|
||||
def extract_payload(raw_packet: bytes) -> bytes | None:
|
||||
"""
|
||||
Extract just the payload from a raw packet, skipping header and path.
|
||||
@@ -233,7 +224,7 @@ def try_decrypt_packet_with_channel_key(
|
||||
return None
|
||||
|
||||
packet_channel_hash = format(packet_info.payload[0], "02x")
|
||||
expected_hash = calculate_channel_hash(channel_key)
|
||||
expected_hash = format(hashlib.sha256(channel_key).digest()[0], "02x")
|
||||
|
||||
if packet_channel_hash != expected_hash:
|
||||
return None
|
||||
|
||||
@@ -13,6 +13,7 @@ from app.repository import (
|
||||
from app.services import dm_ack_tracker
|
||||
from app.services.contact_reconciliation import (
|
||||
claim_prefix_messages_for_contact,
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast
|
||||
@@ -27,11 +28,12 @@ logger = logging.getLogger(__name__)
|
||||
# This prevents handler duplication after reconnects
|
||||
_active_subscriptions: list["Subscription"] = []
|
||||
_pending_acks = dm_ack_tracker._pending_acks
|
||||
_buffered_acks = dm_ack_tracker._buffered_acks
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
||||
"""Compatibility wrapper for pending DM ACK tracking."""
|
||||
dm_ack_tracker.track_pending_ack(expected_ack, message_id, timeout_ms)
|
||||
return dm_ack_tracker.track_pending_ack(expected_ack, message_id, timeout_ms)
|
||||
|
||||
|
||||
def cleanup_expired_acks() -> None:
|
||||
@@ -88,6 +90,20 @@ async def on_contact_message(event: "Event") -> None:
|
||||
sender_pubkey[:12],
|
||||
)
|
||||
return
|
||||
elif sender_pubkey:
|
||||
placeholder_upsert = ContactUpsert(
|
||||
public_key=sender_pubkey.lower(),
|
||||
type=0,
|
||||
last_seen=received_at,
|
||||
last_contacted=received_at,
|
||||
first_seen=received_at,
|
||||
on_radio=False,
|
||||
out_path_hash_mode=-1,
|
||||
)
|
||||
await ContactRepository.upsert(placeholder_upsert)
|
||||
contact = await ContactRepository.get_by_key(sender_pubkey.lower())
|
||||
if contact:
|
||||
broadcast_event("contact", contact.model_dump())
|
||||
|
||||
# Try to create message - INSERT OR IGNORE handles duplicates atomically
|
||||
# If the packet processor already stored this message, this returns None
|
||||
@@ -228,9 +244,13 @@ async def on_new_contact(event: "Event") -> None:
|
||||
|
||||
logger.debug("New contact: %s", public_key[:12])
|
||||
|
||||
contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=True)
|
||||
contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=False)
|
||||
contact_upsert.last_seen = int(time.time())
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=public_key,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
adv_name = payload.get("adv_name")
|
||||
await record_contact_name_and_reconcile(
|
||||
@@ -251,6 +271,15 @@ async def on_new_contact(event: "Event") -> None:
|
||||
else Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump()
|
||||
),
|
||||
)
|
||||
if db_contact:
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": db_contact.model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def on_ack(event: "Event") -> None:
|
||||
@@ -274,6 +303,7 @@ async def on_ack(event: "Event") -> None:
|
||||
# preserving any previously known paths.
|
||||
await increment_ack_and_broadcast(message_id=message_id, broadcast_fn=broadcast_event)
|
||||
else:
|
||||
dm_ack_tracker.buffer_unmatched_ack(ack_code)
|
||||
logger.debug("ACK code %s does not match any pending messages", ack_code)
|
||||
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ WsEventType = Literal[
|
||||
"health",
|
||||
"message",
|
||||
"contact",
|
||||
"contact_resolved",
|
||||
"channel",
|
||||
"contact_deleted",
|
||||
"channel_deleted",
|
||||
@@ -30,6 +31,11 @@ class ContactDeletedPayload(TypedDict):
|
||||
public_key: str
|
||||
|
||||
|
||||
class ContactResolvedPayload(TypedDict):
|
||||
previous_public_key: str
|
||||
contact: Contact
|
||||
|
||||
|
||||
class ChannelDeletedPayload(TypedDict):
|
||||
key: str
|
||||
|
||||
@@ -49,6 +55,7 @@ WsEventPayload = (
|
||||
HealthResponse
|
||||
| Message
|
||||
| Contact
|
||||
| ContactResolvedPayload
|
||||
| Channel
|
||||
| ContactDeletedPayload
|
||||
| ChannelDeletedPayload
|
||||
@@ -61,6 +68,7 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
|
||||
"health": TypeAdapter(HealthResponse),
|
||||
"message": TypeAdapter(Message),
|
||||
"contact": TypeAdapter(Contact),
|
||||
"contact_resolved": TypeAdapter(ContactResolvedPayload),
|
||||
"channel": TypeAdapter(Channel),
|
||||
"contact_deleted": TypeAdapter(ContactDeletedPayload),
|
||||
"channel_deleted": TypeAdapter(ChannelDeletedPayload),
|
||||
|
||||
@@ -79,6 +79,14 @@ Push notifications via Apprise library. Config blob:
|
||||
- `preserve_identity` — suppress Discord webhook name/avatar override
|
||||
- `include_path` — include routing path in notification body
|
||||
|
||||
### sqs (sqs.py)
|
||||
Amazon SQS delivery. Config blob:
|
||||
- `queue_url` — target queue URL
|
||||
- `region_name` (optional; inferred from standard AWS SQS queue URLs when omitted), `endpoint_url` (optional)
|
||||
- `access_key_id`, `secret_access_key`, `session_token` (all optional; blank uses the normal AWS credential chain)
|
||||
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
|
||||
- Supports both decoded messages and raw packets via normal scope selection
|
||||
|
||||
## Adding a New Integration Type
|
||||
|
||||
### Step-by-step checklist
|
||||
@@ -132,7 +140,7 @@ Three changes needed:
|
||||
|
||||
**a)** Add to `_VALID_TYPES` set:
|
||||
```python
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "my_type"}
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "my_type"}
|
||||
```
|
||||
|
||||
**b)** Add a validation function:
|
||||
@@ -280,6 +288,7 @@ Migrations:
|
||||
- `app/fanout/bot_exec.py` — Bot code execution, response processing, rate limiting
|
||||
- `app/fanout/webhook.py` — Webhook fanout module
|
||||
- `app/fanout/apprise_mod.py` — Apprise fanout module
|
||||
- `app/fanout/sqs.py` — Amazon SQS fanout module
|
||||
- `app/repository/fanout.py` — Database CRUD
|
||||
- `app/routers/fanout.py` — REST API
|
||||
- `app/websocket.py` — `broadcast_event()` dispatches to fanout
|
||||
|
||||
@@ -10,6 +10,36 @@ from app.fanout.base import FanoutModule
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _derive_path_bytes_per_hop(paths: object, path_value: str | None) -> int | None:
|
||||
"""Derive hop width from the first serialized message path when possible."""
|
||||
if not isinstance(path_value, str) or not path_value:
|
||||
return None
|
||||
if not isinstance(paths, list) or not paths:
|
||||
return None
|
||||
|
||||
first_path = paths[0]
|
||||
if not isinstance(first_path, dict):
|
||||
return None
|
||||
|
||||
path_hops = first_path.get("path_len")
|
||||
if not isinstance(path_hops, int) or path_hops <= 0:
|
||||
return None
|
||||
|
||||
path_hex_chars = len(path_value)
|
||||
if path_hex_chars % 2 != 0:
|
||||
return None
|
||||
|
||||
path_bytes = path_hex_chars // 2
|
||||
if path_bytes % path_hops != 0:
|
||||
return None
|
||||
|
||||
hop_width = path_bytes // path_hops
|
||||
if hop_width not in (1, 2, 3):
|
||||
return None
|
||||
|
||||
return hop_width
|
||||
|
||||
|
||||
class BotModule(FanoutModule):
|
||||
"""Wraps a single bot's code execution and response routing.
|
||||
|
||||
@@ -101,11 +131,11 @@ class BotModule(FanoutModule):
|
||||
|
||||
sender_timestamp = data.get("sender_timestamp")
|
||||
path_value = data.get("path")
|
||||
paths = data.get("paths")
|
||||
# Message model serializes paths as list of dicts; extract first path string
|
||||
if path_value is None:
|
||||
paths = data.get("paths")
|
||||
if paths and isinstance(paths, list) and len(paths) > 0:
|
||||
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
|
||||
if path_value is None and paths and isinstance(paths, list) and len(paths) > 0:
|
||||
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
|
||||
path_bytes_per_hop = _derive_path_bytes_per_hop(paths, path_value)
|
||||
|
||||
# Wait for message to settle (allows retransmissions to be deduped)
|
||||
await asyncio.sleep(2)
|
||||
@@ -130,6 +160,7 @@ class BotModule(FanoutModule):
|
||||
sender_timestamp,
|
||||
path_value,
|
||||
is_outgoing,
|
||||
path_bytes_per_hop,
|
||||
),
|
||||
timeout=BOT_EXECUTION_TIMEOUT,
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import inspect
|
||||
import logging
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
@@ -39,6 +40,102 @@ _bot_send_lock = asyncio.Lock()
|
||||
_last_bot_send_time: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BotCallPlan:
|
||||
"""How to call a validated bot() function."""
|
||||
|
||||
call_style: str
|
||||
keyword_args: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
|
||||
"""Validate bot() signature and return a supported call plan."""
|
||||
try:
|
||||
sig = (
|
||||
bot_func_or_sig
|
||||
if isinstance(bot_func_or_sig, inspect.Signature)
|
||||
else inspect.signature(bot_func_or_sig)
|
||||
)
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise ValueError("Bot function signature could not be inspected") from exc
|
||||
|
||||
params = sig.parameters
|
||||
param_values = tuple(params.values())
|
||||
positional_params = [
|
||||
p
|
||||
for p in param_values
|
||||
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
||||
]
|
||||
has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in param_values)
|
||||
has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in param_values)
|
||||
explicit_optional_names = tuple(
|
||||
name for name in ("is_outgoing", "path_bytes_per_hop") if name in params
|
||||
)
|
||||
unsupported_required_kwonly = [
|
||||
p.name
|
||||
for p in param_values
|
||||
if p.kind == inspect.Parameter.KEYWORD_ONLY
|
||||
and p.default is inspect.Parameter.empty
|
||||
and p.name not in {"is_outgoing", "path_bytes_per_hop"}
|
||||
]
|
||||
if unsupported_required_kwonly:
|
||||
raise ValueError(
|
||||
"Bot function signature is not supported. Unsupported required keyword-only "
|
||||
"parameters: " + ", ".join(unsupported_required_kwonly)
|
||||
)
|
||||
|
||||
positional_capacity = len(positional_params)
|
||||
base_args = [object()] * 8
|
||||
base_keyword_args: dict[str, object] = {
|
||||
"sender_name": object(),
|
||||
"sender_key": object(),
|
||||
"message_text": object(),
|
||||
"is_dm": object(),
|
||||
"channel_key": object(),
|
||||
"channel_name": object(),
|
||||
"sender_timestamp": object(),
|
||||
"path": object(),
|
||||
}
|
||||
candidate_specs: list[tuple[str, list[object], dict[str, object]]] = []
|
||||
keyword_args = dict(base_keyword_args)
|
||||
if has_kwargs or "is_outgoing" in params:
|
||||
keyword_args["is_outgoing"] = False
|
||||
if has_kwargs or "path_bytes_per_hop" in params:
|
||||
keyword_args["path_bytes_per_hop"] = 1
|
||||
candidate_specs.append(("keyword", [], keyword_args))
|
||||
|
||||
if not has_kwargs and explicit_optional_names:
|
||||
kwargs: dict[str, object] = {}
|
||||
if has_kwargs or "is_outgoing" in params:
|
||||
kwargs["is_outgoing"] = False
|
||||
if has_kwargs or "path_bytes_per_hop" in params:
|
||||
kwargs["path_bytes_per_hop"] = 1
|
||||
candidate_specs.append(("mixed_keyword", base_args, kwargs))
|
||||
|
||||
if has_varargs or positional_capacity >= 10:
|
||||
candidate_specs.append(("positional_10", base_args + [False, 1], {}))
|
||||
if has_varargs or positional_capacity >= 9:
|
||||
candidate_specs.append(("positional_9", base_args + [False], {}))
|
||||
if has_varargs or positional_capacity >= 8:
|
||||
candidate_specs.append(("legacy", base_args, {}))
|
||||
|
||||
for call_style, args, kwargs in candidate_specs:
|
||||
try:
|
||||
sig.bind(*args, **kwargs)
|
||||
except TypeError:
|
||||
continue
|
||||
if call_style in {"keyword", "mixed_keyword"}:
|
||||
return BotCallPlan(call_style="keyword", keyword_args=tuple(kwargs.keys()))
|
||||
return BotCallPlan(call_style=call_style)
|
||||
|
||||
raise ValueError(
|
||||
"Bot function signature is not supported. Use the default bot template as a reference. "
|
||||
"Supported trailing parameters are: path; path + is_outgoing; "
|
||||
"path + path_bytes_per_hop; path + is_outgoing + path_bytes_per_hop; "
|
||||
"or use **kwargs for forward compatibility."
|
||||
)
|
||||
|
||||
|
||||
def execute_bot_code(
|
||||
code: str,
|
||||
sender_name: str | None,
|
||||
@@ -50,17 +147,19 @@ def execute_bot_code(
|
||||
sender_timestamp: int | None,
|
||||
path: str | None,
|
||||
is_outgoing: bool = False,
|
||||
path_bytes_per_hop: int | None = None,
|
||||
) -> str | list[str] | None:
|
||||
"""
|
||||
Execute user-provided bot code with message context.
|
||||
|
||||
The code should define a function:
|
||||
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing)`
|
||||
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing, path_bytes_per_hop)`
|
||||
or use named parameters / `**kwargs`.
|
||||
that returns either None (no response), a string (single response message),
|
||||
or a list of strings (multiple messages sent in order).
|
||||
|
||||
Legacy bot functions with 8 parameters (without is_outgoing) are detected
|
||||
via inspect and called without the new parameter for backward compatibility.
|
||||
Legacy bot functions with older signatures are detected via inspect and
|
||||
called without the newer parameters for backward compatibility.
|
||||
|
||||
Args:
|
||||
code: Python code defining the bot function
|
||||
@@ -73,6 +172,7 @@ def execute_bot_code(
|
||||
sender_timestamp: Sender's timestamp from the message (may be None)
|
||||
path: Hex-encoded routing path (may be None)
|
||||
is_outgoing: True if this is our own outgoing message
|
||||
path_bytes_per_hop: Number of bytes per routing hop (1, 2, or 3), if known
|
||||
|
||||
Returns:
|
||||
Response string, list of strings, or None.
|
||||
@@ -100,30 +200,28 @@ def execute_bot_code(
|
||||
return None
|
||||
|
||||
bot_func = namespace["bot"]
|
||||
|
||||
# Detect whether the bot function accepts is_outgoing (new 9-param signature)
|
||||
# or uses the legacy 8-param signature, for backward compatibility.
|
||||
# Three cases: explicit is_outgoing param or 9+ params (positional),
|
||||
# **kwargs (pass as keyword), or legacy 8-param (omit).
|
||||
call_style = "legacy" # "positional", "keyword", or "legacy"
|
||||
try:
|
||||
sig = inspect.signature(bot_func)
|
||||
params = sig.parameters
|
||||
non_variadic = [
|
||||
p
|
||||
for p in params.values()
|
||||
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
|
||||
]
|
||||
if "is_outgoing" in params or len(non_variadic) >= 9:
|
||||
call_style = "positional"
|
||||
elif any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
|
||||
call_style = "keyword"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
call_plan = _analyze_bot_signature(bot_func)
|
||||
except ValueError as exc:
|
||||
logger.error("%s", exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Call the bot function with appropriate signature
|
||||
if call_style == "positional":
|
||||
if call_plan.call_style == "positional_10":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing,
|
||||
path_bytes_per_hop,
|
||||
)
|
||||
elif call_plan.call_style == "positional_9":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
@@ -135,18 +233,29 @@ def execute_bot_code(
|
||||
path,
|
||||
is_outgoing,
|
||||
)
|
||||
elif call_style == "keyword":
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
sender_key,
|
||||
message_text,
|
||||
is_dm,
|
||||
channel_key,
|
||||
channel_name,
|
||||
sender_timestamp,
|
||||
path,
|
||||
is_outgoing=is_outgoing,
|
||||
)
|
||||
elif call_plan.call_style == "keyword":
|
||||
keyword_args: dict[str, Any] = {}
|
||||
if "sender_name" in call_plan.keyword_args:
|
||||
keyword_args["sender_name"] = sender_name
|
||||
if "sender_key" in call_plan.keyword_args:
|
||||
keyword_args["sender_key"] = sender_key
|
||||
if "message_text" in call_plan.keyword_args:
|
||||
keyword_args["message_text"] = message_text
|
||||
if "is_dm" in call_plan.keyword_args:
|
||||
keyword_args["is_dm"] = is_dm
|
||||
if "channel_key" in call_plan.keyword_args:
|
||||
keyword_args["channel_key"] = channel_key
|
||||
if "channel_name" in call_plan.keyword_args:
|
||||
keyword_args["channel_name"] = channel_name
|
||||
if "sender_timestamp" in call_plan.keyword_args:
|
||||
keyword_args["sender_timestamp"] = sender_timestamp
|
||||
if "path" in call_plan.keyword_args:
|
||||
keyword_args["path"] = path
|
||||
if "is_outgoing" in call_plan.keyword_args:
|
||||
keyword_args["is_outgoing"] = is_outgoing
|
||||
if "path_bytes_per_hop" in call_plan.keyword_args:
|
||||
keyword_args["path_bytes_per_hop"] = path_bytes_per_hop
|
||||
result = bot_func(**keyword_args)
|
||||
else:
|
||||
result = bot_func(
|
||||
sender_name,
|
||||
|
||||
@@ -23,6 +23,7 @@ def _register_module_types() -> None:
|
||||
from app.fanout.bot import BotModule
|
||||
from app.fanout.mqtt_community import MqttCommunityModule
|
||||
from app.fanout.mqtt_private import MqttPrivateModule
|
||||
from app.fanout.sqs import SqsModule
|
||||
from app.fanout.webhook import WebhookModule
|
||||
|
||||
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
|
||||
@@ -30,6 +31,7 @@ def _register_module_types() -> None:
|
||||
_MODULE_TYPES["bot"] = BotModule
|
||||
_MODULE_TYPES["webhook"] = WebhookModule
|
||||
_MODULE_TYPES["apprise"] = AppriseModule
|
||||
_MODULE_TYPES["sqs"] = SqsModule
|
||||
|
||||
|
||||
def _matches_filter(filter_value: Any, key: str) -> bool:
|
||||
|
||||
164
app/fanout/sqs.py
Normal file
164
app/fanout/sqs.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Fanout module for Amazon SQS delivery."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from functools import partial
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import BotoCoreError, ClientError
|
||||
|
||||
from app.fanout.base import FanoutModule
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_payload(data: dict, *, event_type: str) -> str:
|
||||
"""Serialize a fanout event into a stable JSON envelope."""
|
||||
return json.dumps(
|
||||
{
|
||||
"event_type": event_type,
|
||||
"data": data,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
)
|
||||
|
||||
|
||||
def _infer_region_from_queue_url(queue_url: str) -> str | None:
|
||||
"""Infer AWS region from a standard SQS queue URL host when possible."""
|
||||
host = urlparse(queue_url).hostname or ""
|
||||
if not host:
|
||||
return None
|
||||
|
||||
parts = host.split(".")
|
||||
if len(parts) < 4 or parts[0] != "sqs":
|
||||
return None
|
||||
if parts[2] != "amazonaws":
|
||||
return None
|
||||
if parts[3] not in {"com", "com.cn"}:
|
||||
return None
|
||||
|
||||
region = parts[1].strip()
|
||||
return region or None
|
||||
|
||||
|
||||
def _is_fifo_queue(queue_url: str) -> bool:
|
||||
"""Return True when the configured queue URL points at an SQS FIFO queue."""
|
||||
return queue_url.rstrip("/").endswith(".fifo")
|
||||
|
||||
|
||||
def _build_message_group_id(data: dict, *, event_type: str) -> str:
|
||||
"""Choose a stable FIFO group ID from the event identity."""
|
||||
if event_type == "message":
|
||||
conversation_key = str(data.get("conversation_key", "")).strip()
|
||||
if conversation_key:
|
||||
return f"message-{conversation_key}"
|
||||
return "message-default"
|
||||
return "raw-packets"
|
||||
|
||||
|
||||
def _build_message_deduplication_id(data: dict, *, event_type: str, body: str) -> str:
|
||||
"""Choose a deterministic deduplication ID for FIFO queues."""
|
||||
if event_type == "message":
|
||||
message_id = data.get("id")
|
||||
if isinstance(message_id, int):
|
||||
return f"message-{message_id}"
|
||||
else:
|
||||
observation_id = data.get("observation_id")
|
||||
if isinstance(observation_id, str) and observation_id.strip():
|
||||
return f"raw-{observation_id}"
|
||||
packet_id = data.get("id")
|
||||
if isinstance(packet_id, int):
|
||||
return f"raw-{packet_id}"
|
||||
return hashlib.sha256(body.encode()).hexdigest()
|
||||
|
||||
|
||||
class SqsModule(FanoutModule):
|
||||
"""Delivers message and raw-packet events to an Amazon SQS queue."""
|
||||
|
||||
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
|
||||
super().__init__(config_id, config, name=name)
|
||||
self._client = None
|
||||
self._last_error: str | None = None
|
||||
|
||||
async def start(self) -> None:
|
||||
kwargs: dict[str, str] = {}
|
||||
queue_url = str(self.config.get("queue_url", "")).strip()
|
||||
region_name = str(self.config.get("region_name", "")).strip()
|
||||
endpoint_url = str(self.config.get("endpoint_url", "")).strip()
|
||||
access_key_id = str(self.config.get("access_key_id", "")).strip()
|
||||
secret_access_key = str(self.config.get("secret_access_key", "")).strip()
|
||||
session_token = str(self.config.get("session_token", "")).strip()
|
||||
|
||||
if not region_name:
|
||||
region_name = _infer_region_from_queue_url(queue_url) or ""
|
||||
if region_name:
|
||||
kwargs["region_name"] = region_name
|
||||
if endpoint_url:
|
||||
kwargs["endpoint_url"] = endpoint_url
|
||||
if access_key_id and secret_access_key:
|
||||
kwargs["aws_access_key_id"] = access_key_id
|
||||
kwargs["aws_secret_access_key"] = secret_access_key
|
||||
if session_token:
|
||||
kwargs["aws_session_token"] = session_token
|
||||
|
||||
self._client = boto3.client("sqs", **kwargs)
|
||||
self._last_error = None
|
||||
|
||||
async def stop(self) -> None:
|
||||
self._client = None
|
||||
|
||||
async def on_message(self, data: dict) -> None:
|
||||
await self._send(data, event_type="message")
|
||||
|
||||
async def on_raw(self, data: dict) -> None:
|
||||
await self._send(data, event_type="raw_packet")
|
||||
|
||||
async def _send(self, data: dict, *, event_type: str) -> None:
|
||||
if self._client is None:
|
||||
return
|
||||
|
||||
queue_url = str(self.config.get("queue_url", "")).strip()
|
||||
if not queue_url:
|
||||
return
|
||||
|
||||
body = _build_payload(data, event_type=event_type)
|
||||
request_kwargs: dict[str, object] = {
|
||||
"QueueUrl": queue_url,
|
||||
"MessageBody": body,
|
||||
"MessageAttributes": {
|
||||
"event_type": {
|
||||
"DataType": "String",
|
||||
"StringValue": event_type,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if _is_fifo_queue(queue_url):
|
||||
request_kwargs["MessageGroupId"] = _build_message_group_id(data, event_type=event_type)
|
||||
request_kwargs["MessageDeduplicationId"] = _build_message_deduplication_id(
|
||||
data, event_type=event_type, body=body
|
||||
)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(partial(self._client.send_message, **request_kwargs))
|
||||
self._last_error = None
|
||||
except (ClientError, BotoCoreError) as exc:
|
||||
self._last_error = str(exc)
|
||||
logger.warning("SQS %s send error: %s", self.config_id, exc)
|
||||
except Exception as exc:
|
||||
self._last_error = str(exc)
|
||||
logger.exception("Unexpected SQS send error for %s", self.config_id)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
if not str(self.config.get("queue_url", "")).strip():
|
||||
return "disconnected"
|
||||
if self._last_error:
|
||||
return "error"
|
||||
return "connected"
|
||||
@@ -7,6 +7,32 @@ from fastapi.staticfiles import StaticFiles
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INDEX_CACHE_CONTROL = "no-store"
|
||||
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
|
||||
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
|
||||
|
||||
|
||||
class CacheControlStaticFiles(StaticFiles):
|
||||
"""StaticFiles variant that adds a fixed Cache-Control header."""
|
||||
|
||||
def __init__(self, *args, cache_control: str, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.cache_control = cache_control
|
||||
|
||||
def file_response(self, *args, **kwargs):
|
||||
response = super().file_response(*args, **kwargs)
|
||||
response.headers["Cache-Control"] = self.cache_control
|
||||
return response
|
||||
|
||||
|
||||
def _file_response(path: Path, *, cache_control: str) -> FileResponse:
|
||||
return FileResponse(path, headers={"Cache-Control": cache_control})
|
||||
|
||||
|
||||
def _is_index_file(path: Path, index_file: Path) -> bool:
|
||||
"""Return True when the requested file is the SPA shell index.html."""
|
||||
return path == index_file
|
||||
|
||||
|
||||
def _resolve_request_origin(request: Request) -> str:
|
||||
"""Resolve the external origin, honoring common reverse-proxy headers."""
|
||||
@@ -57,7 +83,11 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
return False
|
||||
|
||||
if assets_dir.exists() and assets_dir.is_dir():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
app.mount(
|
||||
"/assets",
|
||||
CacheControlStaticFiles(directory=assets_dir, cache_control=ASSET_CACHE_CONTROL),
|
||||
name="assets",
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"Frontend assets directory missing at %s; /assets files will not be served",
|
||||
@@ -67,7 +97,7 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
@app.get("/")
|
||||
async def serve_index():
|
||||
"""Serve the frontend index.html."""
|
||||
return FileResponse(index_file)
|
||||
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
|
||||
|
||||
@app.get("/site.webmanifest")
|
||||
async def serve_webmanifest(request: Request):
|
||||
@@ -114,9 +144,14 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
raise HTTPException(status_code=404, detail="Not found") from None
|
||||
|
||||
if file_path.exists() and file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
cache_control = (
|
||||
INDEX_CACHE_CONTROL
|
||||
if _is_index_file(file_path, index_file)
|
||||
else STATIC_FILE_CACHE_CONTROL
|
||||
)
|
||||
return _file_response(file_path, cache_control=cache_control)
|
||||
|
||||
return FileResponse(index_file)
|
||||
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
|
||||
|
||||
logger.info("Serving frontend from %s", frontend_dir)
|
||||
return True
|
||||
|
||||
@@ -5,8 +5,10 @@ from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.config import settings as server_settings
|
||||
from app.config import setup_logging
|
||||
from app.database import db
|
||||
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
|
||||
@@ -19,6 +21,7 @@ from app.radio_sync import (
|
||||
from app.routers import (
|
||||
channels,
|
||||
contacts,
|
||||
debug,
|
||||
fanout,
|
||||
health,
|
||||
messages,
|
||||
@@ -30,6 +33,7 @@ from app.routers import (
|
||||
statistics,
|
||||
ws,
|
||||
)
|
||||
from app.security import add_optional_basic_auth_middleware
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
setup_logging()
|
||||
@@ -114,6 +118,8 @@ app = FastAPI(
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
add_optional_basic_auth_middleware(app, server_settings)
|
||||
app.add_middleware(GZipMiddleware, minimum_size=500)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
@@ -131,6 +137,7 @@ async def radio_disconnected_handler(request: Request, exc: RadioDisconnectedErr
|
||||
|
||||
# API routes - all prefixed with /api for production compatibility
|
||||
app.include_router(health.router, prefix="/api")
|
||||
app.include_router(debug.router, prefix="/api")
|
||||
app.include_router(fanout.router, prefix="/api")
|
||||
app.include_router(radio.router, prefix="/api")
|
||||
app.include_router(contacts.router, prefix="/api")
|
||||
|
||||
138
app/models.py
138
app/models.py
@@ -225,6 +225,52 @@ class ContactDetail(BaseModel):
|
||||
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
|
||||
|
||||
|
||||
class NameOnlyContactDetail(BaseModel):
|
||||
"""Channel activity summary for a sender name that is not tied to a known key."""
|
||||
|
||||
name: str
|
||||
channel_message_count: int = 0
|
||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ContactAnalyticsHourlyBucket(BaseModel):
|
||||
"""A single hourly activity bucket for contact analytics."""
|
||||
|
||||
bucket_start: int = Field(description="Unix timestamp for the start of the hour bucket")
|
||||
last_24h_count: int = 0
|
||||
last_week_average: float = 0
|
||||
all_time_average: float = 0
|
||||
|
||||
|
||||
class ContactAnalyticsWeeklyBucket(BaseModel):
|
||||
"""A single weekly activity bucket for contact analytics."""
|
||||
|
||||
bucket_start: int = Field(description="Unix timestamp for the start of the 7-day bucket")
|
||||
message_count: int = 0
|
||||
|
||||
|
||||
class ContactAnalytics(BaseModel):
|
||||
"""Unified contact analytics payload for keyed and name-only lookups."""
|
||||
|
||||
lookup_type: Literal["contact", "name"]
|
||||
name: str
|
||||
contact: Contact | None = None
|
||||
name_first_seen_at: int | None = None
|
||||
name_history: list[ContactNameHistory] = Field(default_factory=list)
|
||||
dm_message_count: int = 0
|
||||
channel_message_count: int = 0
|
||||
includes_direct_messages: bool = False
|
||||
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
|
||||
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
|
||||
advert_frequency: float | None = Field(
|
||||
default=None,
|
||||
description="Advert observations per hour (includes multi-path arrivals of same advert)",
|
||||
)
|
||||
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
|
||||
hourly_activity: list[ContactAnalyticsHourlyBucket] = Field(default_factory=list)
|
||||
weekly_activity: list[ContactAnalyticsWeeklyBucket] = Field(default_factory=list)
|
||||
|
||||
|
||||
class Channel(BaseModel):
|
||||
key: str = Field(description="Channel key (32-char hex)")
|
||||
name: str
|
||||
@@ -301,6 +347,12 @@ class MessagesAroundResponse(BaseModel):
|
||||
has_newer: bool
|
||||
|
||||
|
||||
class ResendChannelMessageResponse(BaseModel):
|
||||
status: str
|
||||
message_id: int
|
||||
message: Message | None = None
|
||||
|
||||
|
||||
class RawPacketDecryptedInfo(BaseModel):
|
||||
"""Decryption info for a raw packet (when successfully decrypted)."""
|
||||
|
||||
@@ -382,8 +434,17 @@ class RepeaterStatusResponse(BaseModel):
|
||||
full_events: int = Field(description="Full event queue count")
|
||||
|
||||
|
||||
class RepeaterNodeInfoResponse(BaseModel):
|
||||
"""Identity/location info from a repeater (small CLI batch)."""
|
||||
|
||||
name: str | None = Field(default=None, description="Repeater name")
|
||||
lat: str | None = Field(default=None, description="Latitude")
|
||||
lon: str | None = Field(default=None, description="Longitude")
|
||||
clock_utc: str | None = Field(default=None, description="Repeater clock in UTC")
|
||||
|
||||
|
||||
class RepeaterRadioSettingsResponse(BaseModel):
|
||||
"""Radio settings from a repeater (batch CLI get commands)."""
|
||||
"""Radio settings from a repeater (radio/config CLI batch)."""
|
||||
|
||||
firmware_version: str | None = Field(default=None, description="Firmware version string")
|
||||
radio: str | None = Field(default=None, description="Radio settings (freq,bw,sf,cr)")
|
||||
@@ -391,10 +452,6 @@ class RepeaterRadioSettingsResponse(BaseModel):
|
||||
airtime_factor: str | None = Field(default=None, description="Airtime factor")
|
||||
repeat_enabled: str | None = Field(default=None, description="Repeat mode enabled")
|
||||
flood_max: str | None = Field(default=None, description="Max flood hops")
|
||||
name: str | None = Field(default=None, description="Repeater name")
|
||||
lat: str | None = Field(default=None, description="Latitude")
|
||||
lon: str | None = Field(default=None, description="Longitude")
|
||||
clock_utc: str | None = Field(default=None, description="Repeater clock in UTC")
|
||||
|
||||
|
||||
class RepeaterAdvertIntervalsResponse(BaseModel):
|
||||
@@ -473,6 +530,30 @@ class TraceResponse(BaseModel):
|
||||
path_len: int = Field(description="Number of hops in the trace path")
|
||||
|
||||
|
||||
class PathDiscoveryRoute(BaseModel):
|
||||
"""One resolved route returned by contact path discovery."""
|
||||
|
||||
path: str = Field(description="Hex-encoded path bytes")
|
||||
path_len: int = Field(description="Hop count for this route")
|
||||
path_hash_mode: int = Field(
|
||||
description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte hop identifiers)"
|
||||
)
|
||||
|
||||
|
||||
class PathDiscoveryResponse(BaseModel):
|
||||
"""Round-trip routing data for a contact path discovery request."""
|
||||
|
||||
contact: Contact = Field(
|
||||
description="Updated contact row after saving the learned forward path"
|
||||
)
|
||||
forward_path: PathDiscoveryRoute = Field(
|
||||
description="Route used from the local radio to the target contact"
|
||||
)
|
||||
return_path: PathDiscoveryRoute = Field(
|
||||
description="Route used from the target contact back to the local radio"
|
||||
)
|
||||
|
||||
|
||||
class CommandRequest(BaseModel):
|
||||
"""Request to send a CLI command to a repeater."""
|
||||
|
||||
@@ -489,6 +570,48 @@ class CommandResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class RadioDiscoveryRequest(BaseModel):
|
||||
"""Request to discover nearby mesh nodes from the local radio."""
|
||||
|
||||
target: Literal["repeaters", "sensors", "all"] = Field(
|
||||
default="all",
|
||||
description="Which node classes to discover over the mesh",
|
||||
)
|
||||
|
||||
|
||||
class RadioDiscoveryResult(BaseModel):
|
||||
"""One mesh node heard during a discovery sweep."""
|
||||
|
||||
public_key: str = Field(description="Discovered node public key as hex")
|
||||
node_type: Literal["repeater", "sensor"] = Field(description="Discovered node class")
|
||||
heard_count: int = Field(default=1, description="How many responses were heard from this node")
|
||||
local_snr: float | None = Field(
|
||||
default=None,
|
||||
description="SNR at which the local radio heard the response (dB)",
|
||||
)
|
||||
local_rssi: int | None = Field(
|
||||
default=None,
|
||||
description="RSSI at which the local radio heard the response (dBm)",
|
||||
)
|
||||
remote_snr: float | None = Field(
|
||||
default=None,
|
||||
description="SNR reported by the remote node while hearing our discovery request (dB)",
|
||||
)
|
||||
|
||||
|
||||
class RadioDiscoveryResponse(BaseModel):
|
||||
"""Response payload for a mesh discovery sweep."""
|
||||
|
||||
target: Literal["repeaters", "sensors", "all"] = Field(
|
||||
description="Which node classes were requested"
|
||||
)
|
||||
duration_seconds: float = Field(description="How long the sweep listened for responses")
|
||||
results: list[RadioDiscoveryResult] = Field(
|
||||
default_factory=list,
|
||||
description="Deduplicated discovery responses heard during the sweep",
|
||||
)
|
||||
|
||||
|
||||
class Favorite(BaseModel):
|
||||
"""A favorite conversation."""
|
||||
|
||||
@@ -508,6 +631,9 @@ class UnreadCounts(BaseModel):
|
||||
last_message_times: dict[str, int] = Field(
|
||||
default_factory=dict, description="Map of stateKey -> last message timestamp"
|
||||
)
|
||||
last_read_ats: dict[str, int | None] = Field(
|
||||
default_factory=dict, description="Map of stateKey -> server-side last_read_at boundary"
|
||||
)
|
||||
|
||||
|
||||
class AppSettings(BaseModel):
|
||||
@@ -565,7 +691,7 @@ class FanoutConfig(BaseModel):
|
||||
"""Configuration for a single fanout integration."""
|
||||
|
||||
id: str
|
||||
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise'
|
||||
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise' | 'sqs'
|
||||
name: str
|
||||
enabled: bool
|
||||
config: dict
|
||||
|
||||
@@ -40,7 +40,10 @@ from app.repository import (
|
||||
ContactRepository,
|
||||
RawPacketRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import record_contact_name_and_reconcile
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
record_contact_name_and_reconcile,
|
||||
)
|
||||
from app.services.messages import (
|
||||
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
|
||||
)
|
||||
@@ -439,8 +442,8 @@ async def _process_advertisement(
|
||||
PATH_FRESHNESS_SECONDS = 60
|
||||
use_existing_path = False
|
||||
|
||||
if existing and existing.last_seen:
|
||||
path_age = timestamp - existing.last_seen
|
||||
if existing and existing.last_advert:
|
||||
path_age = timestamp - existing.last_advert
|
||||
existing_path_len = existing.last_path_len if existing.last_path_len >= 0 else float("inf")
|
||||
|
||||
# Keep existing path if it's fresh and shorter (or equal)
|
||||
@@ -504,6 +507,10 @@ async def _process_advertisement(
|
||||
)
|
||||
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=advert.public_key,
|
||||
log=logger,
|
||||
)
|
||||
await record_contact_name_and_reconcile(
|
||||
public_key=advert.public_key,
|
||||
contact_name=advert.name,
|
||||
@@ -516,6 +523,14 @@ async def _process_advertisement(
|
||||
db_contact = await ContactRepository.get_by_key(advert.public_key.lower())
|
||||
if db_contact:
|
||||
broadcast_event("contact", db_contact.model_dump())
|
||||
for old_key in promoted_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": db_contact.model_dump(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
broadcast_event(
|
||||
"contact",
|
||||
|
||||
173
app/radio.py
173
app/radio.py
@@ -2,6 +2,7 @@ import asyncio
|
||||
import glob
|
||||
import logging
|
||||
import platform
|
||||
from collections import OrderedDict
|
||||
from contextlib import asynccontextmanager, nullcontext
|
||||
from pathlib import Path
|
||||
|
||||
@@ -121,6 +122,7 @@ class RadioManager:
|
||||
def __init__(self):
|
||||
self._meshcore: MeshCore | None = None
|
||||
self._connection_info: str | None = None
|
||||
self._connection_desired: bool = True
|
||||
self._reconnect_task: asyncio.Task | None = None
|
||||
self._last_connected: bool = False
|
||||
self._reconnect_lock: asyncio.Lock | None = None
|
||||
@@ -128,8 +130,12 @@ class RadioManager:
|
||||
self._setup_lock: asyncio.Lock | None = None
|
||||
self._setup_in_progress: bool = False
|
||||
self._setup_complete: bool = False
|
||||
self.max_channels: int = 40
|
||||
self.path_hash_mode: int = 0
|
||||
self.path_hash_mode_supported: bool = False
|
||||
self._channel_slot_by_key: OrderedDict[str, int] = OrderedDict()
|
||||
self._channel_key_by_slot: dict[int, str] = {}
|
||||
self._pending_message_channel_key_by_slot: dict[int, str] = {}
|
||||
|
||||
async def _acquire_operation_lock(
|
||||
self,
|
||||
@@ -222,6 +228,121 @@ class RadioManager:
|
||||
|
||||
await run_post_connect_setup(self)
|
||||
|
||||
def reset_channel_send_cache(self) -> None:
|
||||
"""Forget any session-local channel-slot reuse state."""
|
||||
self._channel_slot_by_key.clear()
|
||||
self._channel_key_by_slot.clear()
|
||||
|
||||
def remember_pending_message_channel_slot(self, channel_key: str, slot: int) -> None:
|
||||
"""Remember a channel key for later queued-message recovery."""
|
||||
self._pending_message_channel_key_by_slot[slot] = channel_key.upper()
|
||||
|
||||
def get_pending_message_channel_key(self, slot: int) -> str | None:
|
||||
"""Return the last remembered channel key for a radio slot."""
|
||||
return self._pending_message_channel_key_by_slot.get(slot)
|
||||
|
||||
def clear_pending_message_channel_slots(self) -> None:
|
||||
"""Drop any queued-message recovery slot metadata."""
|
||||
self._pending_message_channel_key_by_slot.clear()
|
||||
|
||||
def channel_slot_reuse_enabled(self) -> bool:
|
||||
"""Return whether this transport can safely reuse cached channel slots."""
|
||||
if settings.force_channel_slot_reconfigure:
|
||||
return False
|
||||
if self._connection_info:
|
||||
return not self._connection_info.startswith("TCP:")
|
||||
return settings.connection_type != "tcp"
|
||||
|
||||
def get_channel_send_cache_capacity(self) -> int:
|
||||
"""Return the app-managed channel cache capacity for the current session."""
|
||||
try:
|
||||
return max(1, int(self.max_channels))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
def get_cached_channel_slot(self, channel_key: str) -> int | None:
|
||||
"""Return the cached radio slot for a channel key, if present."""
|
||||
return self._channel_slot_by_key.get(channel_key.upper())
|
||||
|
||||
def plan_channel_send_slot(
|
||||
self,
|
||||
channel_key: str,
|
||||
*,
|
||||
preferred_slot: int = 0,
|
||||
) -> tuple[int, bool, str | None]:
|
||||
"""Choose a radio slot for a channel send.
|
||||
|
||||
Returns `(slot, needs_configure, evicted_channel_key)`.
|
||||
"""
|
||||
if not self.channel_slot_reuse_enabled():
|
||||
return preferred_slot, True, None
|
||||
|
||||
normalized_key = channel_key.upper()
|
||||
cached_slot = self._channel_slot_by_key.get(normalized_key)
|
||||
if cached_slot is not None:
|
||||
return cached_slot, False, None
|
||||
|
||||
capacity = self.get_channel_send_cache_capacity()
|
||||
if len(self._channel_slot_by_key) < capacity:
|
||||
slot = self._find_first_free_channel_slot(capacity, preferred_slot)
|
||||
return slot, True, None
|
||||
|
||||
evicted_key, slot = next(iter(self._channel_slot_by_key.items()))
|
||||
return slot, True, evicted_key
|
||||
|
||||
def note_channel_slot_loaded(self, channel_key: str, slot: int) -> None:
|
||||
"""Record that a channel is now resident in the given radio slot."""
|
||||
if not self.channel_slot_reuse_enabled():
|
||||
return
|
||||
|
||||
normalized_key = channel_key.upper()
|
||||
previous_slot = self._channel_slot_by_key.pop(normalized_key, None)
|
||||
if previous_slot is not None and previous_slot != slot:
|
||||
self._channel_key_by_slot.pop(previous_slot, None)
|
||||
|
||||
displaced_key = self._channel_key_by_slot.get(slot)
|
||||
if displaced_key is not None and displaced_key != normalized_key:
|
||||
self._channel_slot_by_key.pop(displaced_key, None)
|
||||
|
||||
self._channel_key_by_slot[slot] = normalized_key
|
||||
self._channel_slot_by_key[normalized_key] = slot
|
||||
|
||||
def note_channel_slot_used(self, channel_key: str) -> None:
|
||||
"""Refresh LRU order for a previously loaded channel slot."""
|
||||
if not self.channel_slot_reuse_enabled():
|
||||
return
|
||||
|
||||
normalized_key = channel_key.upper()
|
||||
slot = self._channel_slot_by_key.get(normalized_key)
|
||||
if slot is None:
|
||||
return
|
||||
self._channel_slot_by_key.move_to_end(normalized_key)
|
||||
self._channel_key_by_slot[slot] = normalized_key
|
||||
|
||||
def invalidate_cached_channel_slot(self, channel_key: str) -> None:
|
||||
"""Drop any cached slot assignment for a channel key."""
|
||||
normalized_key = channel_key.upper()
|
||||
slot = self._channel_slot_by_key.pop(normalized_key, None)
|
||||
if slot is None:
|
||||
return
|
||||
if self._channel_key_by_slot.get(slot) == normalized_key:
|
||||
self._channel_key_by_slot.pop(slot, None)
|
||||
|
||||
def get_channel_send_cache_snapshot(self) -> list[tuple[str, int]]:
|
||||
"""Return the current channel send cache contents in LRU order."""
|
||||
return list(self._channel_slot_by_key.items())
|
||||
|
||||
def _find_first_free_channel_slot(self, capacity: int, preferred_slot: int) -> int:
|
||||
"""Pick the first unclaimed app-managed slot, preferring the requested slot."""
|
||||
if preferred_slot < capacity and preferred_slot not in self._channel_key_by_slot:
|
||||
return preferred_slot
|
||||
|
||||
for slot in range(capacity):
|
||||
if slot not in self._channel_key_by_slot:
|
||||
return slot
|
||||
|
||||
return preferred_slot
|
||||
|
||||
@property
|
||||
def meshcore(self) -> MeshCore | None:
|
||||
return self._meshcore
|
||||
@@ -246,6 +367,41 @@ class RadioManager:
|
||||
def is_setup_complete(self) -> bool:
|
||||
return self._setup_complete
|
||||
|
||||
@property
|
||||
def connection_desired(self) -> bool:
|
||||
return self._connection_desired
|
||||
|
||||
def resume_connection(self) -> None:
|
||||
"""Allow connection monitor and manual reconnects to establish transport again."""
|
||||
self._connection_desired = True
|
||||
|
||||
async def pause_connection(self) -> None:
|
||||
"""Stop automatic reconnect attempts and tear down any current transport."""
|
||||
self._connection_desired = False
|
||||
self._last_connected = False
|
||||
await self.disconnect()
|
||||
|
||||
async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None:
|
||||
"""Disable library-managed reconnects so manual teardown fully releases transport."""
|
||||
connection_manager = getattr(mc, "connection_manager", None)
|
||||
if connection_manager is None:
|
||||
return
|
||||
|
||||
if hasattr(connection_manager, "auto_reconnect"):
|
||||
connection_manager.auto_reconnect = False
|
||||
|
||||
reconnect_task = getattr(connection_manager, "_reconnect_task", None)
|
||||
if reconnect_task is None or not isinstance(reconnect_task, asyncio.Task | asyncio.Future):
|
||||
return
|
||||
|
||||
reconnect_task.cancel()
|
||||
try:
|
||||
await reconnect_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
connection_manager._reconnect_task = None
|
||||
|
||||
async def connect(self) -> None:
|
||||
"""Connect to the radio using the configured transport."""
|
||||
if self._meshcore is not None:
|
||||
@@ -324,11 +480,17 @@ class RadioManager:
|
||||
"""Disconnect from the radio."""
|
||||
if self._meshcore is not None:
|
||||
logger.debug("Disconnecting from radio")
|
||||
await self._meshcore.disconnect()
|
||||
mc = self._meshcore
|
||||
await self._disable_meshcore_auto_reconnect(mc)
|
||||
await mc.disconnect()
|
||||
await self._disable_meshcore_auto_reconnect(mc)
|
||||
self._meshcore = None
|
||||
self._setup_complete = False
|
||||
self.max_channels = 40
|
||||
self.path_hash_mode = 0
|
||||
self.path_hash_mode_supported = False
|
||||
self.reset_channel_send_cache()
|
||||
self.clear_pending_message_channel_slots()
|
||||
logger.debug("Radio disconnected")
|
||||
|
||||
async def reconnect(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
@@ -344,6 +506,10 @@ class RadioManager:
|
||||
self._reconnect_lock = asyncio.Lock()
|
||||
|
||||
async with self._reconnect_lock:
|
||||
if not self._connection_desired:
|
||||
logger.info("Reconnect skipped because connection is paused by operator")
|
||||
return False
|
||||
|
||||
# If we became connected while waiting for the lock (another
|
||||
# reconnect succeeded ahead of us), skip the redundant attempt.
|
||||
if self.is_connected:
|
||||
@@ -364,6 +530,11 @@ class RadioManager:
|
||||
# Try to connect (will auto-detect if no port specified)
|
||||
await self.connect()
|
||||
|
||||
if not self._connection_desired:
|
||||
logger.info("Reconnect completed after pause request; disconnecting transport")
|
||||
await self.disconnect()
|
||||
return False
|
||||
|
||||
if self.is_connected:
|
||||
logger.info("Radio reconnected successfully at %s", self._connection_info)
|
||||
if broadcast_on_success:
|
||||
|
||||
@@ -28,11 +28,14 @@ from app.repository import (
|
||||
ContactRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.messages import create_fallback_channel_message
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_error
|
||||
from app.websocket import broadcast_error, broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_MAX_CHANNELS = 40
|
||||
|
||||
|
||||
def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
"""Return key contact fields for sync failure diagnostics."""
|
||||
@@ -48,7 +51,6 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
"last_advert": contact.last_advert,
|
||||
"lat": contact.lat,
|
||||
"lon": contact.lon,
|
||||
"on_radio": contact.on_radio,
|
||||
}
|
||||
|
||||
|
||||
@@ -98,6 +100,20 @@ async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> st
|
||||
return key_hex
|
||||
|
||||
|
||||
def get_radio_channel_limit(max_channels: int | None = None) -> int:
|
||||
"""Return the effective channel-slot limit for the connected firmware."""
|
||||
discovered = getattr(radio_manager, "max_channels", DEFAULT_MAX_CHANNELS)
|
||||
try:
|
||||
limit = max(1, int(discovered))
|
||||
except (TypeError, ValueError):
|
||||
limit = DEFAULT_MAX_CHANNELS
|
||||
|
||||
if max_channels is not None:
|
||||
return min(limit, max(1, int(max_channels)))
|
||||
|
||||
return limit
|
||||
|
||||
|
||||
# Message poll task handle
|
||||
_message_poll_task: asyncio.Task | None = None
|
||||
|
||||
@@ -285,7 +301,7 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
return {"synced": synced, "removed": removed}
|
||||
|
||||
|
||||
async def sync_and_offload_channels(mc: MeshCore) -> dict:
|
||||
async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> dict:
|
||||
"""
|
||||
Sync channels from radio to database, then clear them from radio.
|
||||
Returns counts of synced and cleared channels.
|
||||
@@ -294,8 +310,11 @@ async def sync_and_offload_channels(mc: MeshCore) -> dict:
|
||||
cleared = 0
|
||||
|
||||
try:
|
||||
# Check all 40 channel slots
|
||||
for idx in range(40):
|
||||
radio_manager.reset_channel_send_cache()
|
||||
channel_limit = get_radio_channel_limit(max_channels)
|
||||
|
||||
# Check all available channel slots for this firmware variant
|
||||
for idx in range(channel_limit):
|
||||
result = await mc.commands.get_channel(idx)
|
||||
|
||||
if result.type != EventType.CHANNEL_INFO:
|
||||
@@ -308,6 +327,7 @@ async def sync_and_offload_channels(mc: MeshCore) -> dict:
|
||||
if key_hex is None:
|
||||
continue
|
||||
|
||||
radio_manager.remember_pending_message_channel_slot(key_hex, idx)
|
||||
synced += 1
|
||||
logger.debug("Synced channel %s: %s", key_hex[:8], result.payload.get("channel_name"))
|
||||
|
||||
@@ -334,12 +354,94 @@ async def sync_and_offload_channels(mc: MeshCore) -> dict:
|
||||
return {"synced": synced, "cleared": cleared}
|
||||
|
||||
|
||||
def _split_channel_sender_and_text(text: str) -> tuple[str | None, str]:
|
||||
"""Parse the canonical MeshCore "<sender>: <message>" channel text format."""
|
||||
sender = None
|
||||
message_text = text
|
||||
colon_idx = text.find(": ")
|
||||
if 0 < colon_idx < 50:
|
||||
potential_sender = text[:colon_idx]
|
||||
if not any(char in potential_sender for char in ":[]\x00"):
|
||||
sender = potential_sender
|
||||
message_text = text[colon_idx + 2 :]
|
||||
return sender, message_text
|
||||
|
||||
|
||||
async def _resolve_channel_for_pending_message(
|
||||
mc: MeshCore,
|
||||
channel_idx: int,
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Resolve a pending channel message's slot to a channel key and name."""
|
||||
try:
|
||||
result = await mc.commands.get_channel(channel_idx)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to fetch channel slot %s for pending message: %s", channel_idx, exc)
|
||||
else:
|
||||
if result.type == EventType.CHANNEL_INFO:
|
||||
key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=False)
|
||||
if key_hex is not None:
|
||||
radio_manager.remember_pending_message_channel_slot(key_hex, channel_idx)
|
||||
return key_hex, result.payload.get("channel_name") or None
|
||||
|
||||
current_slot_map = getattr(radio_manager, "_channel_key_by_slot", {})
|
||||
cached_key = current_slot_map.get(channel_idx)
|
||||
if cached_key is None:
|
||||
cached_key = radio_manager.get_pending_message_channel_key(channel_idx)
|
||||
if cached_key is None:
|
||||
return None, None
|
||||
|
||||
channel = await ChannelRepository.get_by_key(cached_key)
|
||||
return cached_key, channel.name if channel else None
|
||||
|
||||
|
||||
async def _store_pending_channel_message(mc: MeshCore, payload: dict) -> None:
|
||||
"""Persist a CHANNEL_MSG_RECV event pulled via get_msg()."""
|
||||
channel_idx = payload.get("channel_idx")
|
||||
if channel_idx is None:
|
||||
logger.warning("Pending channel message missing channel_idx; dropping payload")
|
||||
return
|
||||
|
||||
try:
|
||||
normalized_channel_idx = int(channel_idx)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("Pending channel message had invalid channel_idx=%r", channel_idx)
|
||||
return
|
||||
|
||||
channel_key, channel_name = await _resolve_channel_for_pending_message(
|
||||
mc, normalized_channel_idx
|
||||
)
|
||||
if channel_key is None:
|
||||
logger.warning(
|
||||
"Could not resolve channel slot %d for pending message; message cannot be stored",
|
||||
normalized_channel_idx,
|
||||
)
|
||||
return
|
||||
|
||||
received_at = int(time.time())
|
||||
sender_timestamp = payload.get("sender_timestamp") or received_at
|
||||
sender_name, message_text = _split_channel_sender_and_text(payload.get("text", ""))
|
||||
|
||||
await create_fallback_channel_message(
|
||||
conversation_key=channel_key,
|
||||
message_text=message_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=payload.get("path"),
|
||||
path_len=payload.get("path_len"),
|
||||
txt_type=payload.get("txt_type", 0),
|
||||
sender_name=sender_name,
|
||||
channel_name=channel_name,
|
||||
broadcast_fn=broadcast_event,
|
||||
)
|
||||
|
||||
|
||||
async def ensure_default_channels() -> None:
|
||||
"""
|
||||
Ensure default channels exist in the database.
|
||||
These will be configured on the radio when needed for sending.
|
||||
|
||||
The Public channel is protected - it always exists with the canonical name.
|
||||
This seeds the canonical Public channel row in the database if it is missing
|
||||
or misnamed. It does not make the channel undeletable through the router.
|
||||
"""
|
||||
# Public channel - no hashtag, specific well-known key
|
||||
PUBLIC_CHANNEL_KEY_HEX = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
|
||||
@@ -360,16 +462,19 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
"""Sync and offload both contacts and channels, then ensure defaults exist."""
|
||||
logger.info("Starting full radio sync and offload")
|
||||
|
||||
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
||||
# cycle so old rows stop claiming radio residency we do not actively track.
|
||||
await ContactRepository.clear_on_radio_except([])
|
||||
|
||||
contacts_result = await sync_and_offload_contacts(mc)
|
||||
channels_result = await sync_and_offload_channels(mc)
|
||||
|
||||
# Ensure default channels exist
|
||||
await ensure_default_channels()
|
||||
|
||||
# Reload favorites plus a working-set fill back onto the radio immediately
|
||||
# so they do not stay in on_radio=False limbo after offload. Pass mc directly
|
||||
# since the caller already holds the radio operation lock (asyncio.Lock is not
|
||||
# reentrant).
|
||||
# Reload favorites plus a working-set fill back onto the radio immediately.
|
||||
# Pass mc directly since the caller already holds the radio operation lock
|
||||
# (asyncio.Lock is not reentrant).
|
||||
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
|
||||
|
||||
return {
|
||||
@@ -399,6 +504,8 @@ async def drain_pending_messages(mc: MeshCore) -> int:
|
||||
logger.debug("Error during message drain: %s", result.payload)
|
||||
break
|
||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
await _store_pending_channel_message(mc, result.payload)
|
||||
count += 1
|
||||
|
||||
# Small delay between fetches
|
||||
@@ -434,6 +541,8 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
||||
elif result.type == EventType.ERROR:
|
||||
return 0
|
||||
elif result.type in (EventType.CONTACT_MSG_RECV, EventType.CHANNEL_MSG_RECV):
|
||||
if result.type == EventType.CHANNEL_MSG_RECV:
|
||||
await _store_pending_channel_message(mc, result.payload)
|
||||
count += 1
|
||||
# If we got a message, there might be more - drain them
|
||||
count += await drain_pending_messages(mc)
|
||||
@@ -446,6 +555,63 @@ async def poll_for_messages(mc: MeshCore) -> int:
|
||||
return count
|
||||
|
||||
|
||||
def _normalize_channel_secret(payload: dict) -> bytes:
|
||||
"""Return a normalized bytes representation of a radio channel secret."""
|
||||
secret = payload.get("channel_secret", b"")
|
||||
if isinstance(secret, bytes):
|
||||
return secret
|
||||
return bytes(secret)
|
||||
|
||||
|
||||
async def audit_channel_send_cache(mc: MeshCore) -> bool:
|
||||
"""Verify cached send-slot expectations still match radio channel contents.
|
||||
|
||||
If a mismatch is detected, the app's send-slot cache is reset so future sends
|
||||
fall back to reloading channels before reuse resumes.
|
||||
"""
|
||||
if not radio_manager.channel_slot_reuse_enabled():
|
||||
return True
|
||||
|
||||
cached_slots = radio_manager.get_channel_send_cache_snapshot()
|
||||
if not cached_slots:
|
||||
return True
|
||||
|
||||
mismatches: list[str] = []
|
||||
for channel_key, slot in cached_slots:
|
||||
result = await mc.commands.get_channel(slot)
|
||||
if result.type != EventType.CHANNEL_INFO:
|
||||
mismatches.append(
|
||||
f"slot {slot}: expected {channel_key[:8]} but radio returned {result.type}"
|
||||
)
|
||||
continue
|
||||
|
||||
observed_name = result.payload.get("channel_name") or ""
|
||||
observed_key = _normalize_channel_secret(result.payload).hex().upper()
|
||||
expected_channel = await ChannelRepository.get_by_key(channel_key)
|
||||
expected_name = expected_channel.name if expected_channel is not None else None
|
||||
|
||||
if observed_key != channel_key or expected_name is None or observed_name != expected_name:
|
||||
mismatches.append(
|
||||
f"slot {slot}: expected {expected_name or '(missing db row)'} "
|
||||
f"{channel_key[:8]}, got {observed_name or '(empty)'} {observed_key[:8]}"
|
||||
)
|
||||
|
||||
if not mismatches:
|
||||
return True
|
||||
|
||||
logger.error(
|
||||
"[RADIO SYNC ERROR] A periodic radio audit discovered that the channel send-slot cache fell out of sync with radio state. This indicates that some other system, internal or external to the radio, has updated the channel slots on the radio (which the app assumes it has exclusive rights to, except on TCP-linked devices). The cache is resetting now, but you should review the README.md and consider using the environment variable MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true to make the radio use non-optimistic channel management and force-write the channel to radio before each send. This is a minor performance hit, but guarantees consistency. Mismatches found: %s",
|
||||
"; ".join(mismatches),
|
||||
)
|
||||
radio_manager.reset_channel_send_cache()
|
||||
broadcast_error(
|
||||
"A periodic poll task has discovered radio inconsistencies.",
|
||||
"Please check the logs for recommendations (search "
|
||||
"'MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE').",
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def _message_poll_loop():
|
||||
"""Background task that periodically polls for messages."""
|
||||
while True:
|
||||
@@ -463,6 +629,7 @@ async def _message_poll_loop():
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
count = await poll_for_messages(mc)
|
||||
await audit_channel_send_cache(mc)
|
||||
if count > 0:
|
||||
if aggressive_fallback:
|
||||
logger.warning(
|
||||
@@ -471,10 +638,10 @@ async def _message_poll_loop():
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Periodic radio audit caught %d message(s) that were not "
|
||||
"surfaced via event subscription. See README and consider "
|
||||
"[RADIO SYNC ERROR] Periodic radio audit caught %d message(s) that were not "
|
||||
"surfaced via event subscription. This means that the method of event (new contacts, messages, etc.) awareness we want isn't giving us everything. There is a fallback method available; see README.md and consider "
|
||||
"setting MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true to "
|
||||
"enable more frequent polling.",
|
||||
"enable active radio polling every few seconds.",
|
||||
count,
|
||||
)
|
||||
broadcast_error(
|
||||
@@ -604,7 +771,6 @@ async def _periodic_advert_loop():
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error("Error in periodic advertisement loop: %s", e, exc_info=True)
|
||||
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
|
||||
|
||||
|
||||
def start_periodic_advert():
|
||||
@@ -699,20 +865,8 @@ _last_contact_sync: float = 0.0
|
||||
CONTACT_SYNC_THROTTLE_SECONDS = 30 # Don't sync more than once per 30 seconds
|
||||
|
||||
|
||||
async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
"""
|
||||
Core logic for loading contacts onto the radio.
|
||||
|
||||
Fill order is:
|
||||
1. Favorite contacts
|
||||
2. Most recently interacted-with non-repeaters
|
||||
3. Most recently advert-heard non-repeaters without interaction history
|
||||
|
||||
Favorite contacts are always reloaded first, up to the configured capacity.
|
||||
Additional non-favorite fill stops at the refill target (80% of capacity).
|
||||
|
||||
Caller must hold the radio operation lock and pass a valid MeshCore instance.
|
||||
"""
|
||||
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||
"""Return the contacts that would be loaded onto the radio right now."""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
max_contacts = app_settings.max_radio_contacts
|
||||
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||
@@ -733,6 +887,12 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
continue
|
||||
if not contact:
|
||||
continue
|
||||
if len(contact.public_key) < 64:
|
||||
logger.debug(
|
||||
"Skipping unresolved prefix-only favorite contact '%s' for radio sync",
|
||||
favorite.id,
|
||||
)
|
||||
continue
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
@@ -773,6 +933,24 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
refill_target,
|
||||
max_contacts,
|
||||
)
|
||||
return selected_contacts
|
||||
|
||||
|
||||
async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
"""
|
||||
Core logic for loading contacts onto the radio.
|
||||
|
||||
Fill order is:
|
||||
1. Favorite contacts
|
||||
2. Most recently interacted-with non-repeaters
|
||||
3. Most recently advert-heard non-repeaters without interaction history
|
||||
|
||||
Favorite contacts are always reloaded first, up to the configured capacity.
|
||||
Additional non-favorite fill stops at the refill target (80% of capacity).
|
||||
|
||||
Caller must hold the radio operation lock and pass a valid MeshCore instance.
|
||||
"""
|
||||
selected_contacts = await get_contacts_selected_for_radio_sync()
|
||||
return await _load_contacts_to_radio(mc, selected_contacts)
|
||||
|
||||
|
||||
@@ -802,6 +980,9 @@ async def ensure_contact_on_radio(
|
||||
if not contact:
|
||||
logger.debug("Cannot sync favorite contact %s: not found", public_key[:12])
|
||||
return {"loaded": 0, "error": "Contact not found"}
|
||||
if len(contact.public_key) < 64:
|
||||
logger.debug("Cannot sync unresolved prefix-only contact %s to radio", public_key)
|
||||
return {"loaded": 0, "error": "Full contact key not yet known"}
|
||||
|
||||
if mc is not None:
|
||||
_last_contact_sync = now
|
||||
@@ -834,11 +1015,14 @@ async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict
|
||||
failed = 0
|
||||
|
||||
for contact in contacts:
|
||||
if len(contact.public_key) < 64:
|
||||
logger.debug(
|
||||
"Skipping unresolved prefix-only contact %s during radio load", contact.public_key
|
||||
)
|
||||
continue
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
already_on_radio += 1
|
||||
if not contact.on_radio:
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
continue
|
||||
|
||||
try:
|
||||
@@ -846,7 +1030,6 @@ async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict
|
||||
result = await mc.commands.add_contact(radio_contact_payload)
|
||||
if result.type == EventType.OK:
|
||||
loaded += 1
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
logger.debug("Loaded contact %s to radio", contact.public_key[:12])
|
||||
else:
|
||||
failed += 1
|
||||
|
||||
@@ -66,6 +66,30 @@ class ChannelRepository:
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_on_radio() -> list[Channel]:
|
||||
"""Return channels currently marked as resident on the radio in the database."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
|
||||
FROM channels
|
||||
WHERE on_radio = 1
|
||||
ORDER BY name
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [
|
||||
Channel(
|
||||
key=row["key"],
|
||||
name=row["name"],
|
||||
is_hashtag=bool(row["is_hashtag"]),
|
||||
on_radio=bool(row["on_radio"]),
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> None:
|
||||
"""Delete a channel by key."""
|
||||
|
||||
@@ -168,6 +168,9 @@ class ContactRepository:
|
||||
the prefix (to avoid silently selecting the wrong contact).
|
||||
"""
|
||||
normalized_prefix = prefix.lower()
|
||||
exact = await ContactRepository.get_by_key(normalized_prefix)
|
||||
if exact:
|
||||
return exact
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT 2",
|
||||
(f"{normalized_prefix}%",),
|
||||
@@ -258,7 +261,7 @@ class ContactRepository:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2 AND last_contacted IS NOT NULL
|
||||
WHERE type != 2 AND last_contacted IS NOT NULL AND length(public_key) = 64
|
||||
ORDER BY last_contacted DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
@@ -273,7 +276,7 @@ class ContactRepository:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2 AND last_advert IS NOT NULL
|
||||
WHERE type != 2 AND last_advert IS NOT NULL AND length(public_key) = 64
|
||||
ORDER BY last_advert DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
@@ -349,14 +352,6 @@ class ContactRepository:
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def set_on_radio(public_key: str, on_radio: bool) -> None:
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET on_radio = ? WHERE public_key = ?",
|
||||
(on_radio, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def clear_on_radio_except(keep_keys: list[str]) -> None:
|
||||
"""Set on_radio=False for all contacts NOT in keep_keys."""
|
||||
@@ -406,6 +401,103 @@ class ContactRepository:
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def promote_prefix_placeholders(full_key: str) -> list[str]:
|
||||
"""Promote prefix-only placeholder contacts to a resolved full key.
|
||||
|
||||
Returns the placeholder public keys that were merged into the full key.
|
||||
"""
|
||||
normalized_full_key = full_key.lower()
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT public_key, last_seen, last_contacted, first_seen, last_read_at
|
||||
FROM contacts
|
||||
WHERE length(public_key) < 64
|
||||
AND ? LIKE public_key || '%'
|
||||
ORDER BY length(public_key) DESC, public_key
|
||||
""",
|
||||
(normalized_full_key,),
|
||||
)
|
||||
rows = list(await cursor.fetchall())
|
||||
if not rows:
|
||||
return []
|
||||
|
||||
promoted_keys: list[str] = []
|
||||
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
|
||||
|
||||
for row in rows:
|
||||
old_key = row["public_key"]
|
||||
if old_key == normalized_full_key:
|
||||
continue
|
||||
|
||||
match_cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(*) AS match_count
|
||||
FROM contacts
|
||||
WHERE length(public_key) = 64
|
||||
AND public_key LIKE ? || '%'
|
||||
""",
|
||||
(old_key,),
|
||||
)
|
||||
match_row = await match_cursor.fetchone()
|
||||
if (match_row["match_count"] if match_row is not None else 0) != 1:
|
||||
continue
|
||||
|
||||
if full_exists:
|
||||
await db.conn.execute(
|
||||
"""
|
||||
UPDATE contacts
|
||||
SET last_seen = CASE
|
||||
WHEN contacts.last_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_seen
|
||||
WHEN ? > contacts.last_seen THEN ?
|
||||
ELSE contacts.last_seen
|
||||
END,
|
||||
last_contacted = CASE
|
||||
WHEN contacts.last_contacted IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.last_contacted
|
||||
WHEN ? > contacts.last_contacted THEN ?
|
||||
ELSE contacts.last_contacted
|
||||
END,
|
||||
first_seen = CASE
|
||||
WHEN contacts.first_seen IS NULL THEN ?
|
||||
WHEN ? IS NULL THEN contacts.first_seen
|
||||
WHEN ? < contacts.first_seen THEN ?
|
||||
ELSE contacts.first_seen
|
||||
END,
|
||||
last_read_at = COALESCE(contacts.last_read_at, ?)
|
||||
WHERE public_key = ?
|
||||
""",
|
||||
(
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_seen"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["last_contacted"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["first_seen"],
|
||||
row["last_read_at"],
|
||||
normalized_full_key,
|
||||
),
|
||||
)
|
||||
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
|
||||
else:
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
|
||||
(normalized_full_key, old_key),
|
||||
)
|
||||
full_exists = True
|
||||
|
||||
promoted_keys.append(old_key)
|
||||
|
||||
await db.conn.commit()
|
||||
return promoted_keys
|
||||
|
||||
@staticmethod
|
||||
async def mark_all_read(timestamp: int) -> None:
|
||||
"""Mark all contacts as read at the given timestamp."""
|
||||
|
||||
@@ -1,12 +1,43 @@
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
from app.models import Message, MessagePath
|
||||
from app.models import (
|
||||
ContactAnalyticsHourlyBucket,
|
||||
ContactAnalyticsWeeklyBucket,
|
||||
Message,
|
||||
MessagePath,
|
||||
)
|
||||
|
||||
|
||||
class MessageRepository:
|
||||
@dataclass
|
||||
class _SearchQuery:
|
||||
free_text: str
|
||||
user_terms: list[str]
|
||||
channel_terms: list[str]
|
||||
|
||||
_SEARCH_OPERATOR_RE = re.compile(
|
||||
r'(?<!\S)(user|channel):(?:"((?:[^"\\]|\\.)*)"|(\S+))',
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
|
||||
lower_key = public_key.lower()
|
||||
return (
|
||||
"((type = 'PRIV' AND LOWER(conversation_key) = ?)"
|
||||
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
|
||||
[lower_key, lower_key],
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _name_activity_filter(sender_name: str) -> tuple[str, list[Any]]:
|
||||
return "type = 'CHAN' AND sender_name = ?", [sender_name]
|
||||
|
||||
@staticmethod
|
||||
def _parse_paths(paths_json: str | None) -> list[MessagePath] | None:
|
||||
"""Parse paths JSON string to list of MessagePath objects."""
|
||||
@@ -131,7 +162,8 @@ class MessageRepository:
|
||||
AND ? LIKE conversation_key || '%'
|
||||
AND (
|
||||
SELECT COUNT(*) FROM contacts
|
||||
WHERE public_key LIKE messages.conversation_key || '%'
|
||||
WHERE length(public_key) = 64
|
||||
AND public_key LIKE messages.conversation_key || '%'
|
||||
) = 1""",
|
||||
(lower_key, lower_key),
|
||||
)
|
||||
@@ -148,8 +180,16 @@ class MessageRepository:
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""UPDATE messages SET sender_key = ?
|
||||
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL""",
|
||||
(public_key.lower(), name),
|
||||
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL
|
||||
AND (
|
||||
SELECT COUNT(*) FROM contacts
|
||||
WHERE name = ?
|
||||
) = 1
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM contacts
|
||||
WHERE public_key = ? AND name = ?
|
||||
)""",
|
||||
(public_key.lower(), name, name, public_key.lower(), name),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount
|
||||
@@ -167,6 +207,126 @@ class MessageRepository:
|
||||
else:
|
||||
return "AND conversation_key LIKE ?", f"{conversation_key}%"
|
||||
|
||||
@staticmethod
|
||||
def _unescape_search_quoted_value(value: str) -> str:
|
||||
return value.replace('\\"', '"').replace("\\\\", "\\")
|
||||
|
||||
@staticmethod
|
||||
def _parse_search_query(q: str) -> _SearchQuery:
|
||||
user_terms: list[str] = []
|
||||
channel_terms: list[str] = []
|
||||
fragments: list[str] = []
|
||||
last_end = 0
|
||||
|
||||
for match in MessageRepository._SEARCH_OPERATOR_RE.finditer(q):
|
||||
fragments.append(q[last_end : match.start()])
|
||||
raw_value = match.group(2) if match.group(2) is not None else match.group(3) or ""
|
||||
value = MessageRepository._unescape_search_quoted_value(raw_value)
|
||||
if match.group(1).lower() == "user":
|
||||
user_terms.append(value)
|
||||
else:
|
||||
channel_terms.append(value)
|
||||
last_end = match.end()
|
||||
|
||||
if not user_terms and not channel_terms:
|
||||
return MessageRepository._SearchQuery(free_text=q, user_terms=[], channel_terms=[])
|
||||
|
||||
fragments.append(q[last_end:])
|
||||
free_text = " ".join(fragment.strip() for fragment in fragments if fragment.strip())
|
||||
return MessageRepository._SearchQuery(
|
||||
free_text=free_text,
|
||||
user_terms=user_terms,
|
||||
channel_terms=channel_terms,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _escape_like(value: str) -> str:
|
||||
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_hex_prefix(value: str) -> bool:
|
||||
return bool(value) and all(ch in "0123456789abcdefABCDEF" for ch in value)
|
||||
|
||||
@staticmethod
|
||||
def _build_channel_scope_clause(value: str) -> tuple[str, list[Any]]:
|
||||
params: list[Any] = [value]
|
||||
clause = "(messages.type = 'CHAN' AND (channels.name = ? COLLATE NOCASE"
|
||||
|
||||
if MessageRepository._looks_like_hex_prefix(value):
|
||||
if len(value) == 32:
|
||||
clause += " OR UPPER(messages.conversation_key) = ?"
|
||||
params.append(value.upper())
|
||||
else:
|
||||
clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
||||
params.append(f"{MessageRepository._escape_like(value.upper())}%")
|
||||
|
||||
clause += "))"
|
||||
return clause, params
|
||||
|
||||
@staticmethod
|
||||
def _build_user_scope_clause(value: str) -> tuple[str, list[Any]]:
|
||||
params: list[Any] = [value, value]
|
||||
clause = (
|
||||
"((messages.type = 'PRIV' AND contacts.name = ? COLLATE NOCASE)"
|
||||
" OR (messages.type = 'CHAN' AND sender_name = ? COLLATE NOCASE)"
|
||||
)
|
||||
|
||||
if MessageRepository._looks_like_hex_prefix(value):
|
||||
lower_value = value.lower()
|
||||
priv_key_clause: str
|
||||
chan_key_clause: str
|
||||
if len(value) == 64:
|
||||
priv_key_clause = "LOWER(messages.conversation_key) = ?"
|
||||
chan_key_clause = "LOWER(sender_key) = ?"
|
||||
params.extend([lower_value, lower_value])
|
||||
else:
|
||||
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
|
||||
priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'"
|
||||
chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'"
|
||||
params.extend([escaped_prefix, escaped_prefix])
|
||||
|
||||
clause += (
|
||||
f" OR (messages.type = 'PRIV' AND {priv_key_clause})"
|
||||
f" OR (messages.type = 'CHAN' AND sender_key IS NOT NULL AND {chan_key_clause})"
|
||||
)
|
||||
|
||||
clause += ")"
|
||||
return clause, params
|
||||
|
||||
@staticmethod
|
||||
def _build_blocked_incoming_clause(
|
||||
message_alias: str = "",
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> tuple[str, list[Any]]:
|
||||
prefix = f"{message_alias}." if message_alias else ""
|
||||
blocked_matchers: list[str] = []
|
||||
params: list[Any] = []
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
blocked_matchers.append(
|
||||
f"({prefix}type = 'PRIV' AND LOWER({prefix}conversation_key) IN ({placeholders}))"
|
||||
)
|
||||
params.extend(blocked_keys)
|
||||
blocked_matchers.append(
|
||||
f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL"
|
||||
f" AND LOWER({prefix}sender_key) IN ({placeholders}))"
|
||||
)
|
||||
params.extend(blocked_keys)
|
||||
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
blocked_matchers.append(
|
||||
f"({prefix}sender_name IS NOT NULL AND {prefix}sender_name IN ({placeholders}))"
|
||||
)
|
||||
params.extend(blocked_names)
|
||||
|
||||
if not blocked_matchers:
|
||||
return "", []
|
||||
|
||||
return f"NOT ({prefix}outgoing = 0 AND ({' OR '.join(blocked_matchers)}))", params
|
||||
|
||||
@staticmethod
|
||||
def _row_to_message(row: Any) -> Message:
|
||||
"""Convert a database row to a Message model."""
|
||||
@@ -200,53 +360,70 @@ class MessageRepository:
|
||||
blocked_keys: list[str] | None = None,
|
||||
blocked_names: list[str] | None = None,
|
||||
) -> list[Message]:
|
||||
query = "SELECT * FROM messages WHERE 1=1"
|
||||
search_query = MessageRepository._parse_search_query(q) if q else None
|
||||
query = (
|
||||
"SELECT messages.* FROM messages "
|
||||
"LEFT JOIN contacts ON messages.type = 'PRIV' "
|
||||
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
|
||||
"LEFT JOIN channels ON messages.type = 'CHAN' "
|
||||
"AND UPPER(messages.conversation_key) = UPPER(channels.key) "
|
||||
"WHERE 1=1"
|
||||
)
|
||||
params: list[Any] = []
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
query += (
|
||||
f" AND NOT (outgoing=0 AND ("
|
||||
f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))"
|
||||
f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))"
|
||||
f"))"
|
||||
)
|
||||
params.extend(blocked_keys)
|
||||
params.extend(blocked_keys)
|
||||
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
query += (
|
||||
f" AND NOT (outgoing=0 AND sender_name IS NOT NULL"
|
||||
f" AND sender_name IN ({placeholders}))"
|
||||
)
|
||||
params.extend(blocked_names)
|
||||
blocked_clause, blocked_params = MessageRepository._build_blocked_incoming_clause(
|
||||
"messages", blocked_keys, blocked_names
|
||||
)
|
||||
if blocked_clause:
|
||||
query += f" AND {blocked_clause}"
|
||||
params.extend(blocked_params)
|
||||
|
||||
if msg_type:
|
||||
query += " AND type = ?"
|
||||
query += " AND messages.type = ?"
|
||||
params.append(msg_type)
|
||||
if conversation_key:
|
||||
clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key)
|
||||
query += f" {clause}"
|
||||
query += f" {clause.replace('conversation_key', 'messages.conversation_key')}"
|
||||
params.append(norm_key)
|
||||
|
||||
if q:
|
||||
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
query += " AND text LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
if search_query and search_query.user_terms:
|
||||
scope_clauses: list[str] = []
|
||||
for term in search_query.user_terms:
|
||||
clause, clause_params = MessageRepository._build_user_scope_clause(term)
|
||||
scope_clauses.append(clause)
|
||||
params.extend(clause_params)
|
||||
query += f" AND ({' OR '.join(scope_clauses)})"
|
||||
|
||||
if search_query and search_query.channel_terms:
|
||||
scope_clauses = []
|
||||
for term in search_query.channel_terms:
|
||||
clause, clause_params = MessageRepository._build_channel_scope_clause(term)
|
||||
scope_clauses.append(clause)
|
||||
params.extend(clause_params)
|
||||
query += f" AND ({' OR '.join(scope_clauses)})"
|
||||
|
||||
if search_query and search_query.free_text:
|
||||
escaped_q = MessageRepository._escape_like(search_query.free_text)
|
||||
query += " AND messages.text LIKE ? ESCAPE '\\' COLLATE NOCASE"
|
||||
params.append(f"%{escaped_q}%")
|
||||
|
||||
# Forward cursor (after/after_id) — mutually exclusive with before/before_id
|
||||
if after is not None and after_id is not None:
|
||||
query += " AND (received_at > ? OR (received_at = ? AND id > ?))"
|
||||
query += (
|
||||
" AND (messages.received_at > ? OR (messages.received_at = ? AND messages.id > ?))"
|
||||
)
|
||||
params.extend([after, after, after_id])
|
||||
query += " ORDER BY received_at ASC, id ASC LIMIT ?"
|
||||
query += " ORDER BY messages.received_at ASC, messages.id ASC LIMIT ?"
|
||||
params.append(limit)
|
||||
else:
|
||||
if before is not None and before_id is not None:
|
||||
query += " AND (received_at < ? OR (received_at = ? AND id < ?))"
|
||||
query += (
|
||||
" AND (messages.received_at < ?"
|
||||
" OR (messages.received_at = ? AND messages.id < ?))"
|
||||
)
|
||||
params.extend([before, before, before_id])
|
||||
|
||||
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
|
||||
query += " ORDER BY messages.received_at DESC, messages.id DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
if before is None or before_id is None:
|
||||
query += " OFFSET ?"
|
||||
@@ -281,23 +458,12 @@ class MessageRepository:
|
||||
where_parts.append(clause.removeprefix("AND "))
|
||||
base_params.append(norm_key)
|
||||
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
where_parts.append(
|
||||
f"NOT (outgoing=0 AND ("
|
||||
f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))"
|
||||
f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))"
|
||||
f"))"
|
||||
)
|
||||
base_params.extend(blocked_keys)
|
||||
base_params.extend(blocked_keys)
|
||||
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
where_parts.append(
|
||||
f"NOT (outgoing=0 AND sender_name IS NOT NULL AND sender_name IN ({placeholders}))"
|
||||
)
|
||||
base_params.extend(blocked_names)
|
||||
blocked_clause, blocked_params = MessageRepository._build_blocked_incoming_clause(
|
||||
blocked_keys=blocked_keys, blocked_names=blocked_names
|
||||
)
|
||||
if blocked_clause:
|
||||
where_parts.append(blocked_clause)
|
||||
base_params.extend(blocked_params)
|
||||
|
||||
where_sql = " AND ".join(["1=1", *where_parts])
|
||||
|
||||
@@ -423,29 +589,19 @@ class MessageRepository:
|
||||
blocked_names: Display names whose messages should be excluded from counts.
|
||||
|
||||
Returns:
|
||||
Dict with 'counts', 'mentions', and 'last_message_times' keys.
|
||||
Dict with 'counts', 'mentions', 'last_message_times', and 'last_read_ats' keys.
|
||||
"""
|
||||
counts: dict[str, int] = {}
|
||||
mention_flags: dict[str, bool] = {}
|
||||
last_message_times: dict[str, int] = {}
|
||||
last_read_ats: dict[str, int | None] = {}
|
||||
|
||||
mention_token = f"@[{name}]" if name else None
|
||||
|
||||
# Build optional block-list WHERE fragments for channel messages
|
||||
chan_block_sql = ""
|
||||
chan_block_params: list[Any] = []
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
chan_block_sql += (
|
||||
f" AND NOT (m.sender_key IS NOT NULL AND LOWER(m.sender_key) IN ({placeholders}))"
|
||||
)
|
||||
chan_block_params.extend(blocked_keys)
|
||||
if blocked_names:
|
||||
placeholders = ",".join("?" for _ in blocked_names)
|
||||
chan_block_sql += (
|
||||
f" AND NOT (m.sender_name IS NOT NULL AND m.sender_name IN ({placeholders}))"
|
||||
)
|
||||
chan_block_params.extend(blocked_names)
|
||||
blocked_clause, blocked_params = MessageRepository._build_blocked_incoming_clause(
|
||||
"m", blocked_keys, blocked_names
|
||||
)
|
||||
blocked_sql = f" AND {blocked_clause}" if blocked_clause else ""
|
||||
|
||||
# Channel unreads
|
||||
cursor = await db.conn.execute(
|
||||
@@ -460,10 +616,10 @@ class MessageRepository:
|
||||
JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(c.last_read_at, 0)
|
||||
{chan_block_sql}
|
||||
{blocked_sql}
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_token or "", mention_token or "", *chan_block_params),
|
||||
(mention_token or "", mention_token or "", *blocked_params),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
@@ -472,14 +628,6 @@ class MessageRepository:
|
||||
if mention_token and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Build block-list exclusion for contact (DM) unreads
|
||||
contact_block_sql = ""
|
||||
contact_block_params: list[Any] = []
|
||||
if blocked_keys:
|
||||
placeholders = ",".join("?" for _ in blocked_keys)
|
||||
contact_block_sql += f" AND LOWER(m.conversation_key) NOT IN ({placeholders})"
|
||||
contact_block_params.extend(blocked_keys)
|
||||
|
||||
# Contact unreads
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
@@ -493,10 +641,10 @@ class MessageRepository:
|
||||
JOIN contacts ct ON m.conversation_key = ct.public_key
|
||||
WHERE m.type = 'PRIV' AND m.outgoing = 0
|
||||
AND m.received_at > COALESCE(ct.last_read_at, 0)
|
||||
{contact_block_sql}
|
||||
{blocked_sql}
|
||||
GROUP BY m.conversation_key
|
||||
""",
|
||||
(mention_token or "", mention_token or "", *contact_block_params),
|
||||
(mention_token or "", mention_token or "", *blocked_params),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
@@ -505,13 +653,41 @@ class MessageRepository:
|
||||
if mention_token and row["has_mention"]:
|
||||
mention_flags[state_key] = True
|
||||
|
||||
# Last message times for all conversations (including read ones)
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, last_read_at
|
||||
FROM channels
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
last_read_ats[f"channel-{row['key']}"] = row["last_read_at"]
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT public_key, last_read_at
|
||||
FROM contacts
|
||||
"""
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
last_read_ats[f"contact-{row['public_key']}"] = row["last_read_at"]
|
||||
|
||||
# Last message times for all conversations (including read ones),
|
||||
# excluding blocked incoming traffic so refresh matches live WS behavior.
|
||||
last_time_clause, last_time_params = MessageRepository._build_blocked_incoming_clause(
|
||||
blocked_keys=blocked_keys, blocked_names=blocked_names
|
||||
)
|
||||
last_time_where_sql = f"WHERE {last_time_clause}" if last_time_clause else ""
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
SELECT type, conversation_key, MAX(received_at) as last_message_time
|
||||
FROM messages
|
||||
{last_time_where_sql}
|
||||
GROUP BY type, conversation_key
|
||||
"""
|
||||
""",
|
||||
last_time_params,
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
for row in rows:
|
||||
@@ -523,6 +699,7 @@ class MessageRepository:
|
||||
"counts": counts,
|
||||
"mentions": mention_flags,
|
||||
"last_message_times": last_message_times,
|
||||
"last_read_ats": last_read_ats,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -545,6 +722,26 @@ class MessageRepository:
|
||||
row = await cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def count_channel_messages_by_sender_name(sender_name: str) -> int:
|
||||
"""Count channel messages attributed to a display name."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM messages WHERE type = 'CHAN' AND sender_name = ?",
|
||||
(sender_name,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_first_channel_message_by_sender_name(sender_name: str) -> int | None:
|
||||
"""Get the earliest stored channel message timestamp for a display name."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT MIN(received_at) AS first_seen FROM messages WHERE type = 'CHAN' AND sender_name = ?",
|
||||
(sender_name,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return row["first_seen"] if row and row["first_seen"] is not None else None
|
||||
|
||||
@staticmethod
|
||||
async def get_channel_stats(conversation_key: str) -> dict:
|
||||
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
|
||||
@@ -611,6 +808,19 @@ class MessageRepository:
|
||||
"top_senders_24h": top_senders,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
async def count_channels_with_incoming_messages() -> int:
|
||||
"""Count distinct channel conversations with at least one incoming message."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT COUNT(DISTINCT conversation_key) AS cnt
|
||||
FROM messages
|
||||
WHERE type = 'CHAN' AND outgoing = 0
|
||||
"""
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
return int(row["cnt"]) if row and row["cnt"] is not None else 0
|
||||
|
||||
@staticmethod
|
||||
async def get_most_active_rooms(sender_key: str, limit: int = 5) -> list[tuple[str, str, int]]:
|
||||
"""Get channels where a contact has sent the most messages.
|
||||
@@ -632,3 +842,135 @@ class MessageRepository:
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_most_active_rooms_by_sender_name(
|
||||
sender_name: str, limit: int = 5
|
||||
) -> list[tuple[str, str, int]]:
|
||||
"""Get channels where a display name has sent the most messages."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT m.conversation_key, COALESCE(c.name, m.conversation_key) AS channel_name,
|
||||
COUNT(*) AS cnt
|
||||
FROM messages m
|
||||
LEFT JOIN channels c ON m.conversation_key = c.key
|
||||
WHERE m.type = 'CHAN' AND m.sender_name = ?
|
||||
GROUP BY m.conversation_key
|
||||
ORDER BY cnt DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(sender_name, limit),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def _get_activity_hour_buckets(where_sql: str, params: list[Any]) -> dict[int, int]:
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
SELECT received_at / 3600 AS hour_bucket, COUNT(*) AS cnt
|
||||
FROM messages
|
||||
WHERE {where_sql}
|
||||
GROUP BY hour_bucket
|
||||
""",
|
||||
params,
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return {int(row["hour_bucket"]): row["cnt"] for row in rows}
|
||||
|
||||
@staticmethod
|
||||
def _build_hourly_activity(
|
||||
hour_counts: dict[int, int], now: int
|
||||
) -> list[ContactAnalyticsHourlyBucket]:
|
||||
current_hour = now // 3600
|
||||
if hour_counts:
|
||||
min_hour = min(hour_counts)
|
||||
else:
|
||||
min_hour = current_hour
|
||||
|
||||
buckets: list[ContactAnalyticsHourlyBucket] = []
|
||||
for hour_bucket in range(current_hour - 23, current_hour + 1):
|
||||
last_24h_count = hour_counts.get(hour_bucket, 0)
|
||||
|
||||
week_total = 0
|
||||
week_samples = 0
|
||||
all_time_total = 0
|
||||
all_time_samples = 0
|
||||
compare_hour = hour_bucket
|
||||
while compare_hour >= min_hour:
|
||||
count = hour_counts.get(compare_hour, 0)
|
||||
all_time_total += count
|
||||
all_time_samples += 1
|
||||
if week_samples < 7:
|
||||
week_total += count
|
||||
week_samples += 1
|
||||
compare_hour -= 24
|
||||
|
||||
buckets.append(
|
||||
ContactAnalyticsHourlyBucket(
|
||||
bucket_start=hour_bucket * 3600,
|
||||
last_24h_count=last_24h_count,
|
||||
last_week_average=round(week_total / week_samples, 2) if week_samples else 0,
|
||||
all_time_average=round(all_time_total / all_time_samples, 2)
|
||||
if all_time_samples
|
||||
else 0,
|
||||
)
|
||||
)
|
||||
return buckets
|
||||
|
||||
@staticmethod
|
||||
async def _get_weekly_activity(
|
||||
where_sql: str,
|
||||
params: list[Any],
|
||||
now: int,
|
||||
weeks: int = 26,
|
||||
) -> list[ContactAnalyticsWeeklyBucket]:
|
||||
bucket_seconds = 7 * 24 * 3600
|
||||
current_day_start = (now // 86400) * 86400
|
||||
start = current_day_start - (weeks - 1) * bucket_seconds
|
||||
|
||||
cursor = await db.conn.execute(
|
||||
f"""
|
||||
SELECT (received_at - ?) / ? AS bucket_idx, COUNT(*) AS cnt
|
||||
FROM messages
|
||||
WHERE {where_sql} AND received_at >= ?
|
||||
GROUP BY bucket_idx
|
||||
""",
|
||||
[start, bucket_seconds, *params, start],
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
counts = {int(row["bucket_idx"]): row["cnt"] for row in rows}
|
||||
|
||||
return [
|
||||
ContactAnalyticsWeeklyBucket(
|
||||
bucket_start=start + bucket_idx * bucket_seconds,
|
||||
message_count=counts.get(bucket_idx, 0),
|
||||
)
|
||||
for bucket_idx in range(weeks)
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_contact_activity_series(
|
||||
public_key: str,
|
||||
now: int | None = None,
|
||||
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
|
||||
"""Get combined DM + channel activity series for a keyed contact."""
|
||||
ts = now if now is not None else int(time.time())
|
||||
where_sql, params = MessageRepository._contact_activity_filter(public_key)
|
||||
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
|
||||
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
|
||||
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
|
||||
return hourly, weekly
|
||||
|
||||
@staticmethod
|
||||
async def get_sender_name_activity_series(
|
||||
sender_name: str,
|
||||
now: int | None = None,
|
||||
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
|
||||
"""Get channel-only activity series for a sender name."""
|
||||
ts = now if now is not None else int(time.time())
|
||||
where_sql, params = MessageRepository._name_activity_filter(sender_name)
|
||||
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
|
||||
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
|
||||
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
|
||||
return hourly, weekly
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import logging
|
||||
from hashlib import sha256
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
|
||||
from app.radio_sync import upsert_channel_from_radio_slot
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import ChannelRepository, MessageRepository
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/channels", tags=["channels"])
|
||||
|
||||
|
||||
def _broadcast_channel_update(channel: Channel) -> None:
|
||||
broadcast_event("channel", channel.model_dump())
|
||||
|
||||
|
||||
class CreateChannelRequest(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=32)
|
||||
key: str | None = Field(
|
||||
@@ -55,15 +55,6 @@ async def get_channel_detail(key: str) -> ChannelDetail:
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{key}", response_model=Channel)
|
||||
async def get_channel(key: str) -> Channel:
|
||||
"""Get a specific channel by key (32-char hex string)."""
|
||||
channel = await ChannelRepository.get_by_key(key)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
return channel
|
||||
|
||||
|
||||
@router.post("", response_model=Channel)
|
||||
async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
"""Create a channel in the database.
|
||||
@@ -98,37 +89,12 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
|
||||
on_radio=False,
|
||||
)
|
||||
|
||||
return Channel(
|
||||
key=key_hex,
|
||||
name=request.name,
|
||||
is_hashtag=is_hashtag,
|
||||
on_radio=False,
|
||||
flood_scope_override=None,
|
||||
)
|
||||
stored = await ChannelRepository.get_by_key(key_hex)
|
||||
if stored is None:
|
||||
raise HTTPException(status_code=500, detail="Channel was created but could not be reloaded")
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_channels_from_radio(max_channels: int = Query(default=40, ge=1, le=40)) -> dict:
|
||||
"""Sync channels from the radio to the database."""
|
||||
require_connected()
|
||||
|
||||
logger.info("Syncing channels from radio (checking %d slots)", max_channels)
|
||||
count = 0
|
||||
|
||||
async with radio_manager.radio_operation("sync_channels_from_radio") as mc:
|
||||
for idx in range(max_channels):
|
||||
result = await mc.commands.get_channel(idx)
|
||||
|
||||
if result.type == EventType.CHANNEL_INFO:
|
||||
key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=True)
|
||||
if key_hex is not None:
|
||||
count += 1
|
||||
logger.debug(
|
||||
"Synced channel %s: %s", key_hex, result.payload.get("channel_name")
|
||||
)
|
||||
|
||||
logger.info("Synced %d channels from radio", count)
|
||||
return {"synced": count}
|
||||
_broadcast_channel_update(stored)
|
||||
return stored
|
||||
|
||||
|
||||
@router.post("/{key}/mark-read")
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
from contextlib import suppress
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
|
||||
from meshcore import EventType
|
||||
@@ -8,13 +10,14 @@ from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactDetail,
|
||||
ContactAnalytics,
|
||||
ContactRoutingOverrideRequest,
|
||||
ContactUpsert,
|
||||
CreateContactRequest,
|
||||
NearestRepeater,
|
||||
PathDiscoveryResponse,
|
||||
PathDiscoveryRoute,
|
||||
TraceResponse,
|
||||
)
|
||||
from app.packet_processor import start_historical_dm_decryption
|
||||
@@ -26,7 +29,10 @@ from app.repository import (
|
||||
ContactRepository,
|
||||
MessageRepository,
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.contact_reconciliation import (
|
||||
promote_prefix_contacts_for_contact,
|
||||
reconcile_contact_messages,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -64,8 +70,8 @@ async def _ensure_on_radio(mc, contact: Contact) -> None:
|
||||
|
||||
|
||||
async def _best_effort_push_contact_to_radio(contact: Contact, operation_name: str) -> None:
|
||||
"""Push the current effective route to the radio when the contact is already loaded."""
|
||||
if not radio_manager.is_connected or not contact.on_radio:
|
||||
"""Best-effort push the current effective route to the radio when connected."""
|
||||
if not radio_manager.is_connected:
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -91,6 +97,121 @@ async def _broadcast_contact_update(contact: Contact) -> None:
|
||||
broadcast_event("contact", contact.model_dump())
|
||||
|
||||
|
||||
async def _broadcast_contact_resolution(previous_public_keys: list[str], contact: Contact) -> None:
|
||||
from app.websocket import broadcast_event
|
||||
|
||||
for old_key in previous_public_keys:
|
||||
broadcast_event(
|
||||
"contact_resolved",
|
||||
{
|
||||
"previous_public_key": old_key,
|
||||
"contact": contact.model_dump(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _path_hash_mode_from_hop_width(hop_width: object) -> int:
|
||||
if not isinstance(hop_width, int):
|
||||
return 0
|
||||
return max(0, min(hop_width - 1, 2))
|
||||
|
||||
|
||||
async def _build_keyed_contact_analytics(contact: Contact) -> ContactAnalytics:
|
||||
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
|
||||
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
|
||||
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
|
||||
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
|
||||
hourly_activity, weekly_activity = await MessageRepository.get_contact_activity_series(
|
||||
contact.public_key
|
||||
)
|
||||
|
||||
most_active_rooms = [
|
||||
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
|
||||
for key, name, count in active_rooms_raw
|
||||
]
|
||||
|
||||
advert_frequency: float | None = None
|
||||
if advert_paths:
|
||||
total_observations = sum(p.heard_count for p in advert_paths)
|
||||
earliest = min(p.first_seen for p in advert_paths)
|
||||
latest = max(p.last_seen for p in advert_paths)
|
||||
span_hours = (latest - earliest) / 3600.0
|
||||
if span_hours > 0:
|
||||
advert_frequency = round(total_observations / span_hours, 2)
|
||||
|
||||
first_hop_stats: dict[str, dict] = {}
|
||||
for p in advert_paths:
|
||||
prefix = p.next_hop
|
||||
if prefix:
|
||||
if prefix not in first_hop_stats:
|
||||
first_hop_stats[prefix] = {
|
||||
"heard_count": 0,
|
||||
"path_len": p.path_len,
|
||||
"last_seen": p.last_seen,
|
||||
}
|
||||
first_hop_stats[prefix]["heard_count"] += p.heard_count
|
||||
first_hop_stats[prefix]["last_seen"] = max(
|
||||
first_hop_stats[prefix]["last_seen"], p.last_seen
|
||||
)
|
||||
|
||||
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
|
||||
|
||||
nearest_repeaters: list[NearestRepeater] = []
|
||||
for prefix, stats in first_hop_stats.items():
|
||||
resolved = resolved_contacts.get(prefix)
|
||||
nearest_repeaters.append(
|
||||
NearestRepeater(
|
||||
public_key=resolved.public_key if resolved else prefix,
|
||||
name=resolved.name if resolved else None,
|
||||
path_len=stats["path_len"],
|
||||
last_seen=stats["last_seen"],
|
||||
heard_count=stats["heard_count"],
|
||||
)
|
||||
)
|
||||
|
||||
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
|
||||
|
||||
return ContactAnalytics(
|
||||
lookup_type="contact",
|
||||
name=contact.name or contact.public_key[:12],
|
||||
contact=contact,
|
||||
name_history=name_history,
|
||||
dm_message_count=dm_count,
|
||||
channel_message_count=chan_count,
|
||||
includes_direct_messages=True,
|
||||
most_active_rooms=most_active_rooms,
|
||||
advert_paths=advert_paths,
|
||||
advert_frequency=advert_frequency,
|
||||
nearest_repeaters=nearest_repeaters,
|
||||
hourly_activity=hourly_activity,
|
||||
weekly_activity=weekly_activity,
|
||||
)
|
||||
|
||||
|
||||
async def _build_name_only_contact_analytics(name: str) -> ContactAnalytics:
|
||||
chan_count = await MessageRepository.count_channel_messages_by_sender_name(name)
|
||||
name_first_seen_at = await MessageRepository.get_first_channel_message_by_sender_name(name)
|
||||
active_rooms_raw = await MessageRepository.get_most_active_rooms_by_sender_name(name)
|
||||
hourly_activity, weekly_activity = await MessageRepository.get_sender_name_activity_series(name)
|
||||
|
||||
most_active_rooms = [
|
||||
ContactActiveRoom(channel_key=key, channel_name=room_name, message_count=count)
|
||||
for key, room_name, count in active_rooms_raw
|
||||
]
|
||||
|
||||
return ContactAnalytics(
|
||||
lookup_type="name",
|
||||
name=name,
|
||||
name_first_seen_at=name_first_seen_at,
|
||||
channel_message_count=chan_count,
|
||||
includes_direct_messages=False,
|
||||
most_active_rooms=most_active_rooms,
|
||||
hourly_activity=hourly_activity,
|
||||
weekly_activity=weekly_activity,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[Contact])
|
||||
async def list_contacts(
|
||||
limit: int = Query(default=100, ge=1, le=1000),
|
||||
@@ -114,6 +235,26 @@ async def list_repeater_advert_paths(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/analytics", response_model=ContactAnalytics)
|
||||
async def get_contact_analytics(
|
||||
public_key: str | None = Query(default=None),
|
||||
name: str | None = Query(default=None, min_length=1, max_length=200),
|
||||
) -> ContactAnalytics:
|
||||
"""Get unified contact analytics for either a keyed contact or a sender name."""
|
||||
if bool(public_key) == bool(name):
|
||||
raise HTTPException(status_code=400, detail="Specify exactly one of public_key or name")
|
||||
|
||||
if public_key:
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
return await _build_keyed_contact_analytics(contact)
|
||||
|
||||
assert name is not None
|
||||
normalized_name = name.strip()
|
||||
if not normalized_name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
return await _build_name_only_contact_analytics(normalized_name)
|
||||
|
||||
|
||||
@router.post("", response_model=Contact)
|
||||
async def create_contact(
|
||||
request: CreateContactRequest, background_tasks: BackgroundTasks
|
||||
@@ -139,12 +280,23 @@ async def create_contact(
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=request.public_key,
|
||||
log=logger,
|
||||
)
|
||||
if promoted_keys:
|
||||
refreshed = await ContactRepository.get_by_key(request.public_key)
|
||||
if refreshed is not None:
|
||||
existing = refreshed
|
||||
await _broadcast_contact_resolution(promoted_keys, existing)
|
||||
|
||||
# Trigger historical decryption if requested (even for existing contacts)
|
||||
if request.try_historical:
|
||||
await start_historical_dm_decryption(
|
||||
background_tasks, request.public_key, request.name or existing.name
|
||||
)
|
||||
|
||||
await _broadcast_contact_update(existing)
|
||||
return existing
|
||||
|
||||
# Create new contact
|
||||
@@ -157,6 +309,10 @@ async def create_contact(
|
||||
)
|
||||
await ContactRepository.upsert(contact_upsert)
|
||||
logger.info("Created contact %s", lower_key[:12])
|
||||
promoted_keys = await promote_prefix_contacts_for_contact(
|
||||
public_key=lower_key,
|
||||
log=logger,
|
||||
)
|
||||
|
||||
await reconcile_contact_messages(
|
||||
public_key=lower_key,
|
||||
@@ -168,190 +324,12 @@ async def create_contact(
|
||||
if request.try_historical:
|
||||
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
|
||||
|
||||
return Contact(**contact_upsert.model_dump())
|
||||
|
||||
|
||||
@router.get("/{public_key}/detail", response_model=ContactDetail)
|
||||
async def get_contact_detail(public_key: str) -> ContactDetail:
|
||||
"""Get comprehensive contact profile data.
|
||||
|
||||
Returns contact info, name history, message counts, most active rooms,
|
||||
advertisement paths, advert frequency, and nearest repeaters.
|
||||
"""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
|
||||
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
|
||||
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
|
||||
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
|
||||
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
|
||||
|
||||
most_active_rooms = [
|
||||
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
|
||||
for key, name, count in active_rooms_raw
|
||||
]
|
||||
|
||||
# Compute advert observation rate (observations/hour) from path data.
|
||||
# Note: a single advertisement can arrive via multiple paths, so this counts
|
||||
# RF observations, not unique advertisement broadcasts.
|
||||
advert_frequency: float | None = None
|
||||
if advert_paths:
|
||||
total_observations = sum(p.heard_count for p in advert_paths)
|
||||
earliest = min(p.first_seen for p in advert_paths)
|
||||
latest = max(p.last_seen for p in advert_paths)
|
||||
span_hours = (latest - earliest) / 3600.0
|
||||
if span_hours > 0:
|
||||
advert_frequency = round(total_observations / span_hours, 2)
|
||||
|
||||
# Compute nearest repeaters from first-hop prefixes in advert paths
|
||||
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen}
|
||||
for p in advert_paths:
|
||||
prefix = p.next_hop
|
||||
if prefix:
|
||||
if prefix not in first_hop_stats:
|
||||
first_hop_stats[prefix] = {
|
||||
"heard_count": 0,
|
||||
"path_len": p.path_len,
|
||||
"last_seen": p.last_seen,
|
||||
}
|
||||
first_hop_stats[prefix]["heard_count"] += p.heard_count
|
||||
first_hop_stats[prefix]["last_seen"] = max(
|
||||
first_hop_stats[prefix]["last_seen"], p.last_seen
|
||||
)
|
||||
|
||||
# Resolve all first-hop prefixes to contacts in a single query
|
||||
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
|
||||
|
||||
nearest_repeaters: list[NearestRepeater] = []
|
||||
for prefix, stats in first_hop_stats.items():
|
||||
resolved = resolved_contacts.get(prefix)
|
||||
nearest_repeaters.append(
|
||||
NearestRepeater(
|
||||
public_key=resolved.public_key if resolved else prefix,
|
||||
name=resolved.name if resolved else None,
|
||||
path_len=stats["path_len"],
|
||||
last_seen=stats["last_seen"],
|
||||
heard_count=stats["heard_count"],
|
||||
)
|
||||
)
|
||||
|
||||
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
|
||||
|
||||
return ContactDetail(
|
||||
contact=contact,
|
||||
name_history=name_history,
|
||||
dm_message_count=dm_count,
|
||||
channel_message_count=chan_count,
|
||||
most_active_rooms=most_active_rooms,
|
||||
advert_paths=advert_paths,
|
||||
advert_frequency=advert_frequency,
|
||||
nearest_repeaters=nearest_repeaters,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{public_key}", response_model=Contact)
|
||||
async def get_contact(public_key: str) -> Contact:
|
||||
"""Get a specific contact by public key or prefix."""
|
||||
return await _resolve_contact_or_404(public_key)
|
||||
|
||||
|
||||
@router.get("/{public_key}/advert-paths", response_model=list[ContactAdvertPath])
|
||||
async def get_contact_advert_paths(
|
||||
public_key: str,
|
||||
limit: int = Query(default=10, ge=1, le=50),
|
||||
) -> list[ContactAdvertPath]:
|
||||
"""List recent unique advert paths for a contact."""
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
return await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key, limit)
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_contacts_from_radio() -> dict:
|
||||
"""Sync contacts from the radio to the database."""
|
||||
require_connected()
|
||||
|
||||
logger.info("Syncing contacts from radio")
|
||||
|
||||
async with radio_manager.radio_operation("sync_contacts_from_radio") as mc:
|
||||
result = await mc.commands.get_contacts()
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get contacts: {result.payload}")
|
||||
|
||||
contacts = result.payload
|
||||
count = 0
|
||||
|
||||
synced_keys: list[str] = []
|
||||
for public_key, contact_data in contacts.items():
|
||||
lower_key = public_key.lower()
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert.from_radio_dict(lower_key, contact_data, on_radio=True)
|
||||
)
|
||||
synced_keys.append(lower_key)
|
||||
await reconcile_contact_messages(
|
||||
public_key=lower_key,
|
||||
contact_name=contact_data.get("adv_name"),
|
||||
log=logger,
|
||||
)
|
||||
count += 1
|
||||
|
||||
# Clear on_radio for contacts not found on the radio
|
||||
await ContactRepository.clear_on_radio_except(synced_keys)
|
||||
|
||||
logger.info("Synced %d contacts from radio", count)
|
||||
return {"synced": count}
|
||||
|
||||
|
||||
@router.post("/{public_key}/remove-from-radio")
|
||||
async def remove_contact_from_radio(public_key: str) -> dict:
|
||||
"""Remove a contact from the radio (keeps it in database)."""
|
||||
require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
|
||||
async with radio_manager.radio_operation("remove_contact_from_radio") as mc:
|
||||
# Get the contact from radio
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not radio_contact:
|
||||
# Already not on radio
|
||||
await ContactRepository.set_on_radio(contact.public_key, False)
|
||||
return {"status": "ok", "message": "Contact was not on radio"}
|
||||
|
||||
logger.info("Removing contact %s from radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.remove_contact(radio_contact)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to remove contact: {result.payload}"
|
||||
)
|
||||
|
||||
await ContactRepository.set_on_radio(contact.public_key, False)
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/{public_key}/add-to-radio")
|
||||
async def add_contact_to_radio(public_key: str) -> dict:
|
||||
"""Add a contact from the database to the radio."""
|
||||
require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key, "Contact not found in database")
|
||||
|
||||
async with radio_manager.radio_operation("add_contact_to_radio") as mc:
|
||||
# Check if already on radio
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
return {"status": "ok", "message": "Contact already on radio"}
|
||||
|
||||
logger.info("Adding contact %s to radio", contact.public_key[:12])
|
||||
|
||||
result = await mc.commands.add_contact(contact.to_radio_dict())
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to add contact: {result.payload}")
|
||||
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
return {"status": "ok"}
|
||||
stored = await ContactRepository.get_by_key(lower_key)
|
||||
if stored is None:
|
||||
raise HTTPException(status_code=500, detail="Contact was created but could not be reloaded")
|
||||
await _broadcast_contact_update(stored)
|
||||
await _broadcast_contact_resolution(promoted_keys, stored)
|
||||
return stored
|
||||
|
||||
|
||||
@router.post("/{public_key}/mark-read")
|
||||
@@ -452,6 +430,90 @@ 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}/path-discovery", response_model=PathDiscoveryResponse)
|
||||
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
|
||||
"""Discover the current forward and return paths to a known contact."""
|
||||
require_connected()
|
||||
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
pubkey_prefix = contact.public_key[:12]
|
||||
|
||||
async with radio_manager.radio_operation("request_path_discovery", pause_polling=True) as mc:
|
||||
await _ensure_on_radio(mc, contact)
|
||||
|
||||
response_task = asyncio.create_task(
|
||||
mc.wait_for_event(
|
||||
EventType.PATH_RESPONSE,
|
||||
attribute_filters={"pubkey_pre": pubkey_prefix},
|
||||
timeout=15,
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await mc.commands.send_path_discovery(contact.public_key)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to send path discovery: {result.payload}",
|
||||
)
|
||||
|
||||
event = await response_task
|
||||
finally:
|
||||
if not response_task.done():
|
||||
response_task.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await response_task
|
||||
|
||||
if event is None:
|
||||
raise HTTPException(status_code=504, detail="No path discovery response heard")
|
||||
|
||||
payload = event.payload
|
||||
forward_path = str(payload.get("out_path") or "")
|
||||
forward_len = int(payload.get("out_path_len") or 0)
|
||||
forward_mode = _path_hash_mode_from_hop_width(payload.get("out_path_hash_len"))
|
||||
return_path = str(payload.get("in_path") or "")
|
||||
return_len = int(payload.get("in_path_len") or 0)
|
||||
return_mode = _path_hash_mode_from_hop_width(payload.get("in_path_hash_len"))
|
||||
|
||||
await ContactRepository.update_path(
|
||||
contact.public_key,
|
||||
forward_path,
|
||||
forward_len,
|
||||
forward_mode,
|
||||
)
|
||||
refreshed_contact = await _resolve_contact_or_404(contact.public_key)
|
||||
|
||||
try:
|
||||
sync_result = await mc.commands.add_contact(refreshed_contact.to_radio_dict())
|
||||
if sync_result is not None and sync_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to sync discovered path back to radio for %s: %s",
|
||||
refreshed_contact.public_key[:12],
|
||||
sync_result.payload,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to sync discovered path back to radio for %s",
|
||||
refreshed_contact.public_key[:12],
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
await _broadcast_contact_update(refreshed_contact)
|
||||
|
||||
return PathDiscoveryResponse(
|
||||
contact=refreshed_contact,
|
||||
forward_path=PathDiscoveryRoute(
|
||||
path=forward_path,
|
||||
path_len=forward_len,
|
||||
path_hash_mode=forward_mode,
|
||||
),
|
||||
return_path=PathDiscoveryRoute(
|
||||
path=return_path,
|
||||
path_len=return_len,
|
||||
path_hash_mode=return_mode,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{public_key}/routing-override")
|
||||
async def set_contact_routing_override(
|
||||
public_key: str, request: ContactRoutingOverrideRequest
|
||||
|
||||
299
app/routers/debug.py
Normal file
299
app/routers/debug.py
Normal file
@@ -0,0 +1,299 @@
|
||||
import hashlib
|
||||
import importlib.metadata
|
||||
import logging
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import get_recent_log_lines, settings
|
||||
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
|
||||
from app.repository import MessageRepository
|
||||
from app.routers.health import HealthResponse, build_health_data
|
||||
from app.services.radio_runtime import radio_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["debug"])
|
||||
|
||||
|
||||
class DebugApplicationInfo(BaseModel):
|
||||
version: str
|
||||
commit_hash: str | None = None
|
||||
git_branch: str | None = None
|
||||
git_dirty: bool | None = None
|
||||
python_version: str
|
||||
|
||||
|
||||
class DebugRuntimeInfo(BaseModel):
|
||||
connection_info: str | None = None
|
||||
connection_desired: bool
|
||||
setup_in_progress: bool
|
||||
setup_complete: bool
|
||||
channels_with_incoming_messages: int
|
||||
max_channels: int
|
||||
path_hash_mode: int
|
||||
path_hash_mode_supported: bool
|
||||
channel_slot_reuse_enabled: bool
|
||||
channel_send_cache_capacity: int
|
||||
remediation_flags: dict[str, bool]
|
||||
|
||||
|
||||
class DebugContactAudit(BaseModel):
|
||||
expected_and_found: int
|
||||
expected_but_not_found: list[str]
|
||||
found_but_not_expected: list[str]
|
||||
|
||||
|
||||
class DebugChannelSlotMismatch(BaseModel):
|
||||
slot_number: int
|
||||
expected_sha256_of_room_key: str | None = None
|
||||
actual_sha256_of_room_key: str | None = None
|
||||
|
||||
|
||||
class DebugChannelAudit(BaseModel):
|
||||
matched_slots: int
|
||||
wrong_slots: list[DebugChannelSlotMismatch]
|
||||
|
||||
|
||||
class DebugRadioProbe(BaseModel):
|
||||
performed: bool
|
||||
errors: list[str] = Field(default_factory=list)
|
||||
self_info: dict[str, Any] | None = None
|
||||
device_info: dict[str, Any] | None = None
|
||||
stats_core: dict[str, Any] | None = None
|
||||
stats_radio: dict[str, Any] | None = None
|
||||
contacts: DebugContactAudit | None = None
|
||||
channels: DebugChannelAudit | None = None
|
||||
|
||||
|
||||
class DebugSnapshotResponse(BaseModel):
|
||||
captured_at: str
|
||||
application: DebugApplicationInfo
|
||||
health: HealthResponse
|
||||
runtime: DebugRuntimeInfo
|
||||
radio_probe: DebugRadioProbe
|
||||
logs: list[str]
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
return Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _get_app_version() -> str:
|
||||
try:
|
||||
return importlib.metadata.version("remoteterm-meshcore")
|
||||
except Exception:
|
||||
pyproject = _repo_root() / "pyproject.toml"
|
||||
try:
|
||||
for line in pyproject.read_text().splitlines():
|
||||
if line.startswith("version = "):
|
||||
return line.split('"')[1]
|
||||
except Exception:
|
||||
pass
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def _git_output(*args: str) -> str | None:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=_repo_root(),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
output = result.stdout.strip()
|
||||
return output or None
|
||||
|
||||
|
||||
def _build_application_info() -> DebugApplicationInfo:
|
||||
dirty_output = _git_output("status", "--porcelain")
|
||||
return DebugApplicationInfo(
|
||||
version=_get_app_version(),
|
||||
commit_hash=_git_output("rev-parse", "HEAD"),
|
||||
git_branch=_git_output("rev-parse", "--abbrev-ref", "HEAD"),
|
||||
git_dirty=(dirty_output is not None and dirty_output != ""),
|
||||
python_version=sys.version.split()[0],
|
||||
)
|
||||
|
||||
|
||||
def _event_type_name(event: Any) -> str:
|
||||
event_type = getattr(event, "type", None)
|
||||
return getattr(event_type, "name", str(event_type))
|
||||
|
||||
|
||||
def _sha256_hex(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _normalize_channel_secret(payload: dict[str, Any]) -> bytes:
|
||||
secret = payload.get("channel_secret", b"")
|
||||
if isinstance(secret, bytes):
|
||||
return secret
|
||||
return bytes(secret)
|
||||
|
||||
|
||||
def _is_empty_channel_payload(payload: dict[str, Any]) -> bool:
|
||||
name = payload.get("channel_name", "")
|
||||
return not name or name == "\x00" * len(name)
|
||||
|
||||
|
||||
def _observed_channel_key(event: Any) -> str | None:
|
||||
if getattr(event, "type", None) != EventType.CHANNEL_INFO:
|
||||
return None
|
||||
|
||||
payload = event.payload or {}
|
||||
if _is_empty_channel_payload(payload):
|
||||
return None
|
||||
|
||||
return _normalize_channel_secret(payload).hex().upper()
|
||||
|
||||
|
||||
def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
|
||||
if not device_info or "max_channels" not in device_info:
|
||||
return None
|
||||
try:
|
||||
return int(device_info["max_channels"])
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
async def _build_contact_audit(
|
||||
observed_contacts_payload: dict[str, dict[str, Any]],
|
||||
) -> DebugContactAudit:
|
||||
expected_contacts = await get_contacts_selected_for_radio_sync()
|
||||
expected_keys = {contact.public_key.lower() for contact in expected_contacts}
|
||||
observed_keys = {public_key.lower() for public_key in observed_contacts_payload}
|
||||
|
||||
return DebugContactAudit(
|
||||
expected_and_found=len(expected_keys & observed_keys),
|
||||
expected_but_not_found=sorted(_sha256_hex(key) for key in (expected_keys - observed_keys)),
|
||||
found_but_not_expected=sorted(_sha256_hex(key) for key in (observed_keys - expected_keys)),
|
||||
)
|
||||
|
||||
|
||||
async def _build_channel_audit(mc: Any, max_channels: int | None = None) -> DebugChannelAudit:
|
||||
cache_key_by_slot = {
|
||||
slot: channel_key for channel_key, slot in radio_runtime.get_channel_send_cache_snapshot()
|
||||
}
|
||||
|
||||
matched_slots = 0
|
||||
wrong_slots: list[DebugChannelSlotMismatch] = []
|
||||
for slot in range(get_radio_channel_limit(max_channels)):
|
||||
event = await mc.commands.get_channel(slot)
|
||||
expected_key = cache_key_by_slot.get(slot)
|
||||
observed_key = _observed_channel_key(event)
|
||||
if expected_key == observed_key:
|
||||
matched_slots += 1
|
||||
continue
|
||||
wrong_slots.append(
|
||||
DebugChannelSlotMismatch(
|
||||
slot_number=slot,
|
||||
expected_sha256_of_room_key=_sha256_hex(expected_key) if expected_key else None,
|
||||
actual_sha256_of_room_key=_sha256_hex(observed_key) if observed_key else None,
|
||||
)
|
||||
)
|
||||
|
||||
return DebugChannelAudit(
|
||||
matched_slots=matched_slots,
|
||||
wrong_slots=wrong_slots,
|
||||
)
|
||||
|
||||
|
||||
async def _probe_radio() -> DebugRadioProbe:
|
||||
if not radio_runtime.is_connected:
|
||||
return DebugRadioProbe(performed=False, errors=["Radio not connected"])
|
||||
|
||||
errors: list[str] = []
|
||||
try:
|
||||
async with radio_runtime.radio_operation(
|
||||
"debug_support_snapshot",
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
device_info = None
|
||||
stats_core = None
|
||||
stats_radio = None
|
||||
|
||||
device_event = await mc.commands.send_device_query()
|
||||
if getattr(device_event, "type", None) == EventType.DEVICE_INFO:
|
||||
device_info = device_event.payload
|
||||
else:
|
||||
errors.append(f"send_device_query returned {_event_type_name(device_event)}")
|
||||
|
||||
core_event = await mc.commands.get_stats_core()
|
||||
if getattr(core_event, "type", None) == EventType.STATS_CORE:
|
||||
stats_core = core_event.payload
|
||||
else:
|
||||
errors.append(f"get_stats_core returned {_event_type_name(core_event)}")
|
||||
|
||||
radio_event = await mc.commands.get_stats_radio()
|
||||
if getattr(radio_event, "type", None) == EventType.STATS_RADIO:
|
||||
stats_radio = radio_event.payload
|
||||
else:
|
||||
errors.append(f"get_stats_radio returned {_event_type_name(radio_event)}")
|
||||
|
||||
contacts_event = await mc.commands.get_contacts()
|
||||
observed_contacts_payload: dict[str, dict[str, Any]] = {}
|
||||
if getattr(contacts_event, "type", None) != EventType.ERROR:
|
||||
observed_contacts_payload = contacts_event.payload or {}
|
||||
else:
|
||||
errors.append(f"get_contacts returned {_event_type_name(contacts_event)}")
|
||||
|
||||
return DebugRadioProbe(
|
||||
performed=True,
|
||||
errors=errors,
|
||||
self_info=dict(mc.self_info or {}),
|
||||
device_info=device_info,
|
||||
stats_core=stats_core,
|
||||
stats_radio=stats_radio,
|
||||
contacts=await _build_contact_audit(observed_contacts_payload),
|
||||
channels=await _build_channel_audit(
|
||||
mc,
|
||||
max_channels=_coerce_live_max_channels(device_info),
|
||||
),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Debug support snapshot radio probe failed: %s", exc, exc_info=True)
|
||||
errors.append(str(exc))
|
||||
return DebugRadioProbe(performed=False, errors=errors)
|
||||
|
||||
|
||||
@router.get("/debug", response_model=DebugSnapshotResponse)
|
||||
async def debug_support_snapshot() -> DebugSnapshotResponse:
|
||||
"""Return a support/debug snapshot with recent logs and live radio state."""
|
||||
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
|
||||
radio_probe = await _probe_radio()
|
||||
channels_with_incoming_messages = (
|
||||
await MessageRepository.count_channels_with_incoming_messages()
|
||||
)
|
||||
return DebugSnapshotResponse(
|
||||
captured_at=datetime.now(timezone.utc).isoformat(),
|
||||
application=_build_application_info(),
|
||||
health=HealthResponse(**health_data),
|
||||
runtime=DebugRuntimeInfo(
|
||||
connection_info=radio_runtime.connection_info,
|
||||
connection_desired=radio_runtime.connection_desired,
|
||||
setup_in_progress=radio_runtime.is_setup_in_progress,
|
||||
setup_complete=radio_runtime.is_setup_complete,
|
||||
channels_with_incoming_messages=channels_with_incoming_messages,
|
||||
max_channels=radio_runtime.max_channels,
|
||||
path_hash_mode=radio_runtime.path_hash_mode,
|
||||
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
|
||||
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
|
||||
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
|
||||
remediation_flags={
|
||||
"enable_message_poll_fallback": settings.enable_message_poll_fallback,
|
||||
"force_channel_slot_reconfigure": settings.force_channel_slot_reconfigure,
|
||||
},
|
||||
),
|
||||
radio_probe=radio_probe,
|
||||
logs=get_recent_log_lines(limit=1000),
|
||||
)
|
||||
@@ -1,5 +1,7 @@
|
||||
"""REST API for fanout config CRUD."""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import logging
|
||||
import re
|
||||
import string
|
||||
@@ -8,12 +10,13 @@ from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.config import settings as server_settings
|
||||
from app.fanout.bot_exec import _analyze_bot_signature
|
||||
from app.repository.fanout import FanoutConfigRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/fanout", tags=["fanout"])
|
||||
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise"}
|
||||
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs"}
|
||||
|
||||
_IATA_RE = re.compile(r"^[A-Z]{3}$")
|
||||
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
|
||||
@@ -144,18 +147,78 @@ def _validate_mqtt_community_config(config: dict) -> None:
|
||||
|
||||
|
||||
def _validate_bot_config(config: dict) -> None:
|
||||
"""Validate bot config blob (syntax-check the code)."""
|
||||
"""Validate bot config blob (syntax-check the code and supported signature)."""
|
||||
code = config.get("code", "")
|
||||
if not code or not code.strip():
|
||||
raise HTTPException(status_code=400, detail="Bot code cannot be empty")
|
||||
try:
|
||||
compile(code, "<bot_code>", "exec")
|
||||
tree = ast.parse(code, filename="<bot_code>", mode="exec")
|
||||
except SyntaxError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
|
||||
) from None
|
||||
|
||||
bot_def = next(
|
||||
(
|
||||
node
|
||||
for node in tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "bot"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if bot_def is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Bot code must define a callable bot() function. "
|
||||
"Use the default bot template as a reference."
|
||||
),
|
||||
)
|
||||
|
||||
try:
|
||||
parameters: list[inspect.Parameter] = []
|
||||
positional_args = [
|
||||
*((arg, inspect.Parameter.POSITIONAL_ONLY) for arg in bot_def.args.posonlyargs),
|
||||
*((arg, inspect.Parameter.POSITIONAL_OR_KEYWORD) for arg in bot_def.args.args),
|
||||
]
|
||||
positional_defaults_start = len(positional_args) - len(bot_def.args.defaults)
|
||||
sentinel_default = object()
|
||||
|
||||
for index, (arg, kind) in enumerate(positional_args):
|
||||
has_default = index >= positional_defaults_start
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
arg.arg,
|
||||
kind=kind,
|
||||
default=sentinel_default if has_default else inspect.Parameter.empty,
|
||||
)
|
||||
)
|
||||
if bot_def.args.vararg is not None:
|
||||
parameters.append(
|
||||
inspect.Parameter(bot_def.args.vararg.arg, kind=inspect.Parameter.VAR_POSITIONAL)
|
||||
)
|
||||
for kwonly_arg, kw_default in zip(
|
||||
bot_def.args.kwonlyargs, bot_def.args.kw_defaults, strict=True
|
||||
):
|
||||
parameters.append(
|
||||
inspect.Parameter(
|
||||
kwonly_arg.arg,
|
||||
kind=inspect.Parameter.KEYWORD_ONLY,
|
||||
default=(
|
||||
sentinel_default if kw_default is not None else inspect.Parameter.empty
|
||||
),
|
||||
)
|
||||
)
|
||||
if bot_def.args.kwarg is not None:
|
||||
parameters.append(
|
||||
inspect.Parameter(bot_def.args.kwarg.arg, kind=inspect.Parameter.VAR_KEYWORD)
|
||||
)
|
||||
|
||||
_analyze_bot_signature(inspect.Signature(parameters))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from None
|
||||
|
||||
|
||||
def _validate_apprise_config(config: dict) -> None:
|
||||
"""Validate apprise config blob."""
|
||||
@@ -179,6 +242,39 @@ def _validate_webhook_config(config: dict) -> None:
|
||||
raise HTTPException(status_code=400, detail="headers must be a JSON object")
|
||||
|
||||
|
||||
def _validate_sqs_config(config: dict) -> None:
|
||||
"""Validate sqs config blob."""
|
||||
queue_url = str(config.get("queue_url", "")).strip()
|
||||
if not queue_url:
|
||||
raise HTTPException(status_code=400, detail="queue_url is required for sqs")
|
||||
if not queue_url.startswith(("https://", "http://")):
|
||||
raise HTTPException(status_code=400, detail="queue_url must start with http:// or https://")
|
||||
|
||||
endpoint_url = str(config.get("endpoint_url", "")).strip()
|
||||
if endpoint_url and not endpoint_url.startswith(("https://", "http://")):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="endpoint_url must start with http:// or https://",
|
||||
)
|
||||
|
||||
access_key_id = str(config.get("access_key_id", "")).strip()
|
||||
secret_access_key = str(config.get("secret_access_key", "")).strip()
|
||||
session_token = str(config.get("session_token", "")).strip()
|
||||
has_static_keypair = bool(access_key_id) and bool(secret_access_key)
|
||||
has_partial_keypair = bool(access_key_id) != bool(secret_access_key)
|
||||
|
||||
if has_partial_keypair:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="access_key_id and secret_access_key must be set together for sqs",
|
||||
)
|
||||
if session_token and not has_static_keypair:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="session_token requires access_key_id and secret_access_key for sqs",
|
||||
)
|
||||
|
||||
|
||||
def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
"""Enforce type-specific scope constraints. Returns normalized scope."""
|
||||
if config_type == "mqtt_community":
|
||||
@@ -193,7 +289,7 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
|
||||
detail="scope.messages must be 'all', 'none', or a filter object",
|
||||
)
|
||||
return {"messages": messages, "raw_packets": "none"}
|
||||
# For mqtt_private, validate scope values
|
||||
# For mqtt_private and sqs, validate scope values
|
||||
messages = scope.get("messages", "all")
|
||||
if messages not in ("all", "none") and not isinstance(messages, dict):
|
||||
raise HTTPException(
|
||||
@@ -240,6 +336,8 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
|
||||
_validate_webhook_config(body.config)
|
||||
elif body.type == "apprise":
|
||||
_validate_apprise_config(body.config)
|
||||
elif body.type == "sqs":
|
||||
_validate_sqs_config(body.config)
|
||||
|
||||
scope = _enforce_scope(body.type, body.scope)
|
||||
|
||||
@@ -295,6 +393,8 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
||||
_validate_webhook_config(config_to_validate)
|
||||
elif existing["type"] == "apprise":
|
||||
_validate_apprise_config(config_to_validate)
|
||||
elif existing["type"] == "sqs":
|
||||
_validate_sqs_config(config_to_validate)
|
||||
|
||||
updated = await FanoutConfigRepository.update(config_id, **kwargs)
|
||||
if updated is None:
|
||||
|
||||
@@ -15,6 +15,7 @@ class HealthResponse(BaseModel):
|
||||
status: str
|
||||
radio_connected: bool
|
||||
radio_initializing: bool = False
|
||||
radio_state: str = "disconnected"
|
||||
connection_info: str | None
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
@@ -56,12 +57,31 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
if not radio_connected:
|
||||
setup_complete = False
|
||||
|
||||
connection_desired = getattr(radio_manager, "connection_desired", True)
|
||||
if not isinstance(connection_desired, bool):
|
||||
connection_desired = True
|
||||
|
||||
is_reconnecting = getattr(radio_manager, "is_reconnecting", False)
|
||||
if not isinstance(is_reconnecting, bool):
|
||||
is_reconnecting = False
|
||||
|
||||
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
|
||||
if not connection_desired:
|
||||
radio_state = "paused"
|
||||
elif radio_initializing:
|
||||
radio_state = "initializing"
|
||||
elif radio_connected:
|
||||
radio_state = "connected"
|
||||
elif is_reconnecting:
|
||||
radio_state = "connecting"
|
||||
else:
|
||||
radio_state = "disconnected"
|
||||
|
||||
return {
|
||||
"status": "ok" if radio_connected and not radio_initializing else "degraded",
|
||||
"radio_connected": radio_connected,
|
||||
"radio_initializing": radio_initializing,
|
||||
"radio_state": radio_state,
|
||||
"connection_info": connection_info,
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
|
||||
@@ -8,6 +8,7 @@ from app.event_handlers import track_pending_ack
|
||||
from app.models import (
|
||||
Message,
|
||||
MessagesAroundResponse,
|
||||
ResendChannelMessageResponse,
|
||||
SendChannelMessageRequest,
|
||||
SendDirectMessageRequest,
|
||||
)
|
||||
@@ -108,6 +109,11 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Contact not found in database: {request.destination}"
|
||||
)
|
||||
if len(db_contact.public_key) < 64:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Cannot send to an unresolved prefix-only contact until a full key is known",
|
||||
)
|
||||
|
||||
return await send_direct_message_to_contact(
|
||||
contact=db_contact,
|
||||
@@ -121,7 +127,9 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
|
||||
)
|
||||
|
||||
|
||||
# Temporary radio slot used for sending channel messages
|
||||
# Preferred first radio slot used for sending channel messages.
|
||||
# The send service may reuse/load other app-managed slots depending on transport
|
||||
# and session cache state.
|
||||
TEMP_RADIO_SLOT = 0
|
||||
|
||||
|
||||
@@ -131,7 +139,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
require_connected()
|
||||
|
||||
# Get channel info from our database
|
||||
from app.decoder import calculate_channel_hash
|
||||
from app.repository import ChannelRepository
|
||||
|
||||
db_channel = await ChannelRepository.get_by_key(request.channel_key)
|
||||
@@ -148,14 +155,6 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
status_code=400, detail=f"Invalid channel key format: {request.channel_key}"
|
||||
) from None
|
||||
|
||||
expected_hash = calculate_channel_hash(key_bytes)
|
||||
logger.info(
|
||||
"Sending to channel %s (%s) via radio slot %d, key hash: %s",
|
||||
request.channel_key,
|
||||
db_channel.name,
|
||||
TEMP_RADIO_SLOT,
|
||||
expected_hash,
|
||||
)
|
||||
return await send_channel_message_to_channel(
|
||||
channel=db_channel,
|
||||
channel_key_upper=request.channel_key.upper(),
|
||||
@@ -173,11 +172,15 @@ async def send_channel_message(request: SendChannelMessageRequest) -> Message:
|
||||
RESEND_WINDOW_SECONDS = 30
|
||||
|
||||
|
||||
@router.post("/channel/{message_id}/resend")
|
||||
@router.post(
|
||||
"/channel/{message_id}/resend",
|
||||
response_model=ResendChannelMessageResponse,
|
||||
response_model_exclude_none=True,
|
||||
)
|
||||
async def resend_channel_message(
|
||||
message_id: int,
|
||||
new_timestamp: bool = Query(default=False),
|
||||
) -> dict:
|
||||
) -> ResendChannelMessageResponse:
|
||||
"""Resend a channel message.
|
||||
|
||||
When new_timestamp=False (default): byte-perfect resend using the original timestamp.
|
||||
|
||||
@@ -1,11 +1,23 @@
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
import time
|
||||
from typing import Literal, TypeAlias
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from meshcore import EventType
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.dependencies import require_connected
|
||||
from app.models import (
|
||||
ContactUpsert,
|
||||
RadioDiscoveryRequest,
|
||||
RadioDiscoveryResponse,
|
||||
RadioDiscoveryResult,
|
||||
)
|
||||
from app.radio_sync import send_advertisement as do_send_advertisement
|
||||
from app.radio_sync import sync_radio_time
|
||||
from app.repository import ContactRepository
|
||||
from app.services.radio_commands import (
|
||||
KeystoreRefreshError,
|
||||
PathHashModeUnsupportedError,
|
||||
@@ -14,13 +26,27 @@ from app.services.radio_commands import (
|
||||
import_private_key_and_refresh_keystore,
|
||||
)
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_event, broadcast_health
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/radio", tags=["radio"])
|
||||
|
||||
AdvertLocationSource = Literal["off", "current"]
|
||||
DiscoveryNodeType: TypeAlias = Literal["repeater", "sensor"]
|
||||
DISCOVERY_WINDOW_SECONDS = 8.0
|
||||
_DISCOVERY_TARGET_BITS = {
|
||||
"repeaters": 1 << 2,
|
||||
"sensors": 1 << 4,
|
||||
"all": (1 << 2) | (1 << 4),
|
||||
}
|
||||
_DISCOVERY_NODE_TYPES: dict[int, DiscoveryNodeType] = {
|
||||
2: "repeater",
|
||||
4: "sensor",
|
||||
}
|
||||
|
||||
async def _prepare_connected(*, broadcast_on_success: bool) -> None:
|
||||
await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
|
||||
|
||||
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
|
||||
return await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
|
||||
|
||||
|
||||
async def _reconnect_and_prepare(*, broadcast_on_success: bool) -> bool:
|
||||
@@ -50,6 +76,10 @@ class RadioConfigResponse(BaseModel):
|
||||
path_hash_mode_supported: bool = Field(
|
||||
default=False, description="Whether firmware supports path hash mode setting"
|
||||
)
|
||||
advert_location_source: AdvertLocationSource = Field(
|
||||
default="current",
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
|
||||
|
||||
class RadioConfigUpdate(BaseModel):
|
||||
@@ -64,12 +94,98 @@ class RadioConfigUpdate(BaseModel):
|
||||
le=2,
|
||||
description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)",
|
||||
)
|
||||
advert_location_source: AdvertLocationSource | None = Field(
|
||||
default=None,
|
||||
description="Whether adverts include the node's current location state",
|
||||
)
|
||||
|
||||
|
||||
class PrivateKeyUpdate(BaseModel):
|
||||
private_key: str = Field(description="Private key as hex string")
|
||||
|
||||
|
||||
def _monotonic() -> float:
|
||||
return time.monotonic()
|
||||
|
||||
|
||||
def _better_signal(first: float | None, second: float | None) -> float | None:
|
||||
if first is None:
|
||||
return second
|
||||
if second is None:
|
||||
return first
|
||||
return second if second > first else first
|
||||
|
||||
|
||||
def _coerce_float(value: object) -> float | None:
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_int(value: object) -> int | None:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _merge_discovery_result(
|
||||
existing: RadioDiscoveryResult | None, event_payload: dict[str, object]
|
||||
) -> RadioDiscoveryResult | None:
|
||||
public_key = event_payload.get("pubkey")
|
||||
node_type_code = event_payload.get("node_type")
|
||||
if not isinstance(public_key, str) or not public_key:
|
||||
return existing
|
||||
if not isinstance(node_type_code, int):
|
||||
return existing
|
||||
|
||||
node_type = _DISCOVERY_NODE_TYPES.get(node_type_code)
|
||||
if node_type is None:
|
||||
return existing
|
||||
|
||||
if existing is None:
|
||||
return RadioDiscoveryResult(
|
||||
public_key=public_key,
|
||||
node_type=node_type,
|
||||
heard_count=1,
|
||||
local_snr=_coerce_float(event_payload.get("SNR")),
|
||||
local_rssi=_coerce_int(event_payload.get("RSSI")),
|
||||
remote_snr=_coerce_float(event_payload.get("SNR_in")),
|
||||
)
|
||||
|
||||
existing.heard_count += 1
|
||||
existing.local_snr = _better_signal(existing.local_snr, _coerce_float(event_payload.get("SNR")))
|
||||
current_rssi = _coerce_int(event_payload.get("RSSI"))
|
||||
if existing.local_rssi is None or (
|
||||
current_rssi is not None and current_rssi > existing.local_rssi
|
||||
):
|
||||
existing.local_rssi = current_rssi
|
||||
existing.remote_snr = _better_signal(
|
||||
existing.remote_snr,
|
||||
_coerce_float(event_payload.get("SNR_in")),
|
||||
)
|
||||
return existing
|
||||
|
||||
|
||||
async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -> None:
|
||||
now = int(time.time())
|
||||
for result in results:
|
||||
existing = await ContactRepository.get_by_key(result.public_key)
|
||||
if existing is not None:
|
||||
continue
|
||||
|
||||
contact = ContactUpsert(
|
||||
public_key=result.public_key,
|
||||
type=2 if result.node_type == "repeater" else 4,
|
||||
last_seen=now,
|
||||
first_seen=now,
|
||||
on_radio=False,
|
||||
)
|
||||
await ContactRepository.upsert(contact)
|
||||
created = await ContactRepository.get_by_key(result.public_key)
|
||||
if created is not None:
|
||||
broadcast_event("contact", created.model_dump())
|
||||
|
||||
|
||||
@router.get("/config", response_model=RadioConfigResponse)
|
||||
async def get_radio_config() -> RadioConfigResponse:
|
||||
"""Get the current radio configuration."""
|
||||
@@ -79,6 +195,9 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
if not info:
|
||||
raise HTTPException(status_code=503, detail="Radio info not available")
|
||||
|
||||
adv_loc_policy = info.get("adv_loc_policy", 1)
|
||||
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
|
||||
|
||||
return RadioConfigResponse(
|
||||
public_key=info.get("public_key", ""),
|
||||
name=info.get("name", ""),
|
||||
@@ -94,6 +213,7 @@ async def get_radio_config() -> RadioConfigResponse:
|
||||
),
|
||||
path_hash_mode=radio_manager.path_hash_mode,
|
||||
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
|
||||
advert_location_source=advert_location_source,
|
||||
)
|
||||
|
||||
|
||||
@@ -168,8 +288,76 @@ async def send_advertisement() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@router.post("/discover", response_model=RadioDiscoveryResponse)
|
||||
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
|
||||
"""Run a short node-discovery sweep from the local radio."""
|
||||
require_connected()
|
||||
|
||||
target_bits = _DISCOVERY_TARGET_BITS[request.target]
|
||||
tag = random.randint(1, 0xFFFFFFFF)
|
||||
tag_hex = tag.to_bytes(4, "little", signed=False).hex()
|
||||
events: asyncio.Queue = asyncio.Queue()
|
||||
|
||||
async with radio_manager.radio_operation(
|
||||
"discover_mesh",
|
||||
pause_polling=True,
|
||||
suspend_auto_fetch=True,
|
||||
) as mc:
|
||||
subscription = mc.subscribe(
|
||||
EventType.DISCOVER_RESPONSE,
|
||||
lambda event: events.put_nowait(event),
|
||||
{"tag": tag_hex},
|
||||
)
|
||||
try:
|
||||
send_result = await mc.commands.send_node_discover_req(
|
||||
target_bits,
|
||||
prefix_only=False,
|
||||
tag=tag,
|
||||
)
|
||||
if send_result is None or send_result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail="Failed to start mesh discovery")
|
||||
|
||||
deadline = _monotonic() + DISCOVERY_WINDOW_SECONDS
|
||||
results_by_key: dict[str, RadioDiscoveryResult] = {}
|
||||
|
||||
while True:
|
||||
remaining = deadline - _monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
try:
|
||||
event = await asyncio.wait_for(events.get(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
break
|
||||
|
||||
merged = _merge_discovery_result(
|
||||
results_by_key.get(event.payload.get("pubkey")),
|
||||
event.payload,
|
||||
)
|
||||
if merged is not None:
|
||||
results_by_key[merged.public_key] = merged
|
||||
finally:
|
||||
subscription.unsubscribe()
|
||||
|
||||
results = sorted(
|
||||
results_by_key.values(),
|
||||
key=lambda item: (
|
||||
item.node_type,
|
||||
-(item.local_snr if item.local_snr is not None else -999.0),
|
||||
item.public_key,
|
||||
),
|
||||
)
|
||||
await _persist_new_discovery_contacts(results)
|
||||
return RadioDiscoveryResponse(
|
||||
target=request.target,
|
||||
duration_seconds=DISCOVERY_WINDOW_SECONDS,
|
||||
results=results,
|
||||
)
|
||||
|
||||
|
||||
async def _attempt_reconnect() -> dict:
|
||||
"""Shared reconnection logic for reboot and reconnect endpoints."""
|
||||
radio_manager.resume_connection()
|
||||
|
||||
if radio_manager.is_reconnecting:
|
||||
return {
|
||||
"status": "pending",
|
||||
@@ -194,6 +382,20 @@ async def _attempt_reconnect() -> dict:
|
||||
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
|
||||
|
||||
|
||||
@router.post("/disconnect")
|
||||
async def disconnect_radio() -> dict:
|
||||
"""Disconnect from the radio and pause automatic reconnect attempts."""
|
||||
logger.info("Manual radio disconnect requested")
|
||||
await radio_manager.pause_connection()
|
||||
broadcast_health(False, radio_manager.connection_info)
|
||||
return {
|
||||
"status": "ok",
|
||||
"message": "Disconnected. Automatic reconnect is paused.",
|
||||
"connected": False,
|
||||
"paused": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/reboot")
|
||||
async def reboot_radio() -> dict:
|
||||
"""Reboot the radio, or reconnect if not currently connected.
|
||||
@@ -228,8 +430,11 @@ async def reconnect_radio() -> dict:
|
||||
|
||||
logger.info("Radio connected but setup incomplete, retrying setup")
|
||||
try:
|
||||
await _prepare_connected(broadcast_on_success=True)
|
||||
if not await _prepare_connected(broadcast_on_success=True):
|
||||
raise HTTPException(status_code=503, detail="Radio connection is paused")
|
||||
return {"status": "ok", "message": "Setup completed", "connected": True}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.exception("Post-connect setup failed")
|
||||
raise HTTPException(
|
||||
|
||||
@@ -21,6 +21,7 @@ from app.models import (
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterNeighborsResponse,
|
||||
RepeaterNodeInfoResponse,
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
@@ -373,9 +374,29 @@ async def _batch_cli_fetch(
|
||||
return results
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
|
||||
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
|
||||
"""Fetch repeater identity/location info via a small CLI batch."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
|
||||
results = await _batch_cli_fetch(
|
||||
contact,
|
||||
"repeater_node_info",
|
||||
[
|
||||
("get name", "name"),
|
||||
("get lat", "lat"),
|
||||
("get lon", "lon"),
|
||||
("clock", "clock_utc"),
|
||||
],
|
||||
)
|
||||
return RepeaterNodeInfoResponse(**results)
|
||||
|
||||
|
||||
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
|
||||
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
|
||||
"""Fetch radio settings from a repeater via batch CLI commands."""
|
||||
"""Fetch radio settings from a repeater via radio/config CLI commands."""
|
||||
require_connected()
|
||||
contact = await _resolve_contact_or_404(public_key)
|
||||
_require_repeater(contact)
|
||||
@@ -390,10 +411,6 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
|
||||
("get af", "airtime_factor"),
|
||||
("get repeat", "repeat_enabled"),
|
||||
("get flood.max", "flood_max"),
|
||||
("get name", "name"),
|
||||
("get lat", "lat"),
|
||||
("get lon", "lon"),
|
||||
("clock", "clock_utc"),
|
||||
],
|
||||
)
|
||||
return RepeaterRadioSettingsResponse(**results)
|
||||
|
||||
121
app/security.py
Normal file
121
app/security.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""ASGI middleware for optional app-wide HTTP Basic authentication."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any
|
||||
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_AUTH_REALM = "RemoteTerm"
|
||||
_UNAUTHORIZED_BODY = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
|
||||
|
||||
|
||||
class BasicAuthMiddleware:
|
||||
"""Protect all HTTP and WebSocket entrypoints with HTTP Basic auth."""
|
||||
|
||||
def __init__(self, app, *, username: str, password: str, realm: str = _AUTH_REALM) -> None:
|
||||
self.app = app
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.realm = realm
|
||||
self._challenge_value = f'Basic realm="{realm}", charset="UTF-8"'.encode("latin-1")
|
||||
|
||||
def _is_authorized(self, scope: dict[str, Any]) -> bool:
|
||||
headers = Headers(scope=scope)
|
||||
authorization = headers.get("authorization")
|
||||
if not authorization:
|
||||
return False
|
||||
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
if not token or scheme.lower() != "basic":
|
||||
return False
|
||||
|
||||
token = token.strip()
|
||||
try:
|
||||
decoded = base64.b64decode(token, validate=True).decode("utf-8")
|
||||
except (binascii.Error, UnicodeDecodeError):
|
||||
logger.debug("Rejecting malformed basic auth header")
|
||||
return False
|
||||
|
||||
username, sep, password = decoded.partition(":")
|
||||
if not sep:
|
||||
return False
|
||||
|
||||
return secrets.compare_digest(username, self.username) and secrets.compare_digest(
|
||||
password, self.password
|
||||
)
|
||||
|
||||
async def _send_http_unauthorized(self, send) -> None:
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
|
||||
(b"www-authenticate", self._challenge_value),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send(
|
||||
{
|
||||
"type": "http.response.body",
|
||||
"body": _UNAUTHORIZED_BODY,
|
||||
}
|
||||
)
|
||||
|
||||
async def _send_websocket_unauthorized(self, send) -> None:
|
||||
await send(
|
||||
{
|
||||
"type": "websocket.http.response.start",
|
||||
"status": 401,
|
||||
"headers": [
|
||||
(b"content-type", b"application/json"),
|
||||
(b"cache-control", b"no-store"),
|
||||
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
|
||||
(b"www-authenticate", self._challenge_value),
|
||||
],
|
||||
}
|
||||
)
|
||||
await send(
|
||||
{
|
||||
"type": "websocket.http.response.body",
|
||||
"body": _UNAUTHORIZED_BODY,
|
||||
}
|
||||
)
|
||||
|
||||
async def __call__(self, scope, receive, send) -> None:
|
||||
scope_type = scope["type"]
|
||||
if scope_type not in {"http", "websocket"}:
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
if self._is_authorized(scope):
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
if scope_type == "http":
|
||||
await self._send_http_unauthorized(send)
|
||||
return
|
||||
|
||||
await self._send_websocket_unauthorized(send)
|
||||
|
||||
|
||||
def add_optional_basic_auth_middleware(app, settings) -> None:
|
||||
"""Enable app-wide basic auth when configured via environment variables."""
|
||||
if not settings.basic_auth_enabled:
|
||||
return
|
||||
|
||||
app.add_middleware(
|
||||
BasicAuthMiddleware,
|
||||
username=settings.basic_auth_username,
|
||||
password=settings.basic_auth_password,
|
||||
)
|
||||
@@ -2,11 +2,29 @@
|
||||
|
||||
import logging
|
||||
|
||||
from app.repository import ContactNameHistoryRepository, MessageRepository
|
||||
from app.repository import ContactNameHistoryRepository, ContactRepository, MessageRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def promote_prefix_contacts_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
contact_repository=ContactRepository,
|
||||
log: logging.Logger | None = None,
|
||||
) -> list[str]:
|
||||
"""Promote prefix-only placeholder contacts once a full key is known."""
|
||||
normalized_key = public_key.lower()
|
||||
promoted = await contact_repository.promote_prefix_placeholders(normalized_key)
|
||||
if promoted:
|
||||
(log or logger).info(
|
||||
"Promoted %d prefix contact placeholder(s) for %s",
|
||||
len(promoted),
|
||||
normalized_key[:12],
|
||||
)
|
||||
return promoted
|
||||
|
||||
|
||||
async def claim_prefix_messages_for_contact(
|
||||
*,
|
||||
public_key: str,
|
||||
|
||||
@@ -6,12 +6,26 @@ import time
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PendingAck = tuple[int, float, int]
|
||||
BUFFERED_ACK_TTL_SECONDS = 30.0
|
||||
|
||||
_pending_acks: dict[str, PendingAck] = {}
|
||||
_buffered_acks: dict[str, float] = {}
|
||||
|
||||
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> None:
|
||||
"""Track an expected ACK code for an outgoing direct message."""
|
||||
def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> bool:
|
||||
"""Track an expected ACK code for an outgoing direct message.
|
||||
|
||||
Returns True when the ACK was already observed and buffered before registration.
|
||||
"""
|
||||
buffered_at = _buffered_acks.pop(expected_ack, None)
|
||||
if buffered_at is not None:
|
||||
logger.debug(
|
||||
"Matched buffered ACK %s immediately for message %d",
|
||||
expected_ack,
|
||||
message_id,
|
||||
)
|
||||
return True
|
||||
|
||||
_pending_acks[expected_ack] = (message_id, time.time(), timeout_ms)
|
||||
logger.debug(
|
||||
"Tracking pending ACK %s for message %d (timeout %dms)",
|
||||
@@ -19,6 +33,13 @@ def track_pending_ack(expected_ack: str, message_id: int, timeout_ms: int) -> No
|
||||
message_id,
|
||||
timeout_ms,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def buffer_unmatched_ack(ack_code: str) -> None:
|
||||
"""Remember an ACK that arrived before its message registration."""
|
||||
_buffered_acks[ack_code] = time.time()
|
||||
logger.debug("Buffered unmatched ACK %s for late registration", ack_code)
|
||||
|
||||
|
||||
def cleanup_expired_acks() -> None:
|
||||
@@ -33,6 +54,15 @@ def cleanup_expired_acks() -> None:
|
||||
del _pending_acks[code]
|
||||
logger.debug("Expired pending ACK %s", code)
|
||||
|
||||
expired_buffered_codes = [
|
||||
code
|
||||
for code, buffered_at in _buffered_acks.items()
|
||||
if now - buffered_at > BUFFERED_ACK_TTL_SECONDS
|
||||
]
|
||||
for code in expired_buffered_codes:
|
||||
del _buffered_acks[code]
|
||||
logger.debug("Expired buffered ACK %s", code)
|
||||
|
||||
|
||||
def pop_pending_ack(ack_code: str) -> int | None:
|
||||
"""Claim the tracked message ID for an ACK code if present."""
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Shared send/resend orchestration for outgoing messages."""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
@@ -7,29 +8,104 @@ from typing import Any
|
||||
from fastapi import HTTPException
|
||||
from meshcore import EventType
|
||||
|
||||
from app.models import ResendChannelMessageResponse
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
|
||||
from app.services.messages import (
|
||||
build_message_model,
|
||||
create_outgoing_channel_message,
|
||||
create_outgoing_direct_message,
|
||||
increment_ack_and_broadcast,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
TrackAckFn = Callable[[str, int, int], None]
|
||||
TrackAckFn = Callable[[str, int, int], bool]
|
||||
NowFn = Callable[[], float]
|
||||
OutgoingReservationKey = tuple[str, str, str]
|
||||
|
||||
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
|
||||
_outgoing_timestamp_reservations_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def allocate_outgoing_sender_timestamp(
|
||||
*,
|
||||
message_repository,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
requested_timestamp: int,
|
||||
) -> int:
|
||||
"""Pick a sender timestamp that will not collide with an existing stored message."""
|
||||
reservation_key = (msg_type, conversation_key, text)
|
||||
candidate = requested_timestamp
|
||||
while True:
|
||||
async with _outgoing_timestamp_reservations_lock:
|
||||
reserved = _pending_outgoing_timestamp_reservations.get(reservation_key, set())
|
||||
is_reserved = candidate in reserved
|
||||
|
||||
if is_reserved:
|
||||
candidate += 1
|
||||
continue
|
||||
|
||||
existing = await message_repository.get_by_content(
|
||||
msg_type=msg_type,
|
||||
conversation_key=conversation_key,
|
||||
text=text,
|
||||
sender_timestamp=candidate,
|
||||
)
|
||||
if existing is not None:
|
||||
candidate += 1
|
||||
continue
|
||||
|
||||
async with _outgoing_timestamp_reservations_lock:
|
||||
reserved = _pending_outgoing_timestamp_reservations.setdefault(reservation_key, set())
|
||||
if candidate in reserved:
|
||||
candidate += 1
|
||||
continue
|
||||
reserved.add(candidate)
|
||||
break
|
||||
|
||||
if candidate != requested_timestamp:
|
||||
logger.info(
|
||||
"Bumped outgoing %s timestamp for %s from %d to %d to avoid same-content collision",
|
||||
msg_type,
|
||||
conversation_key[:12],
|
||||
requested_timestamp,
|
||||
candidate,
|
||||
)
|
||||
|
||||
return candidate
|
||||
|
||||
|
||||
async def release_outgoing_sender_timestamp(
|
||||
*,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
sender_timestamp: int,
|
||||
) -> None:
|
||||
reservation_key = (msg_type, conversation_key, text)
|
||||
async with _outgoing_timestamp_reservations_lock:
|
||||
reserved = _pending_outgoing_timestamp_reservations.get(reservation_key)
|
||||
if not reserved:
|
||||
return
|
||||
reserved.discard(sender_timestamp)
|
||||
if not reserved:
|
||||
_pending_outgoing_timestamp_reservations.pop(reservation_key, None)
|
||||
|
||||
|
||||
async def send_channel_message_with_effective_scope(
|
||||
*,
|
||||
mc,
|
||||
channel,
|
||||
channel_key: str,
|
||||
key_bytes: bytes,
|
||||
text: str,
|
||||
timestamp_bytes: bytes,
|
||||
action_label: str,
|
||||
radio_manager,
|
||||
temp_radio_slot: int,
|
||||
error_broadcast_fn: BroadcastFn,
|
||||
app_settings_repository=AppSettingsRepository,
|
||||
@@ -64,28 +140,64 @@ async def send_channel_message_with_effective_scope(
|
||||
)
|
||||
|
||||
try:
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=temp_radio_slot,
|
||||
channel_name=channel.name,
|
||||
channel_secret=key_bytes,
|
||||
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
|
||||
channel_key,
|
||||
preferred_slot=temp_radio_slot,
|
||||
)
|
||||
if set_result.type == EventType.ERROR:
|
||||
logger.warning(
|
||||
"Failed to set channel on radio slot %d before %s: %s",
|
||||
temp_radio_slot,
|
||||
if needs_configure:
|
||||
logger.debug(
|
||||
"Loading channel %s into radio slot %d before %s%s",
|
||||
channel.name,
|
||||
channel_slot,
|
||||
action_label,
|
||||
set_result.payload,
|
||||
(
|
||||
f" (evicting cached {evicted_channel_key[:8]})"
|
||||
if evicted_channel_key is not None
|
||||
else ""
|
||||
),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to configure channel on radio before {action_label}",
|
||||
try:
|
||||
set_result = await mc.commands.set_channel(
|
||||
channel_idx=channel_slot,
|
||||
channel_name=channel.name,
|
||||
channel_secret=key_bytes,
|
||||
)
|
||||
except Exception:
|
||||
if evicted_channel_key is not None:
|
||||
radio_manager.invalidate_cached_channel_slot(evicted_channel_key)
|
||||
raise
|
||||
if set_result.type == EventType.ERROR:
|
||||
if evicted_channel_key is not None:
|
||||
radio_manager.invalidate_cached_channel_slot(evicted_channel_key)
|
||||
logger.warning(
|
||||
"Failed to set channel on radio slot %d before %s: %s",
|
||||
channel_slot,
|
||||
action_label,
|
||||
set_result.payload,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to configure channel on radio before {action_label}",
|
||||
)
|
||||
radio_manager.note_channel_slot_loaded(channel_key, channel_slot)
|
||||
else:
|
||||
logger.debug(
|
||||
"Reusing cached radio slot %d for channel %s before %s",
|
||||
channel_slot,
|
||||
channel.name,
|
||||
action_label,
|
||||
)
|
||||
|
||||
return await mc.commands.send_chan_msg(
|
||||
chan=temp_radio_slot,
|
||||
send_result = await mc.commands.send_chan_msg(
|
||||
chan=channel_slot,
|
||||
msg=text,
|
||||
timestamp=timestamp_bytes,
|
||||
)
|
||||
if send_result.type == EventType.ERROR:
|
||||
radio_manager.invalidate_cached_channel_slot(channel_key)
|
||||
else:
|
||||
radio_manager.note_channel_slot_used(channel_key)
|
||||
return send_result
|
||||
finally:
|
||||
if override_scope and override_scope != baseline_scope:
|
||||
try:
|
||||
@@ -137,57 +249,78 @@ async def send_direct_message_to_contact(
|
||||
) -> Any:
|
||||
"""Send a direct message and persist/broadcast the outgoing row."""
|
||||
contact_data = contact.to_radio_dict()
|
||||
contact_ensured_on_radio = False
|
||||
async with radio_manager.radio_operation("send_direct_message") as mc:
|
||||
logger.debug("Ensuring contact %s is on radio before sending", contact.public_key[:12])
|
||||
add_result = await mc.commands.add_contact(contact_data)
|
||||
if add_result.type == EventType.ERROR:
|
||||
logger.warning("Failed to add contact to radio: %s", add_result.payload)
|
||||
else:
|
||||
contact_ensured_on_radio = True
|
||||
sent_at: int | None = None
|
||||
sender_timestamp: int | None = None
|
||||
message = None
|
||||
result = None
|
||||
try:
|
||||
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)
|
||||
|
||||
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
|
||||
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if not cached_contact:
|
||||
cached_contact = contact_data
|
||||
|
||||
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,
|
||||
logger.info("Sending direct message to %s", contact.public_key[:12])
|
||||
sent_at = int(now_fn())
|
||||
sender_timestamp = await allocate_outgoing_sender_timestamp(
|
||||
message_repository=message_repository,
|
||||
msg_type="PRIV",
|
||||
conversation_key=contact.public_key.lower(),
|
||||
text=text,
|
||||
requested_timestamp=sent_at,
|
||||
)
|
||||
result = await mc.commands.send_msg(
|
||||
dst=cached_contact,
|
||||
msg=text,
|
||||
timestamp=sender_timestamp,
|
||||
)
|
||||
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
|
||||
message = await create_outgoing_direct_message(
|
||||
conversation_key=contact.public_key.lower(),
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
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",
|
||||
)
|
||||
finally:
|
||||
if sender_timestamp is not None:
|
||||
await release_outgoing_sender_timestamp(
|
||||
msg_type="PRIV",
|
||||
conversation_key=contact.public_key.lower(),
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
if sent_at is None or sender_timestamp is None or message is None or result is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
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)
|
||||
await contact_repository.update_last_contacted(contact.public_key.lower(), sent_at)
|
||||
|
||||
expected_ack = result.payload.get("expected_ack")
|
||||
suggested_timeout: int = result.payload.get("suggested_timeout", 10000)
|
||||
if expected_ack:
|
||||
ack_code = expected_ack.hex() if isinstance(expected_ack, bytes) else expected_ack
|
||||
track_pending_ack_fn(ack_code, message.id, suggested_timeout)
|
||||
matched_immediately = track_pending_ack_fn(ack_code, message.id, suggested_timeout) is True
|
||||
logger.debug("Tracking ACK %s for message %d", ack_code, message.id)
|
||||
if matched_immediately:
|
||||
ack_count = await increment_ack_and_broadcast(
|
||||
message_id=message.id,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
message.acked = ack_count
|
||||
|
||||
return message
|
||||
|
||||
@@ -206,40 +339,53 @@ async def send_channel_message_to_channel(
|
||||
message_repository=MessageRepository,
|
||||
) -> Any:
|
||||
"""Send a channel message and persist/broadcast the outgoing row."""
|
||||
message_id: int | None = None
|
||||
now: int | None = None
|
||||
sent_at: int | None = None
|
||||
sender_timestamp: int | None = None
|
||||
radio_name = ""
|
||||
our_public_key: str | None = None
|
||||
text_with_sender = text
|
||||
outgoing_message = 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}: {text}" if radio_name else text
|
||||
logger.info("Sending channel message to %s: %s", channel.name, text[:50])
|
||||
try:
|
||||
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")
|
||||
sent_at = int(now_fn())
|
||||
sender_timestamp = await allocate_outgoing_sender_timestamp(
|
||||
message_repository=message_repository,
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
requested_timestamp=sent_at,
|
||||
)
|
||||
timestamp_bytes = sender_timestamp.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,
|
||||
)
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
channel_key=channel_key_upper,
|
||||
key_bytes=key_bytes,
|
||||
text=text,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="sending message",
|
||||
radio_manager=radio_manager,
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
|
||||
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_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=our_public_key,
|
||||
channel_name=channel.name,
|
||||
@@ -251,19 +397,27 @@ async def send_channel_message_to_channel(
|
||||
status_code=500,
|
||||
detail="Failed to store outgoing message - unexpected duplicate",
|
||||
)
|
||||
message_id = outgoing_message.id
|
||||
finally:
|
||||
if sender_timestamp is not None:
|
||||
await release_outgoing_sender_timestamp(
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
|
||||
if message_id is None or now is None:
|
||||
if sent_at is None or sender_timestamp is None or outgoing_message is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
|
||||
|
||||
message_id = outgoing_message.id
|
||||
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
|
||||
return build_message_model(
|
||||
message_id=message_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=channel_key_upper,
|
||||
text=text_with_sender,
|
||||
sender_timestamp=now,
|
||||
received_at=now,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
paths=paths,
|
||||
outgoing=True,
|
||||
acked=acked_count,
|
||||
@@ -284,7 +438,7 @@ async def resend_channel_message_record(
|
||||
now_fn: NowFn,
|
||||
temp_radio_slot: int,
|
||||
message_repository=MessageRepository,
|
||||
) -> dict[str, Any]:
|
||||
) -> ResendChannelMessageResponse:
|
||||
"""Resend a stored outgoing channel message."""
|
||||
try:
|
||||
key_bytes = bytes.fromhex(message.conversation_key)
|
||||
@@ -294,59 +448,82 @@ async def resend_channel_message_record(
|
||||
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")
|
||||
sent_at: int | None = None
|
||||
sender_timestamp = message.sender_timestamp
|
||||
timestamp_bytes = message.sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
resend_public_key: str | None = None
|
||||
radio_name = ""
|
||||
new_message = None
|
||||
stored_text = message.text
|
||||
|
||||
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}: ") :]
|
||||
try:
|
||||
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}: ") :]
|
||||
if new_timestamp:
|
||||
sent_at = int(now_fn())
|
||||
sender_timestamp = await allocate_outgoing_sender_timestamp(
|
||||
message_repository=message_repository,
|
||||
msg_type="CHAN",
|
||||
conversation_key=message.conversation_key,
|
||||
text=stored_text,
|
||||
requested_timestamp=sent_at,
|
||||
)
|
||||
timestamp_bytes = sender_timestamp.to_bytes(4, "little")
|
||||
|
||||
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}",
|
||||
result = await send_channel_message_with_effective_scope(
|
||||
mc=mc,
|
||||
channel=channel,
|
||||
channel_key=message.conversation_key,
|
||||
key_bytes=key_bytes,
|
||||
text=text_to_send,
|
||||
timestamp_bytes=timestamp_bytes,
|
||||
action_label="resending message",
|
||||
radio_manager=radio_manager,
|
||||
temp_radio_slot=temp_radio_slot,
|
||||
error_broadcast_fn=error_broadcast_fn,
|
||||
)
|
||||
if result.type == EventType.ERROR:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to resend message: {result.payload}",
|
||||
)
|
||||
|
||||
if new_timestamp:
|
||||
if sent_at is None:
|
||||
raise HTTPException(status_code=500, detail="Failed to assign resend timestamp")
|
||||
new_message = await create_outgoing_channel_message(
|
||||
conversation_key=message.conversation_key,
|
||||
text=message.text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=sent_at,
|
||||
sender_name=radio_name or None,
|
||||
sender_key=resend_public_key,
|
||||
channel_name=channel.name,
|
||||
broadcast_fn=broadcast_fn,
|
||||
message_repository=message_repository,
|
||||
)
|
||||
if new_message is None:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Failed to store resent message - unexpected duplicate",
|
||||
)
|
||||
finally:
|
||||
if new_timestamp and sent_at is not None:
|
||||
await release_outgoing_sender_timestamp(
|
||||
msg_type="CHAN",
|
||||
conversation_key=message.conversation_key,
|
||||
text=stored_text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
)
|
||||
|
||||
if new_timestamp:
|
||||
if now is None:
|
||||
if sent_at is None or new_message 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",
|
||||
@@ -354,7 +531,11 @@ async def resend_channel_message_record(
|
||||
new_message.id,
|
||||
channel.name,
|
||||
)
|
||||
return {"status": "ok", "message_id": new_message.id}
|
||||
return ResendChannelMessageResponse(
|
||||
status="ok",
|
||||
message_id=new_message.id,
|
||||
message=new_message,
|
||||
)
|
||||
|
||||
logger.info("Resent channel message %d to %s", message.id, channel.name)
|
||||
return {"status": "ok", "message_id": message.id}
|
||||
return ResendChannelMessageResponse(status="ok", message_id=message.id)
|
||||
|
||||
@@ -12,6 +12,25 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BroadcastFn = Callable[..., Any]
|
||||
LOG_MESSAGE_PREVIEW_LEN = 32
|
||||
|
||||
|
||||
def _truncate_for_log(text: str, max_chars: int = LOG_MESSAGE_PREVIEW_LEN) -> str:
|
||||
"""Return a compact single-line message preview for log output."""
|
||||
normalized = " ".join(text.split())
|
||||
if len(normalized) <= max_chars:
|
||||
return normalized
|
||||
return f"{normalized[:max_chars].rstrip()}..."
|
||||
|
||||
|
||||
def _format_channel_log_target(channel_name: str | None, channel_key: str) -> str:
|
||||
"""Return a human-friendly channel label for logs."""
|
||||
return channel_name or channel_key
|
||||
|
||||
|
||||
def _format_contact_log_target(contact_name: str | None, public_key: str) -> str:
|
||||
"""Return a human-friendly DM target label for logs."""
|
||||
return contact_name or public_key[:12]
|
||||
|
||||
|
||||
def build_message_paths(
|
||||
@@ -108,7 +127,7 @@ async def increment_ack_and_broadcast(
|
||||
|
||||
async def handle_duplicate_message(
|
||||
*,
|
||||
packet_id: int,
|
||||
packet_id: int | None,
|
||||
msg_type: str,
|
||||
conversation_key: str,
|
||||
text: str,
|
||||
@@ -147,7 +166,7 @@ async def handle_duplicate_message(
|
||||
else:
|
||||
paths = existing_msg.paths or []
|
||||
|
||||
if existing_msg.outgoing:
|
||||
if existing_msg.outgoing and existing_msg.type == "CHAN":
|
||||
ack_count = await MessageRepository.increment_ack_count(existing_msg.id)
|
||||
else:
|
||||
ack_count = existing_msg.acked
|
||||
@@ -160,7 +179,8 @@ async def handle_duplicate_message(
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
if packet_id is not None:
|
||||
await RawPacketRepository.mark_decrypted(packet_id, existing_msg.id)
|
||||
|
||||
|
||||
async def create_message_from_decrypted(
|
||||
@@ -214,7 +234,13 @@ async def create_message_from_decrypted(
|
||||
)
|
||||
return None
|
||||
|
||||
logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8])
|
||||
logger.info(
|
||||
'Stored channel message "%s" for %r (msg ID %d in chan ID %s)',
|
||||
_truncate_for_log(text),
|
||||
_format_channel_log_target(channel_name, channel_key_normalized),
|
||||
msg_id,
|
||||
channel_key_normalized,
|
||||
)
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
|
||||
broadcast_message(
|
||||
@@ -292,9 +318,11 @@ async def create_dm_message_from_decrypted(
|
||||
return None
|
||||
|
||||
logger.info(
|
||||
"Stored direct message %d for contact %s (outgoing=%s)",
|
||||
'Stored direct message "%s" for %r (msg ID %d in contact ID %s, outgoing=%s)',
|
||||
_truncate_for_log(decrypted.message),
|
||||
_format_contact_log_target(contact.name if contact else None, conversation_key),
|
||||
msg_id,
|
||||
conversation_key[:12],
|
||||
conversation_key,
|
||||
outgoing,
|
||||
)
|
||||
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
|
||||
@@ -369,6 +397,73 @@ async def create_fallback_direct_message(
|
||||
return message
|
||||
|
||||
|
||||
async def create_fallback_channel_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
message_text: str,
|
||||
sender_timestamp: int,
|
||||
received_at: int,
|
||||
path: str | None,
|
||||
path_len: int | None,
|
||||
txt_type: int,
|
||||
sender_name: str | None,
|
||||
channel_name: str | None,
|
||||
broadcast_fn: BroadcastFn,
|
||||
message_repository=MessageRepository,
|
||||
) -> Message | None:
|
||||
"""Store and broadcast a CHANNEL_MSG_RECV fallback channel message."""
|
||||
conversation_key_normalized = conversation_key.upper()
|
||||
text = f"{sender_name}: {message_text}" if sender_name else message_text
|
||||
|
||||
resolved_sender_key: str | None = None
|
||||
if sender_name:
|
||||
candidates = await ContactRepository.get_by_name(sender_name)
|
||||
if len(candidates) == 1:
|
||||
resolved_sender_key = candidates[0].public_key
|
||||
|
||||
msg_id = await message_repository.create(
|
||||
msg_type="CHAN",
|
||||
text=text,
|
||||
conversation_key=conversation_key_normalized,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
path=path,
|
||||
path_len=path_len,
|
||||
txt_type=txt_type,
|
||||
sender_name=sender_name,
|
||||
sender_key=resolved_sender_key,
|
||||
)
|
||||
if msg_id is None:
|
||||
await handle_duplicate_message(
|
||||
packet_id=None,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
path=path,
|
||||
received_at=received_at,
|
||||
path_len=path_len,
|
||||
broadcast_fn=broadcast_fn,
|
||||
)
|
||||
return None
|
||||
|
||||
message = build_message_model(
|
||||
message_id=msg_id,
|
||||
msg_type="CHAN",
|
||||
conversation_key=conversation_key_normalized,
|
||||
text=text,
|
||||
sender_timestamp=sender_timestamp,
|
||||
received_at=received_at,
|
||||
paths=build_message_paths(path, received_at, path_len),
|
||||
txt_type=txt_type,
|
||||
sender_name=sender_name,
|
||||
sender_key=resolved_sender_key,
|
||||
channel_name=channel_name,
|
||||
)
|
||||
broadcast_message(message=message, broadcast_fn=broadcast_fn)
|
||||
return message
|
||||
|
||||
|
||||
async def create_outgoing_direct_message(
|
||||
*,
|
||||
conversation_key: str,
|
||||
|
||||
@@ -32,6 +32,18 @@ async def apply_radio_config_update(
|
||||
sync_radio_time_fn: Callable[[Any], Awaitable[Any]],
|
||||
) -> None:
|
||||
"""Apply a validated radio-config update to the connected radio."""
|
||||
if update.advert_location_source is not None:
|
||||
advert_loc_policy = 0 if update.advert_location_source == "off" else 1
|
||||
logger.info(
|
||||
"Setting advert location policy to %s",
|
||||
update.advert_location_source,
|
||||
)
|
||||
result = await mc.commands.set_advert_loc_policy(advert_loc_policy)
|
||||
if result is not None and result.type == EventType.ERROR:
|
||||
raise RadioCommandRejectedError(
|
||||
f"Failed to set advert location policy: {result.payload}"
|
||||
)
|
||||
|
||||
if update.name is not None:
|
||||
logger.info("Setting radio name to %s", update.name)
|
||||
await mc.commands.set_name(update.name)
|
||||
|
||||
@@ -32,16 +32,19 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
return
|
||||
radio_manager._setup_in_progress = True
|
||||
radio_manager._setup_complete = False
|
||||
mc = radio_manager.meshcore
|
||||
try:
|
||||
# Register event handlers (no radio I/O, just callback setup)
|
||||
register_event_handlers(mc)
|
||||
|
||||
# Hold the operation lock for all radio I/O during setup.
|
||||
# This prevents user-initiated operations (send message, etc.)
|
||||
# from interleaving commands on the serial link.
|
||||
await radio_manager._acquire_operation_lock("post_connect_setup", blocking=True)
|
||||
try:
|
||||
mc = radio_manager.meshcore
|
||||
if not mc:
|
||||
return
|
||||
|
||||
# Register event handlers against the locked, current transport.
|
||||
register_event_handlers(mc)
|
||||
|
||||
await export_and_store_private_key(mc)
|
||||
|
||||
# Sync radio clock with system time
|
||||
@@ -75,17 +78,25 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
return await _original_handle_rx(data)
|
||||
|
||||
reader.handle_rx = _capture_handle_rx
|
||||
radio_manager.max_channels = 40
|
||||
radio_manager.path_hash_mode = 0
|
||||
radio_manager.path_hash_mode_supported = False
|
||||
try:
|
||||
device_query = await mc.commands.send_device_query()
|
||||
if device_query and "max_channels" in device_query.payload:
|
||||
radio_manager.max_channels = max(
|
||||
1, int(device_query.payload["max_channels"])
|
||||
)
|
||||
if device_query and "path_hash_mode" in device_query.payload:
|
||||
radio_manager.path_hash_mode = device_query.payload["path_hash_mode"]
|
||||
radio_manager.path_hash_mode_supported = True
|
||||
elif _captured_frame:
|
||||
# Raw-frame fallback: byte 1 = fw_ver, byte 81 = path_hash_mode
|
||||
# Raw-frame fallback:
|
||||
# byte 1 = fw_ver, byte 3 = max_channels, byte 81 = path_hash_mode
|
||||
raw = _captured_frame[-1]
|
||||
fw_ver = raw[1] if len(raw) > 1 else 0
|
||||
if fw_ver >= 3 and len(raw) >= 4:
|
||||
radio_manager.max_channels = max(1, raw[3])
|
||||
if fw_ver >= 10 and len(raw) >= 82:
|
||||
radio_manager.path_hash_mode = raw[81]
|
||||
radio_manager.path_hash_mode_supported = True
|
||||
@@ -103,8 +114,9 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
logger.info("Path hash mode: %d (supported)", radio_manager.path_hash_mode)
|
||||
else:
|
||||
logger.debug("Firmware does not report path_hash_mode")
|
||||
logger.info("Max channel slots: %d", radio_manager.max_channels)
|
||||
except Exception as exc:
|
||||
logger.debug("Failed to query path_hash_mode: %s", exc)
|
||||
logger.debug("Failed to query device info capabilities: %s", exc)
|
||||
finally:
|
||||
reader.handle_rx = _original_handle_rx
|
||||
|
||||
@@ -125,6 +137,7 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
drained = await drain_pending_messages(mc)
|
||||
if drained > 0:
|
||||
logger.info("Drained %d pending message(s)", drained)
|
||||
radio_manager.clear_pending_message_channel_slots()
|
||||
|
||||
await mc.start_auto_message_fetching()
|
||||
logger.info("Auto message fetching started")
|
||||
@@ -147,10 +160,15 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
logger.info("Post-connect setup complete")
|
||||
|
||||
|
||||
async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> None:
|
||||
async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> bool:
|
||||
"""Finish setup for an already-connected radio and optionally broadcast health."""
|
||||
from app.websocket import broadcast_error, broadcast_health
|
||||
|
||||
if not radio_manager.connection_desired:
|
||||
if radio_manager.is_connected:
|
||||
await radio_manager.disconnect()
|
||||
return False
|
||||
|
||||
for attempt in range(1, POST_CONNECT_SETUP_MAX_ATTEMPTS + 1):
|
||||
try:
|
||||
await radio_manager.post_connect_setup()
|
||||
@@ -177,9 +195,15 @@ async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool =
|
||||
)
|
||||
raise RuntimeError("Post-connect setup timed out") from exc
|
||||
|
||||
if not radio_manager.connection_desired:
|
||||
if radio_manager.is_connected:
|
||||
await radio_manager.disconnect()
|
||||
return False
|
||||
|
||||
radio_manager._last_connected = True
|
||||
if broadcast_on_success:
|
||||
broadcast_health(True, radio_manager.connection_info)
|
||||
return True
|
||||
|
||||
|
||||
async def reconnect_and_prepare_radio(
|
||||
@@ -192,8 +216,7 @@ async def reconnect_and_prepare_radio(
|
||||
if not connected:
|
||||
return False
|
||||
|
||||
await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success)
|
||||
return True
|
||||
return await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success)
|
||||
|
||||
|
||||
async def connection_monitor_loop(radio_manager) -> None:
|
||||
@@ -209,6 +232,7 @@ async def connection_monitor_loop(radio_manager) -> None:
|
||||
await asyncio.sleep(check_interval_seconds)
|
||||
|
||||
current_connected = radio_manager.is_connected
|
||||
connection_desired = radio_manager.connection_desired
|
||||
|
||||
if radio_manager._last_connected and not current_connected:
|
||||
logger.warning("Radio connection lost, broadcasting status change")
|
||||
@@ -216,6 +240,13 @@ async def connection_monitor_loop(radio_manager) -> None:
|
||||
radio_manager._last_connected = False
|
||||
consecutive_setup_failures = 0
|
||||
|
||||
if not connection_desired:
|
||||
if current_connected:
|
||||
logger.info("Radio connection paused by operator; disconnecting transport")
|
||||
await radio_manager.disconnect()
|
||||
consecutive_setup_failures = 0
|
||||
continue
|
||||
|
||||
if not current_connected:
|
||||
if not radio_manager.is_reconnecting and await reconnect_and_prepare_radio(
|
||||
radio_manager,
|
||||
|
||||
@@ -74,10 +74,12 @@ class RadioRuntime:
|
||||
async def disconnect(self) -> None:
|
||||
await self.manager.disconnect()
|
||||
|
||||
async def prepare_connected(self, *, broadcast_on_success: bool = True) -> None:
|
||||
async def prepare_connected(self, *, broadcast_on_success: bool = True) -> bool:
|
||||
from app.services.radio_lifecycle import prepare_connected_radio
|
||||
|
||||
await prepare_connected_radio(self.manager, broadcast_on_success=broadcast_on_success)
|
||||
return 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
|
||||
|
||||
@@ -51,7 +51,7 @@ frontend/src/
|
||||
│ ├── 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
|
||||
│ ├── useRadioControl.ts # Radio health/config state, reconnection, mesh discovery sweeps
|
||||
│ ├── useAppSettings.ts # Settings, favorites, preferences migration
|
||||
│ ├── useConversationRouter.ts # URL hash → active conversation routing
|
||||
│ └── useContactsAndChannels.ts # Contact/channel loading, creation, deletion
|
||||
@@ -74,6 +74,7 @@ frontend/src/
|
||||
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
|
||||
│ ├── contactAvatar.ts # Avatar color derivation from public key
|
||||
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
|
||||
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
|
||||
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
|
||||
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
|
||||
│ ├── a11y.ts # Keyboard accessibility helper
|
||||
@@ -105,10 +106,11 @@ frontend/src/
|
||||
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
|
||||
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
|
||||
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
|
||||
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
|
||||
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
|
||||
│ ├── settings/
|
||||
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
|
||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot
|
||||
│ │ ├── SettingsRadioSection.tsx # Name, keys, advert interval, max contacts, radio preset, freq/bw/sf/cr, txPower, lat/lon, reboot, mesh discovery
|
||||
│ │ ├── SettingsLocalSection.tsx # Browser-local settings: theme, local label, reopen last conversation
|
||||
│ │ ├── SettingsFanoutSection.tsx # Fanout integrations: MQTT, bots, config CRUD
|
||||
│ │ ├── SettingsDatabaseSection.tsx # DB size, cleanup, auto-decrypt, local label
|
||||
@@ -120,7 +122,8 @@ frontend/src/
|
||||
│ │ ├── RepeaterTelemetryPane.tsx # Battery, airtime, packet counts
|
||||
│ │ ├── RepeaterNeighborsPane.tsx # Neighbor table + lazy mini-map
|
||||
│ │ ├── RepeaterAclPane.tsx # Permission table
|
||||
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio settings + advert intervals
|
||||
│ │ ├── RepeaterNodeInfoPane.tsx # Repeater name, coords, clock drift
|
||||
│ │ ├── RepeaterRadioSettingsPane.tsx # Radio config + advert intervals
|
||||
│ │ ├── RepeaterLppTelemetryPane.tsx # CayenneLPP sensor data
|
||||
│ │ ├── RepeaterOwnerInfoPane.tsx # Owner info + guest password
|
||||
│ │ ├── RepeaterActionsPane.tsx # Send Advert, Sync Clock, Reboot
|
||||
@@ -136,9 +139,13 @@ frontend/src/
|
||||
├── appFavorites.test.tsx
|
||||
├── appStartupHash.test.tsx
|
||||
├── contactAvatar.test.ts
|
||||
├── contactInfoPane.test.tsx
|
||||
├── integration.test.ts
|
||||
├── mapView.test.tsx
|
||||
├── messageCache.test.ts
|
||||
├── messageList.test.tsx
|
||||
├── messageParser.test.ts
|
||||
├── rawPacketList.test.tsx
|
||||
├── pathUtils.test.ts
|
||||
├── prefetch.test.ts
|
||||
├── radioPresets.test.ts
|
||||
@@ -152,12 +159,14 @@ frontend/src/
|
||||
├── newMessageModal.test.tsx
|
||||
├── settingsModal.test.tsx
|
||||
├── sidebar.test.tsx
|
||||
├── statusBar.test.tsx
|
||||
├── unreadCounts.test.ts
|
||||
├── urlHash.test.ts
|
||||
├── appSearchJump.test.tsx
|
||||
├── channelInfoKeyVisibility.test.tsx
|
||||
├── chatHeaderKeyVisibility.test.tsx
|
||||
├── searchView.test.tsx
|
||||
├── useConversationActions.test.ts
|
||||
├── useConversationMessages.test.ts
|
||||
├── useConversationMessages.race.test.ts
|
||||
├── useConversationNavigation.test.ts
|
||||
@@ -240,14 +249,16 @@ High-level state is delegated to hooks:
|
||||
### Radio settings behavior
|
||||
|
||||
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
|
||||
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
|
||||
- Mesh discovery in the radio section is limited to node classes that currently answer discovery control-data requests in firmware: repeaters and sensors.
|
||||
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
|
||||
|
||||
## WebSocket (`useWebSocket.ts`)
|
||||
|
||||
- Auto reconnect (3s) with cleanup guard on unmount.
|
||||
- Heartbeat ping every 30s.
|
||||
- Incoming JSON is parsed through `wsEvents.ts`, which returns a typed discriminated union for known events and a centralized `unknown` fallback.
|
||||
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
|
||||
- Incoming JSON is parsed through `wsEvents.ts`, which validates the top-level envelope and known event type strings, then casts payloads at the handler boundary. It does not schema-validate per-event payload shapes.
|
||||
- Event handlers: `health`, `message`, `contact`, `contact_resolved`, `channel`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
|
||||
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.
|
||||
|
||||
## URL Hash Navigation (`utils/urlHash.ts`)
|
||||
@@ -317,7 +328,7 @@ Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_config
|
||||
|
||||
## Contact Info Pane
|
||||
|
||||
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/{key}/detail`:
|
||||
Clicking a contact's avatar in `ChatHeader` or `MessageList` opens a `ContactInfoPane` sheet (right drawer) showing comprehensive contact details fetched from `GET /api/contacts/analytics` using either `?public_key=...` or `?name=...`:
|
||||
|
||||
- Header: avatar, name, public key, type badge, on-radio badge
|
||||
- Info grid: last seen, first heard, last contacted, distance, hops
|
||||
@@ -350,7 +361,7 @@ For repeater contacts (`type=2`), `ConversationPane.tsx` renders `RepeaterDashbo
|
||||
|
||||
**Login**: `RepeaterLogin` component — password or guest login via `POST /api/contacts/{key}/repeater/login`.
|
||||
|
||||
**Dashboard panes** (after login): Telemetry, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
|
||||
**Dashboard panes** (after login): Telemetry, Node Info, Neighbors, ACL, Radio Settings, Advert Intervals, Owner Info — each fetched via granular `POST /api/contacts/{key}/repeater/{pane}` endpoints. Panes retry up to 3 times client-side. `Neighbors` depends on the smaller `node-info` fetch for repeater GPS, not the heavier radio-settings batch. "Load All" fetches all panes serially (parallel would queue behind the radio lock).
|
||||
|
||||
**Actions pane**: Send Advert, Sync Clock, Reboot — all send CLI commands via `POST /api/contacts/{key}/command`.
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "2.7.9",
|
||||
"version": "3.3.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useCallback, useRef, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import * as messageCache from './messageCache';
|
||||
import { takePrefetchOrFetch } from './prefetch';
|
||||
import { useWebSocket } from './useWebSocket';
|
||||
import {
|
||||
@@ -18,11 +19,62 @@ import {
|
||||
import { AppShell } from './components/AppShell';
|
||||
import type { MessageInputHandle } from './components/MessageInput';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import type { Conversation, RawPacket } from './types';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { Conversation, Message, RawPacket } from './types';
|
||||
|
||||
interface ChannelUnreadMarker {
|
||||
channelId: string;
|
||||
lastReadAt: number | null;
|
||||
}
|
||||
|
||||
interface UnreadBoundaryBackfillParams {
|
||||
activeConversation: Conversation | null;
|
||||
unreadMarker: ChannelUnreadMarker | null;
|
||||
messages: Message[];
|
||||
messagesLoading: boolean;
|
||||
loadingOlder: boolean;
|
||||
hasOlderMessages: boolean;
|
||||
}
|
||||
|
||||
export function getUnreadBoundaryBackfillKey({
|
||||
activeConversation,
|
||||
unreadMarker,
|
||||
messages,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
}: UnreadBoundaryBackfillParams): string | null {
|
||||
if (activeConversation?.type !== 'channel') return null;
|
||||
if (!unreadMarker || unreadMarker.channelId !== activeConversation.id) return null;
|
||||
if (unreadMarker.lastReadAt === null) return null;
|
||||
if (messagesLoading || loadingOlder || !hasOlderMessages || messages.length === 0) return null;
|
||||
|
||||
const oldestLoadedMessage = messages.reduce(
|
||||
(oldest, msg) => {
|
||||
if (!oldest) return msg;
|
||||
if (msg.received_at < oldest.received_at) return msg;
|
||||
if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg;
|
||||
return oldest;
|
||||
},
|
||||
null as Message | null
|
||||
);
|
||||
|
||||
if (!oldestLoadedMessage) return null;
|
||||
if (oldestLoadedMessage.received_at <= unreadMarker.lastReadAt) return null;
|
||||
|
||||
return `${activeConversation.id}:${unreadMarker.lastReadAt}:${oldestLoadedMessage.id}`;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const quoteSearchOperatorValue = useCallback((value: string) => {
|
||||
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
||||
}, []);
|
||||
|
||||
const messageInputRef = useRef<MessageInputHandle>(null);
|
||||
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
|
||||
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
|
||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||
const {
|
||||
notificationsSupported,
|
||||
notificationsPermission,
|
||||
@@ -69,7 +121,12 @@ export function App() {
|
||||
handleSaveConfig,
|
||||
handleSetPrivateKey,
|
||||
handleReboot,
|
||||
handleDisconnect,
|
||||
handleReconnect,
|
||||
handleAdvertise,
|
||||
meshDiscovery,
|
||||
meshDiscoveryLoadingTarget,
|
||||
handleDiscoverMesh,
|
||||
handleHealthRefresh,
|
||||
} = useRadioControl();
|
||||
|
||||
@@ -150,6 +207,7 @@ export function App() {
|
||||
infoPaneContactKey,
|
||||
infoPaneFromChannel,
|
||||
infoPaneChannelKey,
|
||||
searchPrefillRequest,
|
||||
handleOpenContactInfo,
|
||||
handleCloseContactInfo,
|
||||
handleOpenChannelInfo,
|
||||
@@ -157,6 +215,7 @@ export function App() {
|
||||
handleSelectConversationWithTargetReset,
|
||||
handleNavigateToChannel,
|
||||
handleNavigateToMessage,
|
||||
handleOpenSearchWithQuery,
|
||||
} = useConversationNavigation({
|
||||
channels,
|
||||
handleSelectConversation,
|
||||
@@ -174,6 +233,7 @@ export function App() {
|
||||
fetchOlderMessages,
|
||||
fetchNewerMessages,
|
||||
jumpToBottom,
|
||||
reloadCurrentConversation,
|
||||
addMessageIfNew,
|
||||
updateMessageAck,
|
||||
triggerReconcile,
|
||||
@@ -183,12 +243,67 @@ export function App() {
|
||||
unreadCounts,
|
||||
mentions,
|
||||
lastMessageTimes,
|
||||
unreadLastReadAts,
|
||||
incrementUnread,
|
||||
renameConversationState,
|
||||
markAllRead,
|
||||
trackNewMessage,
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeConversation?.type !== 'channel') {
|
||||
setChannelUnreadMarker(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const activeChannelId = activeConversation.id;
|
||||
const activeChannelUnreadCount = unreadCounts[getStateKey('channel', activeChannelId)] ?? 0;
|
||||
|
||||
setChannelUnreadMarker((prev) => {
|
||||
if (prev?.channelId === activeChannelId) {
|
||||
return prev;
|
||||
}
|
||||
if (activeChannelUnreadCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channelId: activeChannelId,
|
||||
lastReadAt: unreadLastReadAts[getStateKey('channel', activeChannelId)] ?? null,
|
||||
};
|
||||
});
|
||||
}, [activeConversation, unreadCounts, unreadLastReadAts]);
|
||||
|
||||
useEffect(() => {
|
||||
lastUnreadBackfillAttemptRef.current = null;
|
||||
}, [activeConversation?.id, channelUnreadMarker?.channelId, channelUnreadMarker?.lastReadAt]);
|
||||
|
||||
useEffect(() => {
|
||||
const backfillKey = getUnreadBoundaryBackfillKey({
|
||||
activeConversation,
|
||||
unreadMarker: channelUnreadMarker,
|
||||
messages,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
});
|
||||
|
||||
if (!backfillKey || lastUnreadBackfillAttemptRef.current === backfillKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastUnreadBackfillAttemptRef.current = backfillKey;
|
||||
void fetchOlderMessages();
|
||||
}, [
|
||||
activeConversation,
|
||||
channelUnreadMarker,
|
||||
messages,
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
fetchOlderMessages,
|
||||
]);
|
||||
|
||||
const wsHandlers = useRealtimeAppState({
|
||||
prevHealthRef,
|
||||
setHealth,
|
||||
@@ -206,28 +321,48 @@ export function App() {
|
||||
addMessageIfNew,
|
||||
trackNewMessage,
|
||||
incrementUnread,
|
||||
renameConversationState,
|
||||
checkMention,
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
updateMessageAck,
|
||||
notifyIncomingMessage,
|
||||
});
|
||||
const handleVisibilityPolicyChanged = useCallback(() => {
|
||||
messageCache.clear();
|
||||
reloadCurrentConversation();
|
||||
void refreshUnreads();
|
||||
setVisibilityVersion((current) => current + 1);
|
||||
}, [refreshUnreads, reloadCurrentConversation]);
|
||||
|
||||
const handleBlockKey = useCallback(
|
||||
async (key: string) => {
|
||||
await handleToggleBlockedKey(key);
|
||||
handleVisibilityPolicyChanged();
|
||||
},
|
||||
[handleToggleBlockedKey, handleVisibilityPolicyChanged]
|
||||
);
|
||||
|
||||
const handleBlockName = useCallback(
|
||||
async (name: string) => {
|
||||
await handleToggleBlockedName(name);
|
||||
handleVisibilityPolicyChanged();
|
||||
},
|
||||
[handleToggleBlockedName, handleVisibilityPolicyChanged]
|
||||
);
|
||||
const {
|
||||
handleSendMessage,
|
||||
handleResendChannelMessage,
|
||||
handleSetChannelFloodScopeOverride,
|
||||
handleSenderClick,
|
||||
handleTrace,
|
||||
handleBlockKey,
|
||||
handleBlockName,
|
||||
handlePathDiscovery,
|
||||
} = useConversationActions({
|
||||
activeConversation,
|
||||
activeConversationRef,
|
||||
setContacts,
|
||||
setChannels,
|
||||
addMessageIfNew,
|
||||
jumpToBottom,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
messageInputRef,
|
||||
});
|
||||
const handleCreateCrackedChannel = useCallback(
|
||||
@@ -284,11 +419,17 @@ export function App() {
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
unreadMarkerLastReadAt:
|
||||
activeConversation?.type === 'channel' &&
|
||||
channelUnreadMarker?.channelId === activeConversation.id
|
||||
? channelUnreadMarker.lastReadAt
|
||||
: undefined,
|
||||
targetMessageId,
|
||||
hasNewerMessages,
|
||||
loadingNewer,
|
||||
messageInputRef,
|
||||
onTrace: handleTrace,
|
||||
onPathDiscovery: handlePathDiscovery,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onDeleteContact: handleDeleteContact,
|
||||
onDeleteChannel: handleDeleteChannel,
|
||||
@@ -302,6 +443,7 @@ export function App() {
|
||||
onLoadNewer: fetchNewerMessages,
|
||||
onJumpToBottom: jumpToBottom,
|
||||
onSendMessage: handleSendMessage,
|
||||
onDismissUnreadMarker: () => setChannelUnreadMarker(null),
|
||||
notificationsSupported,
|
||||
notificationsPermission,
|
||||
notificationsEnabled:
|
||||
@@ -321,7 +463,9 @@ export function App() {
|
||||
const searchProps = {
|
||||
contacts,
|
||||
channels,
|
||||
visibilityVersion,
|
||||
onNavigateToMessage: handleNavigateToMessage,
|
||||
prefillRequest: searchPrefillRequest,
|
||||
};
|
||||
const settingsProps = {
|
||||
config,
|
||||
@@ -331,7 +475,12 @@ export function App() {
|
||||
onSaveAppSettings: handleSaveAppSettings,
|
||||
onSetPrivateKey: handleSetPrivateKey,
|
||||
onReboot: handleReboot,
|
||||
onDisconnect: handleDisconnect,
|
||||
onReconnect: handleReconnect,
|
||||
onAdvertise: handleAdvertise,
|
||||
meshDiscovery,
|
||||
meshDiscoveryLoadingTarget,
|
||||
onDiscoverMesh: handleDiscoverMesh,
|
||||
onHealthRefresh: handleHealthRefresh,
|
||||
onRefreshAppSettings: fetchAppSettings,
|
||||
blockedKeys: appSettings?.blocked_keys,
|
||||
@@ -361,6 +510,12 @@ export function App() {
|
||||
favorites,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onNavigateToChannel: handleNavigateToChannel,
|
||||
onSearchMessagesByKey: (publicKey: string) => {
|
||||
handleOpenSearchWithQuery(`user:${publicKey}`);
|
||||
},
|
||||
onSearchMessagesByName: (name: string) => {
|
||||
handleOpenSearchWithQuery(`user:${quoteSearchOperatorValue(name)}`);
|
||||
},
|
||||
onToggleBlockedKey: handleBlockKey,
|
||||
onToggleBlockedName: handleBlockName,
|
||||
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||
|
||||
@@ -5,9 +5,8 @@ import type {
|
||||
ChannelDetail,
|
||||
CommandResponse,
|
||||
Contact,
|
||||
ContactAdvertPath,
|
||||
ContactAnalytics,
|
||||
ContactAdvertPathSummary,
|
||||
ContactDetail,
|
||||
FanoutConfig,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
@@ -18,11 +17,16 @@ import type {
|
||||
MigratePreferencesResponse,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
RadioDiscoveryTarget,
|
||||
PathDiscoveryResponse,
|
||||
ResendChannelMessageResponse,
|
||||
RepeaterAclResponse,
|
||||
RepeaterAdvertIntervalsResponse,
|
||||
RepeaterLoginResponse,
|
||||
RepeaterLppTelemetryResponse,
|
||||
RepeaterNeighborsResponse,
|
||||
RepeaterNodeInfoResponse,
|
||||
RepeaterOwnerInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterStatusResponse,
|
||||
@@ -95,10 +99,22 @@ export const api = {
|
||||
fetchJson<{ status: string }>('/radio/advertise', {
|
||||
method: 'POST',
|
||||
}),
|
||||
discoverMesh: (target: RadioDiscoveryTarget) =>
|
||||
fetchJson<RadioDiscoveryResponse>('/radio/discover', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ target }),
|
||||
}),
|
||||
rebootRadio: () =>
|
||||
fetchJson<{ status: string; message: string }>('/radio/reboot', {
|
||||
method: 'POST',
|
||||
}),
|
||||
disconnectRadio: () =>
|
||||
fetchJson<{ status: string; message: string; connected: boolean; paused: boolean }>(
|
||||
'/radio/disconnect',
|
||||
{
|
||||
method: 'POST',
|
||||
}
|
||||
),
|
||||
reconnectRadio: () =>
|
||||
fetchJson<{ status: string; message: string; connected: boolean }>('/radio/reconnect', {
|
||||
method: 'POST',
|
||||
@@ -111,10 +127,12 @@ export const api = {
|
||||
fetchJson<ContactAdvertPathSummary[]>(
|
||||
`/contacts/repeaters/advert-paths?limit_per_repeater=${limitPerRepeater}`
|
||||
),
|
||||
getContactAdvertPaths: (publicKey: string, limit = 10) =>
|
||||
fetchJson<ContactAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
|
||||
getContactDetail: (publicKey: string) =>
|
||||
fetchJson<ContactDetail>(`/contacts/${publicKey}/detail`),
|
||||
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.publicKey) searchParams.set('public_key', params.publicKey);
|
||||
if (params.name) searchParams.set('name', params.name);
|
||||
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
|
||||
},
|
||||
deleteContact: (publicKey: string) =>
|
||||
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
|
||||
method: 'DELETE',
|
||||
@@ -137,6 +155,10 @@ export const api = {
|
||||
fetchJson<TraceResponse>(`/contacts/${publicKey}/trace`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
requestPathDiscovery: (publicKey: string) =>
|
||||
fetchJson<PathDiscoveryResponse>(`/contacts/${publicKey}/path-discovery`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
setContactRoutingOverride: (publicKey: string, route: string) =>
|
||||
fetchJson<{ status: string; public_key: string }>(`/contacts/${publicKey}/routing-override`, {
|
||||
method: 'POST',
|
||||
@@ -217,7 +239,7 @@ export const api = {
|
||||
body: JSON.stringify({ channel_key: channelKey, text }),
|
||||
}),
|
||||
resendChannelMessage: (messageId: number, newTimestamp?: boolean) =>
|
||||
fetchJson<{ status: string; message_id: number }>(
|
||||
fetchJson<ResendChannelMessageResponse>(
|
||||
`/messages/channel/${messageId}/resend${newTimestamp ? '?new_timestamp=true' : ''}`,
|
||||
{ method: 'POST' }
|
||||
),
|
||||
@@ -335,6 +357,10 @@ export const api = {
|
||||
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterNodeInfo: (publicKey: string) =>
|
||||
fetchJson<RepeaterNodeInfoResponse>(`/contacts/${publicKey}/repeater/node-info`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
repeaterAcl: (publicKey: string) =>
|
||||
fetchJson<RepeaterAclResponse>(`/contacts/${publicKey}/repeater/acl`, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -178,7 +178,14 @@ export function AppShell({
|
||||
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
|
||||
|
||||
<Sheet open={sidebarOpen} onOpenChange={onSidebarOpenChange}>
|
||||
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
|
||||
<SheetContent
|
||||
side="left"
|
||||
className="w-[280px] p-0 flex flex-col"
|
||||
hideCloseButton
|
||||
onOpenAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
}}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Navigation</SheetTitle>
|
||||
<SheetDescription>Sidebar navigation</SheetDescription>
|
||||
@@ -220,18 +227,6 @@ export function AppShell({
|
||||
|
||||
{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={
|
||||
|
||||
105
frontend/src/components/ChannelFloodScopeOverrideModal.tsx
Normal file
105
frontend/src/components/ChannelFloodScopeOverrideModal.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { stripRegionScopePrefix } from '../utils/regionScope';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
interface ChannelFloodScopeOverrideModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
roomName: string;
|
||||
currentOverride: string | null;
|
||||
onSetOverride: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ChannelFloodScopeOverrideModal({
|
||||
open,
|
||||
onClose,
|
||||
roomName,
|
||||
currentOverride,
|
||||
onSetOverride,
|
||||
}: ChannelFloodScopeOverrideModalProps) {
|
||||
const [region, setRegion] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setRegion(stripRegionScopePrefix(currentOverride));
|
||||
}, [currentOverride, open]);
|
||||
|
||||
const trimmedRegion = region.trim();
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Regional Override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Room-level regional routing temporarily changes the radio flood scope before send and
|
||||
restores it after. This can noticeably slow room sends.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||
<div className="font-medium">{roomName}</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Current regional override:{' '}
|
||||
{currentOverride ? stripRegionScopePrefix(currentOverride) : 'none'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="channel-region-input">Region</Label>
|
||||
<Input
|
||||
id="channel-region-input"
|
||||
value={region}
|
||||
onChange={(event) => setRegion(event.target.value)}
|
||||
placeholder="Esperance"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:block sm:space-x-0">
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
disabled={trimmedRegion.length === 0}
|
||||
onClick={() => {
|
||||
onSetOverride(trimmedRegion);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{trimmedRegion.length > 0
|
||||
? `Use ${trimmedRegion} region for ${roomName}`
|
||||
: `Use region for ${roomName}`}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
onSetOverride('');
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Do not use region routing for {roomName}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,23 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { stripRegionScopePrefix } from '../utils/regionScope';
|
||||
import { isPrefixOnlyContact } from '../utils/pubkey';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
PathDiscoveryResponse,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
conversation: Conversation;
|
||||
@@ -18,6 +29,7 @@ interface ChatHeaderProps {
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
onTrace: () => void;
|
||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||
@@ -37,6 +49,7 @@ export function ChatHeader({
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
onTrace,
|
||||
onPathDiscovery,
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onSetChannelFloodScopeOverride,
|
||||
@@ -46,9 +59,15 @@ export function ChatHeader({
|
||||
onOpenChannelInfo,
|
||||
}: ChatHeaderProps) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [contactStatusInline, setContactStatusInline] = useState(true);
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
|
||||
const keyTextRef = useRef<HTMLSpanElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setShowKey(false);
|
||||
setPathDiscoveryOpen(false);
|
||||
setChannelOverrideOpen(false);
|
||||
}, [conversation.id]);
|
||||
|
||||
const activeChannel =
|
||||
@@ -62,6 +81,13 @@ export function ChatHeader({
|
||||
: null;
|
||||
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
|
||||
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
|
||||
const activeContact =
|
||||
conversation.type === 'contact'
|
||||
? contacts.find((contact) => contact.public_key === conversation.id)
|
||||
: null;
|
||||
const activeContactIsPrefixOnly = activeContact
|
||||
? isPrefixOnlyContact(activeContact.public_key)
|
||||
: false;
|
||||
|
||||
const titleClickable =
|
||||
(conversation.type === 'contact' && onOpenContactInfo) ||
|
||||
@@ -77,23 +103,63 @@ export function ChatHeader({
|
||||
|
||||
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);
|
||||
setChannelOverrideOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenConversationInfo = () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
return;
|
||||
}
|
||||
if (conversation.type === 'channel' && onOpenChannelInfo) {
|
||||
onOpenChannelInfo(conversation.id);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (conversation.type !== 'contact') {
|
||||
setContactStatusInline(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const measure = () => {
|
||||
const keyElement = keyTextRef.current;
|
||||
if (!keyElement) return;
|
||||
const isTruncated = keyElement.scrollWidth > keyElement.clientWidth + 1;
|
||||
setContactStatusInline(!isTruncated);
|
||||
};
|
||||
|
||||
measure();
|
||||
|
||||
const onResize = () => {
|
||||
window.requestAnimationFrame(measure);
|
||||
};
|
||||
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
let observer: ResizeObserver | null = null;
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
observer = new ResizeObserver(() => {
|
||||
window.requestAnimationFrame(measure);
|
||||
});
|
||||
if (keyTextRef.current?.parentElement) {
|
||||
observer.observe(keyTextRef.current.parentElement);
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [conversation.id, conversation.type, showKey]);
|
||||
|
||||
return (
|
||||
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
|
||||
<header className="conversation-header flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex min-w-0 flex-1 items-start gap-2">
|
||||
{conversation.type === 'contact' && onOpenContactInfo && (
|
||||
<span
|
||||
className="flex-shrink-0 cursor-pointer"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-action-button flex-shrink-0 cursor-pointer rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onOpenContactInfo(conversation.id)}
|
||||
title="View contact info"
|
||||
aria-label={`View info for ${conversation.name}`}
|
||||
@@ -105,42 +171,41 @@ export function ChatHeader({
|
||||
contactType={contacts.find((c) => c.public_key === conversation.id)?.type}
|
||||
clickable
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<h2
|
||||
className={`flex shrink min-w-0 items-center gap-1.5 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
|
||||
role={titleClickable ? 'button' : undefined}
|
||||
tabIndex={titleClickable ? 0 : undefined}
|
||||
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
|
||||
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
|
||||
onClick={
|
||||
titleClickable
|
||||
? () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
|
||||
onOpenChannelInfo(conversation.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="truncate">
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
activeChannel?.is_hashtag
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</span>
|
||||
{titleClickable && (
|
||||
<Info
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2 whitespace-nowrap">
|
||||
<h2 className="min-w-0 shrink font-semibold text-base">
|
||||
{titleClickable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
|
||||
aria-label={`View info for ${conversation.name}`}
|
||||
onClick={handleOpenConversationInfo}
|
||||
>
|
||||
<span className="truncate">
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
activeChannel?.is_hashtag
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</span>
|
||||
<Info
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="truncate">
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
activeChannel?.is_hashtag
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
@@ -156,6 +221,7 @@ export function ChatHeader({
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
ref={keyTextRef}
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
@@ -178,21 +244,25 @@ export function ChatHeader({
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{conversation.type === 'contact' &&
|
||||
(() => {
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||
if (!contact) return null;
|
||||
return (
|
||||
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
|
||||
<ContactStatusInfo
|
||||
contact={contact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
{conversation.type === 'contact' && activeContact && contactStatusInline && (
|
||||
<span className="min-w-0 flex-none text-[11px] text-muted-foreground">
|
||||
<ContactStatusInfo
|
||||
contact={activeContact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{conversation.type === 'contact' && activeContact && !contactStatusInline && (
|
||||
<span className="mt-0.5 min-w-0 text-[11px] text-muted-foreground">
|
||||
<ContactStatusInfo
|
||||
contact={activeContact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
{conversation.type === 'channel' && activeFloodScopeDisplay && (
|
||||
<button
|
||||
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
|
||||
@@ -214,12 +284,32 @@ export function ChatHeader({
|
||||
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
|
||||
{conversation.type === 'contact' && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTrace}
|
||||
title="Direct Trace"
|
||||
aria-label="Direct Trace"
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setPathDiscoveryOpen(true)}
|
||||
title={
|
||||
activeContactIsPrefixOnly
|
||||
? 'Path Discovery unavailable until the full contact key is known'
|
||||
: 'Path Discovery. Send a routed probe and inspect the forward and return paths'
|
||||
}
|
||||
aria-label="Path Discovery"
|
||||
disabled={activeContactIsPrefixOnly}
|
||||
>
|
||||
<Route className="h-4 w-4" aria-hidden="true" />
|
||||
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{conversation.type === 'contact' && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTrace}
|
||||
title={
|
||||
activeContactIsPrefixOnly
|
||||
? 'Direct Trace unavailable until the full contact key is known'
|
||||
: 'Direct Trace. Send a zero-hop packet to thie contact and display out and back SNR'
|
||||
}
|
||||
aria-label="Direct Trace"
|
||||
disabled={activeContactIsPrefixOnly}
|
||||
>
|
||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{notificationsSupported && (
|
||||
@@ -259,7 +349,7 @@ export function ChatHeader({
|
||||
aria-label="Set regional override"
|
||||
>
|
||||
<Globe2
|
||||
className={`h-4 w-4 ${activeFloodScopeLabel ? 'text-[hsl(var(--region-override))]' : ''}`}
|
||||
className={`h-4 w-4 ${activeFloodScopeLabel ? 'text-[hsl(var(--region-override))]' : 'text-muted-foreground'}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{activeFloodScopeDisplay && (
|
||||
@@ -306,6 +396,25 @@ export function ChatHeader({
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{conversation.type === 'contact' && activeContact && (
|
||||
<ContactPathDiscoveryModal
|
||||
open={pathDiscoveryOpen}
|
||||
onClose={() => setPathDiscoveryOpen(false)}
|
||||
contact={activeContact}
|
||||
contacts={contacts}
|
||||
radioName={config?.name ?? null}
|
||||
onDiscover={onPathDiscovery}
|
||||
/>
|
||||
)}
|
||||
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
||||
<ChannelFloodScopeOverrideModal
|
||||
open={channelOverrideOpen}
|
||||
onClose={() => setChannelOverrideOpen(false)}
|
||||
roomName={conversation.name}
|
||||
currentOverride={activeFloodScopeDisplay}
|
||||
onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { Ban, Star } from 'lucide-react';
|
||||
import { Ban, Search, Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
getContactDisplayName,
|
||||
isPrefixOnlyContact,
|
||||
isUnknownFullKeyContact,
|
||||
} from '../utils/pubkey';
|
||||
import {
|
||||
isValidLocation,
|
||||
calculateDistance,
|
||||
@@ -17,7 +22,15 @@ import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import type { Contact, ContactDetail, Favorite, RadioConfig } from '../types';
|
||||
import type {
|
||||
Contact,
|
||||
ContactActiveRoom,
|
||||
ContactAnalytics,
|
||||
ContactAnalyticsHourlyBucket,
|
||||
ContactAnalyticsWeeklyBucket,
|
||||
Favorite,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
|
||||
const CONTACT_TYPE_LABELS: Record<number, string> = {
|
||||
0: 'Unknown',
|
||||
@@ -43,6 +56,8 @@ interface ContactInfoPaneProps {
|
||||
favorites: Favorite[];
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
onSearchMessagesByKey?: (publicKey: string) => void;
|
||||
onSearchMessagesByName?: (name: string) => void;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
onToggleBlockedKey?: (key: string) => void;
|
||||
@@ -58,6 +73,8 @@ export function ContactInfoPane({
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
onNavigateToChannel,
|
||||
onSearchMessagesByKey,
|
||||
onSearchMessagesByName,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
@@ -66,7 +83,7 @@ export function ContactInfoPane({
|
||||
const isNameOnly = contactKey?.startsWith('name:') ?? false;
|
||||
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
|
||||
|
||||
const [detail, setDetail] = useState<ContactDetail | null>(null);
|
||||
const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Get live contact data from contacts array (real-time via WS)
|
||||
@@ -74,21 +91,26 @@ export function ContactInfoPane({
|
||||
contactKey && !isNameOnly ? (contacts.find((c) => c.public_key === contactKey) ?? null) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!contactKey || isNameOnly) {
|
||||
setDetail(null);
|
||||
if (!contactKey) {
|
||||
setAnalytics(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setAnalytics(null);
|
||||
setLoading(true);
|
||||
api
|
||||
.getContactDetail(contactKey)
|
||||
const request =
|
||||
isNameOnly && nameOnlyValue
|
||||
? api.getContactAnalytics({ name: nameOnlyValue })
|
||||
: api.getContactAnalytics({ publicKey: contactKey });
|
||||
|
||||
request
|
||||
.then((data) => {
|
||||
if (!cancelled) setDetail(data);
|
||||
if (!cancelled) setAnalytics(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (!cancelled) {
|
||||
console.error('Failed to fetch contact detail:', err);
|
||||
console.error('Failed to fetch contact analytics:', err);
|
||||
toast.error('Failed to load contact info');
|
||||
}
|
||||
})
|
||||
@@ -98,10 +120,10 @@ export function ContactInfoPane({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [contactKey, isNameOnly]);
|
||||
}, [contactKey, isNameOnly, nameOnlyValue]);
|
||||
|
||||
// Use live contact data where available, fall back to detail snapshot
|
||||
const contact = liveContact ?? detail?.contact ?? null;
|
||||
// Use live contact data where available, fall back to analytics snapshot
|
||||
const contact = liveContact ?? analytics?.contact ?? null;
|
||||
|
||||
const distFromUs =
|
||||
contact &&
|
||||
@@ -116,6 +138,11 @@ export function ContactInfoPane({
|
||||
? formatPathHashMode(effectiveRoute.pathHashMode)
|
||||
: null;
|
||||
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
|
||||
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
|
||||
const isUnknownFullKeyResolvedContact =
|
||||
contact !== null &&
|
||||
!isPrefixOnlyResolvedContact &&
|
||||
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
|
||||
|
||||
return (
|
||||
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
@@ -130,9 +157,15 @@ export function ContactInfoPane({
|
||||
{/* Name-only header */}
|
||||
<div className="px-5 pt-5 pb-4 border-b border-border">
|
||||
<div className="flex items-start gap-4">
|
||||
<ContactAvatar name={nameOnlyValue} publicKey={`name:${nameOnlyValue}`} size={56} />
|
||||
<ContactAvatar
|
||||
name={analytics?.name ?? nameOnlyValue}
|
||||
publicKey={`name:${nameOnlyValue}`}
|
||||
size={56}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">{nameOnlyValue}</h2>
|
||||
<h2 className="text-lg font-semibold truncate">
|
||||
{analytics?.name ?? nameOnlyValue}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
We have not heard an advertisement associated with this name, so we cannot
|
||||
identify their key.
|
||||
@@ -141,8 +174,6 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fromChannel && <ChannelAttributionWarning />}
|
||||
|
||||
{/* Block by name toggle */}
|
||||
{onToggleBlockedName && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
@@ -165,8 +196,53 @@ export function ContactInfoPane({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSearchMessagesByName && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onSearchMessagesByName(nameOnlyValue)}
|
||||
>
|
||||
<Search className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Search user's messages by name</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fromChannel && (
|
||||
<ChannelAttributionWarning
|
||||
nameOnly
|
||||
includeAliasNote={false}
|
||||
className="border-b border-border mx-0 my-0 rounded-none px-5 py-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
<MessageStatsSection
|
||||
dmMessageCount={0}
|
||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||
showDirectMessages={false}
|
||||
/>
|
||||
|
||||
{analytics?.name_first_seen_at && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<InfoItem
|
||||
label="Name First In Use"
|
||||
value={formatTime(analytics.name_first_seen_at)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
) : loading && !detail ? (
|
||||
) : loading && !analytics && !contact ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
@@ -183,7 +259,7 @@ export function ContactInfoPane({
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h2 className="text-lg font-semibold truncate">
|
||||
{contact.name || contact.public_key.slice(0, 12)}
|
||||
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
|
||||
</h2>
|
||||
<span
|
||||
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
|
||||
@@ -202,17 +278,26 @@ export function ContactInfoPane({
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
|
||||
{CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
|
||||
</span>
|
||||
{contact.on_radio && (
|
||||
<span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
|
||||
On Radio
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fromChannel && <ChannelAttributionWarning />}
|
||||
{isPrefixOnlyResolvedContact && (
|
||||
<div className="mx-5 mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
We only know a key prefix for this sender, which can happen when a fallback DM
|
||||
arrives before we hear an advertisement. This contact stays read-only until the full
|
||||
key resolves from a later advertisement.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isUnknownFullKeyResolvedContact && (
|
||||
<div className="mx-5 mt-4 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
We know this sender's full key, but we have not yet heard an advertisement that
|
||||
fills in their identity details. Those details will appear automatically when an
|
||||
advertisement arrives.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
@@ -340,85 +425,25 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AKA (Name History) - only show if more than one name */}
|
||||
{detail && detail.name_history.length > 1 && (
|
||||
{onSearchMessagesByKey && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Also Known As</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.name_history.map((h) => (
|
||||
<div key={h.name} className="flex justify-between items-center text-sm">
|
||||
<span className="font-medium truncate">{h.name}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{formatTime(h.first_seen)} – {formatTime(h.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Stats */}
|
||||
{detail && (detail.dm_message_count > 0 || detail.channel_message_count > 0) && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Messages</SectionLabel>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
{detail.dm_message_count > 0 && (
|
||||
<InfoItem
|
||||
label="Direct Messages"
|
||||
value={detail.dm_message_count.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
{detail.channel_message_count > 0 && (
|
||||
<InfoItem
|
||||
label="Channel Messages"
|
||||
value={detail.channel_message_count.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Most Active Rooms */}
|
||||
{detail && detail.most_active_rooms.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.most_active_rooms.map((room) => (
|
||||
<div
|
||||
key={room.channel_key}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
onNavigateToChannel
|
||||
? 'cursor-pointer hover:text-primary transition-colors truncate'
|
||||
: 'truncate'
|
||||
}
|
||||
role={onNavigateToChannel ? 'button' : undefined}
|
||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
||||
>
|
||||
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
|
||||
? room.channel_name
|
||||
: `#${room.channel_name}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{room.message_count.toLocaleString()} msg
|
||||
{room.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onSearchMessagesByKey(contact.public_key)}
|
||||
>
|
||||
<Search className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Search user's messages by key</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nearest Repeaters */}
|
||||
{detail && detail.nearest_repeaters.length > 0 && (
|
||||
{analytics && analytics.nearest_repeaters.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Nearest Repeaters</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.nearest_repeaters.map((r) => (
|
||||
{analytics.nearest_repeaters.map((r) => (
|
||||
<div key={r.public_key} className="flex justify-between items-center text-sm">
|
||||
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
@@ -434,11 +459,11 @@ export function ContactInfoPane({
|
||||
)}
|
||||
|
||||
{/* Advert Paths */}
|
||||
{detail && detail.advert_paths.length > 0 && (
|
||||
<div className="px-5 py-3">
|
||||
{analytics && analytics.advert_paths.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Recent Advert Paths</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.advert_paths.map((p) => (
|
||||
{analytics.advert_paths.map((p) => (
|
||||
<div
|
||||
key={p.path + p.first_seen}
|
||||
className="flex justify-between items-center text-sm"
|
||||
@@ -454,6 +479,41 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fromChannel && (
|
||||
<ChannelAttributionWarning
|
||||
includeAliasNote={Boolean(analytics && analytics.name_history.length > 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AKA (Name History) - only show if more than one name */}
|
||||
{analytics && analytics.name_history.length > 1 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Also Known As</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{analytics.name_history.map((h) => (
|
||||
<div key={h.name} className="flex justify-between items-center text-sm">
|
||||
<span className="font-medium truncate">{h.name}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{formatTime(h.first_seen)} – {formatTime(h.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageStatsSection
|
||||
dmMessageCount={analytics?.dm_message_count ?? 0}
|
||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||
/>
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
@@ -473,18 +533,304 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ChannelAttributionWarning() {
|
||||
function ChannelAttributionWarning({
|
||||
includeAliasNote = false,
|
||||
nameOnly = false,
|
||||
className = 'mx-5 my-3 px-3 py-2 rounded-md bg-warning/10 border border-warning/20',
|
||||
}: {
|
||||
includeAliasNote?: boolean;
|
||||
nameOnly?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="mx-5 my-3 px-3 py-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400">
|
||||
<div className={className}>
|
||||
<p className="text-xs text-warning">
|
||||
Channel sender identity is based on best-effort name matching. Different nodes using the
|
||||
same name will be attributed to the same contact. Message counts and key-based statistics
|
||||
same name will be attributed to the same {nameOnly ? 'sender name' : 'contact'}. Stats below
|
||||
may be inaccurate.
|
||||
{includeAliasNote &&
|
||||
' Historical counts below may include messages previously attributed under names shown in Also Known As.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageStatsSection({
|
||||
dmMessageCount,
|
||||
channelMessageCount,
|
||||
showDirectMessages = true,
|
||||
}: {
|
||||
dmMessageCount: number;
|
||||
channelMessageCount: number;
|
||||
showDirectMessages?: boolean;
|
||||
}) {
|
||||
if ((showDirectMessages ? dmMessageCount : 0) <= 0 && channelMessageCount <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Messages</SectionLabel>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
{showDirectMessages && dmMessageCount > 0 && (
|
||||
<InfoItem label="Direct Messages" value={dmMessageCount.toLocaleString()} />
|
||||
)}
|
||||
{channelMessageCount > 0 && (
|
||||
<InfoItem label="Channel Messages" value={channelMessageCount.toLocaleString()} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MostActiveRoomsSection({
|
||||
rooms,
|
||||
onNavigateToChannel,
|
||||
}: {
|
||||
rooms: ContactActiveRoom[];
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
}) {
|
||||
if (rooms.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{rooms.map((room) => (
|
||||
<div key={room.channel_key} className="flex justify-between items-center text-sm">
|
||||
<span
|
||||
className={
|
||||
onNavigateToChannel
|
||||
? 'cursor-pointer hover:text-primary transition-colors truncate'
|
||||
: 'truncate'
|
||||
}
|
||||
role={onNavigateToChannel ? 'button' : undefined}
|
||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
||||
>
|
||||
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
|
||||
? room.channel_name
|
||||
: `#${room.channel_name}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{room.message_count.toLocaleString()} msg
|
||||
{room.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | null }) {
|
||||
if (!analytics) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasHourlyActivity = analytics.hourly_activity.some(
|
||||
(bucket) =>
|
||||
bucket.last_24h_count > 0 || bucket.last_week_average > 0 || bucket.all_time_average > 0
|
||||
);
|
||||
const hasWeeklyActivity = analytics.weekly_activity.some((bucket) => bucket.message_count > 0);
|
||||
if (!hasHourlyActivity && !hasWeeklyActivity) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-5 py-3 border-b border-border space-y-4">
|
||||
{hasHourlyActivity && (
|
||||
<div>
|
||||
<SectionLabel>Messages Per Hour</SectionLabel>
|
||||
<ChartLegend
|
||||
items={[
|
||||
{ label: 'Last 24h', color: '#2563eb' },
|
||||
{ label: '7-day avg', color: '#ea580c' },
|
||||
{ label: 'All-time avg', color: '#64748b' },
|
||||
]}
|
||||
/>
|
||||
<ActivityLineChart
|
||||
ariaLabel="Messages per hour"
|
||||
points={analytics.hourly_activity}
|
||||
series={[
|
||||
{ key: 'last_24h_count', color: '#2563eb' },
|
||||
{ key: 'last_week_average', color: '#ea580c' },
|
||||
{ key: 'all_time_average', color: '#64748b' },
|
||||
]}
|
||||
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
|
||||
tickFormatter={(bucket) =>
|
||||
new Date(bucket.bucket_start * 1000).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasWeeklyActivity && (
|
||||
<div>
|
||||
<SectionLabel>Messages Per Week</SectionLabel>
|
||||
<ActivityLineChart
|
||||
ariaLabel="Messages per week"
|
||||
points={analytics.weekly_activity}
|
||||
series={[{ key: 'message_count', color: '#16a34a' }]}
|
||||
valueFormatter={(value) => value.toFixed(0)}
|
||||
tickFormatter={(bucket) =>
|
||||
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[11px] text-muted-foreground">
|
||||
Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour
|
||||
slots.
|
||||
{!analytics.includes_direct_messages &&
|
||||
' Name-only analytics include channel messages only.'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
|
||||
{items.map((item) => (
|
||||
<span key={item.label} className="inline-flex items-center gap-1.5">
|
||||
<span
|
||||
className="inline-block h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: item.color }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{item.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
|
||||
ariaLabel,
|
||||
points,
|
||||
series,
|
||||
tickFormatter,
|
||||
valueFormatter,
|
||||
}: {
|
||||
ariaLabel: string;
|
||||
points: T[];
|
||||
series: Array<{ key: keyof T; color: string }>;
|
||||
tickFormatter: (point: T) => string;
|
||||
valueFormatter: (value: number) => string;
|
||||
}) {
|
||||
const width = 320;
|
||||
const height = 132;
|
||||
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
|
||||
const plotWidth = width - padding.left - padding.right;
|
||||
const plotHeight = height - padding.top - padding.bottom;
|
||||
const allValues = points.flatMap((point) =>
|
||||
series.map((entry) => {
|
||||
const value = point[entry.key];
|
||||
return typeof value === 'number' ? value : 0;
|
||||
})
|
||||
);
|
||||
const maxValue = Math.max(1, ...allValues);
|
||||
const tickIndices = Array.from(
|
||||
new Set([
|
||||
0,
|
||||
Math.floor((points.length - 1) / 3),
|
||||
Math.floor(((points.length - 1) * 2) / 3),
|
||||
points.length - 1,
|
||||
])
|
||||
);
|
||||
|
||||
const buildPolyline = (key: keyof T) =>
|
||||
points
|
||||
.map((point, index) => {
|
||||
const rawValue = point[key];
|
||||
const value = typeof rawValue === 'number' ? rawValue : 0;
|
||||
const x =
|
||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
||||
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ');
|
||||
|
||||
return (
|
||||
<div>
|
||||
<svg
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
className="w-full h-auto"
|
||||
role="img"
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{[0, 0.5, 1].map((ratio) => {
|
||||
const y = padding.top + plotHeight - ratio * plotHeight;
|
||||
const value = maxValue * ratio;
|
||||
return (
|
||||
<g key={ratio}>
|
||||
<line
|
||||
x1={padding.left}
|
||||
x2={width - padding.right}
|
||||
y1={y}
|
||||
y2={y}
|
||||
stroke="hsl(var(--border))"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<text
|
||||
x={padding.left - 6}
|
||||
y={y + 4}
|
||||
fontSize="10"
|
||||
textAnchor="end"
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
>
|
||||
{valueFormatter(value)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{series.map((entry) => (
|
||||
<polyline
|
||||
key={String(entry.key)}
|
||||
fill="none"
|
||||
stroke={entry.color}
|
||||
strokeWidth="2"
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
points={buildPolyline(entry.key)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{tickIndices.map((index) => {
|
||||
const point = points[index];
|
||||
const x =
|
||||
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
|
||||
return (
|
||||
<text
|
||||
key={`${ariaLabel}-${point.bucket_start}`}
|
||||
x={x}
|
||||
y={height - 6}
|
||||
fontSize="10"
|
||||
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
|
||||
fill="hsl(var(--muted-foreground))"
|
||||
>
|
||||
{tickFormatter(point)}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
|
||||
213
frontend/src/components/ContactPathDiscoveryModal.tsx
Normal file
213
frontend/src/components/ContactPathDiscoveryModal.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import type { Contact, PathDiscoveryResponse, PathDiscoveryRoute } from '../types';
|
||||
import {
|
||||
findContactsByPrefix,
|
||||
formatRouteLabel,
|
||||
getEffectiveContactRoute,
|
||||
hasRoutingOverride,
|
||||
parsePathHops,
|
||||
} from '../utils/pathUtils';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
|
||||
interface ContactPathDiscoveryModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
contact: Contact;
|
||||
contacts: Contact[];
|
||||
radioName: string | null;
|
||||
onDiscover: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
}
|
||||
|
||||
function formatPathHashMode(mode: number): string {
|
||||
if (mode === 0) return '1-byte hops';
|
||||
if (mode === 1) return '2-byte hops';
|
||||
if (mode === 2) return '3-byte hops';
|
||||
return 'Unknown hop width';
|
||||
}
|
||||
|
||||
function renderRouteNodes(
|
||||
route: PathDiscoveryRoute,
|
||||
startLabel: string,
|
||||
endLabel: string,
|
||||
contacts: Contact[]
|
||||
): string {
|
||||
if (route.path_len <= 0 || !route.path) {
|
||||
return `${startLabel} -> ${endLabel}`;
|
||||
}
|
||||
|
||||
const hops = parsePathHops(route.path, route.path_len).map((prefix) => {
|
||||
const matches = findContactsByPrefix(prefix, contacts, true);
|
||||
if (matches.length === 1) {
|
||||
return matches[0].name || `${matches[0].public_key.slice(0, prefix.length)}…`;
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
return `${prefix}…?`;
|
||||
}
|
||||
return `${prefix}…`;
|
||||
});
|
||||
|
||||
return [startLabel, ...hops, endLabel].join(' -> ');
|
||||
}
|
||||
|
||||
function RouteCard({
|
||||
label,
|
||||
route,
|
||||
chain,
|
||||
}: {
|
||||
label: string;
|
||||
route: PathDiscoveryRoute;
|
||||
chain: string;
|
||||
}) {
|
||||
const rawPath = parsePathHops(route.path, route.path_len).join(' -> ') || 'direct';
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold">{label}</h4>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{formatRouteLabel(route.path_len, true)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm">{chain}</p>
|
||||
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
|
||||
<span>Raw: {rawPath}</span>
|
||||
<span>{formatPathHashMode(route.path_hash_mode)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactPathDiscoveryModal({
|
||||
open,
|
||||
onClose,
|
||||
contact,
|
||||
contacts,
|
||||
radioName,
|
||||
onDiscover,
|
||||
}: ContactPathDiscoveryModalProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<PathDiscoveryResponse | null>(null);
|
||||
|
||||
const effectiveRoute = useMemo(() => getEffectiveContactRoute(contact), [contact]);
|
||||
const hasForcedRoute = hasRoutingOverride(contact);
|
||||
const learnedRouteSummary = useMemo(() => {
|
||||
if (contact.last_path_len === -1) {
|
||||
return 'Flood';
|
||||
}
|
||||
const hops = parsePathHops(contact.last_path, contact.last_path_len);
|
||||
return hops.length > 0
|
||||
? `${formatRouteLabel(contact.last_path_len, true)} (${hops.join(' -> ')})`
|
||||
: formatRouteLabel(contact.last_path_len, true);
|
||||
}, [contact.last_path, contact.last_path_len]);
|
||||
const forcedRouteSummary = useMemo(() => {
|
||||
if (!hasForcedRoute) {
|
||||
return null;
|
||||
}
|
||||
if (effectiveRoute.pathLen === -1) {
|
||||
return 'Flood';
|
||||
}
|
||||
const hops = parsePathHops(effectiveRoute.path, effectiveRoute.pathLen);
|
||||
return hops.length > 0
|
||||
? `${formatRouteLabel(effectiveRoute.pathLen, true)} (${hops.join(' -> ')})`
|
||||
: formatRouteLabel(effectiveRoute.pathLen, true);
|
||||
}, [effectiveRoute, hasForcedRoute]);
|
||||
|
||||
const forwardChain = result
|
||||
? renderRouteNodes(
|
||||
result.forward_path,
|
||||
radioName || 'Local radio',
|
||||
contact.name || contact.public_key.slice(0, 12),
|
||||
contacts
|
||||
)
|
||||
: null;
|
||||
const returnChain = result
|
||||
? renderRouteNodes(
|
||||
result.return_path,
|
||||
contact.name || contact.public_key.slice(0, 12),
|
||||
radioName || 'Local radio',
|
||||
contacts
|
||||
)
|
||||
: null;
|
||||
|
||||
const handleDiscover = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const discovered = await onDiscover(contact.public_key);
|
||||
setResult(discovered);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Path Discovery</DialogTitle>
|
||||
<DialogDescription>
|
||||
Send a routed probe to this contact and wait for the round-trip path response. The
|
||||
learned forward route will be saved back onto the contact if a response comes back.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||
<div className="font-medium">{contact.name || contact.public_key.slice(0, 12)}</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Current learned route: {learnedRouteSummary}
|
||||
</div>
|
||||
{forcedRouteSummary && (
|
||||
<div className="mt-1 text-destructive">
|
||||
Current forced route: {forcedRouteSummary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasForcedRoute && (
|
||||
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
A forced route override is currently set for this contact. Path discovery will update
|
||||
the learned route data, but it will not replace the forced path. Clearing the forced
|
||||
route afterward is enough to make the newly discovered learned path take effect. You
|
||||
only need to rerun path discovery if you want a fresher route sample.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && forwardChain && returnChain && (
|
||||
<div className="space-y-3">
|
||||
<RouteCard label="Forward Path" route={result.forward_path} chain={forwardChain} />
|
||||
<RouteCard label="Return Path" route={result.return_path} chain={returnChain} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Button onClick={handleDiscover} disabled={loading}>
|
||||
{loading ? 'Running...' : 'Run path discovery'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
175
frontend/src/components/ContactRoutingOverrideModal.tsx
Normal file
175
frontend/src/components/ContactRoutingOverrideModal.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { api } from '../api';
|
||||
import type { Contact } from '../types';
|
||||
import {
|
||||
formatRouteLabel,
|
||||
formatRoutingOverrideInput,
|
||||
hasRoutingOverride,
|
||||
} from '../utils/pathUtils';
|
||||
import { Button } from './ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
|
||||
interface ContactRoutingOverrideModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
contact: Contact;
|
||||
onSaved: (message: string) => void;
|
||||
onError: (message: string) => void;
|
||||
}
|
||||
|
||||
function summarizeLearnedRoute(contact: Contact): string {
|
||||
return formatRouteLabel(contact.last_path_len, true);
|
||||
}
|
||||
|
||||
function summarizeForcedRoute(contact: Contact): string | null {
|
||||
if (!hasRoutingOverride(contact)) {
|
||||
return null;
|
||||
}
|
||||
const routeOverrideLen = contact.route_override_len;
|
||||
return routeOverrideLen == null ? null : formatRouteLabel(routeOverrideLen, true);
|
||||
}
|
||||
|
||||
export function ContactRoutingOverrideModal({
|
||||
open,
|
||||
onClose,
|
||||
contact,
|
||||
onSaved,
|
||||
onError,
|
||||
}: ContactRoutingOverrideModalProps) {
|
||||
const [route, setRoute] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setRoute(formatRoutingOverrideInput(contact));
|
||||
setError(null);
|
||||
}, [contact, open]);
|
||||
|
||||
const forcedRouteSummary = useMemo(() => summarizeForcedRoute(contact), [contact]);
|
||||
|
||||
const saveRoute = async (value: string) => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.setContactRoutingOverride(contact.public_key, value);
|
||||
onSaved(value.trim() === '' ? 'Routing override cleared' : 'Routing override updated');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to update routing override';
|
||||
setError(message);
|
||||
onError(message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Routing Override</DialogTitle>
|
||||
<DialogDescription>
|
||||
Set a forced route for this contact. Leave the field blank to clear the override and
|
||||
fall back to the learned route or flood until a new path is heard.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void saveRoute(route);
|
||||
}}
|
||||
>
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3 text-sm">
|
||||
<div className="font-medium">{contact.name || contact.public_key.slice(0, 12)}</div>
|
||||
<div className="mt-1 text-muted-foreground">
|
||||
Current learned route: {summarizeLearnedRoute(contact)}
|
||||
</div>
|
||||
{forcedRouteSummary && (
|
||||
<div className="mt-1 text-destructive">
|
||||
Current forced route: {forcedRouteSummary}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="routing-override-input">Forced route</Label>
|
||||
<Input
|
||||
id="routing-override-input"
|
||||
value={route}
|
||||
onChange={(event) => setRoute(event.target.value)}
|
||||
placeholder='Examples: "ae,f1" or "ae92,f13e"'
|
||||
autoFocus
|
||||
disabled={saving}
|
||||
/>
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>Use comma-separated 1, 2, or 3 byte hop IDs for an explicit path.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => void saveRoute('-1')}
|
||||
disabled={saving}
|
||||
>
|
||||
Force Flood
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => void saveRoute('0')}
|
||||
disabled={saving}
|
||||
>
|
||||
Force Direct
|
||||
</Button>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={saving || route.trim().length === 0}>
|
||||
{saving
|
||||
? 'Saving...'
|
||||
: `Force ${route.trim() === '' ? 'custom' : route.trim()} routing`}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
<Button type="button" variant="secondary" onClick={onClose} disabled={saving}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void saveRoute('')}
|
||||
disabled={saving}
|
||||
>
|
||||
Clear override
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
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';
|
||||
import { ContactRoutingOverrideModal } from './ContactRoutingOverrideModal';
|
||||
|
||||
interface ContactStatusInfoProps {
|
||||
contact: Contact;
|
||||
@@ -25,28 +24,10 @@ interface ContactStatusInfoProps {
|
||||
* shared between ChatHeader and RepeaterDashboard.
|
||||
*/
|
||||
export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfoProps) {
|
||||
const [routingModalOpen, setRoutingModalOpen] = useState(false);
|
||||
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)}`);
|
||||
}
|
||||
@@ -54,13 +35,13 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
|
||||
parts.push(
|
||||
<span
|
||||
key="path"
|
||||
className="cursor-pointer hover:text-primary hover:underline"
|
||||
className="cursor-pointer underline underline-offset-2 decoration-muted-foreground/50 hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
editRoutingOverride();
|
||||
setRoutingModalOpen(true);
|
||||
}}
|
||||
title="Click to edit routing override"
|
||||
>
|
||||
@@ -101,15 +82,24 @@ export function ContactStatusInfo({ contact, ourLat, ourLon }: ContactStatusInfo
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
return (
|
||||
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
|
||||
(
|
||||
{parts.map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ', '}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
)
|
||||
</span>
|
||||
<>
|
||||
<span className="font-normal text-sm text-muted-foreground flex-shrink-0">
|
||||
(
|
||||
{parts.map((part, i) => (
|
||||
<span key={i}>
|
||||
{i > 0 && ', '}
|
||||
{part}
|
||||
</span>
|
||||
))}
|
||||
)
|
||||
</span>
|
||||
<ContactRoutingOverrideModal
|
||||
open={routingModalOpen}
|
||||
onClose={() => setRoutingModalOpen(false)}
|
||||
contact={contact}
|
||||
onSaved={(message) => toast.success(message)}
|
||||
onError={(message) => toast.error(message)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,10 +11,12 @@ import type {
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
Message,
|
||||
PathDiscoveryResponse,
|
||||
RawPacket,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_REPEATER } from '../types';
|
||||
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
|
||||
|
||||
const RepeaterDashboard = lazy(() =>
|
||||
import('./RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
|
||||
@@ -39,11 +41,13 @@ interface ConversationPaneProps {
|
||||
messagesLoading: boolean;
|
||||
loadingOlder: boolean;
|
||||
hasOlderMessages: boolean;
|
||||
unreadMarkerLastReadAt?: number | null;
|
||||
targetMessageId: number | null;
|
||||
hasNewerMessages: boolean;
|
||||
loadingNewer: boolean;
|
||||
messageInputRef: Ref<MessageInputHandle>;
|
||||
onTrace: () => Promise<void>;
|
||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => Promise<void>;
|
||||
onDeleteContact: (publicKey: string) => Promise<void>;
|
||||
onDeleteChannel: (key: string) => Promise<void>;
|
||||
@@ -56,6 +60,7 @@ interface ConversationPaneProps {
|
||||
onTargetReached: () => void;
|
||||
onLoadNewer: () => Promise<void>;
|
||||
onJumpToBottom: () => void;
|
||||
onDismissUnreadMarker: () => void;
|
||||
onSendMessage: (text: string) => Promise<void>;
|
||||
onToggleNotifications: () => void;
|
||||
}
|
||||
@@ -66,6 +71,25 @@ function LoadingPane({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ContactResolutionBanner({ variant }: { variant: 'unknown-full-key' | 'prefix-only' }) {
|
||||
if (variant === 'prefix-only') {
|
||||
return (
|
||||
<div className="mx-4 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
We only know a key prefix for this sender, which can happen when a fallback DM arrives
|
||||
before we learn their full identity. This conversation is read-only until we hear an
|
||||
advertisement that resolves the full key.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-4 mt-3 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
|
||||
A full identity profile is not yet available because we have not heard an advertisement from
|
||||
this sender. The contact will fill in automatically when an advertisement arrives.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConversationPane({
|
||||
activeConversation,
|
||||
contacts,
|
||||
@@ -81,11 +105,13 @@ export function ConversationPane({
|
||||
messagesLoading,
|
||||
loadingOlder,
|
||||
hasOlderMessages,
|
||||
unreadMarkerLastReadAt,
|
||||
targetMessageId,
|
||||
hasNewerMessages,
|
||||
loadingNewer,
|
||||
messageInputRef,
|
||||
onTrace,
|
||||
onPathDiscovery,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
onDeleteChannel,
|
||||
@@ -98,6 +124,7 @@ export function ConversationPane({
|
||||
onTargetReached,
|
||||
onLoadNewer,
|
||||
onJumpToBottom,
|
||||
onDismissUnreadMarker,
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
}: ConversationPaneProps) {
|
||||
@@ -106,6 +133,17 @@ export function ConversationPane({
|
||||
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
|
||||
return contact?.type === CONTACT_TYPE_REPEATER;
|
||||
}, [activeConversation, contacts]);
|
||||
const activeContact = useMemo(() => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
|
||||
}, [activeConversation, contacts]);
|
||||
const isPrefixOnlyActiveContact = activeContact
|
||||
? isPrefixOnlyContact(activeContact.public_key)
|
||||
: false;
|
||||
const isUnknownFullKeyActiveContact =
|
||||
activeContact !== null &&
|
||||
!isPrefixOnlyActiveContact &&
|
||||
isUnknownFullKeyContact(activeContact.public_key, activeContact.last_advert);
|
||||
|
||||
if (!activeConversation) {
|
||||
return (
|
||||
@@ -170,6 +208,7 @@ export function ConversationPane({
|
||||
radioLon={config?.lon ?? null}
|
||||
radioName={config?.name ?? null}
|
||||
onTrace={onTrace}
|
||||
onPathDiscovery={onPathDiscovery}
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDeleteContact={onDeleteContact}
|
||||
@@ -190,6 +229,7 @@ export function ConversationPane({
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
onTrace={onTrace}
|
||||
onPathDiscovery={onPathDiscovery}
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||
@@ -198,6 +238,12 @@ export function ConversationPane({
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
onOpenChannelInfo={onOpenChannelInfo}
|
||||
/>
|
||||
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact && (
|
||||
<ContactResolutionBanner variant="prefix-only" />
|
||||
)}
|
||||
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
|
||||
<ContactResolutionBanner variant="unknown-full-key" />
|
||||
)}
|
||||
<MessageList
|
||||
key={activeConversation.id}
|
||||
messages={messages}
|
||||
@@ -205,6 +251,12 @@ export function ConversationPane({
|
||||
loading={messagesLoading}
|
||||
loadingOlder={loadingOlder}
|
||||
hasOlderMessages={hasOlderMessages}
|
||||
unreadMarkerLastReadAt={
|
||||
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
|
||||
}
|
||||
onDismissUnreadMarker={
|
||||
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
|
||||
}
|
||||
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
|
||||
onLoadOlder={onLoadOlder}
|
||||
onResendChannelMessage={
|
||||
@@ -220,16 +272,20 @@ export function ConversationPane({
|
||||
onLoadNewer={onLoadNewer}
|
||||
onJumpToBottom={onJumpToBottom}
|
||||
/>
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
onSend={onSendMessage}
|
||||
disabled={!health?.radio_connected}
|
||||
conversationType={activeConversation.type}
|
||||
senderName={config?.name}
|
||||
placeholder={
|
||||
!health?.radio_connected ? 'Radio not connected' : `Message ${activeConversation.name}...`
|
||||
}
|
||||
/>
|
||||
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : (
|
||||
<MessageInput
|
||||
ref={messageInputRef}
|
||||
onSend={onSendMessage}
|
||||
disabled={!health?.radio_connected}
|
||||
conversationType={activeConversation.type}
|
||||
senderName={config?.name}
|
||||
placeholder={
|
||||
!health?.radio_connected
|
||||
? 'Radio not connected'
|
||||
: `Message ${activeConversation.name}...`
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
22
frontend/src/components/DirectTraceIcon.tsx
Normal file
22
frontend/src/components/DirectTraceIcon.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
interface DirectTraceIconProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DirectTraceIcon({ className }: DirectTraceIconProps) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M3 12h12" />
|
||||
<circle cx="18" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
|
||||
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
|
||||
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
@@ -13,17 +13,27 @@ interface MapViewProps {
|
||||
focusedKey?: string | null;
|
||||
}
|
||||
|
||||
const MAP_RECENCY_COLORS = {
|
||||
recent: '#06b6d4',
|
||||
today: '#2563eb',
|
||||
stale: '#f59e0b',
|
||||
old: '#64748b',
|
||||
} as const;
|
||||
const MAP_MARKER_STROKE = '#0f172a';
|
||||
const MAP_REPEATER_RING = '#f8fafc';
|
||||
|
||||
// Calculate marker color based on how recently the contact was heard
|
||||
function getMarkerColor(lastSeen: number): string {
|
||||
function getMarkerColor(lastSeen: number | null | undefined): string {
|
||||
if (lastSeen == null) return MAP_RECENCY_COLORS.old;
|
||||
const now = Date.now() / 1000;
|
||||
const age = now - lastSeen;
|
||||
const hour = 3600;
|
||||
const day = 86400;
|
||||
|
||||
if (age < hour) return '#22c55e'; // Bright green - less than 1 hour
|
||||
if (age < day) return '#4ade80'; // Light green - less than 1 day
|
||||
if (age < 3 * day) return '#a3e635'; // Yellow-green - less than 3 days
|
||||
return '#9ca3af'; // Gray - older (up to 7 days)
|
||||
if (age < hour) return MAP_RECENCY_COLORS.recent;
|
||||
if (age < day) return MAP_RECENCY_COLORS.today;
|
||||
if (age < 3 * day) return MAP_RECENCY_COLORS.stale;
|
||||
return MAP_RECENCY_COLORS.old;
|
||||
}
|
||||
|
||||
// Component to handle map bounds fitting
|
||||
@@ -94,16 +104,17 @@ function MapBoundsHandler({
|
||||
}
|
||||
|
||||
export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
|
||||
|
||||
// Filter to contacts with GPS coordinates, heard within the last 7 days.
|
||||
// Always include the focused contact so "view on map" links work for older nodes.
|
||||
const mappableContacts = useMemo(() => {
|
||||
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
|
||||
return contacts.filter(
|
||||
(c) =>
|
||||
isValidLocation(c.lat, c.lon) &&
|
||||
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
|
||||
);
|
||||
}, [contacts, focusedKey]);
|
||||
}, [contacts, focusedKey, sevenDaysAgo]);
|
||||
|
||||
// Find the focused contact by key
|
||||
const focusedContact = useMemo(() => {
|
||||
@@ -111,6 +122,10 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
return mappableContacts.find((c) => c.public_key === focusedKey) || null;
|
||||
}, [focusedKey, mappableContacts]);
|
||||
|
||||
const includesFocusedOutsideWindow =
|
||||
focusedContact != null &&
|
||||
(focusedContact.last_seen == null || focusedContact.last_seen <= sevenDaysAgo);
|
||||
|
||||
// Track marker refs to open popup programmatically
|
||||
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
|
||||
|
||||
@@ -137,19 +152,48 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
<span>
|
||||
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard
|
||||
in the last 7 days
|
||||
{includesFocusedOutsideWindow ? ' plus the focused contact' : ''}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#22c55e]" aria-hidden="true" /> <1h
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1h
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#4ade80]" aria-hidden="true" /> <1d
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<1d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#a3e635]" aria-hidden="true" /> <3d
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
<3d
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="w-3 h-3 rounded-full bg-[#9ca3af]" aria-hidden="true" /> older
|
||||
<span
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
older
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<span
|
||||
className="w-3 h-3 rounded-full border-2"
|
||||
style={{ borderColor: MAP_REPEATER_RING, backgroundColor: MAP_RECENCY_COLORS.today }}
|
||||
aria-hidden="true"
|
||||
/>{' '}
|
||||
repeater
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,41 +219,46 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
|
||||
|
||||
{mappableContacts.map((contact) => {
|
||||
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
|
||||
const color = getMarkerColor(contact.last_seen!);
|
||||
const color = getMarkerColor(contact.last_seen);
|
||||
const displayName = contact.name || contact.public_key.slice(0, 12);
|
||||
const lastHeardLabel =
|
||||
contact.last_seen != null
|
||||
? formatTime(contact.last_seen)
|
||||
: 'Never heard by this server';
|
||||
const radius = isRepeater ? 10 : 7;
|
||||
|
||||
return (
|
||||
<CircleMarker
|
||||
key={contact.public_key}
|
||||
ref={(ref) => setMarkerRef(contact.public_key, ref)}
|
||||
center={[contact.lat!, contact.lon!]}
|
||||
radius={isRepeater ? 10 : 7}
|
||||
pathOptions={{
|
||||
color: isRepeater ? color : '#000',
|
||||
fillColor: color,
|
||||
fillOpacity: 0.8,
|
||||
weight: isRepeater ? 0 : 1,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{isRepeater && (
|
||||
<span title="Repeater" aria-hidden="true">
|
||||
🛜
|
||||
</span>
|
||||
)}
|
||||
{displayName}
|
||||
<Fragment key={contact.public_key}>
|
||||
<CircleMarker
|
||||
key={contact.public_key}
|
||||
ref={(ref) => setMarkerRef(contact.public_key, ref)}
|
||||
center={[contact.lat!, contact.lon!]}
|
||||
radius={radius}
|
||||
pathOptions={{
|
||||
color: isRepeater ? MAP_REPEATER_RING : MAP_MARKER_STROKE,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.9,
|
||||
weight: isRepeater ? 3 : 2,
|
||||
}}
|
||||
>
|
||||
<Popup>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium flex items-center gap-1">
|
||||
{isRepeater && (
|
||||
<span title="Repeater" aria-hidden="true">
|
||||
🛜
|
||||
</span>
|
||||
)}
|
||||
{displayName}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">Last heard: {lastHeardLabel}</div>
|
||||
<div className="text-xs text-gray-400 mt-1 font-mono">
|
||||
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Last heard: {formatTime(contact.last_seen!)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-1 font-mono">
|
||||
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
|
||||
</div>
|
||||
</div>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
</Popup>
|
||||
</CircleMarker>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</MapContainer>
|
||||
|
||||
@@ -150,7 +150,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
|
||||
return (
|
||||
<form
|
||||
className="px-4 py-2.5 border-t border-border flex flex-col gap-1"
|
||||
className="message-input-shell px-4 py-2.5 border-t border-border flex flex-col gap-1"
|
||||
onSubmit={handleSubmit}
|
||||
autoComplete="off"
|
||||
>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Fragment,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
@@ -22,6 +23,8 @@ interface MessageListProps {
|
||||
loading: boolean;
|
||||
loadingOlder?: boolean;
|
||||
hasOlderMessages?: boolean;
|
||||
unreadMarkerLastReadAt?: number | null;
|
||||
onDismissUnreadMarker?: () => void;
|
||||
onSenderClick?: (sender: string) => void;
|
||||
onLoadOlder?: () => void;
|
||||
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
|
||||
@@ -172,6 +175,8 @@ export function MessageList({
|
||||
loading,
|
||||
loadingOlder = false,
|
||||
hasOlderMessages = false,
|
||||
unreadMarkerLastReadAt,
|
||||
onDismissUnreadMarker,
|
||||
onSenderClick,
|
||||
onLoadOlder,
|
||||
onResendChannelMessage,
|
||||
@@ -198,7 +203,10 @@ export function MessageList({
|
||||
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
|
||||
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
|
||||
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
|
||||
const [jumpToUnreadDismissed, setJumpToUnreadDismissed] = useState(false);
|
||||
const targetScrolledRef = useRef(false);
|
||||
const unreadMarkerRef = useRef<HTMLButtonElement | HTMLDivElement | null>(null);
|
||||
|
||||
// Capture scroll state in the scroll handler BEFORE any state updates
|
||||
const scrollStateRef = useRef({
|
||||
@@ -320,6 +328,57 @@ export function MessageList({
|
||||
};
|
||||
}, [messages, onResendChannelMessage]);
|
||||
|
||||
// Sort messages by received_at ascending (oldest first)
|
||||
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
|
||||
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
||||
const sortedMessages = useMemo(
|
||||
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
|
||||
[messages]
|
||||
);
|
||||
const unreadMarkerIndex = useMemo(() => {
|
||||
if (unreadMarkerLastReadAt === undefined) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const boundary = unreadMarkerLastReadAt ?? 0;
|
||||
return sortedMessages.findIndex((msg) => !msg.outgoing && msg.received_at > boundary);
|
||||
}, [sortedMessages, unreadMarkerLastReadAt]);
|
||||
|
||||
const syncJumpToUnreadVisibility = useCallback(() => {
|
||||
if (unreadMarkerIndex === -1 || jumpToUnreadDismissed) {
|
||||
setShowJumpToUnread(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const marker = unreadMarkerRef.current;
|
||||
const list = listRef.current;
|
||||
if (!marker || !list) {
|
||||
setShowJumpToUnread(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const markerRect = marker.getBoundingClientRect();
|
||||
const listRect = list.getBoundingClientRect();
|
||||
|
||||
if (
|
||||
markerRect.width === 0 ||
|
||||
markerRect.height === 0 ||
|
||||
listRect.width === 0 ||
|
||||
listRect.height === 0
|
||||
) {
|
||||
setShowJumpToUnread(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const markerVisible =
|
||||
markerRect.top >= listRect.top &&
|
||||
markerRect.bottom <= listRect.bottom &&
|
||||
markerRect.left >= listRect.left &&
|
||||
markerRect.right <= listRect.right;
|
||||
|
||||
setShowJumpToUnread(!markerVisible);
|
||||
}, [jumpToUnreadDismissed, unreadMarkerIndex]);
|
||||
|
||||
// Refs for scroll handler to read without causing callback recreation
|
||||
const onLoadOlderRef = useRef(onLoadOlder);
|
||||
const loadingOlderRef = useRef(loadingOlder);
|
||||
@@ -334,6 +393,22 @@ export function MessageList({
|
||||
loadingNewerRef.current = loadingNewer;
|
||||
hasNewerMessagesRef.current = hasNewerMessages;
|
||||
|
||||
const setUnreadMarkerElement = useCallback(
|
||||
(node: HTMLButtonElement | HTMLDivElement | null) => {
|
||||
unreadMarkerRef.current = node;
|
||||
syncJumpToUnreadVisibility();
|
||||
},
|
||||
[syncJumpToUnreadVisibility]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setJumpToUnreadDismissed(false);
|
||||
}, [unreadMarkerIndex]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
syncJumpToUnreadVisibility();
|
||||
}, [messages, syncJumpToUnreadVisibility]);
|
||||
|
||||
// Handle scroll - capture state and detect when user is near top/bottom
|
||||
// Stable callback: reads changing values from refs, never recreated.
|
||||
const handleScroll = useCallback(() => {
|
||||
@@ -342,7 +417,6 @@ export function MessageList({
|
||||
const { scrollTop, scrollHeight, clientHeight } = listRef.current;
|
||||
const distanceFromBottom = scrollHeight - scrollTop - clientHeight;
|
||||
|
||||
// Always capture current scroll state (needed for scroll preservation)
|
||||
scrollStateRef.current = {
|
||||
scrollTop,
|
||||
scrollHeight,
|
||||
@@ -351,7 +425,6 @@ export function MessageList({
|
||||
wasNearBottom: distanceFromBottom < 100,
|
||||
};
|
||||
|
||||
// Show scroll-to-bottom button when not near the bottom (more than 100px away)
|
||||
setShowScrollToBottom(distanceFromBottom > 100);
|
||||
|
||||
if (!onLoadOlderRef.current || loadingOlderRef.current || !hasOlderMessagesRef.current) {
|
||||
@@ -360,7 +433,6 @@ export function MessageList({
|
||||
onLoadOlderRef.current();
|
||||
}
|
||||
|
||||
// Trigger load newer when within 100px of bottom
|
||||
if (
|
||||
onLoadNewerRef.current &&
|
||||
!loadingNewerRef.current &&
|
||||
@@ -369,7 +441,8 @@ export function MessageList({
|
||||
) {
|
||||
onLoadNewerRef.current();
|
||||
}
|
||||
}, []);
|
||||
syncJumpToUnreadVisibility();
|
||||
}, [syncJumpToUnreadVisibility]);
|
||||
|
||||
// Scroll to bottom handler (or jump to bottom if viewing historical messages)
|
||||
const scrollToBottom = useCallback(() => {
|
||||
@@ -382,14 +455,6 @@ export function MessageList({
|
||||
}
|
||||
}, [hasNewerMessages, onJumpToBottom]);
|
||||
|
||||
// Sort messages by received_at ascending (oldest first)
|
||||
// Note: Deduplication is handled by useConversationMessages.addMessageIfNew()
|
||||
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
|
||||
const sortedMessages = useMemo(
|
||||
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
|
||||
[messages]
|
||||
);
|
||||
|
||||
// Sender info for outgoing messages (used by path modal on own messages)
|
||||
const selfSenderInfo = useMemo<SenderInfo>(
|
||||
() => ({
|
||||
@@ -606,97 +671,108 @@ export function MessageList({
|
||||
(avatarName ? `name:${avatarName}` : `message:${msg.id}`);
|
||||
}
|
||||
}
|
||||
const avatarActionLabel =
|
||||
avatarName && avatarName !== 'Unknown'
|
||||
? `View info for ${avatarName}`
|
||||
: `View info for ${avatarKey.slice(0, 12)}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={msg.id}
|
||||
data-message-id={msg.id}
|
||||
className={cn(
|
||||
'flex items-start max-w-[85%]',
|
||||
msg.outgoing && 'flex-row-reverse self-end',
|
||||
isFirstInGroup && !isFirstMessage && 'mt-3'
|
||||
)}
|
||||
>
|
||||
{!msg.outgoing && (
|
||||
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
|
||||
{showAvatar && avatarKey && (
|
||||
<span
|
||||
role={onOpenContactInfo ? 'button' : undefined}
|
||||
tabIndex={onOpenContactInfo ? 0 : undefined}
|
||||
onKeyDown={onOpenContactInfo ? handleKeyboardActivate : undefined}
|
||||
onClick={
|
||||
onOpenContactInfo
|
||||
? () => onOpenContactInfo(avatarKey, msg.type === 'CHAN')
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
publicKey={avatarKey}
|
||||
size={32}
|
||||
clickable={!!onOpenContactInfo}
|
||||
variant={avatarVariant}
|
||||
/>
|
||||
<Fragment key={msg.id}>
|
||||
{unreadMarkerIndex === index &&
|
||||
(onDismissUnreadMarker ? (
|
||||
<button
|
||||
ref={setUnreadMarkerElement}
|
||||
type="button"
|
||||
className="my-2 flex w-full items-center gap-3 text-left text-xs font-medium text-primary transition-colors hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onDismissUnreadMarker}
|
||||
>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
|
||||
Unread messages
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</button>
|
||||
) : (
|
||||
<div
|
||||
ref={setUnreadMarkerElement}
|
||||
className="my-2 flex w-full items-center gap-3 text-xs font-medium text-primary"
|
||||
>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
|
||||
Unread messages
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-border" />
|
||||
</div>
|
||||
))}
|
||||
<div
|
||||
data-message-id={msg.id}
|
||||
className={cn(
|
||||
'py-1.5 px-3 rounded-lg min-w-0',
|
||||
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
|
||||
highlightedMessageId === msg.id && 'message-highlight'
|
||||
'flex items-start max-w-[85%]',
|
||||
msg.outgoing && 'flex-row-reverse self-end',
|
||||
isFirstInGroup && !isFirstMessage && 'mt-3'
|
||||
)}
|
||||
>
|
||||
{showAvatar && (
|
||||
<div className="text-[13px] font-semibold text-foreground mb-0.5">
|
||||
{canClickSender ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => onSenderClick(displaySender)}
|
||||
title={`Mention ${displaySender}`}
|
||||
>
|
||||
{displaySender}
|
||||
</span>
|
||||
) : (
|
||||
displaySender
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
paths={msg.paths}
|
||||
variant="header"
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, sender),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!msg.outgoing && (
|
||||
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
|
||||
{showAvatar &&
|
||||
avatarKey &&
|
||||
(onOpenContactInfo ? (
|
||||
<button
|
||||
type="button"
|
||||
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label={avatarActionLabel}
|
||||
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
|
||||
>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
publicKey={avatarKey}
|
||||
size={32}
|
||||
clickable
|
||||
variant={avatarVariant}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span>
|
||||
<ContactAvatar
|
||||
name={avatarName}
|
||||
publicKey={avatarKey}
|
||||
size={32}
|
||||
variant={avatarVariant}
|
||||
/>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{content.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>
|
||||
{renderTextWithMentions(line, radioName)}
|
||||
{i < arr.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
{!showAvatar && (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground ml-2">
|
||||
<div
|
||||
className={cn(
|
||||
'py-1.5 px-3 rounded-lg min-w-0',
|
||||
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
|
||||
highlightedMessageId === msg.id && 'message-highlight'
|
||||
)}
|
||||
>
|
||||
{showAvatar && (
|
||||
<div className="text-[13px] font-semibold text-foreground mb-0.5">
|
||||
{canClickSender ? (
|
||||
<span
|
||||
className="cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => onSenderClick(displaySender)}
|
||||
title={`Mention ${displaySender}`}
|
||||
>
|
||||
{displaySender}
|
||||
</span>
|
||||
) : (
|
||||
displaySender
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
paths={msg.paths}
|
||||
variant="inline"
|
||||
variant="header"
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
@@ -705,11 +781,58 @@ export function MessageList({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)}
|
||||
{msg.outgoing &&
|
||||
(msg.acked > 0 ? (
|
||||
msg.paths && msg.paths.length > 0 ? (
|
||||
<div className="break-words whitespace-pre-wrap">
|
||||
{content.split('\n').map((line, i, arr) => (
|
||||
<span key={i}>
|
||||
{renderTextWithMentions(line, radioName)}
|
||||
{i < arr.length - 1 && <br />}
|
||||
</span>
|
||||
))}
|
||||
{!showAvatar && (
|
||||
<>
|
||||
<span className="text-[10px] text-muted-foreground ml-2">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
paths={msg.paths}
|
||||
variant="inline"
|
||||
onClick={() =>
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: getSenderInfo(msg, contact, sender),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{msg.outgoing &&
|
||||
(msg.acked > 0 ? (
|
||||
msg.paths && msg.paths.length > 0 ? (
|
||||
<span
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
|
||||
});
|
||||
}}
|
||||
title="View echo paths"
|
||||
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
|
||||
>{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
)
|
||||
) : onResendChannelMessage && msg.type === 'CHAN' ? (
|
||||
<span
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
role="button"
|
||||
@@ -718,48 +841,28 @@ export function MessageList({
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
paths: msg.paths!,
|
||||
paths: [],
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
|
||||
isOutgoingChan: true,
|
||||
});
|
||||
}}
|
||||
title="View echo paths"
|
||||
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
|
||||
>{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
title="Message status"
|
||||
aria-label="No echoes yet — view message status"
|
||||
>
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">{` ✓${msg.acked > 1 ? msg.acked : ''}`}</span>
|
||||
)
|
||||
) : onResendChannelMessage && msg.type === 'CHAN' ? (
|
||||
<span
|
||||
className="text-muted-foreground cursor-pointer hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedPath({
|
||||
paths: [],
|
||||
senderInfo: selfSenderInfo,
|
||||
messageId: msg.id,
|
||||
isOutgoingChan: true,
|
||||
});
|
||||
}}
|
||||
title="Message status"
|
||||
aria-label="No echoes yet — view message status"
|
||||
>
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground" title="No repeats heard yet">
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
))}
|
||||
<span className="text-muted-foreground" title="No repeats heard yet">
|
||||
{' '}
|
||||
?
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{loadingNewer && (
|
||||
@@ -775,6 +878,35 @@ export function MessageList({
|
||||
</div>
|
||||
|
||||
{/* Scroll to bottom button */}
|
||||
{showJumpToUnread && (
|
||||
<div className="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
<div className="pointer-events-auto flex h-9 items-center overflow-hidden rounded-full border border-border bg-card shadow-lg transition-all hover:scale-105">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
unreadMarkerRef.current?.scrollIntoView?.({ block: 'center' });
|
||||
setJumpToUnreadDismissed(true);
|
||||
setShowJumpToUnread(false);
|
||||
}}
|
||||
className="h-full px-3 text-sm font-medium hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
Jump to unread
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setJumpToUnreadDismissed(true);
|
||||
setShowJumpToUnread(false);
|
||||
}}
|
||||
className="flex h-full w-9 items-center justify-center border-l border-border text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
aria-label="Dismiss jump to unread"
|
||||
title="Dismiss jump to unread"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{showScrollToBottom && (
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
|
||||
@@ -169,34 +169,40 @@ export function NewMessageModal({
|
||||
|
||||
<TabsContent value="existing" className="mt-4">
|
||||
<div className="max-h-[300px] overflow-y-auto rounded-md border">
|
||||
{contacts.length === 0 ? (
|
||||
{contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
|
||||
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
|
||||
) : (
|
||||
contacts.map((contact) => (
|
||||
<div
|
||||
key={contact.public_key}
|
||||
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).click();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
onSelectConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
});
|
||||
resetForm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{getContactDisplayName(contact.name, contact.public_key)}
|
||||
</div>
|
||||
))
|
||||
contacts
|
||||
.filter((contact) => contact.public_key.length === 64)
|
||||
.map((contact) => (
|
||||
<div
|
||||
key={contact.public_key}
|
||||
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).click();
|
||||
}
|
||||
}}
|
||||
onClick={() => {
|
||||
onSelectConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(
|
||||
contact.name,
|
||||
contact.public_key,
|
||||
contact.last_advert
|
||||
),
|
||||
});
|
||||
resetForm();
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -29,6 +29,9 @@ export function PacketVisualizer3D({
|
||||
const [showAmbiguousPaths, setShowAmbiguousPaths] = useState(savedSettings.showAmbiguousPaths);
|
||||
const [showAmbiguousNodes, setShowAmbiguousNodes] = useState(savedSettings.showAmbiguousNodes);
|
||||
const [useAdvertPathHints, setUseAdvertPathHints] = useState(savedSettings.useAdvertPathHints);
|
||||
const [collapseLikelyKnownSiblingRepeaters, setCollapseLikelyKnownSiblingRepeaters] = useState(
|
||||
savedSettings.collapseLikelyKnownSiblingRepeaters
|
||||
);
|
||||
const [splitAmbiguousByTraffic, setSplitAmbiguousByTraffic] = useState(
|
||||
savedSettings.splitAmbiguousByTraffic
|
||||
);
|
||||
@@ -52,6 +55,7 @@ export function PacketVisualizer3D({
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic,
|
||||
chargeStrength,
|
||||
observationWindowSec,
|
||||
@@ -66,6 +70,7 @@ export function PacketVisualizer3D({
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic,
|
||||
chargeStrength,
|
||||
observationWindowSec,
|
||||
@@ -108,6 +113,7 @@ export function PacketVisualizer3D({
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic,
|
||||
chargeStrength,
|
||||
letEmDrift,
|
||||
@@ -117,7 +123,7 @@ export function PacketVisualizer3D({
|
||||
pruneStaleMinutes,
|
||||
});
|
||||
|
||||
const { hoveredNodeId, hoveredNeighborIds, pinnedNodeId } = useVisualizer3DScene({
|
||||
const { hoveredNodeId, pinnedNodeId } = useVisualizer3DScene({
|
||||
containerRef,
|
||||
data,
|
||||
autoOrbit,
|
||||
@@ -143,6 +149,8 @@ export function PacketVisualizer3D({
|
||||
setShowAmbiguousNodes={setShowAmbiguousNodes}
|
||||
useAdvertPathHints={useAdvertPathHints}
|
||||
setUseAdvertPathHints={setUseAdvertPathHints}
|
||||
collapseLikelyKnownSiblingRepeaters={collapseLikelyKnownSiblingRepeaters}
|
||||
setCollapseLikelyKnownSiblingRepeaters={setCollapseLikelyKnownSiblingRepeaters}
|
||||
splitAmbiguousByTraffic={splitAmbiguousByTraffic}
|
||||
setSplitAmbiguousByTraffic={setSplitAmbiguousByTraffic}
|
||||
observationWindowSec={observationWindowSec}
|
||||
@@ -167,8 +175,9 @@ export function PacketVisualizer3D({
|
||||
|
||||
<VisualizerTooltip
|
||||
activeNodeId={tooltipNodeId}
|
||||
nodes={data.nodes}
|
||||
neighborIds={hoveredNeighborIds}
|
||||
canonicalNodes={data.canonicalNodes}
|
||||
canonicalNeighborIds={data.canonicalNeighborIds}
|
||||
renderedNodeIds={data.renderedNodeIds}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type { Contact, Conversation, Favorite } from '../types';
|
||||
import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||
import { AclPane } from './repeater/RepeaterAclPane';
|
||||
import { NodeInfoPane } from './repeater/RepeaterNodeInfoPane';
|
||||
import { RadioSettingsPane } from './repeater/RepeaterRadioSettingsPane';
|
||||
import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
|
||||
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
|
||||
import { ActionsPane } from './repeater/RepeaterActionsPane';
|
||||
import { ConsolePane } from './repeater/RepeaterConsolePane';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
|
||||
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
|
||||
export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared';
|
||||
@@ -32,6 +37,7 @@ interface RepeaterDashboardProps {
|
||||
radioLon: number | null;
|
||||
radioName: string | null;
|
||||
onTrace: () => void;
|
||||
onPathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
@@ -48,10 +54,12 @@ export function RepeaterDashboard({
|
||||
radioLon,
|
||||
radioName,
|
||||
onTrace,
|
||||
onPathDiscovery,
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
}: RepeaterDashboardProps) {
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const {
|
||||
loggedIn,
|
||||
loginLoading,
|
||||
@@ -120,13 +128,23 @@ export function RepeaterDashboard({
|
||||
{anyLoading ? 'Loading...' : 'Load All'}
|
||||
</Button>
|
||||
)}
|
||||
{contact && (
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => setPathDiscoveryOpen(true)}
|
||||
title="Path Discovery. Send a routed probe and inspect the forward and return paths"
|
||||
aria-label="Path Discovery"
|
||||
>
|
||||
<Route className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTrace}
|
||||
title="Direct Trace"
|
||||
aria-label="Direct Trace"
|
||||
>
|
||||
<Route className="h-4 w-4" aria-hidden="true" />
|
||||
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
|
||||
</button>
|
||||
{notificationsSupported && (
|
||||
<button
|
||||
@@ -182,6 +200,16 @@ export function RepeaterDashboard({
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{contact && (
|
||||
<ContactPathDiscoveryModal
|
||||
open={pathDiscoveryOpen}
|
||||
onClose={() => setPathDiscoveryOpen(false)}
|
||||
contact={contact}
|
||||
contacts={contacts}
|
||||
radioName={radioName}
|
||||
onDiscover={onPathDiscovery}
|
||||
/>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
@@ -196,9 +224,15 @@ export function RepeaterDashboard({
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Top row: Telemetry + Radio Settings | Neighbors (with expanding map) */}
|
||||
{/* Top row: Telemetry + Radio Settings | Node Info + Neighbors */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<NodeInfoPane
|
||||
data={paneData.nodeInfo}
|
||||
state={paneStates.nodeInfo}
|
||||
onRefresh={() => refreshPane('nodeInfo')}
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
<TelemetryPane
|
||||
data={paneData.status}
|
||||
state={paneStates.status}
|
||||
@@ -221,16 +255,18 @@ export function RepeaterDashboard({
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
</div>
|
||||
<NeighborsPane
|
||||
data={paneData.neighbors}
|
||||
state={paneStates.neighbors}
|
||||
onRefresh={() => refreshPane('neighbors')}
|
||||
disabled={anyLoading}
|
||||
contacts={contacts}
|
||||
radioLat={radioLat}
|
||||
radioLon={radioLon}
|
||||
radioName={radioName}
|
||||
/>
|
||||
<div className="flex flex-col gap-4">
|
||||
<NeighborsPane
|
||||
data={paneData.neighbors}
|
||||
state={paneStates.neighbors}
|
||||
onRefresh={() => refreshPane('neighbors')}
|
||||
disabled={anyLoading}
|
||||
contacts={contacts}
|
||||
nodeInfo={paneData.nodeInfo}
|
||||
nodeInfoState={paneStates.nodeInfo}
|
||||
repeaterName={conversation.name}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Remaining panes: ACL | Owner Info + Actions */}
|
||||
|
||||
@@ -19,6 +19,8 @@ interface SearchResult {
|
||||
sender_name: string | null;
|
||||
}
|
||||
|
||||
const SEARCH_OPERATOR_RE = /(?<!\S)(user|channel):(?:"((?:[^"\\]|\\.)*)"|(\S+))/gi;
|
||||
|
||||
export interface SearchNavigateTarget {
|
||||
id: number;
|
||||
type: 'PRIV' | 'CHAN';
|
||||
@@ -29,7 +31,12 @@ export interface SearchNavigateTarget {
|
||||
export interface SearchViewProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
visibilityVersion?: number;
|
||||
onNavigateToMessage: (target: SearchNavigateTarget) => void;
|
||||
prefillRequest?: {
|
||||
query: string;
|
||||
nonce: number;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function highlightMatch(text: string, query: string): React.ReactNode[] {
|
||||
@@ -53,7 +60,35 @@ function highlightMatch(text: string, query: string): React.ReactNode[] {
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function SearchView({ contacts, channels, onNavigateToMessage }: SearchViewProps) {
|
||||
function getHighlightQuery(query: string): string {
|
||||
const fragments: string[] = [];
|
||||
let lastIndex = 0;
|
||||
let foundOperator = false;
|
||||
|
||||
for (const match of query.matchAll(SEARCH_OPERATOR_RE)) {
|
||||
foundOperator = true;
|
||||
fragments.push(query.slice(lastIndex, match.index));
|
||||
lastIndex = (match.index ?? 0) + match[0].length;
|
||||
}
|
||||
|
||||
if (!foundOperator) {
|
||||
return query;
|
||||
}
|
||||
|
||||
fragments.push(query.slice(lastIndex));
|
||||
return fragments
|
||||
.map((fragment) => fragment.trim())
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
export function SearchView({
|
||||
contacts,
|
||||
channels,
|
||||
visibilityVersion = 0,
|
||||
onNavigateToMessage,
|
||||
prefillRequest = null,
|
||||
}: SearchViewProps) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [debouncedQuery, setDebouncedQuery] = useState('');
|
||||
const [results, setResults] = useState<SearchResult[]>([]);
|
||||
@@ -62,6 +97,7 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
|
||||
const [offset, setOffset] = useState(0);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const highlightQuery = getHighlightQuery(debouncedQuery);
|
||||
|
||||
// Debounce query
|
||||
useEffect(() => {
|
||||
@@ -76,7 +112,24 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
|
||||
setResults([]);
|
||||
setOffset(0);
|
||||
setHasMore(false);
|
||||
}, [debouncedQuery]);
|
||||
}, [debouncedQuery, visibilityVersion]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prefillRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextQuery = prefillRequest.query.trim();
|
||||
setQuery(nextQuery);
|
||||
setDebouncedQuery(nextQuery);
|
||||
inputRef.current?.focus();
|
||||
}, [prefillRequest]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fetch search results
|
||||
useEffect(() => {
|
||||
@@ -108,7 +161,7 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
|
||||
});
|
||||
|
||||
return () => controller.abort();
|
||||
}, [debouncedQuery]);
|
||||
}, [debouncedQuery, visibilityVersion]);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
if (!debouncedQuery || loading) return;
|
||||
@@ -193,7 +246,16 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{!debouncedQuery && (
|
||||
<div className="p-8 text-center text-muted-foreground text-sm">
|
||||
Type to search across all messages
|
||||
<p>Type to search across all messages</p>
|
||||
<p className="mt-2 text-xs">
|
||||
Tip: use <code>user:</code> or <code>channel:</code> for keys or names, and wrap names
|
||||
with spaces in them in quotes.
|
||||
</p>
|
||||
<p className="mt-2 text-xs">
|
||||
Warning: User-key linkage for group messages is best-effort and based on correlation
|
||||
at advertise time. It does not account for multiple users with the same name, and
|
||||
should be considered unreliable.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -246,7 +308,7 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
|
||||
result.sender_name && result.text.startsWith(`${result.sender_name}: `)
|
||||
? result.text.slice(result.sender_name.length + 2)
|
||||
: result.text,
|
||||
debouncedQuery
|
||||
highlightQuery
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,8 @@ import type {
|
||||
HealthStatus,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
RadioDiscoveryTarget,
|
||||
} from '../types';
|
||||
import type { LocalLabel } from '../utils/localLabel';
|
||||
import {
|
||||
@@ -31,7 +33,12 @@ interface SettingsModalBaseProps {
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onSetPrivateKey: (key: string) => Promise<void>;
|
||||
onReboot: () => Promise<void>;
|
||||
onDisconnect: () => Promise<void>;
|
||||
onReconnect: () => Promise<void>;
|
||||
onAdvertise: () => Promise<void>;
|
||||
meshDiscovery: RadioDiscoveryResponse | null;
|
||||
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
|
||||
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
|
||||
onHealthRefresh: () => Promise<void>;
|
||||
onRefreshAppSettings: () => Promise<void>;
|
||||
onLocalLabelChange?: (label: LocalLabel) => void;
|
||||
@@ -59,7 +66,12 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onSaveAppSettings,
|
||||
onSetPrivateKey,
|
||||
onReboot,
|
||||
onDisconnect,
|
||||
onReconnect,
|
||||
onAdvertise,
|
||||
meshDiscovery,
|
||||
meshDiscoveryLoadingTarget,
|
||||
onDiscoverMesh,
|
||||
onHealthRefresh,
|
||||
onRefreshAppSettings,
|
||||
onLocalLabelChange,
|
||||
@@ -182,7 +194,12 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
onSaveAppSettings={onSaveAppSettings}
|
||||
onSetPrivateKey={onSetPrivateKey}
|
||||
onReboot={onReboot}
|
||||
onDisconnect={onDisconnect}
|
||||
onReconnect={onReconnect}
|
||||
onAdvertise={onAdvertise}
|
||||
meshDiscovery={meshDiscovery}
|
||||
meshDiscoveryLoadingTarget={meshDiscoveryLoadingTarget}
|
||||
onDiscoverMesh={onDiscoverMesh}
|
||||
onClose={onClose}
|
||||
className={sectionContentClass}
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
LockOpen,
|
||||
Logs,
|
||||
Map,
|
||||
Search as SearchIcon,
|
||||
Sparkles,
|
||||
SquarePen,
|
||||
Waypoints,
|
||||
X,
|
||||
@@ -416,7 +416,7 @@ export function Sidebar({
|
||||
key: `${keyPrefix}-${contact.public_key}`,
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
|
||||
unreadCount: getUnreadCount('contact', contact.public_key),
|
||||
isMention: hasMention('contact', contact.public_key),
|
||||
notificationsEnabled:
|
||||
@@ -533,7 +533,7 @@ export function Sidebar({
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-raw',
|
||||
active: isActive('raw', 'raw'),
|
||||
icon: <Waypoints className="h-4 w-4" />,
|
||||
icon: <Logs className="h-4 w-4" />,
|
||||
label: 'Packet Feed',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
@@ -557,7 +557,7 @@ export function Sidebar({
|
||||
renderSidebarActionRow({
|
||||
key: 'tool-visualizer',
|
||||
active: isActive('visualizer', 'visualizer'),
|
||||
icon: <Sparkles className="h-4 w-4" />,
|
||||
icon: <Waypoints className="h-4 w-4" />,
|
||||
label: 'Mesh Visualizer',
|
||||
onClick: () =>
|
||||
handleSelectConversation({
|
||||
@@ -671,11 +671,11 @@ export function Sidebar({
|
||||
<div className="relative min-w-0 flex-1">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search conversations..."
|
||||
placeholder="Search rooms/contacts..."
|
||||
aria-label="Search conversations"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="h-7 text-[13px] pr-8 bg-background/50"
|
||||
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
|
||||
@@ -22,13 +22,24 @@ export function StatusBar({
|
||||
onSettingsClick,
|
||||
onMenuClick,
|
||||
}: StatusBarProps) {
|
||||
const radioState =
|
||||
health?.radio_state ??
|
||||
(health?.radio_initializing
|
||||
? 'initializing'
|
||||
: health?.radio_connected
|
||||
? 'connected'
|
||||
: 'disconnected');
|
||||
const connected = health?.radio_connected ?? false;
|
||||
const initializing = health?.radio_initializing ?? false;
|
||||
const statusLabel = initializing
|
||||
? 'Radio Initializing'
|
||||
: connected
|
||||
? 'Radio OK'
|
||||
: 'Radio Disconnected';
|
||||
const statusLabel =
|
||||
radioState === 'paused'
|
||||
? 'Radio Paused'
|
||||
: radioState === 'connecting'
|
||||
? 'Radio Connecting'
|
||||
: radioState === 'initializing'
|
||||
? 'Radio Initializing'
|
||||
: connected
|
||||
? 'Radio OK'
|
||||
: 'Radio Disconnected';
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState(getSavedTheme);
|
||||
|
||||
@@ -97,7 +108,7 @@ export function StatusBar({
|
||||
<div
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full transition-colors',
|
||||
initializing
|
||||
radioState === 'initializing' || radioState === 'connecting'
|
||||
? 'bg-warning'
|
||||
: connected
|
||||
? 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]'
|
||||
@@ -128,13 +139,13 @@ export function StatusBar({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!connected && !initializing && (
|
||||
{(radioState === 'disconnected' || radioState === 'paused') && (
|
||||
<button
|
||||
onClick={handleReconnect}
|
||||
disabled={reconnecting}
|
||||
className="px-3 py-1 bg-warning/10 border border-warning/20 text-warning rounded-md text-xs cursor-pointer hover:bg-warning/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
|
||||
{reconnecting ? 'Reconnecting...' : radioState === 'paused' ? 'Connect' : 'Reconnect'}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -2,7 +2,13 @@ import { useMemo, lazy, Suspense } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RepeaterPane, NotFetched, formatDuration } from './repeaterPaneShared';
|
||||
import { isValidLocation, calculateDistance, formatDistance } from '../../utils/pathUtils';
|
||||
import type { Contact, RepeaterNeighborsResponse, PaneState, NeighborInfo } from '../../types';
|
||||
import type {
|
||||
Contact,
|
||||
RepeaterNeighborsResponse,
|
||||
PaneState,
|
||||
NeighborInfo,
|
||||
RepeaterNodeInfoResponse,
|
||||
} from '../../types';
|
||||
|
||||
const NeighborsMiniMap = lazy(() =>
|
||||
import('../NeighborsMiniMap').then((m) => ({ default: m.NeighborsMiniMap }))
|
||||
@@ -14,19 +20,35 @@ export function NeighborsPane({
|
||||
onRefresh,
|
||||
disabled,
|
||||
contacts,
|
||||
radioLat,
|
||||
radioLon,
|
||||
radioName,
|
||||
nodeInfo,
|
||||
nodeInfoState,
|
||||
repeaterName,
|
||||
}: {
|
||||
data: RepeaterNeighborsResponse | null;
|
||||
state: PaneState;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
contacts: Contact[];
|
||||
radioLat: number | null;
|
||||
radioLon: number | null;
|
||||
radioName: string | null;
|
||||
nodeInfo: RepeaterNodeInfoResponse | null;
|
||||
nodeInfoState: PaneState;
|
||||
repeaterName: string | null;
|
||||
}) {
|
||||
const radioLat = useMemo(() => {
|
||||
const parsed = nodeInfo?.lat != null ? parseFloat(nodeInfo.lat) : null;
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}, [nodeInfo?.lat]);
|
||||
|
||||
const radioLon = useMemo(() => {
|
||||
const parsed = nodeInfo?.lon != null ? parseFloat(nodeInfo.lon) : null;
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}, [nodeInfo?.lon]);
|
||||
|
||||
const radioName = nodeInfo?.name || repeaterName;
|
||||
const hasValidRepeaterGps = isValidLocation(radioLat, radioLon);
|
||||
const showGpsUnavailableMessage =
|
||||
!hasValidRepeaterGps &&
|
||||
(nodeInfoState.error !== null || nodeInfoState.fetched_at != null || nodeInfo !== null);
|
||||
|
||||
// Resolve contact data for each neighbor in a single pass — used for
|
||||
// coords (mini-map), distances (table column), and sorted display order.
|
||||
const { neighborsWithCoords, sorted, hasDistances } = useMemo(() => {
|
||||
@@ -48,7 +70,7 @@ export function NeighborsPane({
|
||||
const nLon = contact?.lon ?? null;
|
||||
|
||||
let dist: string | null = null;
|
||||
if (isValidLocation(radioLat, radioLon) && isValidLocation(nLat, nLon)) {
|
||||
if (hasValidRepeaterGps && isValidLocation(nLat, nLon)) {
|
||||
const distKm = calculateDistance(radioLat, radioLon, nLat, nLon);
|
||||
if (distKm != null) {
|
||||
dist = formatDistance(distKm);
|
||||
@@ -69,7 +91,7 @@ export function NeighborsPane({
|
||||
sorted: enriched,
|
||||
hasDistances: anyDist,
|
||||
};
|
||||
}, [data, contacts, radioLat, radioLon]);
|
||||
}, [contacts, data, hasValidRepeaterGps, radioLat, radioLon]);
|
||||
|
||||
return (
|
||||
<RepeaterPane
|
||||
@@ -120,7 +142,7 @@ export function NeighborsPane({
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{(neighborsWithCoords.length > 0 || isValidLocation(radioLat, radioLon)) && (
|
||||
{hasValidRepeaterGps && (neighborsWithCoords.length > 0 || hasValidRepeaterGps) ? (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="h-48 flex items-center justify-center text-xs text-muted-foreground">
|
||||
@@ -136,7 +158,13 @@ export function NeighborsPane({
|
||||
radioName={radioName}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
) : showGpsUnavailableMessage ? (
|
||||
<div className="rounded border border-border/70 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
GPS info failed to fetch; map and distance data not available. This may be due to
|
||||
missing or zero-zero GPS data on the repeater, or due to transient fetch failure. Try
|
||||
refreshing.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</RepeaterPane>
|
||||
|
||||
55
frontend/src/components/repeater/RepeaterNodeInfoPane.tsx
Normal file
55
frontend/src/components/repeater/RepeaterNodeInfoPane.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { RepeaterPane, NotFetched, KvRow, formatClockDrift } from './repeaterPaneShared';
|
||||
import type { RepeaterNodeInfoResponse, PaneState } from '../../types';
|
||||
|
||||
export function NodeInfoPane({
|
||||
data,
|
||||
state,
|
||||
onRefresh,
|
||||
disabled,
|
||||
}: {
|
||||
data: RepeaterNodeInfoResponse | null;
|
||||
state: PaneState;
|
||||
onRefresh: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const clockDrift = useMemo(() => {
|
||||
if (!data?.clock_utc) return null;
|
||||
return formatClockDrift(data.clock_utc);
|
||||
}, [data?.clock_utc]);
|
||||
|
||||
return (
|
||||
<RepeaterPane title="Node Info" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
{!data ? (
|
||||
<NotFetched />
|
||||
) : (
|
||||
<div>
|
||||
<KvRow label="Name" value={data.name ?? '—'} />
|
||||
<KvRow
|
||||
label="Lat / Lon"
|
||||
value={
|
||||
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-between text-sm py-0.5">
|
||||
<span className="text-muted-foreground">Clock (UTC)</span>
|
||||
<span>
|
||||
{data.clock_utc ?? '—'}
|
||||
{clockDrift && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-2 text-xs',
|
||||
clockDrift.isLarge ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
(drift: {clockDrift.text})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</RepeaterPane>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import { useMemo } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Separator } from '../ui/separator';
|
||||
import {
|
||||
@@ -6,7 +5,6 @@ import {
|
||||
RefreshIcon,
|
||||
NotFetched,
|
||||
KvRow,
|
||||
formatClockDrift,
|
||||
formatAdvertInterval,
|
||||
} from './repeaterPaneShared';
|
||||
import type {
|
||||
@@ -15,6 +13,35 @@ import type {
|
||||
PaneState,
|
||||
} from '../../types';
|
||||
|
||||
function formatRadioTuple(radio: string | null): { display: string; raw: string | null } {
|
||||
if (radio == null) {
|
||||
return { display: '—', raw: null };
|
||||
}
|
||||
|
||||
const trimmed = radio.trim();
|
||||
const parts = trimmed.split(',').map((part) => part.trim());
|
||||
if (parts.length !== 4) {
|
||||
return { display: trimmed || '—', raw: trimmed || null };
|
||||
}
|
||||
|
||||
const [freqRaw, bwRaw, sfRaw, crRaw] = parts;
|
||||
const freq = Number.parseFloat(freqRaw);
|
||||
const bw = Number.parseFloat(bwRaw);
|
||||
const sf = Number.parseInt(sfRaw, 10);
|
||||
const cr = Number.parseInt(crRaw, 10);
|
||||
|
||||
if (![freq, bw, sf, cr].every(Number.isFinite)) {
|
||||
return { display: trimmed || '—', raw: trimmed || null };
|
||||
}
|
||||
|
||||
const formattedFreq = Number(freq.toFixed(3)).toString();
|
||||
const formattedBw = Number(bw.toFixed(3)).toString();
|
||||
return {
|
||||
display: `${formattedFreq} MHz, BW ${formattedBw} kHz, SF${sf}, CR${cr}`,
|
||||
raw: trimmed,
|
||||
};
|
||||
}
|
||||
|
||||
export function RadioSettingsPane({
|
||||
data,
|
||||
state,
|
||||
@@ -32,10 +59,7 @@ export function RadioSettingsPane({
|
||||
advertState: PaneState;
|
||||
onRefreshAdvert: () => void;
|
||||
}) {
|
||||
const clockDrift = useMemo(() => {
|
||||
if (!data?.clock_utc) return null;
|
||||
return formatClockDrift(data.clock_utc);
|
||||
}, [data?.clock_utc]);
|
||||
const formattedRadio = formatRadioTuple(data?.radio ?? null);
|
||||
|
||||
return (
|
||||
<RepeaterPane title="Radio Settings" state={state} onRefresh={onRefresh} disabled={disabled}>
|
||||
@@ -44,36 +68,14 @@ export function RadioSettingsPane({
|
||||
) : (
|
||||
<div>
|
||||
<KvRow label="Firmware" value={data.firmware_version ?? '—'} />
|
||||
<KvRow label="Radio" value={data.radio ?? '—'} />
|
||||
<KvRow
|
||||
label="Radio"
|
||||
value={<span title={formattedRadio.raw ?? undefined}>{formattedRadio.display}</span>}
|
||||
/>
|
||||
<KvRow label="TX Power" value={data.tx_power != null ? `${data.tx_power} dBm` : '—'} />
|
||||
<KvRow label="Airtime Factor" value={data.airtime_factor ?? '—'} />
|
||||
<KvRow label="Repeat Mode" value={data.repeat_enabled ?? '—'} />
|
||||
<KvRow label="Max Flood Hops" value={data.flood_max ?? '—'} />
|
||||
<Separator className="my-1" />
|
||||
<KvRow label="Name" value={data.name ?? '—'} />
|
||||
<KvRow
|
||||
label="Lat / Lon"
|
||||
value={
|
||||
data.lat != null || data.lon != null ? `${data.lat ?? '—'}, ${data.lon ?? '—'}` : '—'
|
||||
}
|
||||
/>
|
||||
<Separator className="my-1" />
|
||||
<div className="flex justify-between text-sm py-0.5">
|
||||
<span className="text-muted-foreground">Clock (UTC)</span>
|
||||
<span>
|
||||
{data.clock_utc ?? '—'}
|
||||
{clockDrift && (
|
||||
<span
|
||||
className={cn(
|
||||
'ml-2 text-xs',
|
||||
clockDrift.isLarge ? 'text-destructive' : 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
(drift: {clockDrift.text})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Advert Intervals sub-section */}
|
||||
|
||||
@@ -113,6 +113,19 @@ export function SettingsAboutSection({ className }: { className?: string }) {
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="/api/debug"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-primary hover:underline"
|
||||
>
|
||||
Open debug support snapshot
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,6 +18,7 @@ const TYPE_LABELS: Record<string, string> = {
|
||||
bot: 'Bot',
|
||||
webhook: 'Webhook',
|
||||
apprise: 'Apprise',
|
||||
sqs: 'Amazon SQS',
|
||||
};
|
||||
|
||||
const TYPE_OPTIONS = [
|
||||
@@ -26,6 +27,7 @@ const TYPE_OPTIONS = [
|
||||
{ value: 'bot', label: 'Bot' },
|
||||
{ value: 'webhook', label: 'Webhook' },
|
||||
{ value: 'apprise', label: 'Apprise' },
|
||||
{ value: 'sqs', label: 'Amazon SQS' },
|
||||
];
|
||||
|
||||
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
|
||||
@@ -60,41 +62,45 @@ function formatAppriseTargets(urls: string | undefined, maxLength = 80) {
|
||||
return `${joined.slice(0, maxLength - 3)}...`;
|
||||
}
|
||||
|
||||
function formatSqsQueueSummary(config: Record<string, unknown>) {
|
||||
const queueUrl = ((config.queue_url as string) || '').trim();
|
||||
if (!queueUrl) return 'No queue configured';
|
||||
return queueUrl;
|
||||
}
|
||||
|
||||
function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) {
|
||||
const label = TYPE_LABELS[type] || type;
|
||||
const nextIndex = configs.filter((cfg) => cfg.type === type).length + 1;
|
||||
return `${label} #${nextIndex}`;
|
||||
}
|
||||
|
||||
const DEFAULT_BOT_CODE = `def bot(
|
||||
sender_name: str | None,
|
||||
sender_key: str | None,
|
||||
message_text: str,
|
||||
is_dm: bool,
|
||||
channel_key: str | None,
|
||||
channel_name: str | None,
|
||||
sender_timestamp: int | None,
|
||||
path: str | None,
|
||||
is_outgoing: bool = False,
|
||||
) -> str | list[str] | None:
|
||||
const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
|
||||
"""
|
||||
Process messages and optionally return a reply.
|
||||
|
||||
Args:
|
||||
sender_name: Display name of sender (may be None)
|
||||
sender_key: 64-char hex public key (None for channel msgs)
|
||||
message_text: The message content
|
||||
is_dm: True for direct messages, False for channel
|
||||
channel_key: 32-char hex key for channels, None for DMs
|
||||
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
|
||||
sender_timestamp: Sender's timestamp (unix seconds, may be None)
|
||||
path: Hex-encoded routing path (may be None)
|
||||
is_outgoing: True if this is our own outgoing message
|
||||
kwargs keys currently provided:
|
||||
sender_name: Display name of sender (may be None)
|
||||
sender_key: 64-char hex public key (None for channel msgs)
|
||||
message_text: The message content
|
||||
is_dm: True for direct messages, False for channel
|
||||
channel_key: 32-char hex key for channels, None for DMs
|
||||
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
|
||||
sender_timestamp: Sender's timestamp (unix seconds, may be None)
|
||||
path: Hex-encoded routing path (may be None)
|
||||
is_outgoing: True if this is our own outgoing message
|
||||
path_bytes_per_hop: Bytes per hop in path (1, 2, or 3) when known
|
||||
|
||||
Returns:
|
||||
None for no reply, a string for a single reply,
|
||||
or a list of strings to send multiple messages in order
|
||||
"""
|
||||
sender_name = kwargs.get("sender_name")
|
||||
message_text = kwargs.get("message_text", "")
|
||||
channel_name = kwargs.get("channel_name")
|
||||
is_outgoing = kwargs.get("is_outgoing", False)
|
||||
path_bytes_per_hop = kwargs.get("path_bytes_per_hop")
|
||||
|
||||
# Don't reply to our own outgoing messages
|
||||
if is_outgoing:
|
||||
return None
|
||||
@@ -998,6 +1004,111 @@ function WebhookConfigEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function SqsConfigEditor({
|
||||
config,
|
||||
scope,
|
||||
onChange,
|
||||
onScopeChange,
|
||||
}: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
onScopeChange: (scope: Record<string, unknown>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send matched mesh events to an Amazon SQS queue for durable processing by workers, Lambdas,
|
||||
or downstream automation.
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
Outgoing messages and any selected raw packets will be delivered exactly as forwarded by the
|
||||
fanout scope, including decrypted/plaintext message content.
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-queue-url">Queue URL</Label>
|
||||
<Input
|
||||
id="fanout-sqs-queue-url"
|
||||
type="url"
|
||||
placeholder="https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events"
|
||||
value={(config.queue_url as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, queue_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-region">Region (optional)</Label>
|
||||
<Input
|
||||
id="fanout-sqs-region"
|
||||
type="text"
|
||||
placeholder="us-east-1"
|
||||
value={(config.region_name as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, region_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-endpoint">Endpoint URL (optional)</Label>
|
||||
<Input
|
||||
id="fanout-sqs-endpoint"
|
||||
type="url"
|
||||
placeholder="http://localhost:4566"
|
||||
value={(config.endpoint_url as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, endpoint_url: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Useful for LocalStack or custom endpoints</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Static Credentials (optional)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave blank to use the server's normal AWS credential chain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-access-key">Access Key ID</Label>
|
||||
<Input
|
||||
id="fanout-sqs-access-key"
|
||||
type="text"
|
||||
value={(config.access_key_id as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, access_key_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-secret-key">Secret Access Key</Label>
|
||||
<Input
|
||||
id="fanout-sqs-secret-key"
|
||||
type="password"
|
||||
value={(config.secret_access_key as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, secret_access_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-session-token">Session Token (optional)</Label>
|
||||
<Input
|
||||
id="fanout-sqs-session-token"
|
||||
type="password"
|
||||
value={(config.session_token as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, session_token: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScopeSelector scope={scope} onChange={onScopeChange} showRawPackets />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsFanoutSection({
|
||||
health,
|
||||
onHealthRefresh,
|
||||
@@ -1208,6 +1319,14 @@ export function SettingsFanoutSection({
|
||||
preserve_identity: true,
|
||||
include_path: true,
|
||||
},
|
||||
sqs: {
|
||||
queue_url: '',
|
||||
region_name: '',
|
||||
endpoint_url: '',
|
||||
access_key_id: '',
|
||||
secret_access_key: '',
|
||||
session_token: '',
|
||||
},
|
||||
};
|
||||
const defaultScopes: Record<string, Record<string, unknown>> = {
|
||||
mqtt_private: { messages: 'all', raw_packets: 'all' },
|
||||
@@ -1215,6 +1334,7 @@ export function SettingsFanoutSection({
|
||||
bot: { messages: 'all', raw_packets: 'none' },
|
||||
webhook: { messages: 'all', raw_packets: 'none' },
|
||||
apprise: { messages: 'all', raw_packets: 'none' },
|
||||
sqs: { messages: 'all', raw_packets: 'none' },
|
||||
};
|
||||
setAddMenuOpen(false);
|
||||
setEditingId(null);
|
||||
@@ -1296,6 +1416,15 @@ export function SettingsFanoutSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailType === 'sqs' && (
|
||||
<SqsConfigEditor
|
||||
config={editConfig}
|
||||
scope={editScope}
|
||||
onChange={setEditConfig}
|
||||
onScopeChange={setEditScope}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -1328,7 +1457,8 @@ export function SettingsFanoutSection({
|
||||
return (
|
||||
<div className={cn('mx-auto w-full max-w-[800px] space-y-4', className)}>
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 px-4 py-3 text-sm text-warning">
|
||||
Integrations are an experimental feature in open beta.
|
||||
Integrations are an experimental feature in open beta, and allow you to fanout raw and
|
||||
decrypted messages across multiple services for automation, analysis, or archiving.
|
||||
</div>
|
||||
|
||||
{health?.bots_disabled && (
|
||||
@@ -1520,6 +1650,17 @@ export function SettingsFanoutSection({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{cfg.type === 'sqs' && (
|
||||
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
|
||||
<div className="break-all">
|
||||
Queue:{' '}
|
||||
<code>
|
||||
{formatSqsQueueSummary(cfg.config as Record<string, unknown>)}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { Logs, MessageSquare } from 'lucide-react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { ContactAvatar } from '../ContactAvatar';
|
||||
import {
|
||||
captureLastViewedConversationFromHash,
|
||||
getReopenLastConversationEnabled,
|
||||
@@ -40,6 +42,7 @@ export function SettingsLocalSection({
|
||||
<div className="space-y-1">
|
||||
<Label>Color Scheme</Label>
|
||||
<ThemeSelector />
|
||||
<ThemePreview className="mt-6" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -91,3 +94,131 @@ export function SettingsLocalSection({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThemePreview({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Preview alert, message, sidebar, and badge contrast for the selected theme.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<PreviewBanner className="border border-status-connected/30 bg-status-connected/15 text-status-connected">
|
||||
Connected preview: radio link healthy and syncing.
|
||||
</PreviewBanner>
|
||||
<PreviewBanner className="border border-warning/50 bg-warning/10 text-warning">
|
||||
Warning preview: packet audit suggests missing history.
|
||||
</PreviewBanner>
|
||||
<PreviewBanner className="border border-destructive/30 bg-destructive/10 text-destructive">
|
||||
Error preview: radio reconnect failed.
|
||||
</PreviewBanner>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<PreviewMessage
|
||||
sender="Alice"
|
||||
bubbleClassName="bg-msg-incoming text-foreground"
|
||||
text="Hello, mesh!"
|
||||
/>
|
||||
<PreviewMessage
|
||||
sender="You"
|
||||
alignRight
|
||||
bubbleClassName="bg-msg-outgoing text-foreground"
|
||||
text="Hi there! I'm using RemoteTerm."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-border bg-background p-2">
|
||||
<p className="mb-2 text-[11px] font-medium text-muted-foreground">Sidebar preview</p>
|
||||
<div className="space-y-1">
|
||||
<PreviewSidebarRow
|
||||
active
|
||||
leading={
|
||||
<span
|
||||
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Logs className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
}
|
||||
label="Packet Feed"
|
||||
/>
|
||||
<PreviewSidebarRow
|
||||
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
|
||||
label="Alice"
|
||||
badge={
|
||||
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[10px] font-semibold text-badge-unread-foreground">
|
||||
3
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<PreviewSidebarRow
|
||||
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
|
||||
label="Mesh Ops"
|
||||
badge={
|
||||
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[10px] font-semibold text-badge-mention-foreground">
|
||||
@2
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewBanner({ children, className }: { children: React.ReactNode; className: string }) {
|
||||
return <div className={`rounded-md px-3 py-2 text-xs ${className}`}>{children}</div>;
|
||||
}
|
||||
|
||||
function PreviewMessage({
|
||||
sender,
|
||||
text,
|
||||
bubbleClassName,
|
||||
alignRight = false,
|
||||
}: {
|
||||
sender: string;
|
||||
text: string;
|
||||
bubbleClassName: string;
|
||||
alignRight?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
|
||||
<div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||
<span className="mb-1 text-[11px] text-muted-foreground">{sender}</span>
|
||||
<div className={`rounded-2xl px-3 py-2 text-sm break-words ${bubbleClassName}`}>{text}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviewSidebarRow({
|
||||
leading,
|
||||
label,
|
||||
badge,
|
||||
active = false,
|
||||
}: {
|
||||
leading: React.ReactNode;
|
||||
label: string;
|
||||
badge?: React.ReactNode;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
|
||||
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
{leading}
|
||||
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
|
||||
{label}
|
||||
</span>
|
||||
{badge}
|
||||
{!badge && (
|
||||
<span className="text-muted-foreground" aria-hidden="true">
|
||||
<MessageSquare className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import type {
|
||||
HealthStatus,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
RadioDiscoveryTarget,
|
||||
} from '../../types';
|
||||
|
||||
export function SettingsRadioSection({
|
||||
@@ -24,7 +26,12 @@ export function SettingsRadioSection({
|
||||
onSaveAppSettings,
|
||||
onSetPrivateKey,
|
||||
onReboot,
|
||||
onDisconnect,
|
||||
onReconnect,
|
||||
onAdvertise,
|
||||
meshDiscovery,
|
||||
meshDiscoveryLoadingTarget,
|
||||
onDiscoverMesh,
|
||||
onClose,
|
||||
className,
|
||||
}: {
|
||||
@@ -36,7 +43,12 @@ export function SettingsRadioSection({
|
||||
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
|
||||
onSetPrivateKey: (key: string) => Promise<void>;
|
||||
onReboot: () => Promise<void>;
|
||||
onDisconnect: () => Promise<void>;
|
||||
onReconnect: () => Promise<void>;
|
||||
onAdvertise: () => Promise<void>;
|
||||
meshDiscovery: RadioDiscoveryResponse | null;
|
||||
meshDiscoveryLoadingTarget: RadioDiscoveryTarget | null;
|
||||
onDiscoverMesh: (target: RadioDiscoveryTarget) => Promise<void>;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
@@ -50,6 +62,7 @@ export function SettingsRadioSection({
|
||||
const [sf, setSf] = useState('');
|
||||
const [cr, setCr] = useState('');
|
||||
const [pathHashMode, setPathHashMode] = useState('0');
|
||||
const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
|
||||
const [gettingLocation, setGettingLocation] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [rebooting, setRebooting] = useState(false);
|
||||
@@ -70,6 +83,8 @@ export function SettingsRadioSection({
|
||||
|
||||
// Advertise state
|
||||
const [advertising, setAdvertising] = useState(false);
|
||||
const [discoverError, setDiscoverError] = useState<string | null>(null);
|
||||
const [connectionBusy, setConnectionBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setName(config.name);
|
||||
@@ -81,6 +96,7 @@ export function SettingsRadioSection({
|
||||
setSf(String(config.radio.sf));
|
||||
setCr(String(config.radio.cr));
|
||||
setPathHashMode(String(config.path_hash_mode));
|
||||
setAdvertLocationSource(config.advert_location_source ?? 'current');
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -170,6 +186,9 @@ export function SettingsRadioSection({
|
||||
lat: parsedLat,
|
||||
lon: parsedLon,
|
||||
tx_power: parsedTxPower,
|
||||
...(advertLocationSource !== (config.advert_location_source ?? 'current')
|
||||
? { advert_location_source: advertLocationSource }
|
||||
: {}),
|
||||
radio: {
|
||||
freq: parsedFreq,
|
||||
bw: parsedBw,
|
||||
@@ -285,24 +304,91 @@ export function SettingsRadioSection({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDiscover = async (target: RadioDiscoveryTarget) => {
|
||||
setDiscoverError(null);
|
||||
try {
|
||||
await onDiscoverMesh(target);
|
||||
} catch (err) {
|
||||
setDiscoverError(err instanceof Error ? err.message : 'Failed to run mesh discovery');
|
||||
}
|
||||
};
|
||||
|
||||
const radioState =
|
||||
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
|
||||
const connectionActionLabel =
|
||||
radioState === 'paused'
|
||||
? 'Reconnect'
|
||||
: radioState === 'connected' || radioState === 'initializing'
|
||||
? 'Disconnect'
|
||||
: 'Stop Trying';
|
||||
|
||||
const connectionStatusLabel =
|
||||
radioState === 'connected'
|
||||
? health?.connection_info || 'Connected'
|
||||
: radioState === 'initializing'
|
||||
? `Initializing ${health?.connection_info || 'radio'}`
|
||||
: radioState === 'connecting'
|
||||
? `Attempting to connect${health?.connection_info ? ` to ${health.connection_info}` : ''}`
|
||||
: radioState === 'paused'
|
||||
? `Connection paused${health?.connection_info ? ` (${health.connection_info})` : ''}`
|
||||
: 'Not connected';
|
||||
|
||||
const handleConnectionAction = async () => {
|
||||
setConnectionBusy(true);
|
||||
try {
|
||||
if (radioState === 'paused') {
|
||||
await onReconnect();
|
||||
toast.success('Reconnect requested');
|
||||
} else {
|
||||
await onDisconnect();
|
||||
toast.success('Radio connection paused');
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error('Failed to change radio connection state', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection and try again',
|
||||
});
|
||||
} finally {
|
||||
setConnectionBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{/* Connection display */}
|
||||
<div className="space-y-2">
|
||||
<div className="space-y-3">
|
||||
<Label>Connection</Label>
|
||||
{health?.connection_info ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-status-connected" />
|
||||
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
|
||||
{health.connection_info}
|
||||
</code>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<div className="w-2 h-2 rounded-full bg-status-disconnected" />
|
||||
<span>Not connected</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full ${
|
||||
radioState === 'connected'
|
||||
? 'bg-status-connected'
|
||||
: radioState === 'initializing' || radioState === 'connecting'
|
||||
? 'bg-warning'
|
||||
: 'bg-status-disconnected'
|
||||
}`}
|
||||
/>
|
||||
<span
|
||||
className={
|
||||
radioState === 'paused' || radioState === 'disconnected'
|
||||
? 'text-muted-foreground'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{connectionStatusLabel}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleConnectionAction}
|
||||
disabled={connectionBusy}
|
||||
className="w-full"
|
||||
>
|
||||
{connectionBusy ? `${connectionActionLabel}...` : connectionActionLabel}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Disconnect pauses automatic reconnect attempts so another device can use the radio.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Radio Name */}
|
||||
@@ -443,6 +529,25 @@ export function SettingsRadioSection({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="advert-location-source">Advert Location Source</Label>
|
||||
<select
|
||||
id="advert-location-source"
|
||||
value={advertLocationSource}
|
||||
onChange={(e) => setAdvertLocationSource(e.target.value as 'off' | 'current')}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="current">Include Node Location</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Companion-radio firmware does not distinguish between saved coordinates and live GPS
|
||||
here. When enabled, adverts include the node's current location state. That may be
|
||||
the last coordinates you set from RemoteTerm or live GPS coordinates if the node itself
|
||||
is already updating them. RemoteTerm cannot enable GPS on the node through the interface
|
||||
library.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.path_hash_mode_supported && (
|
||||
@@ -460,7 +565,7 @@ export function SettingsRadioSection({
|
||||
<option value="1">2 bytes</option>
|
||||
<option value="2">3 bytes</option>
|
||||
</select>
|
||||
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
|
||||
<p className="font-semibold mb-1">Compatibility Warning</p>
|
||||
<p>
|
||||
ALL nodes along a message's route — your radio, every repeater, and the
|
||||
@@ -600,7 +705,10 @@ export function SettingsRadioSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Send Advertisement */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-base">Hear & Be Heard</Label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Send Advertisement</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
@@ -617,6 +725,81 @@ export function SettingsRadioSection({
|
||||
<p className="text-sm text-destructive">Radio not connected</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>Mesh Discovery</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Discover nearby node types that currently respond to mesh discovery requests: repeaters
|
||||
and sensors.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-3">
|
||||
{[
|
||||
{ target: 'repeaters', label: 'Discover Repeaters' },
|
||||
{ target: 'sensors', label: 'Discover Sensors' },
|
||||
{ target: 'all', label: 'Discover Both' },
|
||||
].map(({ target, label }) => (
|
||||
<Button
|
||||
key={target}
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleDiscover(target as RadioDiscoveryTarget)}
|
||||
disabled={meshDiscoveryLoadingTarget !== null || !health?.radio_connected}
|
||||
className="w-full"
|
||||
>
|
||||
{meshDiscoveryLoadingTarget === target ? 'Listening...' : label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{!health?.radio_connected && (
|
||||
<p className="text-sm text-destructive">Radio not connected</p>
|
||||
)}
|
||||
{discoverError && (
|
||||
<p className="text-sm text-destructive" role="alert">
|
||||
{discoverError}
|
||||
</p>
|
||||
)}
|
||||
{meshDiscovery && (
|
||||
<div className="space-y-2 rounded-md border border-input bg-muted/20 p-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm font-medium">
|
||||
Last sweep: {meshDiscovery.results.length} node
|
||||
{meshDiscovery.results.length === 1 ? '' : 's'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{meshDiscovery.duration_seconds.toFixed(0)}s listen window
|
||||
</p>
|
||||
</div>
|
||||
{meshDiscovery.results.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No supported nodes responded during the last discovery sweep.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{meshDiscovery.results.map((result) => (
|
||||
<div
|
||||
key={result.public_key}
|
||||
className="rounded-md border border-input bg-background px-3 py-2"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium capitalize">{result.node_type}</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
heard {result.heard_count} time{result.heard_count === 1 ? '' : 's'}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 break-all font-mono text-xs text-muted-foreground">
|
||||
{result.public_key}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Heard here: {result.local_snr ?? 'n/a'} dB SNR / {result.local_rssi ?? 'n/a'}{' '}
|
||||
dBm RSSI. Remote heard us: {result.remote_snr ?? 'n/a'} dB SNR.
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,13 +25,13 @@ export function ThemeSelector() {
|
||||
};
|
||||
|
||||
return (
|
||||
<fieldset className="flex flex-wrap gap-2">
|
||||
<fieldset className="flex flex-wrap gap-2 md:grid md:grid-cols-4">
|
||||
<legend className="sr-only">Color theme</legend>
|
||||
{THEMES.map((theme) => (
|
||||
<label
|
||||
key={theme.id}
|
||||
className={
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer border transition-colors focus-within:ring-2 focus-within:ring-ring ' +
|
||||
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer border transition-colors focus-within:ring-2 focus-within:ring-ring md:w-full ' +
|
||||
(current === theme.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-transparent hover:bg-accent/50')
|
||||
|
||||
@@ -12,24 +12,41 @@ The visualizer displays:
|
||||
|
||||
## Architecture
|
||||
|
||||
### Data Layer (`components/visualizer/useVisualizerData3D.ts`)
|
||||
### Semantic Data Layer (`networkGraph/packetNetworkGraph.ts`)
|
||||
|
||||
The custom hook manages all graph state and simulation logic:
|
||||
The packet-network module owns the canonical mesh representation and the visibility-aware projection logic:
|
||||
|
||||
```
|
||||
Packets → Parse → Aggregate by key → Observation window → Publish → Animate
|
||||
Packets → Parse → Canonical observations/adjacency → Projection by settings
|
||||
```
|
||||
|
||||
**Key responsibilities:**
|
||||
|
||||
- Maintains node and link maps (`nodesRef`, `linksRef`)
|
||||
- Resolves packet source / repeater / destination nodes into a canonical path
|
||||
- Maintains canonical node, link, observation, and neighbor state independent of UI toggles
|
||||
- Applies ambiguous repeater heuristics and advert-path hints while building canonical data
|
||||
- Projects canonical paths into rendered links, including dashed bridges over hidden ambiguous runs
|
||||
- Exposes a reusable semantic surface for other consumers besides the 3D visualizer
|
||||
|
||||
### Visualizer Data Hook (`components/visualizer/useVisualizerData3D.ts`)
|
||||
|
||||
The hook manages render-specific state and animation timing on top of the shared packet-network data layer:
|
||||
|
||||
```
|
||||
Canonical projection → Aggregate by key → Observation window → Publish → Animate
|
||||
```
|
||||
|
||||
**Key responsibilities:**
|
||||
|
||||
- Adapts semantic packet-network nodes/links into `GraphNode` / `GraphLink` render objects
|
||||
- Runs `d3-force-3d` simulation for 3D layout (`.numDimensions(3)`)
|
||||
- Processes incoming packets with deduplication
|
||||
- Aggregates packet repeats across multiple paths
|
||||
- Processes incoming packets with deduplication and feeds them into the semantic layer
|
||||
- Aggregates packet repeats across multiple projected paths
|
||||
- Manages particle queue and animation timing
|
||||
|
||||
**State:**
|
||||
|
||||
- `networkStateRef`: Canonical packet-network state (nodes, links, observations, neighbors)
|
||||
- `nodesRef`: Map of node ID → GraphNode
|
||||
- `linksRef`: Map of link key → GraphLink
|
||||
- `particlesRef`: Array of active Particle objects
|
||||
@@ -50,6 +67,8 @@ Scene creation, render-loop updates, raycasting hover, and click-to-pin interact
|
||||
|
||||
### Shared Utilities
|
||||
|
||||
- `networkGraph/packetNetworkGraph.ts`
|
||||
- Canonical packet-network types and replay/projection logic
|
||||
- `components/visualizer/shared.ts`
|
||||
- Graph-specific types: `GraphNode`, `GraphLink`, `NodeMeshData`
|
||||
- Shared rendering helpers: node colors, relative-time formatting, typed-array growth helpers
|
||||
@@ -75,8 +94,9 @@ When a new packet arrives from the WebSocket:
|
||||
|
||||
```typescript
|
||||
packets.forEach((packet) => {
|
||||
if (processedRef.current.has(packet.id)) return; // Skip duplicates
|
||||
processedRef.current.add(packet.id);
|
||||
const observationKey = getRawPacketObservationKey(packet);
|
||||
if (processedRef.current.has(observationKey)) return; // Skip duplicates
|
||||
processedRef.current.add(observationKey);
|
||||
|
||||
const parsed = parsePacket(packet.data);
|
||||
const key = generatePacketKey(parsed, packet);
|
||||
@@ -196,6 +216,8 @@ When a winner is found, the ambiguous node gets a `probableIdentity` label (the
|
||||
|
||||
**Interaction with traffic splitting:** Advert-path hints run first. If a probable identity is found, the display name is set. Traffic splitting can still produce separate node IDs (`?XX:>YY`), but won't overwrite the advert-path display name.
|
||||
|
||||
**Sibling collapse projection:** When an ambiguous repeater has a high-confidence likely identity and that likely repeater also appears as a definitely-known sibling connecting to the same next hop, the projection layer can collapse the ambiguous node into the known repeater. This is projection-only: canonical observations and canonical neighbor truth remain unchanged.
|
||||
|
||||
**Toggle:** "Use repeater advert-path identity hints" checkbox (enabled by default, disabled when ambiguous repeaters are hidden).
|
||||
|
||||
### Traffic Pattern Splitting (Experimental)
|
||||
@@ -308,40 +330,44 @@ function buildPath(parsed, packet, myPrefix): string[] {
|
||||
| Pan (right-drag) | Pan the camera |
|
||||
| Scroll wheel | Zoom in/out |
|
||||
|
||||
**Click-to-pin:** When a node is pinned, hovering other nodes does not change the highlight. The tooltip shows "Traffic exchanged with:" listing all connected neighbors with their possible names.
|
||||
**Click-to-pin:** When a node is pinned, hovering other nodes does not change the highlight. The tooltip shows "Traffic exchanged with:" using canonical packet-network adjacency, not rendered-link adjacency, so hidden repeaters still appear truthfully as hidden neighbors.
|
||||
|
||||
## Configuration Options
|
||||
|
||||
| Option | Default | Description |
|
||||
| -------------------------- | ------- | --------------------------------------------------------- |
|
||||
| Ambiguous repeaters | On | Show nodes when only partial prefix known |
|
||||
| Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders |
|
||||
| Advert-path identity hints | On | Use stored advert paths to label ambiguous repeaters |
|
||||
| Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) |
|
||||
| Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) |
|
||||
| Let 'em drift | On | Continuous layout optimization |
|
||||
| Repulsion | 200 | Force strength (50-2500) |
|
||||
| Packet speed | 2x | Particle animation speed multiplier (1x-5x) |
|
||||
| Shuffle layout | - | Button to randomize node positions and reheat sim |
|
||||
| Oooh Big Stretch! | - | Button to temporarily increase repulsion then relax |
|
||||
| Clear & Reset | - | Button to clear all nodes, links, and packets |
|
||||
| Hide UI | Off | Hide legends and most controls for cleaner view |
|
||||
| Full screen | Off | Hide the packet feed panel (desktop only) |
|
||||
| Option | Default | Description |
|
||||
| -------------------------- | ------- | ----------------------------------------------------------- |
|
||||
| Ambiguous repeaters | On | Show nodes when only partial prefix known |
|
||||
| Ambiguous sender/recipient | Off | Show placeholder nodes for unknown senders |
|
||||
| Advert-path identity hints | On | Use stored advert paths to label ambiguous repeaters |
|
||||
| Collapse sibling repeaters | On | Merge likely ambiguous repeater with known sibling repeater |
|
||||
| Split by traffic pattern | Off | Split ambiguous repeaters by next-hop routing (see above) |
|
||||
| Observation window | 15 sec | Wait time for duplicate packets before animating (1-60s) |
|
||||
| Let 'em drift | On | Continuous layout optimization |
|
||||
| Repulsion | 200 | Force strength (50-2500) |
|
||||
| Packet speed | 2x | Particle animation speed multiplier (1x-5x) |
|
||||
| Shuffle layout | - | Button to randomize node positions and reheat sim |
|
||||
| Oooh Big Stretch! | - | Button to temporarily increase repulsion then relax |
|
||||
| Clear & Reset | - | Button to clear all nodes, links, and packets |
|
||||
| Hide UI | Off | Hide legends and most controls for cleaner view |
|
||||
| Full screen | Off | Hide the packet feed panel (desktop only) |
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
PacketVisualizer3D.tsx
|
||||
├── TYPES (GraphNode extends SimulationNodeDatum3D, GraphLink)
|
||||
├── CONSTANTS (NODE_COLORS, NODE_LEGEND_ITEMS)
|
||||
├── DATA LAYER HOOK (useVisualizerData3D)
|
||||
│ ├── Refs (nodes, links, particles, simulation, pending, timers, trafficPatterns, stretchRaf)
|
||||
│ ├── d3-force-3d simulation initialization (.numDimensions(3))
|
||||
│ ├── Contact indexing (byPrefix12 / byName / byPrefix)
|
||||
│ ├── Node/link management (addNode, addLink, syncSimulation)
|
||||
│ ├── Path building (resolveNode, buildPath)
|
||||
├── SEMANTIC DATA LAYER (networkGraph/packetNetworkGraph.ts)
|
||||
│ ├── Contact/advert indexes
|
||||
│ ├── Canonical node/link/neighbor/observation state
|
||||
│ ├── Path building (resolveNode, buildCanonicalPathForPacket)
|
||||
│ ├── Traffic pattern analysis (for repeater disambiguation)
|
||||
│ └── Packet processing & publishing
|
||||
│ └── Projection (projectCanonicalPath, projectPacketNetwork)
|
||||
├── DATA HOOK (useVisualizerData3D)
|
||||
│ ├── Refs (network state, render nodes, links, particles, simulation, pending, timers, stretchRaf)
|
||||
│ ├── d3-force-3d simulation initialization (.numDimensions(3))
|
||||
│ ├── Semantic→render adaptation
|
||||
│ ├── Observation-window packet aggregation
|
||||
│ └── Particle publishing
|
||||
└── MAIN COMPONENT (PacketVisualizer3D)
|
||||
├── Three.js scene setup (WebGLRenderer, CSS2DRenderer, OrbitControls)
|
||||
├── Node mesh management (SphereGeometry + CSS2DObject labels)
|
||||
@@ -356,6 +382,13 @@ utils/visualizerUtils.ts
|
||||
├── Constants (COLORS, PARTICLE_COLOR_MAP, PARTICLE_SPEED, PACKET_LEGEND_ITEMS)
|
||||
└── Functions (parsePacket, generatePacketKey, analyzeRepeaterTraffic, etc.)
|
||||
|
||||
networkGraph/packetNetworkGraph.ts
|
||||
├── Types (PacketNetworkNode, PacketNetworkLink, PacketNetworkObservation, projection types)
|
||||
├── Context builders (contact and advert-path indexes)
|
||||
├── Canonical replay (ingestPacketIntoPacketNetwork)
|
||||
├── Projection helpers (projectCanonicalPath, projectPacketNetwork)
|
||||
└── State maintenance (clear, prune, neighbor snapshots)
|
||||
|
||||
types/d3-force-3d.d.ts
|
||||
└── Type declarations for d3-force-3d (SimulationNodeDatum3D, Simulation3D, forces)
|
||||
```
|
||||
|
||||
@@ -13,6 +13,8 @@ interface VisualizerControlsProps {
|
||||
setShowAmbiguousNodes: (value: boolean) => void;
|
||||
useAdvertPathHints: boolean;
|
||||
setUseAdvertPathHints: (value: boolean) => void;
|
||||
collapseLikelyKnownSiblingRepeaters: boolean;
|
||||
setCollapseLikelyKnownSiblingRepeaters: (value: boolean) => void;
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
setSplitAmbiguousByTraffic: (value: boolean) => void;
|
||||
observationWindowSec: number;
|
||||
@@ -46,6 +48,8 @@ export function VisualizerControls({
|
||||
setShowAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
setUseAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
setCollapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic,
|
||||
setSplitAmbiguousByTraffic,
|
||||
observationWindowSec,
|
||||
@@ -149,55 +153,77 @@ export function VisualizerControls({
|
||||
Show ambiguous sender/recipient
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={useAdvertPathHints}
|
||||
onCheckedChange={(c) => setUseAdvertPathHints(c === true)}
|
||||
disabled={!showAmbiguousPaths}
|
||||
/>
|
||||
<span
|
||||
title="Use stored repeater advert paths to assign likely identity labels for ambiguous repeater nodes"
|
||||
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
|
||||
>
|
||||
Use repeater advert-path identity hints
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={splitAmbiguousByTraffic}
|
||||
onCheckedChange={(c) => setSplitAmbiguousByTraffic(c === true)}
|
||||
disabled={!showAmbiguousPaths}
|
||||
/>
|
||||
<span
|
||||
title="Split ambiguous repeaters into separate nodes based on traffic patterns (prev→next). Helps identify colliding prefixes representing different physical nodes, but requires enough traffic to disambiguate."
|
||||
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
|
||||
>
|
||||
Heuristically group repeaters by traffic pattern
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="observation-window-3d"
|
||||
className="text-muted-foreground"
|
||||
title="How long to wait for duplicate packets via different paths before animating"
|
||||
>
|
||||
Ack/echo listen window:
|
||||
</label>
|
||||
<input
|
||||
id="observation-window-3d"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={observationWindowSec}
|
||||
onChange={(e) =>
|
||||
setObservationWindowSec(
|
||||
Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
|
||||
)
|
||||
}
|
||||
className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground">sec</span>
|
||||
</div>
|
||||
<details className="rounded border border-border/60 px-2 py-1">
|
||||
<summary className="cursor-pointer select-none text-muted-foreground">
|
||||
Advanced
|
||||
</summary>
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={useAdvertPathHints}
|
||||
onCheckedChange={(c) => setUseAdvertPathHints(c === true)}
|
||||
disabled={!showAmbiguousPaths}
|
||||
/>
|
||||
<span
|
||||
title="Use stored repeater advert paths to assign likely identity labels for ambiguous repeater nodes."
|
||||
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
|
||||
>
|
||||
Use repeater advert-path identity hints
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={collapseLikelyKnownSiblingRepeaters}
|
||||
onCheckedChange={(c) => setCollapseLikelyKnownSiblingRepeaters(c === true)}
|
||||
disabled={!showAmbiguousPaths || !useAdvertPathHints}
|
||||
/>
|
||||
<span
|
||||
title="When an ambiguous repeater has a high-confidence likely-identity that matches a sibling definitely-known repeater, and they both connect to the same next hop, collapse them into the known repeater. This should resolve more ambiguity as the mesh navigates the 1.14 upgrade."
|
||||
className={
|
||||
!showAmbiguousPaths || !useAdvertPathHints ? 'text-muted-foreground' : ''
|
||||
}
|
||||
>
|
||||
Collapse likely sibling repeaters
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
checked={splitAmbiguousByTraffic}
|
||||
onCheckedChange={(c) => setSplitAmbiguousByTraffic(c === true)}
|
||||
disabled={!showAmbiguousPaths}
|
||||
/>
|
||||
<span
|
||||
title="Split ambiguous repeaters into separate nodes based on traffic patterns (prev→next). Helps identify colliding prefixes representing different physical nodes, but requires enough traffic to disambiguate."
|
||||
className={!showAmbiguousPaths ? 'text-muted-foreground' : ''}
|
||||
>
|
||||
Heuristically group repeaters by traffic pattern
|
||||
</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label
|
||||
htmlFor="observation-window-3d"
|
||||
className="text-muted-foreground"
|
||||
title="How long to wait for duplicate packets via different paths before animating"
|
||||
>
|
||||
Ack/echo listen window:
|
||||
</label>
|
||||
<input
|
||||
id="observation-window-3d"
|
||||
type="number"
|
||||
min="1"
|
||||
max="60"
|
||||
value={observationWindowSec}
|
||||
onChange={(e) =>
|
||||
setObservationWindowSec(
|
||||
Math.max(1, Math.min(60, parseInt(e.target.value, 10) || 1))
|
||||
)
|
||||
}
|
||||
className="w-12 px-1 py-0.5 bg-background border border-border rounded text-xs text-center"
|
||||
/>
|
||||
<span className="text-muted-foreground">sec</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<div className="border-t border-border pt-2 mt-1 flex flex-col gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<Checkbox
|
||||
@@ -298,7 +324,7 @@ export function VisualizerControls({
|
||||
</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"
|
||||
className="mt-1 rounded border border-warning/40 bg-warning/10 px-3 py-1.5 text-warning text-xs transition-colors hover:bg-warning/20"
|
||||
title="Clear all nodes and links from the visualization - packets are preserved"
|
||||
>
|
||||
Clear & Reset
|
||||
|
||||
@@ -1,25 +1,37 @@
|
||||
import type { GraphNode } from './shared';
|
||||
import type { PacketNetworkNode } from '../../networkGraph/packetNetworkGraph';
|
||||
import { formatRelativeTime } from './shared';
|
||||
|
||||
interface VisualizerTooltipProps {
|
||||
activeNodeId: string | null;
|
||||
nodes: Map<string, GraphNode>;
|
||||
neighborIds: string[];
|
||||
canonicalNodes: Map<string, PacketNetworkNode>;
|
||||
canonicalNeighborIds: Map<string, string[]>;
|
||||
renderedNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: VisualizerTooltipProps) {
|
||||
export function VisualizerTooltip({
|
||||
activeNodeId,
|
||||
canonicalNodes,
|
||||
canonicalNeighborIds,
|
||||
renderedNodeIds,
|
||||
}: VisualizerTooltipProps) {
|
||||
if (!activeNodeId) return null;
|
||||
|
||||
const node = nodes.get(activeNodeId);
|
||||
const node = canonicalNodes.get(activeNodeId);
|
||||
if (!node) return null;
|
||||
|
||||
const neighborIds = canonicalNeighborIds.get(activeNodeId) ?? [];
|
||||
const neighbors = neighborIds
|
||||
.map((nid) => {
|
||||
const neighbor = nodes.get(nid);
|
||||
const neighbor = canonicalNodes.get(nid);
|
||||
if (!neighbor) return null;
|
||||
const displayName =
|
||||
neighbor.name || (neighbor.type === 'self' ? 'Me' : neighbor.id.slice(0, 8));
|
||||
return { id: nid, name: displayName, ambiguousNames: neighbor.ambiguousNames };
|
||||
return {
|
||||
id: nid,
|
||||
name: displayName,
|
||||
ambiguousNames: neighbor.ambiguousNames,
|
||||
hidden: !renderedNodeIds.has(nid),
|
||||
};
|
||||
})
|
||||
.filter((neighbor): neighbor is NonNullable<typeof neighbor> => neighbor !== null);
|
||||
|
||||
@@ -56,6 +68,7 @@ export function VisualizerTooltip({ activeNodeId, nodes, neighborIds }: Visualiz
|
||||
{neighbors.map((neighbor) => (
|
||||
<li key={neighbor.id}>
|
||||
{neighbor.name}
|
||||
{neighbor.hidden && <span className="text-muted-foreground/60"> (hidden)</span>}
|
||||
{neighbor.ambiguousNames && neighbor.ambiguousNames.length > 0 && (
|
||||
<span className="text-muted-foreground/60">
|
||||
{' '}
|
||||
|
||||
@@ -21,6 +21,9 @@ export interface GraphLink extends SimulationLinkDatum<GraphNode> {
|
||||
source: string | GraphNode;
|
||||
target: string | GraphNode;
|
||||
lastActivity: number;
|
||||
hasDirectObservation: boolean;
|
||||
hasHiddenIntermediate: boolean;
|
||||
hiddenHopLabels: string[];
|
||||
}
|
||||
|
||||
export interface NodeMeshData {
|
||||
@@ -74,6 +77,11 @@ export function formatRelativeTime(timestamp: number): string {
|
||||
return secs > 0 ? `${minutes}m ${secs}s ago` : `${minutes}m ago`;
|
||||
}
|
||||
|
||||
export function getSceneNodeLabel(node: Pick<GraphNode, 'id' | 'name' | 'type' | 'isAmbiguous'>) {
|
||||
const baseLabel = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
|
||||
return node.isAmbiguous ? `${baseLabel} (?)` : baseLabel;
|
||||
}
|
||||
|
||||
export function normalizePacketTimestampMs(timestamp: number | null | undefined): number {
|
||||
if (!Number.isFinite(timestamp) || !timestamp || timestamp <= 0) {
|
||||
return Date.now();
|
||||
|
||||
@@ -5,7 +5,13 @@ import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRe
|
||||
|
||||
import { COLORS, getLinkId } from '../../utils/visualizerUtils';
|
||||
import type { VisualizerData3D } from './useVisualizerData3D';
|
||||
import { arraysEqual, getBaseNodeColor, growFloat32Buffer, type NodeMeshData } from './shared';
|
||||
import {
|
||||
arraysEqual,
|
||||
getBaseNodeColor,
|
||||
getSceneNodeLabel,
|
||||
growFloat32Buffer,
|
||||
type NodeMeshData,
|
||||
} from './shared';
|
||||
|
||||
interface UseVisualizer3DSceneArgs {
|
||||
containerRef: RefObject<HTMLDivElement | null>;
|
||||
@@ -32,10 +38,12 @@ export function useVisualizer3DScene({
|
||||
const nodeMeshesRef = useRef<Map<string, NodeMeshData>>(new Map());
|
||||
const raycastTargetsRef = useRef<THREE.Mesh[]>([]);
|
||||
const linkLineRef = useRef<THREE.LineSegments | null>(null);
|
||||
const dashedLinkLineRef = useRef<THREE.LineSegments | null>(null);
|
||||
const highlightLineRef = useRef<THREE.LineSegments | null>(null);
|
||||
const particlePointsRef = useRef<THREE.Points | null>(null);
|
||||
const particleTextureRef = useRef<THREE.Texture | null>(null);
|
||||
const linkPositionBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
const dashedLinkPositionBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
const highlightPositionBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
const particlePositionBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
const particleColorBufferRef = useRef<Float32Array>(new Float32Array(0));
|
||||
@@ -126,6 +134,19 @@ export function useVisualizer3DScene({
|
||||
scene.add(linkSegments);
|
||||
linkLineRef.current = linkSegments;
|
||||
|
||||
const dashedLinkGeometry = new THREE.BufferGeometry();
|
||||
const dashedLinkMaterial = new THREE.LineDashedMaterial({
|
||||
color: 0x94a3b8,
|
||||
transparent: true,
|
||||
opacity: 0.85,
|
||||
dashSize: 16,
|
||||
gapSize: 10,
|
||||
});
|
||||
const dashedLinkSegments = new THREE.LineSegments(dashedLinkGeometry, dashedLinkMaterial);
|
||||
dashedLinkSegments.visible = false;
|
||||
scene.add(dashedLinkSegments);
|
||||
dashedLinkLineRef.current = dashedLinkSegments;
|
||||
|
||||
const highlightGeometry = new THREE.BufferGeometry();
|
||||
const highlightMaterial = new THREE.LineBasicMaterial({
|
||||
color: 0xffd700,
|
||||
@@ -198,6 +219,12 @@ export function useVisualizer3DScene({
|
||||
(linkLineRef.current.material as THREE.Material).dispose();
|
||||
linkLineRef.current = null;
|
||||
}
|
||||
if (dashedLinkLineRef.current) {
|
||||
scene.remove(dashedLinkLineRef.current);
|
||||
dashedLinkLineRef.current.geometry.dispose();
|
||||
(dashedLinkLineRef.current.material as THREE.Material).dispose();
|
||||
dashedLinkLineRef.current = null;
|
||||
}
|
||||
if (highlightLineRef.current) {
|
||||
scene.remove(highlightLineRef.current);
|
||||
highlightLineRef.current.geometry.dispose();
|
||||
@@ -213,6 +240,7 @@ export function useVisualizer3DScene({
|
||||
particleTexture.dispose();
|
||||
particleTextureRef.current = null;
|
||||
linkPositionBufferRef.current = new Float32Array(0);
|
||||
dashedLinkPositionBufferRef.current = new Float32Array(0);
|
||||
highlightPositionBufferRef.current = new Float32Array(0);
|
||||
particlePositionBufferRef.current = new Float32Array(0);
|
||||
particleColorBufferRef.current = new Float32Array(0);
|
||||
@@ -340,7 +368,7 @@ export function useVisualizer3DScene({
|
||||
if (nd.labelDiv.style.color !== labelColor) {
|
||||
nd.labelDiv.style.color = labelColor;
|
||||
}
|
||||
const labelText = node.name || (node.type === 'self' ? 'Me' : node.id.slice(0, 8));
|
||||
const labelText = getSceneNodeLabel(node);
|
||||
if (nd.labelDiv.textContent !== labelText) {
|
||||
nd.labelDiv.textContent = labelText;
|
||||
}
|
||||
@@ -369,11 +397,16 @@ export function useVisualizer3DScene({
|
||||
}
|
||||
const activeId = pinnedNodeIdRef.current ?? hoveredNodeIdRef.current;
|
||||
|
||||
const visibleLinks = [];
|
||||
const solidLinks = [];
|
||||
const dashedLinks = [];
|
||||
for (const link of links.values()) {
|
||||
const { sourceId, targetId } = getLinkId(link);
|
||||
if (currentNodeIds.has(sourceId) && currentNodeIds.has(targetId)) {
|
||||
visibleLinks.push(link);
|
||||
if (link.hasDirectObservation || !link.hasHiddenIntermediate) {
|
||||
solidLinks.push(link);
|
||||
} else {
|
||||
dashedLinks.push(link);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -382,7 +415,8 @@ export function useVisualizer3DScene({
|
||||
const linkLine = linkLineRef.current;
|
||||
if (linkLine) {
|
||||
const geometry = linkLine.geometry as THREE.BufferGeometry;
|
||||
const requiredLength = visibleLinks.length * 6;
|
||||
const requiredLength = solidLinks.length * 6;
|
||||
const highlightRequiredLength = (solidLinks.length + dashedLinks.length) * 6;
|
||||
if (linkPositionBufferRef.current.length < requiredLength) {
|
||||
linkPositionBufferRef.current = growFloat32Buffer(
|
||||
linkPositionBufferRef.current,
|
||||
@@ -397,10 +431,10 @@ export function useVisualizer3DScene({
|
||||
}
|
||||
|
||||
const highlightLine = highlightLineRef.current;
|
||||
if (highlightLine && highlightPositionBufferRef.current.length < requiredLength) {
|
||||
if (highlightLine && highlightPositionBufferRef.current.length < highlightRequiredLength) {
|
||||
highlightPositionBufferRef.current = growFloat32Buffer(
|
||||
highlightPositionBufferRef.current,
|
||||
requiredLength
|
||||
highlightRequiredLength
|
||||
);
|
||||
(highlightLine.geometry as THREE.BufferGeometry).setAttribute(
|
||||
'position',
|
||||
@@ -415,7 +449,7 @@ export function useVisualizer3DScene({
|
||||
let idx = 0;
|
||||
let hlIdx = 0;
|
||||
|
||||
for (const link of visibleLinks) {
|
||||
for (const link of solidLinks) {
|
||||
const { sourceId, targetId } = getLinkId(link);
|
||||
const sNode = nodes.get(sourceId);
|
||||
const tNode = nodes.get(targetId);
|
||||
@@ -446,6 +480,23 @@ export function useVisualizer3DScene({
|
||||
}
|
||||
}
|
||||
|
||||
for (const link of dashedLinks) {
|
||||
const { sourceId, targetId } = getLinkId(link);
|
||||
if (activeId && (sourceId === activeId || targetId === activeId)) {
|
||||
const sNode = nodes.get(sourceId);
|
||||
const tNode = nodes.get(targetId);
|
||||
if (!sNode || !tNode) continue;
|
||||
|
||||
connectedIds?.add(sourceId === activeId ? targetId : sourceId);
|
||||
hlPositions[hlIdx++] = sNode.x ?? 0;
|
||||
hlPositions[hlIdx++] = sNode.y ?? 0;
|
||||
hlPositions[hlIdx++] = sNode.z ?? 0;
|
||||
hlPositions[hlIdx++] = tNode.x ?? 0;
|
||||
hlPositions[hlIdx++] = tNode.y ?? 0;
|
||||
hlPositions[hlIdx++] = tNode.z ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
|
||||
if (positionAttr) {
|
||||
positionAttr.needsUpdate = true;
|
||||
@@ -464,6 +515,51 @@ export function useVisualizer3DScene({
|
||||
}
|
||||
}
|
||||
|
||||
const dashedLinkLine = dashedLinkLineRef.current;
|
||||
if (dashedLinkLine) {
|
||||
const geometry = dashedLinkLine.geometry as THREE.BufferGeometry;
|
||||
const requiredLength = dashedLinks.length * 6;
|
||||
if (dashedLinkPositionBufferRef.current.length < requiredLength) {
|
||||
dashedLinkPositionBufferRef.current = growFloat32Buffer(
|
||||
dashedLinkPositionBufferRef.current,
|
||||
requiredLength
|
||||
);
|
||||
geometry.setAttribute(
|
||||
'position',
|
||||
new THREE.BufferAttribute(dashedLinkPositionBufferRef.current, 3).setUsage(
|
||||
THREE.DynamicDrawUsage
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const positions = dashedLinkPositionBufferRef.current;
|
||||
let idx = 0;
|
||||
|
||||
for (const link of dashedLinks) {
|
||||
const { sourceId, targetId } = getLinkId(link);
|
||||
const sNode = nodes.get(sourceId);
|
||||
const tNode = nodes.get(targetId);
|
||||
if (!sNode || !tNode) continue;
|
||||
|
||||
positions[idx++] = sNode.x ?? 0;
|
||||
positions[idx++] = sNode.y ?? 0;
|
||||
positions[idx++] = sNode.z ?? 0;
|
||||
positions[idx++] = tNode.x ?? 0;
|
||||
positions[idx++] = tNode.y ?? 0;
|
||||
positions[idx++] = tNode.z ?? 0;
|
||||
}
|
||||
|
||||
const positionAttr = geometry.getAttribute('position') as THREE.BufferAttribute | undefined;
|
||||
if (positionAttr) {
|
||||
positionAttr.needsUpdate = true;
|
||||
}
|
||||
geometry.setDrawRange(0, idx / 3);
|
||||
dashedLinkLine.visible = idx > 0;
|
||||
if (idx > 0 && positionAttr) {
|
||||
dashedLinkLine.computeLineDistances();
|
||||
}
|
||||
}
|
||||
|
||||
let writeIdx = 0;
|
||||
for (let readIdx = 0; readIdx < particles.length; readIdx++) {
|
||||
const particle = particles[readIdx];
|
||||
|
||||
@@ -10,10 +10,20 @@ import {
|
||||
type ForceLink3D,
|
||||
type Simulation3D,
|
||||
} from 'd3-force-3d';
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import type { PacketNetworkNode } from '../../networkGraph/packetNetworkGraph';
|
||||
import {
|
||||
buildPacketNetworkContext,
|
||||
clearPacketNetworkState,
|
||||
createPacketNetworkState,
|
||||
ensureSelfNode,
|
||||
ingestPacketIntoPacketNetwork,
|
||||
projectCanonicalPath,
|
||||
projectPacketNetwork,
|
||||
prunePacketNetworkState,
|
||||
snapshotNeighborIds,
|
||||
} from '../../networkGraph/packetNetworkGraph';
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
type ContactAdvertPathSummary,
|
||||
type RadioConfig,
|
||||
@@ -21,22 +31,15 @@ import {
|
||||
} from '../../types';
|
||||
import { getRawPacketObservationKey } from '../../utils/rawPacketIdentity';
|
||||
import {
|
||||
type Particle,
|
||||
type PendingPacket,
|
||||
type RepeaterTrafficData,
|
||||
PARTICLE_COLOR_MAP,
|
||||
PARTICLE_SPEED,
|
||||
analyzeRepeaterTraffic,
|
||||
buildAmbiguousRepeaterLabel,
|
||||
buildAmbiguousRepeaterNodeId,
|
||||
buildLinkKey,
|
||||
dedupeConsecutive,
|
||||
generatePacketKey,
|
||||
getNodeType,
|
||||
getPacketLabel,
|
||||
parsePacket,
|
||||
recordTrafficObservation,
|
||||
type Particle,
|
||||
PARTICLE_COLOR_MAP,
|
||||
PARTICLE_SPEED,
|
||||
type PendingPacket,
|
||||
} from '../../utils/visualizerUtils';
|
||||
import { type GraphLink, type GraphNode, normalizePacketTimestampMs } from './shared';
|
||||
import { type GraphLink, type GraphNode } from './shared';
|
||||
|
||||
export interface UseVisualizerData3DOptions {
|
||||
packets: RawPacket[];
|
||||
@@ -46,6 +49,7 @@ export interface UseVisualizerData3DOptions {
|
||||
showAmbiguousPaths: boolean;
|
||||
showAmbiguousNodes: boolean;
|
||||
useAdvertPathHints: boolean;
|
||||
collapseLikelyKnownSiblingRepeaters: boolean;
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
chargeStrength: number;
|
||||
letEmDrift: boolean;
|
||||
@@ -58,12 +62,42 @@ export interface UseVisualizerData3DOptions {
|
||||
export interface VisualizerData3D {
|
||||
nodes: Map<string, GraphNode>;
|
||||
links: Map<string, GraphLink>;
|
||||
canonicalNodes: Map<string, PacketNetworkNode>;
|
||||
canonicalNeighborIds: Map<string, string[]>;
|
||||
renderedNodeIds: Set<string>;
|
||||
particles: Particle[];
|
||||
stats: { processed: number; animated: number; nodes: number; links: number };
|
||||
expandContract: () => void;
|
||||
clearAndReset: () => void;
|
||||
}
|
||||
|
||||
function buildInitialRenderNode(node: PacketNetworkNode): GraphNode {
|
||||
if (node.id === 'self') {
|
||||
return {
|
||||
...node,
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
fx: 0,
|
||||
fy: 0,
|
||||
fz: 0,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
vz: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const r = 80 + Math.random() * 100;
|
||||
return {
|
||||
...node,
|
||||
x: r * Math.sin(phi) * Math.cos(theta),
|
||||
y: r * Math.sin(phi) * Math.sin(theta),
|
||||
z: r * Math.cos(phi),
|
||||
};
|
||||
}
|
||||
|
||||
export function useVisualizerData3D({
|
||||
packets,
|
||||
contacts,
|
||||
@@ -72,6 +106,7 @@ export function useVisualizerData3D({
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
splitAmbiguousByTraffic,
|
||||
chargeStrength,
|
||||
letEmDrift,
|
||||
@@ -80,6 +115,7 @@ export function useVisualizerData3D({
|
||||
pruneStaleNodes,
|
||||
pruneStaleMinutes,
|
||||
}: UseVisualizerData3DOptions): VisualizerData3D {
|
||||
const networkStateRef = useRef(createPacketNetworkState(config?.name || 'Me'));
|
||||
const nodesRef = useRef<Map<string, GraphNode>>(new Map());
|
||||
const linksRef = useRef<Map<string, GraphLink>>(new Map());
|
||||
const particlesRef = useRef<Particle[]>([]);
|
||||
@@ -87,47 +123,23 @@ export function useVisualizerData3D({
|
||||
const processedRef = useRef<Set<string>>(new Set());
|
||||
const pendingRef = useRef<Map<string, PendingPacket>>(new Map());
|
||||
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map());
|
||||
const trafficPatternsRef = useRef<Map<string, RepeaterTrafficData>>(new Map());
|
||||
const speedMultiplierRef = useRef(particleSpeedMultiplier);
|
||||
const observationWindowRef = useRef(observationWindowSec * 1000);
|
||||
const stretchRafRef = useRef<number | null>(null);
|
||||
const [stats, setStats] = useState({ processed: 0, animated: 0, nodes: 0, links: 0 });
|
||||
const [, setProjectionVersion] = useState(0);
|
||||
|
||||
const contactIndex = useMemo(() => {
|
||||
const byPrefix12 = new Map<string, Contact>();
|
||||
const byName = new Map<string, Contact>();
|
||||
const byPrefix = new Map<string, Contact[]>();
|
||||
|
||||
for (const contact of contacts) {
|
||||
const prefix12 = contact.public_key.slice(0, 12).toLowerCase();
|
||||
byPrefix12.set(prefix12, contact);
|
||||
|
||||
if (contact.name && !byName.has(contact.name)) {
|
||||
byName.set(contact.name, contact);
|
||||
}
|
||||
|
||||
for (let len = 1; len <= 12; len++) {
|
||||
const prefix = prefix12.slice(0, len);
|
||||
const matches = byPrefix.get(prefix);
|
||||
if (matches) {
|
||||
matches.push(contact);
|
||||
} else {
|
||||
byPrefix.set(prefix, [contact]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { byPrefix12, byName, byPrefix };
|
||||
}, [contacts]);
|
||||
|
||||
const advertPathIndex = useMemo(() => {
|
||||
const byRepeater = new Map<string, ContactAdvertPathSummary['paths']>();
|
||||
for (const summary of repeaterAdvertPaths) {
|
||||
const key = summary.public_key.slice(0, 12).toLowerCase();
|
||||
byRepeater.set(key, summary.paths);
|
||||
}
|
||||
return { byRepeater };
|
||||
}, [repeaterAdvertPaths]);
|
||||
const packetNetworkContext = useMemo(
|
||||
() =>
|
||||
buildPacketNetworkContext({
|
||||
contacts,
|
||||
config,
|
||||
repeaterAdvertPaths,
|
||||
splitAmbiguousByTraffic,
|
||||
useAdvertPathHints,
|
||||
}),
|
||||
[contacts, config, repeaterAdvertPaths, splitAmbiguousByTraffic, useAdvertPathHints]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
speedMultiplierRef.current = particleSpeedMultiplier;
|
||||
@@ -213,96 +225,108 @@ export function useVisualizerData3D({
|
||||
? prev
|
||||
: { ...prev, nodes: nodes.length, links: links.length }
|
||||
);
|
||||
setProjectionVersion((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodesRef.current.has('self')) {
|
||||
nodesRef.current.set('self', {
|
||||
id: 'self',
|
||||
name: config?.name || 'Me',
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: Date.now(),
|
||||
x: 0,
|
||||
y: 0,
|
||||
z: 0,
|
||||
});
|
||||
syncSimulation();
|
||||
}
|
||||
}, [config, syncSimulation]);
|
||||
|
||||
useEffect(() => {
|
||||
processedRef.current.clear();
|
||||
const selfNode = nodesRef.current.get('self');
|
||||
nodesRef.current.clear();
|
||||
if (selfNode) nodesRef.current.set('self', selfNode);
|
||||
linksRef.current.clear();
|
||||
particlesRef.current = [];
|
||||
pendingRef.current.clear();
|
||||
timersRef.current.forEach((t) => clearTimeout(t));
|
||||
timersRef.current.clear();
|
||||
trafficPatternsRef.current.clear();
|
||||
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
||||
syncSimulation();
|
||||
}, [
|
||||
showAmbiguousPaths,
|
||||
showAmbiguousNodes,
|
||||
useAdvertPathHints,
|
||||
splitAmbiguousByTraffic,
|
||||
syncSimulation,
|
||||
]);
|
||||
|
||||
const addNode = useCallback(
|
||||
(
|
||||
id: string,
|
||||
name: string | null,
|
||||
type: GraphNode['type'],
|
||||
isAmbiguous: boolean,
|
||||
probableIdentity?: string | null,
|
||||
ambiguousNames?: string[],
|
||||
lastSeen?: number | null,
|
||||
activityAtMs?: number
|
||||
) => {
|
||||
const activityAt = activityAtMs ?? Date.now();
|
||||
const existing = nodesRef.current.get(id);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAt);
|
||||
if (name) existing.name = name;
|
||||
if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity;
|
||||
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
|
||||
if (lastSeen !== undefined) existing.lastSeen = lastSeen;
|
||||
} else {
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.acos(2 * Math.random() - 1);
|
||||
const r = 80 + Math.random() * 100;
|
||||
nodesRef.current.set(id, {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
isAmbiguous,
|
||||
lastActivity: activityAt,
|
||||
probableIdentity,
|
||||
lastSeen,
|
||||
ambiguousNames,
|
||||
x: r * Math.sin(phi) * Math.cos(theta),
|
||||
y: r * Math.sin(phi) * Math.sin(theta),
|
||||
z: r * Math.cos(phi),
|
||||
});
|
||||
const upsertRenderNode = useCallback(
|
||||
(node: PacketNetworkNode, existing?: GraphNode): GraphNode => {
|
||||
if (!existing) {
|
||||
return buildInitialRenderNode(node);
|
||||
}
|
||||
|
||||
existing.name = node.name;
|
||||
existing.type = node.type;
|
||||
existing.isAmbiguous = node.isAmbiguous;
|
||||
existing.lastActivity = node.lastActivity;
|
||||
existing.lastActivityReason = node.lastActivityReason;
|
||||
existing.lastSeen = node.lastSeen;
|
||||
existing.probableIdentity = node.probableIdentity;
|
||||
existing.ambiguousNames = node.ambiguousNames;
|
||||
|
||||
if (node.id === 'self') {
|
||||
existing.x = 0;
|
||||
existing.y = 0;
|
||||
existing.z = 0;
|
||||
existing.fx = 0;
|
||||
existing.fy = 0;
|
||||
existing.fz = 0;
|
||||
existing.vx = 0;
|
||||
existing.vy = 0;
|
||||
existing.vz = 0;
|
||||
}
|
||||
|
||||
return existing;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const addLink = useCallback((sourceId: string, targetId: string, activityAtMs?: number) => {
|
||||
const activityAt = activityAtMs ?? Date.now();
|
||||
const key = [sourceId, targetId].sort().join('->');
|
||||
const existing = linksRef.current.get(key);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAt);
|
||||
} else {
|
||||
linksRef.current.set(key, { source: sourceId, target: targetId, lastActivity: activityAt });
|
||||
const rebuildRenderProjection = useCallback(() => {
|
||||
const projection = projectPacketNetwork(networkStateRef.current, {
|
||||
showAmbiguousNodes,
|
||||
showAmbiguousPaths,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
});
|
||||
const previousNodes = nodesRef.current;
|
||||
const nextNodes = new Map<string, GraphNode>();
|
||||
|
||||
for (const [nodeId, node] of projection.nodes) {
|
||||
nextNodes.set(nodeId, upsertRenderNode(node, previousNodes.get(nodeId)));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const nextLinks = new Map<string, GraphLink>();
|
||||
for (const [key, link] of projection.links) {
|
||||
nextLinks.set(key, {
|
||||
source: link.sourceId,
|
||||
target: link.targetId,
|
||||
lastActivity: link.lastActivity,
|
||||
hasDirectObservation: link.hasDirectObservation,
|
||||
hasHiddenIntermediate: link.hasHiddenIntermediate,
|
||||
hiddenHopLabels: [...link.hiddenHopLabels],
|
||||
});
|
||||
}
|
||||
|
||||
nodesRef.current = nextNodes;
|
||||
linksRef.current = nextLinks;
|
||||
syncSimulation();
|
||||
}, [
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
showAmbiguousNodes,
|
||||
showAmbiguousPaths,
|
||||
syncSimulation,
|
||||
upsertRenderNode,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
ensureSelfNode(networkStateRef.current, config?.name || 'Me');
|
||||
const selfNode = networkStateRef.current.nodes.get('self');
|
||||
if (selfNode) {
|
||||
nodesRef.current.set('self', upsertRenderNode(selfNode, nodesRef.current.get('self')));
|
||||
}
|
||||
syncSimulation();
|
||||
}, [config?.name, syncSimulation, upsertRenderNode]);
|
||||
|
||||
useEffect(() => {
|
||||
processedRef.current.clear();
|
||||
clearPacketNetworkState(networkStateRef.current, { selfName: config?.name || 'Me' });
|
||||
nodesRef.current.clear();
|
||||
linksRef.current.clear();
|
||||
particlesRef.current = [];
|
||||
pendingRef.current.clear();
|
||||
timersRef.current.forEach((timer) => clearTimeout(timer));
|
||||
timersRef.current.clear();
|
||||
|
||||
const selfNode = networkStateRef.current.nodes.get('self');
|
||||
if (selfNode) {
|
||||
nodesRef.current.set('self', upsertRenderNode(selfNode));
|
||||
}
|
||||
|
||||
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
||||
syncSimulation();
|
||||
}, [config?.name, splitAmbiguousByTraffic, syncSimulation, upsertRenderNode, useAdvertPathHints]);
|
||||
|
||||
useEffect(() => {
|
||||
rebuildRenderProjection();
|
||||
}, [rebuildRenderProjection]);
|
||||
|
||||
const publishPacket = useCallback((packetKey: string) => {
|
||||
const pending = pendingRef.current.get(packetKey);
|
||||
@@ -319,7 +343,7 @@ export function useVisualizerData3D({
|
||||
|
||||
for (let i = 0; i < dedupedPath.length - 1; i++) {
|
||||
particlesRef.current.push({
|
||||
linkKey: [dedupedPath[i], dedupedPath[i + 1]].sort().join('->'),
|
||||
linkKey: buildLinkKey(dedupedPath[i], dedupedPath[i + 1]),
|
||||
progress: -i,
|
||||
speed: PARTICLE_SPEED * speedMultiplierRef.current,
|
||||
color: PARTICLE_COLOR_MAP[pending.label],
|
||||
@@ -331,334 +355,10 @@ export function useVisualizerData3D({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const pickLikelyRepeaterByAdvertPath = useCallback(
|
||||
(candidates: Contact[], nextPrefix: string | null) => {
|
||||
const nextHop = nextPrefix?.toLowerCase() ?? null;
|
||||
const scored = candidates
|
||||
.map((candidate) => {
|
||||
const prefix12 = candidate.public_key.slice(0, 12).toLowerCase();
|
||||
const paths = advertPathIndex.byRepeater.get(prefix12) ?? [];
|
||||
let matchScore = 0;
|
||||
let totalScore = 0;
|
||||
|
||||
for (const path of paths) {
|
||||
totalScore += path.heard_count;
|
||||
const pathNextHop = path.next_hop?.toLowerCase() ?? null;
|
||||
if (pathNextHop === nextHop) {
|
||||
matchScore += path.heard_count;
|
||||
}
|
||||
}
|
||||
|
||||
return { candidate, matchScore, totalScore };
|
||||
})
|
||||
.filter((entry) => entry.totalScore > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.matchScore - a.matchScore ||
|
||||
b.totalScore - a.totalScore ||
|
||||
a.candidate.public_key.localeCompare(b.candidate.public_key)
|
||||
);
|
||||
|
||||
if (scored.length === 0) return null;
|
||||
|
||||
const top = scored[0];
|
||||
const second = scored[1] ?? null;
|
||||
|
||||
if (top.matchScore < 2) return null;
|
||||
if (second && top.matchScore < second.matchScore * 2) return null;
|
||||
|
||||
return top.candidate;
|
||||
},
|
||||
[advertPathIndex]
|
||||
);
|
||||
|
||||
const resolveNode = useCallback(
|
||||
(
|
||||
source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
|
||||
isRepeater: boolean,
|
||||
showAmbiguous: boolean,
|
||||
myPrefix: string | null,
|
||||
activityAtMs: number,
|
||||
trafficContext?: { packetSource: string | null; nextPrefix: string | null }
|
||||
): string | null => {
|
||||
if (source.type === 'pubkey') {
|
||||
if (source.value.length < 12) return null;
|
||||
const nodeId = source.value.slice(0, 12).toLowerCase();
|
||||
if (myPrefix && nodeId === myPrefix) return 'self';
|
||||
const contact = contactIndex.byPrefix12.get(nodeId);
|
||||
addNode(
|
||||
nodeId,
|
||||
contact?.name || null,
|
||||
getNodeType(contact),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
contact?.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (source.type === 'name') {
|
||||
const contact = contactIndex.byName.get(source.value) ?? null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (myPrefix && nodeId === myPrefix) return 'self';
|
||||
addNode(
|
||||
nodeId,
|
||||
contact.name,
|
||||
getNodeType(contact),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
contact.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
const nodeId = `name:${source.value}`;
|
||||
addNode(
|
||||
nodeId,
|
||||
source.value,
|
||||
'client',
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
const lookupValue = source.value.toLowerCase();
|
||||
const matches = contactIndex.byPrefix.get(lookupValue) ?? [];
|
||||
const contact = matches.length === 1 ? matches[0] : null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (myPrefix && nodeId === myPrefix) return 'self';
|
||||
addNode(
|
||||
nodeId,
|
||||
contact.name,
|
||||
getNodeType(contact),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
contact.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (showAmbiguous) {
|
||||
const filtered = isRepeater
|
||||
? matches.filter((c) => c.type === CONTACT_TYPE_REPEATER)
|
||||
: matches.filter((c) => c.type !== CONTACT_TYPE_REPEATER);
|
||||
|
||||
if (filtered.length === 1) {
|
||||
const c = filtered[0];
|
||||
const nodeId = c.public_key.slice(0, 12).toLowerCase();
|
||||
addNode(
|
||||
nodeId,
|
||||
c.name,
|
||||
getNodeType(c),
|
||||
false,
|
||||
undefined,
|
||||
undefined,
|
||||
c.last_seen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (filtered.length > 1 || (filtered.length === 0 && isRepeater)) {
|
||||
const names = filtered.map((c) => c.name || c.public_key.slice(0, 8));
|
||||
const lastSeen = filtered.reduce(
|
||||
(max, c) => (c.last_seen && (!max || c.last_seen > max) ? c.last_seen : max),
|
||||
null as number | null
|
||||
);
|
||||
|
||||
let nodeId = buildAmbiguousRepeaterNodeId(lookupValue);
|
||||
let displayName = buildAmbiguousRepeaterLabel(lookupValue);
|
||||
let probableIdentity: string | null = null;
|
||||
let ambiguousNames = names.length > 0 ? names : undefined;
|
||||
|
||||
if (useAdvertPathHints && isRepeater && trafficContext) {
|
||||
const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null;
|
||||
const likely = pickLikelyRepeaterByAdvertPath(filtered, normalizedNext);
|
||||
if (likely) {
|
||||
const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase();
|
||||
probableIdentity = likelyName;
|
||||
displayName = likelyName;
|
||||
ambiguousNames = filtered
|
||||
.filter((c) => c.public_key !== likely.public_key)
|
||||
.map((c) => c.name || c.public_key.slice(0, 8));
|
||||
}
|
||||
}
|
||||
|
||||
if (splitAmbiguousByTraffic && isRepeater && trafficContext) {
|
||||
const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null;
|
||||
|
||||
if (trafficContext.packetSource) {
|
||||
recordTrafficObservation(
|
||||
trafficPatternsRef.current,
|
||||
lookupValue,
|
||||
trafficContext.packetSource,
|
||||
normalizedNext
|
||||
);
|
||||
}
|
||||
|
||||
const trafficData = trafficPatternsRef.current.get(lookupValue);
|
||||
if (trafficData) {
|
||||
const analysis = analyzeRepeaterTraffic(trafficData);
|
||||
if (analysis.shouldSplit && normalizedNext) {
|
||||
nodeId = buildAmbiguousRepeaterNodeId(lookupValue, normalizedNext);
|
||||
if (!probableIdentity) {
|
||||
displayName = buildAmbiguousRepeaterLabel(lookupValue, normalizedNext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addNode(
|
||||
nodeId,
|
||||
displayName,
|
||||
isRepeater ? 'repeater' : 'client',
|
||||
true,
|
||||
probableIdentity,
|
||||
ambiguousNames,
|
||||
lastSeen,
|
||||
activityAtMs
|
||||
);
|
||||
return nodeId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[
|
||||
contactIndex,
|
||||
addNode,
|
||||
useAdvertPathHints,
|
||||
pickLikelyRepeaterByAdvertPath,
|
||||
splitAmbiguousByTraffic,
|
||||
]
|
||||
);
|
||||
|
||||
const buildPath = useCallback(
|
||||
(
|
||||
parsed: ReturnType<typeof parsePacket>,
|
||||
packet: RawPacket,
|
||||
myPrefix: string | null,
|
||||
activityAtMs: number
|
||||
): string[] => {
|
||||
if (!parsed) return [];
|
||||
const path: string[] = [];
|
||||
let packetSource: string | null = null;
|
||||
|
||||
if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'pubkey', value: parsed.advertPubkey },
|
||||
false,
|
||||
false,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'pubkey', value: parsed.anonRequestPubkey },
|
||||
false,
|
||||
false,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) {
|
||||
if (myPrefix && parsed.srcHash.toLowerCase() === myPrefix) {
|
||||
path.push('self');
|
||||
packetSource = 'self';
|
||||
} else {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'prefix', value: parsed.srcHash },
|
||||
false,
|
||||
showAmbiguousNodes,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.GroupText) {
|
||||
const senderName = parsed.groupTextSender || packet.decrypted_info?.sender;
|
||||
if (senderName) {
|
||||
const resolved = resolveNode(
|
||||
{ type: 'name', value: senderName },
|
||||
false,
|
||||
false,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (resolved) {
|
||||
path.push(resolved);
|
||||
packetSource = resolved;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < parsed.pathBytes.length; i++) {
|
||||
const hexPrefix = parsed.pathBytes[i];
|
||||
const nextPrefix = parsed.pathBytes[i + 1] || null;
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'prefix', value: hexPrefix },
|
||||
true,
|
||||
showAmbiguousPaths,
|
||||
myPrefix,
|
||||
activityAtMs,
|
||||
{ packetSource, nextPrefix }
|
||||
);
|
||||
if (nodeId) path.push(nodeId);
|
||||
}
|
||||
|
||||
if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) {
|
||||
if (myPrefix && parsed.dstHash.toLowerCase() === myPrefix) {
|
||||
path.push('self');
|
||||
} else {
|
||||
const nodeId = resolveNode(
|
||||
{ type: 'prefix', value: parsed.dstHash },
|
||||
false,
|
||||
showAmbiguousNodes,
|
||||
myPrefix,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) path.push(nodeId);
|
||||
else path.push('self');
|
||||
}
|
||||
} else if (path.length > 0) {
|
||||
path.push('self');
|
||||
}
|
||||
|
||||
if (path.length > 0 && path[path.length - 1] !== 'self') {
|
||||
path.push('self');
|
||||
}
|
||||
|
||||
return dedupeConsecutive(path);
|
||||
},
|
||||
[resolveNode, showAmbiguousPaths, showAmbiguousNodes]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let newProcessed = 0;
|
||||
let newAnimated = 0;
|
||||
let needsUpdate = false;
|
||||
const myPrefix = config?.public_key?.slice(0, 12).toLowerCase() || null;
|
||||
let needsProjectionRebuild = false;
|
||||
|
||||
for (const packet of packets) {
|
||||
const observationKey = getRawPacketObservationKey(packet);
|
||||
@@ -670,34 +370,31 @@ export function useVisualizerData3D({
|
||||
processedRef.current = new Set(Array.from(processedRef.current).slice(-500));
|
||||
}
|
||||
|
||||
const parsed = parsePacket(packet.data);
|
||||
if (!parsed) continue;
|
||||
const ingested = ingestPacketIntoPacketNetwork(
|
||||
networkStateRef.current,
|
||||
packetNetworkContext,
|
||||
packet
|
||||
);
|
||||
if (!ingested) continue;
|
||||
needsProjectionRebuild = true;
|
||||
|
||||
const packetActivityAt = normalizePacketTimestampMs(packet.timestamp);
|
||||
const path = buildPath(parsed, packet, myPrefix, packetActivityAt);
|
||||
if (path.length < 2) continue;
|
||||
const projectedPath = projectCanonicalPath(networkStateRef.current, ingested.canonicalPath, {
|
||||
showAmbiguousNodes,
|
||||
showAmbiguousPaths,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
});
|
||||
if (projectedPath.nodes.length < 2) continue;
|
||||
|
||||
const label = getPacketLabel(parsed.payloadType);
|
||||
for (let i = 0; i < path.length; i++) {
|
||||
const n = nodesRef.current.get(path[i]);
|
||||
if (n && n.id !== 'self') {
|
||||
n.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
if (path[i] !== path[i + 1]) {
|
||||
addLink(path[i], path[i + 1], packetActivityAt);
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
const packetKey = generatePacketKey(parsed, packet);
|
||||
const packetKey = generatePacketKey(ingested.parsed, packet);
|
||||
const now = Date.now();
|
||||
const existing = pendingRef.current.get(packetKey);
|
||||
|
||||
if (existing && now < existing.expiresAt) {
|
||||
existing.paths.push({ nodes: path, snr: packet.snr ?? null, timestamp: now });
|
||||
existing.paths.push({
|
||||
nodes: projectedPath.nodes,
|
||||
snr: packet.snr ?? null,
|
||||
timestamp: now,
|
||||
});
|
||||
} else {
|
||||
const existingTimer = timersRef.current.get(packetKey);
|
||||
if (existingTimer) {
|
||||
@@ -706,8 +403,8 @@ export function useVisualizerData3D({
|
||||
const windowMs = observationWindowRef.current;
|
||||
pendingRef.current.set(packetKey, {
|
||||
key: packetKey,
|
||||
label: getPacketLabel(parsed.payloadType),
|
||||
paths: [{ nodes: path, snr: packet.snr ?? null, timestamp: now }],
|
||||
label: ingested.label,
|
||||
paths: [{ nodes: projectedPath.nodes, snr: packet.snr ?? null, timestamp: now }],
|
||||
firstSeen: now,
|
||||
expiresAt: now + windowMs,
|
||||
});
|
||||
@@ -734,7 +431,9 @@ export function useVisualizerData3D({
|
||||
newAnimated++;
|
||||
}
|
||||
|
||||
if (needsUpdate) syncSimulation();
|
||||
if (needsProjectionRebuild) {
|
||||
rebuildRenderProjection();
|
||||
}
|
||||
if (newProcessed > 0) {
|
||||
setStats((prev) => ({
|
||||
...prev,
|
||||
@@ -742,7 +441,15 @@ export function useVisualizerData3D({
|
||||
animated: prev.animated + newAnimated,
|
||||
}));
|
||||
}
|
||||
}, [packets, config, buildPath, addLink, syncSimulation, publishPacket]);
|
||||
}, [
|
||||
packets,
|
||||
packetNetworkContext,
|
||||
publishPacket,
|
||||
collapseLikelyKnownSiblingRepeaters,
|
||||
rebuildRenderProjection,
|
||||
showAmbiguousNodes,
|
||||
showAmbiguousPaths,
|
||||
]);
|
||||
|
||||
const expandContract = useCallback(() => {
|
||||
const sim = simulationRef.current;
|
||||
@@ -831,21 +538,14 @@ export function useVisualizerData3D({
|
||||
timersRef.current.clear();
|
||||
pendingRef.current.clear();
|
||||
processedRef.current.clear();
|
||||
trafficPatternsRef.current.clear();
|
||||
particlesRef.current.length = 0;
|
||||
linksRef.current.clear();
|
||||
clearPacketNetworkState(networkStateRef.current, { selfName: config?.name || 'Me' });
|
||||
|
||||
const selfNode = nodesRef.current.get('self');
|
||||
linksRef.current.clear();
|
||||
nodesRef.current.clear();
|
||||
const selfNode = networkStateRef.current.nodes.get('self');
|
||||
if (selfNode) {
|
||||
selfNode.x = 0;
|
||||
selfNode.y = 0;
|
||||
selfNode.z = 0;
|
||||
selfNode.vx = 0;
|
||||
selfNode.vy = 0;
|
||||
selfNode.vz = 0;
|
||||
selfNode.lastActivity = Date.now();
|
||||
nodesRef.current.set('self', selfNode);
|
||||
nodesRef.current.set('self', upsertRenderNode(selfNode));
|
||||
}
|
||||
|
||||
const sim = simulationRef.current;
|
||||
@@ -856,8 +556,8 @@ export function useVisualizerData3D({
|
||||
sim.alpha(0.3).restart();
|
||||
}
|
||||
|
||||
setStats({ processed: 0, animated: 0, nodes: 1, links: 0 });
|
||||
}, []);
|
||||
setStats({ processed: 0, animated: 0, nodes: selfNode ? 1 : 0, links: 0 });
|
||||
}, [config?.name, upsertRenderNode]);
|
||||
|
||||
useEffect(() => {
|
||||
const stretchRaf = stretchRafRef;
|
||||
@@ -883,40 +583,23 @@ export function useVisualizerData3D({
|
||||
|
||||
const interval = setInterval(() => {
|
||||
const cutoff = Date.now() - staleMs;
|
||||
let pruned = false;
|
||||
|
||||
for (const [id, node] of nodesRef.current) {
|
||||
if (id === 'self') continue;
|
||||
if (node.lastActivity < cutoff) {
|
||||
nodesRef.current.delete(id);
|
||||
pruned = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (pruned) {
|
||||
for (const [key, link] of linksRef.current) {
|
||||
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
|
||||
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
|
||||
if (!nodesRef.current.has(sourceId) || !nodesRef.current.has(targetId)) {
|
||||
linksRef.current.delete(key);
|
||||
}
|
||||
}
|
||||
syncSimulation();
|
||||
if (prunePacketNetworkState(networkStateRef.current, cutoff)) {
|
||||
rebuildRenderProjection();
|
||||
}
|
||||
}, pruneIntervalMs);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [pruneStaleNodes, pruneStaleMinutes, syncSimulation]);
|
||||
}, [pruneStaleMinutes, pruneStaleNodes, rebuildRenderProjection]);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
nodes: nodesRef.current,
|
||||
links: linksRef.current,
|
||||
particles: particlesRef.current,
|
||||
stats,
|
||||
expandContract,
|
||||
clearAndReset,
|
||||
}),
|
||||
[stats, expandContract, clearAndReset]
|
||||
);
|
||||
return {
|
||||
nodes: nodesRef.current,
|
||||
links: linksRef.current,
|
||||
canonicalNodes: networkStateRef.current.nodes,
|
||||
canonicalNeighborIds: snapshotNeighborIds(networkStateRef.current),
|
||||
renderedNodeIds: new Set(nodesRef.current.keys()),
|
||||
particles: particlesRef.current,
|
||||
stats,
|
||||
expandContract,
|
||||
clearAndReset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ export function useContactsAndChannels({
|
||||
setActiveConversation({
|
||||
type: 'contact',
|
||||
id: created.public_key,
|
||||
name: getContactDisplayName(created.name, created.public_key),
|
||||
name: getContactDisplayName(created.name, created.public_key, created.last_advert),
|
||||
});
|
||||
},
|
||||
[fetchAllContacts, setActiveConversation]
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
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';
|
||||
import type { Channel, Contact, Conversation, Message, PathDiscoveryResponse } from '../types';
|
||||
import { mergeContactIntoList } from '../utils/contactMerge';
|
||||
|
||||
interface UseConversationActionsArgs {
|
||||
activeConversation: Conversation | null;
|
||||
activeConversationRef: MutableRefObject<Conversation | null>;
|
||||
setContacts: React.Dispatch<React.SetStateAction<Contact[]>>;
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -25,18 +23,15 @@ interface UseConversationActionsResult {
|
||||
) => Promise<void>;
|
||||
handleSenderClick: (sender: string) => void;
|
||||
handleTrace: () => Promise<void>;
|
||||
handleBlockKey: (key: string) => Promise<void>;
|
||||
handleBlockName: (name: string) => Promise<void>;
|
||||
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
|
||||
}
|
||||
|
||||
export function useConversationActions({
|
||||
activeConversation,
|
||||
activeConversationRef,
|
||||
setContacts,
|
||||
setChannels,
|
||||
addMessageIfNew,
|
||||
jumpToBottom,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
messageInputRef,
|
||||
}: UseConversationActionsArgs): UseConversationActionsResult {
|
||||
const mergeChannelIntoList = useCallback(
|
||||
@@ -74,7 +69,16 @@ export function useConversationActions({
|
||||
const handleResendChannelMessage = useCallback(
|
||||
async (messageId: number, newTimestamp?: boolean) => {
|
||||
try {
|
||||
await api.resendChannelMessage(messageId, newTimestamp);
|
||||
const resent = await api.resendChannelMessage(messageId, newTimestamp);
|
||||
const resentMessage = resent.message;
|
||||
if (
|
||||
newTimestamp &&
|
||||
resentMessage &&
|
||||
activeConversationRef.current?.type === 'channel' &&
|
||||
activeConversationRef.current.id === resentMessage.conversation_key
|
||||
) {
|
||||
addMessageIfNew(resentMessage);
|
||||
}
|
||||
toast.success(newTimestamp ? 'Message resent with new timestamp' : 'Message resent');
|
||||
} catch (err) {
|
||||
toast.error('Failed to resend', {
|
||||
@@ -82,7 +86,7 @@ export function useConversationActions({
|
||||
});
|
||||
}
|
||||
},
|
||||
[]
|
||||
[activeConversationRef, addMessageIfNew]
|
||||
);
|
||||
|
||||
const handleSetChannelFloodScopeOverride = useCallback(
|
||||
@@ -126,22 +130,13 @@ export function useConversationActions({
|
||||
}
|
||||
}, [activeConversation]);
|
||||
|
||||
const handleBlockKey = useCallback(
|
||||
async (key: string) => {
|
||||
await handleToggleBlockedKey(key);
|
||||
messageCache.clear();
|
||||
jumpToBottom();
|
||||
const handlePathDiscovery = useCallback(
|
||||
async (publicKey: string) => {
|
||||
const result = await api.requestPathDiscovery(publicKey);
|
||||
setContacts((prev) => mergeContactIntoList(prev, result.contact));
|
||||
return result;
|
||||
},
|
||||
[handleToggleBlockedKey, jumpToBottom]
|
||||
);
|
||||
|
||||
const handleBlockName = useCallback(
|
||||
async (name: string) => {
|
||||
await handleToggleBlockedName(name);
|
||||
messageCache.clear();
|
||||
jumpToBottom();
|
||||
},
|
||||
[handleToggleBlockedName, jumpToBottom]
|
||||
[setContacts]
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -150,7 +145,6 @@ export function useConversationActions({
|
||||
handleSetChannelFloodScopeOverride,
|
||||
handleSenderClick,
|
||||
handleTrace,
|
||||
handleBlockKey,
|
||||
handleBlockName,
|
||||
handlePathDiscovery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ interface UseConversationMessagesResult {
|
||||
fetchOlderMessages: () => Promise<void>;
|
||||
fetchNewerMessages: () => Promise<void>;
|
||||
jumpToBottom: () => void;
|
||||
reloadCurrentConversation: () => void;
|
||||
addMessageIfNew: (msg: Message) => boolean;
|
||||
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
triggerReconcile: () => void;
|
||||
@@ -86,6 +87,30 @@ function isMessageConversation(conversation: Conversation | null): conversation
|
||||
return !!conversation && !['raw', 'map', 'visualizer', 'search'].includes(conversation.type);
|
||||
}
|
||||
|
||||
function appendUniqueMessages(current: Message[], incoming: Message[]): Message[] {
|
||||
if (incoming.length === 0) return current;
|
||||
|
||||
const seenIds = new Set(current.map((msg) => msg.id));
|
||||
const seenContent = new Set(current.map((msg) => getMessageContentKey(msg)));
|
||||
const additions: Message[] = [];
|
||||
|
||||
for (const msg of incoming) {
|
||||
const contentKey = getMessageContentKey(msg);
|
||||
if (seenIds.has(msg.id) || seenContent.has(contentKey)) {
|
||||
continue;
|
||||
}
|
||||
seenIds.add(msg.id);
|
||||
seenContent.add(contentKey);
|
||||
additions.push(msg);
|
||||
}
|
||||
|
||||
if (additions.length === 0) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return [...current, ...additions];
|
||||
}
|
||||
|
||||
export function useConversationMessages(
|
||||
activeConversation: Conversation | null,
|
||||
targetMessageId?: number | null
|
||||
@@ -137,15 +162,23 @@ export function useConversationMessages(
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const fetchingConversationIdRef = useRef<string | null>(null);
|
||||
const latestReconcileRequestIdRef = useRef(0);
|
||||
const messagesRef = useRef<Message[]>([]);
|
||||
const loadingOlderRef = useRef(false);
|
||||
const hasOlderMessagesRef = useRef(false);
|
||||
const hasNewerMessagesRef = useRef(false);
|
||||
const prevConversationIdRef = useRef<string | null>(null);
|
||||
const prevReloadVersionRef = useRef(0);
|
||||
const [reloadVersion, setReloadVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages;
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
loadingOlderRef.current = loadingOlder;
|
||||
}, [loadingOlder]);
|
||||
|
||||
useEffect(() => {
|
||||
hasOlderMessagesRef.current = hasOlderMessages;
|
||||
}, [hasOlderMessages]);
|
||||
@@ -194,8 +227,12 @@ export function useConversationMessages(
|
||||
}
|
||||
|
||||
const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||
setMessages(messagesWithPendingAck);
|
||||
syncSeenContent(messagesWithPendingAck);
|
||||
const merged = messageCache.reconcile(messagesRef.current, messagesWithPendingAck);
|
||||
const nextMessages = merged ?? messagesRef.current;
|
||||
if (merged) {
|
||||
setMessages(merged);
|
||||
}
|
||||
syncSeenContent(nextMessages);
|
||||
setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE);
|
||||
} catch (err) {
|
||||
if (isAbortError(err)) {
|
||||
@@ -215,7 +252,7 @@ export function useConversationMessages(
|
||||
);
|
||||
|
||||
const reconcileFromBackend = useCallback(
|
||||
(conversation: Conversation, signal: AbortSignal) => {
|
||||
(conversation: Conversation, signal: AbortSignal, requestId: number) => {
|
||||
const conversationId = conversation.id;
|
||||
api
|
||||
.getMessages(
|
||||
@@ -228,16 +265,15 @@ export function useConversationMessages(
|
||||
)
|
||||
.then((data) => {
|
||||
if (fetchingConversationIdRef.current !== conversationId) return;
|
||||
if (latestReconcileRequestIdRef.current !== requestId) return;
|
||||
|
||||
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
|
||||
const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck);
|
||||
if (!merged) return;
|
||||
|
||||
setMessages(merged);
|
||||
syncSeenContent(merged);
|
||||
if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) {
|
||||
setHasOlderMessages(true);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (isAbortError(err)) return;
|
||||
@@ -248,7 +284,13 @@ export function useConversationMessages(
|
||||
);
|
||||
|
||||
const fetchOlderMessages = useCallback(async () => {
|
||||
if (!isMessageConversation(activeConversation) || loadingOlder || !hasOlderMessages) return;
|
||||
if (
|
||||
!isMessageConversation(activeConversation) ||
|
||||
loadingOlderRef.current ||
|
||||
!hasOlderMessagesRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const conversationId = activeConversation.id;
|
||||
const oldestMessage = messages.reduce(
|
||||
@@ -262,6 +304,7 @@ export function useConversationMessages(
|
||||
);
|
||||
if (!oldestMessage) return;
|
||||
|
||||
loadingOlderRef.current = true;
|
||||
setLoadingOlder(true);
|
||||
try {
|
||||
const data = await api.getMessages({
|
||||
@@ -277,9 +320,17 @@ export function useConversationMessages(
|
||||
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
|
||||
|
||||
if (dataWithPendingAck.length > 0) {
|
||||
setMessages((prev) => [...prev, ...dataWithPendingAck]);
|
||||
for (const msg of dataWithPendingAck) {
|
||||
seenMessageContent.current.add(getMessageContentKey(msg));
|
||||
let nextMessages: Message[] | null = null;
|
||||
setMessages((prev) => {
|
||||
const merged = appendUniqueMessages(prev, dataWithPendingAck);
|
||||
if (merged !== prev) {
|
||||
nextMessages = merged;
|
||||
}
|
||||
return merged;
|
||||
});
|
||||
if (nextMessages) {
|
||||
messagesRef.current = nextMessages;
|
||||
syncSeenContent(nextMessages);
|
||||
}
|
||||
}
|
||||
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
|
||||
@@ -289,9 +340,10 @@ export function useConversationMessages(
|
||||
description: err instanceof Error ? err.message : 'Check your connection',
|
||||
});
|
||||
} finally {
|
||||
loadingOlderRef.current = false;
|
||||
setLoadingOlder(false);
|
||||
}
|
||||
}, [activeConversation, applyPendingAck, hasOlderMessages, loadingOlder, messages]);
|
||||
}, [activeConversation, applyPendingAck, messages, syncSeenContent]);
|
||||
|
||||
const fetchNewerMessages = useCallback(async () => {
|
||||
if (!isMessageConversation(activeConversation) || loadingNewer || !hasNewerMessages) return;
|
||||
@@ -349,10 +401,19 @@ export function useConversationMessages(
|
||||
void fetchLatestMessages(true);
|
||||
}, [activeConversation, fetchLatestMessages]);
|
||||
|
||||
const reloadCurrentConversation = useCallback(() => {
|
||||
if (!isMessageConversation(activeConversation)) return;
|
||||
setHasNewerMessages(false);
|
||||
messageCache.remove(activeConversation.id);
|
||||
setReloadVersion((current) => current + 1);
|
||||
}, [activeConversation]);
|
||||
|
||||
const triggerReconcile = useCallback(() => {
|
||||
if (!isMessageConversation(activeConversation)) return;
|
||||
const controller = new AbortController();
|
||||
reconcileFromBackend(activeConversation, controller.signal);
|
||||
const requestId = latestReconcileRequestIdRef.current + 1;
|
||||
latestReconcileRequestIdRef.current = requestId;
|
||||
reconcileFromBackend(activeConversation, controller.signal, requestId);
|
||||
}, [activeConversation, reconcileFromBackend]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -363,15 +424,19 @@ export function useConversationMessages(
|
||||
const prevId = prevConversationIdRef.current;
|
||||
const newId = activeConversation?.id ?? null;
|
||||
const conversationChanged = prevId !== newId;
|
||||
const reloadRequested = prevReloadVersionRef.current !== reloadVersion;
|
||||
fetchingConversationIdRef.current = newId;
|
||||
prevConversationIdRef.current = newId;
|
||||
prevReloadVersionRef.current = reloadVersion;
|
||||
latestReconcileRequestIdRef.current = 0;
|
||||
|
||||
// Preserve around-loaded context on the same conversation when search clears targetMessageId.
|
||||
if (!conversationChanged && !targetMessageId) {
|
||||
if (!conversationChanged && !targetMessageId && !reloadRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingOlder(false);
|
||||
loadingOlderRef.current = false;
|
||||
setLoadingNewer(false);
|
||||
if (conversationChanged) {
|
||||
setHasNewerMessages(false);
|
||||
@@ -433,7 +498,9 @@ export function useConversationMessages(
|
||||
seenMessageContent.current = new Set(cached.seenContent);
|
||||
setHasOlderMessages(cached.hasOlderMessages);
|
||||
setMessagesLoading(false);
|
||||
reconcileFromBackend(activeConversation, controller.signal);
|
||||
const requestId = latestReconcileRequestIdRef.current + 1;
|
||||
latestReconcileRequestIdRef.current = requestId;
|
||||
reconcileFromBackend(activeConversation, controller.signal, requestId);
|
||||
} else {
|
||||
void fetchLatestMessages(true, controller.signal);
|
||||
}
|
||||
@@ -443,7 +510,7 @@ export function useConversationMessages(
|
||||
controller.abort();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeConversation?.id, activeConversation?.type, targetMessageId]);
|
||||
}, [activeConversation?.id, activeConversation?.type, targetMessageId, reloadVersion]);
|
||||
|
||||
// Add a message if it's new (deduplication)
|
||||
// Returns true if the message was added, false if it was a duplicate
|
||||
@@ -529,6 +596,7 @@ export function useConversationMessages(
|
||||
fetchOlderMessages,
|
||||
fetchNewerMessages,
|
||||
jumpToBottom,
|
||||
reloadCurrentConversation,
|
||||
addMessageIfNew,
|
||||
updateMessageAck,
|
||||
triggerReconcile,
|
||||
|
||||
@@ -14,6 +14,7 @@ interface UseConversationNavigationResult {
|
||||
infoPaneContactKey: string | null;
|
||||
infoPaneFromChannel: boolean;
|
||||
infoPaneChannelKey: string | null;
|
||||
searchPrefillRequest: { query: string; nonce: number } | null;
|
||||
handleOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
|
||||
handleCloseContactInfo: () => void;
|
||||
handleOpenChannelInfo: (channelKey: string) => void;
|
||||
@@ -24,6 +25,7 @@ interface UseConversationNavigationResult {
|
||||
) => void;
|
||||
handleNavigateToChannel: (channelKey: string) => void;
|
||||
handleNavigateToMessage: (target: SearchNavigateTarget) => void;
|
||||
handleOpenSearchWithQuery: (query: string) => void;
|
||||
}
|
||||
|
||||
export function useConversationNavigation({
|
||||
@@ -34,6 +36,10 @@ export function useConversationNavigation({
|
||||
const [infoPaneContactKey, setInfoPaneContactKey] = useState<string | null>(null);
|
||||
const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false);
|
||||
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(null);
|
||||
const [searchPrefillRequest, setSearchPrefillRequest] = useState<{
|
||||
query: string;
|
||||
nonce: number;
|
||||
} | null>(null);
|
||||
|
||||
const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => {
|
||||
setInfoPaneContactKey(publicKey);
|
||||
@@ -95,12 +101,30 @@ export function useConversationNavigation({
|
||||
[handleSelectConversationWithTargetReset]
|
||||
);
|
||||
|
||||
const handleOpenSearchWithQuery = useCallback(
|
||||
(query: string) => {
|
||||
setTargetMessageId(null);
|
||||
setInfoPaneContactKey(null);
|
||||
handleSelectConversationWithTargetReset({
|
||||
type: 'search',
|
||||
id: 'search',
|
||||
name: 'Message Search',
|
||||
});
|
||||
setSearchPrefillRequest((prev) => ({
|
||||
query,
|
||||
nonce: (prev?.nonce ?? 0) + 1,
|
||||
}));
|
||||
},
|
||||
[handleSelectConversationWithTargetReset]
|
||||
);
|
||||
|
||||
return {
|
||||
targetMessageId,
|
||||
setTargetMessageId,
|
||||
infoPaneContactKey,
|
||||
infoPaneFromChannel,
|
||||
infoPaneChannelKey,
|
||||
searchPrefillRequest,
|
||||
handleOpenContactInfo,
|
||||
handleCloseContactInfo,
|
||||
handleOpenChannelInfo,
|
||||
@@ -108,5 +132,6 @@ export function useConversationNavigation({
|
||||
handleSelectConversationWithTargetReset,
|
||||
handleNavigateToChannel,
|
||||
handleNavigateToMessage,
|
||||
handleOpenSearchWithQuery,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ export function useConversationRouter({
|
||||
setActiveConversationState({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
|
||||
});
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
@@ -179,7 +179,7 @@ export function useConversationRouter({
|
||||
setActiveConversationState({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
|
||||
});
|
||||
hasSetDefaultConversation.current = true;
|
||||
return;
|
||||
|
||||
@@ -2,11 +2,20 @@ import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { api } from '../api';
|
||||
import { takePrefetchOrFetch } from '../prefetch';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import type { HealthStatus, RadioConfig, RadioConfigUpdate } from '../types';
|
||||
import type {
|
||||
HealthStatus,
|
||||
RadioConfig,
|
||||
RadioConfigUpdate,
|
||||
RadioDiscoveryResponse,
|
||||
RadioDiscoveryTarget,
|
||||
} from '../types';
|
||||
|
||||
export function useRadioControl() {
|
||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||
const [config, setConfig] = useState<RadioConfig | null>(null);
|
||||
const [meshDiscovery, setMeshDiscovery] = useState<RadioDiscoveryResponse | null>(null);
|
||||
const [meshDiscoveryLoadingTarget, setMeshDiscoveryLoadingTarget] =
|
||||
useState<RadioDiscoveryTarget | null>(null);
|
||||
|
||||
const prevHealthRef = useRef<HealthStatus | null>(null);
|
||||
const rebootPollTokenRef = useRef(0);
|
||||
@@ -69,6 +78,21 @@ export function useRadioControl() {
|
||||
pollUntilReconnected();
|
||||
}, [fetchConfig]);
|
||||
|
||||
const handleDisconnect = useCallback(async () => {
|
||||
await api.disconnectRadio();
|
||||
const pausedHealth = await api.getHealth();
|
||||
setHealth(pausedHealth);
|
||||
}, []);
|
||||
|
||||
const handleReconnect = useCallback(async () => {
|
||||
await api.reconnectRadio();
|
||||
const refreshedHealth = await api.getHealth();
|
||||
setHealth(refreshedHealth);
|
||||
if (refreshedHealth.radio_connected) {
|
||||
await fetchConfig();
|
||||
}
|
||||
}, [fetchConfig]);
|
||||
|
||||
const handleAdvertise = useCallback(async () => {
|
||||
try {
|
||||
await api.sendAdvertisement();
|
||||
@@ -81,6 +105,26 @@ export function useRadioControl() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDiscoverMesh = useCallback(async (target: RadioDiscoveryTarget) => {
|
||||
setMeshDiscoveryLoadingTarget(target);
|
||||
try {
|
||||
const data = await api.discoverMesh(target);
|
||||
setMeshDiscovery(data);
|
||||
toast.success(
|
||||
data.results.length === 0
|
||||
? 'No nearby nodes responded'
|
||||
: `Found ${data.results.length} nearby node${data.results.length === 1 ? '' : 's'}`
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('Failed to discover nearby nodes:', err);
|
||||
toast.error('Failed to run mesh discovery', {
|
||||
description: err instanceof Error ? err.message : 'Check radio connection',
|
||||
});
|
||||
} finally {
|
||||
setMeshDiscoveryLoadingTarget(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleHealthRefresh = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.getHealth();
|
||||
@@ -100,7 +144,12 @@ export function useRadioControl() {
|
||||
handleSaveConfig,
|
||||
handleSetPrivateKey,
|
||||
handleReboot,
|
||||
handleDisconnect,
|
||||
handleReconnect,
|
||||
handleAdvertise,
|
||||
meshDiscovery,
|
||||
meshDiscoveryLoadingTarget,
|
||||
handleDiscoverMesh,
|
||||
handleHealthRefresh,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { UseWebSocketOptions } from '../useWebSocket';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
import { mergeContactIntoList } from '../utils/contactMerge';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
|
||||
import { getMessageContentKey } from './useConversationMessages';
|
||||
import type {
|
||||
@@ -40,6 +41,7 @@ interface UseRealtimeAppStateArgs {
|
||||
addMessageIfNew: (msg: Message) => boolean;
|
||||
trackNewMessage: (msg: Message) => void;
|
||||
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
|
||||
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
||||
checkMention: (text: string) => boolean;
|
||||
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||
setActiveConversation: (conv: Conversation | null) => void;
|
||||
@@ -100,6 +102,7 @@ export function useRealtimeAppState({
|
||||
addMessageIfNew,
|
||||
trackNewMessage,
|
||||
incrementUnread,
|
||||
renameConversationState,
|
||||
checkMention,
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
@@ -128,6 +131,13 @@ export function useRealtimeAppState({
|
||||
const prev = prevHealthRef.current;
|
||||
prevHealthRef.current = data;
|
||||
setHealth(data);
|
||||
const nextRadioState =
|
||||
data.radio_state ??
|
||||
(data.radio_initializing
|
||||
? 'initializing'
|
||||
: data.radio_connected
|
||||
? 'connected'
|
||||
: 'disconnected');
|
||||
const initializationCompleted =
|
||||
prev !== null &&
|
||||
prev.radio_connected &&
|
||||
@@ -144,9 +154,13 @@ export function useRealtimeAppState({
|
||||
});
|
||||
fetchConfig();
|
||||
} else {
|
||||
toast.error('Radio disconnected', {
|
||||
description: 'Check radio connection and power',
|
||||
});
|
||||
if (nextRadioState === 'paused') {
|
||||
toast.success('Radio connection paused');
|
||||
} else {
|
||||
toast.error('Radio disconnected', {
|
||||
description: 'Check radio connection and power',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +228,28 @@ export function useRealtimeAppState({
|
||||
onContact: (contact: Contact) => {
|
||||
setContacts((prev) => mergeContactIntoList(prev, contact));
|
||||
},
|
||||
onContactResolved: (previousPublicKey: string, contact: Contact) => {
|
||||
setContacts((prev) =>
|
||||
mergeContactIntoList(
|
||||
prev.filter((candidate) => candidate.public_key !== previousPublicKey),
|
||||
contact
|
||||
)
|
||||
);
|
||||
messageCache.rename(previousPublicKey, contact.public_key);
|
||||
renameConversationState(
|
||||
getStateKey('contact', previousPublicKey),
|
||||
getStateKey('contact', contact.public_key)
|
||||
);
|
||||
|
||||
const active = activeConversationRef.current;
|
||||
if (active?.type === 'contact' && active.id === previousPublicKey) {
|
||||
setActiveConversation({
|
||||
type: 'contact',
|
||||
id: contact.public_key,
|
||||
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
|
||||
});
|
||||
}
|
||||
},
|
||||
onChannel: (channel: Channel) => {
|
||||
mergeChannelIntoList(channel);
|
||||
},
|
||||
@@ -253,6 +289,7 @@ export function useRealtimeAppState({
|
||||
fetchConfig,
|
||||
hasNewerMessagesRef,
|
||||
incrementUnread,
|
||||
renameConversationState,
|
||||
maxRawPackets,
|
||||
mergeChannelIntoList,
|
||||
pendingDeleteFallbackRef,
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
RepeaterStatusResponse,
|
||||
RepeaterNeighborsResponse,
|
||||
RepeaterAclResponse,
|
||||
RepeaterNodeInfoResponse,
|
||||
RepeaterRadioSettingsResponse,
|
||||
RepeaterAdvertIntervalsResponse,
|
||||
RepeaterOwnerInfoResponse,
|
||||
@@ -28,6 +29,7 @@ interface ConsoleEntry {
|
||||
|
||||
interface PaneData {
|
||||
status: RepeaterStatusResponse | null;
|
||||
nodeInfo: RepeaterNodeInfoResponse | null;
|
||||
neighbors: RepeaterNeighborsResponse | null;
|
||||
acl: RepeaterAclResponse | null;
|
||||
radioSettings: RepeaterRadioSettingsResponse | null;
|
||||
@@ -49,6 +51,7 @@ const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null,
|
||||
function createInitialPaneStates(): Record<PaneName, PaneState> {
|
||||
return {
|
||||
status: { ...INITIAL_PANE_STATE },
|
||||
nodeInfo: { ...INITIAL_PANE_STATE },
|
||||
neighbors: { ...INITIAL_PANE_STATE },
|
||||
acl: { ...INITIAL_PANE_STATE },
|
||||
radioSettings: { ...INITIAL_PANE_STATE },
|
||||
@@ -61,6 +64,7 @@ function createInitialPaneStates(): Record<PaneName, PaneState> {
|
||||
function createInitialPaneData(): PaneData {
|
||||
return {
|
||||
status: null,
|
||||
nodeInfo: null,
|
||||
neighbors: null,
|
||||
acl: null,
|
||||
radioSettings: null,
|
||||
@@ -79,6 +83,7 @@ function clonePaneData(data: PaneData): PaneData {
|
||||
function normalizePaneStates(paneStates: Record<PaneName, PaneState>): Record<PaneName, PaneState> {
|
||||
return {
|
||||
status: { ...paneStates.status, loading: false },
|
||||
nodeInfo: { ...paneStates.nodeInfo, loading: false },
|
||||
neighbors: { ...paneStates.neighbors, loading: false },
|
||||
acl: { ...paneStates.acl, loading: false },
|
||||
radioSettings: { ...paneStates.radioSettings, loading: false },
|
||||
@@ -136,6 +141,8 @@ function fetchPaneData(publicKey: string, pane: PaneName) {
|
||||
switch (pane) {
|
||||
case 'status':
|
||||
return api.repeaterStatus(publicKey);
|
||||
case 'nodeInfo':
|
||||
return api.repeaterNodeInfo(publicKey);
|
||||
case 'neighbors':
|
||||
return api.repeaterNeighbors(publicKey);
|
||||
case 'acl':
|
||||
@@ -187,6 +194,10 @@ export function useRepeaterDashboard(
|
||||
const [paneStates, setPaneStates] = useState<Record<PaneName, PaneState>>(
|
||||
cachedState?.paneStates ?? createInitialPaneStates
|
||||
);
|
||||
const paneDataRef = useRef<PaneData>(cachedState?.paneData ?? createInitialPaneData());
|
||||
const paneStatesRef = useRef<Record<PaneName, PaneState>>(
|
||||
cachedState?.paneStates ?? createInitialPaneStates()
|
||||
);
|
||||
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
|
||||
cachedState?.consoleHistory ?? []
|
||||
@@ -222,6 +233,14 @@ export function useRepeaterDashboard(
|
||||
});
|
||||
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
|
||||
|
||||
useEffect(() => {
|
||||
paneDataRef.current = paneData;
|
||||
}, [paneData]);
|
||||
|
||||
useEffect(() => {
|
||||
paneStatesRef.current = paneStates;
|
||||
}, [paneStates]);
|
||||
|
||||
const getPublicKey = useCallback((): string | null => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return activeConversation.id;
|
||||
@@ -262,27 +281,60 @@ export function useRepeaterDashboard(
|
||||
if (!publicKey) return;
|
||||
const conversationId = publicKey;
|
||||
|
||||
if (pane === 'neighbors') {
|
||||
const nodeInfoState = paneStatesRef.current.nodeInfo;
|
||||
const nodeInfoData = paneDataRef.current.nodeInfo;
|
||||
const needsNodeInfoPrefetch =
|
||||
nodeInfoState.error !== null ||
|
||||
(nodeInfoState.fetched_at == null && nodeInfoData == null);
|
||||
|
||||
if (needsNodeInfoPrefetch) {
|
||||
await refreshPane('nodeInfo');
|
||||
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
|
||||
}
|
||||
}
|
||||
|
||||
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
|
||||
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
|
||||
|
||||
const loadingState = {
|
||||
loading: true,
|
||||
attempt,
|
||||
error: null,
|
||||
fetched_at: paneStatesRef.current[pane].fetched_at ?? null,
|
||||
};
|
||||
paneStatesRef.current = {
|
||||
...paneStatesRef.current,
|
||||
[pane]: loadingState,
|
||||
};
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
loading: true,
|
||||
attempt,
|
||||
error: null,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
[pane]: loadingState,
|
||||
}));
|
||||
|
||||
try {
|
||||
const data = await fetchPaneData(publicKey, pane);
|
||||
if (!mountedRef.current || activeIdRef.current !== conversationId) return;
|
||||
|
||||
paneDataRef.current = {
|
||||
...paneDataRef.current,
|
||||
[pane]: data,
|
||||
};
|
||||
const successState = {
|
||||
loading: false,
|
||||
attempt,
|
||||
error: null,
|
||||
fetched_at: Date.now(),
|
||||
};
|
||||
paneStatesRef.current = {
|
||||
...paneStatesRef.current,
|
||||
[pane]: successState,
|
||||
};
|
||||
|
||||
setPaneData((prev) => ({ ...prev, [pane]: data }));
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: null, fetched_at: Date.now() },
|
||||
[pane]: successState,
|
||||
}));
|
||||
return; // Success
|
||||
} catch (err) {
|
||||
@@ -291,14 +343,19 @@ export function useRepeaterDashboard(
|
||||
const msg = err instanceof Error ? err.message : 'Request failed';
|
||||
|
||||
if (attempt === MAX_RETRIES) {
|
||||
const errorState = {
|
||||
loading: false,
|
||||
attempt,
|
||||
error: msg,
|
||||
fetched_at: paneStatesRef.current[pane].fetched_at ?? null,
|
||||
};
|
||||
paneStatesRef.current = {
|
||||
...paneStatesRef.current,
|
||||
[pane]: errorState,
|
||||
};
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: {
|
||||
loading: false,
|
||||
attempt,
|
||||
error: msg,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
[pane]: errorState,
|
||||
}));
|
||||
toast.error(`Failed to fetch ${pane}`, { description: msg });
|
||||
} else {
|
||||
@@ -314,9 +371,10 @@ export function useRepeaterDashboard(
|
||||
const loadAll = useCallback(async () => {
|
||||
const panes: PaneName[] = [
|
||||
'status',
|
||||
'nodeInfo',
|
||||
'neighbors',
|
||||
'acl',
|
||||
'radioSettings',
|
||||
'acl',
|
||||
'advertIntervals',
|
||||
'ownerInfo',
|
||||
'lppTelemetry',
|
||||
|
||||
@@ -3,6 +3,7 @@ import { api } from '../api';
|
||||
import {
|
||||
getLastMessageTimes,
|
||||
setLastMessageTime,
|
||||
renameConversationTimeKey,
|
||||
getStateKey,
|
||||
type ConversationTimes,
|
||||
} from '../utils/conversationState';
|
||||
@@ -14,7 +15,9 @@ interface UseUnreadCountsResult {
|
||||
/** Tracks which conversations have unread messages that mention the user */
|
||||
mentions: Record<string, boolean>;
|
||||
lastMessageTimes: ConversationTimes;
|
||||
unreadLastReadAts: Record<string, number | null>;
|
||||
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
|
||||
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
|
||||
markAllRead: () => void;
|
||||
trackNewMessage: (msg: Message) => void;
|
||||
refreshUnreads: () => Promise<void>;
|
||||
@@ -28,6 +31,7 @@ export function useUnreadCounts(
|
||||
const [unreadCounts, setUnreadCounts] = useState<Record<string, number>>({});
|
||||
const [mentions, setMentions] = useState<Record<string, boolean>>({});
|
||||
const [lastMessageTimes, setLastMessageTimes] = useState<ConversationTimes>(getLastMessageTimes);
|
||||
const [unreadLastReadAts, setUnreadLastReadAts] = useState<Record<string, number | null>>({});
|
||||
|
||||
// Track active conversation via ref so applyUnreads can filter without
|
||||
// destabilizing the callback chain (avoids re-creating fetchUnreads on
|
||||
@@ -60,6 +64,8 @@ export function useUnreadCounts(
|
||||
setMentions(data.mentions);
|
||||
}
|
||||
|
||||
setUnreadLastReadAts(data.last_read_ats);
|
||||
|
||||
if (Object.keys(data.last_message_times).length > 0) {
|
||||
for (const [key, ts] of Object.entries(data.last_message_times)) {
|
||||
setLastMessageTime(key, ts);
|
||||
@@ -170,12 +176,35 @@ export function useUnreadCounts(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const renameConversationState = useCallback((oldStateKey: string, newStateKey: string) => {
|
||||
if (oldStateKey === newStateKey) return;
|
||||
|
||||
setUnreadCounts((prev) => {
|
||||
if (!(oldStateKey in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
next[newStateKey] = (next[newStateKey] || 0) + next[oldStateKey];
|
||||
delete next[oldStateKey];
|
||||
return next;
|
||||
});
|
||||
|
||||
setMentions((prev) => {
|
||||
if (!(oldStateKey in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
next[newStateKey] = next[newStateKey] || next[oldStateKey];
|
||||
delete next[oldStateKey];
|
||||
return next;
|
||||
});
|
||||
|
||||
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
|
||||
}, []);
|
||||
|
||||
// Mark all conversations as read
|
||||
// Calls single bulk API endpoint to persist read state
|
||||
const markAllRead = useCallback(() => {
|
||||
// Update local state immediately
|
||||
setUnreadCounts({});
|
||||
setMentions({});
|
||||
setUnreadLastReadAts({});
|
||||
|
||||
// Persist to server with single bulk request
|
||||
api.markAllRead().catch((err) => {
|
||||
@@ -203,7 +232,9 @@ export function useUnreadCounts(
|
||||
unreadCounts,
|
||||
mentions,
|
||||
lastMessageTimes,
|
||||
unreadLastReadAts,
|
||||
incrementUnread,
|
||||
renameConversationState,
|
||||
markAllRead,
|
||||
trackNewMessage,
|
||||
refreshUnreads: fetchUnreads,
|
||||
|
||||
@@ -138,6 +138,36 @@ export function remove(id: string): void {
|
||||
cache.delete(id);
|
||||
}
|
||||
|
||||
/** Move cached conversation state to a new conversation id. */
|
||||
export function rename(oldId: string, newId: string): void {
|
||||
if (oldId === newId) return;
|
||||
const oldEntry = cache.get(oldId);
|
||||
if (!oldEntry) return;
|
||||
|
||||
const newEntry = cache.get(newId);
|
||||
if (!newEntry) {
|
||||
cache.delete(oldId);
|
||||
cache.set(newId, oldEntry);
|
||||
return;
|
||||
}
|
||||
|
||||
const mergedMessages = [...newEntry.messages];
|
||||
const seenIds = new Set(mergedMessages.map((message) => message.id));
|
||||
for (const message of oldEntry.messages) {
|
||||
if (!seenIds.has(message.id)) {
|
||||
mergedMessages.push(message);
|
||||
seenIds.add(message.id);
|
||||
}
|
||||
}
|
||||
|
||||
cache.delete(oldId);
|
||||
cache.set(newId, {
|
||||
messages: mergedMessages,
|
||||
seenContent: new Set([...newEntry.seenContent, ...oldEntry.seenContent]),
|
||||
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
|
||||
});
|
||||
}
|
||||
|
||||
/** Clear the entire cache. */
|
||||
export function clear(): void {
|
||||
cache.clear();
|
||||
|
||||
861
frontend/src/networkGraph/packetNetworkGraph.ts
Normal file
861
frontend/src/networkGraph/packetNetworkGraph.ts
Normal file
@@ -0,0 +1,861 @@
|
||||
import { PayloadType } from '@michaelhart/meshcore-decoder';
|
||||
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
type ContactAdvertPathSummary,
|
||||
type RadioConfig,
|
||||
type RawPacket,
|
||||
} from '../types';
|
||||
import {
|
||||
analyzeRepeaterTraffic,
|
||||
buildAmbiguousRepeaterLabel,
|
||||
buildAmbiguousRepeaterNodeId,
|
||||
buildLinkKey,
|
||||
compactPathSteps,
|
||||
dedupeConsecutive,
|
||||
getNodeType,
|
||||
getPacketLabel,
|
||||
parsePacket,
|
||||
recordTrafficObservation,
|
||||
type NodeType,
|
||||
type ParsedPacket,
|
||||
type RepeaterTrafficData,
|
||||
} from '../utils/visualizerUtils';
|
||||
import { normalizePacketTimestampMs } from '../components/visualizer/shared';
|
||||
|
||||
interface ContactIndex {
|
||||
byPrefix12: Map<string, Contact>;
|
||||
byName: Map<string, Contact>;
|
||||
byPrefix: Map<string, Contact[]>;
|
||||
}
|
||||
|
||||
interface AdvertPathIndex {
|
||||
byRepeater: Map<string, ContactAdvertPathSummary['paths']>;
|
||||
}
|
||||
|
||||
export interface PacketNetworkContext {
|
||||
advertPathIndex: AdvertPathIndex;
|
||||
contactIndex: ContactIndex;
|
||||
myPrefix: string | null;
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
useAdvertPathHints: boolean;
|
||||
}
|
||||
|
||||
export interface PacketNetworkVisibilityOptions {
|
||||
showAmbiguousNodes: boolean;
|
||||
showAmbiguousPaths: boolean;
|
||||
collapseLikelyKnownSiblingRepeaters: boolean;
|
||||
}
|
||||
|
||||
export interface PacketNetworkNode {
|
||||
id: string;
|
||||
name: string | null;
|
||||
type: NodeType;
|
||||
isAmbiguous: boolean;
|
||||
lastActivity: number;
|
||||
lastActivityReason?: string;
|
||||
lastSeen?: number | null;
|
||||
probableIdentity?: string | null;
|
||||
probableIdentityNodeId?: string | null;
|
||||
ambiguousNames?: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkLink {
|
||||
lastActivity: number;
|
||||
sourceId: string;
|
||||
targetId: string;
|
||||
}
|
||||
|
||||
export interface ProjectedPacketNetworkLink extends PacketNetworkLink {
|
||||
hasDirectObservation: boolean;
|
||||
hasHiddenIntermediate: boolean;
|
||||
hiddenHopLabels: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkObservation {
|
||||
activityAtMs: number;
|
||||
nodes: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkState {
|
||||
links: Map<string, PacketNetworkLink>;
|
||||
neighborIds: Map<string, Set<string>>;
|
||||
nodes: Map<string, PacketNetworkNode>;
|
||||
observations: PacketNetworkObservation[];
|
||||
trafficPatterns: Map<string, RepeaterTrafficData>;
|
||||
}
|
||||
|
||||
export interface PacketNetworkIngestResult {
|
||||
activityAtMs: number;
|
||||
canonicalPath: string[];
|
||||
label: ReturnType<typeof getPacketLabel>;
|
||||
parsed: ParsedPacket;
|
||||
}
|
||||
|
||||
export interface ProjectedPacketNetworkPath {
|
||||
dashedLinkDetails: Map<string, string[]>;
|
||||
nodes: string[];
|
||||
}
|
||||
|
||||
export interface PacketNetworkProjection {
|
||||
links: Map<string, ProjectedPacketNetworkLink>;
|
||||
nodes: Map<string, PacketNetworkNode>;
|
||||
renderedNodeIds: Set<string>;
|
||||
}
|
||||
|
||||
export function buildPacketNetworkContext({
|
||||
config,
|
||||
contacts,
|
||||
repeaterAdvertPaths,
|
||||
splitAmbiguousByTraffic,
|
||||
useAdvertPathHints,
|
||||
}: {
|
||||
config: RadioConfig | null;
|
||||
contacts: Contact[];
|
||||
repeaterAdvertPaths: ContactAdvertPathSummary[];
|
||||
splitAmbiguousByTraffic: boolean;
|
||||
useAdvertPathHints: boolean;
|
||||
}): PacketNetworkContext {
|
||||
const byPrefix12 = new Map<string, Contact>();
|
||||
const byName = new Map<string, Contact>();
|
||||
const byPrefix = new Map<string, Contact[]>();
|
||||
|
||||
for (const contact of contacts) {
|
||||
const prefix12 = contact.public_key.slice(0, 12).toLowerCase();
|
||||
byPrefix12.set(prefix12, contact);
|
||||
|
||||
if (contact.name && !byName.has(contact.name)) {
|
||||
byName.set(contact.name, contact);
|
||||
}
|
||||
|
||||
for (let len = 1; len <= 12; len++) {
|
||||
const prefix = prefix12.slice(0, len);
|
||||
const matches = byPrefix.get(prefix);
|
||||
if (matches) {
|
||||
matches.push(contact);
|
||||
} else {
|
||||
byPrefix.set(prefix, [contact]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const byRepeater = new Map<string, ContactAdvertPathSummary['paths']>();
|
||||
for (const summary of repeaterAdvertPaths) {
|
||||
const key = summary.public_key.slice(0, 12).toLowerCase();
|
||||
byRepeater.set(key, summary.paths);
|
||||
}
|
||||
|
||||
return {
|
||||
contactIndex: { byPrefix12, byName, byPrefix },
|
||||
advertPathIndex: { byRepeater },
|
||||
myPrefix: config?.public_key?.slice(0, 12).toLowerCase() || null,
|
||||
splitAmbiguousByTraffic,
|
||||
useAdvertPathHints,
|
||||
};
|
||||
}
|
||||
|
||||
export function createPacketNetworkState(selfName: string = 'Me'): PacketNetworkState {
|
||||
const now = Date.now();
|
||||
return {
|
||||
nodes: new Map([
|
||||
[
|
||||
'self',
|
||||
{
|
||||
id: 'self',
|
||||
name: selfName,
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: now,
|
||||
},
|
||||
],
|
||||
]),
|
||||
links: new Map(),
|
||||
neighborIds: new Map(),
|
||||
observations: [],
|
||||
trafficPatterns: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureSelfNode(state: PacketNetworkState, selfName: string = 'Me'): void {
|
||||
const existing = state.nodes.get('self');
|
||||
if (existing) {
|
||||
existing.name = selfName;
|
||||
return;
|
||||
}
|
||||
state.nodes.set('self', {
|
||||
id: 'self',
|
||||
name: selfName,
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export function clearPacketNetworkState(
|
||||
state: PacketNetworkState,
|
||||
{ selfName = 'Me' }: { selfName?: string } = {}
|
||||
): void {
|
||||
state.links.clear();
|
||||
state.neighborIds.clear();
|
||||
state.observations = [];
|
||||
state.trafficPatterns.clear();
|
||||
|
||||
const selfNode = state.nodes.get('self');
|
||||
state.nodes.clear();
|
||||
state.nodes.set('self', {
|
||||
id: 'self',
|
||||
name: selfName,
|
||||
type: 'self',
|
||||
isAmbiguous: false,
|
||||
lastActivity: Date.now(),
|
||||
lastActivityReason: undefined,
|
||||
lastSeen: null,
|
||||
probableIdentity: undefined,
|
||||
probableIdentityNodeId: undefined,
|
||||
ambiguousNames: undefined,
|
||||
});
|
||||
|
||||
if (selfNode?.name && selfNode.name !== selfName) {
|
||||
state.nodes.get('self')!.name = selfName;
|
||||
}
|
||||
}
|
||||
|
||||
function addOrUpdateNode(
|
||||
state: PacketNetworkState,
|
||||
{
|
||||
activityAtMs,
|
||||
ambiguousNames,
|
||||
id,
|
||||
isAmbiguous,
|
||||
lastSeen,
|
||||
name,
|
||||
probableIdentity,
|
||||
probableIdentityNodeId,
|
||||
type,
|
||||
}: {
|
||||
activityAtMs: number;
|
||||
ambiguousNames?: string[];
|
||||
id: string;
|
||||
isAmbiguous: boolean;
|
||||
lastSeen?: number | null;
|
||||
name: string | null;
|
||||
probableIdentity?: string | null;
|
||||
probableIdentityNodeId?: string | null;
|
||||
type: NodeType;
|
||||
}
|
||||
): void {
|
||||
const existing = state.nodes.get(id);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAtMs);
|
||||
if (name) existing.name = name;
|
||||
if (probableIdentity !== undefined) existing.probableIdentity = probableIdentity;
|
||||
if (probableIdentityNodeId !== undefined) {
|
||||
existing.probableIdentityNodeId = probableIdentityNodeId;
|
||||
}
|
||||
if (ambiguousNames) existing.ambiguousNames = ambiguousNames;
|
||||
if (lastSeen !== undefined) existing.lastSeen = lastSeen;
|
||||
return;
|
||||
}
|
||||
|
||||
state.nodes.set(id, {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
isAmbiguous,
|
||||
lastActivity: activityAtMs,
|
||||
probableIdentity,
|
||||
probableIdentityNodeId,
|
||||
ambiguousNames,
|
||||
lastSeen,
|
||||
});
|
||||
}
|
||||
|
||||
function addCanonicalLink(
|
||||
state: PacketNetworkState,
|
||||
sourceId: string,
|
||||
targetId: string,
|
||||
activityAtMs: number
|
||||
): void {
|
||||
const key = buildLinkKey(sourceId, targetId);
|
||||
const existing = state.links.get(key);
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, activityAtMs);
|
||||
} else {
|
||||
state.links.set(key, { sourceId, targetId, lastActivity: activityAtMs });
|
||||
}
|
||||
}
|
||||
|
||||
function upsertNeighbor(state: PacketNetworkState, sourceId: string, targetId: string): void {
|
||||
const ensureSet = (id: string) => {
|
||||
const existing = state.neighborIds.get(id);
|
||||
if (existing) return existing;
|
||||
const created = new Set<string>();
|
||||
state.neighborIds.set(id, created);
|
||||
return created;
|
||||
};
|
||||
|
||||
ensureSet(sourceId).add(targetId);
|
||||
ensureSet(targetId).add(sourceId);
|
||||
}
|
||||
|
||||
function pickLikelyRepeaterByAdvertPath(
|
||||
context: PacketNetworkContext,
|
||||
candidates: Contact[],
|
||||
nextPrefix: string | null
|
||||
): Contact | null {
|
||||
const nextHop = nextPrefix?.toLowerCase() ?? null;
|
||||
const scored = candidates
|
||||
.map((candidate) => {
|
||||
const prefix12 = candidate.public_key.slice(0, 12).toLowerCase();
|
||||
const paths = context.advertPathIndex.byRepeater.get(prefix12) ?? [];
|
||||
let matchScore = 0;
|
||||
let totalScore = 0;
|
||||
|
||||
for (const path of paths) {
|
||||
totalScore += path.heard_count;
|
||||
const pathNextHop = path.next_hop?.toLowerCase() ?? null;
|
||||
if (pathNextHop === nextHop) {
|
||||
matchScore += path.heard_count;
|
||||
}
|
||||
}
|
||||
|
||||
return { candidate, matchScore, totalScore };
|
||||
})
|
||||
.filter((entry) => entry.totalScore > 0)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
b.matchScore - a.matchScore ||
|
||||
b.totalScore - a.totalScore ||
|
||||
a.candidate.public_key.localeCompare(b.candidate.public_key)
|
||||
);
|
||||
|
||||
if (scored.length === 0) return null;
|
||||
|
||||
const top = scored[0];
|
||||
const second = scored[1] ?? null;
|
||||
|
||||
if (top.matchScore < 2) return null;
|
||||
if (second && top.matchScore < second.matchScore * 2) return null;
|
||||
|
||||
return top.candidate;
|
||||
}
|
||||
|
||||
function resolveNode(
|
||||
state: PacketNetworkState,
|
||||
context: PacketNetworkContext,
|
||||
source: { type: 'prefix' | 'pubkey' | 'name'; value: string },
|
||||
isRepeater: boolean,
|
||||
showAmbiguous: boolean,
|
||||
activityAtMs: number,
|
||||
trafficContext?: { packetSource: string | null; nextPrefix: string | null }
|
||||
): string | null {
|
||||
if (source.type === 'pubkey') {
|
||||
if (source.value.length < 12) return null;
|
||||
const nodeId = source.value.slice(0, 12).toLowerCase();
|
||||
if (context.myPrefix && nodeId === context.myPrefix) return 'self';
|
||||
const contact = context.contactIndex.byPrefix12.get(nodeId);
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: contact?.name || null,
|
||||
type: getNodeType(contact),
|
||||
isAmbiguous: false,
|
||||
lastSeen: contact?.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (source.type === 'name') {
|
||||
const contact = context.contactIndex.byName.get(source.value) ?? null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (context.myPrefix && nodeId === context.myPrefix) return 'self';
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: contact.name,
|
||||
type: getNodeType(contact),
|
||||
isAmbiguous: false,
|
||||
lastSeen: contact.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
const nodeId = `name:${source.value}`;
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: source.value,
|
||||
type: 'client',
|
||||
isAmbiguous: false,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
const lookupValue = source.value.toLowerCase();
|
||||
const matches = context.contactIndex.byPrefix.get(lookupValue) ?? [];
|
||||
const contact = matches.length === 1 ? matches[0] : null;
|
||||
if (contact) {
|
||||
const nodeId = contact.public_key.slice(0, 12).toLowerCase();
|
||||
if (context.myPrefix && nodeId === context.myPrefix) return 'self';
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: contact.name,
|
||||
type: getNodeType(contact),
|
||||
isAmbiguous: false,
|
||||
lastSeen: contact.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (!showAmbiguous) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filtered = isRepeater
|
||||
? matches.filter((candidate) => candidate.type === CONTACT_TYPE_REPEATER)
|
||||
: matches.filter((candidate) => candidate.type !== CONTACT_TYPE_REPEATER);
|
||||
|
||||
if (filtered.length === 1) {
|
||||
const only = filtered[0];
|
||||
const nodeId = only.public_key.slice(0, 12).toLowerCase();
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: only.name,
|
||||
type: getNodeType(only),
|
||||
isAmbiguous: false,
|
||||
lastSeen: only.last_seen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
if (filtered.length === 0 && !isRepeater) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const names = filtered.map((candidate) => candidate.name || candidate.public_key.slice(0, 8));
|
||||
const lastSeen = filtered.reduce(
|
||||
(max, candidate) =>
|
||||
candidate.last_seen && (!max || candidate.last_seen > max) ? candidate.last_seen : max,
|
||||
null as number | null
|
||||
);
|
||||
|
||||
let nodeId = buildAmbiguousRepeaterNodeId(lookupValue);
|
||||
let displayName = buildAmbiguousRepeaterLabel(lookupValue);
|
||||
let probableIdentity: string | null = null;
|
||||
let probableIdentityNodeId: string | null = null;
|
||||
let ambiguousNames = names.length > 0 ? names : undefined;
|
||||
|
||||
if (context.useAdvertPathHints && isRepeater && trafficContext) {
|
||||
const likely = pickLikelyRepeaterByAdvertPath(context, filtered, trafficContext.nextPrefix);
|
||||
if (likely) {
|
||||
const likelyName = likely.name || likely.public_key.slice(0, 12).toUpperCase();
|
||||
probableIdentity = likelyName;
|
||||
probableIdentityNodeId = likely.public_key.slice(0, 12).toLowerCase();
|
||||
displayName = likelyName;
|
||||
ambiguousNames = filtered
|
||||
.filter((candidate) => candidate.public_key !== likely.public_key)
|
||||
.map((candidate) => candidate.name || candidate.public_key.slice(0, 8));
|
||||
}
|
||||
}
|
||||
|
||||
if (context.splitAmbiguousByTraffic && isRepeater && trafficContext) {
|
||||
const normalizedNext = trafficContext.nextPrefix?.toLowerCase() ?? null;
|
||||
|
||||
if (trafficContext.packetSource) {
|
||||
recordTrafficObservation(
|
||||
state.trafficPatterns,
|
||||
lookupValue,
|
||||
trafficContext.packetSource,
|
||||
normalizedNext
|
||||
);
|
||||
}
|
||||
|
||||
const trafficData = state.trafficPatterns.get(lookupValue);
|
||||
if (trafficData) {
|
||||
const analysis = analyzeRepeaterTraffic(trafficData);
|
||||
if (analysis.shouldSplit && normalizedNext) {
|
||||
nodeId = buildAmbiguousRepeaterNodeId(lookupValue, normalizedNext);
|
||||
if (!probableIdentity) {
|
||||
displayName = buildAmbiguousRepeaterLabel(lookupValue, normalizedNext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addOrUpdateNode(state, {
|
||||
id: nodeId,
|
||||
name: displayName,
|
||||
type: isRepeater ? 'repeater' : 'client',
|
||||
isAmbiguous: true,
|
||||
probableIdentity,
|
||||
probableIdentityNodeId,
|
||||
ambiguousNames,
|
||||
lastSeen,
|
||||
activityAtMs,
|
||||
});
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
export function buildCanonicalPathForPacket(
|
||||
state: PacketNetworkState,
|
||||
context: PacketNetworkContext,
|
||||
parsed: ParsedPacket,
|
||||
packet: RawPacket,
|
||||
activityAtMs: number
|
||||
): string[] {
|
||||
const path: string[] = [];
|
||||
let packetSource: string | null = null;
|
||||
const isDm = parsed.payloadType === PayloadType.TextMessage;
|
||||
const isOutgoingDm =
|
||||
isDm && !!context.myPrefix && parsed.srcHash?.toLowerCase() === context.myPrefix;
|
||||
|
||||
if (parsed.payloadType === PayloadType.Advert && parsed.advertPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'pubkey', value: parsed.advertPubkey },
|
||||
false,
|
||||
false,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.AnonRequest && parsed.anonRequestPubkey) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'pubkey', value: parsed.anonRequestPubkey },
|
||||
false,
|
||||
false,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.TextMessage && parsed.srcHash) {
|
||||
if (context.myPrefix && parsed.srcHash.toLowerCase() === context.myPrefix) {
|
||||
path.push('self');
|
||||
packetSource = 'self';
|
||||
} else {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'prefix', value: parsed.srcHash },
|
||||
false,
|
||||
true,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
}
|
||||
} else if (parsed.payloadType === PayloadType.GroupText) {
|
||||
const senderName = parsed.groupTextSender || packet.decrypted_info?.sender;
|
||||
if (senderName) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'name', value: senderName },
|
||||
false,
|
||||
false,
|
||||
activityAtMs
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
packetSource = nodeId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < parsed.pathBytes.length; i++) {
|
||||
const nodeId = resolveNode(
|
||||
state,
|
||||
context,
|
||||
{ type: 'prefix', value: parsed.pathBytes[i] },
|
||||
true,
|
||||
true,
|
||||
activityAtMs,
|
||||
{ packetSource, nextPrefix: parsed.pathBytes[i + 1] || null }
|
||||
);
|
||||
if (nodeId) {
|
||||
path.push(nodeId);
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed.payloadType === PayloadType.TextMessage && parsed.dstHash) {
|
||||
if (context.myPrefix && parsed.dstHash.toLowerCase() === context.myPrefix) {
|
||||
path.push('self');
|
||||
} else if (!isOutgoingDm && path.length > 0) {
|
||||
path.push('self');
|
||||
}
|
||||
} else if (path.length > 0) {
|
||||
path.push('self');
|
||||
}
|
||||
|
||||
return dedupeConsecutive(path);
|
||||
}
|
||||
|
||||
export function ingestPacketIntoPacketNetwork(
|
||||
state: PacketNetworkState,
|
||||
context: PacketNetworkContext,
|
||||
packet: RawPacket
|
||||
): PacketNetworkIngestResult | null {
|
||||
const parsed = parsePacket(packet.data);
|
||||
if (!parsed) return null;
|
||||
|
||||
const activityAtMs = normalizePacketTimestampMs(packet.timestamp);
|
||||
const canonicalPath = buildCanonicalPathForPacket(state, context, parsed, packet, activityAtMs);
|
||||
if (canonicalPath.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = getPacketLabel(parsed.payloadType);
|
||||
for (let i = 0; i < canonicalPath.length; i++) {
|
||||
const node = state.nodes.get(canonicalPath[i]);
|
||||
if (node && node.id !== 'self') {
|
||||
node.lastActivityReason = i === 0 ? `${label} source` : `Relayed ${label}`;
|
||||
}
|
||||
}
|
||||
|
||||
state.observations.push({ nodes: canonicalPath, activityAtMs });
|
||||
|
||||
for (let i = 0; i < canonicalPath.length - 1; i++) {
|
||||
if (canonicalPath[i] !== canonicalPath[i + 1]) {
|
||||
addCanonicalLink(state, canonicalPath[i], canonicalPath[i + 1], activityAtMs);
|
||||
upsertNeighbor(state, canonicalPath[i], canonicalPath[i + 1]);
|
||||
}
|
||||
}
|
||||
|
||||
return { parsed, label, canonicalPath, activityAtMs };
|
||||
}
|
||||
|
||||
export function isPacketNetworkNodeVisible(
|
||||
node: PacketNetworkNode | undefined,
|
||||
visibility: PacketNetworkVisibilityOptions
|
||||
): boolean {
|
||||
if (!node) return false;
|
||||
if (node.id === 'self') return true;
|
||||
if (!node.isAmbiguous) return true;
|
||||
return node.type === 'repeater' ? visibility.showAmbiguousPaths : visibility.showAmbiguousNodes;
|
||||
}
|
||||
|
||||
function buildKnownSiblingRepeaterAliasMap(
|
||||
state: PacketNetworkState,
|
||||
visibility: PacketNetworkVisibilityOptions
|
||||
): Map<string, string> {
|
||||
if (!visibility.collapseLikelyKnownSiblingRepeaters || !visibility.showAmbiguousPaths) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const knownRepeaterNextHops = new Map<string, Set<string>>();
|
||||
for (const observation of state.observations) {
|
||||
for (let i = 0; i < observation.nodes.length - 1; i++) {
|
||||
const currentNode = state.nodes.get(observation.nodes[i]);
|
||||
if (!currentNode || currentNode.type !== 'repeater' || currentNode.isAmbiguous) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextNodeId = observation.nodes[i + 1];
|
||||
const existing = knownRepeaterNextHops.get(currentNode.id);
|
||||
if (existing) {
|
||||
existing.add(nextNodeId);
|
||||
} else {
|
||||
knownRepeaterNextHops.set(currentNode.id, new Set([nextNodeId]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const aliases = new Map<string, string>();
|
||||
for (const observation of state.observations) {
|
||||
for (let i = 0; i < observation.nodes.length - 1; i++) {
|
||||
const currentNodeId = observation.nodes[i];
|
||||
const currentNode = state.nodes.get(currentNodeId);
|
||||
if (
|
||||
!currentNode ||
|
||||
currentNode.type !== 'repeater' ||
|
||||
!currentNode.isAmbiguous ||
|
||||
!currentNode.probableIdentityNodeId
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const probableNode = state.nodes.get(currentNode.probableIdentityNodeId);
|
||||
if (!probableNode || probableNode.type !== 'repeater' || probableNode.isAmbiguous) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextNodeId = observation.nodes[i + 1];
|
||||
const probableNextHops = knownRepeaterNextHops.get(probableNode.id);
|
||||
if (probableNextHops?.has(nextNodeId)) {
|
||||
aliases.set(currentNodeId, probableNode.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return aliases;
|
||||
}
|
||||
|
||||
function projectCanonicalPathWithAliases(
|
||||
state: PacketNetworkState,
|
||||
canonicalPath: string[],
|
||||
visibility: PacketNetworkVisibilityOptions,
|
||||
repeaterAliases: Map<string, string>
|
||||
): ProjectedPacketNetworkPath {
|
||||
const projected = compactPathSteps(
|
||||
canonicalPath.map((nodeId, index) => {
|
||||
const node = state.nodes.get(nodeId);
|
||||
const visible = isPacketNetworkNodeVisible(node, visibility);
|
||||
return {
|
||||
nodeId: visible ? (repeaterAliases.get(nodeId) ?? nodeId) : null,
|
||||
// Only hidden repeater hops should imply a bridged dashed segment.
|
||||
// Hidden sender/recipient endpoints should disappear with their own edge.
|
||||
markHiddenLinkWhenOmitted:
|
||||
!visible &&
|
||||
!!node &&
|
||||
node.type === 'repeater' &&
|
||||
index > 0 &&
|
||||
index < canonicalPath.length - 1,
|
||||
hiddenLabel: null,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
nodes: dedupeConsecutive(projected.nodes),
|
||||
dashedLinkDetails: projected.dashedLinkDetails,
|
||||
};
|
||||
}
|
||||
|
||||
export function projectCanonicalPath(
|
||||
state: PacketNetworkState,
|
||||
canonicalPath: string[],
|
||||
visibility: PacketNetworkVisibilityOptions
|
||||
): ProjectedPacketNetworkPath {
|
||||
return projectCanonicalPathWithAliases(
|
||||
state,
|
||||
canonicalPath,
|
||||
visibility,
|
||||
buildKnownSiblingRepeaterAliasMap(state, visibility)
|
||||
);
|
||||
}
|
||||
|
||||
export function projectPacketNetwork(
|
||||
state: PacketNetworkState,
|
||||
visibility: PacketNetworkVisibilityOptions
|
||||
): PacketNetworkProjection {
|
||||
const repeaterAliases = buildKnownSiblingRepeaterAliasMap(state, visibility);
|
||||
const nodes = new Map<string, PacketNetworkNode>();
|
||||
const selfNode = state.nodes.get('self');
|
||||
if (selfNode) {
|
||||
nodes.set('self', selfNode);
|
||||
}
|
||||
|
||||
const links = new Map<string, ProjectedPacketNetworkLink>();
|
||||
|
||||
for (const observation of state.observations) {
|
||||
const projected = projectCanonicalPathWithAliases(
|
||||
state,
|
||||
observation.nodes,
|
||||
visibility,
|
||||
repeaterAliases
|
||||
);
|
||||
if (projected.nodes.length < 2) continue;
|
||||
|
||||
for (const nodeId of projected.nodes) {
|
||||
const node = state.nodes.get(nodeId);
|
||||
if (node) {
|
||||
nodes.set(nodeId, node);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < projected.nodes.length - 1; i++) {
|
||||
const sourceId = projected.nodes[i];
|
||||
const targetId = projected.nodes[i + 1];
|
||||
if (sourceId === targetId) continue;
|
||||
|
||||
const key = buildLinkKey(sourceId, targetId);
|
||||
const hiddenIntermediate = projected.dashedLinkDetails.has(key);
|
||||
const existing = links.get(key);
|
||||
|
||||
if (existing) {
|
||||
existing.lastActivity = Math.max(existing.lastActivity, observation.activityAtMs);
|
||||
if (hiddenIntermediate) {
|
||||
existing.hasHiddenIntermediate = true;
|
||||
for (const label of projected.dashedLinkDetails.get(key) ?? []) {
|
||||
if (!existing.hiddenHopLabels.includes(label)) {
|
||||
existing.hiddenHopLabels.push(label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
existing.hasDirectObservation = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
links.set(key, {
|
||||
sourceId,
|
||||
targetId,
|
||||
lastActivity: observation.activityAtMs,
|
||||
hasDirectObservation: !hiddenIntermediate,
|
||||
hasHiddenIntermediate: hiddenIntermediate,
|
||||
hiddenHopLabels: [...(projected.dashedLinkDetails.get(key) ?? [])],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes,
|
||||
links,
|
||||
renderedNodeIds: new Set(nodes.keys()),
|
||||
};
|
||||
}
|
||||
|
||||
export function prunePacketNetworkState(state: PacketNetworkState, cutoff: number): boolean {
|
||||
let pruned = false;
|
||||
|
||||
for (const [id, node] of state.nodes) {
|
||||
if (id === 'self') continue;
|
||||
if (node.lastActivity < cutoff) {
|
||||
state.nodes.delete(id);
|
||||
pruned = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!pruned) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [key, link] of state.links) {
|
||||
if (!state.nodes.has(link.sourceId) || !state.nodes.has(link.targetId)) {
|
||||
state.links.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
state.observations = state.observations.filter((observation) =>
|
||||
observation.nodes.every((nodeId) => state.nodes.has(nodeId))
|
||||
);
|
||||
|
||||
state.neighborIds.clear();
|
||||
for (const link of state.links.values()) {
|
||||
upsertNeighbor(state, link.sourceId, link.targetId);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function snapshotNeighborIds(state: PacketNetworkState): Map<string, string[]> {
|
||||
return new Map(
|
||||
Array.from(state.neighborIds.entries()).map(([nodeId, neighborIds]) => [
|
||||
nodeId,
|
||||
Array.from(neighborIds).sort(),
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -200,6 +200,25 @@ describe('fetchJson (via api methods)', () => {
|
||||
expect.objectContaining({ 'Content-Type': 'application/json' })
|
||||
);
|
||||
});
|
||||
|
||||
it('omits Content-Type on POST requests without a body', async () => {
|
||||
installMockFetch();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
contact: null,
|
||||
forward_path: { path: '', path_len: 0, path_hash_mode: 0 },
|
||||
return_path: { path: '', path_len: 0, path_hash_mode: 0 },
|
||||
}),
|
||||
});
|
||||
|
||||
await api.requestPathDiscovery('aa'.repeat(32));
|
||||
|
||||
const [, options] = mockFetch.mock.calls[0];
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.headers).not.toHaveProperty('Content-Type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HTTP methods and body', () => {
|
||||
@@ -257,6 +276,21 @@ describe('fetchJson (via api methods)', () => {
|
||||
expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' });
|
||||
});
|
||||
|
||||
it('sends POST with JSON body for mesh discovery', async () => {
|
||||
installMockFetch();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ target: 'repeaters', duration_seconds: 8, results: [] }),
|
||||
});
|
||||
|
||||
await api.discoverMesh('repeaters');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/discover');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' });
|
||||
});
|
||||
|
||||
it('sends DELETE for deleteContact', async () => {
|
||||
installMockFetch();
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
|
||||
@@ -69,6 +69,7 @@ vi.mock('../hooks', async (importOriginal) => {
|
||||
fetchOlderMessages: mocks.hookFns.fetchOlderMessages,
|
||||
fetchNewerMessages: vi.fn(async () => {}),
|
||||
jumpToBottom: vi.fn(),
|
||||
reloadCurrentConversation: vi.fn(),
|
||||
addMessageIfNew: mocks.hookFns.addMessageIfNew,
|
||||
updateMessageAck: mocks.hookFns.updateMessageAck,
|
||||
triggerReconcile: mocks.hookFns.triggerReconcile,
|
||||
@@ -77,7 +78,9 @@ vi.mock('../hooks', async (importOriginal) => {
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
lastMessageTimes: {},
|
||||
unreadLastReadAts: {},
|
||||
incrementUnread: mocks.hookFns.incrementUnread,
|
||||
renameConversationState: vi.fn(),
|
||||
markAllRead: mocks.hookFns.markAllRead,
|
||||
trackNewMessage: mocks.hookFns.trackNewMessage,
|
||||
refreshUnreads: mocks.hookFns.refreshUnreads,
|
||||
|
||||
@@ -42,6 +42,7 @@ vi.mock('../hooks', async (importOriginal) => {
|
||||
fetchOlderMessages: vi.fn(async () => {}),
|
||||
fetchNewerMessages: vi.fn(async () => {}),
|
||||
jumpToBottom: vi.fn(),
|
||||
reloadCurrentConversation: vi.fn(),
|
||||
addMessageIfNew: vi.fn(),
|
||||
updateMessageAck: vi.fn(),
|
||||
triggerReconcile: vi.fn(),
|
||||
@@ -51,7 +52,9 @@ vi.mock('../hooks', async (importOriginal) => {
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
lastMessageTimes: {},
|
||||
unreadLastReadAts: {},
|
||||
incrementUnread: vi.fn(),
|
||||
renameConversationState: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
trackNewMessage: vi.fn(),
|
||||
refreshUnreads: vi.fn(),
|
||||
@@ -132,6 +135,7 @@ vi.mock('../components/NewMessageModal', () => ({
|
||||
vi.mock('../components/SearchView', () => ({
|
||||
SearchView: ({
|
||||
onNavigateToMessage,
|
||||
prefillRequest,
|
||||
}: {
|
||||
onNavigateToMessage: (target: {
|
||||
id: number;
|
||||
@@ -139,20 +143,24 @@ vi.mock('../components/SearchView', () => ({
|
||||
conversation_key: string;
|
||||
conversation_name: string;
|
||||
}) => void;
|
||||
prefillRequest?: { query: string; nonce: number } | null;
|
||||
}) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onNavigateToMessage({
|
||||
id: 321,
|
||||
type: 'CHAN',
|
||||
conversation_key: PUBLIC_CHANNEL_KEY,
|
||||
conversation_name: 'Public',
|
||||
})
|
||||
}
|
||||
>
|
||||
Jump Result
|
||||
</button>
|
||||
<div>
|
||||
<div data-testid="search-prefill">{prefillRequest?.query ?? ''}</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onNavigateToMessage({
|
||||
id: 321,
|
||||
type: 'CHAN',
|
||||
conversation_key: PUBLIC_CHANNEL_KEY,
|
||||
conversation_name: 'Public',
|
||||
})
|
||||
}
|
||||
>
|
||||
Jump Result
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -165,7 +173,15 @@ vi.mock('../components/RawPacketList', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('../components/ContactInfoPane', () => ({
|
||||
ContactInfoPane: () => null,
|
||||
ContactInfoPane: ({
|
||||
onSearchMessagesByKey,
|
||||
}: {
|
||||
onSearchMessagesByKey?: (publicKey: string) => void;
|
||||
}) => (
|
||||
<button type="button" onClick={() => onSearchMessagesByKey?.('aa'.repeat(32))}>
|
||||
Search Contact By Key
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ChannelInfoPane', () => ({
|
||||
@@ -258,4 +274,23 @@ describe('App search jump target handling', () => {
|
||||
expect(lastCall?.[1]).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens search with a prefilled query from the contact pane', async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Search Contact By Key')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText('Search Contact By Key'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('search-prefill')).toHaveTextContent(`user:${'aa'.repeat(32)}`);
|
||||
expect(
|
||||
screen
|
||||
.getAllByTestId('active-conversation')
|
||||
.some((node) => node.textContent === 'search:search')
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,19 +30,29 @@ vi.mock('../hooks', async (importOriginal) => {
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: false,
|
||||
hasNewerMessages: false,
|
||||
loadingNewer: false,
|
||||
hasNewerMessagesRef: { current: false },
|
||||
setMessages: vi.fn(),
|
||||
fetchMessages: vi.fn(async () => {}),
|
||||
fetchOlderMessages: vi.fn(async () => {}),
|
||||
fetchNewerMessages: vi.fn(async () => {}),
|
||||
jumpToBottom: vi.fn(),
|
||||
reloadCurrentConversation: vi.fn(),
|
||||
addMessageIfNew: vi.fn(),
|
||||
updateMessageAck: vi.fn(),
|
||||
triggerReconcile: vi.fn(),
|
||||
}),
|
||||
useUnreadCounts: () => ({
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
lastMessageTimes: {},
|
||||
unreadLastReadAts: {},
|
||||
incrementUnread: vi.fn(),
|
||||
renameConversationState: vi.fn(),
|
||||
markAllRead: vi.fn(),
|
||||
trackNewMessage: vi.fn(),
|
||||
refreshUnreads: vi.fn(async () => {}),
|
||||
}),
|
||||
getMessageContentKey: () => 'content-key',
|
||||
};
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatHeader } from '../components/ChatHeader';
|
||||
import type { Channel, Conversation, Favorite } from '../types';
|
||||
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
|
||||
@@ -18,6 +18,9 @@ const baseProps = {
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
onTrace: noop,
|
||||
onPathDiscovery: vi.fn(async () => {
|
||||
throw new Error('unused');
|
||||
}) as (_: string) => Promise<PathDiscoveryResponse>,
|
||||
onToggleNotifications: noop,
|
||||
onToggleFavorite: noop,
|
||||
onSetChannelFloodScopeOverride: noop,
|
||||
@@ -94,6 +97,28 @@ describe('ChatHeader key visibility', () => {
|
||||
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the clickable conversation title as a real button inside the heading', () => {
|
||||
const pubKey = '12'.repeat(32);
|
||||
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' };
|
||||
const onOpenContactInfo = vi.fn();
|
||||
|
||||
render(
|
||||
<ChatHeader
|
||||
{...baseProps}
|
||||
conversation={conversation}
|
||||
channels={[]}
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
/>
|
||||
);
|
||||
|
||||
const heading = screen.getByRole('heading', { name: /alice/i });
|
||||
const titleButton = within(heading).getByRole('button', { name: 'View info for Alice' });
|
||||
|
||||
expect(heading).toContainElement(titleButton);
|
||||
fireEvent.click(titleButton);
|
||||
expect(onOpenContactInfo).toHaveBeenCalledWith(pubKey);
|
||||
});
|
||||
|
||||
it('copies key to clipboard when revealed key is clicked', async () => {
|
||||
const key = 'FF'.repeat(16);
|
||||
const channel = makeChannel(key, 'Priv', false);
|
||||
@@ -144,12 +169,94 @@ describe('ChatHeader key visibility', () => {
|
||||
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prompts for regional override when globe button is clicked', () => {
|
||||
it('opens path discovery modal for contacts and runs the request on demand', async () => {
|
||||
const pubKey = '21'.repeat(32);
|
||||
const contact: Contact = {
|
||||
public_key: pubKey,
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: 'AA',
|
||||
last_path_len: 1,
|
||||
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,
|
||||
};
|
||||
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' };
|
||||
const onPathDiscovery = vi.fn().mockResolvedValue({
|
||||
contact,
|
||||
forward_path: { path: 'AA', path_len: 1, path_hash_mode: 0 },
|
||||
return_path: { path: '', path_len: 0, path_hash_mode: 0 },
|
||||
} satisfies PathDiscoveryResponse);
|
||||
|
||||
render(
|
||||
<ChatHeader
|
||||
{...baseProps}
|
||||
conversation={conversation}
|
||||
channels={[]}
|
||||
contacts={[contact]}
|
||||
onPathDiscovery={onPathDiscovery}
|
||||
/>
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Path Discovery' }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Run path discovery' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onPathDiscovery).toHaveBeenCalledWith(pubKey);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an override warning in the path discovery modal when forced routing is set', async () => {
|
||||
const pubKey = '31'.repeat(32);
|
||||
const contact: Contact = {
|
||||
public_key: pubKey,
|
||||
name: 'Alice',
|
||||
type: 1,
|
||||
flags: 0,
|
||||
last_path: 'AA',
|
||||
last_path_len: 1,
|
||||
out_path_hash_mode: 0,
|
||||
route_override_path: 'BBDD',
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 0,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' };
|
||||
|
||||
render(
|
||||
<ChatHeader {...baseProps} conversation={conversation} channels={[]} contacts={[contact]} />
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Path Discovery' }));
|
||||
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
expect(screen.getByText(/current learned route: 1 hop \(AA\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/current forced route: 2 hops \(BB -> DD\)/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/forced route override is currently set/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/clearing the forced route afterward is enough/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('opens the regional override modal and applies the entered region', async () => {
|
||||
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
|
||||
@@ -162,8 +269,10 @@ describe('ChatHeader key visibility', () => {
|
||||
|
||||
fireEvent.click(screen.getByTitle('Set regional override'));
|
||||
|
||||
expect(promptSpy).toHaveBeenCalled();
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText('Region'), { target: { value: 'Esperance' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Use Esperance region for #flightless' }));
|
||||
|
||||
expect(onSetChannelFloodScopeOverride).toHaveBeenCalledWith(key, 'Esperance');
|
||||
promptSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,15 +2,15 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
||||
|
||||
import { ContactInfoPane } from '../components/ContactInfoPane';
|
||||
import type { Contact, ContactDetail } from '../types';
|
||||
import type { Contact, ContactAnalytics } from '../types';
|
||||
|
||||
const { getContactDetail } = vi.hoisted(() => ({
|
||||
getContactDetail: vi.fn(),
|
||||
const { getContactAnalytics } = vi.hoisted(() => ({
|
||||
getContactAnalytics: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: {
|
||||
getContactDetail,
|
||||
getContactAnalytics,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -54,16 +54,34 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
};
|
||||
}
|
||||
|
||||
function createDetail(contact: Contact): ContactDetail {
|
||||
function createAnalytics(
|
||||
contact: Contact | null,
|
||||
overrides: Partial<ContactAnalytics> = {}
|
||||
): ContactAnalytics {
|
||||
return {
|
||||
lookup_type: contact ? 'contact' : 'name',
|
||||
name: contact?.name ?? 'Mystery',
|
||||
contact,
|
||||
name_first_seen_at: null,
|
||||
name_history: [],
|
||||
dm_message_count: 0,
|
||||
channel_message_count: 0,
|
||||
includes_direct_messages: Boolean(contact),
|
||||
most_active_rooms: [],
|
||||
advert_paths: [],
|
||||
advert_frequency: null,
|
||||
nearest_repeaters: [],
|
||||
hourly_activity: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_start: 1_700_000_000 + index * 3600,
|
||||
last_24h_count: 0,
|
||||
last_week_average: 0,
|
||||
all_time_average: 0,
|
||||
})),
|
||||
weekly_activity: Array.from({ length: 26 }, (_, index) => ({
|
||||
bucket_start: 1_700_000_000 + index * 604800,
|
||||
message_count: 0,
|
||||
})),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,20 +92,24 @@ const baseProps = {
|
||||
config: null,
|
||||
favorites: [],
|
||||
onToggleFavorite: () => {},
|
||||
onSearchMessagesByKey: vi.fn(),
|
||||
onSearchMessagesByName: vi.fn(),
|
||||
};
|
||||
|
||||
describe('ContactInfoPane', () => {
|
||||
beforeEach(() => {
|
||||
getContactDetail.mockReset();
|
||||
getContactAnalytics.mockReset();
|
||||
baseProps.onSearchMessagesByKey = vi.fn();
|
||||
baseProps.onSearchMessagesByName = vi.fn();
|
||||
});
|
||||
|
||||
it('shows hop width when contact has a stored path hash mode', async () => {
|
||||
const contact = createContact({ out_path_hash_mode: 1 });
|
||||
getContactDetail.mockResolvedValue(createDetail(contact));
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
|
||||
await screen.findByText('Alice');
|
||||
await screen.findByText(contact.public_key);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Hop Width')).toBeInTheDocument();
|
||||
expect(screen.getByText('2-byte IDs')).toBeInTheDocument();
|
||||
@@ -96,7 +118,7 @@ describe('ContactInfoPane', () => {
|
||||
|
||||
it('does not show hop width for flood-routed contacts', async () => {
|
||||
const contact = createContact({ last_path_len: -1, out_path_hash_mode: -1 });
|
||||
getContactDetail.mockResolvedValue(createDetail(contact));
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
|
||||
@@ -115,7 +137,7 @@ describe('ContactInfoPane', () => {
|
||||
route_override_len: 2,
|
||||
route_override_hash_mode: 1,
|
||||
});
|
||||
getContactDetail.mockResolvedValue(createDetail(contact));
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
|
||||
@@ -127,4 +149,102 @@ describe('ContactInfoPane', () => {
|
||||
expect(screen.getByText('1 hop')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('loads name-only channel stats and most active rooms', async () => {
|
||||
getContactAnalytics.mockResolvedValue(
|
||||
createAnalytics(null, {
|
||||
lookup_type: 'name',
|
||||
name: 'Mystery',
|
||||
name_first_seen_at: 1_699_999_000,
|
||||
channel_message_count: 4,
|
||||
most_active_rooms: [
|
||||
{
|
||||
channel_key: 'ab'.repeat(16),
|
||||
channel_name: '#ops',
|
||||
message_count: 3,
|
||||
},
|
||||
],
|
||||
hourly_activity: Array.from({ length: 24 }, (_, index) => ({
|
||||
bucket_start: 1_700_000_000 + index * 3600,
|
||||
last_24h_count: index === 23 ? 2 : 0,
|
||||
last_week_average: index === 23 ? 1.5 : 0,
|
||||
all_time_average: index === 23 ? 1.2 : 0,
|
||||
})),
|
||||
weekly_activity: Array.from({ length: 26 }, (_, index) => ({
|
||||
bucket_start: 1_700_000_000 + index * 604800,
|
||||
message_count: index === 25 ? 4 : 0,
|
||||
})),
|
||||
})
|
||||
);
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey="name:Mystery" fromChannel />);
|
||||
|
||||
await screen.findByText('Mystery');
|
||||
await waitFor(() => {
|
||||
expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' });
|
||||
expect(screen.getByText('Messages')).toBeInTheDocument();
|
||||
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
|
||||
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
|
||||
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
|
||||
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
|
||||
expect(screen.getByText('Most Active Rooms')).toBeInTheDocument();
|
||||
expect(screen.getByText('#ops')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(/Name-only analytics include channel messages only/i)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/same sender name/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Search user's messages by name")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('fires the name search callback from the name-only pane', async () => {
|
||||
getContactAnalytics.mockResolvedValue(
|
||||
createAnalytics(null, { lookup_type: 'name', name: 'Mystery' })
|
||||
);
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey="name:Mystery" fromChannel />);
|
||||
|
||||
const button = await screen.findByRole('button', { name: "Search user's messages by name" });
|
||||
button.click();
|
||||
|
||||
expect(baseProps.onSearchMessagesByName).toHaveBeenCalledWith('Mystery');
|
||||
});
|
||||
|
||||
it('shows alias note in the channel attribution warning for keyed contacts', async () => {
|
||||
const contact = createContact();
|
||||
getContactAnalytics.mockResolvedValue(
|
||||
createAnalytics(contact, {
|
||||
name_history: [
|
||||
{ name: 'Alice', first_seen: 1000, last_seen: 2000 },
|
||||
{ name: 'AliceOld', first_seen: 900, last_seen: 999 },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} fromChannel />);
|
||||
|
||||
await screen.findByText(contact.public_key);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: 'Also Known As' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(
|
||||
/may include messages previously attributed under names shown in Also Known As/i
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("Search user's messages by key")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('fires the key search callback from the keyed pane', async () => {
|
||||
const contact = createContact();
|
||||
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
|
||||
|
||||
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
|
||||
|
||||
const button = await screen.findByRole('button', { name: "Search user's messages by key" });
|
||||
button.click();
|
||||
|
||||
expect(baseProps.onSearchMessagesByKey).toHaveBeenCalledWith(contact.public_key);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,12 +13,16 @@ import type {
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
messageList: vi.fn(() => <div data-testid="message-list" />),
|
||||
}));
|
||||
|
||||
vi.mock('../components/ChatHeader', () => ({
|
||||
ChatHeader: () => <div data-testid="chat-header" />,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageList', () => ({
|
||||
MessageList: () => <div data-testid="message-list" />,
|
||||
MessageList: mocks.messageList,
|
||||
}));
|
||||
|
||||
vi.mock('../components/MessageInput', () => ({
|
||||
@@ -107,11 +111,15 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
hasOlderMessages: false,
|
||||
unreadMarkerLastReadAt: undefined,
|
||||
targetMessageId: null,
|
||||
hasNewerMessages: false,
|
||||
loadingNewer: false,
|
||||
messageInputRef: { current: null },
|
||||
onTrace: vi.fn(async () => {}),
|
||||
onPathDiscovery: vi.fn(async () => {
|
||||
throw new Error('unused');
|
||||
}),
|
||||
onToggleFavorite: vi.fn(async () => {}),
|
||||
onDeleteContact: vi.fn(async () => {}),
|
||||
onDeleteChannel: vi.fn(async () => {}),
|
||||
@@ -124,6 +132,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
onTargetReached: vi.fn(),
|
||||
onLoadNewer: vi.fn(async () => {}),
|
||||
onJumpToBottom: vi.fn(),
|
||||
onDismissUnreadMarker: vi.fn(),
|
||||
onSendMessage: vi.fn(async () => {}),
|
||||
onToggleNotifications: vi.fn(),
|
||||
...overrides,
|
||||
@@ -133,6 +142,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
describe('ConversationPane', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.messageList.mockImplementation(() => <div data-testid="message-list" />);
|
||||
});
|
||||
|
||||
it('renders the empty state when no conversation is active', () => {
|
||||
@@ -196,4 +206,122 @@ describe('ConversationPane', () => {
|
||||
expect(screen.getByTestId('message-input')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('passes unread marker props to MessageList only for channel conversations', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'channel',
|
||||
id: channel.key,
|
||||
name: channel.name,
|
||||
},
|
||||
unreadMarkerLastReadAt: 1700000000,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.messageList).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const channelCallArgs = mocks.messageList.mock.calls[
|
||||
mocks.messageList.mock.calls.length - 1
|
||||
] as unknown[] | undefined;
|
||||
const channelCall = channelCallArgs?.[0] as Record<string, unknown> | undefined;
|
||||
expect(channelCall?.unreadMarkerLastReadAt).toBe(1700000000);
|
||||
expect(channelCall?.onDismissUnreadMarker).toBeTypeOf('function');
|
||||
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'contact',
|
||||
id: 'cc'.repeat(32),
|
||||
name: 'Alice',
|
||||
},
|
||||
unreadMarkerLastReadAt: 1700000000,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
const contactCallArgs = mocks.messageList.mock.calls[
|
||||
mocks.messageList.mock.calls.length - 1
|
||||
] as unknown[] | undefined;
|
||||
const contactCall = contactCallArgs?.[0] as Record<string, unknown> | undefined;
|
||||
expect(contactCall?.unreadMarkerLastReadAt).toBeUndefined();
|
||||
expect(contactCall?.onDismissUnreadMarker).toBeUndefined();
|
||||
});
|
||||
|
||||
it('shows a warning but keeps input for full-key contacts without an advert', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'contact',
|
||||
id: 'cc'.repeat(32),
|
||||
name: '[unknown sender]',
|
||||
},
|
||||
contacts: [
|
||||
{
|
||||
public_key: 'cc'.repeat(32),
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/A full identity profile is not yet available/i)).toBeInTheDocument();
|
||||
expect(screen.getByTestId('message-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides input and shows a read-only warning for prefix-only contacts', async () => {
|
||||
render(
|
||||
<ConversationPane
|
||||
{...createProps({
|
||||
activeConversation: {
|
||||
type: 'contact',
|
||||
id: 'abc123def456',
|
||||
name: 'abc123def456',
|
||||
},
|
||||
contacts: [
|
||||
{
|
||||
public_key: 'abc123def456',
|
||||
name: null,
|
||||
type: 0,
|
||||
flags: 0,
|
||||
last_path: null,
|
||||
last_path_len: -1,
|
||||
out_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
},
|
||||
],
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/This conversation is read-only/i)).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -90,6 +90,7 @@ describe('SettingsFanoutSection', () => {
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Amazon SQS' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -309,6 +310,23 @@ describe('SettingsFanoutSection', () => {
|
||||
expect(mockedApi.createFanoutConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('new SQS draft shows queue url fields and sensible defaults', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: 'Amazon SQS' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('← Back to list')).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('Name')).toHaveValue('Amazon SQS #1');
|
||||
expect(screen.getByLabelText('Queue URL')).toBeInTheDocument();
|
||||
expect(screen.getByText('Forward raw packets')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('backing out of a new draft does not create an integration', async () => {
|
||||
renderSection();
|
||||
await waitFor(() => {
|
||||
@@ -672,6 +690,30 @@ describe('SettingsFanoutSection', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('sqs list shows queue url summary', async () => {
|
||||
const config: FanoutConfig = {
|
||||
id: 'sqs-1',
|
||||
type: 'sqs',
|
||||
name: 'Queue Feed',
|
||||
enabled: true,
|
||||
config: {
|
||||
queue_url: 'https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events',
|
||||
region_name: 'us-east-1',
|
||||
},
|
||||
scope: { messages: 'all', raw_packets: 'none' },
|
||||
sort_order: 0,
|
||||
created_at: 1000,
|
||||
};
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
|
||||
renderSection();
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
|
||||
).toBeInTheDocument()
|
||||
);
|
||||
});
|
||||
|
||||
it('groups integrations by type and sorts entries alphabetically within each group', async () => {
|
||||
mockedApi.getFanoutConfigs.mockResolvedValue([
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user