Compare commits
42 Commits
codex-refa
...
3.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b311c406da | ||
|
|
b5e9e4d04c | ||
|
|
ce87dd9376 | ||
|
|
5273d9139d | ||
|
|
04ac3d6ed4 | ||
|
|
1a1d3059db | ||
|
|
633510b7de | ||
|
|
7f4c1e94fd | ||
|
|
a06fefb34e | ||
|
|
4e0b6a49b0 | ||
|
|
e993009782 | ||
|
|
ad7028e508 | ||
|
|
ce9bbd1059 | ||
|
|
0c35601af3 | ||
|
|
93369f8d64 | ||
|
|
e7d1f28076 | ||
|
|
472b4a5ed2 | ||
|
|
314e4c7fff | ||
|
|
528a94d2bd | ||
|
|
fa1c086f5f | ||
|
|
d8bb747152 | ||
|
|
18a465fde8 | ||
|
|
c52e00d2b7 | ||
|
|
e17d1ba4b4 | ||
|
|
48a49ce48d | ||
|
|
9d1676818f | ||
|
|
b5edd00220 | ||
|
|
d3a7b7ce07 | ||
|
|
42ca242ee1 | ||
|
|
3e7e0669c5 | ||
|
|
bee273ab56 | ||
|
|
1842bcf43e | ||
|
|
7c68973e30 | ||
|
|
c9ede1f71f | ||
|
|
42e9628d98 | ||
|
|
1bf760121d | ||
|
|
bb4a601788 | ||
|
|
d0ed3484ce | ||
|
|
738e0b9815 | ||
|
|
97997e23e8 | ||
|
|
eaee66f836 | ||
|
|
9a99d3f17e |
74
.github/workflows/all-quality.yml
vendored
Normal file
@@ -0,0 +1,74 @@
|
||||
name: All Quality
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
backend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install backend dependencies
|
||||
run: uv sync --dev
|
||||
|
||||
- name: Backend lint
|
||||
run: uv run ruff check app/ tests/
|
||||
|
||||
- name: Backend format check
|
||||
run: uv run ruff format --check app/ tests/
|
||||
|
||||
- name: Backend typecheck
|
||||
run: uv run pyright app/
|
||||
|
||||
- name: Backend tests
|
||||
run: PYTHONPATH=. uv run pytest tests/ -v
|
||||
|
||||
frontend-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: npm ci
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend lint
|
||||
run: npm run lint
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend format check
|
||||
run: npm run format:check
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend tests
|
||||
run: npm run test:run
|
||||
working-directory: frontend
|
||||
|
||||
- name: Frontend build
|
||||
run: npm run build
|
||||
working-directory: frontend
|
||||
1
.gitignore
vendored
@@ -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)
|
||||
|
||||
21
AGENTS.md
@@ -21,7 +21,7 @@ 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)
|
||||
- `app/fanout/AGENTS_fanout.md` - Fanout bus architecture (MQTT, bots, webhooks, Apprise, SQS)
|
||||
- `frontend/src/components/AGENTS_packet_visualizer.md` - Packet visualizer (force-directed graph, advert-path identity, layout engine)
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
@@ -391,7 +393,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 +442,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.
|
||||
|
||||
|
||||
49
CHANGELOG.md
@@ -1,3 +1,52 @@
|
||||
## [3.1.1] - 2026-03-11
|
||||
|
||||
Feature: Add basic auth
|
||||
Feature: SQS fanout
|
||||
Feature: Enrich contact info pane
|
||||
Feature: Search operators for node and channel
|
||||
Feature: Pause radio connection attempts from Radio settings
|
||||
Feature: New themes! What a great use of time!
|
||||
Feature: Github workflows runs for validation
|
||||
Bugfix: More consistent log format with times
|
||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
|
||||
## [3.1.0] - 2026-03-11
|
||||
|
||||
Feature: Add basic auth
|
||||
Feature: SQS fanout
|
||||
Feature: Enrich contact info pane
|
||||
Feature: Search operators for node and channel
|
||||
Feature: Pause radio connection attempts from Radio settings
|
||||
Feature: New themes! What a great use of time!
|
||||
Feature: Github workflows runs for validation
|
||||
Bugfix: More consistent log format with times
|
||||
Bugfix: Patch meshcore_py bluetooth eager reconnection out during pauses
|
||||
|
||||
## [3.0.0] - 2026-03-10
|
||||
|
||||
Feature: Custom regions per-channel
|
||||
Feature: Add custom contact pathing
|
||||
Feature: Corrupt packets are more clear that they're corrupt
|
||||
Feature: Better, faster patterns around background fetching with explicit opt-in for recurring sync if the app detects you need it
|
||||
Feature: More consistent icons
|
||||
Feature: Add per-channel local notifications
|
||||
Feature: New themes
|
||||
Feature: Massive codebase refactor and overhaul
|
||||
Bugfix: Fix packet parsing for trace packets
|
||||
Bugfix: Refetch channels on reconnect
|
||||
Bugfix: Load All on repeater pane on mobile doesn't etend into lower text
|
||||
Bugfix: Timestamps in logs
|
||||
Bugfix: Correct wrong clock sync command
|
||||
Misc: Improve bot error bubble up
|
||||
Misc: Update to non-lib-included meshcore-decoder version
|
||||
Misc: Revise refactors to be more LLM friendly
|
||||
Misc: Fix script executability
|
||||
Misc: Better logging format with timestamp
|
||||
Misc: Repeater advert buttons separate flood and one-hop
|
||||
Misc: Preserve repeater pane on navigation away
|
||||
Misc: Clearer iconography and coloring for status bar buttons
|
||||
Misc: Search bar to top bar
|
||||
|
||||
## [2.7.9] - 2026-03-08
|
||||
|
||||
Bugfix: Don't obscure new integration dropdown on session boundary
|
||||
|
||||
@@ -5,8 +5,8 @@ ARG COMMIT_HASH=unknown
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY frontend/package.json frontend/.npmrc ./
|
||||
RUN npm install
|
||||
COPY frontend/package.json frontend/package-lock.json frontend/.npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
COPY frontend/ ./
|
||||
RUN VITE_COMMIT_HASH=${COMMIT_HASH} npm run build
|
||||
|
||||
186
LICENSES.md
@@ -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>
|
||||
|
||||
15
README.md
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -44,7 +44,8 @@ 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
|
||||
├── keystore.py # Ephemeral private/public key storage for DM decryption
|
||||
@@ -87,7 +88,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 +132,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.
|
||||
@@ -230,7 +232,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 +252,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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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=["*"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
@@ -415,7 +414,6 @@ async def _process_advertisement(
|
||||
Process an advertisement packet.
|
||||
|
||||
Extracts contact info and updates the database/broadcasts to clients.
|
||||
For non-repeater contacts, triggers sync of recent contacts to radio for DM ACK support.
|
||||
"""
|
||||
# Parse packet to get path info if not already provided
|
||||
if packet_info is None:
|
||||
@@ -533,14 +531,6 @@ async def _process_advertisement(
|
||||
if settings.auto_decrypt_dm_on_advert:
|
||||
await start_historical_dm_decryption(None, advert.public_key.lower(), advert.name)
|
||||
|
||||
# If this is not a repeater, trigger recent contacts sync to radio
|
||||
# This ensures we can auto-ACK DMs from recent contacts
|
||||
if contact_type != CONTACT_TYPE_REPEATER:
|
||||
# Import here to avoid circular import
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
|
||||
asyncio.create_task(sync_recent_contacts_to_radio())
|
||||
|
||||
|
||||
async def _process_direct_message(
|
||||
raw_bytes: bytes,
|
||||
|
||||
50
app/radio.py
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -290,10 +366,10 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
# Ensure default channels exist
|
||||
await ensure_default_channels()
|
||||
|
||||
# Reload favorites and recent contacts back onto the radio immediately
|
||||
# so favorited contacts don't stay in the on_radio=False limbo until the
|
||||
# next advertisement arrives. Pass mc directly since the caller already
|
||||
# holds the radio operation lock (asyncio.Lock is not reentrant).
|
||||
# Reload favorites plus a working-set fill back onto the radio immediately
|
||||
# so they do not stay in on_radio=False limbo after offload. Pass mc directly
|
||||
# since the caller already holds the radio operation lock (asyncio.Lock is not
|
||||
# reentrant).
|
||||
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
|
||||
|
||||
return {
|
||||
@@ -374,11 +450,10 @@ async def _message_poll_loop():
|
||||
"""Background task that periodically polls for messages."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(MESSAGE_POLL_INTERVAL)
|
||||
|
||||
# Clean up expired pending ACKs every poll cycle so they don't
|
||||
# accumulate when no ACKs arrive (e.g. all recipients out of range).
|
||||
cleanup_expired_acks()
|
||||
aggressive_fallback = settings.enable_message_poll_fallback
|
||||
await asyncio.sleep(
|
||||
MESSAGE_POLL_INTERVAL if aggressive_fallback else MESSAGE_POLL_AUDIT_INTERVAL
|
||||
)
|
||||
|
||||
if radio_manager.is_connected and not is_polling_paused():
|
||||
try:
|
||||
@@ -389,10 +464,24 @@ async def _message_poll_loop():
|
||||
) as mc:
|
||||
count = await poll_for_messages(mc)
|
||||
if count > 0:
|
||||
logger.warning(
|
||||
"Poll loop caught %d message(s) missed by auto-fetch",
|
||||
count,
|
||||
)
|
||||
if aggressive_fallback:
|
||||
logger.warning(
|
||||
"Poll loop caught %d message(s) missed by auto-fetch",
|
||||
count,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Periodic radio audit caught %d message(s) that were not "
|
||||
"surfaced via event subscription. See README and consider "
|
||||
"setting MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true to "
|
||||
"enable more frequent polling.",
|
||||
count,
|
||||
)
|
||||
broadcast_error(
|
||||
"A periodic poll task has discovered radio inconsistencies.",
|
||||
"Please check the logs for recommendations (search "
|
||||
"'MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK').",
|
||||
)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping message poll: radio busy")
|
||||
|
||||
@@ -407,7 +496,16 @@ def start_message_polling():
|
||||
global _message_poll_task
|
||||
if _message_poll_task is None or _message_poll_task.done():
|
||||
_message_poll_task = asyncio.create_task(_message_poll_loop())
|
||||
logger.info("Started periodic message polling (interval: %ds)", MESSAGE_POLL_INTERVAL)
|
||||
if settings.enable_message_poll_fallback:
|
||||
logger.info(
|
||||
"Started periodic message polling task (aggressive fallback, interval: %ds)",
|
||||
MESSAGE_POLL_INTERVAL,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Started periodic message audit task (interval: %ds)",
|
||||
MESSAGE_POLL_AUDIT_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
async def stop_message_polling():
|
||||
@@ -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()
|
||||
|
||||
@@ -637,34 +742,101 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
if len(selected_contacts) >= max_contacts:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < max_contacts:
|
||||
recent_contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts)
|
||||
for contact in recent_contacts:
|
||||
if len(selected_contacts) < refill_target:
|
||||
for contact in await ContactRepository.get_recently_contacted_non_repeaters(
|
||||
limit=max_contacts
|
||||
):
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
if len(selected_contacts) >= max_contacts:
|
||||
if len(selected_contacts) >= refill_target:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < refill_target:
|
||||
for contact in await ContactRepository.get_recently_advertised_non_repeaters(
|
||||
limit=max_contacts
|
||||
):
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
if len(selected_contacts) >= refill_target:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"Selected %d contacts to sync (%d favorite contacts first, limit=%d)",
|
||||
"Selected %d contacts to sync (%d favorites, refill_target=%d, capacity=%d)",
|
||||
len(selected_contacts),
|
||||
favorite_contacts_loaded,
|
||||
refill_target,
|
||||
max_contacts,
|
||||
)
|
||||
return await _load_contacts_to_radio(mc, selected_contacts)
|
||||
|
||||
|
||||
async def ensure_contact_on_radio(
|
||||
public_key: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
mc: MeshCore | None = None,
|
||||
) -> dict:
|
||||
"""Ensure one contact is loaded on the radio for ACK/routing support."""
|
||||
global _last_contact_sync
|
||||
|
||||
now = time.time()
|
||||
if not force and (now - _last_contact_sync) < CONTACT_SYNC_THROTTLE_SECONDS:
|
||||
logger.debug(
|
||||
"Single-contact sync throttled (last sync %ds ago)",
|
||||
int(now - _last_contact_sync),
|
||||
)
|
||||
return {"loaded": 0, "throttled": True}
|
||||
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning("Cannot sync favorite contact '%s': ambiguous key prefix", public_key)
|
||||
return {"loaded": 0, "error": "Ambiguous contact key prefix"}
|
||||
|
||||
if not contact:
|
||||
logger.debug("Cannot sync favorite contact %s: not found", public_key[:12])
|
||||
return {"loaded": 0, "error": "Contact not found"}
|
||||
|
||||
if mc is not None:
|
||||
_last_contact_sync = now
|
||||
return await _load_contacts_to_radio(mc, [contact])
|
||||
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.debug("Cannot sync favorite contact to radio: not connected")
|
||||
return {"loaded": 0, "error": "Radio not connected"}
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"ensure_contact_on_radio",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
_last_contact_sync = now
|
||||
assert mc is not None
|
||||
return await _load_contacts_to_radio(mc, [contact])
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping favorite contact sync: radio busy")
|
||||
return {"loaded": 0, "busy": True}
|
||||
except Exception as e:
|
||||
logger.error("Error syncing favorite contact to radio: %s", e, exc_info=True)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
|
||||
|
||||
async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict:
|
||||
"""Load the provided contacts onto the radio."""
|
||||
loaded = 0
|
||||
already_on_radio = 0
|
||||
failed = 0
|
||||
|
||||
for contact in selected_contacts:
|
||||
# Check if already on radio
|
||||
for contact in contacts:
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
already_on_radio += 1
|
||||
# Update DB if not marked as on_radio
|
||||
if not contact.on_radio:
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
continue
|
||||
@@ -722,8 +894,10 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
"""
|
||||
Load contacts to the radio for DM ACK support.
|
||||
|
||||
Favorite contacts are prioritized first, then recent non-repeater contacts
|
||||
fill remaining slots up to max_radio_contacts.
|
||||
Fill order is favorites, then recently contacted non-repeaters,
|
||||
then recently advert-heard non-repeaters. Favorites are always reloaded
|
||||
up to the configured capacity; additional non-favorite fill stops at the
|
||||
80% refill target.
|
||||
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -253,17 +253,28 @@ class ContactRepository:
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get the most recently active non-repeater contacts.
|
||||
|
||||
Orders by most recent activity (last_contacted or last_advert),
|
||||
excluding repeaters (type=2).
|
||||
"""
|
||||
async def get_recently_contacted_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get recently interacted-with non-repeater contacts."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2
|
||||
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
|
||||
WHERE type != 2 AND last_contacted IS NOT NULL
|
||||
ORDER BY last_contacted DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get recently advert-heard non-repeater contacts."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2 AND last_advert IS NOT NULL
|
||||
ORDER BY last_advert DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
|
||||
@@ -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."""
|
||||
@@ -167,6 +198,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 +317,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 +343,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 ?"
|
||||
@@ -545,6 +692,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 +799,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
|
||||
|
||||
@@ -10,10 +10,12 @@ from app.models import (
|
||||
ContactActiveRoom,
|
||||
ContactAdvertPath,
|
||||
ContactAdvertPathSummary,
|
||||
ContactAnalytics,
|
||||
ContactDetail,
|
||||
ContactRoutingOverrideRequest,
|
||||
ContactUpsert,
|
||||
CreateContactRequest,
|
||||
NameOnlyContactDetail,
|
||||
NearestRepeater,
|
||||
TraceResponse,
|
||||
)
|
||||
@@ -91,6 +93,102 @@ async def _broadcast_contact_update(contact: Contact) -> None:
|
||||
broadcast_event("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 +212,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
|
||||
@@ -179,73 +297,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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ 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"
|
||||
@@ -179,6 +179,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 +226,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 +273,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 +330,8 @@ async def update_fanout_config(config_id: str, body: FanoutConfigUpdate) -> dict
|
||||
_validate_webhook_config(config_to_validate)
|
||||
elif existing["type"] == "apprise":
|
||||
_validate_apprise_config(config_to_validate)
|
||||
elif existing["type"] == "sqs":
|
||||
_validate_sqs_config(config_to_validate)
|
||||
|
||||
updated = await FanoutConfigRepository.update(config_id, **kwargs)
|
||||
if updated is None:
|
||||
|
||||
@@ -15,6 +15,7 @@ class HealthResponse(BaseModel):
|
||||
status: str
|
||||
radio_connected: bool
|
||||
radio_initializing: bool = False
|
||||
radio_state: str = "disconnected"
|
||||
connection_info: str | None
|
||||
database_size_mb: float
|
||||
oldest_undecrypted_timestamp: int | None
|
||||
@@ -56,12 +57,31 @@ async def build_health_data(radio_connected: bool, connection_info: str | None)
|
||||
if not radio_connected:
|
||||
setup_complete = False
|
||||
|
||||
connection_desired = getattr(radio_manager, "connection_desired", True)
|
||||
if not isinstance(connection_desired, bool):
|
||||
connection_desired = True
|
||||
|
||||
is_reconnecting = getattr(radio_manager, "is_reconnecting", False)
|
||||
if not isinstance(is_reconnecting, bool):
|
||||
is_reconnecting = False
|
||||
|
||||
radio_initializing = bool(radio_connected and (setup_in_progress or not setup_complete))
|
||||
if not connection_desired:
|
||||
radio_state = "paused"
|
||||
elif radio_initializing:
|
||||
radio_state = "initializing"
|
||||
elif radio_connected:
|
||||
radio_state = "connected"
|
||||
elif is_reconnecting:
|
||||
radio_state = "connecting"
|
||||
else:
|
||||
radio_state = "disconnected"
|
||||
|
||||
return {
|
||||
"status": "ok" if radio_connected and not radio_initializing else "degraded",
|
||||
"radio_connected": radio_connected,
|
||||
"radio_initializing": radio_initializing,
|
||||
"radio_state": radio_state,
|
||||
"connection_info": connection_info,
|
||||
"database_size_mb": db_size_mb,
|
||||
"oldest_undecrypted_timestamp": oldest_ts,
|
||||
|
||||
@@ -14,13 +14,14 @@ 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"])
|
||||
|
||||
|
||||
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:
|
||||
@@ -170,6 +171,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 +197,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 +245,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(
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,7 +27,7 @@ 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
|
||||
@@ -138,17 +141,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 +203,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 +219,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 +227,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 +246,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "2.7.9",
|
||||
"version": "3.1.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/favicon-256x256.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -13,6 +13,7 @@ import {
|
||||
useConversationActions,
|
||||
useConversationNavigation,
|
||||
useRealtimeAppState,
|
||||
useBrowserNotifications,
|
||||
} from './hooks';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import type { MessageInputHandle } from './components/MessageInput';
|
||||
@@ -20,8 +21,19 @@ import { messageContainsMention } from './utils/messageParser';
|
||||
import type { Conversation, RawPacket } from './types';
|
||||
|
||||
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 {
|
||||
notificationsSupported,
|
||||
notificationsPermission,
|
||||
isConversationNotificationsEnabled,
|
||||
toggleConversationNotifications,
|
||||
notifyIncomingMessage,
|
||||
} = useBrowserNotifications();
|
||||
const {
|
||||
showNewMessage,
|
||||
showSettings,
|
||||
@@ -61,6 +73,8 @@ export function App() {
|
||||
handleSaveConfig,
|
||||
handleSetPrivateKey,
|
||||
handleReboot,
|
||||
handleDisconnect,
|
||||
handleReconnect,
|
||||
handleAdvertise,
|
||||
handleHealthRefresh,
|
||||
} = useRadioControl();
|
||||
@@ -142,6 +156,7 @@ export function App() {
|
||||
infoPaneContactKey,
|
||||
infoPaneFromChannel,
|
||||
infoPaneChannelKey,
|
||||
searchPrefillRequest,
|
||||
handleOpenContactInfo,
|
||||
handleCloseContactInfo,
|
||||
handleOpenChannelInfo,
|
||||
@@ -149,6 +164,7 @@ export function App() {
|
||||
handleSelectConversationWithTargetReset,
|
||||
handleNavigateToChannel,
|
||||
handleNavigateToMessage,
|
||||
handleOpenSearchWithQuery,
|
||||
} = useConversationNavigation({
|
||||
channels,
|
||||
handleSelectConversation,
|
||||
@@ -202,6 +218,7 @@ export function App() {
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
updateMessageAck,
|
||||
notifyIncomingMessage,
|
||||
});
|
||||
const {
|
||||
handleSendMessage,
|
||||
@@ -237,7 +254,10 @@ export function App() {
|
||||
[fetchUndecryptedCount, setChannels]
|
||||
);
|
||||
|
||||
const statusProps = { health, config };
|
||||
const statusProps = {
|
||||
health,
|
||||
config,
|
||||
};
|
||||
const sidebarProps = {
|
||||
contacts,
|
||||
channels,
|
||||
@@ -258,6 +278,7 @@ export function App() {
|
||||
onSortOrderChange: (sortOrder: 'recent' | 'alpha') => {
|
||||
void handleSortOrderChange(sortOrder);
|
||||
},
|
||||
isConversationNotificationsEnabled,
|
||||
};
|
||||
const conversationPaneProps = {
|
||||
activeConversation,
|
||||
@@ -289,11 +310,27 @@ export function App() {
|
||||
onLoadNewer: fetchNewerMessages,
|
||||
onJumpToBottom: jumpToBottom,
|
||||
onSendMessage: handleSendMessage,
|
||||
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 +340,8 @@ export function App() {
|
||||
onSaveAppSettings: handleSaveAppSettings,
|
||||
onSetPrivateKey: handleSetPrivateKey,
|
||||
onReboot: handleReboot,
|
||||
onDisconnect: handleDisconnect,
|
||||
onReconnect: handleReconnect,
|
||||
onAdvertise: handleAdvertise,
|
||||
onHealthRefresh: handleHealthRefresh,
|
||||
onRefreshAppSettings: fetchAppSettings,
|
||||
@@ -333,6 +372,12 @@ export function App() {
|
||||
favorites,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onNavigateToChannel: handleNavigateToChannel,
|
||||
onSearchMessagesByKey: (publicKey: string) => {
|
||||
handleOpenSearchWithQuery(`user:${publicKey}`);
|
||||
},
|
||||
onSearchMessagesByName: (name: string) => {
|
||||
handleOpenSearchWithQuery(`user:${quoteSearchOperatorValue(name)}`);
|
||||
},
|
||||
onToggleBlockedKey: handleBlockKey,
|
||||
onToggleBlockedName: handleBlockName,
|
||||
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||
|
||||
@@ -5,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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
@@ -213,12 +220,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={
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
@@ -125,12 +126,12 @@ export function ChannelInfoPane({
|
||||
>
|
||||
{isFavorite(favorites, 'channel', channel.key) ? (
|
||||
<>
|
||||
<span className="text-favorite text-lg">★</span>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">☆</span>
|
||||
<Star className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Add to favorites</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
@@ -13,7 +14,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 +33,11 @@ export function ChatHeader({
|
||||
channels,
|
||||
config,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
onTrace,
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onDeleteChannel,
|
||||
@@ -46,25 +55,39 @@ 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 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);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
|
||||
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex min-w-0 flex-1 items-start gap-2">
|
||||
{conversation.type === 'contact' && onOpenContactInfo && (
|
||||
<span
|
||||
className="flex-shrink-0 cursor-pointer"
|
||||
@@ -84,111 +107,175 @@ export function ChatHeader({
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<h2
|
||||
className={`flex-shrink-0 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
|
||||
role={titleClickable ? 'button' : undefined}
|
||||
tabIndex={titleClickable ? 0 : undefined}
|
||||
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
|
||||
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
|
||||
onClick={
|
||||
titleClickable
|
||||
? () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
|
||||
onOpenChannelInfo(conversation.id);
|
||||
}
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<h2
|
||||
className={`flex shrink min-w-0 items-center gap-1.5 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
|
||||
role={titleClickable ? 'button' : undefined}
|
||||
tabIndex={titleClickable ? 0 : undefined}
|
||||
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
|
||||
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
|
||||
onClick={
|
||||
titleClickable
|
||||
? () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
|
||||
onOpenChannelInfo(conversation.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
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 className="truncate">
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
activeChannel?.is_hashtag
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</span>
|
||||
{titleClickable && (
|
||||
<Info
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</h2>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
<button
|
||||
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowKey(true);
|
||||
}}
|
||||
title="Reveal channel key"
|
||||
>
|
||||
Show Key
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success(
|
||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
||||
);
|
||||
}}
|
||||
title="Click to copy"
|
||||
aria-label={
|
||||
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
|
||||
}
|
||||
>
|
||||
{conversation.type === 'channel'
|
||||
? conversation.id.toLowerCase()
|
||||
: conversation.id}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{conversation.type === 'contact' &&
|
||||
(() => {
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||
if (!contact) return null;
|
||||
return (
|
||||
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
|
||||
<ContactStatusInfo
|
||||
contact={contact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
{conversation.type === '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>
|
||||
)}
|
||||
{conversation.type === 'contact' &&
|
||||
(() => {
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||
if (!contact) return null;
|
||||
return (
|
||||
<ContactStatusInfo
|
||||
contact={contact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
{conversation.type === 'channel' && activeFloodScopeDisplay && (
|
||||
<button
|
||||
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
|
||||
onClick={handleEditFloodScopeOverride}
|
||||
title="Set regional override"
|
||||
aria-label="Set regional override"
|
||||
>
|
||||
<Globe2
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
|
||||
{activeFloodScopeDisplay}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
|
||||
{conversation.type === 'contact' && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTrace}
|
||||
title="Direct Trace"
|
||||
aria-label="Direct Trace"
|
||||
>
|
||||
<span aria-hidden="true">🛎</span>
|
||||
<Route className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{notificationsSupported && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onToggleNotifications}
|
||||
title={
|
||||
notificationsEnabled
|
||||
? 'Disable desktop notifications for this conversation'
|
||||
: notificationsPermission === 'denied'
|
||||
? 'Notifications blocked by the browser'
|
||||
: 'Enable desktop notifications for this conversation'
|
||||
}
|
||||
aria-label={
|
||||
notificationsEnabled
|
||||
? 'Disable notifications for this conversation'
|
||||
: 'Enable notifications for this conversation'
|
||||
}
|
||||
>
|
||||
<Bell
|
||||
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||
fill={notificationsEnabled ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
||||
<button
|
||||
className="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">🌎</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 +283,15 @@ export function ChatHeader({
|
||||
}
|
||||
>
|
||||
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
|
||||
<span className="text-favorite">★</span>
|
||||
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</span>
|
||||
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!(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 +302,7 @@ export function ChatHeader({
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { Ban, Search, Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import {
|
||||
@@ -16,7 +17,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 +51,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 +68,8 @@ export function ContactInfoPane({
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
onNavigateToChannel,
|
||||
onSearchMessagesByKey,
|
||||
onSearchMessagesByName,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
onToggleBlockedKey,
|
||||
@@ -65,7 +78,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 +86,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 +115,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 &&
|
||||
@@ -129,9 +147,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 +164,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 +174,65 @@ export function ContactInfoPane({
|
||||
>
|
||||
{blockedNames.includes(nameOnlyValue) ? (
|
||||
<>
|
||||
<span className="text-destructive text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
|
||||
<span>Unblock this name</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Block this name</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{onSearchMessagesByName && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onSearchMessagesByName(nameOnlyValue)}
|
||||
>
|
||||
<Search className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Search user's messages by name</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fromChannel && (
|
||||
<ChannelAttributionWarning
|
||||
nameOnly
|
||||
includeAliasNote={false}
|
||||
className="border-b border-border mx-0 my-0 rounded-none px-5 py-3"
|
||||
/>
|
||||
)}
|
||||
|
||||
<MessageStatsSection
|
||||
dmMessageCount={0}
|
||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||
showDirectMessages={false}
|
||||
/>
|
||||
|
||||
{analytics?.name_first_seen_at && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
<InfoItem
|
||||
label="Name First In Use"
|
||||
value={formatTime(analytics.name_first_seen_at)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
) : loading && !detail ? (
|
||||
) : loading && !analytics && !contact ? (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
@@ -211,8 +278,6 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{fromChannel && <ChannelAttributionWarning />}
|
||||
|
||||
{/* Info grid */}
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
@@ -279,15 +344,16 @@ export function ContactInfoPane({
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onToggleFavorite('contact', contact.public_key)}
|
||||
title="Favorite contacts stay loaded on the radio for ACK support"
|
||||
>
|
||||
{isFavorite(favorites, 'contact', contact.public_key) ? (
|
||||
<>
|
||||
<span className="text-favorite text-lg">★</span>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">☆</span>
|
||||
<Star className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Add to favorites</span>
|
||||
</>
|
||||
)}
|
||||
@@ -305,12 +371,12 @@ export function ContactInfoPane({
|
||||
>
|
||||
{blockedKeys.includes(contact.public_key.toLowerCase()) ? (
|
||||
<>
|
||||
<span className="text-destructive text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
|
||||
<span>Unblock this key</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Block this key</span>
|
||||
</>
|
||||
)}
|
||||
@@ -324,12 +390,12 @@ export function ContactInfoPane({
|
||||
>
|
||||
{blockedNames.includes(contact.name) ? (
|
||||
<>
|
||||
<span className="text-destructive text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
|
||||
<span>Unblock name “{contact.name}”</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Block name “{contact.name}”</span>
|
||||
</>
|
||||
)}
|
||||
@@ -338,85 +404,25 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AKA (Name History) - only show if more than one name */}
|
||||
{detail && detail.name_history.length > 1 && (
|
||||
{onSearchMessagesByKey && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Also Known As</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.name_history.map((h) => (
|
||||
<div key={h.name} className="flex justify-between items-center text-sm">
|
||||
<span className="font-medium truncate">{h.name}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{formatTime(h.first_seen)} – {formatTime(h.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Message Stats */}
|
||||
{detail && (detail.dm_message_count > 0 || detail.channel_message_count > 0) && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Messages</SectionLabel>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm">
|
||||
{detail.dm_message_count > 0 && (
|
||||
<InfoItem
|
||||
label="Direct Messages"
|
||||
value={detail.dm_message_count.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
{detail.channel_message_count > 0 && (
|
||||
<InfoItem
|
||||
label="Channel Messages"
|
||||
value={detail.channel_message_count.toLocaleString()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Most Active Rooms */}
|
||||
{detail && detail.most_active_rooms.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Most Active Rooms</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.most_active_rooms.map((room) => (
|
||||
<div
|
||||
key={room.channel_key}
|
||||
className="flex justify-between items-center text-sm"
|
||||
>
|
||||
<span
|
||||
className={
|
||||
onNavigateToChannel
|
||||
? 'cursor-pointer hover:text-primary transition-colors truncate'
|
||||
: 'truncate'
|
||||
}
|
||||
role={onNavigateToChannel ? 'button' : undefined}
|
||||
tabIndex={onNavigateToChannel ? 0 : undefined}
|
||||
onKeyDown={onNavigateToChannel ? handleKeyboardActivate : undefined}
|
||||
onClick={() => onNavigateToChannel?.(room.channel_key)}
|
||||
>
|
||||
{room.channel_name.startsWith('#') || room.channel_name === 'Public'
|
||||
? room.channel_name
|
||||
: `#${room.channel_name}`}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{room.message_count.toLocaleString()} msg
|
||||
{room.message_count !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onSearchMessagesByKey(contact.public_key)}
|
||||
>
|
||||
<Search className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Search user's messages by key</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Nearest Repeaters */}
|
||||
{detail && detail.nearest_repeaters.length > 0 && (
|
||||
{analytics && analytics.nearest_repeaters.length > 0 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Nearest Repeaters</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{detail.nearest_repeaters.map((r) => (
|
||||
{analytics.nearest_repeaters.map((r) => (
|
||||
<div key={r.public_key} className="flex justify-between items-center text-sm">
|
||||
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
@@ -432,11 +438,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 +458,41 @@ export function ContactInfoPane({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fromChannel && (
|
||||
<ChannelAttributionWarning
|
||||
includeAliasNote={Boolean(analytics && analytics.name_history.length > 1)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* AKA (Name History) - only show if more than one name */}
|
||||
{analytics && analytics.name_history.length > 1 && (
|
||||
<div className="px-5 py-3 border-b border-border">
|
||||
<SectionLabel>Also Known As</SectionLabel>
|
||||
<div className="space-y-1">
|
||||
{analytics.name_history.map((h) => (
|
||||
<div key={h.name} className="flex justify-between items-center text-sm">
|
||||
<span className="font-medium truncate">{h.name}</span>
|
||||
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
|
||||
{formatTime(h.first_seen)} – {formatTime(h.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MessageStatsSection
|
||||
dmMessageCount={analytics?.dm_message_count ?? 0}
|
||||
channelMessageCount={analytics?.channel_message_count ?? 0}
|
||||
/>
|
||||
|
||||
<ActivityChartsSection analytics={analytics} />
|
||||
|
||||
<MostActiveRoomsSection
|
||||
rooms={analytics?.most_active_rooms ?? []}
|
||||
onNavigateToChannel={onNavigateToChannel}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
@@ -471,18 +512,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>
|
||||
|
||||
@@ -31,6 +31,9 @@ interface ConversationPaneProps {
|
||||
rawPackets: RawPacket[];
|
||||
config: RadioConfig | null;
|
||||
health: HealthStatus | null;
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
favorites: Favorite[];
|
||||
messages: Message[];
|
||||
messagesLoading: boolean;
|
||||
@@ -54,6 +57,7 @@ interface ConversationPaneProps {
|
||||
onLoadNewer: () => Promise<void>;
|
||||
onJumpToBottom: () => void;
|
||||
onSendMessage: (text: string) => Promise<void>;
|
||||
onToggleNotifications: () => void;
|
||||
}
|
||||
|
||||
function LoadingPane({ label }: { label: string }) {
|
||||
@@ -69,6 +73,9 @@ export function ConversationPane({
|
||||
rawPackets,
|
||||
config,
|
||||
health,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
favorites,
|
||||
messages,
|
||||
messagesLoading,
|
||||
@@ -92,6 +99,7 @@ export function ConversationPane({
|
||||
onLoadNewer,
|
||||
onJumpToBottom,
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
}: ConversationPaneProps) {
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
||||
@@ -155,10 +163,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 +186,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}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Dice5 } from 'lucide-react';
|
||||
import type { Contact, Conversation } from '../types';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
@@ -256,7 +257,7 @@ export function NewMessageModal({
|
||||
title="Generate random key"
|
||||
aria-label="Generate random key"
|
||||
>
|
||||
<span aria-hidden="true">🎲</span>
|
||||
<Dice5 className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
@@ -24,10 +25,14 @@ interface RepeaterDashboardProps {
|
||||
conversation: Conversation;
|
||||
contacts: Contact[];
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
radioLat: number | null;
|
||||
radioLon: number | null;
|
||||
radioName: string | null;
|
||||
onTrace: () => void;
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
}
|
||||
@@ -36,10 +41,14 @@ export function RepeaterDashboard({
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
radioLat,
|
||||
radioLon,
|
||||
radioName,
|
||||
onTrace,
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
}: RepeaterDashboardProps) {
|
||||
@@ -56,7 +65,8 @@ export function RepeaterDashboard({
|
||||
refreshPane,
|
||||
loadAll,
|
||||
sendConsoleCommand,
|
||||
sendAdvert,
|
||||
sendZeroHopAdvert,
|
||||
sendFloodAdvert,
|
||||
rebootRepeater,
|
||||
syncClock,
|
||||
} = useRepeaterDashboard(conversation);
|
||||
@@ -70,23 +80,33 @@ export function RepeaterDashboard({
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-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 +121,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">🛎</span>
|
||||
<Route className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
{notificationsSupported && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onToggleNotifications}
|
||||
title={
|
||||
notificationsEnabled
|
||||
? 'Disable desktop notifications for this conversation'
|
||||
: notificationsPermission === 'denied'
|
||||
? 'Notifications blocked by the browser'
|
||||
: 'Enable desktop notifications for this conversation'
|
||||
}
|
||||
aria-label={
|
||||
notificationsEnabled
|
||||
? 'Disable notifications for this conversation'
|
||||
: 'Enable notifications for this conversation'
|
||||
}
|
||||
>
|
||||
<Bell
|
||||
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||
fill={notificationsEnabled ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onToggleFavorite('contact', conversation.id)}
|
||||
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={
|
||||
isFav
|
||||
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
}
|
||||
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFav ? (
|
||||
<span className="text-favorite">★</span>
|
||||
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</span>
|
||||
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onDeleteContact(conversation.id)}
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -196,7 +249,8 @@ export function RepeaterDashboard({
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
<ActionsPane
|
||||
onSendAdvert={sendAdvert}
|
||||
onSendZeroHopAdvert={sendZeroHopAdvert}
|
||||
onSendFloodAdvert={sendFloodAdvert}
|
||||
onSyncClock={syncClock}
|
||||
onReboot={rebootRepeater}
|
||||
consoleLoading={consoleLoading}
|
||||
|
||||
@@ -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,17 @@ 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]);
|
||||
|
||||
// Fetch search results
|
||||
useEffect(() => {
|
||||
if (!debouncedQuery) {
|
||||
@@ -193,7 +238,11 @@ 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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -246,7 +295,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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bell,
|
||||
CheckCheck,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
LockOpen,
|
||||
Map,
|
||||
Search as SearchIcon,
|
||||
Sparkles,
|
||||
SquarePen,
|
||||
Waypoints,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
type Contact,
|
||||
@@ -24,6 +37,7 @@ type ConversationRow = {
|
||||
name: string;
|
||||
unreadCount: number;
|
||||
isMention: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
contact?: Contact;
|
||||
};
|
||||
|
||||
@@ -81,6 +95,7 @@ interface SidebarProps {
|
||||
sortOrder?: SortOrder;
|
||||
/** Callback when sort order changes */
|
||||
onSortOrderChange?: (order: SortOrder) => void;
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
@@ -99,6 +114,7 @@ export function Sidebar({
|
||||
favorites,
|
||||
sortOrder: sortOrderProp = 'recent',
|
||||
onSortOrderChange,
|
||||
isConversationNotificationsEnabled,
|
||||
}: SidebarProps) {
|
||||
const sortOrder = sortOrderProp;
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -393,6 +409,7 @@ export function Sidebar({
|
||||
name: channel.name,
|
||||
unreadCount: getUnreadCount('channel', channel.key),
|
||||
isMention: hasMention('channel', channel.key),
|
||||
notificationsEnabled: isConversationNotificationsEnabled?.('channel', channel.key) ?? false,
|
||||
});
|
||||
|
||||
const buildContactRow = (contact: Contact, keyPrefix: string): ConversationRow => ({
|
||||
@@ -402,6 +419,8 @@ export function Sidebar({
|
||||
name: getContactDisplayName(contact.name, contact.public_key),
|
||||
unreadCount: getUnreadCount('contact', contact.public_key),
|
||||
isMention: hasMention('contact', contact.public_key),
|
||||
notificationsEnabled:
|
||||
isConversationNotificationsEnabled?.('contact', contact.public_key) ?? false,
|
||||
contact,
|
||||
});
|
||||
|
||||
@@ -434,19 +453,26 @@ export function Sidebar({
|
||||
/>
|
||||
)}
|
||||
<span className="name flex-1 truncate text-[13px]">{row.name}</span>
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
row.isMention
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{row.notificationsEnabled && (
|
||||
<span aria-label="Notifications enabled" title="Notifications enabled">
|
||||
<Bell className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
)}
|
||||
{row.unreadCount > 0 && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-[10px] font-semibold px-1.5 py-0.5 rounded-full min-w-[18px] text-center',
|
||||
row.isMention
|
||||
? 'bg-badge-mention text-badge-mention-foreground'
|
||||
: 'bg-badge-unread/90 text-badge-unread-foreground'
|
||||
)}
|
||||
aria-label={`${row.unreadCount} unread message${row.unreadCount !== 1 ? 's' : ''}`}
|
||||
>
|
||||
{row.unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -459,7 +485,7 @@ export function Sidebar({
|
||||
}: {
|
||||
key: string;
|
||||
active?: boolean;
|
||||
icon: string;
|
||||
icon: React.ReactNode;
|
||||
label: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}) => (
|
||||
@@ -475,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: <Waypoints 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: <Sparkles 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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function ActionsPane({
|
||||
onSendAdvert,
|
||||
onSendZeroHopAdvert,
|
||||
onSendFloodAdvert,
|
||||
onSyncClock,
|
||||
onReboot,
|
||||
consoleLoading,
|
||||
}: {
|
||||
onSendAdvert: () => void;
|
||||
onSendZeroHopAdvert: () => void;
|
||||
onSendFloodAdvert: () => void;
|
||||
onSyncClock: () => void;
|
||||
onReboot: () => void;
|
||||
consoleLoading: boolean;
|
||||
@@ -36,8 +38,16 @@ export function ActionsPane({
|
||||
<h3 className="text-sm font-medium">Actions</h3>
|
||||
</div>
|
||||
<div className="p-3 flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onSendAdvert} disabled={consoleLoading}>
|
||||
Send Advert
|
||||
<Button variant="outline" size="sm" onClick={onSendZeroHopAdvert} disabled={consoleLoading}>
|
||||
Zero Hop Advert
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onSendFloodAdvert}
|
||||
disabled={consoleLoading}
|
||||
>
|
||||
Flood Advert
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onSyncClock} disabled={consoleLoading}>
|
||||
Sync Clock
|
||||
|
||||
@@ -80,6 +80,28 @@ export function formatAdvertInterval(val: string | null): string {
|
||||
return `${trimmed}h`;
|
||||
}
|
||||
|
||||
function formatFetchedRelative(fetchedAt: number): string {
|
||||
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - fetchedAt) / 1000));
|
||||
|
||||
if (elapsedSeconds < 60) return 'Just now';
|
||||
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
||||
if (elapsedMinutes < 60) {
|
||||
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
||||
return `${elapsedHours} hour${elapsedHours === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
function formatFetchedTime(fetchedAt: number): string {
|
||||
return new Date(fetchedAt).toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// --- Generic Pane Wrapper ---
|
||||
|
||||
export function RepeaterPane({
|
||||
@@ -99,10 +121,22 @@ export function RepeaterPane({
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}) {
|
||||
const fetchedAt = state.fetched_at ?? null;
|
||||
|
||||
return (
|
||||
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{fetchedAt && (
|
||||
<p
|
||||
className="text-[11px] text-muted-foreground"
|
||||
title={new Date(fetchedAt).toLocaleString()}
|
||||
>
|
||||
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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,6 +62,12 @@ 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;
|
||||
@@ -998,6 +1006,111 @@ function WebhookConfigEditor({
|
||||
);
|
||||
}
|
||||
|
||||
function SqsConfigEditor({
|
||||
config,
|
||||
scope,
|
||||
onChange,
|
||||
onScopeChange,
|
||||
}: {
|
||||
config: Record<string, unknown>;
|
||||
scope: Record<string, unknown>;
|
||||
onChange: (config: Record<string, unknown>) => void;
|
||||
onScopeChange: (scope: Record<string, unknown>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Send matched mesh events to an Amazon SQS queue for durable processing by workers, Lambdas,
|
||||
or downstream automation.
|
||||
</p>
|
||||
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning">
|
||||
Outgoing messages and any selected raw packets will be delivered exactly as forwarded by the
|
||||
fanout scope, including decrypted/plaintext message content.
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-queue-url">Queue URL</Label>
|
||||
<Input
|
||||
id="fanout-sqs-queue-url"
|
||||
type="url"
|
||||
placeholder="https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events"
|
||||
value={(config.queue_url as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, queue_url: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-region">Region (optional)</Label>
|
||||
<Input
|
||||
id="fanout-sqs-region"
|
||||
type="text"
|
||||
placeholder="us-east-1"
|
||||
value={(config.region_name as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, region_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-endpoint">Endpoint URL (optional)</Label>
|
||||
<Input
|
||||
id="fanout-sqs-endpoint"
|
||||
type="url"
|
||||
placeholder="http://localhost:4566"
|
||||
value={(config.endpoint_url as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, endpoint_url: e.target.value })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Useful for LocalStack or custom endpoints</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Static Credentials (optional)</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Leave blank to use the server's normal AWS credential chain.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-access-key">Access Key ID</Label>
|
||||
<Input
|
||||
id="fanout-sqs-access-key"
|
||||
type="text"
|
||||
value={(config.access_key_id as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, access_key_id: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-secret-key">Secret Access Key</Label>
|
||||
<Input
|
||||
id="fanout-sqs-secret-key"
|
||||
type="password"
|
||||
value={(config.secret_access_key as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, secret_access_key: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="fanout-sqs-session-token">Session Token (optional)</Label>
|
||||
<Input
|
||||
id="fanout-sqs-session-token"
|
||||
type="password"
|
||||
value={(config.session_token as string) || ''}
|
||||
onChange={(e) => onChange({ ...config, session_token: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<ScopeSelector scope={scope} onChange={onScopeChange} showRawPackets />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsFanoutSection({
|
||||
health,
|
||||
onHealthRefresh,
|
||||
@@ -1208,6 +1321,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 +1336,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 +1418,15 @@ export function SettingsFanoutSection({
|
||||
/>
|
||||
)}
|
||||
|
||||
{detailType === 'sqs' && (
|
||||
<SqsConfigEditor
|
||||
config={editConfig}
|
||||
scope={editScope}
|
||||
onChange={setEditConfig}
|
||||
onScopeChange={setEditScope}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex gap-2">
|
||||
@@ -1520,6 +1651,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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -40,6 +40,7 @@ export function SettingsLocalSection({
|
||||
<div className="space-y-1">
|
||||
<Label>Color Scheme</Label>
|
||||
<ThemeSelector />
|
||||
<ThemePreview className="mt-6" />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
@@ -91,3 +92,64 @@ 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 and message 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>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -69,6 +74,7 @@ export function SettingsRadioSection({
|
||||
|
||||
// Advertise state
|
||||
const [advertising, setAdvertising] = useState(false);
|
||||
const [connectionBusy, setConnectionBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setName(config.name);
|
||||
@@ -284,24 +290,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 +470,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">
|
||||
@@ -452,7 +523,7 @@ export function SettingsRadioSection({
|
||||
<option value="1">2 bytes</option>
|
||||
<option value="2">3 bytes</option>
|
||||
</select>
|
||||
<div className="rounded-md border border-amber-500/50 bg-amber-500/10 p-3 text-xs text-amber-200">
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
|
||||
<p className="font-semibold mb-1">Compatibility Warning</p>
|
||||
<p>
|
||||
ALL nodes along a message's route — your radio, every repeater, and the
|
||||
@@ -575,8 +646,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>
|
||||
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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 & Reset
|
||||
|
||||
@@ -9,3 +9,4 @@ export { useContactsAndChannels } from './useContactsAndChannels';
|
||||
export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
|
||||
207
frontend/src/hooks/useBrowserNotifications.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import type { Message } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation';
|
||||
const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
|
||||
|
||||
type NotificationPermissionState = NotificationPermission | 'unsupported';
|
||||
type ConversationNotificationMap = Record<string, boolean>;
|
||||
|
||||
function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string {
|
||||
return getStateKey(type, id);
|
||||
}
|
||||
|
||||
function readStoredEnabledMap(): ConversationNotificationMap {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key, value]) => typeof key === 'string' && value === true)
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredEnabledMap(enabledByConversation: ConversationNotificationMap) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(enabledByConversation));
|
||||
}
|
||||
|
||||
function getInitialPermission(): NotificationPermissionState {
|
||||
if (typeof window === 'undefined' || !('Notification' in window)) {
|
||||
return 'unsupported';
|
||||
}
|
||||
return window.Notification.permission;
|
||||
}
|
||||
|
||||
function shouldShowDesktopNotification(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return document.visibilityState !== 'visible' || !document.hasFocus();
|
||||
}
|
||||
|
||||
function getMessageConversationNotificationKey(message: Message): string | null {
|
||||
if (message.type === 'PRIV' && message.conversation_key) {
|
||||
return getConversationNotificationKey('contact', message.conversation_key);
|
||||
}
|
||||
if (message.type === 'CHAN' && message.conversation_key) {
|
||||
return getConversationNotificationKey('channel', message.conversation_key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildNotificationTitle(message: Message): string {
|
||||
if (message.type === 'PRIV') {
|
||||
return message.sender_name
|
||||
? `New message from ${message.sender_name}`
|
||||
: `New message from ${message.conversation_key.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
const roomName = message.channel_name || message.conversation_key.slice(0, 8);
|
||||
return `New message in ${roomName}`;
|
||||
}
|
||||
|
||||
function buildPreviewNotificationTitle(type: 'channel' | 'contact', label: string): string {
|
||||
return type === 'contact' ? `New message from ${label}` : `New message in ${label}`;
|
||||
}
|
||||
|
||||
function buildMessageNotificationHash(message: Message): string | null {
|
||||
if (message.type === 'PRIV' && message.conversation_key) {
|
||||
const label = message.sender_name || message.conversation_key.slice(0, 12);
|
||||
return `#contact/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
|
||||
}
|
||||
if (message.type === 'CHAN' && message.conversation_key) {
|
||||
const label = message.channel_name || message.conversation_key.slice(0, 8);
|
||||
return `#channel/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useBrowserNotifications() {
|
||||
const [permission, setPermission] = useState<NotificationPermissionState>(getInitialPermission);
|
||||
const [enabledByConversation, setEnabledByConversation] =
|
||||
useState<ConversationNotificationMap>(readStoredEnabledMap);
|
||||
|
||||
useEffect(() => {
|
||||
setPermission(getInitialPermission());
|
||||
}, []);
|
||||
|
||||
const isConversationNotificationsEnabled = useCallback(
|
||||
(type: 'channel' | 'contact', id: string) =>
|
||||
permission === 'granted' &&
|
||||
enabledByConversation[getConversationNotificationKey(type, id)] === true,
|
||||
[enabledByConversation, permission]
|
||||
);
|
||||
|
||||
const toggleConversationNotifications = useCallback(
|
||||
async (type: 'channel' | 'contact', id: string, label: string) => {
|
||||
const conversationKey = getConversationNotificationKey(type, id);
|
||||
if (enabledByConversation[conversationKey]) {
|
||||
setEnabledByConversation((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[conversationKey];
|
||||
writeStoredEnabledMap(next);
|
||||
return next;
|
||||
});
|
||||
toast.success(`${label} notifications disabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === 'unsupported') {
|
||||
toast.error('Browser notifications unavailable', {
|
||||
description: 'This browser does not support desktop notifications.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === 'denied') {
|
||||
toast.error('Browser notifications blocked', {
|
||||
description: 'Allow notifications in your browser settings, then try again.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPermission = await window.Notification.requestPermission();
|
||||
setPermission(nextPermission);
|
||||
|
||||
if (nextPermission === 'granted') {
|
||||
setEnabledByConversation((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
[conversationKey]: true,
|
||||
};
|
||||
writeStoredEnabledMap(next);
|
||||
return next;
|
||||
});
|
||||
new window.Notification(buildPreviewNotificationTitle(type, label), {
|
||||
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
|
||||
icon: NOTIFICATION_ICON_PATH,
|
||||
tag: `meshcore-notification-preview-${conversationKey}`,
|
||||
});
|
||||
toast.success(`${label} notifications enabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error('Browser notifications not enabled', {
|
||||
description:
|
||||
nextPermission === 'denied'
|
||||
? 'Permission was denied by the browser.'
|
||||
: 'Permission request was dismissed.',
|
||||
});
|
||||
},
|
||||
[enabledByConversation, permission]
|
||||
);
|
||||
|
||||
const notifyIncomingMessage = useCallback(
|
||||
(message: Message) => {
|
||||
const conversationKey = getMessageConversationNotificationKey(message);
|
||||
if (
|
||||
permission !== 'granted' ||
|
||||
!conversationKey ||
|
||||
enabledByConversation[conversationKey] !== true ||
|
||||
!shouldShowDesktopNotification()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new window.Notification(buildNotificationTitle(message), {
|
||||
body: message.text,
|
||||
icon: NOTIFICATION_ICON_PATH,
|
||||
tag: `meshcore-message-${message.id}`,
|
||||
});
|
||||
|
||||
notification.onclick = () => {
|
||||
const hash = buildMessageNotificationHash(message);
|
||||
if (hash) {
|
||||
window.open(`${window.location.origin}${window.location.pathname}${hash}`, '_self');
|
||||
}
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
},
|
||||
[enabledByConversation, permission]
|
||||
);
|
||||
|
||||
return {
|
||||
notificationsSupported: permission !== 'unsupported',
|
||||
notificationsPermission: permission,
|
||||
isConversationNotificationsEnabled,
|
||||
toggleConversationNotifications,
|
||||
notifyIncomingMessage,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -44,6 +44,7 @@ interface UseRealtimeAppStateArgs {
|
||||
pendingDeleteFallbackRef: MutableRefObject<boolean>;
|
||||
setActiveConversation: (conv: Conversation | null) => void;
|
||||
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
|
||||
notifyIncomingMessage?: (msg: Message) => void;
|
||||
maxRawPackets?: number;
|
||||
}
|
||||
|
||||
@@ -103,6 +104,7 @@ export function useRealtimeAppState({
|
||||
pendingDeleteFallbackRef,
|
||||
setActiveConversation,
|
||||
updateMessageAck,
|
||||
notifyIncomingMessage,
|
||||
maxRawPackets = 500,
|
||||
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
|
||||
const mergeChannelIntoList = useCallback(
|
||||
@@ -126,6 +128,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 +151,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 +193,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,6 +217,10 @@ export function useRealtimeAppState({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!msg.outgoing && isNewMessage) {
|
||||
notifyIncomingMessage?.(msg);
|
||||
}
|
||||
},
|
||||
onContact: (contact: Contact) => {
|
||||
setContacts((prev) => mergeContactIntoList(prev, contact));
|
||||
@@ -259,6 +277,7 @@ export function useRealtimeAppState({
|
||||
trackNewMessage,
|
||||
triggerReconcile,
|
||||
updateMessageAck,
|
||||
notifyIncomingMessage,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
const MAX_CACHED_REPEATERS = 20;
|
||||
|
||||
interface ConsoleEntry {
|
||||
command: string;
|
||||
@@ -35,7 +36,15 @@ interface PaneData {
|
||||
lppTelemetry: RepeaterLppTelemetryResponse | null;
|
||||
}
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null };
|
||||
interface RepeaterDashboardCacheEntry {
|
||||
loggedIn: boolean;
|
||||
loginError: string | null;
|
||||
paneData: PaneData;
|
||||
paneStates: Record<PaneName, PaneState>;
|
||||
consoleHistory: ConsoleEntry[];
|
||||
}
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null, fetched_at: null };
|
||||
|
||||
function createInitialPaneStates(): Record<PaneName, PaneState> {
|
||||
return {
|
||||
@@ -61,6 +70,67 @@ function createInitialPaneData(): PaneData {
|
||||
};
|
||||
}
|
||||
|
||||
const repeaterDashboardCache = new Map<string, RepeaterDashboardCacheEntry>();
|
||||
|
||||
function clonePaneData(data: PaneData): PaneData {
|
||||
return { ...data };
|
||||
}
|
||||
|
||||
function normalizePaneStates(paneStates: Record<PaneName, PaneState>): Record<PaneName, PaneState> {
|
||||
return {
|
||||
status: { ...paneStates.status, loading: false },
|
||||
neighbors: { ...paneStates.neighbors, loading: false },
|
||||
acl: { ...paneStates.acl, loading: false },
|
||||
radioSettings: { ...paneStates.radioSettings, loading: false },
|
||||
advertIntervals: { ...paneStates.advertIntervals, loading: false },
|
||||
ownerInfo: { ...paneStates.ownerInfo, loading: false },
|
||||
lppTelemetry: { ...paneStates.lppTelemetry, loading: false },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneConsoleHistory(consoleHistory: ConsoleEntry[]): ConsoleEntry[] {
|
||||
return consoleHistory.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry | null {
|
||||
if (!publicKey) return null;
|
||||
const cached = repeaterDashboardCache.get(publicKey);
|
||||
if (!cached) return null;
|
||||
|
||||
repeaterDashboardCache.delete(publicKey);
|
||||
repeaterDashboardCache.set(publicKey, cached);
|
||||
|
||||
return {
|
||||
loggedIn: cached.loggedIn,
|
||||
loginError: cached.loginError,
|
||||
paneData: clonePaneData(cached.paneData),
|
||||
paneStates: normalizePaneStates(cached.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
|
||||
};
|
||||
}
|
||||
|
||||
function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
|
||||
repeaterDashboardCache.delete(publicKey);
|
||||
repeaterDashboardCache.set(publicKey, {
|
||||
loggedIn: entry.loggedIn,
|
||||
loginError: entry.loginError,
|
||||
paneData: clonePaneData(entry.paneData),
|
||||
paneStates: normalizePaneStates(entry.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
|
||||
});
|
||||
|
||||
if (repeaterDashboardCache.size > MAX_CACHED_REPEATERS) {
|
||||
const lruKey = repeaterDashboardCache.keys().next().value as string | undefined;
|
||||
if (lruKey) {
|
||||
repeaterDashboardCache.delete(lruKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRepeaterDashboardCacheForTests() {
|
||||
repeaterDashboardCache.clear();
|
||||
}
|
||||
|
||||
// Maps pane name to the API call
|
||||
function fetchPaneData(publicKey: string, pane: PaneName) {
|
||||
switch (pane) {
|
||||
@@ -94,7 +164,8 @@ export interface UseRepeaterDashboardResult {
|
||||
refreshPane: (pane: PaneName) => Promise<void>;
|
||||
loadAll: () => Promise<void>;
|
||||
sendConsoleCommand: (command: string) => Promise<void>;
|
||||
sendAdvert: () => Promise<void>;
|
||||
sendZeroHopAdvert: () => Promise<void>;
|
||||
sendFloodAdvert: () => Promise<void>;
|
||||
rebootRepeater: () => Promise<void>;
|
||||
syncClock: () => Promise<void>;
|
||||
}
|
||||
@@ -102,15 +173,24 @@ export interface UseRepeaterDashboardResult {
|
||||
export function useRepeaterDashboard(
|
||||
activeConversation: Conversation | null
|
||||
): UseRepeaterDashboardResult {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const conversationId =
|
||||
activeConversation && activeConversation.type === 'contact' ? activeConversation.id : null;
|
||||
const cachedState = getCachedState(conversationId);
|
||||
|
||||
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
|
||||
|
||||
const [paneData, setPaneData] = useState<PaneData>(createInitialPaneData);
|
||||
const [paneStates, setPaneStates] =
|
||||
useState<Record<PaneName, PaneState>>(createInitialPaneStates);
|
||||
const [paneData, setPaneData] = useState<PaneData>(
|
||||
cachedState?.paneData ?? createInitialPaneData
|
||||
);
|
||||
const [paneStates, setPaneStates] = useState<Record<PaneName, PaneState>>(
|
||||
cachedState?.paneStates ?? createInitialPaneStates
|
||||
);
|
||||
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
|
||||
cachedState?.consoleHistory ?? []
|
||||
);
|
||||
const [consoleLoading, setConsoleLoading] = useState(false);
|
||||
|
||||
// Track which conversation we're operating on to avoid stale updates after
|
||||
@@ -120,6 +200,10 @@ export function useRepeaterDashboard(
|
||||
|
||||
// Guard against setting state after unmount (retry timers firing late)
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
activeIdRef.current = conversationId;
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
@@ -127,6 +211,17 @@ export function useRepeaterDashboard(
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
cacheState(conversationId, {
|
||||
loggedIn,
|
||||
loginError,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
});
|
||||
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
|
||||
|
||||
const getPublicKey = useCallback((): string | null => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return activeConversation.id;
|
||||
@@ -172,7 +267,12 @@ export function useRepeaterDashboard(
|
||||
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: true, attempt, error: null },
|
||||
[pane]: {
|
||||
loading: true,
|
||||
attempt,
|
||||
error: null,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -182,7 +282,7 @@ export function useRepeaterDashboard(
|
||||
setPaneData((prev) => ({ ...prev, [pane]: data }));
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: null },
|
||||
[pane]: { loading: false, attempt, error: null, fetched_at: Date.now() },
|
||||
}));
|
||||
return; // Success
|
||||
} catch (err) {
|
||||
@@ -193,7 +293,12 @@ export function useRepeaterDashboard(
|
||||
if (attempt === MAX_RETRIES) {
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: msg },
|
||||
[pane]: {
|
||||
loading: false,
|
||||
attempt,
|
||||
error: msg,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
}));
|
||||
toast.error(`Failed to fetch ${pane}`, { description: msg });
|
||||
} else {
|
||||
@@ -266,7 +371,11 @@ export function useRepeaterDashboard(
|
||||
[getPublicKey]
|
||||
);
|
||||
|
||||
const sendAdvert = useCallback(async () => {
|
||||
const sendZeroHopAdvert = useCallback(async () => {
|
||||
await sendConsoleCommand('advert.zerohop');
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
const sendFloodAdvert = useCallback(async () => {
|
||||
await sendConsoleCommand('advert');
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
@@ -275,8 +384,8 @@ export function useRepeaterDashboard(
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
const syncClock = useCallback(async () => {
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
await sendConsoleCommand(`clock ${epoch}`);
|
||||
const epochSeconds = Math.floor(Date.now() / 1000);
|
||||
await sendConsoleCommand(`time ${epochSeconds}`);
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
return {
|
||||
@@ -292,7 +401,8 @@ export function useRepeaterDashboard(
|
||||
refreshPane,
|
||||
loadAll,
|
||||
sendConsoleCommand,
|
||||
sendAdvert,
|
||||
sendZeroHopAdvert,
|
||||
sendFloodAdvert,
|
||||
rebootRepeater,
|
||||
syncClock,
|
||||
};
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -107,7 +111,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 +121,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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -99,6 +99,9 @@ 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,
|
||||
@@ -122,6 +125,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
onLoadNewer: vi.fn(async () => {}),
|
||||
onJumpToBottom: vi.fn(),
|
||||
onSendMessage: vi.fn(async () => {}),
|
||||
onToggleNotifications: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -70,6 +70,7 @@ describe('SearchView', () => {
|
||||
mockGetMessages.mockResolvedValue([]);
|
||||
render(<SearchView {...defaultProps} />);
|
||||
expect(screen.getByText('Type to search across all messages')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Tip: use/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('focuses input on mount', () => {
|
||||
@@ -246,4 +247,37 @@ 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)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,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 +84,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 +98,8 @@ function renderModal(overrides?: {
|
||||
onSaveAppSettings,
|
||||
onSetPrivateKey,
|
||||
onReboot,
|
||||
onDisconnect,
|
||||
onReconnect,
|
||||
onAdvertise: vi.fn(async () => {}),
|
||||
onHealthRefresh: vi.fn(async () => {}),
|
||||
onRefreshAppSettings,
|
||||
@@ -116,6 +122,8 @@ function renderModal(overrides?: {
|
||||
onClose,
|
||||
onSetPrivateKey,
|
||||
onReboot,
|
||||
onDisconnect,
|
||||
onReconnect,
|
||||
view,
|
||||
};
|
||||
}
|
||||
@@ -179,15 +187,20 @@ 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 changed max contacts value through onSaveAppSettings', async () => {
|
||||
@@ -266,6 +279,32 @@ describe('SettingsModal', () => {
|
||||
expect(screen.queryByLabelText('Local label text')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the theme contrast preview in local settings', () => {
|
||||
renderModal();
|
||||
openLocalSection();
|
||||
|
||||
expect(
|
||||
screen.getByText('Preview alert and message contrast for the selected theme.')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Connected preview: radio link healthy and syncing.')
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Warning preview: packet audit suggests missing history.')
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Error preview: radio reconnect failed.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hello, mesh!')).toBeInTheDocument();
|
||||
expect(screen.getByText("Hi there! I'm using RemoteTerm.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lists the new Windows 95 and iPhone themes', () => {
|
||||
renderModal();
|
||||
openLocalSection();
|
||||
|
||||
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 +334,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 +357,8 @@ describe('SettingsModal', () => {
|
||||
onSave,
|
||||
onSetPrivateKey,
|
||||
onReboot,
|
||||
onDisconnect: vi.fn(async () => {}),
|
||||
onReconnect: vi.fn(async () => {}),
|
||||
});
|
||||
openRadioSection();
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
151
frontend/src/test/useBrowserNotifications.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
pendingDeleteFallbackRef: { current: false },
|
||||
setActiveConversation: vi.fn(),
|
||||
updateMessageAck: vi.fn(),
|
||||
notifyIncomingMessage: vi.fn(),
|
||||
...overrides,
|
||||
},
|
||||
fns: {
|
||||
@@ -163,6 +164,7 @@ describe('useRealtimeAppState', () => {
|
||||
`contact-${incomingDm.conversation_key}`,
|
||||
true
|
||||
);
|
||||
expect(args.notifyIncomingMessage).toHaveBeenCalledWith(incomingDm);
|
||||
});
|
||||
|
||||
it('deleting the active contact clears it and marks fallback recovery pending', () => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { StrictMode, createElement, type ReactNode } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import {
|
||||
resetRepeaterDashboardCacheForTests,
|
||||
useRepeaterDashboard,
|
||||
} from '../hooks/useRepeaterDashboard';
|
||||
import type { Conversation } from '../types';
|
||||
|
||||
// Mock the api module
|
||||
@@ -43,6 +46,7 @@ const repeaterConversation: Conversation = {
|
||||
describe('useRepeaterDashboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetRepeaterDashboardCacheForTests();
|
||||
});
|
||||
|
||||
it('starts with logged out state', () => {
|
||||
@@ -123,6 +127,7 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(result.current.paneData.status).toEqual(statusData);
|
||||
expect(result.current.paneStates.status.loading).toBe(false);
|
||||
expect(result.current.paneStates.status.error).toBe(null);
|
||||
expect(result.current.paneStates.status.fetched_at).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it('refreshPane still issues requests under StrictMode remount probing', async () => {
|
||||
@@ -211,7 +216,23 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(result.current.consoleLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('sendAdvert sends "advert" command', async () => {
|
||||
it('sendZeroHopAdvert sends "advert.zerohop" command', async () => {
|
||||
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
|
||||
command: 'advert.zerohop',
|
||||
response: 'ok',
|
||||
sender_timestamp: 1000,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendZeroHopAdvert();
|
||||
});
|
||||
|
||||
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'advert.zerohop');
|
||||
});
|
||||
|
||||
it('sendFloodAdvert sends "advert" command', async () => {
|
||||
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
|
||||
command: 'advert',
|
||||
response: 'ok',
|
||||
@@ -221,7 +242,7 @@ describe('useRepeaterDashboard', () => {
|
||||
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendAdvert();
|
||||
await result.current.sendFloodAdvert();
|
||||
});
|
||||
|
||||
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'advert');
|
||||
@@ -243,12 +264,10 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'reboot');
|
||||
});
|
||||
|
||||
it('syncClock sends "clock <epoch>" command', async () => {
|
||||
const fakeNow = 1700000000000;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(fakeNow);
|
||||
|
||||
it('syncClock sends "time <epoch>" command', async () => {
|
||||
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
|
||||
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
|
||||
command: 'clock 1700000000',
|
||||
command: 'time 1700000000',
|
||||
response: 'ok',
|
||||
sender_timestamp: 1000,
|
||||
});
|
||||
@@ -259,9 +278,8 @@ describe('useRepeaterDashboard', () => {
|
||||
await result.current.syncClock();
|
||||
});
|
||||
|
||||
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'clock 1700000000');
|
||||
|
||||
vi.restoreAllMocks();
|
||||
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'time 1700000000');
|
||||
dateNowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('loadAll calls refreshPane for all panes serially', async () => {
|
||||
@@ -304,4 +322,38 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(mockApi.repeaterOwnerInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restores dashboard state when navigating away and back to the same repeater', async () => {
|
||||
const statusData = { battery_volts: 4.2 };
|
||||
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
|
||||
mockApi.repeaterStatus.mockResolvedValueOnce(statusData);
|
||||
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
|
||||
command: 'ver',
|
||||
response: 'v2.1.0',
|
||||
sender_timestamp: 1000,
|
||||
});
|
||||
|
||||
const firstMount = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
await act(async () => {
|
||||
await firstMount.result.current.login('secret');
|
||||
await firstMount.result.current.refreshPane('status');
|
||||
await firstMount.result.current.sendConsoleCommand('ver');
|
||||
});
|
||||
|
||||
expect(firstMount.result.current.loggedIn).toBe(true);
|
||||
expect(firstMount.result.current.paneData.status).toEqual(statusData);
|
||||
expect(firstMount.result.current.consoleHistory).toHaveLength(2);
|
||||
|
||||
firstMount.unmount();
|
||||
|
||||
const secondMount = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
expect(secondMount.result.current.loggedIn).toBe(true);
|
||||
expect(secondMount.result.current.loginError).toBe(null);
|
||||
expect(secondMount.result.current.paneData.status).toEqual(statusData);
|
||||
expect(secondMount.result.current.paneStates.status.loading).toBe(false);
|
||||
expect(secondMount.result.current.consoleHistory).toHaveLength(2);
|
||||
expect(secondMount.result.current.consoleHistory[1].response).toBe('v2.1.0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
--success-foreground: 0 0% 100%;
|
||||
--info: 217 91% 48%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
--region-override: 274 78% 24%;
|
||||
--favorite: 43 96% 50%;
|
||||
--console: 153 50% 22%;
|
||||
--console-command: 153 55% 18%;
|
||||
@@ -48,6 +49,195 @@
|
||||
--overlay: 220 20% 10%;
|
||||
}
|
||||
|
||||
:root[data-theme='light'] .sidebar-tool-label {
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
/* ── Windows 95 ───────────────────────────────────────────── */
|
||||
:root[data-theme='windows-95'] {
|
||||
--background: 180 100% 25%;
|
||||
--foreground: 0 0% 0%;
|
||||
--card: 0 0% 75%;
|
||||
--card-foreground: 0 0% 0%;
|
||||
--popover: 0 0% 75%;
|
||||
--popover-foreground: 0 0% 0%;
|
||||
--primary: 240 100% 25%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 0 0% 84%;
|
||||
--secondary-foreground: 0 0% 0%;
|
||||
--muted: 0 0% 80%;
|
||||
--muted-foreground: 0 0% 18%;
|
||||
--accent: 0 0% 88%;
|
||||
--accent-foreground: 0 0% 0%;
|
||||
--destructive: 0 82% 44%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 0 0% 47%;
|
||||
--input: 0 0% 47%;
|
||||
--ring: 240 100% 25%;
|
||||
--radius: 0px;
|
||||
--msg-outgoing: 210 100% 92%;
|
||||
--msg-incoming: 0 0% 92%;
|
||||
--status-connected: 142 75% 32%;
|
||||
--status-disconnected: 0 0% 35%;
|
||||
--warning: 45 100% 45%;
|
||||
--warning-foreground: 0 0% 0%;
|
||||
--success: 142 75% 28%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--info: 210 100% 42%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
--region-override: 300 100% 28%;
|
||||
--favorite: 44 100% 50%;
|
||||
--console: 120 100% 22%;
|
||||
--console-command: 120 100% 28%;
|
||||
--console-bg: 0 0% 0%;
|
||||
--toast-error: 0 0% 86%;
|
||||
--toast-error-foreground: 0 72% 30%;
|
||||
--toast-error-border: 0 0% 38%;
|
||||
--code-editor-bg: 0 0% 94%;
|
||||
--font-sans: Tahoma, 'MS Sans Serif', 'Segoe UI', sans-serif;
|
||||
--font-mono: 'Courier New', monospace;
|
||||
--scrollbar: 0 0% 62%;
|
||||
--scrollbar-hover: 0 0% 48%;
|
||||
--overlay: 0 0% 0%;
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] body {
|
||||
background: hsl(180 100% 25%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] .bg-card,
|
||||
[data-theme='windows-95'] .bg-popover,
|
||||
[data-theme='windows-95'] .bg-secondary,
|
||||
[data-theme='windows-95'] .bg-muted {
|
||||
box-shadow:
|
||||
inset 1px 1px 0 hsl(0 0% 100%),
|
||||
inset -1px -1px 0 hsl(0 0% 34%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] button,
|
||||
[data-theme='windows-95'] input,
|
||||
[data-theme='windows-95'] select,
|
||||
[data-theme='windows-95'] textarea {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] button {
|
||||
background: hsl(0 0% 75%);
|
||||
color: hsl(0 0% 0%);
|
||||
padding: 0.35rem 0.75rem;
|
||||
box-shadow:
|
||||
inset 1px 1px 0 hsl(0 0% 100%),
|
||||
inset -1px -1px 0 hsl(0 0% 34%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] button:active,
|
||||
[data-theme='windows-95'] button:active {
|
||||
box-shadow:
|
||||
inset -1px -1px 0 hsl(0 0% 100%),
|
||||
inset 1px 1px 0 hsl(0 0% 34%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] input,
|
||||
[data-theme='windows-95'] select,
|
||||
[data-theme='windows-95'] textarea {
|
||||
background: hsl(0 0% 100%);
|
||||
box-shadow:
|
||||
inset 1px 1px 0 hsl(0 0% 34%),
|
||||
inset -1px -1px 0 hsl(0 0% 100%);
|
||||
}
|
||||
|
||||
[data-theme='windows-95'] .bg-msg-incoming,
|
||||
[data-theme='windows-95'] .bg-msg-outgoing {
|
||||
box-shadow:
|
||||
inset 1px 1px 0 hsl(0 0% 100%),
|
||||
inset -1px -1px 0 hsl(0 0% 34%);
|
||||
}
|
||||
|
||||
/* ── iPhone / iOS ─────────────────────────────────────────── */
|
||||
:root[data-theme='ios'] {
|
||||
--background: 240 18% 96%;
|
||||
--foreground: 220 18% 14%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 220 18% 14%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 220 18% 14%;
|
||||
--primary: 211 100% 50%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 10% 92%;
|
||||
--secondary-foreground: 220 10% 26%;
|
||||
--muted: 240 10% 94%;
|
||||
--muted-foreground: 220 8% 45%;
|
||||
--accent: 240 12% 90%;
|
||||
--accent-foreground: 220 18% 14%;
|
||||
--destructive: 3 100% 59%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 240 7% 84%;
|
||||
--input: 240 7% 84%;
|
||||
--ring: 211 100% 50%;
|
||||
--radius: 1.35rem;
|
||||
--msg-outgoing: 211 100% 50%;
|
||||
--msg-incoming: 240 12% 90%;
|
||||
--status-connected: 134 61% 49%;
|
||||
--status-disconnected: 240 5% 60%;
|
||||
--warning: 35 100% 50%;
|
||||
--warning-foreground: 0 0% 100%;
|
||||
--success: 134 61% 42%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--info: 188 100% 43%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
--region-override: 279 100% 67%;
|
||||
--favorite: 47 100% 50%;
|
||||
--console: 134 61% 40%;
|
||||
--console-command: 134 61% 34%;
|
||||
--console-bg: 0 0% 100%;
|
||||
--toast-error: 0 0% 100%;
|
||||
--toast-error-foreground: 3 70% 46%;
|
||||
--toast-error-border: 240 7% 84%;
|
||||
--code-editor-bg: 240 16% 97%;
|
||||
--font-sans:
|
||||
-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Segoe UI', sans-serif;
|
||||
--font-mono: 'SF Mono', SFMono-Regular, ui-monospace, monospace;
|
||||
--scrollbar: 240 8% 76%;
|
||||
--scrollbar-hover: 240 8% 64%;
|
||||
--overlay: 220 30% 8%;
|
||||
}
|
||||
|
||||
[data-theme='ios'] body {
|
||||
background:
|
||||
radial-gradient(circle at top, hsl(211 100% 97%), transparent 40%),
|
||||
linear-gradient(180deg, hsl(240 25% 98%), hsl(240 16% 95%));
|
||||
}
|
||||
|
||||
[data-theme='ios'] .bg-card,
|
||||
[data-theme='ios'] .bg-popover {
|
||||
background: hsl(0 0% 100% / 0.82);
|
||||
backdrop-filter: saturate(180%) blur(24px);
|
||||
box-shadow:
|
||||
0 14px 32px hsl(220 30% 10% / 0.08),
|
||||
0 1px 0 hsl(0 0% 100% / 0.7) inset;
|
||||
}
|
||||
|
||||
[data-theme='ios'] button,
|
||||
[data-theme='ios'] [role='button'] {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
[data-theme='ios'] input,
|
||||
[data-theme='ios'] select,
|
||||
[data-theme='ios'] textarea {
|
||||
background: hsl(0 0% 100% / 0.9);
|
||||
box-shadow: 0 1px 2px hsl(220 30% 10% / 0.06) inset;
|
||||
}
|
||||
|
||||
[data-theme='ios'] .bg-msg-outgoing {
|
||||
color: hsl(0 0% 100%);
|
||||
box-shadow: 0 10px 24px hsl(211 100% 50% / 0.18);
|
||||
}
|
||||
|
||||
[data-theme='ios'] .bg-msg-incoming {
|
||||
box-shadow: 0 8px 18px hsl(220 30% 10% / 0.08);
|
||||
}
|
||||
|
||||
/* ── Cyberpunk ("Neon Bleed") ──────────────────────────────── */
|
||||
:root[data-theme='cyberpunk'] {
|
||||
--background: 210 18% 3%;
|
||||
@@ -80,6 +270,7 @@
|
||||
--success-foreground: 135 100% 6%;
|
||||
--info: 185 100% 42%;
|
||||
--info-foreground: 185 100% 6%;
|
||||
--region-override: 292 100% 68%;
|
||||
--favorite: 62 100% 52%;
|
||||
--console: 135 100% 50%;
|
||||
--console-command: 135 100% 62%;
|
||||
@@ -126,6 +317,7 @@
|
||||
--success-foreground: 0 0% 100%;
|
||||
--info: 212 100% 58%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
--region-override: 286 100% 76%;
|
||||
--favorite: 43 100% 54%;
|
||||
--console: 212 100% 62%;
|
||||
--console-command: 212 100% 74%;
|
||||
@@ -172,6 +364,7 @@
|
||||
--success-foreground: 0 0% 100%;
|
||||
--info: 210 50% 56%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
--region-override: 273 72% 72%;
|
||||
--favorite: 38 70% 56%;
|
||||
--console: 30 40% 58%;
|
||||
--console-command: 30 40% 70%;
|
||||
@@ -267,6 +460,7 @@
|
||||
--success-foreground: 0 0% 100%;
|
||||
--info: 198 80% 54%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
--region-override: 282 100% 72%;
|
||||
--favorite: 46 100% 54%;
|
||||
--console: 338 100% 54%;
|
||||
--console-command: 338 100% 68%;
|
||||
@@ -373,3 +567,416 @@
|
||||
[data-theme='solar-flare'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, hsl(338 90% 48%), hsl(24 90% 48%));
|
||||
}
|
||||
|
||||
/* ── Lagoon Pop ("Tidal Candy") ───────────────────────────── */
|
||||
:root[data-theme='lagoon-pop'] {
|
||||
--background: 197 62% 9%;
|
||||
--foreground: 42 33% 92%;
|
||||
--card: 197 46% 13%;
|
||||
--card-foreground: 42 33% 92%;
|
||||
--popover: 197 46% 14%;
|
||||
--popover-foreground: 42 33% 92%;
|
||||
--primary: 175 72% 49%;
|
||||
--primary-foreground: 196 60% 9%;
|
||||
--secondary: 197 34% 18%;
|
||||
--secondary-foreground: 42 22% 84%;
|
||||
--muted: 197 30% 16%;
|
||||
--muted-foreground: 195 16% 64%;
|
||||
--accent: 205 46% 22%;
|
||||
--accent-foreground: 42 33% 92%;
|
||||
--destructive: 8 88% 61%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 191 34% 24%;
|
||||
--input: 191 34% 24%;
|
||||
--ring: 175 72% 49%;
|
||||
--radius: 1rem;
|
||||
--msg-outgoing: 184 46% 16%;
|
||||
--msg-incoming: 204 34% 14%;
|
||||
--status-connected: 167 76% 46%;
|
||||
--status-disconnected: 204 12% 46%;
|
||||
--warning: 41 100% 58%;
|
||||
--warning-foreground: 38 100% 10%;
|
||||
--success: 167 76% 42%;
|
||||
--success-foreground: 196 60% 9%;
|
||||
--info: 229 90% 72%;
|
||||
--info-foreground: 232 56% 14%;
|
||||
--region-override: 277 88% 76%;
|
||||
--favorite: 49 100% 63%;
|
||||
--console: 175 72% 54%;
|
||||
--console-command: 175 78% 68%;
|
||||
--console-bg: 198 68% 7%;
|
||||
--toast-error: 8 38% 14%;
|
||||
--toast-error-foreground: 10 86% 77%;
|
||||
--toast-error-border: 8 30% 24%;
|
||||
--code-editor-bg: 198 44% 11%;
|
||||
--font-sans: 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif;
|
||||
--scrollbar: 191 34% 22%;
|
||||
--scrollbar-hover: 191 40% 30%;
|
||||
--overlay: 198 80% 4%;
|
||||
}
|
||||
|
||||
[data-theme='lagoon-pop'] body {
|
||||
background:
|
||||
radial-gradient(circle at top left, hsl(175 72% 49% / 0.1), transparent 28%),
|
||||
radial-gradient(circle at top right, hsl(229 90% 72% / 0.1), transparent 24%),
|
||||
radial-gradient(circle at bottom center, hsl(8 88% 61% / 0.08), transparent 26%),
|
||||
hsl(197 62% 9%);
|
||||
}
|
||||
|
||||
[data-theme='lagoon-pop'] .bg-card {
|
||||
background: linear-gradient(145deg, hsl(197 46% 14%), hsl(205 40% 16%));
|
||||
}
|
||||
|
||||
[data-theme='lagoon-pop'] .bg-popover {
|
||||
background: linear-gradient(145deg, hsl(197 46% 15%), hsl(205 40% 17%));
|
||||
}
|
||||
|
||||
[data-theme='lagoon-pop'] .bg-msg-outgoing {
|
||||
background: linear-gradient(135deg, hsl(184 48% 16%), hsl(175 38% 19%));
|
||||
border-left: 2px solid hsl(175 72% 49% / 0.45);
|
||||
}
|
||||
|
||||
[data-theme='lagoon-pop'] .bg-msg-incoming {
|
||||
background: linear-gradient(135deg, hsl(204 34% 14%), hsl(214 30% 16%));
|
||||
border-left: 2px solid hsl(229 90% 72% / 0.35);
|
||||
}
|
||||
|
||||
[data-theme='lagoon-pop'] .bg-primary {
|
||||
background: linear-gradient(135deg, hsl(175 72% 49%), hsl(191 78% 56%));
|
||||
}
|
||||
|
||||
[data-theme='lagoon-pop'] button {
|
||||
transition:
|
||||
transform 0.12s ease,
|
||||
filter 0.2s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
[data-theme='lagoon-pop'] button:hover {
|
||||
filter: drop-shadow(0 0 10px hsl(175 72% 49% / 0.18));
|
||||
}
|
||||
|
||||
[data-theme='lagoon-pop'] button:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
[data-theme='lagoon-pop'] ::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, hsl(175 40% 32%), hsl(229 38% 40%));
|
||||
}
|
||||
|
||||
[data-theme='lagoon-pop'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, hsl(175 52% 42%), hsl(229 52% 54%));
|
||||
}
|
||||
|
||||
/* ── Candy Dusk ("Dream Arcade") ──────────────────────────── */
|
||||
:root[data-theme='candy-dusk'] {
|
||||
--background: 258 38% 10%;
|
||||
--foreground: 302 30% 93%;
|
||||
--card: 258 30% 15%;
|
||||
--card-foreground: 302 30% 93%;
|
||||
--popover: 258 30% 16%;
|
||||
--popover-foreground: 302 30% 93%;
|
||||
--primary: 325 100% 74%;
|
||||
--primary-foreground: 258 38% 12%;
|
||||
--secondary: 255 24% 20%;
|
||||
--secondary-foreground: 291 20% 85%;
|
||||
--muted: 255 20% 18%;
|
||||
--muted-foreground: 265 12% 66%;
|
||||
--accent: 251 28% 24%;
|
||||
--accent-foreground: 302 30% 93%;
|
||||
--destructive: 9 88% 66%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 256 24% 28%;
|
||||
--input: 256 24% 28%;
|
||||
--ring: 325 100% 74%;
|
||||
--radius: 1.25rem;
|
||||
--msg-outgoing: 307 32% 20%;
|
||||
--msg-incoming: 250 24% 18%;
|
||||
--status-connected: 164 78% 58%;
|
||||
--status-disconnected: 255 10% 48%;
|
||||
--warning: 43 100% 63%;
|
||||
--warning-foreground: 36 100% 12%;
|
||||
--success: 164 78% 54%;
|
||||
--success-foreground: 258 38% 12%;
|
||||
--info: 191 90% 76%;
|
||||
--info-foreground: 242 32% 18%;
|
||||
--region-override: 278 100% 82%;
|
||||
--favorite: 43 100% 66%;
|
||||
--console: 191 90% 76%;
|
||||
--console-command: 325 100% 82%;
|
||||
--console-bg: 252 42% 8%;
|
||||
--toast-error: 352 34% 16%;
|
||||
--toast-error-foreground: 8 92% 82%;
|
||||
--toast-error-border: 352 24% 26%;
|
||||
--code-editor-bg: 255 28% 13%;
|
||||
--font-sans: 'Nunito', 'Trebuchet MS', 'Segoe UI', sans-serif;
|
||||
--scrollbar: 256 28% 24%;
|
||||
--scrollbar-hover: 256 34% 32%;
|
||||
--overlay: 258 40% 6%;
|
||||
}
|
||||
|
||||
[data-theme='candy-dusk'] body {
|
||||
background:
|
||||
radial-gradient(circle at 20% 10%, hsl(325 100% 74% / 0.16), transparent 22%),
|
||||
radial-gradient(circle at 85% 12%, hsl(191 90% 76% / 0.12), transparent 18%),
|
||||
radial-gradient(circle at 50% 100%, hsl(43 100% 63% / 0.08), transparent 24%), hsl(258 38% 10%);
|
||||
}
|
||||
|
||||
[data-theme='candy-dusk'] .bg-card {
|
||||
background: linear-gradient(160deg, hsl(258 30% 16%), hsl(248 28% 18%));
|
||||
box-shadow: inset 0 1px 0 hsl(302 50% 96% / 0.04);
|
||||
}
|
||||
|
||||
[data-theme='candy-dusk'] .bg-popover {
|
||||
background: linear-gradient(160deg, hsl(258 30% 17%), hsl(248 28% 19%));
|
||||
}
|
||||
|
||||
[data-theme='candy-dusk'] .bg-msg-outgoing {
|
||||
background: linear-gradient(135deg, hsl(307 34% 21%), hsl(325 28% 24%));
|
||||
border-left: 2px solid hsl(325 100% 74% / 0.55);
|
||||
}
|
||||
|
||||
[data-theme='candy-dusk'] .bg-msg-incoming {
|
||||
background: linear-gradient(135deg, hsl(250 24% 18%), hsl(258 20% 20%));
|
||||
border-left: 2px solid hsl(191 90% 76% / 0.38);
|
||||
}
|
||||
|
||||
[data-theme='candy-dusk'] .bg-primary {
|
||||
background: linear-gradient(135deg, hsl(325 100% 74%), hsl(289 84% 74%));
|
||||
}
|
||||
|
||||
[data-theme='candy-dusk'] button {
|
||||
border-radius: 999px;
|
||||
transition:
|
||||
transform 0.12s ease,
|
||||
filter 0.2s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
[data-theme='candy-dusk'] button:hover {
|
||||
filter: drop-shadow(0 0 10px hsl(325 100% 74% / 0.22))
|
||||
drop-shadow(0 0 18px hsl(191 90% 76% / 0.08));
|
||||
}
|
||||
|
||||
[data-theme='candy-dusk'] button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
[data-theme='candy-dusk'] ::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, hsl(325 48% 44%), hsl(256 46% 42%));
|
||||
}
|
||||
|
||||
[data-theme='candy-dusk'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, hsl(325 66% 58%), hsl(191 58% 56%));
|
||||
}
|
||||
|
||||
/* ── Paper Grove ("Field Notes") ──────────────────────────── */
|
||||
:root[data-theme='paper-grove'] {
|
||||
--background: 41 43% 93%;
|
||||
--foreground: 148 16% 18%;
|
||||
--card: 43 52% 97%;
|
||||
--card-foreground: 148 16% 18%;
|
||||
--popover: 43 52% 98%;
|
||||
--popover-foreground: 148 16% 18%;
|
||||
--primary: 157 54% 40%;
|
||||
--primary-foreground: 45 60% 98%;
|
||||
--secondary: 42 26% 87%;
|
||||
--secondary-foreground: 148 14% 26%;
|
||||
--muted: 42 22% 89%;
|
||||
--muted-foreground: 148 10% 44%;
|
||||
--accent: 36 42% 83%;
|
||||
--accent-foreground: 148 16% 18%;
|
||||
--destructive: 12 76% 58%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 38 22% 76%;
|
||||
--input: 38 22% 76%;
|
||||
--ring: 157 54% 40%;
|
||||
--radius: 0.9rem;
|
||||
--msg-outgoing: 151 32% 90%;
|
||||
--msg-incoming: 40 30% 94%;
|
||||
--status-connected: 157 54% 38%;
|
||||
--status-disconnected: 148 8% 58%;
|
||||
--warning: 39 92% 46%;
|
||||
--warning-foreground: 39 100% 12%;
|
||||
--success: 157 54% 34%;
|
||||
--success-foreground: 45 60% 98%;
|
||||
--info: 227 78% 64%;
|
||||
--info-foreground: 228 40% 20%;
|
||||
--region-override: 273 56% 44%;
|
||||
--favorite: 43 92% 48%;
|
||||
--console: 157 54% 34%;
|
||||
--console-command: 224 48% 42%;
|
||||
--console-bg: 45 24% 89%;
|
||||
--toast-error: 8 52% 94%;
|
||||
--toast-error-foreground: 9 58% 40%;
|
||||
--toast-error-border: 10 34% 78%;
|
||||
--code-editor-bg: 42 30% 90%;
|
||||
--font-sans: 'Avenir Next', 'Segoe UI', sans-serif;
|
||||
--scrollbar: 36 18% 68%;
|
||||
--scrollbar-hover: 36 22% 58%;
|
||||
--overlay: 148 20% 12%;
|
||||
}
|
||||
|
||||
[data-theme='paper-grove'] body {
|
||||
background:
|
||||
linear-gradient(hsl(157 20% 50% / 0.04) 1px, transparent 1px),
|
||||
linear-gradient(90deg, hsl(157 20% 50% / 0.04) 1px, transparent 1px), hsl(41 43% 93%);
|
||||
background-size:
|
||||
32px 32px,
|
||||
32px 32px,
|
||||
auto;
|
||||
}
|
||||
|
||||
[data-theme='paper-grove'] .bg-card {
|
||||
background: linear-gradient(180deg, hsl(43 52% 98%), hsl(40 42% 95%));
|
||||
box-shadow:
|
||||
0 1px 0 hsl(0 0% 100% / 0.8),
|
||||
0 8px 22px hsl(148 18% 20% / 0.06);
|
||||
}
|
||||
|
||||
[data-theme='paper-grove'] .bg-popover {
|
||||
background: linear-gradient(180deg, hsl(43 52% 98%), hsl(40 38% 96%));
|
||||
}
|
||||
|
||||
[data-theme='paper-grove'] .bg-msg-outgoing {
|
||||
background: linear-gradient(135deg, hsl(151 34% 90%), hsl(157 30% 87%));
|
||||
border-left: 2px solid hsl(157 54% 40% / 0.45);
|
||||
}
|
||||
|
||||
[data-theme='paper-grove'] .bg-msg-incoming {
|
||||
background: linear-gradient(135deg, hsl(40 30% 95%), hsl(38 26% 92%));
|
||||
border-left: 2px solid hsl(227 78% 64% / 0.28);
|
||||
}
|
||||
|
||||
[data-theme='paper-grove'] .bg-primary {
|
||||
background: linear-gradient(135deg, hsl(157 54% 40%), hsl(180 42% 42%));
|
||||
}
|
||||
|
||||
[data-theme='paper-grove'] button {
|
||||
box-shadow: 0 1px 0 hsl(0 0% 100% / 0.7);
|
||||
transition:
|
||||
transform 0.12s ease,
|
||||
box-shadow 0.18s ease,
|
||||
background-color 0.15s ease,
|
||||
color 0.15s ease;
|
||||
}
|
||||
|
||||
[data-theme='paper-grove'] button:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow:
|
||||
0 1px 0 hsl(0 0% 100% / 0.8),
|
||||
0 6px 14px hsl(148 20% 20% / 0.08);
|
||||
}
|
||||
|
||||
[data-theme='paper-grove'] button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
[data-theme='paper-grove'] ::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, hsl(157 26% 54%), hsl(227 26% 60%));
|
||||
}
|
||||
|
||||
[data-theme='paper-grove'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, hsl(157 34% 46%), hsl(227 34% 52%));
|
||||
}
|
||||
|
||||
/* ── Monochrome ("Workbench") ─────────────────────────────── */
|
||||
:root[data-theme='monochrome'] {
|
||||
--background: 0 0% 98%;
|
||||
--foreground: 0 0% 8%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 0 0% 8%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 0 0% 8%;
|
||||
--primary: 0 0% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 0 0% 96%;
|
||||
--secondary-foreground: 0 0% 10%;
|
||||
--muted: 0 0% 97%;
|
||||
--muted-foreground: 0 0% 18%;
|
||||
--accent: 0 0% 94%;
|
||||
--accent-foreground: 0 0% 8%;
|
||||
--destructive: 0 0% 18%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 72%;
|
||||
--input: 0 0% 72%;
|
||||
--ring: 0 0% 10%;
|
||||
--radius: 0.2rem;
|
||||
--msg-outgoing: 0 0% 92%;
|
||||
--msg-incoming: 0 0% 98%;
|
||||
--status-connected: 0 0% 12%;
|
||||
--status-disconnected: 0 0% 24%;
|
||||
--warning: 0 0% 22%;
|
||||
--warning-foreground: 0 0% 98%;
|
||||
--success: 0 0% 12%;
|
||||
--success-foreground: 0 0% 98%;
|
||||
--info: 0 0% 18%;
|
||||
--info-foreground: 0 0% 98%;
|
||||
--region-override: 0 0% 14%;
|
||||
--favorite: 0 0% 10%;
|
||||
--console: 0 0% 18%;
|
||||
--console-command: 0 0% 8%;
|
||||
--console-bg: 0 0% 96%;
|
||||
--toast-error: 0 0% 96%;
|
||||
--toast-error-foreground: 0 0% 12%;
|
||||
--toast-error-border: 0 0% 70%;
|
||||
--code-editor-bg: 0 0% 96%;
|
||||
--font-sans: 'Inter', 'Segoe UI', sans-serif;
|
||||
--font-mono: 'IBM Plex Mono', 'Cascadia Mono', 'SFMono-Regular', monospace;
|
||||
--scrollbar: 0 0% 18%;
|
||||
--scrollbar-hover: 0 0% 8%;
|
||||
--overlay: 0 0% 6%;
|
||||
}
|
||||
|
||||
[data-theme='monochrome'] body {
|
||||
background: hsl(0 0% 98%);
|
||||
}
|
||||
|
||||
[data-theme='monochrome'] .bg-card,
|
||||
[data-theme='monochrome'] .bg-popover {
|
||||
background: hsl(0 0% 100%);
|
||||
}
|
||||
|
||||
[data-theme='monochrome'] .bg-msg-outgoing {
|
||||
background: hsl(0 0% 92%);
|
||||
border-left: 2px solid hsl(0 0% 12% / 0.9);
|
||||
}
|
||||
|
||||
[data-theme='monochrome'] .bg-msg-incoming {
|
||||
background: hsl(0 0% 98%);
|
||||
border-left: 2px solid hsl(0 0% 12% / 0.35);
|
||||
}
|
||||
|
||||
[data-theme='monochrome'] .bg-primary {
|
||||
background: hsl(0 0% 10%);
|
||||
}
|
||||
|
||||
[data-theme='monochrome'] button {
|
||||
border-radius: 0.2rem;
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
transition:
|
||||
background-color 0.12s ease,
|
||||
color 0.12s ease,
|
||||
border-color 0.12s ease;
|
||||
}
|
||||
|
||||
[data-theme='monochrome'] button:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
[data-theme='monochrome'] button:active {
|
||||
transform: none;
|
||||
}
|
||||
|
||||
[data-theme='monochrome'] ::-webkit-scrollbar-thumb {
|
||||
background: hsl(0 0% 18%);
|
||||
}
|
||||
|
||||
[data-theme='monochrome'] ::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(0 0% 8%);
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ export interface HealthStatus {
|
||||
status: string;
|
||||
radio_connected: boolean;
|
||||
radio_initializing: boolean;
|
||||
radio_state?: 'connected' | 'initializing' | 'connecting' | 'disconnected' | 'paused';
|
||||
connection_info: string | null;
|
||||
database_size_mb: number;
|
||||
oldest_undecrypted_timestamp: number | null;
|
||||
@@ -125,6 +126,41 @@ export interface ContactDetail {
|
||||
nearest_repeaters: NearestRepeater[];
|
||||
}
|
||||
|
||||
export interface NameOnlyContactDetail {
|
||||
name: string;
|
||||
channel_message_count: number;
|
||||
most_active_rooms: ContactActiveRoom[];
|
||||
}
|
||||
|
||||
export interface ContactAnalyticsHourlyBucket {
|
||||
bucket_start: number;
|
||||
last_24h_count: number;
|
||||
last_week_average: number;
|
||||
all_time_average: number;
|
||||
}
|
||||
|
||||
export interface ContactAnalyticsWeeklyBucket {
|
||||
bucket_start: number;
|
||||
message_count: number;
|
||||
}
|
||||
|
||||
export interface ContactAnalytics {
|
||||
lookup_type: 'contact' | 'name';
|
||||
name: string;
|
||||
contact: Contact | null;
|
||||
name_first_seen_at: number | null;
|
||||
name_history: ContactNameHistory[];
|
||||
dm_message_count: number;
|
||||
channel_message_count: number;
|
||||
includes_direct_messages: boolean;
|
||||
most_active_rooms: ContactActiveRoom[];
|
||||
advert_paths: ContactAdvertPath[];
|
||||
advert_frequency: number | null;
|
||||
nearest_repeaters: NearestRepeater[];
|
||||
hourly_activity: ContactAnalyticsHourlyBucket[];
|
||||
weekly_activity: ContactAnalyticsWeeklyBucket[];
|
||||
}
|
||||
|
||||
export interface Channel {
|
||||
key: string;
|
||||
name: string;
|
||||
@@ -364,6 +400,7 @@ export interface PaneState {
|
||||
loading: boolean;
|
||||
attempt: number;
|
||||
error: string | null;
|
||||
fetched_at?: number | null;
|
||||
}
|
||||
|
||||
export interface TraceResponse {
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface Theme {
|
||||
metaThemeColor: string;
|
||||
}
|
||||
|
||||
export const THEME_CHANGE_EVENT = 'remoteterm-theme-change';
|
||||
|
||||
export const THEMES: Theme[] = [
|
||||
{
|
||||
id: 'original',
|
||||
@@ -20,18 +22,24 @@ export const THEMES: Theme[] = [
|
||||
swatches: ['#F8F7F4', '#FFFFFF', '#1B7D4E', '#EDEBE7', '#D97706', '#3B82F6'],
|
||||
metaThemeColor: '#F8F7F4',
|
||||
},
|
||||
{
|
||||
id: 'ios',
|
||||
name: 'iPhone',
|
||||
swatches: ['#F2F2F7', '#FFFFFF', '#007AFF', '#E5E5EA', '#FF9F0A', '#34C759'],
|
||||
metaThemeColor: '#F2F2F7',
|
||||
},
|
||||
{
|
||||
id: 'paper-grove',
|
||||
name: 'Paper Grove',
|
||||
swatches: ['#F7F1E4', '#FFF9EE', '#2F9E74', '#E7DEC8', '#E76F51', '#5C7CFA'],
|
||||
metaThemeColor: '#F7F1E4',
|
||||
},
|
||||
{
|
||||
id: 'cyberpunk',
|
||||
name: 'Cyberpunk',
|
||||
swatches: ['#07080A', '#0D1112', '#00FF41', '#141E17', '#FAFF00', '#FF2E6C'],
|
||||
metaThemeColor: '#07080A',
|
||||
},
|
||||
{
|
||||
id: 'high-contrast',
|
||||
name: 'High Contrast',
|
||||
swatches: ['#000000', '#141414', '#3B9EFF', '#1E1E1E', '#FFB800', '#FF4757'],
|
||||
metaThemeColor: '#000000',
|
||||
},
|
||||
{
|
||||
id: 'obsidian-glass',
|
||||
name: 'Obsidian Glass',
|
||||
@@ -44,6 +52,36 @@ export const THEMES: Theme[] = [
|
||||
swatches: ['#0D0607', '#151012', '#FF0066', '#2D1D22', '#FF8C1A', '#30ACD4'],
|
||||
metaThemeColor: '#0D0607',
|
||||
},
|
||||
{
|
||||
id: 'lagoon-pop',
|
||||
name: 'Lagoon Pop',
|
||||
swatches: ['#081A22', '#0F2630', '#23D7C6', '#173844', '#FF7A66', '#7C83FF'],
|
||||
metaThemeColor: '#081A22',
|
||||
},
|
||||
{
|
||||
id: 'candy-dusk',
|
||||
name: 'Candy Dusk',
|
||||
swatches: ['#140F24', '#201736', '#FF79C9', '#2A2144', '#FFC857', '#8BE9FD'],
|
||||
metaThemeColor: '#140F24',
|
||||
},
|
||||
{
|
||||
id: 'high-contrast',
|
||||
name: 'High Contrast',
|
||||
swatches: ['#000000', '#141414', '#3B9EFF', '#1E1E1E', '#FFB800', '#FF4757'],
|
||||
metaThemeColor: '#000000',
|
||||
},
|
||||
{
|
||||
id: 'monochrome',
|
||||
name: 'Monochrome',
|
||||
swatches: ['#FAFAFA', '#FFFFFF', '#111111', '#EAEAEA', '#8A8A8A', '#4A4A4A'],
|
||||
metaThemeColor: '#FAFAFA',
|
||||
},
|
||||
{
|
||||
id: 'windows-95',
|
||||
name: 'Windows 95',
|
||||
swatches: ['#008080', '#C0C0C0', '#000080', '#DFDFDF', '#FFDE59', '#000000'],
|
||||
metaThemeColor: '#008080',
|
||||
},
|
||||
];
|
||||
|
||||
const THEME_KEY = 'remoteterm-theme';
|
||||
@@ -77,4 +115,8 @@ export function applyTheme(themeId: string): void {
|
||||
meta.setAttribute('content', theme.metaThemeColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: themeId }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "2.7.9"
|
||||
version = "3.1.1"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -15,6 +15,7 @@ dependencies = [
|
||||
"meshcore==2.2.29",
|
||||
"aiomqtt>=2.0",
|
||||
"apprise>=1.9.7",
|
||||
"boto3>=1.38.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -35,11 +35,6 @@ npm run lint:fix
|
||||
npm run format
|
||||
echo -e "${GREEN}[frontend lint]${NC} Passed!"
|
||||
|
||||
echo -e "${BLUE}[licenses]${NC} Regenerating LICENSES.md (always run)..."
|
||||
cd "$SCRIPT_DIR"
|
||||
bash scripts/collect_licenses.sh LICENSES.md
|
||||
echo -e "${GREEN}[licenses]${NC} LICENSES.md updated"
|
||||
|
||||
echo -e "${GREEN}=== Phase 1 complete ===${NC}"
|
||||
echo
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
OUT="${1:-$REPO_ROOT/LICENSES.md}"
|
||||
FRONTEND_DOCKER_LOCK="$REPO_ROOT/frontend/package-lock.docker.json"
|
||||
FRONTEND_LICENSE_IMAGE="${FRONTEND_LICENSE_IMAGE:-node:20-slim}"
|
||||
FRONTEND_LICENSE_NPM="${FRONTEND_LICENSE_NPM:-10.9.5}"
|
||||
|
||||
@@ -72,7 +71,6 @@ frontend_licenses_docker() {
|
||||
set -euo pipefail
|
||||
cp -a /src/frontend ./frontend
|
||||
cd frontend
|
||||
cp package-lock.docker.json package-lock.json
|
||||
npm i -g npm@$FRONTEND_LICENSE_NPM >/dev/null
|
||||
npm ci --ignore-scripts >/dev/null
|
||||
node /src/scripts/print_frontend_licenses.cjs
|
||||
@@ -80,11 +78,7 @@ frontend_licenses_docker() {
|
||||
}
|
||||
|
||||
frontend_licenses() {
|
||||
if [ -f "$FRONTEND_DOCKER_LOCK" ]; then
|
||||
frontend_licenses_docker
|
||||
else
|
||||
frontend_licenses_local
|
||||
fi
|
||||
frontend_licenses_docker
|
||||
}
|
||||
|
||||
# ── Assemble ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -35,7 +35,7 @@ run_combo() {
|
||||
npm i -g npm@${npm_version}
|
||||
echo 'Using Node:' \$(node -v)
|
||||
echo 'Using npm:' \$(npm -v)
|
||||
npm install
|
||||
npm ci
|
||||
npm run build
|
||||
"
|
||||
|
||||
|
||||
@@ -58,6 +58,11 @@ echo -e "${GREEN}Frontend build complete!${NC}"
|
||||
cd "$SCRIPT_DIR"
|
||||
echo
|
||||
|
||||
echo -e "${YELLOW}Regenerating LICENSES.md...${NC}"
|
||||
bash scripts/collect_licenses.sh LICENSES.md
|
||||
echo -e "${GREEN}LICENSES.md updated!${NC}"
|
||||
echo
|
||||
|
||||
# Prompt for version
|
||||
echo -e "${YELLOW}Current versions:${NC}"
|
||||
echo -n " pyproject.toml: "
|
||||
@@ -66,7 +71,8 @@ echo -n " package.json: "
|
||||
grep '"version"' frontend/package.json | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
|
||||
echo
|
||||
|
||||
read -p "Enter new version (e.g., 1.2.3): " VERSION
|
||||
read -r -p "Enter new version (e.g., 1.2.3): " VERSION
|
||||
VERSION="$(printf '%s' "$VERSION" | tr -d '\r' | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
||||
|
||||
if [[ ! $VERSION =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo -e "${RED}Error: Version must be in format X.Y.Z${NC}"
|
||||
@@ -179,11 +185,12 @@ RELEASE_NOTES_FILE=$(mktemp)
|
||||
} > "$RELEASE_NOTES_FILE"
|
||||
|
||||
# Create and push the release tag first so GitHub release creation does not
|
||||
# depend on resolving a symbolic ref like HEAD on the remote side.
|
||||
# depend on resolving a symbolic ref like HEAD on the remote side. Use the same
|
||||
# changelog-derived notes for the annotated tag message.
|
||||
if git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then
|
||||
echo -e "${YELLOW}Tag $VERSION already exists locally; reusing it.${NC}"
|
||||
else
|
||||
git tag "$VERSION" "$FULL_GIT_HASH"
|
||||
git tag -a "$VERSION" "$FULL_GIT_HASH" -F "$RELEASE_NOTES_FILE"
|
||||
fi
|
||||
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/$VERSION" >/dev/null 2>&1; then
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FullConfig } from '@playwright/test';
|
||||
|
||||
const BASE_URL = 'http://localhost:8000';
|
||||
const BASE_URL = 'http://localhost:8001';
|
||||
const MAX_RETRIES = 10;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* These bypass the UI to set up preconditions and verify backend state.
|
||||
*/
|
||||
|
||||
const BASE_URL = 'http://localhost:8000/api';
|
||||
const BASE_URL = 'http://localhost:8001/api';
|
||||
|
||||
async function fetchJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
|
||||
@@ -22,7 +22,7 @@ export default defineConfig({
|
||||
reporter: [['list'], ['html', { open: 'never' }]],
|
||||
|
||||
use: {
|
||||
baseURL: 'http://localhost:8000',
|
||||
baseURL: 'http://localhost:8001',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
@@ -38,17 +38,17 @@ export default defineConfig({
|
||||
command: `bash -c '
|
||||
echo "[e2e] $(date +%T.%3N) Starting webServer command..."
|
||||
if [ ! -d frontend/dist ]; then
|
||||
echo "[e2e] $(date +%T.%3N) frontend/dist missing — running npm install + build"
|
||||
cd frontend && npm install && npm run build
|
||||
echo "[e2e] $(date +%T.%3N) frontend/dist missing — running npm ci + build"
|
||||
cd frontend && npm ci && npm run build
|
||||
echo "[e2e] $(date +%T.%3N) Frontend build complete"
|
||||
else
|
||||
echo "[e2e] $(date +%T.%3N) frontend/dist exists — skipping build"
|
||||
fi
|
||||
echo "[e2e] $(date +%T.%3N) Launching uvicorn..."
|
||||
uv run uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||
uv run uvicorn app.main:app --host 127.0.0.1 --port 8001
|
||||
'`,
|
||||
cwd: projectRoot,
|
||||
port: 8000,
|
||||
port: 8001,
|
||||
reuseExistingServer: false,
|
||||
timeout: 180_000,
|
||||
env: {
|
||||
|
||||
@@ -8,7 +8,7 @@ test.describe('Health & UI basics', () => {
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
// Sidebar is visible with key sections
|
||||
await expect(page.getByRole('heading', { name: 'Conversations' })).toBeVisible();
|
||||
await expect(page.getByRole('navigation', { name: 'Conversations' })).toBeVisible();
|
||||
await expect(page.getByText('Packet Feed')).toBeVisible();
|
||||
await expect(page.getByText('Node Map')).toBeVisible();
|
||||
});
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { createChannel, deleteChannel, getChannels } from '../helpers/api';
|
||||
import { createChannel, createContact, deleteChannel, deleteContact } from '../helpers/api';
|
||||
|
||||
test.describe('Sidebar search/filter', () => {
|
||||
const suffix = Date.now().toString().slice(-6);
|
||||
const nameA = `#alpha${suffix}`;
|
||||
const nameB = `#bravo${suffix}`;
|
||||
const contactName = `Search Contact ${suffix}`;
|
||||
const contactKey = `feed${suffix.padStart(8, '0')}${'ab'.repeat(26)}`;
|
||||
let keyA = '';
|
||||
let keyB = '';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const chA = await createChannel(nameA);
|
||||
const chB = await createChannel(nameB);
|
||||
await createContact(contactKey, contactName);
|
||||
keyA = chA.key;
|
||||
keyB = chB.key;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
try {
|
||||
await deleteContact(contactKey);
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
for (const key of [keyA, keyB]) {
|
||||
try {
|
||||
await deleteChannel(key);
|
||||
@@ -25,27 +33,40 @@ test.describe('Sidebar search/filter', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('search filters conversations by name', async ({ page }) => {
|
||||
test('search filters channel and contact conversations by name and key prefix', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto('/');
|
||||
await expect(page.getByRole('status', { name: 'Radio OK' })).toBeVisible();
|
||||
|
||||
// Both channels should be visible
|
||||
// Seeded conversations should be visible.
|
||||
await expect(page.getByText(nameA, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(nameB, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(contactName, { exact: true })).toBeVisible();
|
||||
|
||||
// Type partial name to filter
|
||||
const searchInput = page.getByPlaceholder('Search...');
|
||||
const searchInput = page.getByLabel('Search conversations');
|
||||
|
||||
// Channel name query should filter to the matching channel only.
|
||||
await searchInput.fill(`alpha${suffix}`);
|
||||
|
||||
// Only nameA should be visible
|
||||
await expect(page.getByText(nameA, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(nameB, { exact: true })).not.toBeVisible();
|
||||
await expect(page.getByText(contactName, { exact: true })).not.toBeVisible();
|
||||
|
||||
// Clear search
|
||||
// Contact name query should filter to the matching contact.
|
||||
await searchInput.fill(`contact ${suffix}`);
|
||||
await expect(page.getByText(contactName, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(nameA, { exact: true })).not.toBeVisible();
|
||||
await expect(page.getByText(nameB, { exact: true })).not.toBeVisible();
|
||||
|
||||
// Contact key prefix query should also match that contact.
|
||||
await searchInput.fill(contactKey.slice(0, 12));
|
||||
await expect(page.getByText(contactName, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(nameA, { exact: true })).not.toBeVisible();
|
||||
|
||||
// Clear search should restore the full conversation list.
|
||||
await page.getByTitle('Clear search').click();
|
||||
|
||||
// Both should return
|
||||
await expect(page.getByText(nameA, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(nameB, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(contactName, { exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,3 +80,41 @@ class TestBLEPinRequirement:
|
||||
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
|
||||
assert s.ble_address == "AA:BB:CC:DD:EE:FF"
|
||||
assert s.ble_pin == "123456"
|
||||
|
||||
|
||||
class TestBasicAuthConfiguration:
|
||||
"""Ensure basic auth credentials are configured as a pair."""
|
||||
|
||||
def test_basic_auth_disabled_by_default(self):
|
||||
s = Settings(serial_port="", tcp_host="", ble_address="")
|
||||
assert s.basic_auth_enabled is False
|
||||
|
||||
def test_basic_auth_enabled_when_both_credentials_are_set(self):
|
||||
s = Settings(
|
||||
serial_port="",
|
||||
tcp_host="",
|
||||
ble_address="",
|
||||
basic_auth_username="mesh",
|
||||
basic_auth_password="secret",
|
||||
)
|
||||
assert s.basic_auth_enabled is True
|
||||
|
||||
def test_basic_auth_requires_password_with_username(self):
|
||||
with pytest.raises(ValidationError, match="MESHCORE_BASIC_AUTH_USERNAME"):
|
||||
Settings(
|
||||
serial_port="",
|
||||
tcp_host="",
|
||||
ble_address="",
|
||||
basic_auth_username="mesh",
|
||||
basic_auth_password="",
|
||||
)
|
||||
|
||||
def test_basic_auth_requires_username_with_password(self):
|
||||
with pytest.raises(ValidationError, match="MESHCORE_BASIC_AUTH_USERNAME"):
|
||||
Settings(
|
||||
serial_port="",
|
||||
tcp_host="",
|
||||
ble_address="",
|
||||
basic_auth_username="",
|
||||
basic_auth_password="secret",
|
||||
)
|
||||
|
||||
@@ -358,6 +358,60 @@ class TestContactDetail:
|
||||
assert repeater["name"] == "Relay1"
|
||||
assert repeater["heard_count"] == 2
|
||||
|
||||
|
||||
class TestNameOnlyContactDetail:
|
||||
"""Test GET /api/contacts/name-detail."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_name_detail_returns_channel_stats(self, test_db, client):
|
||||
chan_a = "11" * 16
|
||||
chan_b = "22" * 16
|
||||
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Mystery: hi",
|
||||
conversation_key=chan_a,
|
||||
sender_timestamp=1000,
|
||||
received_at=1000,
|
||||
sender_name="Mystery",
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Mystery: hello",
|
||||
conversation_key=chan_a,
|
||||
sender_timestamp=1001,
|
||||
received_at=1001,
|
||||
sender_name="Mystery",
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Mystery: ping",
|
||||
conversation_key=chan_b,
|
||||
sender_timestamp=1002,
|
||||
received_at=1002,
|
||||
sender_name="Mystery",
|
||||
)
|
||||
|
||||
response = await client.get("/api/contacts/name-detail", params={"name": "Mystery"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Mystery"
|
||||
assert data["channel_message_count"] == 3
|
||||
assert len(data["most_active_rooms"]) == 2
|
||||
assert data["most_active_rooms"][0]["channel_key"] == chan_a
|
||||
assert data["most_active_rooms"][0]["message_count"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_name_detail_with_no_activity_returns_empty(self, test_db, client):
|
||||
response = await client.get("/api/contacts/name-detail", params={"name": "Mystery"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["name"] == "Mystery"
|
||||
assert data["channel_message_count"] == 0
|
||||
assert data["most_active_rooms"] == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_detail_nearest_repeaters_use_full_multibyte_next_hop(self, test_db, client):
|
||||
"""Nearest repeater resolution should distinguish multi-byte hops with the same first byte."""
|
||||
@@ -399,6 +453,97 @@ class TestContactDetail:
|
||||
assert data["advert_frequency"] > 0
|
||||
|
||||
|
||||
class TestContactAnalytics:
|
||||
"""Test GET /api/contacts/analytics."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics_returns_keyed_contact_profile_and_series(self, test_db, client):
|
||||
now = 2_000_000_000
|
||||
chan_key = "11" * 16
|
||||
await _insert_contact(KEY_A, "Alice", type=1)
|
||||
|
||||
await MessageRepository.create(
|
||||
msg_type="PRIV",
|
||||
text="hi",
|
||||
conversation_key=KEY_A,
|
||||
sender_timestamp=now - 100,
|
||||
received_at=now - 100,
|
||||
sender_key=KEY_A,
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Alice: ping",
|
||||
conversation_key=chan_key,
|
||||
sender_timestamp=now - 7200,
|
||||
received_at=now - 7200,
|
||||
sender_name="Alice",
|
||||
sender_key=KEY_A,
|
||||
)
|
||||
|
||||
with patch("app.repository.messages.time.time", return_value=now):
|
||||
response = await client.get("/api/contacts/analytics", params={"public_key": KEY_A})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["lookup_type"] == "contact"
|
||||
assert data["contact"]["public_key"] == KEY_A
|
||||
assert data["includes_direct_messages"] is True
|
||||
assert data["dm_message_count"] == 1
|
||||
assert data["channel_message_count"] == 1
|
||||
assert len(data["hourly_activity"]) == 24
|
||||
assert len(data["weekly_activity"]) == 26
|
||||
assert sum(bucket["last_24h_count"] for bucket in data["hourly_activity"]) == 2
|
||||
assert sum(bucket["message_count"] for bucket in data["weekly_activity"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics_returns_name_only_profile_and_series(self, test_db, client):
|
||||
now = 2_000_000_000
|
||||
chan_key = "22" * 16
|
||||
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Mystery: hi",
|
||||
conversation_key=chan_key,
|
||||
sender_timestamp=now - 100,
|
||||
received_at=now - 100,
|
||||
sender_name="Mystery",
|
||||
)
|
||||
await MessageRepository.create(
|
||||
msg_type="CHAN",
|
||||
text="Mystery: hello",
|
||||
conversation_key=chan_key,
|
||||
sender_timestamp=now - 86400,
|
||||
received_at=now - 86400,
|
||||
sender_name="Mystery",
|
||||
)
|
||||
|
||||
with patch("app.repository.messages.time.time", return_value=now):
|
||||
response = await client.get("/api/contacts/analytics", params={"name": "Mystery"})
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["lookup_type"] == "name"
|
||||
assert data["contact"] is None
|
||||
assert data["name"] == "Mystery"
|
||||
assert data["name_first_seen_at"] == now - 86400
|
||||
assert data["includes_direct_messages"] is False
|
||||
assert data["dm_message_count"] == 0
|
||||
assert data["channel_message_count"] == 2
|
||||
assert len(data["hourly_activity"]) == 24
|
||||
assert len(data["weekly_activity"]) == 26
|
||||
assert sum(bucket["last_24h_count"] for bucket in data["hourly_activity"]) == 1
|
||||
assert sum(bucket["message_count"] for bucket in data["weekly_activity"]) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analytics_requires_exactly_one_lookup_mode(self, test_db, client):
|
||||
response = await client.get(
|
||||
"/api/contacts/analytics",
|
||||
params={"public_key": KEY_A, "name": "Alice"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "exactly one" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestDeleteContactCascade:
|
||||
"""Test that contact delete cleans up related tables."""
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Tests for fanout bus: manager, scope matching, repository, and modules."""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -570,6 +570,121 @@ class TestWebhookValidation:
|
||||
assert scope["messages"] == {"channels": ["ch1"], "contacts": "none"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQS module unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSqsModule:
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_disconnected_when_no_queue_url(self):
|
||||
from app.fanout.sqs import SqsModule
|
||||
|
||||
with patch("app.fanout.sqs.boto3.client", return_value=MagicMock()):
|
||||
mod = SqsModule("test", {"queue_url": ""})
|
||||
await mod.start()
|
||||
assert mod.status == "disconnected"
|
||||
await mod.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_status_connected_with_queue_url(self):
|
||||
from app.fanout.sqs import SqsModule
|
||||
|
||||
with patch("app.fanout.sqs.boto3.client", return_value=MagicMock()):
|
||||
mod = SqsModule(
|
||||
"test",
|
||||
{"queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events"},
|
||||
)
|
||||
await mod.start()
|
||||
assert mod.status == "connected"
|
||||
await mod.stop()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sends_message_payload(self):
|
||||
from app.fanout.sqs import SqsModule
|
||||
|
||||
mock_client = MagicMock()
|
||||
with patch("app.fanout.sqs.boto3.client", return_value=mock_client):
|
||||
mod = SqsModule(
|
||||
"test",
|
||||
{"queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events"},
|
||||
)
|
||||
await mod.start()
|
||||
await mod.on_message(
|
||||
{"id": 42, "type": "PRIV", "conversation_key": "pk1", "text": "hi"}
|
||||
)
|
||||
|
||||
mock_client.send_message.assert_called_once()
|
||||
kwargs = mock_client.send_message.call_args.kwargs
|
||||
assert kwargs["QueueUrl"].endswith("/mesh-events")
|
||||
assert kwargs["MessageAttributes"]["event_type"]["StringValue"] == "message"
|
||||
assert '"event_type":"message"' in kwargs["MessageBody"]
|
||||
assert '"text":"hi"' in kwargs["MessageBody"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fifo_queue_adds_group_and_dedup_ids(self):
|
||||
from app.fanout.sqs import SqsModule
|
||||
|
||||
mock_client = MagicMock()
|
||||
with patch("app.fanout.sqs.boto3.client", return_value=mock_client):
|
||||
mod = SqsModule(
|
||||
"test",
|
||||
{"queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events.fifo"},
|
||||
)
|
||||
await mod.start()
|
||||
await mod.on_raw({"observation_id": "obs-123", "id": 7, "raw": "abcd"})
|
||||
|
||||
kwargs = mock_client.send_message.call_args.kwargs
|
||||
assert kwargs["MessageGroupId"] == "raw-packets"
|
||||
assert kwargs["MessageDeduplicationId"] == "raw-obs-123"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SQS router validation tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSqsValidation:
|
||||
def test_validate_sqs_config_requires_queue_url(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.fanout import _validate_sqs_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_sqs_config({"queue_url": ""})
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "queue_url is required" in exc_info.value.detail
|
||||
|
||||
def test_validate_sqs_config_requires_static_keypair_together(self):
|
||||
from fastapi import HTTPException
|
||||
|
||||
from app.routers.fanout import _validate_sqs_config
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_validate_sqs_config(
|
||||
{
|
||||
"queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events",
|
||||
"access_key_id": "AKIA...",
|
||||
}
|
||||
)
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "must be set together" in exc_info.value.detail
|
||||
|
||||
def test_validate_sqs_config_accepts_minimal_valid_config(self):
|
||||
from app.routers.fanout import _validate_sqs_config
|
||||
|
||||
_validate_sqs_config(
|
||||
{"queue_url": "https://sqs.us-east-1.amazonaws.com/123456789012/mesh-events"}
|
||||
)
|
||||
|
||||
def test_enforce_scope_sqs_preserves_raw_packets_setting(self):
|
||||
from app.routers.fanout import _enforce_scope
|
||||
|
||||
scope = _enforce_scope("sqs", {"messages": "all", "raw_packets": "all"})
|
||||
assert scope["messages"] == "all"
|
||||
assert scope["raw_packets"] == "all"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Apprise module unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,13 @@ import logging
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.frontend_static import register_frontend_missing_fallback, register_frontend_static_routes
|
||||
from app.frontend_static import (
|
||||
ASSET_CACHE_CONTROL,
|
||||
INDEX_CACHE_CONTROL,
|
||||
STATIC_FILE_CACHE_CONTROL,
|
||||
register_frontend_missing_fallback,
|
||||
register_frontend_static_routes,
|
||||
)
|
||||
|
||||
|
||||
def test_missing_dist_logs_error_and_keeps_app_running(tmp_path, caplog):
|
||||
@@ -22,7 +28,7 @@ def test_missing_dist_logs_error_and_keeps_app_running(tmp_path, caplog):
|
||||
with TestClient(app) as client:
|
||||
resp = client.get("/")
|
||||
assert resp.status_code == 404
|
||||
assert "npm install" in resp.json()["detail"]
|
||||
assert "npm ci" in resp.json()["detail"]
|
||||
assert "npm run build" in resp.json()["detail"]
|
||||
|
||||
|
||||
@@ -57,10 +63,12 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path):
|
||||
root_response = client.get("/")
|
||||
assert root_response.status_code == 200
|
||||
assert "index page" in root_response.text
|
||||
assert root_response.headers["cache-control"] == INDEX_CACHE_CONTROL
|
||||
|
||||
manifest_response = client.get("/site.webmanifest")
|
||||
assert manifest_response.status_code == 200
|
||||
assert manifest_response.headers["content-type"].startswith("application/manifest+json")
|
||||
assert manifest_response.headers["cache-control"] == "no-store"
|
||||
manifest = manifest_response.json()
|
||||
assert manifest["start_url"] == "http://testserver/"
|
||||
assert manifest["scope"] == "http://testserver/"
|
||||
@@ -71,14 +79,22 @@ def test_valid_dist_serves_static_and_spa_fallback(tmp_path):
|
||||
file_response = client.get("/robots.txt")
|
||||
assert file_response.status_code == 200
|
||||
assert file_response.text == "User-agent: *"
|
||||
assert file_response.headers["cache-control"] == STATIC_FILE_CACHE_CONTROL
|
||||
|
||||
explicit_index_response = client.get("/index.html")
|
||||
assert explicit_index_response.status_code == 200
|
||||
assert "index page" in explicit_index_response.text
|
||||
assert explicit_index_response.headers["cache-control"] == INDEX_CACHE_CONTROL
|
||||
|
||||
missing_response = client.get("/channel/some-route")
|
||||
assert missing_response.status_code == 200
|
||||
assert "index page" in missing_response.text
|
||||
assert missing_response.headers["cache-control"] == INDEX_CACHE_CONTROL
|
||||
|
||||
asset_response = client.get("/assets/app.js")
|
||||
assert asset_response.status_code == 200
|
||||
assert "console.log('ok');" in asset_response.text
|
||||
assert asset_response.headers["cache-control"] == ASSET_CACHE_CONTROL
|
||||
|
||||
|
||||
def test_webmanifest_uses_forwarded_origin_headers(tmp_path):
|
||||
|
||||
@@ -56,6 +56,7 @@ class TestHealthFanoutStatus:
|
||||
assert data["status"] == "ok"
|
||||
assert data["radio_connected"] is True
|
||||
assert data["radio_initializing"] is False
|
||||
assert data["radio_state"] == "connected"
|
||||
assert data["connection_info"] == "Serial: /dev/ttyUSB0"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -69,6 +70,7 @@ class TestHealthFanoutStatus:
|
||||
assert data["status"] == "degraded"
|
||||
assert data["radio_connected"] is False
|
||||
assert data["radio_initializing"] is False
|
||||
assert data["radio_state"] == "disconnected"
|
||||
assert data["connection_info"] is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -87,3 +89,40 @@ class TestHealthFanoutStatus:
|
||||
assert data["status"] == "degraded"
|
||||
assert data["radio_connected"] is True
|
||||
assert data["radio_initializing"] is True
|
||||
assert data["radio_state"] == "initializing"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_state_paused_when_operator_disabled_connection(self, test_db):
|
||||
"""Health reports paused when the operator has disabled reconnect attempts."""
|
||||
with (
|
||||
patch(
|
||||
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
|
||||
),
|
||||
patch("app.routers.health.radio_manager") as mock_rm,
|
||||
):
|
||||
mock_rm.is_setup_in_progress = False
|
||||
mock_rm.is_setup_complete = False
|
||||
mock_rm.connection_desired = False
|
||||
mock_rm.is_reconnecting = False
|
||||
data = await build_health_data(False, "BLE: AA:BB:CC:DD:EE:FF")
|
||||
|
||||
assert data["radio_state"] == "paused"
|
||||
assert data["radio_connected"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_health_state_connecting_while_reconnect_in_progress(self, test_db):
|
||||
"""Health reports connecting while retries are active but transport is not up yet."""
|
||||
with (
|
||||
patch(
|
||||
"app.routers.health.RawPacketRepository.get_oldest_undecrypted", return_value=None
|
||||
),
|
||||
patch("app.routers.health.radio_manager") as mock_rm,
|
||||
):
|
||||
mock_rm.is_setup_in_progress = False
|
||||
mock_rm.is_setup_complete = False
|
||||
mock_rm.connection_desired = True
|
||||
mock_rm.is_reconnecting = True
|
||||
data = await build_health_data(False, None)
|
||||
|
||||
assert data["radio_state"] == "connecting"
|
||||
assert data["radio_connected"] is False
|
||||
|
||||
13
tests/test_http_quality.py
Normal file
@@ -0,0 +1,13 @@
|
||||
"""Tests for direct-serve HTTP quality features such as gzip compression."""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def test_openapi_json_is_gzipped_when_client_accepts_gzip():
|
||||
with TestClient(app) as client:
|
||||
response = client.get("/openapi.json", headers={"Accept-Encoding": "gzip"})
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers["content-encoding"] == "gzip"
|
||||