19 Commits

Author SHA1 Message Date
Jack Kingsman
e17d1ba4b4 Move search bar to top level 2026-03-10 19:56:10 -07:00
Jack Kingsman
48a49ce48d Add some new themes 2026-03-10 19:54:06 -07:00
Jack Kingsman
9d1676818f Add lagoon pop 2026-03-10 19:51:10 -07:00
Jack Kingsman
b5edd00220 Tweak light mode tools color and icon state 2026-03-10 19:36:04 -07:00
Jack Kingsman
d3a7b7ce07 Add light mode toggle 2026-03-10 19:32:22 -07:00
Jack Kingsman
42ca242ee1 Update override badge for region routing 2026-03-10 19:26:31 -07:00
Jack Kingsman
3e7e0669c5 Add bell icon and use better notif icon 2026-03-10 19:04:52 -07:00
Jack Kingsman
bee273ab56 Add notifications 2026-03-10 19:03:52 -07:00
Jack Kingsman
1842bcf43e Add new icon size + crush PNGs 2026-03-10 19:03:34 -07:00
Jack Kingsman
7c68973e30 Icon overhaul 2026-03-10 17:43:15 -07:00
Jack Kingsman
c9ede1f71f Clearer about advertiser repeat button 2026-03-10 15:49:28 -07:00
Jack Kingsman
42e9628d98 Fix clock sync command 2026-03-10 15:46:34 -07:00
Jack Kingsman
1bf760121d Preserve repeater values when browsing away 2026-03-10 15:40:26 -07:00
Jack Kingsman
bb4a601788 Coerce uvicorn logging to better format 2026-03-10 14:58:44 -07:00
Jack Kingsman
d0ed3484ce Add hourly sync and crow loudly if it finds a discrepancy 2026-03-10 14:47:18 -07:00
Jack Kingsman
738e0b9815 Don't load full right away 2026-03-10 14:39:40 -07:00
Jack Kingsman
97997e23e8 Drop frequency of contact sync task, make standard polling opt-in only 2026-03-10 14:04:51 -07:00
Jack Kingsman
eaee66f836 Add timestamps to logs and stop regen'ing licenses every time 2026-03-10 13:08:26 -07:00
Jack Kingsman
9a99d3f17e Codex Refactor -- Make things more manageable and LLM friendly 2026-03-10 12:26:30 -07:00
58 changed files with 2440 additions and 354 deletions

View File

@@ -84,6 +84,8 @@ Ancillary AGENTS.md files which should generally not be reviewed unless specific
**Background tasks** (WebSocket broadcasts, periodic sync, contact auto-loading, etc.) use fire-and-forget `asyncio.create_task`. Exceptions in these tasks are logged to the backend logs, which is sufficient for debugging. There is no need to track task references or add done-callbacks purely for error visibility. If there's a convenient way to bubble an error to the frontend (e.g., via `broadcast_error` for user-actionable problems), do so, but this is minor and best-effort.
Radio startup/setup is one place where that frontend bubbling is intentional: if post-connect setup hangs past its timeout, the backend both logs the failure and pushes a toast instructing the operator to reboot the radio and restart the server.
## Key Design Principles
1. **Store-and-serve**: Backend stores all packets even when no client is connected
@@ -440,8 +442,9 @@ mc.subscribe(EventType.ACK, handler)
| `MESHCORE_LOG_LEVEL` | `INFO` | Logging level (`DEBUG`/`INFO`/`WARNING`/`ERROR`) |
| `MESHCORE_DATABASE_PATH` | `data/meshcore.db` | SQLite database location |
| `MESHCORE_DISABLE_BOTS` | `false` | Disable bot system entirely (blocks execution and config) |
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | `false` | Switch the always-on message audit task from hourly checks to aggressive 10-second `get_msg()` fallback polling |
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
**Note:** Runtime app settings are stored in the database (`app_settings` table), not environment variables. These include `max_radio_contacts`, `auto_decrypt_dm_on_advert`, `sidebar_sort_order`, `advert_interval`, `last_advert_time`, `favorites`, `last_message_times`, `flood_scope`, `blocked_keys`, and `blocked_names`. `max_radio_contacts` is the configured radio contact capacity baseline used by background maintenance: favorites reload first, non-favorite fill targets about 80% of that value, and full offload/reload triggers around 95% occupancy. They are configured via `GET/PATCH /api/settings`. MQTT, bot, webhook, and Apprise configs are stored in the `fanout_configs` table, managed via `/api/fanout`.
Byte-perfect channel retries are user-triggered via `POST /api/messages/channel/{message_id}/resend` and are allowed for 30 seconds after the original send.

View File

@@ -224,9 +224,12 @@ npm run build # build the frontend
| `MESHCORE_LOG_LEVEL` | INFO | DEBUG, INFO, WARNING, ERROR |
| `MESHCORE_DATABASE_PATH` | data/meshcore.db | SQLite database path |
| `MESHCORE_DISABLE_BOTS` | false | Disable bot system entirely (blocks execution and config) |
| `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK` | false | Run aggressive 10-second `get_msg()` fallback polling instead of the default hourly audit task |
Only one transport may be active at a time. If multiple are set, the server will refuse to start.
By default the app relies on radio events plus MeshCore auto-fetch for incoming messages, and also runs a low-frequency hourly audit poll. If that audit ever finds radio data that was not surfaced through event subscription, the backend logs an error and the UI shows a toast telling the operator to check the logs. If you see that warning, or if messages on the radio never show up in the app, try `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` to switch that task into a more aggressive 10-second `get_msg()` safety net.
## Additional Setup
<details>

View File

@@ -87,7 +87,8 @@ app/
- `RadioManager.post_connect_setup()` delegates to `services/radio_lifecycle.py`.
- Routers, startup/lifespan code, fanout helpers, and `radio_sync.py` should reach radio state through `services/radio_runtime.py`, not by importing `app.radio.radio_manager` directly.
- Shared reconnect/setup helpers in `services/radio_lifecycle.py` are used by startup, the monitor, and manual reconnect/reboot flows before broadcasting healthy state.
- Setup still includes handler registration, key export, time sync, contact/channel sync, polling/advert tasks.
- Setup still includes handler registration, key export, time sync, contact/channel sync, and advertisement tasks. The message-poll task always starts: by default it runs as a low-frequency hourly audit, and `MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true` switches it to aggressive 10-second polling.
- Post-connect setup is timeout-bounded. If initial radio offload/setup hangs too long, the backend logs the failure and broadcasts an `error` toast telling the operator to reboot the radio and restart the server.
## Important Behaviors
@@ -230,7 +231,7 @@ app/
- `contact_deleted` — contact removed from database (payload: `{ public_key }`)
- `channel` — single channel upsert/update (payload: full `Channel`)
- `channel_deleted` — channel removed from database (payload: `{ key }`)
- `error` — toast notification (reconnect failure, missing private key, etc.)
- `error` — toast notification (reconnect failure, missing private key, stuck radio startup, etc.)
- `success` — toast notification (historical decrypt complete, etc.)
Backend WS sends go through typed serialization in `events.py`. Initial WS connect sends `health` only. Contacts/channels are loaded by REST.
@@ -250,6 +251,8 @@ Main tables:
Repository writes should prefer typed models such as `ContactUpsert` over ad hoc dict payloads when adding or updating schema-coupled data.
`max_radio_contacts` is the configured radio contact capacity baseline. Favorites reload first, the app refills non-favorite working-set contacts to about 80% of that capacity, and periodic offload triggers once occupancy reaches about 95%.
`app_settings` fields in active model:
- `max_radio_contacts`
- `favorites`

View File

@@ -1,4 +1,5 @@
import logging
import logging.config
from typing import Literal
from pydantic import model_validator
@@ -17,6 +18,7 @@ class Settings(BaseSettings):
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
database_path: str = "data/meshcore.db"
disable_bots: bool = False
enable_message_poll_fallback: bool = False
@model_validator(mode="after")
def validate_transport_exclusivity(self) -> "Settings":
@@ -84,10 +86,54 @@ class _RepeatSquelch(logging.Filter):
def setup_logging() -> None:
"""Configure logging for the application."""
logging.basicConfig(
level=settings.log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
logging.config.dictConfig(
{
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"default": {
"format": "%(asctime)s - %(name)s - %(levelname)s - %(message)s",
"datefmt": "%Y-%m-%d %H:%M:%S",
},
"uvicorn_access": {
"()": "uvicorn.logging.AccessFormatter",
"fmt": '%(asctime)s - %(name)s - %(levelname)s - %(client_addr)s - "%(request_line)s" %(status_code)s',
"datefmt": "%Y-%m-%d %H:%M:%S",
"use_colors": None,
},
},
"handlers": {
"default": {
"class": "logging.StreamHandler",
"formatter": "default",
},
"uvicorn_access": {
"class": "logging.StreamHandler",
"formatter": "uvicorn_access",
},
},
"root": {
"level": settings.log_level,
"handlers": ["default"],
},
"loggers": {
"uvicorn": {
"level": settings.log_level,
"handlers": ["default"],
"propagate": False,
},
"uvicorn.error": {
"level": settings.log_level,
"handlers": ["default"],
"propagate": False,
},
"uvicorn.access": {
"level": settings.log_level,
"handlers": ["uvicorn_access"],
"propagate": False,
},
},
}
)
# Squelch repeated messages from the meshcore library (e.g. rapid-fire
# "Serial Connection started" when the port is contended).

View File

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

View File

