mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-11 12:00:28 +02:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a02c3cae9e | |||
| ca7349a1a8 | |||
| eeaa11b8b0 | |||
| 08eaf090b2 | |||
| 2f43420235 | |||
| af74663518 | |||
| b7981c0450 | |||
| 0f4976b9ee | |||
| 1991f2515b | |||
| a351c86ccb | |||
| c2e1a3cbe6 | |||
| c2d1339256 | |||
| cb7139a7e1 | |||
| 6332387704 | |||
| 3f2b8e2a1f | |||
| 40c37745b6 | |||
| 9edac47aa2 | |||
| 44f8aafb66 | |||
| 9e3805f5d0 | |||
| 457799d8df | |||
| de3ad2d51f | |||
| ad83bc7979 | |||
| 9ebf63491c | |||
| b19585db6d | |||
| c28d22379e | |||
| 1e5ccf6c29 | |||
| 81f5bde287 |
@@ -1,3 +1,24 @@
|
||||
## [3.9.0] - 2026-04-06
|
||||
|
||||
* Feature: Add hop counts to hop-width selection options
|
||||
* Feature: Show cached repeater telemetry inline in settings
|
||||
* Feature: Retain recent traces and make them click-to-re-run
|
||||
* Feature: Autofocus channel/DM textbox on desktop
|
||||
* Feature: Favorites on the radio are now imported as favorites
|
||||
* Bugfix: Be clearer on issue identification for missing HTTPS context in channel finder
|
||||
* Bugfix: Don't use sender timestamp for message sequence display
|
||||
* Bugfix: Function on subdomains happily
|
||||
* Misc: Be gentler, room s/cracker/finder/
|
||||
* Misc: Test and frontend correctness & test fixes
|
||||
* Misc: Don't repeat clock sync failure logs
|
||||
* Misc: Make warning in readme clearer about taking over the radio
|
||||
* Misc: Improve readme phrasings
|
||||
* Misc: Better y-axis selection for battery read-out
|
||||
* Misc: Provide clearer warning on docker setup without docker installed
|
||||
* Misc: Default visualizer stale pruning to on/5 minutes
|
||||
* Misc: Migrate favorites to better storage pattern
|
||||
* Misc: Provide dumper script for API + WS interfaces for prep for HA integration
|
||||
|
||||
## [3.8.0] - 2026-04-03
|
||||
|
||||
* Feature: Per-channel hop width override
|
||||
|
||||
+31
@@ -1188,6 +1188,37 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
||||
|
||||
</details>
|
||||
|
||||
### cmdk (1.1.1) — MIT
|
||||
|
||||
<details>
|
||||
<summary>Full license text</summary>
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Paco Coursey
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### d3-force (3.0.0) — ISC
|
||||
|
||||
<details>
|
||||
|
||||
@@ -12,21 +12,14 @@ Backend server + browser interface for MeshCore mesh radio networks. Connect you
|
||||
* Use the more recent 1.14 firmwares which support multibyte pathing
|
||||
* Visualize the mesh as a map or node set, view repeater stats, and more!
|
||||
|
||||
For advanced setup and troubleshooting see [README_ADVANCED.md](README_ADVANCED.md). If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
**Warning:** This app is for trusted environments only. _Do not put this on an untrusted network, or open it to the public._ You can optionally set `MESHCORE_BASIC_AUTH_USERNAME` and `MESHCORE_BASIC_AUTH_PASSWORD` for app-wide HTTP Basic auth, but that is only a coarse gate and must be paired with HTTPS. The bots can execute arbitrary Python code which means anyone who gets access to the app can, too. To completely disable the bot system, start the server with `MESHCORE_DISABLE_BOTS=true` — this prevents all bot execution and blocks bot configuration changes via the API. If you need stronger access control, consider using a reverse proxy like Nginx, or extending FastAPI; full access control and user management are outside the scope of this app.
|
||||
|
||||

