70 Commits

Author SHA1 Message Date
Jack Kingsman
08e00373aa Updating changelog + build for 3.2.0 2026-03-12 11:55:56 -07:00
Jack Kingsman
2f0d35748a Deepend contrast on map colors. Fixes #54 2026-03-12 11:39:42 -07:00
Jack Kingsman
7db2974481 Fix bot kwargs and scoot over unread button to middle 2026-03-12 10:58:38 -07:00
Jack Kingsman
0a20929df6 Add conversation unread marker and jump-to-unread button 2026-03-12 10:54:25 -07:00
Jack Kingsman
30f6f95d8e Add path_bytes_per_hop to bot kwargs and encourage kwargs-only going forward 2026-03-12 10:32:33 -07:00
Jack Kingsman
9e8cf56b31 Be clearer about reality of location inclusion. Closes #53 2026-03-12 10:00:00 -07:00
Jack Kingsman
fb535298be Fix up search pane and header key alignment regression 2026-03-12 00:35:05 -07:00
Jack Kingsman
1f2903fc2d Add better preview pane and tweak some themes for contrast 2026-03-12 00:06:33 -07:00
Jack Kingsman
6466a5c355 Add node GPS enablement + sourcing. Closes #53. 2026-03-11 22:42:41 -07:00
Jack Kingsman
f8e88b3737 Docs tune up 2026-03-11 22:05:59 -07:00
Jack Kingsman
a13e241636 Other connected clients get new chans over WS 2026-03-11 22:02:05 -07:00
Jack Kingsman
8c1a58b293 Don't use a stale MC instance 2026-03-11 21:57:59 -07:00
Jack Kingsman
bf53e8a4cb Clarify message for fanout 2026-03-11 21:51:22 -07:00
Jack Kingsman
c6cd209192 Change direct trace icon 2026-03-11 21:40:09 -07:00
Jack Kingsman
e5c7ebb388 Tweak the win95 theme coloring a bit 2026-03-11 21:05:55 -07:00
Jack Kingsman
e37632de3f Add dedupe bug the agents keep getting hung up on to errata 2026-03-11 20:58:29 -07:00
Jack Kingsman
d36c5e3e32 Deal with reconciliation conflict from colliding WS fetches 2026-03-11 20:52:48 -07:00
Jack Kingsman
bc7506b0d9 Some misc frontend cleanup grossness 2026-03-11 20:49:37 -07:00
Jack Kingsman
38c7277c9d More careful guards around channel message key matching on collision 2026-03-11 20:41:31 -07:00
Jack Kingsman
20d0bd92bb Oh god, so much code for such a minor flow. Ambiguous sender manually fetched prefix DMs are now visible. 2026-03-11 20:38:41 -07:00
Jack Kingsman
e0df30b5f0 AGENTS.md fixups 2026-03-11 19:44:29 -07:00
Jack Kingsman
83635845b6 Don't sleep in the exception handler 2026-03-11 19:32:54 -07:00
Jack Kingsman
2e705538fd Abort serch requests on unmount 2026-03-11 19:26:27 -07:00
Jack Kingsman
4363fd2a73 Track message reconciliation and don't fire on stale returns 2026-03-11 19:20:38 -07:00
Jack Kingsman
5bd3205de5 Stop channs with blocked senders bothering the unread count 2026-03-11 19:11:53 -07:00
Jack Kingsman
bcde3bd9d5 Don't construct synthetic objects 2026-03-11 19:04:57 -07:00
Jack Kingsman
15a8c637e4 Do our own tracking for contact-on-radio with more correct return state 2026-03-11 19:01:14 -07:00
Jack Kingsman
d38efc0421 Add warning on search for user-key linkage unreliability 2026-03-11 18:45:03 -07:00
Jack Kingsman
b311c406da Updating changelog + build for 3.1.1 2026-03-11 18:24:41 -07:00
Jack Kingsman
b5e9e4d04c Fix tag notes 2026-03-11 18:23:20 -07:00
Jack Kingsman
ce87dd9376 Updating changelog + build for 3.1.0 2026-03-11 18:20:31 -07:00
Jack Kingsman
5273d9139d Use newer workflow steps 2026-03-11 18:19:04 -07:00
Jack Kingsman
04ac3d6ed4 Drop out meshcore_py's autoreconnect logic on connection disable 2026-03-11 18:12:11 -07:00
Jack Kingsman
1a1d3059db Split up runs 2026-03-11 18:06:40 -07:00
Jack Kingsman
633510b7de Bring back in package-lock.json 2026-03-11 17:44:55 -07:00
Jack Kingsman
7f4c1e94fd Add github workflows 2026-03-11 17:37:57 -07:00
Jack Kingsman
a06fefb34e New themes 2026-03-11 17:28:12 -07:00
Jack Kingsman
4e0b6a49b0 Add ability to pause radio connection (closes #51) 2026-03-11 17:17:03 -07:00
Jack Kingsman
e993009782 True up some UX inconsistencies and have a theme preview pane 2026-03-11 17:03:43 -07:00
Jack Kingsman
ad7028e508 Add better search management and operators + contact search quick link 2026-03-11 16:56:09 -07:00
Jack Kingsman
ce9bbd1059 Better clarity on sidebar search 2026-03-11 16:22:02 -07:00
Jack Kingsman
0c35601af3 Enrich contact no-key info pane with first-in-use date 2026-03-11 16:19:10 -07:00
Jack Kingsman
93369f8d64 Enrich names-based contact pane a bit 2026-03-11 15:57:29 -07:00
Jack Kingsman
e7d1f28076 Add SQS fanout 2026-03-11 14:17:08 -07:00
Jack Kingsman
472b4a5ed2 Better logging output 2026-03-11 13:40:48 -07:00
Jack Kingsman
314e4c7fff True up regional routing icon style 2026-03-11 10:04:01 -07:00
Jack Kingsman
528a94d2bd Add basic auth 2026-03-11 10:02:02 -07:00
Jack Kingsman
fa1c086f5f Updating changelog + build for 3.0.0 2026-03-10 21:41:04 -07:00
Jack Kingsman
d8bb747152 Reorder themes 2026-03-10 21:27:05 -07:00
Jack Kingsman
18a465fde8 Fix ordering 2026-03-10 21:06:50 -07:00
Jack Kingsman
c52e00d2b7 Merge pull request #50 from jkingsman/notifications
Notifications
2026-03-10 20:49:12 -07:00
Jack Kingsman
e17d1ba4b4 Move search bar to top level 2026-03-10 19:56:10 -07:00
Jack Kingsman
48a49ce48d Add some new themes 2026-03-10 19:54:06 -07:00
Jack Kingsman
9d1676818f Add lagoon pop 2026-03-10 19:51:10 -07:00
Jack Kingsman
b5edd00220 Tweak light mode tools color and icon state 2026-03-10 19:36:04 -07:00
Jack Kingsman
d3a7b7ce07 Add light mode toggle 2026-03-10 19:32:22 -07:00
Jack Kingsman
42ca242ee1 Update override badge for region routing 2026-03-10 19:26:31 -07:00
Jack Kingsman
3e7e0669c5 Add bell icon and use better notif icon 2026-03-10 19:04:52 -07:00
Jack Kingsman
bee273ab56 Add notifications 2026-03-10 19:03:52 -07:00
Jack Kingsman
1842bcf43e Add new icon size + crush PNGs 2026-03-10 19:03:34 -07:00
Jack Kingsman
7c68973e30 Icon overhaul 2026-03-10 17:43:15 -07:00
Jack Kingsman
c9ede1f71f Clearer about advertiser repeat button 2026-03-10 15:49:28 -07:00
Jack Kingsman
42e9628d98 Fix clock sync command 2026-03-10 15:46:34 -07:00
Jack Kingsman
1bf760121d Preserve repeater values when browsing away 2026-03-10 15:40:26 -07:00
Jack Kingsman
bb4a601788 Coerce uvicorn logging to better format 2026-03-10 14:58:44 -07:00
Jack Kingsman
d0ed3484ce Add hourly sync and crow loudly if it finds a discrepancy 2026-03-10 14:47:18 -07:00
Jack Kingsman
738e0b9815 Don't load full right away 2026-03-10 14:39:40 -07:00
Jack Kingsman
97997e23e8 Drop frequency of contact sync task, make standard polling opt-in only 2026-03-10 14:04:51 -07:00
Jack Kingsman
eaee66f836 Add timestamps to logs and stop regen'ing licenses every time 2026-03-10 13:08:26 -07:00
Jack Kingsman
9a99d3f17e Codex Refactor -- Make things more manageable and LLM friendly 2026-03-10 12:26:30 -07:00
148 changed files with 9205 additions and 1042 deletions

74
.github/workflows/all-quality.yml vendored Normal file
View File

@@ -0,0 +1,74 @@
name: All Quality
on:
push:
pull_request:
jobs:
backend-checks:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: "3.12"
- name: Set up uv
uses: astral-sh/setup-uv@v7
with:
enable-cache: true
- name: Install backend dependencies
run: uv sync --dev
- name: Backend lint
run: uv run ruff check app/ tests/
- name: Backend format check
run: uv run ruff format --check app/ tests/
- name: Backend typecheck
run: uv run pyright app/
- name: Backend tests
run: PYTHONPATH=. uv run pytest tests/ -v
frontend-checks:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Check out repository
uses: actions/checkout@v5
- name: Set up Node.js
uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm
cache-dependency-path: frontend/package-lock.json
- name: Install frontend dependencies
run: npm ci
working-directory: frontend
- name: Frontend lint
run: npm run lint
working-directory: frontend
- name: Frontend format check
run: npm run format:check
working-directory: frontend
- name: Frontend tests
run: npm run test:run
working-directory: frontend
- name: Frontend build
run: npm run build
working-directory: frontend

1
.gitignore vendored
View File

@@ -8,7 +8,6 @@ wheels/
# Virtual environments
.venv
frontend/node_modules/
frontend/package-lock.json
frontend/test-results/
# Frontend build output (built from source by end users)

View File

@@ -21,8 +21,8 @@ A web interface for MeshCore mesh radio networks. The backend connects to a Mesh
- `frontend/AGENTS.md` - Frontend (React, state management, WebSocket, components)
Ancillary AGENTS.md files which should generally not be reviewed unless specific work is being performed on those features include:
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise)
- `frontend/src/components/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise, SQS)
- `frontend/src/components/visualizer/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
## Architecture Overview
@@ -77,13 +77,15 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific
- Raw packet feed — a debug/observation tool ("radio aquarium"); interesting to watch or copy packets from, but not critical infrastructure
- Map view — visual display of node locations from advertisements
- Network visualizer — force-directed graph of mesh topology
- Fanout integrations (MQTT, bots, webhooks, Apprise) — see `app/fanout/AGENTS_fanout.md`
- Fanout integrations (MQTT, bots, webhooks, Apprise, SQS) — see `app/fanout/AGENTS_fanout.md`
- Read state tracking / mark-all-read — convenience feature for unread badges; no need for transactional atomicity or race-condition hardening
## Error Handling Philosophy
**Background tasks** (WebSocket broadcasts, periodic sync, contact auto-loading, etc.) use fire-and-forget `asyncio.create_task`. Exceptions in these tasks are logged to the backend logs, which is sufficient for debugging. There is no need to track task references or add done-callbacks purely for error visibility. If there's a convenient way to bubble an error to the frontend (e.g., via `broadcast_error` for user-actionable problems), do so, but this is minor and best-effort.
Radio startup/setup is one place where that frontend bubbling is intentional: if post-connect setup hangs past its timeout, the backend both logs the failure and pushes a toast instructing the operator to reboot the radio and restart the server.
## Key Design Principles
1. **Store-and-serve**: Backend stores all packets even when no client is connected
@@ -107,7 +109,7 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific
The following are **deliberate design choices**, not bugs. They are documented in the README with appropriate warnings. Do not "fix" these or flag them as vulnerabilities.
1. **No CORS restrictions**: The backend allows all origins (`allow_origins=["*"]`). This lets users access their radio from any device/origin on their network without configuration hassle.
2. **No authentication or authorization**: There is no login, no API keys, no session management. The app is designed for trusted networks (home LAN, VPN). The README warns users not to expose it to untrusted networks.
2. **Minimal optional access control only**: The app has no user accounts, sessions, authorization model, or per-feature permissions. Operators may optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but this is only a coarse gate and still requires HTTPS plus a trusted network posture.
3. **Arbitrary bot code execution**: The bot system (`app/fanout/bot_exec.py`) executes user-provided Python via `exec()` with full `__builtins__`. This is intentional — bots are a power-user feature for automation. The README explicitly warns that anyone on the network can execute arbitrary code through this. Operators can set `MESHCORE_DISABLE_BOTS=true` to completely disable the bot system at startup — this skips all bot execution, returns 403 on bot settings updates, and shows a disabled message in the frontend.
## Intentional Packet Handling Decision
@@ -179,7 +181,7 @@ This message-layer echo/path handling is independent of raw-packet storage dedup
│ ├── event_handlers.py # Radio events
│ ├── decoder.py # Packet decryption
│ ├── websocket.py # Real-time broadcasts
│ └── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
│ └── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
├── frontend/ # React frontend
│ ├── AGENTS.md # Frontend documentation
│ ├── src/
@@ -221,7 +223,7 @@ MESHCORE_SERIAL_PORT=/dev/cu.usbserial-0001 uv run uvicorn app.main:app --reload
```bash
cd frontend
npm install
npm ci
npm run dev # http://localhost:5173, proxies /api to :8000
```
@@ -235,7 +237,7 @@ Terminal 2: `cd frontend && npm run dev`
In production, the FastAPI backend serves the compiled frontend. Build the frontend first:
```bash
cd frontend && npm install && npm run build && cd ..
cd frontend && npm ci && npm run build && cd ..
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
```
@@ -288,14 +290,17 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`).
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | `/api/health` | Connection status, fanout statuses, bots_disabled flag |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode` and `path_hash_mode_supported` |
| PATCH | `/api/radio/config` | Update name, location, radio params, and `path_hash_mode` when supported |
| GET | `/api/radio/config` | Radio configuration, including `path_hash_mode`, `path_hash_mode_supported`, and whether adverts include current node location |
| PATCH | `/api/radio/config` | Update name, location, advert-location on/off, radio params, and `path_hash_mode` when supported |
| PUT | `/api/radio/private-key` | Import private key to radio |
| POST | `/api/radio/advertise` | Send advertisement |
| POST | `/api/radio/reboot` | Reboot radio or reconnect if disconnected |
| POST | `/api/radio/disconnect` | Disconnect from radio and pause automatic reconnect attempts |
| POST | `/api/radio/reconnect` | Manual radio reconnection |
| GET | `/api/contacts` | List contacts |
| GET | `/api/contacts/analytics` | Unified keyed-or-name contact analytics payload |
| GET | `/api/contacts/repeaters/advert-paths` | List recent unique advert paths for all contacts |
| GET | `/api/contacts/name-detail` | Channel activity summary for a sender name without a resolved key |
| GET | `/api/contacts/{public_key}` | Get contact by public key or prefix |
| GET | `/api/contacts/{public_key}/detail` | Comprehensive contact profile (stats, name history, paths) |
| GET | `/api/contacts/{public_key}/advert-paths` | List recent unique advert paths for a contact |
@@ -391,7 +396,7 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de
**Note:** These are NOT the same as `Message.conversation_key` (the database field).
### Fanout Bus (MQTT, Bots, Webhooks, Apprise)
### Fanout Bus (MQTT, Bots, Webhooks, Apprise, SQS)
All external integrations are managed through the fanout bus (`app/fanout/`). Each integration is a `FanoutModule` with scope-based event filtering, stored in the `fanout_configs` table and managed via `GET/POST/PATCH/DELETE /api/fanout`.
@@ -440,8 +445,11 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on message audit task from hourly checks to aggressive 10-second `get_msg()` fallback polling |
| `MESHCORE_BASIC_AUTH_USERNAME` | *(none)* | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
| `MESHCORE_BASIC_AUTH_PASSWORD` | *(none)* | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, Apprise, and SQS configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.
@@ -454,3 +462,9 @@ Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/
The vendored MeshCore Python reader's `LOG_DATA` advert path assumes the decoded advert payload always contains at least 101 bytes of advert body and reads the flags byte with `pk_buf.read(1)[0]` without a length guard. If a malformed or truncated RF log frame slips through, `MessageReader.handle_rx()` can fail with `IndexError: index out of range` from `meshcore/reader.py` while parsing payload type `0x04` (advert).
This does not indicate database corruption or a message-store bug. It is a parser-hardening gap in `meshcore_py`: the reader does not fully mirror firmware-side packet/path validation before attempting advert decode. The practical effect is usually a one-off asyncio task failure for that packet while later packets continue processing normally.
### Channel-message dedup intentionally treats same-name/same-text/same-second channel sends as indistinguishable because they are
Channel message storage deduplicates on `(type, conversation_key, text, sender_timestamp)`. Reviewers often flag this as "missing sender identity," but for channel messages the stored `text` already includes the displayed sender label (for example `Alice: hello`). That means two different users only collide when they produce the same rendered sender name, the same body text, and the same sender timestamp.
In that case, RemoteTerm usually does not have enough information to distinguish "two independent same-name sends" from "one message observed again as an echo/repeat." Without a reliable sender identity at ingest, treating those packets as the same message is an accepted limitation of the observable data model, not an obvious correctness bug.

View File

@@ -1,3 +1,63 @@
## [3.2.0] - 2026-03-12
Feature: Improve ambiguous-sender DM handling and visibility
Feature: Allow for toggling of node GPS broadcast
Feature: Add path width to bot and move example to full kwargs
Feature: Improve node map color contrast
Bugfix: More accurate tracking of contact data
Bugfix: Misc. frontend performance and bugfixes
Misc: Clearer warnings on user-key linkage
Misc: Documentation improvements
## [3.1.1] - 2026-03-11
Feature: Add basic auth
Feature: SQS fanout
Feature: Enrich contact info pane
Feature: Search operators for node and channel
Feature: Pause radio connection attempts from Radio settings
Feature: New themes! What a great use of time!
Feature: Github workflows runs for validation
Bugfix: More consistent log format with times
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.1.0] - 2026-03-11
Feature: Add basic auth
Feature: SQS fanout
Feature: Enrich contact info pane
Feature: Search operators for node and channel
Feature: Pause radio connection attempts from Radio settings
Feature: New themes! What a great use of time!
Feature: Github workflows runs for validation
Bugfix: More consistent log format with times
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
## [3.0.0] - 2026-03-10
Feature: Custom regions per-channel
Feature: Add custom contact pathing
Feature: Corrupt packets are more clear that they're corrupt
Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
Feature: More consistent icons
Feature: Add per-channel local notifications
Feature: New themes
Feature: Massive codebase refactor and overhaul
Bugfix: Fix packet parsing for trace packets
Bugfix: Refetch channels on reconnect
Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
Bugfix: Timestamps in logs
Bugfix: Correct wrong clock sync command
Misc: Improve bot error bubble up
Misc: Update to non-lib-included meshcore-decoder version
Misc: Revise refactors to be more LLM friendly
Misc: Fix script executability
Misc: Better logging format with timestamp
Misc: Repeater advert buttons separate flood and one-hop
Misc: Preserve repeater pane on navigation away
Misc: Clearer iconography and coloring for status bar buttons
Misc: Search bar to top bar
## [2.7.9] - 2026-03-08
Bugfix: Don't obscure new integration dropdown on session boundary

View File

@@ -5,8 +5,8 @@ ARG COMMIT_HASH=unknown
WORKDIR /build
COPY frontend/package.json frontend/.npmrc ./
RUN npm install
COPY frontend/package.json frontend/package-lock.json frontend/.npmrc ./
RUN npm ci
COPY frontend/ ./
RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build

View File

@@ -91,6 +91,192 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
</details>
### boto3 (1.42.66) — Apache-2.0
<details>
<summary>Full license text</summary>
```
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
```
</details>
### fastapi (0.128.0) — MIT
<details>

View File

@@ -12,7 +12,7 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
* Use the more recent 1.14 firmwares which support multibyte pathing in all traffic and display systems within the app
* Visualize the mesh as a map or node set, view repeater stats, and more!
**Warning:** This app has no auth, and is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ The bots can execute arbitrary Python code which means anyone on your network can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need access control, consider using a reverse proxy like Nginx, or extending FastAPI; access control and user management are outside the scope of this app.
**Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
![Screenshot of the application's web interface](app_screenshot.png)
@@ -77,7 +77,7 @@ cd Remote-Terminal-for-MeshCore
uv sync
# Build frontend
cd frontend && npm install && npm run build && cd ..
cd frontend && npm ci && npm run build && cd ..
# Run server
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
@@ -175,7 +175,7 @@ uv run uvicorn app.main:app --reload
```bash
cd frontend
npm install
npm ci
npm run dev # Dev server at http://localhost:5173 (proxies API to :8000)
npm run build # Production build to dist/
```
@@ -224,9 +224,16 @@ npm run build # build the frontend
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config) |
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly audit task |
| `MESHCORE_BASIC_AUTH_USERNAME` | | Optional app-wide HTTP Basic auth username; must be set together with `MESHCORE_BASIC_AUTH_PASSWORD` |
| `MESHCORE_BASIC_AUTH_PASSWORD` | | Optional app-wide HTTP Basic auth password; must be set together with `MESHCORE_BASIC_AUTH_USERNAME` |
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
If you enable Basic Auth, protect the app with HTTPS. HTTP Basic credentials are not safe on plain HTTP.
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. If that audit ever finds radio data that was not surfaced through event subscription, the backend logs an error and the UI shows a toast telling the operator to check the logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second `get_msg()` safety net.
## Additional Setup
<details>
@@ -280,7 +287,7 @@ sudo -u remoteterm uv sync
# Build frontend (required for the backend to serve the web UI)
cd /opt/remoteterm/frontend
sudo -u remoteterm npm install
sudo -u remoteterm npm ci
sudo -u remoteterm npm run build
# Install and start service

View File

@@ -44,9 +44,11 @@ app/
├── event_handlers.py # MeshCore event subscriptions and ACK tracking
├── events.py # Typed WS event payload serialization
├── websocket.py # WS manager + broadcast helpers
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise (see fanout/AGENTS_fanout.md)
├── security.py # Optional app-wide HTTP Basic auth middleware for HTTP + WS
├── fanout/ # Fanout bus: MQTT, bots, webhooks, Apprise, SQS (see fanout/AGENTS_fanout.md)
├── dependencies.py # Shared FastAPI dependency providers
├── path_utils.py # Path hex rendering and hop-width helpers
├── region_scope.py # Normalize/validate regional flood-scope values
├── keystore.py # Ephemeral private/public key storage for DM decryption
├── frontend_static.py # Mount/serve built frontend (production)
└── routers/
@@ -87,7 +89,8 @@ app/
- `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`.
- Routers, startup/lifespan code, fanout helpers, and `radio_sync.py` should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly.
- Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state.
- Setup still includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks.
- Setup still includes handler registration, key export, time sync, contact/channel sync, and advertisement tasks. The message-poll task always starts: by default it runs as a low-frequency hourly audit, and `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` switches it to aggressive 10-second polling.
- Post-connect setup is timeout-bounded. If initial radio offload/setup hangs too long, the backend logs the failure and broadcasts an `error` toast telling the operator to reboot the radio and restart the server.
## Important Behaviors
@@ -130,7 +133,7 @@ app/
### Fanout bus
- All external integrations (MQTT, bots, webhooks, Apprise) are managed through the fanout bus (`app/fanout/`).
- All external integrations (MQTT, bots, webhooks, Apprise, SQS) are managed through the fanout bus (`app/fanout/`).
- Configs stored in `fanout_configs` table, managed via `GET/POST/PATCH/DELETE /api/fanout`.
- `broadcast_event()` in `websocket.py` dispatches to the fanout manager for `message` and `raw_packet` events.
- Each integration is a `FanoutModule` with scope-based filtering.
@@ -143,16 +146,19 @@ app/
- `GET /health`
### Radio
- `GET /radio/config` — includes `path_hash_mode` and `path_hash_mode_supported`
- `GET /radio/config` — includes `path_hash_mode`, `path_hash_mode_supported`, and advert-location on/off
- `PATCH /radio/config` — may update `path_hash_mode` (`0..2`) when firmware supports it
- `PUT /radio/private-key`
- `POST /radio/advertise`
- `POST /radio/disconnect`
- `POST /radio/reboot`
- `POST /radio/reconnect`
### Contacts
- `GET /contacts`
- `GET /contacts/analytics` — unified keyed-or-name analytics payload
- `GET /contacts/repeaters/advert-paths` — recent advert paths for all contacts
- `GET /contacts/name-detail` — name-only activity summary for unresolved channel senders
- `GET /contacts/{public_key}`
- `GET /contacts/{public_key}/detail` — comprehensive contact profile (stats, name history, paths, nearest repeaters)
- `GET /contacts/{public_key}/advert-paths` — recent advert paths for one contact
@@ -230,7 +236,7 @@ app/
- `contact_deleted` — contact removed from database (payload: `{ public_key }`)
- `channel` — single channel upsert/update (payload: full `Channel`)
- `channel_deleted` — channel removed from database (payload: `{ key }`)
- `error` — toast notification (reconnect failure, missing private key, etc.)
- `error` — toast notification (reconnect failure, missing private key, stuck radio startup, etc.)
- `success` — toast notification (historical decrypt complete, etc.)
Backend WS sends go through typed serialization in `events.py`. Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
@@ -250,6 +256,8 @@ Main tables:
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
`app_settings` fields in active model:
- `max_radio_contacts`
- `favorites`
@@ -289,16 +297,20 @@ tests/
├── test_api.py # REST endpoint integration tests
├── test_bot.py # Bot execution and sandboxing
├── test_channels_router.py # Channels router endpoints
├── test_channel_sender_backfill.py # Sender-key backfill uniqueness rules for channel messages
├── test_config.py # Configuration validation
├── test_contact_reconciliation_service.py # Prefix/contact reconciliation service helpers
├── test_contacts_router.py # Contacts router endpoints
├── test_decoder.py # Packet parsing/decryption
├── test_disable_bots.py # MESHCORE_DISABLE_BOTS=true feature
├── test_echo_dedup.py # Echo/repeat deduplication (incl. concurrent)
├── test_fanout.py # Fanout bus CRUD, scope matching, manager dispatch
├── test_fanout_integration.py # Fanout integration tests
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
├── test_event_handlers.py # ACK tracking, event registration, cleanup
├── test_frontend_static.py # Frontend static file serving
├── test_health_mqtt_status.py # Health endpoint MQTT status field
├── test_http_quality.py # Cache-control / gzip / basic-auth HTTP quality checks
├── test_key_normalization.py # Public key normalization
├── test_keystore.py # Ephemeral keystore
├── test_message_pagination.py # Cursor-based message pagination
@@ -311,6 +323,7 @@ tests/
├── test_radio.py # RadioManager, serial detection
├── test_radio_commands_service.py # Radio config/private-key service workflows
├── test_radio_lifecycle_service.py # Reconnect/setup orchestration helpers
├── test_radio_runtime_service.py # radio_runtime seam behavior and helpers
├── test_real_crypto.py # Real cryptographic operations
├── test_radio_operation.py # radio_operation() context manager
├── test_radio_router.py # Radio router endpoints
@@ -320,11 +333,10 @@ tests/
├── test_rx_log_data.py # on_rx_log_data event handler integration
├── test_messages_search.py # Message search, around, forward pagination
├── test_block_lists.py # Blocked keys/names filtering
├── test_security.py # Optional Basic Auth middleware / config behavior
├── test_send_messages.py # Outgoing messages, bot triggers, concurrent sends
├── test_settings_router.py # Settings endpoints, advert validation
├── test_statistics.py # Statistics aggregation
├── test_channel_sender_backfill.py # Sender key backfill for channel messages
├── test_fanout_hitlist.py # Fanout-related hitlist regression tests
├── test_main_startup.py # App startup and lifespan
├── test_path_utils.py # Path hex rendering helpers
├── test_websocket.py # WS manager broadcast/cleanup

View File

@@ -1,4 +1,5 @@
import logging
import logging.config
from typing import Literal
from pydantic import model_validator
@@ -17,6 +18,9 @@ class Settings(BaseSettings):
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
database_path: str = "data/meshcore.db"
disable_bots: bool = False
enable_message_poll_fallback: bool = False
basic_auth_username: str = ""
basic_auth_password: str = ""
@model_validator(mode="after")
def validate_transport_exclusivity(self) -> "Settings":
@@ -34,6 +38,11 @@ class Settings(BaseSettings):
)
if self.ble_address and not self.ble_pin:
raise ValueError("MESHCORE_BLE_PIN is required when MESHCORE_BLE_ADDRESS is set.")
if self.basic_auth_partially_configured:
raise ValueError(
"MESHCORE_BASIC_AUTH_USERNAME and MESHCORE_BASIC_AUTH_PASSWORD "
"must be set together."
)
return self
@property
@@ -44,6 +53,15 @@ class Settings(BaseSettings):
return "ble"
return "serial"
@property
def basic_auth_enabled(self) -> bool:
return bool(self.basic_auth_username and self.basic_auth_password)
@property
def basic_auth_partially_configured(self) -> bool:
any_credentials_set = bool(self.basic_auth_username or self.basic_auth_password)
return any_credentials_set and not self.basic_auth_enabled
settings = Settings()
@@ -84,10 +102,54 @@ class _RepeatSquelch(logging.Filter):
def setup_logging() -> None:
"""Configure logging for the application."""
logging.basicConfig(
level=settings.log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
logging.config.dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"uvicorn_access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(asctime)s - %(name)s - %(levelname)s - %(client_addr)s - "%(request_line)s" %(status_code)s',
"datefmt": "%Y-%m-%d %H:%M:%S",
"use_colors": None,
},
},
"handlers": {
"default": {
"class": "logging.StreamHandler",
"formatter": "default",
},
"uvicorn_access": {
"class": "logging.StreamHandler",
"formatter": "uvicorn_access",
},
},
"root": {
"level": settings.log_level,
"handlers": ["default"],
},
"loggers": {
"uvicorn": {
"level": settings.log_level,
"handlers": ["default"],
"propagate": False,
},
"uvicorn.error": {
"level": settings.log_level,
"handlers": ["default"],
"propagate": False,
},
"uvicorn.access": {
"level": settings.log_level,
"handlers": ["uvicorn_access"],
"propagate": False,
},
},
}
)
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
# "Serial Connection started" when the port is contended).

View File

@@ -13,6 +13,7 @@ from app.repository import (
from app.services import dm_ack_tracker
from app.services.contact_reconciliation import (
claim_prefix_messages_for_contact,
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.messages import create_fallback_direct_message, increment_ack_and_broadcast
@@ -88,6 +89,20 @@ async def on_contact_message(event: "Event") -> None:
sender_pubkey[:12],
)
return
elif sender_pubkey:
placeholder_upsert = ContactUpsert(
public_key=sender_pubkey.lower(),
type=0,
last_seen=received_at,
last_contacted=received_at,
first_seen=received_at,
on_radio=False,
out_path_hash_mode=-1,
)
await ContactRepository.upsert(placeholder_upsert)
contact = await ContactRepository.get_by_key(sender_pubkey.lower())
if contact:
broadcast_event("contact", contact.model_dump())
# Try to create message - INSERT OR IGNORE handles duplicates atomically
# If the packet processor already stored this message, this returns None
@@ -231,6 +246,10 @@ async def on_new_contact(event: "Event") -> None:
contact_upsert = ContactUpsert.from_radio_dict(public_key.lower(), payload, on_radio=True)
contact_upsert.last_seen = int(time.time())
await ContactRepository.upsert(contact_upsert)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=public_key,
log=logger,
)
adv_name = payload.get("adv_name")
await record_contact_name_and_reconcile(
@@ -251,6 +270,15 @@ async def on_new_contact(event: "Event") -> None:
else Contact(**contact_upsert.model_dump(exclude_none=True)).model_dump()
),
)
if db_contact:
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{
"previous_public_key": old_key,
"contact": db_contact.model_dump(),
},
)
async def on_ack(event: "Event") -> None:

View File