@@ -516,8 +516,8 @@ class AppSettings(BaseModel):
max_radio_contacts: int = Field(
default=200,
description=(
"Maximum contacts to keep on radio for DM ACKs "
"(favorite contacts first, then recent non-repeaters)"
"Configured radio contact capacity used for maintenance thresholds; "
"favorites reload first, then background fill targets about 80% of this value"
),
)
favorites: list[Favorite] = Field(

View File

@@ -29,7 +29,6 @@ from app.decoder import (
)
from app.keystore import get_private_key, get_public_key, has_private_key
from app.models import (
CONTACT_TYPE_REPEATER,
Contact,
ContactUpsert,
RawPacketBroadcast,
@@ -415,7 +414,6 @@ async def _process_advertisement(
Process an advertisement packet.
Extracts contact info and updates the database/broadcasts to clients.
For non-repeater contacts, triggers sync of recent contacts to radio for DM ACK support.
"""
# Parse packet to get path info if not already provided
if packet_info is None:
@@ -533,14 +531,6 @@ async def _process_advertisement(
if settings.auto_decrypt_dm_on_advert:
await start_historical_dm_decryption(None, advert.public_key.lower(), advert.name)
# If this is not a repeater, trigger recent contacts sync to radio
# This ensures we can auto-ACK DMs from recent contacts
if contact_type != CONTACT_TYPE_REPEATER:
# Import here to avoid circular import
from app.radio_sync import sync_recent_contacts_to_radio
asyncio.create_task(sync_recent_contacts_to_radio())
async def _process_direct_message(
raw_bytes: bytes,

View File

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

View File

@@ -253,17 +253,28 @@ class ContactRepository:
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def get_recent_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get the most recently active non-repeater contacts.
Orders by most recent activity (last_contacted or last_advert),
excluding repeaters (type=2).
"""
async def get_recently_contacted_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get recently interacted-with non-repeater contacts."""
cursor = await db.conn.execute(
"""
SELECT * FROM contacts
WHERE type != 2
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
WHERE type != 2 AND last_contacted IS NOT NULL
ORDER BY last_contacted DESC
LIMIT ?
""",
(limit,),
)
rows = await cursor.fetchall()
return [ContactRepository._row_to_contact(row) for row in rows]
@staticmethod
async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]:
"""Get recently advert-heard non-repeater contacts."""
cursor = await db.conn.execute(
"""
SELECT * FROM contacts
WHERE type != 2 AND last_advert IS NOT NULL
ORDER BY last_advert DESC
LIMIT ?
""",
(limit,),

View File

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

View File

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

View File

@@ -137,15 +137,20 @@ async def send_direct_message_to_contact(
) -> Any:
"""Send a direct message and persist/broadcast the outgoing row."""
contact_data = contact.to_radio_dict()
contact_ensured_on_radio = False
async with radio_manager.radio_operation("send_direct_message") as mc:
logger.debug("Ensuring contact %s is on radio before sending", contact.public_key[:12])
add_result = await mc.commands.add_contact(contact_data)
if add_result.type == EventType.ERROR:
logger.warning("Failed to add contact to radio: %s", add_result.payload)
else:
contact_ensured_on_radio = True
cached_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if not cached_contact:
cached_contact = contact_data
else:
contact_ensured_on_radio = True
logger.info("Sending direct message to %s", contact.public_key[:12])
now = int(now_fn())
@@ -158,6 +163,9 @@ async def send_direct_message_to_contact(
if result.type == EventType.ERROR:
raise HTTPException(status_code=500, detail=f"Failed to send message: {result.payload}")
if contact_ensured_on_radio and not contact.on_radio:
await contact_repository.set_on_radio(contact.public_key.lower(), True)
message = await create_outgoing_direct_message(
conversation_key=contact.public_key.lower(),
text=text,

View File

@@ -3,6 +3,9 @@ import logging
logger = logging.getLogger(__name__)
POST_CONNECT_SETUP_TIMEOUT_SECONDS = 300
POST_CONNECT_SETUP_MAX_ATTEMPTS = 2
async def run_post_connect_setup(radio_manager) -> None:
"""Run shared radio initialization after a transport connection succeeds."""
@@ -24,7 +27,7 @@ async def run_post_connect_setup(radio_manager) -> None:
if radio_manager._setup_lock is None:
radio_manager._setup_lock = asyncio.Lock()
async with radio_manager._setup_lock:
async def _setup_body() -> None:
if not radio_manager.meshcore:
return
radio_manager._setup_in_progress = True
@@ -138,14 +141,42 @@ async def run_post_connect_setup(radio_manager) -> None:
finally:
radio_manager._setup_in_progress = False
async with radio_manager._setup_lock:
await asyncio.wait_for(_setup_body(), timeout=POST_CONNECT_SETUP_TIMEOUT_SECONDS)
logger.info("Post-connect setup complete")
async def prepare_connected_radio(radio_manager, *, broadcast_on_success: bool = True) -> None:
"""Finish setup for an already-connected radio and optionally broadcast health."""
from app.websocket import broadcast_health
from app.websocket import broadcast_error, broadcast_health
for attempt in range(1, POST_CONNECT_SETUP_MAX_ATTEMPTS + 1):
try:
await radio_manager.post_connect_setup()
break
except asyncio.TimeoutError as exc:
if attempt < POST_CONNECT_SETUP_MAX_ATTEMPTS:
logger.warning(
"Post-connect setup timed out after %ds on attempt %d/%d; retrying once",
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
attempt,
POST_CONNECT_SETUP_MAX_ATTEMPTS,
)
continue
logger.error(
"Post-connect setup timed out after %ds on %d attempts. Initial radio offload "
"took too long; something is probably wrong.",
POST_CONNECT_SETUP_TIMEOUT_SECONDS,
POST_CONNECT_SETUP_MAX_ATTEMPTS,
)
broadcast_error(
"Radio startup appears stuck",
"Initial radio offload took too long. Reboot the radio and restart the server.",
)
raise RuntimeError("Post-connect setup timed out") from exc
await radio_manager.post_connect_setup()
radio_manager._last_connected = True
if broadcast_on_success:
broadcast_health(True, radio_manager.connection_info)
@@ -197,7 +228,11 @@ async def connection_monitor_loop(radio_manager) -> None:
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
consecutive_setup_failures = 0
elif current_connected and not radio_manager.is_setup_complete:
elif (
current_connected
and not radio_manager.is_setup_complete
and not radio_manager.is_setup_in_progress
):
logger.info("Retrying post-connect setup...")
await prepare_connected_radio(radio_manager, broadcast_on_success=True)
consecutive_setup_failures = 0

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

@@ -13,6 +13,7 @@ import {
useConversationActions,
useConversationNavigation,
useRealtimeAppState,
useBrowserNotifications,
} from './hooks';
import { AppShell } from './components/AppShell';
import type { MessageInputHandle } from './components/MessageInput';
@@ -22,6 +23,13 @@ import type { Conversation, RawPacket } from './types';
export function App() {
const messageInputRef = useRef<MessageInputHandle>(null);
const [rawPackets, setRawPackets] = useState<RawPacket[]>([]);
const {
notificationsSupported,
notificationsPermission,
isConversationNotificationsEnabled,
toggleConversationNotifications,
notifyIncomingMessage,
} = useBrowserNotifications();
const {
showNewMessage,
showSettings,
@@ -202,6 +210,7 @@ export function App() {
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
notifyIncomingMessage,
});
const {
handleSendMessage,
@@ -237,7 +246,10 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const statusProps = { health, config };
const statusProps = {
health,
config,
};
const sidebarProps = {
contacts,
channels,
@@ -258,6 +270,7 @@ export function App() {
onSortOrderChange: (sortOrder: 'recent' | 'alpha') => {
void handleSortOrderChange(sortOrder);
},
isConversationNotificationsEnabled,
};
const conversationPaneProps = {
activeConversation,
@@ -289,6 +302,21 @@ export function App() {
onLoadNewer: fetchNewerMessages,
onJumpToBottom: jumpToBottom,
onSendMessage: handleSendMessage,
notificationsSupported,
notificationsPermission,
notificationsEnabled:
activeConversation?.type === 'contact' || activeConversation?.type === 'channel'
? isConversationNotificationsEnabled(activeConversation.type, activeConversation.id)
: false,
onToggleNotifications: () => {
if (activeConversation?.type === 'contact' || activeConversation?.type === 'channel') {
void toggleConversationNotifications(
activeConversation.type,
activeConversation.id,
activeConversation.name
);
}
},
};
const searchProps = {
contacts,

View File

@@ -9,6 +9,7 @@ import { ChannelInfoPane } from './ChannelInfoPane';
import { Toaster } from './ui/sonner';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import {
SETTINGS_SECTION_ICONS,
SETTINGS_SECTION_LABELS,
SETTINGS_SECTION_ORDER,
type SettingsSection,
@@ -115,20 +116,26 @@ export function AppShell({
</button>
</div>
<div className="flex-1 min-h-0 overflow-y-auto py-1 [contain:layout_paint]">
{SETTINGS_SECTION_ORDER.map((section) => (
<button
key={section}
type="button"
className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
settingsSection === section && 'bg-accent border-l-primary'
)}
aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => onSettingsSectionChange(section)}
>
{SETTINGS_SECTION_LABELS[section]}
</button>
))}
{SETTINGS_SECTION_ORDER.map((section) => {
const Icon = SETTINGS_SECTION_ICONS[section];
return (
<button
key={section}
type="button"
className={cn(
'w-full px-3 py-2 text-left text-[13px] border-l-2 border-transparent hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset',
settingsSection === section && 'bg-accent border-l-primary'
)}
aria-current={settingsSection === section ? 'true' : undefined}
onClick={() => onSettingsSectionChange(section)}
>
<span className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span>{SETTINGS_SECTION_LABELS[section]}</span>
</span>
</button>
);
})}
</div>
</nav>
);
@@ -216,7 +223,13 @@ export function AppShell({
<h2 className="flex justify-between items-center px-4 py-2.5 border-b border-border font-semibold text-base">
<span>Radio & Settings</span>
<span className="text-sm text-muted-foreground hidden md:inline">
{SETTINGS_SECTION_LABELS[settingsSection]}
<span className="inline-flex items-center gap-1.5">
{(() => {
const Icon = SETTINGS_SECTION_ICONS[settingsSection];
return <Icon className="h-4 w-4" aria-hidden="true" />;
})()}
<span>{SETTINGS_SECTION_LABELS[settingsSection]}</span>
</span>
</span>
</h2>
<div className="flex-1 min-h-0 overflow-hidden">

View File

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

View File

@@ -1,4 +1,5 @@
import { useEffect, useState } from 'react';
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
import { toast } from './ui/sonner';
import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
@@ -13,7 +14,11 @@ interface ChatHeaderProps {
channels: Channel[];
config: RadioConfig | null;
favorites: Favorite[];
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
onTrace: () => void;
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
onDeleteChannel: (key: string) => void;
@@ -28,7 +33,11 @@ export function ChatHeader({
channels,
config,
favorites,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
onTrace,
onToggleNotifications,
onToggleFavorite,
onSetChannelFloodScopeOverride,
onDeleteChannel,
@@ -46,25 +55,39 @@ export function ChatHeader({
conversation.type === 'channel'
? channels.find((channel) => channel.key === conversation.id)
: undefined;
const activeFloodScopeOverride =
conversation.type === 'channel' ? (activeChannel?.flood_scope_override ?? null) : null;
const activeFloodScopeLabel = activeFloodScopeOverride
? stripRegionScopePrefix(activeFloodScopeOverride)
: null;
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
const titleClickable =
(conversation.type === 'contact' && onOpenContactInfo) ||
(conversation.type === 'channel' && onOpenChannelInfo);
const favoriteTitle =
conversation.type === 'contact'
? isFavorite(favorites, 'contact', conversation.id)
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
: isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites';
const handleEditFloodScopeOverride = () => {
if (conversation.type !== 'channel' || !onSetChannelFloodScopeOverride) return;
const nextValue = window.prompt(
'Enter regional override flood scope for this room. This temporarily changes the radio flood scope before send and restores it after, which significantly slows room sends. Leave blank to clear.',
stripRegionScopePrefix(activeChannel?.flood_scope_override)
activeFloodScopeLabel ?? ''
);
if (nextValue === null) return;
onSetChannelFloodScopeOverride(conversation.id, nextValue);
};
return (
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
<span className="flex min-w-0 flex-1 items-start gap-2">
{conversation.type === 'contact' && onOpenContactInfo && (
<span
className="flex-shrink-0 cursor-pointer"
@@ -84,111 +107,175 @@ export function ChatHeader({
/>
</span>
)}
<h2
className={`flex-shrink-0 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
role={titleClickable ? 'button' : undefined}
tabIndex={titleClickable ? 0 : undefined}
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
onClick={
titleClickable
? () => {
if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id);
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
onOpenChannelInfo(conversation.id);
}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<h2
className={`flex shrink min-w-0 items-center gap-1.5 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
role={titleClickable ? 'button' : undefined}
tabIndex={titleClickable ? 0 : undefined}
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
onClick={
titleClickable
? () => {
if (conversation.type === 'contact' && onOpenContactInfo) {
onOpenContactInfo(conversation.id);
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
onOpenChannelInfo(conversation.id);
}
}
: undefined
}
: undefined
}
>
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</h2>
{isPrivateChannel && !showKey ? (
<button
className="font-normal text-[11px] text-muted-foreground font-mono hover:text-primary transition-colors"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
>
Show Key
</button>
) : (
<span
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
);
}}
title="Click to copy"
aria-label={conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'}
>
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
>
<span className="truncate">
{conversation.type === 'channel' &&
!conversation.name.startsWith('#') &&
activeChannel?.is_hashtag
? '#'
: ''}
{conversation.name}
</span>
{titleClickable && (
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
)}
</h2>
{isPrivateChannel && !showKey ? (
<button
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
onClick={(e) => {
e.stopPropagation();
setShowKey(true);
}}
title="Reveal channel key"
>
Show Key
</button>
) : (
<span
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
role="button"
tabIndex={0}
onKeyDown={handleKeyboardActivate}
onClick={(e) => {
e.stopPropagation();
navigator.clipboard.writeText(conversation.id);
toast.success(
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
);
}}
title="Click to copy"
aria-label={
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
}
>
{conversation.type === 'channel'
? conversation.id.toLowerCase()
: conversation.id}
</span>
)}
</span>
{conversation.type === 'contact' &&
(() => {
const contact = contacts.find((c) => c.public_key === conversation.id);
if (!contact) return null;
return (
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
<ContactStatusInfo
contact={contact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
/>
</span>
);
})()}
</span>
)}
{conversation.type === 'channel' && activeChannel?.flood_scope_override && (
<span className="basis-full sm:basis-auto text-[11px] text-amber-700 dark:text-amber-300 truncate">
Regional override active: {stripRegionScopePrefix(activeChannel.flood_scope_override)}
</span>
)}
{conversation.type === 'contact' &&
(() => {
const contact = contacts.find((c) => c.public_key === conversation.id);
if (!contact) return null;
return (
<ContactStatusInfo
contact={contact}
ourLat={config?.lat ?? null}
ourLon={config?.lon ?? null}
{conversation.type === 'channel' && activeFloodScopeDisplay && (
<button
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<Globe2
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
aria-hidden="true"
/>
);
})()}
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
{activeFloodScopeDisplay}
</span>
</button>
)}
</span>
</span>
<div className="flex items-center gap-0.5 flex-shrink-0">
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
{conversation.type === 'contact' && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onTrace}
title="Direct Trace"
aria-label="Direct Trace"
>
<span aria-hidden="true">&#x1F6CE;</span>
<Route className="h-4 w-4" aria-hidden="true" />
</button>
)}
{notificationsSupported && (
<button
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={onToggleNotifications}
title={
notificationsEnabled
? 'Disable desktop notifications for this conversation'
: notificationsPermission === 'denied'
? 'Notifications blocked by the browser'
: 'Enable desktop notifications for this conversation'
}
aria-label={
notificationsEnabled
? 'Disable notifications for this conversation'
: 'Enable notifications for this conversation'
}
>
<Bell
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
fill={notificationsEnabled ? 'currentColor' : 'none'}
aria-hidden="true"
/>
{notificationsEnabled && (
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
Notifications On
</span>
)}
</button>
)}
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={handleEditFloodScopeOverride}
title="Set regional override"
aria-label="Set regional override"
>
<span aria-hidden="true">&#127758;</span>
<Globe2
className={`h-4 w-4 ${activeFloodScopeLabel ? 'text-[hsl(var(--region-override))]' : ''}`}
aria-hidden="true"
/>
{activeFloodScopeDisplay && (
<span className="hidden text-[11px] font-medium text-[hsl(var(--region-override))] sm:inline">
{activeFloodScopeDisplay}
</span>
)}
</button>
)}
{(conversation.type === 'channel' || conversation.type === 'contact') && (
<button
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() =>
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
}
title={
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
: 'Add to favorites'
}
title={favoriteTitle}
aria-label={
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
? 'Remove from favorites'
@@ -196,15 +283,15 @@ export function ChatHeader({
}
>
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
<span className="text-favorite">&#9733;</span>
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
) : (
<span className="text-muted-foreground">&#9734;</span>
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
)}
</button>
)}
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
<button
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => {
if (conversation.type === 'channel') {
onDeleteChannel(conversation.id);
@@ -215,7 +302,7 @@ export function ChatHeader({
title="Delete"
aria-label="Delete"
>
<span aria-hidden="true">&#128465;</span>
<Trash2 className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>

View File

@@ -1,4 +1,5 @@
import { type ReactNode, useEffect, useState } from 'react';
import { Ban, Star } from 'lucide-react';
import { api } from '../api';
import { formatTime } from '../utils/messageParser';
import {
@@ -152,12 +153,12 @@ export function ContactInfoPane({
>
{blockedNames.includes(nameOnlyValue) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
<span>Unblock this name</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
<span>Block this name</span>
</>
)}
@@ -279,15 +280,16 @@ export function ContactInfoPane({
type="button"
className="text-sm flex items-center gap-2 hover:text-primary transition-colors"
onClick={() => onToggleFavorite('contact', contact.public_key)}
title="Favorite contacts stay loaded on the radio for ACK support"
>
{isFavorite(favorites, 'contact', contact.public_key) ? (
<>
<span className="text-favorite text-lg">&#9733;</span>
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
<span>Remove from favorites</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#9734;</span>
<Star className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
<span>Add to favorites</span>
</>
)}
@@ -305,12 +307,12 @@ export function ContactInfoPane({
>
{blockedKeys.includes(contact.public_key.toLowerCase()) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
<span>Unblock this key</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
<span>Block this key</span>
</>
)}
@@ -324,12 +326,12 @@ export function ContactInfoPane({
>
{blockedNames.includes(contact.name) ? (
<>
<span className="text-destructive text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
<span>Unblock name &ldquo;{contact.name}&rdquo;</span>
</>
) : (
<>
<span className="text-muted-foreground text-lg">&#x2718;</span>
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
<span>Block name &ldquo;{contact.name}&rdquo;</span>
</>
)}

View File

@@ -31,6 +31,9 @@ interface ConversationPaneProps {
rawPackets: RawPacket[];
config: RadioConfig | null;
health: HealthStatus | null;
notificationsSupported: boolean;
notificationsEnabled: boolean;
notificationsPermission: NotificationPermission | 'unsupported';
favorites: Favorite[];
messages: Message[];
messagesLoading: boolean;
@@ -54,6 +57,7 @@ interface ConversationPaneProps {
onLoadNewer: () => Promise<void>;
onJumpToBottom: () => void;
onSendMessage: (text: string) => Promise<void>;
onToggleNotifications: () => void;
}
function LoadingPane({ label }: { label: string }) {
@@ -69,6 +73,9 @@ export function ConversationPane({
rawPackets,
config,
health,
notificationsSupported,
notificationsEnabled,
notificationsPermission,
favorites,
messages,
messagesLoading,
@@ -92,6 +99,7 @@ export function ConversationPane({
onLoadNewer,
onJumpToBottom,
onSendMessage,
onToggleNotifications,
}: ConversationPaneProps) {
const activeContactIsRepeater = useMemo(() => {
if (!activeConversation || activeConversation.type !== 'contact') return false;
@@ -155,10 +163,14 @@ export function ConversationPane({
conversation={activeConversation}
contacts={contacts}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
radioLat={config?.lat ?? null}
radioLon={config?.lon ?? null}
radioName={config?.name ?? null}
onTrace={onTrace}
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
/>
@@ -174,7 +186,11 @@ export function ConversationPane({
channels={channels}
config={config}
favorites={favorites}
notificationsSupported={notificationsSupported}
notificationsEnabled={notificationsEnabled}
notificationsPermission={notificationsPermission}
onTrace={onTrace}
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
onDeleteChannel={onDeleteChannel}

View File

@@ -1,4 +1,5 @@
import { useState, useRef } from 'react';
import { Dice5 } from 'lucide-react';
import type { Contact, Conversation } from '../types';
import { getContactDisplayName } from '../utils/pubkey';
import {
@@ -256,7 +257,7 @@ export function NewMessageModal({
title="Generate random key"
aria-label="Generate random key"
>
<span aria-hidden="true">🎲</span>
<Dice5 className="h-4 w-4" aria-hidden="true" />
</Button>
</div>
</div>

View File

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

View File

@@ -7,7 +7,11 @@ import type {
RadioConfigUpdate,
} from '../types';
import type { LocalLabel } from '../utils/localLabel';
import { SETTINGS_SECTION_LABELS, type SettingsSection } from './settings/settingsConstants';
import {
SETTINGS_SECTION_ICONS,
SETTINGS_SECTION_LABELS,
type SettingsSection,
} from './settings/settingsConstants';
import { SettingsRadioSection } from './settings/SettingsRadioSection';
import { SettingsLocalSection } from './settings/SettingsLocalSection';
@@ -138,6 +142,7 @@ export function SettingsModal(props: SettingsModalProps) {
const renderSectionHeader = (section: SettingsSection): ReactNode => {
if (!showSectionButton) return null;
const Icon = SETTINGS_SECTION_ICONS[section];
return (
<button
type="button"
@@ -145,8 +150,9 @@ export function SettingsModal(props: SettingsModalProps) {
aria-expanded={expandedSections[section]}
onClick={() => toggleSection(section)}
>
<span className="font-medium" role="heading" aria-level={3}>
{SETTINGS_SECTION_LABELS[section]}
<span className="inline-flex items-center gap-2 font-medium" role="heading" aria-level={3}>
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
<span>{SETTINGS_SECTION_LABELS[section]}</span>
</span>
<span className="text-muted-foreground md:hidden" aria-hidden="true">
{expandedSections[section] ? '' : '+'}

View File

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

View File

@@ -1,9 +1,10 @@
import { useState } from 'react';
import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Menu, Moon, Sun } from 'lucide-react';
import type { HealthStatus, RadioConfig } from '../types';
import { api } from '../api';
import { toast } from './ui/sonner';
import { handleKeyboardActivate } from '../utils/a11y';
import { applyTheme, getSavedTheme, THEME_CHANGE_EVENT } from '../utils/theme';
import { cn } from '@/lib/utils';
interface StatusBarProps {
@@ -29,6 +30,19 @@ export function StatusBar({
? 'Radio OK'
: 'Radio Disconnected';
const [reconnecting, setReconnecting] = useState(false);
const [currentTheme, setCurrentTheme] = useState(getSavedTheme);
useEffect(() => {
const handleThemeChange = (event: Event) => {
const themeId = (event as CustomEvent<string>).detail;
setCurrentTheme(typeof themeId === 'string' && themeId ? themeId : getSavedTheme());
};
window.addEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener);
return () => {
window.removeEventListener(THEME_CHANGE_EVENT, handleThemeChange as EventListener);
};
}, []);
const handleReconnect = async () => {
setReconnecting(true);
@@ -46,22 +60,28 @@ export function StatusBar({
}
};
const handleThemeToggle = () => {
const nextTheme = currentTheme === 'light' ? 'original' : 'light';
applyTheme(nextTheme);
setCurrentTheme(nextTheme);
};
return (
<header className="flex items-center gap-3 px-4 py-2.5 bg-card border-b border-border text-xs">
{/* Mobile menu button - only visible on small screens */}
{onMenuClick && (
<button
onClick={onMenuClick}
className="md:hidden p-1 bg-transparent border-none text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
className="md:hidden p-0.5 bg-transparent border-none text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
<Menu className="h-4 w-4" />
</button>
)}
<h1 className="text-base font-semibold tracking-tight mr-auto text-foreground flex items-center gap-1.5">
<svg
className="h-5 w-5 shrink-0 text-white"
className="h-4 w-4 shrink-0 text-white"
viewBox="0 0 512 512"
fill="currentColor"
aria-hidden="true"
@@ -128,6 +148,18 @@ export function StatusBar({
>
{settingsMode ? 'Back to Chat' : 'Settings'}
</button>
<button
onClick={handleThemeToggle}
className="p-0.5 text-muted-foreground hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm"
title={currentTheme === 'light' ? 'Switch to classic theme' : 'Switch to light theme'}
aria-label={currentTheme === 'light' ? 'Switch to classic theme' : 'Switch to light theme'}
>
{currentTheme === 'light' ? (
<Moon className="h-4 w-4" aria-hidden="true" />
) : (
<Sun className="h-4 w-4" aria-hidden="true" />
)}
</button>
</header>
);
}

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useMemo } from 'react';
import { MapPinned } from 'lucide-react';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Button } from '../ui/button';
@@ -406,7 +407,14 @@ export function SettingsRadioSection({
onClick={handleGetLocation}
disabled={gettingLocation}
>
{gettingLocation ? 'Getting...' : '📍 Use My Location'}
{gettingLocation ? (
'Getting...'
) : (
<>
<MapPinned className="mr-1.5 h-4 w-4" aria-hidden="true" />
Use My Location
</>
)}
</Button>
</div>
<div className="grid grid-cols-2 gap-4">
@@ -575,8 +583,8 @@ export function SettingsRadioSection({
onChange={(e) => setMaxRadioContacts(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Favorite contacts load first, then recent non-repeater contacts until this limit is
reached (1-1000)
Configured radio contact capacity. Favorites reload first, then background maintenance
refills to about 80% of this value and offloads once occupancy reaches about 95%.
</p>
</div>

View File

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

View File

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

View File

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

View File

@@ -44,6 +44,7 @@ interface UseRealtimeAppStateArgs {
pendingDeleteFallbackRef: MutableRefObject<boolean>;
setActiveConversation: (conv: Conversation | null) => void;
updateMessageAck: (messageId: number, ackCount: number, paths?: MessagePath[]) => void;
notifyIncomingMessage?: (msg: Message) => void;
maxRawPackets?: number;
}
@@ -103,6 +104,7 @@ export function useRealtimeAppState({
pendingDeleteFallbackRef,
setActiveConversation,
updateMessageAck,
notifyIncomingMessage,
maxRawPackets = 500,
}: UseRealtimeAppStateArgs): UseWebSocketOptions {
const mergeChannelIntoList = useCallback(
@@ -180,18 +182,19 @@ export function useRealtimeAppState({
activeConversationRef.current,
msg
);
let isNewMessage = false;
if (isForActiveConversation && !hasNewerMessagesRef.current) {
addMessageIfNew(msg);
isNewMessage = addMessageIfNew(msg);
}
trackNewMessage(msg);
const contentKey = getMessageContentKey(msg);
if (!isForActiveConversation) {
const isNew = messageCache.addMessage(msg.conversation_key, msg, contentKey);
isNewMessage = messageCache.addMessage(msg.conversation_key, msg, contentKey);
if (!msg.outgoing && isNew) {
if (!msg.outgoing && isNewMessage) {
let stateKey: string | null = null;
if (msg.type === 'CHAN' && msg.conversation_key) {
stateKey = getStateKey('channel', msg.conversation_key);
@@ -203,6 +206,10 @@ export function useRealtimeAppState({
}
}
}
if (!msg.outgoing && isNewMessage) {
notifyIncomingMessage?.(msg);
}
},
onContact: (contact: Contact) => {
setContacts((prev) => mergeContactIntoList(prev, contact));
@@ -259,6 +266,7 @@ export function useRealtimeAppState({
trackNewMessage,
triggerReconcile,
updateMessageAck,
notifyIncomingMessage,
]
);
}

View File

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

View File

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

View File

@@ -14,7 +14,11 @@ const baseProps = {
contacts: [],
config: null,
favorites: [] as Favorite[],
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
onTrace: noop,
onToggleNotifications: noop,
onToggleFavorite: noop,
onSetChannelFloodScopeOverride: noop,
onDeleteChannel: noop,
@@ -107,7 +111,7 @@ describe('ChatHeader key visibility', () => {
expect(writeText).toHaveBeenCalledWith(key);
});
it('shows active regional override banner for channels', () => {
it('shows active regional override badge for channels', () => {
const key = 'AB'.repeat(16);
const channel = {
...makeChannel(key, '#flightless', true),
@@ -117,7 +121,27 @@ describe('ChatHeader key visibility', () => {
render(<ChatHeader {...baseProps} conversation={conversation} channels={[channel]} />);
expect(screen.getByText('Regional override active: Esperance')).toBeInTheDocument();
expect(screen.getAllByText('#Esperance')).toHaveLength(2);
});
it('shows enabled notification state and toggles when clicked', () => {
const conversation: Conversation = { type: 'contact', id: '11'.repeat(32), name: 'Alice' };
const onToggleNotifications = vi.fn();
render(
<ChatHeader
{...baseProps}
conversation={conversation}
channels={[]}
notificationsEnabled
onToggleNotifications={onToggleNotifications}
/>
);
fireEvent.click(screen.getByText('Notifications On'));
expect(screen.getByText('Notifications On')).toBeInTheDocument();
expect(onToggleNotifications).toHaveBeenCalledTimes(1);
});
it('prompts for regional override when globe button is clicked', () => {

View File

@@ -99,6 +99,9 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
rawPackets: [],
config,
health,
notificationsSupported: true,
notificationsEnabled: false,
notificationsPermission: 'granted' as const,
favorites: [] as Favorite[],
messages: [message],
messagesLoading: false,
@@ -122,6 +125,7 @@ function createProps(overrides: Partial<React.ComponentProps<typeof Conversation
onLoadNewer: vi.fn(async () => {}),
onJumpToBottom: vi.fn(),
onSendMessage: vi.fn(async () => {}),
onToggleNotifications: vi.fn(),
...overrides,
};
}

View File

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

View File

@@ -179,15 +179,11 @@ describe('SettingsModal', () => {
expect(screen.queryByLabelText('Preset')).not.toBeInTheDocument();
});
it('shows favorite-first contact sync helper text in radio tab', async () => {
it('shows favorite-contact radio sync helper text in radio tab', async () => {
renderModal();
openRadioSection();
expect(
screen.getByText(
/Favorite contacts load first, then recent non-repeater contacts until this\s+limit is reached/i
)
).toBeInTheDocument();
expect(screen.getByText(/Configured radio contact capacity/i)).toBeInTheDocument();
});
it('saves changed max contacts value through onSaveAppSettings', async () => {

View File

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

View File

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

View File

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

View File

@@ -81,6 +81,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
pendingDeleteFallbackRef: { current: false },
setActiveConversation: vi.fn(),
updateMessageAck: vi.fn(),
notifyIncomingMessage: vi.fn(),
...overrides,
},
fns: {
@@ -163,6 +164,7 @@ describe('useRealtimeAppState', () => {
`contact-${incomingDm.conversation_key}`,
true
);
expect(args.notifyIncomingMessage).toHaveBeenCalledWith(incomingDm);
});
it('deleting the active contact clears it and marks fallback recovery pending', () => {

View File

@@ -1,7 +1,10 @@
import { StrictMode, createElement, type ReactNode } from 'react';
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
import {
resetRepeaterDashboardCacheForTests,
useRepeaterDashboard,
} from '../hooks/useRepeaterDashboard';
import type { Conversation } from '../types';
// Mock the api module
@@ -43,6 +46,7 @@ const repeaterConversation: Conversation = {
describe('useRepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
resetRepeaterDashboardCacheForTests();
});
it('starts with logged out state', () => {
@@ -123,6 +127,7 @@ describe('useRepeaterDashboard', () => {
expect(result.current.paneData.status).toEqual(statusData);
expect(result.current.paneStates.status.loading).toBe(false);
expect(result.current.paneStates.status.error).toBe(null);
expect(result.current.paneStates.status.fetched_at).toEqual(expect.any(Number));
});
it('refreshPane still issues requests under StrictMode remount probing', async () => {
@@ -211,7 +216,23 @@ describe('useRepeaterDashboard', () => {
expect(result.current.consoleLoading).toBe(false);
});
it('sendAdvert sends "advert" command', async () => {
it('sendZeroHopAdvert sends "advert.zerohop" command', async () => {
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'advert.zerohop',
response: 'ok',
sender_timestamp: 1000,
});
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.sendZeroHopAdvert();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'advert.zerohop');
});
it('sendFloodAdvert sends "advert" command', async () => {
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'advert',
response: 'ok',
@@ -221,7 +242,7 @@ describe('useRepeaterDashboard', () => {
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await result.current.sendAdvert();
await result.current.sendFloodAdvert();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'advert');
@@ -243,12 +264,10 @@ describe('useRepeaterDashboard', () => {
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'reboot');
});
it('syncClock sends "clock <epoch>" command', async () => {
const fakeNow = 1700000000000;
vi.spyOn(Date, 'now').mockReturnValue(fakeNow);
it('syncClock sends "time <epoch>" command', async () => {
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'clock 1700000000',
command: 'time 1700000000',
response: 'ok',
sender_timestamp: 1000,
});
@@ -259,9 +278,8 @@ describe('useRepeaterDashboard', () => {
await result.current.syncClock();
});
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'clock 1700000000');
vi.restoreAllMocks();
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'time 1700000000');
dateNowSpy.mockRestore();
});
it('loadAll calls refreshPane for all panes serially', async () => {
@@ -304,4 +322,38 @@ describe('useRepeaterDashboard', () => {
expect(mockApi.repeaterOwnerInfo).toHaveBeenCalledTimes(1);
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
});
it('restores dashboard state when navigating away and back to the same repeater', async () => {
const statusData = { battery_volts: 4.2 };
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
mockApi.repeaterStatus.mockResolvedValueOnce(statusData);
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
command: 'ver',
response: 'v2.1.0',
sender_timestamp: 1000,
});
const firstMount = renderHook(() => useRepeaterDashboard(repeaterConversation));
await act(async () => {
await firstMount.result.current.login('secret');
await firstMount.result.current.refreshPane('status');
await firstMount.result.current.sendConsoleCommand('ver');
});
expect(firstMount.result.current.loggedIn).toBe(true);
expect(firstMount.result.current.paneData.status).toEqual(statusData);
expect(firstMount.result.current.consoleHistory).toHaveLength(2);
firstMount.unmount();
const secondMount = renderHook(() => useRepeaterDashboard(repeaterConversation));
expect(secondMount.result.current.loggedIn).toBe(true);
expect(secondMount.result.current.loginError).toBe(null);
expect(secondMount.result.current.paneData.status).toEqual(statusData);
expect(secondMount.result.current.paneStates.status.loading).toBe(false);
expect(secondMount.result.current.consoleHistory).toHaveLength(2);
expect(secondMount.result.current.consoleHistory[1].response).toBe('v2.1.0');
});
});

View File

@@ -35,6 +35,7 @@
--success-foreground: 0 0% 100%;
--info: 217 91% 48%;
--info-foreground: 0 0% 100%;
--region-override: 274 78% 24%;
--favorite: 43 96% 50%;
--console: 153 50% 22%;
--console-command: 153 55% 18%;
@@ -48,6 +49,10 @@
--overlay: 220 20% 10%;
}
:root[data-theme='light'] .sidebar-tool-label {
color: hsl(var(--foreground));
}
/* ── Cyberpunk ("Neon Bleed") ──────────────────────────────── */
:root[data-theme='cyberpunk'] {
--background: 210 18% 3%;
@@ -80,6 +85,7 @@
--success-foreground: 135 100% 6%;
--info: 185 100% 42%;
--info-foreground: 185 100% 6%;
--region-override: 292 100% 68%;
--favorite: 62 100% 52%;
--console: 135 100% 50%;
--console-command: 135 100% 62%;
@@ -126,6 +132,7 @@
--success-foreground: 0 0% 100%;
--info: 212 100% 58%;
--info-foreground: 0 0% 100%;
--region-override: 286 100% 76%;
--favorite: 43 100% 54%;
--console: 212 100% 62%;
--console-command: 212 100% 74%;
@@ -172,6 +179,7 @@
--success-foreground: 0 0% 100%;
--info: 210 50% 56%;
--info-foreground: 0 0% 100%;
--region-override: 273 72% 72%;
--favorite: 38 70% 56%;
--console: 30 40% 58%;
--console-command: 30 40% 70%;
@@ -267,6 +275,7 @@
--success-foreground: 0 0% 100%;
--info: 198 80% 54%;
--info-foreground: 0 0% 100%;
--region-override: 282 100% 72%;
--favorite: 46 100% 54%;
--console: 338 100% 54%;
--console-command: 338 100% 68%;
@@ -373,3 +382,317 @@
[data-theme='solar-flare'] ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, hsl(338 90% 48%), hsl(24 90% 48%));
}
/* ── Lagoon Pop ("Tidal Candy") ───────────────────────────── */
:root[data-theme='lagoon-pop'] {
--background: 197 62% 9%;
--foreground: 42 33% 92%;
--card: 197 46% 13%;
--card-foreground: 42 33% 92%;
--popover: 197 46% 14%;
--popover-foreground: 42 33% 92%;
--primary: 175 72% 49%;
--primary-foreground: 196 60% 9%;
--secondary: 197 34% 18%;
--secondary-foreground: 42 22% 84%;
--muted: 197 30% 16%;
--muted-foreground: 195 16% 64%;
--accent: 205 46% 22%;
--accent-foreground: 42 33% 92%;
--destructive: 8 88% 61%;
--destructive-foreground: 0 0% 100%;
--border: 191 34% 24%;
--input: 191 34% 24%;
--ring: 175 72% 49%;
--radius: 1rem;
--msg-outgoing: 184 46% 16%;
--msg-incoming: 204 34% 14%;
--status-connected: 167 76% 46%;
--status-disconnected: 204 12% 46%;
--warning: 41 100% 58%;
--warning-foreground: 38 100% 10%;
--success: 167 76% 42%;
--success-foreground: 196 60% 9%;
--info: 229 90% 72%;
--info-foreground: 232 56% 14%;
--region-override: 277 88% 76%;
--favorite: 49 100% 63%;
--console: 175 72% 54%;
--console-command: 175 78% 68%;
--console-bg: 198 68% 7%;
--toast-error: 8 38% 14%;
--toast-error-foreground: 10 86% 77%;
--toast-error-border: 8 30% 24%;
--code-editor-bg: 198 44% 11%;
--font-sans: 'Trebuchet MS', 'Avenir Next', 'Segoe UI', sans-serif;
--scrollbar: 191 34% 22%;
--scrollbar-hover: 191 40% 30%;
--overlay: 198 80% 4%;
}
[data-theme='lagoon-pop'] body {
background:
radial-gradient(circle at top left, hsl(175 72% 49% / 0.1), transparent 28%),
radial-gradient(circle at top right, hsl(229 90% 72% / 0.1), transparent 24%),
radial-gradient(circle at bottom center, hsl(8 88% 61% / 0.08), transparent 26%),
hsl(197 62% 9%);
}
[data-theme='lagoon-pop'] .bg-card {
background: linear-gradient(145deg, hsl(197 46% 14%), hsl(205 40% 16%));
}
[data-theme='lagoon-pop'] .bg-popover {
background: linear-gradient(145deg, hsl(197 46% 15%), hsl(205 40% 17%));
}
[data-theme='lagoon-pop'] .bg-msg-outgoing {
background: linear-gradient(135deg, hsl(184 48% 16%), hsl(175 38% 19%));
border-left: 2px solid hsl(175 72% 49% / 0.45);
}
[data-theme='lagoon-pop'] .bg-msg-incoming {
background: linear-gradient(135deg, hsl(204 34% 14%), hsl(214 30% 16%));
border-left: 2px solid hsl(229 90% 72% / 0.35);
}
[data-theme='lagoon-pop'] .bg-primary {
background: linear-gradient(135deg, hsl(175 72% 49%), hsl(191 78% 56%));
}
[data-theme='lagoon-pop'] button {
transition:
transform 0.12s ease,
filter 0.2s ease,
background-color 0.15s ease,
color 0.15s ease;
}
[data-theme='lagoon-pop'] button:hover {
filter: drop-shadow(0 0 10px hsl(175 72% 49% / 0.18));
}
[data-theme='lagoon-pop'] button:active {
transform: translateY(1px);
}
[data-theme='lagoon-pop'] ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, hsl(175 40% 32%), hsl(229 38% 40%));
}
[data-theme='lagoon-pop'] ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, hsl(175 52% 42%), hsl(229 52% 54%));
}
/* ── Candy Dusk ("Dream Arcade") ──────────────────────────── */
:root[data-theme='candy-dusk'] {
--background: 258 38% 10%;
--foreground: 302 30% 93%;
--card: 258 30% 15%;
--card-foreground: 302 30% 93%;
--popover: 258 30% 16%;
--popover-foreground: 302 30% 93%;
--primary: 325 100% 74%;
--primary-foreground: 258 38% 12%;
--secondary: 255 24% 20%;
--secondary-foreground: 291 20% 85%;
--muted: 255 20% 18%;
--muted-foreground: 265 12% 66%;
--accent: 251 28% 24%;
--accent-foreground: 302 30% 93%;
--destructive: 9 88% 66%;
--destructive-foreground: 0 0% 100%;
--border: 256 24% 28%;
--input: 256 24% 28%;
--ring: 325 100% 74%;
--radius: 1.25rem;
--msg-outgoing: 307 32% 20%;
--msg-incoming: 250 24% 18%;
--status-connected: 164 78% 58%;
--status-disconnected: 255 10% 48%;
--warning: 43 100% 63%;
--warning-foreground: 36 100% 12%;
--success: 164 78% 54%;
--success-foreground: 258 38% 12%;
--info: 191 90% 76%;
--info-foreground: 242 32% 18%;
--region-override: 278 100% 82%;
--favorite: 43 100% 66%;
--console: 191 90% 76%;
--console-command: 325 100% 82%;
--console-bg: 252 42% 8%;
--toast-error: 352 34% 16%;
--toast-error-foreground: 8 92% 82%;
--toast-error-border: 352 24% 26%;
--code-editor-bg: 255 28% 13%;
--font-sans: 'Nunito', 'Trebuchet MS', 'Segoe UI', sans-serif;
--scrollbar: 256 28% 24%;
--scrollbar-hover: 256 34% 32%;
--overlay: 258 40% 6%;
}
[data-theme='candy-dusk'] body {
background:
radial-gradient(circle at 20% 10%, hsl(325 100% 74% / 0.16), transparent 22%),
radial-gradient(circle at 85% 12%, hsl(191 90% 76% / 0.12), transparent 18%),
radial-gradient(circle at 50% 100%, hsl(43 100% 63% / 0.08), transparent 24%), hsl(258 38% 10%);
}
[data-theme='candy-dusk'] .bg-card {
background: linear-gradient(160deg, hsl(258 30% 16%), hsl(248 28% 18%));
box-shadow: inset 0 1px 0 hsl(302 50% 96% / 0.04);
}
[data-theme='candy-dusk'] .bg-popover {
background: linear-gradient(160deg, hsl(258 30% 17%), hsl(248 28% 19%));
}
[data-theme='candy-dusk'] .bg-msg-outgoing {
background: linear-gradient(135deg, hsl(307 34% 21%), hsl(325 28% 24%));
border-left: 2px solid hsl(325 100% 74% / 0.55);
}
[data-theme='candy-dusk'] .bg-msg-incoming {
background: linear-gradient(135deg, hsl(250 24% 18%), hsl(258 20% 20%));
border-left: 2px solid hsl(191 90% 76% / 0.38);
}
[data-theme='candy-dusk'] .bg-primary {
background: linear-gradient(135deg, hsl(325 100% 74%), hsl(289 84% 74%));
}
[data-theme='candy-dusk'] button {
border-radius: 999px;
transition:
transform 0.12s ease,
filter 0.2s ease,
background-color 0.15s ease,
color 0.15s ease;
}
[data-theme='candy-dusk'] button:hover {
filter: drop-shadow(0 0 10px hsl(325 100% 74% / 0.22))
drop-shadow(0 0 18px hsl(191 90% 76% / 0.08));
}
[data-theme='candy-dusk'] button:active {
transform: scale(0.98);
}
[data-theme='candy-dusk'] ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, hsl(325 48% 44%), hsl(256 46% 42%));
}
[data-theme='candy-dusk'] ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, hsl(325 66% 58%), hsl(191 58% 56%));
}
/* ── Paper Grove ("Field Notes") ──────────────────────────── */
:root[data-theme='paper-grove'] {
--background: 41 43% 93%;
--foreground: 148 16% 18%;
--card: 43 52% 97%;
--card-foreground: 148 16% 18%;
--popover: 43 52% 98%;
--popover-foreground: 148 16% 18%;
--primary: 157 54% 40%;
--primary-foreground: 45 60% 98%;
--secondary: 42 26% 87%;
--secondary-foreground: 148 14% 26%;
--muted: 42 22% 89%;
--muted-foreground: 148 10% 44%;
--accent: 36 42% 83%;
--accent-foreground: 148 16% 18%;
--destructive: 12 76% 58%;
--destructive-foreground: 0 0% 100%;
--border: 38 22% 76%;
--input: 38 22% 76%;
--ring: 157 54% 40%;
--radius: 0.9rem;
--msg-outgoing: 151 32% 90%;
--msg-incoming: 40 30% 94%;
--status-connected: 157 54% 38%;
--status-disconnected: 148 8% 58%;
--warning: 39 92% 46%;
--warning-foreground: 39 100% 12%;
--success: 157 54% 34%;
--success-foreground: 45 60% 98%;
--info: 227 78% 64%;
--info-foreground: 228 40% 20%;
--region-override: 273 56% 44%;
--favorite: 43 92% 48%;
--console: 157 54% 34%;
--console-command: 224 48% 42%;
--console-bg: 45 24% 89%;
--toast-error: 8 52% 94%;
--toast-error-foreground: 9 58% 40%;
--toast-error-border: 10 34% 78%;
--code-editor-bg: 42 30% 90%;
--font-sans: 'Avenir Next', 'Segoe UI', sans-serif;
--scrollbar: 36 18% 68%;
--scrollbar-hover: 36 22% 58%;
--overlay: 148 20% 12%;
}
[data-theme='paper-grove'] body {
background:
linear-gradient(hsl(157 20% 50% / 0.04) 1px, transparent 1px),
linear-gradient(90deg, hsl(157 20% 50% / 0.04) 1px, transparent 1px), hsl(41 43% 93%);
background-size:
32px 32px,
32px 32px,
auto;
}
[data-theme='paper-grove'] .bg-card {
background: linear-gradient(180deg, hsl(43 52% 98%), hsl(40 42% 95%));
box-shadow:
0 1px 0 hsl(0 0% 100% / 0.8),
0 8px 22px hsl(148 18% 20% / 0.06);
}
[data-theme='paper-grove'] .bg-popover {
background: linear-gradient(180deg, hsl(43 52% 98%), hsl(40 38% 96%));
}
[data-theme='paper-grove'] .bg-msg-outgoing {
background: linear-gradient(135deg, hsl(151 34% 90%), hsl(157 30% 87%));
border-left: 2px solid hsl(157 54% 40% / 0.45);
}
[data-theme='paper-grove'] .bg-msg-incoming {
background: linear-gradient(135deg, hsl(40 30% 95%), hsl(38 26% 92%));
border-left: 2px solid hsl(227 78% 64% / 0.28);
}
[data-theme='paper-grove'] .bg-primary {
background: linear-gradient(135deg, hsl(157 54% 40%), hsl(180 42% 42%));
}
[data-theme='paper-grove'] button {
box-shadow: 0 1px 0 hsl(0 0% 100% / 0.7);
transition:
transform 0.12s ease,
box-shadow 0.18s ease,
background-color 0.15s ease,
color 0.15s ease;
}
[data-theme='paper-grove'] button:hover {
transform: translateY(-1px);
box-shadow:
0 1px 0 hsl(0 0% 100% / 0.8),
0 6px 14px hsl(148 20% 20% / 0.08);
}
[data-theme='paper-grove'] button:active {
transform: translateY(0);
}
[data-theme='paper-grove'] ::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, hsl(157 26% 54%), hsl(227 26% 60%));
}
[data-theme='paper-grove'] ::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, hsl(157 34% 46%), hsl(227 34% 52%));
}

View File

@@ -364,6 +364,7 @@ export interface PaneState {
loading: boolean;
attempt: number;
error: string | null;
fetched_at?: number | null;
}
export interface TraceResponse {

View File

@@ -7,6 +7,8 @@ export interface Theme {
metaThemeColor: string;
}
export const THEME_CHANGE_EVENT = 'remoteterm-theme-change';
export const THEMES: Theme[] = [
{
id: 'original',
@@ -44,6 +46,24 @@ export const THEMES: Theme[] = [
swatches: ['#0D0607', '#151012', '#FF0066', '#2D1D22', '#FF8C1A', '#30ACD4'],
metaThemeColor: '#0D0607',
},
{
id: 'lagoon-pop',
name: 'Lagoon Pop',
swatches: ['#081A22', '#0F2630', '#23D7C6', '#173844', '#FF7A66', '#7C83FF'],
metaThemeColor: '#081A22',
},
{
id: 'candy-dusk',
name: 'Candy Dusk',
swatches: ['#140F24', '#201736', '#FF79C9', '#2A2144', '#FFC857', '#8BE9FD'],
metaThemeColor: '#140F24',
},
{
id: 'paper-grove',
name: 'Paper Grove',
swatches: ['#F7F1E4', '#FFF9EE', '#2F9E74', '#E7DEC8', '#E76F51', '#5C7CFA'],
metaThemeColor: '#F7F1E4',
},
];
const THEME_KEY = 'remoteterm-theme';
@@ -77,4 +97,8 @@ export function applyTheme(themeId: string): void {
meta.setAttribute('content', theme.metaThemeColor);
}
}
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent(THEME_CHANGE_EVENT, { detail: themeId }));
}
}

View File

@@ -35,11 +35,6 @@ npm run lint:fix
npm run format
echo -e "${GREEN}[frontend lint]${NC} Passed!"
echo -e "${BLUE}[licenses]${NC} Regenerating LICENSES.md (always run)..."
cd "$SCRIPT_DIR"
bash scripts/collect_licenses.sh LICENSES.md
echo -e "${GREEN}[licenses]${NC} LICENSES.md updated"
echo -e "${GREEN}=== Phase 1 complete ===${NC}"
echo

View File

@@ -58,6 +58,11 @@ echo -e "${GREEN}Frontend build complete!${NC}"
cd "$SCRIPT_DIR"
echo
echo -e "${YELLOW}Regenerating LICENSES.md...${NC}"
bash scripts/collect_licenses.sh LICENSES.md
echo -e "${GREEN}LICENSES.md updated!${NC}"
echo
# Prompt for version
echo -e "${YELLOW}Current versions:${NC}"
echo -n " pyproject.toml: "

View File

@@ -273,6 +273,33 @@ class TestConnectionMonitor:
mock_broadcast.assert_any_call(True, "TCP: test:4000")
assert rm._setup_complete is True
@pytest.mark.asyncio
async def test_monitor_does_not_retry_while_setup_already_running(self):
"""Monitor leaves an in-progress setup alone instead of queueing another one."""
from app.radio import RadioManager
rm = RadioManager()
mock_mc = MagicMock()
mock_mc.is_connected = True
rm._meshcore = mock_mc
rm._connection_info = "TCP: test:4000"
rm._last_connected = True
rm._setup_complete = False
rm._setup_in_progress = True
rm.post_connect_setup = AsyncMock()
async def _sleep(_seconds: float):
raise asyncio.CancelledError()
with patch("app.radio.asyncio.sleep", side_effect=_sleep):
await rm.start_connection_monitor()
try:
await rm._reconnect_task
finally:
await rm.stop_connection_monitor()
rm.post_connect_setup.assert_not_called()
class TestReconnectLock:
"""Tests for reconnect() lock serialization — no duplicate reconnections."""
@@ -722,3 +749,120 @@ class TestPostConnectSetupOrdering:
await rm.post_connect_setup()
mock_mc.commands.set_flood_scope.assert_awaited_once_with("")
@pytest.mark.asyncio
async def test_message_polling_starts_hourly_audit_by_default(self):
"""Post-connect setup always starts the message audit task by default."""
from app.models import AppSettings
from app.radio import RadioManager
rm = RadioManager()
mock_mc = MagicMock()
mock_mc.start_auto_message_fetching = AsyncMock()
mock_mc.commands.set_flood_scope = AsyncMock()
rm._meshcore = mock_mc
with (
patch("app.event_handlers.register_event_handlers"),
patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
patch(
"app.repository.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=AppSettings(),
),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
patch("app.radio_sync.start_periodic_sync"),
patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
patch("app.radio_sync.start_periodic_advert"),
patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
patch("app.radio_sync.start_message_polling") as mock_start_message_polling,
):
await rm.post_connect_setup()
mock_start_message_polling.assert_called_once()
@pytest.mark.asyncio
async def test_message_polling_starts_when_env_flag_enabled(self):
"""Post-connect setup also starts the same task when aggressive fallback is enabled."""
from app.models import AppSettings
from app.radio import RadioManager
rm = RadioManager()
mock_mc = MagicMock()
mock_mc.start_auto_message_fetching = AsyncMock()
mock_mc.commands.set_flood_scope = AsyncMock()
rm._meshcore = mock_mc
with (
patch("app.event_handlers.register_event_handlers"),
patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
patch(
"app.repository.AppSettingsRepository.get",
new_callable=AsyncMock,
return_value=AppSettings(),
),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
patch("app.radio_sync.start_periodic_sync"),
patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
patch("app.radio_sync.start_periodic_advert"),
patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
patch("app.radio_sync.start_message_polling") as mock_start_message_polling,
):
await rm.post_connect_setup()
mock_start_message_polling.assert_called_once()
@pytest.mark.asyncio
async def test_prepare_connected_radio_retries_timeout_once_before_failing(self):
"""Hung post-connect setup gets one retry before surfacing an operator error."""
from app.radio import RadioManager
from app.services.radio_lifecycle import prepare_connected_radio
rm = RadioManager()
rm._connection_info = "Serial: /dev/ttyUSB0"
rm.post_connect_setup = AsyncMock(
side_effect=[asyncio.TimeoutError(), asyncio.TimeoutError()]
)
with (
patch("app.services.radio_lifecycle.logger") as mock_logger,
patch("app.websocket.broadcast_error") as mock_broadcast_error,
patch("app.websocket.broadcast_health") as mock_broadcast_health,
):
with pytest.raises(RuntimeError, match="Post-connect setup timed out"):
await prepare_connected_radio(rm, broadcast_on_success=True)
assert rm.post_connect_setup.await_count == 2
mock_logger.warning.assert_called_once()
mock_logger.error.assert_called_once()
mock_broadcast_error.assert_called_once_with(
"Radio startup appears stuck",
"Initial radio offload took too long. Reboot the radio and restart the server.",
)
mock_broadcast_health.assert_not_called()
@pytest.mark.asyncio
async def test_prepare_connected_radio_succeeds_on_retry_after_timeout(self):
"""A slow first attempt can time out once without failing the reconnect flow."""
from app.radio import RadioManager
from app.services.radio_lifecycle import prepare_connected_radio
rm = RadioManager()
rm._connection_info = "Serial: /dev/ttyUSB0"
rm.post_connect_setup = AsyncMock(side_effect=[asyncio.TimeoutError(), None])
with (
patch("app.services.radio_lifecycle.logger") as mock_logger,
patch("app.websocket.broadcast_error") as mock_broadcast_error,
patch("app.websocket.broadcast_health") as mock_broadcast_health,
):
await prepare_connected_radio(rm, broadcast_on_success=True)
assert rm.post_connect_setup.await_count == 2
assert rm._last_connected is True
mock_logger.warning.assert_called_once()
mock_logger.error.assert_not_called()
mock_broadcast_error.assert_not_called()
mock_broadcast_health.assert_called_once_with(True, "Serial: /dev/ttyUSB0")

View File

@@ -16,6 +16,7 @@ from app.radio_sync import (
_message_poll_loop,
_periodic_advert_loop,
_periodic_sync_loop,
ensure_contact_on_radio,
is_polling_paused,
pause_polling,
sync_radio_time,
@@ -180,10 +181,16 @@ class TestSyncRecentContactsToRadio:
"""Test the sync_recent_contacts_to_radio function."""
@pytest.mark.asyncio
async def test_loads_contacts_not_on_radio(self, test_db):
"""Contacts not on radio are added via add_contact."""
async def test_loads_favorite_contacts_not_on_radio(self, test_db):
"""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),
]
)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -202,16 +209,16 @@ class TestSyncRecentContactsToRadio:
assert bob.on_radio is True
@pytest.mark.asyncio
async def test_favorites_loaded_before_recent_contacts(self, test_db):
"""Favorite contacts are loaded first, then recents until limit."""
async def test_fills_remaining_slots_with_recently_contacted_then_advertised(self, test_db):
"""Fill order is favorites, then recent contacts, then recent adverts."""
await _insert_contact(KEY_A, "Alice", last_contacted=100)
await _insert_contact(KEY_B, "Bob", last_contacted=2000)
await _insert_contact("cc" * 32, "Carol", last_contacted=1000)
await _insert_contact("dd" * 32, "Dave", last_advert=3000)
await _insert_contact("ee" * 32, "Eve", last_advert=2500)
# Set max_radio_contacts=2 and add KEY_A as favorite
await AppSettingsRepository.update(
max_radio_contacts=2,
favorites=[Favorite(type="contact", id=KEY_A)],
max_radio_contacts=5, favorites=[Favorite(type="contact", id=KEY_A)]
)
mock_mc = MagicMock()
@@ -223,22 +230,72 @@ class TestSyncRecentContactsToRadio:
radio_manager._meshcore = mock_mc
result = await sync_recent_contacts_to_radio()
assert result["loaded"] == 2
# KEY_A (favorite) should be loaded first, then KEY_B (most recent)
assert result["loaded"] == 4
loaded_keys = [
call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list
]
assert loaded_keys == [KEY_A, KEY_B]
assert loaded_keys == [KEY_A, KEY_B, "cc" * 32, "dd" * 32]
@pytest.mark.asyncio
async def test_favorite_contact_not_loaded_twice_if_also_recent(self, test_db):
"""A favorite contact that is also recent is loaded only once."""
async def test_favorites_can_exceed_non_favorite_refill_target(self, test_db):
"""Favorites are reloaded even when they exceed the 80% background refill target."""
favorite_keys = ["aa" * 32, "bb" * 32, "cc" * 32, "dd" * 32]
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],
)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
mock_result = MagicMock()
mock_result.type = EventType.OK
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
radio_manager._meshcore = mock_mc
result = await sync_recent_contacts_to_radio()
assert result["loaded"] == 4
loaded_keys = [
call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list
]
assert loaded_keys == favorite_keys
@pytest.mark.asyncio
async def test_advert_fill_skips_repeaters(self, test_db):
"""Recent advert fallback only considers non-repeaters."""
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=[])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
mock_result = MagicMock()
mock_result.type = EventType.OK
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
radio_manager._meshcore = mock_mc
result = await sync_recent_contacts_to_radio()
assert result["loaded"] == 1
payload = mock_mc.commands.add_contact.call_args.args[0]
assert payload["public_key"] == KEY_B
@pytest.mark.asyncio
async def test_duplicate_favorite_not_loaded_twice(self, test_db):
"""Duplicate favorite entries still load the contact only once."""
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)],
favorites=[
Favorite(type="contact", id=KEY_A),
Favorite(type="contact", id=KEY_A),
],
)
mock_mc = MagicMock()
@@ -260,6 +317,7 @@ class TestSyncRecentContactsToRadio:
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=True)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
@@ -319,6 +377,7 @@ class TestSyncRecentContactsToRadio:
async def test_marks_on_radio_when_found_but_not_flagged(self, test_db):
"""Contact found on radio but not flagged gets set_on_radio(True)."""
await _insert_contact(KEY_A, "Alice", on_radio=False)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
@@ -335,6 +394,7 @@ class TestSyncRecentContactsToRadio:
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)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -360,6 +420,7 @@ class TestSyncRecentContactsToRadio:
last_path_len=2,
out_path_hash_mode=1,
)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -388,6 +449,7 @@ class TestSyncRecentContactsToRadio:
last_path_len=-125,
out_path_hash_mode=2,
)
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -412,6 +474,7 @@ class TestSyncRecentContactsToRadio:
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)])
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
@@ -451,6 +514,7 @@ class TestSyncRecentContactsToRadio:
"""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)])
old_mc = MagicMock(name="old_mc")
new_mc = MagicMock(name="new_mc")
@@ -471,6 +535,26 @@ class TestSyncRecentContactsToRadio:
new_mc.commands.add_contact.assert_called_once()
old_mc.commands.add_contact.assert_not_called()
@pytest.mark.asyncio
async def test_ensure_contact_on_radio_loads_single_contact_even_when_not_favorited(
self, test_db
):
"""Targeted sync loads one contact without needing it in favorites."""
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
mock_mc = MagicMock()
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
mock_result = MagicMock()
mock_result.type = EventType.OK
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
radio_manager._meshcore = mock_mc
result = await ensure_contact_on_radio(KEY_A, force=True)
assert result["loaded"] == 1
payload = mock_mc.commands.add_contact.call_args.args[0]
assert payload["public_key"] == KEY_A
class TestSyncAndOffloadContacts:
"""Test sync_and_offload_contacts: pull contacts from radio, save to DB, remove from radio."""
@@ -511,7 +595,7 @@ class TestSyncAndOffloadContacts:
@pytest.mark.asyncio
async def test_claims_prefix_messages_for_each_contact(self, test_db):
"""claim_prefix_messages is called for each synced contact."""
"""Prefix message claims still complete via scheduled reconciliation tasks."""
from app.radio_sync import sync_and_offload_contacts
# Pre-insert a message with a prefix key that matches KEY_A
@@ -536,13 +620,73 @@ class TestSyncAndOffloadContacts:
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
await sync_and_offload_contacts(mock_mc)
created_tasks: list[asyncio.Task] = []
real_create_task = asyncio.create_task
def _capture_task(coro):
task = real_create_task(coro)
created_tasks.append(task)
return task
with patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task):
await sync_and_offload_contacts(mock_mc)
await asyncio.gather(*created_tasks)
# Verify the prefix message was claimed (promoted to full key)
messages = await MessageRepository.get_all(conversation_key=KEY_A)
assert len(messages) == 1
assert messages[0].conversation_key == KEY_A.lower()
@pytest.mark.asyncio
async def test_reconciliation_does_not_block_contact_removal(self, test_db):
"""Slow reconciliation work is scheduled in background, not awaited inline."""
from app.radio_sync import sync_and_offload_contacts
contact_payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}}
mock_get_result = MagicMock()
mock_get_result.type = EventType.NEW_CONTACT
mock_get_result.payload = contact_payload
mock_remove_result = MagicMock()
mock_remove_result.type = EventType.OK
mock_mc = MagicMock()
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
reconcile_started = asyncio.Event()
reconcile_release = asyncio.Event()
created_tasks: list[asyncio.Task] = []
real_create_task = asyncio.create_task
async def _slow_reconcile(*, public_key: str, contact_name: str | None, log):
del public_key, contact_name, log
reconcile_started.set()
await reconcile_release.wait()
def _capture_task(coro):
task = real_create_task(coro)
created_tasks.append(task)
return task
with (
patch("app.radio_sync.reconcile_contact_messages", side_effect=_slow_reconcile),
patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task),
):
result = await sync_and_offload_contacts(mock_mc)
await asyncio.sleep(0)
assert result["synced"] == 1
assert result["removed"] == 1
assert reconcile_started.is_set() is True
assert created_tasks and created_tasks[0].done() is False
mock_mc.commands.remove_contact.assert_awaited_once()
reconcile_release.set()
await asyncio.gather(*created_tasks)
@pytest.mark.asyncio
async def test_handles_remove_failure_gracefully(self, test_db):
"""Failed remove_contact logs warning but continues to next contact."""
@@ -1047,6 +1191,34 @@ def _sleep_controller(*, cancel_after: int = 2):
class TestMessagePollLoopRaces:
"""Regression tests for disconnect/reconnect race paths in _message_poll_loop."""
@pytest.mark.asyncio
async def test_uses_hourly_audit_interval_when_fallback_disabled(self):
rm, _mc = _make_connected_manager()
mock_sleep, sleep_calls = _sleep_controller(cancel_after=1)
with (
patch("app.radio_sync.radio_manager", rm),
patch("app.radio_sync.settings.enable_message_poll_fallback", False),
patch("asyncio.sleep", side_effect=mock_sleep),
):
await _message_poll_loop()
assert sleep_calls == [3600]
@pytest.mark.asyncio
async def test_uses_fast_poll_interval_when_fallback_enabled(self):
rm, _mc = _make_connected_manager()
mock_sleep, sleep_calls = _sleep_controller(cancel_after=1)
with (
patch("app.radio_sync.radio_manager", rm),
patch("app.radio_sync.settings.enable_message_poll_fallback", True),
patch("asyncio.sleep", side_effect=mock_sleep),
):
await _message_poll_loop()
assert sleep_calls == [10]
@pytest.mark.asyncio
async def test_disconnect_race_between_precheck_and_lock(self):
"""RadioDisconnectedError between is_connected and radio_operation()
@@ -1104,6 +1276,46 @@ class TestMessagePollLoopRaces:
mock_poll.assert_called_once_with(mock_mc)
@pytest.mark.asyncio
async def test_hourly_audit_crows_loudly_when_it_finds_hidden_messages(self):
rm, mock_mc = _make_connected_manager()
mock_sleep, _ = _sleep_controller(cancel_after=2)
with (
patch("app.radio_sync.radio_manager", rm),
patch("app.radio_sync.settings.enable_message_poll_fallback", False),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.poll_for_messages", new_callable=AsyncMock, return_value=2),
patch("app.radio_sync.logger") as mock_logger,
patch("app.radio_sync.broadcast_error") as mock_broadcast_error,
):
await _message_poll_loop()
mock_logger.error.assert_called_once()
mock_broadcast_error.assert_called_once_with(
"A periodic poll task has discovered radio inconsistencies.",
"Please check the logs for recommendations (search "
"'MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK').",
)
@pytest.mark.asyncio
async def test_fast_poll_logs_missed_messages_without_error_toast(self):
rm, mock_mc = _make_connected_manager()
mock_sleep, _ = _sleep_controller(cancel_after=2)
with (
patch("app.radio_sync.radio_manager", rm),
patch("app.radio_sync.settings.enable_message_poll_fallback", True),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.poll_for_messages", new_callable=AsyncMock, return_value=2),
patch("app.radio_sync.logger") as mock_logger,
patch("app.radio_sync.broadcast_error") as mock_broadcast_error,
):
await _message_poll_loop()
mock_logger.warning.assert_called_once()
mock_broadcast_error.assert_not_called()
class TestPeriodicAdvertLoopRaces:
"""Regression tests for disconnect/reconnect race paths in _periodic_advert_loop."""
@@ -1170,6 +1382,36 @@ class TestPeriodicAdvertLoopRaces:
class TestPeriodicSyncLoopRaces:
"""Regression tests for disconnect/reconnect race paths in _periodic_sync_loop."""
@pytest.mark.asyncio
async def test_should_run_full_periodic_sync_at_trigger_threshold(self, test_db):
"""Occupancy at 95% of configured capacity triggers a full offload/reload."""
from app.radio_sync import should_run_full_periodic_sync
await AppSettingsRepository.update(max_radio_contacts=100)
mock_mc = MagicMock()
mock_result = MagicMock()
mock_result.type = EventType.NEW_CONTACT
mock_result.payload = {f"{i:064x}": {"adv_name": f"Node{i}"} for i in range(95)}
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result)
assert await should_run_full_periodic_sync(mock_mc) is True
@pytest.mark.asyncio
async def test_should_skip_full_periodic_sync_below_trigger_threshold(self, test_db):
"""Occupancy below 95% of configured capacity does not trigger offload/reload."""
from app.radio_sync import should_run_full_periodic_sync
await AppSettingsRepository.update(max_radio_contacts=100)
mock_mc = MagicMock()
mock_result = MagicMock()
mock_result.type = EventType.NEW_CONTACT
mock_result.payload = {f"{i:064x}": {"adv_name": f"Node{i}"} for i in range(94)}
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_result)
assert await should_run_full_periodic_sync(mock_mc) is False
@pytest.mark.asyncio
async def test_disconnect_race_between_precheck_and_lock(self):
"""RadioDisconnectedError between is_connected and radio_operation()
@@ -1181,11 +1423,17 @@ class TestPeriodicSyncLoopRaces:
with (
patch("app.radio_sync.radio_manager", rm),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
patch(
"app.radio_sync.should_run_full_periodic_sync", new_callable=AsyncMock
) as mock_check,
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
):
await _periodic_sync_loop()
mock_cleanup.assert_called_once()
mock_check.assert_not_called()
mock_sync.assert_not_called()
mock_time.assert_not_called()
assert len(sleep_calls) == 2
@@ -1201,6 +1449,10 @@ class TestPeriodicSyncLoopRaces:
with (
patch("app.radio_sync.radio_manager", rm),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
patch(
"app.radio_sync.should_run_full_periodic_sync", new_callable=AsyncMock
) as mock_check,
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
):
@@ -1208,6 +1460,8 @@ class TestPeriodicSyncLoopRaces:
finally:
lock.release()
mock_cleanup.assert_called_once()
mock_check.assert_not_called()
mock_sync.assert_not_called()
mock_time.assert_not_called()
@@ -1222,10 +1476,41 @@ class TestPeriodicSyncLoopRaces:
with (
patch("app.radio_sync.radio_manager", rm),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
patch(
"app.radio_sync.should_run_full_periodic_sync",
new_callable=AsyncMock,
return_value=True,
),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
):
await _periodic_sync_loop()
mock_cleanup.assert_called_once()
mock_sync.assert_called_once_with(mock_mc)
mock_time.assert_called_once_with(mock_mc)
@pytest.mark.asyncio
async def test_skips_full_sync_below_threshold_but_still_syncs_time(self):
"""Periodic maintenance still does time sync when occupancy is below the trigger."""
rm, mock_mc = _make_connected_manager()
mock_sleep, _ = _sleep_controller(cancel_after=2)
with (
patch("app.radio_sync.radio_manager", rm),
patch("asyncio.sleep", side_effect=mock_sleep),
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
patch(
"app.radio_sync.should_run_full_periodic_sync",
new_callable=AsyncMock,
return_value=False,
),
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
):
await _periodic_sync_loop()
mock_cleanup.assert_called_once()
mock_sync.assert_not_called()
mock_time.assert_called_once_with(mock_mc)

View File

@@ -525,6 +525,49 @@ class TestContactRepositoryResolvePrefixes:
assert result == {}
class TestContactRepositoryRecentQueries:
"""Test recent-contact selection helpers used for radio fill."""
@pytest.mark.asyncio
async def test_recently_advertised_includes_contacted_contacts(self, test_db):
stale_contacted_fresh_advert = "ab" * 32
advert_only = "cd" * 32
repeater = "ef" * 32
await ContactRepository.upsert(
{
"public_key": stale_contacted_fresh_advert,
"name": "SeenAgain",
"type": 1,
"last_contacted": 100,
"last_advert": 5000,
}
)
await ContactRepository.upsert(
{
"public_key": advert_only,
"name": "AdvertOnly",
"type": 1,
"last_advert": 4000,
}
)
await ContactRepository.upsert(
{
"public_key": repeater,
"name": "Repeater",
"type": 2,
"last_advert": 6000,
}
)
contacts = await ContactRepository.get_recently_advertised_non_repeaters()
assert [contact.public_key for contact in contacts] == [
stale_contacted_fresh_advert,
advert_only,
]
class TestAppSettingsRepository:
"""Test AppSettingsRepository parsing and migration edge cases."""

View File

@@ -153,6 +153,9 @@ class TestOutgoingDMBroadcast:
assert contact_payload["out_path"] == "aa00bb00"
assert contact_payload["out_path_len"] == 2
assert contact_payload["out_path_hash_mode"] == 1
contact = await ContactRepository.get_by_key(pub_key)
assert contact is not None
assert contact.on_radio is True
@pytest.mark.asyncio
async def test_send_dm_prefers_route_override_over_learned_path(self, test_db):

View File

@@ -133,21 +133,35 @@ class TestToggleFavorite:
@pytest.mark.asyncio
async def test_adds_when_not_favorited(self, test_db):
request = FavoriteRequest(type="contact", id="aa" * 32)
result = await toggle_favorite(request)
with (
patch("app.radio_sync.ensure_contact_on_radio", new_callable=AsyncMock) as mock_sync,
patch("app.routers.settings.asyncio.create_task") as mock_create_task,
):
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
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("channel", "ABCD")
await AppSettingsRepository.add_favorite("contact", "aa" * 32)
request = FavoriteRequest(type="channel", id="ABCD")
result = await toggle_favorite(request)
request = FavoriteRequest(type="contact", id="aa" * 32)
with (
patch("app.radio_sync.ensure_contact_on_radio", new_callable=AsyncMock) as mock_sync,
patch("app.routers.settings.asyncio.create_task") as mock_create_task,
):
mock_create_task.side_effect = lambda coro: coro.close()
result = await toggle_favorite(request)
assert result.favorites == []
mock_sync.assert_not_called()
mock_create_task.assert_not_called()
class TestMigratePreferences: