Compare commits
19 Commits
codex-refa
...
notificati
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e17d1ba4b4 | ||
|
|
48a49ce48d | ||
|
|
9d1676818f | ||
|
|
b5edd00220 | ||
|
|
d3a7b7ce07 | ||
|
|
42ca242ee1 | ||
|
|
3e7e0669c5 | ||
|
|
bee273ab56 | ||
|
|
1842bcf43e | ||
|
|
7c68973e30 | ||
|
|
c9ede1f71f | ||
|
|
42e9628d98 | ||
|
|
1bf760121d | ||
|
|
bb4a601788 | ||
|
|
d0ed3484ce | ||
|
|
738e0b9815 | ||
|
|
97997e23e8 | ||
|
|
eaee66f836 | ||
|
|
9a99d3f17e |
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -4,18 +4,20 @@ Radio sync and offload management.
|
||||
This module handles syncing contacts and channels from the radio to the database,
|
||||
then removing them from the radio to free up space for new discoveries.
|
||||
|
||||
Also handles loading recent non-repeater contacts TO the radio for DM ACK support.
|
||||
Also handles loading favorites plus recently active contacts TO the radio for DM ACK support.
|
||||
Also handles periodic message polling as a fallback for platforms where push events
|
||||
don't work reliably.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from meshcore import EventType, MeshCore
|
||||
|
||||
from app.config import settings
|
||||
from app.event_handlers import cleanup_expired_acks
|
||||
from app.models import Contact, ContactUpsert
|
||||
from app.radio import RadioOperationBusyError
|
||||
@@ -27,6 +29,7 @@ from app.repository import (
|
||||
)
|
||||
from app.services.contact_reconciliation import reconcile_contact_messages
|
||||
from app.services.radio_runtime import radio_runtime as radio_manager
|
||||
from app.websocket import broadcast_error
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -49,6 +52,26 @@ def _contact_sync_debug_fields(contact: Contact) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
async def _reconcile_contact_messages_background(
|
||||
public_key: str,
|
||||
contact_name: str | None,
|
||||
) -> None:
|
||||
"""Run contact/message reconciliation outside the radio critical path."""
|
||||
try:
|
||||
await reconcile_contact_messages(
|
||||
public_key=public_key,
|
||||
contact_name=contact_name,
|
||||
log=logger,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Background contact reconciliation failed for %s: %s",
|
||||
public_key[:12],
|
||||
exc,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> str | None:
|
||||
"""Parse a radio channel-slot payload and upsert to the database.
|
||||
|
||||
@@ -78,9 +101,12 @@ async def upsert_channel_from_radio_slot(payload: dict, *, on_radio: bool) -> st
|
||||
# Message poll task handle
|
||||
_message_poll_task: asyncio.Task | None = None
|
||||
|
||||
# Message poll interval in seconds (10s gives DM ACKs plenty of time to arrive)
|
||||
# Message poll interval in seconds when aggressive fallback is enabled.
|
||||
MESSAGE_POLL_INTERVAL = 10
|
||||
|
||||
# Always-on audit interval when aggressive fallback is disabled.
|
||||
MESSAGE_POLL_AUDIT_INTERVAL = 3600
|
||||
|
||||
# Periodic advertisement task handle
|
||||
_advert_task: asyncio.Task | None = None
|
||||
|
||||
@@ -119,9 +145,58 @@ async def pause_polling():
|
||||
# Background task handle
|
||||
_sync_task: asyncio.Task | None = None
|
||||
|
||||
# Sync interval in seconds (5 minutes)
|
||||
# Periodic maintenance check interval in seconds (5 minutes)
|
||||
SYNC_INTERVAL = 300
|
||||
|
||||
# Reload non-favorite contacts up to 80% of configured radio capacity after offload.
|
||||
RADIO_CONTACT_REFILL_RATIO = 0.80
|
||||
|
||||
# Trigger a full offload/reload once occupancy reaches 95% of configured capacity.
|
||||
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
|
||||
|
||||
|
||||
def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
|
||||
"""Return (refill_target, full_sync_trigger) for the configured capacity."""
|
||||
capacity = max(1, max_contacts)
|
||||
refill_target = max(1, min(capacity, int((capacity * RADIO_CONTACT_REFILL_RATIO) + 0.5)))
|
||||
full_sync_trigger = max(
|
||||
refill_target,
|
||||
min(capacity, math.ceil(capacity * RADIO_CONTACT_FULL_SYNC_RATIO)),
|
||||
)
|
||||
return refill_target, full_sync_trigger
|
||||
|
||||
|
||||
async def should_run_full_periodic_sync(mc: MeshCore) -> bool:
|
||||
"""Check current radio occupancy and decide whether to offload/reload."""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
capacity = app_settings.max_radio_contacts
|
||||
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
|
||||
|
||||
result = await mc.commands.get_contacts()
|
||||
if result is None or result.type == EventType.ERROR:
|
||||
logger.warning("Periodic sync occupancy check failed: %s", result)
|
||||
return False
|
||||
|
||||
current_contacts = len(result.payload or {})
|
||||
if current_contacts >= full_sync_trigger:
|
||||
logger.info(
|
||||
"Running full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
|
||||
current_contacts,
|
||||
capacity,
|
||||
full_sync_trigger,
|
||||
refill_target,
|
||||
)
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
"Skipping full radio sync: %d/%d contacts on radio (trigger=%d, refill_target=%d)",
|
||||
current_contacts,
|
||||
capacity,
|
||||
full_sync_trigger,
|
||||
refill_target,
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
"""
|
||||
@@ -157,10 +232,11 @@ async def sync_and_offload_contacts(mc: MeshCore) -> dict:
|
||||
await ContactRepository.upsert(
|
||||
ContactUpsert.from_radio_dict(public_key, contact_data, on_radio=False)
|
||||
)
|
||||
await reconcile_contact_messages(
|
||||
public_key=public_key,
|
||||
contact_name=contact_data.get("adv_name"),
|
||||
log=logger,
|
||||
asyncio.create_task(
|
||||
_reconcile_contact_messages_background(
|
||||
public_key,
|
||||
contact_data.get("adv_name"),
|
||||
)
|
||||
)
|
||||
synced += 1
|
||||
|
||||
@@ -290,10 +366,10 @@ async def sync_and_offload_all(mc: MeshCore) -> dict:
|
||||
# Ensure default channels exist
|
||||
await ensure_default_channels()
|
||||
|
||||
# Reload favorites and recent contacts back onto the radio immediately
|
||||
# so favorited contacts don't stay in the on_radio=False limbo until the
|
||||
# next advertisement arrives. Pass mc directly since the caller already
|
||||
# holds the radio operation lock (asyncio.Lock is not reentrant).
|
||||
# Reload favorites plus a working-set fill back onto the radio immediately
|
||||
# so they do not stay in on_radio=False limbo after offload. Pass mc directly
|
||||
# since the caller already holds the radio operation lock (asyncio.Lock is not
|
||||
# reentrant).
|
||||
reload_result = await sync_recent_contacts_to_radio(force=True, mc=mc)
|
||||
|
||||
return {
|
||||
@@ -374,11 +450,10 @@ async def _message_poll_loop():
|
||||
"""Background task that periodically polls for messages."""
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(MESSAGE_POLL_INTERVAL)
|
||||
|
||||
# Clean up expired pending ACKs every poll cycle so they don't
|
||||
# accumulate when no ACKs arrive (e.g. all recipients out of range).
|
||||
cleanup_expired_acks()
|
||||
aggressive_fallback = settings.enable_message_poll_fallback
|
||||
await asyncio.sleep(
|
||||
MESSAGE_POLL_INTERVAL if aggressive_fallback else MESSAGE_POLL_AUDIT_INTERVAL
|
||||
)
|
||||
|
||||
if radio_manager.is_connected and not is_polling_paused():
|
||||
try:
|
||||
@@ -389,10 +464,24 @@ async def _message_poll_loop():
|
||||
) as mc:
|
||||
count = await poll_for_messages(mc)
|
||||
if count > 0:
|
||||
logger.warning(
|
||||
"Poll loop caught %d message(s) missed by auto-fetch",
|
||||
count,
|
||||
)
|
||||
if aggressive_fallback:
|
||||
logger.warning(
|
||||
"Poll loop caught %d message(s) missed by auto-fetch",
|
||||
count,
|
||||
)
|
||||
else:
|
||||
logger.error(
|
||||
"Periodic radio audit caught %d message(s) that were not "
|
||||
"surfaced via event subscription. See README and consider "
|
||||
"setting MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK=true to "
|
||||
"enable more frequent polling.",
|
||||
count,
|
||||
)
|
||||
broadcast_error(
|
||||
"A periodic poll task has discovered radio inconsistencies.",
|
||||
"Please check the logs for recommendations (search "
|
||||
"'MESHCORE_ENABLE_MESSAGE_POLL_FALLBACK').",
|
||||
)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping message poll: radio busy")
|
||||
|
||||
@@ -407,7 +496,16 @@ def start_message_polling():
|
||||
global _message_poll_task
|
||||
if _message_poll_task is None or _message_poll_task.done():
|
||||
_message_poll_task = asyncio.create_task(_message_poll_loop())
|
||||
logger.info("Started periodic message polling (interval: %ds)", MESSAGE_POLL_INTERVAL)
|
||||
if settings.enable_message_poll_fallback:
|
||||
logger.info(
|
||||
"Started periodic message polling task (aggressive fallback, interval: %ds)",
|
||||
MESSAGE_POLL_INTERVAL,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Started periodic message audit task (interval: %ds)",
|
||||
MESSAGE_POLL_AUDIT_INTERVAL,
|
||||
)
|
||||
|
||||
|
||||
async def stop_message_polling():
|
||||
@@ -554,6 +652,7 @@ async def _periodic_sync_loop():
|
||||
while True:
|
||||
try:
|
||||
await asyncio.sleep(SYNC_INTERVAL)
|
||||
cleanup_expired_acks()
|
||||
if not radio_manager.is_connected:
|
||||
continue
|
||||
|
||||
@@ -562,8 +661,8 @@ async def _periodic_sync_loop():
|
||||
"periodic_sync",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
logger.debug("Running periodic radio sync")
|
||||
await sync_and_offload_all(mc)
|
||||
if await should_run_full_periodic_sync(mc):
|
||||
await sync_and_offload_all(mc)
|
||||
await sync_radio_time(mc)
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping periodic sync: radio busy")
|
||||
@@ -604,13 +703,19 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
"""
|
||||
Core logic for loading contacts onto the radio.
|
||||
|
||||
Favorite contacts are prioritized first, then recent non-repeater contacts
|
||||
fill remaining slots up to max_radio_contacts.
|
||||
Fill order is:
|
||||
1. Favorite contacts
|
||||
2. Most recently interacted-with non-repeaters
|
||||
3. Most recently advert-heard non-repeaters without interaction history
|
||||
|
||||
Favorite contacts are always reloaded first, up to the configured capacity.
|
||||
Additional non-favorite fill stops at the refill target (80% of capacity).
|
||||
|
||||
Caller must hold the radio operation lock and pass a valid MeshCore instance.
|
||||
"""
|
||||
app_settings = await AppSettingsRepository.get()
|
||||
max_contacts = app_settings.max_radio_contacts
|
||||
refill_target, _full_sync_trigger = _compute_radio_contact_limits(max_contacts)
|
||||
selected_contacts: list[Contact] = []
|
||||
selected_keys: set[str] = set()
|
||||
|
||||
@@ -637,34 +742,101 @@ async def _sync_contacts_to_radio_inner(mc: MeshCore) -> dict:
|
||||
if len(selected_contacts) >= max_contacts:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < max_contacts:
|
||||
recent_contacts = await ContactRepository.get_recent_non_repeaters(limit=max_contacts)
|
||||
for contact in recent_contacts:
|
||||
if len(selected_contacts) < refill_target:
|
||||
for contact in await ContactRepository.get_recently_contacted_non_repeaters(
|
||||
limit=max_contacts
|
||||
):
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
if len(selected_contacts) >= max_contacts:
|
||||
if len(selected_contacts) >= refill_target:
|
||||
break
|
||||
|
||||
if len(selected_contacts) < refill_target:
|
||||
for contact in await ContactRepository.get_recently_advertised_non_repeaters(
|
||||
limit=max_contacts
|
||||
):
|
||||
key = contact.public_key.lower()
|
||||
if key in selected_keys:
|
||||
continue
|
||||
selected_keys.add(key)
|
||||
selected_contacts.append(contact)
|
||||
if len(selected_contacts) >= refill_target:
|
||||
break
|
||||
|
||||
logger.debug(
|
||||
"Selected %d contacts to sync (%d favorite contacts first, limit=%d)",
|
||||
"Selected %d contacts to sync (%d favorites, refill_target=%d, capacity=%d)",
|
||||
len(selected_contacts),
|
||||
favorite_contacts_loaded,
|
||||
refill_target,
|
||||
max_contacts,
|
||||
)
|
||||
return await _load_contacts_to_radio(mc, selected_contacts)
|
||||
|
||||
|
||||
async def ensure_contact_on_radio(
|
||||
public_key: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
mc: MeshCore | None = None,
|
||||
) -> dict:
|
||||
"""Ensure one contact is loaded on the radio for ACK/routing support."""
|
||||
global _last_contact_sync
|
||||
|
||||
now = time.time()
|
||||
if not force and (now - _last_contact_sync) < CONTACT_SYNC_THROTTLE_SECONDS:
|
||||
logger.debug(
|
||||
"Single-contact sync throttled (last sync %ds ago)",
|
||||
int(now - _last_contact_sync),
|
||||
)
|
||||
return {"loaded": 0, "throttled": True}
|
||||
|
||||
try:
|
||||
contact = await ContactRepository.get_by_key_or_prefix(public_key)
|
||||
except AmbiguousPublicKeyPrefixError:
|
||||
logger.warning("Cannot sync favorite contact '%s': ambiguous key prefix", public_key)
|
||||
return {"loaded": 0, "error": "Ambiguous contact key prefix"}
|
||||
|
||||
if not contact:
|
||||
logger.debug("Cannot sync favorite contact %s: not found", public_key[:12])
|
||||
return {"loaded": 0, "error": "Contact not found"}
|
||||
|
||||
if mc is not None:
|
||||
_last_contact_sync = now
|
||||
return await _load_contacts_to_radio(mc, [contact])
|
||||
|
||||
if not radio_manager.is_connected or radio_manager.meshcore is None:
|
||||
logger.debug("Cannot sync favorite contact to radio: not connected")
|
||||
return {"loaded": 0, "error": "Radio not connected"}
|
||||
|
||||
try:
|
||||
async with radio_manager.radio_operation(
|
||||
"ensure_contact_on_radio",
|
||||
blocking=False,
|
||||
) as mc:
|
||||
_last_contact_sync = now
|
||||
assert mc is not None
|
||||
return await _load_contacts_to_radio(mc, [contact])
|
||||
except RadioOperationBusyError:
|
||||
logger.debug("Skipping favorite contact sync: radio busy")
|
||||
return {"loaded": 0, "busy": True}
|
||||
except Exception as e:
|
||||
logger.error("Error syncing favorite contact to radio: %s", e, exc_info=True)
|
||||
return {"loaded": 0, "error": str(e)}
|
||||
|
||||
|
||||
async def _load_contacts_to_radio(mc: MeshCore, contacts: list[Contact]) -> dict:
|
||||
"""Load the provided contacts onto the radio."""
|
||||
loaded = 0
|
||||
already_on_radio = 0
|
||||
failed = 0
|
||||
|
||||
for contact in selected_contacts:
|
||||
# Check if already on radio
|
||||
for contact in contacts:
|
||||
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
|
||||
if radio_contact:
|
||||
already_on_radio += 1
|
||||
# Update DB if not marked as on_radio
|
||||
if not contact.on_radio:
|
||||
await ContactRepository.set_on_radio(contact.public_key, True)
|
||||
continue
|
||||
@@ -722,8 +894,10 @@ async def sync_recent_contacts_to_radio(force: bool = False, mc: MeshCore | None
|
||||
"""
|
||||
Load contacts to the radio for DM ACK support.
|
||||
|
||||
Favorite contacts are prioritized first, then recent non-repeater contacts
|
||||
fill remaining slots up to max_radio_contacts.
|
||||
Fill order is favorites, then recently contacted non-repeaters,
|
||||
then recently advert-heard non-repeaters. Favorites are always reloaded
|
||||
up to the configured capacity; additional non-favorite fill stops at the
|
||||
80% refill target.
|
||||
Only runs at most once every CONTACT_SYNC_THROTTLE_SECONDS unless forced.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -253,17 +253,28 @@ class ContactRepository:
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recent_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get the most recently active non-repeater contacts.
|
||||
|
||||
Orders by most recent activity (last_contacted or last_advert),
|
||||
excluding repeaters (type=2).
|
||||
"""
|
||||
async def get_recently_contacted_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get recently interacted-with non-repeater contacts."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2
|
||||
ORDER BY COALESCE(last_contacted, 0) DESC, COALESCE(last_advert, 0) DESC
|
||||
WHERE type != 2 AND last_contacted IS NOT NULL
|
||||
ORDER BY last_contacted DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
return [ContactRepository._row_to_contact(row) for row in rows]
|
||||
|
||||
@staticmethod
|
||||
async def get_recently_advertised_non_repeaters(limit: int = 200) -> list[Contact]:
|
||||
"""Get recently advert-heard non-repeater contacts."""
|
||||
cursor = await db.conn.execute(
|
||||
"""
|
||||
SELECT * FROM contacts
|
||||
WHERE type != 2 AND last_advert IS NOT NULL
|
||||
ORDER BY last_advert DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(limit,),
|
||||
|
||||
@@ -451,7 +451,7 @@ async def send_repeater_command(public_key: str, request: CommandRequest) -> Com
|
||||
- get radio, set radio <freq,bw,sf,cr>
|
||||
- tempradio <freq,bw,sf,cr,minutes>
|
||||
- setperm <pubkey> <permission> (0=guest, 1=read-only, 2=read-write, 3=admin)
|
||||
- clock, clock sync
|
||||
- clock, clock sync, time <epoch_seconds>
|
||||
- reboot
|
||||
- ver
|
||||
"""
|
||||
|
||||
@@ -19,7 +19,8 @@ class AppSettingsUpdate(BaseModel):
|
||||
ge=1,
|
||||
le=1000,
|
||||
description=(
|
||||
"Maximum contacts to keep on radio (favorites first, then recent non-repeaters)"
|
||||
"Configured radio contact capacity used for maintenance thresholds and "
|
||||
"background refill behavior"
|
||||
),
|
||||
)
|
||||
auto_decrypt_dm_on_advert: bool | None = Field(
|
||||
@@ -161,12 +162,12 @@ async def toggle_favorite(request: FavoriteRequest) -> AppSettings:
|
||||
logger.info("Adding favorite: %s %s", request.type, request.id[:12])
|
||||
result = await AppSettingsRepository.add_favorite(request.type, request.id)
|
||||
|
||||
# When a contact favorite changes, sync the radio so the contact is
|
||||
# loaded/unloaded immediately rather than waiting for the next advert.
|
||||
if request.type == "contact":
|
||||
from app.radio_sync import sync_recent_contacts_to_radio
|
||||
# When a contact is newly favorited, load just that contact to the radio
|
||||
# immediately so DM ACK support does not wait for the next maintenance cycle.
|
||||
if request.type == "contact" and not is_favorited:
|
||||
from app.radio_sync import ensure_contact_on_radio
|
||||
|
||||
asyncio.create_task(sync_recent_contacts_to_radio(force=True))
|
||||
asyncio.create_task(ensure_contact_on_radio(request.id, force=True))
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 1.5 KiB |
BIN
frontend/public/favicon-256x256.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 9.3 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 4.3 KiB |
@@ -13,6 +13,7 @@ import {
|
||||
useConversationActions,
|
||||
useConversationNavigation,
|
||||
useRealtimeAppState,
|
||||
useBrowserNotifications,
|
||||
} from './hooks';
|
||||
import { AppShell } from './components/AppShell';
|
||||
import type { MessageInputHandle } from './components/MessageInput';
|
||||
@@ -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,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Star } from 'lucide-react';
|
||||
import { api } from '../api';
|
||||
import { formatTime } from '../utils/messageParser';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
@@ -125,12 +126,12 @@ export function ChannelInfoPane({
|
||||
>
|
||||
{isFavorite(favorites, 'channel', channel.key) ? (
|
||||
<>
|
||||
<span className="text-favorite text-lg">★</span>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">☆</span>
|
||||
<Star className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Add to favorites</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Bell, Globe2, Info, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { toast } from './ui/sonner';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
@@ -13,7 +14,11 @@ interface ChatHeaderProps {
|
||||
channels: Channel[];
|
||||
config: RadioConfig | null;
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
onTrace: () => void;
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onSetChannelFloodScopeOverride?: (key: string, floodScopeOverride: string) => void;
|
||||
onDeleteChannel: (key: string) => void;
|
||||
@@ -28,7 +33,11 @@ export function ChatHeader({
|
||||
channels,
|
||||
config,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
onTrace,
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onSetChannelFloodScopeOverride,
|
||||
onDeleteChannel,
|
||||
@@ -46,25 +55,39 @@ export function ChatHeader({
|
||||
conversation.type === 'channel'
|
||||
? channels.find((channel) => channel.key === conversation.id)
|
||||
: undefined;
|
||||
const activeFloodScopeOverride =
|
||||
conversation.type === 'channel' ? (activeChannel?.flood_scope_override ?? null) : null;
|
||||
const activeFloodScopeLabel = activeFloodScopeOverride
|
||||
? stripRegionScopePrefix(activeFloodScopeOverride)
|
||||
: null;
|
||||
const activeFloodScopeDisplay = activeFloodScopeOverride ? activeFloodScopeOverride : null;
|
||||
const isPrivateChannel = conversation.type === 'channel' && !activeChannel?.is_hashtag;
|
||||
|
||||
const titleClickable =
|
||||
(conversation.type === 'contact' && onOpenContactInfo) ||
|
||||
(conversation.type === 'channel' && onOpenChannelInfo);
|
||||
const favoriteTitle =
|
||||
conversation.type === 'contact'
|
||||
? isFavorite(favorites, 'contact', conversation.id)
|
||||
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
: isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites';
|
||||
|
||||
const handleEditFloodScopeOverride = () => {
|
||||
if (conversation.type !== 'channel' || !onSetChannelFloodScopeOverride) return;
|
||||
const nextValue = window.prompt(
|
||||
'Enter regional override flood scope for this room. This temporarily changes the radio flood scope before send and restores it after, which significantly slows room sends. Leave blank to clear.',
|
||||
stripRegionScopePrefix(activeChannel?.flood_scope_override)
|
||||
activeFloodScopeLabel ?? ''
|
||||
);
|
||||
if (nextValue === null) return;
|
||||
onSetChannelFloodScopeOverride(conversation.id, nextValue);
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="flex justify-between items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
|
||||
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex min-w-0 flex-1 items-start gap-2">
|
||||
{conversation.type === 'contact' && onOpenContactInfo && (
|
||||
<span
|
||||
className="flex-shrink-0 cursor-pointer"
|
||||
@@ -84,111 +107,175 @@ export function ChatHeader({
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
<h2
|
||||
className={`flex-shrink-0 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
|
||||
role={titleClickable ? 'button' : undefined}
|
||||
tabIndex={titleClickable ? 0 : undefined}
|
||||
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
|
||||
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
|
||||
onClick={
|
||||
titleClickable
|
||||
? () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
|
||||
onOpenChannelInfo(conversation.id);
|
||||
}
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<h2
|
||||
className={`flex shrink min-w-0 items-center gap-1.5 font-semibold text-base ${titleClickable ? 'cursor-pointer hover:text-primary transition-colors' : ''}`}
|
||||
role={titleClickable ? 'button' : undefined}
|
||||
tabIndex={titleClickable ? 0 : undefined}
|
||||
aria-label={titleClickable ? `View info for ${conversation.name}` : undefined}
|
||||
onKeyDown={titleClickable ? handleKeyboardActivate : undefined}
|
||||
onClick={
|
||||
titleClickable
|
||||
? () => {
|
||||
if (conversation.type === 'contact' && onOpenContactInfo) {
|
||||
onOpenContactInfo(conversation.id);
|
||||
} else if (conversation.type === 'channel' && onOpenChannelInfo) {
|
||||
onOpenChannelInfo(conversation.id);
|
||||
}
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
activeChannel?.is_hashtag
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</h2>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
<button
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono hover:text-primary transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowKey(true);
|
||||
}}
|
||||
title="Reveal channel key"
|
||||
>
|
||||
Show Key
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success(
|
||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
||||
);
|
||||
}}
|
||||
title="Click to copy"
|
||||
aria-label={conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'}
|
||||
>
|
||||
{conversation.type === 'channel' ? conversation.id.toLowerCase() : conversation.id}
|
||||
>
|
||||
<span className="truncate">
|
||||
{conversation.type === 'channel' &&
|
||||
!conversation.name.startsWith('#') &&
|
||||
activeChannel?.is_hashtag
|
||||
? '#'
|
||||
: ''}
|
||||
{conversation.name}
|
||||
</span>
|
||||
{titleClickable && (
|
||||
<Info
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</h2>
|
||||
{isPrivateChannel && !showKey ? (
|
||||
<button
|
||||
className="min-w-0 flex-shrink text-[11px] font-mono text-muted-foreground transition-colors hover:text-primary"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowKey(true);
|
||||
}}
|
||||
title="Reveal channel key"
|
||||
>
|
||||
Show Key
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success(
|
||||
conversation.type === 'channel' ? 'Room key copied!' : 'Contact key copied!'
|
||||
);
|
||||
}}
|
||||
title="Click to copy"
|
||||
aria-label={
|
||||
conversation.type === 'channel' ? 'Copy channel key' : 'Copy contact key'
|
||||
}
|
||||
>
|
||||
{conversation.type === 'channel'
|
||||
? conversation.id.toLowerCase()
|
||||
: conversation.id}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{conversation.type === 'contact' &&
|
||||
(() => {
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||
if (!contact) return null;
|
||||
return (
|
||||
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
|
||||
<ContactStatusInfo
|
||||
contact={contact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</span>
|
||||
)}
|
||||
{conversation.type === 'channel' && activeChannel?.flood_scope_override && (
|
||||
<span className="basis-full sm:basis-auto text-[11px] text-amber-700 dark:text-amber-300 truncate">
|
||||
Regional override active: {stripRegionScopePrefix(activeChannel.flood_scope_override)}
|
||||
</span>
|
||||
)}
|
||||
{conversation.type === 'contact' &&
|
||||
(() => {
|
||||
const contact = contacts.find((c) => c.public_key === conversation.id);
|
||||
if (!contact) return null;
|
||||
return (
|
||||
<ContactStatusInfo
|
||||
contact={contact}
|
||||
ourLat={config?.lat ?? null}
|
||||
ourLon={config?.lon ?? null}
|
||||
{conversation.type === 'channel' && activeFloodScopeDisplay && (
|
||||
<button
|
||||
className="mt-0.5 flex items-center gap-1 text-left sm:hidden"
|
||||
onClick={handleEditFloodScopeOverride}
|
||||
title="Set regional override"
|
||||
aria-label="Set regional override"
|
||||
>
|
||||
<Globe2
|
||||
className="h-3.5 w-3.5 flex-shrink-0 text-[hsl(var(--region-override))]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<span className="min-w-0 truncate text-[11px] font-medium text-[hsl(var(--region-override))]">
|
||||
{activeFloodScopeDisplay}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
<div className="flex items-center justify-end gap-0.5 flex-shrink-0">
|
||||
{conversation.type === 'contact' && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTrace}
|
||||
title="Direct Trace"
|
||||
aria-label="Direct Trace"
|
||||
>
|
||||
<span aria-hidden="true">🛎</span>
|
||||
<Route className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
{notificationsSupported && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onToggleNotifications}
|
||||
title={
|
||||
notificationsEnabled
|
||||
? 'Disable desktop notifications for this conversation'
|
||||
: notificationsPermission === 'denied'
|
||||
? 'Notifications blocked by the browser'
|
||||
: 'Enable desktop notifications for this conversation'
|
||||
}
|
||||
aria-label={
|
||||
notificationsEnabled
|
||||
? 'Disable notifications for this conversation'
|
||||
: 'Enable notifications for this conversation'
|
||||
}
|
||||
>
|
||||
<Bell
|
||||
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||
fill={notificationsEnabled ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{conversation.type === 'channel' && onSetChannelFloodScopeOverride && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="flex shrink-0 items-center gap-1 rounded px-1 py-1 text-lg leading-none transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={handleEditFloodScopeOverride}
|
||||
title="Set regional override"
|
||||
aria-label="Set regional override"
|
||||
>
|
||||
<span aria-hidden="true">🌎</span>
|
||||
<Globe2
|
||||
className={`h-4 w-4 ${activeFloodScopeLabel ? 'text-[hsl(var(--region-override))]' : ''}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{activeFloodScopeDisplay && (
|
||||
<span className="hidden text-[11px] font-medium text-[hsl(var(--region-override))] sm:inline">
|
||||
{activeFloodScopeDisplay}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{(conversation.type === 'channel' || conversation.type === 'contact') && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() =>
|
||||
onToggleFavorite(conversation.type as 'channel' | 'contact', conversation.id)
|
||||
}
|
||||
title={
|
||||
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
? 'Remove from favorites'
|
||||
: 'Add to favorites'
|
||||
}
|
||||
title={favoriteTitle}
|
||||
aria-label={
|
||||
isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id)
|
||||
? 'Remove from favorites'
|
||||
@@ -196,15 +283,15 @@ export function ChatHeader({
|
||||
}
|
||||
>
|
||||
{isFavorite(favorites, conversation.type as 'channel' | 'contact', conversation.id) ? (
|
||||
<span className="text-favorite">★</span>
|
||||
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</span>
|
||||
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{!(conversation.type === 'channel' && conversation.name === 'Public') && (
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => {
|
||||
if (conversation.type === 'channel') {
|
||||
onDeleteChannel(conversation.id);
|
||||
@@ -215,7 +302,7 @@ export function ChatHeader({
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { type ReactNode, useEffect, useState } from 'react';
|
||||
import { Ban, 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">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
|
||||
<span>Unblock this name</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Block this name</span>
|
||||
</>
|
||||
)}
|
||||
@@ -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">★</span>
|
||||
<Star className="h-4.5 w-4.5 fill-current text-favorite" aria-hidden="true" />
|
||||
<span>Remove from favorites</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">☆</span>
|
||||
<Star className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Add to favorites</span>
|
||||
</>
|
||||
)}
|
||||
@@ -305,12 +307,12 @@ export function ContactInfoPane({
|
||||
>
|
||||
{blockedKeys.includes(contact.public_key.toLowerCase()) ? (
|
||||
<>
|
||||
<span className="text-destructive text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
|
||||
<span>Unblock this key</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Block this key</span>
|
||||
</>
|
||||
)}
|
||||
@@ -324,12 +326,12 @@ export function ContactInfoPane({
|
||||
>
|
||||
{blockedNames.includes(contact.name) ? (
|
||||
<>
|
||||
<span className="text-destructive text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-destructive" aria-hidden="true" />
|
||||
<span>Unblock name “{contact.name}”</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-muted-foreground text-lg">✘</span>
|
||||
<Ban className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
|
||||
<span>Block name “{contact.name}”</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -31,6 +31,9 @@ interface ConversationPaneProps {
|
||||
rawPackets: RawPacket[];
|
||||
config: RadioConfig | null;
|
||||
health: HealthStatus | null;
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
favorites: Favorite[];
|
||||
messages: Message[];
|
||||
messagesLoading: boolean;
|
||||
@@ -54,6 +57,7 @@ interface ConversationPaneProps {
|
||||
onLoadNewer: () => Promise<void>;
|
||||
onJumpToBottom: () => void;
|
||||
onSendMessage: (text: string) => Promise<void>;
|
||||
onToggleNotifications: () => void;
|
||||
}
|
||||
|
||||
function LoadingPane({ label }: { label: string }) {
|
||||
@@ -69,6 +73,9 @@ export function ConversationPane({
|
||||
rawPackets,
|
||||
config,
|
||||
health,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
favorites,
|
||||
messages,
|
||||
messagesLoading,
|
||||
@@ -92,6 +99,7 @@ export function ConversationPane({
|
||||
onLoadNewer,
|
||||
onJumpToBottom,
|
||||
onSendMessage,
|
||||
onToggleNotifications,
|
||||
}: ConversationPaneProps) {
|
||||
const activeContactIsRepeater = useMemo(() => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return false;
|
||||
@@ -155,10 +163,14 @@ export function ConversationPane({
|
||||
conversation={activeConversation}
|
||||
contacts={contacts}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
radioLat={config?.lat ?? null}
|
||||
radioLon={config?.lon ?? null}
|
||||
radioName={config?.name ?? null}
|
||||
onTrace={onTrace}
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onDeleteContact={onDeleteContact}
|
||||
/>
|
||||
@@ -174,7 +186,11 @@ export function ConversationPane({
|
||||
channels={channels}
|
||||
config={config}
|
||||
favorites={favorites}
|
||||
notificationsSupported={notificationsSupported}
|
||||
notificationsEnabled={notificationsEnabled}
|
||||
notificationsPermission={notificationsPermission}
|
||||
onTrace={onTrace}
|
||||
onToggleNotifications={onToggleNotifications}
|
||||
onToggleFavorite={onToggleFavorite}
|
||||
onSetChannelFloodScopeOverride={onSetChannelFloodScopeOverride}
|
||||
onDeleteChannel={onDeleteChannel}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef } from 'react';
|
||||
import { Dice5 } from 'lucide-react';
|
||||
import type { Contact, Conversation } from '../types';
|
||||
import { getContactDisplayName } from '../utils/pubkey';
|
||||
import {
|
||||
@@ -256,7 +257,7 @@ export function NewMessageModal({
|
||||
title="Generate random key"
|
||||
aria-label="Generate random key"
|
||||
>
|
||||
<span aria-hidden="true">🎲</span>
|
||||
<Dice5 className="h-4 w-4" aria-hidden="true" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { toast } from './ui/sonner';
|
||||
import { Button } from './ui/button';
|
||||
import { Bell, Route, Star, Trash2 } from 'lucide-react';
|
||||
import { RepeaterLogin } from './RepeaterLogin';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import { isFavorite } from '../utils/favorites';
|
||||
@@ -24,10 +25,14 @@ interface RepeaterDashboardProps {
|
||||
conversation: Conversation;
|
||||
contacts: Contact[];
|
||||
favorites: Favorite[];
|
||||
notificationsSupported: boolean;
|
||||
notificationsEnabled: boolean;
|
||||
notificationsPermission: NotificationPermission | 'unsupported';
|
||||
radioLat: number | null;
|
||||
radioLon: number | null;
|
||||
radioName: string | null;
|
||||
onTrace: () => void;
|
||||
onToggleNotifications: () => void;
|
||||
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
|
||||
onDeleteContact: (publicKey: string) => void;
|
||||
}
|
||||
@@ -36,10 +41,14 @@ export function RepeaterDashboard({
|
||||
conversation,
|
||||
contacts,
|
||||
favorites,
|
||||
notificationsSupported,
|
||||
notificationsEnabled,
|
||||
notificationsPermission,
|
||||
radioLat,
|
||||
radioLon,
|
||||
radioName,
|
||||
onTrace,
|
||||
onToggleNotifications,
|
||||
onToggleFavorite,
|
||||
onDeleteContact,
|
||||
}: RepeaterDashboardProps) {
|
||||
@@ -56,7 +65,8 @@ export function RepeaterDashboard({
|
||||
refreshPane,
|
||||
loadAll,
|
||||
sendConsoleCommand,
|
||||
sendAdvert,
|
||||
sendZeroHopAdvert,
|
||||
sendFloodAdvert,
|
||||
rebootRepeater,
|
||||
syncClock,
|
||||
} = useRepeaterDashboard(conversation);
|
||||
@@ -70,23 +80,33 @@ export function RepeaterDashboard({
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Header */}
|
||||
<header className="flex justify-between items-start sm:items-center px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex flex-wrap items-baseline gap-x-2 min-w-0 flex-1">
|
||||
<span className="flex-shrink-0 font-semibold text-base">{conversation.name}</span>
|
||||
<span
|
||||
className="font-normal text-[11px] text-muted-foreground font-mono truncate cursor-pointer hover:text-primary transition-colors"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success('Contact key copied!');
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{conversation.id}
|
||||
<header className="flex justify-between items-start px-4 py-2.5 border-b border-border gap-2">
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-0.5">
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="min-w-0 flex-shrink truncate font-semibold text-base">
|
||||
{conversation.name}
|
||||
</span>
|
||||
<span
|
||||
className="min-w-0 flex-1 truncate font-mono text-[11px] text-muted-foreground transition-colors hover:text-primary"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={handleKeyboardActivate}
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(conversation.id);
|
||||
toast.success('Contact key copied!');
|
||||
}}
|
||||
title="Click to copy"
|
||||
>
|
||||
{conversation.id}
|
||||
</span>
|
||||
</span>
|
||||
{contact && (
|
||||
<span className="min-w-0 flex-none text-[11px] text-muted-foreground max-sm:basis-full">
|
||||
<ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
{contact && <ContactStatusInfo contact={contact} ourLat={radioLat} ourLon={radioLon} />}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5 flex-shrink-0">
|
||||
{loggedIn && (
|
||||
@@ -101,32 +121,65 @@ export function RepeaterDashboard({
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onTrace}
|
||||
title="Direct Trace"
|
||||
aria-label="Direct Trace"
|
||||
>
|
||||
<span aria-hidden="true">🛎</span>
|
||||
<Route className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
{notificationsSupported && (
|
||||
<button
|
||||
className="flex items-center gap-1 rounded px-1 py-1 hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={onToggleNotifications}
|
||||
title={
|
||||
notificationsEnabled
|
||||
? 'Disable desktop notifications for this conversation'
|
||||
: notificationsPermission === 'denied'
|
||||
? 'Notifications blocked by the browser'
|
||||
: 'Enable desktop notifications for this conversation'
|
||||
}
|
||||
aria-label={
|
||||
notificationsEnabled
|
||||
? 'Disable notifications for this conversation'
|
||||
: 'Enable notifications for this conversation'
|
||||
}
|
||||
>
|
||||
<Bell
|
||||
className={`h-4 w-4 ${notificationsEnabled ? 'text-status-connected' : 'text-muted-foreground'}`}
|
||||
fill={notificationsEnabled ? 'currentColor' : 'none'}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{notificationsEnabled && (
|
||||
<span className="hidden md:inline text-[11px] font-medium text-status-connected">
|
||||
Notifications On
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="p-1 rounded hover:bg-accent text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onToggleFavorite('contact', conversation.id)}
|
||||
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={
|
||||
isFav
|
||||
? 'Remove from favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
: 'Add to favorites. Favorite contacts stay loaded on the radio for ACK support.'
|
||||
}
|
||||
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFav ? (
|
||||
<span className="text-favorite">★</span>
|
||||
<Star className="h-4 w-4 fill-current text-favorite" aria-hidden="true" />
|
||||
) : (
|
||||
<span className="text-muted-foreground">☆</span>
|
||||
<Star className="h-4 w-4 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
className="p-1 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive text-lg leading-none transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
onClick={() => onDeleteContact(conversation.id)}
|
||||
title="Delete"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<span aria-hidden="true">🗑</span>
|
||||
<Trash2 className="h-4 w-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -196,7 +249,8 @@ export function RepeaterDashboard({
|
||||
disabled={anyLoading}
|
||||
/>
|
||||
<ActionsPane
|
||||
onSendAdvert={sendAdvert}
|
||||
onSendZeroHopAdvert={sendZeroHopAdvert}
|
||||
onSendFloodAdvert={sendFloodAdvert}
|
||||
onSyncClock={syncClock}
|
||||
onReboot={rebootRepeater}
|
||||
consoleLoading={consoleLoading}
|
||||
|
||||
@@ -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] ? '−' : '+'}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
|
||||
export function ActionsPane({
|
||||
onSendAdvert,
|
||||
onSendZeroHopAdvert,
|
||||
onSendFloodAdvert,
|
||||
onSyncClock,
|
||||
onReboot,
|
||||
consoleLoading,
|
||||
}: {
|
||||
onSendAdvert: () => void;
|
||||
onSendZeroHopAdvert: () => void;
|
||||
onSendFloodAdvert: () => void;
|
||||
onSyncClock: () => void;
|
||||
onReboot: () => void;
|
||||
consoleLoading: boolean;
|
||||
@@ -36,8 +38,16 @@ export function ActionsPane({
|
||||
<h3 className="text-sm font-medium">Actions</h3>
|
||||
</div>
|
||||
<div className="p-3 flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onSendAdvert} disabled={consoleLoading}>
|
||||
Send Advert
|
||||
<Button variant="outline" size="sm" onClick={onSendZeroHopAdvert} disabled={consoleLoading}>
|
||||
Zero Hop Advert
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onSendFloodAdvert}
|
||||
disabled={consoleLoading}
|
||||
>
|
||||
Flood Advert
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onSyncClock} disabled={consoleLoading}>
|
||||
Sync Clock
|
||||
|
||||
@@ -80,6 +80,28 @@ export function formatAdvertInterval(val: string | null): string {
|
||||
return `${trimmed}h`;
|
||||
}
|
||||
|
||||
function formatFetchedRelative(fetchedAt: number): string {
|
||||
const elapsedSeconds = Math.max(0, Math.floor((Date.now() - fetchedAt) / 1000));
|
||||
|
||||
if (elapsedSeconds < 60) return 'Just now';
|
||||
|
||||
const elapsedMinutes = Math.floor(elapsedSeconds / 60);
|
||||
if (elapsedMinutes < 60) {
|
||||
return `${elapsedMinutes} minute${elapsedMinutes === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
const elapsedHours = Math.floor(elapsedMinutes / 60);
|
||||
return `${elapsedHours} hour${elapsedHours === 1 ? '' : 's'} ago`;
|
||||
}
|
||||
|
||||
function formatFetchedTime(fetchedAt: number): string {
|
||||
return new Date(fetchedAt).toLocaleTimeString([], {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
// --- Generic Pane Wrapper ---
|
||||
|
||||
export function RepeaterPane({
|
||||
@@ -99,10 +121,22 @@ export function RepeaterPane({
|
||||
className?: string;
|
||||
contentClassName?: string;
|
||||
}) {
|
||||
const fetchedAt = state.fetched_at ?? null;
|
||||
|
||||
return (
|
||||
<div className={cn('border border-border rounded-lg overflow-hidden', className)}>
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b border-border">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{fetchedAt && (
|
||||
<p
|
||||
className="text-[11px] text-muted-foreground"
|
||||
title={new Date(fetchedAt).toLocaleString()}
|
||||
>
|
||||
Fetched {formatFetchedTime(fetchedAt)} ({formatFetchedRelative(fetchedAt)})
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{onRefresh && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -9,3 +9,4 @@ export { useContactsAndChannels } from './useContactsAndChannels';
|
||||
export { useRealtimeAppState } from './useRealtimeAppState';
|
||||
export { useConversationActions } from './useConversationActions';
|
||||
export { useConversationNavigation } from './useConversationNavigation';
|
||||
export { useBrowserNotifications } from './useBrowserNotifications';
|
||||
|
||||
207
frontend/src/hooks/useBrowserNotifications.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from '../components/ui/sonner';
|
||||
import type { Message } from '../types';
|
||||
import { getStateKey } from '../utils/conversationState';
|
||||
|
||||
const STORAGE_KEY = 'meshcore_browser_notifications_enabled_by_conversation';
|
||||
const NOTIFICATION_ICON_PATH = '/favicon-256x256.png';
|
||||
|
||||
type NotificationPermissionState = NotificationPermission | 'unsupported';
|
||||
type ConversationNotificationMap = Record<string, boolean>;
|
||||
|
||||
function getConversationNotificationKey(type: 'channel' | 'contact', id: string): string {
|
||||
return getStateKey(type, id);
|
||||
}
|
||||
|
||||
function readStoredEnabledMap(): ConversationNotificationMap {
|
||||
if (typeof window === 'undefined') {
|
||||
return {};
|
||||
}
|
||||
try {
|
||||
const raw = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return {};
|
||||
}
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return {};
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(parsed).filter(([key, value]) => typeof key === 'string' && value === true)
|
||||
);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeStoredEnabledMap(enabledByConversation: ConversationNotificationMap) {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(enabledByConversation));
|
||||
}
|
||||
|
||||
function getInitialPermission(): NotificationPermissionState {
|
||||
if (typeof window === 'undefined' || !('Notification' in window)) {
|
||||
return 'unsupported';
|
||||
}
|
||||
return window.Notification.permission;
|
||||
}
|
||||
|
||||
function shouldShowDesktopNotification(): boolean {
|
||||
if (typeof document === 'undefined') {
|
||||
return false;
|
||||
}
|
||||
return document.visibilityState !== 'visible' || !document.hasFocus();
|
||||
}
|
||||
|
||||
function getMessageConversationNotificationKey(message: Message): string | null {
|
||||
if (message.type === 'PRIV' && message.conversation_key) {
|
||||
return getConversationNotificationKey('contact', message.conversation_key);
|
||||
}
|
||||
if (message.type === 'CHAN' && message.conversation_key) {
|
||||
return getConversationNotificationKey('channel', message.conversation_key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildNotificationTitle(message: Message): string {
|
||||
if (message.type === 'PRIV') {
|
||||
return message.sender_name
|
||||
? `New message from ${message.sender_name}`
|
||||
: `New message from ${message.conversation_key.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
const roomName = message.channel_name || message.conversation_key.slice(0, 8);
|
||||
return `New message in ${roomName}`;
|
||||
}
|
||||
|
||||
function buildPreviewNotificationTitle(type: 'channel' | 'contact', label: string): string {
|
||||
return type === 'contact' ? `New message from ${label}` : `New message in ${label}`;
|
||||
}
|
||||
|
||||
function buildMessageNotificationHash(message: Message): string | null {
|
||||
if (message.type === 'PRIV' && message.conversation_key) {
|
||||
const label = message.sender_name || message.conversation_key.slice(0, 12);
|
||||
return `#contact/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
|
||||
}
|
||||
if (message.type === 'CHAN' && message.conversation_key) {
|
||||
const label = message.channel_name || message.conversation_key.slice(0, 8);
|
||||
return `#channel/${encodeURIComponent(message.conversation_key)}/${encodeURIComponent(label)}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useBrowserNotifications() {
|
||||
const [permission, setPermission] = useState<NotificationPermissionState>(getInitialPermission);
|
||||
const [enabledByConversation, setEnabledByConversation] =
|
||||
useState<ConversationNotificationMap>(readStoredEnabledMap);
|
||||
|
||||
useEffect(() => {
|
||||
setPermission(getInitialPermission());
|
||||
}, []);
|
||||
|
||||
const isConversationNotificationsEnabled = useCallback(
|
||||
(type: 'channel' | 'contact', id: string) =>
|
||||
permission === 'granted' &&
|
||||
enabledByConversation[getConversationNotificationKey(type, id)] === true,
|
||||
[enabledByConversation, permission]
|
||||
);
|
||||
|
||||
const toggleConversationNotifications = useCallback(
|
||||
async (type: 'channel' | 'contact', id: string, label: string) => {
|
||||
const conversationKey = getConversationNotificationKey(type, id);
|
||||
if (enabledByConversation[conversationKey]) {
|
||||
setEnabledByConversation((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[conversationKey];
|
||||
writeStoredEnabledMap(next);
|
||||
return next;
|
||||
});
|
||||
toast.success(`${label} notifications disabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === 'unsupported') {
|
||||
toast.error('Browser notifications unavailable', {
|
||||
description: 'This browser does not support desktop notifications.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (permission === 'denied') {
|
||||
toast.error('Browser notifications blocked', {
|
||||
description: 'Allow notifications in your browser settings, then try again.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPermission = await window.Notification.requestPermission();
|
||||
setPermission(nextPermission);
|
||||
|
||||
if (nextPermission === 'granted') {
|
||||
setEnabledByConversation((prev) => {
|
||||
const next = {
|
||||
...prev,
|
||||
[conversationKey]: true,
|
||||
};
|
||||
writeStoredEnabledMap(next);
|
||||
return next;
|
||||
});
|
||||
new window.Notification(buildPreviewNotificationTitle(type, label), {
|
||||
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
|
||||
icon: NOTIFICATION_ICON_PATH,
|
||||
tag: `meshcore-notification-preview-${conversationKey}`,
|
||||
});
|
||||
toast.success(`${label} notifications enabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error('Browser notifications not enabled', {
|
||||
description:
|
||||
nextPermission === 'denied'
|
||||
? 'Permission was denied by the browser.'
|
||||
: 'Permission request was dismissed.',
|
||||
});
|
||||
},
|
||||
[enabledByConversation, permission]
|
||||
);
|
||||
|
||||
const notifyIncomingMessage = useCallback(
|
||||
(message: Message) => {
|
||||
const conversationKey = getMessageConversationNotificationKey(message);
|
||||
if (
|
||||
permission !== 'granted' ||
|
||||
!conversationKey ||
|
||||
enabledByConversation[conversationKey] !== true ||
|
||||
!shouldShowDesktopNotification()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const notification = new window.Notification(buildNotificationTitle(message), {
|
||||
body: message.text,
|
||||
icon: NOTIFICATION_ICON_PATH,
|
||||
tag: `meshcore-message-${message.id}`,
|
||||
});
|
||||
|
||||
notification.onclick = () => {
|
||||
const hash = buildMessageNotificationHash(message);
|
||||
if (hash) {
|
||||
window.open(`${window.location.origin}${window.location.pathname}${hash}`, '_self');
|
||||
}
|
||||
window.focus();
|
||||
notification.close();
|
||||
};
|
||||
},
|
||||
[enabledByConversation, permission]
|
||||
);
|
||||
|
||||
return {
|
||||
notificationsSupported: permission !== 'unsupported',
|
||||
notificationsPermission: permission,
|
||||
isConversationNotificationsEnabled,
|
||||
toggleConversationNotifications,
|
||||
notifyIncomingMessage,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
|
||||
const MAX_RETRIES = 3;
|
||||
const RETRY_DELAY_MS = 2000;
|
||||
const MAX_CACHED_REPEATERS = 20;
|
||||
|
||||
interface ConsoleEntry {
|
||||
command: string;
|
||||
@@ -35,7 +36,15 @@ interface PaneData {
|
||||
lppTelemetry: RepeaterLppTelemetryResponse | null;
|
||||
}
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null };
|
||||
interface RepeaterDashboardCacheEntry {
|
||||
loggedIn: boolean;
|
||||
loginError: string | null;
|
||||
paneData: PaneData;
|
||||
paneStates: Record<PaneName, PaneState>;
|
||||
consoleHistory: ConsoleEntry[];
|
||||
}
|
||||
|
||||
const INITIAL_PANE_STATE: PaneState = { loading: false, attempt: 0, error: null, fetched_at: null };
|
||||
|
||||
function createInitialPaneStates(): Record<PaneName, PaneState> {
|
||||
return {
|
||||
@@ -61,6 +70,67 @@ function createInitialPaneData(): PaneData {
|
||||
};
|
||||
}
|
||||
|
||||
const repeaterDashboardCache = new Map<string, RepeaterDashboardCacheEntry>();
|
||||
|
||||
function clonePaneData(data: PaneData): PaneData {
|
||||
return { ...data };
|
||||
}
|
||||
|
||||
function normalizePaneStates(paneStates: Record<PaneName, PaneState>): Record<PaneName, PaneState> {
|
||||
return {
|
||||
status: { ...paneStates.status, loading: false },
|
||||
neighbors: { ...paneStates.neighbors, loading: false },
|
||||
acl: { ...paneStates.acl, loading: false },
|
||||
radioSettings: { ...paneStates.radioSettings, loading: false },
|
||||
advertIntervals: { ...paneStates.advertIntervals, loading: false },
|
||||
ownerInfo: { ...paneStates.ownerInfo, loading: false },
|
||||
lppTelemetry: { ...paneStates.lppTelemetry, loading: false },
|
||||
};
|
||||
}
|
||||
|
||||
function cloneConsoleHistory(consoleHistory: ConsoleEntry[]): ConsoleEntry[] {
|
||||
return consoleHistory.map((entry) => ({ ...entry }));
|
||||
}
|
||||
|
||||
function getCachedState(publicKey: string | null): RepeaterDashboardCacheEntry | null {
|
||||
if (!publicKey) return null;
|
||||
const cached = repeaterDashboardCache.get(publicKey);
|
||||
if (!cached) return null;
|
||||
|
||||
repeaterDashboardCache.delete(publicKey);
|
||||
repeaterDashboardCache.set(publicKey, cached);
|
||||
|
||||
return {
|
||||
loggedIn: cached.loggedIn,
|
||||
loginError: cached.loginError,
|
||||
paneData: clonePaneData(cached.paneData),
|
||||
paneStates: normalizePaneStates(cached.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(cached.consoleHistory),
|
||||
};
|
||||
}
|
||||
|
||||
function cacheState(publicKey: string, entry: RepeaterDashboardCacheEntry) {
|
||||
repeaterDashboardCache.delete(publicKey);
|
||||
repeaterDashboardCache.set(publicKey, {
|
||||
loggedIn: entry.loggedIn,
|
||||
loginError: entry.loginError,
|
||||
paneData: clonePaneData(entry.paneData),
|
||||
paneStates: normalizePaneStates(entry.paneStates),
|
||||
consoleHistory: cloneConsoleHistory(entry.consoleHistory),
|
||||
});
|
||||
|
||||
if (repeaterDashboardCache.size > MAX_CACHED_REPEATERS) {
|
||||
const lruKey = repeaterDashboardCache.keys().next().value as string | undefined;
|
||||
if (lruKey) {
|
||||
repeaterDashboardCache.delete(lruKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetRepeaterDashboardCacheForTests() {
|
||||
repeaterDashboardCache.clear();
|
||||
}
|
||||
|
||||
// Maps pane name to the API call
|
||||
function fetchPaneData(publicKey: string, pane: PaneName) {
|
||||
switch (pane) {
|
||||
@@ -94,7 +164,8 @@ export interface UseRepeaterDashboardResult {
|
||||
refreshPane: (pane: PaneName) => Promise<void>;
|
||||
loadAll: () => Promise<void>;
|
||||
sendConsoleCommand: (command: string) => Promise<void>;
|
||||
sendAdvert: () => Promise<void>;
|
||||
sendZeroHopAdvert: () => Promise<void>;
|
||||
sendFloodAdvert: () => Promise<void>;
|
||||
rebootRepeater: () => Promise<void>;
|
||||
syncClock: () => Promise<void>;
|
||||
}
|
||||
@@ -102,15 +173,24 @@ export interface UseRepeaterDashboardResult {
|
||||
export function useRepeaterDashboard(
|
||||
activeConversation: Conversation | null
|
||||
): UseRepeaterDashboardResult {
|
||||
const [loggedIn, setLoggedIn] = useState(false);
|
||||
const conversationId =
|
||||
activeConversation && activeConversation.type === 'contact' ? activeConversation.id : null;
|
||||
const cachedState = getCachedState(conversationId);
|
||||
|
||||
const [loggedIn, setLoggedIn] = useState(cachedState?.loggedIn ?? false);
|
||||
const [loginLoading, setLoginLoading] = useState(false);
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [loginError, setLoginError] = useState<string | null>(cachedState?.loginError ?? null);
|
||||
|
||||
const [paneData, setPaneData] = useState<PaneData>(createInitialPaneData);
|
||||
const [paneStates, setPaneStates] =
|
||||
useState<Record<PaneName, PaneState>>(createInitialPaneStates);
|
||||
const [paneData, setPaneData] = useState<PaneData>(
|
||||
cachedState?.paneData ?? createInitialPaneData
|
||||
);
|
||||
const [paneStates, setPaneStates] = useState<Record<PaneName, PaneState>>(
|
||||
cachedState?.paneStates ?? createInitialPaneStates
|
||||
);
|
||||
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>([]);
|
||||
const [consoleHistory, setConsoleHistory] = useState<ConsoleEntry[]>(
|
||||
cachedState?.consoleHistory ?? []
|
||||
);
|
||||
const [consoleLoading, setConsoleLoading] = useState(false);
|
||||
|
||||
// Track which conversation we're operating on to avoid stale updates after
|
||||
@@ -120,6 +200,10 @@ export function useRepeaterDashboard(
|
||||
|
||||
// Guard against setting state after unmount (retry timers firing late)
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
activeIdRef.current = conversationId;
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
@@ -127,6 +211,17 @@ export function useRepeaterDashboard(
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!conversationId) return;
|
||||
cacheState(conversationId, {
|
||||
loggedIn,
|
||||
loginError,
|
||||
paneData,
|
||||
paneStates,
|
||||
consoleHistory,
|
||||
});
|
||||
}, [consoleHistory, conversationId, loggedIn, loginError, paneData, paneStates]);
|
||||
|
||||
const getPublicKey = useCallback((): string | null => {
|
||||
if (!activeConversation || activeConversation.type !== 'contact') return null;
|
||||
return activeConversation.id;
|
||||
@@ -172,7 +267,12 @@ export function useRepeaterDashboard(
|
||||
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: true, attempt, error: null },
|
||||
[pane]: {
|
||||
loading: true,
|
||||
attempt,
|
||||
error: null,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -182,7 +282,7 @@ export function useRepeaterDashboard(
|
||||
setPaneData((prev) => ({ ...prev, [pane]: data }));
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: null },
|
||||
[pane]: { loading: false, attempt, error: null, fetched_at: Date.now() },
|
||||
}));
|
||||
return; // Success
|
||||
} catch (err) {
|
||||
@@ -193,7 +293,12 @@ export function useRepeaterDashboard(
|
||||
if (attempt === MAX_RETRIES) {
|
||||
setPaneStates((prev) => ({
|
||||
...prev,
|
||||
[pane]: { loading: false, attempt, error: msg },
|
||||
[pane]: {
|
||||
loading: false,
|
||||
attempt,
|
||||
error: msg,
|
||||
fetched_at: prev[pane].fetched_at ?? null,
|
||||
},
|
||||
}));
|
||||
toast.error(`Failed to fetch ${pane}`, { description: msg });
|
||||
} else {
|
||||
@@ -266,7 +371,11 @@ export function useRepeaterDashboard(
|
||||
[getPublicKey]
|
||||
);
|
||||
|
||||
const sendAdvert = useCallback(async () => {
|
||||
const sendZeroHopAdvert = useCallback(async () => {
|
||||
await sendConsoleCommand('advert.zerohop');
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
const sendFloodAdvert = useCallback(async () => {
|
||||
await sendConsoleCommand('advert');
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
@@ -275,8 +384,8 @@ export function useRepeaterDashboard(
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
const syncClock = useCallback(async () => {
|
||||
const epoch = Math.floor(Date.now() / 1000);
|
||||
await sendConsoleCommand(`clock ${epoch}`);
|
||||
const epochSeconds = Math.floor(Date.now() / 1000);
|
||||
await sendConsoleCommand(`time ${epochSeconds}`);
|
||||
}, [sendConsoleCommand]);
|
||||
|
||||
return {
|
||||
@@ -292,7 +401,8 @@ export function useRepeaterDashboard(
|
||||
refreshPane,
|
||||
loadAll,
|
||||
sendConsoleCommand,
|
||||
sendAdvert,
|
||||
sendZeroHopAdvert,
|
||||
sendFloodAdvert,
|
||||
rebootRepeater,
|
||||
syncClock,
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
--success-foreground: 0 0% 100%;
|
||||
--info: 217 91% 60%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
--region-override: 270 80% 74%;
|
||||
|
||||
/* Favorites */
|
||||
--favorite: 43 96% 56%;
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
151
frontend/src/test/useBrowserNotifications.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { useBrowserNotifications } from '../hooks/useBrowserNotifications';
|
||||
import type { Message } from '../types';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
toast: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../components/ui/sonner', () => ({
|
||||
toast: mocks.toast,
|
||||
}));
|
||||
|
||||
const incomingChannelMessage: Message = {
|
||||
id: 42,
|
||||
type: 'CHAN',
|
||||
conversation_key: 'ab'.repeat(16),
|
||||
text: 'hello room',
|
||||
sender_timestamp: 1700000000,
|
||||
received_at: 1700000001,
|
||||
paths: null,
|
||||
txt_type: 0,
|
||||
signature: null,
|
||||
sender_key: 'cd'.repeat(32),
|
||||
outgoing: false,
|
||||
acked: 0,
|
||||
sender_name: 'Alice',
|
||||
channel_name: '#flightless',
|
||||
};
|
||||
|
||||
describe('useBrowserNotifications', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
window.localStorage.clear();
|
||||
window.location.hash = '';
|
||||
vi.spyOn(window, 'open').mockReturnValue(null);
|
||||
|
||||
Object.defineProperty(document, 'visibilityState', {
|
||||
configurable: true,
|
||||
value: 'hidden',
|
||||
});
|
||||
vi.spyOn(document, 'hasFocus').mockReturnValue(false);
|
||||
|
||||
const NotificationMock = vi.fn().mockImplementation(function (this: Record<string, unknown>) {
|
||||
this.close = vi.fn();
|
||||
this.onclick = null;
|
||||
});
|
||||
Object.assign(NotificationMock, {
|
||||
permission: 'granted',
|
||||
requestPermission: vi.fn(async () => 'granted'),
|
||||
});
|
||||
Object.defineProperty(window, 'Notification', {
|
||||
configurable: true,
|
||||
value: NotificationMock,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores notification opt-in per conversation', async () => {
|
||||
const { result } = renderHook(() => useBrowserNotifications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
expect(
|
||||
result.current.isConversationNotificationsEnabled(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key
|
||||
)
|
||||
).toBe(true);
|
||||
expect(result.current.isConversationNotificationsEnabled('contact', 'ef'.repeat(32))).toBe(
|
||||
false
|
||||
);
|
||||
expect(window.Notification).toHaveBeenCalledWith('New message in #flightless', {
|
||||
body: 'Notifications will look like this. These require the tab to stay open, and will not be reliable on mobile.',
|
||||
icon: '/favicon-256x256.png',
|
||||
tag: `meshcore-notification-preview-channel-${incomingChannelMessage.conversation_key}`,
|
||||
});
|
||||
});
|
||||
|
||||
it('only sends desktop notifications for opted-in conversations', async () => {
|
||||
const { result } = renderHook(() => useBrowserNotifications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.notifyIncomingMessage(incomingChannelMessage);
|
||||
result.current.notifyIncomingMessage({
|
||||
...incomingChannelMessage,
|
||||
id: 43,
|
||||
conversation_key: '34'.repeat(16),
|
||||
channel_name: '#elsewhere',
|
||||
});
|
||||
});
|
||||
|
||||
expect(window.Notification).toHaveBeenCalledTimes(2);
|
||||
expect(window.Notification).toHaveBeenNthCalledWith(2, 'New message in #flightless', {
|
||||
body: 'hello room',
|
||||
icon: '/favicon-256x256.png',
|
||||
tag: 'meshcore-message-42',
|
||||
});
|
||||
});
|
||||
|
||||
it('notification click deep-links to the conversation hash', async () => {
|
||||
const focusSpy = vi.spyOn(window, 'focus').mockImplementation(() => {});
|
||||
const { result } = renderHook(() => useBrowserNotifications());
|
||||
|
||||
await act(async () => {
|
||||
await result.current.toggleConversationNotifications(
|
||||
'channel',
|
||||
incomingChannelMessage.conversation_key,
|
||||
'#flightless'
|
||||
);
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.notifyIncomingMessage(incomingChannelMessage);
|
||||
});
|
||||
|
||||
const notificationInstance = (window.Notification as unknown as ReturnType<typeof vi.fn>).mock
|
||||
.instances[1] as {
|
||||
onclick: (() => void) | null;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
act(() => {
|
||||
notificationInstance.onclick?.();
|
||||
});
|
||||
|
||||
expect(window.open).toHaveBeenCalledWith(
|
||||
`${window.location.origin}${window.location.pathname}#channel/${incomingChannelMessage.conversation_key}/%23flightless`,
|
||||
'_self'
|
||||
);
|
||||
expect(focusSpy).toHaveBeenCalledTimes(1);
|
||||
expect(notificationInstance.close).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,7 @@ function createRealtimeArgs(overrides: Partial<Parameters<typeof useRealtimeAppS
|
||||
pendingDeleteFallbackRef: { current: false },
|
||||
setActiveConversation: vi.fn(),
|
||||
updateMessageAck: vi.fn(),
|
||||
notifyIncomingMessage: vi.fn(),
|
||||
...overrides,
|
||||
},
|
||||
fns: {
|
||||
@@ -163,6 +164,7 @@ describe('useRealtimeAppState', () => {
|
||||
`contact-${incomingDm.conversation_key}`,
|
||||
true
|
||||
);
|
||||
expect(args.notifyIncomingMessage).toHaveBeenCalledWith(incomingDm);
|
||||
});
|
||||
|
||||
it('deleting the active contact clears it and marks fallback recovery pending', () => {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { StrictMode, createElement, type ReactNode } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
import { renderHook, act } from '@testing-library/react';
|
||||
import { useRepeaterDashboard } from '../hooks/useRepeaterDashboard';
|
||||
import {
|
||||
resetRepeaterDashboardCacheForTests,
|
||||
useRepeaterDashboard,
|
||||
} from '../hooks/useRepeaterDashboard';
|
||||
import type { Conversation } from '../types';
|
||||
|
||||
// Mock the api module
|
||||
@@ -43,6 +46,7 @@ const repeaterConversation: Conversation = {
|
||||
describe('useRepeaterDashboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetRepeaterDashboardCacheForTests();
|
||||
});
|
||||
|
||||
it('starts with logged out state', () => {
|
||||
@@ -123,6 +127,7 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(result.current.paneData.status).toEqual(statusData);
|
||||
expect(result.current.paneStates.status.loading).toBe(false);
|
||||
expect(result.current.paneStates.status.error).toBe(null);
|
||||
expect(result.current.paneStates.status.fetched_at).toEqual(expect.any(Number));
|
||||
});
|
||||
|
||||
it('refreshPane still issues requests under StrictMode remount probing', async () => {
|
||||
@@ -211,7 +216,23 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(result.current.consoleLoading).toBe(false);
|
||||
});
|
||||
|
||||
it('sendAdvert sends "advert" command', async () => {
|
||||
it('sendZeroHopAdvert sends "advert.zerohop" command', async () => {
|
||||
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
|
||||
command: 'advert.zerohop',
|
||||
response: 'ok',
|
||||
sender_timestamp: 1000,
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendZeroHopAdvert();
|
||||
});
|
||||
|
||||
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'advert.zerohop');
|
||||
});
|
||||
|
||||
it('sendFloodAdvert sends "advert" command', async () => {
|
||||
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
|
||||
command: 'advert',
|
||||
response: 'ok',
|
||||
@@ -221,7 +242,7 @@ describe('useRepeaterDashboard', () => {
|
||||
const { result } = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
await act(async () => {
|
||||
await result.current.sendAdvert();
|
||||
await result.current.sendFloodAdvert();
|
||||
});
|
||||
|
||||
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'advert');
|
||||
@@ -243,12 +264,10 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'reboot');
|
||||
});
|
||||
|
||||
it('syncClock sends "clock <epoch>" command', async () => {
|
||||
const fakeNow = 1700000000000;
|
||||
vi.spyOn(Date, 'now').mockReturnValue(fakeNow);
|
||||
|
||||
it('syncClock sends "time <epoch>" command', async () => {
|
||||
const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000);
|
||||
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
|
||||
command: 'clock 1700000000',
|
||||
command: 'time 1700000000',
|
||||
response: 'ok',
|
||||
sender_timestamp: 1000,
|
||||
});
|
||||
@@ -259,9 +278,8 @@ describe('useRepeaterDashboard', () => {
|
||||
await result.current.syncClock();
|
||||
});
|
||||
|
||||
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'clock 1700000000');
|
||||
|
||||
vi.restoreAllMocks();
|
||||
expect(mockApi.sendRepeaterCommand).toHaveBeenCalledWith(REPEATER_KEY, 'time 1700000000');
|
||||
dateNowSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('loadAll calls refreshPane for all panes serially', async () => {
|
||||
@@ -304,4 +322,38 @@ describe('useRepeaterDashboard', () => {
|
||||
expect(mockApi.repeaterOwnerInfo).toHaveBeenCalledTimes(1);
|
||||
expect(mockApi.repeaterLppTelemetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('restores dashboard state when navigating away and back to the same repeater', async () => {
|
||||
const statusData = { battery_volts: 4.2 };
|
||||
mockApi.repeaterLogin.mockResolvedValueOnce({ status: 'ok' });
|
||||
mockApi.repeaterStatus.mockResolvedValueOnce(statusData);
|
||||
mockApi.sendRepeaterCommand.mockResolvedValueOnce({
|
||||
command: 'ver',
|
||||
response: 'v2.1.0',
|
||||
sender_timestamp: 1000,
|
||||
});
|
||||
|
||||
const firstMount = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
await act(async () => {
|
||||
await firstMount.result.current.login('secret');
|
||||
await firstMount.result.current.refreshPane('status');
|
||||
await firstMount.result.current.sendConsoleCommand('ver');
|
||||
});
|
||||
|
||||
expect(firstMount.result.current.loggedIn).toBe(true);
|
||||
expect(firstMount.result.current.paneData.status).toEqual(statusData);
|
||||
expect(firstMount.result.current.consoleHistory).toHaveLength(2);
|
||||
|
||||
firstMount.unmount();
|
||||
|
||||
const secondMount = renderHook(() => useRepeaterDashboard(repeaterConversation));
|
||||
|
||||
expect(secondMount.result.current.loggedIn).toBe(true);
|
||||
expect(secondMount.result.current.loginError).toBe(null);
|
||||
expect(secondMount.result.current.paneData.status).toEqual(statusData);
|
||||
expect(secondMount.result.current.paneStates.status.loading).toBe(false);
|
||||
expect(secondMount.result.current.consoleHistory).toHaveLength(2);
|
||||
expect(secondMount.result.current.consoleHistory[1].response).toBe('v2.1.0');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
--success-foreground: 0 0% 100%;
|
||||
--info: 217 91% 48%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
--region-override: 274 78% 24%;
|
||||
--favorite: 43 96% 50%;
|
||||
--console: 153 50% 22%;
|
||||
--console-command: 153 55% 18%;
|
||||
@@ -48,6 +49,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%));
|
||||
}
|
||||
|
||||
@@ -364,6 +364,7 @@ export interface PaneState {
|
||||
loading: boolean;
|
||||
attempt: number;
|
||||
error: string | null;
|
||||
fetched_at?: number | null;
|
||||
}
|
||||
|
||||
export interface TraceResponse {
|
||||
|
||||
@@ -7,6 +7,8 @@ export interface Theme {
|
||||
metaThemeColor: string;
|
||||
}
|
||||
|
||||
export const THEME_CHANGE_EVENT = 'remoteterm-theme-change';
|
||||
|
||||
export const THEMES: Theme[] = [
|
||||
{
|
||||
id: 'original',
|
||||
@@ -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 }));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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: "
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||