Compare commits

..

1 Commits

Author SHA1 Message Date
Jack Kingsman 8843836bdf Use nginx docke 2026-03-31 21:37:44 -07:00
199 changed files with 3682 additions and 10212 deletions
-73
View File
@@ -1,73 +0,0 @@
name: Publish AUR package
# Pushes the contents of pkg/aur/ to the remoteterm-meshcore AUR repository
# whenever a GitHub release is published. Can also be triggered manually for
# testing or out-of-band republishes.
#
# Required secrets:
# AUR_SSH_PRIVATE_KEY Private SSH key registered with the AUR maintainer
# account that owns the remoteterm-meshcore package.
# AUR_COMMIT_EMAIL Email used for the AUR git commit identity.
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: 'Version to publish (no v prefix, e.g. 3.9.1)'
required: true
concurrency:
# Serialize publishes so a fast back-to-back release sequence cannot race
# two pushes against the AUR repo. The later one wins by virtue of being
# the final state.
group: publish-aur
cancel-in-progress: false
jobs:
publish-aur:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Resolve version from event
id: version
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
VERSION="${{ inputs.version }}"
else
VERSION="${{ github.event.release.tag_name }}"
fi
VERSION="${VERSION#v}"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Publishing AUR package for version $VERSION"
- name: Stamp pkgver into PKGBUILD
run: |
sed -i "s/^pkgver=.*/pkgver=${{ steps.version.outputs.version }}/" pkg/aur/PKGBUILD
sed -i "s/^pkgrel=.*/pkgrel=1/" pkg/aur/PKGBUILD
- name: Publish to AUR
uses: KSXGitHub/github-actions-deploy-aur@v4.1.2
with:
pkgname: remoteterm-meshcore
pkgbuild: pkg/aur/PKGBUILD
assets: |
pkg/aur/remoteterm-meshcore.install
pkg/aur/remoteterm-meshcore.service
pkg/aur/remoteterm-meshcore.sysusers
pkg/aur/remoteterm-meshcore.tmpfiles
pkg/aur/remoteterm.env
commit_username: jackkingsman
commit_email: ${{ secrets.AUR_COMMIT_EMAIL }}
ssh_private_key: ${{ secrets.AUR_SSH_PRIVATE_KEY }}
commit_message: "Update to ${{ steps.version.outputs.version }}"
# Recompute sha256sums from the live release tarball + the bundled
# service/env files. The committed PKGBUILD has SKIP placeholders.
updpkgsums: true
# Validate the PKGBUILD parses and sources download, but skip the
# actual build (which would run uv sync + npm install for several
# minutes of CI time on every release).
test: true
test_flags: --clean --cleanbuild --nodeps --nobuild
+1
View File
@@ -30,3 +30,4 @@ references/
docker-compose.yml docker-compose.yml
docker-compose.yaml docker-compose.yaml
.docker-certs/ .docker-certs/
.docker-nginx/
+4 -9
View File
@@ -209,7 +209,6 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ │ ├── MapView.tsx # Leaflet map showing node locations │ │ ├── MapView.tsx # Leaflet map showing node locations
│ │ └── ... │ │ └── ...
│ └── vite.config.ts │ └── vite.config.ts
├── pkg/aur/ # AUR package files (PKGBUILD, systemd service, env, install hooks)
├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive) ├── scripts/ # Quality / release helpers (listing below is representative, not exhaustive)
│ ├── build/ │ ├── build/
│ │ ├── collect_licenses.sh # Gather third-party license attributions │ │ ├── collect_licenses.sh # Gather third-party license attributions
@@ -217,8 +216,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ ├── quality/ │ ├── quality/
│ │ ├── all_quality.sh # Repo-standard autofix + validate gate │ │ ├── all_quality.sh # Repo-standard autofix + validate gate
│ │ ├── e2e.sh # End-to-end test runner │ │ ├── e2e.sh # End-to-end test runner
│ │ ── extended_quality.sh # Quality gate plus e2e and Docker matrix │ │ ── extended_quality.sh # Quality gate plus e2e and Docker matrix
│ │ └── test_aur_package.sh # Build + install AUR package in Arch Docker containers
│ └── setup/ │ └── setup/
│ ├── fetch_prebuilt_frontend.py # Download release frontend fallback │ ├── fetch_prebuilt_frontend.py # Download release frontend fallback
│ └── install_service.sh # Install/configure Linux systemd service │ └── install_service.sh # Install/configure Linux systemd service
@@ -329,7 +327,6 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload | | 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/repeaters/advert-paths` | List recent unique advert paths for all contacts |
| POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) | | POST | `/api/contacts` | Create contact (optionally trigger historical DM decrypt) |
| POST | `/api/contacts/bulk-delete` | Delete multiple contacts |
| DELETE | `/api/contacts/{public_key}` | Delete contact | | DELETE | `/api/contacts/{public_key}` | Delete contact |
| POST | `/api/contacts/{public_key}/mark-read` | Mark contact conversation as read | | 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}/command` | Send CLI command to repeater |
@@ -349,13 +346,12 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry | | POST | `/api/contacts/{public_key}/room/status` | Fetch room-server status telemetry |
| POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data | | POST | `/api/contacts/{public_key}/room/lpp-telemetry` | Fetch room-server CayenneLPP sensor data |
| POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries | | POST | `/api/contacts/{public_key}/room/acl` | Fetch room-server ACL entries |
| GET | `/api/channels` | List channels | | GET | `/api/channels` | List channels |
| GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) | | GET | `/api/channels/{key}/detail` | Comprehensive channel profile (message stats, top senders) |
| POST | `/api/channels` | Create channel | | POST | `/api/channels` | Create channel |
| POST | `/api/channels/bulk-hashtag` | Create multiple hashtag channels |
| DELETE | `/api/channels/{key}` | Delete channel | | DELETE | `/api/channels/{key}` | Delete channel |
| POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override | | POST | `/api/channels/{key}/flood-scope-override` | Set or clear a per-channel regional flood-scope override |
| POST | `/api/channels/{key}/path-hash-mode-override` | Set or clear a per-channel path hash mode override |
| POST | `/api/channels/{key}/mark-read` | Mark channel as read | | POST | `/api/channels/{key}/mark-read` | Mark channel as read |
| GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) | | GET | `/api/messages` | List with filters (`q`, `after`/`after_id` for forward pagination) |
| GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) | | GET | `/api/messages/around/{id}` | Get messages around a specific message (for jump-to-message) |
@@ -404,7 +400,6 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
- Hashtag channels: `SHA256("#name")[:16]` converted to hex - Hashtag channels: `SHA256("#name")[:16]` converted to hex
- Custom channels: User-provided or generated - Custom channels: User-provided or generated
- Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting. - Channels may also persist `flood_scope_override`; when set, channel sends temporarily switch the radio flood scope to that value for the duration of the send, then restore the global app setting.
- Channels may persist `path_hash_mode_override` (0/1/2); when set, channel sends temporarily switch the radio path hash mode for the duration of the send, then restore the radio default.
### Message Types ### Message Types
@@ -468,7 +463,7 @@ mc.subscribe(EventType.ACK, handler)
|----------|---------|-------------| |----------|---------|-------------|
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio | | `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) | | `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | `5000` | TCP port (used with `MESHCORE_TCP_HOST`) | | `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) | | `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code | | `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate | | `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
@@ -480,7 +475,7 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on radio audit task from hourly checks to aggressive 10-second polling; the audit checks both missed message drift and channel-slot cache drift | | `MESHCORE_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 | | `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`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, `blocked_names`, and `discovery_blocked_types`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`. **Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. The backend still carries `sidebar_sort_order` for compatibility and migration, but the current frontend sidebar stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in localStorage rather than treating it as one shared server-backed preference. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`. If the radio's channel slots appear unstable or another client is mutating them underneath this app, operators can force the old always-reconfigure send path with `MESHCORE_FORCE_CHANNEL_SLOT_RECONFIGURE=true`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send. 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.
+334 -418
View File
@@ -1,256 +1,172 @@
## [3.10.0] - 2026-04-10
* Feature: Add Arch AUR package
* Feature: 72hr packet density view in statistics
* Feature: Add warnings for event loop selection for MQTT on Windows startup
* Bufix: Bump Apprise to 1.9.9 to fix Matrix bug
* Misc: More memory-conscious on recent contact fetch
* Misc: Fix statistics pane e2e test
## [3.9.0] - 2026-04-06
* Feature: Add hop counts to hop-width selection options
* Feature: Show cached repeater telemetry inline in settings
* Feature: Retain recent traces and make them click-to-re-run
* Feature: Autofocus channel/DM textbox on desktop
* Feature: Favorites on the radio are now imported as favorites
* Bugfix: Be clearer on issue identification for missing HTTPS context in channel finder
* Bugfix: Don't use sender timestamp for message sequence display
* Bugfix: Function on subdomains happily
* Misc: Be gentler, room s/cracker/finder/
* Misc: Test and frontend correctness & test fixes
* Misc: Don't repeat clock sync failure logs
* Misc: Make warning in readme clearer about taking over the radio
* Misc: Improve readme phrasings
* Misc: Better y-axis selection for battery read-out
* Misc: Provide clearer warning on docker setup without docker installed
* Misc: Default visualizer stale pruning to on/5 minutes
* Misc: Migrate favorites to better storage pattern
* Misc: Provide dumper script for API + WS interfaces for prep for HA integration
## [3.8.0] - 2026-04-03
* Feature: Per-channel hop width override
* Feature: Intervalized repeater telemetry collection
* Feature: Auto-resend option for byte-perfect resends on no repeater echo
* Feature: Attach RSSI/SNR to received packets
* Feature: Add motion packet display to map
* Feature: Map dark mode
* Bugfix: Make DB indices more useful around capitalization
* Misc: Bump required Python to 3.11
* Misc: Performance, documentation, and test improvements
* Misc: More yields during long radio operations
* Misc: Dead code & crufty test removal
* Misc: Remove all but stub frontend favorites migration for very very old versions
## [3.7.1] - 2026-04-02
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
## [3.7.0] - 2026-04-02
* Feature: Repeater battery tracking
* Feature: Repeater info pane just like contacts
* Feature: Make repeaters blockable
* Feature: Add new-node advert blocking
* Feature: Add bulk deletion interface
* Feature: Bulk room add on alt+click of new channel button
* Feature: More info in debug endpoint
* Bugfix: Be more conservative around radio load limits and don't exceed radio-reported capacity
* Misc: Default auto-DM decrypt to true
* Misc: Reorganize some settings panes
* Misc: Enable FK pragma
* Misc: Various performance and correctness fixes
* Misc: Correct TCP default port
## [3.6.7] - 2026-03-31
* Misc: Remove armv7 (for now)
## [3.6.6] - 2026-03-31
* Misc: Please I'm begging for the build scripts to be working now
## [3.6.5] - 2026-03-31
* Bugfix: Maybe fix problem with publish script
## [3.6.4] - 2026-03-31
* Feature: Clarify New Channel/Contact button
* Bugfix: Rename "Best RSSI" to "Strongest Neighbor"
* Bugfix: Improve layout of Trace pane
* Misc: Docker setup improvements
## [3.6.3] - 2026-03-30 ## [3.6.3] - 2026-03-30
* Feature: Add multi-byte trace Feature: Add multi-byte trace
* Feature: Show node name on discovered node if we know it Feature: Show node name on discovered node if we know it
* Feature: Add docker installation script Feature: Add docker installation script
* Feature: Add historical noise floor to stats Feature: Add historical noise floor to stats
* Feature: Add trace tool Feature: Add trace tool
* Bugfix: 100x performance on statistics endpoint with indices and better queries Bugfix: 100x performance on statistics endpoint with indices and better queries
* Misc: Performance and correctness improvements for backend-of-the-frontend Misc: Performance and correctness improvements for backend-of-the-frontend
* Misc: Reorganize scripts Misc: Reorganize scripts
## [3.6.2] - 2026-03-29 ## [3.6.2] - 2026-03-29
* Feature: Be more flexible about timing and volume of full contact offload Feature: Be more flexible about timing and volume of full contact offload
* Feature: Improve room server and repeater ops to be much more clearer about auth status Feature: Improve room server and repeater ops to be much more clearer about auth status
* Feature: Show last error status on integrations Feature: Show last error status on integrations
* Feature: Push multi-platform docker builds Feature: Push multi-platform docker builds
* Bugfix: Fix advert interval time unit display Bugfix: Fix advert interval time unit display
* Bugfix: Don't cast RSSI/SNR to string for community MQTT Bugfix: Don't cast RSSI/SNR to string for community MQTT
* Bugfix: Map uploader follows redirect Bugfix: Map uploader follows redirect
* Misc: Thin out unnecessary cruft in unreads endpoint Misc: Thin out unnecessary cruft in unreads endpoint
* Misc: Fall back gracefully if linked to an unknown contact Misc: Fall back gracefully if linked to an unknown contact
## [3.6.1] - 2026-03-26 ## [3.6.1] - 2026-03-26
* Feature: MeshCore Map integration Feature: MeshCore Map integration
* Feature: Add warning screen about bots Feature: Add warning screen about bots
* Feature: Favicon reflects unread message state Feature: Favicon reflects unread message state
* Feature: Show hop map in larger modal Feature: Show hop map in larger modal
* Feature: Add prebuilt frontend install script Feature: Add prebuilt frontend install script
* Feature: Add clean service installer script Feature: Add clean service installer script
* Feature: Swipe in to show menu Feature: Swipe in to show menu
* Bugfix: Invalid backend API path serves error, not fallback index Bugfix: Invalid backend API path serves error, not fallback index
* Bugfix: Fix some spacing/page height issues Bugfix: Fix some spacing/page height issues
* Misc: Misc. bugfixes and performance and test improvements Misc: Misc. bugfixes and performance and test improvements
## [3.6.0] - 2026-03-22 ## [3.6.0] - 2026-03-22
* Feature: Add incoming-packet analytics Feature: Add incoming-packet analytics
* Feature: BYOPacket for analysis Feature: BYOPacket for analysis
* Feature: Add room activity to stats view Feature: Add room activity to stats view
* Bugfix: Handle Heltec v3 serial noise Bugfix: Handle Heltec v3 serial noise
* Misc: Swap repeaters and room servers for better ordering Misc: Swap repeaters and room servers for better ordering
## [3.5.0] - 2026-03-19 ## [3.5.0] - 2026-03-19
* Feature: Add room server alpha support Feature: Add room server alpha support
* Feature: Add option to force-reset node clock when it's too far ahead Feature: Add option to force-reset node clock when it's too far ahead
* Feature: DMs auto-retry before resorting to flood Feature: DMs auto-retry before resorting to flood
* Feature: Add impulse zero-hop advert Feature: Add impulse zero-hop advert
* Feature: Utilize PATH packets to correctly source a contact's route Feature: Utilize PATH packets to correctly source a contact's route
* Feature: Metrics view on raw packet pane Feature: Metrics view on raw packet pane
* Feature: Metric, Imperial, and Smoots are now selectable for distance display Feature: Metric, Imperial, and Smoots are now selectable for distance display
* Feature: Allow favorites to be sorted Feature: Allow favorites to be sorted
* Feature: Add multi-ack support Feature: Add multi-ack support
* Feature: Password-remember checkbox on repeaters + room servers Feature: Password-remember checkbox on repeaters + room servers
* Bugfix: Serialize radio disconnect in a lock Bugfix: Serialize radio disconnect in a lock
* Bugfix: Fix contact bar layout issues Bugfix: Fix contact bar layout issues
* Bugfix: Fix sidebar ordering for contacts by advert recency Bugfix: Fix sidebar ordering for contacts by advert recency
* Bugfix: Fix version reporting in community MQTT Bugfix: Fix version reporting in community MQTT
* Bugfix: Fix Apprise duplicate names Bugfix: Fix Apprise duplicate names
* Bugfix: Be better about identity resolution in the stats pane Bugfix: Be better about identity resolution in the stats pane
* Misc: Docs, test, and performance enhancements Misc: Docs, test, and performance enhancements
* Misc: Don't prompt "Are you sure" when leaving an unedited interation Misc: Don't prompt "Are you sure" when leaving an unedited interation
* Misc: Log node time on startup Misc: Log node time on startup
* Misc: Improve community MQTT error bubble-up Misc: Improve community MQTT error bubble-up
* Misc: Unread DMs always have a red unread counter Misc: Unread DMs always have a red unread counter
* Misc: Improve information in the debug view to show DB status Misc: Improve information in the debug view to show DB status
## [3.4.1] - 2026-03-16 ## [3.4.1] - 2026-03-16
* Bugfix: Improve handling of version information on prebuilt bundles Bugfix: Improve handling of version information on prebuilt bundles
* Bugfix: Improve frontend usability on disconnected radio Bugfix: Improve frontend usability on disconnected radio
* Misc: Docs and readme updates Misc: Docs and readme updates
* Misc: Overhaul DM ingest and frontend state handling Misc: Overhaul DM ingest and frontend state handling
## [3.4.0] - 2026-03-16 ## [3.4.0] - 2026-03-16
* Feature: Add radio model and stats display Feature: Add radio model and stats display
* Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts Feature: Add prebuilt frontends, then deleted that and moved to prebuilt release artifacts
* Bugfix: Misc. frontend performance and correctness fixes Bugfix: Misc. frontend performance and correctness fixes
* Bugfix: Fix same-second same-content DM send collition Bugfix: Fix same-second same-content DM send collition
* Bugfix: Discard clearly-wrong GPS data Bugfix: Discard clearly-wrong GPS data
* Bugfix: Prevent repeater clock skew drift on page nav Bugfix: Prevent repeater clock skew drift on page nav
* Misc: Use repeater's advertised location if we haven't loaded one from repeater admin Misc: Use repeater's advertised location if we haven't loaded one from repeater admin
* Misc: Don't permit invalid fanout configs to be saved ever` Misc: Don't permit invalid fanout configs to be saved ever`
## [3.3.0] - 2026-03-13 ## [3.3.0] - 2026-03-13
* Feature: Use dashed lines to show collapsed ambiguous router results Feature: Use dashed lines to show collapsed ambiguous router results
* Feature: Jump to unred Feature: Jump to unred
* Feature: Local channel management to prevent need to reload channel every time Feature: Local channel management to prevent need to reload channel every time
* Feature: Debug endpoint Feature: Debug endpoint
* Feature: Force-singleton channel management Feature: Force-singleton channel management
* Feature: Local node discovery Feature: Local node discovery
* Feature: Node routing discovery Feature: Node routing discovery
* Bugfix: Don't tell users to us npm ci Bugfix: Don't tell users to us npm ci
* Bugfix: Fallback polling dm message persistence Bugfix: Fallback polling dm message persistence
* Bugfix: All native-JS inputs are now modals Bugfix: All native-JS inputs are now modals
* Bugfix: Same-second send collision resolution Bugfix: Same-second send collision resolution
* Bugfix: Proper browser updates on resend 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 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 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: Visualizer data layer overhaul for future map work
* Misc: Parallelize docker tests Misc: Parallelize docker tests
## [3.2.0] - 2026-03-12 ## [3.2.0] - 2026-03-12
* Feature: Improve ambiguous-sender DM handling and visibility Feature: Improve ambiguous-sender DM handling and visibility
* Feature: Allow for toggling of node GPS broadcast Feature: Allow for toggling of node GPS broadcast
* Feature: Add path width to bot and move example to full kwargs Feature: Add path width to bot and move example to full kwargs
* Feature: Improve node map color contrast Feature: Improve node map color contrast
* Bugfix: More accurate tracking of contact data Bugfix: More accurate tracking of contact data
* Bugfix: Misc. frontend performance and bugfixes Bugfix: Misc. frontend performance and bugfixes
* Misc: Clearer warnings on user-key linkage Misc: Clearer warnings on user-key linkage
* Misc: Documentation improvements Misc: Documentation improvements
## [3.1.1] - 2026-03-11 ## [3.1.1] - 2026-03-11
* Feature: Add basic auth Feature: Add basic auth
* Feature: SQS fanout Feature: SQS fanout
* Feature: Enrich contact info pane Feature: Enrich contact info pane
* Feature: Search operators for node and channel Feature: Search operators for node and channel
* Feature: Pause radio connection attempts from Radio settings Feature: Pause radio connection attempts from Radio settings
* Feature: New themes! What a great use of time! Feature: New themes! What a great use of time!
* Feature: Github workflows runs for validation Feature: Github workflows runs for validation
* Bugfix: More consistent log format with times Bugfix: More consistent log format with times
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.1.0] - 2026-03-11 ## [3.1.0] - 2026-03-11
* Feature: Add basic auth Feature: Add basic auth
* Feature: SQS fanout Feature: SQS fanout
* Feature: Enrich contact info pane Feature: Enrich contact info pane
* Feature: Search operators for node and channel Feature: Search operators for node and channel
* Feature: Pause radio connection attempts from Radio settings Feature: Pause radio connection attempts from Radio settings
* Feature: New themes! What a great use of time! Feature: New themes! What a great use of time!
* Feature: Github workflows runs for validation Feature: Github workflows runs for validation
* Bugfix: More consistent log format with times Bugfix: More consistent log format with times
* Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.0.0] - 2026-03-10 ## [3.0.0] - 2026-03-10
* Feature: Custom regions per-channel Feature: Custom regions per-channel
* Feature: Add custom contact pathing Feature: Add custom contact pathing
* Feature: Corrupt packets are more clear that they're corrupt 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: 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: More consistent icons
* Feature: Add per-channel local notifications Feature: Add per-channel local notifications
* Feature: New themes Feature: New themes
* Feature: Massive codebase refactor and overhaul Feature: Massive codebase refactor and overhaul
* Bugfix: Fix packet parsing for trace packets Bugfix: Fix packet parsing for trace packets
* Bugfix: Refetch channels on reconnect Bugfix: Refetch channels on reconnect
* Bugfix: Load All on repeater pane on mobile doesn't etend into lower text Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
* Bugfix: Timestamps in logs Bugfix: Timestamps in logs
* Bugfix: Correct wrong clock sync command Bugfix: Correct wrong clock sync command
* Misc: Improve bot error bubble up Misc: Improve bot error bubble up
* Misc: Update to non-lib-included meshcore-decoder version Misc: Update to non-lib-included meshcore-decoder version
* Misc: Revise refactors to be more LLM friendly Misc: Revise refactors to be more LLM friendly
* Misc: Fix script executability Misc: Fix script executability
* Misc: Better logging format with timestamp Misc: Better logging format with timestamp
* Misc: Repeater advert buttons separate flood and one-hop Misc: Repeater advert buttons separate flood and one-hop
* Misc: Preserve repeater pane on navigation away Misc: Preserve repeater pane on navigation away
* Misc: Clearer iconography and coloring for status bar buttons Misc: Clearer iconography and coloring for status bar buttons
* Misc: Search bar to top bar Misc: Search bar to top bar
## [2.7.9] - 2026-03-08 ## [2.7.9] - 2026-03-08
* Bugfix: Don't obscure new integration dropdown on session boundary Bugfix: Don't obscure new integration dropdown on session boundary
## [2.7.8] - 2026-03-08 ## [2.7.8] - 2026-03-08
@@ -258,287 +174,287 @@
## [2.7.8] - 2026-03-08 ## [2.7.8] - 2026-03-08
* Bugfix: Improve frontend asset resolution and fixup the build/push script Bugfix: Improve frontend asset resolution and fixup the build/push script
## [2.7.1] - 2026-03-08 ## [2.7.1] - 2026-03-08
* Bugfix: Fix historical DM packet length passing Bugfix: Fix historical DM packet length passing
* Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package Misc: Follow better inclusion patterns for the patched meshcore-decoder and just publish the dang package
* Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page Misc: Patch a bewildering browser quirk that cause large raw packet lists to extend past the bottom of the page
## [2.7.0] - 2026-03-08 ## [2.7.0] - 2026-03-08
* Feature: Multibyte path support Feature: Multibyte path support
* Feature: Add multibyte statistics to statistics pane Feature: Add multibyte statistics to statistics pane
* Feature: Add path bittage to contact info pane Feature: Add path bittage to contact info pane
* Feature: Put tools in a collapsible Feature: Put tools in a collapsible
## [2.6.1] - 2026-03-08 ## [2.6.1] - 2026-03-08
* Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry Misc: Fix busted docker builds; we don't have a 2.6.0 build sorry
## [2.6.0] - 2026-03-08 ## [2.6.0] - 2026-03-08
* Feature: A11y improvements Feature: A11y improvements
* Feature: New themes Feature: New themes
* Feature: Backfill channel sender identity when available Feature: Backfill channel sender identity when available
* Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise Feature: Modular fanout bus, including Webhooks, more customizable community MQTT, and Apprise
* Bugfix: Unreads now respect blocklist Bugfix: Unreads now respect blocklist
* Bugfix: Unreads can't accumulate on an open thread Bugfix: Unreads can't accumulate on an open thread
* Bugfix: Channel name in broadcasts Bugfix: Channel name in broadcasts
* Bugfix: Add missing httpx dependency Bugfix: Add missing httpx dependency
* Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting Bugfix: Improvements to radio startup frontend-blocking time and radio status reporting
* Misc: Improved button signage for app movement Misc: Improved button signage for app movement
* Misc: Test, performance, and documentation improvements Misc: Test, performance, and documentation improvements
## [2.5.0] - 2026-03-05 ## [2.5.0] - 2026-03-05
* Feature: Far better accessibility across the app (with far to go) Feature: Far better accessibility across the app (with far to go)
* Feature: Add community MQTT stats reporting, and improve over a few commits Feature: Add community MQTT stats reporting, and improve over a few commits
* Feature: Color schemes and misc. settings reorg Feature: Color schemes and misc. settings reorg
* Feature: Add why-active to filtered nodes Feature: Add why-active to filtered nodes
* Feature: Add channel and contact info box Feature: Add channel and contact info box
* Feature: Add contact blocking Feature: Add contact blocking
* Feature: Add potential repeater path map display Feature: Add potential repeater path map display
* Feature: Add flood scoping/regions Feature: Add flood scoping/regions
* Feature: Global message search Feature: Global message search
* Feature: Fully safe bot disable Feature: Fully safe bot disable
* Feature: Add default #remoteterm channel (lol sorry I had to) Feature: Add default #remoteterm channel (lol sorry I had to)
* Feature: Custom recency pruning in visualizer Feature: Custom recency pruning in visualizer
* Bugfix: Be more cautious around null byte stripping Bugfix: Be more cautious around null byte stripping
* Bugfix: Clear channel-add interface on not-add-another Bugfix: Clear channel-add interface on not-add-another
* Bugfix: Add status/name/MQTT LWT Bugfix: Add status/name/MQTT LWT
* Bugfix: Channel deletion propagates over WS Bugfix: Channel deletion propagates over WS
* Bugfix: Show map location for all nodes on link, not 7-day-limited Bugfix: Show map location for all nodes on link, not 7-day-limited
* Bugfix: Hide private key channel keys by default Bugfix: Hide private key channel keys by default
* Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything Misc: Logline to show if cleanup loop on non-sync'd meshcore radio links fixes anything
* Misc: Doc, changelog, and test improvements Misc: Doc, changelog, and test improvements
* Misc: Add, and remove, package lock (sorry Windows users) Misc: Add, and remove, package lock (sorry Windows users)
* Misc: Don't show mark all as read if not necessary Misc: Don't show mark all as read if not necessary
* Misc: Fix stale closures and misc. frontend perf/correctness improvements Misc: Fix stale closures and misc. frontend perf/correctness improvements
* Misc: Add Windows startup notes Misc: Add Windows startup notes
* Misc: E2E expansion + improvement Misc: E2E expansion + improvement
* Misc: Move around visualizer settings Misc: Move around visualizer settings
## [2.4.0] - 2026-03-02 ## [2.4.0] - 2026-03-02
* Feature: Add community MQTT reporting (e.g. LetsMesh.net) Feature: Add community MQTT reporting (e.g. LetsMesh.net)
* Misc: Build scripts and library attribution Misc: Build scripts and library attribution
* Misc: Add sign of life to E2E tests Misc: Add sign of life to E2E tests
## [2.3.0] - 2026-03-01 ## [2.3.0] - 2026-03-01
* Feature: Click path description to reset to flood Feature: Click path description to reset to flood
* Feature: Add MQTT publishing Feature: Add MQTT publishing
* Feature: Visualizer remembers settings Feature: Visualizer remembers settings
* Bugfix: Fix prefetch usage Bugfix: Fix prefetch usage
* Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages Bugfix: Fixed an issue where busy channels can result in double-display of incoming messages
* Misc: Drop py3.12 requirement Misc: Drop py3.12 requirement
* Misc: Performance, documentation, test, and file structure optimizations Misc: Performance, documentation, test, and file structure optimizations
* Misc: Add arrows between route nodes on contact info Misc: Add arrows between route nodes on contact info
* Misc: Show repeater path/type in title bar Misc: Show repeater path/type in title bar
## [2.2.0] - 2026-02-28 ## [2.2.0] - 2026-02-28
* Feature: Track advert paths and use to disambiguate repeater identity in visualizer Feature: Track advert paths and use to disambiguate repeater identity in visualizer
* Feature: Contact info pane Feature: Contact info pane
* Feature: Overhaul repeater interface Feature: Overhaul repeater interface
* Bugfix: Misc. frontend rendering + perf improvements Bugfix: Misc. frontend rendering + perf improvements
* Bugfix: Better behavior around radio locking and autofetch/polling Bugfix: Better behavior around radio locking and autofetch/polling
* Bugfix: Clear channel name field on new-channel modal tab change Bugfix: Clear channel name field on new-channel modal tab change
* Bugfix: Repeater inforbox can scroll Bugfix: Repeater inforbox can scroll
* Bugfix: Better handling of historical DM encrypts Bugfix: Better handling of historical DM encrypts
* Bugfix: Handle errors if returned in prefetch phase Bugfix: Handle errors if returned in prefetch phase
* Misc: Radio event response failure is logged/surfaced better Misc: Radio event response failure is logged/surfaced better
* Misc: Improve test coverage and remove dead code Misc: Improve test coverage and remove dead code
* Misc: Documentation and errata improvements Misc: Documentation and errata improvements
* Misc: Database storage optimization Misc: Database storage optimization
## [2.1.0] - 2026-02-23 ## [2.1.0] - 2026-02-23
* Feature: Add ability to remember last-used channel on load Feature: Add ability to remember last-used channel on load
* Feature: Add `docker compose` support (thanks @suymur !) Feature: Add `docker compose` support (thanks @suymur !)
* Feature: Better-aligned favicon (lol) Feature: Better-aligned favicon (lol)
* Bugfix: Disable autocomplete on message field Bugfix: Disable autocomplete on message field
* Bugfix: Legacy hash restoration on page load Bugfix: Legacy hash restoration on page load
* Bugfix: Align resend buttons in pathing modal Bugfix: Align resend buttons in pathing modal
* Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host Bugfix: Update README.md (briefly), then docker-compose.yaml, to reflect correct docker image host
* Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !) Bugfix: Correct settings pane scroll lock on zoom (thanks @yellowcooln !)
* Bugfix: Improved repeater comms on busy meshes Bugfix: Improved repeater comms on busy meshes
* Bugfix: Drain before autofetch from radio Bugfix: Drain before autofetch from radio
* Bugfix: Fix, or document exceptions to, sub-second resolution message failure Bugfix: Fix, or document exceptions to, sub-second resolution message failure
* Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status Bugfix: Improved handling of radio connection, disconnection, and connection-aliveness-status
* Bugfix: Force server-side keystore update when radio key changes Bugfix: Force server-side keystore update when radio key changes
* Bugfix: Reduce WS churn for incoming message handling Bugfix: Reduce WS churn for incoming message handling
* Bugfix: Fix content type signalling for irrelevant endpoints Bugfix: Fix content type signalling for irrelevant endpoints
* Bugfix: Handle stuck post-connect failure state Bugfix: Handle stuck post-connect failure state
* Misc: Documentation & version parsing improvements Misc: Documentation & version parsing improvements
* Misc: Hide char counter on mobile for short messages Misc: Hide char counter on mobile for short messages
* Misc: Typo fixes in docs and settings Misc: Typo fixes in docs and settings
* Misc: Add dynamic webmanifest for hosts that can support it Misc: Add dynamic webmanifest for hosts that can support it
* Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets Misc: Improve DB size via dropping unnecessary uniqs, indices, vacuum, and offering ability to drop historical matches packets
* Misc: Drop weird rounded bounding box for settings Misc: Drop weird rounded bounding box for settings
* Misc: Move resend buttons to pathing modal Misc: Move resend buttons to pathing modal
* Misc: Improved comments around database ownership on *nix systems Misc: Improved comments around database ownership on *nix systems
* Misc: Move to SSoT for message dedupe on frontend Misc: Move to SSoT for message dedupe on frontend
* Misc: Move DM ack clearing to standard poll, and increase hold time between polling Misc: Move DM ack clearing to standard poll, and increase hold time between polling
* Misc: Holistic testing overhaul Misc: Holistic testing overhaul
## [2.0.1] - 2026-02-16 ## [2.0.1] - 2026-02-16
* Bugfix: Fix missing trigger condition on statistics pane expansion on mobile Bugfix: Fix missing trigger condition on statistics pane expansion on mobile
## [2.0.0] - 2026-02-16 ## [2.0.0] - 2026-02-16
* Feature: Frontend UX + log overhaul Feature: Frontend UX + log overhaul
* Bugfix: Use contact object from DB for broadcast rather than handrolling Bugfix: Use contact object from DB for broadcast rather than handrolling
* Bugfix: Fix out of order path WS messages overwriting each other Bugfix: Fix out of order path WS messages overwriting each other
* Bugfix: Make broadcast timestamp match fallback logic used in storage code Bugfix: Make broadcast timestamp match fallback logic used in storage code
* Bugfix: Fix repeater command timestamp selection logic Bugfix: Fix repeater command timestamp selection logic
* Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet) Bugfix: Use actual pubkey matching for path update, and don't action serial path update events (use RX packet)
* Bugfix: Add missing radio operation locks in a few spots Bugfix: Add missing radio operation locks in a few spots
* Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!) Bugfix: Fix dedupe for frontend raw packet delivery (mesh visualizer much more active now!)
* Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh) Bugfix: Less aggressive dedupe for advert packets (we don't care about the payload, we care about the path, duh)
* Misc: Visualizer layout refinement & option labels Misc: Visualizer layout refinement & option labels
## [1.10.0] - 2026-02-16 ## [1.10.0] - 2026-02-16
* Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !) Feature: Collapsible sidebar sections with per-section unread badge (thanks @rgregg !)
* Feature: 3D mesh visualizer Feature: 3D mesh visualizer
* Feature: Statistics pane Feature: Statistics pane
* Feature: Support incoming/outgoing indication for bot invocations Feature: Support incoming/outgoing indication for bot invocations
* Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!) Feature: Quick byte-perfect message resend if you got unlucky with repeats (thanks @rgregg -- we had a parallel implementation but I appreciate your work!)
* Bugfix: Fix top padding out outgoing message Bugfix: Fix top padding out outgoing message
* Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes) Bugfix: Frontend performance, appearance, and Lighthouse improvements (prefetches, form labelling, contrast, channel/roomlist changes)
* Bugfix: Multiple-sent messages had path appearing delays until rerender Bugfix: Multiple-sent messages had path appearing delays until rerender
* Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender Bugfix: Fix ack/message race condition that caused dropped ack displays until rerender
* Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations Misc: Dedupe contacts/rooms by key and not name to prevent name collisions creating unreachable conversations
* Misc: s/stopped/idle/ for room finder Misc: s/stopped/idle/ for room finder
## [1.9.3] - 2026-02-12 ## [1.9.3] - 2026-02-12
* Feature: Upgrade the room finder to support two-word rooms Feature: Upgrade the room finder to support two-word rooms
## [1.9.2] - 2026-02-12 ## [1.9.2] - 2026-02-12
* Feature: Options dialog sucks less Feature: Options dialog sucks less
* Bugfix: Move tests to isolated memory DB Bugfix: Move tests to isolated memory DB
* Bugfix: Mention case sensitivity Bugfix: Mention case sensitivity
* Bugfix: Stale header retention on settings page view Bugfix: Stale header retention on settings page view
* Bugfix: Non-isolated path writing Bugfix: Non-isolated path writing
* Bugfix: Nullable contact fields are now passed as real nulls Bugfix: Nullable contact fields are now passed as real nulls
* Bugfix: Look at all fields on message reconcile, not just text Bugfix: Look at all fields on message reconcile, not just text
* Bugfix: Make mark-all-as-read atomic Bugfix: Make mark-all-as-read atomic
* Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API Misc: Purge unused WS handlers from back when we did chans and contacts over WS, not API
* Misc: Massive test and AGENTS.md overhauls and additions Misc: Massive test and AGENTS.md overhauls and additions
## [1.9.1] - 2026-02-10 ## [1.9.1] - 2026-02-10
* Feature: Contacts and channels use keys, not names Feature: Contacts and channels use keys, not names
* Bugfix: Fix falsy casting of 0 in lat lon and timing data Bugfix: Fix falsy casting of 0 in lat lon and timing data
* Bugfix: Show message length in bytes, not chars Bugfix: Show message length in bytes, not chars
* Bugfix: Fix phantom unread badges on focused convos Bugfix: Fix phantom unread badges on focused convos
* Misc: Bot invocation to async Misc: Bot invocation to async
* Misc: Use full key, not prefix, where we can Misc: Use full key, not prefix, where we can
## [1.9.0] - 2026-02-10 ## [1.9.0] - 2026-02-10
* Feature: Favorited contacts are preferentially loaded onto the radio Feature: Favorited contacts are preferentially loaded onto the radio
* Feature: Add recent-message caching for fast switching Feature: Add recent-message caching for fast switching
* Feature: Add echo paths modal when echo-heard checkbox is clicked Feature: Add echo paths modal when echo-heard checkbox is clicked
* Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out Feature: Add experimental byte-perfect double-send for bad RF environments to try to punch the message out
Frontend: Better styling on echo + message path display Frontend: Better styling on echo + message path display
* Bugfix: Prevent frontend static file serving path traversal vuln Bugfix: Prevent frontend static file serving path traversal vuln
* Bugfix: Safer prefix-claiming for DMs we don't have the key for Bugfix: Safer prefix-claiming for DMs we don't have the key for
* Bugfix: Prevent injection from mentions with special characters Bugfix: Prevent injection from mentions with special characters
* Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly Bugfix: Fix repeaters comms showing in wrong channel when repeater operations are in flight and the channel is changed quickly
* Bugfix: App can boot and test without a frontend dir Bugfix: App can boot and test without a frontend dir
* Misc: Improve and consistent-ify (?) backend radio operation lock management Misc: Improve and consistent-ify (?) backend radio operation lock management
* Misc: Frontend performance and safety enhancements Misc: Frontend performance and safety enhancements
* Misc: Move builds to non-bundled; usage requires building the Frontend Misc: Move builds to non-bundled; usage requires building the Frontend
* Misc: Update tests and agent docs Misc: Update tests and agent docs
## [1.8.0] - 2026-02-07 ## [1.8.0] - 2026-02-07
* Feature: Single hop ping Feature: Single hop ping
* Feature: PWA viewport fixes(thanks @rgregg) Feature: PWA viewport fixes(thanks @rgregg)
Feature (?): No frontend distribution; build it yourself ;P Feature (?): No frontend distribution; build it yourself ;P
* Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot) Bugfix: Fix channel message send race condition (concurrent sends could corrupt shared radio slot)
* Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention) Bugfix: Fix TOCTOU race in radio reconnect (duplicate connections under contention)
* Bugfix: Better guarding around reconnection Bugfix: Better guarding around reconnection
* Bugfix: Duplicate websocket connection fixes Bugfix: Duplicate websocket connection fixes
* Bugfix: Settings tab error cleanliness on tab swap Bugfix: Settings tab error cleanliness on tab swap
* Bugfix: Fix path traversal vuln Bugfix: Fix path traversal vuln
UI: Swap visualizer legend ordering (yay prettier) UI: Swap visualizer legend ordering (yay prettier)
* Misc: Perf and locking improvements Misc: Perf and locking improvements
* Misc: Always flood advertisements Misc: Always flood advertisements
* Misc: Better packet dupe handling Misc: Better packet dupe handling
* Misc: Dead code cleanup, test improvements Misc: Dead code cleanup, test improvements
## [1.7.1] - 2026-02-03 ## [1.7.1] - 2026-02-03
* Feature: Clickable hyperlinks Feature: Clickable hyperlinks
* Bugfix: More consistent public key normalization Bugfix: More consistent public key normalization
* Bugfix: Use more reliable cursor paging Bugfix: Use more reliable cursor paging
* Bugfix: Fix null timestamp dedupe failure Bugfix: Fix null timestamp dedupe failure
* Bugfix: More consistent prefix-based message claiming on key receipt Bugfix: More consistent prefix-based message claiming on key receipt
* Misc: Bot can respond to its own messages Misc: Bot can respond to its own messages
* Misc: Additional tests Misc: Additional tests
* Misc: Remove unneeded message dedupe logic Misc: Remove unneeded message dedupe logic
* Misc: Resync settings after radio settings mutation Misc: Resync settings after radio settings mutation
## [1.7.0] - 2026-01-27 ## [1.7.0] - 2026-01-27
* Feature: Multi-bot functionality Feature: Multi-bot functionality
* Bugfix: Adjust bot code editor display and add line numbers Bugfix: Adjust bot code editor display and add line numbers
* Bugfix: Fix clock filtering and contact lookup behavior bugs Bugfix: Fix clock filtering and contact lookup behavior bugs
* Bugfix: Fix repeater message duplication issue Bugfix: Fix repeater message duplication issue
* Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming) Bugfix: Correct outbound message timestamp assignment (affecting outgoing messages seen as incoming)
UI: Move advertise button to identity tab UI: Move advertise button to identity tab
* Misc: Clarify fallback functionality for missing private key export in logs Misc: Clarify fallback functionality for missing private key export in logs
## [1.6.0] - 2026-01-26 ## [1.6.0] - 2026-01-26
* Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes Feature: Visualizer: extract public key from AnonReq, add heuristic repeater disambiguation, add reset button, draggable nodes
* Feature: Customizable advertising interval Feature: Customizable advertising interval
* Feature: In-app bot setup Feature: In-app bot setup
* Bugfix: Force contact onto radio before DM send Bugfix: Force contact onto radio before DM send
* Misc: Remove unused code Misc: Remove unused code
## [1.5.0] - 2026-01-19 ## [1.5.0] - 2026-01-19
* Feature: Network visualizer Feature: Network visualizer
## [1.4.1] - 2026-01-19 ## [1.4.1] - 2026-01-19
* Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default) Feature: Add option to attempt historical DM decrypt on new-contact advertisement (disabled by default)
* Feature: Server-side preference management for favorites, read status, etc. Feature: Server-side preference management for favorites, read status, etc.
UI: More compact hop labelling UI: More compact hop labelling
* Bugfix: Misc. race conditions and websocket handling Bugfix: Misc. race conditions and websocket handling
* Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update Bugfix: Reduce fetching cadence by loading all contact data at start to prevent fetches on advertise-driven update
## [1.4.0] - 2026-01-18 ## [1.4.0] - 2026-01-18
UI: Improve button layout for room searcher UI: Improve button layout for room searcher
UI: Improve favicon coloring UI: Improve favicon coloring
UI: Improve status bar button layout on small screen UI: Improve status bar button layout on small screen
* Feature: Show multi-path hop display with distance estimates Feature: Show multi-path hop display with distance estimates
* Feature: Search rooms and contacts by key, not just name Feature: Search rooms and contacts by key, not just name
* Bugfix: Historical DM decryption now works as expected Bugfix: Historical DM decryption now works as expected
* Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization Bugfix: Don't double-set active conversation after addition; wait for backend room name normalization
## [1.3.1] - 2026-01-17 ## [1.3.1] - 2026-01-17
UI: Rework restart handling UI: Rework restart handling
* Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking Feature: Add `dutycyle_start` command to logged-in repeater session to start five min duty cycle tracking
Bug: Improve error message rendering from server-side errors Bug: Improve error message rendering from server-side errors
UI: Remove octothorpe from channel listing UI: Remove octothorpe from channel listing
## [1.3.0] - 2026-01-17 ## [1.3.0] - 2026-01-17
* Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level Feature: Rework database schema to drop unnecessary columns and dedupe payloads at the DB level
* Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate. Feature: Massive frontend settings overhaul. It ain't gorgeous but it's easier to navigate.
* Feature: Drop repeater login wait time; vestigial from debugging a different issue Feature: Drop repeater login wait time; vestigial from debugging a different issue
## [1.2.1] - 2026-01-17 ## [1.2.1] - 2026-01-17
@@ -546,27 +462,27 @@ Update: Update meshcore-hashtag-cracker to include sender-identification correct
## [1.2.0] - 2026-01-16 ## [1.2.0] - 2026-01-16
* Feature: Add favorites Feature: Add favorites
## [1.1.0] - 2026-01-14 ## [1.1.0] - 2026-01-14
* Bugfix: Use actual pathing data from advertisements, not just always flood (oops) Bugfix: Use actual pathing data from advertisements, not just always flood (oops)
* Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms) Bugfix: Autosync radio clock periodically to prevent drift (would show up most commonly as issues with repeater comms)
## [1.0.3] - 2026-01-13 ## [1.0.3] - 2026-01-13
* Bugfix: Add missing test management packages Bugfix: Add missing test management packages
* Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable! Improvement: Drop unnecessary repeater timeouts, and retain timeout for login only -- repeater ops are faster AND more reliable!
## [1.0.2] - 2026-01-13 ## [1.0.2] - 2026-01-13
* Improvement: Add delays between router ops to prevent traffic collisions Improvement: Add delays between router ops to prevent traffic collisions
## [1.0.1] - 2026-01-13 ## [1.0.1] - 2026-01-13
* Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal Bugixes: Cleaner DB shutdown, radio reconnect contention, packet dedupe garbage removal
## [1.0.0] - 2026-01-13 ## [1.0.0] - 2026-01-13
* Initial full release! Initial full release!
+2 -33
View File
@@ -56,7 +56,7 @@ SOFTWARE.
</details> </details>
### apprise (1.9.9) — BSD-2-Clause ### apprise (1.9.7) — BSD-2-Clause
<details> <details>
<summary>Full license text</summary> <summary>Full license text</summary>
@@ -64,7 +64,7 @@ SOFTWARE.
``` ```
BSD 2-Clause License BSD 2-Clause License
Copyright (c) 2026, Chris Caron <lead2gold@gmail.com> Copyright (c) 2025, Chris Caron <lead2gold@gmail.com>
All rights reserved. All rights reserved.
Redistribution and use in source and binary forms, with or without Redistribution and use in source and binary forms, with or without
@@ -1188,37 +1188,6 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
</details> </details>
### cmdk (1.1.1) — MIT
<details>
<summary>Full license text</summary>
```
MIT License
Copyright (c) 2022 Paco Coursey
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
</details>
### d3-force (3.0.0) — ISC ### d3-force (3.0.0) — ISC
<details> <details>
+17 -42
View File
@@ -12,14 +12,21 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* Use the more recent 1.14 firmwares which support multibyte pathing * 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! * Visualize the mesh as a map or node set, view repeater stats, and more!
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
**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. **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.
![Screenshot of the application's web interface](app_screenshot.png) ![Screenshot of the application's web interface](app_screenshot.png)
> [!WARNING] ## Start Here
> RemoteTerm does *full* management of the radio, meaning that once a radio is connected to RemoteTerm, all contacts/channels will be imported and offloaded to RemoteTerm and the contacts actually synced to the device will be governed by RemoteTerm. This means that RemoteTerm can be a poor fit for users who are looking to swap radios in and out, maintaining radio state (favorites, channels, etc.) irrespective of app usage.
Most users should choose one of these paths:
1. Clone and build from source.
2. Download the prebuilt release zip if you are on a resource-constrained system and do not want to build the frontend locally.
3. Use Docker if that better matches how you deploy.
For advanced setup, troubleshooting, HTTPS, systemd service setup, and remediation environment variables, see [README_ADVANCED.md](README_ADVANCED.md).
If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
## Requirements ## Requirements
@@ -64,7 +71,7 @@ usbipd attach --wsl --busid 3-8
``` ```
</details> </details>
## Install Path 1: Clone And Build ## Path 1: Clone And Build
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.** **This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
@@ -82,10 +89,10 @@ Access the app at http://localhost:8000.
Source checkouts expect a normal frontend build in `frontend/dist`. Source checkouts expect a normal frontend build in `frontend/dist`.
> [!TIP] > [!NOTE]
> Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`. > Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
> [!NOTE] > [!TIP]
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure: > On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
> >
> ```bash > ```bash
@@ -94,7 +101,7 @@ Source checkouts expect a normal frontend build in `frontend/dist`.
> >
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md). > For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
## Install Path 2: Docker ## Path 2: Docker
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable. > **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
@@ -161,29 +168,6 @@ To stop:
sudo docker compose down sudo docker compose down
``` ```
## Install Path 3: Arch Linux (AUR)
A [`remoteterm-meshcore`](https://aur.archlinux.org/packages/remoteterm-meshcore) package is available in the AUR. Install it with an AUR helper or build it manually:
```bash
# with an AUR helper
yay -S remoteterm-meshcore
# or manually
git clone https://aur.archlinux.org/remoteterm-meshcore.git
cd remoteterm-meshcore
makepkg -si
```
Configure your radio connection, then start the service:
```bash
sudo vi /etc/remoteterm-meshcore/remoteterm.env
sudo systemctl enable --now remoteterm-meshcore
```
Access the app at http://localhost:8000.
## Standard Environment Variables ## Standard Environment Variables
Only one transport may be active at a time. If multiple are set, the server will refuse to start. Only one transport may be active at a time. If multiple are set, the server will refuse to start.
@@ -193,7 +177,7 @@ Only one transport may be active at a time. If multiple are set, the server will
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path | | `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate | | `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) | | `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | 5000 | TCP port | | `MESHCORE_TCP_PORT` | 4000 | TCP port |
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) | | `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) | | `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` | | `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
@@ -209,7 +193,7 @@ Common launch patterns:
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# TCP # TCP
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=5000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# BLE # BLE
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
@@ -222,15 +206,6 @@ $env:MESHCORE_SERIAL_PORT="COM8" # or your COM port
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
``` ```
> [!WARNING]
> **Windows + MQTT fanout:** Python's default Windows event loop (ProactorEventLoop) is not compatible with the MQTT libraries used by RemoteTerm. If you configure any MQTT integration, add `--loop none` to your uvicorn command:
>
> ```powershell
> uv run uvicorn app.main:app --host 0.0.0.0 --port 8000 --loop none
> ```
>
> If you forget, the app will start normally but MQTT connections will fail and you'll see a toast in the UI with this same guidance.
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate. If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP. Also note that the app's permissive CORS policy is a deliberate trusted-network tradeoff, so cross-origin browser JavaScript is not a reliable way to use that Basic Auth gate.
## Where To Go Next ## Where To Go Next
-9
View File
@@ -19,15 +19,6 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental. `__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
## Sub-Path Reverse Proxy
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
**Requirements:**
- The proxy must ensure the sub-path URL has a **trailing slash**. If a user visits `/meshcore` (no slash), relative paths break. Most proxies handle this automatically; for Nginx, a `location /meshcore/ { ... }` block (note the trailing slash) does the right thing.
- For correct PWA install behavior, the proxy should forward `X-Forwarded-Prefix` (set to the sub-path, e.g. `/meshcore`) so the web manifest generates correct `start_url` and `scope` values. `X-Forwarded-Proto` and `X-Forwarded-Host` are also respected for origin resolution.
## HTTPS ## HTTPS
WebGPU channel-finding requires a secure context when you are not on `localhost`. WebGPU channel-finding requires a secure context when you are not on `localhost`.
+5 -5
View File
@@ -190,7 +190,6 @@ app/
- `GET /contacts/analytics` — unified keyed-or-name analytics payload - `GET /contacts/analytics` — unified keyed-or-name analytics payload
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts - `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
- `POST /contacts` - `POST /contacts`
- `POST /contacts/bulk-delete`
- `DELETE /contacts/{public_key}` - `DELETE /contacts/{public_key}`
- `POST /contacts/{public_key}/mark-read` - `POST /contacts/{public_key}/mark-read`
- `POST /contacts/{public_key}/command` - `POST /contacts/{public_key}/command`
@@ -215,10 +214,8 @@ app/
- `GET /channels` - `GET /channels`
- `GET /channels/{key}/detail` - `GET /channels/{key}/detail`
- `POST /channels` - `POST /channels`
- `POST /channels/bulk-hashtag`
- `DELETE /channels/{key}` - `DELETE /channels/{key}`
- `POST /channels/{key}/flood-scope-override` - `POST /channels/{key}/flood-scope-override`
- `POST /channels/{key}/path-hash-mode-override`
- `POST /channels/{key}/mark-read` - `POST /channels/{key}/mark-read`
### Messages ### Messages
@@ -281,7 +278,7 @@ Client sends `"ping"` text; server replies `{"type":"pong"}`.
Main tables: Main tables:
- `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing) - `contacts` (includes `first_seen` for contact age tracking and `direct_path_hash_mode` / `route_override_*` for DM routing)
- `channels` - `channels`
Includes optional `flood_scope_override` for channel-specific regional sends and optional `path_hash_mode_override` for per-channel path hop width. Includes optional `flood_scope_override` for channel-specific regional sends.
- `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution) - `messages` (includes `sender_name`, `sender_key` for per-contact channel message attribution)
- `raw_packets` - `raw_packets`
- `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count) - `contact_advert_paths` (recent unique advertisement paths per contact, keyed by contact + path bytes + hop count)
@@ -303,12 +300,15 @@ Repository writes should prefer typed models such as `ContactUpsert` over ad hoc
- `max_radio_contacts` - `max_radio_contacts`
- `favorites` - `favorites`
- `auto_decrypt_dm_on_advert` - `auto_decrypt_dm_on_advert`
- `sidebar_sort_order`
- `last_message_times` - `last_message_times`
- `preferences_migrated` - `preferences_migrated`
- `advert_interval` - `advert_interval`
- `last_advert_time` - `last_advert_time`
- `flood_scope` - `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types` - `blocked_keys`, `blocked_names`
Note: `sidebar_sort_order` remains in the backend model for compatibility and migration, but the current frontend sidebar uses per-section localStorage sort preferences instead of a single shared server-backed sort mode.
Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38). Note: MQTT, community MQTT, and bot configs were migrated to the `fanout_configs` table (migrations 36-38).
+1 -2
View File
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
serial_port: str = "" # Empty string triggers auto-detection serial_port: str = "" # Empty string triggers auto-detection
serial_baudrate: int = 115200 serial_baudrate: int = 115200
tcp_host: str = "" tcp_host: str = ""
tcp_port: int = 5000 tcp_port: int = 4000
ble_address: str = "" ble_address: str = ""
ble_pin: str = "" ble_pin: str = ""
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO" log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
@@ -26,7 +26,6 @@ class Settings(BaseSettings):
default=False, default=False,
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND", validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
) )
skip_post_connect_sync: bool = False
basic_auth_username: str = "" basic_auth_username: str = ""
basic_auth_password: str = "" basic_auth_password: str = ""
+9 -66
View File
@@ -27,8 +27,7 @@ CREATE TABLE IF NOT EXISTS contacts (
on_radio INTEGER DEFAULT 0, on_radio INTEGER DEFAULT 0,
last_contacted INTEGER, last_contacted INTEGER,
first_seen INTEGER, first_seen INTEGER,
last_read_at INTEGER, last_read_at INTEGER
favorite INTEGER DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS channels ( CREATE TABLE IF NOT EXISTS channels (
@@ -37,9 +36,7 @@ CREATE TABLE IF NOT EXISTS channels (
is_hashtag INTEGER DEFAULT 0, is_hashtag INTEGER DEFAULT 0,
on_radio INTEGER DEFAULT 0, on_radio INTEGER DEFAULT 0,
flood_scope_override TEXT, flood_scope_override TEXT,
path_hash_mode_override INTEGER, last_read_at INTEGER
last_read_at INTEGER,
favorite INTEGER DEFAULT 0
); );
CREATE TABLE IF NOT EXISTS messages ( CREATE TABLE IF NOT EXISTS messages (
@@ -49,7 +46,7 @@ CREATE TABLE IF NOT EXISTS messages (
text TEXT NOT NULL, text TEXT NOT NULL,
sender_timestamp INTEGER, sender_timestamp INTEGER,
received_at INTEGER NOT NULL, received_at INTEGER NOT NULL,
paths TEXT, path TEXT,
txt_type INTEGER DEFAULT 0, txt_type INTEGER DEFAULT 0,
signature TEXT, signature TEXT,
outgoing INTEGER DEFAULT 0, outgoing INTEGER DEFAULT 0,
@@ -69,7 +66,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
data BLOB NOT NULL, data BLOB NOT NULL,
message_id INTEGER, message_id INTEGER,
payload_hash BLOB, payload_hash BLOB,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL FOREIGN KEY (message_id) REFERENCES messages(id)
); );
CREATE TABLE IF NOT EXISTS contact_advert_paths ( CREATE TABLE IF NOT EXISTS contact_advert_paths (
@@ -81,7 +78,7 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
last_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1, heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex, path_len), UNIQUE(public_key, path_hex, path_len),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE FOREIGN KEY (public_key) REFERENCES contacts(public_key)
); );
CREATE TABLE IF NOT EXISTS contact_name_history ( CREATE TABLE IF NOT EXISTS contact_name_history (
@@ -91,70 +88,26 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
first_seen INTEGER NOT NULL, first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL, last_seen INTEGER NOT NULL,
UNIQUE(public_key, name), UNIQUE(public_key, name),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE FOREIGN KEY (public_key) REFERENCES contacts(public_key)
);
CREATE TABLE IF NOT EXISTS app_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0,
advert_interval INTEGER DEFAULT 0,
last_advert_time INTEGER DEFAULT 0,
flood_scope TEXT DEFAULT '',
blocked_keys TEXT DEFAULT '[]',
blocked_names TEXT DEFAULT '[]',
discovery_blocked_types TEXT DEFAULT '[]',
tracked_telemetry_repeaters TEXT DEFAULT '[]',
auto_resend_channel INTEGER DEFAULT 0
);
INSERT OR IGNORE INTO app_settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS fanout_configs (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER DEFAULT 0,
config TEXT NOT NULL DEFAULT '{}',
scope TEXT NOT NULL DEFAULT '{}',
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
); );
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at); CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_dedup_null_safe
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0)) ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'CHAN'; WHERE type = 'CHAN';
CREATE UNIQUE INDEX IF NOT EXISTS idx_messages_incoming_priv_dedup
ON messages(type, conversation_key, text, COALESCE(sender_timestamp, 0))
WHERE type = 'PRIV' AND outgoing = 0;
CREATE INDEX IF NOT EXISTS idx_messages_sender_key ON messages(sender_key);
CREATE INDEX IF NOT EXISTS idx_messages_pagination
ON messages(type, conversation_key, received_at DESC, id DESC);
CREATE INDEX IF NOT EXISTS idx_messages_unread_covering
ON messages(type, conversation_key, outgoing, received_at);
CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id); CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id);
CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp); CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp);
CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash); CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash);
CREATE INDEX IF NOT EXISTS idx_contacts_on_radio ON contacts(on_radio);
CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen); CREATE INDEX IF NOT EXISTS idx_contacts_type_last_seen ON contacts(type, last_seen);
CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation CREATE INDEX IF NOT EXISTS idx_messages_type_received_conversation
ON messages(type, received_at, conversation_key); ON messages(type, received_at, conversation_key);
-- idx_messages_sender_key is created by migration 25 (after adding the sender_key column)
-- idx_messages_incoming_priv_dedup is created by migration 44 after legacy rows are reconciled
CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent
ON contact_advert_paths(public_key, last_seen DESC); ON contact_advert_paths(public_key, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_contact_name_history_key CREATE INDEX IF NOT EXISTS idx_contact_name_history_key
ON contact_name_history(public_key, last_seen DESC); ON contact_name_history(public_key, last_seen DESC);
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
ON repeater_telemetry_history(public_key, timestamp);
""" """
@@ -179,12 +132,6 @@ class Database:
# migration 20 handles the one-time VACUUM to restructure the file. # migration 20 handles the one-time VACUUM to restructure the file.
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL") await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
# Foreign key enforcement: must be set per-connection (not persisted).
# Disabled during schema init and migrations to avoid issues with
# historical table-rebuild migrations that may temporarily violate
# constraints, then re-enabled for all subsequent application queries.
await self._connection.execute("PRAGMA foreign_keys = OFF")
await self._connection.executescript(SCHEMA) await self._connection.executescript(SCHEMA)
await self._connection.commit() await self._connection.commit()
logger.debug("Database schema initialized") logger.debug("Database schema initialized")
@@ -194,10 +141,6 @@ class Database:
await run_migrations(self._connection) await run_migrations(self._connection)
# Enable FK enforcement for all application queries from this point on.
await self._connection.execute("PRAGMA foreign_keys = ON")
logger.debug("Foreign key enforcement enabled")
async def disconnect(self) -> None: async def disconnect(self) -> None:
if self._connection: if self._connection:
await self._connection.close() await self._connection.close()
+1 -4
View File
@@ -299,11 +299,8 @@ def parse_advertisement(
timestamp = int.from_bytes(payload[32:36], byteorder="little") timestamp = int.from_bytes(payload[32:36], byteorder="little")
flags = payload[100] flags = payload[100]
# Parse flags — clamp device_role to valid range (0-4); corrupted # Parse flags
# advertisements can have junk in the lower nibble.
device_role = flags & 0x0F device_role = flags & 0x0F
if device_role > 4:
device_role = 0
has_location = bool(flags & 0x10) has_location = bool(flags & 0x10)
has_feature1 = bool(flags & 0x20) has_feature1 = bool(flags & 0x20)
has_feature2 = bool(flags & 0x40) has_feature2 = bool(flags & 0x40)
+8
View File
@@ -0,0 +1,8 @@
"""Shared dependencies for FastAPI routers."""
from app.services.radio_runtime import radio_runtime as radio_manager
def require_connected():
"""Dependency that ensures radio is connected and returns meshcore instance."""
return radio_manager.require_connected()
+1
View File
@@ -202,6 +202,7 @@ async def on_path_update(event: "Event") -> None:
# Legacy firmware/library payloads only support 1-byte hop hashes. # Legacy firmware/library payloads only support 1-byte hop hashes.
normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0 normalized_path_hash_mode = -1 if normalized_path_len == -1 else 0
else: else:
normalized_path_hash_mode = None
try: try:
normalized_path_hash_mode = int(path_hash_mode) normalized_path_hash_mode = int(path_hash_mode)
except (TypeError, ValueError): except (TypeError, ValueError):
+31
View File
@@ -52,6 +52,19 @@ class ToastPayload(TypedDict):
details: NotRequired[str] details: NotRequired[str]
WsEventPayload = (
HealthResponse
| Message
| Contact
| ContactResolvedPayload
| Channel
| ContactDeletedPayload
| ChannelDeletedPayload
| RawPacketBroadcast
| MessageAckedPayload
| ToastPayload
)
_PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = { _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
"health": TypeAdapter(HealthResponse), "health": TypeAdapter(HealthResponse),
"message": TypeAdapter(Message), "message": TypeAdapter(Message),
@@ -67,6 +80,14 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
} }
def validate_ws_event_payload(event_type: str, data: Any) -> WsEventPayload | Any:
"""Validate known WebSocket payloads; pass unknown events through unchanged."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
if adapter is None:
return data
return adapter.validate_python(data)
def dump_ws_event(event_type: str, data: Any) -> str: def dump_ws_event(event_type: str, data: Any) -> str:
"""Serialize a WebSocket event envelope with validation for known event types.""" """Serialize a WebSocket event envelope with validation for known event types."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type] adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
@@ -83,3 +104,13 @@ def dump_ws_event(event_type: str, data: Any) -> str:
event_type, event_type,
) )
return json.dumps({"type": event_type, "data": data}) return json.dumps({"type": event_type, "data": data})
def dump_ws_event_payload(event_type: str, data: Any) -> Any:
"""Return the JSON-serializable payload for a WebSocket event."""
adapter = _PAYLOAD_ADAPTERS.get(event_type) # type: ignore[arg-type]
if adapter is None:
return data
validated = adapter.validate_python(data)
return adapter.dump_python(validated, mode="json")
+5 -2
View File
@@ -144,8 +144,11 @@ class MapUploadModule(FanoutModule):
if advert is None: if advert is None:
return return
# Advert Ed25519 signature verification is intentionally skipped. # TODO: advert Ed25519 signature verification is skipped here.
# The radio validates packets before passing them to RT. # The radio has already validated the packet before passing it to RT,
# so re-verification is redundant in practice. If added, verify that
# nacl.bindings.crypto_sign_open(sig + (pubkey_bytes || timestamp_bytes),
# advert.public_key_bytes) succeeds before proceeding.
# Only process repeaters (2) and rooms (3) — any other role is rejected # Only process repeaters (2) and rooms (3) — any other role is rejected
if advert.device_role not in _ALLOWED_DEVICE_ROLES: if advert.device_role not in _ALLOWED_DEVICE_ROLES:
-29
View File
@@ -12,7 +12,6 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import sys
import time import time
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Any from typing import Any
@@ -253,34 +252,6 @@ class BaseMqttPublisher(ABC):
self._client = None self._client = None
self._last_error = _format_error_detail(e) self._last_error = _format_error_detail(e)
# Windows ProactorEventLoop does not implement add_reader /
# add_writer, which paho-mqtt requires. The failure can
# surface as a direct NotImplementedError (add_writer in
# __aenter__) or as a generic timeout (add_reader fails
# inside an event-loop callback, so paho never hears back).
# Either way, if we're on Windows with Proactor the root
# cause is the same and retrying won't help.
_on_proactor = (
sys.platform == "win32"
and type(asyncio.get_event_loop()).__name__ == "ProactorEventLoop"
)
if _on_proactor:
broadcast_error(
"MQTT unavailable — Windows event loop incompatible",
"The default Windows event loop (ProactorEventLoop) does "
"not support MQTT. Add --loop none to your uvicorn "
"command and restart. See README.md for details.",
)
_broadcast_health()
logger.error(
"%s cannot run: Windows ProactorEventLoop does not "
"implement add_reader/add_writer required by paho-mqtt. "
"Restart uvicorn with '--loop none' to use "
"SelectorEventLoop instead. Giving up (will not retry).",
self._integration_label(),
)
return
title, detail = self._on_error() title, detail = self._on_error()
broadcast_error(title, detail) broadcast_error(title, detail)
_broadcast_health() _broadcast_health()
+11 -31
View File
@@ -38,17 +38,8 @@ def _is_index_file(path: Path, index_file: Path) -> bool:
return path == index_file return path == index_file
def _resolve_request_base(request: Request) -> str: def _resolve_request_origin(request: Request) -> str:
"""Resolve the external base URL, honoring common reverse-proxy headers. """Resolve the external origin, honoring common reverse-proxy headers."""
Returns a URL like ``https://host:8000/meshcore/`` (always trailing-slash)
so callers can append paths directly.
Recognized headers:
- ``X-Forwarded-Proto`` + ``X-Forwarded-Host``: override scheme and host.
- ``X-Forwarded-Prefix`` (or ``X-Forwarded-Path``): sub-path prefix added
by the proxy (e.g. ``/meshcore``).
"""
forwarded_proto = request.headers.get("x-forwarded-proto") forwarded_proto = request.headers.get("x-forwarded-proto")
forwarded_host = request.headers.get("x-forwarded-host") forwarded_host = request.headers.get("x-forwarded-host")
@@ -56,20 +47,9 @@ def _resolve_request_base(request: Request) -> str:
proto = forwarded_proto.split(",")[0].strip() proto = forwarded_proto.split(",")[0].strip()
host = forwarded_host.split(",")[0].strip() host = forwarded_host.split(",")[0].strip()
if proto and host: if proto and host:
origin = f"{proto}://{host}" return f"{proto}://{host}"
else:
origin = str(request.base_url).rstrip("/")
else:
origin = str(request.base_url).rstrip("/")
# Sub-path prefix (e.g. /meshcore) communicated by the reverse proxy return str(request.base_url).rstrip("/")
prefix = (
(request.headers.get("x-forwarded-prefix") or request.headers.get("x-forwarded-path") or "")
.strip()
.rstrip("/")
)
return f"{origin}{prefix}/"
def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]: def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
@@ -123,27 +103,27 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
@app.get("/site.webmanifest") @app.get("/site.webmanifest")
async def serve_webmanifest(request: Request): async def serve_webmanifest(request: Request):
"""Serve a dynamic web manifest using the active request base URL.""" """Serve a dynamic web manifest using the active request origin."""
base = _resolve_request_base(request) origin = _resolve_request_origin(request)
manifest = { manifest = {
"name": "RemoteTerm for MeshCore", "name": "RemoteTerm for MeshCore",
"short_name": "RemoteTerm", "short_name": "RemoteTerm",
"id": base, "id": f"{origin}/",
"start_url": base, "start_url": f"{origin}/",
"scope": base, "scope": f"{origin}/",
"display": "standalone", "display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "fullscreen"], "display_override": ["window-controls-overlay", "standalone", "fullscreen"],
"theme_color": "#111419", "theme_color": "#111419",
"background_color": "#111419", "background_color": "#111419",
"icons": [ "icons": [
{ {
"src": f"{base}web-app-manifest-192x192.png", "src": f"{origin}/web-app-manifest-192x192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable", "purpose": "maskable",
}, },
{ {
"src": f"{base}web-app-manifest-512x512.png", "src": f"{origin}/web-app-manifest-512x512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable", "purpose": "maskable",
+1 -39
View File
@@ -1,41 +1,5 @@
import logging
import sys
# ---------------------------------------------------------------------------
# Windows event-loop advisory for MQTT fanout
# ---------------------------------------------------------------------------
# On Windows, uvicorn's default event loop (ProactorEventLoop) does not
# implement add_reader()/add_writer(), which paho-mqtt (via aiomqtt) requires.
# We cannot fix this from inside the app — the loop is already created by the
# time this module is imported. Log a prominent warning so Windows operators
# who want MQTT know to add ``--loop none`` to their uvicorn command.
# ---------------------------------------------------------------------------
if sys.platform == "win32":
import asyncio as _asyncio
_loop = _asyncio.get_event_loop()
_is_proactor = type(_loop).__name__ == "ProactorEventLoop"
if _is_proactor:
print(
"\n" + "!" * 78 + "\n"
" NOTE FOR WINDOWS USERS\n" + "!" * 78 + "\n"
"\n"
" The running event loop is ProactorEventLoop, which is not\n"
" compatible with MQTT fanout (aiomqtt / paho-mqtt).\n"
"\n"
" If you use MQTT integrations, restart with --loop none:\n"
"\n"
" uv run uvicorn app.main:app \033[1m--loop none\033[0m"
" [... other options ...]\n"
"\n"
" Everything else works fine as-is.\n"
"\n" + "!" * 78 + "\n",
file=sys.stderr,
flush=True,
)
del _loop, _is_proactor
import asyncio import asyncio
import logging
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -57,7 +21,6 @@ from app.radio_sync import (
stop_message_polling, stop_message_polling,
stop_periodic_advert, stop_periodic_advert,
stop_periodic_sync, stop_periodic_sync,
stop_telemetry_collect,
) )
from app.routers import ( from app.routers import (
channels, channels,
@@ -140,7 +103,6 @@ async def lifespan(app: FastAPI):
await stop_noise_floor_sampling() await stop_noise_floor_sampling()
await stop_periodic_advert() await stop_periodic_advert()
await stop_periodic_sync() await stop_periodic_sync()
await stop_telemetry_collect()
if radio_manager.meshcore: if radio_manager.meshcore:
await radio_manager.meshcore.stop_auto_message_fetching() await radio_manager.meshcore.stop_auto_message_fetching()
await radio_manager.disconnect() await radio_manager.disconnect()
+8 -406
View File
@@ -367,58 +367,6 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 47) await set_version(conn, 47)
applied += 1 applied += 1
# Migration 48: Add discovery_blocked_types column to app_settings
if version < 48:
logger.info("Applying migration 48: add discovery_blocked_types to app_settings")
await _migrate_048_discovery_blocked_types(conn)
await set_version(conn, 48)
applied += 1
# Migration 49: Enable foreign key enforcement — rebuild tables with
# CASCADE / SET NULL and clean up any orphaned rows first.
if version < 49:
logger.info("Applying migration 49: add foreign key cascade/set-null and clean orphans")
await _migrate_049_foreign_key_cascade(conn)
await set_version(conn, 49)
applied += 1
# Migration 50: Repeater telemetry history table + tracking opt-in column
if version < 50:
logger.info("Applying migration 50: repeater telemetry history")
await _migrate_050_repeater_telemetry_history(conn)
await set_version(conn, 50)
applied += 1
if version < 51:
logger.info("Applying migration 51: drop sidebar_sort_order from app_settings")
await _migrate_051_drop_sidebar_sort_order(conn)
await set_version(conn, 51)
applied += 1
if version < 52:
logger.info("Applying migration 52: add path_hash_mode_override to channels")
await _migrate_052_add_channel_path_hash_mode_override(conn)
await set_version(conn, 52)
applied += 1
if version < 53:
logger.info("Applying migration 53: add tracked_telemetry_repeaters to app_settings")
await _migrate_053_tracked_telemetry_repeaters(conn)
await set_version(conn, 53)
applied += 1
if version < 54:
logger.info("Applying migration 54: add auto_resend_channel to app_settings")
await _migrate_054_auto_resend_channel(conn)
await set_version(conn, 54)
applied += 1
if version < 55:
logger.info("Applying migration 55: move favorites to per-entity columns")
await _migrate_055_favorites_to_columns(conn)
await set_version(conn, 55)
applied += 1
if applied > 0: if applied > 0:
logger.info( logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn) "Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -881,7 +829,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
id INTEGER PRIMARY KEY CHECK (id = 1), id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200, max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]', favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 1, auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
sidebar_sort_order TEXT DEFAULT 'recent', sidebar_sort_order TEXT DEFAULT 'recent',
last_message_times TEXT DEFAULT '{}', last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0 preferences_migrated INTEGER DEFAULT 0
@@ -889,9 +837,13 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
""" """
) )
# Initialize with default row (use only the id column so this works # Initialize with default row
# regardless of which columns exist — defaults fill the rest). await conn.execute(
await conn.execute("INSERT OR IGNORE INTO app_settings (id) VALUES (1)") """
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
VALUES (1, 200, '[]', 0, 'recent', '{}', 0)
"""
)
await conn.commit() await conn.commit()
logger.debug("Created app_settings table with default values") logger.debug("Created app_settings table with default values")
@@ -2957,353 +2909,3 @@ async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> Non
""" """
) )
await conn.commit() await conn.commit()
async def _migrate_048_discovery_blocked_types(conn: aiosqlite.Connection) -> None:
"""Add discovery_blocked_types column to app_settings.
Stores a JSON array of integer contact type codes (1=Client, 2=Repeater,
3=Room, 4=Sensor) whose advertisements should not create new contacts.
Empty list means all types are accepted.
"""
try:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN discovery_blocked_types TEXT DEFAULT '[]'"
)
except Exception as e:
error_msg = str(e).lower()
if "duplicate column" in error_msg:
logger.debug("discovery_blocked_types column already exists, skipping")
elif "no such table" in error_msg:
logger.debug("app_settings table not ready, skipping discovery_blocked_types migration")
else:
raise
await conn.commit()
async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None:
"""Rebuild FK tables with CASCADE/SET NULL and clean orphaned rows.
SQLite cannot ALTER existing FK constraints, so each table is rebuilt.
Orphaned child rows are cleaned up before the rebuild to ensure the
INSERT...SELECT into the new table (which has enforced FKs) succeeds.
"""
import shutil
from pathlib import Path
# Back up the database before table rebuilds (skip for in-memory DBs).
cursor = await conn.execute("PRAGMA database_list")
db_row = await cursor.fetchone()
db_path = db_row[2] if db_row else ""
if db_path and db_path != ":memory:" and Path(db_path).exists():
backup_path = db_path + ".pre-fk-migration.bak"
for suffix in ("", "-wal", "-shm"):
src = Path(db_path + suffix)
if src.exists():
shutil.copy2(str(src), backup_path + suffix)
logger.info("Database backed up to %s before FK migration", backup_path)
# --- Phase 1: clean orphans (guard each table's existence) ---
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
if "contact_advert_paths" in existing_tables and "contacts" in existing_tables:
await conn.execute(
"DELETE FROM contact_advert_paths "
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
)
if "contact_name_history" in existing_tables and "contacts" in existing_tables:
await conn.execute(
"DELETE FROM contact_name_history "
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
)
if "raw_packets" in existing_tables and "messages" in existing_tables:
# Guard: message_id column may not exist on very old schemas
col_cursor = await conn.execute("PRAGMA table_info(raw_packets)")
raw_cols = {row[1] for row in await col_cursor.fetchall()}
if "message_id" in raw_cols:
await conn.execute(
"UPDATE raw_packets SET message_id = NULL WHERE message_id IS NOT NULL "
"AND message_id NOT IN (SELECT id FROM messages)"
)
await conn.commit()
logger.debug("Cleaned orphaned child rows before FK rebuild")
# --- Phase 2: rebuild raw_packets with ON DELETE SET NULL ---
# Skip if raw_packets doesn't have message_id (pre-migration-18 schema)
raw_has_message_id = False
if "raw_packets" in existing_tables:
col_cursor2 = await conn.execute("PRAGMA table_info(raw_packets)")
raw_has_message_id = "message_id" in {row[1] for row in await col_cursor2.fetchall()}
if raw_has_message_id:
# Dynamically build column list based on what the old table actually has,
# since very old schemas may lack payload_hash (added in migration 28).
col_cursor3 = await conn.execute("PRAGMA table_info(raw_packets)")
old_cols = [row[1] for row in await col_cursor3.fetchall()]
new_col_defs = [
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"timestamp INTEGER NOT NULL",
"data BLOB NOT NULL",
"message_id INTEGER",
]
copy_cols = ["id", "timestamp", "data", "message_id"]
if "payload_hash" in old_cols:
new_col_defs.append("payload_hash BLOB")
copy_cols.append("payload_hash")
new_col_defs.append("FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL")
cols_sql = ", ".join(new_col_defs)
copy_sql = ", ".join(copy_cols)
await conn.execute(f"CREATE TABLE raw_packets_fk ({cols_sql})")
await conn.execute(
f"INSERT INTO raw_packets_fk ({copy_sql}) SELECT {copy_sql} FROM raw_packets"
)
await conn.execute("DROP TABLE raw_packets")
await conn.execute("ALTER TABLE raw_packets_fk RENAME TO raw_packets")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
)
if "payload_hash" in old_cols:
await conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash)"
)
await conn.commit()
logger.debug("Rebuilt raw_packets with ON DELETE SET NULL")
# --- Phase 3: rebuild contact_advert_paths with ON DELETE CASCADE ---
if "contact_advert_paths" in existing_tables:
await conn.execute(
"""
CREATE TABLE contact_advert_paths_fk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
path_hex TEXT NOT NULL,
path_len INTEGER NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex, path_len),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"INSERT INTO contact_advert_paths_fk (id, public_key, path_hex, path_len, first_seen, last_seen, heard_count) "
"SELECT id, public_key, path_hex, path_len, first_seen, last_seen, heard_count FROM contact_advert_paths"
)
await conn.execute("DROP TABLE contact_advert_paths")
await conn.execute("ALTER TABLE contact_advert_paths_fk RENAME TO contact_advert_paths")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent "
"ON contact_advert_paths(public_key, last_seen DESC)"
)
await conn.commit()
logger.debug("Rebuilt contact_advert_paths with ON DELETE CASCADE")
# --- Phase 4: rebuild contact_name_history with ON DELETE CASCADE ---
if "contact_name_history" in existing_tables:
await conn.execute(
"""
CREATE TABLE contact_name_history_fk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
name TEXT NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
UNIQUE(public_key, name),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"INSERT INTO contact_name_history_fk (id, public_key, name, first_seen, last_seen) "
"SELECT id, public_key, name, first_seen, last_seen FROM contact_name_history"
)
await conn.execute("DROP TABLE contact_name_history")
await conn.execute("ALTER TABLE contact_name_history_fk RENAME TO contact_name_history")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contact_name_history_key "
"ON contact_name_history(public_key, last_seen DESC)"
)
await conn.commit()
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
"""Create repeater_telemetry_history table for JSON-blob telemetry snapshots."""
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS repeater_telemetry_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
timestamp INTEGER NOT NULL,
data TEXT NOT NULL,
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_repeater_telemetry_pk_ts
ON repeater_telemetry_history (public_key, timestamp)
"""
)
await conn.commit()
async def _migrate_051_drop_sidebar_sort_order(conn: aiosqlite.Connection) -> None:
"""Remove vestigial sidebar_sort_order column from app_settings."""
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "sidebar_sort_order" in columns:
try:
await conn.execute("ALTER TABLE app_settings DROP COLUMN sidebar_sort_order")
await conn.commit()
except Exception as e:
error_msg = str(e).lower()
if "syntax error" in error_msg or "drop column" in error_msg:
logger.debug(
"SQLite doesn't support DROP COLUMN, sidebar_sort_order column will remain"
)
await conn.commit()
else:
raise
async def _migrate_052_add_channel_path_hash_mode_override(conn: aiosqlite.Connection) -> None:
"""Add nullable per-channel path hash mode override column."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "channels" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
try:
await conn.execute("ALTER TABLE channels ADD COLUMN path_hash_mode_override INTEGER")
await conn.commit()
except Exception as e:
if "duplicate column" in str(e).lower():
await conn.commit()
else:
raise
async def _migrate_053_tracked_telemetry_repeaters(conn: aiosqlite.Connection) -> None:
"""Add tracked_telemetry_repeaters JSON list column to app_settings."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "tracked_telemetry_repeaters" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN tracked_telemetry_repeaters TEXT DEFAULT '[]'"
)
await conn.commit()
async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None:
"""Add auto_resend_channel boolean column to app_settings."""
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
columns = {row[1] for row in await col_cursor.fetchall()}
if "auto_resend_channel" not in columns:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN auto_resend_channel INTEGER DEFAULT 0"
)
await conn.commit()
async def _migrate_055_favorites_to_columns(conn: aiosqlite.Connection) -> None:
"""Move favorites from app_settings JSON blob to per-entity boolean columns.
1. Add ``favorite`` column to contacts and channels tables.
2. Backfill from the ``app_settings.favorites`` JSON array.
3. Drop the ``favorites`` column from app_settings.
"""
import json as _json
# --- Add columns ---
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
for table in ("contacts", "channels"):
if table not in existing_tables:
continue
col_cursor = await conn.execute(f"PRAGMA table_info({table})")
columns = {row[1] for row in await col_cursor.fetchall()}
if "favorite" not in columns:
await conn.execute(f"ALTER TABLE {table} ADD COLUMN favorite INTEGER DEFAULT 0")
await conn.commit()
# --- Backfill from JSON ---
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
await conn.commit()
return
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
settings_columns = {row[1] for row in await col_cursor.fetchall()}
if "favorites" not in settings_columns:
await conn.commit()
return
cursor = await conn.execute("SELECT favorites FROM app_settings WHERE id = 1")
row = await cursor.fetchone()
if row and row[0]:
try:
favorites = _json.loads(row[0])
except (ValueError, TypeError):
favorites = []
contact_keys = []
channel_keys = []
for fav in favorites:
if not isinstance(fav, dict):
continue
fav_type = fav.get("type")
fav_id = fav.get("id")
if not fav_id:
continue
if fav_type == "contact":
contact_keys.append(fav_id)
elif fav_type == "channel":
channel_keys.append(fav_id)
if contact_keys:
placeholders = ",".join("?" for _ in contact_keys)
await conn.execute(
f"UPDATE contacts SET favorite = 1 WHERE public_key IN ({placeholders})",
contact_keys,
)
if channel_keys:
placeholders = ",".join("?" for _ in channel_keys)
await conn.execute(
f"UPDATE channels SET favorite = 1 WHERE key IN ({placeholders})",
channel_keys,
)
if contact_keys or channel_keys:
logger.info(
"Backfilled %d contact favorite(s) and %d channel favorite(s) from app_settings",
len(contact_keys),
len(channel_keys),
)
await conn.commit()
# --- Drop the JSON column ---
try:
await conn.execute("ALTER TABLE app_settings DROP COLUMN favorites")
await conn.commit()
except Exception as e:
error_msg = str(e).lower()
if "syntax error" in error_msg or "drop column" in error_msg:
logger.debug("SQLite doesn't support DROP COLUMN; favorites column will remain unused")
await conn.commit()
else:
raise
+78 -75
View File
@@ -4,10 +4,6 @@ from pydantic import BaseModel, Field
from app.path_utils import normalize_contact_route, normalize_route_override from app.path_utils import normalize_contact_route, normalize_route_override
# Valid MeshCore contact types: 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor.
# Corrupted radio data can produce values outside this range.
_VALID_CONTACT_TYPES = frozenset({0, 1, 2, 3, 4})
class ContactRoute(BaseModel): class ContactRoute(BaseModel):
"""A normalized contact route.""" """A normalized contact route."""
@@ -63,30 +59,16 @@ class ContactUpsert(BaseModel):
-1 if radio_data.get("out_path_len", -1) == -1 else 0, -1 if radio_data.get("out_path_len", -1) == -1 else 0,
), ),
) )
# Clamp invalid contact types to 0 (unknown) — corrupted radio data
# can produce values like 111 or 240 that break downstream branching.
raw_type = radio_data.get("type", 0)
contact_type = raw_type if raw_type in _VALID_CONTACT_TYPES else 0
# Null out impossible coordinates — the contact is still ingested,
# but garbage lat/lon (e.g. 1953.7) is discarded rather than stored.
lat = radio_data.get("adv_lat")
lon = radio_data.get("adv_lon")
if lat is not None and not (-90 <= lat <= 90):
lat = None
if lon is not None and not (-180 <= lon <= 180):
lon = None
return cls( return cls(
public_key=public_key, public_key=public_key,
name=radio_data.get("adv_name"), name=radio_data.get("adv_name"),
type=contact_type, type=radio_data.get("type", 0),
flags=radio_data.get("flags", 0), flags=radio_data.get("flags", 0),
direct_path=direct_path, direct_path=direct_path,
direct_path_len=direct_path_len, direct_path_len=direct_path_len,
direct_path_hash_mode=direct_path_hash_mode, direct_path_hash_mode=direct_path_hash_mode,
lat=lat, lat=radio_data.get("adv_lat"),
lon=lon, lon=radio_data.get("adv_lon"),
last_advert=radio_data.get("last_advert"), last_advert=radio_data.get("last_advert"),
on_radio=on_radio, on_radio=on_radio,
) )
@@ -109,7 +91,6 @@ class Contact(BaseModel):
lon: float | None = None lon: float | None = None
last_seen: int | None = None last_seen: int | None = None
on_radio: bool = False on_radio: bool = False
favorite: bool = False
last_contacted: int | None = None # Last time we sent/received a message last_contacted: int | None = None # Last time we sent/received a message
last_read_at: int | None = None # Server-side read state tracking last_read_at: int | None = None # Server-side read state tracking
first_seen: int | None = None first_seen: int | None = None
@@ -215,6 +196,15 @@ class Contact(BaseModel):
"""Convert the stored contact to the repository's write contract.""" """Convert the stored contact to the repository's write contract."""
return ContactUpsert.from_contact(self, **changes) return ContactUpsert.from_contact(self, **changes)
@staticmethod
def from_radio_dict(public_key: str, radio_data: dict, on_radio: bool = False) -> dict:
"""Backward-compatible dict wrapper over ContactUpsert.from_radio_dict()."""
return ContactUpsert.from_radio_dict(
public_key,
radio_data,
on_radio=on_radio,
).model_dump()
class CreateContactRequest(BaseModel): class CreateContactRequest(BaseModel):
"""Request to create a new contact.""" """Request to create a new contact."""
@@ -293,6 +283,30 @@ class NearestRepeater(BaseModel):
heard_count: int heard_count: int
class ContactDetail(BaseModel):
"""Comprehensive contact profile data."""
contact: Contact
name_history: list[ContactNameHistory] = Field(default_factory=list)
dm_message_count: int = 0
channel_message_count: int = 0
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)
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): class ContactAnalyticsHourlyBucket(BaseModel):
"""A single hourly activity bucket for contact analytics.""" """A single hourly activity bucket for contact analytics."""
@@ -340,12 +354,7 @@ class Channel(BaseModel):
default=None, default=None,
description="Per-channel outbound flood scope override (null = use global app setting)", description="Per-channel outbound flood scope override (null = use global app setting)",
) )
path_hash_mode_override: int | None = Field(
default=None,
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
)
last_read_at: int | None = None # Server-side read state tracking last_read_at: int | None = None # Server-side read state tracking
favorite: bool = False
class ChannelMessageCounts(BaseModel): class ChannelMessageCounts(BaseModel):
@@ -366,18 +375,6 @@ class ChannelTopSender(BaseModel):
message_count: int message_count: int
class PathHashWidthStats(BaseModel):
"""Hop byte width distribution for parsed raw packets."""
total_packets: int = 0
single_byte: int = 0
double_byte: int = 0
triple_byte: int = 0
single_byte_pct: float = 0.0
double_byte_pct: float = 0.0
triple_byte_pct: float = 0.0
class ChannelDetail(BaseModel): class ChannelDetail(BaseModel):
"""Comprehensive channel profile data.""" """Comprehensive channel profile data."""
@@ -386,7 +383,6 @@ class ChannelDetail(BaseModel):
first_message_at: int | None = None first_message_at: int | None = None
unique_sender_count: int = 0 unique_sender_count: int = 0
top_senders_24h: list[ChannelTopSender] = Field(default_factory=list) top_senders_24h: list[ChannelTopSender] = Field(default_factory=list)
path_hash_width_24h: PathHashWidthStats = Field(default_factory=PathHashWidthStats)
class MessagePath(BaseModel): class MessagePath(BaseModel):
@@ -398,8 +394,6 @@ class MessagePath(BaseModel):
default=None, default=None,
description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)", description="Hop count. None = legacy (infer as len(path)//2, i.e. 1-byte hops)",
) )
rssi: int | None = Field(default=None, description="Last-hop RSSI in dBm")
snr: float | None = Field(default=None, description="Last-hop SNR in dB")
class Message(BaseModel): class Message(BaseModel):
@@ -536,9 +530,6 @@ class RepeaterStatusResponse(BaseModel):
flood_dups: int = Field(description="Duplicate flood packets") flood_dups: int = Field(description="Duplicate flood packets")
direct_dups: int = Field(description="Duplicate direct packets") direct_dups: int = Field(description="Duplicate direct packets")
full_events: int = Field(description="Full event queue count") full_events: int = Field(description="Full event queue count")
telemetry_history: list["TelemetryHistoryEntry"] = Field(
default_factory=list, description="Recent telemetry history snapshots"
)
class RepeaterNodeInfoResponse(BaseModel): class RepeaterNodeInfoResponse(BaseModel):
@@ -776,6 +767,13 @@ class RadioDiscoveryResponse(BaseModel):
) )
class Favorite(BaseModel):
"""A favorite conversation."""
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
id: str = Field(description="Channel key or contact public key")
class UnreadCounts(BaseModel): class UnreadCounts(BaseModel):
"""Aggregated unread counts, mention flags, and last message times for all conversations.""" """Aggregated unread counts, mention flags, and last message times for all conversations."""
@@ -803,14 +801,25 @@ class AppSettings(BaseModel):
"favorites reload first, then background fill targets about 80% of this value" "favorites reload first, then background fill targets about 80% of this value"
), ),
) )
favorites: list[Favorite] = Field(
default_factory=list, description="List of favorited conversations"
)
auto_decrypt_dm_on_advert: bool = Field( auto_decrypt_dm_on_advert: bool = Field(
default=True, default=False,
description="Whether to attempt historical DM decryption on new contact advertisement", description="Whether to attempt historical DM decryption on new contact advertisement",
) )
sidebar_sort_order: Literal["recent", "alpha"] = Field(
default="recent",
description="Sidebar sort order: 'recent' or 'alpha'",
)
last_message_times: dict[str, int] = Field( last_message_times: dict[str, int] = Field(
default_factory=dict, default_factory=dict,
description="Map of conversation state keys to last message timestamps", description="Map of conversation state keys to last message timestamps",
) )
preferences_migrated: bool = Field(
default=False,
description="Whether preferences have been migrated from localStorage",
)
advert_interval: int = Field( advert_interval: int = Field(
default=0, default=0,
description="Periodic advertisement interval in seconds (0 = disabled)", description="Periodic advertisement interval in seconds (0 = disabled)",
@@ -831,24 +840,19 @@ class AppSettings(BaseModel):
default_factory=list, default_factory=list,
description="Display names whose messages are hidden from the UI", description="Display names whose messages are hidden from the UI",
) )
discovery_blocked_types: list[int] = Field(
default_factory=list,
description=( class FanoutConfig(BaseModel):
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose " """Configuration for a single fanout integration."""
"advertisements should not create new contacts; existing contacts are still updated"
), id: str
) type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise' | 'sqs'
tracked_telemetry_repeaters: list[str] = Field( name: str
default_factory=list, enabled: bool
description="Public keys of repeaters opted into periodic telemetry collection (max 8)", config: dict
) scope: dict
auto_resend_channel: bool = Field( sort_order: int = 0
default=False, created_at: int = 0
description=(
"When enabled, outgoing channel messages that receive no echo within 2 seconds "
"are automatically byte-perfect resent once (within the 30-second dedup window)"
),
)
class BusyChannel(BaseModel): class BusyChannel(BaseModel):
@@ -863,6 +867,16 @@ class ContactActivityCounts(BaseModel):
last_week: int last_week: int
class PathHashWidthStats(BaseModel):
total_packets: int
single_byte: int
double_byte: int
triple_byte: int
single_byte_pct: float
double_byte_pct: float
triple_byte_pct: float
class NoiseFloorSample(BaseModel): class NoiseFloorSample(BaseModel):
timestamp: int = Field(description="Unix timestamp of the sampled reading") timestamp: int = Field(description="Unix timestamp of the sampled reading")
noise_floor_dbm: int = Field(description="Noise floor in dBm") noise_floor_dbm: int = Field(description="Noise floor in dBm")
@@ -884,11 +898,6 @@ class NoiseFloorHistoryStats(BaseModel):
samples: list[NoiseFloorSample] = Field(default_factory=list) samples: list[NoiseFloorSample] = Field(default_factory=list)
class PacketsPerHourBucket(BaseModel):
timestamp: int = Field(description="Unix timestamp at the start of the hour")
count: int = Field(description="Number of packets received in that hour")
class StatisticsResponse(BaseModel): class StatisticsResponse(BaseModel):
busiest_channels_24h: list[BusyChannel] busiest_channels_24h: list[BusyChannel]
contact_count: int contact_count: int
@@ -904,10 +913,4 @@ class StatisticsResponse(BaseModel):
repeaters_heard: ContactActivityCounts repeaters_heard: ContactActivityCounts
known_channels_active: ContactActivityCounts known_channels_active: ContactActivityCounts
path_hash_width_24h: PathHashWidthStats path_hash_width_24h: PathHashWidthStats
packets_per_hour_72h: list[PacketsPerHourBucket]
noise_floor_24h: NoiseFloorHistoryStats noise_floor_24h: NoiseFloorHistoryStats
class TelemetryHistoryEntry(BaseModel):
timestamp: int
data: dict
+10 -46
View File
@@ -68,8 +68,6 @@ async def create_message_from_decrypted(
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
channel_name: str | None = None, channel_name: str | None = None,
realtime: bool = True, realtime: bool = True,
) -> int | None: ) -> int | None:
@@ -83,8 +81,6 @@ async def create_message_from_decrypted(
received_at=received_at, received_at=received_at,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
channel_name=channel_name, channel_name=channel_name,
realtime=realtime, realtime=realtime,
broadcast_fn=broadcast_event, broadcast_fn=broadcast_event,
@@ -99,8 +95,6 @@ async def create_dm_message_from_decrypted(
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
outgoing: bool = False, outgoing: bool = False,
realtime: bool = True, realtime: bool = True,
) -> int | None: ) -> int | None:
@@ -113,8 +107,6 @@ async def create_dm_message_from_decrypted(
received_at=received_at, received_at=received_at,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
outgoing=outgoing, outgoing=outgoing,
realtime=realtime, realtime=realtime,
broadcast_fn=broadcast_event, broadcast_fn=broadcast_event,
@@ -327,9 +319,7 @@ async def process_raw_packet(
# deduplication in create_message_from_decrypted handles adding paths to existing messages. # deduplication in create_message_from_decrypted handles adding paths to existing messages.
# This is more reliable than trying to look up the message via raw packet linking. # This is more reliable than trying to look up the message via raw packet linking.
if payload_type == PayloadType.GROUP_TEXT: if payload_type == PayloadType.GROUP_TEXT:
decrypt_result = await _process_group_text( decrypt_result = await _process_group_text(raw_bytes, packet_id, ts, packet_info)
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
)
if decrypt_result: if decrypt_result:
result.update(decrypt_result) result.update(decrypt_result)
@@ -340,9 +330,7 @@ async def process_raw_packet(
elif payload_type == PayloadType.TEXT_MESSAGE: elif payload_type == PayloadType.TEXT_MESSAGE:
# Try to decrypt direct messages using stored private key and known contacts # Try to decrypt direct messages using stored private key and known contacts
decrypt_result = await _process_direct_message( decrypt_result = await _process_direct_message(raw_bytes, packet_id, ts, packet_info)
raw_bytes, packet_id, ts, packet_info, rssi=rssi, snr=snr
)
if decrypt_result: if decrypt_result:
result.update(decrypt_result) result.update(decrypt_result)
@@ -379,8 +367,6 @@ async def _process_group_text(
packet_id: int, packet_id: int,
timestamp: int, timestamp: int,
packet_info: PacketInfo | None, packet_info: PacketInfo | None,
rssi: int | None = None,
snr: float | None = None,
) -> dict | None: ) -> dict | None:
""" """
Process a GroupText (channel message) packet. Process a GroupText (channel message) packet.
@@ -417,8 +403,6 @@ async def _process_group_text(
received_at=timestamp, received_at=timestamp,
path=packet_info.path.hex() if packet_info else None, path=packet_info.path.hex() if packet_info else None,
path_len=packet_info.path_length if packet_info else None, path_len=packet_info.path_length if packet_info else None,
rssi=rssi,
snr=snr,
) )
return { return {
@@ -478,19 +462,14 @@ async def _process_advertisement(
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0) advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
) )
# Check discovery_blocked_types: skip new contacts whose type is blocked. # Keep recent unique advert paths for all contacts.
# Existing contacts are always updated (location, name, last_seen, etc.). await ContactAdvertPathRepository.record_observation(
if existing is None and contact_type > 0: public_key=advert.public_key.lower(),
from app.repository import AppSettingsRepository path_hex=new_path_hex,
timestamp=timestamp,
settings = await AppSettingsRepository.get() max_paths=10,
if contact_type in settings.discovery_blocked_types: hop_count=new_path_len,
logger.debug( )
"Skipping new contact %s: type %d is in discovery_blocked_types",
advert.public_key[:12],
contact_type,
)
return
contact_upsert = ContactUpsert( contact_upsert = ContactUpsert(
public_key=advert.public_key.lower(), public_key=advert.public_key.lower(),
@@ -503,18 +482,7 @@ async def _process_advertisement(
first_seen=timestamp, # COALESCE in upsert preserves existing value first_seen=timestamp, # COALESCE in upsert preserves existing value
) )
# Upsert the contact BEFORE recording advert paths so the parent row
# exists when foreign key enforcement is enabled.
await ContactRepository.upsert(contact_upsert) await ContactRepository.upsert(contact_upsert)
# Keep recent unique advert paths for all contacts.
await ContactAdvertPathRepository.record_observation(
public_key=advert.public_key.lower(),
path_hex=new_path_hex,
timestamp=timestamp,
max_paths=10,
hop_count=new_path_len,
)
promoted_keys = await promote_prefix_contacts_for_contact( promoted_keys = await promote_prefix_contacts_for_contact(
public_key=advert.public_key, public_key=advert.public_key,
log=logger, log=logger,
@@ -560,8 +528,6 @@ async def _process_direct_message(
packet_id: int, packet_id: int,
timestamp: int, timestamp: int,
packet_info: PacketInfo | None, packet_info: PacketInfo | None,
rssi: int | None = None,
snr: float | None = None,
) -> dict | None: ) -> dict | None:
""" """
Process a TEXT_MESSAGE (direct message) packet. Process a TEXT_MESSAGE (direct message) packet.
@@ -662,8 +628,6 @@ async def _process_direct_message(
received_at=timestamp, received_at=timestamp,
path=packet_info.path.hex() if packet_info else None, path=packet_info.path.hex() if packet_info else None,
path_len=packet_info.path_length if packet_info else None, path_len=packet_info.path_length if packet_info else None,
rssi=rssi,
snr=snr,
outgoing=is_outgoing, outgoing=is_outgoing,
) )
-48
View File
@@ -244,51 +244,3 @@ def parse_explicit_hop_route(route_text: str) -> tuple[str, int, int]:
raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes") raise ValueError(f"Explicit path exceeds MAX_PATH_SIZE={MAX_PATH_SIZE} bytes")
return "".join(hops), len(hops), hash_size - 1 return "".join(hops), len(hops), hash_size - 1
async def bucket_path_hash_widths(cursor, *, batch_size: int = 500) -> dict[str, int | float]:
"""Bucket raw packet rows by hop hash width and return counts + percentages.
*cursor* must be an already-executed async cursor whose rows have a ``data``
column containing raw packet bytes.
"""
single_byte = 0
double_byte = 0
triple_byte = 0
while True:
rows = await cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
envelope = parse_packet_envelope(bytes(row["data"]))
if envelope is None:
continue
if envelope.hash_size == 1:
single_byte += 1
elif envelope.hash_size == 2:
double_byte += 1
elif envelope.hash_size == 3:
triple_byte += 1
total = single_byte + double_byte + triple_byte
if total == 0:
return {
"total_packets": 0,
"single_byte": 0,
"double_byte": 0,
"triple_byte": 0,
"single_byte_pct": 0.0,
"double_byte_pct": 0.0,
"triple_byte_pct": 0.0,
}
return {
"total_packets": total,
"single_byte": single_byte,
"double_byte": double_byte,
"triple_byte": triple_byte,
"single_byte_pct": (single_byte / total) * 100,
"double_byte_pct": (double_byte / total) * 100,
"triple_byte_pct": (triple_byte / total) * 100,
}
+96 -230
View File
@@ -21,19 +21,15 @@ from meshcore import EventType, MeshCore
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
from app.config import settings from app.config import settings
from app.event_handlers import cleanup_expired_acks, on_contact_message from app.event_handlers import cleanup_expired_acks, on_contact_message
from app.models import _VALID_CONTACT_TYPES, Contact, ContactUpsert from app.models import Contact, ContactUpsert
from app.radio import RadioOperationBusyError from app.radio import RadioOperationBusyError
from app.repository import ( from app.repository import (
AmbiguousPublicKeyPrefixError, AmbiguousPublicKeyPrefixError,
AppSettingsRepository, AppSettingsRepository,
ChannelRepository, ChannelRepository,
ContactRepository, ContactRepository,
RepeaterTelemetryRepository,
)
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
) )
from app.services.contact_reconciliation import reconcile_contact_messages
from app.services.messages import create_fallback_channel_message from app.services.messages import create_fallback_channel_message
from app.services.radio_runtime import radio_runtime as radio_manager from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_error, broadcast_event from app.websocket import broadcast_error, broadcast_event
@@ -67,25 +63,13 @@ async def _reconcile_contact_messages_background(
public_key: str, public_key: str,
contact_name: str | None, contact_name: str | None,
) -> None: ) -> None:
"""Run prefix promotion and contact/message reconciliation outside the radio critical path.""" """Run contact/message reconciliation outside the radio critical path."""
try: try:
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=public_key,
log=logger,
)
await reconcile_contact_messages( await reconcile_contact_messages(
public_key=public_key, public_key=public_key,
contact_name=contact_name, contact_name=contact_name,
log=logger, log=logger,
) )
if promoted_keys:
contact = await ContactRepository.get_by_key(public_key.lower())
if contact is not None:
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{"previous_public_key": old_key, "contact": contact.model_dump()},
)
except Exception as exc: except Exception as exc:
logger.warning( logger.warning(
"Background contact reconciliation failed for %s: %s", "Background contact reconciliation failed for %s: %s",
@@ -156,15 +140,6 @@ ADVERT_CHECK_INTERVAL = 60
# more frequently than this. # more frequently than this.
MIN_ADVERT_INTERVAL = 3600 MIN_ADVERT_INTERVAL = 3600
# Periodic telemetry collection task handle
_telemetry_collect_task: asyncio.Task | None = None
# Telemetry collection interval (8 hours)
TELEMETRY_COLLECT_INTERVAL = 8 * 3600
# Initial delay before the first telemetry collection cycle (let radio settle)
TELEMETRY_COLLECT_INITIAL_DELAY = 60
# Counter to pause polling during repeater operations (supports nested pauses) # Counter to pause polling during repeater operations (supports nested pauses)
_polling_pause_count: int = 0 _polling_pause_count: int = 0
@@ -204,22 +179,6 @@ RADIO_CONTACT_REFILL_RATIO = 0.80
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95 RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
def _effective_radio_capacity(configured: int) -> int:
"""Return the effective radio contact capacity.
Uses the lower of the user-configured ``max_radio_contacts`` and the
hardware limit reported by the radio at connect time. The existing
80% refill ratio already reserves headroom for the radio to
organically add contacts it hears via adverts, so no additional
reduction is applied here.
"""
capacity = max(1, configured)
hw_limit = radio_manager.max_contacts
if hw_limit is not None:
capacity = min(capacity, hw_limit)
return max(1, capacity)
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]: def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
"""Return (refill_target, full_sync_trigger) for the configured capacity.""" """Return (refill_target, full_sync_trigger) for the configured capacity."""
capacity = max(1, max_contacts) capacity = max(1, max_contacts)
@@ -234,7 +193,7 @@ def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
async def should_run_full_periodic_sync(mc: MeshCore) -> bool: async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
"""Check current radio occupancy and decide whether to offload/reload.""" """Check current radio occupancy and decide whether to offload/reload."""
app_settings = await AppSettingsRepository.get() app_settings = await AppSettingsRepository.get()
capacity = _effective_radio_capacity(app_settings.max_radio_contacts) capacity = app_settings.max_radio_contacts
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity) refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
result = await mc.commands.get_contacts() result = await mc.commands.get_contacts()
@@ -263,6 +222,70 @@ async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
return False return False
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
"""
Sync contacts from radio to database, then remove them from radio.
Returns counts of synced and removed contacts.
"""
synced = 0
removed = 0
try:
# Get all contacts from radio
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.error(
"Failed to get contacts from radio: %s. "
"If you see this repeatedly, the radio may be visible on the "
"serial/TCP/BLE port but not responding to commands. Check for "
"another process with the serial port open (other RemoteTerm "
"instances, serial monitors, etc.), verify the firmware is "
"up-to-date and in client mode (not repeater), or try a "
"power cycle.",
result,
)
return {"synced": 0, "removed": 0, "error": str(result)}
contacts = result.payload or {}
logger.info("Found %d contacts on radio", len(contacts))
# Sync each contact to database, then remove from radio
for public_key, contact_data in contacts.items():
# Save to database
await ContactRepository.upsert(
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
)
asyncio.create_task(
_reconcile_contact_messages_background(
public_key,
contact_data.get("adv_name"),
)
)
synced += 1
# Remove from radio
try:
remove_result = await mc.commands.remove_contact(contact_data)
if remove_result.type == EventType.OK:
removed += 1
_evict_removed_contact_from_library_cache(mc, public_key)
else:
logger.warning(
"Failed to remove contact %s: %s", public_key[:12], remove_result.payload
)
except Exception as e:
logger.warning("Error removing contact %s: %s", public_key[:12], e)
logger.info("Synced %d contacts, removed %d from radio", synced, removed)
except Exception as e:
logger.error("Error during contact sync: %s", e)
return {"synced": synced, "removed": removed, "error": str(e)}
return {"synced": synced, "removed": removed}
async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = None) -> 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. Sync channels from radio to database, then clear them from radio.
@@ -307,7 +330,7 @@ async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = Non
except Exception as e: except Exception as e:
logger.warning("Error clearing channel %d: %s", idx, e) logger.warning("Error clearing channel %d: %s", idx, e)
logger.debug("Synced %d channels, cleared %d from radio", synced, cleared) logger.info("Synced %d channels, cleared %d from radio", synced, cleared)
except Exception as e: except Exception as e:
logger.error("Error during channel sync: %s", e) logger.error("Error during channel sync: %s", e)
@@ -428,6 +451,7 @@ async def ensure_default_channels() -> None:
async def sync_and_offload_all(mc: MeshCore) -> dict: async def sync_and_offload_all(mc: MeshCore) -> dict:
"""Run fast startup sync, then background contact reconcile.""" """Run fast startup sync, then background contact reconcile."""
logger.info("Starting full radio sync and offload")
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload # 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. # cycle so old rows stop claiming radio residency we do not actively track.
@@ -943,8 +967,10 @@ async def sync_radio_time(mc: MeshCore) -> bool:
except Exception: except Exception:
logger.warning("Reboot command failed", exc_info=True) logger.warning("Reboot command failed", exc_info=True)
elif _clock_reboot_attempted: elif _clock_reboot_attempted:
logger.debug( logger.warning(
"Clock skew persists after reboot (hardware RTC); ignoring until next session." "Clock skew persists after reboot — the radio likely has a "
"hardware RTC that preserved the wrong time. A manual "
"'clkreboot' CLI command is needed to reset it."
) )
return False return False
@@ -1054,7 +1080,7 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
return {"synced": 0, "radio_contacts": {}, "error": str(result)} return {"synced": 0, "radio_contacts": {}, "error": str(result)}
contacts = _normalize_radio_contacts_payload(result.payload) contacts = _normalize_radio_contacts_payload(result.payload)
logger.debug("Found %d contacts on radio", len(contacts)) logger.info("Found %d contacts on radio", len(contacts))
for public_key, contact_data in contacts.items(): for public_key, contact_data in contacts.items():
await ContactRepository.upsert( await ContactRepository.upsert(
@@ -1068,29 +1094,7 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
) )
synced += 1 synced += 1
logger.debug("Synced %d contacts from radio snapshot", synced) logger.info("Synced %d contacts from radio snapshot", synced)
# Import radio-favorited contacts into app favorites.
# Only trust the favorite bit on contacts with a valid type (0-4);
# garbled radio data can have junk flags with bit 0 set.
radio_fav_keys = [
pk
for pk, data in contacts.items()
if data.get("flags", 0) & 0x01 and data.get("type", -1) in _VALID_CONTACT_TYPES
]
if radio_fav_keys:
try:
imported = 0
for pk in radio_fav_keys:
existing = await ContactRepository.get_by_key(pk)
if existing and not existing.favorite:
await ContactRepository.set_favorite(pk, True)
imported += 1
if imported:
logger.info("Imported %d radio favorite(s) into app favorites", imported)
except Exception as e:
logger.warning("Failed to import radio favorites: %s", e)
return {"synced": synced, "radio_contacts": contacts} return {"synced": synced, "radio_contacts": contacts}
except Exception as e: except Exception as e:
logger.error("Error during contact snapshot sync: %s", e) logger.error("Error during contact snapshot sync: %s", e)
@@ -1297,14 +1301,31 @@ async def stop_background_contact_reconciliation() -> None:
async def get_contacts_selected_for_radio_sync() -> list[Contact]: async def get_contacts_selected_for_radio_sync() -> list[Contact]:
"""Return the contacts that would be loaded onto the radio right now.""" """Return the contacts that would be loaded onto the radio right now."""
app_settings = await AppSettingsRepository.get() app_settings = await AppSettingsRepository.get()
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts) max_contacts = app_settings.max_radio_contacts
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts) refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
selected_contacts: list[Contact] = [] selected_contacts: list[Contact] = []
selected_keys: set[str] = set() selected_keys: set[str] = set()
# Favorites first — always loaded up to max_contacts
favorite_contacts_loaded = 0 favorite_contacts_loaded = 0
for contact in await ContactRepository.get_favorites(): for favorite in app_settings.favorites:
if favorite.type != "contact":
continue
try:
contact = await ContactRepository.get_by_key_or_prefix(favorite.id)
except AmbiguousPublicKeyPrefixError:
logger.warning(
"Skipping favorite contact '%s': ambiguous key prefix; use full key",
favorite.id,
)
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() key = contact.public_key.lower()
if key in selected_keys: if key in selected_keys:
continue continue
@@ -1536,158 +1557,3 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
except Exception as e: except Exception as e:
logger.error("Error syncing contacts to radio: %s", e, exc_info=True) logger.error("Error syncing contacts to radio: %s", e, exc_info=True)
return {"loaded": 0, "error": str(e)} return {"loaded": 0, "error": str(e)}
# ---------------------------------------------------------------------------
# Periodic repeater telemetry collection
# ---------------------------------------------------------------------------
async def _collect_repeater_telemetry(mc: MeshCore, contact: Contact) -> bool:
"""Fetch status telemetry from a single repeater and record it.
Returns True on success, False on failure (logged, not raised).
"""
try:
await mc.commands.add_contact(contact.to_radio_dict())
status = await mc.commands.req_status_sync(contact.public_key, timeout=10, min_timeout=5)
except Exception as e:
logger.debug(
"Telemetry collect: radio command failed for %s: %s",
contact.public_key[:12],
e,
)
return False
if status is None:
logger.debug("Telemetry collect: no response from %s", contact.public_key[:12])
return False
# Map to the same field names as the manual repeater status endpoint
data = {
"battery_volts": status.get("bat", 0) / 1000.0,
"tx_queue_len": status.get("tx_queue_len", 0),
"noise_floor_dbm": status.get("noise_floor", 0),
"last_rssi_dbm": status.get("last_rssi", 0),
"last_snr_db": status.get("last_snr", 0.0),
"packets_received": status.get("nb_recv", 0),
"packets_sent": status.get("nb_sent", 0),
"airtime_seconds": status.get("airtime", 0),
"rx_airtime_seconds": status.get("rx_airtime", 0),
"uptime_seconds": status.get("uptime", 0),
"sent_flood": status.get("sent_flood", 0),
"sent_direct": status.get("sent_direct", 0),
"recv_flood": status.get("recv_flood", 0),
"recv_direct": status.get("recv_direct", 0),
"flood_dups": status.get("flood_dups", 0),
"direct_dups": status.get("direct_dups", 0),
"full_events": status.get("full_evts", 0),
}
try:
await RepeaterTelemetryRepository.record(
public_key=contact.public_key,
timestamp=int(time.time()),
data=data,
)
logger.info(
"Telemetry collect: recorded snapshot for %s (%s)",
contact.name or contact.public_key[:12],
contact.public_key[:12],
)
return True
except Exception as e:
logger.warning(
"Telemetry collect: failed to record for %s: %s",
contact.public_key[:12],
e,
)
return False
async def _telemetry_collect_loop() -> None:
"""Background task that collects telemetry from tracked repeaters every 8 hours.
Runs a first cycle after a short initial delay (so newly tracked repeaters
get a sample promptly), then sleeps the full interval between subsequent cycles.
Acquires the radio lock per-repeater (non-blocking) so manual operations can
interleave. Failures are logged and skipped.
"""
first_run = True
while True:
try:
delay = TELEMETRY_COLLECT_INITIAL_DELAY if first_run else TELEMETRY_COLLECT_INTERVAL
await asyncio.sleep(delay)
first_run = False
if not radio_manager.is_connected:
logger.debug("Telemetry collect: radio not connected, skipping cycle")
continue
app_settings = await AppSettingsRepository.get()
tracked = app_settings.tracked_telemetry_repeaters
if not tracked:
continue
logger.info("Telemetry collect: starting cycle for %d repeater(s)", len(tracked))
collected = 0
for pub_key in tracked:
contact = await ContactRepository.get_by_key(pub_key)
if not contact or contact.type != 2:
logger.debug(
"Telemetry collect: skipping %s (not found or not repeater)",
pub_key[:12],
)
continue
try:
async with radio_manager.radio_operation(
"telemetry_collect",
blocking=False,
suspend_auto_fetch=True,
) as mc:
if await _collect_repeater_telemetry(mc, contact):
collected += 1
except RadioOperationBusyError:
logger.debug(
"Telemetry collect: radio busy, skipping %s",
pub_key[:12],
)
logger.info(
"Telemetry collect: cycle complete, %d/%d successful",
collected,
len(tracked),
)
except asyncio.CancelledError:
logger.info("Telemetry collect task cancelled")
break
except Exception as e:
logger.error("Error in telemetry collect loop: %s", e, exc_info=True)
def start_telemetry_collect() -> None:
"""Start the periodic telemetry collection background task."""
global _telemetry_collect_task
if _telemetry_collect_task is None or _telemetry_collect_task.done():
_telemetry_collect_task = asyncio.create_task(_telemetry_collect_loop())
logger.info(
"Started periodic telemetry collection (interval: %ds)",
TELEMETRY_COLLECT_INTERVAL,
)
async def stop_telemetry_collect() -> None:
"""Stop the periodic telemetry collection background task."""
global _telemetry_collect_task
if _telemetry_collect_task and not _telemetry_collect_task.done():
_telemetry_collect_task.cancel()
try:
await _telemetry_collect_task
except asyncio.CancelledError:
pass
_telemetry_collect_task = None
logger.info("Stopped periodic telemetry collection")
-2
View File
@@ -8,7 +8,6 @@ from app.repository.contacts import (
from app.repository.fanout import FanoutConfigRepository from app.repository.fanout import FanoutConfigRepository
from app.repository.messages import MessageRepository from app.repository.messages import MessageRepository
from app.repository.raw_packets import RawPacketRepository from app.repository.raw_packets import RawPacketRepository
from app.repository.repeater_telemetry import RepeaterTelemetryRepository
from app.repository.settings import AppSettingsRepository, StatisticsRepository from app.repository.settings import AppSettingsRepository, StatisticsRepository
__all__ = [ __all__ = [
@@ -21,6 +20,5 @@ __all__ = [
"FanoutConfigRepository", "FanoutConfigRepository",
"MessageRepository", "MessageRepository",
"RawPacketRepository", "RawPacketRepository",
"RepeaterTelemetryRepository",
"StatisticsRepository", "StatisticsRepository",
] ]
+22 -22
View File
@@ -26,7 +26,7 @@ class ChannelRepository:
"""Get a channel by its key (32-char hex string).""" """Get a channel by its key (32-char hex string)."""
cursor = await db.conn.execute( cursor = await db.conn.execute(
""" """
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
FROM channels FROM channels
WHERE key = ? WHERE key = ?
""", """,
@@ -40,9 +40,7 @@ class ChannelRepository:
is_hashtag=bool(row["is_hashtag"]), is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]), on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"], flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"], last_read_at=row["last_read_at"],
favorite=bool(row["favorite"]),
) )
return None return None
@@ -50,7 +48,7 @@ class ChannelRepository:
async def get_all() -> list[Channel]: async def get_all() -> list[Channel]:
cursor = await db.conn.execute( cursor = await db.conn.execute(
""" """
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
FROM channels FROM channels
ORDER BY name ORDER BY name
""" """
@@ -63,22 +61,34 @@ class ChannelRepository:
is_hashtag=bool(row["is_hashtag"]), is_hashtag=bool(row["is_hashtag"]),
on_radio=bool(row["on_radio"]), on_radio=bool(row["on_radio"]),
flood_scope_override=row["flood_scope_override"], flood_scope_override=row["flood_scope_override"],
path_hash_mode_override=row["path_hash_mode_override"],
last_read_at=row["last_read_at"], last_read_at=row["last_read_at"],
favorite=bool(row["favorite"]),
) )
for row in rows for row in rows
] ]
@staticmethod @staticmethod
async def set_favorite(key: str, value: bool) -> bool: async def get_on_radio() -> list[Channel]:
"""Set or clear the favorite flag for a channel. Returns True if row was found.""" """Return channels currently marked as resident on the radio in the database."""
cursor = await db.conn.execute( cursor = await db.conn.execute(
"UPDATE channels SET favorite = ? WHERE key = ?", """
(1 if value else 0, key.upper()), SELECT key, name, is_hashtag, on_radio, flood_scope_override, last_read_at
FROM channels
WHERE on_radio = 1
ORDER BY name
"""
) )
await db.conn.commit() rows = await cursor.fetchall()
return cursor.rowcount > 0 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 @staticmethod
async def delete(key: str) -> None: async def delete(key: str) -> None:
@@ -113,16 +123,6 @@ class ChannelRepository:
await db.conn.commit() await db.conn.commit()
return cursor.rowcount > 0 return cursor.rowcount > 0
@staticmethod
async def update_path_hash_mode_override(key: str, path_hash_mode_override: int | None) -> bool:
"""Set or clear a channel's path hash mode override."""
cursor = await db.conn.execute(
"UPDATE channels SET path_hash_mode_override = ? WHERE key = ?",
(path_hash_mode_override, key.upper()),
)
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod @staticmethod
async def mark_all_read(timestamp: int) -> None: async def mark_all_read(timestamp: int) -> None:
"""Mark all channels as read at the given timestamp.""" """Mark all channels as read at the given timestamp."""
+61 -96
View File
@@ -1,4 +1,3 @@
import logging
import time import time
from collections.abc import Mapping from collections.abc import Mapping
from typing import Any from typing import Any
@@ -13,8 +12,6 @@ from app.models import (
) )
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
logger = logging.getLogger(__name__)
class AmbiguousPublicKeyPrefixError(ValueError): class AmbiguousPublicKeyPrefixError(ValueError):
"""Raised when a public key prefix matches multiple contacts.""" """Raised when a public key prefix matches multiple contacts."""
@@ -170,7 +167,6 @@ class ContactRepository:
lon=row["lon"], lon=row["lon"],
last_seen=row["last_seen"], last_seen=row["last_seen"],
on_radio=bool(row["on_radio"]), on_radio=bool(row["on_radio"]),
favorite=bool(row["favorite"]) if "favorite" in available_columns else False,
last_contacted=row["last_contacted"], last_contacted=row["last_contacted"],
last_read_at=row["last_read_at"], last_read_at=row["last_read_at"],
first_seen=row["first_seen"], first_seen=row["first_seen"],
@@ -393,30 +389,15 @@ class ContactRepository:
) )
await db.conn.commit() await db.conn.commit()
@staticmethod
async def get_favorites() -> list[Contact]:
"""Return all contacts marked as favorite."""
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE favorite = 1 AND LENGTH(public_key) = 64"
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def set_favorite(public_key: str, value: bool) -> None:
"""Set or clear the favorite flag for a contact."""
await db.conn.execute(
"UPDATE contacts SET favorite = ? WHERE public_key = ?",
(1 if value else 0, public_key.lower()),
)
await db.conn.commit()
@staticmethod @staticmethod
async def delete(public_key: str) -> None: async def delete(public_key: str) -> None:
normalized = public_key.lower() normalized = public_key.lower()
# contact_name_history and contact_advert_paths cascade via FK. await db.conn.execute(
# Messages are intentionally preserved so history re-surfaces "DELETE FROM contact_name_history WHERE public_key = ?", (normalized,)
# if the contact is re-added later. )
await db.conn.execute(
"DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,)
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,)) await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
await db.conn.commit() await db.conn.commit()
@@ -503,6 +484,7 @@ class ContactRepository:
return [] return []
promoted_keys: list[str] = [] promoted_keys: list[str] = []
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
for row in rows: for row in rows:
old_key = row["public_key"] old_key = row["public_key"]
@@ -519,70 +501,60 @@ class ContactRepository:
(old_key,), (old_key,),
) )
match_row = await match_cursor.fetchone() match_row = await match_cursor.fetchone()
match_count = match_row["match_count"] if match_row is not None else 0 if (match_row["match_count"] if match_row is not None else 0) != 1:
if match_count != 1:
logger.warning(
"Skipping prefix promotion for %s: %d full-key contacts match (expected 1)",
old_key,
match_count,
)
continue continue
await migrate_child_rows(old_key, normalized_full_key) await migrate_child_rows(old_key, normalized_full_key)
# Merge timestamp metadata from the old prefix contact into the if full_exists:
# full-key contact (which all callers guarantee already exists), await db.conn.execute(
# then delete the prefix placeholder. """
await db.conn.execute( UPDATE contacts
""" SET last_seen = CASE
UPDATE contacts WHEN contacts.last_seen IS NULL THEN ?
SET last_seen = CASE WHEN ? IS NULL THEN contacts.last_seen
WHEN contacts.last_seen IS NULL THEN ? WHEN ? > contacts.last_seen THEN ?
WHEN ? IS NULL THEN contacts.last_seen ELSE contacts.last_seen
WHEN ? > contacts.last_seen THEN ? END,
ELSE contacts.last_seen last_contacted = CASE
END, WHEN contacts.last_contacted IS NULL THEN ?
last_contacted = CASE WHEN ? IS NULL THEN contacts.last_contacted
WHEN contacts.last_contacted IS NULL THEN ? WHEN ? > contacts.last_contacted THEN ?
WHEN ? IS NULL THEN contacts.last_contacted ELSE contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ? END,
ELSE contacts.last_contacted first_seen = CASE
END, WHEN contacts.first_seen IS NULL THEN ?
first_seen = CASE WHEN ? IS NULL THEN contacts.first_seen
WHEN contacts.first_seen IS NULL THEN ? WHEN ? < contacts.first_seen THEN ?
WHEN ? IS NULL THEN contacts.first_seen ELSE contacts.first_seen
WHEN ? < contacts.first_seen THEN ? END,
ELSE contacts.first_seen last_read_at = COALESCE(contacts.last_read_at, ?)
END, WHERE public_key = ?
last_read_at = CASE """,
WHEN contacts.last_read_at IS NULL THEN ? (
WHEN ? IS NULL THEN contacts.last_read_at row["last_seen"],
WHEN ? > contacts.last_read_at THEN ? row["last_seen"],
ELSE contacts.last_read_at row["last_seen"],
END row["last_seen"],
WHERE public_key = ? row["last_contacted"],
""", row["last_contacted"],
( row["last_contacted"],
row["last_seen"], row["last_contacted"],
row["last_seen"], row["first_seen"],
row["last_seen"], row["first_seen"],
row["last_seen"], row["first_seen"],
row["last_contacted"], row["first_seen"],
row["last_contacted"], row["last_read_at"],
row["last_contacted"], normalized_full_key,
row["last_contacted"], ),
row["first_seen"], )
row["first_seen"], await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
row["first_seen"], else:
row["first_seen"], await db.conn.execute(
row["last_read_at"], "UPDATE contacts SET public_key = ? WHERE public_key = ?",
row["last_read_at"], (normalized_full_key, old_key),
row["last_read_at"], )
row["last_read_at"], full_exists = True
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
promoted_keys.append(old_key) promoted_keys.append(old_key)
@@ -692,18 +664,9 @@ class ContactAdvertPathRepository:
cursor = await db.conn.execute( cursor = await db.conn.execute(
""" """
SELECT public_key, path_hex, path_len, first_seen, last_seen, heard_count SELECT public_key, path_hex, path_len, first_seen, last_seen, heard_count
FROM ( FROM contact_advert_paths
SELECT *,
ROW_NUMBER() OVER (
PARTITION BY public_key
ORDER BY last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
) AS rn
FROM contact_advert_paths
)
WHERE rn <= ?
ORDER BY public_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC ORDER BY public_key ASC, last_seen DESC, heard_count DESC, path_len ASC, path_hex ASC
""", """
(limit_per_contact,),
) )
rows = await cursor.fetchall() rows = await cursor.fetchall()
@@ -714,6 +677,8 @@ class ContactAdvertPathRepository:
if paths is None: if paths is None:
paths = [] paths = []
grouped[key] = paths grouped[key] = paths
if len(paths) >= limit_per_contact:
continue
paths.append(ContactAdvertPathRepository._row_to_path(row)) paths.append(ContactAdvertPathRepository._row_to_path(row))
return [ return [
+18 -54
View File
@@ -29,7 +29,8 @@ class MessageRepository:
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]: def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
lower_key = public_key.lower() lower_key = public_key.lower()
return ( return (
"((type = 'PRIV' AND conversation_key = ?) OR (type = 'CHAN' AND sender_key = ?))", "((type = 'PRIV' AND LOWER(conversation_key) = ?)"
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
[lower_key, lower_key], [lower_key, lower_key],
) )
@@ -57,8 +58,6 @@ class MessageRepository:
sender_timestamp: int | None = None, sender_timestamp: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
txt_type: int = 0, txt_type: int = 0,
signature: str | None = None, signature: str | None = None,
outgoing: bool = False, outgoing: bool = False,
@@ -80,15 +79,8 @@ class MessageRepository:
entry: dict = {"path": path, "received_at": received_at} entry: dict = {"path": path, "received_at": received_at}
if path_len is not None: if path_len is not None:
entry["path_len"] = path_len entry["path_len"] = path_len
if rssi is not None:
entry["rssi"] = rssi
if snr is not None:
entry["snr"] = snr
paths_json = json.dumps([entry]) paths_json = json.dumps([entry])
# Normalize sender_key to lowercase so queries can match without LOWER().
normalized_sender_key = sender_key.lower() if sender_key else sender_key
cursor = await db.conn.execute( cursor = await db.conn.execute(
""" """
INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp, INSERT OR IGNORE INTO messages (type, conversation_key, text, sender_timestamp,
@@ -107,7 +99,7 @@ class MessageRepository:
signature, signature,
outgoing, outgoing,
sender_name, sender_name,
normalized_sender_key, sender_key,
), ),
) )
await db.conn.commit() await db.conn.commit()
@@ -122,8 +114,6 @@ class MessageRepository:
path: str, path: str,
received_at: int | None = None, received_at: int | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
) -> list[MessagePath]: ) -> list[MessagePath]:
"""Add a new path to an existing message. """Add a new path to an existing message.
@@ -137,10 +127,6 @@ class MessageRepository:
entry: dict = {"path": path, "received_at": ts} entry: dict = {"path": path, "received_at": ts}
if path_len is not None: if path_len is not None:
entry["path_len"] = path_len entry["path_len"] = path_len
if rssi is not None:
entry["rssi"] = rssi
if snr is not None:
entry["snr"] = snr
new_entry = json.dumps(entry) new_entry = json.dumps(entry)
await db.conn.execute( await db.conn.execute(
"""UPDATE messages SET paths = json_insert( """UPDATE messages SET paths = json_insert(
@@ -172,11 +158,7 @@ class MessageRepository:
""" """
lower_key = full_key.lower() lower_key = full_key.lower()
cursor = await db.conn.execute( cursor = await db.conn.execute(
"""UPDATE messages SET conversation_key = ?, """UPDATE messages SET conversation_key = ?
sender_key = CASE
WHEN sender_key IS NOT NULL AND length(sender_key) < 64
AND ? LIKE sender_key || '%'
THEN ? ELSE sender_key END
WHERE type = 'PRIV' AND length(conversation_key) < 64 WHERE type = 'PRIV' AND length(conversation_key) < 64
AND ? LIKE conversation_key || '%' AND ? LIKE conversation_key || '%'
AND ( AND (
@@ -184,7 +166,7 @@ class MessageRepository:
WHERE length(public_key) = 64 WHERE length(public_key) = 64
AND public_key LIKE messages.conversation_key || '%' AND public_key LIKE messages.conversation_key || '%'
) = 1""", ) = 1""",
(lower_key, lower_key, lower_key, lower_key), (lower_key, lower_key),
) )
await db.conn.commit() await db.conn.commit()
return cursor.rowcount return cursor.rowcount
@@ -273,10 +255,10 @@ class MessageRepository:
if MessageRepository._looks_like_hex_prefix(value): if MessageRepository._looks_like_hex_prefix(value):
if len(value) == 32: if len(value) == 32:
clause += " OR messages.conversation_key = ?" clause += " OR UPPER(messages.conversation_key) = ?"
params.append(value.upper()) params.append(value.upper())
else: else:
clause += " OR messages.conversation_key LIKE ? ESCAPE '\\'" clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'"
params.append(f"{MessageRepository._escape_like(value.upper())}%") params.append(f"{MessageRepository._escape_like(value.upper())}%")
clause += "))" clause += "))"
@@ -295,13 +277,13 @@ class MessageRepository:
priv_key_clause: str priv_key_clause: str
chan_key_clause: str chan_key_clause: str
if len(value) == 64: if len(value) == 64:
priv_key_clause = "messages.conversation_key = ?" priv_key_clause = "LOWER(messages.conversation_key) = ?"
chan_key_clause = "sender_key = ?" chan_key_clause = "LOWER(sender_key) = ?"
params.extend([lower_value, lower_value]) params.extend([lower_value, lower_value])
else: else:
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%" escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
priv_key_clause = "messages.conversation_key LIKE ? ESCAPE '\\'" priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'"
chan_key_clause = "sender_key LIKE ? ESCAPE '\\'" chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'"
params.extend([escaped_prefix, escaped_prefix]) params.extend([escaped_prefix, escaped_prefix])
clause += ( clause += (
@@ -325,12 +307,12 @@ class MessageRepository:
if blocked_keys: if blocked_keys:
placeholders = ",".join("?" for _ in blocked_keys) placeholders = ",".join("?" for _ in blocked_keys)
blocked_matchers.append( blocked_matchers.append(
f"({prefix}type = 'PRIV' AND {prefix}conversation_key IN ({placeholders}))" f"({prefix}type = 'PRIV' AND LOWER({prefix}conversation_key) IN ({placeholders}))"
) )
params.extend(blocked_keys) params.extend(blocked_keys)
blocked_matchers.append( blocked_matchers.append(
f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL" f"({prefix}type = 'CHAN' AND {prefix}sender_key IS NOT NULL"
f" AND {prefix}sender_key IN ({placeholders}))" f" AND LOWER({prefix}sender_key) IN ({placeholders}))"
) )
params.extend(blocked_keys) params.extend(blocked_keys)
@@ -397,9 +379,9 @@ class MessageRepository:
query = ( query = (
f"SELECT {MessageRepository._message_select('messages')} FROM messages " f"SELECT {MessageRepository._message_select('messages')} FROM messages "
"LEFT JOIN contacts ON messages.type = 'PRIV' " "LEFT JOIN contacts ON messages.type = 'PRIV' "
"AND messages.conversation_key = contacts.public_key " "AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
"LEFT JOIN channels ON messages.type = 'CHAN' " "LEFT JOIN channels ON messages.type = 'CHAN' "
"AND messages.conversation_key = channels.key " "AND UPPER(messages.conversation_key) = UPPER(channels.key) "
"WHERE 1=1" "WHERE 1=1"
) )
params: list[Any] = [] params: list[Any] = []
@@ -590,9 +572,6 @@ class MessageRepository:
@staticmethod @staticmethod
async def delete_by_id(message_id: int) -> None: async def delete_by_id(message_id: int) -> None:
"""Delete a message row by ID.""" """Delete a message row by ID."""
await db.conn.execute(
"UPDATE raw_packets SET message_id = NULL WHERE message_id = ?", (message_id,)
)
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,)) await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
await db.conn.commit() await db.conn.commit()
@@ -687,7 +666,7 @@ class MessageRepository:
ELSE 0 ELSE 0
END) > 0 as has_mention END) > 0 as has_mention
FROM messages m FROM messages m
LEFT JOIN contacts ct ON m.conversation_key = ct.public_key JOIN contacts ct ON m.conversation_key = ct.public_key
WHERE m.type = 'PRIV' AND m.outgoing = 0 WHERE m.type = 'PRIV' AND m.outgoing = 0
AND m.received_at > COALESCE(ct.last_read_at, 0) AND m.received_at > COALESCE(ct.last_read_at, 0)
{blocked_sql} {blocked_sql}
@@ -798,14 +777,12 @@ class MessageRepository:
@staticmethod @staticmethod
async def get_channel_stats(conversation_key: str) -> dict: async def get_channel_stats(conversation_key: str) -> dict:
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders, path hash widths. """Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h, path_hash_width_24h. Returns a dict with message_counts, first_message_at, unique_sender_count, top_senders_24h.
""" """
import time as _time import time as _time
from app.path_utils import bucket_path_hash_widths
now = int(_time.time()) now = int(_time.time())
t_1h = now - 3600 t_1h = now - 3600
t_24h = now - 86400 t_24h = now - 86400
@@ -857,24 +834,11 @@ class MessageRepository:
for r in top_rows for r in top_rows
] ]
# Path hash width distribution for last 24h (in-Python parse of raw packet envelopes)
cursor3 = await db.conn.execute(
"""
SELECT rp.data FROM raw_packets rp
JOIN messages m ON rp.message_id = m.id
WHERE m.type = 'CHAN' AND m.conversation_key = ?
AND rp.timestamp >= ?
""",
(conversation_key, t_24h),
)
path_hash_width_24h = await bucket_path_hash_widths(cursor3)
return { return {
"message_counts": message_counts, "message_counts": message_counts,
"first_message_at": row["first_message_at"], "first_message_at": row["first_message_at"],
"unique_sender_count": row["unique_sender_count"] or 0, "unique_sender_count": row["unique_sender_count"] or 0,
"top_senders_24h": top_senders, "top_senders_24h": top_senders,
"path_hash_width_24h": path_hash_width_24h,
} }
@staticmethod @staticmethod
+50 -26
View File
@@ -1,4 +1,5 @@
import logging import logging
import sqlite3
import time import time
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from hashlib import sha256 from hashlib import sha256
@@ -34,23 +35,46 @@ class RawPacketRepository:
# For malformed packets, hash the full data # For malformed packets, hash the full data
payload_hash = sha256(data).digest() payload_hash = sha256(data).digest()
cursor = await db.conn.execute( # Check if this payload already exists
"INSERT OR IGNORE INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
(ts, data, payload_hash),
)
await db.conn.commit()
if cursor.rowcount > 0:
assert cursor.lastrowid is not None
return (cursor.lastrowid, True)
# Duplicate payload — look up the existing row.
cursor = await db.conn.execute( cursor = await db.conn.execute(
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,) "SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
) )
existing = await cursor.fetchone() existing = await cursor.fetchone()
assert existing is not None
return (existing["id"], False) if existing:
# Duplicate - return existing packet ID
logger.debug(
"Duplicate payload detected (hash=%s..., existing_id=%d)",
payload_hash.hex()[:12],
existing["id"],
)
return (existing["id"], False)
# New packet - insert with hash
try:
cursor = await db.conn.execute(
"INSERT INTO raw_packets (timestamp, data, payload_hash) VALUES (?, ?, ?)",
(ts, data, payload_hash),
)
await db.conn.commit()
assert cursor.lastrowid is not None # INSERT always returns a row ID
return (cursor.lastrowid, True)
except sqlite3.IntegrityError:
# Race condition: another insert with same payload_hash happened between
# our SELECT and INSERT. This is expected for duplicate packets arriving
# close together. Query again to get the existing ID.
logger.debug(
"Duplicate packet detected via race condition (payload_hash=%s), dropping",
payload_hash.hex()[:16],
)
cursor = await db.conn.execute(
"SELECT id FROM raw_packets WHERE payload_hash = ?", (payload_hash,)
)
existing = await cursor.fetchone()
if existing:
return (existing["id"], False)
# This shouldn't happen, but if it does, re-raise
raise
@staticmethod @staticmethod
async def get_undecrypted_count() -> int: async def get_undecrypted_count() -> int:
@@ -71,22 +95,13 @@ class RawPacketRepository:
return row["oldest"] if row and row["oldest"] is not None else None return row["oldest"] if row and row["oldest"] is not None else None
@staticmethod @staticmethod
async def stream_all_undecrypted( async def get_all_undecrypted() -> list[tuple[int, bytes, int]]:
batch_size: int = UNDECRYPTED_PACKET_BATCH_SIZE, """Get all undecrypted packets as (id, data, timestamp) tuples."""
) -> AsyncIterator[tuple[int, bytes, int]]:
"""Yield all undecrypted packets as (id, data, timestamp) in bounded batches."""
cursor = await db.conn.execute( cursor = await db.conn.execute(
"SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC" "SELECT id, data, timestamp FROM raw_packets WHERE message_id IS NULL ORDER BY timestamp ASC"
) )
try: rows = await cursor.fetchall()
while True: return [(row["id"], bytes(row["data"]), row["timestamp"]) for row in rows]
rows = await cursor.fetchmany(batch_size)
if not rows:
break
for row in rows:
yield (row["id"], bytes(row["data"]), row["timestamp"])
finally:
await cursor.close()
@staticmethod @staticmethod
async def stream_undecrypted_text_messages( async def stream_undecrypted_text_messages(
@@ -172,3 +187,12 @@ class RawPacketRepository:
cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL") cursor = await db.conn.execute("DELETE FROM raw_packets WHERE message_id IS NOT NULL")
await db.conn.commit() await db.conn.commit()
return cursor.rowcount return cursor.rowcount
@staticmethod
async def get_undecrypted_text_messages() -> list[tuple[int, bytes, int]]:
"""Get all undecrypted TEXT_MESSAGE packets as (id, data, timestamp) tuples.
Filters raw packets to only include those with PayloadType.TEXT_MESSAGE (0x02).
These are direct messages that can be decrypted with contact ECDH keys.
"""
return [packet async for packet in RawPacketRepository.stream_undecrypted_text_messages()]
-75
View File
@@ -1,75 +0,0 @@
import json
import logging
import time
from app.database import db
logger = logging.getLogger(__name__)
# Maximum age for telemetry history entries (30 days)
_MAX_AGE_SECONDS = 30 * 86400
# Maximum entries to keep per repeater (sanity cap)
_MAX_ENTRIES_PER_REPEATER = 1000
class RepeaterTelemetryRepository:
@staticmethod
async def record(
public_key: str,
timestamp: int,
data: dict,
) -> None:
"""Insert a telemetry history row and prune stale entries."""
await db.conn.execute(
"""
INSERT INTO repeater_telemetry_history
(public_key, timestamp, data)
VALUES (?, ?, ?)
""",
(public_key, timestamp, json.dumps(data)),
)
# Prune entries older than 30 days
cutoff = int(time.time()) - _MAX_AGE_SECONDS
await db.conn.execute(
"DELETE FROM repeater_telemetry_history WHERE public_key = ? AND timestamp < ?",
(public_key, cutoff),
)
# Cap at _MAX_ENTRIES_PER_REPEATER (keep newest)
await db.conn.execute(
"""
DELETE FROM repeater_telemetry_history
WHERE public_key = ? AND id NOT IN (
SELECT id FROM repeater_telemetry_history
WHERE public_key = ?
ORDER BY timestamp DESC
LIMIT ?
)
""",
(public_key, public_key, _MAX_ENTRIES_PER_REPEATER),
)
await db.conn.commit()
@staticmethod
async def get_history(public_key: str, since_timestamp: int) -> list[dict]:
"""Return telemetry rows for a repeater since a given timestamp, ordered ASC."""
cursor = await db.conn.execute(
"""
SELECT timestamp, data
FROM repeater_telemetry_history
WHERE public_key = ? AND timestamp >= ?
ORDER BY timestamp ASC
""",
(public_key, since_timestamp),
)
rows = await cursor.fetchall()
return [
{
"timestamp": row["timestamp"],
"data": json.loads(row["data"]),
}
for row in rows
]
+139 -70
View File
@@ -1,17 +1,16 @@
import json import json
import logging import logging
import time import time
from typing import Any from typing import Any, Literal
from app.database import db from app.database import db
from app.models import AppSettings from app.models import AppSettings, Favorite
from app.path_utils import bucket_path_hash_widths from app.path_utils import parse_packet_envelope
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
SECONDS_1H = 3600 SECONDS_1H = 3600
SECONDS_24H = 86400 SECONDS_24H = 86400
SECONDS_72H = 259200
SECONDS_7D = 604800 SECONDS_7D = 604800
RAW_PACKET_STATS_BATCH_SIZE = 500 RAW_PACKET_STATS_BATCH_SIZE = 500
@@ -27,11 +26,10 @@ class AppSettingsRepository:
""" """
cursor = await db.conn.execute( cursor = await db.conn.execute(
""" """
SELECT max_radio_contacts, auto_decrypt_dm_on_advert, SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
last_message_times, sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, flood_scope, advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types, blocked_keys, blocked_names
tracked_telemetry_repeaters, auto_resend_channel
FROM app_settings WHERE id = 1 FROM app_settings WHERE id = 1
""" """
) )
@@ -41,6 +39,20 @@ class AppSettingsRepository:
# Should not happen after migration, but handle gracefully # Should not happen after migration, but handle gracefully
return AppSettings() return AppSettings()
# Parse favorites JSON
favorites = []
if row["favorites"]:
try:
favorites_data = json.loads(row["favorites"])
favorites = [Favorite(**f) for f in favorites_data]
except (json.JSONDecodeError, TypeError, KeyError) as e:
logger.warning(
"Failed to parse favorites JSON, using empty list: %s (data=%r)",
e,
row["favorites"][:100] if row["favorites"] else None,
)
favorites = []
# Parse last_message_times JSON # Parse last_message_times JSON
last_message_times: dict[str, int] = {} last_message_times: dict[str, int] = {}
if row["last_message_times"]: if row["last_message_times"]:
@@ -69,56 +81,38 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
blocked_names = [] blocked_names = []
# Parse discovery_blocked_types JSON # Validate sidebar_sort_order (fallback to "recent" if invalid)
discovery_blocked_types: list[int] = [] sort_order = row["sidebar_sort_order"]
if row["discovery_blocked_types"]: if sort_order not in ("recent", "alpha"):
try: sort_order = "recent"
discovery_blocked_types = json.loads(row["discovery_blocked_types"])
except (json.JSONDecodeError, TypeError):
discovery_blocked_types = []
# Parse tracked_telemetry_repeaters JSON
tracked_telemetry_repeaters: list[str] = []
try:
raw_tracked = row["tracked_telemetry_repeaters"]
if raw_tracked:
tracked_telemetry_repeaters = json.loads(raw_tracked)
except (json.JSONDecodeError, TypeError, KeyError):
tracked_telemetry_repeaters = []
# Parse auto_resend_channel boolean
try:
auto_resend_channel = bool(row["auto_resend_channel"])
except (KeyError, TypeError):
auto_resend_channel = False
return AppSettings( return AppSettings(
max_radio_contacts=row["max_radio_contacts"], max_radio_contacts=row["max_radio_contacts"],
favorites=favorites,
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]), auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
sidebar_sort_order=sort_order,
last_message_times=last_message_times, last_message_times=last_message_times,
preferences_migrated=bool(row["preferences_migrated"]),
advert_interval=row["advert_interval"] or 0, advert_interval=row["advert_interval"] or 0,
last_advert_time=row["last_advert_time"] or 0, last_advert_time=row["last_advert_time"] or 0,
flood_scope=row["flood_scope"] or "", flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys, blocked_keys=blocked_keys,
blocked_names=blocked_names, blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
tracked_telemetry_repeaters=tracked_telemetry_repeaters,
auto_resend_channel=auto_resend_channel,
) )
@staticmethod @staticmethod
async def update( async def update(
max_radio_contacts: int | None = None, max_radio_contacts: int | None = None,
favorites: list[Favorite] | None = None,
auto_decrypt_dm_on_advert: bool | None = None, auto_decrypt_dm_on_advert: bool | None = None,
sidebar_sort_order: str | None = None,
last_message_times: dict[str, int] | None = None, last_message_times: dict[str, int] | None = None,
preferences_migrated: bool | None = None,
advert_interval: int | None = None, advert_interval: int | None = None,
last_advert_time: int | None = None, last_advert_time: int | None = None,
flood_scope: str | None = None, flood_scope: str | None = None,
blocked_keys: list[str] | None = None, blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None, blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
tracked_telemetry_repeaters: list[str] | None = None,
auto_resend_channel: bool | None = None,
) -> AppSettings: ) -> AppSettings:
"""Update app settings. Only provided fields are updated.""" """Update app settings. Only provided fields are updated."""
updates = [] updates = []
@@ -128,14 +122,27 @@ class AppSettingsRepository:
updates.append("max_radio_contacts = ?") updates.append("max_radio_contacts = ?")
params.append(max_radio_contacts) params.append(max_radio_contacts)
if favorites is not None:
updates.append("favorites = ?")
favorites_json = json.dumps([f.model_dump() for f in favorites])
params.append(favorites_json)
if auto_decrypt_dm_on_advert is not None: if auto_decrypt_dm_on_advert is not None:
updates.append("auto_decrypt_dm_on_advert = ?") updates.append("auto_decrypt_dm_on_advert = ?")
params.append(1 if auto_decrypt_dm_on_advert else 0) params.append(1 if auto_decrypt_dm_on_advert else 0)
if sidebar_sort_order is not None:
updates.append("sidebar_sort_order = ?")
params.append(sidebar_sort_order)
if last_message_times is not None: if last_message_times is not None:
updates.append("last_message_times = ?") updates.append("last_message_times = ?")
params.append(json.dumps(last_message_times)) params.append(json.dumps(last_message_times))
if preferences_migrated is not None:
updates.append("preferences_migrated = ?")
params.append(1 if preferences_migrated else 0)
if advert_interval is not None: if advert_interval is not None:
updates.append("advert_interval = ?") updates.append("advert_interval = ?")
params.append(advert_interval) params.append(advert_interval)
@@ -156,18 +163,6 @@ class AppSettingsRepository:
updates.append("blocked_names = ?") updates.append("blocked_names = ?")
params.append(json.dumps(blocked_names)) params.append(json.dumps(blocked_names))
if discovery_blocked_types is not None:
updates.append("discovery_blocked_types = ?")
params.append(json.dumps(discovery_blocked_types))
if tracked_telemetry_repeaters is not None:
updates.append("tracked_telemetry_repeaters = ?")
params.append(json.dumps(tracked_telemetry_repeaters))
if auto_resend_channel is not None:
updates.append("auto_resend_channel = ?")
params.append(1 if auto_resend_channel else 0)
if updates: if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1" query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params) await db.conn.execute(query, params)
@@ -175,6 +170,27 @@ class AppSettingsRepository:
return await AppSettingsRepository.get() return await AppSettingsRepository.get()
@staticmethod
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
"""Add a favorite, avoiding duplicates."""
settings = await AppSettingsRepository.get()
# Check if already favorited
if any(f.type == fav_type and f.id == fav_id for f in settings.favorites):
return settings
new_favorites = settings.favorites + [Favorite(type=fav_type, id=fav_id)]
return await AppSettingsRepository.update(favorites=new_favorites)
@staticmethod
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
"""Remove a favorite."""
settings = await AppSettingsRepository.get()
new_favorites = [
f for f in settings.favorites if not (f.type == fav_type and f.id == fav_id)
]
return await AppSettingsRepository.update(favorites=new_favorites)
@staticmethod @staticmethod
async def toggle_blocked_key(key: str) -> AppSettings: async def toggle_blocked_key(key: str) -> AppSettings:
"""Toggle a public key in the blocked list. Keys are normalized to lowercase.""" """Toggle a public key in the blocked list. Keys are normalized to lowercase."""
@@ -196,6 +212,39 @@ class AppSettingsRepository:
new_names = settings.blocked_names + [name] new_names = settings.blocked_names + [name]
return await AppSettingsRepository.update(blocked_names=new_names) return await AppSettingsRepository.update(blocked_names=new_names)
@staticmethod
async def migrate_preferences_from_frontend(
favorites: list[dict],
sort_order: str,
last_message_times: dict[str, int],
) -> tuple[AppSettings, bool]:
"""Migrate all preferences from frontend localStorage.
This is a one-time migration. If already migrated, returns current settings
without overwriting. Returns (settings, did_migrate) tuple.
"""
settings = await AppSettingsRepository.get()
if settings.preferences_migrated:
# Already migrated, don't overwrite
return settings, False
# Convert frontend favorites format to Favorite objects
new_favorites = []
for f in favorites:
if f.get("type") in ("channel", "contact") and f.get("id"):
new_favorites.append(Favorite(type=f["type"], id=f["id"]))
# Update with migrated preferences and mark as migrated
settings = await AppSettingsRepository.update(
favorites=new_favorites,
sidebar_sort_order=sort_order if sort_order in ("recent", "alpha") else "recent",
last_message_times=last_message_times,
preferences_migrated=True,
)
return settings, True
class StatisticsRepository: class StatisticsRepository:
@staticmethod @staticmethod
@@ -275,25 +324,6 @@ class StatisticsRepository:
"last_week": row["last_week"] or 0, "last_week": row["last_week"] or 0,
} }
@staticmethod
async def _packets_per_hour_72h() -> list[dict[str, int]]:
"""Return packet counts bucketed by hour for the last 72 hours."""
now = int(time.time())
cutoff = now - SECONDS_72H
# Bucket timestamps to the start of each hour
cursor = await db.conn.execute(
"""
SELECT (timestamp / 3600) * 3600 AS hour_ts, COUNT(*) AS count
FROM raw_packets
WHERE timestamp >= ?
GROUP BY hour_ts
ORDER BY hour_ts
""",
(cutoff,),
)
rows = await cursor.fetchall()
return [{"timestamp": row["hour_ts"], "count": row["count"]} for row in rows]
@staticmethod @staticmethod
async def _path_hash_width_24h() -> dict[str, int | float]: async def _path_hash_width_24h() -> dict[str, int | float]:
"""Count parsed raw packets from the last 24h by hop hash width.""" """Count parsed raw packets from the last 24h by hop hash width."""
@@ -302,7 +332,48 @@ class StatisticsRepository:
"SELECT data FROM raw_packets WHERE timestamp >= ?", "SELECT data FROM raw_packets WHERE timestamp >= ?",
(now - SECONDS_24H,), (now - SECONDS_24H,),
) )
return await bucket_path_hash_widths(cursor, batch_size=RAW_PACKET_STATS_BATCH_SIZE)
single_byte = 0
double_byte = 0
triple_byte = 0
while True:
rows = await cursor.fetchmany(RAW_PACKET_STATS_BATCH_SIZE)
if not rows:
break
for row in rows:
envelope = parse_packet_envelope(bytes(row["data"]))
if envelope is None:
continue
if envelope.hash_size == 1:
single_byte += 1
elif envelope.hash_size == 2:
double_byte += 1
elif envelope.hash_size == 3:
triple_byte += 1
total_packets = single_byte + double_byte + triple_byte
if total_packets == 0:
return {
"total_packets": 0,
"single_byte": 0,
"double_byte": 0,
"triple_byte": 0,
"single_byte_pct": 0.0,
"double_byte_pct": 0.0,
"triple_byte_pct": 0.0,
}
return {
"total_packets": total_packets,
"single_byte": single_byte,
"double_byte": double_byte,
"triple_byte": triple_byte,
"single_byte_pct": (single_byte / total_packets) * 100,
"double_byte_pct": (double_byte / total_packets) * 100,
"triple_byte_pct": (triple_byte / total_packets) * 100,
}
@staticmethod @staticmethod
async def get_all() -> dict: async def get_all() -> dict:
@@ -370,7 +441,6 @@ class StatisticsRepository:
repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2) repeaters_heard = await StatisticsRepository._activity_counts(contact_type=2)
known_channels_active = await StatisticsRepository._known_channels_active() known_channels_active = await StatisticsRepository._known_channels_active()
path_hash_width_24h = await StatisticsRepository._path_hash_width_24h() path_hash_width_24h = await StatisticsRepository._path_hash_width_24h()
packets_per_hour_72h = await StatisticsRepository._packets_per_hour_72h()
return { return {
"busiest_channels_24h": busiest_channels_24h, "busiest_channels_24h": busiest_channels_24h,
@@ -387,5 +457,4 @@ class StatisticsRepository:
"repeaters_heard": repeaters_heard, "repeaters_heard": repeaters_heard,
"known_channels_active": known_channels_active, "known_channels_active": known_channels_active,
"path_hash_width_24h": path_hash_width_24h, "path_hash_width_24h": path_hash_width_24h,
"packets_per_hour_72h": packets_per_hour_72h,
} }
+47 -260
View File
@@ -1,8 +1,7 @@
import logging import logging
import re
from hashlib import sha256 from hashlib import sha256
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.channel_constants import ( from app.channel_constants import (
@@ -11,12 +10,10 @@ from app.channel_constants import (
is_public_channel_key, is_public_channel_key,
is_public_channel_name, is_public_channel_name,
) )
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.packet_processor import create_message_from_decrypted
from app.region_scope import normalize_region_scope from app.region_scope import normalize_region_scope
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository from app.repository import ChannelRepository, MessageRepository
from app.websocket import broadcast_event, broadcast_success from app.websocket import broadcast_event
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"]) router = APIRouter(prefix="/channels", tags=["channels"])
@@ -34,166 +31,12 @@ class CreateChannelRequest(BaseModel):
) )
class BulkCreateHashtagChannelsRequest(BaseModel):
channel_names: list[str] = Field(
min_length=1,
description="List of hashtag room names. Leading # is optional per entry.",
)
try_historical: bool = Field(
default=False,
description="Attempt one background historical decrypt sweep for the newly added rooms.",
)
class BulkCreateHashtagChannelsResponse(BaseModel):
created_channels: list[Channel]
existing_count: int
invalid_names: list[str]
decrypt_started: bool = False
decrypt_total_packets: int = 0
message: str
class ChannelFloodScopeOverrideRequest(BaseModel): class ChannelFloodScopeOverrideRequest(BaseModel):
flood_scope_override: str = Field( flood_scope_override: str = Field(
description="Blank clears the override; non-empty values temporarily override flood scope" description="Blank clears the override; non-empty values temporarily override flood scope"
) )
class ChannelPathHashModeOverrideRequest(BaseModel):
path_hash_mode_override: int | None = Field(
default=None,
ge=0,
le=2,
description="Path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
)
def _derive_channel_identity(
requested_name: str,
request_key: str | None = None,
) -> tuple[str, str, bool]:
is_hashtag = requested_name.startswith("#")
if is_public_channel_name(requested_name):
if request_key:
try:
key_bytes = bytes.fromhex(request_key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)",
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
raise HTTPException(
status_code=400,
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
)
return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False
if request_key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request_key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
key_hex = key_bytes.hex().upper()
if is_public_channel_key(key_hex):
raise HTTPException(
status_code=400,
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
)
return key_hex, requested_name, False
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
return key_bytes.hex().upper(), requested_name, is_hashtag
def _normalize_bulk_hashtag_name(name: str) -> str | None:
trimmed = name.strip()
if not trimmed:
return None
normalized = trimmed.lstrip("#").strip()
if not normalized:
return None
if len(normalized) > 31:
return None
if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized):
return None
return f"#{normalized}"
async def _run_historical_channel_decryption_for_channels(
channels: list[tuple[bytes, str, str]],
) -> None:
total = await RawPacketRepository.get_undecrypted_count()
decrypted_count = 0
matched_channel_names: set[str] = set()
if total == 0:
logger.info("No undecrypted packets to process for bulk channel decrypt")
return
logger.info(
"Starting bulk historical channel decryption of %d packets across %d channels",
total,
len(channels),
)
async for (
packet_id,
packet_data,
packet_timestamp,
) in RawPacketRepository.stream_all_undecrypted():
packet_info = parse_packet(packet_data)
path_hex = packet_info.path.hex() if packet_info else None
path_len = packet_info.path_length if packet_info else None
for channel_key_bytes, channel_key_hex, channel_name in channels:
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
if result is None:
continue
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel_key_hex,
channel_name=channel_name,
sender=result.sender,
message_text=result.message,
timestamp=result.timestamp,
received_at=packet_timestamp,
path=path_hex,
path_len=path_len,
realtime=False,
)
if msg_id is not None:
decrypted_count += 1
matched_channel_names.add(channel_name)
break
logger.info(
"Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels",
decrypted_count,
total,
len(matched_channel_names),
)
if decrypted_count > 0:
broadcast_success(
"Bulk historical decrypt complete",
(
f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} "
f"across {len(matched_channel_names)} room"
f"{'s' if len(matched_channel_names) != 1 else ''}"
),
)
@router.get("", response_model=list[Channel]) @router.get("", response_model=list[Channel])
async def list_channels() -> list[Channel]: async def list_channels() -> list[Channel]:
"""List all channels from the database.""" """List all channels from the database."""
@@ -215,7 +58,6 @@ async def get_channel_detail(key: str) -> ChannelDetail:
first_message_at=stats["first_message_at"], first_message_at=stats["first_message_at"],
unique_sender_count=stats["unique_sender_count"], unique_sender_count=stats["unique_sender_count"],
top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]], top_senders_24h=[ChannelTopSender(**s) for s in stats["top_senders_24h"]],
path_hash_width_24h=stats["path_hash_width_24h"],
) )
@@ -227,7 +69,50 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
automatically when sending a message (see messages.py send_channel_message). automatically when sending a message (see messages.py send_channel_message).
""" """
requested_name = request.name requested_name = request.name
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key) is_hashtag = requested_name.startswith("#")
# Reserve the canonical Public channel so it cannot drift to another key,
# and the well-known Public key cannot be renamed to something else.
if is_public_channel_name(requested_name):
if request.key:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)",
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
raise HTTPException(
status_code=400,
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
)
key_hex = PUBLIC_CHANNEL_KEY
channel_name = PUBLIC_CHANNEL_NAME
is_hashtag = False
elif request.key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
key_hex = key_bytes.hex().upper()
if is_public_channel_key(key_hex):
raise HTTPException(
status_code=400,
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
)
channel_name = requested_name
else:
# Derive key from name hash (same as meshcore library does)
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
key_hex = key_bytes.hex().upper()
channel_name = requested_name
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag) logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
@@ -247,81 +132,6 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
return stored return stored
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
async def bulk_create_hashtag_channels(
request: BulkCreateHashtagChannelsRequest,
background_tasks: BackgroundTasks,
response: Response,
) -> BulkCreateHashtagChannelsResponse:
created_channels: list[Channel] = []
existing_count = 0
invalid_names: list[str] = []
decrypt_started = False
decrypt_total_packets = 0
decrypt_targets: list[tuple[bytes, str, str]] = []
for raw_name in request.channel_names:
normalized_name = _normalize_bulk_hashtag_name(raw_name)
if normalized_name is None:
invalid_names.append(raw_name)
continue
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
existing = await ChannelRepository.get_by_key(key_hex)
if existing is not None:
existing_count += 1
continue
await ChannelRepository.upsert(
key=key_hex,
name=channel_name,
is_hashtag=is_hashtag,
on_radio=False,
)
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",
)
created_channels.append(stored)
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
_broadcast_channel_update(stored)
if request.try_historical and decrypt_targets:
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
if decrypt_total_packets > 0:
background_tasks.add_task(
_run_historical_channel_decryption_for_channels, decrypt_targets
)
decrypt_started = True
response.status_code = status.HTTP_202_ACCEPTED
message = (
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
if created_channels
else "No new rooms were added"
)
if request.try_historical and decrypt_targets:
if decrypt_started:
message += (
f" and started background decrypt of {decrypt_total_packets} packet"
f"{'s' if decrypt_total_packets != 1 else ''}"
)
else:
message += "; no undecrypted packets were available"
return BulkCreateHashtagChannelsResponse(
created_channels=created_channels,
existing_count=existing_count,
invalid_names=invalid_names,
decrypt_started=decrypt_started,
decrypt_total_packets=decrypt_total_packets,
message=message,
)
@router.post("/{key}/mark-read") @router.post("/{key}/mark-read")
async def mark_channel_read(key: str) -> dict: async def mark_channel_read(key: str) -> dict:
"""Mark a channel as read (update last_read_at timestamp).""" """Mark a channel as read (update last_read_at timestamp)."""
@@ -358,29 +168,6 @@ async def set_channel_flood_scope_override(
return refreshed return refreshed
@router.post("/{key}/path-hash-mode-override", response_model=Channel)
async def set_channel_path_hash_mode_override(
key: str, request: ChannelPathHashModeOverrideRequest
) -> Channel:
"""Set or clear a per-channel path hash mode override."""
channel = await ChannelRepository.get_by_key(key)
if not channel:
raise HTTPException(status_code=404, detail="Channel not found")
updated = await ChannelRepository.update_path_hash_mode_override(
channel.key, request.path_hash_mode_override
)
if not updated:
raise HTTPException(status_code=500, detail="Failed to update path-hash-mode override")
refreshed = await ChannelRepository.get_by_key(channel.key)
if refreshed is None:
raise HTTPException(status_code=500, detail="Channel disappeared after update")
broadcast_event("channel", refreshed.model_dump())
return refreshed
@router.delete("/{key}") @router.delete("/{key}")
async def delete_channel(key: str) -> dict: async def delete_channel(key: str) -> dict:
"""Delete a channel from the database by key. """Delete a channel from the database by key.
+6 -52
View File
@@ -1,13 +1,12 @@
import asyncio import asyncio
import logging import logging
import random import random
import time
from contextlib import suppress from contextlib import suppress
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from meshcore import EventType from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import ( from app.models import (
Contact, Contact,
ContactActiveRoom, ContactActiveRoom,
@@ -32,7 +31,7 @@ from app.repository import (
) )
from app.services.contact_reconciliation import ( from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact, promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile, reconcile_contact_messages,
) )
from app.services.radio_runtime import radio_runtime as radio_manager from app.services.radio_runtime import radio_runtime as radio_manager
@@ -278,18 +277,12 @@ async def create_contact(
# Check if contact already exists # Check if contact already exists
existing = await ContactRepository.get_by_key(request.public_key) existing = await ContactRepository.get_by_key(request.public_key)
if existing: if existing:
# Update name if provided and record name history # Update name if provided
if request.name: if request.name:
await ContactRepository.upsert(existing.to_upsert(name=request.name)) await ContactRepository.upsert(existing.to_upsert(name=request.name))
refreshed = await ContactRepository.get_by_key(request.public_key) refreshed = await ContactRepository.get_by_key(request.public_key)
if refreshed is not None: if refreshed is not None:
existing = refreshed existing = refreshed
await record_contact_name_and_reconcile(
public_key=request.public_key,
contact_name=request.name,
timestamp=int(time.time()),
log=logger,
)
promoted_keys = await promote_prefix_contacts_for_contact( promoted_keys = await promote_prefix_contacts_for_contact(
public_key=request.public_key, public_key=request.public_key,
@@ -324,10 +317,9 @@ async def create_contact(
log=logger, log=logger,
) )
await record_contact_name_and_reconcile( await reconcile_contact_messages(
public_key=lower_key, public_key=lower_key,
contact_name=request.name, contact_name=request.name,
timestamp=int(time.time()),
log=logger, log=logger,
) )
@@ -355,44 +347,6 @@ async def mark_contact_read(public_key: str) -> dict:
return {"status": "ok", "public_key": contact.public_key} return {"status": "ok", "public_key": contact.public_key}
class BulkDeleteRequest(BaseModel):
public_keys: list[str] = Field(description="Public keys to delete")
@router.post("/bulk-delete")
async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict:
"""Delete multiple contacts from the database (and radio if present)."""
from app.websocket import broadcast_event
# Resolve all contacts first
contacts_to_delete: list[Contact] = []
for key in request.public_keys:
contact = await ContactRepository.get_by_key(key.lower())
if contact:
contacts_to_delete.append(contact)
# Remove from radio in a single locked operation (blocks until radio is free)
if radio_manager.is_connected and contacts_to_delete:
try:
async with radio_manager.radio_operation("bulk_delete_contacts_from_radio") as mc:
for contact in contacts_to_delete:
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
await mc.commands.remove_contact(radio_contact)
except Exception as e:
logger.warning("Radio removal during bulk delete failed: %s", e)
# Delete from database and broadcast events
deleted = 0
for contact in contacts_to_delete:
await ContactRepository.delete(contact.public_key)
broadcast_event("contact_deleted", {"public_key": contact.public_key})
deleted += 1
logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys))
return {"deleted": deleted}
@router.delete("/{public_key}") @router.delete("/{public_key}")
async def delete_contact(public_key: str) -> dict: async def delete_contact(public_key: str) -> dict:
"""Delete a contact from the database (and radio if present).""" """Delete a contact from the database (and radio if present)."""
@@ -427,7 +381,7 @@ async def request_trace(public_key: str) -> TraceResponse:
(no intermediate repeaters). This uses TRACE's dedicated width flags rather (no intermediate repeaters). This uses TRACE's dedicated width flags rather
than the radio's normal path_hash_mode setting. than the radio's normal path_hash_mode setting.
""" """
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
@@ -486,7 +440,7 @@ async def request_trace(public_key: str) -> TraceResponse:
@router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse) @router.post("/{public_key}/path-discovery", response_model=PathDiscoveryResponse)
async def request_path_discovery(public_key: str) -> PathDiscoveryResponse: async def request_path_discovery(public_key: str) -> PathDiscoveryResponse:
"""Discover the current forward and return paths to a known contact.""" """Discover the current forward and return paths to a known contact."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
pubkey_prefix = contact.public_key[:12] pubkey_prefix = contact.public_key[:12]
+19 -138
View File
@@ -1,21 +1,17 @@
import hashlib import hashlib
import logging import logging
import os
import platform
import struct
import sys import sys
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Literal from typing import Any
from fastapi import APIRouter from fastapi import APIRouter
from meshcore import EventType from meshcore import EventType
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.config import get_recent_log_lines, settings from app.config import get_recent_log_lines, settings
from app.models import AppSettings
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository from app.repository import MessageRepository, StatisticsRepository
from app.routers.health import FanoutStatusResponse, build_health_data from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output from app.version_info import get_app_build_info, git_output
@@ -38,13 +34,6 @@ LOG_COPY_BOUNDARY_PREFIX = [
] ]
class DebugSystemInfo(BaseModel):
os: str
arch: str
arch_bits: int
total_ram_mb: int
class DebugApplicationInfo(BaseModel): class DebugApplicationInfo(BaseModel):
version: str version: str
version_source: str version_source: str
@@ -61,6 +50,8 @@ class DebugRuntimeInfo(BaseModel):
setup_in_progress: bool setup_in_progress: bool
setup_complete: bool setup_complete: bool
channels_with_incoming_messages: int channels_with_incoming_messages: int
max_channels: int
path_hash_mode: int
path_hash_mode_supported: bool path_hash_mode_supported: bool
channel_slot_reuse_enabled: bool channel_slot_reuse_enabled: bool
channel_send_cache_capacity: int channel_send_cache_capacity: int
@@ -87,6 +78,7 @@ class DebugChannelAudit(BaseModel):
class DebugRadioProbe(BaseModel): class DebugRadioProbe(BaseModel):
performed: bool performed: bool
errors: list[str] = Field(default_factory=list) errors: list[str] = Field(default_factory=list)
multi_acks_enabled: bool | None = None
self_info: dict[str, Any] | None = None self_info: dict[str, Any] | None = None
device_info: dict[str, Any] | None = None device_info: dict[str, Any] | None = None
stats_core: dict[str, Any] | None = None stats_core: dict[str, Any] | None = None
@@ -101,53 +93,16 @@ class DebugDatabaseInfo(BaseModel):
total_outgoing: int total_outgoing: int
class DebugHealthSummary(BaseModel):
radio_state: str
database_size_mb: float
oldest_undecrypted_timestamp: int | None
fanouts_with_errors: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
bots_disabled_source: Literal["env", "until_restart"] | None = None
basic_auth_enabled: bool = False
class DebugAppSettings(BaseModel):
max_radio_contacts: int
auto_decrypt_dm_on_advert: bool
advert_interval: int
flood_scope: str
blocked_keys_count: int
blocked_names_count: int
class DebugSnapshotResponse(BaseModel): class DebugSnapshotResponse(BaseModel):
captured_at: str captured_at: str
system: DebugSystemInfo
application: DebugApplicationInfo application: DebugApplicationInfo
health: DebugHealthSummary health: HealthResponse
settings: DebugAppSettings
runtime: DebugRuntimeInfo runtime: DebugRuntimeInfo
database: DebugDatabaseInfo database: DebugDatabaseInfo
radio_probe: DebugRadioProbe radio_probe: DebugRadioProbe
logs: list[str] logs: list[str]
def _build_system_info() -> DebugSystemInfo:
try:
# os.sysconf is available on Linux/macOS
page_size = os.sysconf("SC_PAGE_SIZE")
page_count = os.sysconf("SC_PHYS_PAGES")
total_ram_mb = (page_size * page_count) // (1024 * 1024)
except (AttributeError, ValueError, OSError):
total_ram_mb = 0
return DebugSystemInfo(
os=f"{platform.system()} {platform.release()}",
arch=platform.machine(),
arch_bits=struct.calcsize("P") * 8,
total_ram_mb=total_ram_mb,
)
def _build_application_info() -> DebugApplicationInfo: def _build_application_info() -> DebugApplicationInfo:
build_info = get_app_build_info() build_info = get_app_build_info()
dirty_output = git_output("status", "--porcelain") dirty_output = git_output("status", "--porcelain")
@@ -203,68 +158,6 @@ def _coerce_live_max_channels(device_info: dict[str, Any] | None) -> int | None:
return None return None
def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
return DebugAppSettings(
max_radio_contacts=app_settings.max_radio_contacts,
auto_decrypt_dm_on_advert=app_settings.auto_decrypt_dm_on_advert,
advert_interval=app_settings.advert_interval,
flood_scope=app_settings.flood_scope,
blocked_keys_count=len(app_settings.blocked_keys),
blocked_names_count=len(app_settings.blocked_names),
)
def _derive_debug_radio_state(
*,
radio_connected: bool,
connection_desired: bool,
setup_in_progress: bool,
setup_complete: bool,
is_reconnecting: bool,
) -> str:
if not connection_desired:
return "paused"
if radio_connected and (setup_in_progress or not setup_complete):
return "initializing"
if radio_connected:
return "connected"
if is_reconnecting:
return "connecting"
return "disconnected"
def _build_debug_health_summary(
health_data: dict[str, Any], *, radio_state: str
) -> DebugHealthSummary:
def _fanout_last_error(status: Any) -> str | None:
if isinstance(status, dict):
value = status.get("last_error")
else:
value = getattr(status, "last_error", None)
return value if isinstance(value, str) and value else None
fanouts_with_errors = {
config_id: status
for config_id, status in health_data["fanout_statuses"].items()
if _fanout_last_error(status)
}
return DebugHealthSummary(
radio_state=radio_state,
database_size_mb=health_data["database_size_mb"],
oldest_undecrypted_timestamp=health_data["oldest_undecrypted_timestamp"],
fanouts_with_errors=fanouts_with_errors,
bots_disabled_source=health_data["bots_disabled_source"],
basic_auth_enabled=health_data["basic_auth_enabled"],
)
def _sanitize_radio_probe_self_info(self_info: dict[str, Any] | None) -> dict[str, Any]:
sanitized = dict(self_info or {})
sanitized.pop("adv_lat", None)
sanitized.pop("adv_lon", None)
return sanitized
async def _build_contact_audit( async def _build_contact_audit(
observed_contacts_payload: dict[str, dict[str, Any]], observed_contacts_payload: dict[str, dict[str, Any]],
) -> DebugContactAudit: ) -> DebugContactAudit:
@@ -349,7 +242,10 @@ async def _probe_radio() -> DebugRadioProbe:
return DebugRadioProbe( return DebugRadioProbe(
performed=True, performed=True,
errors=errors, errors=errors,
self_info=_sanitize_radio_probe_self_info(mc.self_info), multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
if mc.self_info is not None
else None,
self_info=dict(mc.self_info or {}),
device_info=device_info, device_info=device_info,
stats_core=stats_core, stats_core=stats_core,
stats_radio=stats_radio, stats_radio=stats_radio,
@@ -368,39 +264,24 @@ async def _probe_radio() -> DebugRadioProbe:
@router.get("/debug", response_model=DebugSnapshotResponse) @router.get("/debug", response_model=DebugSnapshotResponse)
async def debug_support_snapshot() -> DebugSnapshotResponse: async def debug_support_snapshot() -> DebugSnapshotResponse:
"""Return a support/debug snapshot with recent logs and live radio state.""" """Return a support/debug snapshot with recent logs and live radio state."""
connection_info = radio_runtime.connection_info health_data = await build_health_data(radio_runtime.is_connected, 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
radio_connected = radio_runtime.is_connected
is_reconnecting = getattr(radio_runtime, "is_reconnecting", False)
health_data = await build_health_data(radio_connected, connection_info)
app_settings = await AppSettingsRepository.get()
message_totals = await StatisticsRepository.get_database_message_totals() message_totals = await StatisticsRepository.get_database_message_totals()
radio_probe = await _probe_radio() radio_probe = await _probe_radio()
channels_with_incoming_messages = ( channels_with_incoming_messages = (
await MessageRepository.count_channels_with_incoming_messages() await MessageRepository.count_channels_with_incoming_messages()
) )
radio_state = _derive_debug_radio_state(
radio_connected=radio_connected,
connection_desired=connection_desired,
setup_in_progress=setup_in_progress,
setup_complete=setup_complete,
is_reconnecting=is_reconnecting,
)
return DebugSnapshotResponse( return DebugSnapshotResponse(
captured_at=datetime.now(timezone.utc).isoformat(), captured_at=datetime.now(timezone.utc).isoformat(),
system=_build_system_info(),
application=_build_application_info(), application=_build_application_info(),
health=_build_debug_health_summary(health_data, radio_state=radio_state), health=HealthResponse(**health_data),
settings=_build_debug_app_settings(app_settings),
runtime=DebugRuntimeInfo( runtime=DebugRuntimeInfo(
connection_info=connection_info, connection_info=radio_runtime.connection_info,
connection_desired=connection_desired, connection_desired=radio_runtime.connection_desired,
setup_in_progress=setup_in_progress, setup_in_progress=radio_runtime.is_setup_in_progress,
setup_complete=setup_complete, setup_complete=radio_runtime.is_setup_complete,
channels_with_incoming_messages=channels_with_incoming_messages, 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, path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(), channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(), channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
+4 -3
View File
@@ -3,6 +3,7 @@ import time
from fastapi import APIRouter, HTTPException, Query from fastapi import APIRouter, HTTPException, Query
from app.dependencies import require_connected
from app.event_handlers import track_pending_ack from app.event_handlers import track_pending_ack
from app.models import ( from app.models import (
Message, Message,
@@ -88,7 +89,7 @@ async def list_messages(
@router.post("/direct", response_model=Message) @router.post("/direct", response_model=Message)
async def send_direct_message(request: SendDirectMessageRequest) -> Message: async def send_direct_message(request: SendDirectMessageRequest) -> Message:
"""Send a direct message to a contact.""" """Send a direct message to a contact."""
radio_manager.require_connected() require_connected()
# First check our database for the contact # First check our database for the contact
from app.repository import ContactRepository from app.repository import ContactRepository
@@ -135,7 +136,7 @@ TEMP_RADIO_SLOT = 0
@router.post("/channel", response_model=Message) @router.post("/channel", response_model=Message)
async def send_channel_message(request: SendChannelMessageRequest) -> Message: async def send_channel_message(request: SendChannelMessageRequest) -> Message:
"""Send a message to a channel.""" """Send a message to a channel."""
radio_manager.require_connected() require_connected()
# Get channel info from our database # Get channel info from our database
from app.repository import ChannelRepository from app.repository import ChannelRepository
@@ -188,7 +189,7 @@ async def resend_channel_message(
When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a When new_timestamp=True: resend with a fresh timestamp so repeaters treat it as a
new packet. Creates a new message row in the database. No time window restriction. new packet. Creates a new message row in the database. No time window restriction.
""" """
radio_manager.require_connected() require_connected()
from app.repository import ChannelRepository from app.repository import ChannelRepository
+3 -6
View File
@@ -49,7 +49,8 @@ async def _run_historical_channel_decryption(
channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None channel_key_bytes: bytes, channel_key_hex: str, display_name: str | None = None
) -> None: ) -> None:
"""Background task to decrypt historical packets with a channel key.""" """Background task to decrypt historical packets with a channel key."""
total = await RawPacketRepository.get_undecrypted_count() packets = await RawPacketRepository.get_all_undecrypted()
total = len(packets)
decrypted_count = 0 decrypted_count = 0
if total == 0: if total == 0:
@@ -58,11 +59,7 @@ async def _run_historical_channel_decryption(
logger.info("Starting historical channel decryption of %d packets", total) logger.info("Starting historical channel decryption of %d packets", total)
async for ( for packet_id, packet_data, packet_timestamp in packets:
packet_id,
packet_data,
packet_timestamp,
) in RawPacketRepository.stream_all_undecrypted():
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes) result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
if result is not None: if result is not None:
+14 -21
View File
@@ -9,6 +9,7 @@ from fastapi import APIRouter, HTTPException
from meshcore import EventType from meshcore import EventType
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import ( from app.models import (
CONTACT_TYPE_REPEATER, CONTACT_TYPE_REPEATER,
ContactUpsert, ContactUpsert,
@@ -23,11 +24,7 @@ from app.models import (
from app.radio_sync import send_advertisement as do_send_advertisement from app.radio_sync import send_advertisement as do_send_advertisement
from app.radio_sync import sync_radio_time from app.radio_sync import sync_radio_time
from app.repository import ContactRepository from app.repository import ContactRepository
from app.routers.server_control import _monotonic from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.radio_commands import ( from app.services.radio_commands import (
KeystoreRefreshError, KeystoreRefreshError,
PathHashModeUnsupportedError, PathHashModeUnsupportedError,
@@ -136,6 +133,10 @@ class RadioAdvertiseRequest(BaseModel):
) )
def _monotonic() -> float:
return time.monotonic()
def _better_signal(first: float | None, second: float | None) -> float | None: def _better_signal(first: float | None, second: float | None) -> float | None:
if first is None: if first is None:
return second return second
@@ -213,19 +214,11 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
public_key=result.public_key, public_key=result.public_key,
log=logger, log=logger,
) )
await reconcile_contact_messages(
public_key=result.public_key,
contact_name=result.name,
log=logger,
)
created = await ContactRepository.get_by_key(result.public_key) created = await ContactRepository.get_by_key(result.public_key)
if created is not None: if created is not None:
broadcast_event("contact", created.model_dump()) broadcast_event("contact", created.model_dump())
for old_key in promoted_keys: for old_key in promoted_keys:
broadcast_event( broadcast_event("contact_deleted", {"public_key": old_key})
"contact_resolved",
{"previous_public_key": old_key, "contact": created.model_dump()},
)
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None: async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
@@ -334,7 +327,7 @@ async def _resolve_trace_hops(
@router.get("/config", response_model=RadioConfigResponse) @router.get("/config", response_model=RadioConfigResponse)
async def get_radio_config() -> RadioConfigResponse: async def get_radio_config() -> RadioConfigResponse:
"""Get the current radio configuration.""" """Get the current radio configuration."""
mc = radio_manager.require_connected() mc = require_connected()
info = mc.self_info info = mc.self_info
if not info: if not info:
@@ -366,7 +359,7 @@ async def get_radio_config() -> RadioConfigResponse:
@router.patch("/config", response_model=RadioConfigResponse) @router.patch("/config", response_model=RadioConfigResponse)
async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse: async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
"""Update radio configuration. Only provided fields will be updated.""" """Update radio configuration. Only provided fields will be updated."""
radio_manager.require_connected() require_connected()
async with radio_manager.radio_operation("update_radio_config") as mc: async with radio_manager.radio_operation("update_radio_config") as mc:
try: try:
@@ -388,7 +381,7 @@ async def update_radio_config(update: RadioConfigUpdate) -> RadioConfigResponse:
@router.put("/private-key") @router.put("/private-key")
async def set_private_key(update: PrivateKeyUpdate) -> dict: async def set_private_key(update: PrivateKeyUpdate) -> dict:
"""Set the radio's private key. This is write-only.""" """Set the radio's private key. This is write-only."""
radio_manager.require_connected() require_connected()
try: try:
key_bytes = bytes.fromhex(update.private_key) key_bytes = bytes.fromhex(update.private_key)
@@ -422,7 +415,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
Returns: Returns:
status: "ok" if sent successfully status: "ok" if sent successfully
""" """
radio_manager.require_connected() require_connected()
mode: RadioAdvertMode = request.mode if request is not None else "flood" mode: RadioAdvertMode = request.mode if request is not None else "flood"
logger.info("Sending %s advertisement", mode.replace("_", "-")) logger.info("Sending %s advertisement", mode.replace("_", "-"))
@@ -438,7 +431,7 @@ async def send_advertisement(request: RadioAdvertiseRequest | None = None) -> di
@router.post("/discover", response_model=RadioDiscoveryResponse) @router.post("/discover", response_model=RadioDiscoveryResponse)
async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse: async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryResponse:
"""Run a short node-discovery sweep from the local radio.""" """Run a short node-discovery sweep from the local radio."""
radio_manager.require_connected() require_connected()
target_bits = _DISCOVERY_TARGET_BITS[request.target] target_bits = _DISCOVERY_TARGET_BITS[request.target]
tag = random.randint(1, 0xFFFFFFFF) tag = random.randint(1, 0xFFFFFFFF)
@@ -505,7 +498,7 @@ async def discover_mesh(request: RadioDiscoveryRequest) -> RadioDiscoveryRespons
@router.post("/trace", response_model=RadioTraceResponse) @router.post("/trace", response_model=RadioTraceResponse)
async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse: async def trace_path(request: RadioTraceRequest) -> RadioTraceResponse:
"""Send a multi-hop trace loop through known repeaters and back to the local radio.""" """Send a multi-hop trace loop through known repeaters and back to the local radio."""
radio_manager.require_connected() require_connected()
trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes) trace_nodes, requested_hashes = await _resolve_trace_hops(request.hops, request.hop_hash_bytes)
tag = random.randint(1, 0xFFFFFFFF) tag = random.randint(1, 0xFFFFFFFF)
+18 -50
View File
@@ -1,8 +1,8 @@
import logging import logging
import time
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from app.dependencies import require_connected
from app.models import ( from app.models import (
CONTACT_TYPE_REPEATER, CONTACT_TYPE_REPEATER,
AclEntry, AclEntry,
@@ -21,12 +21,12 @@ from app.models import (
RepeaterOwnerInfoResponse, RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse, RepeaterRadioSettingsResponse,
RepeaterStatusResponse, RepeaterStatusResponse,
TelemetryHistoryEntry,
) )
from app.repository import ContactRepository, RepeaterTelemetryRepository from app.repository import ContactRepository
from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404 from app.routers.contacts import _ensure_on_radio, _resolve_contact_or_404
from app.routers.server_control import ( from app.routers.server_control import (
batch_cli_fetch, batch_cli_fetch,
extract_response_text,
prepare_authenticated_contact_connection, prepare_authenticated_contact_connection,
require_server_capable_contact, require_server_capable_contact,
send_contact_cli_command, send_contact_cli_command,
@@ -46,6 +46,10 @@ router = APIRouter(prefix="/contacts", tags=["repeaters"])
REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0 REPEATER_LOGIN_RESPONSE_TIMEOUT_SECONDS = 5.0
def _extract_response_text(event) -> str:
return extract_response_text(event)
async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse: async def prepare_repeater_connection(mc, contact: Contact, password: str) -> RepeaterLoginResponse:
return await prepare_authenticated_contact_connection( return await prepare_authenticated_contact_connection(
mc, mc,
@@ -74,7 +78,7 @@ def _require_repeater(contact: Contact) -> None:
@router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse) @router.post("/{public_key}/repeater/login", response_model=RepeaterLoginResponse)
async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse: async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
"""Attempt repeater login and report whether auth was confirmed.""" """Attempt repeater login and report whether auth was confirmed."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -89,7 +93,7 @@ async def repeater_login(public_key: str, request: RepeaterLoginRequest) -> Repe
@router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse) @router.post("/{public_key}/repeater/status", response_model=RepeaterStatusResponse)
async def repeater_status(public_key: str) -> RepeaterStatusResponse: async def repeater_status(public_key: str) -> RepeaterStatusResponse:
"""Fetch status telemetry from a repeater (single attempt, 10s timeout).""" """Fetch status telemetry from a repeater (single attempt, 10s timeout)."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -104,7 +108,7 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
if status is None: if status is None:
raise HTTPException(status_code=504, detail="No status response from repeater") raise HTTPException(status_code=504, detail="No status response from repeater")
response = RepeaterStatusResponse( return RepeaterStatusResponse(
battery_volts=status.get("bat", 0) / 1000.0, battery_volts=status.get("bat", 0) / 1000.0,
tx_queue_len=status.get("tx_queue_len", 0), tx_queue_len=status.get("tx_queue_len", 0),
noise_floor_dbm=status.get("noise_floor", 0), noise_floor_dbm=status.get("noise_floor", 0),
@@ -124,47 +128,11 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
full_events=status.get("full_evts", 0), full_events=status.get("full_evts", 0),
) )
# Record to telemetry history as a JSON blob (best-effort)
now = int(time.time())
status_dict = response.model_dump(exclude={"telemetry_history"})
try:
await RepeaterTelemetryRepository.record(
public_key=contact.public_key,
timestamp=now,
data=status_dict,
)
except Exception as e:
logger.warning("Failed to record telemetry history: %s", e)
# Fetch recent history and embed in response
try:
since = now - 30 * 86400 # last 30 days
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
response.telemetry_history = [TelemetryHistoryEntry(**row) for row in rows]
except Exception as e:
logger.warning("Failed to fetch telemetry history: %s", e)
return response
@router.get(
"/{public_key}/repeater/telemetry-history",
response_model=list[TelemetryHistoryEntry],
)
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
"""Return stored telemetry history for a repeater (read-only, no radio access)."""
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
since = int(time.time()) - 30 * 86400
rows = await RepeaterTelemetryRepository.get_history(contact.public_key, since)
return [TelemetryHistoryEntry(**row) for row in rows]
@router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse) @router.post("/{public_key}/repeater/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse: async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
"""Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout).""" """Fetch CayenneLPP sensor telemetry from a repeater (single attempt, 10s timeout)."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -193,7 +161,7 @@ async def repeater_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryRespons
@router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse) @router.post("/{public_key}/repeater/neighbors", response_model=RepeaterNeighborsResponse)
async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse: async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
"""Fetch neighbors from a repeater (single attempt, 10s timeout).""" """Fetch neighbors from a repeater (single attempt, 10s timeout)."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -227,7 +195,7 @@ async def repeater_neighbors(public_key: str) -> RepeaterNeighborsResponse:
@router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse) @router.post("/{public_key}/repeater/acl", response_model=RepeaterAclResponse)
async def repeater_acl(public_key: str) -> RepeaterAclResponse: async def repeater_acl(public_key: str) -> RepeaterAclResponse:
"""Fetch ACL from a repeater (single attempt, 10s timeout).""" """Fetch ACL from a repeater (single attempt, 10s timeout)."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -268,7 +236,7 @@ async def _batch_cli_fetch(
@router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse) @router.post("/{public_key}/repeater/node-info", response_model=RepeaterNodeInfoResponse)
async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse: async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
"""Fetch repeater identity/location info via a small CLI batch.""" """Fetch repeater identity/location info via a small CLI batch."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -288,7 +256,7 @@ async def repeater_node_info(public_key: str) -> RepeaterNodeInfoResponse:
@router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse) @router.post("/{public_key}/repeater/radio-settings", response_model=RepeaterRadioSettingsResponse)
async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse: async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsResponse:
"""Fetch radio settings from a repeater via radio/config CLI commands.""" """Fetch radio settings from a repeater via radio/config CLI commands."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -312,7 +280,7 @@ async def repeater_radio_settings(public_key: str) -> RepeaterRadioSettingsRespo
) )
async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse: async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsResponse:
"""Fetch advertisement intervals from a repeater via CLI commands.""" """Fetch advertisement intervals from a repeater via CLI commands."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -330,7 +298,7 @@ async def repeater_advert_intervals(public_key: str) -> RepeaterAdvertIntervalsR
@router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse) @router.post("/{public_key}/repeater/owner-info", response_model=RepeaterOwnerInfoResponse)
async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse: async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
"""Fetch owner info and guest password from a repeater via CLI commands.""" """Fetch owner info and guest password from a repeater via CLI commands."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact) _require_repeater(contact)
@@ -348,7 +316,7 @@ async def repeater_owner_info(public_key: str) -> RepeaterOwnerInfoResponse:
@router.post("/{public_key}/command", response_model=CommandResponse) @router.post("/{public_key}/command", response_model=CommandResponse)
async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse: async def send_repeater_command(public_key: str, request: CommandRequest) -> CommandResponse:
"""Send a CLI command to a repeater or room server.""" """Send a CLI command to a repeater or room server."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
require_server_capable_contact(contact) require_server_capable_contact(contact)
+5 -4
View File
@@ -1,5 +1,6 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from app.dependencies import require_connected
from app.models import ( from app.models import (
CONTACT_TYPE_ROOM, CONTACT_TYPE_ROOM,
AclEntry, AclEntry,
@@ -27,7 +28,7 @@ def _require_room(contact) -> None:
@router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse) @router.post("/{public_key}/room/login", response_model=RepeaterLoginResponse)
async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse: async def room_login(public_key: str, request: RepeaterLoginRequest) -> RepeaterLoginResponse:
"""Attempt room-server login and report whether auth was confirmed.""" """Attempt room-server login and report whether auth was confirmed."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_room(contact) _require_room(contact)
@@ -47,7 +48,7 @@ async def room_login(public_key: str, request: RepeaterLoginRequest) -> Repeater
@router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse) @router.post("/{public_key}/room/status", response_model=RepeaterStatusResponse)
async def room_status(public_key: str) -> RepeaterStatusResponse: async def room_status(public_key: str) -> RepeaterStatusResponse:
"""Fetch status telemetry from a room server.""" """Fetch status telemetry from a room server."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_room(contact) _require_room(contact)
@@ -84,7 +85,7 @@ async def room_status(public_key: str) -> RepeaterStatusResponse:
@router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse) @router.post("/{public_key}/room/lpp-telemetry", response_model=RepeaterLppTelemetryResponse)
async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse: async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
"""Fetch CayenneLPP telemetry from a room server.""" """Fetch CayenneLPP telemetry from a room server."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_room(contact) _require_room(contact)
@@ -113,7 +114,7 @@ async def room_lpp_telemetry(public_key: str) -> RepeaterLppTelemetryResponse:
@router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse) @router.post("/{public_key}/room/acl", response_model=RepeaterAclResponse)
async def room_acl(public_key: str) -> RepeaterAclResponse: async def room_acl(public_key: str) -> RepeaterAclResponse:
"""Fetch ACL entries from a room server.""" """Fetch ACL entries from a room server."""
radio_manager.require_connected() require_connected()
contact = await _resolve_contact_or_404(public_key) contact = await _resolve_contact_or_404(public_key)
_require_room(contact) _require_room(contact)
+11 -18
View File
@@ -230,27 +230,20 @@ async def batch_cli_fetch(
operation_name: str, operation_name: str,
commands: list[tuple[str, str]], commands: list[tuple[str, str]],
) -> dict[str, str | None]: ) -> dict[str, str | None]:
"""Send a batch of CLI commands to a server-capable contact and collect responses. """Send a batch of CLI commands to a server-capable contact and collect responses."""
Each command acquires and releases the radio lock independently so that
other operations (sends, syncs) can slip in between commands.
"""
results: dict[str, str | None] = {field: None for _, field in commands} results: dict[str, str | None] = {field: None for _, field in commands}
for index, (cmd, field) in enumerate(commands): async with radio_manager.radio_operation(
if index > 0: operation_name,
# Yield briefly so queued operations can acquire the lock. pause_polling=True,
await asyncio.sleep(0.25) suspend_auto_fetch=True,
) as mc:
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0)
async with radio_manager.radio_operation( for index, (cmd, field) in enumerate(commands):
operation_name, if index > 0:
pause_polling=True, await asyncio.sleep(1.0)
suspend_auto_fetch=True,
) as mc:
# Re-ensure contact is loaded each iteration; another operation
# may have evicted it while we didn't hold the lock.
await _ensure_on_radio(mc, contact)
await asyncio.sleep(1.0) # settle after add_contact
send_result = await mc.commands.send_cmd(contact.public_key, cmd) send_result = await mc.commands.send_cmd(contact.public_key, cmd)
if send_result.type == EventType.ERROR: if send_result.type == EventType.ERROR:
+78 -110
View File
@@ -2,18 +2,16 @@ import asyncio
import logging import logging
from typing import Literal from typing import Literal
from fastapi import APIRouter, HTTPException from fastapi import APIRouter
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from app.models import CONTACT_TYPE_REPEATER, AppSettings from app.models import AppSettings
from app.region_scope import normalize_region_scope from app.region_scope import normalize_region_scope
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository from app.repository import AppSettingsRepository
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
MAX_TRACKED_TELEMETRY_REPEATERS = 8
class AppSettingsUpdate(BaseModel): class AppSettingsUpdate(BaseModel):
max_radio_contacts: int | None = Field( max_radio_contacts: int | None = Field(
@@ -29,6 +27,10 @@ class AppSettingsUpdate(BaseModel):
default=None, default=None,
description="Whether to attempt historical DM decryption on new contact advertisement", description="Whether to attempt historical DM decryption on new contact advertisement",
) )
sidebar_sort_order: Literal["recent", "alpha"] | None = Field(
default=None,
description="Sidebar sort order: 'recent' or 'alpha'",
)
advert_interval: int | None = Field( advert_interval: int | None = Field(
default=None, default=None,
ge=0, ge=0,
@@ -46,17 +48,6 @@ class AppSettingsUpdate(BaseModel):
default=None, default=None,
description="Display names whose messages are hidden from the UI", description="Display names whose messages are hidden from the UI",
) )
discovery_blocked_types: list[int] | None = Field(
default=None,
description=(
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
"advertisements should not create new contacts"
),
)
auto_resend_channel: bool | None = Field(
default=None,
description="Auto-resend channel messages once if no echo heard within 2 seconds",
)
class BlockKeyRequest(BaseModel): class BlockKeyRequest(BaseModel):
@@ -72,23 +63,24 @@ class FavoriteRequest(BaseModel):
id: str = Field(description="Channel key or contact public key") id: str = Field(description="Channel key or contact public key")
class FavoriteToggleResponse(BaseModel): class MigratePreferencesRequest(BaseModel):
type: Literal["channel", "contact"] favorites: list[FavoriteRequest] = Field(
id: str default_factory=list,
favorite: bool description="List of favorites from localStorage",
class TrackedTelemetryRequest(BaseModel):
public_key: str = Field(description="Public key of the repeater to toggle tracking")
class TrackedTelemetryResponse(BaseModel):
tracked_telemetry_repeaters: list[str] = Field(
description="Current list of tracked repeater public keys"
) )
names: dict[str, str] = Field( sort_order: str = Field(
description="Map of public key to display name for tracked repeaters" default="recent",
description="Sort order preference from localStorage",
) )
last_message_times: dict[str, int] = Field(
default_factory=dict,
description="Map of conversation state keys to timestamps from localStorage",
)
class MigratePreferencesResponse(BaseModel):
migrated: bool = Field(description="Whether migration occurred (false if already migrated)")
settings: AppSettings = Field(description="Current settings after migration attempt")
@router.get("", response_model=AppSettings) @router.get("", response_model=AppSettings)
@@ -112,6 +104,10 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert) logger.info("Updating auto_decrypt_dm_on_advert to %s", update.auto_decrypt_dm_on_advert)
kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert kwargs["auto_decrypt_dm_on_advert"] = update.auto_decrypt_dm_on_advert
if update.sidebar_sort_order is not None:
logger.info("Updating sidebar_sort_order to %s", update.sidebar_sort_order)
kwargs["sidebar_sort_order"] = update.sidebar_sort_order
if update.advert_interval is not None: if update.advert_interval is not None:
# Enforce minimum 1-hour interval; 0 means disabled # Enforce minimum 1-hour interval; 0 means disabled
interval = update.advert_interval interval = update.advert_interval
@@ -126,16 +122,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
if update.blocked_names is not None: if update.blocked_names is not None:
kwargs["blocked_names"] = update.blocked_names kwargs["blocked_names"] = update.blocked_names
# Discovery blocked types
if update.discovery_blocked_types is not None:
# Only allow valid contact type codes (1-4)
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
kwargs["discovery_blocked_types"] = sorted(set(valid))
# Auto-resend channel
if update.auto_resend_channel is not None:
kwargs["auto_resend_channel"] = update.auto_resend_channel
# Flood scope # Flood scope
flood_scope_changed = False flood_scope_changed = False
if update.flood_scope is not None: if update.flood_scope is not None:
@@ -163,30 +149,27 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
return await AppSettingsRepository.get() return await AppSettingsRepository.get()
@router.post("/favorites/toggle", response_model=FavoriteToggleResponse) @router.post("/favorites/toggle", response_model=AppSettings)
async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse: async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
"""Toggle a conversation's favorite status.""" """Toggle a conversation's favorite status."""
if request.type == "contact": settings = await AppSettingsRepository.get()
contact = await ContactRepository.get_by_key(request.id) is_favorited = any(f.type == request.type and f.id == request.id for f in settings.favorites)
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
new_value = not contact.favorite
await ContactRepository.set_favorite(request.id, new_value)
logger.info("%s contact favorite: %s", "Added" if new_value else "Removed", request.id[:12])
# When newly favorited, load to radio immediately for DM ACK support
if new_value:
from app.radio_sync import ensure_contact_on_radio
asyncio.create_task(ensure_contact_on_radio(request.id, force=True)) if is_favorited:
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
result = await AppSettingsRepository.remove_favorite(request.type, request.id)
else: else:
channel = await ChannelRepository.get_by_key(request.id) logger.info("Adding favorite: %s %s", request.type, request.id[:12])
if not channel: result = await AppSettingsRepository.add_favorite(request.type, request.id)
raise HTTPException(status_code=404, detail="Channel not found")
new_value = not channel.favorite
await ChannelRepository.set_favorite(request.id, new_value)
logger.info("%s channel favorite: %s", "Added" if new_value else "Removed", request.id[:12])
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value) # When a contact is newly favorited, load just that contact to the radio
# immediately so DM ACK support does not wait for the next maintenance cycle.
if request.type == "contact" and not is_favorited:
from app.radio_sync import ensure_contact_on_radio
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
return result
@router.post("/blocked-keys/toggle", response_model=AppSettings) @router.post("/blocked-keys/toggle", response_model=AppSettings)
@@ -203,56 +186,41 @@ async def toggle_blocked_name(request: BlockNameRequest) -> AppSettings:
return await AppSettingsRepository.toggle_blocked_name(request.name) return await AppSettingsRepository.toggle_blocked_name(request.name)
@router.post("/tracked-telemetry/toggle", response_model=TrackedTelemetryResponse) @router.post("/migrate", response_model=MigratePreferencesResponse)
async def toggle_tracked_telemetry(request: TrackedTelemetryRequest) -> TrackedTelemetryResponse: async def migrate_preferences(request: MigratePreferencesRequest) -> MigratePreferencesResponse:
"""Toggle periodic telemetry collection for a repeater. """Migrate all preferences from frontend localStorage to database.
Max 8 repeaters may be tracked. Returns 409 if the limit is reached and This is a one-time migration. If preferences have already been migrated,
the requested repeater is not already tracked. this endpoint will not overwrite them and will return migrated=false.
Call this on frontend startup to ensure preferences are moved to the database.
After successful migration, the frontend should clear localStorage preferences.
Migrates:
- favorites (remoteterm-favorites)
- sort_order (remoteterm-sortOrder)
- last_message_times (remoteterm-lastMessageTime)
""" """
key = request.public_key.lower() # Convert to dict format for the repository method
settings = await AppSettingsRepository.get() frontend_favorites = [{"type": f.type, "id": f.id} for f in request.favorites]
current = settings.tracked_telemetry_repeaters
async def _resolve_names(keys: list[str]) -> dict[str, str]: settings, did_migrate = await AppSettingsRepository.migrate_preferences_from_frontend(
names: dict[str, str] = {} favorites=frontend_favorites,
for k in keys: sort_order=request.sort_order,
contact = await ContactRepository.get_by_key(k) last_message_times=request.last_message_times,
names[k] = contact.name if contact and contact.name else k[:12] )
return names
if did_migrate:
if key in current: logger.info(
# Remove "Migrated preferences from frontend: %d favorites, sort_order=%s, %d message times",
new_list = [k for k in current if k != key] len(frontend_favorites),
logger.info("Removing repeater %s from tracked telemetry", key[:12]) request.sort_order,
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list) len(request.last_message_times),
return TrackedTelemetryResponse( )
tracked_telemetry_repeaters=new_list, else:
names=await _resolve_names(new_list), logger.debug("Preferences already migrated, skipping")
)
return MigratePreferencesResponse(
# Validate it's a repeater migrated=did_migrate,
contact = await ContactRepository.get_by_key(key) settings=settings,
if not contact:
raise HTTPException(status_code=404, detail="Contact not found")
if contact.type != CONTACT_TYPE_REPEATER:
raise HTTPException(status_code=400, detail="Contact is not a repeater")
if len(current) >= MAX_TRACKED_TELEMETRY_REPEATERS:
names = await _resolve_names(current)
raise HTTPException(
status_code=409,
detail={
"message": f"Limit of {MAX_TRACKED_TELEMETRY_REPEATERS} tracked repeaters reached",
"tracked_telemetry_repeaters": current,
"names": names,
},
)
new_list = current + [key]
logger.info("Adding repeater %s to tracked telemetry", key[:12])
await AppSettingsRepository.update(tracked_telemetry_repeaters=new_list)
return TrackedTelemetryResponse(
tracked_telemetry_repeaters=new_list,
names=await _resolve_names(new_list),
) )
+6 -1
View File
@@ -1,7 +1,12 @@
"""Shared direct-message ACK application logic.""" """Shared direct-message ACK application logic."""
from collections.abc import Callable
from typing import Any
from app.services import dm_ack_tracker from app.services import dm_ack_tracker
from app.services.messages import BroadcastFn, increment_ack_and_broadcast from app.services.messages import increment_ack_and_broadcast
BroadcastFn = Callable[..., Any]
async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool: async def apply_dm_ack_code(ack_code: str, *, broadcast_fn: BroadcastFn) -> bool:
+5 -17
View File
@@ -1,8 +1,9 @@
import asyncio import asyncio
import logging import logging
import time import time
from collections.abc import Callable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Any
from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message from app.models import CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, Contact, ContactUpsert, Message
from app.repository import ( from app.repository import (
@@ -13,7 +14,6 @@ from app.repository import (
) )
from app.services.contact_reconciliation import claim_prefix_messages_for_contact from app.services.contact_reconciliation import claim_prefix_messages_for_contact
from app.services.messages import ( from app.services.messages import (
BroadcastFn,
broadcast_message, broadcast_message,
build_message_model, build_message_model,
build_message_paths, build_message_paths,
@@ -27,6 +27,8 @@ if TYPE_CHECKING:
from app.decoder import DecryptedDirectMessage from app.decoder import DecryptedDirectMessage
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BroadcastFn = Callable[..., Any]
_decrypted_dm_store_lock = asyncio.Lock() _decrypted_dm_store_lock = asyncio.Lock()
@@ -142,8 +144,6 @@ async def _store_direct_message(
received_at: int, received_at: int,
path: str | None, path: str | None,
path_len: int | None, path_len: int | None,
rssi: int | None = None,
snr: float | None = None,
outgoing: bool, outgoing: bool,
txt_type: int, txt_type: int,
signature: str | None, signature: str | None,
@@ -170,8 +170,6 @@ async def _store_direct_message(
path=path, path=path,
received_at=received_at, received_at=received_at,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
) )
return None return None
@@ -191,8 +189,6 @@ async def _store_direct_message(
path=path, path=path,
received_at=received_at, received_at=received_at,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
) )
return None return None
@@ -205,8 +201,6 @@ async def _store_direct_message(
received_at=received_at, received_at=received_at,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
txt_type=txt_type, txt_type=txt_type,
signature=signature, signature=signature,
outgoing=outgoing, outgoing=outgoing,
@@ -224,8 +218,6 @@ async def _store_direct_message(
path=path, path=path,
received_at=received_at, received_at=received_at,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
) )
return None return None
@@ -240,7 +232,7 @@ async def _store_direct_message(
text=text, text=text,
sender_timestamp=sender_timestamp, sender_timestamp=sender_timestamp,
received_at=received_at, received_at=received_at,
paths=build_message_paths(path, received_at, path_len, rssi=rssi, snr=snr), paths=build_message_paths(path, received_at, path_len),
txt_type=txt_type, txt_type=txt_type,
signature=signature, signature=signature,
sender_key=sender_key, sender_key=sender_key,
@@ -269,8 +261,6 @@ async def ingest_decrypted_direct_message(
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
outgoing: bool = False, outgoing: bool = False,
realtime: bool = True, realtime: bool = True,
broadcast_fn: BroadcastFn, broadcast_fn: BroadcastFn,
@@ -321,8 +311,6 @@ async def ingest_decrypted_direct_message(
received_at=received, received_at=received,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
outgoing=outgoing, outgoing=outgoing,
txt_type=decrypted.txt_type, txt_type=decrypted.txt_type,
signature=signature, signature=signature,
+4 -184
View File
@@ -2,7 +2,6 @@
import asyncio import asyncio
import logging import logging
import time as _time
from collections.abc import Callable from collections.abc import Callable
from typing import Any from typing import Any
@@ -10,17 +9,10 @@ from fastapi import HTTPException
from meshcore import EventType from meshcore import EventType
from app.models import ResendChannelMessageResponse from app.models import ResendChannelMessageResponse
from app.radio import RadioOperationBusyError
from app.region_scope import normalize_region_scope from app.region_scope import normalize_region_scope
from app.repository import ( from app.repository import AppSettingsRepository, ContactRepository, MessageRepository
AppSettingsRepository,
ChannelRepository,
ContactRepository,
MessageRepository,
)
from app.services import dm_ack_tracker from app.services import dm_ack_tracker
from app.services.messages import ( from app.services.messages import (
BroadcastFn,
broadcast_message, broadcast_message,
build_stored_outgoing_channel_message, build_stored_outgoing_channel_message,
create_outgoing_channel_message, create_outgoing_channel_message,
@@ -34,20 +26,13 @@ NO_RADIO_RESPONSE_AFTER_SEND_DETAIL = (
"Send command was issued to the radio, but no response was heard back. " "Send command was issued to the radio, but no response was heard back. "
"The message may or may not have sent successfully." "The message may or may not have sent successfully."
) )
BroadcastFn = Callable[..., Any]
TrackAckFn = Callable[[str, int, int], bool] TrackAckFn = Callable[[str, int, int], bool]
NowFn = Callable[[], float] NowFn = Callable[[], float]
OutgoingReservationKey = tuple[str, str, str] OutgoingReservationKey = tuple[str, str, str]
RetryTaskScheduler = Callable[[Any], Any] RetryTaskScheduler = Callable[[Any], Any]
# Channel echo watchdog: delay before checking for echoes
ECHO_WATCHDOG_DELAY_SECONDS = 2.0
# Byte-perfect resend window (must match router's RESEND_WINDOW_SECONDS)
RESEND_WINDOW_SECONDS = 30
# Temp radio slot used by the router for channel sends
WATCHDOG_TEMP_RADIO_SLOT = 0
_pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {} _pending_outgoing_timestamp_reservations: dict[OutgoingReservationKey, set[int]] = {}
_outgoing_timestamp_reservations_lock = asyncio.Lock() _outgoing_timestamp_reservations_lock = asyncio.Lock()
@@ -137,7 +122,7 @@ async def send_channel_message_with_effective_scope(
error_broadcast_fn: BroadcastFn, error_broadcast_fn: BroadcastFn,
app_settings_repository=AppSettingsRepository, app_settings_repository=AppSettingsRepository,
) -> Any: ) -> Any:
"""Send a channel message, temporarily overriding flood scope and/or path hash mode.""" """Send a channel message, temporarily overriding flood scope when configured."""
override_scope = normalize_region_scope(channel.flood_scope_override) override_scope = normalize_region_scope(channel.flood_scope_override)
baseline_scope = "" baseline_scope = ""
@@ -166,36 +151,6 @@ async def send_channel_message_with_effective_scope(
), ),
) )
# Path hash mode per-channel override
override_phm = channel.path_hash_mode_override
baseline_phm = radio_manager.path_hash_mode
apply_phm = (
override_phm is not None
and radio_manager.path_hash_mode_supported
and override_phm != baseline_phm
)
if apply_phm:
logger.info(
"Temporarily applying channel path_hash_mode override for %s: %d",
channel.name,
override_phm,
)
phm_result = await mc.commands.set_path_hash_mode(override_phm)
if phm_result is not None and phm_result.type == EventType.ERROR:
logger.warning(
"Failed to apply channel path_hash_mode override for %s: %s",
channel.name,
phm_result.payload,
)
raise HTTPException(
status_code=500,
detail=(
f"Failed to apply path hash mode override before {action_label}: "
f"{phm_result.payload}"
),
)
try: try:
channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot( channel_slot, needs_configure, evicted_channel_key = radio_manager.plan_channel_send_slot(
channel_key, channel_key,
@@ -299,46 +254,6 @@ async def send_channel_message_with_effective_scope(
), ),
) )
if apply_phm:
restored = False
for attempt in range(3):
try:
restore_phm = await mc.commands.set_path_hash_mode(baseline_phm)
if restore_phm is not None and restore_phm.type == EventType.ERROR:
logger.warning(
"Attempt %d/3: failed to restore path_hash_mode after sending to %s: %s",
attempt + 1,
channel.name,
restore_phm.payload,
)
else:
radio_manager.path_hash_mode = baseline_phm
logger.debug(
"Restored baseline path_hash_mode after channel send: %d",
baseline_phm,
)
restored = True
break
except Exception:
logger.exception(
"Attempt %d/3: exception restoring path_hash_mode after sending to %s",
attempt + 1,
channel.name,
)
if not restored:
logger.error(
"All 3 attempts to restore path_hash_mode failed for %s",
channel.name,
)
error_broadcast_fn(
"Path hash mode restore failed",
(
f"Sent to {channel.name}, but restoring path hash mode failed "
f"after 3 attempts. The radio is still using a non-default hop "
f"width. Set it back manually in Radio settings."
),
)
def _extract_expected_ack_code(result: Any) -> str | None: def _extract_expected_ack_code(result: Any) -> str | None:
if result is None or result.type == EventType.ERROR: if result is None or result.type == EventType.ERROR:
@@ -635,85 +550,6 @@ async def send_direct_message_to_contact(
return message return message
async def _channel_echo_watchdog(
message_id: int,
radio_manager,
broadcast_fn: BroadcastFn,
error_broadcast_fn: BroadcastFn,
) -> None:
"""One-shot watchdog: if no echo heard after delay, attempt one byte-perfect resend.
Spawned as a fire-and-forget task after a channel send when auto_resend_channel is enabled.
Uses non-blocking radio lock so it never stalls user actions.
"""
try:
await asyncio.sleep(ECHO_WATCHDOG_DELAY_SECONDS)
msg = await MessageRepository.get_by_id(message_id)
if not msg:
return
if msg.acked > 0:
logger.debug(
"Echo watchdog: message %d already has %d echo(s), skipping", message_id, msg.acked
)
return
if msg.sender_timestamp is None:
return
elapsed = int(_time.time()) - msg.sender_timestamp
if elapsed > RESEND_WINDOW_SECONDS:
logger.debug(
"Echo watchdog: message %d outside resend window (%ds)", message_id, elapsed
)
return
channel = await ChannelRepository.get_by_key(msg.conversation_key)
if not channel:
return
logger.info(
"Echo watchdog: no echo for message %d after %.0fs, attempting byte-perfect resend",
message_id,
ECHO_WATCHDOG_DELAY_SECONDS,
)
try:
key_bytes = bytes.fromhex(msg.conversation_key)
except ValueError:
return
timestamp_bytes = msg.sender_timestamp.to_bytes(4, "little")
# Strip sender name prefix to get the raw text for the radio
async with radio_manager.radio_operation("echo_watchdog_resend", blocking=False) as mc:
radio_name = mc.self_info.get("name", "") if mc.self_info else ""
text_to_send = msg.text
if radio_name and text_to_send.startswith(f"{radio_name}: "):
text_to_send = text_to_send[len(f"{radio_name}: ") :]
result = await send_channel_message_with_effective_scope(
mc=mc,
channel=channel,
channel_key=msg.conversation_key,
key_bytes=key_bytes,
text=text_to_send,
timestamp_bytes=timestamp_bytes,
action_label="echo watchdog resend",
radio_manager=radio_manager,
temp_radio_slot=WATCHDOG_TEMP_RADIO_SLOT,
error_broadcast_fn=error_broadcast_fn,
)
if result is not None and result.type != EventType.ERROR:
logger.info("Echo watchdog: resent message %d successfully", message_id)
else:
logger.debug("Echo watchdog: resend got no/error result for message %d", message_id)
except RadioOperationBusyError:
logger.debug("Echo watchdog: radio busy, skipping resend for message %d", message_id)
except Exception:
logger.debug("Echo watchdog: resend failed for message %d", message_id, exc_info=True)
async def send_channel_message_to_channel( async def send_channel_message_to_channel(
*, *,
channel, channel,
@@ -822,22 +658,6 @@ async def send_channel_message_to_channel(
message_repository=message_repository, message_repository=message_repository,
) )
broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn) broadcast_message(message=outgoing_message, broadcast_fn=broadcast_fn)
# Spawn echo watchdog if auto-resend is enabled
try:
settings = await AppSettingsRepository.get()
if settings.auto_resend_channel:
asyncio.create_task(
_channel_echo_watchdog(
message_id=outgoing_message.id,
radio_manager=radio_manager,
broadcast_fn=broadcast_fn,
error_broadcast_fn=error_broadcast_fn,
)
)
except Exception:
pass # Never let watchdog setup failure break the send
return outgoing_message return outgoing_message
+3 -27
View File
@@ -37,16 +37,10 @@ def build_message_paths(
path: str | None, path: str | None,
received_at: int, received_at: int,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
) -> list[MessagePath] | None: ) -> list[MessagePath] | None:
"""Build the single-path list used by message payloads.""" """Build the single-path list used by message payloads."""
return ( return (
[ [MessagePath(path=path or "", received_at=received_at, path_len=path_len)]
MessagePath(
path=path or "", received_at=received_at, path_len=path_len, rssi=rssi, snr=snr
)
]
if path is not None if path is not None
else None else None
) )
@@ -172,8 +166,6 @@ async def reconcile_duplicate_message(
path: str | None, path: str | None,
received_at: int, received_at: int,
path_len: int | None, path_len: int | None,
rssi: int | None = None,
snr: float | None = None,
broadcast_fn: BroadcastFn, broadcast_fn: BroadcastFn,
) -> None: ) -> None:
logger.debug( logger.debug(
@@ -185,9 +177,7 @@ async def reconcile_duplicate_message(
) )
if path is not None: if path is not None:
paths = await MessageRepository.add_path( paths = await MessageRepository.add_path(existing_msg.id, path, received_at, path_len)
existing_msg.id, path, received_at, path_len, rssi=rssi, snr=snr
)
else: else:
paths = existing_msg.paths or [] paths = existing_msg.paths or []
@@ -224,8 +214,6 @@ async def handle_duplicate_message(
path: str | None, path: str | None,
received_at: int, received_at: int,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
broadcast_fn: BroadcastFn, broadcast_fn: BroadcastFn,
) -> None: ) -> None:
"""Handle a duplicate message by updating paths/acks on the existing record.""" """Handle a duplicate message by updating paths/acks on the existing record."""
@@ -251,8 +239,6 @@ async def handle_duplicate_message(
path=path, path=path,
received_at=received_at, received_at=received_at,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
) )
@@ -267,8 +253,6 @@ async def create_message_from_decrypted(
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
channel_name: str | None = None, channel_name: str | None = None,
realtime: bool = True, realtime: bool = True,
broadcast_fn: BroadcastFn, broadcast_fn: BroadcastFn,
@@ -292,8 +276,6 @@ async def create_message_from_decrypted(
received_at=received, received_at=received,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
sender_name=sender, sender_name=sender,
sender_key=resolved_sender_key, sender_key=resolved_sender_key,
) )
@@ -309,8 +291,6 @@ async def create_message_from_decrypted(
path=path, path=path,
received_at=received, received_at=received,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
) )
return None return None
@@ -332,7 +312,7 @@ async def create_message_from_decrypted(
text=text, text=text,
sender_timestamp=timestamp, sender_timestamp=timestamp,
received_at=received, received_at=received,
paths=build_message_paths(path, received, path_len, rssi=rssi, snr=snr), paths=build_message_paths(path, received, path_len),
sender_name=sender, sender_name=sender,
sender_key=resolved_sender_key, sender_key=resolved_sender_key,
channel_name=channel_name, channel_name=channel_name,
@@ -354,8 +334,6 @@ async def create_dm_message_from_decrypted(
received_at: int | None = None, received_at: int | None = None,
path: str | None = None, path: str | None = None,
path_len: int | None = None, path_len: int | None = None,
rssi: int | None = None,
snr: float | None = None,
outgoing: bool = False, outgoing: bool = False,
realtime: bool = True, realtime: bool = True,
broadcast_fn: BroadcastFn, broadcast_fn: BroadcastFn,
@@ -370,8 +348,6 @@ async def create_dm_message_from_decrypted(
received_at=received_at, received_at=received_at,
path=path, path=path,
path_len=path_len, path_len=path_len,
rssi=rssi,
snr=snr,
outgoing=outgoing, outgoing=outgoing,
realtime=realtime, realtime=realtime,
broadcast_fn=broadcast_fn, broadcast_fn=broadcast_fn,
+20 -37
View File
@@ -33,7 +33,6 @@ async def run_post_connect_setup(radio_manager) -> None:
start_message_polling, start_message_polling,
start_periodic_advert, start_periodic_advert,
start_periodic_sync, start_periodic_sync,
start_telemetry_collect,
sync_and_offload_all, sync_and_offload_all,
sync_radio_time, sync_radio_time,
) )
@@ -205,51 +204,35 @@ async def run_post_connect_setup(radio_manager) -> None:
finally: finally:
reader.handle_rx = _original_handle_rx reader.handle_rx = _original_handle_rx
from app.config import settings as app_settings_config # Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
if app_settings_config.skip_post_connect_sync: # Send advertisement to announce our presence (if enabled and not throttled)
logger.info( if await send_advertisement(mc):
"Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)" logger.info("Advertisement sent")
)
else: else:
# Sync contacts/channels from radio to DB and clear radio logger.debug("Advertisement skipped (disabled or throttled)")
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
c = result.get("contacts", {})
ch = result.get("channels", {})
logger.info(
"Sync complete: %d contacts synced, %d channels synced, %d channels cleared",
c.get("synced", 0),
ch.get("synced", 0),
ch.get("cleared", 0),
)
# Send advertisement to announce our presence (if enabled and not throttled) # Drain any messages that were queued before we connected.
if await send_advertisement(mc): # This must happen BEFORE starting auto-fetch, otherwise both
logger.info("Advertisement sent") # compete on get_msg() with interleaved radio I/O.
else: drained = await drain_pending_messages(mc)
logger.debug("Advertisement skipped (disabled or throttled)") if drained > 0:
logger.info("Drained %d pending message(s)", drained)
# Drain any messages that were queued before we connected. radio_manager.clear_pending_message_channel_slots()
# This must happen BEFORE starting auto-fetch, otherwise both
# compete on get_msg() with interleaved radio I/O.
drained = await drain_pending_messages(mc)
if drained > 0:
logger.info("Drained %d pending message(s)", drained)
radio_manager.clear_pending_message_channel_slots()
await mc.start_auto_message_fetching() await mc.start_auto_message_fetching()
logger.info("Auto message fetching started") logger.info("Auto message fetching started")
finally: finally:
radio_manager._release_operation_lock("post_connect_setup") radio_manager._release_operation_lock("post_connect_setup")
if not app_settings_config.skip_post_connect_sync: # Start background tasks AFTER releasing the operation lock.
# Start background tasks AFTER releasing the operation lock. # These tasks acquire their own locks when they need radio access.
# These tasks acquire their own locks when they need radio access. start_periodic_sync()
start_periodic_sync() start_periodic_advert()
start_periodic_advert() start_message_polling()
start_message_polling()
start_telemetry_collect()
radio_manager._setup_complete = True radio_manager._setup_complete = True
finally: finally:
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 112 KiB

+1 -1
View File
@@ -31,7 +31,7 @@ services:
# TCP # TCP
# MESHCORE_TCP_HOST: 192.168.1.100 # MESHCORE_TCP_HOST: 192.168.1.100
# MESHCORE_TCP_PORT: 5000 # MESHCORE_TCP_PORT: 4000
# BLE # BLE
# BLE in Docker usually needs additional manual compose changes such as # BLE in Docker usually needs additional manual compose changes such as
+3 -13
View File
@@ -350,13 +350,15 @@ LocalStorage migration helpers for favorites; canonical favorites are server-sid
- `max_radio_contacts` - `max_radio_contacts`
- `favorites` - `favorites`
- `auto_decrypt_dm_on_advert` - `auto_decrypt_dm_on_advert`
- `sidebar_sort_order`
- `last_message_times` - `last_message_times`
- `preferences_migrated` - `preferences_migrated`
- `advert_interval` - `advert_interval`
- `last_advert_time` - `last_advert_time`
- `flood_scope` - `flood_scope`
- `blocked_keys`, `blocked_names`, `discovery_blocked_types` - `blocked_keys`, `blocked_names`
The backend still carries `sidebar_sort_order` for compatibility and old preference migration, but the current sidebar UI stores sort order per section (`Channels`, `Contacts`, `Repeaters`) in frontend localStorage rather than treating it as one global server-backed setting.
Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`. Note: MQTT, bot, and community MQTT settings were migrated to the `fanout_configs` table (managed via `/api/fanout`). They are no longer part of `AppSettings`.
@@ -434,18 +436,6 @@ The `SearchView` component (`components/SearchView.tsx`) provides full-text sear
UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`. UI styling is mostly utility-class driven (Tailwind-style classes in JSX) plus shared globals in `index.css` and `styles.css`.
Do not rely on old class-only layout assumptions. Do not rely on old class-only layout assumptions.
### Canonical style reference
`SettingsLocalSection.tsx` contains a **ThemePreview** component with a collapsible "Canonical style reference" section. This is the authoritative catalog of text sizes, button variants, badge patterns, and interactive elements used throughout the app. **When adding or modifying UI, match the patterns shown there rather than inventing new ones.**
Key conventions documented in the reference:
- **Text sizes** use `rem`-based Tailwind values so they scale with the user's font-size slider. Do not use hard-locked `px` values (e.g., `text-[10px]`). The canonical sizes are `text-[0.625rem]` (10px), `text-[0.6875rem]` (11px), `text-[0.8125rem]` (13px), plus standard Tailwind `text-xs`/`text-sm`/`text-base`/`text-lg`/`text-xl`.
- **Section labels** use `text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium`.
- **Buttons** use the shadcn `<Button>` component. Semantic color overrides (danger, warning, success) use `variant="outline"` with `className="border-{color}/50 text-{color} hover:bg-{color}/10"`.
- **Badges/tags** use `text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded` with `bg-muted` (neutral) or `bg-primary/10` (active).
- **Clickable text** (copy-to-clipboard, navigational links) uses `role="button" tabIndex={0}` with `cursor-pointer hover:text-primary transition-colors`.
## Security Posture (intentional) ## Security Posture (intentional)
- No authentication UI. - No authentication UI.
+12 -12
View File
@@ -9,11 +9,11 @@
<meta name="theme-color" content="#111419" /> <meta name="theme-color" content="#111419" />
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." /> <meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
<title>RemoteTerm for MeshCore</title> <title>RemoteTerm for MeshCore</title>
<link rel="icon" type="image/svg+xml" href="./favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="./favicon-96x96.png" sizes="96x96" /> <link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
<link rel="shortcut icon" href="./favicon.ico" /> <link rel="shortcut icon" href="/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" /> <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
<link rel="manifest" href="./site.webmanifest" /> <link rel="manifest" href="/site.webmanifest" />
<script> <script>
// Start critical data fetches before React/Vite JS loads. // Start critical data fetches before React/Vite JS loads.
// Must be in <head> BEFORE the module script so the browser queues these // Must be in <head> BEFORE the module script so the browser queues these
@@ -42,17 +42,17 @@
}); });
}; };
window.__prefetch = { window.__prefetch = {
config: fetchJsonOrThrow('./api/radio/config'), config: fetchJsonOrThrow('/api/radio/config'),
settings: fetchJsonOrThrow('./api/settings'), settings: fetchJsonOrThrow('/api/settings'),
channels: fetchJsonOrThrow('./api/channels'), channels: fetchJsonOrThrow('/api/channels'),
contacts: fetchJsonOrThrow('./api/contacts?limit=1000&offset=0'), contacts: fetchJsonOrThrow('/api/contacts?limit=1000&offset=0'),
unreads: fetchJsonOrThrow('./api/read-state/unreads'), unreads: fetchJsonOrThrow('/api/read-state/unreads'),
undecryptedCount: fetchJsonOrThrow('./api/packets/undecrypted/count'), undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'),
}; };
</script> </script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="./src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+2 -19
View File
@@ -1,12 +1,12 @@
{ {
"name": "remoteterm-meshcore-frontend", "name": "remoteterm-meshcore-frontend",
"version": "3.8.0", "version": "3.6.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "remoteterm-meshcore-frontend", "name": "remoteterm-meshcore-frontend",
"version": "3.8.0", "version": "3.6.2",
"dependencies": { "dependencies": {
"@codemirror/lang-python": "^6.2.1", "@codemirror/lang-python": "^6.2.1",
"@codemirror/theme-one-dark": "^6.1.3", "@codemirror/theme-one-dark": "^6.1.3",
@@ -20,7 +20,6 @@
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0", "d3-force": "^3.0.0",
"d3-force-3d": "^3.0.6", "d3-force-3d": "^3.0.6",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
@@ -3688,22 +3687,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/cmdk": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "^1.1.1",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-id": "^1.1.0",
"@radix-ui/react-primitive": "^2.0.2"
},
"peerDependencies": {
"react": "^18 || ^19 || ^19.0.0-rc",
"react-dom": "^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/codemirror": { "node_modules/codemirror": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
+1 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "remoteterm-meshcore-frontend", "name": "remoteterm-meshcore-frontend",
"private": true, "private": true,
"version": "3.10.0", "version": "3.6.3",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
@@ -28,7 +28,6 @@
"@uiw/react-codemirror": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1",
"d3-force": "^3.0.0", "d3-force": "^3.0.0",
"d3-force-3d": "^3.0.6", "d3-force-3d": "^3.0.6",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
+16 -118
View File
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react'; import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
import { api } from './api'; import { api } from './api';
import { takePrefetchOrFetch } from './prefetch'; import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket'; import { useWebSocket } from './useWebSocket';
@@ -18,15 +18,13 @@ import {
useUnreadTitle, useUnreadTitle,
useRawPacketStatsSession, useRawPacketStatsSession,
} from './hooks'; } from './hooks';
import { toast } from './components/ui/sonner';
import { AppShell } from './components/AppShell'; import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput'; import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext'; import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser'; import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState'; import { getStateKey } from './utils/conversationState';
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types'; import type { Conversation, Message, RawPacket } from './types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types'; import { CONTACT_TYPE_ROOM } from './types';
import { shouldAutoFocusInput } from './utils/autoFocusInput';
interface ChannelUnreadMarker { interface ChannelUnreadMarker {
channelId: string; channelId: string;
@@ -87,9 +85,6 @@ export function App() {
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null); const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const [newMessagePrefillRequest, setNewMessagePrefillRequest] = const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
useState<NewMessagePrefillRequest | null>(null); useState<NewMessagePrefillRequest | null>(null);
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState<string | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0); const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null); const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const { const {
@@ -153,11 +148,12 @@ export function App() {
const { const {
appSettings, appSettings,
favorites,
fetchAppSettings, fetchAppSettings,
handleSaveAppSettings, handleSaveAppSettings,
handleToggleFavorite,
handleToggleBlockedKey, handleToggleBlockedKey,
handleToggleBlockedName, handleToggleBlockedName,
handleToggleTrackedTelemetry,
} = useAppSettings(); } = useAppSettings();
// Keep user's name in ref for mention detection in WebSocket callback // Keep user's name in ref for mention detection in WebSocket callback
@@ -194,7 +190,6 @@ export function App() {
handleCreateContact, handleCreateContact,
handleCreateChannel, handleCreateChannel,
handleCreateHashtagChannel, handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel, handleDeleteChannel,
handleDeleteContact, handleDeleteContact,
} = useContactsAndChannels({ } = useContactsAndChannels({
@@ -205,38 +200,6 @@ export function App() {
removeConversationMessagesRef.current(conversationId), removeConversationMessagesRef.current(conversationId),
}); });
const handleToggleFavorite = useCallback(
async (type: 'channel' | 'contact', id: string) => {
// Optimistically toggle the favorite flag
if (type === 'contact') {
setContacts((prev) =>
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
);
} else {
setChannels((prev) =>
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
);
}
try {
await api.toggleFavorite(type, id);
} catch {
// Revert on failure
if (type === 'contact') {
setContacts((prev) =>
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
);
} else {
setChannels((prev) =>
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
);
}
toast.error('Failed to update favorite');
}
},
[setContacts, setChannels]
);
// useConversationRouter is called second — it receives channels/contacts as inputs // useConversationRouter is called second — it receives channels/contacts as inputs
const { const {
activeConversation, activeConversation,
@@ -297,21 +260,6 @@ export function App() {
} = useConversationMessages(activeConversation, targetMessageId); } = useConversationMessages(activeConversation, targetMessageId);
removeConversationMessagesRef.current = removeConversationMessages; removeConversationMessagesRef.current = removeConversationMessages;
// Auto-focus the message input on conversation change (desktop only by default)
useEffect(() => {
if (!activeConversation) return;
if (activeConversation.type !== 'channel' && activeConversation.type !== 'contact') return;
// Repeaters show a login form, not a message input
if (activeConversation.type === 'contact') {
const contact = contacts.find((c) => c.public_key === activeConversation.id);
if (contact?.type === CONTACT_TYPE_REPEATER) return;
}
if (!shouldAutoFocusInput()) return;
// Defer to let the input mount/render first
const raf = requestAnimationFrame(() => messageInputRef.current?.focus?.());
return () => cancelAnimationFrame(raf);
}, [activeConversation?.id]); // eslint-disable-line react-hooks/exhaustive-deps
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at // Room servers replay stored history as a burst of DMs, all arriving with similar received_at
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts // but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
// so the display reflects the original send order rather than our radio's receipt order. // so the display reflects the original send order rather than our radio's receipt order.
@@ -338,8 +286,8 @@ export function App() {
markAllRead, markAllRead,
refreshUnreads, refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation); } = useUnreadCounts(channels, contacts, activeConversation);
useFaviconBadge(unreadCounts, mentions, channels); useFaviconBadge(unreadCounts, mentions, favorites);
useUnreadTitle(unreadCounts, contacts, channels); useUnreadTitle(unreadCounts, favorites);
useEffect(() => { useEffect(() => {
if (activeConversation?.type !== 'channel') { if (activeConversation?.type !== 'channel') {
@@ -446,7 +394,6 @@ export function App() {
handleSendMessage, handleSendMessage,
handleResendChannelMessage, handleResendChannelMessage,
handleSetChannelFloodScopeOverride, handleSetChannelFloodScopeOverride,
handleSetChannelPathHashModeOverride,
handleSenderClick, handleSenderClick,
handleTrace, handleTrace,
handlePathDiscovery, handlePathDiscovery,
@@ -474,37 +421,16 @@ export function App() {
[fetchUndecryptedCount, setChannels] [fetchUndecryptedCount, setChannels]
); );
const handleRepeaterAutoLogin = useCallback( const handleOpenNewMessage = useCallback(() => {
(publicKey: string, displayName: string) => { setNewMessagePrefillRequest(null);
handleSelectConversationWithTargetReset({ openNewMessageModal();
type: 'contact', }, [openNewMessageModal]);
id: publicKey,
name: displayName,
});
setRepeaterAutoLoginKey(publicKey);
},
[handleSelectConversationWithTargetReset]
);
const handleOpenNewMessage = useCallback(
(event?: MouseEvent<HTMLButtonElement>) => {
setNewMessagePrefillRequest(null);
setShowBulkAddChannelTab(event?.altKey === true);
openNewMessageModal();
},
[openNewMessageModal]
);
const handleCloseNewMessage = useCallback(() => { const handleCloseNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null); setNewMessagePrefillRequest(null);
setShowBulkAddChannelTab(false);
closeNewMessageModal(); closeNewMessageModal();
}, [closeNewMessageModal]); }, [closeNewMessageModal]);
const handleCloseBulkAddResults = useCallback(() => {
setBulkAddResult(null);
}, []);
const handleChannelReferenceClick = useCallback( const handleChannelReferenceClick = useCallback(
(channelName: string) => { (channelName: string) => {
const existingChannel = channels.find((channel) => channel.name === channelName); const existingChannel = channels.find((channel) => channel.name === channelName);
@@ -518,20 +444,11 @@ export function App() {
hashtagName: channelName.slice(1), hashtagName: channelName.slice(1),
nonce: (previous?.nonce ?? 0) + 1, nonce: (previous?.nonce ?? 0) + 1,
})); }));
setShowBulkAddChannelTab(false);
openNewMessageModal(); openNewMessageModal();
}, },
[channels, handleNavigateToChannel, openNewMessageModal] [channels, handleNavigateToChannel, openNewMessageModal]
); );
const handleBulkAddChannels = useCallback(
async (channelNames: string[], tryHistorical: boolean) => {
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
setBulkAddResult(result);
},
[handleBulkCreateHashtagChannels]
);
const statusProps = { const statusProps = {
health, health,
config, config,
@@ -551,12 +468,9 @@ export function App() {
onMarkAllRead: () => { onMarkAllRead: () => {
void markAllRead(); void markAllRead();
}, },
favorites,
legacySortOrder: appSettings?.sidebar_sort_order,
isConversationNotificationsEnabled, isConversationNotificationsEnabled,
blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [],
};
const bulkAddChannelResultModalProps = {
result: bulkAddResult,
}; };
const conversationPaneProps = { const conversationPaneProps = {
activeConversation, activeConversation,
@@ -566,8 +480,8 @@ export function App() {
rawPacketStatsSession, rawPacketStatsSession,
config, config,
health, health,
favorites,
messages: sortedMessages, messages: sortedMessages,
preSorted: activeContactIsRoom,
messagesLoading, messagesLoading,
loadingOlder, loadingOlder,
hasOlderMessages, hasOlderMessages,
@@ -587,7 +501,6 @@ export function App() {
onDeleteContact: handleDeleteContact, onDeleteContact: handleDeleteContact,
onDeleteChannel: handleDeleteChannel, onDeleteChannel: handleDeleteChannel,
onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride, onSetChannelFloodScopeOverride: handleSetChannelFloodScopeOverride,
onSetChannelPathHashModeOverride: handleSetChannelPathHashModeOverride,
onOpenContactInfo: handleOpenContactInfo, onOpenContactInfo: handleOpenContactInfo,
onOpenChannelInfo: handleOpenChannelInfo, onOpenChannelInfo: handleOpenChannelInfo,
onSenderClick: handleSenderClick, onSenderClick: handleSenderClick,
@@ -614,10 +527,6 @@ export function App() {
); );
} }
}, },
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
repeaterAutoLoginKey,
onClearRepeaterAutoLogin: () => setRepeaterAutoLoginKey(null),
}; };
const searchProps = { const searchProps = {
contacts, contacts,
@@ -646,13 +555,6 @@ export function App() {
blockedNames: appSettings?.blocked_names, blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey, onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName, onToggleBlockedName: handleBlockName,
contacts,
onBulkDeleteContacts: (deletedKeys: string[]) => {
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
},
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
}; };
const crackerProps = { const crackerProps = {
packets: rawPackets, packets: rawPackets,
@@ -661,12 +563,10 @@ export function App() {
}; };
const newMessageModalProps = { const newMessageModalProps = {
undecryptedCount, undecryptedCount,
showBulkAddChannelTab,
prefillRequest: newMessagePrefillRequest, prefillRequest: newMessagePrefillRequest,
onCreateContact: handleCreateContact, onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel, onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel, onCreateHashtagChannel: handleCreateHashtagChannel,
onBulkAddHashtagChannels: handleBulkAddChannels,
}; };
const contactInfoPaneProps = { const contactInfoPaneProps = {
contactKey: infoPaneContactKey, contactKey: infoPaneContactKey,
@@ -674,6 +574,7 @@ export function App() {
onClose: handleCloseContactInfo, onClose: handleCloseContactInfo,
contacts, contacts,
config, config,
favorites,
onToggleFavorite: handleToggleFavorite, onToggleFavorite: handleToggleFavorite,
onNavigateToChannel: handleNavigateToChannel, onNavigateToChannel: handleNavigateToChannel,
onSearchMessagesByKey: (publicKey: string) => { onSearchMessagesByKey: (publicKey: string) => {
@@ -691,6 +592,7 @@ export function App() {
channelKey: infoPaneChannelKey, channelKey: infoPaneChannelKey,
onClose: handleCloseChannelInfo, onClose: handleCloseChannelInfo,
channels, channels,
favorites,
onToggleFavorite: handleToggleFavorite, onToggleFavorite: handleToggleFavorite,
}; };
@@ -728,7 +630,6 @@ export function App() {
<AppShell <AppShell
localLabel={localLabel} localLabel={localLabel}
showNewMessage={showNewMessage} showNewMessage={showNewMessage}
showBulkAddResults={bulkAddResult !== null}
showSettings={showSettings} showSettings={showSettings}
settingsSection={settingsSection} settingsSection={settingsSection}
sidebarOpen={sidebarOpen} sidebarOpen={sidebarOpen}
@@ -739,7 +640,6 @@ export function App() {
onToggleSettingsView={handleToggleSettingsView} onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView} onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage} onCloseNewMessage={handleCloseNewMessage}
onCloseBulkAddResults={handleCloseBulkAddResults}
onLocalLabelChange={setLocalLabel} onLocalLabelChange={setLocalLabel}
statusProps={statusProps} statusProps={statusProps}
sidebarProps={sidebarProps} sidebarProps={sidebarProps}
@@ -748,10 +648,8 @@ export function App() {
settingsProps={settingsProps} settingsProps={settingsProps}
crackerProps={crackerProps} crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps} newMessageModalProps={newMessageModalProps}
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps} contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps} channelInfoPaneProps={channelInfoPaneProps}
onRepeaterAutoLogin={handleRepeaterAutoLogin}
/> />
</DistanceUnitProvider> </DistanceUnitProvider>
); );
+13 -32
View File
@@ -1,7 +1,6 @@
import type { import type {
AppSettings, AppSettings,
AppSettingsUpdate, AppSettingsUpdate,
BulkCreateHashtagChannelsResult,
Channel, Channel,
ChannelDetail, ChannelDetail,
CommandResponse, CommandResponse,
@@ -9,10 +8,13 @@ import type {
ContactAnalytics, ContactAnalytics,
ContactAdvertPathSummary, ContactAdvertPathSummary,
FanoutConfig, FanoutConfig,
Favorite,
HealthStatus, HealthStatus,
MaintenanceResult, MaintenanceResult,
Message, Message,
MessagesAroundResponse, MessagesAroundResponse,
MigratePreferencesRequest,
MigratePreferencesResponse,
RawPacket, RawPacket,
RadioAdvertMode, RadioAdvertMode,
RadioConfig, RadioConfig,
@@ -32,14 +34,12 @@ import type {
RepeaterOwnerInfoResponse, RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse, RepeaterRadioSettingsResponse,
RepeaterStatusResponse, RepeaterStatusResponse,
TelemetryHistoryEntry,
TrackedTelemetryResponse,
StatisticsResponse, StatisticsResponse,
TraceResponse, TraceResponse,
UnreadCounts, UnreadCounts,
} from './types'; } from './types';
const API_BASE = './api'; const API_BASE = '/api';
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> { async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
const hasBody = options?.body !== undefined; const hasBody = options?.body !== undefined;
@@ -149,12 +149,6 @@ export const api = {
fetchJson<{ status: string }>(`/contacts/${publicKey}`, { fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE', method: 'DELETE',
}), }),
bulkDeleteContacts: (publicKeys: string[]) =>
fetchJson<{ deleted: number }>('/contacts/bulk-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ public_keys: publicKeys }),
}),
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) => createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
fetchJson<Contact>('/contacts', { fetchJson<Contact>('/contacts', {
method: 'POST', method: 'POST',
@@ -190,11 +184,6 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, key }), body: JSON.stringify({ name, key }),
}), }),
bulkCreateHashtagChannels: (channelNames: string[], tryHistorical?: boolean) =>
fetchJson<BulkCreateHashtagChannelsResult>('/channels/bulk-hashtag', {
method: 'POST',
body: JSON.stringify({ channel_names: channelNames, try_historical: tryHistorical }),
}),
deleteChannel: (key: string) => deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }), fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`), getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
@@ -208,12 +197,6 @@ export const api = {
body: JSON.stringify({ flood_scope_override: floodScopeOverride }), body: JSON.stringify({ flood_scope_override: floodScopeOverride }),
}), }),
setChannelPathHashModeOverride: (key: string, pathHashModeOverride: number | null) =>
fetchJson<Channel>(`/channels/${key}/path-hash-mode-override`, {
method: 'POST',
body: JSON.stringify({ path_hash_mode_override: pathHashModeOverride }),
}),
// Messages // Messages
getMessages: ( getMessages: (
params?: { params?: {
@@ -325,20 +308,20 @@ export const api = {
body: JSON.stringify({ name }), body: JSON.stringify({ name }),
}), }),
// Tracked telemetry
toggleTrackedTelemetry: (publicKey: string) =>
fetchJson<TrackedTelemetryResponse>('/settings/tracked-telemetry/toggle', {
method: 'POST',
body: JSON.stringify({ public_key: publicKey }),
}),
// Favorites // Favorites
toggleFavorite: (type: 'channel' | 'contact', id: string) => toggleFavorite: (type: Favorite['type'], id: string) =>
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', { fetchJson<AppSettings>('/settings/favorites/toggle', {
method: 'POST', method: 'POST',
body: JSON.stringify({ type, id }), body: JSON.stringify({ type, id }),
}), }),
// Preferences migration (one-time, from localStorage to database)
migratePreferences: (request: MigratePreferencesRequest) =>
fetchJson<MigratePreferencesResponse>('/settings/migrate', {
method: 'POST',
body: JSON.stringify(request),
}),
// Fanout // Fanout
getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'), getFanoutConfigs: () => fetchJson<FanoutConfig[]>('/fanout'),
createFanoutConfig: (config: { createFanoutConfig: (config: {
@@ -419,8 +402,6 @@ export const api = {
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, { fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST', method: 'POST',
}), }),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
roomLogin: (publicKey: string, password: string) => roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, { fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST', method: 'POST',
+4 -38
View File
@@ -1,14 +1,12 @@
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react'; import { lazy, Suspense, useRef, type ComponentProps } from 'react';
import { useSwipeable } from 'react-swipeable'; import { useSwipeable } from 'react-swipeable';
import { StatusBar } from './StatusBar'; import { StatusBar } from './StatusBar';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { ConversationPane } from './ConversationPane'; import { ConversationPane } from './ConversationPane';
import { NewMessageModal } from './NewMessageModal'; import { NewMessageModal } from './NewMessageModal';
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
import { ContactInfoPane } from './ContactInfoPane'; import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane'; import { ChannelInfoPane } from './ChannelInfoPane';
import { CommandPalette } from './CommandPalette';
import { SecurityWarningModal } from './SecurityWarningModal'; import { SecurityWarningModal } from './SecurityWarningModal';
import { Toaster } from './ui/sonner'; import { Toaster } from './ui/sonner';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
@@ -35,17 +33,12 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
type SidebarProps = ComponentProps<typeof Sidebar>; type SidebarProps = ComponentProps<typeof Sidebar>;
type ConversationPaneProps = ComponentProps<typeof ConversationPane>; type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>; type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
type BulkAddChannelResultModalProps = Omit<
ComponentProps<typeof BulkAddChannelResultModal>,
'open' | 'onClose'
>;
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>; type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>; type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
interface AppShellProps { interface AppShellProps {
localLabel: LocalLabel; localLabel: LocalLabel;
showNewMessage: boolean; showNewMessage: boolean;
showBulkAddResults: boolean;
showSettings: boolean; showSettings: boolean;
settingsSection: SettingsSection; settingsSection: SettingsSection;
sidebarOpen: boolean; sidebarOpen: boolean;
@@ -57,7 +50,6 @@ interface AppShellProps {
onToggleSettingsView: () => void; onToggleSettingsView: () => void;
onCloseSettingsView: () => void; onCloseSettingsView: () => void;
onCloseNewMessage: () => void; onCloseNewMessage: () => void;
onCloseBulkAddResults: () => void;
onLocalLabelChange: (label: LocalLabel) => void; onLocalLabelChange: (label: LocalLabel) => void;
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>; statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
sidebarProps: SidebarProps; sidebarProps: SidebarProps;
@@ -69,16 +61,13 @@ interface AppShellProps {
>; >;
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>; crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
newMessageModalProps: NewMessageModalProps; newMessageModalProps: NewMessageModalProps;
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
contactInfoPaneProps: ContactInfoPaneProps; contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps; channelInfoPaneProps: ChannelInfoPaneProps;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
} }
export function AppShell({ export function AppShell({
localLabel, localLabel,
showNewMessage, showNewMessage,
showBulkAddResults,
showSettings, showSettings,
settingsSection, settingsSection,
sidebarOpen, sidebarOpen,
@@ -90,7 +79,6 @@ export function AppShell({
onToggleSettingsView, onToggleSettingsView,
onCloseSettingsView, onCloseSettingsView,
onCloseNewMessage, onCloseNewMessage,
onCloseBulkAddResults,
onLocalLabelChange, onLocalLabelChange,
statusProps, statusProps,
sidebarProps, sidebarProps,
@@ -99,10 +87,8 @@ export function AppShell({
settingsProps, settingsProps,
crackerProps, crackerProps,
newMessageModalProps, newMessageModalProps,
bulkAddChannelResultModalProps,
contactInfoPaneProps, contactInfoPaneProps,
channelInfoPaneProps, channelInfoPaneProps,
onRepeaterAutoLogin,
}: AppShellProps) { }: AppShellProps) {
const swipeHandlers = useSwipeable({ const swipeHandlers = useSwipeable({
onSwipedRight: ({ initial }) => { onSwipedRight: ({ initial }) => {
@@ -122,14 +108,6 @@ export function AppShell({
preventScrollOnSwipe: false, preventScrollOnSwipe: false,
}); });
const handleOpenSettings = useCallback(
(section: SettingsSection) => {
onSettingsSectionChange(section);
if (!showSettings) onToggleSettingsView();
},
[onSettingsSectionChange, onToggleSettingsView, showSettings]
);
const searchMounted = useRef(false); const searchMounted = useRef(false);
if (conversationPaneProps.activeConversation?.type === 'search') { if (conversationPaneProps.activeConversation?.type === 'search') {
searchMounted.current = true; searchMounted.current = true;
@@ -146,7 +124,7 @@ export function AppShell({
aria-label="Settings" aria-label="Settings"
> >
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border"> <div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
<h2 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium"> <h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
Settings Settings
</h2> </h2>
<button <button
@@ -169,7 +147,7 @@ export function AppShell({
type="button" type="button"
disabled={disabled} disabled={disabled}
className={cn( className={cn(
'w-full px-3 py-2 text-left text-[0.8125rem] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50', 'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset disabled:cursor-not-allowed disabled:opacity-50',
!disabled && 'hover:bg-accent', !disabled && 'hover:bg-accent',
settingsSection === section && !disabled && 'bg-accent border-l-primary' settingsSection === section && !disabled && 'bg-accent border-l-primary'
)} )}
@@ -310,7 +288,7 @@ export function AppShell({
<Suspense <Suspense
fallback={ fallback={
<div className="flex items-center justify-center h-full text-muted-foreground"> <div className="flex items-center justify-center h-full text-muted-foreground">
Loading channel finder... Loading cracker...
</div> </div>
} }
> >
@@ -328,19 +306,7 @@ export function AppShell({
open={showNewMessage} open={showNewMessage}
onClose={onCloseNewMessage} onClose={onCloseNewMessage}
/> />
<BulkAddChannelResultModal
{...bulkAddChannelResultModalProps}
open={showBulkAddResults}
onClose={onCloseBulkAddResults}
/>
<CommandPalette
contacts={sidebarProps.contacts}
channels={sidebarProps.channels}
onSelectConversation={sidebarProps.onSelectConversation}
onOpenSettings={handleOpenSettings}
onRepeaterAutoLogin={onRepeaterAutoLogin}
/>
<SecurityWarningModal health={statusProps.health} /> <SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} /> <ContactInfoPane {...contactInfoPaneProps} />
<ChannelInfoPane {...channelInfoPaneProps} /> <ChannelInfoPane {...channelInfoPaneProps} />
@@ -1,103 +0,0 @@
import type { BulkCreateHashtagChannelsResult, Channel } from '../types';
import { getConversationHash } from '../utils/urlHash';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
interface BulkAddChannelResultModalProps {
open: boolean;
result: BulkCreateHashtagChannelsResult | null;
onClose: () => void;
}
function getChannelHref(channel: Channel): string {
const hash = getConversationHash({
type: 'channel',
id: channel.key,
name: channel.name,
});
if (typeof window === 'undefined') {
return hash;
}
return `${window.location.origin}${window.location.pathname}${hash}`;
}
export function BulkAddChannelResultModal({
open,
result,
onClose,
}: BulkAddChannelResultModalProps) {
const createdChannels = result?.created_channels ?? [];
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Bulk Add Complete</DialogTitle>
<DialogDescription>
{result?.message ?? 'Review the newly added rooms below.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{result && (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
Created
</div>
<div className="mt-1 font-medium">{createdChannels.length}</div>
</div>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground">
Already Present
</div>
<div className="mt-1 font-medium">{result.existing_count}</div>
</div>
</div>
)}
{createdChannels.length > 0 ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Ctrl+click any room to open it in a new tab.
</p>
<div className="max-h-64 overflow-y-auto rounded-md border border-border/70">
<ul className="divide-y divide-border/70">
{createdChannels.map((channel) => (
<li key={channel.key}>
<a
href={getChannelHref(channel)}
className="block px-3 py-2 text-sm text-primary hover:bg-accent hover:text-primary"
>
{channel.name}
</a>
</li>
))}
</ul>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No new rooms were added.</p>
)}
{result && result.invalid_names.length > 0 && (
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
Ignored invalid room names: {result.invalid_names.join(', ')}
</div>
)}
</div>
<DialogFooter>
<Button onClick={onClose}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+9 -94
View File
@@ -1,17 +1,18 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip } from 'recharts';
import { Star } from 'lucide-react'; import { Star } from 'lucide-react';
import { api } from '../api'; import { api } from '../api';
import { formatTime } from '../utils/messageParser'; import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import type { Channel, ChannelDetail, PathHashWidthStats } from '../types'; import type { Channel, ChannelDetail, Favorite } from '../types';
interface ChannelInfoPaneProps { interface ChannelInfoPaneProps {
channelKey: string | null; channelKey: string | null;
onClose: () => void; onClose: () => void;
channels: Channel[]; channels: Channel[];
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
} }
@@ -19,6 +20,7 @@ export function ChannelInfoPane({
channelKey, channelKey,
onClose, onClose,
channels, channels,
favorites,
onToggleFavorite, onToggleFavorite,
}: ChannelInfoPaneProps) { }: ChannelInfoPaneProps) {
const [detail, setDetail] = useState<ChannelDetail | null>(null); const [detail, setDetail] = useState<ChannelDetail | null>(null);
@@ -104,11 +106,11 @@ export function ChannelInfoPane({
</span> </span>
)} )}
<div className="flex items-center gap-2 mt-1.5"> <div className="flex items-center gap-2 mt-1.5">
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium"> <span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
{channel.is_hashtag ? 'Hashtag' : 'Private Key'} {channel.is_hashtag ? 'Hashtag' : 'Private Key'}
</span> </span>
{channel.on_radio && ( {channel.on_radio && (
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium"> <span className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
On Radio On Radio
</span> </span>
)} )}
@@ -122,7 +124,7 @@ export function ChannelInfoPane({
className="text-sm flex items-center gap-2 hover:text-primary transition-colors" className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleFavorite('channel', channel.key)} onClick={() => onToggleFavorite('channel', channel.key)}
> >
{channel.favorite ? ( {isFavorite(favorites, 'channel', channel.key) ? (
<> <>
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" /> <Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
<span>Remove from favorites</span> <span>Remove from favorites</span>
@@ -177,14 +179,6 @@ export function ChannelInfoPane({
</div> </div>
)} )}
{/* Hop Byte Widths (24h) */}
{detail && detail.path_hash_width_24h.total_packets > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Hop Byte Widths (24h)</SectionLabel>
<HopWidthChart stats={detail.path_hash_width_24h} />
</div>
)}
{/* Top Senders 24h */} {/* Top Senders 24h */}
{detail && detail.top_senders_24h.length > 0 && ( {detail && detail.top_senders_24h.length > 0 && (
<div className="px-5 py-3"> <div className="px-5 py-3">
@@ -218,7 +212,7 @@ export function ChannelInfoPane({
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<h3 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1.5"> <h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
{children} {children}
</h3> </h3>
); );
@@ -232,82 +226,3 @@ function InfoItem({ label, value }: { label: string; value: string }) {
</div> </div>
); );
} }
const HOP_WIDTH_SEGMENTS = [
{ key: 'single_byte', label: '1-byte', color: '#22c55e' },
{ key: 'double_byte', label: '2-byte', color: '#0ea5e9' },
{ key: 'triple_byte', label: '3-byte', color: '#8b5cf6' },
] as const;
const TOOLTIP_STYLE = {
contentStyle: {
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '11px',
color: 'hsl(var(--popover-foreground))',
},
} as const;
function HopWidthChart({ stats }: { stats: PathHashWidthStats }) {
const data = useMemo(
() =>
HOP_WIDTH_SEGMENTS.map(({ key, label, color }) => ({
name: label,
value: stats[key] as number,
color,
})).filter((d) => d.value > 0),
[stats]
);
return (
<div className="flex items-center gap-3">
<div className="flex-shrink-0" style={{ width: 90, height: 90 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={data}
dataKey="value"
cx="50%"
cy="50%"
innerRadius={22}
outerRadius={40}
strokeWidth={1.5}
stroke="hsl(var(--background))"
>
{data.map((d) => (
<Cell key={d.name} fill={d.color} />
))}
</Pie>
<RechartsTooltip
{...TOOLTIP_STYLE}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
const v = typeof value === 'number' ? value : Number(value);
return [`${v.toLocaleString()} pkt${v !== 1 ? 's' : ''}`, name];
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex-1 space-y-1">
{data.map((d) => (
<div key={d.name} className="flex items-center gap-1.5">
<span
className="w-2 h-2 rounded-full flex-shrink-0"
style={{ backgroundColor: d.color }}
/>
<span className="text-[0.6875rem] text-muted-foreground flex-1">{d.name}</span>
<span className="text-[0.6875rem] font-medium tabular-nums">
{d.value.toLocaleString()}
</span>
</div>
))}
<p className="text-[0.625rem] text-muted-foreground pt-0.5">
{stats.total_packets.toLocaleString()} total
</p>
</div>
</div>
);
}
@@ -1,132 +0,0 @@
import { useEffect, useState } from 'react';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
import { Label } from './ui/label';
const PATH_HASH_MODE_LABELS: Record<number, string> = {
0: '1-byte',
1: '2-byte',
2: '3-byte',
};
interface ChannelPathHashModeOverrideModalProps {
open: boolean;
onClose: () => void;
channelName: string;
currentOverride: number | null;
radioDefault: number;
onSetOverride: (value: number | null) => void;
}
export function ChannelPathHashModeOverrideModal({
open,
onClose,
channelName,
currentOverride,
radioDefault,
onSetOverride,
}: ChannelPathHashModeOverrideModalProps) {
const [selected, setSelected] = useState<number | null>(null);
useEffect(() => {
if (open) {
setSelected(currentOverride);
}
}, [currentOverride, open]);
const radioDefaultLabel = PATH_HASH_MODE_LABELS[radioDefault] ?? `${radioDefault}`;
const options: { value: number | null; label: string; description: string }[] = [
{
value: null,
label: `Radio default (${radioDefaultLabel})`,
description: 'Use the radio-wide path hash mode setting',
},
{
value: 0,
label: '1-byte hop identifiers',
description: 'Least repeater disambiguation, up to 63 hops',
},
{
value: 1,
label: '2-byte hop identifiers',
description: 'Better repeater disambiguation, up to 32 hops',
},
{
value: 2,
label: '3-byte hop identifiers',
description: 'Best repeater disambiguation, up to 21 hops',
},
];
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Path Hop Width Override</DialogTitle>
<DialogDescription>
Override the path hash mode for this channel. Wider hop identifiers improve repeater
disambiguation but extend send time and will prevent users on old (&lt;1.14) firmware
from receiving the message.
</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">{channelName}</div>
<div className="mt-1 text-muted-foreground">
Current override:{' '}
{currentOverride != null
? (PATH_HASH_MODE_LABELS[currentOverride] ?? `mode ${currentOverride}`)
: `none (using radio default: ${radioDefaultLabel})`}
</div>
</div>
<div className="space-y-2">
<Label>Hop width for this channel</Label>
<div className="space-y-1.5">
{options.map((opt) => (
<button
key={String(opt.value)}
type="button"
className={`w-full rounded-md border px-3 py-2 text-left text-sm transition-colors ${
selected === opt.value
? 'border-primary bg-primary/10 text-foreground'
: 'border-border hover:bg-accent'
}`}
onClick={() => setSelected(opt.value)}
>
<div className="font-medium">{opt.label}</div>
<div className="text-xs text-muted-foreground">{opt.description}</div>
</button>
))}
</div>
</div>
</div>
<DialogFooter className="gap-2 sm:block sm:space-x-0">
<Button
type="button"
className="w-full"
onClick={() => {
onSetOverride(selected);
onClose();
}}
>
{selected == null
? `Use radio default for ${channelName}`
: `Use ${PATH_HASH_MODE_LABELS[selected]} hops for ${channelName}`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+26 -57
View File
@@ -1,10 +1,10 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Bell, ChevronsLeftRight, Globe2, Info, Route, Star, Trash2 } from 'lucide-react'; import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import { DirectTraceIcon } from './DirectTraceIcon'; import { DirectTraceIcon } from './DirectTraceIcon';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal'; import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal'; import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal'; import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { isPublicChannelKey } from '../utils/publicChannel'; import { isPublicChannelKey } from '../utils/publicChannel';
import { stripRegionScopePrefix } from '../utils/regionScope'; import { stripRegionScopePrefix } from '../utils/regionScope';
@@ -12,7 +12,14 @@ import { isPrefixOnlyContact } from '../utils/pubkey';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { ContactAvatar } from './ContactAvatar'; import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo'; import { ContactStatusInfo } from './ContactStatusInfo';
import type { Channel, Contact, Conversation, PathDiscoveryResponse, RadioConfig } from '../types'; import type {
Channel,
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
RadioConfig,
} from '../types';
import { CONTACT_TYPE_ROOM } from '../types'; import { CONTACT_TYPE_ROOM } from '../types';
interface ChatHeaderProps { interface ChatHeaderProps {
@@ -20,6 +27,7 @@ interface ChatHeaderProps {
contacts: Contact[]; contacts: Contact[];
channels: Channel[]; channels: Channel[];
config: RadioConfig | null; config: RadioConfig | null;
favorites: Favorite[];
notificationsSupported: boolean; notificationsSupported: boolean;
notificationsEnabled: boolean; notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported'; notificationsPermission: NotificationPermission | 'unsupported';
@@ -28,7 +36,6 @@ interface ChatHeaderProps {
onToggleNotifications: () => void; onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void; onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onSetChannelPathHashModeOverride?: (key: string, pathHashModeOverride: number | null) => void;
onDeleteChannel: (key: string) => void; onDeleteChannel: (key: string) => void;
onDeleteContact: (publicKey: string) => void; onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void; onOpenContactInfo?: (publicKey: string) => void;
@@ -40,6 +47,7 @@ export function ChatHeader({
contacts, contacts,
channels, channels,
config, config,
favorites,
notificationsSupported, notificationsSupported,
notificationsEnabled, notificationsEnabled,
notificationsPermission, notificationsPermission,
@@ -48,7 +56,6 @@ export function ChatHeader({
onToggleNotifications, onToggleNotifications,
onToggleFavorite, onToggleFavorite,
onSetChannelFloodScopeOverride, onSetChannelFloodScopeOverride,
onSetChannelPathHashModeOverride,
onDeleteChannel, onDeleteChannel,
onDeleteContact, onDeleteContact,
onOpenContactInfo, onOpenContactInfo,
@@ -57,13 +64,11 @@ export function ChatHeader({
const [showKey, setShowKey] = useState(false); const [showKey, setShowKey] = useState(false);
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false); const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const [channelOverrideOpen, setChannelOverrideOpen] = useState(false); const [channelOverrideOpen, setChannelOverrideOpen] = useState(false);
const [pathHashModeOverrideOpen, setPathHashModeOverrideOpen] = useState(false);
useEffect(() => { useEffect(() => {
setShowKey(false); setShowKey(false);
setPathDiscoveryOpen(false); setPathDiscoveryOpen(false);
setChannelOverrideOpen(false); setChannelOverrideOpen(false);
setPathHashModeOverrideOpen(false);
}, [conversation.id]); }, [conversation.id]);
const activeChannel = const activeChannel =
@@ -76,12 +81,6 @@ export function ChatHeader({
? stripRegionScopePrefix(activeFloodScopeOverride) ? stripRegionScopePrefix(activeFloodScopeOverride)
: null; : null;
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null; const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
const activePathHashModeOverride =
conversation.type === 'channel' ? (activeChannel?.path_hash_mode_override ?? null) : null;
const showPathHashModeOverride =
conversation.type === 'channel' &&
onSetChannelPathHashModeOverride &&
config?.path_hash_mode_supported;
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag; const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
const activeContact = const activeContact =
conversation.type === 'contact' conversation.type === 'contact'
@@ -95,18 +94,12 @@ export function ChatHeader({
const titleClickable = const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) || (conversation.type === 'contact' && onOpenContactInfo) ||
(conversation.type === 'channel' && onOpenChannelInfo); (conversation.type === 'channel' && onOpenChannelInfo);
const isFav =
conversation.type === 'contact'
? (activeContact?.favorite ?? false)
: conversation.type === 'channel'
? (activeChannel?.favorite ?? false)
: false;
const favoriteTitle = const favoriteTitle =
conversation.type === 'contact' conversation.type === 'contact'
? isFav ? isFavorite(favorites, 'contact', conversation.id)
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.' ? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.' : 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
: isFav : isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites' ? 'Remove from favorites'
: 'Add to favorites'; : 'Add to favorites';
@@ -115,11 +108,6 @@ export function ChatHeader({
setChannelOverrideOpen(true); setChannelOverrideOpen(true);
}; };
const handleEditPathHashModeOverride = () => {
if (conversation.type !== 'channel' || !onSetChannelPathHashModeOverride) return;
setPathHashModeOverrideOpen(true);
};
const handleOpenConversationInfo = () => { const handleOpenConversationInfo = () => {
if (conversation.type === 'contact' && onOpenContactInfo) { if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id); onOpenContactInfo(conversation.id);
@@ -194,7 +182,7 @@ export function ChatHeader({
</h2> </h2>
{isPrivateChannel && !showKey ? ( {isPrivateChannel && !showKey ? (
<button <button
className="min-w-0 flex-shrink text-[0.6875rem] font-mono text-muted-foreground transition-colors hover:text-primary" className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setShowKey(true); setShowKey(true);
@@ -205,7 +193,7 @@ export function ChatHeader({
</button> </button>
) : ( ) : (
<span <span
className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-muted-foreground transition-colors hover:text-primary" className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyboardActivate} onKeyDown={handleKeyboardActivate}
@@ -240,7 +228,7 @@ export function ChatHeader({
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]" className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true" aria-hidden="true"
/> />
<span className="min-w-0 truncate text-[0.6875rem] font-medium text-[hsl(var(--region-override))]"> <span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay} {activeFloodScopeDisplay}
</span> </span>
</button> </button>
@@ -249,7 +237,7 @@ export function ChatHeader({
</span> </span>
</span> </span>
{conversation.type === 'contact' && activeContact && ( {conversation.type === 'contact' && activeContact && (
<div className="col-span-2 row-start-2 min-w-0 text-[0.6875rem] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1"> <div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
<ContactStatusInfo <ContactStatusInfo
contact={activeContact} contact={activeContact}
ourLat={config?.lat ?? null} ourLat={config?.lat ?? null}
@@ -311,7 +299,7 @@ export function ChatHeader({
aria-hidden="true" aria-hidden="true"
/> />
{notificationsEnabled && ( {notificationsEnabled && (
<span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected"> <span className="hidden md:inline text-[11px] font-medium text-status-connected">
Notifications On Notifications On
</span> </span>
)} )}
@@ -329,25 +317,12 @@ export function ChatHeader({
aria-hidden="true" aria-hidden="true"
/> />
{activeFloodScopeDisplay && ( {activeFloodScopeDisplay && (
<span className="hidden text-[0.6875rem] font-medium text-[hsl(var(--region-override))] sm:inline"> <span className="hidden text-[11px] font-medium text-[hsl(var(--region-override))] sm:inline">
{activeFloodScopeDisplay} {activeFloodScopeDisplay}
</span> </span>
)} )}
</button> </button>
)} )}
{showPathHashModeOverride && (
<button
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={handleEditPathHashModeOverride}
title="Set path hop width override"
aria-label="Set path hop width override"
>
<ChevronsLeftRight
className={`h-4 w-4 ${activePathHashModeOverride != null ? 'text-status-connected' : 'text-muted-foreground'}`}
aria-hidden="true"
/>
</button>
)}
{(conversation.type === 'channel' || conversation.type === 'contact') && ( {(conversation.type === 'channel' || conversation.type === 'contact') && (
<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" 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"
@@ -355,9 +330,13 @@ export function ChatHeader({
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id) onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
} }
title={favoriteTitle} title={favoriteTitle}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'} aria-label={
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites'
}
> >
{isFav ? ( {isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" /> <Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
) : ( ) : (
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" /> <Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
@@ -400,16 +379,6 @@ export function ChatHeader({
onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)} onSetOverride={(value) => onSetChannelFloodScopeOverride(conversation.id, value)}
/> />
)} )}
{showPathHashModeOverride && (
<ChannelPathHashModeOverrideModal
open={pathHashModeOverrideOpen}
onClose={() => setPathHashModeOverrideOpen(false)}
channelName={conversation.name}
currentOverride={activePathHashModeOverride}
radioDefault={config?.path_hash_mode ?? 0}
onSetOverride={(value) => onSetChannelPathHashModeOverride(conversation.id, value)}
/>
)}
</header> </header>
); );
} }
-436
View File
@@ -1,436 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
Hash,
Map,
MessageSquare,
Network,
Radio,
Route,
Search,
Star,
User,
Waypoints,
} from 'lucide-react';
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from './ui/command';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
import { getContactDisplayName } from '../utils/pubkey';
import {
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
SETTINGS_SECTION_ICONS,
type SettingsSection,
} from './settings/settingsConstants';
import type { Channel, Contact, Conversation } from '../types';
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
const MAX_PER_GROUP = 8;
interface CommandPaletteProps {
contacts: Contact[];
channels: Channel[];
onSelectConversation: (conv: Conversation) => void;
onOpenSettings: (section: SettingsSection) => void;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}
interface Searchable {
searchText: string;
}
interface SearchableContact extends Searchable {
contact: Contact;
displayName: string;
}
interface SearchableChannel extends Searchable {
channel: Channel;
}
interface ToolItem extends Searchable {
id: string;
name: string;
icon: React.ComponentType<{ className?: string }>;
type: 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
}
interface SettingItem extends Searchable {
section: SettingsSection;
label: string;
icon: React.ComponentType<{ className?: string }>;
}
const TOOL_ITEMS: ToolItem[] = [
{ id: 'raw', name: 'Raw Packet Feed', icon: Radio, type: 'raw', searchText: 'raw packet feed' },
{ id: 'map', name: 'Map View', icon: Map, type: 'map', searchText: 'map view' },
{
id: 'visualizer',
name: 'Network Visualizer',
icon: Network,
type: 'visualizer',
searchText: 'network visualizer',
},
{
id: 'search',
name: 'Message Search',
icon: Search,
type: 'search',
searchText: 'message search',
},
{ id: 'trace', name: 'Route Trace', icon: Route, type: 'trace', searchText: 'route trace' },
];
const SETTING_ITEMS: SettingItem[] = SETTINGS_SECTION_ORDER.map((section) => ({
section,
label: SETTINGS_SECTION_LABELS[section],
icon: SETTINGS_SECTION_ICONS[section],
searchText: `settings ${SETTINGS_SECTION_LABELS[section]}`.toLowerCase(),
}));
function fuzzyMatch(text: string, query: string): boolean {
let qi = 0;
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
if (text[ti] === query[qi]) qi++;
}
return qi === query.length;
}
function filterList<T extends Searchable>(items: T[], query: string): T[] {
if (!query) return items.slice(0, MAX_PER_GROUP);
const results: T[] = [];
for (const item of items) {
if (fuzzyMatch(item.searchText, query)) {
results.push(item);
if (results.length >= MAX_PER_GROUP) break;
}
}
return results;
}
export function CommandPalette({
contacts,
channels,
onSelectConversation,
onOpenSettings,
onRepeaterAutoLogin,
}: CommandPaletteProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
setOpen((prev) => !prev);
}
}
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, []);
const select = useCallback((action: () => void) => {
setOpen(false);
action();
}, []);
const {
favContacts,
favRepeaters,
regularContacts,
repeaters,
rooms,
favChannels,
regularChannels,
} = useMemo(() => {
const fc: SearchableContact[] = [];
const fr: SearchableContact[] = [];
const rc: SearchableContact[] = [];
const rp: SearchableContact[] = [];
const rm: SearchableContact[] = [];
for (const c of contacts) {
const displayName = getContactDisplayName(c.name, c.public_key, c.last_advert);
const entry: SearchableContact = {
contact: c,
displayName,
searchText: `${displayName} ${c.public_key}`.toLowerCase(),
};
if (c.type === CONTACT_TYPE_REPEATER) {
(c.favorite ? fr : rp).push(entry);
} else if (c.type === CONTACT_TYPE_ROOM) {
rm.push(entry);
} else {
(c.favorite ? fc : rc).push(entry);
}
}
const fch: SearchableChannel[] = [];
const rch: SearchableChannel[] = [];
for (const ch of channels) {
const entry: SearchableChannel = {
channel: ch,
searchText: `${ch.name} ${ch.key}`.toLowerCase(),
};
(ch.favorite ? fch : rch).push(entry);
}
return {
favContacts: fc,
favRepeaters: fr,
regularContacts: rc,
repeaters: rp,
rooms: rm,
favChannels: fch,
regularChannels: rch,
};
}, [contacts, channels]);
const lq = query.toLowerCase();
const fTools = filterList(TOOL_ITEMS, lq);
const fSettings = filterList(SETTING_ITEMS, lq);
const fFavContacts = filterList(favContacts, lq);
const fFavRepeaters = filterList(favRepeaters, lq);
const fFavChannels = filterList(favChannels, lq);
const fContacts = filterList(regularContacts, lq);
const fRepeaters = filterList(repeaters, lq);
const fRooms = filterList(rooms, lq);
const fChannels = filterList(regularChannels, lq);
const totalResults =
fTools.length +
fSettings.length +
fFavContacts.length +
fFavRepeaters.length +
fFavChannels.length +
fContacts.length +
fRepeaters.length +
fRooms.length +
fChannels.length;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
setOpen(nextOpen);
if (!nextOpen) setQuery('');
}}
>
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogDescription className="sr-only">
Search for conversations, settings, and tools
</DialogDescription>
<Command shouldFilter={false}>
<CommandInput placeholder="Jump to..." value={query} onValueChange={setQuery} />
<CommandList>
{totalResults === 0 && <CommandEmpty>No results found.</CommandEmpty>}
{fTools.length > 0 && (
<CommandGroup heading="Tools">
{fTools.map((tool) => (
<CommandItem
key={tool.id}
onSelect={() =>
select(() =>
onSelectConversation({ type: tool.type, id: tool.id, name: tool.name })
)
}
>
<tool.icon className="text-muted-foreground" />
<span>{tool.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
{fSettings.length > 0 && (
<CommandGroup heading="Settings">
{fSettings.map((item) => (
<CommandItem
key={item.section}
onSelect={() => select(() => onOpenSettings(item.section))}
>
<item.icon className="text-muted-foreground" />
<span>{item.label}</span>
</CommandItem>
))}
</CommandGroup>
)}
{fFavContacts.length > 0 && (
<ContactGroup
heading="Favorite Contacts"
items={fFavContacts}
icon={User}
onSelect={select}
onSelectConversation={onSelectConversation}
showStar
/>
)}
{fFavRepeaters.length > 0 && (
<RepeaterGroup
heading="Favorite Repeaters"
items={fFavRepeaters}
onSelect={select}
onSelectConversation={onSelectConversation}
onRepeaterAutoLogin={onRepeaterAutoLogin}
showStar
/>
)}
{fFavChannels.length > 0 && (
<CommandGroup heading="Favorite Channels">
{fFavChannels.map(({ channel: ch }) => (
<CommandItem
key={ch.key}
onSelect={() =>
select(() =>
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
)
}
>
<Hash className="text-muted-foreground" />
<span>{ch.name}</span>
<Star className="ml-auto h-3 w-3 text-favorite" />
</CommandItem>
))}
</CommandGroup>
)}
{fContacts.length > 0 && (
<ContactGroup
heading="Contacts"
items={fContacts}
icon={User}
onSelect={select}
onSelectConversation={onSelectConversation}
/>
)}
{fRepeaters.length > 0 && (
<RepeaterGroup
heading="Repeaters"
items={fRepeaters}
onSelect={select}
onSelectConversation={onSelectConversation}
onRepeaterAutoLogin={onRepeaterAutoLogin}
/>
)}
{fRooms.length > 0 && (
<ContactGroup
heading="Rooms"
items={fRooms}
icon={MessageSquare}
onSelect={select}
onSelectConversation={onSelectConversation}
/>
)}
{fChannels.length > 0 && (
<CommandGroup heading="Channels">
{fChannels.map(({ channel: ch }) => (
<CommandItem
key={ch.key}
onSelect={() =>
select(() =>
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
)
}
>
<Hash className="text-muted-foreground" />
<span>{ch.name}</span>
</CommandItem>
))}
</CommandGroup>
)}
</CommandList>
</Command>
</DialogContent>
</Dialog>
);
}
function ContactGroup({
heading,
items,
icon: Icon,
showStar,
onSelect,
onSelectConversation,
}: {
heading: string;
items: SearchableContact[];
icon: React.ComponentType<{ className?: string }>;
showStar?: boolean;
onSelect: (action: () => void) => void;
onSelectConversation: (conv: Conversation) => void;
}) {
return (
<CommandGroup heading={heading}>
{items.map(({ contact: c, displayName }) => (
<CommandItem
key={c.public_key}
onSelect={() =>
onSelect(() =>
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
)
}
>
<Icon className="text-muted-foreground" />
<span>{displayName}</span>
{showStar && <Star className="ml-auto h-3 w-3 text-favorite" />}
</CommandItem>
))}
</CommandGroup>
);
}
function RepeaterGroup({
heading,
items,
showStar,
onSelect,
onSelectConversation,
onRepeaterAutoLogin,
}: {
heading: string;
items: SearchableContact[];
showStar?: boolean;
onSelect: (action: () => void) => void;
onSelectConversation: (conv: Conversation) => void;
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
}) {
return (
<CommandGroup heading={heading}>
{items.flatMap(({ contact: c, displayName }) => [
<CommandItem
key={c.public_key}
onSelect={() =>
onSelect(() =>
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
)
}
>
<Waypoints className="text-muted-foreground" />
<span>{displayName}</span>
{showStar && <Star className="ml-auto h-3 w-3 text-favorite" />}
</CommandItem>,
<CommandItem
key={`${c.public_key}-acl`}
onSelect={() => onSelect(() => onRepeaterAutoLogin(c.public_key, displayName))}
>
<Waypoints className="text-muted-foreground" />
<span>
{displayName} <span className="text-muted-foreground">(ACL login + load all)</span>
</span>
</CommandItem>,
])}
</CommandGroup>
);
}
+41 -117
View File
@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react'; import { type ReactNode, useEffect, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react'; import { Ban, Search, Star } from 'lucide-react';
import { import {
LineChart, LineChart,
@@ -29,18 +29,19 @@ import {
} from '../utils/pathUtils'; } from '../utils/pathUtils';
import { isPublicChannelKey } from '../utils/publicChannel'; import { isPublicChannelKey } from '../utils/publicChannel';
import { getMapFocusHash } from '../utils/urlHash'; import { getMapFocusHash } from '../utils/urlHash';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar'; import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet'; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext'; import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import { CONTACT_TYPE_REPEATER } from '../types';
import type { import type {
Contact, Contact,
ContactActiveRoom, ContactActiveRoom,
ContactAnalytics, ContactAnalytics,
ContactAnalyticsHourlyBucket, ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket, ContactAnalyticsWeeklyBucket,
Favorite,
RadioConfig, RadioConfig,
} from '../types'; } from '../types';
@@ -65,6 +66,7 @@ interface ContactInfoPaneProps {
onClose: () => void; onClose: () => void;
contacts: Contact[]; contacts: Contact[];
config: RadioConfig | null; config: RadioConfig | null;
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onNavigateToChannel?: (channelKey: string) => void; onNavigateToChannel?: (channelKey: string) => void;
onSearchMessagesByKey?: (publicKey: string) => void; onSearchMessagesByKey?: (publicKey: string) => void;
@@ -81,6 +83,7 @@ export function ContactInfoPane({
onClose, onClose,
contacts, contacts,
config, config,
favorites,
onToggleFavorite, onToggleFavorite,
onNavigateToChannel, onNavigateToChannel,
onSearchMessagesByKey, onSearchMessagesByKey,
@@ -155,7 +158,6 @@ export function ContactInfoPane({
contact !== null && contact !== null &&
!isPrefixOnlyResolvedContact && !isPrefixOnlyResolvedContact &&
isUnknownFullKeyContact(contact.public_key, contact.last_advert); isUnknownFullKeyContact(contact.public_key, contact.last_advert);
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
return ( return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}> <Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
@@ -288,7 +290,7 @@ export function ContactInfoPane({
{contact.public_key} {contact.public_key}
</span> </span>
<div className="flex items-center gap-2 mt-1.5"> <div className="flex items-center gap-2 mt-1.5">
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium"> <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'} {CONTACT_TYPE_LABELS[contact.type] ?? 'Unknown'}
</span> </span>
</div> </div>
@@ -380,7 +382,7 @@ export function ContactInfoPane({
onClick={() => onToggleFavorite('contact', contact.public_key)} onClick={() => onToggleFavorite('contact', contact.public_key)}
title="Favorite contacts stay loaded on the radio for ACK support" title="Favorite contacts stay loaded on the radio for ACK support"
> >
{contact.favorite ? ( {isFavorite(favorites, 'contact', contact.public_key) ? (
<> <>
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" /> <Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
<span>Remove from favorites</span> <span>Remove from favorites</span>
@@ -438,7 +440,7 @@ export function ContactInfoPane({
</div> </div>
)} )}
{!isRepeater && onSearchMessagesByKey && ( {onSearchMessagesByKey && (
<div className="px-5 py-3 border-b border-border"> <div className="px-5 py-3 border-b border-border">
<button <button
type="button" type="button"
@@ -451,60 +453,40 @@ export function ContactInfoPane({
</div> </div>
)} )}
{/* Nearest Repeaters (Hops) — last 7 days only */} {/* Nearest Repeaters */}
{analytics && {analytics && analytics.nearest_repeaters.length > 0 && (
(() => { <div className="px-5 py-3 border-b border-border">
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400; <SectionLabel>Nearest Repeaters</SectionLabel>
const recent = analytics.nearest_repeaters.filter( <div className="space-y-1">
(r) => r.last_seen >= sevenDaysAgo {analytics.nearest_repeaters.map((r) => (
); <div key={r.public_key} className="flex justify-between items-center text-sm">
if (recent.length === 0) return null; <span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
return ( <span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
<div className="px-5 py-3 border-b border-border"> {r.path_len === 0
<SectionLabel>Nearest Repeaters Hops (last 7 days)</SectionLabel> ? 'direct'
<div className="space-y-1"> : `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
{recent.map((r) => ( · {r.heard_count}x
<div </span>
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">
{r.path_len === 0
? 'direct'
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
· {r.heard_count}x
</span>
</div>
))}
</div> </div>
</div> ))}
); </div>
})()} </div>
{/* Geographically nearest repeaters (repeaters only) */}
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
<NearbyRepeatersSection
contact={contact}
contacts={contacts}
distanceUnit={distanceUnit}
/>
)} )}
{/* Advert Paths */} {/* Advert Paths */}
{analytics && analytics.advert_paths.length > 0 && ( {analytics && analytics.advert_paths.length > 0 && (
<div className="px-5 py-3 border-b border-border"> <div className="px-5 py-3 border-b border-border">
<SectionLabel>Recent Advert Paths</SectionLabel> <SectionLabel>Recent Advert Paths</SectionLabel>
<div className="space-y-1.5"> <div className="space-y-1">
{analytics.advert_paths.map((p) => ( {analytics.advert_paths.map((p) => (
<div <div
key={p.path + p.first_seen} key={p.path + p.first_seen}
className="flex justify-between items-start gap-2 text-sm" className="flex justify-between items-center text-sm"
> >
<span className="font-mono text-xs break-all"> <span className="font-mono text-xs truncate">
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'} {p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
</span> </span>
<span className="text-xs text-muted-foreground flex-shrink-0"> <span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{p.heard_count}x · {formatTime(p.last_seen)} {p.heard_count}x · {formatTime(p.last_seen)}
</span> </span>
</div> </div>
@@ -536,21 +518,17 @@ export function ContactInfoPane({
</div> </div>
)} )}
{!isRepeater && ( <MessageStatsSection
<> dmMessageCount={analytics?.dm_message_count ?? 0}
<MessageStatsSection channelMessageCount={analytics?.channel_message_count ?? 0}
dmMessageCount={analytics?.dm_message_count ?? 0} />
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<ActivityChartsSection analytics={analytics} /> <ActivityChartsSection analytics={analytics} />
<MostActiveChannelsSection <MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []} channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel} onNavigateToChannel={onNavigateToChannel}
/> />
</>
)}
</div> </div>
) : ( ) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground"> <div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -564,7 +542,7 @@ export function ContactInfoPane({
function SectionLabel({ children }: { children: React.ReactNode }) { function SectionLabel({ children }: { children: React.ReactNode }) {
return ( return (
<h3 className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1.5"> <h3 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium mb-1.5">
{children} {children}
</h3> </h3>
); );
@@ -725,7 +703,7 @@ function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | nu
</div> </div>
)} )}
<p className="text-[0.6875rem] text-muted-foreground"> <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 Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour
slots. slots.
{!analytics.includes_direct_messages && {!analytics.includes_direct_messages &&
@@ -817,7 +795,7 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
{legendItems && ( {legendItems && (
<Legend <Legend
content={() => ( content={() => (
<div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[0.6875rem] text-muted-foreground"> <div className="flex flex-wrap justify-center gap-x-3 gap-y-1 mt-1 text-[11px] text-muted-foreground">
{legendItems.map((item) => ( {legendItems.map((item) => (
<span key={item.label} className="inline-flex items-center gap-1.5"> <span key={item.label} className="inline-flex items-center gap-1.5">
<span <span
@@ -848,60 +826,6 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
); );
} }
function NearbyRepeatersSection({
contact,
contacts,
distanceUnit,
}: {
contact: Contact;
contacts: Contact[];
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
}) {
const nearby = useMemo(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
for (const other of contacts) {
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
if (
other.public_key === contact.public_key ||
other.type !== CONTACT_TYPE_REPEATER ||
!isValidLocation(other.lat, other.lon) ||
heardAt < sevenDaysAgo
) {
continue;
}
const dist = calculateDistance(contact.lat, contact.lon, other.lat, other.lon);
if (dist !== null) {
results.push({
name: getContactDisplayName(other.name, other.public_key, other.last_advert),
publicKey: other.public_key,
distance: dist,
});
}
}
results.sort((a, b) => a.distance - b.distance);
return results.slice(0, 5);
}, [contact.public_key, contact.lat, contact.lon, contacts]);
if (nearby.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Geo (last 7 days)</SectionLabel>
<div className="space-y-1">
{nearby.map((r) => (
<div key={r.publicKey} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{formatDistance(r.distance, distanceUnit)}
</span>
</div>
))}
</div>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: ReactNode }) { function InfoItem({ label, value }: { label: string; value: ReactNode }) {
return ( return (
<div> <div>
@@ -74,12 +74,12 @@ function RouteCard({
<div className="rounded-md border border-border bg-muted/20 p-3"> <div className="rounded-md border border-border bg-muted/20 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h4 className="text-sm font-semibold">{label}</h4> <h4 className="text-sm font-semibold">{label}</h4>
<span className="text-[0.6875rem] text-muted-foreground"> <span className="text-[11px] text-muted-foreground">
{formatRouteLabel(route.path_len, true)} {formatRouteLabel(route.path_len, true)}
</span> </span>
</div> </div>
<p className="mt-2 text-sm">{chain}</p> <p className="mt-2 text-sm">{chain}</p>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[0.6875rem] text-muted-foreground"> <div className="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-muted-foreground">
<span>Raw: {rawPath}</span> <span>Raw: {rawPath}</span>
<span>{formatPathHashMode(route.path_hash_mode)}</span> <span>{formatPathHashMode(route.path_hash_mode)}</span>
</div> </div>
+6 -28
View File
@@ -10,6 +10,7 @@ import type {
Channel, Channel,
Contact, Contact,
Conversation, Conversation,
Favorite,
HealthStatus, HealthStatus,
Message, Message,
PathDiscoveryResponse, PathDiscoveryResponse,
@@ -41,8 +42,8 @@ interface ConversationPaneProps {
notificationsSupported: boolean; notificationsSupported: boolean;
notificationsEnabled: boolean; notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported'; notificationsPermission: NotificationPermission | 'unsupported';
favorites: Favorite[];
messages: Message[]; messages: Message[];
preSorted?: boolean;
messagesLoading: boolean; messagesLoading: boolean;
loadingOlder: boolean; loadingOlder: boolean;
hasOlderMessages: boolean; hasOlderMessages: boolean;
@@ -61,10 +62,6 @@ interface ConversationPaneProps {
onDeleteContact: (publicKey: string) => Promise<void>; onDeleteContact: (publicKey: string) => Promise<void>;
onDeleteChannel: (key: string) => Promise<void>; onDeleteChannel: (key: string) => Promise<void>;
onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>; onSetChannelFloodScopeOverride: (channelKey: string, floodScopeOverride: string) => Promise<void>;
onSetChannelPathHashModeOverride?: (
channelKey: string,
pathHashModeOverride: number | null
) => Promise<void>;
onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void; onOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
onOpenChannelInfo: (channelKey: string) => void; onOpenChannelInfo: (channelKey: string) => void;
onSenderClick: (sender: string) => void; onSenderClick: (sender: string) => void;
@@ -77,10 +74,6 @@ interface ConversationPaneProps {
onDismissUnreadMarker: () => void; onDismissUnreadMarker: () => void;
onSendMessage: (text: string) => Promise<void>; onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void; onToggleNotifications: () => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
repeaterAutoLoginKey: string | null;
onClearRepeaterAutoLogin: () => void;
} }
function LoadingPane({ label }: { label: string }) { function LoadingPane({ label }: { label: string }) {
@@ -119,8 +112,8 @@ export function ConversationPane({
notificationsSupported, notificationsSupported,
notificationsEnabled, notificationsEnabled,
notificationsPermission, notificationsPermission,
favorites,
messages, messages,
preSorted,
messagesLoading, messagesLoading,
loadingOlder, loadingOlder,
hasOlderMessages, hasOlderMessages,
@@ -136,7 +129,6 @@ export function ConversationPane({
onDeleteContact, onDeleteContact,
onDeleteChannel, onDeleteChannel,
onSetChannelFloodScopeOverride, onSetChannelFloodScopeOverride,
onSetChannelPathHashModeOverride,
onOpenContactInfo, onOpenContactInfo,
onOpenChannelInfo, onOpenChannelInfo,
onSenderClick, onSenderClick,
@@ -149,10 +141,6 @@ export function ConversationPane({
onDismissUnreadMarker, onDismissUnreadMarker,
onSendMessage, onSendMessage,
onToggleNotifications, onToggleNotifications,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
repeaterAutoLoginKey,
onClearRepeaterAutoLogin,
}: ConversationPaneProps) { }: ConversationPaneProps) {
const [roomAuthenticated, setRoomAuthenticated] = useState(false); const [roomAuthenticated, setRoomAuthenticated] = useState(false);
const activeContactIsRepeater = useMemo(() => { const activeContactIsRepeater = useMemo(() => {
@@ -192,12 +180,7 @@ export function ConversationPane({
</h2> </h2>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<Suspense fallback={<LoadingPane label="Loading map..." />}> <Suspense fallback={<LoadingPane label="Loading map..." />}>
<MapView <MapView contacts={contacts} focusedKey={activeConversation.mapFocusKey} />
contacts={contacts}
focusedKey={activeConversation.mapFocusKey}
rawPackets={rawPackets}
config={config}
/>
</Suspense> </Suspense>
</div> </div>
</> </>
@@ -238,6 +221,7 @@ export function ConversationPane({
key={activeConversation.id} key={activeConversation.id}
conversation={activeConversation} conversation={activeConversation}
contacts={contacts} contacts={contacts}
favorites={favorites}
notificationsSupported={notificationsSupported} notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled} notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission} notificationsPermission={notificationsPermission}
@@ -249,11 +233,6 @@ export function ConversationPane({
onToggleNotifications={onToggleNotifications} onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact} onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
autoLoginAndLoadAll={repeaterAutoLoginKey === activeConversation.id}
onAutoLoginConsumed={onClearRepeaterAutoLogin}
/> />
</Suspense> </Suspense>
); );
@@ -268,6 +247,7 @@ export function ConversationPane({
contacts={contacts} contacts={contacts}
channels={channels} channels={channels}
config={config} config={config}
favorites={favorites}
notificationsSupported={notificationsSupported} notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled} notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission} notificationsPermission={notificationsPermission}
@@ -276,7 +256,6 @@ export function ConversationPane({
onToggleNotifications={onToggleNotifications} onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite} onToggleFavorite={onToggleFavorite}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride} onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
onSetChannelPathHashModeOverride={onSetChannelPathHashModeOverride}
onDeleteChannel={onDeleteChannel} onDeleteChannel={onDeleteChannel}
onDeleteContact={onDeleteContact} onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo} onOpenContactInfo={onOpenContactInfo}
@@ -295,7 +274,6 @@ export function ConversationPane({
<MessageList <MessageList
key={activeConversation.id} key={activeConversation.id}
messages={messages} messages={messages}
preSorted={preSorted}
contacts={contacts} contacts={contacts}
channels={channels} channels={channels}
loading={messagesLoading} loading={messagesLoading}
+12 -33
View File
@@ -98,7 +98,7 @@ export function CrackerPanel({
.catch((err) => { .catch((err) => {
console.error('Failed to load wordlist:', err); console.error('Failed to load wordlist:', err);
toast.error('Failed to load wordlist', { toast.error('Failed to load wordlist', {
description: 'Channel finder will not be available', description: 'Cracking will not be available',
}); });
}); });
}, [visible, wordlistLoaded]); }, [visible, wordlistLoaded]);
@@ -356,7 +356,7 @@ export function CrackerPanel({
} }
} catch (err) { } catch (err) {
console.error('Failed to create channel or decrypt historical:', err); console.error('Failed to create channel or decrypt historical:', err);
toast.error('Failed to save found channel', { toast.error('Failed to save cracked channel', {
description: description:
err instanceof Error ? err.message : 'Channel discovered but could not be saved', err instanceof Error ? err.message : 'Channel discovered but could not be saved',
}); });
@@ -409,10 +409,7 @@ export function CrackerPanel({
const handleStart = () => { const handleStart = () => {
if (!gpuAvailable) { if (!gpuAvailable) {
toast.error('WebGPU not available', { toast.error('WebGPU not available', {
description: description: 'Cracking requires Chrome 113+ or Edge 113+ with WebGPU support.',
typeof window !== 'undefined' && !window.isSecureContext
? 'WebGPU requires HTTPS when not on localhost. Set up a certificate or configure your browser to treat this origin as secure.'
: 'Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.',
}); });
return; return;
} }
@@ -540,7 +537,7 @@ export function CrackerPanel({
Pending: <span className="text-foreground font-medium">{pendingCount}</span> Pending: <span className="text-foreground font-medium">{pendingCount}</span>
</span> </span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Found: <span className="text-success font-medium">{crackedCount}</span> Cracked: <span className="text-success font-medium">{crackedCount}</span>
</span> </span>
<span className="text-muted-foreground"> <span className="text-muted-foreground">
Failed: <span className="text-destructive font-medium">{failedCount}</span> Failed: <span className="text-destructive font-medium">{failedCount}</span>
@@ -584,7 +581,7 @@ export function CrackerPanel({
aria-valuenow={Math.round(progress.percent)} aria-valuenow={Math.round(progress.percent)}
aria-valuemin={0} aria-valuemin={0}
aria-valuemax={100} aria-valuemax={100}
aria-label="Channel finder progress" aria-label="Cracking progress"
> >
<div <div
className="h-full bg-primary transition-all duration-200" className="h-full bg-primary transition-all duration-200"
@@ -596,26 +593,8 @@ export function CrackerPanel({
{/* GPU status */} {/* GPU status */}
{gpuAvailable === false && ( {gpuAvailable === false && (
<div className="text-sm text-destructive space-y-1.5" role="alert"> <div className="text-sm text-destructive" role="alert">
<p>WebGPU not available.</p> WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
{typeof window !== 'undefined' && !window.isSecureContext ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive/90">
<p className="font-medium mb-1">WebGPU requires HTTPS when not on localhost.</p>
<p>To enable it:</p>
<ul className="list-disc ml-4 mt-1 space-y-0.5">
<li>
Set up a TLS certificate (see the HTTPS section of README_ADVANCED.md, or re-run
the Docker setup script which can generate one automatically)
</li>
<li>
Or configure your browser to treat this origin as secure (sometimes called
&ldquo;insecure origins treated as secure&rdquo; in browser flags)
</li>
</ul>
</div>
) : (
<p>Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.</p>
)}
</div> </div>
)} )}
{!wordlistLoaded && gpuAvailable !== false && ( {!wordlistLoaded && gpuAvailable !== false && (
@@ -624,10 +603,10 @@ export function CrackerPanel({
</div> </div>
)} )}
{/* Found channels list */} {/* Cracked channels list */}
{crackedChannels.length > 0 && ( {crackedChannels.length > 0 && (
<div> <div>
<div className="text-xs text-muted-foreground mb-1">Found Channels:</div> <div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
<div className="space-y-1"> <div className="space-y-1">
{crackedChannels.map((channel, i) => ( {crackedChannels.map((channel, i) => (
<div <div
@@ -651,8 +630,8 @@ export function CrackerPanel({
force payloads as they arrive, testing channel names up to the specified length to discover force payloads as they arrive, testing channel names up to the specified length to discover
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
way of knowing but try as if they are). way of knowing but try as if they are).
<strong> Retry failed at n+1</strong> will return to the failed queue and pick up messages <strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
it couldn't find a key for, attempting them at one longer length. pick up messages it couldn't crack, attempting them at one longer length.
<strong> Try word pairs</strong> will also try every combination of two dictionary words <strong> Try word pairs</strong> will also try every combination of two dictionary words
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
dictionary pass; this can substantially increase search time and also result in dictionary pass; this can substantially increase search time and also result in
@@ -660,7 +639,7 @@ export function CrackerPanel({
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to <strong> Decrypt historical</strong> will run an async job on any channel name it finds to
see if any historically captured packets will decrypt with that key. see if any historically captured packets will decrypt with that key.
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and <strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
may allow accelerated searching and/or system instability. may allow accelerated cracking and/or system instability.
</p> </p>
</div> </div>
); );
+64 -642
View File
@@ -1,47 +1,16 @@
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react'; import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap, Polyline } from 'react-leaflet'; import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet'; import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import type { Contact, RadioConfig, RawPacket } from '../types'; import type { Contact } from '../types';
import { formatTime } from '../utils/messageParser'; import { formatTime } from '../utils/messageParser';
import { isValidLocation } from '../utils/pathUtils'; import { isValidLocation } from '../utils/pathUtils';
import { CONTACT_TYPE_REPEATER } from '../types'; import { CONTACT_TYPE_REPEATER } from '../types';
import {
parsePacket,
getPacketLabel,
PARTICLE_COLOR_MAP,
dedupeConsecutive,
} from '../utils/visualizerUtils';
import { getRawPacketObservationKey } from '../utils/rawPacketIdentity';
interface MapViewProps { interface MapViewProps {
contacts: Contact[]; contacts: Contact[];
/** Public key of contact to focus on and open popup */ /** Public key of contact to focus on and open popup */
focusedKey?: string | null; focusedKey?: string | null;
rawPackets?: RawPacket[];
config?: RadioConfig | null;
}
// --- Tile layer presets ---
const TILE_LIGHT = {
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
background: '#1a1a2e',
};
const TILE_DARK = {
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
background: '#0d0d0d',
};
function getSavedDarkMap(): boolean {
try {
return localStorage.getItem('remoteterm-dark-map') === 'true';
} catch {
return false;
}
} }
const MAP_RECENCY_COLORS = { const MAP_RECENCY_COLORS = {
@@ -53,16 +22,7 @@ const MAP_RECENCY_COLORS = {
const MAP_MARKER_STROKE = '#0f172a'; const MAP_MARKER_STROKE = '#0f172a';
const MAP_REPEATER_RING = '#f8fafc'; const MAP_REPEATER_RING = '#f8fafc';
// --- Packet visualization constants --- // Calculate marker color based on how recently the contact was heard
const THREE_DAYS_SEC = 3 * 24 * 60 * 60;
const PARTICLE_LIFETIME_MS = 3000;
const PARTICLE_TAIL_LENGTH = 0.25; // fraction of progress to trail behind
const PARTICLE_RADIUS = 8;
const PARTICLE_TAIL_WIDTH = 5;
const MAX_MAP_PARTICLES = 200;
// --- Helpers ---
function getMarkerColor(lastSeen: number | null | undefined): string { function getMarkerColor(lastSeen: number | null | undefined): string {
if (lastSeen == null) return MAP_RECENCY_COLORS.old; if (lastSeen == null) return MAP_RECENCY_COLORS.old;
const now = Date.now() / 1000; const now = Date.now() / 1000;
@@ -76,85 +36,7 @@ function getMarkerColor(lastSeen: number | null | undefined): string {
return MAP_RECENCY_COLORS.old; return MAP_RECENCY_COLORS.old;
} }
/** Resolve a hop token to a single contact with GPS, or null. */ // Component to handle map bounds fitting
function resolveHopToGps(hopToken: string, prefixIndex: Map<string, Contact[]>): Contact | null {
const matches = prefixIndex.get(hopToken.toLowerCase());
if (!matches || matches.length !== 1) return null;
const c = matches[0];
return isValidLocation(c.lat, c.lon) ? c : null;
}
/** Resolve a contact by display name (for GroupText senders). */
function resolveNameToGps(name: string, nameIndex: Map<string, Contact>): Contact | null {
const c = nameIndex.get(name);
if (!c) return null;
return isValidLocation(c.lat, c.lon) ? c : null;
}
/** Collect public keys of all unambiguously resolved GPS-bearing contacts from a parsed packet. */
function resolvePacketContacts(
parsed: ReturnType<typeof parsePacket>,
prefixIndex: Map<string, Contact[]>,
nameIndex: Map<string, Contact>,
myLatLon: [number, number] | null,
config?: RadioConfig | null
): Set<string> {
const keys = new Set<string>();
if (!parsed) return keys;
// Source by pubkey prefix
const sourcePrefixes = parsed.advertPubkey
? [parsed.advertPubkey.slice(0, 12).toLowerCase()]
: parsed.srcHash
? [parsed.srcHash.toLowerCase()]
: [];
for (const prefix of sourcePrefixes) {
const matches = prefixIndex.get(prefix);
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
keys.add(matches[0].public_key);
}
}
// Source by name (GroupText sender)
if (parsed.groupTextSender) {
const c = resolveNameToGps(parsed.groupTextSender, nameIndex);
if (c) keys.add(c.public_key);
}
// Intermediate hops
for (const hop of parsed.pathBytes) {
if (hop.length < 4) continue;
const matches = prefixIndex.get(hop.toLowerCase());
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
keys.add(matches[0].public_key);
}
}
// Self
if (myLatLon && config?.public_key) {
keys.add(config.public_key.toLowerCase());
}
// Destination
if (parsed.dstHash) {
const matches = prefixIndex.get(parsed.dstHash.toLowerCase());
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
keys.add(matches[0].public_key);
}
}
return keys;
}
interface MapParticle {
id: number;
path: [number, number][]; // lat/lon waypoints
color: string;
startedAt: number;
}
// --- Map bounds handler ---
function MapBoundsHandler({ function MapBoundsHandler({
contacts, contacts,
focusedContact, focusedContact,
@@ -166,6 +48,7 @@ function MapBoundsHandler({
const [hasInitialized, setHasInitialized] = useState(false); const [hasInitialized, setHasInitialized] = useState(false);
useEffect(() => { useEffect(() => {
// If we have a focused contact, center on it immediately (even if already initialized)
if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) { if (focusedContact && focusedContact.lat != null && focusedContact.lon != null) {
map.setView([focusedContact.lat, focusedContact.lon], 12); map.setView([focusedContact.lat, focusedContact.lon], 12);
setHasInitialized(true); setHasInitialized(true);
@@ -176,17 +59,20 @@ function MapBoundsHandler({
const fitToContacts = () => { const fitToContacts = () => {
if (contacts.length === 0) { if (contacts.length === 0) {
// No contacts with location - show world view
map.setView([20, 0], 2); map.setView([20, 0], 2);
setHasInitialized(true); setHasInitialized(true);
return; return;
} }
if (contacts.length === 1) { if (contacts.length === 1) {
// Single contact - center on it
map.setView([contacts[0].lat!, contacts[0].lon!], 10); map.setView([contacts[0].lat!, contacts[0].lon!], 10);
setHasInitialized(true); setHasInitialized(true);
return; return;
} }
// Multiple contacts - fit bounds
const bounds: LatLngBoundsExpression = contacts.map( const bounds: LatLngBoundsExpression = contacts.map(
(c) => [c.lat!, c.lon!] as [number, number] (c) => [c.lat!, c.lon!] as [number, number]
); );
@@ -194,18 +80,22 @@ function MapBoundsHandler({
setHasInitialized(true); setHasInitialized(true);
}; };
// Try geolocation first
if ('geolocation' in navigator) { if ('geolocation' in navigator) {
navigator.geolocation.getCurrentPosition( navigator.geolocation.getCurrentPosition(
(position) => { (position) => {
// Success - center on user location with reasonable zoom
map.setView([position.coords.latitude, position.coords.longitude], 8); map.setView([position.coords.latitude, position.coords.longitude], 8);
setHasInitialized(true); setHasInitialized(true);
}, },
() => { () => {
// Geolocation denied/failed - fit to contacts
fitToContacts(); fitToContacts();
}, },
{ timeout: 5000, maximumAge: 300000 } { timeout: 5000, maximumAge: 300000 }
); );
} else { } else {
// No geolocation support - fit to contacts
fitToContacts(); fitToContacts();
} }
}, [map, contacts, hasInitialized, focusedContact]); }, [map, contacts, hasInitialized, focusedContact]);
@@ -213,404 +103,18 @@ function MapBoundsHandler({
return null; return null;
} }
// --- Canvas particle overlay --- export function MapView({ contacts, focusedKey }: MapViewProps) {
function ParticleOverlay({ particles }: { particles: MapParticle[] }) {
const map = useMap();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const animRef = useRef<number>(0);
useEffect(() => {
const container = map.getContainer();
const canvas = document.createElement('canvas');
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.pointerEvents = 'none';
canvas.style.zIndex = '450'; // above tiles, below popups
container.appendChild(canvas);
canvasRef.current = canvas;
const resize = () => {
const size = map.getSize();
canvas.width = size.x * window.devicePixelRatio;
canvas.height = size.y * window.devicePixelRatio;
canvas.style.width = `${size.x}px`;
canvas.style.height = `${size.y}px`;
};
resize();
map.on('resize', resize);
map.on('zoom', resize);
return () => {
cancelAnimationFrame(animRef.current);
map.off('resize', resize);
map.off('zoom', resize);
container.removeChild(canvas);
canvasRef.current = null;
};
}, [map]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const draw = () => {
const now = Date.now();
const dpr = window.devicePixelRatio;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.scale(dpr, dpr);
for (const particle of particles) {
const elapsed = now - particle.startedAt;
if (elapsed < 0 || elapsed > PARTICLE_LIFETIME_MS) continue;
const progress = elapsed / PARTICLE_LIFETIME_MS;
const path = particle.path;
if (path.length < 2) continue;
// Calculate total path length in pixels for even speed
const pixelPath = path.map((ll) => map.latLngToContainerPoint(L.latLng(ll[0], ll[1])));
const segLengths: number[] = [];
let totalLen = 0;
for (let i = 1; i < pixelPath.length; i++) {
const dx = pixelPath[i].x - pixelPath[i - 1].x;
const dy = pixelPath[i].y - pixelPath[i - 1].y;
const len = Math.sqrt(dx * dx + dy * dy);
segLengths.push(len);
totalLen += len;
}
if (totalLen === 0) continue;
// Interpolate head position
const headDist = progress * totalLen;
const tailDist = Math.max(0, headDist - PARTICLE_TAIL_LENGTH * totalLen);
const pointAtDist = (d: number): { x: number; y: number } => {
let accum = 0;
for (let i = 0; i < segLengths.length; i++) {
if (accum + segLengths[i] >= d) {
const t = segLengths[i] > 0 ? (d - accum) / segLengths[i] : 0;
return {
x: pixelPath[i].x + (pixelPath[i + 1].x - pixelPath[i].x) * t,
y: pixelPath[i].y + (pixelPath[i + 1].y - pixelPath[i].y) * t,
};
}
accum += segLengths[i];
}
const last = pixelPath[pixelPath.length - 1];
return { x: last.x, y: last.y };
};
const head = pointAtDist(headDist);
const tail = pointAtDist(tailDist);
// Draw tail as a gradient line from transparent to opaque
const grad = ctx.createLinearGradient(tail.x, tail.y, head.x, head.y);
grad.addColorStop(0, particle.color + '00');
grad.addColorStop(1, particle.color + 'cc');
ctx.beginPath();
ctx.moveTo(tail.x, tail.y);
// Sample intermediate points along the tail for curved paths
const steps = 8;
for (let s = 1; s <= steps; s++) {
const d = tailDist + ((headDist - tailDist) * s) / steps;
const pt = pointAtDist(d);
ctx.lineTo(pt.x, pt.y);
}
ctx.strokeStyle = grad;
ctx.lineWidth = PARTICLE_TAIL_WIDTH;
ctx.lineCap = 'round';
ctx.stroke();
// Draw head blob with glow
const fade = progress > 0.8 ? 1 - (progress - 0.8) / 0.2 : 1;
const alpha = Math.round(fade * 230)
.toString(16)
.padStart(2, '0');
// Outer glow
ctx.beginPath();
ctx.arc(head.x, head.y, PARTICLE_RADIUS + 4, 0, Math.PI * 2);
ctx.fillStyle =
particle.color +
Math.round(fade * 40)
.toString(16)
.padStart(2, '0');
ctx.fill();
// Core blob
ctx.beginPath();
ctx.arc(head.x, head.y, PARTICLE_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = particle.color + alpha;
ctx.shadowColor = particle.color;
ctx.shadowBlur = 12 * fade;
ctx.fill();
ctx.shadowBlur = 0;
// Bright center
ctx.beginPath();
ctx.arc(head.x, head.y, PARTICLE_RADIUS * 0.4, 0, Math.PI * 2);
ctx.fillStyle = '#ffffff' + alpha;
ctx.fill();
}
ctx.restore();
animRef.current = requestAnimationFrame(draw);
};
animRef.current = requestAnimationFrame(draw);
return () => cancelAnimationFrame(animRef.current);
}, [map, particles]);
// Redraw on map move/zoom
useEffect(() => {
const redraw = () => {}; // Animation loop already redraws every frame
map.on('move', redraw);
map.on('zoom', redraw);
return () => {
map.off('move', redraw);
map.off('zoom', redraw);
};
}, [map]);
return null;
}
// --- Main component ---
export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewProps) {
const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60); const [sevenDaysAgo] = useState(() => Date.now() / 1000 - 7 * 24 * 60 * 60);
const [darkMap, setDarkMap] = useState(getSavedDarkMap);
const tile = darkMap ? TILE_DARK : TILE_LIGHT;
// Sync with settings changes from other components // Filter to contacts with GPS coordinates, heard within the last 7 days.
useEffect(() => { // Always include the focused contact so "view on map" links work for older nodes.
const onStorage = (e: StorageEvent) => {
if (e.key === 'remoteterm-dark-map') setDarkMap(e.newValue === 'true');
};
window.addEventListener('storage', onStorage);
return () => window.removeEventListener('storage', onStorage);
}, []);
const [showPackets, setShowPackets] = useState(false);
const [discoveryMode, setDiscoveryMode] = useState(false);
const [discoveredKeys, setDiscoveredKeys] = useState<Set<string>>(new Set());
const [particles, setParticles] = useState<MapParticle[]>([]);
const particleIdRef = useRef(0);
const seenObservationsRef = useRef(new Set<string>());
// Build prefix index and name index for hop resolution
const { prefixIndex, nameIndex } = useMemo(() => {
const prefix = new Map<string, Contact[]>();
const name = new Map<string, Contact>();
for (const c of contacts) {
const pubkey = c.public_key.toLowerCase();
for (let len = 1; len <= 12 && len <= pubkey.length; len++) {
const p = pubkey.slice(0, len);
const arr = prefix.get(p);
if (arr) arr.push(c);
else prefix.set(p, [c]);
}
if (c.name && !name.has(c.name)) name.set(c.name, c);
}
return { prefixIndex: prefix, nameIndex: name };
}, [contacts]);
// Self GPS
const myLatLon = useMemo<[number, number] | null>(() => {
if (!config || !isValidLocation(config.lat, config.lon)) return null;
return [config.lat, config.lon];
}, [config]);
// Determine time window for packet visualization
const threeDaysAgoSec = useMemo(() => Date.now() / 1000 - THREE_DAYS_SEC, []);
// Filter contacts for map display
const mappableContacts = useMemo(() => { const mappableContacts = useMemo(() => {
if (showPackets && discoveryMode) {
// Discovery mode: only show nodes that have appeared in resolved packets
return contacts.filter(
(c) => isValidLocation(c.lat, c.lon) && discoveredKeys.has(c.public_key)
);
}
if (showPackets) {
// Packet mode: show only last 3 days
return contacts.filter(
(c) =>
isValidLocation(c.lat, c.lon) &&
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > threeDaysAgoSec))
);
}
return contacts.filter( return contacts.filter(
(c) => (c) =>
isValidLocation(c.lat, c.lon) && isValidLocation(c.lat, c.lon) &&
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo)) (c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
); );
}, [ }, [contacts, focusedKey, sevenDaysAgo]);
contacts,
focusedKey,
sevenDaysAgo,
threeDaysAgoSec,
showPackets,
discoveryMode,
discoveredKeys,
]);
// Resolve a path of hop tokens to geographic waypoints (only unambiguous + has GPS)
const resolvePacketPath = useCallback(
(parsed: ReturnType<typeof parsePacket>): [number, number][] | null => {
if (!parsed) return null;
const waypoints: [number, number][] = [];
// Source: advertPubkey, srcHash, or groupTextSender resolved by name
let sourceContact: Contact | null = null;
if (parsed.advertPubkey) {
const prefix = parsed.advertPubkey.slice(0, 12).toLowerCase();
const matches = prefixIndex.get(prefix);
if (matches?.length === 1 && isValidLocation(matches[0].lat, matches[0].lon)) {
sourceContact = matches[0];
}
} else if (parsed.srcHash) {
sourceContact = resolveHopToGps(parsed.srcHash, prefixIndex);
} else if (parsed.groupTextSender) {
sourceContact = resolveNameToGps(parsed.groupTextSender, nameIndex);
}
if (sourceContact) {
waypoints.push([sourceContact.lat!, sourceContact.lon!]);
}
// Intermediate hops (path bytes)
for (const hop of parsed.pathBytes) {
// Only resolve 2+ byte hops (4+ hex chars) to avoid ambiguous 1-byte hops
if (hop.length < 4) continue;
const contact = resolveHopToGps(hop, prefixIndex);
if (contact) {
waypoints.push([contact.lat!, contact.lon!]);
}
}
// Destination: self (our radio), or dstHash
if (myLatLon) {
waypoints.push(myLatLon);
} else if (parsed.dstHash) {
const dest = resolveHopToGps(parsed.dstHash, prefixIndex);
if (dest) {
waypoints.push([dest.lat!, dest.lon!]);
}
}
// Dedupe consecutive identical waypoints
const deduped = dedupeConsecutive(waypoints.map((w) => `${w[0]},${w[1]}`));
if (deduped.length < 2) return null;
return deduped.map((s) => {
const [lat, lon] = s.split(',').map(Number);
return [lat, lon] as [number, number];
});
},
[prefixIndex, nameIndex, myLatLon]
);
// Process new packets into particles and track discovered contacts
useEffect(() => {
if (!showPackets || !rawPackets?.length) return;
const now = Date.now();
const newParticles: MapParticle[] = [];
const newDiscovered = new Set<string>();
for (const pkt of rawPackets) {
// Skip old packets
if (pkt.timestamp < threeDaysAgoSec) continue;
// Deduplicate by observation
const obsKey = getRawPacketObservationKey(pkt);
if (seenObservationsRef.current.has(obsKey)) continue;
const parsed = parsePacket(pkt.data);
if (!parsed) continue;
// Discover contacts from this packet regardless of whether a full path resolves
const resolvedContacts = resolvePacketContacts(
parsed,
prefixIndex,
nameIndex,
myLatLon,
config
);
const path = resolvePacketPath(parsed);
// Only mark as seen if we got something useful; otherwise a later run
// with updated contacts/config can retry this observation.
if (resolvedContacts.size === 0 && !path) continue;
seenObservationsRef.current.add(obsKey);
for (const key of resolvedContacts) newDiscovered.add(key);
if (path) {
newParticles.push({
id: particleIdRef.current++,
path,
color: PARTICLE_COLOR_MAP[getPacketLabel(parsed.payloadType)],
startedAt: now,
});
}
}
if (newDiscovered.size > 0) {
setDiscoveredKeys((prev) => {
const next = new Set(prev);
for (const k of newDiscovered) next.add(k);
return next.size !== prev.size ? next : prev;
});
}
if (newParticles.length === 0) return;
setParticles((prev) => {
const combined = [...prev, ...newParticles];
// Prune expired and cap total
const alive = combined.filter((p) => now - p.startedAt < PARTICLE_LIFETIME_MS);
return alive.slice(-MAX_MAP_PARTICLES);
});
}, [
rawPackets,
showPackets,
resolvePacketPath,
threeDaysAgoSec,
prefixIndex,
nameIndex,
myLatLon,
config,
]);
// Prune expired particles periodically
useEffect(() => {
if (!showPackets) return;
const interval = setInterval(() => {
const now = Date.now();
setParticles((prev) => prev.filter((p) => now - p.startedAt < PARTICLE_LIFETIME_MS));
}, 1000);
return () => clearInterval(interval);
}, [showPackets]);
// Reset discovered set when exiting discovery mode
useEffect(() => {
if (!discoveryMode) setDiscoveredKeys(new Set());
}, [discoveryMode]);
// Clear state when toggling off
useEffect(() => {
if (!showPackets) {
setParticles([]);
setDiscoveredKeys(new Set());
setDiscoveryMode(false);
seenObservationsRef.current.clear();
}
}, [showPackets]);
// Find the focused contact by key // Find the focused contact by key
const focusedContact = useMemo(() => { const focusedContact = useMemo(() => {
@@ -620,17 +124,18 @@ export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewPro
const includesFocusedOutsideWindow = const includesFocusedOutsideWindow =
focusedContact != null && focusedContact != null &&
(focusedContact.last_seen == null || (focusedContact.last_seen == null || focusedContact.last_seen <= sevenDaysAgo);
focusedContact.last_seen <= (showPackets ? threeDaysAgoSec : sevenDaysAgo));
// Track marker refs to open popup programmatically // Track marker refs to open popup programmatically
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({}); const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
// Store ref for a marker
const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => { const setMarkerRef = useCallback((key: string, ref: LeafletCircleMarker | null) => {
if (ref === null) { if (ref === null) {
delete markerRefs.current[key]; delete markerRefs.current[key];
return; return;
} }
markerRefs.current[key] = ref; markerRefs.current[key] = ref;
}, []); }, []);
@@ -643,8 +148,10 @@ export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewPro
} }
}, [mappableContacts]); }, [mappableContacts]);
// Open popup for focused contact after map is ready
useEffect(() => { useEffect(() => {
if (focusedContact && markerRefs.current[focusedContact.public_key]) { if (focusedContact && markerRefs.current[focusedContact.public_key]) {
// Small delay to ensure map has finished rendering
const timer = setTimeout(() => { const timer = setTimeout(() => {
markerRefs.current[focusedContact.public_key]?.openPopup(); markerRefs.current[focusedContact.public_key]?.openPopup();
}, 100); }, 100);
@@ -652,104 +159,48 @@ export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewPro
} }
}, [focusedContact]); }, [focusedContact]);
// Gather unique link paths for static route lines when packet viz is on
const routeLines = useMemo(() => {
if (!showPackets) return [];
const seen = new Set<string>();
const lines: { path: [number, number][]; color: string }[] = [];
for (const p of particles) {
const key = p.path.map((w) => `${w[0]},${w[1]}`).join('|');
if (seen.has(key)) continue;
seen.add(key);
lines.push({ path: p.path, color: p.color });
}
return lines;
}, [showPackets, particles]);
const timeWindowLabel = showPackets ? '3 days' : '7 days';
const infoLabel =
showPackets && discoveryMode
? `${mappableContacts.length} node${mappableContacts.length !== 1 ? 's' : ''} discovered from live traffic`
: `Showing ${mappableContacts.length} contact${mappableContacts.length !== 1 ? 's' : ''} heard in the last ${timeWindowLabel}${includesFocusedOutsideWindow ? ' plus the focused contact' : ''}`;
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Info bar */} {/* Info bar */}
<div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between"> <div className="px-4 py-2 bg-muted/50 text-xs text-muted-foreground flex items-center justify-between">
<span>{infoLabel}</span> <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"> <div className="flex items-center gap-3">
{!showPackets && ( <span className="flex items-center gap-1">
<> <span
<span className="flex items-center gap-1"> className="w-3 h-3 rounded-full"
<span style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
className="w-3 h-3 rounded-full" aria-hidden="true"
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }} />{' '}
aria-hidden="true" &lt;1h
/>{' '} </span>
&lt;1h <span className="flex items-center gap-1">
</span> <span
<span className="flex items-center gap-1"> className="w-3 h-3 rounded-full"
<span style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
className="w-3 h-3 rounded-full" aria-hidden="true"
style={{ backgroundColor: MAP_RECENCY_COLORS.today }} />{' '}
aria-hidden="true" &lt;1d
/>{' '} </span>
&lt;1d <span className="flex items-center gap-1">
</span> <span
<span className="flex items-center gap-1"> className="w-3 h-3 rounded-full"
<span style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
className="w-3 h-3 rounded-full" aria-hidden="true"
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }} />{' '}
aria-hidden="true" &lt;3d
/>{' '} </span>
&lt;3d <span className="flex items-center gap-1">
</span> <span
<span className="flex items-center gap-1"> className="w-3 h-3 rounded-full"
<span style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
className="w-3 h-3 rounded-full" aria-hidden="true"
style={{ backgroundColor: MAP_RECENCY_COLORS.old }} />{' '}
aria-hidden="true" older
/>{' '} </span>
older
</span>
</>
)}
{showPackets && (
<>
<span className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: PARTICLE_COLOR_MAP['AD'] }}
aria-hidden="true"
/>
Ad
</span>
<span className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: PARTICLE_COLOR_MAP['GT'] }}
aria-hidden="true"
/>
Ch
</span>
<span className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: PARTICLE_COLOR_MAP['DM'] }}
aria-hidden="true"
/>
DM
</span>
<span className="flex items-center gap-1">
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: PARTICLE_COLOR_MAP['ACK'] }}
aria-hidden="true"
/>
ACK
</span>
</>
)}
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<span <span
className="w-3 h-3 rounded-full border-2" className="w-3 h-3 rounded-full border-2"
@@ -758,30 +209,10 @@ export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewPro
/>{' '} />{' '}
repeater repeater
</span> </span>
<label className="flex items-center gap-1.5 cursor-pointer ml-2">
<input
type="checkbox"
checked={showPackets}
onChange={(e) => setShowPackets(e.target.checked)}
className="rounded border-border"
/>
<span className="text-[0.6875rem]">Visualize packets</span>
</label>
{showPackets && (
<label className="flex items-center gap-1.5 cursor-pointer">
<input
type="checkbox"
checked={discoveryMode}
onChange={(e) => setDiscoveryMode(e.target.checked)}
className="rounded border-border"
/>
<span className="text-[0.6875rem]">Discover nodes</span>
</label>
)}
</div> </div>
</div> </div>
{/* Map */} {/* Map - z-index constrained to stay below modals/sheets */}
<div <div
className="flex-1 relative" className="flex-1 relative"
style={{ zIndex: 0 }} style={{ zIndex: 0 }}
@@ -792,21 +223,14 @@ export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewPro
center={[20, 0]} center={[20, 0]}
zoom={2} zoom={2}
className="h-full w-full" className="h-full w-full"
style={{ background: tile.background }} style={{ background: '#1a1a2e' }}
> >
<TileLayer key={tile.url} attribution={tile.attribution} url={tile.url} /> <TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
<MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} /> <MapBoundsHandler contacts={mappableContacts} focusedContact={focusedContact} />
{/* Faint route lines for active packet paths */}
{showPackets &&
routeLines.map((line, i) => (
<Polyline
key={i}
positions={line.path}
pathOptions={{ color: line.color, weight: 1, opacity: 0.15, dashArray: '4 6' }}
/>
))}
{mappableContacts.map((contact) => { {mappableContacts.map((contact) => {
const isRepeater = contact.type === CONTACT_TYPE_REPEATER; const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
const color = getMarkerColor(contact.last_seen); const color = getMarkerColor(contact.last_seen);
@@ -851,8 +275,6 @@ export function MapView({ contacts, focusedKey, rawPackets, config }: MapViewPro
</Fragment> </Fragment>
); );
})} })}
{showPackets && <ParticleOverlay particles={particles} />}
</MapContainer> </MapContainer>
</div> </div>
</div> </div>
-4
View File
@@ -44,7 +44,6 @@ type LimitState = 'normal' | 'warning' | 'danger' | 'error';
export interface MessageInputHandle { export interface MessageInputHandle {
appendText: (text: string) => void; appendText: (text: string) => void;
focus: () => void;
} }
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput( export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
@@ -61,9 +60,6 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
// Focus the input after appending // Focus the input after appending
inputRef.current?.focus(); inputRef.current?.focus();
}, },
focus: () => {
inputRef.current?.focus();
},
})); }));
// Calculate character limits based on conversation type // Calculate character limits based on conversation type
+10 -31
View File
@@ -47,7 +47,6 @@ interface MessageListProps {
loadingNewer?: boolean; loadingNewer?: boolean;
onLoadNewer?: () => void; onLoadNewer?: () => void;
onJumpToBottom?: () => void; onJumpToBottom?: () => void;
preSorted?: boolean;
} }
// URL regex for linkifying plain text // URL regex for linkifying plain text
@@ -220,8 +219,8 @@ function HopCountBadge({ paths, onClick, variant }: HopCountBadgeProps) {
const className = const className =
variant === 'header' variant === 'header'
? 'font-normal text-muted-foreground ml-1 text-[0.6875rem] cursor-pointer hover:text-primary hover:underline' ? 'font-normal text-muted-foreground ml-1 text-[11px] cursor-pointer hover:text-primary hover:underline'
: 'text-[0.625rem] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline'; : 'text-[10px] text-muted-foreground ml-1 cursor-pointer hover:text-primary hover:underline';
return ( return (
<span <span
@@ -284,7 +283,6 @@ export function MessageList({
loadingNewer = false, loadingNewer = false,
onLoadNewer, onLoadNewer,
onJumpToBottom, onJumpToBottom,
preSorted = false,
}: MessageListProps) { }: MessageListProps) {
const listRef = useRef<HTMLDivElement>(null); const listRef = useRef<HTMLDivElement>(null);
const prevMessagesLengthRef = useRef<number>(0); const prevMessagesLengthRef = useRef<number>(0);
@@ -300,9 +298,6 @@ export function MessageList({
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set()); const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map()); const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const packetCacheRef = useRef<Map<number, RawPacket>>(new Map()); const packetCacheRef = useRef<Map<number, RawPacket>>(new Map());
const packetSignalOverrideRef = useRef<{ rssi: number | null; snr: number | null } | undefined>(
undefined
);
const [packetInspectorSource, setPacketInspectorSource] = useState< const [packetInspectorSource, setPacketInspectorSource] = useState<
| { kind: 'packet'; packet: RawPacket } | { kind: 'packet'; packet: RawPacket }
| { kind: 'loading'; message: string } | { kind: 'loading'; message: string }
@@ -328,13 +323,6 @@ export function MessageList({
const prevConvKeyRef = useRef<string | null>(null); const prevConvKeyRef = useRef<string | null>(null);
const handleAnalyzePacket = useCallback(async (message: Message) => { const handleAnalyzePacket = useCallback(async (message: Message) => {
// Extract signal from the first path if available
const firstPath = message.paths?.[0];
packetSignalOverrideRef.current =
firstPath && (firstPath.rssi != null || firstPath.snr != null)
? { rssi: firstPath.rssi ?? null, snr: firstPath.snr ?? null }
: undefined;
if (message.packet_id == null) { if (message.packet_id == null) {
setPacketInspectorSource({ setPacketInspectorSource({
kind: 'unavailable', kind: 'unavailable',
@@ -498,11 +486,8 @@ export function MessageList({
// Note: Deduplication is handled by useConversationMessages.observeMessage() // Note: Deduplication is handled by useConversationMessages.observeMessage()
// and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp) // and the database UNIQUE constraint on (type, conversation_key, text, sender_timestamp)
const sortedMessages = useMemo( const sortedMessages = useMemo(
() => () => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
preSorted [messages]
? messages
: [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
[messages, preSorted]
); );
const unreadMarkerIndex = useMemo(() => { const unreadMarkerIndex = useMemo(() => {
if (unreadMarkerLastReadAt === undefined) { if (unreadMarkerLastReadAt === undefined) {
@@ -975,7 +960,7 @@ export function MessageList({
)} )}
> >
{showAvatar && ( {showAvatar && (
<div className="text-[0.8125rem] font-semibold text-foreground mb-0.5"> <div className="text-[13px] font-semibold text-foreground mb-0.5">
{canClickSender ? ( {canClickSender ? (
<span <span
className="cursor-pointer hover:text-primary transition-colors" className="cursor-pointer hover:text-primary transition-colors"
@@ -990,8 +975,8 @@ export function MessageList({
) : ( ) : (
displaySender displaySender
)} )}
<span className="font-normal text-muted-foreground ml-2 text-[0.6875rem]"> <span className="font-normal text-muted-foreground ml-2 text-[11px]">
{formatTime(msg.received_at)} {formatTime(msg.sender_timestamp || msg.received_at)}
</span> </span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && ( {!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge <HopCountBadge
@@ -1018,8 +1003,8 @@ export function MessageList({
))} ))}
{!showAvatar && ( {!showAvatar && (
<> <>
<span className="text-[0.625rem] text-muted-foreground ml-2"> <span className="text-[10px] text-muted-foreground ml-2">
{formatTime(msg.received_at)} {formatTime(msg.sender_timestamp || msg.received_at)}
</span> </span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && ( {!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge <HopCountBadge
@@ -1190,18 +1175,12 @@ export function MessageList({
{packetInspectorSource && ( {packetInspectorSource && (
<RawPacketInspectorDialog <RawPacketInspectorDialog
open={packetInspectorSource !== null} open={packetInspectorSource !== null}
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => !isOpen && setPacketInspectorSource(null)}
if (!isOpen) {
setPacketInspectorSource(null);
packetSignalOverrideRef.current = undefined;
}
}}
channels={channels} channels={channels}
source={packetInspectorSource} source={packetInspectorSource}
title="Analyze Packet" title="Analyze Packet"
description="On-demand raw packet analysis for a message-backed archival packet." description="On-demand raw packet analysis for a message-backed archival packet."
notice={ANALYZE_PACKET_NOTICE} notice={ANALYZE_PACKET_NOTICE}
signalOverride={packetSignalOverrideRef.current}
/> />
)} )}
</div> </div>
+41 -166
View File
@@ -3,29 +3,23 @@ import { Dice5 } from 'lucide-react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription,
DialogFooter,
} from './ui/dialog'; } from './ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Label } from './ui/label'; import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox'; import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag'; type Tab = 'new-contact' | 'new-channel' | 'hashtag';
interface BulkParseResult {
channelNames: string[];
invalidNames: string[];
}
interface NewMessageModalProps { interface NewMessageModalProps {
open: boolean; open: boolean;
undecryptedCount: number; undecryptedCount: number;
showBulkAddChannelTab?: boolean;
prefillRequest?: { prefillRequest?: {
tab: 'hashtag'; tab: 'hashtag';
hashtagName: string; hashtagName: string;
@@ -35,121 +29,53 @@ interface NewMessageModalProps {
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>; onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>; onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>; onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
}
function validateHashtagName(channelName: string): string | null {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
}
function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult {
const tokens = rawText
.split(/[\s,]+/)
.map((token) => token.trim())
.filter(Boolean);
const invalidNames: string[] = [];
const channelNames: string[] = [];
const seen = new Set<string>();
for (const token of tokens) {
const stripped = token.replace(/^#+/, '');
const validationError = validateHashtagName(stripped);
if (validationError) {
invalidNames.push(token);
continue;
}
const normalized = permitCapitals ? stripped : stripped.toLowerCase();
const channelName = `#${normalized}`;
if (seen.has(channelName)) {
continue;
}
seen.add(channelName);
channelNames.push(channelName);
}
return { channelNames, invalidNames };
} }
export function NewMessageModal({ export function NewMessageModal({
open, open,
undecryptedCount, undecryptedCount,
showBulkAddChannelTab = false,
prefillRequest = null, prefillRequest = null,
onClose, onClose,
onCreateContact, onCreateContact,
onCreateChannel, onCreateChannel,
onCreateHashtagChannel, onCreateHashtagChannel,
onBulkAddHashtagChannels,
}: NewMessageModalProps) { }: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('new-contact'); const [tab, setTab] = useState<Tab>('new-contact');
const [name, setName] = useState(''); const [name, setName] = useState('');
const [contactKey, setContactKey] = useState(''); const [contactKey, setContactKey] = useState('');
const [channelKey, setChannelKey] = useState(''); const [channelKey, setChannelKey] = useState('');
const [bulkChannelText, setBulkChannelText] = useState('');
const [tryHistorical, setTryHistorical] = useState(false); const [tryHistorical, setTryHistorical] = useState(false);
const [permitCapitals, setPermitCapitals] = useState(false); const [permitCapitals, setPermitCapitals] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const hashtagInputRef = useRef<HTMLInputElement>(null); const hashtagInputRef = useRef<HTMLInputElement>(null);
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
const resetForm = () => { const resetForm = () => {
setName(''); setName('');
setContactKey(''); setContactKey('');
setChannelKey(''); setChannelKey('');
setBulkChannelText('');
setTryHistorical(false); setTryHistorical(false);
setPermitCapitals(false); setPermitCapitals(false);
setError(''); setError('');
}; };
useEffect(() => { useEffect(() => {
if (!open) { if (!open || !prefillRequest) {
return; return;
} }
if (prefillRequest) { setTab(prefillRequest.tab);
setTab(prefillRequest.tab); setName(prefillRequest.hashtagName);
setName(prefillRequest.hashtagName); setContactKey('');
setContactKey(''); setChannelKey('');
setChannelKey(''); setTryHistorical(false);
setBulkChannelText(''); setPermitCapitals(false);
setTryHistorical(false); setError('');
setPermitCapitals(false); setLoading(false);
setError(''); requestAnimationFrame(() => {
setLoading(false); hashtagInputRef.current?.focus();
requestAnimationFrame(() => { });
hashtagInputRef.current?.focus(); }, [open, prefillRequest]);
});
return;
}
if (showBulkAddChannelTab) {
setTab('bulk-hashtag');
setName('');
setContactKey('');
setChannelKey('');
setBulkChannelText('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
bulkTextareaRef.current?.focus();
});
return;
}
setTab('new-contact');
}, [open, prefillRequest, showBulkAddChannelTab]);
const handleCreate = async () => { const handleCreate = async () => {
setError(''); setError('');
@@ -161,6 +87,7 @@ export function NewMessageModal({
setError('Name and public key are required'); setError('Name and public key are required');
return; return;
} }
// handleCreateContact sets activeConversation with the backend-normalized key
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical); await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
} else if (tab === 'new-channel') { } else if (tab === 'new-channel') {
if (!name.trim() || !channelKey.trim()) { if (!name.trim() || !channelKey.trim()) {
@@ -175,24 +102,10 @@ export function NewMessageModal({
setError(validationError); setError(validationError);
return; return;
} }
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase(); const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical); await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
} else {
const { channelNames, invalidNames } = parseBulkHashtagNames(
bulkChannelText,
permitCapitals
);
if (channelNames.length === 0) {
setError('Enter at least one valid room name');
return;
}
if (invalidNames.length > 0) {
setError(`Invalid room names: ${invalidNames.join(', ')}`);
return;
}
await onBulkAddHashtagChannels(channelNames, tryHistorical);
} }
resetForm(); resetForm();
onClose(); onClose();
} catch (err) { } catch (err) {
@@ -205,6 +118,16 @@ export function NewMessageModal({
} }
}; };
const validateHashtagName = (channelName: string): string | null => {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
};
const handleCreateAndAddAnother = async () => { const handleCreateAndAddAnother = async () => {
setError(''); setError('');
const channelName = name.trim(); const channelName = name.trim();
@@ -216,6 +139,7 @@ export function NewMessageModal({
setLoading(true); setLoading(true);
try { try {
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase(); const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical); await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
setName(''); setName('');
@@ -242,36 +166,28 @@ export function NewMessageModal({
} }
}} }}
> >
<DialogContent className="sm:max-w-[560px]"> <DialogContent className="sm:max-w-[500px]">
<DialogHeader> <DialogHeader>
<DialogTitle>New Conversation</DialogTitle> <DialogTitle>New Conversation</DialogTitle>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'} {tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'} {tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
{tab === 'hashtag' && 'Join a public hashtag channel'} {tab === 'hashtag' && 'Join a public hashtag channel'}
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<Tabs <Tabs
value={tab} value={tab}
onValueChange={(value) => { onValueChange={(v) => {
setTab(value as Tab); setTab(v as Tab);
resetForm(); resetForm();
}} }}
className="w-full" className="w-full"
> >
<TabsList <TabsList className="grid w-full grid-cols-3">
className={
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
}
>
<TabsTrigger value="new-contact">Contact</TabsTrigger> <TabsTrigger value="new-contact">Contact</TabsTrigger>
<TabsTrigger value="new-channel">Private Channel</TabsTrigger> <TabsTrigger value="new-channel">Private Channel</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger> <TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
{showBulkAddChannelTab && (
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
)}
</TabsList> </TabsList>
<TabsContent value="new-contact" className="mt-4 space-y-4"> <TabsContent value="new-contact" className="mt-4 space-y-4">
@@ -323,7 +239,7 @@ export function NewMessageModal({
const bytes = new Uint8Array(16); const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes); crypto.getRandomValues(bytes);
const hex = Array.from(bytes) const hex = Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, '0')) .map((b) => b.toString(16).padStart(2, '0'))
.join(''); .join('');
setChannelKey(hex); setChannelKey(hex);
}} }}
@@ -352,55 +268,20 @@ export function NewMessageModal({
</div> </div>
</div> </div>
<div className="mt-3 space-y-1"> <div className="mt-3 space-y-1">
<label className="flex cursor-pointer items-center gap-3"> <label className="flex items-center gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
checked={permitCapitals} checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)} onChange={(e) => setPermitCapitals(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary" className="w-4 h-4 rounded border-input accent-primary"
/> />
<span className="text-sm">Permit capitals in channel key derivation</span> <span className="text-sm">Permit capitals in channel key derivation</span>
</label> </label>
<p className="pl-7 text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground pl-7">
Not recommended; most companions normalize to lowercase Not recommended; most companions normalize to lowercase
</p> </p>
</div> </div>
</TabsContent> </TabsContent>
{showBulkAddChannelTab && (
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
<textarea
ref={bulkTextareaRef}
id="bulk-hashtag-names"
aria-label="Bulk channel names"
value={bulkChannelText}
onChange={(e) => setBulkChannelText(e.target.value)}
placeholder={'#ops\nmesh-room\nanother-room'}
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Paste room names separated by lines, spaces, or commas. Leading # marks are
stripped automatically.
</p>
</div>
<div className="space-y-1">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in channel key derivation</span>
</label>
<p className="pl-7 text-xs text-muted-foreground">
Not recommended; most companions normalize to lowercase
</p>
</div>
</TabsContent>
)}
</Tabs> </Tabs>
{showHistoricalOption && ( {showHistoricalOption && (
@@ -408,7 +289,7 @@ export function NewMessageModal({
<div className="flex items-center justify-end space-x-2"> <div className="flex items-center justify-end space-x-2">
<Label <Label
htmlFor="try-historical" htmlFor="try-historical"
className="cursor-pointer text-sm text-muted-foreground" className="text-sm text-muted-foreground cursor-pointer"
> >
Try decrypting {undecryptedCount.toLocaleString()} stored packet Try decrypting {undecryptedCount.toLocaleString()} stored packet
{undecryptedCount !== 1 ? 's' : ''} {undecryptedCount !== 1 ? 's' : ''}
@@ -420,7 +301,7 @@ export function NewMessageModal({
/> />
</div> </div>
{tryHistorical && ( {tryHistorical && (
<p className="text-right text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground text-right">
Messages will stream in as they decrypt in the background Messages will stream in as they decrypt in the background
</p> </p>
)} )}
@@ -449,13 +330,7 @@ export function NewMessageModal({
</Button> </Button>
)} )}
<Button onClick={handleCreate} disabled={loading}> <Button onClick={handleCreate} disabled={loading}>
{loading {loading ? 'Creating...' : 'Create'}
? tab === 'bulk-hashtag'
? 'Adding...'
: 'Creating...'
: tab === 'bulk-hashtag'
? 'Add Channels'
: 'Create'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
+26 -51
View File
@@ -103,25 +103,14 @@ export function PathModal({
) : null} ) : null}
{/* Raw path summary */} {/* Raw path summary */}
<div className="text-sm space-y-1"> <div className="text-sm">
{paths.map((p, index) => { {paths.map((p, index) => {
const hops = parsePathHops(p.path, p.path_len); const hops = parsePathHops(p.path, p.path_len);
const rawPath = hops.length > 0 ? hops.join('->') : 'direct'; const rawPath = hops.length > 0 ? hops.join('->') : 'direct';
const hasSignal = p.rssi != null || p.snr != null;
return ( return (
<div key={index}> <div key={index}>
<div> <span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '}
<span className="text-foreground/70 font-semibold">Path {index + 1}:</span>{' '} <span className="font-mono text-muted-foreground">{rawPath}</span>
<span className="font-mono text-muted-foreground">{rawPath}</span>
</div>
{hasSignal && (
<div className="text-[0.6875rem] text-muted-foreground ml-4">
Last hop (as heard by you):{' '}
{p.rssi != null && <span>{p.rssi} dBm RSSI</span>}
{p.rssi != null && p.snr != null && <span> · </span>}
{p.snr != null && <span>{p.snr.toFixed(1)} dB SNR</span>}
</div>
)}
</div> </div>
); );
})} })}
@@ -232,7 +221,7 @@ export function PathModal({
> >
<span className="flex flex-col items-center leading-tight"> <span className="flex flex-col items-center leading-tight">
<span> Resend</span> <span> Resend</span>
<span className="text-[0.625rem] font-normal opacity-80"> <span className="text-[10px] font-normal opacity-80">
Only repeated by new repeaters Only repeated by new repeaters
</span> </span>
</span> </span>
@@ -248,7 +237,7 @@ export function PathModal({
> >
<span className="flex flex-col items-center leading-tight"> <span className="flex flex-col items-center leading-tight">
<span> Resend as new</span> <span> Resend as new</span>
<span className="text-[0.625rem] font-normal opacity-80"> <span className="text-[10px] font-normal opacity-80">
Will appear as duplicate to receivers Will appear as duplicate to receivers
</span> </span>
</span> </span>
@@ -417,12 +406,9 @@ interface HopNodeProps {
distanceUnit: DistanceUnit; distanceUnit: DistanceUnit;
} }
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) { function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
const isAmbiguous = hop.matches.length > 1; const isAmbiguous = hop.matches.length > 1;
const isUnknown = hop.matches.length === 0; const isUnknown = hop.matches.length === 0;
const [expanded, setExpanded] = useState(false);
// Calculate distance from previous location for a contact // Calculate distance from previous location for a contact
// Returns null if prev location unknown/ambiguous or contact has no valid location // Returns null if prev location unknown/ambiguous or contact has no valid location
@@ -461,38 +447,27 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
<div className="font-medium text-muted-foreground">&lt;UNKNOWN&gt;</div> <div className="font-medium text-muted-foreground">&lt;UNKNOWN&gt;</div>
) : isAmbiguous ? ( ) : isAmbiguous ? (
<div> <div>
{(expanded ? hop.matches : hop.matches.slice(0, AMBIGUOUS_MATCH_PREVIEW_LIMIT)).map( {hop.matches.map((contact) => {
(contact) => { const dist = getDistanceForContact(contact);
const dist = getDistanceForContact(contact); const hasLocation = isValidLocation(contact.lat, contact.lon);
const hasLocation = isValidLocation(contact.lat, contact.lon); return (
return ( <div key={contact.public_key} className="font-medium truncate">
<div key={contact.public_key} className="font-medium truncate"> {contact.name || contact.public_key.slice(0, 12)}
{contact.name || contact.public_key.slice(0, 12)} {dist !== null && (
{dist !== null && ( <span className="text-xs text-muted-foreground ml-1">
<span className="text-xs text-muted-foreground ml-1"> - {formatDistance(dist, distanceUnit)}
- {formatDistance(dist, distanceUnit)} </span>
</span> )}
)} {hasLocation && (
{hasLocation && ( <CoordinateLink
<CoordinateLink lat={contact.lat!}
lat={contact.lat!} lon={contact.lon!}
lon={contact.lon!} publicKey={contact.public_key}
publicKey={contact.public_key} />
/> )}
)} </div>
</div> );
); })}
}
)}
{!expanded && hop.matches.length > AMBIGUOUS_MATCH_PREVIEW_LIMIT && (
<button
type="button"
className="text-xs text-primary hover:underline cursor-pointer"
onClick={() => setExpanded(true)}
>
(and {hop.matches.length - AMBIGUOUS_MATCH_PREVIEW_LIMIT} more)
</button>
)}
</div> </div>
) : ( ) : (
<div className="font-medium truncate"> <div className="font-medium truncate">
@@ -35,11 +35,6 @@ type RawPacketInspectorDialogSource =
message: string; message: string;
}; };
interface SignalOverride {
rssi: number | null;
snr: number | null;
}
interface RawPacketInspectorDialogProps { interface RawPacketInspectorDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
@@ -48,12 +43,10 @@ interface RawPacketInspectorDialogProps {
title: string; title: string;
description: string; description: string;
notice?: ReactNode; notice?: ReactNode;
signalOverride?: SignalOverride;
} }
interface RawPacketInspectionPanelProps { interface RawPacketInspectionPanelProps {
packet: RawPacket; packet: RawPacket;
signalOverride?: SignalOverride;
channels: Channel[]; channels: Channel[];
} }
@@ -132,21 +125,15 @@ function formatTimestamp(timestamp: number): string {
}); });
} }
function formatSignal( function formatSignal(packet: RawPacket): string {
packet: RawPacket, const parts: string[] = [];
signalOverride?: SignalOverride if (packet.rssi !== null) {
): { lines: string[]; label: string } { parts.push(`${packet.rssi} dBm RSSI`);
const rssi = signalOverride?.rssi ?? packet.rssi; }
const snr = signalOverride?.snr ?? packet.snr; if (packet.snr !== null) {
const lines: string[] = []; parts.push(`${packet.snr.toFixed(1)} dB SNR`);
if (rssi !== null) lines.push(`${rssi} dBm RSSI`); }
if (snr !== null) lines.push(`${snr.toFixed(1)} dB SNR`); return parts.length > 0 ? parts.join(' · ') : 'No signal sample';
const isOverride =
signalOverride != null && (signalOverride.rssi != null || signalOverride.snr != null);
return {
lines: lines.length > 0 ? lines : ['No signal sample'],
label: isOverride ? 'Last Hop Signal' : 'Signal',
};
} }
function formatByteRange(field: PacketByteField): string { function formatByteRange(field: PacketByteField): string {
@@ -325,7 +312,7 @@ function CompactMetaCard({
}) { }) {
return ( return (
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5"> <div className="rounded-lg border border-border/70 bg-card/70 p-2.5">
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground">{label}</div> <div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">{label}</div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div> <div className="mt-1 text-sm font-medium leading-tight text-foreground">{primary}</div>
{secondary ? ( {secondary ? (
<div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div> <div className="mt-1 text-xs leading-tight text-muted-foreground">{secondary}</div>
@@ -353,7 +340,7 @@ function FullPacketHex({
const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]); const byteRuns = useMemo(() => buildByteRuns(bytes, byteOwners), [byteOwners, bytes]);
return ( return (
<div className="font-mono text-[0.9375rem] leading-7 text-foreground"> <div className="font-mono text-[15px] leading-7 text-foreground">
{byteRuns.map((run, index) => { {byteRuns.map((run, index) => {
const fieldId = run.fieldId; const fieldId = run.fieldId;
const palette = fieldId ? colorMap.get(fieldId) : null; const palette = fieldId ? colorMap.get(fieldId) : null;
@@ -459,9 +446,7 @@ function FieldBox({
<div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between"> <div className="flex flex-col items-start gap-2 sm:flex-row sm:justify-between">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-base font-semibold leading-tight text-foreground">{field.name}</div> <div className="text-base font-semibold leading-tight text-foreground">{field.name}</div>
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground"> <div className="mt-0.5 text-[11px] text-muted-foreground">{formatByteRange(field)}</div>
{formatByteRange(field)}
</div>
</div> </div>
<div <div
className={cn( className={cn(
@@ -479,7 +464,7 @@ function FieldBox({
{field.decryptedMessage ? ( {field.decryptedMessage ? (
<div className="mt-2 rounded border border-border/50 bg-background/40 p-2"> <div className="mt-2 rounded border border-border/50 bg-background/40 p-2">
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground"> <div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
{field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'} {field.name === 'Ciphertext' ? 'Plaintext' : 'Decoded value'}
</div> </div>
<PlaintextContent text={field.decryptedMessage} /> <PlaintextContent text={field.decryptedMessage} />
@@ -501,13 +486,11 @@ function FieldBox({
<div className="text-sm font-medium leading-tight text-foreground"> <div className="text-sm font-medium leading-tight text-foreground">
{part.field} {part.field}
</div> </div>
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground"> <div className="mt-0.5 text-[11px] text-muted-foreground">Bits {part.bits}</div>
Bits {part.bits}
</div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="font-mono text-sm text-foreground">{part.binary}</div> <div className="font-mono text-sm text-foreground">{part.binary}</div>
<div className="mt-0.5 text-[0.6875rem] text-muted-foreground">{part.value}</div> <div className="mt-0.5 text-[11px] text-muted-foreground">{part.value}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -582,11 +565,7 @@ function FieldSection({
); );
} }
export function RawPacketInspectionPanel({ export function RawPacketInspectionPanel({ packet, channels }: RawPacketInspectionPanelProps) {
packet,
channels,
signalOverride,
}: RawPacketInspectionPanelProps) {
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]); const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
const groupTextCandidates = useMemo( const groupTextCandidates = useMemo(
() => buildGroupTextResolutionCandidates(channels), () => buildGroupTextResolutionCandidates(channels),
@@ -619,7 +598,7 @@ export function RawPacketInspectionPanel({
<section className="rounded-lg border border-border/70 bg-card/70 p-3"> <section className="rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-start justify-between gap-2"> <div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground"> <div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
Summary Summary
</div> </div>
<div className="mt-1 text-base font-semibold leading-tight text-foreground"> <div className="mt-1 text-base font-semibold leading-tight text-foreground">
@@ -632,7 +611,7 @@ export function RawPacketInspectionPanel({
</div> </div>
{packetContext ? ( {packetContext ? (
<div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2"> <div className="mt-2 rounded-md border border-border/60 bg-background/35 px-2.5 py-2">
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground"> <div className="text-[10px] uppercase tracking-[0.18em] text-muted-foreground">
{packetContext.title} {packetContext.title}
</div> </div>
<div className="mt-1 text-sm font-medium leading-tight text-foreground"> <div className="mt-1 text-sm font-medium leading-tight text-foreground">
@@ -658,24 +637,11 @@ export function RawPacketInspectionPanel({
primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`} primary={`${inspection.routeTypeName} · ${inspection.payloadTypeName}`}
secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`} secondary={`${inspection.payloadVersionName} · ${formatPathMode(inspection.decoded?.pathHashSize, inspection.pathTokens.length)}`}
/> />
{(() => { <CompactMetaCard
const sig = formatSignal(packet, signalOverride); label="Signal"
return ( primary={formatSignal(packet)}
<div className="rounded-lg border border-border/70 bg-card/70 p-2.5"> secondary={packetContext ? null : undefined}
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground"> />
{sig.label}
</div>
{sig.lines.map((line, i) => (
<div
key={i}
className={`${i === 0 ? 'mt-1' : 'mt-0.5'} text-sm font-medium leading-tight text-foreground`}
>
{line}
</div>
))}
</div>
);
})()}
</section> </section>
</div> </div>
@@ -745,7 +711,6 @@ export function RawPacketInspectorDialog({
title, title,
description, description,
notice, notice,
signalOverride,
}: RawPacketInspectorDialogProps) { }: RawPacketInspectorDialogProps) {
const [packetInput, setPacketInput] = useState(''); const [packetInput, setPacketInput] = useState('');
@@ -770,13 +735,7 @@ export function RawPacketInspectorDialog({
let body: ReactNode; let body: ReactNode;
if (source.kind === 'packet') { if (source.kind === 'packet') {
body = ( body = <RawPacketInspectionPanel packet={source.packet} channels={channels} />;
<RawPacketInspectionPanel
packet={source.packet}
channels={channels}
signalOverride={signalOverride}
/>
);
} else if (source.kind === 'paste') { } else if (source.kind === 'paste') {
body = ( body = (
<> <>
+30 -185
View File
@@ -11,14 +11,11 @@ import {
Cell, Cell,
} from 'recharts'; } from 'recharts';
import { MeshCoreDecoder, Utils } from '@michaelhart/meshcore-decoder';
import { RawPacketList } from './RawPacketList'; import { RawPacketList } from './RawPacketList';
import { RawPacketInspectorDialog } from './RawPacketDetailModal'; import { RawPacketInspectorDialog } from './RawPacketDetailModal';
import { Button } from './ui/button'; import { Button } from './ui/button';
import type { Channel, Contact, RawPacket } from '../types'; import type { Channel, Contact, RawPacket } from '../types';
import { import {
KNOWN_PAYLOAD_TYPES,
RAW_PACKET_STATS_WINDOWS, RAW_PACKET_STATS_WINDOWS,
buildRawPacketStatsSnapshot, buildRawPacketStatsSnapshot,
type NeighborStat, type NeighborStat,
@@ -27,26 +24,9 @@ import {
type RawPacketStatsSessionState, type RawPacketStatsSessionState,
type RawPacketStatsWindow, type RawPacketStatsWindow,
} from '../utils/rawPacketStats'; } from '../utils/rawPacketStats';
import { createDecoderOptions } from '../utils/rawPacketInspector';
import { getContactDisplayName } from '../utils/pubkey'; import { getContactDisplayName } from '../utils/pubkey';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const KNOWN_PAYLOAD_TYPE_SET = new Set<string>(KNOWN_PAYLOAD_TYPES);
function getPacketTypeName(
packet: RawPacket,
decoderOptions?: ReturnType<typeof createDecoderOptions>
): string {
try {
const decoded = MeshCoreDecoder.decode(packet.data, decoderOptions);
if (!decoded.isValid) return 'Unknown';
const name = Utils.getPayloadTypeName(decoded.payloadType);
return KNOWN_PAYLOAD_TYPE_SET.has(name) ? name : 'Unknown';
} catch {
return 'Unknown';
}
}
interface RawPacketFeedViewProps { interface RawPacketFeedViewProps {
packets: RawPacket[]; packets: RawPacket[];
rawPacketStatsSession: RawPacketStatsSessionState; rawPacketStatsSession: RawPacketStatsSessionState;
@@ -231,9 +211,7 @@ function getCoverageMessage(
function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) { function StatTile({ label, value, detail }: { label: string; value: string; detail?: string }) {
return ( return (
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3"> <div className="break-inside-avoid rounded-lg border border-border/70 bg-card/80 p-3">
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground"> <div className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</div>
{label}
</div>
<div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div> <div className="mt-1 text-xl font-semibold tabular-nums text-foreground">{value}</div>
{detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null} {detail ? <div className="mt-1 text-xs text-muted-foreground">{detail}</div> : null}
</div> </div>
@@ -351,7 +329,7 @@ function NeighborList({
: `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`} : `Last seen ${new Date(item.lastSeen * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`}
</div> </div>
{!isNeighborIdentityResolvable(item, contacts) ? ( {!isNeighborIdentityResolvable(item, contacts) ? (
<div className="text-[0.6875rem] text-warning">Identity not resolvable</div> <div className="text-[11px] text-warning">Identity not resolvable</div>
) : null} ) : null}
</div> </div>
{mode !== 'signal' ? ( {mode !== 'signal' ? (
@@ -385,7 +363,7 @@ function TimelineChart({ bins }: { bins: PacketTimelineBin[] }) {
<section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3"> <section className="mb-4 break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3> <h3 className="text-sm font-semibold text-foreground">Traffic Timeline</h3>
<div className="flex flex-wrap justify-end gap-2 text-[0.6875rem] text-muted-foreground"> <div className="flex flex-wrap justify-end gap-2 text-[11px] text-muted-foreground">
{typeOrder.map((type, i) => ( {typeOrder.map((type, i) => (
<span key={type} className="inline-flex items-center gap-1"> <span key={type} className="inline-flex items-center gap-1">
<span <span
@@ -448,48 +426,6 @@ export function RawPacketFeedView({
const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000)); const [nowSec, setNowSec] = useState(() => Math.floor(Date.now() / 1000));
const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null); const [selectedPacket, setSelectedPacket] = useState<RawPacket | null>(null);
const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false); const [analyzeModalOpen, setAnalyzeModalOpen] = useState(false);
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const [enabledTypes, setEnabledTypes] = useState<Set<string>>(() => new Set(KNOWN_PAYLOAD_TYPES));
const decoderOptions = useMemo(() => createDecoderOptions(channels), [channels]);
const packetsWithTypes = useMemo(
() =>
packets.map((packet) => ({
packet,
payloadType: getPacketTypeName(packet, decoderOptions),
})),
[packets, decoderOptions]
);
const allTypesEnabled = enabledTypes.size === KNOWN_PAYLOAD_TYPES.length;
const filteredPackets = useMemo(() => {
if (allTypesEnabled) return packets;
return packetsWithTypes
.filter(({ payloadType }) => enabledTypes.has(payloadType))
.map(({ packet }) => packet);
}, [packetsWithTypes, enabledTypes, packets, allTypesEnabled]);
const handleToggleAll = () => {
setEnabledTypes(allTypesEnabled ? new Set() : new Set(KNOWN_PAYLOAD_TYPES));
};
const handleToggleType = (type: string) => {
setEnabledTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
const handleOnly = (type: string) => {
setEnabledTypes(new Set([type]));
};
useEffect(() => { useEffect(() => {
const interval = window.setInterval(() => { const interval = window.setInterval(() => {
@@ -530,129 +466,38 @@ export function RawPacketFeedView({
); );
return ( return (
<> <>
<div className="border-b border-border px-4 py-2.5"> <div className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
<div className="flex items-center justify-between gap-3"> <div>
<div> <h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2>
<h2 className="font-semibold text-base text-foreground">Raw Packet Feed</h2> <p className="text-xs text-muted-foreground">
<p className="hidden md:block text-xs text-muted-foreground"> Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)}
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)} </p>
</p>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setAnalyzeModalOpen(true)}
>
Analyze Packet
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setStatsOpen((current) => !current)}
aria-expanded={statsOpen}
>
{statsOpen ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
{statsOpen ? 'Hide Stats' : 'Show Stats'}
</Button>
</div>
</div> </div>
<p className="md:hidden text-xs text-muted-foreground"> <div className="flex items-center gap-2">
Collecting stats since {formatTimestamp(rawPacketStatsSession.sessionStartedAt)} <Button
{!mobileFiltersOpen && ( type="button"
<> variant="outline"
{' · '} size="sm"
<button onClick={() => setAnalyzeModalOpen(true)}
type="button" >
className="text-primary hover:text-primary/80 transition-colors" Analyze Packet
onClick={() => setMobileFiltersOpen(true)} </Button>
> <Button
Show Filters type="button"
</button> variant="outline"
</> size="sm"
)} onClick={() => setStatsOpen((current) => !current)}
</p> aria-expanded={statsOpen}
>
{mobileFiltersOpen && ( {statsOpen ? <ChevronRight className="h-4 w-4" /> : <ChevronLeft className="h-4 w-4" />}
<div className="mt-1.5 md:hidden flex flex-wrap items-center gap-x-3 gap-y-1"> {statsOpen ? 'Hide Stats' : 'Show Stats'}
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer"> </Button>
<input
type="checkbox"
checked={allTypesEnabled}
onChange={handleToggleAll}
className="rounded"
/>
All
</label>
{KNOWN_PAYLOAD_TYPES.map((type) => (
<span key={type} className="inline-flex items-center gap-1 text-xs">
<label className="flex items-center gap-1 text-foreground cursor-pointer">
<input
type="checkbox"
checked={enabledTypes.has(type)}
onChange={() => handleToggleType(type)}
className="rounded"
/>
{type}
</label>
<button
type="button"
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
onClick={() => handleOnly(type)}
>
(only)
</button>
</span>
))}
</div>
)}
<div className="mt-1.5 hidden md:flex flex-wrap items-center gap-x-3 gap-y-1">
<label className="flex items-center gap-1 text-xs text-muted-foreground cursor-pointer">
<input
type="checkbox"
checked={allTypesEnabled}
onChange={handleToggleAll}
className="rounded"
/>
All
</label>
{KNOWN_PAYLOAD_TYPES.map((type) => (
<span key={type} className="inline-flex items-center gap-1 text-xs">
<label className="flex items-center gap-1 text-foreground cursor-pointer">
<input
type="checkbox"
checked={enabledTypes.has(type)}
onChange={() => handleToggleType(type)}
className="rounded"
/>
{type}
</label>
<button
type="button"
className="text-[0.625rem] text-muted-foreground hover:text-primary transition-colors"
onClick={() => handleOnly(type)}
>
(only)
</button>
</span>
))}
</div> </div>
</div> </div>
<div className="flex min-h-0 flex-1 flex-col md:flex-row"> <div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}> <div className={cn('min-h-0 min-w-0 flex-1', statsOpen && 'md:border-r md:border-border')}>
<RawPacketList <RawPacketList packets={packets} channels={channels} onPacketClick={setSelectedPacket} />
packets={filteredPackets}
channels={channels}
onPacketClick={setSelectedPacket}
/>
</div> </div>
<aside <aside
@@ -668,7 +513,7 @@ export function RawPacketFeedView({
<div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3"> <div className="break-inside-avoid rounded-lg border border-border/70 bg-card/70 p-3">
<div className="flex flex-wrap items-center justify-between gap-3"> <div className="flex flex-wrap items-center justify-between gap-3">
<div> <div>
<div className="text-[0.625rem] uppercase tracking-wider font-medium text-muted-foreground"> <div className="text-[11px] uppercase tracking-wide text-muted-foreground">
Coverage Coverage
</div> </div>
<div <div
+5 -8
View File
@@ -101,7 +101,7 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Route type badge */} {/* Route type badge */}
<span <span
className={`text-[0.625rem] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`} className={`text-[10px] font-mono px-1.5 py-0.5 rounded ${getRouteTypeColor(decoded.routeType)}`}
title={decoded.routeType} title={decoded.routeType}
> >
{getRouteTypeLabel(decoded.routeType)} {getRouteTypeLabel(decoded.routeType)}
@@ -117,29 +117,26 @@ export function RawPacketList({ packets, channels, onPacketClick }: RawPacketLis
{/* Summary */} {/* Summary */}
<span <span
className={cn( className={cn('text-[13px]', packet.decrypted ? 'text-primary' : 'text-foreground')}
'text-[0.8125rem]',
packet.decrypted ? 'text-primary' : 'text-foreground'
)}
> >
{decoded.summary} {decoded.summary}
</span> </span>
{/* Time */} {/* Time */}
<span className="text-muted-foreground ml-auto text-xs tabular-nums"> <span className="text-muted-foreground ml-auto text-[12px] tabular-nums">
{formatTime(packet.timestamp)} {formatTime(packet.timestamp)}
</span> </span>
</div> </div>
{/* Signal info */} {/* Signal info */}
{(packet.snr !== null || packet.rssi !== null) && ( {(packet.snr !== null || packet.rssi !== null) && (
<div className="text-[0.6875rem] text-muted-foreground mt-0.5 tabular-nums"> <div className="text-[11px] text-muted-foreground mt-0.5 tabular-nums">
{formatSignalInfo(packet)} {formatSignalInfo(packet)}
</div> </div>
)} )}
{/* Raw hex data (always visible) */} {/* Raw hex data (always visible) */}
<div className="font-mono text-[0.625rem] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded"> <div className="font-mono text-[10px] break-all text-muted-foreground mt-1.5 p-1.5 bg-background/60 rounded">
{packet.data.toUpperCase()} {packet.data.toUpperCase()}
</div> </div>
</> </>
+14 -89
View File
@@ -1,18 +1,18 @@
import { useEffect, useRef, useState } from 'react'; import { useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner'; import { toast } from './ui/sonner';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react'; import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon'; import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin'; import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner'; import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword'; import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard'; import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils'; import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo'; import { ContactStatusInfo } from './ContactStatusInfo';
import type { Contact, Conversation, PathDiscoveryResponse, TelemetryHistoryEntry } from '../types'; import type { Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
import { cn } from '../lib/utils'; import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane'; import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane'; import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
@@ -23,7 +23,6 @@ import { LppTelemetryPane } from './repeater/RepeaterLppTelemetryPane';
import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane'; import { OwnerInfoPane } from './repeater/RepeaterOwnerInfoPane';
import { ActionsPane } from './repeater/RepeaterActionsPane'; import { ActionsPane } from './repeater/RepeaterActionsPane';
import { ConsolePane } from './repeater/RepeaterConsolePane'; import { ConsolePane } from './repeater/RepeaterConsolePane';
import { TelemetryHistoryPane } from './repeater/RepeaterTelemetryHistoryPane';
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal'; import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
// Re-export for backwards compatibility (used by repeaterFormatters.test.ts) // Re-export for backwards compatibility (used by repeaterFormatters.test.ts)
@@ -34,6 +33,7 @@ export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared'
interface RepeaterDashboardProps { interface RepeaterDashboardProps {
conversation: Conversation; conversation: Conversation;
contacts: Contact[]; contacts: Contact[];
favorites: Favorite[];
notificationsSupported: boolean; notificationsSupported: boolean;
notificationsEnabled: boolean; notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported'; notificationsPermission: NotificationPermission | 'unsupported';
@@ -45,16 +45,12 @@ interface RepeaterDashboardProps {
onToggleNotifications: () => void; onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void; onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void; onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
autoLoginAndLoadAll?: boolean;
onAutoLoginConsumed?: () => void;
} }
export function RepeaterDashboard({ export function RepeaterDashboard({
conversation, conversation,
contacts, contacts,
favorites,
notificationsSupported, notificationsSupported,
notificationsEnabled, notificationsEnabled,
notificationsPermission, notificationsPermission,
@@ -66,11 +62,6 @@ export function RepeaterDashboard({
onToggleNotifications, onToggleNotifications,
onToggleFavorite, onToggleFavorite,
onDeleteContact, onDeleteContact,
onOpenContactInfo,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
autoLoginAndLoadAll,
onAutoLoginConsumed,
}: RepeaterDashboardProps) { }: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false); const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null; const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
@@ -97,49 +88,7 @@ export function RepeaterDashboard({
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } = const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('repeater', conversation.id); useRememberedServerPassword('repeater', conversation.id);
// Telemetry history: preload from stored data, refresh from live status const isFav = isFavorite(favorites, 'contact', conversation.id);
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
const telemetryHistorySourceRef = useRef<'none' | 'preload' | 'live'>('none');
const telemetryHistoryRequestRef = useRef(0);
useEffect(() => {
telemetryHistoryRequestRef.current += 1;
telemetryHistorySourceRef.current = 'none';
setTelemetryHistory([]);
if (!loggedIn) return;
const requestId = telemetryHistoryRequestRef.current;
api
.repeaterTelemetryHistory(conversation.id)
.then((history) => {
if (telemetryHistoryRequestRef.current !== requestId) return;
if (telemetryHistorySourceRef.current === 'live') return;
telemetryHistorySourceRef.current = 'preload';
setTelemetryHistory(history);
})
.catch(() => {});
}, [loggedIn, conversation.id]);
// When a live status fetch returns embedded telemetry_history, replace local state
useEffect(() => {
const liveHistory = paneData.status?.telemetry_history;
if (!liveHistory) return;
telemetryHistorySourceRef.current = 'live';
setTelemetryHistory(liveHistory);
}, [paneData.status?.telemetry_history]);
// Command palette "ACL login + load all" auto-action
const autoLoginConsumedRef = useRef(false);
useEffect(() => {
if (!autoLoginAndLoadAll || autoLoginConsumedRef.current) return;
autoLoginConsumedRef.current = true;
onAutoLoginConsumed?.();
void loginAsGuest().then(() => loadAll());
}, [autoLoginAndLoadAll, onAutoLoginConsumed, loginAsGuest, loadAll]);
const isFav = contact?.favorite ?? false;
const handleRepeaterLogin = async (nextPassword: string) => { const handleRepeaterLogin = async (nextPassword: string) => {
await login(nextPassword); await login(nextPassword);
persistAfterLogin(nextPassword); persistAfterLogin(nextPassword);
@@ -166,26 +115,11 @@ export function RepeaterDashboard({
<span className="flex min-w-0 flex-col"> <span className="flex min-w-0 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5"> <span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2"> <span className="flex min-w-0 flex-1 items-baseline gap-2">
<h2 className="min-w-0 flex-shrink font-semibold text-base"> <span className="min-w-0 flex-shrink truncate font-semibold text-base">
{onOpenContactInfo ? ( {conversation.name}
<button </span>
type="button"
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`View info for ${conversation.name}`}
onClick={() => onOpenContactInfo(conversation.id)}
>
<span className="truncate">{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.name}</span>
)}
</h2>
<span <span
className="min-w-0 flex-1 truncate font-mono text-[0.6875rem] text-muted-foreground transition-colors hover:text-primary" className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyboardActivate} onKeyDown={handleKeyboardActivate}
@@ -201,7 +135,7 @@ export function RepeaterDashboard({
</span> </span>
</span> </span>
{contact && ( {contact && (
<div className="col-span-2 row-start-2 min-w-0 text-[0.6875rem] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1"> <div className="col-span-2 row-start-2 min-w-0 text-[11px] text-muted-foreground min-[1100px]:col-span-1 min-[1100px]:col-start-2 min-[1100px]:row-start-1">
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} /> <ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
</div> </div>
)} )}
@@ -212,7 +146,7 @@ export function RepeaterDashboard({
size="sm" size="sm"
onClick={loadAll} onClick={loadAll}
disabled={anyLoading} disabled={anyLoading}
className="h-7 px-2 text-[0.6875rem] leading-none border-success text-success hover:bg-success/10 hover:text-success sm:h-8 sm:px-3 sm:text-xs" className="h-7 px-2 text-[11px] leading-none border-success text-success hover:bg-success/10 hover:text-success sm:h-8 sm:px-3 sm:text-xs"
> >
{anyLoading ? 'Loading...' : 'Load All'} {anyLoading ? 'Loading...' : 'Load All'}
</Button> </Button>
@@ -258,7 +192,7 @@ export function RepeaterDashboard({
aria-hidden="true" aria-hidden="true"
/> />
{notificationsEnabled && ( {notificationsEnabled && (
<span className="hidden md:inline text-[0.6875rem] font-medium text-status-connected"> <span className="hidden md:inline text-[11px] font-medium text-status-connected">
Notifications On Notifications On
</span> </span>
)} )}
@@ -402,15 +336,6 @@ export function RepeaterDashboard({
loading={consoleLoading} loading={consoleLoading}
onSend={sendConsoleCommand} onSend={sendConsoleCommand}
/> />
{/* Telemetry history chart — full width, below console */}
<TelemetryHistoryPane
entries={telemetryHistory}
publicKey={conversation.id}
contacts={contacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
/>
</div> </div>
)} )}
</div> </div>
+1 -2
View File
@@ -2,7 +2,6 @@ import { useCallback, type FormEvent } from 'react';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Checkbox } from './ui/checkbox'; import { Checkbox } from './ui/checkbox';
import { shouldAutoFocusInput } from '../utils/autoFocusInput';
interface RepeaterLoginProps { interface RepeaterLoginProps {
repeaterName: string; repeaterName: string;
@@ -65,7 +64,7 @@ export function RepeaterLogin({
placeholder={passwordPlaceholder} placeholder={passwordPlaceholder}
aria-label="Repeater password" aria-label="Repeater password"
disabled={loading} disabled={loading}
autoFocus={shouldAutoFocusInput()} autoFocus
/> />
<label <label
+4 -4
View File
@@ -290,7 +290,7 @@ export function SearchView({
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span <span
className={cn( className={cn(
'text-[0.625rem] font-medium px-1.5 py-0.5 rounded', 'text-[10px] font-medium px-1.5 py-0.5 rounded',
result.type === 'CHAN' result.type === 'CHAN'
? 'bg-primary/20 text-primary' ? 'bg-primary/20 text-primary'
: 'bg-secondary text-secondary-foreground' : 'bg-secondary text-secondary-foreground'
@@ -298,12 +298,12 @@ export function SearchView({
> >
{typeBadge} {typeBadge}
</span> </span>
<span className="text-xs font-medium text-foreground truncate">{convName}</span> <span className="text-[12px] font-medium text-foreground truncate">{convName}</span>
<span className="text-[0.6875rem] text-muted-foreground ml-auto flex-shrink-0"> <span className="text-[11px] text-muted-foreground ml-auto flex-shrink-0">
{formatTime(result.received_at)} {formatTime(result.received_at)}
</span> </span>
</div> </div>
<div className="text-[0.8125rem] text-foreground/80 line-clamp-2 break-words"> <div className="text-[13px] text-foreground/80 line-clamp-2 break-words">
{result.sender_name && !result.outgoing && ( {result.sender_name && !result.outgoing && (
<span className="text-muted-foreground">{result.sender_name}: </span> <span className="text-muted-foreground">{result.sender_name}: </span>
)} )}
-13
View File
@@ -2,7 +2,6 @@ import { useState, useEffect, type ReactNode } from 'react';
import type { import type {
AppSettings, AppSettings,
AppSettingsUpdate, AppSettingsUpdate,
Contact,
HealthStatus, HealthStatus,
RadioAdvertMode, RadioAdvertMode,
RadioConfig, RadioConfig,
@@ -48,10 +47,6 @@ interface SettingsModalBaseProps {
blockedNames?: string[]; blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void; onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void; onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
} }
export type SettingsModalProps = SettingsModalBaseProps & export type SettingsModalProps = SettingsModalBaseProps &
@@ -85,10 +80,6 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames, blockedNames,
onToggleBlockedKey, onToggleBlockedKey,
onToggleBlockedName, onToggleBlockedName,
contacts,
onBulkDeleteContacts,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
} = props; } = props;
const externalSidebarNav = props.externalSidebarNav === true; const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined; const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -248,10 +239,6 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames={blockedNames} blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey} onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName} onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
className={sectionContentClass} className={sectionContentClass}
/> />
) : ( ) : (
+102 -62
View File
@@ -19,6 +19,7 @@ import {
type Contact, type Contact,
type Channel, type Channel,
type Conversation, type Conversation,
type Favorite,
} from '../types'; } from '../types';
import { import {
buildSidebarSectionSortOrders, buildSidebarSectionSortOrders,
@@ -35,6 +36,7 @@ import { isPublicChannelKey } from '../utils/publicChannel';
import { getContactDisplayName } from '../utils/pubkey'; import { getContactDisplayName } from '../utils/pubkey';
import { handleKeyboardActivate } from '../utils/a11y'; import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar'; import { ContactAvatar } from './ContactAvatar';
import { isFavorite } from '../utils/favorites';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -95,7 +97,7 @@ interface SidebarProps {
channels: Channel[]; channels: Channel[];
activeConversation: Conversation | null; activeConversation: Conversation | null;
onSelectConversation: (conversation: Conversation) => void; onSelectConversation: (conversation: Conversation) => void;
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void; onNewMessage: () => void;
lastMessageTimes: ConversationTimes; lastMessageTimes: ConversationTimes;
unreadCounts: Record<string, number>; unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */ /** Tracks which conversations have unread messages that mention the user */
@@ -104,19 +106,35 @@ interface SidebarProps {
crackerRunning: boolean; crackerRunning: boolean;
onToggleCracker: () => void; onToggleCracker: () => void;
onMarkAllRead: () => void; onMarkAllRead: () => void;
favorites: Favorite[];
/** Legacy global sort order, used only to seed per-section local preferences. */
legacySortOrder?: SortOrder;
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean; isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
blockedKeys?: string[];
blockedNames?: string[];
} }
function loadInitialSectionSortOrders(): SidebarSectionSortOrders { type InitialSectionSortState = {
orders: SidebarSectionSortOrders;
source: 'section' | 'legacy' | 'none';
};
function loadInitialSectionSortOrders(): InitialSectionSortState {
const storedOrders = loadLocalStorageSidebarSectionSortOrders(); const storedOrders = loadLocalStorageSidebarSectionSortOrders();
if (storedOrders) return storedOrders; if (storedOrders) {
return { orders: storedOrders, source: 'section' };
}
const legacyOrder = loadLegacyLocalStorageSortOrder(); const legacyOrder = loadLegacyLocalStorageSortOrder();
const orders = buildSidebarSectionSortOrders(legacyOrder ?? undefined); if (legacyOrder) {
saveLocalStorageSidebarSectionSortOrders(orders); return {
return orders; orders: buildSidebarSectionSortOrders(legacyOrder),
source: 'legacy',
};
}
return {
orders: buildSidebarSectionSortOrders(),
source: 'none',
};
} }
export function Sidebar({ export function Sidebar({
@@ -132,20 +150,13 @@ export function Sidebar({
crackerRunning, crackerRunning,
onToggleCracker, onToggleCracker,
onMarkAllRead, onMarkAllRead,
favorites,
legacySortOrder,
isConversationNotificationsEnabled, isConversationNotificationsEnabled,
blockedKeys = [],
blockedNames = [],
}: SidebarProps) { }: SidebarProps) {
const isContactBlocked = useCallback(
(c: Contact) =>
blockedKeys.includes(c.public_key.toLowerCase()) ||
(c.name != null && blockedNames.includes(c.name)),
[blockedKeys, blockedNames]
);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const initialSectionSortOrders = useMemo(loadInitialSectionSortOrders, []); const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortOrders); const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
const initialCollapsedState = useMemo(loadCollapsedState, []); const initialCollapsedState = useMemo(loadCollapsedState, []);
const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools); const [toolsCollapsed, setToolsCollapsed] = useState(initialCollapsedState.tools);
const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites); const [favoritesCollapsed, setFavoritesCollapsed] = useState(initialCollapsedState.favorites);
@@ -154,12 +165,29 @@ export function Sidebar({
const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms); const [roomsCollapsed, setRoomsCollapsed] = useState(initialCollapsedState.rooms);
const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters); const [repeatersCollapsed, setRepeatersCollapsed] = useState(initialCollapsedState.repeaters);
const collapseSnapshotRef = useRef<CollapseState | null>(null); const collapseSnapshotRef = useRef<CollapseState | null>(null);
const sectionSortSourceRef = useRef(initialSectionSortState.source);
useEffect(() => {
if (sectionSortSourceRef.current === 'legacy') {
saveLocalStorageSidebarSectionSortOrders(sectionSortOrders);
sectionSortSourceRef.current = 'section';
return;
}
if (sectionSortSourceRef.current !== 'none' || legacySortOrder === undefined) return;
const seededOrders = buildSidebarSectionSortOrders(legacySortOrder);
setSectionSortOrders(seededOrders);
saveLocalStorageSidebarSectionSortOrders(seededOrders);
sectionSortSourceRef.current = 'section';
}, [legacySortOrder, sectionSortOrders]);
const handleSortToggle = (section: SidebarSortableSection) => { const handleSortToggle = (section: SidebarSortableSection) => {
setSectionSortOrders((prev) => { setSectionSortOrders((prev) => {
const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha'; const nextOrder = prev[section] === 'alpha' ? 'recent' : 'alpha';
const updated = { ...prev, [section]: nextOrder }; const updated = { ...prev, [section]: nextOrder };
saveLocalStorageSidebarSectionSortOrders(updated); saveLocalStorageSidebarSectionSortOrders(updated);
sectionSortSourceRef.current = 'section';
return updated; return updated;
}); });
}; };
@@ -370,32 +398,38 @@ export function Sidebar({
[sortedChannels, query] [sortedChannels, query]
); );
const filteredNonRepeaterContacts = useMemo(() => { const filteredNonRepeaterContacts = useMemo(
const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c)); () =>
return query query
? visible.filter( ? sortedNonRepeaterContacts.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) (c) =>
) c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
: visible; )
}, [sortedNonRepeaterContacts, query, isContactBlocked]); : sortedNonRepeaterContacts,
[sortedNonRepeaterContacts, query]
);
const filteredRooms = useMemo(() => { const filteredRooms = useMemo(
const visible = sortedRooms.filter((c) => !isContactBlocked(c)); () =>
return query query
? visible.filter( ? sortedRooms.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) (c) =>
) c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
: visible; )
}, [sortedRooms, query, isContactBlocked]); : sortedRooms,
[sortedRooms, query]
);
const filteredRepeaters = useMemo(() => { const filteredRepeaters = useMemo(
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c)); () =>
return query query
? visible.filter( ? sortedRepeaters.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query) (c) =>
) c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
: visible; )
}, [sortedRepeaters, query, isContactBlocked]); : sortedRepeaters,
[sortedRepeaters, query]
);
// Expand sections while searching; restore prior collapse state when search ends. // Expand sections while searching; restore prior collapse state when search ends.
useEffect(() => { useEffect(() => {
@@ -484,16 +518,22 @@ export function Sidebar({
nonFavoriteRooms, nonFavoriteRooms,
nonFavoriteRepeaters, nonFavoriteRepeaters,
} = useMemo(() => { } = useMemo(() => {
const favChannels = filteredChannels.filter((c) => c.favorite); const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
const favContacts = [ const favContacts = [
...filteredNonRepeaterContacts, ...filteredNonRepeaterContacts,
...filteredRooms, ...filteredRooms,
...filteredRepeaters, ...filteredRepeaters,
].filter((c) => c.favorite); ].filter((c) => isFavorite(favorites, 'contact', c.public_key));
const nonFavChannels = filteredChannels.filter((c) => !c.favorite); const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
const nonFavContacts = filteredNonRepeaterContacts.filter((c) => !c.favorite); const nonFavContacts = filteredNonRepeaterContacts.filter(
const nonFavRooms = filteredRooms.filter((c) => !c.favorite); (c) => !isFavorite(favorites, 'contact', c.public_key)
const nonFavRepeaters = filteredRepeaters.filter((c) => !c.favorite); );
const nonFavRooms = filteredRooms.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const nonFavRepeaters = filteredRepeaters.filter(
(c) => !isFavorite(favorites, 'contact', c.public_key)
);
const items: FavoriteItem[] = [ const items: FavoriteItem[] = [
...favChannels.map((channel) => ({ type: 'channel' as const, channel })), ...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
@@ -512,6 +552,7 @@ export function Sidebar({
filteredNonRepeaterContacts, filteredNonRepeaterContacts,
filteredRooms, filteredRooms,
filteredRepeaters, filteredRepeaters,
favorites,
sectionSortOrders.favorites, sectionSortOrders.favorites,
sortFavoriteItemsByOrder, sortFavoriteItemsByOrder,
]); ]);
@@ -573,7 +614,7 @@ export function Sidebar({
contactType={row.contact.type} contactType={row.contact.type}
/> />
)} )}
<span className="name flex-1 truncate text-[0.8125rem]">{row.name}</span> <span className="name flex-1 truncate text-[13px]">{row.name}</span>
<span className="ml-auto flex items-center gap-1"> <span className="ml-auto flex items-center gap-1">
{row.notificationsEnabled && ( {row.notificationsEnabled && (
<span aria-label="Notifications enabled" title="Notifications enabled"> <span aria-label="Notifications enabled" title="Notifications enabled">
@@ -583,7 +624,7 @@ export function Sidebar({
{row.unreadCount > 0 && ( {row.unreadCount > 0 && (
<span <span
className={cn( className={cn(
'text-[0.625rem] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center', 'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
highlightUnread highlightUnread
? 'bg-badge-mention text-badge-mention-foreground' ? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground' : 'bg-badge-unread/90 text-badge-unread-foreground'
@@ -613,9 +654,8 @@ export function Sidebar({
}) => ( }) => (
<div <div
key={key} key={key}
data-active={active ? 'true' : undefined}
className={cn( className={cn(
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[0.8125rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', 'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-accent border-l-primary' active && 'bg-accent border-l-primary'
)} )}
role="button" role="button"
@@ -624,10 +664,10 @@ export function Sidebar({
onKeyDown={handleKeyboardActivate} onKeyDown={handleKeyboardActivate}
onClick={onClick} onClick={onClick}
> >
<span className="sidebar-tool-icon" aria-hidden="true"> <span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
{icon} {icon}
</span> </span>
<span className="sidebar-tool-label flex-1 truncate">{label}</span> <span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
</div> </div>
); );
@@ -724,7 +764,7 @@ export function Sidebar({
{showCracker ? 'Hide' : 'Show'} Channel Finder {showCracker ? 'Hide' : 'Show'} Channel Finder
<span <span
className={cn( className={cn(
'ml-1 text-[0.6875rem]', 'ml-1 text-[11px]',
crackerRunning ? 'text-primary' : 'text-muted-foreground' crackerRunning ? 'text-primary' : 'text-muted-foreground'
)} )}
> >
@@ -752,7 +792,7 @@ export function Sidebar({
<div className="flex justify-between items-center px-3 py-2 pt-3.5"> <div className="flex justify-between items-center px-3 py-2 pt-3.5">
<button <button
className={cn( className={cn(
'flex items-center gap-1.5 text-[0.625rem] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded', 'flex items-center gap-1.5 text-[10px] uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded',
isSearching && 'cursor-default' isSearching && 'cursor-default'
)} )}
aria-expanded={!effectiveCollapsed} aria-expanded={!effectiveCollapsed}
@@ -772,7 +812,7 @@ export function Sidebar({
<div className="ml-auto flex items-center gap-1.5"> <div className="ml-auto flex items-center gap-1.5">
{sortSection && sectionSortOrder && ( {sortSection && sectionSortOrder && (
<button <button
className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[0.625rem] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="bg-transparent text-muted-foreground/60 px-1 py-0.5 text-[10px] rounded hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => handleSortToggle(sortSection)} onClick={() => handleSortToggle(sortSection)}
aria-label={ aria-label={
sectionSortOrder === 'alpha' sectionSortOrder === 'alpha'
@@ -791,7 +831,7 @@ export function Sidebar({
{unreadCount > 0 && ( {unreadCount > 0 && (
<span <span
className={cn( className={cn(
'text-[0.625rem] font-medium px-1.5 py-0.5 rounded-full', 'text-[10px] font-medium px-1.5 py-0.5 rounded-full',
highlightUnread highlightUnread
? 'bg-badge-mention text-badge-mention-foreground' ? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-secondary text-muted-foreground' : 'bg-secondary text-muted-foreground'
@@ -820,7 +860,7 @@ export function Sidebar({
onClick={onNewMessage} onClick={onNewMessage}
title="Add channel or contact" title="Add channel or contact"
aria-label="Add channel or contact" aria-label="Add channel or contact"
className="h-8 w-full justify-start gap-2 border-primary/20 bg-primary/5 px-3 text-[0.8125rem] text-primary hover:bg-primary/10 hover:text-primary" className="h-8 w-full justify-start gap-2 px-3 text-[13px]"
> >
<SquarePen className="h-4 w-4" /> <SquarePen className="h-4 w-4" />
<span>Add Channel/Contact</span> <span>Add Channel/Contact</span>
@@ -837,7 +877,7 @@ export function Sidebar({
aria-label="Search conversations" aria-label="Search conversations"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className={cn('h-7 text-[0.8125rem] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')} className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
/> />
{searchQuery && ( {searchQuery && (
<button <button
@@ -863,7 +903,7 @@ export function Sidebar({
{/* Mark All Read */} {/* Mark All Read */}
{!query && Object.values(unreadCounts).some((c) => c > 0) && ( {!query && Object.values(unreadCounts).some((c) => c > 0) && (
<div <div
className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[0.8125rem] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" className="px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyboardActivate} onKeyDown={handleKeyboardActivate}
+1 -1
View File
@@ -123,7 +123,7 @@ export function StatusBar({
<div className="hidden lg:flex items-center gap-2 text-muted-foreground"> <div className="hidden lg:flex items-center gap-2 text-muted-foreground">
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span> <span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
<span <span
className="font-mono text-[0.6875rem] text-muted-foreground cursor-pointer hover:text-primary transition-colors" className="font-mono text-[11px] text-muted-foreground cursor-pointer hover:text-primary transition-colors"
role="button" role="button"
tabIndex={0} tabIndex={0}
onKeyDown={handleKeyboardActivate} onKeyDown={handleKeyboardActivate}
+7 -154
View File
@@ -28,48 +28,6 @@ import { cn } from '@/lib/utils';
type TraceSortMode = 'alpha' | 'recent' | 'distance'; type TraceSortMode = 'alpha' | 'recent' | 'distance';
type CustomHopBytes = 1 | 2 | 4; type CustomHopBytes = 1 | 2 | 4;
const RECENT_TRACES_KEY = 'remoteterm-recent-traces';
const MAX_RECENT_TRACES = 5;
interface SavedTraceHop {
kind: 'repeater' | 'custom';
publicKey?: string;
hopHex?: string;
hopBytes?: CustomHopBytes;
displayName: string;
}
interface SavedTrace {
hops: SavedTraceHop[];
ranAt: number;
}
function loadRecentTraces(): SavedTrace[] {
try {
const raw = localStorage.getItem(RECENT_TRACES_KEY);
if (!raw) return [];
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed.slice(0, MAX_RECENT_TRACES) : [];
} catch {
return [];
}
}
function saveRecentTrace(trace: SavedTrace): void {
try {
const existing = loadRecentTraces();
// Dedupe by hop signature
const sig = trace.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(',');
const deduped = existing.filter(
(t) => t.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(',') !== sig
);
const updated = [trace, ...deduped].slice(0, MAX_RECENT_TRACES);
localStorage.setItem(RECENT_TRACES_KEY, JSON.stringify(updated));
} catch {
// localStorage may be disabled
}
}
type TraceDraftHop = type TraceDraftHop =
| { id: string; kind: 'repeater'; publicKey: string } | { id: string; kind: 'repeater'; publicKey: string }
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes }; | { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
@@ -160,7 +118,7 @@ function TraceNodeRow({
> >
<div <div
className={cn( className={cn(
'flex h-9 w-9 items-center justify-center rounded-full border text-[0.6875rem] font-semibold uppercase tracking-wide', 'flex h-9 w-9 items-center justify-center rounded-full border text-[11px] font-semibold uppercase tracking-wide',
fixed fixed
? 'border-primary/30 bg-primary/10 text-primary' ? 'border-primary/30 bg-primary/10 text-primary'
: 'border-border bg-muted text-muted-foreground' : 'border-border bg-muted text-muted-foreground'
@@ -171,12 +129,12 @@ function TraceNodeRow({
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium">{title}</div> <div className="truncate text-sm font-medium">{title}</div>
<div className="truncate text-xs text-muted-foreground">{subtitle}</div> <div className="truncate text-xs text-muted-foreground">{subtitle}</div>
{meta ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{meta}</div> : null} {meta ? <div className="mt-1 text-[11px] text-muted-foreground">{meta}</div> : null}
{note ? <div className="mt-1 text-[0.6875rem] text-muted-foreground">{note}</div> : null} {note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
</div> </div>
{snr ? ( {snr ? (
<div className="shrink-0 text-right"> <div className="shrink-0 text-right">
<div className="text-[0.6875rem] text-muted-foreground">SNR</div> <div className="text-[11px] text-muted-foreground">SNR</div>
<div className="font-mono text-sm">{snr}</div> <div className="font-mono text-sm">{snr}</div>
</div> </div>
) : null} ) : null}
@@ -196,7 +154,6 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
const [customHopBytesDraft, setCustomHopBytesDraft] = useState<CustomHopBytes>(1); const [customHopBytesDraft, setCustomHopBytesDraft] = useState<CustomHopBytes>(1);
const [customHopHexDraft, setCustomHopHexDraft] = useState(''); const [customHopHexDraft, setCustomHopHexDraft] = useState('');
const [customHopError, setCustomHopError] = useState<string | null>(null); const [customHopError, setCustomHopError] = useState<string | null>(null);
const [recentTraces, setRecentTraces] = useState<SavedTrace[]>(loadRecentTraces);
const activeRunTokenRef = useRef(0); const activeRunTokenRef = useRef(0);
const repeaters = useMemo(() => { const repeaters = useMemo(() => {
@@ -315,56 +272,6 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
clearPendingResult(); clearPendingResult();
}; };
const handleLoadRecentTrace = async (trace: SavedTrace) => {
const hops: TraceDraftHop[] = trace.hops.map((h, i) => {
if (h.kind === 'repeater' && h.publicKey) {
return {
id: nextDraftHopId('repeater', i),
kind: 'repeater' as const,
publicKey: h.publicKey,
};
}
return {
id: nextDraftHopId('custom', i),
kind: 'custom' as const,
hopHex: h.hopHex ?? '',
hopBytes: h.hopBytes ?? (1 as CustomHopBytes),
};
});
setDraftHops(hops);
// Determine hop hash bytes from the loaded hops
const customHop = hops.find((h) => h.kind === 'custom');
const hopHashBytes: CustomHopBytes = customHop?.hopBytes ?? 4;
// Run the trace immediately
const runToken = activeRunTokenRef.current + 1;
activeRunTokenRef.current = runToken;
setLoading(true);
setError(null);
setResult(null);
try {
const traceResult = await onRunTracePath(
hopHashBytes,
hops.map((hop) =>
hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex }
)
);
if (activeRunTokenRef.current !== runToken) return;
setResult(traceResult);
// Re-save to bump this trace to the top of recents
const savedTrace: SavedTrace = { hops: trace.hops, ranAt: Date.now() };
saveRecentTrace(savedTrace);
setRecentTraces(loadRecentTraces());
} catch (err) {
if (activeRunTokenRef.current !== runToken) return;
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
if (activeRunTokenRef.current === runToken) setLoading(false);
}
};
const handleRunTrace = async () => { const handleRunTrace = async () => {
if (draftHops.length === 0) { if (draftHops.length === 0) {
return; return;
@@ -385,27 +292,6 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
return; return;
} }
setResult(traceResult); setResult(traceResult);
// Persist to recent traces
const savedHops: SavedTraceHop[] = draftHops.map((hop) => {
if (hop.kind === 'repeater') {
const c = repeatersByKey.get(hop.publicKey);
return {
kind: 'repeater',
publicKey: hop.publicKey,
displayName: getContactDisplayName(c?.name, hop.publicKey, c?.last_advert ?? null),
};
}
return {
kind: 'custom',
hopHex: hop.hopHex,
hopBytes: hop.hopBytes,
displayName: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}B)`,
};
});
const trace: SavedTrace = { hops: savedHops, ranAt: Date.now() };
saveRecentTrace(trace);
setRecentTraces(loadRecentTraces());
} catch (err) { } catch (err) {
if (activeRunTokenRef.current !== runToken) { if (activeRunTokenRef.current !== runToken) {
return; return;
@@ -484,7 +370,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
))} ))}
</div> </div>
{sortMode === 'distance' && !canSortByDistance ? ( {sortMode === 'distance' && !canSortByDistance ? (
<p className="mt-2 text-[0.6875rem] text-muted-foreground"> <p className="mt-2 text-[11px] text-muted-foreground">
Distance sorting is using known repeater coordinates, but the local radio does not Distance sorting is using known repeater coordinates, but the local radio does not
currently have a valid location. currently have a valid location.
</p> </p>
@@ -535,12 +421,12 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
{getShortKey(contact.public_key)} {getShortKey(contact.public_key)}
</div> </div>
{sortMode === 'distance' && distanceKm !== null ? ( {sortMode === 'distance' && distanceKm !== null ? (
<div className="mt-1 text-[0.6875rem] text-muted-foreground"> <div className="mt-1 text-[11px] text-muted-foreground">
{distanceKm.toFixed(1)} km away {distanceKm.toFixed(1)} km away
</div> </div>
) : null} ) : null}
{selectedCount > 0 ? ( {selectedCount > 0 ? (
<div className="mt-1 text-[0.6875rem] text-muted-foreground"> <div className="mt-1 text-[11px] text-muted-foreground">
Added {selectedCount} time{selectedCount === 1 ? '' : 's'} Added {selectedCount} time{selectedCount === 1 ? '' : 's'}
</div> </div>
) : null} ) : null}
@@ -567,39 +453,6 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
The first node is display-only. The terminal node is the local radio. The first node is display-only. The terminal node is the local radio.
</p> </p>
{recentTraces.length > 0 && (
<div className="mt-2">
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1">
Rerun a recent trace:
</div>
<div className="flex flex-wrap gap-1.5">
{recentTraces.map((trace, i) => {
const label = trace.hops
.map((h) => {
if (h.kind === 'repeater' && h.publicKey) {
const shortKey = h.publicKey.slice(0, 12);
return h.displayName !== shortKey
? `${h.displayName} (${shortKey})`
: shortKey;
}
return h.displayName;
})
.join(' → ');
return (
<button
key={i}
type="button"
className="rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-accent transition-colors truncate max-w-full disabled:opacity-50 disabled:cursor-not-allowed"
disabled={loading}
onClick={() => handleLoadRecentTrace(trace)}
>
{label}
</button>
);
})}
</div>
</div>
)}
</div> </div>
{draftHops.length > 0 ? ( {draftHops.length > 0 ? (
<Button <Button
@@ -13,8 +13,6 @@ export function ConsolePane({
}) { }) {
const [input, setInput] = useState(''); const [input, setInput] = useState('');
const outputRef = useRef<HTMLDivElement>(null); const outputRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const prevLoadingRef = useRef(loading);
// Auto-scroll to bottom on new entries // Auto-scroll to bottom on new entries
useEffect(() => { useEffect(() => {
@@ -23,14 +21,6 @@ export function ConsolePane({
} }
}, [history]); }, [history]);
// Refocus input after command completes
useEffect(() => {
if (prevLoadingRef.current && !loading) {
inputRef.current?.focus();
}
prevLoadingRef.current = loading;
}, [loading]);
const handleSubmit = useCallback( const handleSubmit = useCallback(
async (e: FormEvent) => { async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -69,7 +59,6 @@ export function ConsolePane({
</div> </div>
<form onSubmit={handleSubmit} className="flex gap-2 p-2 border-t border-border"> <form onSubmit={handleSubmit} className="flex gap-2 p-2 border-t border-border">
<Input <Input
ref={inputRef}
type="text" type="text"
autoComplete="off" autoComplete="off"
name="console-input" name="console-input"
@@ -1,278 +0,0 @@
import { useState, useMemo } from 'react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip as RechartsTooltip,
ResponsiveContainer,
} from 'recharts';
import { cn } from '@/lib/utils';
import { Button } from '../ui/button';
import { Separator } from '../ui/separator';
import type { TelemetryHistoryEntry, Contact } from '../../types';
const MAX_TRACKED = 8;
type Metric = 'battery_volts' | 'noise_floor_dbm' | 'packets' | 'uptime_seconds';
const METRIC_CONFIG: Record<Metric, { label: string; unit: string; color: string }> = {
battery_volts: { label: 'Voltage', unit: 'V', color: '#22c55e' },
noise_floor_dbm: { label: 'Noise Floor', unit: 'dBm', color: '#8b5cf6' },
packets: { label: 'Packets', unit: '', color: '#0ea5e9' },
uptime_seconds: { label: 'Uptime', unit: 's', color: '#f59e0b' },
};
const TOOLTIP_STYLE = {
contentStyle: {
backgroundColor: 'hsl(var(--popover))',
border: '1px solid hsl(var(--border))',
borderRadius: '6px',
fontSize: '11px',
color: 'hsl(var(--popover-foreground))',
},
itemStyle: { color: 'hsl(var(--popover-foreground))' },
labelStyle: { color: 'hsl(var(--muted-foreground))' },
} as const;
function formatTime(ts: number): string {
return new Date(ts * 1000).toLocaleString([], {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatUptime(seconds: number): string {
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
return `${(seconds / 86400).toFixed(1)}d`;
}
interface TelemetryHistoryPaneProps {
entries: TelemetryHistoryEntry[];
publicKey: string;
contacts: Contact[];
trackedTelemetryRepeaters: string[];
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
}
export function TelemetryHistoryPane({
entries,
publicKey,
contacts,
trackedTelemetryRepeaters,
onToggleTrackedTelemetry,
}: TelemetryHistoryPaneProps) {
const [metric, setMetric] = useState<Metric>('battery_volts');
const [toggling, setToggling] = useState(false);
const isTracked = trackedTelemetryRepeaters.includes(publicKey);
const slotsFull = trackedTelemetryRepeaters.length >= MAX_TRACKED && !isTracked;
const config = METRIC_CONFIG[metric];
const chartData = useMemo(() => {
return entries.map((e) => {
const d = e.data;
return {
timestamp: e.timestamp,
battery_volts: d.battery_volts,
noise_floor_dbm: d.noise_floor_dbm,
packets_received: d.packets_received,
packets_sent: d.packets_sent,
uptime_seconds: d.uptime_seconds,
};
});
}, [entries]);
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
const yDomain = useMemo<[number, number] | undefined>(() => {
if (metric !== 'battery_volts' || chartData.length === 0) return undefined;
const values = chartData.map((d) => d.battery_volts).filter((v) => v != null) as number[];
if (values.length === 0) return [3, 5];
const lo = Math.min(...values);
const hi = Math.max(...values);
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
}, [metric, chartData]);
const handleToggle = async () => {
setToggling(true);
try {
await onToggleTrackedTelemetry(publicKey);
} finally {
setToggling(false);
}
};
const trackedNames = useMemo(() => {
if (!slotsFull) return [];
return trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
return { key, name: contact?.name ?? key.slice(0, 12) };
});
}, [slotsFull, trackedTelemetryRepeaters, contacts]);
return (
<div className="border border-border rounded-lg overflow-hidden">
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">Telemetry History</h3>
{entries.length > 0 && (
<span className="text-[0.625rem] text-muted-foreground">{entries.length} samples</span>
)}
</div>
</div>
<div className="p-3">
{/* Explanation + tracking toggle */}
<div className="mb-3 space-y-3">
<p className="text-xs text-muted-foreground leading-relaxed">
Any time repeater telemetry is fetched, the metrics are stored for 30 days (or 1,000
samples, whichever comes first). This telemetry is stored on normal interactive fetches
via the repeater pane, API calls to the endpoint (
<code className="text-[0.6875rem]">POST /api/contacts/&lt;key&gt;/repeater/status</code>
), or when the repeater is opted into interval telemetry polling, in which case the
repeater will be polled for metrics every 8 hours. You can see which repeaters are opted
into this flow in the{' '}
<a
href="#settings/database"
className="underline text-primary hover:text-primary/80 transition-colors"
>
Database &amp; Messaging
</a>{' '}
settings pane. A maximum of {MAX_TRACKED} repeaters may be opted into this for the sake
of keeping mesh congestion reasonable.
</p>
{isTracked ? (
<Button
variant="outline"
onClick={handleToggle}
disabled={toggling}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{toggling ? 'Updating...' : 'Remove Repeater from Interval Metrics Tracking'}
</Button>
) : slotsFull ? (
<div className="space-y-2">
<Button variant="outline" disabled>
Tracking Full ({trackedTelemetryRepeaters.length}/{MAX_TRACKED} slots used)
</Button>
<p className="text-xs text-muted-foreground">
Disable tracking on another repeater to free a slot:{' '}
{trackedNames.map((t) => t.name).join(', ')}
</p>
</div>
) : (
<Button
variant="outline"
onClick={handleToggle}
disabled={toggling}
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
>
{toggling ? 'Updating...' : 'Opt Repeater into 8hr Interval Metrics Tracking'}
</Button>
)}
</div>
<Separator className="mb-3" />
{/* Metric selector */}
<div className="flex gap-1 mb-2">
{(Object.keys(METRIC_CONFIG) as Metric[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setMetric(m)}
className={cn(
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
metric === m
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
{METRIC_CONFIG[m].label}
</button>
))}
</div>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No history yet. Fetch status above to record data points.
</p>
) : (
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="timestamp"
type="number"
domain={['dataMin', 'dataMax']}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={formatTime}
/>
<YAxis
domain={yDomain}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => (metric === 'uptime_seconds' ? formatUptime(v) : `${v}`)}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
stroke: 'hsl(var(--muted-foreground))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
labelFormatter={(ts) => formatTime(Number(ts))}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
formatter={(value: any, name: any) => {
const numVal = typeof value === 'number' ? value : Number(value);
const display = metric === 'uptime_seconds' ? formatUptime(numVal) : `${value}`;
const suffix =
metric === 'uptime_seconds' ? '' : config.unit ? ` ${config.unit}` : '';
const label =
metric === 'packets'
? name === 'packets_received'
? 'Received'
: 'Sent'
: config.label;
return [`${display}${suffix}`, label];
}}
/>
{dataKeys.map((key, i) => (
<Area
key={key}
type="linear"
dataKey={key}
stroke={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
fill={metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color}
fillOpacity={0.15}
strokeWidth={1.5}
dot={{
r: 4,
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
strokeWidth: 1.5,
stroke: 'hsl(var(--popover))',
}}
activeDot={{
r: 6,
fill: metric === 'packets' ? (i === 0 ? '#0ea5e9' : '#f43f5e') : config.color,
strokeWidth: 2,
stroke: 'hsl(var(--popover))',
}}
/>
))}
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
);
}
@@ -141,10 +141,10 @@ export function RepeaterPane({
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border"> <div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-sm font-medium">{title}</h3> <h3 className="text-sm font-medium">{title}</h3>
{headerNote && <p className="text-[0.6875rem] text-muted-foreground">{headerNote}</p>} {headerNote && <p className="text-[11px] text-muted-foreground">{headerNote}</p>}
{fetchedAt && ( {fetchedAt && (
<p <p
className="text-[0.6875rem] text-muted-foreground" className="text-[11px] text-muted-foreground"
title={new Date(fetchedAt).toLocaleString()} title={new Date(fetchedAt).toLocaleString()}
> >
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)}) Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
@@ -1,352 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { api } from '../../api';
import { getContactDisplayName } from '../../utils/pubkey';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
import { toast } from '../ui/sonner';
import type { Contact } from '../../types';
const CONTACT_TYPE_LABELS: Record<number, string> = {
0: 'Unknown',
1: 'Client',
2: 'Repeater',
3: 'Room',
4: 'Sensor',
};
function formatDate(ts: number): string {
return new Date(ts * 1000).toLocaleDateString([], {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
function formatDateISO(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 10);
}
function datetimeToUnix(datetimeStr: string): number {
const d = new Date(datetimeStr);
return Math.floor(d.getTime() / 1000);
}
interface BulkDeleteContactsModalProps {
open: boolean;
onClose: () => void;
contacts: Contact[];
onDeleted: (deletedKeys: string[]) => void;
}
export function BulkDeleteContactsModal({
open,
onClose,
contacts,
onDeleted,
}: BulkDeleteContactsModalProps) {
const [step, setStep] = useState<'select' | 'confirm'>('select');
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
const [deleting, setDeleting] = useState(false);
const lastClickedKeyRef = useRef<string | null>(null);
const resetAndClose = useCallback(() => {
setStep('select');
setSelectedKeys(new Set());
setStartDate('');
setEndDate('');
setTypeFilter('all');
lastClickedKeyRef.current = null;
onClose();
}, [onClose]);
const filteredContacts = useMemo(() => {
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
if (typeFilter !== 'all') {
list = list.filter((c) => c.type === typeFilter);
}
if (startDate) {
const start = datetimeToUnix(startDate);
list = list.filter((c) => (c.first_seen ?? 0) >= start);
}
if (endDate) {
const end = datetimeToUnix(endDate);
list = list.filter((c) => (c.first_seen ?? 0) <= end);
}
return list;
}, [contacts, typeFilter, startDate, endDate]);
const handleToggle = (key: string, shiftKey: boolean) => {
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
const keys = filteredContacts.map((c) => c.public_key);
const lastIdx = keys.indexOf(lastClickedKeyRef.current);
const curIdx = keys.indexOf(key);
if (lastIdx >= 0 && curIdx >= 0) {
const from = Math.min(lastIdx, curIdx);
const to = Math.max(lastIdx, curIdx);
const rangeKeys = keys.slice(from, to + 1);
setSelectedKeys((prev) => {
const next = new Set(prev);
for (const k of rangeKeys) next.add(k);
return next;
});
lastClickedKeyRef.current = key;
return;
}
}
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
lastClickedKeyRef.current = key;
};
const handleSelectAll = () => {
setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key)));
};
const handleSelectNone = () => {
setSelectedKeys(new Set());
};
const selectedContacts = useMemo(
() => contacts.filter((c) => selectedKeys.has(c.public_key)),
[contacts, selectedKeys]
);
const contactCount = selectedContacts.filter((c) => c.type === 1 || c.type === 0).length;
const repeaterCount = selectedContacts.filter((c) => c.type === 2).length;
const roomCount = selectedContacts.filter((c) => c.type === 3).length;
const sensorCount = selectedContacts.filter((c) => c.type === 4).length;
const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0);
const minDate =
firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown';
const maxDate =
firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown';
const handleDelete = async () => {
setDeleting(true);
try {
const keysToDelete = [...selectedKeys];
const result = await api.bulkDeleteContacts(keysToDelete);
toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`);
onDeleted(keysToDelete);
resetAndClose();
} catch (err) {
console.error('Bulk delete failed:', err);
toast.error('Bulk delete failed', {
description: err instanceof Error ? err.message : undefined,
});
} finally {
setDeleting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
<DialogHeader>
<DialogTitle>
{step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'}
</DialogTitle>
<DialogDescription>
{step === 'select'
? 'Select contacts to delete. Message history will be preserved and accessible if a contact is re-added, but will no longer appear in the sidebar.'
: 'Review the contacts that will be permanently deleted.'}
</DialogDescription>
</DialogHeader>
{step === 'select' && (
<>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Show</label>
<select
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
onChange={(e) =>
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
}
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="all">All</option>
<option value="1">Clients</option>
<option value="2">Repeaters</option>
<option value="3">Room Servers</option>
<option value="4">Sensors</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created after</label>
<Input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created before</label>
<Input
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="flex gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
Select all
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleSelectNone}>
Select none
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
{(startDate || endDate) && ' (filtered)'}
{' · '}
{selectedKeys.size} selected
</div>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
{filteredContacts.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No contacts match the selected date range.
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5 w-8" />
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{filteredContacts.map((c) => (
<tr
key={c.public_key}
className="border-t border-border hover:bg-accent/50 cursor-pointer"
onClick={(e) => handleToggle(c.public_key, e.shiftKey)}
>
<td className="px-3 py-1.5">
<input
type="checkbox"
checked={selectedKeys.has(c.public_key)}
onChange={(e) =>
handleToggle(
c.public_key,
e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey
)
}
onClick={(e) => e.stopPropagation()}
className="rounded border-input"
/>
</td>
<td className="px-3 py-1.5 truncate max-w-[10rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={resetAndClose}>
Cancel
</Button>
<Button
variant="outline"
className="border-warning text-warning hover:bg-warning/10 hover:text-warning"
disabled={selectedKeys.size === 0}
onClick={() => setStep('confirm')}
>
Proceed to confirmation ({selectedKeys.size})
</Button>
</div>
</>
)}
{step === 'confirm' && (
<>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{selectedContacts.map((c) => (
<tr key={c.public_key} className="border-t border-border">
<td className="px-3 py-1.5 truncate max-w-[12rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 pt-2">
<Button
variant="destructive"
className="w-full h-auto py-3 text-wrap"
disabled={deleting}
onClick={handleDelete}
>
{deleting
? 'Deleting...'
: `I confirm permanent, irrevocable deletion of all listed nodes above, totalling ${[
contactCount > 0 && `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
repeaterCount > 0 &&
`${repeaterCount} repeater${repeaterCount === 1 ? '' : 's'}`,
roomCount > 0 && `${roomCount} room${roomCount === 1 ? '' : 's'}`,
sensorCount > 0 && `${sensorCount} sensor${sensorCount === 1 ? '' : 's'}`,
]
.filter(Boolean)
.join(', ')}, spanning creation dates from ${minDate} to ${maxDate}`}
</Button>
<Button variant="secondary" onClick={() => setStep('select')} disabled={deleting}>
Back
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -131,7 +131,7 @@ export function SettingsAboutSection({
<div className="text-center"> <div className="text-center">
<a <a
href="./api/debug" href="/api/debug"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="text-xs text-muted-foreground hover:text-primary hover:underline" className="text-xs text-muted-foreground hover:text-primary hover:underline"
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect } from 'react';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { Button } from '../ui/button'; import { Button } from '../ui/button';
@@ -6,14 +6,7 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner'; import { toast } from '../ui/sonner';
import { api } from '../../api'; import { api } from '../../api';
import { formatTime } from '../../utils/messageParser'; import { formatTime } from '../../utils/messageParser';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal'; import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
HealthStatus,
TelemetryHistoryEntry,
} from '../../types';
export function SettingsDatabaseSection({ export function SettingsDatabaseSection({
appSettings, appSettings,
@@ -24,10 +17,6 @@ export function SettingsDatabaseSection({
blockedNames = [], blockedNames = [],
onToggleBlockedKey, onToggleBlockedKey,
onToggleBlockedName, onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
trackedTelemetryRepeaters = [],
onToggleTrackedTelemetry,
className, className,
}: { }: {
appSettings: AppSettings; appSettings: AppSettings;
@@ -38,51 +27,20 @@ export function SettingsDatabaseSection({
blockedNames?: string[]; blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void; onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void; onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
trackedTelemetryRepeaters?: string[];
onToggleTrackedTelemetry?: (publicKey: string) => Promise<void>;
className?: string; className?: string;
}) { }) {
const [retentionDays, setRetentionDays] = useState('14'); const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false); const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false); const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false); const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [latestTelemetry, setLatestTelemetry] = useState<
Record<string, TelemetryHistoryEntry | null>
>({});
const telemetryFetchedRef = useRef(false);
useEffect(() => { useEffect(() => {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert); setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
}, [appSettings]); }, [appSettings]);
useEffect(() => {
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
telemetryFetchedRef.current = true;
let cancelled = false;
const fetches = trackedTelemetryRepeaters.map((key) =>
api.repeaterTelemetryHistory(key).then(
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
() => [key, null] as const
)
);
Promise.all(fetches).then((entries) => {
if (cancelled) return;
setLatestTelemetry(Object.fromEntries(entries));
});
return () => {
cancelled = true;
};
}, [trackedTelemetryRepeaters]);
const handleCleanup = async () => { const handleCleanup = async () => {
const days = parseInt(retentionDays, 10); const days = parseInt(retentionDays, 10);
if (isNaN(days) || days < 1) { if (isNaN(days) || days < 1) {
@@ -134,15 +92,7 @@ export function SettingsDatabaseSection({
setError(null); setError(null);
try { try {
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert }; await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
const currentBlocked = appSettings.discovery_blocked_types ?? [];
if (
discoveryBlockedTypes.length !== currentBlocked.length ||
discoveryBlockedTypes.some((t) => !currentBlocked.includes(t))
) {
update.discovery_blocked_types = discoveryBlockedTypes;
}
await onSaveAppSettings(update);
toast.success('Database settings saved'); toast.success('Database settings saved');
} catch (err) { } catch (err) {
console.error('Failed to save database settings:', err); console.error('Failed to save database settings:', err);
@@ -155,93 +105,93 @@ export function SettingsDatabaseSection({
return ( return (
<div className={className}> <div className={className}>
{/* ── Database Overview ── */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base">Database Overview</Label> <div className="flex justify-between items-center">
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2"> <span className="text-sm text-muted-foreground">Database size</span>
<div className="flex justify-between items-center"> <span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
<span className="text-sm">Database size</span>
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">Oldest undecrypted packet</span>
{health?.oldest_undecrypted_timestamp ? (
<span className="text-sm font-semibold">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="font-normal text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days)
</span>
</span>
) : (
<span className="text-sm text-muted-foreground">None</span>
)}
</div>
</div> </div>
{health?.oldest_undecrypted_timestamp ? (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="font-medium">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days old)
</span>
</span>
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="text-muted-foreground">None</span>
</div>
)}
</div> </div>
<Separator /> <Separator />
{/* ── Storage Cleanup ── */} <div className="space-y-3">
<div className="space-y-4"> <Label>Delete Undecrypted Packets</Label>
<Label className="text-base">Storage Cleanup</Label> <p className="text-xs text-muted-foreground">
Permanently deletes stored raw packets containing DMs and channel messages that have not
<div className="rounded-md border border-border p-3 space-y-2"> yet been decrypted. These packets are retained in case you later obtain the correct key
<Label className="text-sm">Delete Undecrypted Packets</Label> once deleted, these messages can never be recovered or decrypted.
<p className="text-xs text-muted-foreground"> </p>
Permanently deletes stored raw packets that have not yet been decrypted. These are <div className="flex gap-2 items-end">
retained in case you later obtain the correct key once deleted, these messages can <div className="space-y-1">
never be recovered. <Label htmlFor="retention-days" className="text-xs">
</p> Older than (days)
<div className="flex gap-2 items-end"> </Label>
<div className="space-y-1"> <Input
<Label htmlFor="retention-days" className="text-xs text-muted-foreground"> id="retention-days"
Older than (days) type="number"
</Label> min="1"
<Input max="365"
id="retention-days" value={retentionDays}
type="number" onChange={(e) => setRetentionDays(e.target.value)}
min="1" className="w-24"
max="365" />
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
</div>
<Button
variant="outline"
onClick={handleCleanup}
disabled={cleaning}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{cleaning ? 'Deleting...' : 'Delete'}
</Button>
</div> </div>
</div>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes the raw packet bytes behind messages that are already decrypted and visible in
chat. This frees space but removes packet-analysis availability for those messages. It
does not affect displayed messages or future decryption.
</p>
<Button <Button
variant="outline" variant="outline"
onClick={handlePurgeDecryptedRawPackets} onClick={handleCleanup}
disabled={purgingDecryptedRaw} disabled={cleaning}
className="w-full border-warning/50 text-warning hover:bg-warning/10" className="border-destructive/50 text-destructive hover:bg-destructive/10"
> >
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'} {cleaning ? 'Deleting...' : 'Permanently Delete'}
</Button> </Button>
</div> </div>
</div> </div>
<Separator /> <Separator />
{/* ── DM Decryption ── */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base">DM Decryption</Label> <Label>Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or your ability to do historical decryption,
but it will remove packet-analysis availability for those historical messages.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-warning/50 text-warning hover:bg-warning/10"
>
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
</Button>
</div>
<Separator />
<div className="space-y-3">
<Label>DM Decryption</Label>
<label className="flex items-center gap-3 cursor-pointer"> <label className="flex items-center gap-3 cursor-pointer">
<input <input
type="checkbox" type="checkbox"
@@ -259,150 +209,15 @@ export function SettingsDatabaseSection({
<Separator /> <Separator />
{/* ── Tracked Repeater Telemetry ── */}
<div className="space-y-3">
<Label className="text-base">Tracked Repeater Telemetry</Label>
<p className="text-xs text-muted-foreground">
Repeaters opted into automatic telemetry collection are polled every 8 hours. Up to 8
repeaters may be tracked at a time ({trackedTelemetryRepeaters.length} / 8 slots used).
</p>
{trackedTelemetryRepeaters.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
</p>
) : (
<div className="space-y-2">
{trackedTelemetryRepeaters.map((key) => {
const contact = contacts.find((c) => c.public_key === key);
const displayName = contact?.name ?? key.slice(0, 12);
const snap = latestTelemetry[key];
const d = snap?.data;
return (
<div key={key} className="rounded-md border border-border px-3 py-2">
<div className="flex items-center justify-between gap-2">
<div className="flex-1 min-w-0">
<span className="text-sm truncate block">{displayName}</span>
<span className="text-[0.625rem] text-muted-foreground font-mono">
{key.slice(0, 12)}
</span>
</div>
{onToggleTrackedTelemetry && (
<Button
variant="ghost"
size="sm"
onClick={() => onToggleTrackedTelemetry(key)}
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
>
Remove
</Button>
)}
</div>
{d ? (
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
<span>{d.battery_volts?.toFixed(2)}V</span>
<span>noise {d.noise_floor_dbm} dBm</span>
<span>
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
</span>
<span>
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
</span>
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
</div>
) : snap === null ? (
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
No telemetry recorded yet
</div>
) : null}
</div>
);
})}
</div>
)}
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-2">
<Label className="text-base">Contact Management</Label>
</div>
{/* Block discovery of new node types */}
<div className="space-y-3">
<Label>Block Discovery of New Node Types</Label>
<p className="text-xs text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() =>
setDiscoveryBlockedTypes((prev) =>
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
)
}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
</p>
)}
</div>
<Separator />
{/* Blocked contacts list */}
<div className="space-y-3"> <div className="space-y-3">
<Label>Blocked Contacts</Label> <Label>Blocked Contacts</Label>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
MQTT forwarding and bot responses are not affected. Messages are still stored and will affected. Messages are still stored and will reappear if unblocked.
reappear if unblocked.
</p> </p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? ( {blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic"> <p className="text-sm text-muted-foreground italic">No blocked contacts</p>
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{blockedKeys.length > 0 && ( {blockedKeys.length > 0 && (
@@ -453,25 +268,15 @@ export function SettingsDatabaseSection({
)} )}
</div> </div>
<Separator /> {error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
{/* Bulk delete */} <Button onClick={handleSave} disabled={busy} className="w-full">
<div className="space-y-3"> {busy ? 'Saving...' : 'Save Settings'}
<Label>Bulk Delete Contacts</Label> </Button>
<p className="text-xs text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
</div>
</div> </div>
); );
} }
@@ -537,7 +537,7 @@ function CreateIntegrationDialog({
<div className="space-y-4"> <div className="space-y-4">
{sectionedOptions.map((group) => ( {sectionedOptions.map((group) => (
<div key={group.section} className="space-y-1.5"> <div key={group.section} className="space-y-1.5">
<div className="px-2 text-[0.6875rem] font-semibold uppercase tracking-wider text-muted-foreground"> <div className="px-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{group.section} {group.section}
</div> </div>
{group.options.map((option) => { {group.options.map((option) => {
@@ -577,7 +577,7 @@ function CreateIntegrationDialog({
{selectedOption ? ( {selectedOption ? (
<> <>
<div className="space-y-1.5"> <div className="space-y-1.5">
<div className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <div className="text-xs font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{selectedOption.section} {selectedOption.section}
</div> </div>
<h3 className="text-lg font-semibold">{selectedOption.label}</h3> <h3 className="text-lg font-semibold">{selectedOption.label}</h3>
@@ -643,20 +643,16 @@ function formatPrivateTopicSummary(config: Record<string, unknown>) {
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`; return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
} }
function censorAppriseUrl(url: string): string { function formatAppriseTargets(urls: string | undefined, maxLength = 80) {
const protoMatch = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
if (protoMatch) return `${protoMatch[0]}********`;
return '********';
}
function formatAppriseTargets(urls: string | undefined) {
const targets = (urls || '') const targets = (urls || '')
.split('\n') .split('\n')
.map((line) => line.trim()) .map((line) => line.trim())
.filter(Boolean); .filter(Boolean);
if (targets.length === 0) return 'No targets configured'; if (targets.length === 0) return 'No targets configured';
return targets.map(censorAppriseUrl).join(', '); const joined = targets.join(', ');
if (joined.length <= maxLength) return joined;
return `${joined.slice(0, maxLength - 3)}...`;
} }
function formatSqsQueueSummary(config: Record<string, unknown>) { function formatSqsQueueSummary(config: Record<string, unknown>) {
@@ -1666,8 +1662,7 @@ function AppriseConfigEditor({
rows={4} rows={4}
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
One URL per line. All URLs receive every matched notification. For Matrix room version 12 One URL per line. All URLs receive every matched notification.
(servername-less room IDs), append <code>?hsreq=no</code> to the URL.
</p> </p>
</div> </div>
@@ -1,10 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { ChevronRight, Logs, MessageSquare, Send, Settings } from 'lucide-react'; import { Logs, MessageSquare } from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input'; import { Input } from '../ui/input';
import { Label } from '../ui/label'; import { Label } from '../ui/label';
import { Separator } from '../ui/separator'; import { Separator } from '../ui/separator';
import { cn } from '../../lib/utils';
import { ContactAvatar } from '../ContactAvatar'; import { ContactAvatar } from '../ContactAvatar';
import { import {
captureLastViewedConversationFromHash, captureLastViewedConversationFromHash,
@@ -27,7 +25,6 @@ import {
getSavedFontScale, getSavedFontScale,
setSavedFontScale, setSavedFontScale,
} from '../../utils/fontScale'; } from '../../utils/fontScale';
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
export function SettingsLocalSection({ export function SettingsLocalSection({
onLocalLabelChange, onLocalLabelChange,
@@ -40,16 +37,8 @@ export function SettingsLocalSection({
const [reopenLastConversation, setReopenLastConversation] = useState( const [reopenLastConversation, setReopenLastConversation] = useState(
getReopenLastConversationEnabled getReopenLastConversationEnabled
); );
const [darkMap, setDarkMap] = useState(() => {
try {
return localStorage.getItem('remoteterm-dark-map') === 'true';
} catch {
return false;
}
});
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text); const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color); const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled);
const [fontScale, setFontScale] = useState(getSavedFontScale); const [fontScale, setFontScale] = useState(getSavedFontScale);
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale); const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale())); const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
@@ -131,6 +120,85 @@ export function SettingsLocalSection({
<Separator /> <Separator />
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps; the
number field accepts any value from 25% to 400%.
</p>
</div>
<Separator />
<div className="space-y-3"> <div className="space-y-3">
<Label htmlFor="distance-units">Distance Units</Label> <Label htmlFor="distance-units">Distance Units</Label>
<select <select
@@ -156,135 +224,20 @@ export function SettingsLocalSection({
<Separator /> <Separator />
<div className="space-y-3"> <label className="flex items-center gap-3 cursor-pointer">
<Label>UI Tweaks</Label> <input
type="checkbox"
<label className="flex items-center gap-3 cursor-pointer"> checked={reopenLastConversation}
<input onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
type="checkbox" className="w-4 h-4 rounded border-input accent-primary"
checked={reopenLastConversation} />
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)} <span className="text-sm">Reopen to last viewed channel/conversation</span>
className="w-4 h-4 rounded border-input accent-primary" </label>
/>
<span className="text-sm">Reopen to last viewed channel/conversation</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={darkMap}
onChange={(e) => {
const v = e.target.checked;
setDarkMap(v);
try {
localStorage.setItem('remoteterm-dark-map', String(v));
} catch {
// localStorage may be disabled
}
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Dark mode map tiles</span>
</label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={autoFocusInput}
onChange={(e) => {
const v = e.target.checked;
setAutoFocusInput(v);
setAutoFocusInputEnabled(v);
}}
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
</label>
<div className="space-y-3">
<Label htmlFor="font-scale-input">Relative Font Size</Label>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<input
type="range"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step={FONT_SCALE_SLIDER_STEP}
value={fontScaleSlider}
onChange={(event) => handleSliderChange(Number(event.target.value))}
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
aria-label="Relative font size slider"
className="w-full accent-primary sm:flex-1"
/>
<div className="flex items-center gap-2 sm:w-40">
<Input
id="font-scale-input"
type="number"
inputMode="decimal"
min={MIN_FONT_SCALE}
max={MAX_FONT_SCALE}
step="any"
value={fontScaleInput}
onChange={(event) => {
const nextValue = event.target.value;
setFontScaleInput(nextValue);
if (nextValue === '') {
return;
}
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
commitFontScale(event.target.valueAsNumber);
}
}}
onBlur={() => {
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
onKeyDown={(event) => {
if (event.key !== 'Enter') {
return;
}
event.preventDefault();
const parsed = Number.parseFloat(fontScaleInput);
if (!Number.isFinite(parsed)) {
restoreFontScaleInput();
return;
}
commitFontScale(parsed);
}}
aria-label="Relative font size percentage"
/>
<span className="text-sm text-muted-foreground">%</span>
</div>
<button
type="button"
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
disabled={fontScale === DEFAULT_FONT_SCALE}
>
Reset
</button>
</div>
<p className="text-xs text-muted-foreground">
Scales the app&apos;s typography for this browser only. The slider moves in 5% steps;
the number field accepts any value from 25% to 400%.
</p>
</div>
</div>
</div> </div>
); );
} }
function ThemePreview({ className }: { className?: string }) { function ThemePreview({ className }: { className?: string }) {
const [showStyleRef, setShowStyleRef] = useState(false);
return ( return (
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}> <div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
<p className="text-xs text-muted-foreground mb-3"> <p className="text-xs text-muted-foreground mb-3">
@@ -318,7 +271,7 @@ function ThemePreview({ className }: { className?: string }) {
</div> </div>
<div className="mt-4 rounded-md border border-border bg-background p-2"> <div className="mt-4 rounded-md border border-border bg-background p-2">
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">Sidebar preview</p> <p className="mb-2 text-[11px] font-medium text-muted-foreground">Sidebar preview</p>
<div className="space-y-1"> <div className="space-y-1">
<PreviewSidebarRow <PreviewSidebarRow
active active
@@ -336,7 +289,7 @@ function ThemePreview({ className }: { className?: string }) {
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />} leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
label="Alice" label="Alice"
badge={ badge={
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground"> <span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[10px] font-semibold text-badge-unread-foreground">
3 3
</span> </span>
} }
@@ -345,267 +298,13 @@ function ThemePreview({ className }: { className?: string }) {
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />} leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
label="Mesh Ops" label="Mesh Ops"
badge={ badge={
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground"> <span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[10px] font-semibold text-badge-mention-foreground">
@2 @2
</span> </span>
} }
/> />
</div> </div>
</div> </div>
{/* ── Style Reference (collapsible) ── */}
<button
type="button"
onClick={() => setShowStyleRef((v) => !v)}
className="mt-4 flex w-full items-center gap-1.5 text-[0.6875rem] font-medium text-muted-foreground hover:text-foreground transition-colors"
>
<ChevronRight
className={cn('h-3.5 w-3.5 transition-transform', showStyleRef && 'rotate-90')}
/>
Canonical style reference
</button>
{showStyleRef && (
<>
{/* ── Text Hierarchy ── */}
<PreviewSection title="Text hierarchy">
<div className="space-y-2">
<PreviewTextRow
classes="text-xl font-semibold"
label="text-xl font-semibold"
desc="Hero / large data"
/>
<PreviewTextRow
classes="text-lg font-semibold"
label="text-lg font-semibold"
desc="Sheet / dialog title"
/>
<PreviewTextRow
classes="text-base font-semibold"
label="text-base font-semibold"
desc="Section title"
/>
<PreviewTextRow classes="text-sm" label="text-sm" desc="Body text, form labels" />
<PreviewTextRow
classes="text-xs text-muted-foreground"
label="text-xs text-muted-foreground"
desc="Helper text"
/>
<PreviewTextRow
classes="text-[0.6875rem] text-muted-foreground"
label="text-[0.6875rem] text-muted-foreground"
desc="Metadata, timestamps"
/>
<div>
<p className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium">
Section Label
</p>
<p className="text-[0.625rem] text-muted-foreground/60 mt-0.5">
text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium
</p>
</div>
</div>
</PreviewSection>
{/* ── Mono Text ── */}
<PreviewSection title="Mono text">
<div className="space-y-1.5">
<div>
<p className="text-xs font-mono text-muted-foreground">
a1b2c3d4e5f6...7890abcdef01
</p>
<p className="text-[0.625rem] text-muted-foreground/60">
text-xs font-mono keys, identifiers
</p>
</div>
<div>
<p className="text-[0.6875rem] font-mono">1h 23m 45s uptime</p>
<p className="text-[0.625rem] text-muted-foreground/60">
text-[0.6875rem] font-mono metadata mono
</p>
</div>
<div>
<p className="text-sm font-mono">$ req_status_sync 0xA1B2...</p>
<p className="text-[0.625rem] text-muted-foreground/60">
text-sm font-mono console / code
</p>
</div>
</div>
</PreviewSection>
{/* ── Badges ── */}
<PreviewSection title="Badges and tags">
<div className="flex flex-wrap items-center gap-2">
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
Hashtag
</span>
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium">
Repeater
</span>
<span className="text-[0.625rem] uppercase tracking-wider px-1.5 py-0.5 rounded bg-primary/10 text-primary font-medium">
On Radio
</span>
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-unread-foreground">
3
</span>
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[0.625rem] font-semibold text-badge-mention-foreground">
@2
</span>
</div>
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
Muted: bg-muted &middot; Primary: bg-primary/10 &middot; Unread/Mention: bg-badge-*
</p>
</PreviewSection>
{/* ── Buttons ── */}
<PreviewSection title="Buttons">
<div className="space-y-3">
<div>
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
Standard variants (size sm)
</p>
<div className="flex flex-wrap gap-1.5">
<Button size="sm">Default</Button>
<Button size="sm" variant="outline">
Outline
</Button>
<Button size="sm" variant="secondary">
Secondary
</Button>
<Button size="sm" variant="destructive">
Destructive
</Button>
<Button size="sm" variant="ghost">
Ghost
</Button>
<Button size="icon" variant="outline">
<Settings className="h-4 w-4" />
</Button>
<Button size="icon" variant="outline">
<Send className="h-4 w-4" />
</Button>
</div>
</div>
<div>
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
Semantic outline variants
</p>
<div className="flex flex-wrap gap-1.5">
<Button
size="sm"
variant="outline"
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
Danger
</Button>
<Button
size="sm"
variant="outline"
className="border-warning/50 text-warning hover:bg-warning/10"
>
Warning
</Button>
<Button
size="sm"
variant="outline"
className="border-green-600/50 text-green-600 hover:bg-green-600/10"
>
Success
</Button>
</div>
</div>
<div>
<p className="text-[0.625rem] text-muted-foreground/60 mb-1.5">
Metric selector pills
</p>
<div className="flex gap-1">
{['Voltage', 'Noise Floor', 'Packets'].map((label, i) => (
<button
key={label}
type="button"
className={cn(
'text-[0.6875rem] px-2 py-0.5 rounded transition-colors',
i === 0
? 'bg-primary text-primary-foreground'
: 'text-muted-foreground hover:text-foreground hover:bg-accent'
)}
>
{label}
</button>
))}
</div>
</div>
</div>
</PreviewSection>
{/* ── Clickable Text ── */}
<PreviewSection title="Clickable text">
<div className="space-y-1.5">
<span
role="button"
tabIndex={0}
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block"
>
a1b2c3d4e5f6 (click to copy)
</span>
<span
role="button"
tabIndex={0}
className="text-sm cursor-pointer underline underline-offset-2 decoration-muted-foreground/50 hover:text-primary transition-colors"
>
Underlined navigational link
</span>
</div>
<p className="text-[0.625rem] text-muted-foreground/60 mt-1.5">
cursor-pointer hover:text-primary transition-colors use role=&quot;button&quot; +
tabIndex
</p>
</PreviewSection>
{/* ── Inline Alerts ── */}
<PreviewSection title="Inline alerts">
<div className="space-y-1.5">
<div className="rounded-md border border-info/30 bg-info/10 px-3 py-2 text-xs text-info">
Info: channel slot cache refreshed from radio.
</div>
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-xs text-warning">
Warning: radio clock skew detected.
</div>
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
Error: post-connect setup timed out. Reboot the radio and restart.
</div>
</div>
</PreviewSection>
</>
)}
</div>
);
}
function PreviewSection({ title, children }: { title: string; children: React.ReactNode }) {
return (
<div className="mt-4 rounded-md border border-border bg-background p-2">
<p className="mb-2 text-[0.6875rem] font-medium text-muted-foreground">{title}</p>
{children}
</div>
);
}
function PreviewTextRow({
classes,
label,
desc,
}: {
classes: string;
label: string;
desc: string;
}) {
return (
<div>
<p className={classes}>Sample text at this size</p>
<p className="text-[0.625rem] text-muted-foreground/60">
{label} {desc}
</p>
</div> </div>
); );
} }
@@ -628,7 +327,7 @@ function PreviewMessage({
return ( return (
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}> <div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}> <div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}>
<span className="mb-1 text-[0.6875rem] text-muted-foreground">{sender}</span> <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 className={`rounded-2xl px-3 py-2 text-sm break-words ${bubbleClassName}`}>{text}</div>
</div> </div>
</div> </div>
@@ -648,20 +347,17 @@ function PreviewSidebarRow({
}) { }) {
return ( return (
<div <div
data-active={active ? 'true' : undefined} className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[0.8125rem] ${
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent' active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
}`} }`}
> >
<span className="sidebar-tool-icon" aria-hidden="true"> {leading}
{leading} <span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
</span>
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
{label} {label}
</span> </span>
{badge} {badge}
{!badge && ( {!badge && (
<span className="sidebar-tool-icon" aria-hidden="true"> <span className="text-muted-foreground" aria-hidden="true">
<MessageSquare className="h-3.5 w-3.5" /> <MessageSquare className="h-3.5 w-3.5" />
</span> </span>
)} )}
@@ -390,9 +390,9 @@ export function SettingsRadioSection({
return ( return (
<div className={className}> <div className={className}>
{/* ── Connection ── */} {/* Connection display */}
<div className="space-y-3"> <div className="space-y-3">
<Label className="text-base">Connection</Label> <Label>Connection</Label>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`w-2 h-2 rounded-full ${ className={`w-2 h-2 rounded-full ${
@@ -428,58 +428,15 @@ export function SettingsRadioSection({
</p> </p>
</div> </div>
<Separator /> {/* Radio Name */}
{/* ── Identity ── */}
<div className="space-y-2">
<Label className="text-base">Identity</Label>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name">Radio Name</Label> <Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} /> <Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div> </div>
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={identityBusy || identityRebooting || !privateKey.trim()}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div>
)}
<Separator /> <Separator />
{/* ── Radio Parameters ── */} {/* Radio Config */}
<div className="space-y-2">
<Label className="text-base">Radio Parameters</Label>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="preset">Preset</Label> <Label htmlFor="preset">Preset</Label>
<select <select
@@ -561,36 +518,11 @@ export function SettingsRadioSection({
</div> </div>
</div> </div>
{config.path_hash_mode_supported && (
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
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="0">1 byte up to 63 hops (default)</option>
<option value="1">2 bytes up to 32 hops</option>
<option value="2">3 bytes up to 21 hops</option>
</select>
<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&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
)}
<Separator /> <Separator />
{/* ── Location ── */}
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-base">Location</Label> <Label>Location</Label>
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
@@ -653,8 +585,53 @@ export function SettingsRadioSection({
library. library.
</p> </p>
</div> </div>
<div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="multi-acks-enabled"
checked={multiAcksEnabled}
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal
ACK for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
</p>
</div>
</div>
</div>
</div> </div>
{config.path_hash_mode_supported && (
<>
<Separator />
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
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="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<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&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
</>
)}
{error && ( {error && (
<div className="text-sm text-destructive" role="alert"> <div className="text-sm text-destructive" role="alert">
{error} {error}
@@ -680,48 +657,64 @@ export function SettingsRadioSection({
<Separator /> <Separator />
{/* ── Messaging ── */} {/* Keys */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-base">Messaging</Label> <Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3"> <Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Checkbox <Input
id="multi-acks-enabled" id="private-key"
checked={multiAcksEnabled} type="password"
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)} autoComplete="off"
className="mt-0.5" value={privateKey}
/> onChange={(e) => setPrivateKey(e.target.value)}
<div className="space-y-1"> placeholder="64-character hex private key"
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label> />
<p className="text-xs text-muted-foreground"> <Button
When enabled, the radio sends one extra direct ACK transmission before the normal ACK onClick={handleSetPrivateKey}
for received direct messages. This is a firmware-level receive behavior, not a disabled={identityBusy || identityRebooting || !privateKey.trim()}
RemoteTerm retry setting. className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
</p> variant="outline"
</div> >
</div> {identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3"> {identityError && (
<Checkbox <div className="text-sm text-destructive" role="alert">
id="auto-resend-channel" {identityError}
checked={appSettings.auto_resend_channel}
onCheckedChange={(checked) =>
onSaveAppSettings({ auto_resend_channel: checked === true })
}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="auto-resend-channel">Auto-Resend Unheard Channel Messages</Label>
<p className="text-xs text-muted-foreground">
When enabled, outgoing channel messages that receive no echo within 2 seconds are
automatically resent once (byte-perfect, within the 30-second dedup window). Repeaters
that already heard the original will ignore the duplicate. This functionality will NOT
create double-sent/duplicate messages.
</p>
</div>
</div> </div>
)}
<Separator />
{/* Flood & Advert Control */}
<div className="space-y-2">
<Label className="text-base">Flood & Advert Control</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -753,13 +746,6 @@ export function SettingsRadioSection({
Configured radio contact capacity. Favorites reload first, then background maintenance Configured radio contact capacity. Favorites reload first, then background maintenance
refills to about 80% of this value and offloads once occupancy reaches about 95%. refills to about 80% of this value and offloads once occupancy reaches about 95%.
</p> </p>
{health?.radio_device_info?.max_contacts != null &&
Number(maxRadioContacts) > health.radio_device_info.max_contacts && (
<p className="text-xs text-warning">
Your radio reports a hardware limit of {health.radio_device_info.max_contacts}{' '}
contacts. The effective cap will be limited to what the radio supports.
</p>
)}
</div> </div>
{floodError && ( {floodError && (
@@ -774,28 +760,8 @@ export function SettingsRadioSection({
<Separator /> <Separator />
{/* ── Advertising & Discovery ── */}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-base">Advertising &amp; Discovery</Label> <Label className="text-base">Hear &amp; Be Heard</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
@@ -42,87 +42,6 @@ function formatTime(ts: number): string {
}); });
} }
function formatDateTime(ts: number): string {
const d = new Date(ts * 1000);
return (
d.toLocaleDateString([], { month: 'short', day: 'numeric' }) +
' ' +
d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false })
);
}
function PacketsPerHourChart({ buckets }: { buckets: { timestamp: number; count: number }[] }) {
// Fill gaps so hours with zero packets still appear on the chart
const filled: { timestamp: number; count: number }[] = [];
if (buckets.length > 0) {
const first = buckets[0].timestamp;
const last = buckets[buckets.length - 1].timestamp;
const byTs = new Map(buckets.map((b) => [b.timestamp, b.count]));
for (let ts = first; ts <= last; ts += 3600) {
filled.push({ timestamp: ts, count: byTs.get(ts) ?? 0 });
}
}
const data = filled.map((b, i) => ({
idx: i,
label: formatDateTime(b.timestamp),
count: b.count,
}));
// Show ~6 evenly-spaced tick labels
const tickCount = Math.min(6, data.length);
const tickIndices: number[] = [];
if (data.length > 1) {
for (let i = 0; i < tickCount; i++) {
tickIndices.push(Math.round((i / (tickCount - 1)) * (data.length - 1)));
}
}
return (
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -8 }}>
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--border))" vertical={false} />
<XAxis
dataKey="idx"
type="number"
domain={[0, data.length - 1]}
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
ticks={tickIndices}
tickFormatter={(idx) => data[idx]?.label ?? ''}
/>
<YAxis
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<RechartsTooltip
{...TOOLTIP_STYLE}
cursor={{
stroke: 'hsl(var(--muted-foreground))',
strokeWidth: 1,
strokeDasharray: '3 3',
}}
labelFormatter={(idx) => data[Number(idx)]?.label ?? ''}
formatter={(value) => [`${Number(value).toLocaleString()} packets`, 'Count']}
/>
<Area
type="monotone"
dataKey="count"
stroke="#0ea5e9"
fill="#0ea5e9"
fillOpacity={0.15}
strokeWidth={1.5}
dot={false}
activeDot={{ r: 4, fill: '#0ea5e9', strokeWidth: 2, stroke: 'hsl(var(--popover))' }}
/>
</AreaChart>
</ResponsiveContainer>
);
}
function NoiseFloorChart({ function NoiseFloorChart({
samples, samples,
}: { }: {
@@ -322,17 +241,6 @@ export function SettingsStatisticsSection({ className }: { className?: string })
</div> </div>
</div> </div>
{/* Packets per Hour (72h) */}
{stats.packets_per_hour_72h?.length > 0 && (
<>
<Separator />
<div>
<h4 className="text-sm font-medium mb-2">Packets per Hour (72h)</h4>
<PacketsPerHourChart buckets={stats.packets_per_hour_72h} />
</div>
</>
)}
<Separator /> <Separator />
{/* Path Hash Width */} {/* Path Hash Width */}
-141
View File
@@ -1,141 +0,0 @@
'use client';
import * as React from 'react';
import { Command as CommandPrimitive } from 'cmdk';
import { Search } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
function CommandDialog({ children, ...props }: React.ComponentProps<typeof Dialog>) {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
<DialogTitle className="sr-only">Command palette</DialogTitle>
<DialogDescription className="sr-only">
Search for conversations, settings, and tools
</DialogDescription>
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3">
{children}
</Command>
</DialogContent>
</Dialog>
);
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => (
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
));
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[0.625rem] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
return (
<span
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
{...props}
/>
);
}
CommandShortcut.displayName = 'CommandShortcut';
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};
-1
View File
@@ -19,7 +19,6 @@ const Toaster = ({ ...props }: ToasterProps) => {
'group-[.toaster]:bg-toast-error group-[.toaster]:text-toast-error-foreground group-[.toaster]:border-toast-error-border [&_[data-description]]:text-toast-error-foreground', 'group-[.toaster]:bg-toast-error group-[.toaster]:text-toast-error-foreground group-[.toaster]:border-toast-error-border [&_[data-description]]:text-toast-error-foreground',
}, },
}} }}
closeButton
{...props} {...props}
/> />
); );
@@ -95,7 +95,7 @@ export function VisualizerControls({
{PACKET_LEGEND_ITEMS.map((item) => ( {PACKET_LEGEND_ITEMS.map((item) => (
<div key={item.label} className="flex items-center gap-2"> <div key={item.label} className="flex items-center gap-2">
<div <div
className="w-5 h-5 rounded-full flex items-center justify-center text-[0.5rem] font-bold text-white" className="w-5 h-5 rounded-full flex items-center justify-center text-[8px] font-bold text-white"
style={{ backgroundColor: item.color }} style={{ backgroundColor: item.color }}
> >
{item.label} {item.label}
+73 -44
View File
@@ -2,12 +2,26 @@ import { useState, useCallback, useEffect, useRef } from 'react';
import { api } from '../api'; import { api } from '../api';
import { takePrefetchOrFetch } from '../prefetch'; import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner'; import { toast } from '../components/ui/sonner';
import { initLastMessageTimes } from '../utils/conversationState'; import {
import type { AppSettings, AppSettingsUpdate } from '../types'; initLastMessageTimes,
loadLocalStorageLastMessageTimes,
loadLocalStorageSortOrder,
clearLocalStorageConversationState,
} from '../utils/conversationState';
import {
isFavorite,
loadLocalStorageFavorites,
clearLocalStorageFavorites,
} from '../utils/favorites';
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
export function useAppSettings() { export function useAppSettings() {
const [appSettings, setAppSettings] = useState<AppSettings | null>(null); const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
// Stable empty array prevents a new reference every render when there are none.
const emptyFavorites = useRef<Favorite[]>([]).current;
const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites;
// One-time migration guard // One-time migration guard
const hasMigratedRef = useRef(false); const hasMigratedRef = useRef(false);
@@ -80,78 +94,93 @@ export function useAppSettings() {
} }
}, []); }, []);
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => { const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
const key = publicKey.toLowerCase();
setAppSettings((prev) => { setAppSettings((prev) => {
if (!prev) return prev; if (!prev) return prev;
const current = prev.tracked_telemetry_repeaters ?? []; const currentFavorites = prev.favorites ?? [];
const wasTracked = current.includes(key); const wasFavorited = isFavorite(currentFavorites, type, id);
const optimistic = wasTracked ? current.filter((k) => k !== key) : [...current, key]; const optimisticFavorites = wasFavorited
return { ...prev, tracked_telemetry_repeaters: optimistic }; ? currentFavorites.filter((f) => !(f.type === type && f.id === id))
: [...currentFavorites, { type, id }];
return { ...prev, favorites: optimisticFavorites };
}); });
try { try {
const result = await api.toggleTrackedTelemetry(publicKey); const updatedSettings = await api.toggleFavorite(type, id);
setAppSettings((prev) => setAppSettings(updatedSettings);
prev ? { ...prev, tracked_telemetry_repeaters: result.tracked_telemetry_repeaters } : prev
);
} catch (err) { } catch (err) {
console.error('Failed to toggle tracked telemetry:', err); console.error('Failed to toggle favorite:', err);
try { try {
const settings = await api.getSettings(); const settings = await api.getSettings();
setAppSettings(settings); setAppSettings(settings);
} catch { } catch {
// If refetch also fails, leave optimistic state // If refetch also fails, leave optimistic state
} }
// eslint-disable-next-line @typescript-eslint/no-explicit-any toast.error('Failed to update favorite');
const detail = (err as any)?.body?.detail;
if (typeof detail === 'object' && detail?.message) {
toast.error(detail.message);
} else {
toast.error('Failed to update tracked telemetry');
}
} }
}, []); }, []);
// Legacy favorites migration: if pre-server-side favorites exist in // One-time migration of localStorage preferences to server
// localStorage, toggle each one via the existing API and clear the key.
useEffect(() => { useEffect(() => {
if (!appSettings || hasMigratedRef.current) return; if (!appSettings || hasMigratedRef.current) return;
if (appSettings.preferences_migrated) {
clearLocalStorageFavorites();
clearLocalStorageConversationState();
hasMigratedRef.current = true;
return;
}
const localFavorites = loadLocalStorageFavorites();
const localSortOrder = loadLocalStorageSortOrder();
const localLastMessageTimes = loadLocalStorageLastMessageTimes();
const hasLocalData =
localFavorites.length > 0 ||
localSortOrder !== 'recent' ||
Object.keys(localLastMessageTimes).length > 0;
if (!hasLocalData) {
hasMigratedRef.current = true;
return;
}
hasMigratedRef.current = true; hasMigratedRef.current = true;
const FAVORITES_KEY = 'remoteterm-favorites'; const migratePreferences = async () => {
let localFavorites: Array<{ type: 'channel' | 'contact'; id: string }> = []; try {
try { const result = await api.migratePreferences({
const stored = localStorage.getItem(FAVORITES_KEY); favorites: localFavorites,
if (stored) localFavorites = JSON.parse(stored); sort_order: localSortOrder,
} catch { last_message_times: localLastMessageTimes,
// corrupt or unavailable });
}
if (localFavorites.length === 0) return;
const migrate = async () => { if (result.migrated) {
let migrated = 0; toast.success('Preferences migrated', {
for (const f of localFavorites) { description: `Migrated ${localFavorites.length} favorites to server`,
try { });
await api.toggleFavorite(f.type, f.id);
migrated++;
} catch {
// Entity may have been deleted; skip and continue
} }
setAppSettings(result.settings);
initLastMessageTimes(result.settings.last_message_times ?? {});
clearLocalStorageFavorites();
clearLocalStorageConversationState();
} catch (err) {
console.error('Failed to migrate preferences:', err);
} }
localStorage.removeItem(FAVORITES_KEY);
// Reload so contacts/channels pick up the new favorite flags
if (migrated > 0) window.location.reload();
}; };
migrate();
migratePreferences();
}, [appSettings]); }, [appSettings]);
return { return {
appSettings, appSettings,
favorites,
fetchAppSettings, fetchAppSettings,
handleSaveAppSettings, handleSaveAppSettings,
handleToggleFavorite,
handleToggleBlockedKey, handleToggleBlockedKey,
handleToggleBlockedName, handleToggleBlockedName,
handleToggleTrackedTelemetry,
}; };
} }
@@ -4,7 +4,7 @@ import type { Message } from '../types';
import { getStateKey } from '../utils/conversationState'; import { getStateKey } from '../utils/conversationState';
const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation'; const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation';
const NOTIFICATION_ICON_PATH = './favicon-256x256.png'; const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
type NotificationPermissionState = NotificationPermission | 'unsupported'; type NotificationPermissionState = NotificationPermission | 'unsupported';
type ConversationNotificationMap = Record<string, boolean>; type ConversationNotificationMap = Record<string, boolean>;
+1 -20
View File
@@ -4,7 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner'; import { toast } from '../components/ui/sonner';
import { getContactDisplayName } from '../utils/pubkey'; import { getContactDisplayName } from '../utils/pubkey';
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel'; import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
import type { BulkCreateHashtagChannelsResult, Channel, Contact, Conversation } from '../types'; import type { Channel, Contact, Conversation } from '../types';
interface UseContactsAndChannelsArgs { interface UseContactsAndChannelsArgs {
setActiveConversation: (conv: Conversation | null) => void; setActiveConversation: (conv: Conversation | null) => void;
@@ -112,24 +112,6 @@ export function useContactsAndChannels({
[fetchUndecryptedCountInternal, setActiveConversation] [fetchUndecryptedCountInternal, setActiveConversation]
); );
const handleBulkCreateHashtagChannels = useCallback(
async (
channelNames: string[],
tryHistorical: boolean
): Promise<BulkCreateHashtagChannelsResult> => {
const result = await api.bulkCreateHashtagChannels(channelNames, tryHistorical);
const data = await api.getChannels();
setChannels(data);
if (tryHistorical && result.decrypt_started) {
fetchUndecryptedCountInternal();
}
return result;
},
[fetchUndecryptedCountInternal]
);
const handleDeleteChannel = useCallback( const handleDeleteChannel = useCallback(
async (key: string) => { async (key: string) => {
if (!confirm('Delete this channel? Message history will be preserved.')) return; if (!confirm('Delete this channel? Message history will be preserved.')) return;
@@ -208,7 +190,6 @@ export function useContactsAndChannels({
handleCreateContact, handleCreateContact,
handleCreateChannel, handleCreateChannel,
handleCreateHashtagChannel, handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel, handleDeleteChannel,
handleDeleteContact, handleDeleteContact,
}; };
@@ -21,10 +21,6 @@ interface UseConversationActionsResult {
channelKey: string, channelKey: string,
floodScopeOverride: string floodScopeOverride: string
) => Promise<void>; ) => Promise<void>;
handleSetChannelPathHashModeOverride: (
channelKey: string,
pathHashModeOverride: number | null
) => Promise<void>;
handleSenderClick: (sender: string) => void; handleSenderClick: (sender: string) => void;
handleTrace: () => Promise<void>; handleTrace: () => Promise<void>;
handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>; handlePathDiscovery: (publicKey: string) => Promise<PathDiscoveryResponse>;
@@ -110,25 +106,6 @@ export function useConversationActions({
[mergeChannelIntoList] [mergeChannelIntoList]
); );
const handleSetChannelPathHashModeOverride = useCallback(
async (channelKey: string, pathHashModeOverride: number | null) => {
try {
const updated = await api.setChannelPathHashModeOverride(channelKey, pathHashModeOverride);
mergeChannelIntoList(updated);
toast.success(
updated.path_hash_mode_override != null
? 'Path hop width override saved'
: 'Path hop width override cleared'
);
} catch (err) {
toast.error('Failed to update path hop width override', {
description: err instanceof Error ? err.message : 'Unknown error',
});
}
},
[mergeChannelIntoList]
);
const handleSenderClick = useCallback( const handleSenderClick = useCallback(
(sender: string) => { (sender: string) => {
messageInputRef.current?.appendText(`@[${sender}] `); messageInputRef.current?.appendText(`@[${sender}] `);
@@ -166,7 +143,6 @@ export function useConversationActions({
handleSendMessage, handleSendMessage,
handleResendChannelMessage, handleResendChannelMessage,
handleSetChannelFloodScopeOverride, handleSetChannelFloodScopeOverride,
handleSetChannelPathHashModeOverride,
handleSenderClick, handleSenderClick,
handleTrace, handleTrace,
handlePathDiscovery, handlePathDiscovery,
+21 -37
View File
@@ -1,11 +1,11 @@
import { useEffect, useMemo, useRef } from 'react'; import { useEffect, useMemo, useRef } from 'react';
import type { Channel, Contact } from '../types'; import type { Favorite } from '../types';
import { getStateKey } from '../utils/conversationState'; import { getStateKey } from '../utils/conversationState';
const APP_TITLE = 'RemoteTerm for MeshCore'; const APP_TITLE = 'RemoteTerm for MeshCore';
const UNREAD_APP_TITLE = 'RemoteTerm'; const UNREAD_APP_TITLE = 'RemoteTerm';
const BASE_FAVICON_PATH = './favicon.svg'; const BASE_FAVICON_PATH = '/favicon.svg';
const GREEN_BADGE_FILL = '#16a34a'; const GREEN_BADGE_FILL = '#16a34a';
const RED_BADGE_FILL = '#dc2626'; const RED_BADGE_FILL = '#dc2626';
const BADGE_CENTER = 750; const BADGE_CENTER = 750;
@@ -25,11 +25,12 @@ function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): numb
function getUnreadFavoriteChannelCount( function getUnreadFavoriteChannelCount(
unreadCounts: Record<string, number>, unreadCounts: Record<string, number>,
channels: Channel[] favorites: Favorite[]
): number { ): number {
return channels.reduce( return favorites.reduce(
(sum, channel) => (sum, favorite) =>
sum + (channel.favorite ? unreadCounts[getStateKey('channel', channel.key)] || 0 : 0), sum +
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
0 0
); );
} }
@@ -40,29 +41,19 @@ export function getTotalUnreadCount(unreadCounts: Record<string, number>): numbe
export function getFavoriteUnreadCount( export function getFavoriteUnreadCount(
unreadCounts: Record<string, number>, unreadCounts: Record<string, number>,
contacts: Contact[], favorites: Favorite[]
channels: Channel[]
): number { ): number {
let sum = 0; return favorites.reduce((sum, favorite) => {
for (const contact of contacts) { const stateKey = getStateKey(favorite.type, favorite.id);
if (contact.favorite) { return sum + (unreadCounts[stateKey] || 0);
sum += unreadCounts[getStateKey('contact', contact.public_key)] || 0; }, 0);
}
}
for (const channel of channels) {
if (channel.favorite) {
sum += unreadCounts[getStateKey('channel', channel.key)] || 0;
}
}
return sum;
} }
export function getUnreadTitle( export function getUnreadTitle(
unreadCounts: Record<string, number>, unreadCounts: Record<string, number>,
contacts: Contact[], favorites: Favorite[]
channels: Channel[]
): string { ): string {
const unreadCount = getFavoriteUnreadCount(unreadCounts, contacts, channels); const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
if (unreadCount <= 0) { if (unreadCount <= 0) {
return APP_TITLE; return APP_TITLE;
} }
@@ -74,13 +65,13 @@ export function getUnreadTitle(
export function deriveFaviconBadgeState( export function deriveFaviconBadgeState(
unreadCounts: Record<string, number>, unreadCounts: Record<string, number>,
mentions: Record<string, boolean>, mentions: Record<string, boolean>,
channels: Channel[] favorites: Favorite[]
): FaviconBadgeState { ): FaviconBadgeState {
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) { if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
return 'red'; return 'red';
} }
if (getUnreadFavoriteChannelCount(unreadCounts, channels) > 0) { if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
return 'green'; return 'green';
} }
@@ -137,15 +128,8 @@ function applyFaviconHref(href: string): void {
upsertFaviconLinks('shortcut icon', href); upsertFaviconLinks('shortcut icon', href);
} }
export function useUnreadTitle( export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
unreadCounts: Record<string, number>, const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
contacts: Contact[],
channels: Channel[]
): void {
const title = useMemo(
() => getUnreadTitle(unreadCounts, contacts, channels),
[contacts, channels, unreadCounts]
);
useEffect(() => { useEffect(() => {
document.title = title; document.title = title;
@@ -159,12 +143,12 @@ export function useUnreadTitle(
export function useFaviconBadge( export function useFaviconBadge(
unreadCounts: Record<string, number>, unreadCounts: Record<string, number>,
mentions: Record<string, boolean>, mentions: Record<string, boolean>,
channels: Channel[] favorites: Favorite[]
): void { ): void {
const objectUrlRef = useRef<string | null>(null); const objectUrlRef = useRef<string | null>(null);
const badgeState = useMemo( const badgeState = useMemo(
() => deriveFaviconBadgeState(unreadCounts, mentions, channels), () => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
[channels, mentions, unreadCounts] [favorites, mentions, unreadCounts]
); );
useEffect(() => { useEffect(() => {
-52
View File
@@ -56,14 +56,6 @@
--badge-mention: var(--destructive); --badge-mention: var(--destructive);
--badge-mention-foreground: var(--destructive-foreground); --badge-mention-foreground: var(--destructive-foreground);
/* Sidebar navigation accents */
--sidebar-icon-color: hsl(var(--foreground));
--sidebar-icon-hover-color: hsl(var(--foreground));
--sidebar-icon-active-color: hsl(var(--foreground));
--sidebar-label-color: hsl(var(--muted-foreground));
--sidebar-label-hover-color: hsl(var(--foreground));
--sidebar-label-active-color: hsl(var(--foreground));
/* Error toast */ /* Error toast */
--toast-error: 0 30% 14%; --toast-error: 0 30% 14%;
--toast-error-foreground: 0 56% 77%; --toast-error-foreground: 0 56% 77%;
@@ -134,50 +126,6 @@
animation: message-highlight 2s ease-out forwards; animation: message-highlight 2s ease-out forwards;
} }
.sidebar-tool-icon {
display: inline-flex;
height: 1.5rem;
width: 1.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.45rem;
color: var(--sidebar-icon-color);
opacity: 1;
transition:
color 150ms ease,
opacity 150ms ease;
}
.sidebar-tool-icon svg {
stroke-width: 2.35;
}
.sidebar-tool-label {
color: var(--sidebar-label-color);
transition: color 150ms ease;
}
.sidebar-action-row:hover .sidebar-tool-icon,
.sidebar-action-row:focus-visible .sidebar-tool-icon {
color: var(--sidebar-icon-hover-color);
opacity: 1;
}
.sidebar-action-row:hover .sidebar-tool-label,
.sidebar-action-row:focus-visible .sidebar-tool-label {
color: var(--sidebar-label-hover-color);
}
.sidebar-action-row[data-active='true'] .sidebar-tool-icon {
color: var(--sidebar-icon-active-color);
opacity: 1;
}
.sidebar-action-row[data-active='true'] .sidebar-tool-label {
color: var(--sidebar-label-active-color);
}
/* Constrain CodeMirror editor width */ /* Constrain CodeMirror editor width */
.cm-editor { .cm-editor {
max-width: 100% !important; max-width: 100% !important;
+11 -11
View File
@@ -105,7 +105,7 @@ describe('fetchJson (via api methods)', () => {
expect(mockFetch).toHaveBeenCalledTimes(1); expect(mockFetch).toHaveBeenCalledTimes(1);
const [url] = mockFetch.mock.calls[0]; const [url] = mockFetch.mock.calls[0];
expect(url).toBe('./api/contacts?limit=100&offset=0'); expect(url).toBe('/api/contacts?limit=100&offset=0');
}); });
it('builds repeater advert path endpoint query', async () => { it('builds repeater advert path endpoint query', async () => {
@@ -118,7 +118,7 @@ describe('fetchJson (via api methods)', () => {
await api.getRepeaterAdvertPaths(12); await api.getRepeaterAdvertPaths(12);
const [url] = mockFetch.mock.calls[0]; const [url] = mockFetch.mock.calls[0];
expect(url).toBe('./api/contacts/repeaters/advert-paths?limit_per_repeater=12'); expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12');
}); });
}); });
@@ -238,7 +238,7 @@ describe('fetchJson (via api methods)', () => {
await api.sendDirectMessage('abc123', 'hello'); await api.sendDirectMessage('abc123', 'hello');
const [url, options] = mockFetch.mock.calls[0]; const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('./api/messages/direct'); expect(url).toBe('/api/messages/direct');
expect(options.method).toBe('POST'); expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ expect(JSON.parse(options.body)).toEqual({
destination: 'abc123', destination: 'abc123',
@@ -256,7 +256,7 @@ describe('fetchJson (via api methods)', () => {
await api.updateRadioConfig({ name: 'NewName' }); await api.updateRadioConfig({ name: 'NewName' });
const [url, options] = mockFetch.mock.calls[0]; const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('./api/radio/config'); expect(url).toBe('/api/radio/config');
expect(options.method).toBe('PATCH'); expect(options.method).toBe('PATCH');
expect(JSON.parse(options.body)).toEqual({ name: 'NewName' }); expect(JSON.parse(options.body)).toEqual({ name: 'NewName' });
}); });
@@ -271,7 +271,7 @@ describe('fetchJson (via api methods)', () => {
await api.setPrivateKey('my-secret-key'); await api.setPrivateKey('my-secret-key');
const [url, options] = mockFetch.mock.calls[0]; const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('./api/radio/private-key'); expect(url).toBe('/api/radio/private-key');
expect(options.method).toBe('PUT'); expect(options.method).toBe('PUT');
expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' }); expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' });
}); });
@@ -286,7 +286,7 @@ describe('fetchJson (via api methods)', () => {
await api.discoverMesh('repeaters'); await api.discoverMesh('repeaters');
const [url, options] = mockFetch.mock.calls[0]; const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('./api/radio/discover'); expect(url).toBe('/api/radio/discover');
expect(options.method).toBe('POST'); expect(options.method).toBe('POST');
expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' }); expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' });
}); });
@@ -301,7 +301,7 @@ describe('fetchJson (via api methods)', () => {
await api.deleteContact('pubkey123'); await api.deleteContact('pubkey123');
const [url, options] = mockFetch.mock.calls[0]; const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('./api/contacts/pubkey123'); expect(url).toBe('/api/contacts/pubkey123');
expect(options.method).toBe('DELETE'); expect(options.method).toBe('DELETE');
}); });
@@ -315,7 +315,7 @@ describe('fetchJson (via api methods)', () => {
await api.sendAdvertisement(); await api.sendAdvertisement();
const [url, options] = mockFetch.mock.calls[0]; const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('./api/radio/advertise'); expect(url).toBe('/api/radio/advertise');
expect(options.method).toBe('POST'); expect(options.method).toBe('POST');
expect(options.body).toBe(JSON.stringify({ mode: 'flood' })); expect(options.body).toBe(JSON.stringify({ mode: 'flood' }));
}); });
@@ -330,7 +330,7 @@ describe('fetchJson (via api methods)', () => {
await api.sendAdvertisement('zero_hop'); await api.sendAdvertisement('zero_hop');
const [url, options] = mockFetch.mock.calls[0]; const [url, options] = mockFetch.mock.calls[0];
expect(url).toBe('./api/radio/advertise'); expect(url).toBe('/api/radio/advertise');
expect(options.method).toBe('POST'); expect(options.method).toBe('POST');
expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' })); expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' }));
}); });
@@ -383,7 +383,7 @@ describe('fetchJson (via api methods)', () => {
}); });
const [url] = mockFetch.mock.calls[0]; const [url] = mockFetch.mock.calls[0];
expect(url).toContain('./api/messages?'); expect(url).toContain('/api/messages?');
expect(url).toContain('limit=50'); expect(url).toContain('limit=50');
expect(url).toContain('offset=10'); expect(url).toContain('offset=10');
expect(url).toContain('type=PRIV'); expect(url).toContain('type=PRIV');
@@ -402,7 +402,7 @@ describe('fetchJson (via api methods)', () => {
await api.getMessages(); await api.getMessages();
const [url] = mockFetch.mock.calls[0]; const [url] = mockFetch.mock.calls[0];
expect(url).toBe('./api/messages'); expect(url).toBe('/api/messages');
}); });
}); });
}); });
+14 -6
View File
@@ -24,6 +24,7 @@ const mocks = vi.hoisted(() => ({
requestTrace: vi.fn(), requestTrace: vi.fn(),
updateRadioConfig: vi.fn(), updateRadioConfig: vi.fn(),
setPrivateKey: vi.fn(), setPrivateKey: vi.fn(),
migratePreferences: vi.fn(),
}, },
toast: { toast: {
success: vi.fn(), success: vi.fn(),
@@ -187,9 +188,11 @@ const baseConfig = {
const baseSettings = { const baseSettings = {
max_radio_contacts: 200, max_radio_contacts: 200,
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
auto_decrypt_dm_on_advert: false, auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent' as const,
last_message_times: {}, last_message_times: {},
preferences_migrated: false,
advert_interval: 0, advert_interval: 0,
last_advert_time: 0, last_advert_time: 0,
flood_scope: '', flood_scope: '',
@@ -203,7 +206,6 @@ const publicChannel = {
is_hashtag: false, is_hashtag: false,
on_radio: false, on_radio: false,
last_read_at: null, last_read_at: null,
favorite: false,
}; };
describe('App favorite toggle flow', () => { describe('App favorite toggle flow', () => {
@@ -216,9 +218,8 @@ describe('App favorite toggle flow', () => {
mocks.api.getChannels.mockResolvedValue([publicChannel]); mocks.api.getChannels.mockResolvedValue([publicChannel]);
mocks.api.getContacts.mockResolvedValue([]); mocks.api.getContacts.mockResolvedValue([]);
mocks.api.toggleFavorite.mockResolvedValue({ mocks.api.toggleFavorite.mockResolvedValue({
type: 'channel', ...baseSettings,
id: publicChannel.key, favorites: [{ type: 'channel', id: publicChannel.key }],
favorite: true,
}); });
}); });
@@ -240,8 +241,11 @@ describe('App favorite toggle flow', () => {
}); });
}); });
it('rolls back favorite state on toggle failure', async () => { it('rolls back favorite state by refetching settings on toggle failure', async () => {
mocks.api.toggleFavorite.mockRejectedValue(new Error('toggle failed')); mocks.api.toggleFavorite.mockRejectedValue(new Error('toggle failed'));
mocks.api.getSettings
.mockResolvedValueOnce({ ...baseSettings }) // initial load
.mockResolvedValueOnce({ ...baseSettings }); // rollback refetch
render(<App />); render(<App />);
@@ -255,6 +259,10 @@ describe('App favorite toggle flow', () => {
expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key); expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key);
}); });
await waitFor(() => {
expect(mocks.api.getSettings).toHaveBeenCalledTimes(2);
});
await waitFor(() => { await waitFor(() => {
expect(mocks.toast.error).toHaveBeenCalledWith('Failed to update favorite'); expect(mocks.toast.error).toHaveBeenCalledWith('Failed to update favorite');
}); });
+4 -2
View File
@@ -11,6 +11,7 @@ const mocks = vi.hoisted(() => ({
getUndecryptedPacketCount: vi.fn(), getUndecryptedPacketCount: vi.fn(),
getChannels: vi.fn(), getChannels: vi.fn(),
getContacts: vi.fn(), getContacts: vi.fn(),
migratePreferences: vi.fn(),
}, },
useConversationMessagesCalls: vi.fn(), useConversationMessagesCalls: vi.fn(),
})); }));
@@ -215,9 +216,11 @@ describe('App search jump target handling', () => {
}); });
mocks.api.getSettings.mockResolvedValue({ mocks.api.getSettings.mockResolvedValue({
max_radio_contacts: 200, max_radio_contacts: 200,
favorites: [],
auto_decrypt_dm_on_advert: false, auto_decrypt_dm_on_advert: false,
sidebar_sort_order: 'recent',
last_message_times: {}, last_message_times: {},
preferences_migrated: true,
advert_interval: 0, advert_interval: 0,
last_advert_time: 0, last_advert_time: 0,
}); });
@@ -229,7 +232,6 @@ describe('App search jump target handling', () => {
is_hashtag: false, is_hashtag: false,
on_radio: false, on_radio: false,
last_read_at: null, last_read_at: null,
favorite: false,
}, },
]); ]);
mocks.api.getContacts.mockResolvedValue([]); mocks.api.getContacts.mockResolvedValue([]);

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