@@ -16,6 +16,7 @@ WsEventType = Literal[
"health",
"message",
"contact",
"contact_resolved",
"channel",
"contact_deleted",
"channel_deleted",
@@ -30,6 +31,11 @@ class ContactDeletedPayload(TypedDict):
public_key: str
class ContactResolvedPayload(TypedDict):
previous_public_key: str
contact: Contact
class ChannelDeletedPayload(TypedDict):
key: str
@@ -49,6 +55,7 @@ WsEventPayload = (
HealthResponse
| Message
| Contact
| ContactResolvedPayload
| Channel
| ContactDeletedPayload
| ChannelDeletedPayload
@@ -61,6 +68,7 @@ _PAYLOAD_ADAPTERS: dict[WsEventType, TypeAdapter[Any]] = {
"health": TypeAdapter(HealthResponse),
"message": TypeAdapter(Message),
"contact": TypeAdapter(Contact),
"contact_resolved": TypeAdapter(ContactResolvedPayload),
"channel": TypeAdapter(Channel),
"contact_deleted": TypeAdapter(ContactDeletedPayload),
"channel_deleted": TypeAdapter(ChannelDeletedPayload),

View File

@@ -79,6 +79,14 @@ Push notifications via Apprise library. Config blob:
- `preserve_identity` — suppress Discord webhook name/avatar override
- `include_path` — include routing path in notification body
### sqs (sqs.py)
Amazon SQS delivery. Config blob:
- `queue_url` — target queue URL
- `region_name` (optional), `endpoint_url` (optional)
- `access_key_id`, `secret_access_key`, `session_token` (all optional; blank uses the normal AWS credential chain)
- Publishes a JSON envelope of the form `{"event_type":"message"|"raw_packet","data":...}`
- Supports both decoded messages and raw packets via normal scope selection
## Adding a New Integration Type
### Step-by-step checklist
@@ -132,7 +140,7 @@ Three changes needed:
**a)** Add to `_VALID_TYPES` set:
```python
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "my_type"}
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs", "my_type"}
```
**b)** Add a validation function:
@@ -280,6 +288,7 @@ Migrations:
- `app/fanout/bot_exec.py` — Bot code execution, response processing, rate limiting
- `app/fanout/webhook.py` — Webhook fanout module
- `app/fanout/apprise_mod.py` — Apprise fanout module
- `app/fanout/sqs.py` — Amazon SQS fanout module
- `app/repository/fanout.py` — Database CRUD
- `app/routers/fanout.py` — REST API
- `app/websocket.py``broadcast_event()` dispatches to fanout

View File

@@ -10,6 +10,36 @@ from app.fanout.base import FanoutModule
logger = logging.getLogger(__name__)
def _derive_path_bytes_per_hop(paths: object, path_value: str | None) -> int | None:
"""Derive hop width from the first serialized message path when possible."""
if not isinstance(path_value, str) or not path_value:
return None
if not isinstance(paths, list) or not paths:
return None
first_path = paths[0]
if not isinstance(first_path, dict):
return None
path_hops = first_path.get("path_len")
if not isinstance(path_hops, int) or path_hops <= 0:
return None
path_hex_chars = len(path_value)
if path_hex_chars % 2 != 0:
return None
path_bytes = path_hex_chars // 2
if path_bytes % path_hops != 0:
return None
hop_width = path_bytes // path_hops
if hop_width not in (1, 2, 3):
return None
return hop_width
class BotModule(FanoutModule):
"""Wraps a single bot's code execution and response routing.
@@ -101,11 +131,11 @@ class BotModule(FanoutModule):
sender_timestamp = data.get("sender_timestamp")
path_value = data.get("path")
paths = data.get("paths")
# Message model serializes paths as list of dicts; extract first path string
if path_value is None:
paths = data.get("paths")
if paths and isinstance(paths, list) and len(paths) > 0:
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
if path_value is None and paths and isinstance(paths, list) and len(paths) > 0:
path_value = paths[0].get("path") if isinstance(paths[0], dict) else None
path_bytes_per_hop = _derive_path_bytes_per_hop(paths, path_value)
# Wait for message to settle (allows retransmissions to be deduped)
await asyncio.sleep(2)
@@ -130,6 +160,7 @@ class BotModule(FanoutModule):
sender_timestamp,
path_value,
is_outgoing,
path_bytes_per_hop,
),
timeout=BOT_EXECUTION_TIMEOUT,
)

View File

@@ -15,6 +15,7 @@ import inspect
import logging
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass
from typing import Any
from fastapi import HTTPException
@@ -39,6 +40,102 @@ _bot_send_lock = asyncio.Lock()
_last_bot_send_time: float = 0.0
@dataclass(frozen=True)
class BotCallPlan:
"""How to call a validated bot() function."""
call_style: str
keyword_args: tuple[str, ...] = ()
def _analyze_bot_signature(bot_func_or_sig) -> BotCallPlan:
"""Validate bot() signature and return a supported call plan."""
try:
sig = (
bot_func_or_sig
if isinstance(bot_func_or_sig, inspect.Signature)
else inspect.signature(bot_func_or_sig)
)
except (ValueError, TypeError) as exc:
raise ValueError("Bot function signature could not be inspected") from exc
params = sig.parameters
param_values = tuple(params.values())
positional_params = [
p
for p in param_values
if p.kind in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
]
has_varargs = any(p.kind == inspect.Parameter.VAR_POSITIONAL for p in param_values)
has_kwargs = any(p.kind == inspect.Parameter.VAR_KEYWORD for p in param_values)
explicit_optional_names = tuple(
name for name in ("is_outgoing", "path_bytes_per_hop") if name in params
)
unsupported_required_kwonly = [
p.name
for p in param_values
if p.kind == inspect.Parameter.KEYWORD_ONLY
and p.default is inspect.Parameter.empty
and p.name not in {"is_outgoing", "path_bytes_per_hop"}
]
if unsupported_required_kwonly:
raise ValueError(
"Bot function signature is not supported. Unsupported required keyword-only "
"parameters: " + ", ".join(unsupported_required_kwonly)
)
positional_capacity = len(positional_params)
base_args = [object()] * 8
base_keyword_args: dict[str, object] = {
"sender_name": object(),
"sender_key": object(),
"message_text": object(),
"is_dm": object(),
"channel_key": object(),
"channel_name": object(),
"sender_timestamp": object(),
"path": object(),
}
candidate_specs: list[tuple[str, list[object], dict[str, object]]] = []
keyword_args = dict(base_keyword_args)
if has_kwargs or "is_outgoing" in params:
keyword_args["is_outgoing"] = False
if has_kwargs or "path_bytes_per_hop" in params:
keyword_args["path_bytes_per_hop"] = 1
candidate_specs.append(("keyword", [], keyword_args))
if not has_kwargs and explicit_optional_names:
kwargs: dict[str, object] = {}
if has_kwargs or "is_outgoing" in params:
kwargs["is_outgoing"] = False
if has_kwargs or "path_bytes_per_hop" in params:
kwargs["path_bytes_per_hop"] = 1
candidate_specs.append(("mixed_keyword", base_args, kwargs))
if has_varargs or positional_capacity >= 10:
candidate_specs.append(("positional_10", base_args + [False, 1], {}))
if has_varargs or positional_capacity >= 9:
candidate_specs.append(("positional_9", base_args + [False], {}))
if has_varargs or positional_capacity >= 8:
candidate_specs.append(("legacy", base_args, {}))
for call_style, args, kwargs in candidate_specs:
try:
sig.bind(*args, **kwargs)
except TypeError:
continue
if call_style in {"keyword", "mixed_keyword"}:
return BotCallPlan(call_style="keyword", keyword_args=tuple(kwargs.keys()))
return BotCallPlan(call_style=call_style)
raise ValueError(
"Bot function signature is not supported. Use the default bot template as a reference. "
"Supported trailing parameters are: path; path + is_outgoing; "
"path + path_bytes_per_hop; path + is_outgoing + path_bytes_per_hop; "
"or use **kwargs for forward compatibility."
)
def execute_bot_code(
code: str,
sender_name: str | None,
@@ -50,17 +147,19 @@ def execute_bot_code(
sender_timestamp: int | None,
path: str | None,
is_outgoing: bool = False,
path_bytes_per_hop: int | None = None,
) -> str | list[str] | None:
"""
Execute user-provided bot code with message context.
The code should define a function:
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing)`
`bot(sender_name, sender_key, message_text, is_dm, channel_key, channel_name, sender_timestamp, path, is_outgoing, path_bytes_per_hop)`
or use named parameters / `**kwargs`.
that returns either None (no response), a string (single response message),
or a list of strings (multiple messages sent in order).
Legacy bot functions with 8 parameters (without is_outgoing) are detected
via inspect and called without the new parameter for backward compatibility.
Legacy bot functions with older signatures are detected via inspect and
called without the newer parameters for backward compatibility.
Args:
code: Python code defining the bot function
@@ -73,6 +172,7 @@ def execute_bot_code(
sender_timestamp: Sender's timestamp from the message (may be None)
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
path_bytes_per_hop: Number of bytes per routing hop (1, 2, or 3), if known
Returns:
Response string, list of strings, or None.
@@ -100,30 +200,28 @@ def execute_bot_code(
return None
bot_func = namespace["bot"]
# Detect whether the bot function accepts is_outgoing (new 9-param signature)
# or uses the legacy 8-param signature, for backward compatibility.
# Three cases: explicit is_outgoing param or 9+ params (positional),
# **kwargs (pass as keyword), or legacy 8-param (omit).
call_style = "legacy" # "positional", "keyword", or "legacy"
try:
sig = inspect.signature(bot_func)
params = sig.parameters
non_variadic = [
p
for p in params.values()
if p.kind not in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD)
]
if "is_outgoing" in params or len(non_variadic) >= 9:
call_style = "positional"
elif any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
call_style = "keyword"
except (ValueError, TypeError):
pass
call_plan = _analyze_bot_signature(bot_func)
except ValueError as exc:
logger.error("%s", exc)
return None
try:
# Call the bot function with appropriate signature
if call_style == "positional":
if call_plan.call_style == "positional_10":
result = bot_func(
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing,
path_bytes_per_hop,
)
elif call_plan.call_style == "positional_9":
result = bot_func(
sender_name,
sender_key,
@@ -135,18 +233,29 @@ def execute_bot_code(
path,
is_outgoing,
)
elif call_style == "keyword":
result = bot_func(
sender_name,
sender_key,
message_text,
is_dm,
channel_key,
channel_name,
sender_timestamp,
path,
is_outgoing=is_outgoing,
)
elif call_plan.call_style == "keyword":
keyword_args: dict[str, Any] = {}
if "sender_name" in call_plan.keyword_args:
keyword_args["sender_name"] = sender_name
if "sender_key" in call_plan.keyword_args:
keyword_args["sender_key"] = sender_key
if "message_text" in call_plan.keyword_args:
keyword_args["message_text"] = message_text
if "is_dm" in call_plan.keyword_args:
keyword_args["is_dm"] = is_dm
if "channel_key" in call_plan.keyword_args:
keyword_args["channel_key"] = channel_key
if "channel_name" in call_plan.keyword_args:
keyword_args["channel_name"] = channel_name
if "sender_timestamp" in call_plan.keyword_args:
keyword_args["sender_timestamp"] = sender_timestamp
if "path" in call_plan.keyword_args:
keyword_args["path"] = path
if "is_outgoing" in call_plan.keyword_args:
keyword_args["is_outgoing"] = is_outgoing
if "path_bytes_per_hop" in call_plan.keyword_args:
keyword_args["path_bytes_per_hop"] = path_bytes_per_hop
result = bot_func(**keyword_args)
else:
result = bot_func(
sender_name,

View File

@@ -23,6 +23,7 @@ def _register_module_types() -> None:
from app.fanout.bot import BotModule
from app.fanout.mqtt_community import MqttCommunityModule
from app.fanout.mqtt_private import MqttPrivateModule
from app.fanout.sqs import SqsModule
from app.fanout.webhook import WebhookModule
_MODULE_TYPES["mqtt_private"] = MqttPrivateModule
@@ -30,6 +31,7 @@ def _register_module_types() -> None:
_MODULE_TYPES["bot"] = BotModule
_MODULE_TYPES["webhook"] = WebhookModule
_MODULE_TYPES["apprise"] = AppriseModule
_MODULE_TYPES["sqs"] = SqsModule
def _matches_filter(filter_value: Any, key: str) -> bool:

142
app/fanout/sqs.py Normal file
View File

@@ -0,0 +1,142 @@
"""Fanout module for Amazon SQS delivery."""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from functools import partial
import boto3
from botocore.exceptions import BotoCoreError, ClientError
from app.fanout.base import FanoutModule
logger = logging.getLogger(__name__)
def _build_payload(data: dict, *, event_type: str) -> str:
"""Serialize a fanout event into a stable JSON envelope."""
return json.dumps(
{
"event_type": event_type,
"data": data,
},
separators=(",", ":"),
sort_keys=True,
)
def _is_fifo_queue(queue_url: str) -> bool:
"""Return True when the configured queue URL points at an SQS FIFO queue."""
return queue_url.rstrip("/").endswith(".fifo")
def _build_message_group_id(data: dict, *, event_type: str) -> str:
"""Choose a stable FIFO group ID from the event identity."""
if event_type == "message":
conversation_key = str(data.get("conversation_key", "")).strip()
if conversation_key:
return f"message-{conversation_key}"
return "message-default"
return "raw-packets"
def _build_message_deduplication_id(data: dict, *, event_type: str, body: str) -> str:
"""Choose a deterministic deduplication ID for FIFO queues."""
if event_type == "message":
message_id = data.get("id")
if isinstance(message_id, int):
return f"message-{message_id}"
else:
observation_id = data.get("observation_id")
if isinstance(observation_id, str) and observation_id.strip():
return f"raw-{observation_id}"
packet_id = data.get("id")
if isinstance(packet_id, int):
return f"raw-{packet_id}"
return hashlib.sha256(body.encode()).hexdigest()
class SqsModule(FanoutModule):
"""Delivers message and raw-packet events to an Amazon SQS queue."""
def __init__(self, config_id: str, config: dict, *, name: str = "") -> None:
super().__init__(config_id, config, name=name)
self._client = None
self._last_error: str | None = None
async def start(self) -> None:
kwargs: dict[str, str] = {}
region_name = str(self.config.get("region_name", "")).strip()
endpoint_url = str(self.config.get("endpoint_url", "")).strip()
access_key_id = str(self.config.get("access_key_id", "")).strip()
secret_access_key = str(self.config.get("secret_access_key", "")).strip()
session_token = str(self.config.get("session_token", "")).strip()
if region_name:
kwargs["region_name"] = region_name
if endpoint_url:
kwargs["endpoint_url"] = endpoint_url
if access_key_id and secret_access_key:
kwargs["aws_access_key_id"] = access_key_id
kwargs["aws_secret_access_key"] = secret_access_key
if session_token:
kwargs["aws_session_token"] = session_token
self._client = boto3.client("sqs", **kwargs)
self._last_error = None
async def stop(self) -> None:
self._client = None
async def on_message(self, data: dict) -> None:
await self._send(data, event_type="message")
async def on_raw(self, data: dict) -> None:
await self._send(data, event_type="raw_packet")
async def _send(self, data: dict, *, event_type: str) -> None:
if self._client is None:
return
queue_url = str(self.config.get("queue_url", "")).strip()
if not queue_url:
return
body = _build_payload(data, event_type=event_type)
request_kwargs: dict[str, object] = {
"QueueUrl": queue_url,
"MessageBody": body,
"MessageAttributes": {
"event_type": {
"DataType": "String",
"StringValue": event_type,
}
},
}
if _is_fifo_queue(queue_url):
request_kwargs["MessageGroupId"] = _build_message_group_id(data, event_type=event_type)
request_kwargs["MessageDeduplicationId"] = _build_message_deduplication_id(
data, event_type=event_type, body=body
)
try:
await asyncio.to_thread(partial(self._client.send_message, **request_kwargs))
self._last_error = None
except (ClientError, BotoCoreError) as exc:
self._last_error = str(exc)
logger.warning("SQS %s send error: %s", self.config_id, exc)
except Exception as exc:
self._last_error = str(exc)
logger.exception("Unexpected SQS send error for %s", self.config_id)
@property
def status(self) -> str:
if not str(self.config.get("queue_url", "")).strip():
return "disconnected"
if self._last_error:
return "error"
return "connected"

View File

@@ -7,6 +7,32 @@ from fastapi.staticfiles import StaticFiles
logger = logging.getLogger(__name__)
INDEX_CACHE_CONTROL = "no-store"
ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
STATIC_FILE_CACHE_CONTROL = "public, max-age=3600"
class CacheControlStaticFiles(StaticFiles):
"""StaticFiles variant that adds a fixed Cache-Control header."""
def __init__(self, *args, cache_control: str, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.cache_control = cache_control
def file_response(self, *args, **kwargs):
response = super().file_response(*args, **kwargs)
response.headers["Cache-Control"] = self.cache_control
return response
def _file_response(path: Path, *, cache_control: str) -> FileResponse:
return FileResponse(path, headers={"Cache-Control": cache_control})
def _is_index_file(path: Path, index_file: Path) -> bool:
"""Return True when the requested file is the SPA shell index.html."""
return path == index_file
def _resolve_request_origin(request: Request) -> str:
"""Resolve the external origin, honoring common reverse-proxy headers."""
@@ -57,7 +83,11 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
return False
if assets_dir.exists() and assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
app.mount(
"/assets",
CacheControlStaticFiles(directory=assets_dir, cache_control=ASSET_CACHE_CONTROL),
name="assets",
)
else:
logger.warning(
"Frontend assets directory missing at %s; /assets files will not be served",
@@ -67,7 +97,7 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
@app.get("/")
async def serve_index():
"""Serve the frontend index.html."""
return FileResponse(index_file)
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
@app.get("/site.webmanifest")
async def serve_webmanifest(request: Request):
@@ -114,9 +144,14 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
raise HTTPException(status_code=404, detail="Not found") from None
if file_path.exists() and file_path.is_file():
return FileResponse(file_path)
cache_control = (
INDEX_CACHE_CONTROL
if _is_index_file(file_path, index_file)
else STATIC_FILE_CACHE_CONTROL
)
return _file_response(file_path, cache_control=cache_control)
return FileResponse(index_file)
return _file_response(index_file, cache_control=INDEX_CACHE_CONTROL)
logger.info("Serving frontend from %s", frontend_dir)
return True
@@ -129,7 +164,5 @@ def register_frontend_missing_fallback(app: FastAPI) -> None:
async def frontend_not_built():
return JSONResponse(
status_code=404,
content={
"detail": "Frontend not built. Run: cd frontend && npm install && npm run build"
},
content={"detail": "Frontend not built. Run: cd frontend && npm ci && npm run build"},
)

View File

@@ -5,8 +5,10 @@ from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.gzip import GZipMiddleware
from fastapi.responses import JSONResponse
from app.config import settings as server_settings
from app.config import setup_logging
from app.database import db
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
@@ -30,6 +32,7 @@ from app.routers import (
statistics,
ws,
)
from app.security import add_optional_basic_auth_middleware
from app.services.radio_runtime import radio_runtime as radio_manager
setup_logging()
@@ -114,6 +117,8 @@ app = FastAPI(
lifespan=lifespan,
)
add_optional_basic_auth_middleware(app, server_settings)
app.add_middleware(GZipMiddleware, minimum_size=500)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],

View File

@@ -778,7 +778,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
Create app_settings table for persistent application preferences.
This table stores:
- max_radio_contacts: Max non-repeater contacts to keep on radio for DM ACKs
- max_radio_contacts: Configured radio contact capacity baseline for maintenance thresholds
- favorites: JSON array of favorite conversations [{type, id}, ...]
- auto_decrypt_dm_on_advert: Whether to attempt historical DM decryption on new contact
- sidebar_sort_order: 'recent' or 'alpha' for sidebar sorting

View File

@@ -225,6 +225,52 @@ class ContactDetail(BaseModel):
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
class NameOnlyContactDetail(BaseModel):
"""Channel activity summary for a sender name that is not tied to a known key."""
name: str
channel_message_count: int = 0
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
class ContactAnalyticsHourlyBucket(BaseModel):
"""A single hourly activity bucket for contact analytics."""
bucket_start: int = Field(description="Unix timestamp for the start of the hour bucket")
last_24h_count: int = 0
last_week_average: float = 0
all_time_average: float = 0
class ContactAnalyticsWeeklyBucket(BaseModel):
"""A single weekly activity bucket for contact analytics."""
bucket_start: int = Field(description="Unix timestamp for the start of the 7-day bucket")
message_count: int = 0
class ContactAnalytics(BaseModel):
"""Unified contact analytics payload for keyed and name-only lookups."""
lookup_type: Literal["contact", "name"]
name: str
contact: Contact | None = None
name_first_seen_at: int | None = None
name_history: list[ContactNameHistory] = Field(default_factory=list)
dm_message_count: int = 0
channel_message_count: int = 0
includes_direct_messages: bool = False
most_active_rooms: list[ContactActiveRoom] = Field(default_factory=list)
advert_paths: list[ContactAdvertPath] = Field(default_factory=list)
advert_frequency: float | None = Field(
default=None,
description="Advert observations per hour (includes multi-path arrivals of same advert)",
)
nearest_repeaters: list[NearestRepeater] = Field(default_factory=list)
hourly_activity: list[ContactAnalyticsHourlyBucket] = Field(default_factory=list)
weekly_activity: list[ContactAnalyticsWeeklyBucket] = Field(default_factory=list)
class Channel(BaseModel):
key: str = Field(description="Channel key (32-char hex)")
name: str
@@ -516,8 +562,8 @@ class AppSettings(BaseModel):
max_radio_contacts: int = Field(
default=200,
description=(
"Maximum contacts to keep on radio for DM ACKs "
"(favorite contacts first, then recent non-repeaters)"
"Configured radio contact capacity used for maintenance thresholds; "
"favorites reload first, then background fill targets about 80% of this value"
),
)
favorites: list[Favorite] = Field(
@@ -565,7 +611,7 @@ class FanoutConfig(BaseModel):
"""Configuration for a single fanout integration."""
id: str
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise'
type: str # 'mqtt_private' | 'mqtt_community' | 'bot' | 'webhook' | 'apprise' | 'sqs'
name: str
enabled: bool
config: dict

View File

@@ -29,7 +29,6 @@ from app.decoder import (
)
from app.keystore import get_private_key, get_public_key, has_private_key
from app.models import (
CONTACT_TYPE_REPEATER,
Contact,
ContactUpsert,
RawPacketBroadcast,
@@ -41,7 +40,10 @@ from app.repository import (
ContactRepository,
RawPacketRepository,
)
from app.services.contact_reconciliation import record_contact_name_and_reconcile
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
)
from app.services.messages import (
create_dm_message_from_decrypted as _create_dm_message_from_decrypted,
)
@@ -415,7 +417,6 @@ async def _process_advertisement(
Process an advertisement packet.
Extracts contact info and updates the database/broadcasts to clients.
For non-repeater contacts, triggers sync of recent contacts to radio for DM ACK support.
"""
# Parse packet to get path info if not already provided
if packet_info is None:
@@ -506,6 +507,10 @@ async def _process_advertisement(
)
await ContactRepository.upsert(contact_upsert)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=advert.public_key,
log=logger,
)
await record_contact_name_and_reconcile(
public_key=advert.public_key,
contact_name=advert.name,
@@ -518,6 +523,14 @@ async def _process_advertisement(
db_contact = await ContactRepository.get_by_key(advert.public_key.lower())
if db_contact:
broadcast_event("contact", db_contact.model_dump())
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{
"previous_public_key": old_key,
"contact": db_contact.model_dump(),
},
)
else:
broadcast_event(
"contact",
@@ -533,14 +546,6 @@ async def _process_advertisement(
if settings.auto_decrypt_dm_on_advert:
await start_historical_dm_decryption(None, advert.public_key.lower(), advert.name)
# If this is not a repeater, trigger recent contacts sync to radio
# This ensures we can auto-ACK DMs from recent contacts
if contact_type != CONTACT_TYPE_REPEATER:
# Import here to avoid circular import
from app.radio_sync import sync_recent_contacts_to_radio
asyncio.create_task(sync_recent_contacts_to_radio())
async def _process_direct_message(
raw_bytes: bytes,

View File

@@ -121,6 +121,7 @@ class RadioManager:
def __init__(self):
self._meshcore: MeshCore | None = None
self._connection_info: str | None = None
self._connection_desired: bool = True
self._reconnect_task: asyncio.Task | None = None
self._last_connected: bool = False
self._reconnect_lock: asyncio.Lock | None = None
@@ -246,6 +247,41 @@ class RadioManager:
def is_setup_complete(self) -> bool:
return self._setup_complete
@property
def connection_desired(self) -> bool:
return self._connection_desired
def resume_connection(self) -> None:
"""Allow connection monitor and manual reconnects to establish transport again."""
self._connection_desired = True
async def pause_connection(self) -> None:
"""Stop automatic reconnect attempts and tear down any current transport."""
self._connection_desired = False
self._last_connected = False
await self.disconnect()
async def _disable_meshcore_auto_reconnect(self, mc: MeshCore) -> None:
"""Disable library-managed reconnects so manual teardown fully releases transport."""
connection_manager = getattr(mc, "connection_manager", None)
if connection_manager is None:
return
if hasattr(connection_manager, "auto_reconnect"):
connection_manager.auto_reconnect = False
reconnect_task = getattr(connection_manager, "_reconnect_task", None)
if reconnect_task is None or not isinstance(reconnect_task, asyncio.Task | asyncio.Future):
return
reconnect_task.cancel()
try:
await reconnect_task
except asyncio.CancelledError:
pass
finally:
connection_manager._reconnect_task = None
async def connect(self) -> None:
"""Connect to the radio using the configured transport."""
if self._meshcore is not None:
@@ -324,7 +360,10 @@ class RadioManager:
"""Disconnect from the radio."""
if self._meshcore is not None:
logger.debug("Disconnecting from radio")
await self._meshcore.disconnect()
mc = self._meshcore
await self._disable_meshcore_auto_reconnect(mc)
await mc.disconnect()
await self._disable_meshcore_auto_reconnect(mc)
self._meshcore = None
self._setup_complete = False
self.path_hash_mode = 0
@@ -344,6 +383,10 @@ class RadioManager:
self._reconnect_lock = asyncio.Lock()
async with self._reconnect_lock:
if not self._connection_desired:
logger.info("Reconnect skipped because connection is paused by operator")
return False
# If we became connected while waiting for the lock (another
# reconnect succeeded ahead of us), skip the redundant attempt.
if self.is_connected:
@@ -364,6 +407,11 @@ class RadioManager:
# Try to connect (will auto-detect if no port specified)
await self.connect()
if not self._connection_desired:
logger.info("Reconnect completed after pause request; disconnecting transport")
await self.disconnect()
return False
if self.is_connected:
logger.info("Radio reconnected successfully at %s", self._connection_info)
if broadcast_on_success:

View File

@@ -4,18 +4,20 @@ Radio sync and offload management.
This module handles syncing contacts and channels from the radio to the database,
then removing them from the radio to free up space for new discoveries.
Also handles loading recent non-repeater contacts TO the radio for DM ACK support.
Also handles loading favorites plus recently active contacts TO the radio for DM ACK support.
Also handles periodic message polling as a fallback for platforms where push events
don't work reliably.
"""
import asyncio
import logging
import math
import time
from contextlib import asynccontextmanager
from meshcore import EventType, MeshCore
from app.config import settings
from app.event_handlers import cleanup_expired_acks
from app.models import Contact, ContactUpsert
from app.radio import RadioOperationBusyError
@@ -27,6 +29,7 @@ from app.repository import (
)
from app.services.contact_reconciliation import reconcile_contact_messages
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_error
logger = logging.getLogger(__name__)
@@ -49,6 +52,26 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
}
async def _reconcile_contact_messages_background(
public_key: str,
contact_name: str | None,
) -> None:
"""Run contact/message reconciliation outside the radio critical path."""
try:
await reconcile_contact_messages(
public_key=public_key,
contact_name=contact_name,
log=logger,
)
except Exception as exc:
logger.warning(
"Background contact reconciliation failed for %s: %s",
public_key[:12],
exc,
exc_info=True,
)
async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> str | None:
"""Parse a radio channel-slot payload and upsert to the database.
@@ -78,9 +101,12 @@ async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> st
# Message poll task handle
_message_poll_task: asyncio.Task | None = None
# Message poll interval in seconds (10s gives DM ACKs plenty of time to arrive)
# Message poll interval in seconds when aggressive fallback is enabled.
MESSAGE_POLL_INTERVAL = 10
# Always-on audit interval when aggressive fallback is disabled.
MESSAGE_POLL_AUDIT_INTERVAL = 3600
# Periodic advertisement task handle
_advert_task: asyncio.Task | None = None
@@ -119,9 +145,58 @@ async def pause_polling():
# Background task handle
_sync_task: asyncio.Task | None = None
# Sync interval in seconds (5 minutes)
# Periodic maintenance check interval in seconds (5 minutes)
SYNC_INTERVAL = 300
# Reload non-favorite contacts up to 80% of configured radio capacity after offload.
RADIO_CONTACT_REFILL_RATIO = 0.80
# Trigger a full offload/reload once occupancy reaches 95% of configured capacity.
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
capacity = max(1, max_contacts)
refill_target = max(1, min(capacity, int((capacity * RADIO_CONTACT_REFILL_RATIO) + 0.5)))
full_sync_trigger = max(
refill_target,
min(capacity, math.ceil(capacity * RADIO_CONTACT_FULL_SYNC_RATIO)),
)
return refill_target, full_sync_trigger
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
"""Check current radio occupancy and decide whether to offload/reload."""
app_settings = await AppSettingsRepository.get()
capacity = app_settings.max_radio_contacts
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
result = await mc.commands.get_contacts()
if result is None or result.type == EventType.ERROR:
logger.warning("Periodic sync occupancy check failed: %s", result)
return False
current_contacts = len(result.payload or {})
if current_contacts >= full_sync_trigger:
logger.info(
"Running full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
current_contacts,
capacity,
full_sync_trigger,
refill_target,
)
return True
logger.debug(
"Skipping full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
current_contacts,
capacity,
full_sync_trigger,
refill_target,
)
return False
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
"""
@@ -157,10 +232,11 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
await ContactRepository.upsert(
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
)
await reconcile_contact_messages(
public_key=public_key,
contact_name=contact_data.get("adv_name"),
log=logger,
asyncio.create_task(
_reconcile_contact_messages_background(
public_key,
contact_data.get("adv_name"),
)
)
synced += 1
@@ -263,7 +339,8 @@ async def ensure_default_channels() -> None:
Ensure default channels exist in the database.
These will be configured on the radio when needed for sending.
The Public channel is protected - it always exists with the canonical name.
This seeds the canonical Public channel row in the database if it is missing
or misnamed. It does not make the channel undeletable through the router.
"""
# Public channel - no hashtag, specific well-known key
PUBLIC_CHANNEL_KEY_HEX = "8B3387E9C5CDEA6AC9E5EDBAA115CD72"
@@ -290,10 +367,10 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
# Ensure default channels exist
await ensure_default_channels()
# Reload favorites and recent contacts back onto the radio immediately
# so favorited contacts don't stay in the on_radio=False limbo until the
# next advertisement arrives. Pass mc directly since the caller already
# holds the radio operation lock (asyncio.Lock is not reentrant).
# Reload favorites plus a working-set fill back onto the radio immediately
# so they do not stay in on_radio=False limbo after offload. Pass mc directly
# since the caller already holds the radio operation lock (asyncio.Lock is not
# reentrant).
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
return {
@@ -374,11 +451,10 @@ async def _message_poll_loop():
"""Background task that periodically polls for messages."""
while True:
try:
await asyncio.sleep(MESSAGE_POLL_INTERVAL)
# Clean up expired pending ACKs every poll cycle so they don't
# accumulate when no ACKs arrive (e.g. all recipients out of range).
cleanup_expired_acks()
aggressive_fallback = settings.enable_message_poll_fallback
await asyncio.sleep(
MESSAGE_POLL_INTERVAL if aggressive_fallback else MESSAGE_POLL_AUDIT_INTERVAL
)
if radio_manager.is_connected and not is_polling_paused():
try:
@@ -389,10 +465,24 @@ async def _message_poll_loop():
) as mc:
count = await poll_for_messages(mc)
if count > 0:
logger.warning(
"Poll loop caught %d message(s) missed by auto-fetch",
count,
)
if aggressive_fallback:
logger.warning(
"Poll loop caught %d message(s) missed by auto-fetch",
count,
)
else:
logger.error(
"Periodic radio audit caught %d message(s) that were not "
"surfaced via event subscription. See README and consider "
"setting MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true to "
"enable more frequent polling.",
count,
)
broadcast_error(
"A periodic poll task has discovered radio inconsistencies.",
"Please check the logs for recommendations (search "
"'MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK').",
)
except RadioOperationBusyError:
logger.debug("Skipping message poll: radio busy")
@@ -407,7 +497,16 @@ def start_message_polling():
global _message_poll_task
if _message_poll_task is None or _message_poll_task.done():
_message_poll_task = asyncio.create_task(_message_poll_loop())
logger.info("Started periodic message polling (interval: %ds)", MESSAGE_POLL_INTERVAL)
if settings.enable_message_poll_fallback:
logger.info(
"Started periodic message polling task (aggressive fallback, interval: %ds)",
MESSAGE_POLL_INTERVAL,
)
else:
logger.info(
"Started periodic message audit task (interval: %ds)",
MESSAGE_POLL_AUDIT_INTERVAL,
)
async def stop_message_polling():
@@ -506,7 +605,6 @@ async def _periodic_advert_loop():
break
except Exception as e:
logger.error("Error in periodic advertisement loop: %s", e, exc_info=True)
await asyncio.sleep(ADVERT_CHECK_INTERVAL)
def start_periodic_advert():
@@ -554,6 +652,7 @@ async def _periodic_sync_loop():
while True:
try:
await asyncio.sleep(SYNC_INTERVAL)
cleanup_expired_acks()
if not radio_manager.is_connected:
continue
@@ -562,8 +661,8 @@ async def _periodic_sync_loop():
"periodic_sync",
blocking=False,
) as mc:
logger.debug("Running periodic radio sync")
await sync_and_offload_all(mc)
if await should_run_full_periodic_sync(mc):
await sync_and_offload_all(mc)
await sync_radio_time(mc)
except RadioOperationBusyError:
logger.debug("Skipping periodic sync: radio busy")
@@ -604,13 +703,19 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
"""
Core logic for loading contacts onto the radio.
Favorite contacts are prioritized first, then recent non-repeater contacts
fill remaining slots up to max_radio_contacts.
Fill order is:
1. Favorite contacts
2. Most recently interacted-with non-repeaters
3. Most recently advert-heard non-repeaters without interaction history
Favorite contacts are always reloaded first, up to the configured capacity.
Additional non-favorite fill stops at the refill target (80% of capacity).
Caller must hold the radio operation lock and pass a valid MeshCore instance.
"""
app_settings = await AppSettingsRepository.get()
max_contacts = app_settings.max_radio_contacts
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
selected_contacts: list[Contact] = []
selected_keys: set[str] = set()
@@ -628,6 +733,12 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
continue
if not contact:
continue
if len(contact.public_key) < 64:
logger.debug(
"Skipping unresolved prefix-only favorite contact '%s' for radio sync",
favorite.id,
)
continue
key = contact.public_key.lower()
if key in selected_keys:
continue
@@ -637,34 +748,109 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
if len(selected_contacts) >= max_contacts:
break
if len(selected_contacts) < max_contacts:
recent_contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts)
for contact in recent_contacts:
if len(selected_contacts) < refill_target:
for contact in await ContactRepository.get_recently_contacted_non_repeaters(
limit=max_contacts
):
key = contact.public_key.lower()
if key in selected_keys:
continue
selected_keys.add(key)
selected_contacts.append(contact)
if len(selected_contacts) >= max_contacts:
if len(selected_contacts) >= refill_target:
break
if len(selected_contacts) < refill_target:
for contact in await ContactRepository.get_recently_advertised_non_repeaters(
limit=max_contacts
):
key = contact.public_key.lower()
if key in selected_keys:
continue
selected_keys.add(key)
selected_contacts.append(contact)
if len(selected_contacts) >= refill_target:
break
logger.debug(
"Selected %d contacts to sync (%d favorite contacts first, limit=%d)",
"Selected %d contacts to sync (%d favorites, refill_target=%d, capacity=%d)",
len(selected_contacts),
favorite_contacts_loaded,
refill_target,
max_contacts,
)
return await _load_contacts_to_radio(mc, selected_contacts)
async def ensure_contact_on_radio(
public_key: str,
*,
force: bool = False,
mc: MeshCore | None = None,
) -> dict:
"""Ensure one contact is loaded on the radio for ACK/routing support."""
global _last_contact_sync
now = time.time()
if not force and (now - _last_contact_sync) < CONTACT_SYNC_THROTTLE_SECONDS:
logger.debug(
"Single-contact sync throttled (last sync %ds ago)",
int(now - _last_contact_sync),
)
return {"loaded": 0, "throttled": True}
try:
contact = await ContactRepository.get_by_key_or_prefix(public_key)
except AmbiguousPublicKeyPrefixError:
logger.warning("Cannot sync favorite contact '%s': ambiguous key prefix", public_key)
return {"loaded": 0, "error": "Ambiguous contact key prefix"}
if not contact:
logger.debug("Cannot sync favorite contact %s: not found", public_key[:12])
return {"loaded": 0, "error": "Contact not found"}
if len(contact.public_key) < 64:
logger.debug("Cannot sync unresolved prefix-only contact %s to radio", public_key)
return {"loaded": 0, "error": "Full contact key not yet known"}
if mc is not None:
_last_contact_sync = now
return await _load_contacts_to_radio(mc, [contact])
if not radio_manager.is_connected or radio_manager.meshcore is None:
logger.debug("Cannot sync favorite contact to radio: not connected")
return {"loaded": 0, "error": "Radio not connected"}
try:
async with radio_manager.radio_operation(
"ensure_contact_on_radio",
blocking=False,
) as mc:
_last_contact_sync = now
assert mc is not None
return await _load_contacts_to_radio(mc, [contact])
except RadioOperationBusyError:
logger.debug("Skipping favorite contact sync: radio busy")
return {"loaded": 0, "busy": True}
except Exception as e:
logger.error("Error syncing favorite contact to radio: %s", e, exc_info=True)
return {"loaded": 0, "error": str(e)}
async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict:
"""Load the provided contacts onto the radio."""
loaded = 0
already_on_radio = 0
failed = 0
for contact in selected_contacts:
# Check if already on radio
for contact in contacts:
if len(contact.public_key) < 64:
logger.debug(
"Skipping unresolved prefix-only contact %s during radio load", contact.public_key
)
continue
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
already_on_radio += 1
# Update DB if not marked as on_radio
if not contact.on_radio:
await ContactRepository.set_on_radio(contact.public_key, True)
continue
@@ -722,8 +908,10 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
"""
Load contacts to the radio for DM ACK support.
Favorite contacts are prioritized first, then recent non-repeater contacts
fill remaining slots up to max_radio_contacts.
Fill order is favorites, then recently contacted non-repeaters,
then recently advert-heard non-repeaters. Favorites are always reloaded
up to the configured capacity; additional non-favorite fill stops at the
80% refill target.
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
Args:

View File

@@ -168,6 +168,9 @@ class ContactRepository:
the prefix (to avoid silently selecting the wrong contact).
"""
normalized_prefix = prefix.lower()
exact = await ContactRepository.get_by_key(normalized_prefix)
if exact:
return exact
cursor = await db.conn.execute(
"SELECT * FROM contacts WHERE public_key LIKE ? ORDER BY public_key LIMIT 2",
(f"{normalized_prefix}%",),
@@ -253,17 +256,28 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def get_recent_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get the most recently active non-repeater contacts.
Orders by most recent activity (last_contacted or last_advert),
excluding repeaters (type=2).
"""
async def get_recently_contacted_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get recently interacted-with non-repeater contacts."""
cursor = await db.conn.execute(
"""
SELECT * FROM contacts
WHERE type != 2
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
WHERE type != 2 AND last_contacted IS NOT NULL AND length(public_key) = 64
ORDER BY last_contacted DESC
LIMIT ?
""",
(limit,),
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get recently advert-heard non-repeater contacts."""
cursor = await db.conn.execute(
"""
SELECT * FROM contacts
WHERE type != 2 AND last_advert IS NOT NULL AND length(public_key) = 64
ORDER BY last_advert DESC
LIMIT ?
""",
(limit,),
@@ -395,6 +409,103 @@ class ContactRepository:
await db.conn.commit()
return cursor.rowcount > 0
@staticmethod
async def promote_prefix_placeholders(full_key: str) -> list[str]:
"""Promote prefix-only placeholder contacts to a resolved full key.
Returns the placeholder public keys that were merged into the full key.
"""
normalized_full_key = full_key.lower()
cursor = await db.conn.execute(
"""
SELECT public_key, last_seen, last_contacted, first_seen, last_read_at
FROM contacts
WHERE length(public_key) < 64
AND ? LIKE public_key || '%'
ORDER BY length(public_key) DESC, public_key
""",
(normalized_full_key,),
)
rows = list(await cursor.fetchall())
if not rows:
return []
promoted_keys: list[str] = []
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
for row in rows:
old_key = row["public_key"]
if old_key == normalized_full_key:
continue
match_cursor = await db.conn.execute(
"""
SELECT COUNT(*) AS match_count
FROM contacts
WHERE length(public_key) = 64
AND public_key LIKE ? || '%'
""",
(old_key,),
)
match_row = await match_cursor.fetchone()
if (match_row["match_count"] if match_row is not None else 0) != 1:
continue
if full_exists:
await db.conn.execute(
"""
UPDATE contacts
SET last_seen = CASE
WHEN contacts.last_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_seen
WHEN ? > contacts.last_seen THEN ?
ELSE contacts.last_seen
END,
last_contacted = CASE
WHEN contacts.last_contacted IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ?
ELSE contacts.last_contacted
END,
first_seen = CASE
WHEN contacts.first_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.first_seen
WHEN ? < contacts.first_seen THEN ?
ELSE contacts.first_seen
END,
last_read_at = COALESCE(contacts.last_read_at, ?)
WHERE public_key = ?
""",
(
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["last_read_at"],
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
else:
await db.conn.execute(
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
(normalized_full_key, old_key),
)
full_exists = True
promoted_keys.append(old_key)
await db.conn.commit()
return promoted_keys
@staticmethod
async def mark_all_read(timestamp: int) -> None:
"""Mark all contacts as read at the given timestamp."""

View File

@@ -1,12 +1,43 @@
import json
import re
import time
from dataclasses import dataclass
from typing import Any
from app.database import db
from app.models import Message, MessagePath
from app.models import (
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
Message,
MessagePath,
)
class MessageRepository:
@dataclass
class _SearchQuery:
free_text: str
user_terms: list[str]
channel_terms: list[str]
_SEARCH_OPERATOR_RE = re.compile(
r'(?<!\S)(user|channel):(?:"((?:[^"\\]|\\.)*)"|(\S+))',
re.IGNORECASE,
)
@staticmethod
def _contact_activity_filter(public_key: str) -> tuple[str, list[Any]]:
lower_key = public_key.lower()
return (
"((type = 'PRIV' AND LOWER(conversation_key) = ?)"
" OR (type = 'CHAN' AND LOWER(sender_key) = ?))",
[lower_key, lower_key],
)
@staticmethod
def _name_activity_filter(sender_name: str) -> tuple[str, list[Any]]:
return "type = 'CHAN' AND sender_name = ?", [sender_name]
@staticmethod
def _parse_paths(paths_json: str | None) -> list[MessagePath] | None:
"""Parse paths JSON string to list of MessagePath objects."""
@@ -131,7 +162,8 @@ class MessageRepository:
AND ? LIKE conversation_key || '%'
AND (
SELECT COUNT(*) FROM contacts
WHERE public_key LIKE messages.conversation_key || '%'
WHERE length(public_key) = 64
AND public_key LIKE messages.conversation_key || '%'
) = 1""",
(lower_key, lower_key),
)
@@ -148,8 +180,16 @@ class MessageRepository:
"""
cursor = await db.conn.execute(
"""UPDATE messages SET sender_key = ?
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL""",
(public_key.lower(), name),
WHERE type = 'CHAN' AND sender_name = ? AND sender_key IS NULL
AND (
SELECT COUNT(*) FROM contacts
WHERE name = ?
) = 1
AND EXISTS (
SELECT 1 FROM contacts
WHERE public_key = ? AND name = ?
)""",
(public_key.lower(), name, name, public_key.lower(), name),
)
await db.conn.commit()
return cursor.rowcount
@@ -167,6 +207,92 @@ class MessageRepository:
else:
return "AND conversation_key LIKE ?", f"{conversation_key}%"
@staticmethod
def _unescape_search_quoted_value(value: str) -> str:
return value.replace('\\"', '"').replace("\\\\", "\\")
@staticmethod
def _parse_search_query(q: str) -> _SearchQuery:
user_terms: list[str] = []
channel_terms: list[str] = []
fragments: list[str] = []
last_end = 0
for match in MessageRepository._SEARCH_OPERATOR_RE.finditer(q):
fragments.append(q[last_end : match.start()])
raw_value = match.group(2) if match.group(2) is not None else match.group(3) or ""
value = MessageRepository._unescape_search_quoted_value(raw_value)
if match.group(1).lower() == "user":
user_terms.append(value)
else:
channel_terms.append(value)
last_end = match.end()
if not user_terms and not channel_terms:
return MessageRepository._SearchQuery(free_text=q, user_terms=[], channel_terms=[])
fragments.append(q[last_end:])
free_text = " ".join(fragment.strip() for fragment in fragments if fragment.strip())
return MessageRepository._SearchQuery(
free_text=free_text,
user_terms=user_terms,
channel_terms=channel_terms,
)
@staticmethod
def _escape_like(value: str) -> str:
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
@staticmethod
def _looks_like_hex_prefix(value: str) -> bool:
return bool(value) and all(ch in "0123456789abcdefABCDEF" for ch in value)
@staticmethod
def _build_channel_scope_clause(value: str) -> tuple[str, list[Any]]:
params: list[Any] = [value]
clause = "(messages.type = 'CHAN' AND (channels.name = ? COLLATE NOCASE"
if MessageRepository._looks_like_hex_prefix(value):
if len(value) == 32:
clause += " OR UPPER(messages.conversation_key) = ?"
params.append(value.upper())
else:
clause += " OR UPPER(messages.conversation_key) LIKE ? ESCAPE '\\'"
params.append(f"{MessageRepository._escape_like(value.upper())}%")
clause += "))"
return clause, params
@staticmethod
def _build_user_scope_clause(value: str) -> tuple[str, list[Any]]:
params: list[Any] = [value, value]
clause = (
"((messages.type = 'PRIV' AND contacts.name = ? COLLATE NOCASE)"
" OR (messages.type = 'CHAN' AND sender_name = ? COLLATE NOCASE)"
)
if MessageRepository._looks_like_hex_prefix(value):
lower_value = value.lower()
priv_key_clause: str
chan_key_clause: str
if len(value) == 64:
priv_key_clause = "LOWER(messages.conversation_key) = ?"
chan_key_clause = "LOWER(sender_key) = ?"
params.extend([lower_value, lower_value])
else:
escaped_prefix = f"{MessageRepository._escape_like(lower_value)}%"
priv_key_clause = "LOWER(messages.conversation_key) LIKE ? ESCAPE '\\'"
chan_key_clause = "LOWER(sender_key) LIKE ? ESCAPE '\\'"
params.extend([escaped_prefix, escaped_prefix])
clause += (
f" OR (messages.type = 'PRIV' AND {priv_key_clause})"
f" OR (messages.type = 'CHAN' AND sender_key IS NOT NULL AND {chan_key_clause})"
)
clause += ")"
return clause, params
@staticmethod
def _row_to_message(row: Any) -> Message:
"""Convert a database row to a Message model."""
@@ -200,15 +326,24 @@ class MessageRepository:
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
) -> list[Message]:
query = "SELECT * FROM messages WHERE 1=1"
search_query = MessageRepository._parse_search_query(q) if q else None
query = (
"SELECT messages.* FROM messages "
"LEFT JOIN contacts ON messages.type = 'PRIV' "
"AND LOWER(messages.conversation_key) = LOWER(contacts.public_key) "
"LEFT JOIN channels ON messages.type = 'CHAN' "
"AND UPPER(messages.conversation_key) = UPPER(channels.key) "
"WHERE 1=1"
)
params: list[Any] = []
if blocked_keys:
placeholders = ",".join("?" for _ in blocked_keys)
query += (
f" AND NOT (outgoing=0 AND ("
f"(type='PRIV' AND LOWER(conversation_key) IN ({placeholders}))"
f" OR (type='CHAN' AND sender_key IS NOT NULL AND LOWER(sender_key) IN ({placeholders}))"
f" AND NOT (messages.outgoing=0 AND ("
f"(messages.type='PRIV' AND LOWER(messages.conversation_key) IN ({placeholders}))"
f" OR (messages.type='CHAN' AND messages.sender_key IS NOT NULL"
f" AND LOWER(messages.sender_key) IN ({placeholders}))"
f"))"
)
params.extend(blocked_keys)
@@ -217,36 +352,57 @@ class MessageRepository:
if blocked_names:
placeholders = ",".join("?" for _ in blocked_names)
query += (
f" AND NOT (outgoing=0 AND sender_name IS NOT NULL"
f" AND sender_name IN ({placeholders}))"
f" AND NOT (messages.outgoing=0 AND messages.sender_name IS NOT NULL"
f" AND messages.sender_name IN ({placeholders}))"
)
params.extend(blocked_names)
if msg_type:
query += " AND type = ?"
query += " AND messages.type = ?"
params.append(msg_type)
if conversation_key:
clause, norm_key = MessageRepository._normalize_conversation_key(conversation_key)
query += f" {clause}"
query += f" {clause.replace('conversation_key', 'messages.conversation_key')}"
params.append(norm_key)
if q:
escaped_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
query += " AND text LIKE ? ESCAPE '\\' COLLATE NOCASE"
if search_query and search_query.user_terms:
scope_clauses: list[str] = []
for term in search_query.user_terms:
clause, clause_params = MessageRepository._build_user_scope_clause(term)
scope_clauses.append(clause)
params.extend(clause_params)
query += f" AND ({' OR '.join(scope_clauses)})"
if search_query and search_query.channel_terms:
scope_clauses = []
for term in search_query.channel_terms:
clause, clause_params = MessageRepository._build_channel_scope_clause(term)
scope_clauses.append(clause)
params.extend(clause_params)
query += f" AND ({' OR '.join(scope_clauses)})"
if search_query and search_query.free_text:
escaped_q = MessageRepository._escape_like(search_query.free_text)
query += " AND messages.text LIKE ? ESCAPE '\\' COLLATE NOCASE"
params.append(f"%{escaped_q}%")
# Forward cursor (after/after_id) — mutually exclusive with before/before_id
if after is not None and after_id is not None:
query += " AND (received_at > ? OR (received_at = ? AND id > ?))"
query += (
" AND (messages.received_at > ? OR (messages.received_at = ? AND messages.id > ?))"
)
params.extend([after, after, after_id])
query += " ORDER BY received_at ASC, id ASC LIMIT ?"
query += " ORDER BY messages.received_at ASC, messages.id ASC LIMIT ?"
params.append(limit)
else:
if before is not None and before_id is not None:
query += " AND (received_at < ? OR (received_at = ? AND id < ?))"
query += (
" AND (messages.received_at < ?"
" OR (messages.received_at = ? AND messages.id < ?))"
)
params.extend([before, before, before_id])
query += " ORDER BY received_at DESC, id DESC LIMIT ?"
query += " ORDER BY messages.received_at DESC, messages.id DESC LIMIT ?"
params.append(limit)
if before is None or before_id is None:
query += " OFFSET ?"
@@ -505,13 +661,61 @@ class MessageRepository:
if mention_token and row["has_mention"]:
mention_flags[state_key] = True
# Last message times for all conversations (including read ones)
# Last message times for all conversations (including read ones),
# excluding blocked incoming traffic so refresh matches live WS behavior.
last_time_filters: list[str] = []
last_time_params: list[Any] = []
if blocked_keys:
placeholders = ",".join("?" for _ in blocked_keys)
last_time_filters.append(
f"""
NOT (
type = 'PRIV'
AND outgoing = 0
AND LOWER(conversation_key) IN ({placeholders})
)
"""
)
last_time_params.extend(blocked_keys)
last_time_filters.append(
f"""
NOT (
type = 'CHAN'
AND outgoing = 0
AND sender_key IS NOT NULL
AND LOWER(sender_key) IN ({placeholders})
)
"""
)
last_time_params.extend(blocked_keys)
if blocked_names:
placeholders = ",".join("?" for _ in blocked_names)
last_time_filters.append(
f"""
NOT (
type = 'CHAN'
AND outgoing = 0
AND sender_name IS NOT NULL
AND sender_name IN ({placeholders})
)
"""
)
last_time_params.extend(blocked_names)
last_time_where_sql = (
f"WHERE {' AND '.join(last_time_filters)}" if last_time_filters else ""
)
cursor = await db.conn.execute(
"""
f"""
SELECT type, conversation_key, MAX(received_at) as last_message_time
FROM messages
{last_time_where_sql}
GROUP BY type, conversation_key
"""
""",
last_time_params,
)
rows = await cursor.fetchall()
for row in rows:
@@ -545,6 +749,26 @@ class MessageRepository:
row = await cursor.fetchone()
return row["cnt"] if row else 0
@staticmethod
async def count_channel_messages_by_sender_name(sender_name: str) -> int:
"""Count channel messages attributed to a display name."""
cursor = await db.conn.execute(
"SELECT COUNT(*) as cnt FROM messages WHERE type = 'CHAN' AND sender_name = ?",
(sender_name,),
)
row = await cursor.fetchone()
return row["cnt"] if row else 0
@staticmethod
async def get_first_channel_message_by_sender_name(sender_name: str) -> int | None:
"""Get the earliest stored channel message timestamp for a display name."""
cursor = await db.conn.execute(
"SELECT MIN(received_at) AS first_seen FROM messages WHERE type = 'CHAN' AND sender_name = ?",
(sender_name,),
)
row = await cursor.fetchone()
return row["first_seen"] if row and row["first_seen"] is not None else None
@staticmethod
async def get_channel_stats(conversation_key: str) -> dict:
"""Get channel message statistics: time-windowed counts, first message, unique senders, top senders.
@@ -632,3 +856,135 @@ class MessageRepository:
)
rows = await cursor.fetchall()
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
@staticmethod
async def get_most_active_rooms_by_sender_name(
sender_name: str, limit: int = 5
) -> list[tuple[str, str, int]]:
"""Get channels where a display name has sent the most messages."""
cursor = await db.conn.execute(
"""
SELECT m.conversation_key, COALESCE(c.name, m.conversation_key) AS channel_name,
COUNT(*) AS cnt
FROM messages m
LEFT JOIN channels c ON m.conversation_key = c.key
WHERE m.type = 'CHAN' AND m.sender_name = ?
GROUP BY m.conversation_key
ORDER BY cnt DESC
LIMIT ?
""",
(sender_name, limit),
)
rows = await cursor.fetchall()
return [(row["conversation_key"], row["channel_name"], row["cnt"]) for row in rows]
@staticmethod
async def _get_activity_hour_buckets(where_sql: str, params: list[Any]) -> dict[int, int]:
cursor = await db.conn.execute(
f"""
SELECT received_at / 3600 AS hour_bucket, COUNT(*) AS cnt
FROM messages
WHERE {where_sql}
GROUP BY hour_bucket
""",
params,
)
rows = await cursor.fetchall()
return {int(row["hour_bucket"]): row["cnt"] for row in rows}
@staticmethod
def _build_hourly_activity(
hour_counts: dict[int, int], now: int
) -> list[ContactAnalyticsHourlyBucket]:
current_hour = now // 3600
if hour_counts:
min_hour = min(hour_counts)
else:
min_hour = current_hour
buckets: list[ContactAnalyticsHourlyBucket] = []
for hour_bucket in range(current_hour - 23, current_hour + 1):
last_24h_count = hour_counts.get(hour_bucket, 0)
week_total = 0
week_samples = 0
all_time_total = 0
all_time_samples = 0
compare_hour = hour_bucket
while compare_hour >= min_hour:
count = hour_counts.get(compare_hour, 0)
all_time_total += count
all_time_samples += 1
if week_samples < 7:
week_total += count
week_samples += 1
compare_hour -= 24
buckets.append(
ContactAnalyticsHourlyBucket(
bucket_start=hour_bucket * 3600,
last_24h_count=last_24h_count,
last_week_average=round(week_total / week_samples, 2) if week_samples else 0,
all_time_average=round(all_time_total / all_time_samples, 2)
if all_time_samples
else 0,
)
)
return buckets
@staticmethod
async def _get_weekly_activity(
where_sql: str,
params: list[Any],
now: int,
weeks: int = 26,
) -> list[ContactAnalyticsWeeklyBucket]:
bucket_seconds = 7 * 24 * 3600
current_day_start = (now // 86400) * 86400
start = current_day_start - (weeks - 1) * bucket_seconds
cursor = await db.conn.execute(
f"""
SELECT (received_at - ?) / ? AS bucket_idx, COUNT(*) AS cnt
FROM messages
WHERE {where_sql} AND received_at >= ?
GROUP BY bucket_idx
""",
[start, bucket_seconds, *params, start],
)
rows = await cursor.fetchall()
counts = {int(row["bucket_idx"]): row["cnt"] for row in rows}
return [
ContactAnalyticsWeeklyBucket(
bucket_start=start + bucket_idx * bucket_seconds,
message_count=counts.get(bucket_idx, 0),
)
for bucket_idx in range(weeks)
]
@staticmethod
async def get_contact_activity_series(
public_key: str,
now: int | None = None,
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
"""Get combined DM + channel activity series for a keyed contact."""
ts = now if now is not None else int(time.time())
where_sql, params = MessageRepository._contact_activity_filter(public_key)
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
return hourly, weekly
@staticmethod
async def get_sender_name_activity_series(
sender_name: str,
now: int | None = None,
) -> tuple[list[ContactAnalyticsHourlyBucket], list[ContactAnalyticsWeeklyBucket]]:
"""Get channel-only activity series for a sender name."""
ts = now if now is not None else int(time.time())
where_sql, params = MessageRepository._name_activity_filter(sender_name)
hour_counts = await MessageRepository._get_activity_hour_buckets(where_sql, params)
hourly = MessageRepository._build_hourly_activity(hour_counts, ts)
weekly = await MessageRepository._get_weekly_activity(where_sql, params, ts)
return hourly, weekly

View File

@@ -17,6 +17,10 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
def _broadcast_channel_update(channel: Channel) -> None:
broadcast_event("channel", channel.model_dump())
class CreateChannelRequest(BaseModel):
name: str = Field(min_length=1, max_length=32)
key: str | None = Field(
@@ -98,13 +102,12 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
on_radio=False,
)
return Channel(
key=key_hex,
name=request.name,
is_hashtag=is_hashtag,
on_radio=False,
flood_scope_override=None,
)
stored = await ChannelRepository.get_by_key(key_hex)
if stored is None:
raise HTTPException(status_code=500, detail="Channel was created but could not be reloaded")
_broadcast_channel_update(stored)
return stored
@router.post("/sync")
@@ -123,6 +126,9 @@ async def sync_channels_from_radio(max_channels: int = Query(default=40, ge=1, l
key_hex = await upsert_channel_from_radio_slot(result.payload, on_radio=True)
if key_hex is not None:
count += 1
stored = await ChannelRepository.get_by_key(key_hex)
if stored is not None:
_broadcast_channel_update(stored)
logger.debug(
"Synced channel %s: %s", key_hex, result.payload.get("channel_name")
)

View File

@@ -10,10 +10,12 @@ from app.models import (
ContactActiveRoom,
ContactAdvertPath,
ContactAdvertPathSummary,
ContactAnalytics,
ContactDetail,
ContactRoutingOverrideRequest,
ContactUpsert,
CreateContactRequest,
NameOnlyContactDetail,
NearestRepeater,
TraceResponse,
)
@@ -26,7 +28,10 @@ from app.repository import (
ContactRepository,
MessageRepository,
)
from app.services.contact_reconciliation import reconcile_contact_messages
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.radio_runtime import radio_runtime as radio_manager
logger = logging.getLogger(__name__)
@@ -91,6 +96,115 @@ async def _broadcast_contact_update(contact: Contact) -> None:
broadcast_event("contact", contact.model_dump())
async def _broadcast_contact_resolution(previous_public_keys: list[str], contact: Contact) -> None:
from app.websocket import broadcast_event
for old_key in previous_public_keys:
broadcast_event(
"contact_resolved",
{
"previous_public_key": old_key,
"contact": contact.model_dump(),
},
)
async def _build_keyed_contact_analytics(contact: Contact) -> ContactAnalytics:
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
hourly_activity, weekly_activity = await MessageRepository.get_contact_activity_series(
contact.public_key
)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
for key, name, count in active_rooms_raw
]
advert_frequency: float | None = None
if advert_paths:
total_observations = sum(p.heard_count for p in advert_paths)
earliest = min(p.first_seen for p in advert_paths)
latest = max(p.last_seen for p in advert_paths)
span_hours = (latest - earliest) / 3600.0
if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2)
first_hop_stats: dict[str, dict] = {}
for p in advert_paths:
prefix = p.next_hop
if prefix:
if prefix not in first_hop_stats:
first_hop_stats[prefix] = {
"heard_count": 0,
"path_len": p.path_len,
"last_seen": p.last_seen,
}
first_hop_stats[prefix]["heard_count"] += p.heard_count
first_hop_stats[prefix]["last_seen"] = max(
first_hop_stats[prefix]["last_seen"], p.last_seen
)
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
nearest_repeaters: list[NearestRepeater] = []
for prefix, stats in first_hop_stats.items():
resolved = resolved_contacts.get(prefix)
nearest_repeaters.append(
NearestRepeater(
public_key=resolved.public_key if resolved else prefix,
name=resolved.name if resolved else None,
path_len=stats["path_len"],
last_seen=stats["last_seen"],
heard_count=stats["heard_count"],
)
)
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
return ContactAnalytics(
lookup_type="contact",
name=contact.name or contact.public_key[:12],
contact=contact,
name_history=name_history,
dm_message_count=dm_count,
channel_message_count=chan_count,
includes_direct_messages=True,
most_active_rooms=most_active_rooms,
advert_paths=advert_paths,
advert_frequency=advert_frequency,
nearest_repeaters=nearest_repeaters,
hourly_activity=hourly_activity,
weekly_activity=weekly_activity,
)
async def _build_name_only_contact_analytics(name: str) -> ContactAnalytics:
chan_count = await MessageRepository.count_channel_messages_by_sender_name(name)
name_first_seen_at = await MessageRepository.get_first_channel_message_by_sender_name(name)
active_rooms_raw = await MessageRepository.get_most_active_rooms_by_sender_name(name)
hourly_activity, weekly_activity = await MessageRepository.get_sender_name_activity_series(name)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=room_name, message_count=count)
for key, room_name, count in active_rooms_raw
]
return ContactAnalytics(
lookup_type="name",
name=name,
name_first_seen_at=name_first_seen_at,
channel_message_count=chan_count,
includes_direct_messages=False,
most_active_rooms=most_active_rooms,
hourly_activity=hourly_activity,
weekly_activity=weekly_activity,
)
@router.get("", response_model=list[Contact])
async def list_contacts(
limit: int = Query(default=100, ge=1, le=1000),
@@ -114,6 +228,26 @@ async def list_repeater_advert_paths(
)
@router.get("/analytics", response_model=ContactAnalytics)
async def get_contact_analytics(
public_key: str | None = Query(default=None),
name: str | None = Query(default=None, min_length=1, max_length=200),
) -> ContactAnalytics:
"""Get unified contact analytics for either a keyed contact or a sender name."""
if bool(public_key) == bool(name):
raise HTTPException(status_code=400, detail="Specify exactly one of public_key or name")
if public_key:
contact = await _resolve_contact_or_404(public_key)
return await _build_keyed_contact_analytics(contact)
assert name is not None
normalized_name = name.strip()
if not normalized_name:
raise HTTPException(status_code=400, detail="name is required")
return await _build_name_only_contact_analytics(normalized_name)
@router.post("", response_model=Contact)
async def create_contact(
request: CreateContactRequest, background_tasks: BackgroundTasks
@@ -139,12 +273,23 @@ async def create_contact(
if refreshed is not None:
existing = refreshed
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=request.public_key,
log=logger,
)
if promoted_keys:
refreshed = await ContactRepository.get_by_key(request.public_key)
if refreshed is not None:
existing = refreshed
await _broadcast_contact_resolution(promoted_keys, existing)
# Trigger historical decryption if requested (even for existing contacts)
if request.try_historical:
await start_historical_dm_decryption(
background_tasks, request.public_key, request.name or existing.name
)
await _broadcast_contact_update(existing)
return existing
# Create new contact
@@ -157,6 +302,10 @@ async def create_contact(
)
await ContactRepository.upsert(contact_upsert)
logger.info("Created contact %s", lower_key[:12])
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=lower_key,
log=logger,
)
await reconcile_contact_messages(
public_key=lower_key,
@@ -168,7 +317,12 @@ async def create_contact(
if request.try_historical:
await start_historical_dm_decryption(background_tasks, lower_key, request.name)
return Contact(**contact_upsert.model_dump())
stored = await ContactRepository.get_by_key(lower_key)
if stored is None:
raise HTTPException(status_code=500, detail="Contact was created but could not be reloaded")
await _broadcast_contact_update(stored)
await _broadcast_contact_resolution(promoted_keys, stored)
return stored
@router.get("/{public_key}/detail", response_model=ContactDetail)
@@ -179,73 +333,33 @@ async def get_contact_detail(public_key: str) -> ContactDetail:
advertisement paths, advert frequency, and nearest repeaters.
"""
contact = await _resolve_contact_or_404(public_key)
name_history = await ContactNameHistoryRepository.get_history(contact.public_key)
dm_count = await MessageRepository.count_dm_messages(contact.public_key)
chan_count = await MessageRepository.count_channel_messages_by_sender(contact.public_key)
active_rooms_raw = await MessageRepository.get_most_active_rooms(contact.public_key)
advert_paths = await ContactAdvertPathRepository.get_recent_for_contact(contact.public_key)
most_active_rooms = [
ContactActiveRoom(channel_key=key, channel_name=name, message_count=count)
for key, name, count in active_rooms_raw
]
# Compute advert observation rate (observations/hour) from path data.
# Note: a single advertisement can arrive via multiple paths, so this counts
# RF observations, not unique advertisement broadcasts.
advert_frequency: float | None = None
if advert_paths:
total_observations = sum(p.heard_count for p in advert_paths)
earliest = min(p.first_seen for p in advert_paths)
latest = max(p.last_seen for p in advert_paths)
span_hours = (latest - earliest) / 3600.0
if span_hours > 0:
advert_frequency = round(total_observations / span_hours, 2)
# Compute nearest repeaters from first-hop prefixes in advert paths
first_hop_stats: dict[str, dict] = {} # prefix -> {heard_count, path_len, last_seen}
for p in advert_paths:
prefix = p.next_hop
if prefix:
if prefix not in first_hop_stats:
first_hop_stats[prefix] = {
"heard_count": 0,
"path_len": p.path_len,
"last_seen": p.last_seen,
}
first_hop_stats[prefix]["heard_count"] += p.heard_count
first_hop_stats[prefix]["last_seen"] = max(
first_hop_stats[prefix]["last_seen"], p.last_seen
)
# Resolve all first-hop prefixes to contacts in a single query
resolved_contacts = await ContactRepository.resolve_prefixes(list(first_hop_stats.keys()))
nearest_repeaters: list[NearestRepeater] = []
for prefix, stats in first_hop_stats.items():
resolved = resolved_contacts.get(prefix)
nearest_repeaters.append(
NearestRepeater(
public_key=resolved.public_key if resolved else prefix,
name=resolved.name if resolved else None,
path_len=stats["path_len"],
last_seen=stats["last_seen"],
heard_count=stats["heard_count"],
)
)
nearest_repeaters.sort(key=lambda r: r.heard_count, reverse=True)
analytics = await _build_keyed_contact_analytics(contact)
assert analytics.contact is not None
return ContactDetail(
contact=contact,
name_history=name_history,
dm_message_count=dm_count,
channel_message_count=chan_count,
most_active_rooms=most_active_rooms,
advert_paths=advert_paths,
advert_frequency=advert_frequency,
nearest_repeaters=nearest_repeaters,
contact=analytics.contact,
name_history=analytics.name_history,
dm_message_count=analytics.dm_message_count,
channel_message_count=analytics.channel_message_count,
most_active_rooms=analytics.most_active_rooms,
advert_paths=analytics.advert_paths,
advert_frequency=analytics.advert_frequency,
nearest_repeaters=analytics.nearest_repeaters,
)
@router.get("/name-detail", response_model=NameOnlyContactDetail)
async def get_name_only_contact_detail(
name: str = Query(min_length=1, max_length=200),
) -> NameOnlyContactDetail:
"""Get channel activity summary for a sender name without a resolved key."""
normalized_name = name.strip()
if not normalized_name:
raise HTTPException(status_code=400, detail="name is required")
analytics = await _build_name_only_contact_analytics(normalized_name)
return NameOnlyContactDetail(
name=analytics.name,
channel_message_count=analytics.channel_message_count,
most_active_rooms=analytics.most_active_rooms,
)
@@ -287,12 +401,20 @@ async def sync_contacts_from_radio() -> dict:
await ContactRepository.upsert(
ContactUpsert.from_radio_dict(lower_key, contact_data, on_radio=True)
)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=lower_key,
log=logger,
)
synced_keys.append(lower_key)
await reconcile_contact_messages(
public_key=lower_key,
contact_name=contact_data.get("adv_name"),
log=logger,
)
stored = await ContactRepository.get_by_key(lower_key)
if stored is not None:
await _broadcast_contact_update(stored)
await _broadcast_contact_resolution(promoted_keys, stored)
count += 1
# Clear on_radio for contacts not found on the radio
@@ -341,6 +463,7 @@ async def add_contact_to_radio(public_key: str) -> dict:
# Check if already on radio
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
await ContactRepository.set_on_radio(contact.public_key, True)
return {"status": "ok", "message": "Contact already on radio"}
logger.info("Adding contact %s to radio", contact.public_key[:12])

View File

@@ -1,5 +1,7 @@
"""REST API for fanout config CRUD."""
import ast
import inspect
import logging
import re
import string
@@ -8,12 +10,13 @@ from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.config import settings as server_settings
from app.fanout.bot_exec import _analyze_bot_signature
from app.repository.fanout import FanoutConfigRepository
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/fanout", tags=["fanout"])
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise"}
_VALID_TYPES = {"mqtt_private", "mqtt_community", "bot", "webhook", "apprise", "sqs"}
_IATA_RE = re.compile(r"^[A-Z]{3}$")
_DEFAULT_COMMUNITY_MQTT_TOPIC_TEMPLATE = "meshcore/{IATA}/{PUBLIC_KEY}/packets"
@@ -144,18 +147,78 @@ def _validate_mqtt_community_config(config: dict) -> None:
def _validate_bot_config(config: dict) -> None:
"""Validate bot config blob (syntax-check the code)."""
"""Validate bot config blob (syntax-check the code and supported signature)."""
code = config.get("code", "")
if not code or not code.strip():
raise HTTPException(status_code=400, detail="Bot code cannot be empty")
try:
compile(code, "<bot_code>", "exec")
tree = ast.parse(code, filename="<bot_code>", mode="exec")
except SyntaxError as e:
raise HTTPException(
status_code=400,
detail=f"Bot code has syntax error at line {e.lineno}: {e.msg}",
) from None
bot_def = next(
(
node
for node in tree.body
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "bot"
),
None,
)
if bot_def is None:
raise HTTPException(
status_code=400,
detail=(
"Bot code must define a callable bot() function. "
"Use the default bot template as a reference."
),
)
try:
parameters: list[inspect.Parameter] = []
positional_args = [
*((arg, inspect.Parameter.POSITIONAL_ONLY) for arg in bot_def.args.posonlyargs),
*((arg, inspect.Parameter.POSITIONAL_OR_KEYWORD) for arg in bot_def.args.args),
]
positional_defaults_start = len(positional_args) - len(bot_def.args.defaults)
sentinel_default = object()
for index, (arg, kind) in enumerate(positional_args):
has_default = index >= positional_defaults_start
parameters.append(
inspect.Parameter(
arg.arg,
kind=kind,
default=sentinel_default if has_default else inspect.Parameter.empty,
)
)
if bot_def.args.vararg is not None:
parameters.append(
inspect.Parameter(bot_def.args.vararg.arg, kind=inspect.Parameter.VAR_POSITIONAL)
)
for kwonly_arg, kw_default in zip(
bot_def.args.kwonlyargs, bot_def.args.kw_defaults, strict=True
):
parameters.append(
inspect.Parameter(
kwonly_arg.arg,
kind=inspect.Parameter.KEYWORD_ONLY,
default=(
sentinel_default if kw_default is not None else inspect.Parameter.empty
),
)
)
if bot_def.args.kwarg is not None:
parameters.append(
inspect.Parameter(bot_def.args.kwarg.arg, kind=inspect.Parameter.VAR_KEYWORD)
)
_analyze_bot_signature(inspect.Signature(parameters))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from None
def _validate_apprise_config(config: dict) -> None:
"""Validate apprise config blob."""
@@ -179,6 +242,39 @@ def _validate_webhook_config(config: dict) -> None:
raise HTTPException(status_code=400, detail="headers must be a JSON object")
def _validate_sqs_config(config: dict) -> None:
"""Validate sqs config blob."""
queue_url = str(config.get("queue_url", "")).strip()
if not queue_url:
raise HTTPException(status_code=400, detail="queue_url is required for sqs")
if not queue_url.startswith(("https://", "http://")):
raise HTTPException(status_code=400, detail="queue_url must start with http:// or https://")
endpoint_url = str(config.get("endpoint_url", "")).strip()
if endpoint_url and not endpoint_url.startswith(("https://", "http://")):
raise HTTPException(
status_code=400,
detail="endpoint_url must start with http:// or https://",
)
access_key_id = str(config.get("access_key_id", "")).strip()
secret_access_key = str(config.get("secret_access_key", "")).strip()
session_token = str(config.get("session_token", "")).strip()
has_static_keypair = bool(access_key_id) and bool(secret_access_key)
has_partial_keypair = bool(access_key_id) != bool(secret_access_key)
if has_partial_keypair:
raise HTTPException(
status_code=400,
detail="access_key_id and secret_access_key must be set together for sqs",
)
if session_token and not has_static_keypair:
raise HTTPException(
status_code=400,
detail="session_token requires access_key_id and secret_access_key for sqs",
)
def _enforce_scope(config_type: str, scope: dict) -> dict:
"""Enforce type-specific scope constraints. Returns normalized scope."""
if config_type == "mqtt_community":
@@ -193,7 +289,7 @@ def _enforce_scope(config_type: str, scope: dict) -> dict:
detail="scope.messages must be 'all', 'none', or a filter object",
)
return {"messages": messages, "raw_packets": "none"}
# For mqtt_private, validate scope values
# For mqtt_private and sqs, validate scope values
messages = scope.get("messages", "all")
if messages not in ("all", "none") and not isinstance(messages, dict):
raise HTTPException(
@@ -240,6 +336,8 @@ async def create_fanout_config(body: FanoutConfigCreate) -> dict:
_validate_webhook_config(body.config)
elif body.type == "apprise":
_validate_apprise_config(body.config)
elif body.type == "sqs":
_validate_sqs_config(body.config)
scope = _enforce_scope(body.type, body.scope)
@@ -295,6 +393,8 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
_validate_webhook_config(config_to_validate)
elif existing["type"] == "apprise":
_validate_apprise_config(config_to_validate)
elif existing["type"] == "sqs":
_validate_sqs_config(config_to_validate)
updated = await FanoutConfigRepository.update(config_id, **kwargs)
if updated is None:

View File

@@ -15,6 +15,7 @@ class HealthResponse(BaseModel):
status: str
radio_connected: bool
radio_initializing: bool = False
radio_state: str = "disconnected"
connection_info: str | None
database_size_mb: float
oldest_undecrypted_timestamp: int | None
@@ -56,12 +57,31 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
if not radio_connected:
setup_complete = False
connection_desired = getattr(radio_manager, "connection_desired", True)
if not isinstance(connection_desired, bool):
connection_desired = True
is_reconnecting = getattr(radio_manager, "is_reconnecting", False)
if not isinstance(is_reconnecting, bool):
is_reconnecting = False
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
if not connection_desired:
radio_state = "paused"
elif radio_initializing:
radio_state = "initializing"
elif radio_connected:
radio_state = "connected"
elif is_reconnecting:
radio_state = "connecting"
else:
radio_state = "disconnected"
return {
"status": "ok" if radio_connected and not radio_initializing else "degraded",
"radio_connected": radio_connected,
"radio_initializing": radio_initializing,
"radio_state": radio_state,
"connection_info": connection_info,
"database_size_mb": db_size_mb,
"oldest_undecrypted_timestamp": oldest_ts,

View File

@@ -108,6 +108,11 @@ async def send_direct_message(request: SendDirectMessageRequest) -> Message:
raise HTTPException(
status_code=404, detail=f"Contact not found in database: {request.destination}"
)
if len(db_contact.public_key) < 64:
raise HTTPException(
status_code=409,
detail="Cannot send to an unresolved prefix-only contact until a full key is known",
)
return await send_direct_message_to_contact(
contact=db_contact,

View File

@@ -1,4 +1,5 @@
import logging
from typing import Literal
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
@@ -14,13 +15,16 @@ from app.services.radio_commands import (
import_private_key_and_refresh_keystore,
)
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_health
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/radio", tags=["radio"])
AdvertLocationSource = Literal["off", "current"]
async def _prepare_connected(*, broadcast_on_success: bool) -> None:
await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
async def _prepare_connected(*, broadcast_on_success: bool) -> bool:
return await radio_manager.prepare_connected(broadcast_on_success=broadcast_on_success)
async def _reconnect_and_prepare(*, broadcast_on_success: bool) -> bool:
@@ -50,6 +54,10 @@ class RadioConfigResponse(BaseModel):
path_hash_mode_supported: bool = Field(
default=False, description="Whether firmware supports path hash mode setting"
)
advert_location_source: AdvertLocationSource = Field(
default="current",
description="Whether adverts include the node's current location state",
)
class RadioConfigUpdate(BaseModel):
@@ -64,6 +72,10 @@ class RadioConfigUpdate(BaseModel):
le=2,
description="Path hash mode (0=1-byte, 1=2-byte, 2=3-byte)",
)
advert_location_source: AdvertLocationSource | None = Field(
default=None,
description="Whether adverts include the node's current location state",
)
class PrivateKeyUpdate(BaseModel):
@@ -79,6 +91,9 @@ async def get_radio_config() -> RadioConfigResponse:
if not info:
raise HTTPException(status_code=503, detail="Radio info not available")
adv_loc_policy = info.get("adv_loc_policy", 1)
advert_location_source: AdvertLocationSource = "off" if adv_loc_policy == 0 else "current"
return RadioConfigResponse(
public_key=info.get("public_key", ""),
name=info.get("name", ""),
@@ -94,6 +109,7 @@ async def get_radio_config() -> RadioConfigResponse:
),
path_hash_mode=radio_manager.path_hash_mode,
path_hash_mode_supported=radio_manager.path_hash_mode_supported,
advert_location_source=advert_location_source,
)
@@ -170,6 +186,8 @@ async def send_advertisement() -> dict:
async def _attempt_reconnect() -> dict:
"""Shared reconnection logic for reboot and reconnect endpoints."""
radio_manager.resume_connection()
if radio_manager.is_reconnecting:
return {
"status": "pending",
@@ -194,6 +212,20 @@ async def _attempt_reconnect() -> dict:
return {"status": "ok", "message": "Reconnected successfully", "connected": True}
@router.post("/disconnect")
async def disconnect_radio() -> dict:
"""Disconnect from the radio and pause automatic reconnect attempts."""
logger.info("Manual radio disconnect requested")
await radio_manager.pause_connection()
broadcast_health(False, radio_manager.connection_info)
return {
"status": "ok",
"message": "Disconnected. Automatic reconnect is paused.",
"connected": False,
"paused": True,
}
@router.post("/reboot")
async def reboot_radio() -> dict:
"""Reboot the radio, or reconnect if not currently connected.
@@ -228,8 +260,11 @@ async def reconnect_radio() -> dict:
logger.info("Radio connected but setup incomplete, retrying setup")
try:
await _prepare_connected(broadcast_on_success=True)
if not await _prepare_connected(broadcast_on_success=True):
raise HTTPException(status_code=503, detail="Radio connection is paused")
return {"status": "ok", "message": "Setup completed", "connected": True}
except HTTPException:
raise
except Exception as e:
logger.exception("Post-connect setup failed")
raise HTTPException(

View File

@@ -451,7 +451,7 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
- get radio, set radio <freq,bw,sf,cr>
- tempradio <freq,bw,sf,cr,minutes>
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
- clock, clock sync
- clock, clock sync, time <epoch_seconds>
- reboot
- ver
"""

View File

@@ -19,7 +19,8 @@ class AppSettingsUpdate(BaseModel):
ge=1,
le=1000,
description=(
"Maximum contacts to keep on radio (favorites first, then recent non-repeaters)"
"Configured radio contact capacity used for maintenance thresholds and "
"background refill behavior"
),
)
auto_decrypt_dm_on_advert: bool | None = Field(
@@ -161,12 +162,12 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
result = await AppSettingsRepository.add_favorite(request.type, request.id)
# When a contact favorite changes, sync the radio so the contact is
# loaded/unloaded immediately rather than waiting for the next advert.
if request.type == "contact":
from app.radio_sync import sync_recent_contacts_to_radio
# When a contact is newly favorited, load just that contact to the radio
# immediately so DM ACK support does not wait for the next maintenance cycle.
if request.type == "contact" and not is_favorited:
from app.radio_sync import ensure_contact_on_radio
asyncio.create_task(sync_recent_contacts_to_radio(force=True))
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
return result

121
app/security.py Normal file
View File

@@ -0,0 +1,121 @@
"""ASGI middleware for optional app-wide HTTP Basic authentication."""
from __future__ import annotations
import base64
import binascii
import json
import logging
import secrets
from typing import Any
from starlette.datastructures import Headers
logger = logging.getLogger(__name__)
_AUTH_REALM = "RemoteTerm"
_UNAUTHORIZED_BODY = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
class BasicAuthMiddleware:
"""Protect all HTTP and WebSocket entrypoints with HTTP Basic auth."""
def __init__(self, app, *, username: str, password: str, realm: str = _AUTH_REALM) -> None:
self.app = app
self.username = username
self.password = password
self.realm = realm
self._challenge_value = f'Basic realm="{realm}", charset="UTF-8"'.encode("latin-1")
def _is_authorized(self, scope: dict[str, Any]) -> bool:
headers = Headers(scope=scope)
authorization = headers.get("authorization")
if not authorization:
return False
scheme, _, token = authorization.partition(" ")
if not token or scheme.lower() != "basic":
return False
token = token.strip()
try:
decoded = base64.b64decode(token, validate=True).decode("utf-8")
except (binascii.Error, UnicodeDecodeError):
logger.debug("Rejecting malformed basic auth header")
return False
username, sep, password = decoded.partition(":")
if not sep:
return False
return secrets.compare_digest(username, self.username) and secrets.compare_digest(
password, self.password
)
async def _send_http_unauthorized(self, send) -> None:
await send(
{
"type": "http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"cache-control", b"no-store"),
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
(b"www-authenticate", self._challenge_value),
],
}
)
await send(
{
"type": "http.response.body",
"body": _UNAUTHORIZED_BODY,
}
)
async def _send_websocket_unauthorized(self, send) -> None:
await send(
{
"type": "websocket.http.response.start",
"status": 401,
"headers": [
(b"content-type", b"application/json"),
(b"cache-control", b"no-store"),
(b"content-length", str(len(_UNAUTHORIZED_BODY)).encode("ascii")),
(b"www-authenticate", self._challenge_value),
],
}
)
await send(
{
"type": "websocket.http.response.body",
"body": _UNAUTHORIZED_BODY,
}
)
async def __call__(self, scope, receive, send) -> None:
scope_type = scope["type"]
if scope_type not in {"http", "websocket"}:
await self.app(scope, receive, send)
return
if self._is_authorized(scope):
await self.app(scope, receive, send)
return
if scope_type == "http":
await self._send_http_unauthorized(send)
return
await self._send_websocket_unauthorized(send)
def add_optional_basic_auth_middleware(app, settings) -> None:
"""Enable app-wide basic auth when configured via environment variables."""
if not settings.basic_auth_enabled:
return
app.add_middleware(
BasicAuthMiddleware,
username=settings.basic_auth_username,
password=settings.basic_auth_password,
)

View File

@@ -2,11 +2,29 @@
import logging
from app.repository import ContactNameHistoryRepository, MessageRepository
from app.repository import ContactNameHistoryRepository, ContactRepository, MessageRepository
logger = logging.getLogger(__name__)
async def promote_prefix_contacts_for_contact(
*,
public_key: str,
contact_repository=ContactRepository,
log: logging.Logger | None = None,
) -> list[str]:
"""Promote prefix-only placeholder contacts once a full key is known."""
normalized_key = public_key.lower()
promoted = await contact_repository.promote_prefix_placeholders(normalized_key)
if promoted:
(log or logger).info(
"Promoted %d prefix contact placeholder(s) for %s",
len(promoted),
normalized_key[:12],
)
return promoted
async def claim_prefix_messages_for_contact(
*,
public_key: str,

View File

@@ -137,15 +137,20 @@ async def send_direct_message_to_contact(
) -> Any:
"""Send a direct message and persist/broadcast the outgoing row."""
contact_data = contact.to_radio_dict()
contact_ensured_on_radio = False
async with radio_manager.radio_operation("send_direct_message") as mc:
logger.debug("Ensuring contact %s is on radio before sending", contact.public_key[:12])
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
logger.warning("Failed to add contact to radio: %s", add_result.payload)
else:
contact_ensured_on_radio = True
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not cached_contact:
cached_contact = contact_data
else:
contact_ensured_on_radio = True
logger.info("Sending direct message to %s", contact.public_key[:12])
now = int(now_fn())
@@ -158,6 +163,9 @@ async def send_direct_message_to_contact(
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
if contact_ensured_on_radio and not contact.on_radio:
await contact_repository.set_on_radio(contact.public_key.lower(), True)
message = await create_outgoing_direct_message(
conversation_key=contact.public_key.lower(),
text=text,
@@ -198,7 +206,6 @@ async def send_channel_message_to_channel(
message_repository=MessageRepository,
) -> Any:
"""Send a channel message and persist/broadcast the outgoing row."""
message_id: int | None = None
now: int | None = None
radio_name = ""
our_public_key: str | None = None
@@ -227,27 +234,27 @@ async def send_channel_message_to_channel(
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
outgoing_message = await create_outgoing_channel_message(
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=now,
received_at=now,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
message_repository=message_repository,
)
if outgoing_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
message_id = outgoing_message.id
if message_id is None or now is None:
if now is None:
raise HTTPException(status_code=500, detail="Failed to store outgoing message")
outgoing_message = await create_outgoing_channel_message(
conversation_key=channel_key_upper,
text=text_with_sender,
sender_timestamp=now,
received_at=now,
sender_name=radio_name or None,
sender_key=our_public_key,
channel_name=channel.name,
broadcast_fn=broadcast_fn,
message_repository=message_repository,
)
if outgoing_message is None:
raise HTTPException(
status_code=500,
detail="Failed to store outgoing message - unexpected duplicate",
)
message_id = outgoing_message.id
acked_count, paths = await message_repository.get_ack_and_paths(message_id)
return build_message_model(
message_id=message_id,

View File

@@ -12,6 +12,25 @@ if TYPE_CHECKING:
logger = logging.getLogger(__name__)
BroadcastFn = Callable[..., Any]
LOG_MESSAGE_PREVIEW_LEN = 32
def _truncate_for_log(text: str, max_chars: int = LOG_MESSAGE_PREVIEW_LEN) -> str:
"""Return a compact single-line message preview for log output."""
normalized = " ".join(text.split())
if len(normalized) <= max_chars:
return normalized
return f"{normalized[:max_chars].rstrip()}..."
def _format_channel_log_target(channel_name: str | None, channel_key: str) -> str:
"""Return a human-friendly channel label for logs."""
return channel_name or channel_key
def _format_contact_log_target(contact_name: str | None, public_key: str) -> str:
"""Return a human-friendly DM target label for logs."""
return contact_name or public_key[:12]
def build_message_paths(
@@ -214,7 +233,13 @@ async def create_message_from_decrypted(
)
return None
logger.info("Stored channel message %d for channel %s", msg_id, channel_key_normalized[:8])
logger.info(
'Stored channel message "%s" for %r (msg ID %d in chan ID %s)',
_truncate_for_log(text),
_format_channel_log_target(channel_name, channel_key_normalized),
msg_id,
channel_key_normalized,
)
await RawPacketRepository.mark_decrypted(packet_id, msg_id)
broadcast_message(
@@ -292,9 +317,11 @@ async def create_dm_message_from_decrypted(
return None
logger.info(
"Stored direct message %d for contact %s (outgoing=%s)",
'Stored direct message "%s" for %r (msg ID %d in contact ID %s, outgoing=%s)',
_truncate_for_log(decrypted.message),
_format_contact_log_target(contact.name if contact else None, conversation_key),
msg_id,
conversation_key[:12],
conversation_key,
outgoing,
)
await RawPacketRepository.mark_decrypted(packet_id, msg_id)

View File

@@ -32,6 +32,18 @@ async def apply_radio_config_update(
sync_radio_time_fn: Callable[[Any], Awaitable[Any]],
) -> None:
"""Apply a validated radio-config update to the connected radio."""
if update.advert_location_source is not None:
advert_loc_policy = 0 if update.advert_location_source == "off" else 1
logger.info(
"Setting advert location policy to %s",
update.advert_location_source,
)
result = await mc.commands.set_advert_loc_policy(advert_loc_policy)
if result is not None and result.type == EventType.ERROR:
raise RadioCommandRejectedError(
f"Failed to set advert location policy: {result.payload}"
)
if update.name is not None:
logger.info("Setting radio name to %s", update.name)
await mc.commands.set_name(update.name)

View File

@@ -3,6 +3,9 @@ import logging
logger = logging.getLogger(__name__)
POST_CONNECT_SETUP_TIMEOUT_SECONDS = 300
POST_CONNECT_SETUP_MAX_ATTEMPTS = 2
async def run_post_connect_setup(radio_manager) -> None:
"""Run shared radio initialization after a transport connection succeeds."""
@@ -24,21 +27,24 @@ async def run_post_connect_setup(radio_manager) -> None:
if radio_manager._setup_lock is None:
radio_manager._setup_lock = asyncio.Lock()
async with radio_manager._setup_lock:
async def _setup_body() -> None:
if not radio_manager.meshcore:
return
radio_manager._setup_in_progress = True
radio_manager._setup_complete = False
mc = radio_manager.meshcore
try:
# Register event handlers (no radio I/O, just callback setup)
register_event_handlers(mc)
# Hold the operation lock for all radio I/O during setup.
# This prevents user-initiated operations (send message, etc.)
# from interleaving commands on the serial link.
await radio_manager._acquire_operation_lock("post_connect_setup", blocking=True)
try:
mc = radio_manager.meshcore
if not mc:
return
# Register event handlers against the locked, current transport.
register_event_handlers(mc)
await export_and_store_private_key(mc)
# Sync radio clock with system time
@@ -138,17 +144,56 @@ async def run_post_connect_setup(radio_manager) -> None:
finally:
radio_manager._setup_in_progress = False
async with radio_manager._setup_lock:
await asyncio.wait_for(_setup_body(), timeout=POST_CONNECT_SETUP_TIMEOUT_SECONDS)
logger.info("Post-connect setup complete")
async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> None:
async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> bool:
"""Finish setup for an already-connected radio and optionally broadcast health."""
from app.websocket import broadcast_health
from app.websocket import broadcast_error, broadcast_health
if not radio_manager.connection_desired:
if radio_manager.is_connected:
await radio_manager.disconnect()
return False
for attempt in range(1, POST_CONNECT_SETUP_MAX_ATTEMPTS + 1):
try:
await radio_manager.post_connect_setup()
break
except asyncio.TimeoutError as exc:
if attempt < POST_CONNECT_SETUP_MAX_ATTEMPTS:
logger.warning(
"Post-connect setup timed out after %ds on attempt %d/%d; retrying once",
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
attempt,
POST_CONNECT_SETUP_MAX_ATTEMPTS,
)
continue
logger.error(
"Post-connect setup timed out after %ds on %d attempts. Initial radio offload "
"took too long; something is probably wrong.",
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
POST_CONNECT_SETUP_MAX_ATTEMPTS,
)
broadcast_error(
"Radio startup appears stuck",
"Initial radio offload took too long. Reboot the radio and restart the server.",
)
raise RuntimeError("Post-connect setup timed out") from exc
if not radio_manager.connection_desired:
if radio_manager.is_connected:
await radio_manager.disconnect()
return False
await radio_manager.post_connect_setup()
radio_manager._last_connected = True
if broadcast_on_success:
broadcast_health(True, radio_manager.connection_info)
return True
async def reconnect_and_prepare_radio(
@@ -161,8 +206,7 @@ async def reconnect_and_prepare_radio(
if not connected:
return False
await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success)
return True
return await prepare_connected_radio(radio_manager, broadcast_on_success=broadcast_on_success)
async def connection_monitor_loop(radio_manager) -> None:
@@ -178,6 +222,7 @@ async def connection_monitor_loop(radio_manager) -> None:
await asyncio.sleep(check_interval_seconds)
current_connected = radio_manager.is_connected
connection_desired = radio_manager.connection_desired
if radio_manager._last_connected and not current_connected:
logger.warning("Radio connection lost, broadcasting status change")
@@ -185,6 +230,13 @@ async def connection_monitor_loop(radio_manager) -> None:
radio_manager._last_connected = False
consecutive_setup_failures = 0
if not connection_desired:
if current_connected:
logger.info("Radio connection paused by operator; disconnecting transport")
await radio_manager.disconnect()
consecutive_setup_failures = 0
continue
if not current_connected:
if not radio_manager.is_reconnecting and await reconnect_and_prepare_radio(
radio_manager,
@@ -197,7 +249,11 @@ async def connection_monitor_loop(radio_manager) -> None:
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
consecutive_setup_failures = 0
elif current_connected and not radio_manager.is_setup_complete:
elif (
current_connected
and not radio_manager.is_setup_complete
and not radio_manager.is_setup_in_progress
):
logger.info("Retrying post-connect setup...")
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
consecutive_setup_failures = 0

View File

@@ -74,10 +74,12 @@ class RadioRuntime:
async def disconnect(self) -> None:
await self.manager.disconnect()
async def prepare_connected(self, *, broadcast_on_success: bool = True) -> None:
async def prepare_connected(self, *, broadcast_on_success: bool = True) -> bool:
from app.services.radio_lifecycle import prepare_connected_radio
await prepare_connected_radio(self.manager, broadcast_on_success=broadcast_on_success)
return await prepare_connected_radio(
self.manager, broadcast_on_success=broadcast_on_success
)
async def reconnect_and_prepare(self, *, broadcast_on_success: bool = True) -> bool:
from app.services.radio_lifecycle import reconnect_and_prepare_radio

View File

@@ -74,6 +74,7 @@ frontend/src/
│ ├── pubkey.ts # getContactDisplayName (12-char prefix fallback)
│ ├── contactAvatar.ts # Avatar color derivation from public key
│ ├── rawPacketIdentity.ts # observation_id vs id dedup helpers
│ ├── regionScope.ts # Regional flood-scope label/normalization helpers
│ ├── visualizerUtils.ts # 3D visualizer node types, colors, particles
│ ├── visualizerSettings.ts # LocalStorage persistence for visualizer options
│ ├── a11y.ts # Keyboard accessibility helper
@@ -105,6 +106,7 @@ frontend/src/
│ ├── RepeaterDashboard.tsx # Layout shell — delegates to repeater/ panes
│ ├── RepeaterLogin.tsx # Repeater login form (password + guest)
│ ├── ChannelInfoPane.tsx # Channel detail sheet (stats, top senders)
│ ├── DirectTraceIcon.tsx # Shared direct-trace glyph used in header/dashboard
│ ├── NeighborsMiniMap.tsx # Leaflet mini-map for repeater neighbor locations
│ ├── settings/
│ │ ├── settingsConstants.ts # Settings section type, ordering, labels
@@ -136,9 +138,13 @@ frontend/src/
├── appFavorites.test.tsx
├── appStartupHash.test.tsx
├── contactAvatar.test.ts
├── contactInfoPane.test.tsx
├── integration.test.ts
├── mapView.test.tsx
├── messageCache.test.ts
├── messageList.test.tsx
├── messageParser.test.ts
├── rawPacketList.test.tsx
├── pathUtils.test.ts
├── prefetch.test.ts
├── radioPresets.test.ts
@@ -152,12 +158,14 @@ frontend/src/
├── newMessageModal.test.tsx
├── settingsModal.test.tsx
├── sidebar.test.tsx
├── statusBar.test.tsx
├── unreadCounts.test.ts
├── urlHash.test.ts
├── appSearchJump.test.tsx
├── channelInfoKeyVisibility.test.tsx
├── chatHeaderKeyVisibility.test.tsx
├── searchView.test.tsx
├── useConversationActions.test.ts
├── useConversationMessages.test.ts
├── useConversationMessages.race.test.ts
├── useConversationNavigation.test.ts
@@ -240,13 +248,14 @@ High-level state is delegated to hooks:
### Radio settings behavior
- `SettingsRadioSection.tsx` surfaces `path_hash_mode` only when `config.path_hash_mode_supported` is true.
- Advert-location control is intentionally only `off` vs `include node location`. Companion-radio firmware does not reliably distinguish saved coordinates from live GPS in this path.
- Frontend `path_len` fields are hop counts, not raw byte lengths; multibyte path rendering must use the accompanying metadata before splitting hop identifiers.
## WebSocket (`useWebSocket.ts`)
- Auto reconnect (3s) with cleanup guard on unmount.
- Heartbeat ping every 30s.
- Incoming JSON is parsed through `wsEvents.ts`, which returns a typed discriminated union for known events and a centralized `unknown` fallback.
- Incoming JSON is parsed through `wsEvents.ts`, which validates the top-level envelope and known event type strings, then casts payloads at the handler boundary. It does not schema-validate per-event payload shapes.
- Event handlers: `health`, `message`, `contact`, `raw_packet`, `message_acked`, `contact_deleted`, `channel_deleted`, `error`, `success`, `pong` (ignored).
- For `raw_packet` events, use `observation_id` as event identity; `id` is a storage reference and may repeat.

View File

@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "2.7.9",
"version": "3.2.0",
"type": "module",
"scripts": {
"dev": "vite",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -13,15 +13,73 @@ import {
useConversationActions,
useConversationNavigation,
useRealtimeAppState,
useBrowserNotifications,
} from './hooks';
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
import { messageContainsMention } from './utils/messageParser';
import type { Conversation, RawPacket } from './types';
import { getStateKey } from './utils/conversationState';
import type { Conversation, Message, RawPacket } from './types';
interface ChannelUnreadMarker {
channelId: string;
lastReadAt: number | null;
}
interface UnreadBoundaryBackfillParams {
activeConversation: Conversation | null;
unreadMarker: ChannelUnreadMarker | null;
messages: Message[];
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
}
export function getUnreadBoundaryBackfillKey({
activeConversation,
unreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
}: UnreadBoundaryBackfillParams): string | null {
if (activeConversation?.type !== 'channel') return null;
if (!unreadMarker || unreadMarker.channelId !== activeConversation.id) return null;
if (unreadMarker.lastReadAt === null) return null;
if (messagesLoading || loadingOlder || !hasOlderMessages || messages.length === 0) return null;
const oldestLoadedMessage = messages.reduce(
(oldest, msg) => {
if (!oldest) return msg;
if (msg.received_at < oldest.received_at) return msg;
if (msg.received_at === oldest.received_at && msg.id < oldest.id) return msg;
return oldest;
},
null as Message | null
);
if (!oldestLoadedMessage) return null;
if (oldestLoadedMessage.received_at <= unreadMarker.lastReadAt) return null;
return `${activeConversation.id}:${unreadMarker.lastReadAt}:${oldestLoadedMessage.id}`;
}
export function App() {
const quoteSearchOperatorValue = useCallback((value: string) => {
return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
}, []);
const messageInputRef = useRef<MessageInputHandle>(null);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
notificationsSupported,
notificationsPermission,
isConversationNotificationsEnabled,
toggleConversationNotifications,
notifyIncomingMessage,
} = useBrowserNotifications();
const {
showNewMessage,
showSettings,
@@ -61,6 +119,8 @@ export function App() {
handleSaveConfig,
handleSetPrivateKey,
handleReboot,
handleDisconnect,
handleReconnect,
handleAdvertise,
handleHealthRefresh,
} = useRadioControl();
@@ -142,6 +202,7 @@ export function App() {
infoPaneContactKey,
infoPaneFromChannel,
infoPaneChannelKey,
searchPrefillRequest,
handleOpenContactInfo,
handleCloseContactInfo,
handleOpenChannelInfo,
@@ -149,6 +210,7 @@ export function App() {
handleSelectConversationWithTargetReset,
handleNavigateToChannel,
handleNavigateToMessage,
handleOpenSearchWithQuery,
} = useConversationNavigation({
channels,
handleSelectConversation,
@@ -176,11 +238,67 @@ export function App() {
mentions,
lastMessageTimes,
incrementUnread,
renameConversationState,
markAllRead,
trackNewMessage,
refreshUnreads,
} = useUnreadCounts(channels, contacts, activeConversation);
useEffect(() => {
if (activeConversation?.type !== 'channel') {
setChannelUnreadMarker(null);
return;
}
const activeChannelId = activeConversation.id;
const activeChannelUnreadCount = unreadCounts[getStateKey('channel', activeChannelId)] ?? 0;
setChannelUnreadMarker((prev) => {
if (prev?.channelId === activeChannelId) {
return prev;
}
if (activeChannelUnreadCount <= 0) {
return null;
}
const activeChannel = channels.find((channel) => channel.key === activeChannelId);
return {
channelId: activeChannelId,
lastReadAt: activeChannel?.last_read_at ?? null,
};
});
}, [activeConversation, channels, unreadCounts]);
useEffect(() => {
lastUnreadBackfillAttemptRef.current = null;
}, [activeConversation?.id, channelUnreadMarker?.channelId, channelUnreadMarker?.lastReadAt]);
useEffect(() => {
const backfillKey = getUnreadBoundaryBackfillKey({
activeConversation,
unreadMarker: channelUnreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
});
if (!backfillKey || lastUnreadBackfillAttemptRef.current === backfillKey) {
return;
}
lastUnreadBackfillAttemptRef.current = backfillKey;
void fetchOlderMessages();
}, [
activeConversation,
channelUnreadMarker,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
fetchOlderMessages,
]);
const wsHandlers = useRealtimeAppState({
prevHealthRef,
setHealth,
@@ -198,10 +316,12 @@ export function App() {
addMessageIfNew,
trackNewMessage,
incrementUnread,
renameConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
notifyIncomingMessage,
});
const {
handleSendMessage,
@@ -237,7 +357,10 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const statusProps = { health, config };
const statusProps = {
health,
config,
};
const sidebarProps = {
contacts,
channels,
@@ -258,6 +381,7 @@ export function App() {
onSortOrderChange: (sortOrder: 'recent' | 'alpha') => {
void handleSortOrderChange(sortOrder);
},
isConversationNotificationsEnabled,
};
const conversationPaneProps = {
activeConversation,
@@ -271,6 +395,11 @@ export function App() {
messagesLoading,
loadingOlder,
hasOlderMessages,
unreadMarkerLastReadAt:
activeConversation?.type === 'channel' &&
channelUnreadMarker?.channelId === activeConversation.id
? channelUnreadMarker.lastReadAt
: undefined,
targetMessageId,
hasNewerMessages,
loadingNewer,
@@ -289,11 +418,28 @@ export function App() {
onLoadNewer: fetchNewerMessages,
onJumpToBottom: jumpToBottom,
onSendMessage: handleSendMessage,
onDismissUnreadMarker: () => setChannelUnreadMarker(null),
notificationsSupported,
notificationsPermission,
notificationsEnabled:
activeConversation?.type === 'contact' || activeConversation?.type === 'channel'
? isConversationNotificationsEnabled(activeConversation.type, activeConversation.id)
: false,
onToggleNotifications: () => {
if (activeConversation?.type === 'contact' || activeConversation?.type === 'channel') {
void toggleConversationNotifications(
activeConversation.type,
activeConversation.id,
activeConversation.name
);
}
},
};
const searchProps = {
contacts,
channels,
onNavigateToMessage: handleNavigateToMessage,
prefillRequest: searchPrefillRequest,
};
const settingsProps = {
config,
@@ -303,6 +449,8 @@ export function App() {
onSaveAppSettings: handleSaveAppSettings,
onSetPrivateKey: handleSetPrivateKey,
onReboot: handleReboot,
onDisconnect: handleDisconnect,
onReconnect: handleReconnect,
onAdvertise: handleAdvertise,
onHealthRefresh: handleHealthRefresh,
onRefreshAppSettings: fetchAppSettings,
@@ -333,6 +481,12 @@ export function App() {
favorites,
onToggleFavorite: handleToggleFavorite,
onNavigateToChannel: handleNavigateToChannel,
onSearchMessagesByKey: (publicKey: string) => {
handleOpenSearchWithQuery(`user:${publicKey}`);
},
onSearchMessagesByName: (name: string) => {
handleOpenSearchWithQuery(`user:${quoteSearchOperatorValue(name)}`);
},
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
blockedKeys: appSettings?.blocked_keys ?? [],

View File

@@ -5,6 +5,7 @@ import type {
ChannelDetail,
CommandResponse,
Contact,
ContactAnalytics,
ContactAdvertPath,
ContactAdvertPathSummary,
ContactDetail,
@@ -16,6 +17,7 @@ import type {
MessagesAroundResponse,
MigratePreferencesRequest,
MigratePreferencesResponse,
NameOnlyContactDetail,
RadioConfig,
RadioConfigUpdate,
RepeaterAclResponse,
@@ -99,6 +101,13 @@ export const api = {
fetchJson<{ status: string; message: string }>('/radio/reboot', {
method: 'POST',
}),
disconnectRadio: () =>
fetchJson<{ status: string; message: string; connected: boolean; paused: boolean }>(
'/radio/disconnect',
{
method: 'POST',
}
),
reconnectRadio: () =>
fetchJson<{ status: string; message: string; connected: boolean }>('/radio/reconnect', {
method: 'POST',
@@ -113,8 +122,16 @@ export const api = {
),
getContactAdvertPaths: (publicKey: string, limit = 10) =>
fetchJson<ContactAdvertPath[]>(`/contacts/${publicKey}/advert-paths?limit=${limit}`),
getContactAnalytics: (params: { publicKey?: string; name?: string }) => {
const searchParams = new URLSearchParams();
if (params.publicKey) searchParams.set('public_key', params.publicKey);
if (params.name) searchParams.set('name', params.name);
return fetchJson<ContactAnalytics>(`/contacts/analytics?${searchParams.toString()}`);
},
getContactDetail: (publicKey: string) =>
fetchJson<ContactDetail>(`/contacts/${publicKey}/detail`),
getNameOnlyContactDetail: (name: string) =>
fetchJson<NameOnlyContactDetail>(`/contacts/name-detail?name=${encodeURIComponent(name)}`),
deleteContact: (publicKey: string) =>
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',

View File

@@ -9,6 +9,7 @@ import { ChannelInfoPane } from './ChannelInfoPane';
import { Toaster } from './ui/sonner';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import {
SETTINGS_SECTION_ICONS,
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
type SettingsSection,
@@ -115,20 +116,26 @@ export function AppShell({
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
{SETTINGS_SECTION_ORDER.map((section) => (
<button
key={section}
type="button"
className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
settingsSection === section && 'bg-accent border-l-primary'
)}
aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => onSettingsSectionChange(section)}
>
{SETTINGS_SECTION_LABELS[section]}
</button>
))}
{SETTINGS_SECTION_ORDER.map((section) => {
const Icon = SETTINGS_SECTION_ICONS[section];
return (
<button
key={section}
type="button"
className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
settingsSection === section && 'bg-accent border-l-primary'
)}
aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => onSettingsSectionChange(section)}
>
<span className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span>{SETTINGS_SECTION_LABELS[section]}</span>
</span>
</button>
);
})}
</div>
</nav>
);
@@ -171,7 +178,14 @@ export function AppShell({
<div className="hidden md:block min-h-0 overflow-hidden">{activeSidebarContent}</div>
<Sheet open={sidebarOpen} onOpenChange={onSidebarOpenChange}>
<SheetContent side="left" className="w-[280px] p-0 flex flex-col" hideCloseButton>
<SheetContent
side="left"
className="w-[280px] p-0 flex flex-col"
hideCloseButton
onOpenAutoFocus={(event) => {
event.preventDefault();
}}
>
<SheetHeader className="sr-only">
<SheetTitle>Navigation</SheetTitle>
<SheetDescription>Sidebar navigation</SheetDescription>
@@ -213,12 +227,6 @@ export function AppShell({
{showSettings && (
<div className="flex-1 flex flex-col min-h-0">
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<span>Radio & Settings</span>
<span className="text-sm text-muted-foreground hidden md:inline">
{SETTINGS_SECTION_LABELS[settingsSection]}
</span>
</h2>
<div className="flex-1 min-h-0 overflow-hidden">
<Suspense
fallback={

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { Star } from 'lucide-react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import { isFavorite } from '../utils/favorites';
@@ -125,12 +126,12 @@ export function ChannelInfoPane({
>
{isFavorite(favorites, 'channel', channel.key) ? (
<>
<span className="text-favorite text-lg">&#9733;</span>
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
<span>Remove from favorites</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#9734;</span>
<Star className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
<span>Add to favorites</span>
</>
)}

View File

@@ -1,8 +1,11 @@
import { useEffect, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { Bell, Globe2, Info, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { DirectTraceIcon } from './DirectTraceIcon';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { stripRegionScopePrefix } from '../utils/regionScope';
import { isPrefixOnlyContact } from '../utils/pubkey';
import { ContactAvatar } from './ContactAvatar';
import { ContactStatusInfo } from './ContactStatusInfo';
import type { Channel, Contact, Conversation, Favorite, RadioConfig } from '../types';
@@ -13,7 +16,11 @@ interface ChatHeaderProps {
channels: Channel[];
config: RadioConfig | null;
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
onTrace: () => void;
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onDeleteChannel: (key: string) => void;
@@ -28,7 +35,11 @@ export function ChatHeader({
channels,
config,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
onTrace,
onToggleNotifications,
onToggleFavorite,
onSetChannelFloodScopeOverride,
onDeleteChannel,
@@ -37,6 +48,8 @@ export function ChatHeader({
onOpenChannelInfo,
}: ChatHeaderProps) {
const [showKey, setShowKey] = useState(false);
const [contactStatusInline, setContactStatusInline] = useState(true);
const keyTextRef = useRef<HTMLSpanElement | null>(null);
useEffect(() => {
setShowKey(false);
@@ -46,31 +59,97 @@ export function ChatHeader({
conversation.type === 'channel'
? channels.find((channel) => channel.key === conversation.id)
: undefined;
const activeFloodScopeOverride =
conversation.type === 'channel' ? (activeChannel?.flood_scope_override ?? null) : null;
const activeFloodScopeLabel = activeFloodScopeOverride
? stripRegionScopePrefix(activeFloodScopeOverride)
: null;
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
const activeContact =
conversation.type === 'contact'
? contacts.find((contact) => contact.public_key === conversation.id)
: null;
const activeContactIsPrefixOnly = activeContact
? isPrefixOnlyContact(activeContact.public_key)
: false;
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
(conversation.type === 'channel' && onOpenChannelInfo);
const favoriteTitle =
conversation.type === 'contact'
? isFavorite(favorites, 'contact', conversation.id)
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
: isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites';
const handleEditFloodScopeOverride = () => {
if (conversation.type !== 'channel' || !onSetChannelFloodScopeOverride) return;
const nextValue = window.prompt(
'Enter regional override flood scope for this room. This temporarily changes the radio flood scope before send and restores it after, which significantly slows room sends. Leave blank to clear.',
stripRegionScopePrefix(activeChannel?.flood_scope_override)
activeFloodScopeLabel ?? ''
);
if (nextValue === null) return;
onSetChannelFloodScopeOverride(conversation.id, nextValue);
};
const handleOpenConversationInfo = () => {
if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id);
return;
}
if (conversation.type === 'channel' && onOpenChannelInfo) {
onOpenChannelInfo(conversation.id);
}
};
useEffect(() => {
if (conversation.type !== 'contact') {
setContactStatusInline(true);
return;
}
const measure = () => {
const keyElement = keyTextRef.current;
if (!keyElement) return;
const isTruncated = keyElement.scrollWidth > keyElement.clientWidth + 1;
setContactStatusInline(!isTruncated);
};
measure();
const onResize = () => {
window.requestAnimationFrame(measure);
};
window.addEventListener('resize', onResize);
let observer: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
observer = new ResizeObserver(() => {
window.requestAnimationFrame(measure);
});
if (keyTextRef.current?.parentElement) {
observer.observe(keyTextRef.current.parentElement);
}
}
return () => {
window.removeEventListener('resize', onResize);
observer?.disconnect();
};
}, [conversation.id, conversation.type, showKey]);
return (
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
<header className="conversation-header flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 items-start gap-2">
{conversation.type === 'contact' && onOpenContactInfo && (
<span
className="flex-shrink-0 cursor-pointer"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
<button
type="button"
className="avatar-action-button flex-shrink-0 cursor-pointer rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onOpenContactInfo(conversation.id)}
title="View contact info"
aria-label={`View info for ${conversation.name}`}
@@ -82,113 +161,186 @@ export function ChatHeader({
contactType={contacts.find((c) => c.public_key === conversation.id)?.type}
clickable
/>
</span>
)}
<h2
className={`flex-shrink-0 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
role={titleClickable ? 'button' : undefined}
tabIndex={titleClickable ? 0 : undefined}
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
onClick={
titleClickable
? () => {
if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id);
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
onOpenChannelInfo(conversation.id);
}
}
: undefined
}
>
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</h2>
{isPrivateChannel && !showKey ? (
<button
className="font-normal text-[11px] text-muted-foreground font-mono hover:text-primary transition-colors"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
>
Show Key
</button>
) : (
<span
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
);
}}
title="Click to copy"
aria-label={conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'}
>
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
</span>
)}
{conversation.type === 'channel' && activeChannel?.flood_scope_override && (
<span className="basis-full sm:basis-auto text-[11px] text-amber-700 dark:text-amber-300 truncate">
Regional override active: {stripRegionScopePrefix(activeChannel.flood_scope_override)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2 whitespace-nowrap">
<h2 className="min-w-0 shrink font-semibold text-base">
{titleClickable ? (
<button
type="button"
className="flex min-w-0 shrink items-center gap-1.5 text-left hover:text-primary transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
aria-label={`View info for ${conversation.name}`}
onClick={handleOpenConversationInfo}
>
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
)}
</h2>
{isPrivateChannel && !showKey ? (
<button
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
>
Show Key
</button>
) : (
<span
ref={keyTextRef}
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
);
}}
title="Click to copy"
aria-label={
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
}
>
{conversation.type === 'channel'
? conversation.id.toLowerCase()
: conversation.id}
</span>
)}
</span>
{conversation.type === 'contact' && activeContact && contactStatusInline && (
<span className="min-w-0 flex-none text-[11px] text-muted-foreground">
<ContactStatusInfo
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</span>
)}
</span>
)}
{conversation.type === 'contact' &&
(() => {
const contact = contacts.find((c) => c.public_key === conversation.id);
if (!contact) return null;
return (
{conversation.type === 'contact' && activeContact && !contactStatusInline && (
<span className="mt-0.5 min-w-0 text-[11px] text-muted-foreground">
<ContactStatusInfo
contact={contact}
contact={activeContact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
);
})()}
</span>
)}
{conversation.type === 'channel' && activeFloodScopeDisplay && (
<button
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<Globe2
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true"
/>
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay}
</span>
</button>
)}
</span>
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors disabled:cursor-not-allowed disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
title="Direct Trace"
title={
activeContactIsPrefixOnly
? 'Direct Trace unavailable until the full contact key is known'
: 'Direct Trace'
}
aria-label="Direct Trace"
disabled={activeContactIsPrefixOnly}
>
<span aria-hidden="true">&#x1F6CE;</span>
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
</button>
)}
{notificationsSupported && (
<button
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onToggleNotifications}
title={
notificationsEnabled
? 'Disable desktop notifications for this conversation'
: notificationsPermission === 'denied'
? 'Notifications blocked by the browser'
: 'Enable desktop notifications for this conversation'
}
aria-label={
notificationsEnabled
? 'Disable notifications for this conversation'
: 'Enable notifications for this conversation'
}
>
<Bell
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
fill={notificationsEnabled ? 'currentColor' : 'none'}
aria-hidden="true"
/>
{notificationsEnabled && (
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
Notifications On
</span>
)}
</button>
)}
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<span aria-hidden="true">&#127758;</span>
<Globe2
className={`h-4 w-4 ${activeFloodScopeLabel ? 'text-[hsl(var(--region-override))]' : 'text-muted-foreground'}`}
aria-hidden="true"
/>
{activeFloodScopeDisplay && (
<span className="hidden text-[11px] font-medium text-[hsl(var(--region-override))] sm:inline">
{activeFloodScopeDisplay}
</span>
)}
</button>
)}
{(conversation.type === 'channel' || conversation.type === 'contact') && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() =>
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
}
title={
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites'
}
title={favoriteTitle}
aria-label={
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
@@ -196,15 +348,15 @@ export function ChatHeader({
}
>
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
<span className="text-favorite">&#9733;</span>
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
) : (
<span className="text-muted-foreground">&#9734;</span>
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
</button>
)}
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
<button
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
if (conversation.type === 'channel') {
onDeleteChannel(conversation.id);
@@ -215,7 +367,7 @@ export function ChatHeader({
title="Delete"
aria-label="Delete"
>
<span aria-hidden="true">&#128465;</span>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>

View File

@@ -1,6 +1,12 @@
import { type ReactNode, useEffect, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import {
getContactDisplayName,
isPrefixOnlyContact,
isUnknownFullKeyContact,
} from '../utils/pubkey';
import {
isValidLocation,
calculateDistance,
@@ -16,7 +22,15 @@ import { handleKeyboardActivate } from '../utils/a11y';
import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import type { Contact, ContactDetail, Favorite, RadioConfig } from '../types';
import type {
Contact,
ContactActiveRoom,
ContactAnalytics,
ContactAnalyticsHourlyBucket,
ContactAnalyticsWeeklyBucket,
Favorite,
RadioConfig,
} from '../types';
const CONTACT_TYPE_LABELS: Record<number, string> = {
0: 'Unknown',
@@ -42,6 +56,8 @@ interface ContactInfoPaneProps {
favorites: Favorite[];
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onNavigateToChannel?: (channelKey: string) => void;
onSearchMessagesByKey?: (publicKey: string) => void;
onSearchMessagesByName?: (name: string) => void;
blockedKeys?: string[];
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
@@ -57,6 +73,8 @@ export function ContactInfoPane({
favorites,
onToggleFavorite,
onNavigateToChannel,
onSearchMessagesByKey,
onSearchMessagesByName,
blockedKeys = [],
blockedNames = [],
onToggleBlockedKey,
@@ -65,7 +83,7 @@ export function ContactInfoPane({
const isNameOnly = contactKey?.startsWith('name:') ?? false;
const nameOnlyValue = isNameOnly && contactKey ? contactKey.slice(5) : null;
const [detail, setDetail] = useState<ContactDetail | null>(null);
const [analytics, setAnalytics] = useState<ContactAnalytics | null>(null);
const [loading, setLoading] = useState(false);
// Get live contact data from contacts array (real-time via WS)
@@ -73,21 +91,26 @@ export function ContactInfoPane({
contactKey && !isNameOnly ? (contacts.find((c) => c.public_key === contactKey) ?? null) : null;
useEffect(() => {
if (!contactKey || isNameOnly) {
setDetail(null);
if (!contactKey) {
setAnalytics(null);
return;
}
let cancelled = false;
setAnalytics(null);
setLoading(true);
api
.getContactDetail(contactKey)
const request =
isNameOnly && nameOnlyValue
? api.getContactAnalytics({ name: nameOnlyValue })
: api.getContactAnalytics({ publicKey: contactKey });
request
.then((data) => {
if (!cancelled) setDetail(data);
if (!cancelled) setAnalytics(data);
})
.catch((err) => {
if (!cancelled) {
console.error('Failed to fetch contact detail:', err);
console.error('Failed to fetch contact analytics:', err);
toast.error('Failed to load contact info');
}
})
@@ -97,10 +120,10 @@ export function ContactInfoPane({
return () => {
cancelled = true;
};
}, [contactKey, isNameOnly]);
}, [contactKey, isNameOnly, nameOnlyValue]);
// Use live contact data where available, fall back to detail snapshot
const contact = liveContact ?? detail?.contact ?? null;
// Use live contact data where available, fall back to analytics snapshot
const contact = liveContact ?? analytics?.contact ?? null;
const distFromUs =
contact &&
@@ -115,6 +138,11 @@ export function ContactInfoPane({
? formatPathHashMode(effectiveRoute.pathHashMode)
: null;
const learnedRouteLabel = contact ? formatRouteLabel(contact.last_path_len, true) : null;
const isPrefixOnlyResolvedContact = contact ? isPrefixOnlyContact(contact.public_key) : false;
const isUnknownFullKeyResolvedContact =
contact !== null &&
!isPrefixOnlyResolvedContact &&
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
@@ -129,9 +157,15 @@ export function ContactInfoPane({
{/* Name-only header */}
<div className="px-5 pt-5 pb-4 border-b border-border">
<div className="flex items-start gap-4">
<ContactAvatar name={nameOnlyValue} publicKey={`name:${nameOnlyValue}`} size={56} />
<ContactAvatar
name={analytics?.name ?? nameOnlyValue}
publicKey={`name:${nameOnlyValue}`}
size={56}
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">{nameOnlyValue}</h2>
<h2 className="text-lg font-semibold truncate">
{analytics?.name ?? nameOnlyValue}
</h2>
<p className="text-xs text-muted-foreground mt-1">
We have not heard an advertisement associated with this name, so we cannot
identify their key.
@@ -140,8 +174,6 @@ export function ContactInfoPane({
</div>
</div>
{fromChannel && <ChannelAttributionWarning />}
{/* Block by name toggle */}
{onToggleBlockedName && (
<div className="px-5 py-3 border-b border-border">
@@ -152,20 +184,65 @@ export function ContactInfoPane({
>
{blockedNames.includes(nameOnlyValue) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
<span>Unblock this name</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
<span>Block this name</span>
</>
)}
</button>
</div>
)}
{onSearchMessagesByName && (
<div className="px-5 py-3 border-b border-border">
<button
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onSearchMessagesByName(nameOnlyValue)}
>
<Search className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
<span>Search user&apos;s messages by name</span>
</button>
</div>
)}
{fromChannel && (
<ChannelAttributionWarning
nameOnly
includeAliasNote={false}
className="border-b border-border mx-0 my-0 rounded-none px-5 py-3"
/>
)}
<MessageStatsSection
dmMessageCount={0}
channelMessageCount={analytics?.channel_message_count ?? 0}
showDirectMessages={false}
/>
{analytics?.name_first_seen_at && (
<div className="px-5 py-3 border-b border-border">
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
<InfoItem
label="Name First In Use"
value={formatTime(analytics.name_first_seen_at)}
/>
</div>
</div>
)}
<ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection
rooms={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
) : loading && !detail ? (
) : loading && !analytics && !contact ? (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
Loading...
</div>
@@ -182,7 +259,7 @@ export function ContactInfoPane({
/>
<div className="flex-1 min-w-0">
<h2 className="text-lg font-semibold truncate">
{contact.name || contact.public_key.slice(0, 12)}
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
</h2>
<span
className="text-xs font-mono text-muted-foreground cursor-pointer hover:text-primary transition-colors block truncate"
@@ -211,7 +288,21 @@ export function ContactInfoPane({
</div>
</div>
{fromChannel && <ChannelAttributionWarning />}
{isPrefixOnlyResolvedContact && (
<div className="mx-5 mt-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
We only know a key prefix for this sender, which can happen when a fallback DM
arrives before we hear an advertisement. This contact stays read-only until the full
key resolves from a later advertisement.
</div>
)}
{isUnknownFullKeyResolvedContact && (
<div className="mx-5 mt-4 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
We know this sender&apos;s full key, but we have not yet heard an advertisement that
fills in their identity details. Those details will appear automatically when an
advertisement arrives.
</div>
)}
{/* Info grid */}
<div className="px-5 py-3 border-b border-border">
@@ -279,15 +370,16 @@ export function ContactInfoPane({
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleFavorite('contact', contact.public_key)}
title="Favorite contacts stay loaded on the radio for ACK support"
>
{isFavorite(favorites, 'contact', contact.public_key) ? (
<>
<span className="text-favorite text-lg">&#9733;</span>
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
<span>Remove from favorites</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#9734;</span>
<Star className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
<span>Add to favorites</span>
</>
)}
@@ -305,12 +397,12 @@ export function ContactInfoPane({
>
{blockedKeys.includes(contact.public_key.toLowerCase()) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
<span>Unblock this key</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
<span>Block this key</span>
</>
)}
@@ -324,12 +416,12 @@ export function ContactInfoPane({
>
{blockedNames.includes(contact.name) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
<span>Unblock name &ldquo;{contact.name}&rdquo;</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
<span>Block name &ldquo;{contact.name}&rdquo;</span>
</>
)}
@@ -338,85 +430,25 @@ export function ContactInfoPane({
</div>
)}
{/* AKA (Name History) - only show if more than one name */}
{detail && detail.name_history.length > 1 && (
{onSearchMessagesByKey && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Also Known As</SectionLabel>
<div className="space-y-1">
{detail.name_history.map((h) => (
<div key={h.name} className="flex justify-between items-center text-sm">
<span className="font-medium truncate">{h.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{formatTime(h.first_seen)} &ndash; {formatTime(h.last_seen)}
</span>
</div>
))}
</div>
</div>
)}
{/* Message Stats */}
{detail && (detail.dm_message_count > 0 || detail.channel_message_count > 0) && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Messages</SectionLabel>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{detail.dm_message_count > 0 && (
<InfoItem
label="Direct Messages"
value={detail.dm_message_count.toLocaleString()}
/>
)}
{detail.channel_message_count > 0 && (
<InfoItem
label="Channel Messages"
value={detail.channel_message_count.toLocaleString()}
/>
)}
</div>
</div>
)}
{/* Most Active Rooms */}
{detail && detail.most_active_rooms.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Most Active Rooms</SectionLabel>
<div className="space-y-1">
{detail.most_active_rooms.map((room) => (
<div
key={room.channel_key}
className="flex justify-between items-center text-sm"
>
<span
className={
onNavigateToChannel
? 'cursor-pointer hover:text-primary transition-colors truncate'
: 'truncate'
}
role={onNavigateToChannel ? 'button' : undefined}
tabIndex={onNavigateToChannel ? 0 : undefined}
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
onClick={() => onNavigateToChannel?.(room.channel_key)}
>
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
? room.channel_name
: `#${room.channel_name}`}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{room.message_count.toLocaleString()} msg
{room.message_count !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
<button
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onSearchMessagesByKey(contact.public_key)}
>
<Search className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
<span>Search user&apos;s messages by key</span>
</button>
</div>
)}
{/* Nearest Repeaters */}
{detail && detail.nearest_repeaters.length > 0 && (
{analytics && analytics.nearest_repeaters.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters</SectionLabel>
<div className="space-y-1">
{detail.nearest_repeaters.map((r) => (
{analytics.nearest_repeaters.map((r) => (
<div key={r.public_key} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
@@ -432,11 +464,11 @@ export function ContactInfoPane({
)}
{/* Advert Paths */}
{detail && detail.advert_paths.length > 0 && (
<div className="px-5 py-3">
{analytics && analytics.advert_paths.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Recent Advert Paths</SectionLabel>
<div className="space-y-1">
{detail.advert_paths.map((p) => (
{analytics.advert_paths.map((p) => (
<div
key={p.path + p.first_seen}
className="flex justify-between items-center text-sm"
@@ -452,6 +484,41 @@ export function ContactInfoPane({
</div>
</div>
)}
{fromChannel && (
<ChannelAttributionWarning
includeAliasNote={Boolean(analytics && analytics.name_history.length > 1)}
/>
)}
{/* AKA (Name History) - only show if more than one name */}
{analytics && analytics.name_history.length > 1 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Also Known As</SectionLabel>
<div className="space-y-1">
{analytics.name_history.map((h) => (
<div key={h.name} className="flex justify-between items-center text-sm">
<span className="font-medium truncate">{h.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{formatTime(h.first_seen)} &ndash; {formatTime(h.last_seen)}
</span>
</div>
))}
</div>
</div>
)}
<MessageStatsSection
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<ActivityChartsSection analytics={analytics} />
<MostActiveRoomsSection
rooms={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -471,18 +538,304 @@ function SectionLabel({ children }: { children: React.ReactNode }) {
);
}
function ChannelAttributionWarning() {
function ChannelAttributionWarning({
includeAliasNote = false,
nameOnly = false,
className = 'mx-5 my-3 px-3 py-2 rounded-md bg-warning/10 border border-warning/20',
}: {
includeAliasNote?: boolean;
nameOnly?: boolean;
className?: string;
}) {
return (
<div className="mx-5 my-3 px-3 py-2 rounded-md bg-yellow-500/10 border border-yellow-500/20">
<p className="text-xs text-yellow-600 dark:text-yellow-400">
<div className={className}>
<p className="text-xs text-warning">
Channel sender identity is based on best-effort name matching. Different nodes using the
same name will be attributed to the same contact. Message counts and key-based statistics
same name will be attributed to the same {nameOnly ? 'sender name' : 'contact'}. Stats below
may be inaccurate.
{includeAliasNote &&
' Historical counts below may include messages previously attributed under names shown in Also Known As.'}
</p>
</div>
);
}
function MessageStatsSection({
dmMessageCount,
channelMessageCount,
showDirectMessages = true,
}: {
dmMessageCount: number;
channelMessageCount: number;
showDirectMessages?: boolean;
}) {
if ((showDirectMessages ? dmMessageCount : 0) <= 0 && channelMessageCount <= 0) {
return null;
}
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Messages</SectionLabel>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
{showDirectMessages && dmMessageCount > 0 && (
<InfoItem label="Direct Messages" value={dmMessageCount.toLocaleString()} />
)}
{channelMessageCount > 0 && (
<InfoItem label="Channel Messages" value={channelMessageCount.toLocaleString()} />
)}
</div>
</div>
);
}
function MostActiveRoomsSection({
rooms,
onNavigateToChannel,
}: {
rooms: ContactActiveRoom[];
onNavigateToChannel?: (channelKey: string) => void;
}) {
if (rooms.length === 0) {
return null;
}
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Most Active Rooms</SectionLabel>
<div className="space-y-1">
{rooms.map((room) => (
<div key={room.channel_key} className="flex justify-between items-center text-sm">
<span
className={
onNavigateToChannel
? 'cursor-pointer hover:text-primary transition-colors truncate'
: 'truncate'
}
role={onNavigateToChannel ? 'button' : undefined}
tabIndex={onNavigateToChannel ? 0 : undefined}
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
onClick={() => onNavigateToChannel?.(room.channel_key)}
>
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
? room.channel_name
: `#${room.channel_name}`}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{room.message_count.toLocaleString()} msg
{room.message_count !== 1 ? 's' : ''}
</span>
</div>
))}
</div>
</div>
);
}
function ActivityChartsSection({ analytics }: { analytics: ContactAnalytics | null }) {
if (!analytics) {
return null;
}
const hasHourlyActivity = analytics.hourly_activity.some(
(bucket) =>
bucket.last_24h_count > 0 || bucket.last_week_average > 0 || bucket.all_time_average > 0
);
const hasWeeklyActivity = analytics.weekly_activity.some((bucket) => bucket.message_count > 0);
if (!hasHourlyActivity && !hasWeeklyActivity) {
return null;
}
return (
<div className="px-5 py-3 border-b border-border space-y-4">
{hasHourlyActivity && (
<div>
<SectionLabel>Messages Per Hour</SectionLabel>
<ChartLegend
items={[
{ label: 'Last 24h', color: '#2563eb' },
{ label: '7-day avg', color: '#ea580c' },
{ label: 'All-time avg', color: '#64748b' },
]}
/>
<ActivityLineChart
ariaLabel="Messages per hour"
points={analytics.hourly_activity}
series={[
{ key: 'last_24h_count', color: '#2563eb' },
{ key: 'last_week_average', color: '#ea580c' },
{ key: 'all_time_average', color: '#64748b' },
]}
valueFormatter={(value) => value.toFixed(value % 1 === 0 ? 0 : 1)}
tickFormatter={(bucket) =>
new Date(bucket.bucket_start * 1000).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
/>
</div>
)}
{hasWeeklyActivity && (
<div>
<SectionLabel>Messages Per Week</SectionLabel>
<ActivityLineChart
ariaLabel="Messages per week"
points={analytics.weekly_activity}
series={[{ key: 'message_count', color: '#16a34a' }]}
valueFormatter={(value) => value.toFixed(0)}
tickFormatter={(bucket) =>
new Date(bucket.bucket_start * 1000).toLocaleDateString([], {
month: 'short',
day: 'numeric',
})
}
/>
</div>
)}
<p className="text-[11px] text-muted-foreground">
Hourly lines compare the last 24 hours against 7-day and all-time averages for the same hour
slots.
{!analytics.includes_direct_messages &&
' Name-only analytics include channel messages only.'}
</p>
</div>
);
}
function ChartLegend({ items }: { items: Array<{ label: string; color: string }> }) {
return (
<div className="flex flex-wrap gap-x-3 gap-y-1 mb-2 text-[11px] text-muted-foreground">
{items.map((item) => (
<span key={item.label} className="inline-flex items-center gap-1.5">
<span
className="inline-block h-2 w-2 rounded-full"
style={{ backgroundColor: item.color }}
aria-hidden="true"
/>
{item.label}
</span>
))}
</div>
);
}
function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnalyticsWeeklyBucket>({
ariaLabel,
points,
series,
tickFormatter,
valueFormatter,
}: {
ariaLabel: string;
points: T[];
series: Array<{ key: keyof T; color: string }>;
tickFormatter: (point: T) => string;
valueFormatter: (value: number) => string;
}) {
const width = 320;
const height = 132;
const padding = { top: 8, right: 8, bottom: 24, left: 32 };
const plotWidth = width - padding.left - padding.right;
const plotHeight = height - padding.top - padding.bottom;
const allValues = points.flatMap((point) =>
series.map((entry) => {
const value = point[entry.key];
return typeof value === 'number' ? value : 0;
})
);
const maxValue = Math.max(1, ...allValues);
const tickIndices = Array.from(
new Set([
0,
Math.floor((points.length - 1) / 3),
Math.floor(((points.length - 1) * 2) / 3),
points.length - 1,
])
);
const buildPolyline = (key: keyof T) =>
points
.map((point, index) => {
const rawValue = point[key];
const value = typeof rawValue === 'number' ? rawValue : 0;
const x =
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
const y = padding.top + plotHeight - (value / maxValue) * plotHeight;
return `${x},${y}`;
})
.join(' ');
return (
<div>
<svg
viewBox={`0 0 ${width} ${height}`}
className="w-full h-auto"
role="img"
aria-label={ariaLabel}
>
{[0, 0.5, 1].map((ratio) => {
const y = padding.top + plotHeight - ratio * plotHeight;
const value = maxValue * ratio;
return (
<g key={ratio}>
<line
x1={padding.left}
x2={width - padding.right}
y1={y}
y2={y}
stroke="hsl(var(--border))"
strokeWidth="1"
/>
<text
x={padding.left - 6}
y={y + 4}
fontSize="10"
textAnchor="end"
fill="hsl(var(--muted-foreground))"
>
{valueFormatter(value)}
</text>
</g>
);
})}
{series.map((entry) => (
<polyline
key={String(entry.key)}
fill="none"
stroke={entry.color}
strokeWidth="2"
strokeLinejoin="round"
strokeLinecap="round"
points={buildPolyline(entry.key)}
/>
))}
{tickIndices.map((index) => {
const point = points[index];
const x =
padding.left + (points.length === 1 ? 0 : (index / (points.length - 1)) * plotWidth);
return (
<text
key={`${ariaLabel}-${point.bucket_start}`}
x={x}
y={height - 6}
fontSize="10"
textAnchor={index === 0 ? 'start' : index === points.length - 1 ? 'end' : 'middle'}
fill="hsl(var(--muted-foreground))"
>
{tickFormatter(point)}
</text>
);
})}
</svg>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div>

View File

@@ -15,6 +15,7 @@ import type {
RadioConfig,
} from '../types';
import { CONTACT_TYPE_REPEATER } from '../types';
import { isPrefixOnlyContact, isUnknownFullKeyContact } from '../utils/pubkey';
const RepeaterDashboard = lazy(() =>
import('./RepeaterDashboard').then((m) => ({ default: m.RepeaterDashboard }))
@@ -31,11 +32,15 @@ interface ConversationPaneProps {
rawPackets: RawPacket[];
config: RadioConfig | null;
health: HealthStatus | null;
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
favorites: Favorite[];
messages: Message[];
messagesLoading: boolean;
loadingOlder: boolean;
hasOlderMessages: boolean;
unreadMarkerLastReadAt?: number | null;
targetMessageId: number | null;
hasNewerMessages: boolean;
loadingNewer: boolean;
@@ -53,7 +58,9 @@ interface ConversationPaneProps {
onTargetReached: () => void;
onLoadNewer: () => Promise<void>;
onJumpToBottom: () => void;
onDismissUnreadMarker: () => void;
onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void;
}
function LoadingPane({ label }: { label: string }) {
@@ -62,6 +69,25 @@ function LoadingPane({ label }: { label: string }) {
);
}
function ContactResolutionBanner({ variant }: { variant: 'unknown-full-key' | 'prefix-only' }) {
if (variant === 'prefix-only') {
return (
<div className="mx-4 mt-3 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
We only know a key prefix for this sender, which can happen when a fallback DM arrives
before we learn their full identity. This conversation is read-only until we hear an
advertisement that resolves the full key.
</div>
);
}
return (
<div className="mx-4 mt-3 rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
A full identity profile is not yet available because we have not heard an advertisement from
this sender. The contact will fill in automatically when an advertisement arrives.
</div>
);
}
export function ConversationPane({
activeConversation,
contacts,
@@ -69,11 +95,15 @@ export function ConversationPane({
rawPackets,
config,
health,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
favorites,
messages,
messagesLoading,
loadingOlder,
hasOlderMessages,
unreadMarkerLastReadAt,
targetMessageId,
hasNewerMessages,
loadingNewer,
@@ -91,13 +121,26 @@ export function ConversationPane({
onTargetReached,
onLoadNewer,
onJumpToBottom,
onDismissUnreadMarker,
onSendMessage,
onToggleNotifications,
}: ConversationPaneProps) {
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
const contact = contacts.find((candidate) => candidate.public_key === activeConversation.id);
return contact?.type === CONTACT_TYPE_REPEATER;
}, [activeConversation, contacts]);
const activeContact = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return null;
return contacts.find((candidate) => candidate.public_key === activeConversation.id) ?? null;
}, [activeConversation, contacts]);
const isPrefixOnlyActiveContact = activeContact
? isPrefixOnlyContact(activeContact.public_key)
: false;
const isUnknownFullKeyActiveContact =
activeContact !== null &&
!isPrefixOnlyActiveContact &&
isUnknownFullKeyContact(activeContact.public_key, activeContact.last_advert);
if (!activeConversation) {
return (
@@ -155,10 +198,14 @@ export function ConversationPane({
conversation={activeConversation}
contacts={contacts}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
radioLat={config?.lat ?? null}
radioLon={config?.lon ?? null}
radioName={config?.name ?? null}
onTrace={onTrace}
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
/>
@@ -174,7 +221,11 @@ export function ConversationPane({
channels={channels}
config={config}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
onTrace={onTrace}
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
onDeleteChannel={onDeleteChannel}
@@ -182,6 +233,12 @@ export function ConversationPane({
onOpenContactInfo={onOpenContactInfo}
onOpenChannelInfo={onOpenChannelInfo}
/>
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact && (
<ContactResolutionBanner variant="prefix-only" />
)}
{activeConversation.type === 'contact' && isUnknownFullKeyActiveContact && (
<ContactResolutionBanner variant="unknown-full-key" />
)}
<MessageList
key={activeConversation.id}
messages={messages}
@@ -189,6 +246,12 @@ export function ConversationPane({
loading={messagesLoading}
loadingOlder={loadingOlder}
hasOlderMessages={hasOlderMessages}
unreadMarkerLastReadAt={
activeConversation.type === 'channel' ? unreadMarkerLastReadAt : undefined
}
onDismissUnreadMarker={
activeConversation.type === 'channel' ? onDismissUnreadMarker : undefined
}
onSenderClick={activeConversation.type === 'channel' ? onSenderClick : undefined}
onLoadOlder={onLoadOlder}
onResendChannelMessage={
@@ -204,16 +267,20 @@ export function ConversationPane({
onLoadNewer={onLoadNewer}
onJumpToBottom={onJumpToBottom}
/>
<MessageInput
ref={messageInputRef}
onSend={onSendMessage}
disabled={!health?.radio_connected}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected ? 'Radio not connected' : `Message ${activeConversation.name}...`
}
/>
{activeConversation.type === 'contact' && isPrefixOnlyActiveContact ? null : (
<MessageInput
ref={messageInputRef}
onSend={onSendMessage}
disabled={!health?.radio_connected}
conversationType={activeConversation.type}
senderName={config?.name}
placeholder={
!health?.radio_connected
? 'Radio not connected'
: `Message ${activeConversation.name}...`
}
/>
)}
</>
);
}

View File

@@ -0,0 +1,22 @@
interface DirectTraceIconProps {
className?: string;
}
export function DirectTraceIcon({ className }: DirectTraceIconProps) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
aria-hidden="true"
>
<path d="M3 12h12" />
<circle cx="18" cy="12" r="3" />
</svg>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { Fragment, useEffect, useState, useMemo, useRef, useCallback } from 'react';
import { MapContainer, TileLayer, CircleMarker, Popup, useMap } from 'react-leaflet';
import type { LatLngBoundsExpression, CircleMarker as LeafletCircleMarker } from 'leaflet';
import 'leaflet/dist/leaflet.css';
@@ -13,17 +13,27 @@ interface MapViewProps {
focusedKey?: string | null;
}
const MAP_RECENCY_COLORS = {
recent: '#06b6d4',
today: '#2563eb',
stale: '#f59e0b',
old: '#64748b',
} as const;
const MAP_MARKER_STROKE = '#0f172a';
const MAP_REPEATER_RING = '#f8fafc';
// Calculate marker color based on how recently the contact was heard
function getMarkerColor(lastSeen: number): string {
function getMarkerColor(lastSeen: number | null | undefined): string {
if (lastSeen == null) return MAP_RECENCY_COLORS.old;
const now = Date.now() / 1000;
const age = now - lastSeen;
const hour = 3600;
const day = 86400;
if (age < hour) return '#22c55e'; // Bright green - less than 1 hour
if (age < day) return '#4ade80'; // Light green - less than 1 day
if (age < 3 * day) return '#a3e635'; // Yellow-green - less than 3 days
return '#9ca3af'; // Gray - older (up to 7 days)
if (age < hour) return MAP_RECENCY_COLORS.recent;
if (age < day) return MAP_RECENCY_COLORS.today;
if (age < 3 * day) return MAP_RECENCY_COLORS.stale;
return MAP_RECENCY_COLORS.old;
}
// Component to handle map bounds fitting
@@ -94,16 +104,17 @@ function MapBoundsHandler({
}
export function MapView({ contacts, focusedKey }: MapViewProps) {
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
// Filter to contacts with GPS coordinates, heard within the last 7 days.
// Always include the focused contact so "view on map" links work for older nodes.
const mappableContacts = useMemo(() => {
const sevenDaysAgo = Date.now() / 1000 - 7 * 24 * 60 * 60;
return contacts.filter(
(c) =>
isValidLocation(c.lat, c.lon) &&
(c.public_key === focusedKey || (c.last_seen != null && c.last_seen > sevenDaysAgo))
);
}, [contacts, focusedKey]);
}, [contacts, focusedKey, sevenDaysAgo]);
// Find the focused contact by key
const focusedContact = useMemo(() => {
@@ -111,6 +122,10 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
return mappableContacts.find((c) => c.public_key === focusedKey) || null;
}, [focusedKey, mappableContacts]);
const includesFocusedOutsideWindow =
focusedContact != null &&
(focusedContact.last_seen == null || focusedContact.last_seen <= sevenDaysAgo);
// Track marker refs to open popup programmatically
const markerRefs = useRef<Record<string, LeafletCircleMarker | null>>({});
@@ -137,19 +152,48 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
<span>
Showing {mappableContacts.length} contact{mappableContacts.length !== 1 ? 's' : ''} heard
in the last 7 days
{includesFocusedOutsideWindow ? ' plus the focused contact' : ''}
</span>
<div className="flex items-center gap-3">
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#22c55e]" aria-hidden="true" /> &lt;1h
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: MAP_RECENCY_COLORS.recent }}
aria-hidden="true"
/>{' '}
&lt;1h
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#4ade80]" aria-hidden="true" /> &lt;1d
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: MAP_RECENCY_COLORS.today }}
aria-hidden="true"
/>{' '}
&lt;1d
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#a3e635]" aria-hidden="true" /> &lt;3d
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: MAP_RECENCY_COLORS.stale }}
aria-hidden="true"
/>{' '}
&lt;3d
</span>
<span className="flex items-center gap-1">
<span className="w-3 h-3 rounded-full bg-[#9ca3af]" aria-hidden="true" /> older
<span
className="w-3 h-3 rounded-full"
style={{ backgroundColor: MAP_RECENCY_COLORS.old }}
aria-hidden="true"
/>{' '}
older
</span>
<span className="flex items-center gap-1">
<span
className="w-3 h-3 rounded-full border-2"
style={{ borderColor: MAP_REPEATER_RING, backgroundColor: MAP_RECENCY_COLORS.today }}
aria-hidden="true"
/>{' '}
repeater
</span>
</div>
</div>
@@ -175,41 +219,46 @@ export function MapView({ contacts, focusedKey }: MapViewProps) {
{mappableContacts.map((contact) => {
const isRepeater = contact.type === CONTACT_TYPE_REPEATER;
const color = getMarkerColor(contact.last_seen!);
const color = getMarkerColor(contact.last_seen);
const displayName = contact.name || contact.public_key.slice(0, 12);
const lastHeardLabel =
contact.last_seen != null
? formatTime(contact.last_seen)
: 'Never heard by this server';
const radius = isRepeater ? 10 : 7;
return (
<CircleMarker
key={contact.public_key}
ref={(ref) => setMarkerRef(contact.public_key, ref)}
center={[contact.lat!, contact.lon!]}
radius={isRepeater ? 10 : 7}
pathOptions={{
color: isRepeater ? color : '#000',
fillColor: color,
fillOpacity: 0.8,
weight: isRepeater ? 0 : 1,
}}
>
<Popup>
<div className="text-sm">
<div className="font-medium flex items-center gap-1">
{isRepeater && (
<span title="Repeater" aria-hidden="true">
🛜
</span>
)}
{displayName}
<Fragment key={contact.public_key}>
<CircleMarker
key={contact.public_key}
ref={(ref) => setMarkerRef(contact.public_key, ref)}
center={[contact.lat!, contact.lon!]}
radius={radius}
pathOptions={{
color: isRepeater ? MAP_REPEATER_RING : MAP_MARKER_STROKE,
fillColor: color,
fillOpacity: 0.9,
weight: isRepeater ? 3 : 2,
}}
>
<Popup>
<div className="text-sm">
<div className="font-medium flex items-center gap-1">
{isRepeater && (
<span title="Repeater" aria-hidden="true">
🛜
</span>
)}
{displayName}
</div>
<div className="text-xs text-gray-500 mt-1">Last heard: {lastHeardLabel}</div>
<div className="text-xs text-gray-400 mt-1 font-mono">
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
</div>
</div>
<div className="text-xs text-gray-500 mt-1">
Last heard: {formatTime(contact.last_seen!)}
</div>
<div className="text-xs text-gray-400 mt-1 font-mono">
{contact.lat!.toFixed(5)}, {contact.lon!.toFixed(5)}
</div>
</div>
</Popup>
</CircleMarker>
</Popup>
</CircleMarker>
</Fragment>
);
})}
</MapContainer>

View File

@@ -150,7 +150,7 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
return (
<form
className="px-4 py-2.5 border-t border-border flex flex-col gap-1"
className="message-input-shell px-4 py-2.5 border-t border-border flex flex-col gap-1"
onSubmit={handleSubmit}
autoComplete="off"
>

View File

@@ -1,4 +1,5 @@
import {
Fragment,
useEffect,
useLayoutEffect,
useRef,
@@ -22,6 +23,8 @@ interface MessageListProps {
loading: boolean;
loadingOlder?: boolean;
hasOlderMessages?: boolean;
unreadMarkerLastReadAt?: number | null;
onDismissUnreadMarker?: () => void;
onSenderClick?: (sender: string) => void;
onLoadOlder?: () => void;
onResendChannelMessage?: (messageId: number, newTimestamp?: boolean) => void;
@@ -172,6 +175,8 @@ export function MessageList({
loading,
loadingOlder = false,
hasOlderMessages = false,
unreadMarkerLastReadAt,
onDismissUnreadMarker,
onSenderClick,
onLoadOlder,
onResendChannelMessage,
@@ -198,7 +203,12 @@ export function MessageList({
const [resendableIds, setResendableIds] = useState<Set<number>>(new Set());
const resendTimersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
const [highlightedMessageId, setHighlightedMessageId] = useState<number | null>(null);
const [showJumpToUnread, setShowJumpToUnread] = useState(false);
const targetScrolledRef = useRef(false);
const unreadMarkerRef = useRef<HTMLButtonElement | HTMLDivElement | null>(null);
const setUnreadMarkerElement = useCallback((node: HTMLButtonElement | HTMLDivElement | null) => {
unreadMarkerRef.current = node;
}, []);
// Capture scroll state in the scroll handler BEFORE any state updates
const scrollStateRef = useRef({
@@ -389,6 +399,18 @@ export function MessageList({
() => [...messages].sort((a, b) => a.received_at - b.received_at || a.id - b.id),
[messages]
);
const unreadMarkerIndex = useMemo(() => {
if (unreadMarkerLastReadAt === undefined) {
return -1;
}
const boundary = unreadMarkerLastReadAt ?? 0;
return sortedMessages.findIndex((msg) => !msg.outgoing && msg.received_at > boundary);
}, [sortedMessages, unreadMarkerLastReadAt]);
useEffect(() => {
setShowJumpToUnread(unreadMarkerIndex !== -1);
}, [unreadMarkerIndex]);
// Sender info for outgoing messages (used by path modal on own messages)
const selfSenderInfo = useMemo<SenderInfo>(
@@ -606,97 +628,108 @@ export function MessageList({
(avatarName ? `name:${avatarName}` : `message:${msg.id}`);
}
}
const avatarActionLabel =
avatarName && avatarName !== 'Unknown'
? `View info for ${avatarName}`
: `View info for ${avatarKey.slice(0, 12)}`;
return (
<div
key={msg.id}
data-message-id={msg.id}
className={cn(
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
isFirstInGroup && !isFirstMessage && 'mt-3'
)}
>
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar && avatarKey && (
<span
role={onOpenContactInfo ? 'button' : undefined}
tabIndex={onOpenContactInfo ? 0 : undefined}
onKeyDown={onOpenContactInfo ? handleKeyboardActivate : undefined}
onClick={
onOpenContactInfo
? () => onOpenContactInfo(avatarKey, msg.type === 'CHAN')
: undefined
}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable={!!onOpenContactInfo}
variant={avatarVariant}
/>
<Fragment key={msg.id}>
{unreadMarkerIndex === index &&
(onDismissUnreadMarker ? (
<button
ref={setUnreadMarkerElement}
type="button"
className="my-2 flex w-full items-center gap-3 text-left text-xs font-medium text-primary transition-colors hover:text-primary/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onDismissUnreadMarker}
>
<span className="h-px flex-1 bg-border" />
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
Unread messages
</span>
)}
</div>
)}
<span className="h-px flex-1 bg-border" />
</button>
) : (
<div
ref={setUnreadMarkerElement}
className="my-2 flex w-full items-center gap-3 text-xs font-medium text-primary"
>
<span className="h-px flex-1 bg-border" />
<span className="rounded-full border border-primary/30 bg-primary/10 px-3 py-1">
Unread messages
</span>
<span className="h-px flex-1 bg-border" />
</div>
))}
<div
data-message-id={msg.id}
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
highlightedMessageId === msg.id && 'message-highlight'
'flex items-start max-w-[85%]',
msg.outgoing && 'flex-row-reverse self-end',
isFirstInGroup && !isFirstMessage && 'mt-3'
)}
>
{showAvatar && (
<div className="text-[13px] font-semibold text-foreground mb-0.5">
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
displaySender
)}
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="header"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
})
}
/>
)}
{!msg.outgoing && (
<div className="w-10 flex-shrink-0 flex items-start pt-0.5">
{showAvatar &&
avatarKey &&
(onOpenContactInfo ? (
<button
type="button"
className="avatar-action-button rounded-full border-none bg-transparent p-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={avatarActionLabel}
onClick={() => onOpenContactInfo(avatarKey, msg.type === 'CHAN')}
>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
clickable
variant={avatarVariant}
/>
</button>
) : (
<span>
<ContactAvatar
name={avatarName}
publicKey={avatarKey}
size={32}
variant={avatarVariant}
/>
</span>
))}
</div>
)}
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<>
<span className="text-[10px] text-muted-foreground ml-2">
<div
className={cn(
'py-1.5 px-3 rounded-lg min-w-0',
msg.outgoing ? 'bg-msg-outgoing' : 'bg-msg-incoming',
highlightedMessageId === msg.id && 'message-highlight'
)}
>
{showAvatar && (
<div className="text-[13px] font-semibold text-foreground mb-0.5">
{canClickSender ? (
<span
className="cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => onSenderClick(displaySender)}
title={`Mention ${displaySender}`}
>
{displaySender}
</span>
) : (
displaySender
)}
<span className="font-normal text-muted-foreground ml-2 text-[11px]">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="inline"
variant="header"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
@@ -705,11 +738,58 @@ export function MessageList({
}
/>
)}
</>
</div>
)}
{msg.outgoing &&
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<div className="break-words whitespace-pre-wrap">
{content.split('\n').map((line, i, arr) => (
<span key={i}>
{renderTextWithMentions(line, radioName)}
{i < arr.length - 1 && <br />}
</span>
))}
{!showAvatar && (
<>
<span className="text-[10px] text-muted-foreground ml-2">
{formatTime(msg.sender_timestamp || msg.received_at)}
</span>
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
<HopCountBadge
paths={msg.paths}
variant="inline"
onClick={() =>
setSelectedPath({
paths: msg.paths!,
senderInfo: getSenderInfo(msg, contact, sender),
})
}
/>
)}
</>
)}
{msg.outgoing &&
(msg.acked > 0 ? (
msg.paths && msg.paths.length > 0 ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: msg.paths!,
senderInfo: selfSenderInfo,
messageId: msg.id,
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
});
}}
title="View echo paths"
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
)
) : onResendChannelMessage && msg.type === 'CHAN' ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
@@ -718,48 +798,28 @@ export function MessageList({
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: msg.paths!,
paths: [],
senderInfo: selfSenderInfo,
messageId: msg.id,
isOutgoingChan: msg.type === 'CHAN' && !!onResendChannelMessage,
isOutgoingChan: true,
});
}}
title="View echo paths"
aria-label={`Acknowledged, ${msg.acked} echo${msg.acked !== 1 ? 's' : ''} — view paths`}
>{`${msg.acked > 1 ? msg.acked : ''}`}</span>
title="Message status"
aria-label="No echoes yet — view message status"
>
{' '}
?
</span>
) : (
<span className="text-muted-foreground">{`${msg.acked > 1 ? msg.acked : ''}`}</span>
)
) : onResendChannelMessage && msg.type === 'CHAN' ? (
<span
className="text-muted-foreground cursor-pointer hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
setSelectedPath({
paths: [],
senderInfo: selfSenderInfo,
messageId: msg.id,
isOutgoingChan: true,
});
}}
title="Message status"
aria-label="No echoes yet — view message status"
>
{' '}
?
</span>
) : (
<span className="text-muted-foreground" title="No repeats heard yet">
{' '}
?
</span>
))}
<span className="text-muted-foreground" title="No repeats heard yet">
{' '}
?
</span>
))}
</div>
</div>
</div>
</div>
</Fragment>
);
})}
{loadingNewer && (
@@ -775,6 +835,20 @@ export function MessageList({
</div>
{/* Scroll to bottom button */}
{showJumpToUnread && (
<div className="pointer-events-none absolute bottom-4 left-1/2 -translate-x-1/2">
<button
type="button"
onClick={() => {
unreadMarkerRef.current?.scrollIntoView?.({ block: 'center' });
setShowJumpToUnread(false);
}}
className="pointer-events-auto h-9 rounded-full bg-card hover:bg-accent border border-border px-3 text-sm font-medium shadow-lg transition-all hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
Jump to unread
</button>
</div>
)}
{showScrollToBottom && (
<button
onClick={scrollToBottom}

View File

@@ -1,4 +1,5 @@
import { useState, useRef } from 'react';
import { Dice5 } from 'lucide-react';
import type { Contact, Conversation } from '../types';
import { getContactDisplayName } from '../utils/pubkey';
import {
@@ -168,34 +169,40 @@ export function NewMessageModal({
<TabsContent value="existing" className="mt-4">
<div className="max-h-[300px] overflow-y-auto rounded-md border">
{contacts.length === 0 ? (
{contacts.filter((contact) => contact.public_key.length === 64).length === 0 ? (
<div className="p-4 text-center text-muted-foreground">No contacts available</div>
) : (
contacts.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
onClick={() => {
onSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
});
resetForm();
onClose();
}}
>
{getContactDisplayName(contact.name, contact.public_key)}
</div>
))
contacts
.filter((contact) => contact.public_key.length === 64)
.map((contact) => (
<div
key={contact.public_key}
className="cursor-pointer px-4 py-2 hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
(e.currentTarget as HTMLElement).click();
}
}}
onClick={() => {
onSelectConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(
contact.name,
contact.public_key,
contact.last_advert
),
});
resetForm();
onClose();
}}
>
{getContactDisplayName(contact.name, contact.public_key, contact.last_advert)}
</div>
))
)}
</div>
</TabsContent>
@@ -256,7 +263,7 @@ export function NewMessageModal({
title="Generate random key"
aria-label="Generate random key"
>
<span aria-hidden="true">🎲</span>
<Dice5 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>

View File

@@ -1,5 +1,7 @@
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import { isFavorite } from '../utils/favorites';
@@ -24,10 +26,14 @@ interface RepeaterDashboardProps {
conversation: Conversation;
contacts: Contact[];
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
radioLat: number | null;
radioLon: number | null;
radioName: string | null;
onTrace: () => void;
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
}
@@ -36,10 +42,14 @@ export function RepeaterDashboard({
conversation,
contacts,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
radioLat,
radioLon,
radioName,
onTrace,
onToggleNotifications,
onToggleFavorite,
onDeleteContact,
}: RepeaterDashboardProps) {
@@ -56,7 +66,8 @@ export function RepeaterDashboard({
refreshPane,
loadAll,
sendConsoleCommand,
sendAdvert,
sendZeroHopAdvert,
sendFloodAdvert,
rebootRepeater,
syncClock,
} = useRepeaterDashboard(conversation);
@@ -70,23 +81,33 @@ export function RepeaterDashboard({
return (
<div className="flex-1 flex flex-col min-h-0">
{/* Header */}
<header className="flex justify-between items-start sm:items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
<span className="flex-shrink-0 font-semibold text-base">{conversation.name}</span>
<span
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
navigator.clipboard.writeText(conversation.id);
toast.success('Contact key copied!');
}}
title="Click to copy"
>
{conversation.id}
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
{conversation.name}
</span>
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={() => {
navigator.clipboard.writeText(conversation.id);
toast.success('Contact key copied!');
}}
title="Click to copy"
>
{conversation.id}
</span>
</span>
{contact && (
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
</span>
)}
</span>
{contact && <ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />}
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
{loggedIn && (
@@ -101,32 +122,65 @@ export function RepeaterDashboard({
</Button>
)}
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
title="Direct Trace"
aria-label="Direct Trace"
>
<span aria-hidden="true">&#x1F6CE;</span>
<DirectTraceIcon className="h-4 w-4 text-muted-foreground" />
</button>
{notificationsSupported && (
<button
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onToggleNotifications}
title={
notificationsEnabled
? 'Disable desktop notifications for this conversation'
: notificationsPermission === 'denied'
? 'Notifications blocked by the browser'
: 'Enable desktop notifications for this conversation'
}
aria-label={
notificationsEnabled
? 'Disable notifications for this conversation'
: 'Enable notifications for this conversation'
}
>
<Bell
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
fill={notificationsEnabled ? 'currentColor' : 'none'}
aria-hidden="true"
/>
{notificationsEnabled && (
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
Notifications On
</span>
)}
</button>
)}
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onToggleFavorite('contact', conversation.id)}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
title={
isFav
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
{isFav ? (
<span className="text-favorite">&#9733;</span>
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
) : (
<span className="text-muted-foreground">&#9734;</span>
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
</button>
<button
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => onDeleteContact(conversation.id)}
title="Delete"
aria-label="Delete"
>
<span aria-hidden="true">&#128465;</span>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
</div>
</header>
@@ -196,7 +250,8 @@ export function RepeaterDashboard({
disabled={anyLoading}
/>
<ActionsPane
onSendAdvert={sendAdvert}
onSendZeroHopAdvert={sendZeroHopAdvert}
onSendFloodAdvert={sendFloodAdvert}
onSyncClock={syncClock}
onReboot={rebootRepeater}
consoleLoading={consoleLoading}

View File

@@ -19,6 +19,8 @@ interface SearchResult {
sender_name: string | null;
}
const SEARCH_OPERATOR_RE = /(?<!\S)(user|channel):(?:"((?:[^"\\]|\\.)*)"|(\S+))/gi;
export interface SearchNavigateTarget {
id: number;
type: 'PRIV' | 'CHAN';
@@ -30,6 +32,10 @@ export interface SearchViewProps {
contacts: Contact[];
channels: Channel[];
onNavigateToMessage: (target: SearchNavigateTarget) => void;
prefillRequest?: {
query: string;
nonce: number;
} | null;
}
function highlightMatch(text: string, query: string): React.ReactNode[] {
@@ -53,7 +59,34 @@ function highlightMatch(text: string, query: string): React.ReactNode[] {
return parts;
}
export function SearchView({ contacts, channels, onNavigateToMessage }: SearchViewProps) {
function getHighlightQuery(query: string): string {
const fragments: string[] = [];
let lastIndex = 0;
let foundOperator = false;
for (const match of query.matchAll(SEARCH_OPERATOR_RE)) {
foundOperator = true;
fragments.push(query.slice(lastIndex, match.index));
lastIndex = (match.index ?? 0) + match[0].length;
}
if (!foundOperator) {
return query;
}
fragments.push(query.slice(lastIndex));
return fragments
.map((fragment) => fragment.trim())
.filter(Boolean)
.join(' ');
}
export function SearchView({
contacts,
channels,
onNavigateToMessage,
prefillRequest = null,
}: SearchViewProps) {
const [query, setQuery] = useState('');
const [debouncedQuery, setDebouncedQuery] = useState('');
const [results, setResults] = useState<SearchResult[]>([]);
@@ -62,6 +95,7 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
const [offset, setOffset] = useState(0);
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const highlightQuery = getHighlightQuery(debouncedQuery);
// Debounce query
useEffect(() => {
@@ -78,6 +112,23 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
setHasMore(false);
}, [debouncedQuery]);
useEffect(() => {
if (!prefillRequest) {
return;
}
const nextQuery = prefillRequest.query.trim();
setQuery(nextQuery);
setDebouncedQuery(nextQuery);
inputRef.current?.focus();
}, [prefillRequest]);
useEffect(() => {
return () => {
abortRef.current?.abort();
};
}, []);
// Fetch search results
useEffect(() => {
if (!debouncedQuery) {
@@ -193,7 +244,16 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
<div className="flex-1 overflow-y-auto">
{!debouncedQuery && (
<div className="p-8 text-center text-muted-foreground text-sm">
Type to search across all messages
<p>Type to search across all messages</p>
<p className="mt-2 text-xs">
Tip: use <code>user:</code> or <code>channel:</code> for keys or names, and wrap names
with spaces in them in quotes.
</p>
<p className="mt-2 text-xs">
Warning: User-key linkage for group messages is best-effort and based on correlation
at advertise time. It does not account for multiple users with the same name, and
should be considered unreliable.
</p>
</div>
)}
@@ -246,7 +306,7 @@ export function SearchView({ contacts, channels, onNavigateToMessage }: SearchVi
result.sender_name && result.text.startsWith(`${result.sender_name}: `)
? result.text.slice(result.sender_name.length + 2)
: result.text,
debouncedQuery
highlightQuery
)}
</div>
</div>

View File

@@ -7,7 +7,11 @@ import type {
RadioConfigUpdate,
} from '../types';
import type { LocalLabel } from '../utils/localLabel';
import { SETTINGS_SECTION_LABELS, type SettingsSection } from './settings/settingsConstants';
import {
SETTINGS_SECTION_ICONS,
SETTINGS_SECTION_LABELS,
type SettingsSection,
} from './settings/settingsConstants';
import { SettingsRadioSection } from './settings/SettingsRadioSection';
import { SettingsLocalSection } from './settings/SettingsLocalSection';
@@ -27,6 +31,8 @@ interface SettingsModalBaseProps {
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>;
onHealthRefresh: () => Promise<void>;
onRefreshAppSettings: () => Promise<void>;
@@ -55,6 +61,8 @@ export function SettingsModal(props: SettingsModalProps) {
onSaveAppSettings,
onSetPrivateKey,
onReboot,
onDisconnect,
onReconnect,
onAdvertise,
onHealthRefresh,
onRefreshAppSettings,
@@ -138,6 +146,7 @@ export function SettingsModal(props: SettingsModalProps) {
const renderSectionHeader = (section: SettingsSection): ReactNode => {
if (!showSectionButton) return null;
const Icon = SETTINGS_SECTION_ICONS[section];
return (
<button
type="button"
@@ -145,8 +154,9 @@ export function SettingsModal(props: SettingsModalProps) {
aria-expanded={expandedSections[section]}
onClick={() => toggleSection(section)}
>
<span className="font-medium" role="heading" aria-level={3}>
{SETTINGS_SECTION_LABELS[section]}
<span className="inline-flex items-center gap-2 font-medium" role="heading" aria-level={3}>
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span>{SETTINGS_SECTION_LABELS[section]}</span>
</span>
<span className="text-muted-foreground md:hidden" aria-hidden="true">
{expandedSections[section] ? '' : '+'}
@@ -176,6 +186,8 @@ export function SettingsModal(props: SettingsModalProps) {
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={onSetPrivateKey}
onReboot={onReboot}
onDisconnect={onDisconnect}
onReconnect={onReconnect}
onAdvertise={onAdvertise}
onClose={onClose}
className={sectionContentClass}

View File

@@ -1,4 +1,17 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bell,
CheckCheck,
ChevronDown,
ChevronRight,
LockOpen,
Logs,
Map,
Search as SearchIcon,
SquarePen,
Waypoints,
X,
} from 'lucide-react';
import {
CONTACT_TYPE_REPEATER,
type Contact,
@@ -24,6 +37,7 @@ type ConversationRow = {
name: string;
unreadCount: number;
isMention: boolean;
notificationsEnabled: boolean;
contact?: Contact;
};
@@ -81,6 +95,7 @@ interface SidebarProps {
sortOrder?: SortOrder;
/** Callback when sort order changes */
onSortOrderChange?: (order: SortOrder) => void;
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
}
export function Sidebar({
@@ -99,6 +114,7 @@ export function Sidebar({
favorites,
sortOrder: sortOrderProp = 'recent',
onSortOrderChange,
isConversationNotificationsEnabled,
}: SidebarProps) {
const sortOrder = sortOrderProp;
const [searchQuery, setSearchQuery] = useState('');
@@ -393,15 +409,18 @@ export function Sidebar({
name: channel.name,
unreadCount: getUnreadCount('channel', channel.key),
isMention: hasMention('channel', channel.key),
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
});
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
key: `${keyPrefix}-${contact.public_key}`,
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
unreadCount: getUnreadCount('contact', contact.public_key),
isMention: hasMention('contact', contact.public_key),
notificationsEnabled:
isConversationNotificationsEnabled?.('contact', contact.public_key) ?? false,
contact,
});
@@ -434,19 +453,26 @@ export function Sidebar({
/>
)}
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
{row.unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
row.isMention
? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
)}
<span className="ml-auto flex items-center gap-1">
{row.notificationsEnabled && (
<span aria-label="Notifications enabled" title="Notifications enabled">
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
</span>
)}
{row.unreadCount > 0 && (
<span
className={cn(
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
row.isMention
? 'bg-badge-mention text-badge-mention-foreground'
: 'bg-badge-unread/90 text-badge-unread-foreground'
)}
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
>
{row.unreadCount}
</span>
)}
</span>
</div>
);
@@ -459,7 +485,7 @@ export function Sidebar({
}: {
key: string;
active?: boolean;
icon: string;
icon: React.ReactNode;
label: React.ReactNode;
onClick: () => void;
}) => (
@@ -475,10 +501,10 @@ export function Sidebar({
onKeyDown={handleKeyboardActivate}
onClick={onClick}
>
<span className="text-muted-foreground text-xs" aria-hidden="true">
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
{icon}
</span>
<span className="flex-1 truncate text-muted-foreground">{label}</span>
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
</div>
);
@@ -507,7 +533,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-raw',
active: isActive('raw', 'raw'),
icon: '📡',
icon: <Logs className="h-4 w-4" />,
label: 'Packet Feed',
onClick: () =>
handleSelectConversation({
@@ -519,7 +545,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-map',
active: isActive('map', 'map'),
icon: '🗺️',
icon: <Map className="h-4 w-4" />,
label: 'Node Map',
onClick: () =>
handleSelectConversation({
@@ -531,7 +557,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-visualizer',
active: isActive('visualizer', 'visualizer'),
icon: '✨',
icon: <Waypoints className="h-4 w-4" />,
label: 'Mesh Visualizer',
onClick: () =>
handleSelectConversation({
@@ -543,7 +569,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-search',
active: isActive('search', 'search'),
icon: '🔍',
icon: <SearchIcon className="h-4 w-4" />,
label: 'Message Search',
onClick: () =>
handleSelectConversation({
@@ -555,7 +581,7 @@ export function Sidebar({
renderSidebarActionRow({
key: 'tool-cracker',
active: showCracker,
icon: '🔓',
icon: <LockOpen className="h-4 w-4" />,
label: (
<>
{showCracker ? 'Hide' : 'Show'} Room Finder
@@ -597,9 +623,11 @@ export function Sidebar({
}}
title={effectiveCollapsed ? `Expand ${title}` : `Collapse ${title}`}
>
<span className="text-[9px]" aria-hidden="true">
{effectiveCollapsed ? '▸' : '▾'}
</span>
{effectiveCollapsed ? (
<ChevronRight className="h-3.5 w-3.5" aria-hidden="true" />
) : (
<ChevronDown className="h-3.5 w-3.5" aria-hidden="true" />
)}
<span>{title}</span>
</button>
{(showSortToggle || unreadCount > 0) && (
@@ -639,44 +667,39 @@ export function Sidebar({
aria-label="Conversations"
>
{/* Header */}
<div className="flex justify-between items-center px-3 py-2.5 border-b border-border">
<h2 className="text-[10px] uppercase tracking-wider text-muted-foreground font-medium">
Conversations
</h2>
<div className="flex items-center gap-2 px-3 py-2 border-b border-border">
<div className="relative min-w-0 flex-1">
<Input
type="text"
placeholder="Search rooms/contacts..."
aria-label="Search conversations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className={cn('h-7 text-[13px] bg-background/50', searchQuery ? 'pr-8' : 'pr-3')}
/>
{searchQuery && (
<button
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
onClick={() => setSearchQuery('')}
title="Clear search"
aria-label="Clear search"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Button
variant="ghost"
size="sm"
onClick={onNewMessage}
title="New Message"
aria-label="New message"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground transition-colors"
className="h-7 w-7 shrink-0 p-0 text-muted-foreground hover:text-foreground transition-colors"
>
+
<SquarePen className="h-4 w-4" />
</Button>
</div>
{/* Search */}
<div className="relative px-3 py-2">
<Input
type="text"
placeholder="Search..."
aria-label="Search conversations"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="h-7 text-[13px] pr-8 bg-background/50"
/>
{searchQuery && (
<button
className="absolute right-4 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded"
onClick={() => setSearchQuery('')}
title="Clear search"
aria-label="Clear search"
>
×
</button>
)}
</div>
{/* List */}
<div className="flex-1 min-h-0 overflow-y-auto [contain:layout_paint]">
{/* Tools */}
@@ -696,9 +719,7 @@ export function Sidebar({
onKeyDown={handleKeyboardActivate}
onClick={onMarkAllRead}
>
<span className="text-muted-foreground text-xs" aria-hidden="true">
</span>
<CheckCheck className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span className="flex-1 truncate text-muted-foreground">Mark all as read</span>
</div>
)}

View File

@@ -1,9 +1,10 @@
import { useState } from 'react';
import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Menu, Moon, Sun } from 'lucide-react';
import type { HealthStatus, RadioConfig } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
import { handleKeyboardActivate } from '../utils/a11y';
import { applyTheme, getSavedTheme, THEME_CHANGE_EVENT } from '../utils/theme';
import { cn } from '@/lib/utils';
interface StatusBarProps {
@@ -21,14 +22,38 @@ export function StatusBar({
onSettingsClick,
onMenuClick,
}: StatusBarProps) {
const radioState =
health?.radio_state ??
(health?.radio_initializing
? 'initializing'
: health?.radio_connected
? 'connected'
: 'disconnected');
const connected = health?.radio_connected ?? false;
const initializing = health?.radio_initializing ?? false;
const statusLabel = initializing
? 'Radio Initializing'
: connected
? 'Radio OK'
: 'Radio Disconnected';
const statusLabel =
radioState === 'paused'
? 'Radio Paused'
: radioState === 'connecting'
? 'Radio Connecting'
: radioState === 'initializing'
? 'Radio Initializing'
: connected
? 'Radio OK'
: 'Radio Disconnected';
const [reconnecting, setReconnecting] = useState(false);
const [currentTheme, setCurrentTheme] = useState(getSavedTheme);
useEffect(() => {
const handleThemeChange = (event: Event) => {
const themeId = (event as CustomEvent<string>).detail;
setCurrentTheme(typeof themeId === 'string' && themeId ? themeId : getSavedTheme());
};
window.addEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener);
return () => {
window.removeEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener);
};
}, []);
const handleReconnect = async () => {
setReconnecting(true);
@@ -46,22 +71,28 @@ export function StatusBar({
}
};
const handleThemeToggle = () => {
const nextTheme = currentTheme === 'light' ? 'original' : 'light';
applyTheme(nextTheme);
setCurrentTheme(nextTheme);
};
return (
<header className="flex items-center gap-3 px-4 py-2.5 bg-card border-b border-border text-xs">
{/* Mobile menu button - only visible on small screens */}
{onMenuClick && (
<button
onClick={onMenuClick}
className="md:hidden p-1 bg-transparent border-none text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
className="md:hidden p-0.5 bg-transparent border-none text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
<Menu className="h-4 w-4" />
</button>
)}
<h1 className="text-base font-semibold tracking-tight mr-auto text-foreground flex items-center gap-1.5">
<svg
className="h-5 w-5 shrink-0 text-white"
className="h-4 w-4 shrink-0 text-white"
viewBox="0 0 512 512"
fill="currentColor"
aria-hidden="true"
@@ -77,7 +108,7 @@ export function StatusBar({
<div
className={cn(
'w-2 h-2 rounded-full transition-colors',
initializing
radioState === 'initializing' || radioState === 'connecting'
? 'bg-warning'
: connected
? 'bg-status-connected shadow-[0_0_6px_hsl(var(--status-connected)/0.5)]'
@@ -108,13 +139,13 @@ export function StatusBar({
</div>
)}
{!connected && !initializing && (
{(radioState === 'disconnected' || radioState === 'paused') && (
<button
onClick={handleReconnect}
disabled={reconnecting}
className="px-3 py-1 bg-warning/10 border border-warning/20 text-warning rounded-md text-xs cursor-pointer hover:bg-warning/15 transition-colors disabled:opacity-50 disabled:cursor-not-allowed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{reconnecting ? 'Reconnecting...' : 'Reconnect'}
{reconnecting ? 'Reconnecting...' : radioState === 'paused' ? 'Connect' : 'Reconnect'}
</button>
)}
<button
@@ -128,6 +159,18 @@ export function StatusBar({
>
{settingsMode ? 'Back to Chat' : 'Settings'}
</button>
<button
onClick={handleThemeToggle}
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
title={currentTheme === 'light' ? 'Switch to classic theme' : 'Switch to light theme'}
aria-label={currentTheme === 'light' ? 'Switch to classic theme' : 'Switch to light theme'}
>
{currentTheme === 'light' ? (
<Moon className="h-4 w-4" aria-hidden="true" />
) : (
<Sun className="h-4 w-4" aria-hidden="true" />
)}
</button>
</header>
);
}

View File

@@ -2,12 +2,14 @@ import { useState, useCallback, useEffect } from 'react';
import { Button } from '../ui/button';
export function ActionsPane({
onSendAdvert,
onSendZeroHopAdvert,
onSendFloodAdvert,
onSyncClock,
onReboot,
consoleLoading,
}: {
onSendAdvert: () => void;
onSendZeroHopAdvert: () => void;
onSendFloodAdvert: () => void;
onSyncClock: () => void;
onReboot: () => void;
consoleLoading: boolean;
@@ -36,8 +38,16 @@ export function ActionsPane({
<h3 className="text-sm font-medium">Actions</h3>
</div>
<div className="p-3 flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={onSendAdvert} disabled={consoleLoading}>
Send Advert
<Button variant="outline" size="sm" onClick={onSendZeroHopAdvert} disabled={consoleLoading}>
Zero Hop Advert
</Button>
<Button
variant="destructive"
size="sm"
onClick={onSendFloodAdvert}
disabled={consoleLoading}
>
Flood Advert
</Button>
<Button variant="outline" size="sm" onClick={onSyncClock} disabled={consoleLoading}>
Sync Clock

View File

@@ -80,6 +80,28 @@ export function formatAdvertInterval(val: string | null): string {
return `${trimmed}h`;
}
function formatFetchedRelative(fetchedAt: number): string {
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - fetchedAt) / 1000));
if (elapsedSeconds < 60) return 'Just now';
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
if (elapsedMinutes < 60) {
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? '' : 's'} ago`;
}
const elapsedHours = Math.floor(elapsedMinutes / 60);
return `${elapsedHours} hour${elapsedHours === 1 ? '' : 's'} ago`;
}
function formatFetchedTime(fetchedAt: number): string {
return new Date(fetchedAt).toLocaleTimeString([], {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
});
}
// --- Generic Pane Wrapper ---
export function RepeaterPane({
@@ -99,10 +121,22 @@ export function RepeaterPane({
className?: string;
contentClassName?: string;
}) {
const fetchedAt = state.fetched_at ?? null;
return (
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
<h3 className="text-sm font-medium">{title}</h3>
<div className="min-w-0">
<h3 className="text-sm font-medium">{title}</h3>
{fetchedAt && (
<p
className="text-[11px] text-muted-foreground"
title={new Date(fetchedAt).toLocaleString()}
>
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
</p>
)}
</div>
{onRefresh && (
<button
type="button"

View File

@@ -18,6 +18,7 @@ const TYPE_LABELS: Record<string, string> = {
bot: 'Bot',
webhook: 'Webhook',
apprise: 'Apprise',
sqs: 'Amazon SQS',
};
const TYPE_OPTIONS = [
@@ -26,6 +27,7 @@ const TYPE_OPTIONS = [
{ value: 'bot', label: 'Bot' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'apprise', label: 'Apprise' },
{ value: 'sqs', label: 'Amazon SQS' },
];
const DEFAULT_COMMUNITY_PACKET_TOPIC_TEMPLATE = 'meshcore/{IATA}/{PUBLIC_KEY}/packets';
@@ -60,41 +62,45 @@ function formatAppriseTargets(urls: string | undefined, maxLength = 80) {
return `${joined.slice(0, maxLength - 3)}...`;
}
function formatSqsQueueSummary(config: Record<string, unknown>) {
const queueUrl = ((config.queue_url as string) || '').trim();
if (!queueUrl) return 'No queue configured';
return queueUrl;
}
function getDefaultIntegrationName(type: string, configs: FanoutConfig[]) {
const label = TYPE_LABELS[type] || type;
const nextIndex = configs.filter((cfg) => cfg.type === type).length + 1;
return `${label} #${nextIndex}`;
}
const DEFAULT_BOT_CODE = `def bot(
sender_name: str | None,
sender_key: str | None,
message_text: str,
is_dm: bool,
channel_key: str | None,
channel_name: str | None,
sender_timestamp: int | None,
path: str | None,
is_outgoing: bool = False,
) -> str | list[str] | None:
const DEFAULT_BOT_CODE = `def bot(**kwargs) -> str | list[str] | None:
"""
Process messages and optionally return a reply.
Args:
sender_name: Display name of sender (may be None)
sender_key: 64-char hex public key (None for channel msgs)
message_text: The message content
is_dm: True for direct messages, False for channel
channel_key: 32-char hex key for channels, None for DMs
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
sender_timestamp: Sender's timestamp (unix seconds, may be None)
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
kwargs keys currently provided:
sender_name: Display name of sender (may be None)
sender_key: 64-char hex public key (None for channel msgs)
message_text: The message content
is_dm: True for direct messages, False for channel
channel_key: 32-char hex key for channels, None for DMs
channel_name: Channel name with hash (e.g. "#bot"), None for DMs
sender_timestamp: Sender's timestamp (unix seconds, may be None)
path: Hex-encoded routing path (may be None)
is_outgoing: True if this is our own outgoing message
path_bytes_per_hop: Bytes per hop in path (1, 2, or 3) when known
Returns:
None for no reply, a string for a single reply,
or a list of strings to send multiple messages in order
"""
sender_name = kwargs.get("sender_name")
message_text = kwargs.get("message_text", "")
channel_name = kwargs.get("channel_name")
is_outgoing = kwargs.get("is_outgoing", False)
path_bytes_per_hop = kwargs.get("path_bytes_per_hop")
# Don't reply to our own outgoing messages
if is_outgoing:
return None
@@ -998,6 +1004,111 @@ function WebhookConfigEditor({
);
}
function SqsConfigEditor({
config,
scope,
onChange,
onScopeChange,
}: {
config: Record<string, unknown>;
scope: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
onScopeChange: (scope: Record<string, unknown>) => void;
}) {
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
Send matched mesh events to an Amazon SQS queue for durable processing by workers, Lambdas,
or downstream automation.
</p>
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
Outgoing messages and any selected raw packets will be delivered exactly as forwarded by the
fanout scope, including decrypted/plaintext message content.
</div>
<div className="space-y-2">
<Label htmlFor="fanout-sqs-queue-url">Queue URL</Label>
<Input
id="fanout-sqs-queue-url"
type="url"
placeholder="https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events"
value={(config.queue_url as string) || ''}
onChange={(e) => onChange({ ...config, queue_url: e.target.value })}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fanout-sqs-region">Region (optional)</Label>
<Input
id="fanout-sqs-region"
type="text"
placeholder="us-east-1"
value={(config.region_name as string) || ''}
onChange={(e) => onChange({ ...config, region_name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="fanout-sqs-endpoint">Endpoint URL (optional)</Label>
<Input
id="fanout-sqs-endpoint"
type="url"
placeholder="http://localhost:4566"
value={(config.endpoint_url as string) || ''}
onChange={(e) => onChange({ ...config, endpoint_url: e.target.value })}
/>
<p className="text-xs text-muted-foreground">Useful for LocalStack or custom endpoints</p>
</div>
</div>
<Separator />
<div className="space-y-2">
<Label>Static Credentials (optional)</Label>
<p className="text-xs text-muted-foreground">
Leave blank to use the server&apos;s normal AWS credential chain.
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="fanout-sqs-access-key">Access Key ID</Label>
<Input
id="fanout-sqs-access-key"
type="text"
value={(config.access_key_id as string) || ''}
onChange={(e) => onChange({ ...config, access_key_id: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="fanout-sqs-secret-key">Secret Access Key</Label>
<Input
id="fanout-sqs-secret-key"
type="password"
value={(config.secret_access_key as string) || ''}
onChange={(e) => onChange({ ...config, secret_access_key: e.target.value })}
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="fanout-sqs-session-token">Session Token (optional)</Label>
<Input
id="fanout-sqs-session-token"
type="password"
value={(config.session_token as string) || ''}
onChange={(e) => onChange({ ...config, session_token: e.target.value })}
/>
</div>
<Separator />
<ScopeSelector scope={scope} onChange={onScopeChange} showRawPackets />
</div>
);
}
export function SettingsFanoutSection({
health,
onHealthRefresh,
@@ -1208,6 +1319,14 @@ export function SettingsFanoutSection({
preserve_identity: true,
include_path: true,
},
sqs: {
queue_url: '',
region_name: '',
endpoint_url: '',
access_key_id: '',
secret_access_key: '',
session_token: '',
},
};
const defaultScopes: Record<string, Record<string, unknown>> = {
mqtt_private: { messages: 'all', raw_packets: 'all' },
@@ -1215,6 +1334,7 @@ export function SettingsFanoutSection({
bot: { messages: 'all', raw_packets: 'none' },
webhook: { messages: 'all', raw_packets: 'none' },
apprise: { messages: 'all', raw_packets: 'none' },
sqs: { messages: 'all', raw_packets: 'none' },
};
setAddMenuOpen(false);
setEditingId(null);
@@ -1296,6 +1416,15 @@ export function SettingsFanoutSection({
/>
)}
{detailType === 'sqs' && (
<SqsConfigEditor
config={editConfig}
scope={editScope}
onChange={setEditConfig}
onScopeChange={setEditScope}
/>
)}
<Separator />
<div className="flex gap-2">
@@ -1328,7 +1457,8 @@ export function SettingsFanoutSection({
return (
<div className={cn('mx-auto w-full max-w-[800px] space-y-4', className)}>
<div className="rounded-md border border-warning/50 bg-warning/10 px-4 py-3 text-sm text-warning">
Integrations are an experimental feature in open beta.
Integrations are an experimental feature in open beta, and allow you to fanout raw and
decrypted messages across multiple services for automation, analysis, or archiving.
</div>
{health?.bots_disabled && (
@@ -1520,6 +1650,17 @@ export function SettingsFanoutSection({
</div>
</div>
)}
{cfg.type === 'sqs' && (
<div className="space-y-1 border-t border-input px-3 py-2 text-xs text-muted-foreground">
<div className="break-all">
Queue:{' '}
<code>
{formatSqsQueueSummary(cfg.config as Record<string, unknown>)}
</code>
</div>
</div>
)}
</div>
);
})}

View File

@@ -1,7 +1,9 @@
import { useState } from 'react';
import { Logs, MessageSquare } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Separator } from '../ui/separator';
import { ContactAvatar } from '../ContactAvatar';
import {
captureLastViewedConversationFromHash,
getReopenLastConversationEnabled,
@@ -40,6 +42,7 @@ export function SettingsLocalSection({
<div className="space-y-1">
<Label>Color Scheme</Label>
<ThemeSelector />
<ThemePreview className="mt-6" />
</div>
<Separator />
@@ -91,3 +94,131 @@ export function SettingsLocalSection({
</div>
);
}
function ThemePreview({ className }: { className?: string }) {
return (
<div className={`rounded-lg border border-border bg-card p-3 ${className ?? ''}`}>
<p className="text-xs text-muted-foreground mb-3">
Preview alert, message, sidebar, and badge contrast for the selected theme.
</p>
<div className="space-y-2">
<PreviewBanner className="border border-status-connected/30 bg-status-connected/15 text-status-connected">
Connected preview: radio link healthy and syncing.
</PreviewBanner>
<PreviewBanner className="border border-warning/50 bg-warning/10 text-warning">
Warning preview: packet audit suggests missing history.
</PreviewBanner>
<PreviewBanner className="border border-destructive/30 bg-destructive/10 text-destructive">
Error preview: radio reconnect failed.
</PreviewBanner>
</div>
<div className="mt-4 space-y-2">
<PreviewMessage
sender="Alice"
bubbleClassName="bg-msg-incoming text-foreground"
text="Hello, mesh!"
/>
<PreviewMessage
sender="You"
alignRight
bubbleClassName="bg-msg-outgoing text-foreground"
text="Hi there! I'm using RemoteTerm."
/>
</div>
<div className="mt-4 rounded-md border border-border bg-background p-2">
<p className="mb-2 text-[11px] font-medium text-muted-foreground">Sidebar preview</p>
<div className="space-y-1">
<PreviewSidebarRow
active
leading={
<span
className="flex h-6 w-6 items-center justify-center rounded-md bg-primary/10 text-primary"
aria-hidden="true"
>
<Logs className="h-3.5 w-3.5" />
</span>
}
label="Packet Feed"
/>
<PreviewSidebarRow
leading={<ContactAvatar name="Alice" publicKey={'ab'.repeat(32)} size={24} />}
label="Alice"
badge={
<span className="rounded-full bg-badge-unread/90 px-1.5 py-0.5 text-[10px] font-semibold text-badge-unread-foreground">
3
</span>
}
/>
<PreviewSidebarRow
leading={<ContactAvatar name="Mesh Ops" publicKey={'cd'.repeat(32)} size={24} />}
label="Mesh Ops"
badge={
<span className="rounded-full bg-badge-mention px-1.5 py-0.5 text-[10px] font-semibold text-badge-mention-foreground">
@2
</span>
}
/>
</div>
</div>
</div>
);
}
function PreviewBanner({ children, className }: { children: React.ReactNode; className: string }) {
return <div className={`rounded-md px-3 py-2 text-xs ${className}`}>{children}</div>;
}
function PreviewMessage({
sender,
text,
bubbleClassName,
alignRight = false,
}: {
sender: string;
text: string;
bubbleClassName: string;
alignRight?: boolean;
}) {
return (
<div className={`flex ${alignRight ? 'justify-end' : 'justify-start'}`}>
<div className={`max-w-[85%] ${alignRight ? 'items-end' : 'items-start'} flex flex-col`}>
<span className="mb-1 text-[11px] text-muted-foreground">{sender}</span>
<div className={`rounded-2xl px-3 py-2 text-sm break-words ${bubbleClassName}`}>{text}</div>
</div>
</div>
);
}
function PreviewSidebarRow({
leading,
label,
badge,
active = false,
}: {
leading: React.ReactNode;
label: string;
badge?: React.ReactNode;
active?: boolean;
}) {
return (
<div
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
}`}
>
{leading}
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
{label}
</span>
{badge}
{!badge && (
<span className="text-muted-foreground" aria-hidden="true">
<MessageSquare className="h-3.5 w-3.5" />
</span>
)}
</div>
);
}

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
import { MapPinned } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
@@ -23,6 +24,8 @@ export function SettingsRadioSection({
onSaveAppSettings,
onSetPrivateKey,
onReboot,
onDisconnect,
onReconnect,
onAdvertise,
onClose,
className,
@@ -35,6 +38,8 @@ export function SettingsRadioSection({
onSaveAppSettings: (update: AppSettingsUpdate) => Promise<void>;
onSetPrivateKey: (key: string) => Promise<void>;
onReboot: () => Promise<void>;
onDisconnect: () => Promise<void>;
onReconnect: () => Promise<void>;
onAdvertise: () => Promise<void>;
onClose: () => void;
className?: string;
@@ -49,6 +54,7 @@ export function SettingsRadioSection({
const [sf, setSf] = useState('');
const [cr, setCr] = useState('');
const [pathHashMode, setPathHashMode] = useState('0');
const [advertLocationSource, setAdvertLocationSource] = useState<'off' | 'current'>('current');
const [gettingLocation, setGettingLocation] = useState(false);
const [busy, setBusy] = useState(false);
const [rebooting, setRebooting] = useState(false);
@@ -69,6 +75,7 @@ export function SettingsRadioSection({
// Advertise state
const [advertising, setAdvertising] = useState(false);
const [connectionBusy, setConnectionBusy] = useState(false);
useEffect(() => {
setName(config.name);
@@ -80,6 +87,7 @@ export function SettingsRadioSection({
setSf(String(config.radio.sf));
setCr(String(config.radio.cr));
setPathHashMode(String(config.path_hash_mode));
setAdvertLocationSource(config.advert_location_source ?? 'current');
}, [config]);
useEffect(() => {
@@ -169,6 +177,9 @@ export function SettingsRadioSection({
lat: parsedLat,
lon: parsedLon,
tx_power: parsedTxPower,
...(advertLocationSource !== (config.advert_location_source ?? 'current')
? { advert_location_source: advertLocationSource }
: {}),
radio: {
freq: parsedFreq,
bw: parsedBw,
@@ -284,24 +295,82 @@ export function SettingsRadioSection({
}
};
const radioState =
health?.radio_state ?? (health?.radio_initializing ? 'initializing' : 'disconnected');
const connectionActionLabel =
radioState === 'paused'
? 'Reconnect'
: radioState === 'connected' || radioState === 'initializing'
? 'Disconnect'
: 'Stop Trying';
const connectionStatusLabel =
radioState === 'connected'
? health?.connection_info || 'Connected'
: radioState === 'initializing'
? `Initializing ${health?.connection_info || 'radio'}`
: radioState === 'connecting'
? `Attempting to connect${health?.connection_info ? ` to ${health.connection_info}` : ''}`
: radioState === 'paused'
? `Connection paused${health?.connection_info ? ` (${health.connection_info})` : ''}`
: 'Not connected';
const handleConnectionAction = async () => {
setConnectionBusy(true);
try {
if (radioState === 'paused') {
await onReconnect();
toast.success('Reconnect requested');
} else {
await onDisconnect();
toast.success('Radio connection paused');
}
} catch (err) {
toast.error('Failed to change radio connection state', {
description: err instanceof Error ? err.message : 'Check radio connection and try again',
});
} finally {
setConnectionBusy(false);
}
};
return (
<div className={className}>
{/* Connection display */}
<div className="space-y-2">
<div className="space-y-3">
<Label>Connection</Label>
{health?.connection_info ? (
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-status-connected" />
<code className="px-2 py-1 bg-muted rounded text-foreground text-sm">
{health.connection_info}
</code>
</div>
) : (
<div className="flex items-center gap-2 text-muted-foreground">
<div className="w-2 h-2 rounded-full bg-status-disconnected" />
<span>Not connected</span>
</div>
)}
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
radioState === 'connected'
? 'bg-status-connected'
: radioState === 'initializing' || radioState === 'connecting'
? 'bg-warning'
: 'bg-status-disconnected'
}`}
/>
<span
className={
radioState === 'paused' || radioState === 'disconnected'
? 'text-muted-foreground'
: ''
}
>
{connectionStatusLabel}
</span>
</div>
<Button
type="button"
variant="outline"
onClick={handleConnectionAction}
disabled={connectionBusy}
className="w-full"
>
{connectionBusy ? `${connectionActionLabel}...` : connectionActionLabel}
</Button>
<p className="text-xs text-muted-foreground">
Disconnect pauses automatic reconnect attempts so another device can use the radio.
</p>
</div>
{/* Radio Name */}
@@ -406,7 +475,14 @@ export function SettingsRadioSection({
onClick={handleGetLocation}
disabled={gettingLocation}
>
{gettingLocation ? 'Getting...' : '📍 Use My Location'}
{gettingLocation ? (
'Getting...'
) : (
<>
<MapPinned className="mr-1.5 h-4 w-4" aria-hidden="true" />
Use My Location
</>
)}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
@@ -435,6 +511,25 @@ export function SettingsRadioSection({
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="advert-location-source">Advert Location Source</Label>
<select
id="advert-location-source"
value={advertLocationSource}
onChange={(e) => setAdvertLocationSource(e.target.value as 'off' | 'current')}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="off">Off</option>
<option value="current">Include Node Location</option>
</select>
<p className="text-xs text-muted-foreground">
Companion-radio firmware does not distinguish between saved coordinates and live GPS
here. When enabled, adverts include the node&apos;s current location state. That may be
the last coordinates you set from RemoteTerm or live GPS coordinates if the node itself
is already updating them. RemoteTerm cannot enable GPS on the node through the interface
library.
</p>
</div>
</div>
{config.path_hash_mode_supported && (
@@ -452,7 +547,7 @@ export function SettingsRadioSection({
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-xs text-amber-200">
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
<p>
ALL nodes along a message&apos;s route &mdash; your radio, every repeater, and the
@@ -575,8 +670,8 @@ export function SettingsRadioSection({
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Favorite contacts load first, then recent non-repeater contacts until this limit is
reached (1-1000)
Configured radio contact capacity. Favorites reload first, then background maintenance
refills to about 80% of this value and offloads once occupancy reaches about 95%.
</p>
</div>

View File

@@ -25,13 +25,13 @@ export function ThemeSelector() {
};
return (
<fieldset className="flex flex-wrap gap-2">
<fieldset className="flex flex-wrap gap-2 md:grid md:grid-cols-4">
<legend className="sr-only">Color theme</legend>
{THEMES.map((theme) => (
<label
key={theme.id}
className={
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer border transition-colors focus-within:ring-2 focus-within:ring-ring ' +
'flex items-center gap-2 px-2 py-1.5 rounded-md cursor-pointer border transition-colors focus-within:ring-2 focus-within:ring-ring md:w-full ' +
(current === theme.id
? 'border-primary bg-primary/5'
: 'border-transparent hover:bg-accent/50')

View File

@@ -1,3 +1,13 @@
import {
BarChart3,
Database,
Info,
MonitorCog,
RadioTower,
Share2,
type LucideIcon,
} from 'lucide-react';
export type SettingsSection = 'radio' | 'local' | 'database' | 'fanout' | 'statistics' | 'about';
export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
@@ -10,10 +20,19 @@ export const SETTINGS_SECTION_ORDER: SettingsSection[] = [
];
export const SETTINGS_SECTION_LABELS: Record<SettingsSection, string> = {
radio: '📻 Radio',
local: '🖥️ Local Configuration',
database: '🗄️ Database & Messaging',
fanout: '📤 MQTT & Automation',
statistics: '📊 Statistics',
radio: 'Radio',
local: 'Local Configuration',
database: 'Database & Messaging',
fanout: 'MQTT & Automation',
statistics: 'Statistics',
about: 'About',
};
export const SETTINGS_SECTION_ICONS: Record<SettingsSection, LucideIcon> = {
radio: RadioTower,
local: MonitorCog,
database: Database,
fanout: Share2,
statistics: BarChart3,
about: Info,
};

View File

@@ -298,7 +298,7 @@ export function VisualizerControls({
</button>
<button
onClick={onClearAndReset}
className="mt-1 px-3 py-1.5 bg-yellow-500/20 hover:bg-yellow-500/30 text-yellow-500 rounded text-xs transition-colors"
className="mt-1 rounded border border-warning/40 bg-warning/10 px-3 py-1.5 text-warning text-xs transition-colors hover:bg-warning/20"
title="Clear all nodes and links from the visualization - packets are preserved"
>
Clear &amp; Reset

View File

@@ -9,3 +9,4 @@ export { useContactsAndChannels } from './useContactsAndChannels';
export { useRealtimeAppState } from './useRealtimeAppState';
export { useConversationActions } from './useConversationActions';
export { useConversationNavigation } from './useConversationNavigation';
export { useBrowserNotifications } from './useBrowserNotifications';

View File

@@ -0,0 +1,207 @@
import { useCallback, useEffect, useState } from 'react';
import { toast } from '../components/ui/sonner';
import type { Message } from '../types';
import { getStateKey } from '../utils/conversationState';
const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation';
const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
type NotificationPermissionState = NotificationPermission | 'unsupported';
type ConversationNotificationMap = Record<string, boolean>;
function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string {
return getStateKey(type, id);
}
function readStoredEnabledMap(): ConversationNotificationMap {
if (typeof window === 'undefined') {
return {};
}
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (!raw) {
return {};
}
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object') {
return {};
}
return Object.fromEntries(
Object.entries(parsed).filter(([key, value]) => typeof key === 'string' && value === true)
);
} catch {
return {};
}
}
function writeStoredEnabledMap(enabledByConversation: ConversationNotificationMap) {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(enabledByConversation));
}
function getInitialPermission(): NotificationPermissionState {
if (typeof window === 'undefined' || !('Notification' in window)) {
return 'unsupported';
}
return window.Notification.permission;
}
function shouldShowDesktopNotification(): boolean {
if (typeof document === 'undefined') {
return false;
}
return document.visibilityState !== 'visible' || !document.hasFocus();
}
function getMessageConversationNotificationKey(message: Message): string | null {
if (message.type === 'PRIV' && message.conversation_key) {
return getConversationNotificationKey('contact', message.conversation_key);
}
if (message.type === 'CHAN' && message.conversation_key) {
return getConversationNotificationKey('channel', message.conversation_key);
}
return null;
}
function buildNotificationTitle(message: Message): string {
if (message.type === 'PRIV') {
return message.sender_name
? `New message from ${message.sender_name}`
: `New message from ${message.conversation_key.slice(0, 12)}`;
}
const roomName = message.channel_name || message.conversation_key.slice(0, 8);
return `New message in ${roomName}`;
}
function buildPreviewNotificationTitle(type: 'channel' | 'contact', label: string): string {
return type === 'contact' ? `New message from ${label}` : `New message in ${label}`;
}
function buildMessageNotificationHash(message: Message): string | null {
if (message.type === 'PRIV' && message.conversation_key) {
const label = message.sender_name || message.conversation_key.slice(0, 12);
return `#contact/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
}
if (message.type === 'CHAN' && message.conversation_key) {
const label = message.channel_name || message.conversation_key.slice(0, 8);
return `#channel/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
}
return null;
}
export function useBrowserNotifications() {
const [permission, setPermission] = useState<NotificationPermissionState>(getInitialPermission);
const [enabledByConversation, setEnabledByConversation] =
useState<ConversationNotificationMap>(readStoredEnabledMap);
useEffect(() => {
setPermission(getInitialPermission());
}, []);
const isConversationNotificationsEnabled = useCallback(
(type: 'channel' | 'contact', id: string) =>
permission === 'granted' &&
enabledByConversation[getConversationNotificationKey(type, id)] === true,
[enabledByConversation, permission]
);
const toggleConversationNotifications = useCallback(
async (type: 'channel' | 'contact', id: string, label: string) => {
const conversationKey = getConversationNotificationKey(type, id);
if (enabledByConversation[conversationKey]) {
setEnabledByConversation((prev) => {
const next = { ...prev };
delete next[conversationKey];
writeStoredEnabledMap(next);
return next;
});
toast.success(`${label} notifications disabled`);
return;
}
if (permission === 'unsupported') {
toast.error('Browser notifications unavailable', {
description: 'This browser does not support desktop notifications.',
});
return;
}
if (permission === 'denied') {
toast.error('Browser notifications blocked', {
description: 'Allow notifications in your browser settings, then try again.',
});
return;
}
const nextPermission = await window.Notification.requestPermission();
setPermission(nextPermission);
if (nextPermission === 'granted') {
setEnabledByConversation((prev) => {
const next = {
...prev,
[conversationKey]: true,
};
writeStoredEnabledMap(next);
return next;
});
new window.Notification(buildPreviewNotificationTitle(type, label), {
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
icon: NOTIFICATION_ICON_PATH,
tag: `meshcore-notification-preview-${conversationKey}`,
});
toast.success(`${label} notifications enabled`);
return;
}
toast.error('Browser notifications not enabled', {
description:
nextPermission === 'denied'
? 'Permission was denied by the browser.'
: 'Permission request was dismissed.',
});
},
[enabledByConversation, permission]
);
const notifyIncomingMessage = useCallback(
(message: Message) => {
const conversationKey = getMessageConversationNotificationKey(message);
if (
permission !== 'granted' ||
!conversationKey ||
enabledByConversation[conversationKey] !== true ||
!shouldShowDesktopNotification()
) {
return;
}
const notification = new window.Notification(buildNotificationTitle(message), {
body: message.text,
icon: NOTIFICATION_ICON_PATH,
tag: `meshcore-message-${message.id}`,
});
notification.onclick = () => {
const hash = buildMessageNotificationHash(message);
if (hash) {
window.open(`${window.location.origin}${window.location.pathname}${hash}`, '_self');
}
window.focus();
notification.close();
};
},
[enabledByConversation, permission]
);
return {
notificationsSupported: permission !== 'unsupported',
notificationsPermission: permission,
isConversationNotificationsEnabled,
toggleConversationNotifications,
notifyIncomingMessage,
};
}

View File

@@ -58,7 +58,7 @@ export function useContactsAndChannels({
setActiveConversation({
type: 'contact',
id: created.public_key,
name: getContactDisplayName(created.name, created.public_key),
name: getContactDisplayName(created.name, created.public_key, created.last_advert),
});
},
[fetchAllContacts, setActiveConversation]

View File

@@ -137,6 +137,7 @@ export function useConversationMessages(
const abortControllerRef = useRef<AbortController | null>(null);
const fetchingConversationIdRef = useRef<string | null>(null);
const latestReconcileRequestIdRef = useRef(0);
const messagesRef = useRef<Message[]>([]);
const hasOlderMessagesRef = useRef(false);
const hasNewerMessagesRef = useRef(false);
@@ -194,8 +195,12 @@ export function useConversationMessages(
}
const messagesWithPendingAck = data.map((msg) => applyPendingAck(msg));
setMessages(messagesWithPendingAck);
syncSeenContent(messagesWithPendingAck);
const merged = messageCache.reconcile(messagesRef.current, messagesWithPendingAck);
const nextMessages = merged ?? messagesRef.current;
if (merged) {
setMessages(merged);
}
syncSeenContent(nextMessages);
setHasOlderMessages(messagesWithPendingAck.length >= MESSAGE_PAGE_SIZE);
} catch (err) {
if (isAbortError(err)) {
@@ -215,7 +220,7 @@ export function useConversationMessages(
);
const reconcileFromBackend = useCallback(
(conversation: Conversation, signal: AbortSignal) => {
(conversation: Conversation, signal: AbortSignal, requestId: number) => {
const conversationId = conversation.id;
api
.getMessages(
@@ -228,16 +233,15 @@ export function useConversationMessages(
)
.then((data) => {
if (fetchingConversationIdRef.current !== conversationId) return;
if (latestReconcileRequestIdRef.current !== requestId) return;
const dataWithPendingAck = data.map((msg) => applyPendingAck(msg));
setHasOlderMessages(dataWithPendingAck.length >= MESSAGE_PAGE_SIZE);
const merged = messageCache.reconcile(messagesRef.current, dataWithPendingAck);
if (!merged) return;
setMessages(merged);
syncSeenContent(merged);
if (dataWithPendingAck.length >= MESSAGE_PAGE_SIZE) {
setHasOlderMessages(true);
}
})
.catch((err) => {
if (isAbortError(err)) return;
@@ -352,7 +356,9 @@ export function useConversationMessages(
const triggerReconcile = useCallback(() => {
if (!isMessageConversation(activeConversation)) return;
const controller = new AbortController();
reconcileFromBackend(activeConversation, controller.signal);
const requestId = latestReconcileRequestIdRef.current + 1;
latestReconcileRequestIdRef.current = requestId;
reconcileFromBackend(activeConversation, controller.signal, requestId);
}, [activeConversation, reconcileFromBackend]);
useEffect(() => {
@@ -365,6 +371,7 @@ export function useConversationMessages(
const conversationChanged = prevId !== newId;
fetchingConversationIdRef.current = newId;
prevConversationIdRef.current = newId;
latestReconcileRequestIdRef.current = 0;
// Preserve around-loaded context on the same conversation when search clears targetMessageId.
if (!conversationChanged && !targetMessageId) {
@@ -433,7 +440,9 @@ export function useConversationMessages(
seenMessageContent.current = new Set(cached.seenContent);
setHasOlderMessages(cached.hasOlderMessages);
setMessagesLoading(false);
reconcileFromBackend(activeConversation, controller.signal);
const requestId = latestReconcileRequestIdRef.current + 1;
latestReconcileRequestIdRef.current = requestId;
reconcileFromBackend(activeConversation, controller.signal, requestId);
} else {
void fetchLatestMessages(true, controller.signal);
}

View File

@@ -14,6 +14,7 @@ interface UseConversationNavigationResult {
infoPaneContactKey: string | null;
infoPaneFromChannel: boolean;
infoPaneChannelKey: string | null;
searchPrefillRequest: { query: string; nonce: number } | null;
handleOpenContactInfo: (publicKey: string, fromChannel?: boolean) => void;
handleCloseContactInfo: () => void;
handleOpenChannelInfo: (channelKey: string) => void;
@@ -24,6 +25,7 @@ interface UseConversationNavigationResult {
) => void;
handleNavigateToChannel: (channelKey: string) => void;
handleNavigateToMessage: (target: SearchNavigateTarget) => void;
handleOpenSearchWithQuery: (query: string) => void;
}
export function useConversationNavigation({
@@ -34,6 +36,10 @@ export function useConversationNavigation({
const [infoPaneContactKey, setInfoPaneContactKey] = useState<string | null>(null);
const [infoPaneFromChannel, setInfoPaneFromChannel] = useState(false);
const [infoPaneChannelKey, setInfoPaneChannelKey] = useState<string | null>(null);
const [searchPrefillRequest, setSearchPrefillRequest] = useState<{
query: string;
nonce: number;
} | null>(null);
const handleOpenContactInfo = useCallback((publicKey: string, fromChannel?: boolean) => {
setInfoPaneContactKey(publicKey);
@@ -95,12 +101,30 @@ export function useConversationNavigation({
[handleSelectConversationWithTargetReset]
);
const handleOpenSearchWithQuery = useCallback(
(query: string) => {
setTargetMessageId(null);
setInfoPaneContactKey(null);
handleSelectConversationWithTargetReset({
type: 'search',
id: 'search',
name: 'Message Search',
});
setSearchPrefillRequest((prev) => ({
query,
nonce: (prev?.nonce ?? 0) + 1,
}));
},
[handleSelectConversationWithTargetReset]
);
return {
targetMessageId,
setTargetMessageId,
infoPaneContactKey,
infoPaneFromChannel,
infoPaneChannelKey,
searchPrefillRequest,
handleOpenContactInfo,
handleCloseContactInfo,
handleOpenChannelInfo,
@@ -108,5 +132,6 @@ export function useConversationNavigation({
handleSelectConversationWithTargetReset,
handleNavigateToChannel,
handleNavigateToMessage,
handleOpenSearchWithQuery,
};
}

View File

@@ -151,7 +151,7 @@ export function useConversationRouter({
setActiveConversationState({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
});
hasSetDefaultConversation.current = true;
return;
@@ -179,7 +179,7 @@ export function useConversationRouter({
setActiveConversationState({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key),
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
});
hasSetDefaultConversation.current = true;
return;

View File

@@ -69,6 +69,21 @@ export function useRadioControl() {
pollUntilReconnected();
}, [fetchConfig]);
const handleDisconnect = useCallback(async () => {
await api.disconnectRadio();
const pausedHealth = await api.getHealth();
setHealth(pausedHealth);
}, []);
const handleReconnect = useCallback(async () => {
await api.reconnectRadio();
const refreshedHealth = await api.getHealth();
setHealth(refreshedHealth);
if (refreshedHealth.radio_connected) {
await fetchConfig();
}
}, [fetchConfig]);
const handleAdvertise = useCallback(async () => {
try {
await api.sendAdvertisement();
@@ -100,6 +115,8 @@ export function useRadioControl() {
handleSaveConfig,
handleSetPrivateKey,
handleReboot,
handleDisconnect,
handleReconnect,
handleAdvertise,
handleHealthRefresh,
};

View File

@@ -11,6 +11,7 @@ import type { UseWebSocketOptions } from '../useWebSocket';
import { toast } from '../components/ui/sonner';
import { getStateKey } from '../utils/conversationState';
import { mergeContactIntoList } from '../utils/contactMerge';
import { getContactDisplayName } from '../utils/pubkey';
import { appendRawPacketUnique } from '../utils/rawPacketIdentity';
import { getMessageContentKey } from './useConversationMessages';
import type {
@@ -40,10 +41,12 @@ interface UseRealtimeAppStateArgs {
addMessageIfNew: (msg: Message) => boolean;
trackNewMessage: (msg: Message) => void;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
checkMention: (text: string) => boolean;
pendingDeleteFallbackRef: MutableRefObject<boolean>;
setActiveConversation: (conv: Conversation | null) => void;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
notifyIncomingMessage?: (msg: Message) => void;
maxRawPackets?: number;
}
@@ -99,10 +102,12 @@ export function useRealtimeAppState({
addMessageIfNew,
trackNewMessage,
incrementUnread,
renameConversationState,
checkMention,
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
notifyIncomingMessage,
maxRawPackets = 500,
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
const mergeChannelIntoList = useCallback(
@@ -126,6 +131,13 @@ export function useRealtimeAppState({
const prev = prevHealthRef.current;
prevHealthRef.current = data;
setHealth(data);
const nextRadioState =
data.radio_state ??
(data.radio_initializing
? 'initializing'
: data.radio_connected
? 'connected'
: 'disconnected');
const initializationCompleted =
prev !== null &&
prev.radio_connected &&
@@ -142,9 +154,13 @@ export function useRealtimeAppState({
});
fetchConfig();
} else {
toast.error('Radio disconnected', {
description: 'Check radio connection and power',
});
if (nextRadioState === 'paused') {
toast.success('Radio connection paused');
} else {
toast.error('Radio disconnected', {
description: 'Check radio connection and power',
});
}
}
}
@@ -180,18 +196,19 @@ export function useRealtimeAppState({
activeConversationRef.current,
msg
);
let isNewMessage = false;
if (isForActiveConversation && !hasNewerMessagesRef.current) {
addMessageIfNew(msg);
isNewMessage = addMessageIfNew(msg);
}
trackNewMessage(msg);
const contentKey = getMessageContentKey(msg);
if (!isForActiveConversation) {
const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey);
isNewMessage = messageCache.addMessage(msg.conversation_key, msg, contentKey);
if (!msg.outgoing && isNew) {
if (!msg.outgoing && isNewMessage) {
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
@@ -203,10 +220,36 @@ export function useRealtimeAppState({
}
}
}
if (!msg.outgoing && isNewMessage) {
notifyIncomingMessage?.(msg);
}
},
onContact: (contact: Contact) => {
setContacts((prev) => mergeContactIntoList(prev, contact));
},
onContactResolved: (previousPublicKey: string, contact: Contact) => {
setContacts((prev) =>
mergeContactIntoList(
prev.filter((candidate) => candidate.public_key !== previousPublicKey),
contact
)
);
messageCache.rename(previousPublicKey, contact.public_key);
renameConversationState(
getStateKey('contact', previousPublicKey),
getStateKey('contact', contact.public_key)
);
const active = activeConversationRef.current;
if (active?.type === 'contact' && active.id === previousPublicKey) {
setActiveConversation({
type: 'contact',
id: contact.public_key,
name: getContactDisplayName(contact.name, contact.public_key, contact.last_advert),
});
}
},
onChannel: (channel: Channel) => {
mergeChannelIntoList(channel);
},
@@ -246,6 +289,7 @@ export function useRealtimeAppState({
fetchConfig,
hasNewerMessagesRef,
incrementUnread,
renameConversationState,
maxRawPackets,
mergeChannelIntoList,
pendingDeleteFallbackRef,
@@ -259,6 +303,7 @@ export function useRealtimeAppState({
trackNewMessage,
triggerReconcile,
updateMessageAck,
notifyIncomingMessage,
]
);
}

View File

@@ -17,6 +17,7 @@ import type {
const MAX_RETRIES = 3;
const RETRY_DELAY_MS = 2000;
const MAX_CACHED_REPEATERS = 20;
interface ConsoleEntry {
command: string;
@@ -35,7 +36,15 @@ interface PaneData {
lppTelemetry: RepeaterLppTelemetryResponse | null;
}
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null };
interface RepeaterDashboardCacheEntry {
loggedIn: boolean;
loginError: string | null;
paneData: PaneData;
paneStates: Record<PaneName, PaneState>;
consoleHistory: ConsoleEntry[];
}
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null, fetched_at: null };
function createInitialPaneStates(): Record<PaneName, PaneState> {
return {
@@ -61,6 +70,67 @@ function createInitialPaneData(): PaneData {
};
}
const repeaterDashboardCache = new Map<string, RepeaterDashboardCacheEntry>();
function clonePaneData(data: PaneData): PaneData {
return { ...data };
}
function normalizePaneStates(paneStates: Record<PaneName, PaneState>): Record<PaneName, PaneState> {
return {
status: { ...paneStates.status, loading: false },
neighbors: { ...paneStates.neighbors, loading: false },
acl: { ...paneStates.acl, loading: false },
radioSettings: { ...paneStates.radioSettings, loading: false },
advertIntervals: { ...paneStates.advertIntervals, loading: false },
ownerInfo: { ...paneStates.ownerInfo, loading: false },
lppTelemetry: { ...paneStates.lppTelemetry, loading: false },
};
}
function cloneConsoleHistory(consoleHistory: ConsoleEntry[]): ConsoleEntry[] {
return consoleHistory.map((entry) => ({ ...entry }));
}
function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry | null {
if (!publicKey) return null;
const cached = repeaterDashboardCache.get(publicKey);
if (!cached) return null;
repeaterDashboardCache.delete(publicKey);
repeaterDashboardCache.set(publicKey, cached);
return {
loggedIn: cached.loggedIn,
loginError: cached.loginError,
paneData: clonePaneData(cached.paneData),
paneStates: normalizePaneStates(cached.paneStates),
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
};
}
function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
repeaterDashboardCache.delete(publicKey);
repeaterDashboardCache.set(publicKey, {
loggedIn: entry.loggedIn,
loginError: entry.loginError,
paneData: clonePaneData(entry.paneData),
paneStates: normalizePaneStates(entry.paneStates),
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
});
if (repeaterDashboardCache.size > MAX_CACHED_REPEATERS) {
const lruKey = repeaterDashboardCache.keys().next().value as string | undefined;
if (lruKey) {
repeaterDashboardCache.delete(lruKey);
}
}
}
export function resetRepeaterDashboardCacheForTests() {
repeaterDashboardCache.clear();
}
// Maps pane name to the API call
function fetchPaneData(publicKey: string, pane: PaneName) {
switch (pane) {
@@ -94,7 +164,8 @@ export interface UseRepeaterDashboardResult {
refreshPane: (pane: PaneName) => Promise<void>;
loadAll: () => Promise<void>;
sendConsoleCommand: (command: string) => Promise<void>;
sendAdvert: () => Promise<void>;
sendZeroHopAdvert: () => Promise<void>;
sendFloodAdvert: () => Promise<void>;
rebootRepeater: () => Promise<void>;
syncClock: () => Promise<void>;
}
@@ -102,15 +173,24 @@ export interface UseRepeaterDashboardResult {
export function useRepeaterDashboard(
activeConversation: Conversation | null
): UseRepeaterDashboardResult {
const [loggedIn, setLoggedIn] = useState(false);
const conversationId =
activeConversation && activeConversation.type === 'contact' ? activeConversation.id : null;
const cachedState = getCachedState(conversationId);
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
const [paneData, setPaneData] = useState<PaneData>(createInitialPaneData);
const [paneStates, setPaneStates] =
useState<Record<PaneName, PaneState>>(createInitialPaneStates);
const [paneData, setPaneData] = useState<PaneData>(
cachedState?.paneData ?? createInitialPaneData
);
const [paneStates, setPaneStates] = useState<Record<PaneName, PaneState>>(
cachedState?.paneStates ?? createInitialPaneStates
);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
cachedState?.consoleHistory ?? []
);
const [consoleLoading, setConsoleLoading] = useState(false);
// Track which conversation we're operating on to avoid stale updates after
@@ -120,6 +200,10 @@ export function useRepeaterDashboard(
// Guard against setting state after unmount (retry timers firing late)
const mountedRef = useRef(true);
useEffect(() => {
activeIdRef.current = conversationId;
}, [conversationId]);
useEffect(() => {
mountedRef.current = true;
return () => {
@@ -127,6 +211,17 @@ export function useRepeaterDashboard(
};
}, []);
useEffect(() => {
if (!conversationId) return;
cacheState(conversationId, {
loggedIn,
loginError,
paneData,
paneStates,
consoleHistory,
});
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
const getPublicKey = useCallback((): string | null => {
if (!activeConversation || activeConversation.type !== 'contact') return null;
return activeConversation.id;
@@ -172,7 +267,12 @@ export function useRepeaterDashboard(
setPaneStates((prev) => ({
...prev,
[pane]: { loading: true, attempt, error: null },
[pane]: {
loading: true,
attempt,
error: null,
fetched_at: prev[pane].fetched_at ?? null,
},
}));
try {
@@ -182,7 +282,7 @@ export function useRepeaterDashboard(
setPaneData((prev) => ({ ...prev, [pane]: data }));
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: null },
[pane]: { loading: false, attempt, error: null, fetched_at: Date.now() },
}));
return; // Success
} catch (err) {
@@ -193,7 +293,12 @@ export function useRepeaterDashboard(
if (attempt === MAX_RETRIES) {
setPaneStates((prev) => ({
...prev,
[pane]: { loading: false, attempt, error: msg },
[pane]: {
loading: false,
attempt,
error: msg,
fetched_at: prev[pane].fetched_at ?? null,
},
}));
toast.error(`Failed to fetch ${pane}`, { description: msg });
} else {
@@ -266,7 +371,11 @@ export function useRepeaterDashboard(
[getPublicKey]
);
const sendAdvert = useCallback(async () => {
const sendZeroHopAdvert = useCallback(async () => {
await sendConsoleCommand('advert.zerohop');
}, [sendConsoleCommand]);
const sendFloodAdvert = useCallback(async () => {
await sendConsoleCommand('advert');
}, [sendConsoleCommand]);
@@ -275,8 +384,8 @@ export function useRepeaterDashboard(
}, [sendConsoleCommand]);
const syncClock = useCallback(async () => {
const epoch = Math.floor(Date.now() / 1000);
await sendConsoleCommand(`clock ${epoch}`);
const epochSeconds = Math.floor(Date.now() / 1000);
await sendConsoleCommand(`time ${epochSeconds}`);
}, [sendConsoleCommand]);
return {
@@ -292,7 +401,8 @@ export function useRepeaterDashboard(
refreshPane,
loadAll,
sendConsoleCommand,
sendAdvert,
sendZeroHopAdvert,
sendFloodAdvert,
rebootRepeater,
syncClock,
};

View File

@@ -3,6 +3,7 @@ import { api } from '../api';
import {
getLastMessageTimes,
setLastMessageTime,
renameConversationTimeKey,
getStateKey,
type ConversationTimes,
} from '../utils/conversationState';
@@ -15,6 +16,7 @@ interface UseUnreadCountsResult {
mentions: Record<string, boolean>;
lastMessageTimes: ConversationTimes;
incrementUnread: (stateKey: string, hasMention?: boolean) => void;
renameConversationState: (oldStateKey: string, newStateKey: string) => void;
markAllRead: () => void;
trackNewMessage: (msg: Message) => void;
refreshUnreads: () => Promise<void>;
@@ -170,6 +172,28 @@ export function useUnreadCounts(
}
}, []);
const renameConversationState = useCallback((oldStateKey: string, newStateKey: string) => {
if (oldStateKey === newStateKey) return;
setUnreadCounts((prev) => {
if (!(oldStateKey in prev)) return prev;
const next = { ...prev };
next[newStateKey] = (next[newStateKey] || 0) + next[oldStateKey];
delete next[oldStateKey];
return next;
});
setMentions((prev) => {
if (!(oldStateKey in prev)) return prev;
const next = { ...prev };
next[newStateKey] = next[newStateKey] || next[oldStateKey];
delete next[oldStateKey];
return next;
});
setLastMessageTimes(renameConversationTimeKey(oldStateKey, newStateKey));
}, []);
// Mark all conversations as read
// Calls single bulk API endpoint to persist read state
const markAllRead = useCallback(() => {
@@ -204,6 +228,7 @@ export function useUnreadCounts(
mentions,
lastMessageTimes,
incrementUnread,
renameConversationState,
markAllRead,
trackNewMessage,
refreshUnreads: fetchUnreads,

View File

@@ -40,6 +40,7 @@
--success-foreground: 0 0% 100%;
--info: 217 91% 60%;
--info-foreground: 0 0% 100%;
--region-override: 270 80% 74%;
/* Favorites */
--favorite: 43 96% 56%;

View File

@@ -138,6 +138,36 @@ export function remove(id: string): void {
cache.delete(id);
}
/** Move cached conversation state to a new conversation id. */
export function rename(oldId: string, newId: string): void {
if (oldId === newId) return;
const oldEntry = cache.get(oldId);
if (!oldEntry) return;
const newEntry = cache.get(newId);
if (!newEntry) {
cache.delete(oldId);
cache.set(newId, oldEntry);
return;
}
const mergedMessages = [...newEntry.messages];
const seenIds = new Set(mergedMessages.map((message) => message.id));
for (const message of oldEntry.messages) {
if (!seenIds.has(message.id)) {
mergedMessages.push(message);
seenIds.add(message.id);
}
}
cache.delete(oldId);
cache.set(newId, {
messages: mergedMessages,
seenContent: new Set([...newEntry.seenContent, ...oldEntry.seenContent]),
hasOlderMessages: newEntry.hasOlderMessages || oldEntry.hasOlderMessages,
});
}
/** Clear the entire cache. */
export function clear(): void {
cache.clear();

View File

@@ -132,6 +132,7 @@ vi.mock('../components/NewMessageModal', () => ({
vi.mock('../components/SearchView', () => ({
SearchView: ({
onNavigateToMessage,
prefillRequest,
}: {
onNavigateToMessage: (target: {
id: number;
@@ -139,20 +140,24 @@ vi.mock('../components/SearchView', () => ({
conversation_key: string;
conversation_name: string;
}) => void;
prefillRequest?: { query: string; nonce: number } | null;
}) => (
<button
type="button"
onClick={() =>
onNavigateToMessage({
id: 321,
type: 'CHAN',
conversation_key: PUBLIC_CHANNEL_KEY,
conversation_name: 'Public',
})
}
>
Jump Result
</button>
<div>
<div data-testid="search-prefill">{prefillRequest?.query ?? ''}</div>
<button
type="button"
onClick={() =>
onNavigateToMessage({
id: 321,
type: 'CHAN',
conversation_key: PUBLIC_CHANNEL_KEY,
conversation_name: 'Public',
})
}
>
Jump Result
</button>
</div>
),
}));
@@ -165,7 +170,15 @@ vi.mock('../components/RawPacketList', () => ({
}));
vi.mock('../components/ContactInfoPane', () => ({
ContactInfoPane: () => null,
ContactInfoPane: ({
onSearchMessagesByKey,
}: {
onSearchMessagesByKey?: (publicKey: string) => void;
}) => (
<button type="button" onClick={() => onSearchMessagesByKey?.('aa'.repeat(32))}>
Search Contact By Key
</button>
),
}));
vi.mock('../components/ChannelInfoPane', () => ({
@@ -258,4 +271,23 @@ describe('App search jump target handling', () => {
expect(lastCall?.[1]).toBeNull();
});
});
it('opens search with a prefilled query from the contact pane', async () => {
render(<App />);
await waitFor(() => {
expect(screen.getByText('Search Contact By Key')).toBeInTheDocument();
});
fireEvent.click(screen.getByText('Search Contact By Key'));
await waitFor(() => {
expect(screen.getByTestId('search-prefill')).toHaveTextContent(`user:${'aa'.repeat(32)}`);
expect(
screen
.getAllByTestId('active-conversation')
.some((node) => node.textContent === 'search:search')
).toBe(true);
});
});
});

View File

@@ -1,4 +1,4 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { ChatHeader } from '../components/ChatHeader';
@@ -14,7 +14,11 @@ const baseProps = {
contacts: [],
config: null,
favorites: [] as Favorite[],
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
onTrace: noop,
onToggleNotifications: noop,
onToggleFavorite: noop,
onSetChannelFloodScopeOverride: noop,
onDeleteChannel: noop,
@@ -90,6 +94,28 @@ describe('ChatHeader key visibility', () => {
expect(screen.queryByText('Show Key')).not.toBeInTheDocument();
});
it('renders the clickable conversation title as a real button inside the heading', () => {
const pubKey = '12'.repeat(32);
const conversation: Conversation = { type: 'contact', id: pubKey, name: 'Alice' };
const onOpenContactInfo = vi.fn();
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[]}
onOpenContactInfo={onOpenContactInfo}
/>
);
const heading = screen.getByRole('heading', { name: /alice/i });
const titleButton = within(heading).getByRole('button', { name: 'View info for Alice' });
expect(heading).toContainElement(titleButton);
fireEvent.click(titleButton);
expect(onOpenContactInfo).toHaveBeenCalledWith(pubKey);
});
it('copies key to clipboard when revealed key is clicked', async () => {
const key = 'FF'.repeat(16);
const channel = makeChannel(key, 'Priv', false);
@@ -107,7 +133,7 @@ describe('ChatHeader key visibility', () => {
expect(writeText).toHaveBeenCalledWith(key);
});
it('shows active regional override banner for channels', () => {
it('shows active regional override badge for channels', () => {
const key = 'AB'.repeat(16);
const channel = {
...makeChannel(key, '#flightless', true),
@@ -117,7 +143,27 @@ describe('ChatHeader key visibility', () => {
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
expect(screen.getByText('Regional override active: Esperance')).toBeInTheDocument();
expect(screen.getAllByText('#Esperance')).toHaveLength(2);
});
it('shows enabled notification state and toggles when clicked', () => {
const conversation: Conversation = { type: 'contact', id: '11'.repeat(32), name: 'Alice' };
const onToggleNotifications = vi.fn();
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[]}
notificationsEnabled
onToggleNotifications={onToggleNotifications}
/>
);
fireEvent.click(screen.getByText('Notifications On'));
expect(screen.getByText('Notifications On')).toBeInTheDocument();
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('prompts for regional override when globe button is clicked', () => {

View File

@@ -2,15 +2,15 @@ import { render, screen, waitFor } from '@testing-library/react';
import { describe, expect, it, vi, beforeEach } from 'vitest';
import { ContactInfoPane } from '../components/ContactInfoPane';
import type { Contact, ContactDetail } from '../types';
import type { Contact, ContactAnalytics } from '../types';
const { getContactDetail } = vi.hoisted(() => ({
getContactDetail: vi.fn(),
const { getContactAnalytics } = vi.hoisted(() => ({
getContactAnalytics: vi.fn(),
}));
vi.mock('../api', () => ({
api: {
getContactDetail,
getContactAnalytics,
},
}));
@@ -54,16 +54,34 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
};
}
function createDetail(contact: Contact): ContactDetail {
function createAnalytics(
contact: Contact | null,
overrides: Partial<ContactAnalytics> = {}
): ContactAnalytics {
return {
lookup_type: contact ? 'contact' : 'name',
name: contact?.name ?? 'Mystery',
contact,
name_first_seen_at: null,
name_history: [],
dm_message_count: 0,
channel_message_count: 0,
includes_direct_messages: Boolean(contact),
most_active_rooms: [],
advert_paths: [],
advert_frequency: null,
nearest_repeaters: [],
hourly_activity: Array.from({ length: 24 }, (_, index) => ({
bucket_start: 1_700_000_000 + index * 3600,
last_24h_count: 0,
last_week_average: 0,
all_time_average: 0,
})),
weekly_activity: Array.from({ length: 26 }, (_, index) => ({
bucket_start: 1_700_000_000 + index * 604800,
message_count: 0,
})),
...overrides,
};
}
@@ -74,20 +92,24 @@ const baseProps = {
config: null,
favorites: [],
onToggleFavorite: () => {},
onSearchMessagesByKey: vi.fn(),
onSearchMessagesByName: vi.fn(),
};
describe('ContactInfoPane', () => {
beforeEach(() => {
getContactDetail.mockReset();
getContactAnalytics.mockReset();
baseProps.onSearchMessagesByKey = vi.fn();
baseProps.onSearchMessagesByName = vi.fn();
});
it('shows hop width when contact has a stored path hash mode', async () => {
const contact = createContact({ out_path_hash_mode: 1 });
getContactDetail.mockResolvedValue(createDetail(contact));
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
await screen.findByText('Alice');
await screen.findByText(contact.public_key);
await waitFor(() => {
expect(screen.getByText('Hop Width')).toBeInTheDocument();
expect(screen.getByText('2-byte IDs')).toBeInTheDocument();
@@ -96,7 +118,7 @@ describe('ContactInfoPane', () => {
it('does not show hop width for flood-routed contacts', async () => {
const contact = createContact({ last_path_len: -1, out_path_hash_mode: -1 });
getContactDetail.mockResolvedValue(createDetail(contact));
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
@@ -115,7 +137,7 @@ describe('ContactInfoPane', () => {
route_override_len: 2,
route_override_hash_mode: 1,
});
getContactDetail.mockResolvedValue(createDetail(contact));
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
@@ -127,4 +149,102 @@ describe('ContactInfoPane', () => {
expect(screen.getByText('1 hop')).toBeInTheDocument();
});
});
it('loads name-only channel stats and most active rooms', async () => {
getContactAnalytics.mockResolvedValue(
createAnalytics(null, {
lookup_type: 'name',
name: 'Mystery',
name_first_seen_at: 1_699_999_000,
channel_message_count: 4,
most_active_rooms: [
{
channel_key: 'ab'.repeat(16),
channel_name: '#ops',
message_count: 3,
},
],
hourly_activity: Array.from({ length: 24 }, (_, index) => ({
bucket_start: 1_700_000_000 + index * 3600,
last_24h_count: index === 23 ? 2 : 0,
last_week_average: index === 23 ? 1.5 : 0,
all_time_average: index === 23 ? 1.2 : 0,
})),
weekly_activity: Array.from({ length: 26 }, (_, index) => ({
bucket_start: 1_700_000_000 + index * 604800,
message_count: index === 25 ? 4 : 0,
})),
})
);
render(<ContactInfoPane {...baseProps} contactKey="name:Mystery" fromChannel />);
await screen.findByText('Mystery');
await waitFor(() => {
expect(getContactAnalytics).toHaveBeenCalledWith({ name: 'Mystery' });
expect(screen.getByText('Messages')).toBeInTheDocument();
expect(screen.getByText('Channel Messages')).toBeInTheDocument();
expect(screen.getByText('4', { selector: 'p' })).toBeInTheDocument();
expect(screen.getByText('Name First In Use')).toBeInTheDocument();
expect(screen.getByText('Messages Per Hour')).toBeInTheDocument();
expect(screen.getByText('Messages Per Week')).toBeInTheDocument();
expect(screen.getByText('Most Active Rooms')).toBeInTheDocument();
expect(screen.getByText('#ops')).toBeInTheDocument();
expect(
screen.getByText(/Name-only analytics include channel messages only/i)
).toBeInTheDocument();
expect(screen.getByText(/same sender name/i)).toBeInTheDocument();
expect(screen.getByText("Search user's messages by name")).toBeInTheDocument();
});
});
it('fires the name search callback from the name-only pane', async () => {
getContactAnalytics.mockResolvedValue(
createAnalytics(null, { lookup_type: 'name', name: 'Mystery' })
);
render(<ContactInfoPane {...baseProps} contactKey="name:Mystery" fromChannel />);
const button = await screen.findByRole('button', { name: "Search user's messages by name" });
button.click();
expect(baseProps.onSearchMessagesByName).toHaveBeenCalledWith('Mystery');
});
it('shows alias note in the channel attribution warning for keyed contacts', async () => {
const contact = createContact();
getContactAnalytics.mockResolvedValue(
createAnalytics(contact, {
name_history: [
{ name: 'Alice', first_seen: 1000, last_seen: 2000 },
{ name: 'AliceOld', first_seen: 900, last_seen: 999 },
],
})
);
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} fromChannel />);
await screen.findByText(contact.public_key);
await waitFor(() => {
expect(screen.getByRole('heading', { name: 'Also Known As' })).toBeInTheDocument();
expect(
screen.getByText(
/may include messages previously attributed under names shown in Also Known As/i
)
).toBeInTheDocument();
expect(screen.getByText("Search user's messages by key")).toBeInTheDocument();
});
});
it('fires the key search callback from the keyed pane', async () => {
const contact = createContact();
getContactAnalytics.mockResolvedValue(createAnalytics(contact));
render(<ContactInfoPane {...baseProps} contactKey={contact.public_key} />);
const button = await screen.findByRole('button', { name: "Search user's messages by key" });
button.click();
expect(baseProps.onSearchMessagesByKey).toHaveBeenCalledWith(contact.public_key);
});
});

View File

@@ -13,12 +13,16 @@ import type {
RadioConfig,
} from '../types';
const mocks = vi.hoisted(() => ({
messageList: vi.fn(() => <div data-testid="message-list" />),
}));
vi.mock('../components/ChatHeader', () => ({
ChatHeader: () => <div data-testid="chat-header" />,
}));
vi.mock('../components/MessageList', () => ({
MessageList: () => <div data-testid="message-list" />,
MessageList: mocks.messageList,
}));
vi.mock('../components/MessageInput', () => ({
@@ -99,11 +103,15 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
rawPackets: [],
config,
health,
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
favorites: [] as Favorite[],
messages: [message],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
unreadMarkerLastReadAt: undefined,
targetMessageId: null,
hasNewerMessages: false,
loadingNewer: false,
@@ -121,7 +129,9 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onTargetReached: vi.fn(),
onLoadNewer: vi.fn(async () => {}),
onJumpToBottom: vi.fn(),
onDismissUnreadMarker: vi.fn(),
onSendMessage: vi.fn(async () => {}),
onToggleNotifications: vi.fn(),
...overrides,
};
}
@@ -129,6 +139,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
describe('ConversationPane', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.messageList.mockImplementation(() => <div data-testid="message-list" />);
});
it('renders the empty state when no conversation is active', () => {
@@ -192,4 +203,122 @@ describe('ConversationPane', () => {
expect(screen.getByTestId('message-input')).toBeInTheDocument();
});
});
it('passes unread marker props to MessageList only for channel conversations', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'channel',
id: channel.key,
name: channel.name,
},
unreadMarkerLastReadAt: 1700000000,
})}
/>
);
await waitFor(() => {
expect(mocks.messageList).toHaveBeenCalled();
});
const channelCallArgs = mocks.messageList.mock.calls[
mocks.messageList.mock.calls.length - 1
] as unknown[] | undefined;
const channelCall = channelCallArgs?.[0] as Record<string, unknown> | undefined;
expect(channelCall?.unreadMarkerLastReadAt).toBe(1700000000);
expect(channelCall?.onDismissUnreadMarker).toBeTypeOf('function');
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'cc'.repeat(32),
name: 'Alice',
},
unreadMarkerLastReadAt: 1700000000,
})}
/>
);
const contactCallArgs = mocks.messageList.mock.calls[
mocks.messageList.mock.calls.length - 1
] as unknown[] | undefined;
const contactCall = contactCallArgs?.[0] as Record<string, unknown> | undefined;
expect(contactCall?.unreadMarkerLastReadAt).toBeUndefined();
expect(contactCall?.onDismissUnreadMarker).toBeUndefined();
});
it('shows a warning but keeps input for full-key contacts without an advert', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'cc'.repeat(32),
name: '[unknown sender]',
},
contacts: [
{
public_key: 'cc'.repeat(32),
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: 1700000000,
on_radio: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
},
],
})}
/>
);
expect(screen.getByText(/A full identity profile is not yet available/i)).toBeInTheDocument();
expect(screen.getByTestId('message-input')).toBeInTheDocument();
});
it('hides input and shows a read-only warning for prefix-only contacts', async () => {
render(
<ConversationPane
{...createProps({
activeConversation: {
type: 'contact',
id: 'abc123def456',
name: 'abc123def456',
},
contacts: [
{
public_key: 'abc123def456',
name: null,
type: 0,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
last_advert: null,
lat: null,
lon: null,
last_seen: 1700000000,
on_radio: false,
last_contacted: 1700000000,
last_read_at: null,
first_seen: 1700000000,
},
],
})}
/>
);
expect(screen.getByText(/This conversation is read-only/i)).toBeInTheDocument();
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument();
});
});

View File

@@ -90,6 +90,7 @@ describe('SettingsFanoutSection', () => {
).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Webhook' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Apprise' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Amazon SQS' })).toBeInTheDocument();
expect(screen.getByRole('menuitem', { name: 'Bot' })).toBeInTheDocument();
});
@@ -309,6 +310,23 @@ describe('SettingsFanoutSection', () => {
expect(mockedApi.createFanoutConfig).not.toHaveBeenCalled();
});
it('new SQS draft shows queue url fields and sensible defaults', async () => {
renderSection();
await waitFor(() => {
expect(screen.getByRole('button', { name: 'Add Integration' })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole('button', { name: 'Add Integration' }));
fireEvent.click(screen.getByRole('menuitem', { name: 'Amazon SQS' }));
await waitFor(() => {
expect(screen.getByText('← Back to list')).toBeInTheDocument();
expect(screen.getByLabelText('Name')).toHaveValue('Amazon SQS #1');
expect(screen.getByLabelText('Queue URL')).toBeInTheDocument();
expect(screen.getByText('Forward raw packets')).toBeInTheDocument();
});
});
it('backing out of a new draft does not create an integration', async () => {
renderSection();
await waitFor(() => {
@@ -672,6 +690,30 @@ describe('SettingsFanoutSection', () => {
);
});
it('sqs list shows queue url summary', async () => {
const config: FanoutConfig = {
id: 'sqs-1',
type: 'sqs',
name: 'Queue Feed',
enabled: true,
config: {
queue_url: 'https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events',
region_name: 'us-east-1',
},
scope: { messages: 'all', raw_packets: 'none' },
sort_order: 0,
created_at: 1000,
};
mockedApi.getFanoutConfigs.mockResolvedValue([config]);
renderSection();
await waitFor(() =>
expect(
screen.getByText('https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events')
).toBeInTheDocument()
);
});
it('groups integrations by type and sorts entries alphabetically within each group', async () => {
mockedApi.getFanoutConfigs.mockResolvedValue([
{

View File

@@ -0,0 +1,55 @@
import { forwardRef } from 'react';
import { render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { MapView } from '../components/MapView';
import type { Contact } from '../types';
vi.mock('react-leaflet', () => ({
MapContainer: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
TileLayer: () => null,
CircleMarker: forwardRef<
HTMLDivElement,
{ children: React.ReactNode; pathOptions?: { fillColor?: string } }
>(({ children, pathOptions }, ref) => (
<div ref={ref} data-fill-color={pathOptions?.fillColor}>
{children}
</div>
)),
Popup: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
useMap: () => ({
setView: vi.fn(),
fitBounds: vi.fn(),
}),
}));
describe('MapView', () => {
it('renders a never-heard fallback for a focused contact without last_seen', () => {
const contact: Contact = {
public_key: 'aa'.repeat(32),
name: 'Mystery Node',
type: 1,
flags: 0,
last_path: null,
last_path_len: -1,
out_path_hash_mode: -1,
route_override_path: null,
route_override_len: null,
route_override_hash_mode: null,
last_advert: null,
lat: 40,
lon: -74,
last_seen: null,
on_radio: false,
last_contacted: null,
last_read_at: null,
first_seen: null,
};
render(<MapView contacts={[contact]} focusedKey={contact.public_key} />);
expect(
screen.getByText(/showing 1 contact heard in the last 7 days plus the focused contact/i)
).toBeInTheDocument();
expect(screen.getByText('Last heard: Never heard by this server')).toBeInTheDocument();
});
});

View File

@@ -1,9 +1,13 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { MessageList } from '../components/MessageList';
import type { Message } from '../types';
const scrollIntoViewMock = vi.fn();
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
@@ -24,6 +28,15 @@ function createMessage(overrides: Partial<Message> = {}): Message {
}
describe('MessageList channel sender rendering', () => {
beforeEach(() => {
scrollIntoViewMock.mockReset();
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoViewMock,
writable: true,
});
});
it('renders explicit corrupt placeholder and warning avatar for unnamed corrupt channel packets', () => {
render(
<MessageList
@@ -61,4 +74,85 @@ describe('MessageList channel sender rendering', () => {
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('A')).toBeInTheDocument();
});
it('gives clickable sender avatars an accessible label', () => {
render(
<MessageList
messages={[
createMessage({
text: 'garbled payload with no sender prefix',
sender_name: 'Alice',
sender_key: 'ab'.repeat(32),
}),
]}
contacts={[]}
loading={false}
onOpenContactInfo={() => {}}
/>
);
expect(screen.getByRole('button', { name: 'View info for Alice' })).toBeInTheDocument();
});
it('renders and dismisses an unread marker at the first unread message boundary', async () => {
const user = userEvent.setup();
const messages = [
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
];
function DismissibleUnreadMarkerList() {
const [unreadMarkerLastReadAt, setUnreadMarkerLastReadAt] = useState<number | undefined>(
1700000005
);
return (
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={unreadMarkerLastReadAt}
onDismissUnreadMarker={() => setUnreadMarkerLastReadAt(undefined)}
/>
);
}
render(<DismissibleUnreadMarkerList />);
const marker = screen.getByRole('button', { name: /Unread messages/i });
expect(marker).toBeInTheDocument();
expect(screen.getByText('older')).toBeInTheDocument();
expect(screen.getByText('newer')).toBeInTheDocument();
await user.click(marker);
expect(screen.queryByRole('button', { name: /Unread messages/i })).not.toBeInTheDocument();
});
it('shows a jump-to-unread button and dismisses it after use without hiding the marker', async () => {
const user = userEvent.setup();
const messages = [
createMessage({ id: 1, received_at: 1700000001, text: 'Alice: older' }),
createMessage({ id: 2, received_at: 1700000010, text: 'Alice: newer' }),
];
render(
<MessageList
messages={messages}
contacts={[]}
loading={false}
unreadMarkerLastReadAt={1700000005}
/>
);
const jumpButton = screen.getByRole('button', { name: 'Jump to unread' });
expect(jumpButton).toBeInTheDocument();
expect(screen.getByText('Unread messages')).toBeInTheDocument();
await user.click(jumpButton);
expect(screen.queryByRole('button', { name: 'Jump to unread' })).not.toBeInTheDocument();
expect(screen.getByText('Unread messages')).toBeInTheDocument();
expect(scrollIntoViewMock).toHaveBeenCalled();
});
});

View File

@@ -38,7 +38,8 @@ const mockHook: {
refreshPane: vi.fn(),
loadAll: vi.fn(),
sendConsoleCommand: vi.fn(),
sendAdvert: vi.fn(),
sendZeroHopAdvert: vi.fn(),
sendFloodAdvert: vi.fn(),
rebootRepeater: vi.fn(),
syncClock: vi.fn(),
};
@@ -98,10 +99,14 @@ const defaultProps = {
conversation,
contacts,
favorites,
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
radioLat: null,
radioLon: null,
radioName: null,
onTrace: vi.fn(),
onToggleNotifications: vi.fn(),
onToggleFavorite: vi.fn(),
onDeleteContact: vi.fn(),
};
@@ -189,6 +194,21 @@ describe('RepeaterDashboard', () => {
expect(mockHook.loadAll).toHaveBeenCalledTimes(1);
});
it('shows enabled notification state and toggles when clicked', () => {
render(
<RepeaterDashboard
{...defaultProps}
notificationsEnabled
onToggleNotifications={defaultProps.onToggleNotifications}
/>
);
fireEvent.click(screen.getByText('Notifications On'));
expect(screen.getByText('Notifications On')).toBeInTheDocument();
expect(defaultProps.onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('shows login error when present', () => {
mockHook.loginError = 'Invalid password';
@@ -244,12 +264,27 @@ describe('RepeaterDashboard', () => {
expect(screen.getByText('7.5 dB')).toBeInTheDocument();
});
it('shows fetched time and relative age when pane data has been loaded', () => {
mockHook.loggedIn = true;
mockHook.paneStates.status = {
loading: false,
attempt: 1,
error: null,
fetched_at: Date.now(),
};
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText(/Fetched .*Just now/)).toBeInTheDocument();
});
it('renders action buttons', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Send Advert')).toBeInTheDocument();
expect(screen.getByText('Zero Hop Advert')).toBeInTheDocument();
expect(screen.getByText('Flood Advert')).toBeInTheDocument();
expect(screen.getByText('Sync Clock')).toBeInTheDocument();
expect(screen.getByText('Reboot')).toBeInTheDocument();
});

View File

@@ -70,6 +70,10 @@ describe('SearchView', () => {
mockGetMessages.mockResolvedValue([]);
render(<SearchView {...defaultProps} />);
expect(screen.getByText('Type to search across all messages')).toBeInTheDocument();
expect(screen.getByText(/Tip: use/i)).toBeInTheDocument();
expect(
screen.getByText(/User-key linkage for group messages is best-effort/i)
).toBeInTheDocument();
});
it('focuses input on mount', () => {
@@ -246,4 +250,68 @@ describe('SearchView', () => {
expect(screen.getByText('Bob')).toBeInTheDocument();
});
it('passes raw operator queries to the API and highlights only free text', async () => {
mockGetMessages.mockResolvedValue([createSearchResult({ text: 'hello world' })]);
render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('user:Alice hello');
expect(mockGetMessages).toHaveBeenCalledWith(
expect.objectContaining({ q: 'user:Alice hello' }),
expect.any(AbortSignal)
);
expect(screen.getByText('hello', { selector: 'mark' })).toBeInTheDocument();
expect(screen.queryByText('user:Alice', { selector: 'mark' })).not.toBeInTheDocument();
});
it('runs a prefilled search immediately', async () => {
mockGetMessages.mockResolvedValue([createSearchResult({ text: 'prefilled result' })]);
render(
<SearchView {...defaultProps} prefillRequest={{ query: 'user:"Alice Smith"', nonce: 1 }} />
);
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(screen.getByLabelText('Search messages')).toHaveValue('user:"Alice Smith"');
expect(mockGetMessages).toHaveBeenCalledWith(
expect.objectContaining({ q: 'user:"Alice Smith"' }),
expect.any(AbortSignal)
);
});
it('aborts the load-more request on unmount', async () => {
const pageResults = Array.from({ length: 50 }, (_, i) =>
createSearchResult({ id: i + 1, text: `result ${i}` })
);
let resolveLoadMore: ((value: Message[]) => void) | null = null;
mockGetMessages.mockResolvedValueOnce(pageResults).mockImplementationOnce(
() =>
new Promise<Message[]>((resolve) => {
resolveLoadMore = resolve;
})
);
const { unmount } = render(<SearchView {...defaultProps} />);
await typeAndWaitForResults('result');
fireEvent.click(screen.getByText('Load more results'));
const loadMoreSignal = mockGetMessages.mock.calls[1]?.[1] as AbortSignal | undefined;
expect(loadMoreSignal).toBeInstanceOf(AbortSignal);
expect(loadMoreSignal?.aborted).toBe(false);
unmount();
expect(loadMoreSignal?.aborted).toBe(true);
await act(async () => {
resolveLoadMore?.([createSearchResult({ id: 99, text: 'late result' })]);
await Promise.resolve();
});
});
});

View File

@@ -32,6 +32,7 @@ const baseConfig: RadioConfig = {
},
path_hash_mode: 0,
path_hash_mode_supported: false,
advert_location_source: 'current',
};
const baseHealth: HealthStatus = {
@@ -68,6 +69,8 @@ function renderModal(overrides?: {
onClose?: () => void;
onSetPrivateKey?: (key: string) => Promise<void>;
onReboot?: () => Promise<void>;
onDisconnect?: () => Promise<void>;
onReconnect?: () => Promise<void>;
open?: boolean;
pageMode?: boolean;
externalSidebarNav?: boolean;
@@ -82,6 +85,8 @@ function renderModal(overrides?: {
const onClose = overrides?.onClose ?? vi.fn();
const onSetPrivateKey = overrides?.onSetPrivateKey ?? vi.fn(async () => {});
const onReboot = overrides?.onReboot ?? vi.fn(async () => {});
const onDisconnect = overrides?.onDisconnect ?? vi.fn(async () => {});
const onReconnect = overrides?.onReconnect ?? vi.fn(async () => {});
const commonProps = {
open: overrides?.open ?? true,
@@ -94,6 +99,8 @@ function renderModal(overrides?: {
onSaveAppSettings,
onSetPrivateKey,
onReboot,
onDisconnect,
onReconnect,
onAdvertise: vi.fn(async () => {}),
onHealthRefresh: vi.fn(async () => {}),
onRefreshAppSettings,
@@ -116,6 +123,8 @@ function renderModal(overrides?: {
onClose,
onSetPrivateKey,
onReboot,
onDisconnect,
onReconnect,
view,
};
}
@@ -179,15 +188,36 @@ describe('SettingsModal', () => {
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
});
it('shows favorite-first contact sync helper text in radio tab', async () => {
it('shows favorite-contact radio sync helper text in radio tab', async () => {
renderModal();
openRadioSection();
expect(
screen.getByText(
/Favorite contacts load first, then recent non-repeater contacts until this\s+limit is reached/i
)
).toBeInTheDocument();
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
});
it('shows reconnect action when radio connection is paused', () => {
renderModal({
health: { ...baseHealth, radio_state: 'paused' },
});
openRadioSection();
expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument();
});
it('saves advert location source through radio config save', async () => {
const { onSave } = renderModal();
openRadioSection();
fireEvent.change(screen.getByLabelText('Advert Location Source'), {
target: { value: 'off' },
});
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
await waitFor(() => {
expect(onSave).toHaveBeenCalledWith(
expect.objectContaining({ advert_location_source: 'off' })
);
});
});
it('saves changed max contacts value through onSaveAppSettings', async () => {
@@ -266,6 +296,14 @@ describe('SettingsModal', () => {
expect(screen.queryByLabelText('Local label text')).not.toBeInTheDocument();
});
it('lists the new Windows 95 and iPhone themes', () => {
renderModal();
openLocalSection();
expect(screen.getByText('Windows 95')).toBeInTheDocument();
expect(screen.getByText('iPhone')).toBeInTheDocument();
});
it('clears stale errors when switching external desktop sections', async () => {
const onSaveAppSettings = vi.fn(async () => {
throw new Error('Save failed');
@@ -295,6 +333,8 @@ describe('SettingsModal', () => {
onSaveAppSettings={onSaveAppSettings}
onSetPrivateKey={vi.fn(async () => {})}
onReboot={vi.fn(async () => {})}
onDisconnect={vi.fn(async () => {})}
onReconnect={vi.fn(async () => {})}
onAdvertise={vi.fn(async () => {})}
onHealthRefresh={vi.fn(async () => {})}
onRefreshAppSettings={vi.fn(async () => {})}
@@ -316,6 +356,8 @@ describe('SettingsModal', () => {
onSave,
onSetPrivateKey,
onReboot,
onDisconnect: vi.fn(async () => {}),
onReconnect: vi.fn(async () => {}),
});
openRadioSection();

View File

@@ -41,6 +41,7 @@ function renderSidebar(overrides?: {
favorites?: Favorite[];
lastMessageTimes?: ConversationTimes;
channels?: Channel[];
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
}) {
const aliceName = 'Alice';
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
@@ -76,6 +77,7 @@ function renderSidebar(overrides?: {
favorites={favorites}
sortOrder="recent"
onSortOrderChange={vi.fn()}
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
/>
);
@@ -148,7 +150,7 @@ describe('Sidebar section summaries', () => {
expect(screen.queryByText(opsChannel.name)).not.toBeInTheDocument();
expect(screen.queryByText(aliceName)).not.toBeInTheDocument();
const search = screen.getByPlaceholderText('Search...');
const search = screen.getByLabelText('Search conversations');
fireEvent.change(search, { target: { value: 'alice' } });
await waitFor(() => {
@@ -218,4 +220,37 @@ describe('Sidebar section summaries', () => {
const selectedIds = onSelectConversation.mock.calls.map(([conv]) => conv.id);
expect(new Set(selectedIds)).toEqual(new Set([channelA.key, channelB.key]));
});
it('shows a notification bell for conversations with notifications enabled', () => {
const { aliceName } = renderSidebar({
unreadCounts: {},
isConversationNotificationsEnabled: (type, id) =>
(type === 'contact' && id === '11'.repeat(32)) ||
(type === 'channel' && id === 'BB'.repeat(16)),
});
const aliceRow = screen.getByText(aliceName).closest('div');
const flightRow = screen.getByText('#flight').closest('div');
if (!aliceRow || !flightRow) throw new Error('Missing sidebar rows');
expect(within(aliceRow).getByLabelText('Notifications enabled')).toBeInTheDocument();
expect(within(flightRow).getByLabelText('Notifications enabled')).toBeInTheDocument();
});
it('keeps the notification bell to the left of the unread pill when both are present', () => {
const { aliceName } = renderSidebar({
unreadCounts: {
[getStateKey('contact', '11'.repeat(32))]: 3,
},
isConversationNotificationsEnabled: (type, id) =>
type === 'contact' && id === '11'.repeat(32),
});
const aliceRow = screen.getByText(aliceName).closest('div');
if (!aliceRow) throw new Error('Missing Alice row');
const bell = within(aliceRow).getByLabelText('Notifications enabled');
const unread = within(aliceRow).getByText('3');
expect(bell.compareDocumentPosition(unread) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
});

View File

@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { StatusBar } from '../components/StatusBar';
@@ -47,4 +47,34 @@ describe('StatusBar', () => {
expect(screen.getByRole('status', { name: 'Radio Disconnected' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reconnect' })).toBeInTheDocument();
});
it('shows Radio Paused and a Connect action when reconnect attempts are paused', () => {
render(
<StatusBar
health={{ ...baseHealth, radio_state: 'paused' }}
config={null}
onSettingsClick={vi.fn()}
/>
);
expect(screen.getByRole('status', { name: 'Radio Paused' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Connect' })).toBeInTheDocument();
});
it('toggles between classic and light themes from the shortcut button', () => {
localStorage.setItem('remoteterm-theme', 'cyberpunk');
render(<StatusBar health={baseHealth} config={null} onSettingsClick={vi.fn()} />);
const themeToggle = screen.getByRole('button', { name: 'Switch to light theme' });
fireEvent.click(themeToggle);
expect(localStorage.getItem('remoteterm-theme')).toBe('light');
expect(document.documentElement.dataset.theme).toBe('light');
fireEvent.click(screen.getByRole('button', { name: 'Switch to classic theme' }));
expect(localStorage.getItem('remoteterm-theme')).toBe('original');
expect(document.documentElement.dataset.theme).toBeUndefined();
});
});

View File

@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { getUnreadBoundaryBackfillKey } from '../App';
import type { Conversation, Message } from '../types';
function createMessage(overrides: Partial<Message> = {}): Message {
return {
id: 1,
type: 'CHAN',
conversation_key: 'channel-1',
text: 'Alice: hello',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: null,
outgoing: false,
acked: 0,
sender_name: 'Alice',
...overrides,
};
}
const channelConversation: Conversation = {
type: 'channel',
id: 'channel-1',
name: 'Busy room',
};
describe('getUnreadBoundaryBackfillKey', () => {
it('returns a fetch key when the unread boundary is older than the loaded window', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000000,
},
messages: [
createMessage({ id: 20, received_at: 1700000200 }),
createMessage({ id: 21, received_at: 1700000300 }),
],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBe('channel-1:1700000000:20');
});
it('does not backfill when the loaded window already reaches the unread boundary', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000200,
},
messages: [
createMessage({ id: 20, received_at: 1700000200 }),
createMessage({ id: 21, received_at: 1700000300 }),
],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBeNull();
});
it('does not backfill when there is no older history to fetch', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: 1700000000,
},
messages: [createMessage({ id: 20, received_at: 1700000200 })],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: false,
})
).toBeNull();
});
it('does not backfill for channels where everything is unread', () => {
expect(
getUnreadBoundaryBackfillKey({
activeConversation: channelConversation,
unreadMarker: {
channelId: 'channel-1',
lastReadAt: null,
},
messages: [createMessage({ id: 20, received_at: 1700000200 })],
messagesLoading: false,
loadingOlder: false,
hasOlderMessages: true,
})
).toBeNull();
});
});

View File

@@ -0,0 +1,151 @@
import { act, renderHook } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useBrowserNotifications } from '../hooks/useBrowserNotifications';
import type { Message } from '../types';
const mocks = vi.hoisted(() => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
vi.mock('../components/ui/sonner', () => ({
toast: mocks.toast,
}));
const incomingChannelMessage: Message = {
id: 42,
type: 'CHAN',
conversation_key: 'ab'.repeat(16),
text: 'hello room',
sender_timestamp: 1700000000,
received_at: 1700000001,
paths: null,
txt_type: 0,
signature: null,
sender_key: 'cd'.repeat(32),
outgoing: false,
acked: 0,
sender_name: 'Alice',
channel_name: '#flightless',
};
describe('useBrowserNotifications', () => {
beforeEach(() => {
vi.clearAllMocks();
window.localStorage.clear();
window.location.hash = '';
vi.spyOn(window, 'open').mockReturnValue(null);
Object.defineProperty(document, 'visibilityState', {
configurable: true,
value: 'hidden',
});
vi.spyOn(document, 'hasFocus').mockReturnValue(false);
const NotificationMock = vi.fn().mockImplementation(function (this: Record<string, unknown>) {
this.close = vi.fn();
this.onclick = null;
});
Object.assign(NotificationMock, {
permission: 'granted',
requestPermission: vi.fn(async () => 'granted'),
});
Object.defineProperty(window, 'Notification', {
configurable: true,
value: NotificationMock,
});
});
it('stores notification opt-in per conversation', async () => {
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
expect(
result.current.isConversationNotificationsEnabled(
'channel',
incomingChannelMessage.conversation_key
)
).toBe(true);
expect(result.current.isConversationNotificationsEnabled('contact', 'ef'.repeat(32))).toBe(
false
);
expect(window.Notification).toHaveBeenCalledWith('New message in #flightless', {
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
icon: '/favicon-256x256.png',
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
});
});
it('only sends desktop notifications for opted-in conversations', async () => {
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
act(() => {
result.current.notifyIncomingMessage(incomingChannelMessage);
result.current.notifyIncomingMessage({
...incomingChannelMessage,
id: 43,
conversation_key: '34'.repeat(16),
channel_name: '#elsewhere',
});
});
expect(window.Notification).toHaveBeenCalledTimes(2);
expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', {
body: 'hello room',
icon: '/favicon-256x256.png',
tag: 'meshcore-message-42',
});
});
it('notification click deep-links to the conversation hash', async () => {
const focusSpy = vi.spyOn(window, 'focus').mockImplementation(() => {});
const { result } = renderHook(() => useBrowserNotifications());
await act(async () => {
await result.current.toggleConversationNotifications(
'channel',
incomingChannelMessage.conversation_key,
'#flightless'
);
});
act(() => {
result.current.notifyIncomingMessage(incomingChannelMessage);
});
const notificationInstance = (window.Notification as unknown as ReturnType<typeof vi.fn>).mock
.instances[1] as {
onclick: (() => void) | null;
close: ReturnType<typeof vi.fn>;
};
act(() => {
notificationInstance.onclick?.();
});
expect(window.open).toHaveBeenCalledWith(
`${window.location.origin}${window.location.pathname}#channel/${incomingChannelMessage.conversation_key}/%23flightless`,
'_self'
);
expect(focusSpy).toHaveBeenCalledTimes(1);
expect(notificationInstance.close).toHaveBeenCalledTimes(1);
});
});

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