|
||||
|
||||
## Start Here
|
||||
|
||||
Most users should choose one of these paths:
|
||||
|
||||
1. Clone and build from source.
|
||||
2. Download the prebuilt release zip if you are on a resource-constrained system and do not want to build the frontend locally.
|
||||
3. Use Docker if that better matches how you deploy.
|
||||
|
||||
For advanced setup, troubleshooting, HTTPS, systemd service setup, and remediation environment variables, see [README_ADVANCED.md](README_ADVANCED.md).
|
||||
|
||||
If you plan to contribute, read [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
> [!WARNING]
|
||||
> RemoteTerm does *full* management of the radio, meaning that once a radio is connected to RemoteTerm, all contacts/channels will be imported and offloaded to RemoteTerm and the contacts actually synced to the device will be governed by RemoteTerm. This means that RemoteTerm can be a poor fit for users who are looking to swap radios in and out, maintaining radio state (favorites, channels, etc.) irrespective of app usage.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -71,7 +64,7 @@ usbipd attach --wsl --busid 3-8
|
||||
```
|
||||
</details>
|
||||
|
||||
## Path 1: Clone And Build
|
||||
## Install Path 1: Clone And Build
|
||||
|
||||
**This approach is recommended over Docker due to intermittent serial communications issues I've seen on \*nix systems.**
|
||||
|
||||
@@ -89,10 +82,10 @@ Access the app at http://localhost:8000.
|
||||
|
||||
Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
|
||||
> [!NOTE]
|
||||
> [!TIP]
|
||||
> Running on lightweight hardware, or just do not want to build the frontend locally? From a cloned checkout, run `python3 scripts/setup/fetch_prebuilt_frontend.py` to fetch and unpack a prebuilt frontend into `frontend/prebuilt`, then start the app normally with `uv run uvicorn app.main:app --host 0.0.0.0 --port 8000`.
|
||||
|
||||
> [!TIP]
|
||||
> [!NOTE]
|
||||
> On Linux, you can also install RemoteTerm as a persistent `systemd` service that starts on boot and restarts automatically on failure:
|
||||
>
|
||||
> ```bash
|
||||
@@ -101,7 +94,7 @@ Source checkouts expect a normal frontend build in `frontend/dist`.
|
||||
>
|
||||
> For the full service workflow and post-install operations, see [README_ADVANCED.md](README_ADVANCED.md).
|
||||
|
||||
## Path 2: Docker
|
||||
## Install Path 2: Docker
|
||||
|
||||
> **Warning:** Docker has had reports intermittent issues with serial event subscriptions. The native method above is more reliable.
|
||||
|
||||
|
||||
@@ -19,6 +19,15 @@ If the audit finds a mismatch, you'll see an error in the application UI and you
|
||||
|
||||
`__CLOWNTOWN_DO_CLOCK_WRAPAROUND=true` is a last-resort clock remediation for nodes whose RTC is stuck in the future and where rescue-mode time setting or GPS-based time is not available. It intentionally relies on the clock rolling past the 32-bit epoch boundary, which is board-specific behavior and may not be safe or effective on all MeshCore targets. Treat it as highly experimental.
|
||||
|
||||
## Sub-Path Reverse Proxy
|
||||
|
||||
RemoteTerm works behind a reverse proxy that serves it under a sub-path (e.g. `/meshcore/` or Home Assistant ingress). All frontend asset and API paths are relative, so they resolve correctly under any prefix.
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- The proxy must ensure the sub-path URL has a **trailing slash**. If a user visits `/meshcore` (no slash), relative paths break. Most proxies handle this automatically; for Nginx, a `location /meshcore/ { ... }` block (note the trailing slash) does the right thing.
|
||||
- For correct PWA install behavior, the proxy should forward `X-Forwarded-Prefix` (set to the sub-path, e.g. `/meshcore`) so the web manifest generates correct `start_url` and `scope` values. `X-Forwarded-Proto` and `X-Forwarded-Host` are also respected for origin resolution.
|
||||
|
||||
## HTTPS
|
||||
|
||||
WebGPU channel-finding requires a secure context when you are not on `localhost`.
|
||||
|
||||
+4
-2
@@ -27,7 +27,8 @@ CREATE TABLE IF NOT EXISTS contacts (
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
last_contacted INTEGER,
|
||||
first_seen INTEGER,
|
||||
last_read_at INTEGER
|
||||
last_read_at INTEGER,
|
||||
favorite INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS channels (
|
||||
@@ -37,7 +38,8 @@ CREATE TABLE IF NOT EXISTS channels (
|
||||
on_radio INTEGER DEFAULT 0,
|
||||
flood_scope_override TEXT,
|
||||
path_hash_mode_override INTEGER,
|
||||
last_read_at INTEGER
|
||||
last_read_at INTEGER,
|
||||
favorite INTEGER DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
|
||||
+4
-1
@@ -299,8 +299,11 @@ def parse_advertisement(
|
||||
timestamp = int.from_bytes(payload[32:36], byteorder="little")
|
||||
flags = payload[100]
|
||||
|
||||
# Parse flags
|
||||
# Parse flags — clamp device_role to valid range (0-4); corrupted
|
||||
# advertisements can have junk in the lower nibble.
|
||||
device_role = flags & 0x0F
|
||||
if device_role > 4:
|
||||
device_role = 0
|
||||
has_location = bool(flags & 0x10)
|
||||
has_feature1 = bool(flags & 0x20)
|
||||
has_feature2 = bool(flags & 0x40)
|
||||
|
||||
+31
-11
@@ -38,8 +38,17 @@ def _is_index_file(path: Path, index_file: Path) -> bool:
|
||||
return path == index_file
|
||||
|
||||
|
||||
def _resolve_request_origin(request: Request) -> str:
|
||||
"""Resolve the external origin, honoring common reverse-proxy headers."""
|
||||
def _resolve_request_base(request: Request) -> str:
|
||||
"""Resolve the external base URL, honoring common reverse-proxy headers.
|
||||
|
||||
Returns a URL like ``https://host:8000/meshcore/`` (always trailing-slash)
|
||||
so callers can append paths directly.
|
||||
|
||||
Recognized headers:
|
||||
- ``X-Forwarded-Proto`` + ``X-Forwarded-Host``: override scheme and host.
|
||||
- ``X-Forwarded-Prefix`` (or ``X-Forwarded-Path``): sub-path prefix added
|
||||
by the proxy (e.g. ``/meshcore``).
|
||||
"""
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto")
|
||||
forwarded_host = request.headers.get("x-forwarded-host")
|
||||
|
||||
@@ -47,9 +56,20 @@ def _resolve_request_origin(request: Request) -> str:
|
||||
proto = forwarded_proto.split(",")[0].strip()
|
||||
host = forwarded_host.split(",")[0].strip()
|
||||
if proto and host:
|
||||
return f"{proto}://{host}"
|
||||
origin = f"{proto}://{host}"
|
||||
else:
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
else:
|
||||
origin = str(request.base_url).rstrip("/")
|
||||
|
||||
return str(request.base_url).rstrip("/")
|
||||
# Sub-path prefix (e.g. /meshcore) communicated by the reverse proxy
|
||||
prefix = (
|
||||
(request.headers.get("x-forwarded-prefix") or request.headers.get("x-forwarded-path") or "")
|
||||
.strip()
|
||||
.rstrip("/")
|
||||
)
|
||||
|
||||
return f"{origin}{prefix}/"
|
||||
|
||||
|
||||
def _validate_frontend_dir(frontend_dir: Path, *, log_failures: bool = True) -> tuple[bool, Path]:
|
||||
@@ -103,27 +123,27 @@ def register_frontend_static_routes(app: FastAPI, frontend_dir: Path) -> bool:
|
||||
|
||||
@app.get("/site.webmanifest")
|
||||
async def serve_webmanifest(request: Request):
|
||||
"""Serve a dynamic web manifest using the active request origin."""
|
||||
origin = _resolve_request_origin(request)
|
||||
"""Serve a dynamic web manifest using the active request base URL."""
|
||||
base = _resolve_request_base(request)
|
||||
manifest = {
|
||||
"name": "RemoteTerm for MeshCore",
|
||||
"short_name": "RemoteTerm",
|
||||
"id": f"{origin}/",
|
||||
"start_url": f"{origin}/",
|
||||
"scope": f"{origin}/",
|
||||
"id": base,
|
||||
"start_url": base,
|
||||
"scope": base,
|
||||
"display": "standalone",
|
||||
"display_override": ["window-controls-overlay", "standalone", "fullscreen"],
|
||||
"theme_color": "#111419",
|
||||
"background_color": "#111419",
|
||||
"icons": [
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-192x192.png",
|
||||
"src": f"{base}web-app-manifest-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
},
|
||||
{
|
||||
"src": f"{origin}/web-app-manifest-512x512.png",
|
||||
"src": f"{base}web-app-manifest-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable",
|
||||
|
||||
@@ -413,6 +413,12 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
|
||||
await set_version(conn, 54)
|
||||
applied += 1
|
||||
|
||||
if version < 55:
|
||||
logger.info("Applying migration 55: move favorites to per-entity columns")
|
||||
await _migrate_055_favorites_to_columns(conn)
|
||||
await set_version(conn, 55)
|
||||
applied += 1
|
||||
|
||||
if applied > 0:
|
||||
logger.info(
|
||||
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
|
||||
@@ -3213,3 +3219,91 @@ async def _migrate_054_auto_resend_channel(conn: aiosqlite.Connection) -> None:
|
||||
"ALTER TABLE app_settings ADD COLUMN auto_resend_channel INTEGER DEFAULT 0"
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
|
||||
async def _migrate_055_favorites_to_columns(conn: aiosqlite.Connection) -> None:
|
||||
"""Move favorites from app_settings JSON blob to per-entity boolean columns.
|
||||
|
||||
1. Add ``favorite`` column to contacts and channels tables.
|
||||
2. Backfill from the ``app_settings.favorites`` JSON array.
|
||||
3. Drop the ``favorites`` column from app_settings.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
# --- Add columns ---
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
|
||||
for table in ("contacts", "channels"):
|
||||
if table not in existing_tables:
|
||||
continue
|
||||
col_cursor = await conn.execute(f"PRAGMA table_info({table})")
|
||||
columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "favorite" not in columns:
|
||||
await conn.execute(f"ALTER TABLE {table} ADD COLUMN favorite INTEGER DEFAULT 0")
|
||||
await conn.commit()
|
||||
|
||||
# --- Backfill from JSON ---
|
||||
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
if "app_settings" not in {row[0] for row in await tables_cursor.fetchall()}:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
col_cursor = await conn.execute("PRAGMA table_info(app_settings)")
|
||||
settings_columns = {row[1] for row in await col_cursor.fetchall()}
|
||||
if "favorites" not in settings_columns:
|
||||
await conn.commit()
|
||||
return
|
||||
|
||||
cursor = await conn.execute("SELECT favorites FROM app_settings WHERE id = 1")
|
||||
row = await cursor.fetchone()
|
||||
if row and row[0]:
|
||||
try:
|
||||
favorites = _json.loads(row[0])
|
||||
except (ValueError, TypeError):
|
||||
favorites = []
|
||||
|
||||
contact_keys = []
|
||||
channel_keys = []
|
||||
for fav in favorites:
|
||||
if not isinstance(fav, dict):
|
||||
continue
|
||||
fav_type = fav.get("type")
|
||||
fav_id = fav.get("id")
|
||||
if not fav_id:
|
||||
continue
|
||||
if fav_type == "contact":
|
||||
contact_keys.append(fav_id)
|
||||
elif fav_type == "channel":
|
||||
channel_keys.append(fav_id)
|
||||
|
||||
if contact_keys:
|
||||
placeholders = ",".join("?" for _ in contact_keys)
|
||||
await conn.execute(
|
||||
f"UPDATE contacts SET favorite = 1 WHERE public_key IN ({placeholders})",
|
||||
contact_keys,
|
||||
)
|
||||
if channel_keys:
|
||||
placeholders = ",".join("?" for _ in channel_keys)
|
||||
await conn.execute(
|
||||
f"UPDATE channels SET favorite = 1 WHERE key IN ({placeholders})",
|
||||
channel_keys,
|
||||
)
|
||||
if contact_keys or channel_keys:
|
||||
logger.info(
|
||||
"Backfilled %d contact favorite(s) and %d channel favorite(s) from app_settings",
|
||||
len(contact_keys),
|
||||
len(channel_keys),
|
||||
)
|
||||
await conn.commit()
|
||||
|
||||
# --- Drop the JSON column ---
|
||||
try:
|
||||
await conn.execute("ALTER TABLE app_settings DROP COLUMN favorites")
|
||||
await conn.commit()
|
||||
except Exception as e:
|
||||
error_msg = str(e).lower()
|
||||
if "syntax error" in error_msg or "drop column" in error_msg:
|
||||
logger.debug("SQLite doesn't support DROP COLUMN; favorites column will remain unused")
|
||||
await conn.commit()
|
||||
else:
|
||||
raise
|
||||
|
||||
+23
-13
@@ -4,6 +4,10 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.path_utils import normalize_contact_route, normalize_route_override
|
||||
|
||||
# Valid MeshCore contact types: 0=unknown, 1=client, 2=repeater, 3=room, 4=sensor.
|
||||
# Corrupted radio data can produce values outside this range.
|
||||
_VALID_CONTACT_TYPES = frozenset({0, 1, 2, 3, 4})
|
||||
|
||||
|
||||
class ContactRoute(BaseModel):
|
||||
"""A normalized contact route."""
|
||||
@@ -59,16 +63,30 @@ class ContactUpsert(BaseModel):
|
||||
-1 if radio_data.get("out_path_len", -1) == -1 else 0,
|
||||
),
|
||||
)
|
||||
# Clamp invalid contact types to 0 (unknown) — corrupted radio data
|
||||
# can produce values like 111 or 240 that break downstream branching.
|
||||
raw_type = radio_data.get("type", 0)
|
||||
contact_type = raw_type if raw_type in _VALID_CONTACT_TYPES else 0
|
||||
|
||||
# Null out impossible coordinates — the contact is still ingested,
|
||||
# but garbage lat/lon (e.g. 1953.7) is discarded rather than stored.
|
||||
lat = radio_data.get("adv_lat")
|
||||
lon = radio_data.get("adv_lon")
|
||||
if lat is not None and not (-90 <= lat <= 90):
|
||||
lat = None
|
||||
if lon is not None and not (-180 <= lon <= 180):
|
||||
lon = None
|
||||
|
||||
return cls(
|
||||
public_key=public_key,
|
||||
name=radio_data.get("adv_name"),
|
||||
type=radio_data.get("type", 0),
|
||||
type=contact_type,
|
||||
flags=radio_data.get("flags", 0),
|
||||
direct_path=direct_path,
|
||||
direct_path_len=direct_path_len,
|
||||
direct_path_hash_mode=direct_path_hash_mode,
|
||||
lat=radio_data.get("adv_lat"),
|
||||
lon=radio_data.get("adv_lon"),
|
||||
lat=lat,
|
||||
lon=lon,
|
||||
last_advert=radio_data.get("last_advert"),
|
||||
on_radio=on_radio,
|
||||
)
|
||||
@@ -91,6 +109,7 @@ class Contact(BaseModel):
|
||||
lon: float | None = None
|
||||
last_seen: int | None = None
|
||||
on_radio: bool = False
|
||||
favorite: bool = False
|
||||
last_contacted: int | None = None # Last time we sent/received a message
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
first_seen: int | None = None
|
||||
@@ -326,6 +345,7 @@ class Channel(BaseModel):
|
||||
description="Per-channel path hash mode override (0=1-byte, 1=2-byte, 2=3-byte, null = use radio default)",
|
||||
)
|
||||
last_read_at: int | None = None # Server-side read state tracking
|
||||
favorite: bool = False
|
||||
|
||||
|
||||
class ChannelMessageCounts(BaseModel):
|
||||
@@ -756,13 +776,6 @@ class RadioDiscoveryResponse(BaseModel):
|
||||
)
|
||||
|
||||
|
||||
class Favorite(BaseModel):
|
||||
"""A favorite conversation."""
|
||||
|
||||
type: Literal["channel", "contact"] = Field(description="'channel' or 'contact'")
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class UnreadCounts(BaseModel):
|
||||
"""Aggregated unread counts, mention flags, and last message times for all conversations."""
|
||||
|
||||
@@ -790,9 +803,6 @@ class AppSettings(BaseModel):
|
||||
"favorites reload first, then background fill targets about 80% of this value"
|
||||
),
|
||||
)
|
||||
favorites: list[Favorite] = Field(
|
||||
default_factory=list, description="List of favorited conversations"
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool = Field(
|
||||
default=True,
|
||||
description="Whether to attempt historical DM decryption on new contact advertisement",
|
||||
|
||||
+30
-28
@@ -21,7 +21,7 @@ from meshcore import EventType, MeshCore
|
||||
from app.channel_constants import PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME
|
||||
from app.config import settings
|
||||
from app.event_handlers import cleanup_expired_acks, on_contact_message
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.models import _VALID_CONTACT_TYPES, Contact, ContactUpsert
|
||||
from app.radio import RadioOperationBusyError
|
||||
from app.repository import (
|
||||
AmbiguousPublicKeyPrefixError,
|
||||
@@ -307,7 +307,7 @@ async def sync_and_offload_channels(mc: MeshCore, max_channels: int | None = Non
|
||||
except Exception as e:
|
||||
logger.warning("Error clearing channel %d: %s", idx, e)
|
||||
|
||||
logger.info("Synced %d channels, cleared %d from radio", synced, cleared)
|
||||
logger.debug("Synced %d channels, cleared %d from radio", synced, cleared)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("Error during channel sync: %s", e)
|
||||
@@ -428,7 +428,6 @@ async def ensure_default_channels() -> None:
|
||||
|
||||
async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
"""Run fast startup sync, then background contact reconcile."""
|
||||
logger.info("Starting full radio sync and offload")
|
||||
|
||||
# Contact on_radio is legacy/stale metadata. Clear it during the offload/reload
|
||||
# cycle so old rows stop claiming radio residency we do not actively track.
|
||||
@@ -944,10 +943,8 @@ async def sync_radio_time(mc: MeshCore) -> bool:
|
||||
except Exception:
|
||||
logger.warning("Reboot command failed", exc_info=True)
|
||||
elif _clock_reboot_attempted:
|
||||
logger.warning(
|
||||
"Clock skew persists after reboot — the radio likely has a "
|
||||
"hardware RTC that preserved the wrong time. A manual "
|
||||
"'clkreboot' CLI command is needed to reset it."
|
||||
logger.debug(
|
||||
"Clock skew persists after reboot (hardware RTC); ignoring until next session."
|
||||
)
|
||||
|
||||
return False
|
||||
@@ -1057,7 +1054,7 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
||||
return {"synced": 0, "radio_contacts": {}, "error": str(result)}
|
||||
|
||||
contacts = _normalize_radio_contacts_payload(result.payload)
|
||||
logger.info("Found %d contacts on radio", len(contacts))
|
||||
logger.debug("Found %d contacts on radio", len(contacts))
|
||||
|
||||
for public_key, contact_data in contacts.items():
|
||||
await ContactRepository.upsert(
|
||||
@@ -1071,7 +1068,29 @@ async def sync_contacts_from_radio(mc: MeshCore) -> dict:
|
||||
)
|
||||
synced += 1
|
||||
|
||||
logger.info("Synced %d contacts from radio snapshot", synced)
|
||||
logger.debug("Synced %d contacts from radio snapshot", synced)
|
||||
|
||||
# Import radio-favorited contacts into app favorites.
|
||||
# Only trust the favorite bit on contacts with a valid type (0-4);
|
||||
# garbled radio data can have junk flags with bit 0 set.
|
||||
radio_fav_keys = [
|
||||
pk
|
||||
for pk, data in contacts.items()
|
||||
if data.get("flags", 0) & 0x01 and data.get("type", -1) in _VALID_CONTACT_TYPES
|
||||
]
|
||||
if radio_fav_keys:
|
||||
try:
|
||||
imported = 0
|
||||
for pk in radio_fav_keys:
|
||||
existing = await ContactRepository.get_by_key(pk)
|
||||
if existing and not existing.favorite:
|
||||
await ContactRepository.set_favorite(pk, True)
|
||||
imported += 1
|
||||
if imported:
|
||||
logger.info("Imported %d radio favorite(s) into app favorites", imported)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to import radio favorites: %s", e)
|
||||
|
||||
return {"synced": synced, "radio_contacts": contacts}
|
||||
except Exception as e:
|
||||
logger.error("Error during contact snapshot sync: %s", e)
|
||||
@@ -1283,26 +1302,9 @@ async def get_contacts_selected_for_radio_sync() -> list[Contact]:
|
||||
selected_contacts: list[Contact] = []
|
||||
selected_keys: set[str] = set()
|
||||
|
||||
# Favorites first — always loaded up to max_contacts
|
||||
favorite_contacts_loaded = 0
|
||||
for favorite in app_settings.favorites:
|
||||
if favorite.type != "contact":
|
||||
continue
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(favorite.id)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning(
|
||||
"Skipping favorite contact '%s': ambiguous key prefix; use full key",
|
||||
favorite.id,
|
||||
)
|
||||
continue
|
||||
if not contact:
|
||||
continue
|
||||
if len(contact.public_key) < 64:
|
||||
logger.debug(
|
||||
"Skipping unresolved prefix-only favorite contact '%s' for radio sync",
|
||||
favorite.id,
|
||||
)
|
||||
continue
|
||||
for contact in await ContactRepository.get_favorites():
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
|
||||
@@ -26,7 +26,7 @@ class ChannelRepository:
|
||||
"""Get a channel by its key (32-char hex string)."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
||||
FROM channels
|
||||
WHERE key = ?
|
||||
""",
|
||||
@@ -42,6 +42,7 @@ class ChannelRepository:
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
favorite=bool(row["favorite"]),
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -49,7 +50,7 @@ class ChannelRepository:
|
||||
async def get_all() -> list[Channel]:
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at
|
||||
SELECT key, name, is_hashtag, on_radio, flood_scope_override, path_hash_mode_override, last_read_at, favorite
|
||||
FROM channels
|
||||
ORDER BY name
|
||||
"""
|
||||
@@ -64,10 +65,21 @@ class ChannelRepository:
|
||||
flood_scope_override=row["flood_scope_override"],
|
||||
path_hash_mode_override=row["path_hash_mode_override"],
|
||||
last_read_at=row["last_read_at"],
|
||||
favorite=bool(row["favorite"]),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def set_favorite(key: str, value: bool) -> bool:
|
||||
"""Set or clear the favorite flag for a channel. Returns True if row was found."""
|
||||
cursor = await db.conn.execute(
|
||||
"UPDATE channels SET favorite = ? WHERE key = ?",
|
||||
(1 if value else 0, key.upper()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
|
||||
@staticmethod
|
||||
async def delete(key: str) -> None:
|
||||
"""Delete a channel by key."""
|
||||
|
||||
@@ -170,6 +170,7 @@ class ContactRepository:
|
||||
lon=row["lon"],
|
||||
last_seen=row["last_seen"],
|
||||
on_radio=bool(row["on_radio"]),
|
||||
favorite=bool(row["favorite"]) if "favorite" in available_columns else False,
|
||||
last_contacted=row["last_contacted"],
|
||||
last_read_at=row["last_read_at"],
|
||||
first_seen=row["first_seen"],
|
||||
@@ -392,6 +393,24 @@ class ContactRepository:
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def get_favorites() -> list[Contact]:
|
||||
"""Return all contacts marked as favorite."""
|
||||
cursor = await db.conn.execute(
|
||||
"SELECT * FROM contacts WHERE favorite = 1 AND LENGTH(public_key) = 64"
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def set_favorite(public_key: str, value: bool) -> None:
|
||||
"""Set or clear the favorite flag for a contact."""
|
||||
await db.conn.execute(
|
||||
"UPDATE contacts SET favorite = ? WHERE public_key = ?",
|
||||
(1 if value else 0, public_key.lower()),
|
||||
)
|
||||
await db.conn.commit()
|
||||
|
||||
@staticmethod
|
||||
async def delete(public_key: str) -> None:
|
||||
normalized = public_key.lower()
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from app.database import db
|
||||
from app.models import AppSettings, Favorite
|
||||
from app.models import AppSettings
|
||||
from app.path_utils import bucket_path_hash_widths
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,7 +26,7 @@ class AppSettingsRepository:
|
||||
"""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
|
||||
SELECT max_radio_contacts, auto_decrypt_dm_on_advert,
|
||||
last_message_times,
|
||||
advert_interval, last_advert_time, flood_scope,
|
||||
blocked_keys, blocked_names, discovery_blocked_types,
|
||||
@@ -40,20 +40,6 @@ class AppSettingsRepository:
|
||||
# Should not happen after migration, but handle gracefully
|
||||
return AppSettings()
|
||||
|
||||
# Parse favorites JSON
|
||||
favorites = []
|
||||
if row["favorites"]:
|
||||
try:
|
||||
favorites_data = json.loads(row["favorites"])
|
||||
favorites = [Favorite(**f) for f in favorites_data]
|
||||
except (json.JSONDecodeError, TypeError, KeyError) as e:
|
||||
logger.warning(
|
||||
"Failed to parse favorites JSON, using empty list: %s (data=%r)",
|
||||
e,
|
||||
row["favorites"][:100] if row["favorites"] else None,
|
||||
)
|
||||
favorites = []
|
||||
|
||||
# Parse last_message_times JSON
|
||||
last_message_times: dict[str, int] = {}
|
||||
if row["last_message_times"]:
|
||||
@@ -107,7 +93,6 @@ class AppSettingsRepository:
|
||||
|
||||
return AppSettings(
|
||||
max_radio_contacts=row["max_radio_contacts"],
|
||||
favorites=favorites,
|
||||
auto_decrypt_dm_on_advert=bool(row["auto_decrypt_dm_on_advert"]),
|
||||
last_message_times=last_message_times,
|
||||
advert_interval=row["advert_interval"] or 0,
|
||||
@@ -123,7 +108,6 @@ class AppSettingsRepository:
|
||||
@staticmethod
|
||||
async def update(
|
||||
max_radio_contacts: int | None = None,
|
||||
favorites: list[Favorite] | None = None,
|
||||
auto_decrypt_dm_on_advert: bool | None = None,
|
||||
last_message_times: dict[str, int] | None = None,
|
||||
advert_interval: int | None = None,
|
||||
@@ -143,11 +127,6 @@ class AppSettingsRepository:
|
||||
updates.append("max_radio_contacts = ?")
|
||||
params.append(max_radio_contacts)
|
||||
|
||||
if favorites is not None:
|
||||
updates.append("favorites = ?")
|
||||
favorites_json = json.dumps([f.model_dump() for f in favorites])
|
||||
params.append(favorites_json)
|
||||
|
||||
if auto_decrypt_dm_on_advert is not None:
|
||||
updates.append("auto_decrypt_dm_on_advert = ?")
|
||||
params.append(1 if auto_decrypt_dm_on_advert else 0)
|
||||
@@ -195,27 +174,6 @@ class AppSettingsRepository:
|
||||
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
@staticmethod
|
||||
async def add_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Add a favorite, avoiding duplicates."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
# Check if already favorited
|
||||
if any(f.type == fav_type and f.id == fav_id for f in settings.favorites):
|
||||
return settings
|
||||
|
||||
new_favorites = settings.favorites + [Favorite(type=fav_type, id=fav_id)]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
|
||||
@staticmethod
|
||||
async def remove_favorite(fav_type: Literal["channel", "contact"], fav_id: str) -> AppSettings:
|
||||
"""Remove a favorite."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
new_favorites = [
|
||||
f for f in settings.favorites if not (f.type == fav_type and f.id == fav_id)
|
||||
]
|
||||
return await AppSettingsRepository.update(favorites=new_favorites)
|
||||
|
||||
@staticmethod
|
||||
async def toggle_blocked_key(key: str) -> AppSettings:
|
||||
"""Toggle a public key in the blocked list. Keys are normalized to lowercase."""
|
||||
|
||||
+27
-18
@@ -7,7 +7,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from app.models import CONTACT_TYPE_REPEATER, AppSettings
|
||||
from app.region_scope import normalize_region_scope
|
||||
from app.repository import AppSettingsRepository, ContactRepository
|
||||
from app.repository import AppSettingsRepository, ChannelRepository, ContactRepository
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
@@ -72,6 +72,12 @@ class FavoriteRequest(BaseModel):
|
||||
id: str = Field(description="Channel key or contact public key")
|
||||
|
||||
|
||||
class FavoriteToggleResponse(BaseModel):
|
||||
type: Literal["channel", "contact"]
|
||||
id: str
|
||||
favorite: bool
|
||||
|
||||
|
||||
class TrackedTelemetryRequest(BaseModel):
|
||||
public_key: str = Field(description="Public key of the repeater to toggle tracking")
|
||||
|
||||
@@ -157,27 +163,30 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
|
||||
return await AppSettingsRepository.get()
|
||||
|
||||
|
||||
@router.post("/favorites/toggle", response_model=AppSettings)
|
||||
async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
|
||||
@router.post("/favorites/toggle", response_model=FavoriteToggleResponse)
|
||||
async def toggle_favorite(request: FavoriteRequest) -> FavoriteToggleResponse:
|
||||
"""Toggle a conversation's favorite status."""
|
||||
settings = await AppSettingsRepository.get()
|
||||
is_favorited = any(f.type == request.type and f.id == request.id for f in settings.favorites)
|
||||
if request.type == "contact":
|
||||
contact = await ContactRepository.get_by_key(request.id)
|
||||
if not contact:
|
||||
raise HTTPException(status_code=404, detail="Contact not found")
|
||||
new_value = not contact.favorite
|
||||
await ContactRepository.set_favorite(request.id, new_value)
|
||||
logger.info("%s contact favorite: %s", "Added" if new_value else "Removed", request.id[:12])
|
||||
# When newly favorited, load to radio immediately for DM ACK support
|
||||
if new_value:
|
||||
from app.radio_sync import ensure_contact_on_radio
|
||||
|
||||
if is_favorited:
|
||||
logger.info("Removing favorite: %s %s", request.type, request.id[:12])
|
||||
result = await AppSettingsRepository.remove_favorite(request.type, request.id)
|
||||
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
|
||||
else:
|
||||
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
|
||||
result = await AppSettingsRepository.add_favorite(request.type, request.id)
|
||||
channel = await ChannelRepository.get_by_key(request.id)
|
||||
if not channel:
|
||||
raise HTTPException(status_code=404, detail="Channel not found")
|
||||
new_value = not channel.favorite
|
||||
await ChannelRepository.set_favorite(request.id, new_value)
|
||||
logger.info("%s channel favorite: %s", "Added" if new_value else "Removed", request.id[:12])
|
||||
|
||||
# When a contact is newly favorited, load just that contact to the radio
|
||||
# immediately so DM ACK support does not wait for the next maintenance cycle.
|
||||
if request.type == "contact" and not is_favorited:
|
||||
from app.radio_sync import ensure_contact_on_radio
|
||||
|
||||
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
|
||||
|
||||
return result
|
||||
return FavoriteToggleResponse(type=request.type, id=request.id, favorite=new_value)
|
||||
|
||||
|
||||
@router.post("/blocked-keys/toggle", response_model=AppSettings)
|
||||
|
||||
@@ -215,7 +215,14 @@ async def run_post_connect_setup(radio_manager) -> None:
|
||||
# Sync contacts/channels from radio to DB and clear radio
|
||||
logger.info("Syncing and offloading radio data...")
|
||||
result = await sync_and_offload_all(mc)
|
||||
logger.info("Sync complete: %s", result)
|
||||
c = result.get("contacts", {})
|
||||
ch = result.get("channels", {})
|
||||
logger.info(
|
||||
"Sync complete: %d contacts synced, %d channels synced, %d channels cleared",
|
||||
c.get("synced", 0),
|
||||
ch.get("synced", 0),
|
||||
ch.get("cleared", 0),
|
||||
)
|
||||
|
||||
# Send advertisement to announce our presence (if enabled and not throttled)
|
||||
if await send_advertisement(mc):
|
||||
|
||||
+12
-12
@@ -9,11 +9,11 @@
|
||||
<meta name="theme-color" content="#111419" />
|
||||
<meta name="description" content="Web interface for MeshCore mesh radio networks. Send and receive messages, manage contacts and channels, and configure your radio." />
|
||||
<title>RemoteTerm for MeshCore</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="/favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="/favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="/site.webmanifest" />
|
||||
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
|
||||
<link rel="icon" type="image/png" href="./favicon-96x96.png" sizes="96x96" />
|
||||
<link rel="shortcut icon" href="./favicon.ico" />
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon.png" />
|
||||
<link rel="manifest" href="./site.webmanifest" />
|
||||
<script>
|
||||
// Start critical data fetches before React/Vite JS loads.
|
||||
// Must be in <head> BEFORE the module script so the browser queues these
|
||||
@@ -42,17 +42,17 @@
|
||||
});
|
||||
};
|
||||
window.__prefetch = {
|
||||
config: fetchJsonOrThrow('/api/radio/config'),
|
||||
settings: fetchJsonOrThrow('/api/settings'),
|
||||
channels: fetchJsonOrThrow('/api/channels'),
|
||||
contacts: fetchJsonOrThrow('/api/contacts?limit=1000&offset=0'),
|
||||
unreads: fetchJsonOrThrow('/api/read-state/unreads'),
|
||||
undecryptedCount: fetchJsonOrThrow('/api/packets/undecrypted/count'),
|
||||
config: fetchJsonOrThrow('./api/radio/config'),
|
||||
settings: fetchJsonOrThrow('./api/settings'),
|
||||
channels: fetchJsonOrThrow('./api/channels'),
|
||||
contacts: fetchJsonOrThrow('./api/contacts?limit=1000&offset=0'),
|
||||
unreads: fetchJsonOrThrow('./api/read-state/unreads'),
|
||||
undecryptedCount: fetchJsonOrThrow('./api/packets/undecrypted/count'),
|
||||
};
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="./src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Generated
+19
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.3",
|
||||
"version": "3.8.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"version": "3.6.3",
|
||||
"version": "3.8.0",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
@@ -20,6 +20,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-force-3d": "^3.0.6",
|
||||
"leaflet": "^1.9.4",
|
||||
@@ -3687,6 +3688,22 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cmdk": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz",
|
||||
"integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/react-compose-refs": "^1.1.1",
|
||||
"@radix-ui/react-dialog": "^1.1.6",
|
||||
"@radix-ui/react-id": "^1.1.0",
|
||||
"@radix-ui/react-primitive": "^2.0.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18 || ^19 || ^19.0.0-rc",
|
||||
"react-dom": "^18 || ^19 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/codemirror": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remoteterm-meshcore-frontend",
|
||||
"private": true,
|
||||
"version": "3.8.0",
|
||||
"version": "3.9.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -28,6 +28,7 @@
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"d3-force": "^3.0.0",
|
||||
"d3-force-3d": "^3.0.6",
|
||||
"leaflet": "^1.9.4",
|
||||
|
||||
+68
-9
@@ -18,13 +18,15 @@ import {
|
||||
useUnreadTitle,
|
||||
useRawPacketStatsSession,
|
||||
} from './hooks';
|
||||
import { toast } from './components/ui/sonner';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import type { MessageInputHandle } from './components/MessageInput';
|
||||
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
|
||||
import { messageContainsMention } from './utils/messageParser';
|
||||
import { getStateKey } from './utils/conversationState';
|
||||
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
|
||||
import { CONTACT_TYPE_ROOM } from './types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from './types';
|
||||
import { shouldAutoFocusInput } from './utils/autoFocusInput';
|
||||
|
||||
interface ChannelUnreadMarker {
|
||||
channelId: string;
|
||||
@@ -87,6 +89,7 @@ export function App() {
|
||||
useState<NewMessagePrefillRequest | null>(null);
|
||||
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
|
||||
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
|
||||
const [repeaterAutoLoginKey, setRepeaterAutoLoginKey] = useState<string | null>(null);
|
||||
const [visibilityVersion, setVisibilityVersion] = useState(0);
|
||||
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
|
||||
const {
|
||||
@@ -150,10 +153,8 @@ export function App() {
|
||||
|
||||
const {
|
||||
appSettings,
|
||||
favorites,
|
||||
fetchAppSettings,
|
||||
handleSaveAppSettings,
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
@@ -204,6 +205,38 @@ export function App() {
|
||||
removeConversationMessagesRef.current(conversationId),
|
||||
});
|
||||
|
||||
const handleToggleFavorite = useCallback(
|
||||
async (type: 'channel' | 'contact', id: string) => {
|
||||
// Optimistically toggle the favorite flag
|
||||
if (type === 'contact') {
|
||||
setContacts((prev) =>
|
||||
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
|
||||
);
|
||||
} else {
|
||||
setChannels((prev) =>
|
||||
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await api.toggleFavorite(type, id);
|
||||
} catch {
|
||||
// Revert on failure
|
||||
if (type === 'contact') {
|
||||
setContacts((prev) =>
|
||||
prev.map((c) => (c.public_key === id ? { ...c, favorite: !c.favorite } : c))
|
||||
);
|
||||
} else {
|
||||
setChannels((prev) =>
|
||||
prev.map((c) => (c.key === id ? { ...c, favorite: !c.favorite } : c))
|
||||
);
|
||||
}
|
||||
toast.error('Failed to update favorite');
|
||||
}
|
||||
},
|
||||
[setContacts, setChannels]
|
||||
);
|
||||
|
||||
// useConversationRouter is called second — it receives channels/contacts as inputs
|
||||
const {
|
||||
activeConversation,
|
||||
@@ -264,6 +297,21 @@ export function App() {
|
||||
} = useConversationMessages(activeConversation, targetMessageId);
|
||||
removeConversationMessagesRef.current = removeConversationMessages;
|
||||
|
||||
// Auto-focus the message input on conversation change (desktop only by default)
|
||||
useEffect(() => {
|
||||
if (!activeConversation) return;
|
||||
if (activeConversation.type !== 'channel' && activeConversation.type !== 'contact') return;
|
||||
// Repeaters show a login form, not a message input
|
||||
if (activeConversation.type === 'contact') {
|
||||
const contact = contacts.find((c) => c.public_key === activeConversation.id);
|
||||
if (contact?.type === CONTACT_TYPE_REPEATER) return;
|
||||
}
|
||||
if (!shouldAutoFocusInput()) return;
|
||||
// Defer to let the input mount/render first
|
||||
const raf = requestAnimationFrame(() => messageInputRef.current?.focus?.());
|
||||
return () => cancelAnimationFrame(raf);
|
||||
}, [activeConversation?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Room servers replay stored history as a burst of DMs, all arriving with similar received_at
|
||||
// but spanning a wide range of sender_timestamps. Sort by sender_timestamp for room contacts
|
||||
// so the display reflects the original send order rather than our radio's receipt order.
|
||||
@@ -290,8 +338,8 @@ export function App() {
|
||||
markAllRead,
|
||||
refreshUnreads,
|
||||
} = useUnreadCounts(channels, contacts, activeConversation);
|
||||
useFaviconBadge(unreadCounts, mentions, favorites);
|
||||
useUnreadTitle(unreadCounts, favorites);
|
||||
useFaviconBadge(unreadCounts, mentions, channels);
|
||||
useUnreadTitle(unreadCounts, contacts, channels);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeConversation?.type !== 'channel') {
|
||||
@@ -426,6 +474,18 @@ export function App() {
|
||||
[fetchUndecryptedCount, setChannels]
|
||||
);
|
||||
|
||||
const handleRepeaterAutoLogin = useCallback(
|
||||
(publicKey: string, displayName: string) => {
|
||||
handleSelectConversationWithTargetReset({
|
||||
type: 'contact',
|
||||
id: publicKey,
|
||||
name: displayName,
|
||||
});
|
||||
setRepeaterAutoLoginKey(publicKey);
|
||||
},
|
||||
[handleSelectConversationWithTargetReset]
|
||||
);
|
||||
|
||||
const handleOpenNewMessage = useCallback(
|
||||
(event?: MouseEvent<HTMLButtonElement>) => {
|
||||
setNewMessagePrefillRequest(null);
|
||||
@@ -491,7 +551,6 @@ export function App() {
|
||||
onMarkAllRead: () => {
|
||||
void markAllRead();
|
||||
},
|
||||
favorites,
|
||||
isConversationNotificationsEnabled,
|
||||
blockedKeys: appSettings?.blocked_keys ?? [],
|
||||
blockedNames: appSettings?.blocked_names ?? [],
|
||||
@@ -507,7 +566,6 @@ export function App() {
|
||||
rawPacketStatsSession,
|
||||
config,
|
||||
health,
|
||||
favorites,
|
||||
messages: sortedMessages,
|
||||
preSorted: activeContactIsRoom,
|
||||
messagesLoading,
|
||||
@@ -558,6 +616,8 @@ export function App() {
|
||||
},
|
||||
trackedTelemetryRepeaters: appSettings?.tracked_telemetry_repeaters ?? [],
|
||||
onToggleTrackedTelemetry: handleToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
onClearRepeaterAutoLogin: () => setRepeaterAutoLoginKey(null),
|
||||
};
|
||||
const searchProps = {
|
||||
contacts,
|
||||
@@ -614,7 +674,6 @@ export function App() {
|
||||
onClose: handleCloseContactInfo,
|
||||
contacts,
|
||||
config,
|
||||
favorites,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
onNavigateToChannel: handleNavigateToChannel,
|
||||
onSearchMessagesByKey: (publicKey: string) => {
|
||||
@@ -632,7 +691,6 @@ export function App() {
|
||||
channelKey: infoPaneChannelKey,
|
||||
onClose: handleCloseChannelInfo,
|
||||
channels,
|
||||
favorites,
|
||||
onToggleFavorite: handleToggleFavorite,
|
||||
};
|
||||
|
||||
@@ -693,6 +751,7 @@ export function App() {
|
||||
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
|
||||
contactInfoPaneProps={contactInfoPaneProps}
|
||||
channelInfoPaneProps={channelInfoPaneProps}
|
||||
onRepeaterAutoLogin={handleRepeaterAutoLogin}
|
||||
/>
|
||||
</DistanceUnitProvider>
|
||||
);
|
||||
|
||||
+3
-4
@@ -9,7 +9,6 @@ import type {
|
||||
ContactAnalytics,
|
||||
ContactAdvertPathSummary,
|
||||
FanoutConfig,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
MaintenanceResult,
|
||||
Message,
|
||||
@@ -40,7 +39,7 @@ import type {
|
||||
UnreadCounts,
|
||||
} from './types';
|
||||
|
||||
const API_BASE = '/api';
|
||||
const API_BASE = './api';
|
||||
|
||||
async function fetchJson<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
const hasBody = options?.body !== undefined;
|
||||
@@ -334,8 +333,8 @@ export const api = {
|
||||
}),
|
||||
|
||||
// Favorites
|
||||
toggleFavorite: (type: Favorite['type'], id: string) =>
|
||||
fetchJson<AppSettings>('/settings/favorites/toggle', {
|
||||
toggleFavorite: (type: 'channel' | 'contact', id: string) =>
|
||||
fetchJson<{ type: string; id: string; favorite: boolean }>('/settings/favorites/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, id }),
|
||||
}),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lazy, Suspense, useRef, type ComponentProps } from 'react';
|
||||
import { lazy, Suspense, useCallback, useRef, type ComponentProps } from 'react';
|
||||
import { useSwipeable } from 'react-swipeable';
|
||||
|
||||
import { StatusBar } from './StatusBar';
|
||||
@@ -8,6 +8,7 @@ import { NewMessageModal } from './NewMessageModal';
|
||||
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
|
||||
import { ContactInfoPane } from './ContactInfoPane';
|
||||
import { ChannelInfoPane } from './ChannelInfoPane';
|
||||
import { CommandPalette } from './CommandPalette';
|
||||
import { SecurityWarningModal } from './SecurityWarningModal';
|
||||
import { Toaster } from './ui/sonner';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
@@ -71,6 +72,7 @@ interface AppShellProps {
|
||||
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
|
||||
contactInfoPaneProps: ContactInfoPaneProps;
|
||||
channelInfoPaneProps: ChannelInfoPaneProps;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}
|
||||
|
||||
export function AppShell({
|
||||
@@ -100,6 +102,7 @@ export function AppShell({
|
||||
bulkAddChannelResultModalProps,
|
||||
contactInfoPaneProps,
|
||||
channelInfoPaneProps,
|
||||
onRepeaterAutoLogin,
|
||||
}: AppShellProps) {
|
||||
const swipeHandlers = useSwipeable({
|
||||
onSwipedRight: ({ initial }) => {
|
||||
@@ -119,6 +122,14 @@ export function AppShell({
|
||||
preventScrollOnSwipe: false,
|
||||
});
|
||||
|
||||
const handleOpenSettings = useCallback(
|
||||
(section: SettingsSection) => {
|
||||
onSettingsSectionChange(section);
|
||||
if (!showSettings) onToggleSettingsView();
|
||||
},
|
||||
[onSettingsSectionChange, onToggleSettingsView, showSettings]
|
||||
);
|
||||
|
||||
const searchMounted = useRef(false);
|
||||
if (conversationPaneProps.activeConversation?.type === 'search') {
|
||||
searchMounted.current = true;
|
||||
@@ -299,7 +310,7 @@ export function AppShell({
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading cracker...
|
||||
Loading channel finder...
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -323,6 +334,13 @@ export function AppShell({
|
||||
onClose={onCloseBulkAddResults}
|
||||
/>
|
||||
|
||||
<CommandPalette
|
||||
contacts={sidebarProps.contacts}
|
||||
channels={sidebarProps.channels}
|
||||
onSelectConversation={sidebarProps.onSelectConversation}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
/>
|
||||
<SecurityWarningModal health={statusProps.health} />
|
||||
<ContactInfoPane {...contactInfoPaneProps} />
|
||||
<ChannelInfoPane {...channelInfoPaneProps} />
|
||||
|
||||
@@ -3,17 +3,15 @@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip as RechartsTooltip }
|
||||
import { Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
import { toast } from './ui/sonner';
|
||||
import type { Channel, ChannelDetail, Favorite, PathHashWidthStats } from '../types';
|
||||
import type { Channel, ChannelDetail, PathHashWidthStats } from '../types';
|
||||
|
||||
interface ChannelInfoPaneProps {
|
||||
channelKey: string | null;
|
||||
onClose: () => void;
|
||||
channels: Channel[];
|
||||
favorites: Favorite[];
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
}
|
||||
|
||||
@@ -21,7 +19,6 @@ export function ChannelInfoPane({
|
||||
channelKey,
|
||||
onClose,
|
||||
channels,
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
}: ChannelInfoPaneProps) {
|
||||
const [detail, setDetail] = useState<ChannelDetail | null>(null);
|
||||
@@ -125,7 +122,7 @@ export function ChannelInfoPane({
|
||||
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
|
||||
onClick={() => onToggleFavorite('channel', channel.key)}
|
||||
>
|
||||
{isFavorite(favorites, 'channel', channel.key) ? (
|
||||
{channel.favorite ? (
|
||||
<>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
|
||||
@@ -53,17 +53,17 @@ export function ChannelPathHashModeOverrideModal({
|
||||
{
|
||||
value: 0,
|
||||
label: '1-byte hop identifiers',
|
||||
description: 'Shortest paths, least repeater disambiguation',
|
||||
description: 'Least repeater disambiguation, up to 63 hops',
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: '2-byte hop identifiers',
|
||||
description: 'Better repeater disambiguation',
|
||||
description: 'Better repeater disambiguation, up to 32 hops',
|
||||
},
|
||||
{
|
||||
value: 2,
|
||||
label: '3-byte hop identifiers',
|
||||
description: 'Best repeater disambiguation, longest paths',
|
||||
description: 'Best repeater disambiguation, up to 21 hops',
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { DirectTraceIcon } from './DirectTraceIcon';
|
||||
import { ContactPathDiscoveryModal } from './ContactPathDiscoveryModal';
|
||||
import { ChannelFloodScopeOverrideModal } from './ChannelFloodScopeOverrideModal';
|
||||
import { ChannelPathHashModeOverrideModal } from './ChannelPathHashModeOverrideModal';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { stripRegionScopePrefix } from '../utils/regionScope';
|
||||
@@ -13,14 +12,7 @@ import { isPrefixOnlyContact } from '../utils/pubkey';
|
||||
import { cn } from '../lib/utils';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
PathDiscoveryResponse,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import type { Channel, Contact, Conversation, PathDiscoveryResponse, RadioConfig } from '../types';
|
||||
import { CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
@@ -28,7 +20,6 @@ interface ChatHeaderProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
config: RadioConfig | null;
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
@@ -49,7 +40,6 @@ export function ChatHeader({
|
||||
contacts,
|
||||
channels,
|
||||
config,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
@@ -105,12 +95,18 @@ export function ChatHeader({
|
||||
const titleClickable =
|
||||
(conversation.type === 'contact' && onOpenContactInfo) ||
|
||||
(conversation.type === 'channel' && onOpenChannelInfo);
|
||||
const isFav =
|
||||
conversation.type === 'contact'
|
||||
? (activeContact?.favorite ?? false)
|
||||
: conversation.type === 'channel'
|
||||
? (activeChannel?.favorite ?? false)
|
||||
: false;
|
||||
const favoriteTitle =
|
||||
conversation.type === 'contact'
|
||||
? isFavorite(favorites, 'contact', conversation.id)
|
||||
? 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.'
|
||||
: isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
: isFav
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites';
|
||||
|
||||
@@ -359,13 +355,9 @@ export function ChatHeader({
|
||||
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
|
||||
}
|
||||
title={favoriteTitle}
|
||||
aria-label={
|
||||
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites'
|
||||
}
|
||||
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
|
||||
{isFav ? (
|
||||
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
|
||||
) : (
|
||||
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
|
||||
@@ -0,0 +1,436 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Hash,
|
||||
Map,
|
||||
MessageSquare,
|
||||
Network,
|
||||
Radio,
|
||||
Route,
|
||||
Search,
|
||||
Sparkles,
|
||||
User,
|
||||
Waypoints,
|
||||
} from 'lucide-react';
|
||||
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from './ui/command';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from './ui/dialog';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
SETTINGS_SECTION_LABELS,
|
||||
SETTINGS_SECTION_ORDER,
|
||||
SETTINGS_SECTION_ICONS,
|
||||
type SettingsSection,
|
||||
} from './settings/settingsConstants';
|
||||
import type { Channel, Contact, Conversation } from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM } from '../types';
|
||||
|
||||
const MAX_PER_GROUP = 8;
|
||||
|
||||
interface CommandPaletteProps {
|
||||
contacts: Contact[];
|
||||
channels: Channel[];
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
onOpenSettings: (section: SettingsSection) => void;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}
|
||||
|
||||
interface Searchable {
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
interface SearchableContact extends Searchable {
|
||||
contact: Contact;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface SearchableChannel extends Searchable {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
interface ToolItem extends Searchable {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
type: 'raw' | 'map' | 'visualizer' | 'search' | 'trace';
|
||||
}
|
||||
|
||||
interface SettingItem extends Searchable {
|
||||
section: SettingsSection;
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
}
|
||||
|
||||
const TOOL_ITEMS: ToolItem[] = [
|
||||
{ id: 'raw', name: 'Raw Packet Feed', icon: Radio, type: 'raw', searchText: 'raw packet feed' },
|
||||
{ id: 'map', name: 'Map View', icon: Map, type: 'map', searchText: 'map view' },
|
||||
{
|
||||
id: 'visualizer',
|
||||
name: 'Network Visualizer',
|
||||
icon: Network,
|
||||
type: 'visualizer',
|
||||
searchText: 'network visualizer',
|
||||
},
|
||||
{
|
||||
id: 'search',
|
||||
name: 'Message Search',
|
||||
icon: Search,
|
||||
type: 'search',
|
||||
searchText: 'message search',
|
||||
},
|
||||
{ id: 'trace', name: 'Route Trace', icon: Route, type: 'trace', searchText: 'route trace' },
|
||||
];
|
||||
|
||||
const SETTING_ITEMS: SettingItem[] = SETTINGS_SECTION_ORDER.map((section) => ({
|
||||
section,
|
||||
label: SETTINGS_SECTION_LABELS[section],
|
||||
icon: SETTINGS_SECTION_ICONS[section],
|
||||
searchText: `settings ${SETTINGS_SECTION_LABELS[section]}`.toLowerCase(),
|
||||
}));
|
||||
|
||||
function fuzzyMatch(text: string, query: string): boolean {
|
||||
let qi = 0;
|
||||
for (let ti = 0; ti < text.length && qi < query.length; ti++) {
|
||||
if (text[ti] === query[qi]) qi++;
|
||||
}
|
||||
return qi === query.length;
|
||||
}
|
||||
|
||||
function filterList<T extends Searchable>(items: T[], query: string): T[] {
|
||||
if (!query) return items.slice(0, MAX_PER_GROUP);
|
||||
const results: T[] = [];
|
||||
for (const item of items) {
|
||||
if (fuzzyMatch(item.searchText, query)) {
|
||||
results.push(item);
|
||||
if (results.length >= MAX_PER_GROUP) break;
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
export function CommandPalette({
|
||||
contacts,
|
||||
channels,
|
||||
onSelectConversation,
|
||||
onOpenSettings,
|
||||
onRepeaterAutoLogin,
|
||||
}: CommandPaletteProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
setOpen((prev) => !prev);
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, []);
|
||||
|
||||
const select = useCallback((action: () => void) => {
|
||||
setOpen(false);
|
||||
action();
|
||||
}, []);
|
||||
|
||||
const {
|
||||
favContacts,
|
||||
favRepeaters,
|
||||
regularContacts,
|
||||
repeaters,
|
||||
rooms,
|
||||
favChannels,
|
||||
regularChannels,
|
||||
} = useMemo(() => {
|
||||
const fc: SearchableContact[] = [];
|
||||
const fr: SearchableContact[] = [];
|
||||
const rc: SearchableContact[] = [];
|
||||
const rp: SearchableContact[] = [];
|
||||
const rm: SearchableContact[] = [];
|
||||
for (const c of contacts) {
|
||||
const displayName = getContactDisplayName(c.name, c.public_key, c.last_advert);
|
||||
const entry: SearchableContact = {
|
||||
contact: c,
|
||||
displayName,
|
||||
searchText: `${displayName} ${c.public_key}`.toLowerCase(),
|
||||
};
|
||||
if (c.type === CONTACT_TYPE_REPEATER) {
|
||||
(c.favorite ? fr : rp).push(entry);
|
||||
} else if (c.type === CONTACT_TYPE_ROOM) {
|
||||
rm.push(entry);
|
||||
} else {
|
||||
(c.favorite ? fc : rc).push(entry);
|
||||
}
|
||||
}
|
||||
const fch: SearchableChannel[] = [];
|
||||
const rch: SearchableChannel[] = [];
|
||||
for (const ch of channels) {
|
||||
const entry: SearchableChannel = {
|
||||
channel: ch,
|
||||
searchText: `${ch.name} ${ch.key}`.toLowerCase(),
|
||||
};
|
||||
(ch.favorite ? fch : rch).push(entry);
|
||||
}
|
||||
return {
|
||||
favContacts: fc,
|
||||
favRepeaters: fr,
|
||||
regularContacts: rc,
|
||||
repeaters: rp,
|
||||
rooms: rm,
|
||||
favChannels: fch,
|
||||
regularChannels: rch,
|
||||
};
|
||||
}, [contacts, channels]);
|
||||
|
||||
const lq = query.toLowerCase();
|
||||
const fTools = filterList(TOOL_ITEMS, lq);
|
||||
const fSettings = filterList(SETTING_ITEMS, lq);
|
||||
const fFavContacts = filterList(favContacts, lq);
|
||||
const fFavRepeaters = filterList(favRepeaters, lq);
|
||||
const fFavChannels = filterList(favChannels, lq);
|
||||
const fContacts = filterList(regularContacts, lq);
|
||||
const fRepeaters = filterList(repeaters, lq);
|
||||
const fRooms = filterList(rooms, lq);
|
||||
const fChannels = filterList(regularChannels, lq);
|
||||
|
||||
const totalResults =
|
||||
fTools.length +
|
||||
fSettings.length +
|
||||
fFavContacts.length +
|
||||
fFavRepeaters.length +
|
||||
fFavChannels.length +
|
||||
fContacts.length +
|
||||
fRepeaters.length +
|
||||
fRooms.length +
|
||||
fChannels.length;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setOpen(nextOpen);
|
||||
if (!nextOpen) setQuery('');
|
||||
}}
|
||||
>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
|
||||
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search for conversations, settings, and tools
|
||||
</DialogDescription>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandInput placeholder="Jump to..." value={query} onValueChange={setQuery} />
|
||||
<CommandList>
|
||||
{totalResults === 0 && <CommandEmpty>No results found.</CommandEmpty>}
|
||||
|
||||
{fTools.length > 0 && (
|
||||
<CommandGroup heading="Tools">
|
||||
{fTools.map((tool) => (
|
||||
<CommandItem
|
||||
key={tool.id}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: tool.type, id: tool.id, name: tool.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<tool.icon className="text-muted-foreground" />
|
||||
<span>{tool.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fSettings.length > 0 && (
|
||||
<CommandGroup heading="Settings">
|
||||
{fSettings.map((item) => (
|
||||
<CommandItem
|
||||
key={item.section}
|
||||
onSelect={() => select(() => onOpenSettings(item.section))}
|
||||
>
|
||||
<item.icon className="text-muted-foreground" />
|
||||
<span>{item.label}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fFavContacts.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Favorite Contacts"
|
||||
items={fFavContacts}
|
||||
icon={User}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
showStar
|
||||
/>
|
||||
)}
|
||||
|
||||
{fFavRepeaters.length > 0 && (
|
||||
<RepeaterGroup
|
||||
heading="Favorite Repeaters"
|
||||
items={fFavRepeaters}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
showStar
|
||||
/>
|
||||
)}
|
||||
|
||||
{fFavChannels.length > 0 && (
|
||||
<CommandGroup heading="Favorite Channels">
|
||||
{fFavChannels.map(({ channel: ch }) => (
|
||||
<CommandItem
|
||||
key={ch.key}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<span>{ch.name}</span>
|
||||
<Sparkles className="ml-auto h-3 w-3 text-yellow-500" />
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
|
||||
{fContacts.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Contacts"
|
||||
items={fContacts}
|
||||
icon={User}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fRepeaters.length > 0 && (
|
||||
<RepeaterGroup
|
||||
heading="Repeaters"
|
||||
items={fRepeaters}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
onRepeaterAutoLogin={onRepeaterAutoLogin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fRooms.length > 0 && (
|
||||
<ContactGroup
|
||||
heading="Rooms"
|
||||
items={fRooms}
|
||||
icon={MessageSquare}
|
||||
onSelect={select}
|
||||
onSelectConversation={onSelectConversation}
|
||||
/>
|
||||
)}
|
||||
|
||||
{fChannels.length > 0 && (
|
||||
<CommandGroup heading="Channels">
|
||||
{fChannels.map(({ channel: ch }) => (
|
||||
<CommandItem
|
||||
key={ch.key}
|
||||
onSelect={() =>
|
||||
select(() =>
|
||||
onSelectConversation({ type: 'channel', id: ch.key, name: ch.name })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Hash className="text-muted-foreground" />
|
||||
<span>{ch.name}</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ContactGroup({
|
||||
heading,
|
||||
items,
|
||||
icon: Icon,
|
||||
showStar,
|
||||
onSelect,
|
||||
onSelectConversation,
|
||||
}: {
|
||||
heading: string;
|
||||
items: SearchableContact[];
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
showStar?: boolean;
|
||||
onSelect: (action: () => void) => void;
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
}) {
|
||||
return (
|
||||
<CommandGroup heading={heading}>
|
||||
{items.map(({ contact: c, displayName }) => (
|
||||
<CommandItem
|
||||
key={c.public_key}
|
||||
onSelect={() =>
|
||||
onSelect(() =>
|
||||
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="text-muted-foreground" />
|
||||
<span>{displayName}</span>
|
||||
{showStar && <Sparkles className="ml-auto h-3 w-3 text-yellow-500" />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function RepeaterGroup({
|
||||
heading,
|
||||
items,
|
||||
showStar,
|
||||
onSelect,
|
||||
onSelectConversation,
|
||||
onRepeaterAutoLogin,
|
||||
}: {
|
||||
heading: string;
|
||||
items: SearchableContact[];
|
||||
showStar?: boolean;
|
||||
onSelect: (action: () => void) => void;
|
||||
onSelectConversation: (conv: Conversation) => void;
|
||||
onRepeaterAutoLogin: (publicKey: string, displayName: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<CommandGroup heading={heading}>
|
||||
{items.flatMap(({ contact: c, displayName }) => [
|
||||
<CommandItem
|
||||
key={c.public_key}
|
||||
onSelect={() =>
|
||||
onSelect(() =>
|
||||
onSelectConversation({ type: 'contact', id: c.public_key, name: displayName })
|
||||
)
|
||||
}
|
||||
>
|
||||
<Waypoints className="text-muted-foreground" />
|
||||
<span>{displayName}</span>
|
||||
{showStar && <Sparkles className="ml-auto h-3 w-3 text-yellow-500" />}
|
||||
</CommandItem>,
|
||||
<CommandItem
|
||||
key={`${c.public_key}-acl`}
|
||||
onSelect={() => onSelect(() => onRepeaterAutoLogin(c.public_key, displayName))}
|
||||
>
|
||||
<Waypoints className="text-muted-foreground" />
|
||||
<span>
|
||||
{displayName} <span className="text-muted-foreground">(ACL login + load all)</span>
|
||||
</span>
|
||||
</CommandItem>,
|
||||
])}
|
||||
</CommandGroup>
|
||||
);
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
} from '../utils/pathUtils';
|
||||
import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { getMapFocusHash } from '../utils/urlHash';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
|
||||
@@ -42,7 +41,6 @@ import type {
|
||||
ContactAnalytics,
|
||||
ContactAnalyticsHourlyBucket,
|
||||
ContactAnalyticsWeeklyBucket,
|
||||
Favorite,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
|
||||
@@ -67,7 +65,6 @@ interface ContactInfoPaneProps {
|
||||
onClose: () => void;
|
||||
contacts: Contact[];
|
||||
config: RadioConfig | null;
|
||||
favorites: Favorite[];
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onNavigateToChannel?: (channelKey: string) => void;
|
||||
onSearchMessagesByKey?: (publicKey: string) => void;
|
||||
@@ -84,7 +81,6 @@ export function ContactInfoPane({
|
||||
onClose,
|
||||
contacts,
|
||||
config,
|
||||
favorites,
|
||||
onToggleFavorite,
|
||||
onNavigateToChannel,
|
||||
onSearchMessagesByKey,
|
||||
@@ -384,7 +380,7 @@ export function ContactInfoPane({
|
||||
onClick={() => onToggleFavorite('contact', contact.public_key)}
|
||||
title="Favorite contacts stay loaded on the radio for ACK support"
|
||||
>
|
||||
{isFavorite(favorites, 'contact', contact.public_key) ? (
|
||||
{contact.favorite ? (
|
||||
<>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
|
||||
@@ -10,7 +10,6 @@ import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
Message,
|
||||
PathDiscoveryResponse,
|
||||
@@ -42,7 +41,6 @@ interface ConversationPaneProps {
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
favorites: Favorite[];
|
||||
messages: Message[];
|
||||
preSorted?: boolean;
|
||||
messagesLoading: boolean;
|
||||
@@ -81,6 +79,8 @@ interface ConversationPaneProps {
|
||||
onToggleNotifications: () => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
repeaterAutoLoginKey: string | null;
|
||||
onClearRepeaterAutoLogin: () => void;
|
||||
}
|
||||
|
||||
function LoadingPane({ label }: { label: string }) {
|
||||
@@ -119,7 +119,6 @@ export function ConversationPane({
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
favorites,
|
||||
messages,
|
||||
preSorted,
|
||||
messagesLoading,
|
||||
@@ -152,6 +151,8 @@ export function ConversationPane({
|
||||
onToggleNotifications,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
repeaterAutoLoginKey,
|
||||
onClearRepeaterAutoLogin,
|
||||
}: ConversationPaneProps) {
|
||||
const [roomAuthenticated, setRoomAuthenticated] = useState(false);
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
@@ -237,7 +238,6 @@ export function ConversationPane({
|
||||
key={activeConversation.id}
|
||||
conversation={activeConversation}
|
||||
contacts={contacts}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
@@ -252,6 +252,8 @@ export function ConversationPane({
|
||||
onOpenContactInfo={onOpenContactInfo}
|
||||
trackedTelemetryRepeaters={trackedTelemetryRepeaters}
|
||||
onToggleTrackedTelemetry={onToggleTrackedTelemetry}
|
||||
autoLoginAndLoadAll={repeaterAutoLoginKey === activeConversation.id}
|
||||
onAutoLoginConsumed={onClearRepeaterAutoLogin}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
@@ -266,7 +268,6 @@ export function ConversationPane({
|
||||
contacts={contacts}
|
||||
channels={channels}
|
||||
config={config}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
|
||||
@@ -98,7 +98,7 @@ export function CrackerPanel({
|
||||
.catch((err) => {
|
||||
console.error('Failed to load wordlist:', err);
|
||||
toast.error('Failed to load wordlist', {
|
||||
description: 'Cracking will not be available',
|
||||
description: 'Channel finder will not be available',
|
||||
});
|
||||
});
|
||||
}, [visible, wordlistLoaded]);
|
||||
@@ -356,7 +356,7 @@ export function CrackerPanel({
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to create channel or decrypt historical:', err);
|
||||
toast.error('Failed to save cracked channel', {
|
||||
toast.error('Failed to save found channel', {
|
||||
description:
|
||||
err instanceof Error ? err.message : 'Channel discovered but could not be saved',
|
||||
});
|
||||
@@ -409,7 +409,10 @@ export function CrackerPanel({
|
||||
const handleStart = () => {
|
||||
if (!gpuAvailable) {
|
||||
toast.error('WebGPU not available', {
|
||||
description: 'Cracking requires Chrome 113+ or Edge 113+ with WebGPU support.',
|
||||
description:
|
||||
typeof window !== 'undefined' && !window.isSecureContext
|
||||
? 'WebGPU requires HTTPS when not on localhost. Set up a certificate or configure your browser to treat this origin as secure.'
|
||||
: 'Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -537,7 +540,7 @@ export function CrackerPanel({
|
||||
Pending: <span className="text-foreground font-medium">{pendingCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Cracked: <span className="text-success font-medium">{crackedCount}</span>
|
||||
Found: <span className="text-success font-medium">{crackedCount}</span>
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
Failed: <span className="text-destructive font-medium">{failedCount}</span>
|
||||
@@ -581,7 +584,7 @@ export function CrackerPanel({
|
||||
aria-valuenow={Math.round(progress.percent)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Cracking progress"
|
||||
aria-label="Channel finder progress"
|
||||
>
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-200"
|
||||
@@ -593,8 +596,26 @@ export function CrackerPanel({
|
||||
|
||||
{/* GPU status */}
|
||||
{gpuAvailable === false && (
|
||||
<div className="text-sm text-destructive" role="alert">
|
||||
WebGPU not available. Cracking requires Chrome 113+ or Edge 113+.
|
||||
<div className="text-sm text-destructive space-y-1.5" role="alert">
|
||||
<p>WebGPU not available.</p>
|
||||
{typeof window !== 'undefined' && !window.isSecureContext ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-2.5 text-xs text-destructive/90">
|
||||
<p className="font-medium mb-1">WebGPU requires HTTPS when not on localhost.</p>
|
||||
<p>To enable it:</p>
|
||||
<ul className="list-disc ml-4 mt-1 space-y-0.5">
|
||||
<li>
|
||||
Set up a TLS certificate (see the HTTPS section of README_ADVANCED.md, or re-run
|
||||
the Docker setup script which can generate one automatically)
|
||||
</li>
|
||||
<li>
|
||||
Or configure your browser to treat this origin as secure (sometimes called
|
||||
“insecure origins treated as secure” in browser flags)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<p>Channel finder requires Chrome 113+ or Edge 113+ with WebGPU support.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!wordlistLoaded && gpuAvailable !== false && (
|
||||
@@ -603,10 +624,10 @@ export function CrackerPanel({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Cracked channels list */}
|
||||
{/* Found channels list */}
|
||||
{crackedChannels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Cracked Channels:</div>
|
||||
<div className="text-xs text-muted-foreground mb-1">Found Channels:</div>
|
||||
<div className="space-y-1">
|
||||
{crackedChannels.map((channel, i) => (
|
||||
<div
|
||||
@@ -630,8 +651,8 @@ export function CrackerPanel({
|
||||
force payloads as they arrive, testing channel names up to the specified length to discover
|
||||
active channels on the local mesh (GroupText packets may not be hashtag messages; we have no
|
||||
way of knowing but try as if they are).
|
||||
<strong> Retry failed at n+1</strong> will let the cracker return to the failed queue and
|
||||
pick up messages it couldn't crack, attempting them at one longer length.
|
||||
<strong> Retry failed at n+1</strong> will return to the failed queue and pick up messages
|
||||
it couldn't find a key for, attempting them at one longer length.
|
||||
<strong> Try word pairs</strong> will also try every combination of two dictionary words
|
||||
concatenated together (e.g. "hello" + "world" = "#helloworld") after the single-word
|
||||
dictionary pass; this can substantially increase search time and also result in
|
||||
@@ -639,7 +660,7 @@ export function CrackerPanel({
|
||||
<strong> Decrypt historical</strong> will run an async job on any channel name it finds to
|
||||
see if any historically captured packets will decrypt with that key.
|
||||
<strong> Turbo mode</strong> will push your GPU to the max (target dispatch time of 10s) and
|
||||
may allow accelerated cracking and/or system instability.
|
||||
may allow accelerated searching and/or system instability.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -44,6 +44,7 @@ type LimitState = 'normal' | 'warning' | 'danger' | 'error';
|
||||
|
||||
export interface MessageInputHandle {
|
||||
appendText: (text: string) => void;
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(function MessageInput(
|
||||
@@ -60,6 +61,9 @@ export const MessageInput = forwardRef<MessageInputHandle, MessageInputProps>(fu
|
||||
// Focus the input after appending
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
focus: () => {
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
// Calculate character limits based on conversation type
|
||||
|
||||
@@ -991,7 +991,7 @@ export function MessageList({
|
||||
displaySender
|
||||
)}
|
||||
<span className="font-normal text-muted-foreground ml-2 text-[0.6875rem]">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
{formatTime(msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
@@ -1019,7 +1019,7 @@ export function MessageList({
|
||||
{!showAvatar && (
|
||||
<>
|
||||
<span className="text-[0.625rem] text-muted-foreground ml-2">
|
||||
{formatTime(msg.sender_timestamp || msg.received_at)}
|
||||
{formatTime(msg.received_at)}
|
||||
</span>
|
||||
{!msg.outgoing && msg.paths && msg.paths.length > 0 && (
|
||||
<HopCountBadge
|
||||
|
||||
@@ -9,17 +9,10 @@ import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
|
||||
import { useRememberedServerPassword } from '../hooks/useRememberedServerPassword';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { isValidLocation } from '../utils/pathUtils';
|
||||
import { ContactStatusInfo } from './ContactStatusInfo';
|
||||
import type {
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
PathDiscoveryResponse,
|
||||
TelemetryHistoryEntry,
|
||||
} from '../types';
|
||||
import type { Contact, Conversation, PathDiscoveryResponse, TelemetryHistoryEntry } from '../types';
|
||||
import { cn } from '../lib/utils';
|
||||
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
|
||||
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
|
||||
@@ -41,7 +34,6 @@ export { formatDuration, formatClockDrift } from './repeater/repeaterPaneShared'
|
||||
interface RepeaterDashboardProps {
|
||||
conversation: Conversation;
|
||||
contacts: Contact[];
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
@@ -56,12 +48,13 @@ interface RepeaterDashboardProps {
|
||||
onOpenContactInfo?: (publicKey: string) => void;
|
||||
trackedTelemetryRepeaters: string[];
|
||||
onToggleTrackedTelemetry: (publicKey: string) => Promise<void>;
|
||||
autoLoginAndLoadAll?: boolean;
|
||||
onAutoLoginConsumed?: () => void;
|
||||
}
|
||||
|
||||
export function RepeaterDashboard({
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
@@ -76,6 +69,8 @@ export function RepeaterDashboard({
|
||||
onOpenContactInfo,
|
||||
trackedTelemetryRepeaters,
|
||||
onToggleTrackedTelemetry,
|
||||
autoLoginAndLoadAll,
|
||||
onAutoLoginConsumed,
|
||||
}: RepeaterDashboardProps) {
|
||||
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
|
||||
@@ -134,7 +129,16 @@ export function RepeaterDashboard({
|
||||
setTelemetryHistory(liveHistory);
|
||||
}, [paneData.status?.telemetry_history]);
|
||||
|
||||
const isFav = isFavorite(favorites, 'contact', conversation.id);
|
||||
// Command palette "ACL login + load all" auto-action
|
||||
const autoLoginConsumedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!autoLoginAndLoadAll || autoLoginConsumedRef.current) return;
|
||||
autoLoginConsumedRef.current = true;
|
||||
onAutoLoginConsumed?.();
|
||||
void loginAsGuest().then(() => loadAll());
|
||||
}, [autoLoginAndLoadAll, onAutoLoginConsumed, loginAsGuest, loadAll]);
|
||||
|
||||
const isFav = contact?.favorite ?? false;
|
||||
|
||||
const handleRepeaterLogin = async (nextPassword: string) => {
|
||||
await login(nextPassword);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, type FormEvent } from 'react';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { shouldAutoFocusInput } from '../utils/autoFocusInput';
|
||||
|
||||
interface RepeaterLoginProps {
|
||||
repeaterName: string;
|
||||
@@ -64,7 +65,7 @@ export function RepeaterLogin({
|
||||
placeholder={passwordPlaceholder}
|
||||
aria-label="Repeater password"
|
||||
disabled={loading}
|
||||
autoFocus
|
||||
autoFocus={shouldAutoFocusInput()}
|
||||
/>
|
||||
|
||||
<label
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
type Contact,
|
||||
type Channel,
|
||||
type Conversation,
|
||||
type Favorite,
|
||||
} from '../types';
|
||||
import {
|
||||
buildSidebarSectionSortOrders,
|
||||
@@ -36,7 +35,6 @@ import { isPublicChannelKey } from '../utils/publicChannel';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { ContactAvatar } from './ContactAvatar';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { Input } from './ui/input';
|
||||
import { Button } from './ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -106,7 +104,6 @@ interface SidebarProps {
|
||||
crackerRunning: boolean;
|
||||
onToggleCracker: () => void;
|
||||
onMarkAllRead: () => void;
|
||||
favorites: Favorite[];
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
blockedKeys?: string[];
|
||||
blockedNames?: string[];
|
||||
@@ -135,7 +132,6 @@ export function Sidebar({
|
||||
crackerRunning,
|
||||
onToggleCracker,
|
||||
onMarkAllRead,
|
||||
favorites,
|
||||
isConversationNotificationsEnabled,
|
||||
blockedKeys = [],
|
||||
blockedNames = [],
|
||||
@@ -488,22 +484,16 @@ export function Sidebar({
|
||||
nonFavoriteRooms,
|
||||
nonFavoriteRepeaters,
|
||||
} = useMemo(() => {
|
||||
const favChannels = filteredChannels.filter((c) => isFavorite(favorites, 'channel', c.key));
|
||||
const favChannels = filteredChannels.filter((c) => c.favorite);
|
||||
const favContacts = [
|
||||
...filteredNonRepeaterContacts,
|
||||
...filteredRooms,
|
||||
...filteredRepeaters,
|
||||
].filter((c) => isFavorite(favorites, 'contact', c.public_key));
|
||||
const nonFavChannels = filteredChannels.filter((c) => !isFavorite(favorites, 'channel', c.key));
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRooms = filteredRooms.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
const nonFavRepeaters = filteredRepeaters.filter(
|
||||
(c) => !isFavorite(favorites, 'contact', c.public_key)
|
||||
);
|
||||
].filter((c) => c.favorite);
|
||||
const nonFavChannels = filteredChannels.filter((c) => !c.favorite);
|
||||
const nonFavContacts = filteredNonRepeaterContacts.filter((c) => !c.favorite);
|
||||
const nonFavRooms = filteredRooms.filter((c) => !c.favorite);
|
||||
const nonFavRepeaters = filteredRepeaters.filter((c) => !c.favorite);
|
||||
|
||||
const items: FavoriteItem[] = [
|
||||
...favChannels.map((channel) => ({ type: 'channel' as const, channel })),
|
||||
@@ -522,7 +512,6 @@ export function Sidebar({
|
||||
filteredNonRepeaterContacts,
|
||||
filteredRooms,
|
||||
filteredRepeaters,
|
||||
favorites,
|
||||
sectionSortOrders.favorites,
|
||||
sortFavoriteItemsByOrder,
|
||||
]);
|
||||
|
||||
@@ -28,6 +28,48 @@ import { cn } from '@/lib/utils';
|
||||
type TraceSortMode = 'alpha' | 'recent' | 'distance';
|
||||
type CustomHopBytes = 1 | 2 | 4;
|
||||
|
||||
const RECENT_TRACES_KEY = 'remoteterm-recent-traces';
|
||||
const MAX_RECENT_TRACES = 5;
|
||||
|
||||
interface SavedTraceHop {
|
||||
kind: 'repeater' | 'custom';
|
||||
publicKey?: string;
|
||||
hopHex?: string;
|
||||
hopBytes?: CustomHopBytes;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
interface SavedTrace {
|
||||
hops: SavedTraceHop[];
|
||||
ranAt: number;
|
||||
}
|
||||
|
||||
function loadRecentTraces(): SavedTrace[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(RECENT_TRACES_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed.slice(0, MAX_RECENT_TRACES) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function saveRecentTrace(trace: SavedTrace): void {
|
||||
try {
|
||||
const existing = loadRecentTraces();
|
||||
// Dedupe by hop signature
|
||||
const sig = trace.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(',');
|
||||
const deduped = existing.filter(
|
||||
(t) => t.hops.map((h) => h.publicKey ?? h.hopHex ?? '').join(',') !== sig
|
||||
);
|
||||
const updated = [trace, ...deduped].slice(0, MAX_RECENT_TRACES);
|
||||
localStorage.setItem(RECENT_TRACES_KEY, JSON.stringify(updated));
|
||||
} catch {
|
||||
// localStorage may be disabled
|
||||
}
|
||||
}
|
||||
|
||||
type TraceDraftHop =
|
||||
| { id: string; kind: 'repeater'; publicKey: string }
|
||||
| { id: string; kind: 'custom'; hopHex: string; hopBytes: CustomHopBytes };
|
||||
@@ -154,6 +196,7 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
const [customHopBytesDraft, setCustomHopBytesDraft] = useState<CustomHopBytes>(1);
|
||||
const [customHopHexDraft, setCustomHopHexDraft] = useState('');
|
||||
const [customHopError, setCustomHopError] = useState<string | null>(null);
|
||||
const [recentTraces, setRecentTraces] = useState<SavedTrace[]>(loadRecentTraces);
|
||||
const activeRunTokenRef = useRef(0);
|
||||
|
||||
const repeaters = useMemo(() => {
|
||||
@@ -272,6 +315,56 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
clearPendingResult();
|
||||
};
|
||||
|
||||
const handleLoadRecentTrace = async (trace: SavedTrace) => {
|
||||
const hops: TraceDraftHop[] = trace.hops.map((h, i) => {
|
||||
if (h.kind === 'repeater' && h.publicKey) {
|
||||
return {
|
||||
id: nextDraftHopId('repeater', i),
|
||||
kind: 'repeater' as const,
|
||||
publicKey: h.publicKey,
|
||||
};
|
||||
}
|
||||
return {
|
||||
id: nextDraftHopId('custom', i),
|
||||
kind: 'custom' as const,
|
||||
hopHex: h.hopHex ?? '',
|
||||
hopBytes: h.hopBytes ?? (1 as CustomHopBytes),
|
||||
};
|
||||
});
|
||||
setDraftHops(hops);
|
||||
|
||||
// Determine hop hash bytes from the loaded hops
|
||||
const customHop = hops.find((h) => h.kind === 'custom');
|
||||
const hopHashBytes: CustomHopBytes = customHop?.hopBytes ?? 4;
|
||||
|
||||
// Run the trace immediately
|
||||
const runToken = activeRunTokenRef.current + 1;
|
||||
activeRunTokenRef.current = runToken;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const traceResult = await onRunTracePath(
|
||||
hopHashBytes,
|
||||
hops.map((hop) =>
|
||||
hop.kind === 'repeater' ? { public_key: hop.publicKey } : { hop_hex: hop.hopHex }
|
||||
)
|
||||
);
|
||||
if (activeRunTokenRef.current !== runToken) return;
|
||||
setResult(traceResult);
|
||||
|
||||
// Re-save to bump this trace to the top of recents
|
||||
const savedTrace: SavedTrace = { hops: trace.hops, ranAt: Date.now() };
|
||||
saveRecentTrace(savedTrace);
|
||||
setRecentTraces(loadRecentTraces());
|
||||
} catch (err) {
|
||||
if (activeRunTokenRef.current !== runToken) return;
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
if (activeRunTokenRef.current === runToken) setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRunTrace = async () => {
|
||||
if (draftHops.length === 0) {
|
||||
return;
|
||||
@@ -292,6 +385,27 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
return;
|
||||
}
|
||||
setResult(traceResult);
|
||||
|
||||
// Persist to recent traces
|
||||
const savedHops: SavedTraceHop[] = draftHops.map((hop) => {
|
||||
if (hop.kind === 'repeater') {
|
||||
const c = repeatersByKey.get(hop.publicKey);
|
||||
return {
|
||||
kind: 'repeater',
|
||||
publicKey: hop.publicKey,
|
||||
displayName: getContactDisplayName(c?.name, hop.publicKey, c?.last_advert ?? null),
|
||||
};
|
||||
}
|
||||
return {
|
||||
kind: 'custom',
|
||||
hopHex: hop.hopHex,
|
||||
hopBytes: hop.hopBytes,
|
||||
displayName: `${hop.hopHex.toUpperCase()} (${hop.hopBytes}B)`,
|
||||
};
|
||||
});
|
||||
const trace: SavedTrace = { hops: savedHops, ranAt: Date.now() };
|
||||
saveRecentTrace(trace);
|
||||
setRecentTraces(loadRecentTraces());
|
||||
} catch (err) {
|
||||
if (activeRunTokenRef.current !== runToken) {
|
||||
return;
|
||||
@@ -453,6 +567,39 @@ export function TracePane({ contacts, config, onRunTracePath }: TracePaneProps)
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
The first node is display-only. The terminal node is the local radio.
|
||||
</p>
|
||||
{recentTraces.length > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="text-[0.625rem] uppercase tracking-wider text-muted-foreground font-medium mb-1">
|
||||
Rerun a recent trace:
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{recentTraces.map((trace, i) => {
|
||||
const label = trace.hops
|
||||
.map((h) => {
|
||||
if (h.kind === 'repeater' && h.publicKey) {
|
||||
const shortKey = h.publicKey.slice(0, 12);
|
||||
return h.displayName !== shortKey
|
||||
? `${h.displayName} (${shortKey})`
|
||||
: shortKey;
|
||||
}
|
||||
return h.displayName;
|
||||
})
|
||||
.join(' → ');
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
className="rounded-md border border-border px-2.5 py-1.5 text-xs hover:bg-accent transition-colors truncate max-w-full disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
disabled={loading}
|
||||
onClick={() => handleLoadRecentTrace(trace)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{draftHops.length > 0 ? (
|
||||
<Button
|
||||
|
||||
@@ -13,6 +13,8 @@ export function ConsolePane({
|
||||
}) {
|
||||
const [input, setInput] = useState('');
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const prevLoadingRef = useRef(loading);
|
||||
|
||||
// Auto-scroll to bottom on new entries
|
||||
useEffect(() => {
|
||||
@@ -21,6 +23,14 @@ export function ConsolePane({
|
||||
}
|
||||
}, [history]);
|
||||
|
||||
// Refocus input after command completes
|
||||
useEffect(() => {
|
||||
if (prevLoadingRef.current && !loading) {
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
prevLoadingRef.current = loading;
|
||||
}, [loading]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -59,6 +69,7 @@ export function ConsolePane({
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="flex gap-2 p-2 border-t border-border">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
name="console-input"
|
||||
|
||||
@@ -90,6 +90,15 @@ export function TelemetryHistoryPane({
|
||||
|
||||
const dataKeys = metric === 'packets' ? ['packets_received', 'packets_sent'] : [metric];
|
||||
|
||||
const yDomain = useMemo<[number, number] | undefined>(() => {
|
||||
if (metric !== 'battery_volts' || chartData.length === 0) return undefined;
|
||||
const values = chartData.map((d) => d.battery_volts).filter((v) => v != null) as number[];
|
||||
if (values.length === 0) return [3, 5];
|
||||
const lo = Math.min(...values);
|
||||
const hi = Math.max(...values);
|
||||
return [Math.min(3, Math.floor(lo) - 1), Math.max(5, Math.ceil(hi) + 1)];
|
||||
}, [metric, chartData]);
|
||||
|
||||
const handleToggle = async () => {
|
||||
setToggling(true);
|
||||
try {
|
||||
@@ -208,6 +217,7 @@ export function TelemetryHistoryPane({
|
||||
tickFormatter={formatTime}
|
||||
/>
|
||||
<YAxis
|
||||
domain={yDomain}
|
||||
tick={{ fontSize: 10, fill: 'hsl(var(--muted-foreground))' }}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
|
||||
@@ -131,7 +131,7 @@ export function SettingsAboutSection({
|
||||
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="/api/debug"
|
||||
href="./api/debug"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-primary hover:underline"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -7,7 +7,13 @@ import { toast } from '../ui/sonner';
|
||||
import { api } from '../../api';
|
||||
import { formatTime } from '../../utils/messageParser';
|
||||
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
|
||||
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
|
||||
import type {
|
||||
AppSettings,
|
||||
AppSettingsUpdate,
|
||||
Contact,
|
||||
HealthStatus,
|
||||
TelemetryHistoryEntry,
|
||||
} from '../../types';
|
||||
|
||||
export function SettingsDatabaseSection({
|
||||
appSettings,
|
||||
@@ -48,11 +54,35 @@ export function SettingsDatabaseSection({
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [latestTelemetry, setLatestTelemetry] = useState<
|
||||
Record<string, TelemetryHistoryEntry | null>
|
||||
>({});
|
||||
const telemetryFetchedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
|
||||
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
|
||||
}, [appSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
if (trackedTelemetryRepeaters.length === 0 || telemetryFetchedRef.current) return;
|
||||
telemetryFetchedRef.current = true;
|
||||
let cancelled = false;
|
||||
const fetches = trackedTelemetryRepeaters.map((key) =>
|
||||
api.repeaterTelemetryHistory(key).then(
|
||||
(history) => [key, history.length > 0 ? history[history.length - 1] : null] as const,
|
||||
() => [key, null] as const
|
||||
)
|
||||
);
|
||||
Promise.all(fetches).then((entries) => {
|
||||
if (cancelled) return;
|
||||
setLatestTelemetry(Object.fromEntries(entries));
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [trackedTelemetryRepeaters]);
|
||||
|
||||
const handleCleanup = async () => {
|
||||
const days = parseInt(retentionDays, 10);
|
||||
if (isNaN(days) || days < 1) {
|
||||
@@ -242,28 +272,49 @@ export function SettingsDatabaseSection({
|
||||
No repeaters are being tracked. Enable tracking from a repeater's dashboard.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-2">
|
||||
{trackedTelemetryRepeaters.map((key) => {
|
||||
const contact = contacts.find((c) => c.public_key === key);
|
||||
const displayName = contact?.name ?? key.slice(0, 12);
|
||||
const snap = latestTelemetry[key];
|
||||
const d = snap?.data;
|
||||
return (
|
||||
<div key={key} className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm truncate block">{displayName}</span>
|
||||
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
||||
{key.slice(0, 12)}
|
||||
</span>
|
||||
<div key={key} className="rounded-md border border-border px-3 py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm truncate block">{displayName}</span>
|
||||
<span className="text-[0.625rem] text-muted-foreground font-mono">
|
||||
{key.slice(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
{onToggleTrackedTelemetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetry(key)}
|
||||
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{onToggleTrackedTelemetry && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onToggleTrackedTelemetry(key)}
|
||||
className="h-7 text-xs flex-shrink-0 text-destructive hover:text-destructive"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
{d ? (
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[0.625rem] text-muted-foreground">
|
||||
<span>{d.battery_volts?.toFixed(2)}V</span>
|
||||
<span>noise {d.noise_floor_dbm} dBm</span>
|
||||
<span>
|
||||
rx {d.packets_received != null ? d.packets_received.toLocaleString() : '?'}
|
||||
</span>
|
||||
<span>
|
||||
tx {d.packets_sent != null ? d.packets_sent.toLocaleString() : '?'}
|
||||
</span>
|
||||
<span className="ml-auto">checked {formatTime(snap.timestamp)}</span>
|
||||
</div>
|
||||
) : snap === null ? (
|
||||
<div className="mt-1 text-[0.625rem] text-muted-foreground italic">
|
||||
No telemetry recorded yet
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
getSavedFontScale,
|
||||
setSavedFontScale,
|
||||
} from '../../utils/fontScale';
|
||||
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
|
||||
|
||||
export function SettingsLocalSection({
|
||||
onLocalLabelChange,
|
||||
@@ -48,6 +49,7 @@ export function SettingsLocalSection({
|
||||
});
|
||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||
const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled);
|
||||
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
|
||||
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
|
||||
@@ -129,85 +131,6 @@ export function SettingsLocalSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step={FONT_SCALE_SLIDER_STEP}
|
||||
value={fontScaleSlider}
|
||||
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
aria-label="Relative font size slider"
|
||||
className="w-full accent-primary sm:flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2 sm:w-40">
|
||||
<Input
|
||||
id="font-scale-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step="any"
|
||||
value={fontScaleInput}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setFontScaleInput(nextValue);
|
||||
|
||||
if (nextValue === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
|
||||
commitFontScale(event.target.valueAsNumber);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
aria-label="Relative font size percentage"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={fontScale === DEFAULT_FONT_SCALE}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scales the app's typography for this browser only. The slider moves in 5% steps; the
|
||||
number field accepts any value from 25% to 400%.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="distance-units">Distance Units</Label>
|
||||
<select
|
||||
@@ -233,33 +156,128 @@ export function SettingsLocalSection({
|
||||
|
||||
<Separator />
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopenLastConversation}
|
||||
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<Label>UI Tweaks</Label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={darkMap}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setDarkMap(v);
|
||||
try {
|
||||
localStorage.setItem('remoteterm-dark-map', String(v));
|
||||
} catch {
|
||||
// localStorage may be disabled
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Dark mode map tiles</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={reopenLastConversation}
|
||||
onChange={(e) => handleToggleReopenLastConversation(e.target.checked)}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Reopen to last viewed channel/conversation</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={darkMap}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setDarkMap(v);
|
||||
try {
|
||||
localStorage.setItem('remoteterm-dark-map', String(v));
|
||||
} catch {
|
||||
// localStorage may be disabled
|
||||
}
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Dark mode map tiles</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoFocusInput}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setAutoFocusInput(v);
|
||||
setAutoFocusInputEnabled(v);
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
|
||||
</label>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
<input
|
||||
type="range"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step={FONT_SCALE_SLIDER_STEP}
|
||||
value={fontScaleSlider}
|
||||
onChange={(event) => handleSliderChange(Number(event.target.value))}
|
||||
onMouseUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onTouchEnd={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onKeyUp={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
onBlur={(event) => handleSliderCommit(Number(event.currentTarget.value))}
|
||||
aria-label="Relative font size slider"
|
||||
className="w-full accent-primary sm:flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-2 sm:w-40">
|
||||
<Input
|
||||
id="font-scale-input"
|
||||
type="number"
|
||||
inputMode="decimal"
|
||||
min={MIN_FONT_SCALE}
|
||||
max={MAX_FONT_SCALE}
|
||||
step="any"
|
||||
value={fontScaleInput}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setFontScaleInput(nextValue);
|
||||
|
||||
if (nextValue === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.validity.valid && Number.isFinite(event.target.valueAsNumber)) {
|
||||
commitFontScale(event.target.valueAsNumber);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key !== 'Enter') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const parsed = Number.parseFloat(fontScaleInput);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
restoreFontScaleInput();
|
||||
return;
|
||||
}
|
||||
commitFontScale(parsed);
|
||||
}}
|
||||
aria-label="Relative font size percentage"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">%</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => commitFontScale(DEFAULT_FONT_SCALE)}
|
||||
className="inline-flex h-9 items-center justify-center rounded-md border border-input px-3 text-sm font-medium transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={fontScale === DEFAULT_FONT_SCALE}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Scales the app's typography for this browser only. The slider moves in 5% steps;
|
||||
the number field accepts any value from 25% to 400%.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -570,9 +570,9 @@ export function SettingsRadioSection({
|
||||
onChange={(e) => setPathHashMode(e.target.value)}
|
||||
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
|
||||
>
|
||||
<option value="0">1 byte (default)</option>
|
||||
<option value="1">2 bytes</option>
|
||||
<option value="2">3 bytes</option>
|
||||
<option value="0">1 byte — up to 63 hops (default)</option>
|
||||
<option value="1">2 bytes — up to 32 hops</option>
|
||||
<option value="2">3 bytes — up to 21 hops</option>
|
||||
</select>
|
||||
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
|
||||
<p className="font-semibold mb-1">Compatibility Warning</p>
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use client';
|
||||
|
||||
import * as React from 'react';
|
||||
import { Command as CommandPrimitive } from 'cmdk';
|
||||
import { Search } from 'lucide-react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Command.displayName = CommandPrimitive.displayName;
|
||||
|
||||
function CommandDialog({ children, ...props }: React.ComponentProps<typeof Dialog>) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent className="overflow-hidden p-0 shadow-lg" hideCloseButton>
|
||||
<DialogTitle className="sr-only">Command palette</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
Search for conversations, settings, and tools
|
||||
</DialogDescription>
|
||||
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
));
|
||||
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||
>((props, ref) => (
|
||||
<CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />
|
||||
));
|
||||
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Group
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-[0.625rem] [&_[cmdk-group-heading]]:uppercase [&_[cmdk-group-heading]]:tracking-wider [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn('-mx-1 h-px bg-border', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||
|
||||
function CommandShortcut({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) {
|
||||
return (
|
||||
<span
|
||||
className={cn('ml-auto text-xs tracking-widest text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
CommandShortcut.displayName = 'CommandShortcut';
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
@@ -3,16 +3,11 @@ import { api } from '../api';
|
||||
import { takePrefetchOrFetch } from '../prefetch';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import { initLastMessageTimes } from '../utils/conversationState';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import type { AppSettings, AppSettingsUpdate, Favorite } from '../types';
|
||||
import type { AppSettings, AppSettingsUpdate } from '../types';
|
||||
|
||||
export function useAppSettings() {
|
||||
const [appSettings, setAppSettings] = useState<AppSettings | null>(null);
|
||||
|
||||
// Stable empty array prevents a new reference every render when there are none.
|
||||
const emptyFavorites = useRef<Favorite[]>([]).current;
|
||||
const favorites: Favorite[] = appSettings?.favorites ?? emptyFavorites;
|
||||
|
||||
// One-time migration guard
|
||||
const hasMigratedRef = useRef(false);
|
||||
|
||||
@@ -85,32 +80,6 @@ export function useAppSettings() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleFavorite = useCallback(async (type: 'channel' | 'contact', id: string) => {
|
||||
setAppSettings((prev) => {
|
||||
if (!prev) return prev;
|
||||
const currentFavorites = prev.favorites ?? [];
|
||||
const wasFavorited = isFavorite(currentFavorites, type, id);
|
||||
const optimisticFavorites = wasFavorited
|
||||
? currentFavorites.filter((f) => !(f.type === type && f.id === id))
|
||||
: [...currentFavorites, { type, id }];
|
||||
return { ...prev, favorites: optimisticFavorites };
|
||||
});
|
||||
|
||||
try {
|
||||
const updatedSettings = await api.toggleFavorite(type, id);
|
||||
setAppSettings(updatedSettings);
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle favorite:', err);
|
||||
try {
|
||||
const settings = await api.getSettings();
|
||||
setAppSettings(settings);
|
||||
} catch {
|
||||
// If refetch also fails, leave optimistic state
|
||||
}
|
||||
toast.error('Failed to update favorite');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleToggleTrackedTelemetry = useCallback(async (publicKey: string) => {
|
||||
const key = publicKey.toLowerCase();
|
||||
setAppSettings((prev) => {
|
||||
@@ -151,7 +120,7 @@ export function useAppSettings() {
|
||||
hasMigratedRef.current = true;
|
||||
|
||||
const FAVORITES_KEY = 'remoteterm-favorites';
|
||||
let localFavorites: Favorite[] = [];
|
||||
let localFavorites: Array<{ type: 'channel' | 'contact'; id: string }> = [];
|
||||
try {
|
||||
const stored = localStorage.getItem(FAVORITES_KEY);
|
||||
if (stored) localFavorites = JSON.parse(stored);
|
||||
@@ -161,25 +130,26 @@ export function useAppSettings() {
|
||||
if (localFavorites.length === 0) return;
|
||||
|
||||
const migrate = async () => {
|
||||
try {
|
||||
for (const f of localFavorites) {
|
||||
let migrated = 0;
|
||||
for (const f of localFavorites) {
|
||||
try {
|
||||
await api.toggleFavorite(f.type, f.id);
|
||||
migrated++;
|
||||
} catch {
|
||||
// Entity may have been deleted; skip and continue
|
||||
}
|
||||
localStorage.removeItem(FAVORITES_KEY);
|
||||
await fetchAppSettings();
|
||||
} catch (err) {
|
||||
console.error('Failed to migrate legacy favorites:', err);
|
||||
}
|
||||
localStorage.removeItem(FAVORITES_KEY);
|
||||
// Reload so contacts/channels pick up the new favorite flags
|
||||
if (migrated > 0) window.location.reload();
|
||||
};
|
||||
migrate();
|
||||
}, [appSettings, fetchAppSettings]);
|
||||
}, [appSettings]);
|
||||
|
||||
return {
|
||||
appSettings,
|
||||
favorites,
|
||||
fetchAppSettings,
|
||||
handleSaveAppSettings,
|
||||
handleToggleFavorite,
|
||||
handleToggleBlockedKey,
|
||||
handleToggleBlockedName,
|
||||
handleToggleTrackedTelemetry,
|
||||
|
||||
@@ -4,7 +4,7 @@ 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';
|
||||
const NOTIFICATION_ICON_PATH = './favicon-256x256.png';
|
||||
|
||||
type NotificationPermissionState = NotificationPermission | 'unsupported';
|
||||
type ConversationNotificationMap = Record<string, boolean>;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
|
||||
import type { Favorite } from '../types';
|
||||
import type { Channel, Contact } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const APP_TITLE = 'RemoteTerm for MeshCore';
|
||||
const UNREAD_APP_TITLE = 'RemoteTerm';
|
||||
const BASE_FAVICON_PATH = '/favicon.svg';
|
||||
const BASE_FAVICON_PATH = './favicon.svg';
|
||||
const GREEN_BADGE_FILL = '#16a34a';
|
||||
const RED_BADGE_FILL = '#dc2626';
|
||||
const BADGE_CENTER = 750;
|
||||
@@ -25,12 +25,11 @@ function getUnreadDirectMessageCount(unreadCounts: Record<string, number>): numb
|
||||
|
||||
function getUnreadFavoriteChannelCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
channels: Channel[]
|
||||
): number {
|
||||
return favorites.reduce(
|
||||
(sum, favorite) =>
|
||||
sum +
|
||||
(favorite.type === 'channel' ? unreadCounts[getStateKey('channel', favorite.id)] || 0 : 0),
|
||||
return channels.reduce(
|
||||
(sum, channel) =>
|
||||
sum + (channel.favorite ? unreadCounts[getStateKey('channel', channel.key)] || 0 : 0),
|
||||
0
|
||||
);
|
||||
}
|
||||
@@ -41,19 +40,29 @@ export function getTotalUnreadCount(unreadCounts: Record<string, number>): numbe
|
||||
|
||||
export function getFavoriteUnreadCount(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
contacts: Contact[],
|
||||
channels: Channel[]
|
||||
): number {
|
||||
return favorites.reduce((sum, favorite) => {
|
||||
const stateKey = getStateKey(favorite.type, favorite.id);
|
||||
return sum + (unreadCounts[stateKey] || 0);
|
||||
}, 0);
|
||||
let sum = 0;
|
||||
for (const contact of contacts) {
|
||||
if (contact.favorite) {
|
||||
sum += unreadCounts[getStateKey('contact', contact.public_key)] || 0;
|
||||
}
|
||||
}
|
||||
for (const channel of channels) {
|
||||
if (channel.favorite) {
|
||||
sum += unreadCounts[getStateKey('channel', channel.key)] || 0;
|
||||
}
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
export function getUnreadTitle(
|
||||
unreadCounts: Record<string, number>,
|
||||
favorites: Favorite[]
|
||||
contacts: Contact[],
|
||||
channels: Channel[]
|
||||
): string {
|
||||
const unreadCount = getFavoriteUnreadCount(unreadCounts, favorites);
|
||||
const unreadCount = getFavoriteUnreadCount(unreadCounts, contacts, channels);
|
||||
if (unreadCount <= 0) {
|
||||
return APP_TITLE;
|
||||
}
|
||||
@@ -65,13 +74,13 @@ export function getUnreadTitle(
|
||||
export function deriveFaviconBadgeState(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
channels: Channel[]
|
||||
): FaviconBadgeState {
|
||||
if (Object.values(mentions).some(Boolean) || getUnreadDirectMessageCount(unreadCounts) > 0) {
|
||||
return 'red';
|
||||
}
|
||||
|
||||
if (getUnreadFavoriteChannelCount(unreadCounts, favorites) > 0) {
|
||||
if (getUnreadFavoriteChannelCount(unreadCounts, channels) > 0) {
|
||||
return 'green';
|
||||
}
|
||||
|
||||
@@ -128,8 +137,15 @@ function applyFaviconHref(href: string): void {
|
||||
upsertFaviconLinks('shortcut icon', href);
|
||||
}
|
||||
|
||||
export function useUnreadTitle(unreadCounts: Record<string, number>, favorites: Favorite[]): void {
|
||||
const title = useMemo(() => getUnreadTitle(unreadCounts, favorites), [favorites, unreadCounts]);
|
||||
export function useUnreadTitle(
|
||||
unreadCounts: Record<string, number>,
|
||||
contacts: Contact[],
|
||||
channels: Channel[]
|
||||
): void {
|
||||
const title = useMemo(
|
||||
() => getUnreadTitle(unreadCounts, contacts, channels),
|
||||
[contacts, channels, unreadCounts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = title;
|
||||
@@ -143,12 +159,12 @@ export function useUnreadTitle(unreadCounts: Record<string, number>, favorites:
|
||||
export function useFaviconBadge(
|
||||
unreadCounts: Record<string, number>,
|
||||
mentions: Record<string, boolean>,
|
||||
favorites: Favorite[]
|
||||
channels: Channel[]
|
||||
): void {
|
||||
const objectUrlRef = useRef<string | null>(null);
|
||||
const badgeState = useMemo(
|
||||
() => deriveFaviconBadgeState(unreadCounts, mentions, favorites),
|
||||
[favorites, mentions, unreadCounts]
|
||||
() => deriveFaviconBadgeState(unreadCounts, mentions, channels),
|
||||
[channels, mentions, unreadCounts]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -105,7 +105,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/contacts?limit=100&offset=0');
|
||||
expect(url).toBe('./api/contacts?limit=100&offset=0');
|
||||
});
|
||||
|
||||
it('builds repeater advert path endpoint query', async () => {
|
||||
@@ -118,7 +118,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.getRepeaterAdvertPaths(12);
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/contacts/repeaters/advert-paths?limit_per_repeater=12');
|
||||
expect(url).toBe('./api/contacts/repeaters/advert-paths?limit_per_repeater=12');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,7 +238,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.sendDirectMessage('abc123', 'hello');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/messages/direct');
|
||||
expect(url).toBe('./api/messages/direct');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({
|
||||
destination: 'abc123',
|
||||
@@ -256,7 +256,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.updateRadioConfig({ name: 'NewName' });
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/config');
|
||||
expect(url).toBe('./api/radio/config');
|
||||
expect(options.method).toBe('PATCH');
|
||||
expect(JSON.parse(options.body)).toEqual({ name: 'NewName' });
|
||||
});
|
||||
@@ -271,7 +271,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.setPrivateKey('my-secret-key');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/private-key');
|
||||
expect(url).toBe('./api/radio/private-key');
|
||||
expect(options.method).toBe('PUT');
|
||||
expect(JSON.parse(options.body)).toEqual({ private_key: 'my-secret-key' });
|
||||
});
|
||||
@@ -286,7 +286,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.discoverMesh('repeaters');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/discover');
|
||||
expect(url).toBe('./api/radio/discover');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(JSON.parse(options.body)).toEqual({ target: 'repeaters' });
|
||||
});
|
||||
@@ -301,7 +301,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.deleteContact('pubkey123');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/contacts/pubkey123');
|
||||
expect(url).toBe('./api/contacts/pubkey123');
|
||||
expect(options.method).toBe('DELETE');
|
||||
});
|
||||
|
||||
@@ -315,7 +315,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.sendAdvertisement();
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/advertise');
|
||||
expect(url).toBe('./api/radio/advertise');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toBe(JSON.stringify({ mode: 'flood' }));
|
||||
});
|
||||
@@ -330,7 +330,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.sendAdvertisement('zero_hop');
|
||||
|
||||
const [url, options] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/radio/advertise');
|
||||
expect(url).toBe('./api/radio/advertise');
|
||||
expect(options.method).toBe('POST');
|
||||
expect(options.body).toBe(JSON.stringify({ mode: 'zero_hop' }));
|
||||
});
|
||||
@@ -383,7 +383,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
});
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toContain('/api/messages?');
|
||||
expect(url).toContain('./api/messages?');
|
||||
expect(url).toContain('limit=50');
|
||||
expect(url).toContain('offset=10');
|
||||
expect(url).toContain('type=PRIV');
|
||||
@@ -402,7 +402,7 @@ describe('fetchJson (via api methods)', () => {
|
||||
await api.getMessages();
|
||||
|
||||
const [url] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/messages');
|
||||
expect(url).toBe('./api/messages');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -187,7 +187,6 @@ const baseConfig = {
|
||||
|
||||
const baseSettings = {
|
||||
max_radio_contacts: 200,
|
||||
favorites: [] as Array<{ type: 'channel' | 'contact'; id: string }>,
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
|
||||
@@ -204,6 +203,7 @@ const publicChannel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
describe('App favorite toggle flow', () => {
|
||||
@@ -216,8 +216,9 @@ describe('App favorite toggle flow', () => {
|
||||
mocks.api.getChannels.mockResolvedValue([publicChannel]);
|
||||
mocks.api.getContacts.mockResolvedValue([]);
|
||||
mocks.api.toggleFavorite.mockResolvedValue({
|
||||
...baseSettings,
|
||||
favorites: [{ type: 'channel', id: publicChannel.key }],
|
||||
type: 'channel',
|
||||
id: publicChannel.key,
|
||||
favorite: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -239,11 +240,8 @@ describe('App favorite toggle flow', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rolls back favorite state by refetching settings on toggle failure', async () => {
|
||||
it('rolls back favorite state on toggle failure', async () => {
|
||||
mocks.api.toggleFavorite.mockRejectedValue(new Error('toggle failed'));
|
||||
mocks.api.getSettings
|
||||
.mockResolvedValueOnce({ ...baseSettings }) // initial load
|
||||
.mockResolvedValueOnce({ ...baseSettings }); // rollback refetch
|
||||
|
||||
render(<App />);
|
||||
|
||||
@@ -257,10 +255,6 @@ describe('App favorite toggle flow', () => {
|
||||
expect(mocks.api.toggleFavorite).toHaveBeenCalledWith('channel', publicChannel.key);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.api.getSettings).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mocks.toast.error).toHaveBeenCalledWith('Failed to update favorite');
|
||||
});
|
||||
|
||||
@@ -215,7 +215,6 @@ describe('App search jump target handling', () => {
|
||||
});
|
||||
mocks.api.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
|
||||
@@ -230,6 +229,7 @@ describe('App search jump target handling', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
]);
|
||||
mocks.api.getContacts.mockResolvedValue([]);
|
||||
|
||||
@@ -145,6 +145,7 @@ const publicChannel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
describe('App startup hash resolution', () => {
|
||||
@@ -166,7 +167,6 @@ describe('App startup hash resolution', () => {
|
||||
});
|
||||
mocks.api.getSettings.mockResolvedValue({
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
|
||||
@@ -247,6 +247,7 @@ describe('App startup hash resolution', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
window.location.hash = '';
|
||||
@@ -278,6 +279,7 @@ describe('App startup hash resolution', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
window.location.hash = '';
|
||||
@@ -308,6 +310,7 @@ describe('App startup hash resolution', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
window.location.hash = '';
|
||||
@@ -345,6 +348,7 @@ describe('App startup hash resolution', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -17,6 +17,7 @@ describe('BulkAddChannelResultModal', () => {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
key: 'BB'.repeat(16),
|
||||
@@ -24,6 +25,7 @@ describe('BulkAddChannelResultModal', () => {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
],
|
||||
existing_count: 3,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChannelInfoPane } from '../components/ChannelInfoPane';
|
||||
import type { Channel, ChannelDetail, Favorite } from '../types';
|
||||
import type { Channel, ChannelDetail } from '../types';
|
||||
|
||||
// Mock the api module
|
||||
vi.mock('../api', () => ({
|
||||
@@ -15,7 +15,7 @@ import { api } from '../api';
|
||||
const mockGetChannelDetail = vi.mocked(api.getChannelDetail);
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
||||
}
|
||||
|
||||
function makeDetail(channel: Channel): ChannelDetail {
|
||||
@@ -41,7 +41,6 @@ const noop = () => {};
|
||||
|
||||
const baseProps = {
|
||||
onClose: noop,
|
||||
favorites: [] as Favorite[],
|
||||
onToggleFavorite: noop,
|
||||
};
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ChatHeader } from '../components/ChatHeader';
|
||||
import type { Channel, Contact, Conversation, Favorite, PathDiscoveryResponse } from '../types';
|
||||
import type { Channel, Contact, Conversation, PathDiscoveryResponse } from '../types';
|
||||
import { CONTACT_TYPE_ROOM } from '../types';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
function makeChannel(key: string, name: string, isHashtag: boolean): Channel {
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null };
|
||||
return { key, name, is_hashtag: isHashtag, on_radio: false, last_read_at: null, favorite: false };
|
||||
}
|
||||
|
||||
const noop = () => {};
|
||||
@@ -15,7 +15,6 @@ const noop = () => {};
|
||||
const baseProps = {
|
||||
contacts: [],
|
||||
config: null,
|
||||
favorites: [] as Favorite[],
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
@@ -186,6 +185,7 @@ describe('ChatHeader key visibility', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -237,6 +237,7 @@ describe('ChatHeader key visibility', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -286,6 +287,7 @@ describe('ChatHeader key visibility', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -47,6 +47,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: 1699990000,
|
||||
@@ -90,7 +91,6 @@ const baseProps = {
|
||||
onClose: () => {},
|
||||
contacts: [] as Contact[],
|
||||
config: null,
|
||||
favorites: [],
|
||||
onToggleFavorite: () => {},
|
||||
onSearchMessagesByKey: vi.fn(),
|
||||
onSearchMessagesByName: vi.fn(),
|
||||
|
||||
@@ -3,15 +3,7 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ConversationPane } from '../components/ConversationPane';
|
||||
import type {
|
||||
Channel,
|
||||
Contact,
|
||||
Conversation,
|
||||
Favorite,
|
||||
HealthStatus,
|
||||
Message,
|
||||
RadioConfig,
|
||||
} from '../types';
|
||||
import type { Channel, Contact, Conversation, HealthStatus, Message, RadioConfig } from '../types';
|
||||
import type { RawPacketStatsSessionState } from '../utils/rawPacketStats';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -97,6 +89,7 @@ const channel: Channel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const message: Message = {
|
||||
@@ -134,7 +127,6 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
favorites: [] as Favorite[],
|
||||
messages: [message],
|
||||
messagesLoading: false,
|
||||
loadingOlder: false,
|
||||
@@ -166,6 +158,8 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
|
||||
onToggleNotifications: vi.fn(),
|
||||
trackedTelemetryRepeaters: [],
|
||||
onToggleTrackedTelemetry: vi.fn(async () => {}),
|
||||
repeaterAutoLoginKey: null,
|
||||
onClearRepeaterAutoLogin: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -205,6 +199,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -278,6 +273,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -372,6 +368,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
@@ -408,6 +405,7 @@ describe('ConversationPane', () => {
|
||||
lon: null,
|
||||
last_seen: 1700000000,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
|
||||
@@ -278,6 +278,7 @@ function makeContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: true,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -40,6 +40,7 @@ describe('MapView', () => {
|
||||
lon: -74,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -74,6 +75,7 @@ describe('MapView', () => {
|
||||
lon: -73,
|
||||
last_seen: Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60 + 60,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -95,6 +95,7 @@ describe('MessageList channel sender rendering', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -9,7 +9,7 @@ import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
findLinkedChannelReferences,
|
||||
formatTime,
|
||||
isValidLinkedChannelName,
|
||||
HASHTAG_CHANNEL_NAME_PATTERN,
|
||||
parseSenderFromText,
|
||||
} from '../utils/messageParser';
|
||||
|
||||
@@ -103,16 +103,16 @@ describe('formatTime', () => {
|
||||
|
||||
describe('linked channel references', () => {
|
||||
it('accepts lowercase alphanumeric names with single dashes', () => {
|
||||
expect(isValidLinkedChannelName('ops')).toBe(true);
|
||||
expect(isValidLinkedChannelName('ops-1')).toBe(true);
|
||||
expect(isValidLinkedChannelName('1-2-3')).toBe(true);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops')).toBe(true);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops-1')).toBe(true);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('1-2-3')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects uppercase, leading or trailing dashes, and repeated dashes', () => {
|
||||
expect(isValidLinkedChannelName('Ops')).toBe(false);
|
||||
expect(isValidLinkedChannelName('-ops')).toBe(false);
|
||||
expect(isValidLinkedChannelName('ops-')).toBe(false);
|
||||
expect(isValidLinkedChannelName('ops--room')).toBe(false);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('Ops')).toBe(false);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('-ops')).toBe(false);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops-')).toBe(false);
|
||||
expect(HASHTAG_CHANNEL_NAME_PATTERN.test('ops--room')).toBe(false);
|
||||
});
|
||||
|
||||
it('finds standalone linked channel references in message text', () => {
|
||||
|
||||
@@ -62,6 +62,7 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -30,6 +30,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -23,6 +23,7 @@ const BOT_CHANNEL: Channel = {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const BOT_PACKET: RawPacket = {
|
||||
|
||||
@@ -14,6 +14,7 @@ const TEST_CHANNEL: Channel = {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const COLLIDING_TEST_CHANNEL: Channel = {
|
||||
@@ -87,6 +88,7 @@ function createContact(overrides: Partial<Contact> = {}): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { RepeaterDashboard } from '../components/RepeaterDashboard';
|
||||
import type { UseRepeaterDashboardResult } from '../hooks/useRepeaterDashboard';
|
||||
import type { Contact, Conversation, Favorite } from '../types';
|
||||
import type { Contact, Conversation } from '../types';
|
||||
|
||||
// Mock the hook — typed as mutable version of the return type
|
||||
const mockHook: {
|
||||
@@ -99,18 +99,16 @@ const contacts: Contact[] = [
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
},
|
||||
];
|
||||
|
||||
const favorites: Favorite[] = [];
|
||||
|
||||
const defaultProps = {
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
notificationsSupported: true,
|
||||
notificationsEnabled: false,
|
||||
notificationsPermission: 'granted' as const,
|
||||
@@ -337,6 +335,7 @@ describe('RepeaterDashboard', () => {
|
||||
lon: 115.87,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -397,6 +396,7 @@ describe('RepeaterDashboard', () => {
|
||||
lon: 115.87,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -40,6 +40,7 @@ const roomContact: Contact = {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -35,7 +35,14 @@ function createSearchResult(overrides: Partial<Message> = {}): Message {
|
||||
const defaultProps = {
|
||||
contacts: [],
|
||||
channels: [
|
||||
{ key: 'ABC123', name: 'Public', is_hashtag: true, on_radio: false, last_read_at: null },
|
||||
{
|
||||
key: 'ABC123',
|
||||
name: 'Public',
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
],
|
||||
onNavigateToMessage: vi.fn(),
|
||||
};
|
||||
@@ -239,6 +246,7 @@ describe('SearchView', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
first_seen: null,
|
||||
last_read_at: null,
|
||||
|
||||
@@ -25,7 +25,7 @@ describe('SettingsAboutSection', () => {
|
||||
);
|
||||
|
||||
const link = screen.getByRole('link', { name: /Open debug support snapshot/i });
|
||||
expect(link).toHaveAttribute('href', '/api/debug');
|
||||
expect(link).toHaveAttribute('href', './api/debug');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -59,7 +59,6 @@ const baseHealth: HealthStatus = {
|
||||
|
||||
const baseSettings: AppSettings = {
|
||||
max_radio_contacts: 200,
|
||||
favorites: [],
|
||||
auto_decrypt_dm_on_advert: false,
|
||||
last_message_times: {},
|
||||
|
||||
@@ -750,7 +749,7 @@ describe('SettingsModal', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Statistics/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchSpy).toHaveBeenCalledWith('/api/statistics', expect.any(Object));
|
||||
expect(fetchSpy).toHaveBeenCalledWith('./api/statistics', expect.any(Object));
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -2,13 +2,7 @@ import { fireEvent, render, screen, waitFor, within } from '@testing-library/rea
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { Sidebar } from '../components/Sidebar';
|
||||
import {
|
||||
CONTACT_TYPE_REPEATER,
|
||||
CONTACT_TYPE_ROOM,
|
||||
type Channel,
|
||||
type Contact,
|
||||
type Favorite,
|
||||
} from '../types';
|
||||
import { CONTACT_TYPE_REPEATER, CONTACT_TYPE_ROOM, type Channel, type Contact } from '../types';
|
||||
import { getStateKey, type ConversationTimes } from '../utils/conversationState';
|
||||
import { PUBLIC_CHANNEL_KEY } from '../utils/publicChannel';
|
||||
|
||||
@@ -19,6 +13,7 @@ function makeChannel(key: string, name: string): Channel {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -41,6 +36,7 @@ function makeContact(
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -51,7 +47,6 @@ function makeContact(
|
||||
function renderSidebar(overrides?: {
|
||||
unreadCounts?: Record<string, number>;
|
||||
mentions?: Record<string, boolean>;
|
||||
favorites?: Favorite[];
|
||||
lastMessageTimes?: ConversationTimes;
|
||||
channels?: Channel[];
|
||||
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
|
||||
@@ -59,7 +54,7 @@ function renderSidebar(overrides?: {
|
||||
const aliceName = 'Alice';
|
||||
const roomName = 'Ops Board';
|
||||
const publicChannel = makeChannel('AA'.repeat(16), 'Public');
|
||||
const flightChannel = makeChannel('BB'.repeat(16), '#flight');
|
||||
const flightChannel = { ...makeChannel('BB'.repeat(16), '#flight'), favorite: true };
|
||||
const opsChannel = makeChannel('CC'.repeat(16), '#ops');
|
||||
const alice = makeContact('11'.repeat(32), aliceName);
|
||||
const board = makeContact('33'.repeat(32), roomName, CONTACT_TYPE_ROOM);
|
||||
@@ -73,7 +68,6 @@ function renderSidebar(overrides?: {
|
||||
[getStateKey('contact', relay.public_key)]: 4,
|
||||
};
|
||||
|
||||
const favorites = overrides?.favorites ?? [{ type: 'channel', id: flightChannel.key }];
|
||||
const channels = overrides?.channels ?? [publicChannel, flightChannel, opsChannel];
|
||||
const onSelectConversation = vi.fn();
|
||||
|
||||
@@ -91,7 +85,6 @@ function renderSidebar(overrides?: {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={favorites}
|
||||
isConversationNotificationsEnabled={overrides?.isConversationNotificationsEnabled}
|
||||
/>
|
||||
);
|
||||
@@ -138,7 +131,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -195,11 +187,26 @@ describe('Sidebar section summaries', () => {
|
||||
});
|
||||
|
||||
it('turns favorite contact row badges red', () => {
|
||||
const { aliceName } = renderSidebar({
|
||||
favorites: [{ type: 'contact', id: '11'.repeat(32) }],
|
||||
});
|
||||
const alice = makeContact('11'.repeat(32), 'Alice', 1, { favorite: true });
|
||||
|
||||
const aliceRow = screen.getByText(aliceName).closest('div');
|
||||
render(
|
||||
<Sidebar
|
||||
contacts={[alice]}
|
||||
channels={[makeChannel(PUBLIC_CHANNEL_KEY, 'Public')]}
|
||||
activeConversation={null}
|
||||
onSelectConversation={vi.fn()}
|
||||
onNewMessage={vi.fn()}
|
||||
lastMessageTimes={{}}
|
||||
unreadCounts={{ [getStateKey('contact', alice.public_key)]: 3 }}
|
||||
mentions={{}}
|
||||
showCracker={false}
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
/>
|
||||
);
|
||||
|
||||
const aliceRow = screen.getByText('Alice').closest('div');
|
||||
if (!aliceRow) throw new Error('Missing Alice row');
|
||||
expect(within(aliceRow).getByText('3')).toHaveClass(
|
||||
'bg-badge-mention',
|
||||
@@ -297,7 +304,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -393,7 +399,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning: false,
|
||||
onToggleCracker: vi.fn(),
|
||||
onMarkAllRead: vi.fn(),
|
||||
favorites: [],
|
||||
};
|
||||
|
||||
const getChannelsOrder = () => screen.getAllByText(/^#/).map((node) => node.textContent);
|
||||
@@ -464,7 +469,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -498,7 +502,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -546,7 +549,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -578,7 +580,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -593,8 +594,8 @@ describe('Sidebar section summaries', () => {
|
||||
|
||||
it('sorts favorites independently and persists the favorites sort preference', () => {
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150, favorite: true });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy', 1, { favorite: true });
|
||||
|
||||
const props = {
|
||||
contacts: [zed, amy],
|
||||
@@ -611,10 +612,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning: false,
|
||||
onToggleCracker: vi.fn(),
|
||||
onMarkAllRead: vi.fn(),
|
||||
favorites: [
|
||||
{ type: 'contact', id: zed.public_key },
|
||||
{ type: 'contact', id: amy.public_key },
|
||||
] satisfies Favorite[],
|
||||
};
|
||||
|
||||
const getFavoritesOrder = () =>
|
||||
@@ -641,8 +638,8 @@ describe('Sidebar section summaries', () => {
|
||||
localStorage.setItem('remoteterm-sortOrder', 'alpha');
|
||||
|
||||
const publicChannel = makeChannel(PUBLIC_CHANNEL_KEY, 'Public');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150 });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy');
|
||||
const zed = makeContact('11'.repeat(32), 'Zed', 1, { last_advert: 150, favorite: true });
|
||||
const amy = makeContact('22'.repeat(32), 'Amy', 1, { favorite: true });
|
||||
|
||||
render(
|
||||
<Sidebar
|
||||
@@ -660,10 +657,6 @@ describe('Sidebar section summaries', () => {
|
||||
crackerRunning={false}
|
||||
onToggleCracker={vi.fn()}
|
||||
onMarkAllRead={vi.fn()}
|
||||
favorites={[
|
||||
{ type: 'contact', id: zed.public_key },
|
||||
{ type: 'contact', id: amy.public_key },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ function makeContact(
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -193,6 +193,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: true,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
key: '11111111111111111111111111111111',
|
||||
@@ -200,6 +201,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
{
|
||||
key: '22222222222222222222222222222222',
|
||||
@@ -207,6 +209,7 @@ describe('resolveChannelFromHashToken', () => {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -241,6 +244,7 @@ describe('resolveContactFromHashToken', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -258,6 +262,7 @@ describe('resolveContactFromHashToken', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -275,6 +280,7 @@ describe('resolveContactFromHashToken', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -89,7 +89,7 @@ describe('useBrowserNotifications', () => {
|
||||
);
|
||||
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',
|
||||
icon: './favicon-256x256.png',
|
||||
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
|
||||
});
|
||||
expect(mocks.toast.warning).toHaveBeenCalledWith('Notifications enabled with warning', {
|
||||
@@ -122,7 +122,7 @@ describe('useBrowserNotifications', () => {
|
||||
expect(window.Notification).toHaveBeenCalledTimes(2);
|
||||
expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', {
|
||||
body: 'hello room',
|
||||
icon: '/favicon-256x256.png',
|
||||
icon: './favicon-256x256.png',
|
||||
tag: 'meshcore-message-42',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,6 +51,7 @@ function makeContact(suffix: string): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -184,6 +185,7 @@ describe('useContactsAndChannels', () => {
|
||||
is_hashtag: true,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
},
|
||||
],
|
||||
existing_count: 1,
|
||||
|
||||
@@ -33,6 +33,7 @@ const publicChannel: Channel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const sentMessage: Message = {
|
||||
@@ -64,7 +65,7 @@ function createArgs(overrides: Partial<Parameters<typeof useConversationActions>
|
||||
setContacts: vi.fn(),
|
||||
setChannels: vi.fn(),
|
||||
observeMessage: vi.fn(() => ({ added: true, activeConversation: true })),
|
||||
messageInputRef: { current: { appendText: vi.fn() } },
|
||||
messageInputRef: { current: { appendText: vi.fn(), focus: vi.fn() } },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -208,6 +209,7 @@ describe('useConversationActions', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -10,6 +10,7 @@ const publicChannel: Channel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
function createArgs(overrides: Partial<Parameters<typeof useConversationNavigation>[0]> = {}) {
|
||||
|
||||
@@ -10,9 +10,34 @@ import {
|
||||
useFaviconBadge,
|
||||
useUnreadTitle,
|
||||
} from '../hooks/useFaviconBadge';
|
||||
import type { Favorite } from '../types';
|
||||
import type { Channel, Contact } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
function makeChannel(key: string, favorite = false): Channel {
|
||||
return { key, name: key, is_hashtag: false, on_radio: false, last_read_at: null, favorite };
|
||||
}
|
||||
|
||||
function makeContact(publicKey: string, favorite = false): Contact {
|
||||
return {
|
||||
public_key: publicKey,
|
||||
name: publicKey,
|
||||
type: 1,
|
||||
flags: 0,
|
||||
direct_path: null,
|
||||
direct_path_len: -1,
|
||||
direct_path_hash_mode: -1,
|
||||
last_advert: null,
|
||||
lat: null,
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
};
|
||||
}
|
||||
|
||||
function getIconHref(rel: 'icon' | 'shortcut icon'): string | null {
|
||||
return (
|
||||
document.head.querySelector<HTMLLinkElement>(`link[rel="${rel}"]`)?.getAttribute('href') ?? null
|
||||
@@ -71,16 +96,16 @@ describe('useFaviconBadge', () => {
|
||||
});
|
||||
|
||||
it('derives badge priority from unread counts, mentions, and favorites', () => {
|
||||
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
|
||||
const channels = [makeChannel('fav-chan', true)];
|
||||
|
||||
expect(deriveFaviconBadgeState({}, {}, favorites)).toBe('none');
|
||||
expect(deriveFaviconBadgeState({}, {}, channels)).toBe('none');
|
||||
expect(
|
||||
deriveFaviconBadgeState(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 3,
|
||||
},
|
||||
{},
|
||||
favorites
|
||||
channels
|
||||
)
|
||||
).toBe('green');
|
||||
expect(
|
||||
@@ -89,7 +114,7 @@ describe('useFaviconBadge', () => {
|
||||
[getStateKey('contact', 'abc')]: 12,
|
||||
},
|
||||
{},
|
||||
favorites
|
||||
channels
|
||||
)
|
||||
).toBe('red');
|
||||
expect(
|
||||
@@ -100,7 +125,7 @@ describe('useFaviconBadge', () => {
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: true,
|
||||
},
|
||||
favorites
|
||||
channels
|
||||
)
|
||||
).toBe('red');
|
||||
});
|
||||
@@ -116,7 +141,7 @@ describe('useFaviconBadge', () => {
|
||||
it('derives the unread count and page title', () => {
|
||||
expect(getTotalUnreadCount({})).toBe(0);
|
||||
expect(getTotalUnreadCount({ a: 2, b: 5 })).toBe(7);
|
||||
expect(getFavoriteUnreadCount({}, [])).toBe(0);
|
||||
expect(getFavoriteUnreadCount({}, [], [])).toBe(0);
|
||||
expect(
|
||||
getFavoriteUnreadCount(
|
||||
{
|
||||
@@ -124,20 +149,19 @@ describe('useFaviconBadge', () => {
|
||||
[getStateKey('contact', 'fav-contact')]: 3,
|
||||
[getStateKey('channel', 'other-chan')]: 9,
|
||||
},
|
||||
[
|
||||
{ type: 'channel', id: 'fav-chan' },
|
||||
{ type: 'contact', id: 'fav-contact' },
|
||||
]
|
||||
[makeContact('fav-contact', true)],
|
||||
[makeChannel('fav-chan', true)]
|
||||
)
|
||||
).toBe(10);
|
||||
expect(getUnreadTitle({}, [])).toBe('RemoteTerm for MeshCore');
|
||||
expect(getUnreadTitle({}, [], [])).toBe('RemoteTerm for MeshCore');
|
||||
expect(
|
||||
getUnreadTitle(
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 7,
|
||||
[getStateKey('channel', 'other-chan')]: 9,
|
||||
},
|
||||
[{ type: 'channel', id: 'fav-chan' }]
|
||||
[],
|
||||
[makeChannel('fav-chan', true)]
|
||||
)
|
||||
).toBe('(7) RemoteTerm');
|
||||
expect(
|
||||
@@ -145,35 +169,36 @@ describe('useFaviconBadge', () => {
|
||||
{
|
||||
[getStateKey('channel', 'fav-chan')]: 120,
|
||||
},
|
||||
[{ type: 'channel', id: 'fav-chan' }]
|
||||
[],
|
||||
[makeChannel('fav-chan', true)]
|
||||
)
|
||||
).toBe('(99+) RemoteTerm');
|
||||
});
|
||||
|
||||
it('switches between the base favicon and generated blob badges', async () => {
|
||||
const favorites: Favorite[] = [{ type: 'channel', id: 'fav-chan' }];
|
||||
const channels = [makeChannel('fav-chan', true)];
|
||||
const { rerender } = renderHook(
|
||||
({
|
||||
unreadCounts,
|
||||
mentions,
|
||||
currentFavorites,
|
||||
currentChannels,
|
||||
}: {
|
||||
unreadCounts: Record<string, number>;
|
||||
mentions: Record<string, boolean>;
|
||||
currentFavorites: Favorite[];
|
||||
}) => useFaviconBadge(unreadCounts, mentions, currentFavorites),
|
||||
currentChannels: Channel[];
|
||||
}) => useFaviconBadge(unreadCounts, mentions, currentChannels),
|
||||
{
|
||||
initialProps: {
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
currentChannels: channels,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('icon')).toBe('./favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('./favicon.svg');
|
||||
});
|
||||
|
||||
rerender({
|
||||
@@ -181,7 +206,7 @@ describe('useFaviconBadge', () => {
|
||||
[getStateKey('channel', 'fav-chan')]: 1,
|
||||
},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
currentChannels: channels,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -194,7 +219,7 @@ describe('useFaviconBadge', () => {
|
||||
[getStateKey('contact', 'dm-key')]: 12,
|
||||
},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
currentChannels: channels,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -205,12 +230,12 @@ describe('useFaviconBadge', () => {
|
||||
rerender({
|
||||
unreadCounts: {},
|
||||
mentions: {},
|
||||
currentFavorites: favorites,
|
||||
currentChannels: channels,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getIconHref('icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('/favicon.svg');
|
||||
expect(getIconHref('icon')).toBe('./favicon.svg');
|
||||
expect(getIconHref('shortcut icon')).toBe('./favicon.svg');
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
@@ -220,18 +245,22 @@ describe('useFaviconBadge', () => {
|
||||
});
|
||||
|
||||
it('writes unread counts into the page title', () => {
|
||||
const channels = [makeChannel('fav-chan', true)];
|
||||
const { rerender, unmount } = renderHook(
|
||||
({
|
||||
unreadCounts,
|
||||
favorites,
|
||||
contacts,
|
||||
currentChannels,
|
||||
}: {
|
||||
unreadCounts: Record<string, number>;
|
||||
favorites: Favorite[];
|
||||
}) => useUnreadTitle(unreadCounts, favorites),
|
||||
contacts: Contact[];
|
||||
currentChannels: Channel[];
|
||||
}) => useUnreadTitle(unreadCounts, contacts, currentChannels),
|
||||
{
|
||||
initialProps: {
|
||||
unreadCounts: {},
|
||||
favorites: [{ type: 'channel', id: 'fav-chan' }],
|
||||
contacts: [],
|
||||
currentChannels: channels,
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -243,7 +272,8 @@ describe('useFaviconBadge', () => {
|
||||
[getStateKey('channel', 'fav-chan')]: 4,
|
||||
[getStateKey('contact', 'dm-key')]: 2,
|
||||
},
|
||||
favorites: [{ type: 'channel', id: 'fav-chan' }],
|
||||
contacts: [],
|
||||
currentChannels: channels,
|
||||
});
|
||||
|
||||
expect(document.title).toBe('(4) RemoteTerm');
|
||||
|
||||
@@ -28,6 +28,7 @@ const publicChannel: Channel = {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
|
||||
const incomingDm: Message = {
|
||||
@@ -109,6 +110,7 @@ describe('useRealtimeAppState', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -151,6 +153,7 @@ describe('useRealtimeAppState', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
@@ -241,6 +244,7 @@ describe('useRealtimeAppState', () => {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: 1700000000,
|
||||
last_read_at: null,
|
||||
first_seen: 1700000000,
|
||||
|
||||
@@ -35,6 +35,7 @@ function makeChannel(key: string, name: string): Channel {
|
||||
is_hashtag: false,
|
||||
on_radio: false,
|
||||
last_read_at: null,
|
||||
favorite: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -52,6 +53,7 @@ function makeContact(pubkey: string): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -55,6 +55,7 @@ function createContact(publicKey: string, name: string, type = 1): Contact {
|
||||
lon: null,
|
||||
last_seen: null,
|
||||
on_radio: false,
|
||||
favorite: false,
|
||||
last_contacted: null,
|
||||
last_read_at: null,
|
||||
first_seen: null,
|
||||
|
||||
@@ -121,6 +121,7 @@ export interface Contact {
|
||||
lon: number | null;
|
||||
last_seen: number | null;
|
||||
on_radio: boolean;
|
||||
favorite: boolean;
|
||||
last_contacted: number | null;
|
||||
last_read_at: number | null;
|
||||
first_seen: number | null;
|
||||
@@ -203,6 +204,7 @@ export interface Channel {
|
||||
flood_scope_override?: string | null;
|
||||
path_hash_mode_override?: number | null;
|
||||
last_read_at: number | null;
|
||||
favorite: boolean;
|
||||
}
|
||||
|
||||
export interface ChannelMessageCounts {
|
||||
@@ -323,14 +325,8 @@ export interface RawPacket {
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface Favorite {
|
||||
type: 'channel' | 'contact';
|
||||
id: string; // channel key or contact public key
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
favorites: Favorite[];
|
||||
auto_decrypt_dm_on_advert: boolean;
|
||||
last_message_times: Record<string, number>;
|
||||
advert_interval: number;
|
||||
|
||||
@@ -54,7 +54,9 @@ export function useWebSocket(options: UseWebSocketOptions) {
|
||||
const connect = useCallback(() => {
|
||||
// Determine WebSocket URL based on current location
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${protocol}//${window.location.host}/api/ws`;
|
||||
// Resolve relative to the page so sub-path reverse proxies work
|
||||
const base = new URL('./api/ws', window.location.href);
|
||||
const wsUrl = `${protocol}//${base.host}${base.pathname}`;
|
||||
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const KEY = 'remoteterm-auto-focus-input';
|
||||
|
||||
export function getAutoFocusInputEnabled(): boolean {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
return raw === null || raw !== 'false';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function setAutoFocusInputEnabled(enabled: boolean): void {
|
||||
try {
|
||||
if (enabled) {
|
||||
localStorage.removeItem(KEY);
|
||||
} else {
|
||||
localStorage.setItem(KEY, 'false');
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when auto-focus should fire: the setting is enabled
|
||||
* AND the viewport is wide enough that focusing won't summon a
|
||||
* mobile keyboard (matches the md: Tailwind breakpoint).
|
||||
*/
|
||||
export function shouldAutoFocusInput(): boolean {
|
||||
return getAutoFocusInputEnabled() && window.innerWidth >= 768;
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* Favorites utilities.
|
||||
*
|
||||
* Favorites are stored server-side in the database.
|
||||
*/
|
||||
|
||||
import type { Favorite } from '../types';
|
||||
|
||||
/**
|
||||
* Check if a conversation is favorited (from provided favorites array)
|
||||
*/
|
||||
export function isFavorite(
|
||||
favorites: Favorite[],
|
||||
type: 'channel' | 'contact',
|
||||
id: string
|
||||
): boolean {
|
||||
return favorites.some((f) => f.type === type && f.id === id);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* Parse sender from channel message text.
|
||||
* Channel messages have format "sender: message".
|
||||
*/
|
||||
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
export const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|[\s.,;:])/g;
|
||||
|
||||
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
|
||||
@@ -26,10 +26,6 @@ export interface HashtagChannelReference {
|
||||
end: number;
|
||||
}
|
||||
|
||||
export function isValidLinkedChannelName(name: string): boolean {
|
||||
return HASHTAG_CHANNEL_NAME_PATTERN.test(name);
|
||||
}
|
||||
|
||||
export function findLinkedChannelReferences(text: string): HashtagChannelReference[] {
|
||||
const references: HashtagChannelReference[] = [];
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
@@ -27,7 +27,7 @@ const VISUALIZER_DEFAULTS: VisualizerSettings = {
|
||||
observationWindowSec: 15,
|
||||
letEmDrift: true,
|
||||
particleSpeedMultiplier: 2,
|
||||
pruneStaleNodes: false,
|
||||
pruneStaleNodes: true,
|
||||
pruneStaleMinutes: 5,
|
||||
autoOrbit: false,
|
||||
showControls: true,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
base: './',
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remoteterm-meshcore"
|
||||
version = "3.8.0"
|
||||
version = "3.9.0"
|
||||
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dump the REST OpenAPI spec and WebSocket event schemas to JSON files.
|
||||
|
||||
These artifacts are generated programmatically from the running codebase so
|
||||
they stay in sync with the actual API and WS contracts. They're intended for
|
||||
consumption by external integrations (e.g., Home Assistant) that need a stable
|
||||
reference without reading our source.
|
||||
|
||||
Usage:
|
||||
PYTHONPATH=. uv run python3 scripts/build/dump_api_specs.py [output_dir]
|
||||
|
||||
Output (default: references/ha/):
|
||||
openapi.json — Full OpenAPI 3.x spec for all REST endpoints
|
||||
ws_events.json — JSON Schema for each WebSocket event type
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def dump_openapi(output_dir: Path) -> None:
|
||||
from app.main import app
|
||||
|
||||
schema = app.openapi()
|
||||
out = output_dir / "openapi.json"
|
||||
out.write_text(json.dumps(schema, indent=2) + "\n")
|
||||
print(f" openapi.json: {len(schema['paths'])} paths, "
|
||||
f"{len(schema.get('components', {}).get('schemas', {}))} schemas")
|
||||
|
||||
|
||||
def dump_ws_events(output_dir: Path) -> None:
|
||||
from app.events import _PAYLOAD_ADAPTERS
|
||||
|
||||
events: dict = {}
|
||||
for event_type, adapter in _PAYLOAD_ADAPTERS.items():
|
||||
schema = adapter.json_schema()
|
||||
events[event_type] = {
|
||||
"description": _event_descriptions().get(event_type, ""),
|
||||
"payload_schema": schema,
|
||||
}
|
||||
|
||||
wrapper = {
|
||||
"$comment": (
|
||||
"Auto-generated from app/events.py. "
|
||||
"Each WebSocket message is a JSON object: {\"type\": \"<event_type>\", \"data\": <payload>}. "
|
||||
"The client also sends \"ping\" as plain text; the server replies {\"type\": \"pong\"}."
|
||||
),
|
||||
"events": events,
|
||||
}
|
||||
|
||||
out = output_dir / "ws_events.json"
|
||||
out.write_text(json.dumps(wrapper, indent=2) + "\n")
|
||||
print(f" ws_events.json: {len(events)} event types")
|
||||
|
||||
|
||||
def _event_descriptions() -> dict[str, str]:
|
||||
return {
|
||||
"health": "Radio connection status. Sent on WS connect and on every state change.",
|
||||
"message": "New or incoming message (DM or channel). Includes outgoing messages sent by this radio.",
|
||||
"contact": "Contact created or updated (from advertisements, radio sync, or API).",
|
||||
"contact_resolved": "A prefix-only placeholder contact was resolved to a full public key.",
|
||||
"channel": "Channel created or updated.",
|
||||
"contact_deleted": "A contact was removed from the database.",
|
||||
"channel_deleted": "A channel was removed from the database.",
|
||||
"raw_packet": "Every incoming RF packet (pre-decryption). Use observation_id as the dedup key, not id.",
|
||||
"message_acked": "An existing message received an ACK or echo/repeat update.",
|
||||
"error": "Toast-level error notification (e.g., radio setup failure, missing private key).",
|
||||
"success": "Toast-level success notification (e.g., historical decrypt complete).",
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
output_dir = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("references/ha")
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
print(f"Dumping API specs to {output_dir}/")
|
||||
dump_openapi(output_dir)
|
||||
dump_ws_events(output_dir)
|
||||
print("Done.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -211,6 +211,12 @@ EOF
|
||||
chmod 644 "$SNAKEOIL_CERT_HOST_PATH"
|
||||
}
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
echo -e "${RED}Warning: docker was not found in PATH. A compose file will still be generated, but you will need Docker installed to run it.${NC}"
|
||||
elif ! docker compose version &>/dev/null; then
|
||||
echo -e "${RED}Warning: docker compose is not available. A compose file will still be generated, but you will need the Docker Compose plugin to run it.${NC}"
|
||||
fi
|
||||
|
||||
echo -e "${BOLD}=== RemoteTerm for MeshCore — Docker Setup ===${NC}"
|
||||
echo
|
||||
echo -e " Repo directory : ${CYAN}${REPO_DIR}${NC}"
|
||||
@@ -218,16 +224,6 @@ echo -e " Example compose : ${CYAN}${EXAMPLE_FILE}${NC}"
|
||||
echo -e " Output compose : ${CYAN}${COMPOSE_FILE}${NC}"
|
||||
echo
|
||||
|
||||
if ! command -v docker &>/dev/null; then
|
||||
echo -e "${RED}Error: docker was not found in PATH.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker compose version &>/dev/null; then
|
||||
echo -e "${RED}Error: docker compose is required but was not available.${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -f "$COMPOSE_FILE" ]; then
|
||||
echo -e "${YELLOW}A local docker-compose.yml already exists.${NC}"
|
||||
read -r -p "Overwrite it? [y/N]: " OVERWRITE
|
||||
@@ -360,35 +356,40 @@ echo
|
||||
|
||||
if [[ "$ENABLE_BOTS" =~ ^[Yy]$ ]]; then
|
||||
echo -e "${GREEN}Bots enabled.${NC}"
|
||||
echo
|
||||
echo -e "${BOLD}─── HTTP Basic Auth ─────────────────────────────────────────────────${NC}"
|
||||
echo "With bots enabled, HTTP Basic Auth is strongly recommended if this"
|
||||
echo "service will be reachable beyond your local machine."
|
||||
echo
|
||||
read -r -p "Set up HTTP Basic Auth? [Y/n]: " ENABLE_AUTH
|
||||
ENABLE_AUTH="${ENABLE_AUTH:-Y}"
|
||||
echo
|
||||
|
||||
if [[ "$ENABLE_AUTH" =~ ^[Yy]$ ]]; then
|
||||
read -r -p "Username: " AUTH_USERNAME
|
||||
while [ -z "$AUTH_USERNAME" ]; do
|
||||
echo -e "${RED}Username cannot be empty.${NC}"
|
||||
read -r -p "Username: " AUTH_USERNAME
|
||||
done
|
||||
read -r -s -p "Password: " AUTH_PASSWORD
|
||||
echo
|
||||
while [ -z "$AUTH_PASSWORD" ]; do
|
||||
echo -e "${RED}Password cannot be empty.${NC}"
|
||||
read -r -s -p "Password: " AUTH_PASSWORD
|
||||
echo
|
||||
done
|
||||
echo -e "${GREEN}Basic Auth configured for user '${AUTH_USERNAME}'.${NC}"
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}Bots disabled.${NC}"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo -e "${BOLD}─── HTTP Basic Auth ─────────────────────────────────────────────────${NC}"
|
||||
if [[ "$ENABLE_BOTS" =~ ^[Yy]$ ]]; then
|
||||
echo "With bots enabled, HTTP Basic Auth is strongly recommended if this"
|
||||
echo "service will be reachable beyond your local machine."
|
||||
else
|
||||
echo "HTTP Basic Auth adds a coarse access gate to the service."
|
||||
fi
|
||||
echo
|
||||
read -r -p "Set up HTTP Basic Auth? [y/N]: " ENABLE_AUTH
|
||||
ENABLE_AUTH="${ENABLE_AUTH:-N}"
|
||||
echo
|
||||
|
||||
if [[ "$ENABLE_AUTH" =~ ^[Yy]$ ]]; then
|
||||
read -r -p "Username: " AUTH_USERNAME
|
||||
while [ -z "$AUTH_USERNAME" ]; do
|
||||
echo -e "${RED}Username cannot be empty.${NC}"
|
||||
read -r -p "Username: " AUTH_USERNAME
|
||||
done
|
||||
read -r -s -p "Password: " AUTH_PASSWORD
|
||||
echo
|
||||
while [ -z "$AUTH_PASSWORD" ]; do
|
||||
echo -e "${RED}Password cannot be empty.${NC}"
|
||||
read -r -s -p "Password: " AUTH_PASSWORD
|
||||
echo
|
||||
done
|
||||
echo -e "${GREEN}Basic Auth configured for user '${AUTH_USERNAME}'.${NC}"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo -e "${BOLD}─── HTTPS / Snakeoil TLS ────────────────────────────────────────────${NC}"
|
||||
echo "Generating a local self-signed certificate enables HTTPS-only browser features"
|
||||
echo "such as the channel key finder and, in some browsers, notifications."
|
||||
|
||||
@@ -285,6 +285,18 @@ fi
|
||||
|
||||
echo -e "${YELLOW}Writing systemd service file to ${SERVICE_FILE}...${NC}"
|
||||
|
||||
# Escape a value for use in a systemd Environment= directive.
|
||||
# Must handle: % (specifier expansion), " and \ (systemd.syntax unquoting),
|
||||
# and trailing backslash (line continuation). Wraps in double quotes so
|
||||
# spaces are preserved.
|
||||
systemd_escape_env_value() {
|
||||
local v="$1"
|
||||
v="${v//\\/\\\\}" # \ → \\ (must be first)
|
||||
v="${v//\"/\\\"}" # " → \"
|
||||
v="${v//%/%%}" # % → %%
|
||||
printf '"%s"' "$v"
|
||||
}
|
||||
|
||||
generate_service_file() {
|
||||
echo "[Unit]"
|
||||
echo "Description=RemoteTerm for MeshCore"
|
||||
@@ -301,14 +313,14 @@ generate_service_file() {
|
||||
|
||||
# Transport
|
||||
case "$TRANSPORT_CHOICE" in
|
||||
2) echo "Environment=MESHCORE_SERIAL_PORT=${SERIAL_PORT}" ;;
|
||||
2) echo "Environment=MESHCORE_SERIAL_PORT=$(systemd_escape_env_value "$SERIAL_PORT")" ;;
|
||||
3)
|
||||
echo "Environment=MESHCORE_TCP_HOST=${TCP_HOST}"
|
||||
echo "Environment=MESHCORE_TCP_PORT=${TCP_PORT}"
|
||||
echo "Environment=MESHCORE_TCP_HOST=$(systemd_escape_env_value "$TCP_HOST")"
|
||||
echo "Environment=MESHCORE_TCP_PORT=$(systemd_escape_env_value "$TCP_PORT")"
|
||||
;;
|
||||
4)
|
||||
echo "Environment=MESHCORE_BLE_ADDRESS=${BLE_ADDRESS}"
|
||||
echo "Environment=MESHCORE_BLE_PIN=${BLE_PIN}"
|
||||
echo "Environment=MESHCORE_BLE_ADDRESS=$(systemd_escape_env_value "$BLE_ADDRESS")"
|
||||
echo "Environment=MESHCORE_BLE_PIN=$(systemd_escape_env_value "$BLE_PIN")"
|
||||
;;
|
||||
esac
|
||||
|
||||
@@ -319,8 +331,8 @@ generate_service_file() {
|
||||
|
||||
# Basic auth
|
||||
if [[ "$ENABLE_BOTS" =~ ^[Yy] ]] && [[ "$ENABLE_AUTH" =~ ^[Yy] ]]; then
|
||||
echo "Environment=MESHCORE_BASIC_AUTH_USERNAME=${AUTH_USERNAME}"
|
||||
echo "Environment=MESHCORE_BASIC_AUTH_PASSWORD=${AUTH_PASSWORD}"
|
||||
echo "Environment=MESHCORE_BASIC_AUTH_USERNAME=$(systemd_escape_env_value "$AUTH_USERNAME")"
|
||||
echo "Environment=MESHCORE_BASIC_AUTH_PASSWORD=$(systemd_escape_env_value "$AUTH_PASSWORD")"
|
||||
fi
|
||||
|
||||
# Serial group access
|
||||
|
||||
@@ -77,6 +77,7 @@ export interface Channel {
|
||||
name: string;
|
||||
is_hashtag: boolean;
|
||||
on_radio: boolean;
|
||||
favorite: boolean;
|
||||
flood_scope_override?: string | null;
|
||||
}
|
||||
|
||||
@@ -216,11 +217,8 @@ export function markAllRead(): Promise<{ status: string; timestamp: number }> {
|
||||
|
||||
// --- Settings ---
|
||||
|
||||
export type Favorite = { type: string; id: string };
|
||||
|
||||
export interface AppSettings {
|
||||
max_radio_contacts: number;
|
||||
favorites: Favorite[];
|
||||
auto_decrypt_dm_on_advert: boolean;
|
||||
last_message_times: Record<string, number>;
|
||||
advert_interval: number;
|
||||
|
||||
@@ -1,24 +1,11 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
createChannel,
|
||||
deleteChannel,
|
||||
getSettings,
|
||||
updateSettings,
|
||||
type Favorite,
|
||||
} from '../helpers/api';
|
||||
import { createChannel, deleteChannel, getChannels } from '../helpers/api';
|
||||
|
||||
test.describe('Favorites persistence', () => {
|
||||
let originalFavorites: Favorite[] = [];
|
||||
let channelName = '';
|
||||
let channelKey = '';
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const settings = await getSettings();
|
||||
originalFavorites = settings.favorites ?? [];
|
||||
|
||||
// Start deterministic: no favorites
|
||||
await updateSettings({ favorites: [] });
|
||||
|
||||
channelName = `#e2efav${Date.now().toString().slice(-6)}`;
|
||||
const channel = await createChannel(channelName);
|
||||
channelKey = channel.key;
|
||||
@@ -30,11 +17,6 @@ test.describe('Favorites persistence', () => {
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
try {
|
||||
await updateSettings({ favorites: originalFavorites });
|
||||
} catch {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
});
|
||||
|
||||
test('add and remove favorite channel with persistence across reload', async ({ page }) => {
|
||||
@@ -51,8 +33,8 @@ test.describe('Favorites persistence', () => {
|
||||
await expect(page.getByText('Favorites')).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const settings = await getSettings();
|
||||
return settings.favorites.some((f) => f.type === 'channel' && f.id === channelKey);
|
||||
const channels = await getChannels();
|
||||
return channels.some((c) => c.key === channelKey && c.favorite);
|
||||
})
|
||||
.toBe(true);
|
||||
|
||||
@@ -66,8 +48,8 @@ test.describe('Favorites persistence', () => {
|
||||
await expect(page.getByTitle('Add to favorites')).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const settings = await getSettings();
|
||||
return settings.favorites.some((f) => f.type === 'channel' && f.id === channelKey);
|
||||
const channels = await getChannels();
|
||||
return channels.some((c) => c.key === channelKey && c.favorite);
|
||||
})
|
||||
.toBe(false);
|
||||
await expect(page.getByText('Favorites')).not.toBeVisible();
|
||||
|
||||
@@ -127,6 +127,34 @@ def test_webmanifest_uses_forwarded_origin_headers(tmp_path):
|
||||
assert data["id"] == "https://mesh.example.com:8443/"
|
||||
|
||||
|
||||
def test_webmanifest_includes_forwarded_prefix(tmp_path):
|
||||
app = FastAPI()
|
||||
dist_dir = tmp_path / "frontend" / "dist"
|
||||
dist_dir.mkdir(parents=True)
|
||||
(dist_dir / "index.html").write_text("<html><body>index page</body></html>")
|
||||
|
||||
registered = register_frontend_static_routes(app, dist_dir)
|
||||
assert registered is True
|
||||
|
||||
with TestClient(app) as client:
|
||||
response = client.get(
|
||||
"/site.webmanifest",
|
||||
headers={
|
||||
"x-forwarded-proto": "https",
|
||||
"x-forwarded-host": "homeassistant.local:8123",
|
||||
"x-forwarded-prefix": "/api/hassio_ingress/abc123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
expected_base = "https://homeassistant.local:8123/api/hassio_ingress/abc123/"
|
||||
assert data["start_url"] == expected_base
|
||||
assert data["scope"] == expected_base
|
||||
assert data["id"] == expected_base
|
||||
assert data["icons"][0]["src"] == f"{expected_base}web-app-manifest-192x192.png"
|
||||
|
||||
|
||||
def test_first_available_prefers_dist_over_prebuilt(tmp_path):
|
||||
app = FastAPI()
|
||||
frontend_dir = tmp_path / "frontend"
|
||||
|
||||
+16
-16
@@ -1224,8 +1224,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 16
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 17
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1296,8 +1296,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 16
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 17
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1363,8 +1363,8 @@ class TestMigration039:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 10
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 11
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1416,8 +1416,8 @@ class TestMigration040:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 15
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 16
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1478,8 +1478,8 @@ class TestMigration041:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 14
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 15
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1531,8 +1531,8 @@ class TestMigration042:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 13
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 14
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
@@ -1671,8 +1671,8 @@ class TestMigration046:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 10
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
@@ -1765,8 +1765,8 @@ class TestMigration047:
|
||||
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 8
|
||||
assert await get_version(conn) == 54
|
||||
assert applied == 9
|
||||
assert await get_version(conn) == 55
|
||||
|
||||
cursor = await conn.execute(
|
||||
"""
|
||||
|
||||
+16
-28
@@ -13,7 +13,6 @@ from meshcore import EventType
|
||||
from meshcore.events import Event
|
||||
|
||||
import app.radio_sync as radio_sync
|
||||
from app.models import Favorite
|
||||
from app.radio import RadioManager, radio_manager
|
||||
from app.radio_sync import (
|
||||
_message_poll_loop,
|
||||
@@ -363,12 +362,8 @@ class TestSyncRecentContactsToRadio:
|
||||
"""Favorite contacts not on radio are added via add_contact."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
|
||||
await AppSettingsRepository.update(
|
||||
favorites=[
|
||||
Favorite(type="contact", id=KEY_A),
|
||||
Favorite(type="contact", id=KEY_B),
|
||||
]
|
||||
)
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
await ContactRepository.set_favorite(KEY_B, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -390,9 +385,8 @@ class TestSyncRecentContactsToRadio:
|
||||
await _insert_contact("dd" * 32, "Dave", last_advert=3000)
|
||||
await _insert_contact("ee" * 32, "Eve", last_advert=2500)
|
||||
|
||||
await AppSettingsRepository.update(
|
||||
max_radio_contacts=5, favorites=[Favorite(type="contact", id=KEY_A)]
|
||||
)
|
||||
await AppSettingsRepository.update(max_radio_contacts=5)
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -416,10 +410,9 @@ class TestSyncRecentContactsToRadio:
|
||||
for index, key in enumerate(favorite_keys):
|
||||
await _insert_contact(key, f"Favorite{index}", last_contacted=2000 - index)
|
||||
|
||||
await AppSettingsRepository.update(
|
||||
max_radio_contacts=4,
|
||||
favorites=[Favorite(type="contact", id=key) for key in favorite_keys],
|
||||
)
|
||||
await AppSettingsRepository.update(max_radio_contacts=4)
|
||||
for key in favorite_keys:
|
||||
await ContactRepository.set_favorite(key, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -498,7 +491,7 @@ class TestSyncAndOffloadAll:
|
||||
await _insert_contact(KEY_A, "Alice", last_advert=3000, contact_type=2)
|
||||
await _insert_contact(KEY_B, "Bob", last_advert=2000, contact_type=1)
|
||||
|
||||
await AppSettingsRepository.update(max_radio_contacts=1, favorites=[])
|
||||
await AppSettingsRepository.update(max_radio_contacts=1)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -519,13 +512,8 @@ class TestSyncAndOffloadAll:
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
|
||||
|
||||
await AppSettingsRepository.update(
|
||||
max_radio_contacts=2,
|
||||
favorites=[
|
||||
Favorite(type="contact", id=KEY_A),
|
||||
Favorite(type="contact", id=KEY_A),
|
||||
],
|
||||
)
|
||||
await AppSettingsRepository.update(max_radio_contacts=2)
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -546,7 +534,7 @@ class TestSyncAndOffloadAll:
|
||||
async def test_skips_contacts_already_on_radio(self, test_db):
|
||||
"""Contacts already on radio are counted but not re-added."""
|
||||
await _insert_contact(KEY_A, "Alice", on_radio=False)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
|
||||
@@ -606,7 +594,7 @@ class TestSyncAndOffloadAll:
|
||||
async def test_handles_add_failure(self, test_db):
|
||||
"""Failed add_contact increments the failed counter."""
|
||||
await _insert_contact(KEY_A, "Alice")
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -632,7 +620,7 @@ class TestSyncAndOffloadAll:
|
||||
direct_path_len=2,
|
||||
direct_path_hash_mode=1,
|
||||
)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -661,7 +649,7 @@ class TestSyncAndOffloadAll:
|
||||
direct_path_len=-125,
|
||||
direct_path_hash_mode=2,
|
||||
)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -686,7 +674,7 @@ class TestSyncAndOffloadAll:
|
||||
so it passes mc directly to avoid deadlock (asyncio.Lock is not reentrant).
|
||||
"""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -726,7 +714,7 @@ class TestSyncAndOffloadAll:
|
||||
"""If _meshcore is swapped between pre-check and lock acquisition,
|
||||
the function uses the new (post-lock) instance, not the stale one."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
await ContactRepository.set_favorite(KEY_A, True)
|
||||
|
||||
old_mc = MagicMock(name="old_mc")
|
||||
new_mc = MagicMock(name="new_mc")
|
||||
|
||||
@@ -620,7 +620,6 @@ class TestAppSettingsRepository:
|
||||
mock_cursor.fetchone = AsyncMock(
|
||||
return_value={
|
||||
"max_radio_contacts": 250,
|
||||
"favorites": "{not-json",
|
||||
"auto_decrypt_dm_on_advert": 1,
|
||||
"last_message_times": "{also-not-json",
|
||||
"advert_interval": None,
|
||||
@@ -641,36 +640,10 @@ class TestAppSettingsRepository:
|
||||
settings = await AppSettingsRepository.get()
|
||||
|
||||
assert settings.max_radio_contacts == 250
|
||||
assert settings.favorites == []
|
||||
assert settings.last_message_times == {}
|
||||
assert settings.advert_interval == 0
|
||||
assert settings.last_advert_time == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_favorite_is_idempotent(self):
|
||||
"""Adding an existing favorite does not write duplicate entries."""
|
||||
from app.models import AppSettings, Favorite
|
||||
|
||||
existing = AppSettings(favorites=[Favorite(type="contact", id="aa" * 32)])
|
||||
|
||||
with (
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=existing,
|
||||
),
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.update",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_update,
|
||||
):
|
||||
from app.repository import AppSettingsRepository
|
||||
|
||||
result = await AppSettingsRepository.add_favorite("contact", "aa" * 32)
|
||||
|
||||
assert result == existing
|
||||
mock_update.assert_not_awaited()
|
||||
|
||||
|
||||
class TestMessageRepositoryGetById:
|
||||
"""Test MessageRepository.get_by_id method."""
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
"""Tests for the installer scripts' environment-value escaping.
|
||||
|
||||
Covers both:
|
||||
- systemd_escape_env_value() in install_service.sh (systemd unit files)
|
||||
- yaml_quote() in install_docker.sh (docker-compose YAML)
|
||||
|
||||
Each function is called via bash subprocess, then round-tripped through a
|
||||
Python re-implementation of the target format's unquoting rules.
|
||||
|
||||
Dangerous characters by format:
|
||||
systemd: % (specifier expansion), " and \\ (unquoting), spaces (field split)
|
||||
YAML single-quoted: ' (only special char; doubled to escape)
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
SERVICE_SCRIPT = "scripts/setup/install_service.sh"
|
||||
DOCKER_SCRIPT = "scripts/setup/install_docker.sh"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Brutal test strings — shared across both formats
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
BRUTAL_STRINGS = [
|
||||
# Basic
|
||||
("simple", "hello"),
|
||||
("with_space", "hello world"),
|
||||
("with_spaces", " hello world "),
|
||||
# Dollar signs (the original bug report, issue #159)
|
||||
("dollar_mid", "p@ss$word"),
|
||||
("dollar_end", "password$"),
|
||||
("double_dollar", "pa$$word"),
|
||||
("dollar_brace", "pa${HOME}ss"),
|
||||
("dollar_paren", "pa$(whoami)ss"),
|
||||
# Percent specifiers (systemd expansion)
|
||||
("percent_n", "pass%nword"),
|
||||
("percent_u", "pass%uword"),
|
||||
("percent_H", "pass%Hword"),
|
||||
("double_percent", "pass%%word"),
|
||||
("percent_at_end", "password%"),
|
||||
# Backslashes
|
||||
("single_backslash", r"pass\word"),
|
||||
("double_backslash", "pass\\\\word"),
|
||||
("trailing_backslash", "password\\"),
|
||||
("backslash_n", "pass\\nword"),
|
||||
# Quotes
|
||||
("double_quote", 'pass"word'),
|
||||
("single_quote", "pass'word"),
|
||||
("mixed_quotes", """pass"wo'rd"""),
|
||||
("all_quotes", """he said "it's done" """),
|
||||
# Combined chaos
|
||||
("kitchen_sink", r"""p@ss$w%ord"with\special'chars"""),
|
||||
("systemd_nightmare", r"%n$HOME\"%u"),
|
||||
# Unicode and emoji
|
||||
("emoji", "p@ss\U0001f512word"),
|
||||
("unicode_accents", "p\u00e4ssw\u00f6rd"),
|
||||
("cjk", "\u5bc6\u7801"),
|
||||
("emoji_pile", "\U0001f680\U0001f525\U0001f4a5"),
|
||||
# Edge cases
|
||||
("empty", ""),
|
||||
("only_spaces", " "),
|
||||
("only_percent", "%"),
|
||||
("only_backslash", "\\"),
|
||||
("only_double_quote", '"'),
|
||||
("only_single_quote", "'"),
|
||||
("tab_embedded", "pass\tword"),
|
||||
("very_long", "A" * 1000),
|
||||
("glob_chars", "p*ss?[w]ord"),
|
||||
("shell_pipe", "pass|word&bg"),
|
||||
("semicolon", "pass;word"),
|
||||
("backtick", "pass`whoami`word"),
|
||||
("exclamation", "pass!word"),
|
||||
("hash", "pass#word"),
|
||||
("tilde", "~pass"),
|
||||
("equals", "pass=word"),
|
||||
("colon", "user:pass"),
|
||||
# Device paths (serial ports, by-id paths with colons)
|
||||
("serial_simple", "/dev/ttyUSB0"),
|
||||
("serial_acm", "/dev/ttyACM0"),
|
||||
("serial_by_id", "/dev/serial/by-id/usb-Heltec_HT-n5262_F423934AA2AB2A5E-if00"),
|
||||
("serial_colon_in_id", "/dev/serial/by-id/usb-vendor:product-0:0"),
|
||||
("tcp_host", "192.168.1.100"),
|
||||
("ble_address", "AA:BB:CC:DD:EE:FF"),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# systemd helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bash_systemd_escape(value: str) -> str:
|
||||
"""Call systemd_escape_env_value() via bash."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
r"""
|
||||
systemd_escape_env_value() {
|
||||
local v="$1"
|
||||
v="${v//\\/\\\\}"
|
||||
v="${v//\"/\\\"}"
|
||||
v="${v//%/%%}"
|
||||
printf '"%s"' "$v"
|
||||
}
|
||||
systemd_escape_env_value "$1"
|
||||
""",
|
||||
"--",
|
||||
value,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _systemd_unquote(raw: str) -> str:
|
||||
"""Re-implement systemd.syntax(7) double-quote unquoting."""
|
||||
raw = raw.strip()
|
||||
if not raw.startswith('"') or not raw.endswith('"') or len(raw) < 2:
|
||||
return raw
|
||||
inner = raw[1:-1]
|
||||
out: list[str] = []
|
||||
i = 0
|
||||
while i < len(inner):
|
||||
if inner[i] == "\\" and i + 1 < len(inner) and inner[i + 1] in ('"', "\\"):
|
||||
out.append(inner[i + 1])
|
||||
i += 2
|
||||
else:
|
||||
out.append(inner[i])
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def _systemd_expand_specifiers(value: str) -> str:
|
||||
"""Expand %% → % and detect leaked single-% specifiers."""
|
||||
stripped = value.replace("%%", "")
|
||||
if re.search(r"%[a-zA-Z]", stripped):
|
||||
return "SPECIFIER_LEAKED"
|
||||
return value.replace("%%", "%")
|
||||
|
||||
|
||||
def _systemd_round_trip(value: str) -> str:
|
||||
return _systemd_expand_specifiers(_systemd_unquote(_bash_systemd_escape(value)))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# YAML helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _bash_yaml_quote(value: str) -> str:
|
||||
"""Call yaml_quote() via bash."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"bash",
|
||||
"-c",
|
||||
r"""
|
||||
yaml_quote() {
|
||||
local value="$1"
|
||||
value=${value//\'/\'\'}
|
||||
printf "'%s'" "$value"
|
||||
}
|
||||
yaml_quote "$1"
|
||||
""",
|
||||
"--",
|
||||
value,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def _yaml_unquote_single(raw: str) -> str:
|
||||
"""YAML single-quoted scalar unquoting: '' → ' inside single quotes."""
|
||||
raw = raw.strip()
|
||||
if not raw.startswith("'") or not raw.endswith("'") or len(raw) < 2:
|
||||
return raw
|
||||
return raw[1:-1].replace("''", "'")
|
||||
|
||||
|
||||
def _yaml_round_trip(value: str) -> str:
|
||||
return _yaml_unquote_single(_bash_yaml_quote(value))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSystemdEscape:
|
||||
"""All brutal strings survive systemd escape → unquote → specifier round trip."""
|
||||
|
||||
def test_all_strings_round_trip(self):
|
||||
failures = []
|
||||
for name, value in BRUTAL_STRINGS:
|
||||
recovered = _systemd_round_trip(value)
|
||||
if recovered != value:
|
||||
escaped = _bash_systemd_escape(value)
|
||||
failures.append(
|
||||
f" {name}: input={value!r} recovered={recovered!r} escaped={escaped!r}"
|
||||
)
|
||||
assert not failures, "Systemd round-trip failures:\n" + "\n".join(failures)
|
||||
|
||||
def test_no_specifier_leaks(self):
|
||||
failures = []
|
||||
for name, value in BRUTAL_STRINGS:
|
||||
escaped = _bash_systemd_escape(value)
|
||||
unquoted = _systemd_unquote(escaped)
|
||||
stripped = unquoted.replace("%%", "")
|
||||
leaked = re.findall(r"%[a-zA-Z]", stripped)
|
||||
if leaked:
|
||||
failures.append(f" {name}: {leaked} in unquoted={unquoted!r}")
|
||||
assert not failures, "Specifier leaks:\n" + "\n".join(failures)
|
||||
|
||||
def test_output_always_double_quoted(self):
|
||||
failures = []
|
||||
for name, value in BRUTAL_STRINGS:
|
||||
escaped = _bash_systemd_escape(value)
|
||||
if not (escaped.startswith('"') and escaped.endswith('"')):
|
||||
failures.append(f" {name}: {escaped!r}")
|
||||
assert not failures, "Not double-quoted:\n" + "\n".join(failures)
|
||||
|
||||
def test_function_present_in_installer(self):
|
||||
with open(SERVICE_SCRIPT) as f:
|
||||
content = f.read()
|
||||
assert "systemd_escape_env_value()" in content
|
||||
assert 'systemd_escape_env_value "$AUTH_USERNAME"' in content
|
||||
assert 'systemd_escape_env_value "$AUTH_PASSWORD"' in content
|
||||
|
||||
|
||||
class TestYamlQuote:
|
||||
"""All brutal strings survive YAML single-quote escape → unquote round trip."""
|
||||
|
||||
def test_all_strings_round_trip(self):
|
||||
failures = []
|
||||
for name, value in BRUTAL_STRINGS:
|
||||
recovered = _yaml_round_trip(value)
|
||||
if recovered != value:
|
||||
escaped = _bash_yaml_quote(value)
|
||||
failures.append(
|
||||
f" {name}: input={value!r} recovered={recovered!r} escaped={escaped!r}"
|
||||
)
|
||||
assert not failures, "YAML round-trip failures:\n" + "\n".join(failures)
|
||||
|
||||
def test_output_always_single_quoted(self):
|
||||
failures = []
|
||||
for name, value in BRUTAL_STRINGS:
|
||||
escaped = _bash_yaml_quote(value)
|
||||
if not (escaped.startswith("'") and escaped.endswith("'")):
|
||||
failures.append(f" {name}: {escaped!r}")
|
||||
assert not failures, "Not single-quoted:\n" + "\n".join(failures)
|
||||
|
||||
def test_function_present_in_installer(self):
|
||||
with open(DOCKER_SCRIPT) as f:
|
||||
content = f.read()
|
||||
assert "yaml_quote()" in content
|
||||
assert 'yaml_quote "$AUTH_USERNAME"' in content
|
||||
assert 'yaml_quote "$AUTH_PASSWORD"' in content
|
||||
@@ -133,6 +133,7 @@ class TestUpdateSettings:
|
||||
class TestToggleFavorite:
|
||||
@pytest.mark.asyncio
|
||||
async def test_adds_when_not_favorited(self, test_db):
|
||||
await ContactRepository.upsert(ContactUpsert(public_key="aa" * 32, name="Alice"))
|
||||
request = FavoriteRequest(type="contact", id="aa" * 32)
|
||||
with (
|
||||
patch("app.radio_sync.ensure_contact_on_radio", new_callable=AsyncMock) as mock_sync,
|
||||
@@ -141,16 +142,16 @@ class TestToggleFavorite:
|
||||
mock_create_task.side_effect = lambda coro: coro.close()
|
||||
result = await toggle_favorite(request)
|
||||
|
||||
assert len(result.favorites) == 1
|
||||
assert result.favorites[0].type == "contact"
|
||||
assert result.favorites[0].id == "aa" * 32
|
||||
assert result.favorite is True
|
||||
assert result.type == "contact"
|
||||
assert result.id == "aa" * 32
|
||||
mock_sync.assert_called_once_with("aa" * 32, force=True)
|
||||
mock_create_task.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_when_already_favorited(self, test_db):
|
||||
# Pre-add a favorite
|
||||
await AppSettingsRepository.add_favorite("contact", "aa" * 32)
|
||||
await ContactRepository.upsert(ContactUpsert(public_key="aa" * 32, name="Alice"))
|
||||
await ContactRepository.set_favorite("aa" * 32, True)
|
||||
|
||||
request = FavoriteRequest(type="contact", id="aa" * 32)
|
||||
with (
|
||||
@@ -160,7 +161,7 @@ class TestToggleFavorite:
|
||||
mock_create_task.side_effect = lambda coro: coro.close()
|
||||
result = await toggle_favorite(request)
|
||||
|
||||
assert result.favorites == []
|
||||
assert result.favorite is False
|
||||
mock_sync.assert_not_called()
|
||||
mock_create_task.assert_not_called()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user