Compare commits

..

16 Commits

Author SHA1 Message Date
Jack Kingsman 2b5937b9e9 Always show on logged-in pane 2026-04-01 20:45:10 -07:00
Jack Kingsman 2bef62dd87 Show telemetry history within repeater view on load 2026-04-01 19:35:55 -07:00
Jack Kingsman 1d4e25d97c Add delete to not depend on FK 2026-04-01 17:29:12 -07:00
Jack Kingsman dc804d4646 Minor comment correction 2026-04-01 17:24:08 -07:00
Jack Kingsman 2d5024de8f Remove statusFetchedAt unused prop 2026-04-01 17:18:30 -07:00
Jack Kingsman 18f4abcb71 Update migrations to account for my new ones 2026-04-01 17:16:42 -07:00
Gnome Adrift 771e809c11 Prune telemetry entries, remove uplot comments, format code 2026-04-01 13:02:02 -07:00
Gnome Adrift 7ded8e1e71 Oops, remove drop table command in migration :/ 2026-04-01 12:34:23 -07:00
Gnome Adrift 4c9a2273e4 Remove reference to tracking opt-in from database migration 2026-04-01 11:59:57 -07:00
Gnome Adrift 7cad06399c Merge branch 'main' of github.com:jkingsman/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history 2026-04-01 11:55:33 -07:00
Gnome Adrift 04c8ccfa45 Remove automatic telemetry querying, remove battery pane, add telemetry history pane 2026-04-01 11:54:39 -07:00
Gnome Adrift 7ba61ef01d Merge branch 'main' of github.com:maplemesh/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history 2026-03-31 09:11:49 -07:00
Gnome Adrift cd8382f9fb Fix for telemetry polling 2026-03-30 11:38:05 -07:00
Gnome Adrift 8e48e1e817 Merge branch 'main' of github.com:maplemesh/Remote-Terminal-for-MeshCore into gnomeadrift/repeater_telemetry_history 2026-03-30 10:31:28 -07:00
Gnome Adrift c393e8c03e Make battery history update when fetching telemetry 2026-03-30 10:07:20 -07:00
Gnome Adrift 7f7e8cacd1 First draft of repeater telemetry feature 2026-03-29 06:14:14 -07:00
60 changed files with 577 additions and 2669 deletions
+1 -1
View File
@@ -463,7 +463,7 @@ mc.subscribe(EventType.ACK, handler)
|----------|---------|-------------|
| `MESHCORE_SERIAL_PORT` | auto-detect | Serial port for radio |
| `MESHCORE_TCP_HOST` | *(none)* | TCP host for radio (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | `5000` | TCP port (used with `MESHCORE_TCP_HOST`) |
| `MESHCORE_TCP_PORT` | `4000` | TCP port (used with `MESHCORE_TCP_HOST`) |
| `MESHCORE_BLE_ADDRESS` | *(none)* | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | *(required with BLE)* | BLE PIN code |
| `MESHCORE_SERIAL_BAUDRATE` | `115200` | Serial baud rate |
-20
View File
@@ -1,23 +1,3 @@
## [3.7.1] - 2026-04-02
* Feature: Redact Apprise URLs to prevent sensitive information disclosure
## [3.7.0] - 2026-04-02
* Feature: Repeater battery tracking
* Feature: Repeater info pane just like contacts
* Feature: Make repeaters blockable
* Feature: Add new-node advert blocking
* Feature: Add bulk deletion interface
* Feature: Bulk room add on alt+click of new channel button
* Feature: More info in debug endpoint
* Bugfix: Be more conservative around radio load limits and don't exceed radio-reported capacity
* Misc: Default auto-DM decrypt to true
* Misc: Reorganize some settings panes
* Misc: Enable FK pragma
* Misc: Various performance and correctness fixes
* Misc: Correct TCP default port
## [3.6.7] - 2026-03-31
* Misc: Remove armv7 (for now)
+2 -2
View File
@@ -177,7 +177,7 @@ Only one transport may be active at a time. If multiple are set, the server will
| `MESHCORE_SERIAL_PORT` | (auto-detect) | Serial port path |
| `MESHCORE_SERIAL_BAUDRATE` | 115200 | Serial baud rate |
| `MESHCORE_TCP_HOST` | | TCP host (mutually exclusive with serial/BLE) |
| `MESHCORE_TCP_PORT` | 5000 | TCP port |
| `MESHCORE_TCP_PORT` | 4000 | TCP port |
| `MESHCORE_BLE_ADDRESS` | | BLE device address (mutually exclusive with serial/TCP) |
| `MESHCORE_BLE_PIN` | | BLE PIN (required when BLE address is set) |
| `MESHCORE_LOG_LEVEL` | INFO | `DEBUG`, `INFO`, `WARNING`, `ERROR` |
@@ -193,7 +193,7 @@ Common launch patterns:
MESHCORE_SERIAL_PORT=/dev/ttyUSB0 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# TCP
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=5000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
MESHCORE_TCP_HOST=192.168.1.100 MESHCORE_TCP_PORT=4000 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
# BLE
MESHCORE_BLE_ADDRESS=AA:BB:CC:DD:EE:FF MESHCORE_BLE_PIN=123456 uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
+1 -2
View File
@@ -14,7 +14,7 @@ class Settings(BaseSettings):
serial_port: str = "" # Empty string triggers auto-detection
serial_baudrate: int = 115200
tcp_host: str = ""
tcp_port: int = 5000
tcp_port: int = 4000
ble_address: str = ""
ble_pin: str = ""
log_level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
@@ -26,7 +26,6 @@ class Settings(BaseSettings):
default=False,
validation_alias="__CLOWNTOWN_DO_CLOCK_WRAPAROUND",
)
skip_post_connect_sync: bool = False
basic_auth_username: str = ""
basic_auth_password: str = ""
+3 -13
View File
@@ -66,7 +66,7 @@ CREATE TABLE IF NOT EXISTS raw_packets (
data BLOB NOT NULL,
message_id INTEGER,
payload_hash BLOB,
FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL
FOREIGN KEY (message_id) REFERENCES messages(id)
);
CREATE TABLE IF NOT EXISTS contact_advert_paths (
@@ -78,7 +78,7 @@ CREATE TABLE IF NOT EXISTS contact_advert_paths (
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex, path_len),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
);
CREATE TABLE IF NOT EXISTS contact_name_history (
@@ -88,7 +88,7 @@ CREATE TABLE IF NOT EXISTS contact_name_history (
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
UNIQUE(public_key, name),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
FOREIGN KEY (public_key) REFERENCES contacts(public_key)
);
CREATE INDEX IF NOT EXISTS idx_messages_received ON messages(received_at);
@@ -132,12 +132,6 @@ class Database:
# migration 20 handles the one-time VACUUM to restructure the file.
await self._connection.execute("PRAGMA auto_vacuum = INCREMENTAL")
# Foreign key enforcement: must be set per-connection (not persisted).
# Disabled during schema init and migrations to avoid issues with
# historical table-rebuild migrations that may temporarily violate
# constraints, then re-enabled for all subsequent application queries.
await self._connection.execute("PRAGMA foreign_keys = OFF")
await self._connection.executescript(SCHEMA)
await self._connection.commit()
logger.debug("Database schema initialized")
@@ -147,10 +141,6 @@ class Database:
await run_migrations(self._connection)
# Enable FK enforcement for all application queries from this point on.
await self._connection.execute("PRAGMA foreign_keys = ON")
logger.debug("Foreign key enforcement enabled")
async def disconnect(self) -> None:
if self._connection:
await self._connection.close()
+6 -196
View File
@@ -367,28 +367,13 @@ async def run_migrations(conn: aiosqlite.Connection) -> int:
await set_version(conn, 47)
applied += 1
# Migration 48: Add discovery_blocked_types column to app_settings
if version < 48:
logger.info("Applying migration 48: add discovery_blocked_types to app_settings")
await _migrate_048_discovery_blocked_types(conn)
await set_version(conn, 48)
applied += 1
# Migration 49: Enable foreign key enforcement — rebuild tables with
# CASCADE / SET NULL and clean up any orphaned rows first.
# Migration 49: Repeater telemetry history table
if version < 49:
logger.info("Applying migration 49: add foreign key cascade/set-null and clean orphans")
await _migrate_049_foreign_key_cascade(conn)
logger.info("Applying migration 49: repeater telemetry history")
await _migrate_049_repeater_telemetry_history(conn)
await set_version(conn, 49)
applied += 1
# Migration 50: Repeater telemetry history table + tracking opt-in column
if version < 50:
logger.info("Applying migration 50: repeater telemetry history")
await _migrate_050_repeater_telemetry_history(conn)
await set_version(conn, 50)
applied += 1
if applied > 0:
logger.info(
"Applied %d migration(s), schema now at version %d", applied, await get_version(conn)
@@ -851,7 +836,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
id INTEGER PRIMARY KEY CHECK (id = 1),
max_radio_contacts INTEGER DEFAULT 200,
favorites TEXT DEFAULT '[]',
auto_decrypt_dm_on_advert INTEGER DEFAULT 1,
auto_decrypt_dm_on_advert INTEGER DEFAULT 0,
sidebar_sort_order TEXT DEFAULT 'recent',
last_message_times TEXT DEFAULT '{}',
preferences_migrated INTEGER DEFAULT 0
@@ -863,7 +848,7 @@ async def _migrate_009_create_app_settings_table(conn: aiosqlite.Connection) ->
await conn.execute(
"""
INSERT OR IGNORE INTO app_settings (id, max_radio_contacts, favorites, auto_decrypt_dm_on_advert, sidebar_sort_order, last_message_times, preferences_migrated)
VALUES (1, 200, '[]', 1, 'recent', '{}', 0)
VALUES (1, 200, '[]', 0, 'recent', '{}', 0)
"""
)
@@ -2933,182 +2918,7 @@ async def _migrate_047_add_statistics_indexes(conn: aiosqlite.Connection) -> Non
await conn.commit()
async def _migrate_048_discovery_blocked_types(conn: aiosqlite.Connection) -> None:
"""Add discovery_blocked_types column to app_settings.
Stores a JSON array of integer contact type codes (1=Client, 2=Repeater,
3=Room, 4=Sensor) whose advertisements should not create new contacts.
Empty list means all types are accepted.
"""
try:
await conn.execute(
"ALTER TABLE app_settings ADD COLUMN discovery_blocked_types TEXT DEFAULT '[]'"
)
except Exception as e:
error_msg = str(e).lower()
if "duplicate column" in error_msg:
logger.debug("discovery_blocked_types column already exists, skipping")
elif "no such table" in error_msg:
logger.debug("app_settings table not ready, skipping discovery_blocked_types migration")
else:
raise
await conn.commit()
async def _migrate_049_foreign_key_cascade(conn: aiosqlite.Connection) -> None:
"""Rebuild FK tables with CASCADE/SET NULL and clean orphaned rows.
SQLite cannot ALTER existing FK constraints, so each table is rebuilt.
Orphaned child rows are cleaned up before the rebuild to ensure the
INSERT...SELECT into the new table (which has enforced FKs) succeeds.
"""
import shutil
from pathlib import Path
# Back up the database before table rebuilds (skip for in-memory DBs).
cursor = await conn.execute("PRAGMA database_list")
db_row = await cursor.fetchone()
db_path = db_row[2] if db_row else ""
if db_path and db_path != ":memory:" and Path(db_path).exists():
backup_path = db_path + ".pre-fk-migration.bak"
for suffix in ("", "-wal", "-shm"):
src = Path(db_path + suffix)
if src.exists():
shutil.copy2(str(src), backup_path + suffix)
logger.info("Database backed up to %s before FK migration", backup_path)
# --- Phase 1: clean orphans (guard each table's existence) ---
tables_cursor = await conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
existing_tables = {row[0] for row in await tables_cursor.fetchall()}
if "contact_advert_paths" in existing_tables and "contacts" in existing_tables:
await conn.execute(
"DELETE FROM contact_advert_paths "
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
)
if "contact_name_history" in existing_tables and "contacts" in existing_tables:
await conn.execute(
"DELETE FROM contact_name_history "
"WHERE public_key NOT IN (SELECT public_key FROM contacts)"
)
if "raw_packets" in existing_tables and "messages" in existing_tables:
# Guard: message_id column may not exist on very old schemas
col_cursor = await conn.execute("PRAGMA table_info(raw_packets)")
raw_cols = {row[1] for row in await col_cursor.fetchall()}
if "message_id" in raw_cols:
await conn.execute(
"UPDATE raw_packets SET message_id = NULL WHERE message_id IS NOT NULL "
"AND message_id NOT IN (SELECT id FROM messages)"
)
await conn.commit()
logger.debug("Cleaned orphaned child rows before FK rebuild")
# --- Phase 2: rebuild raw_packets with ON DELETE SET NULL ---
# Skip if raw_packets doesn't have message_id (pre-migration-18 schema)
raw_has_message_id = False
if "raw_packets" in existing_tables:
col_cursor2 = await conn.execute("PRAGMA table_info(raw_packets)")
raw_has_message_id = "message_id" in {row[1] for row in await col_cursor2.fetchall()}
if raw_has_message_id:
# Dynamically build column list based on what the old table actually has,
# since very old schemas may lack payload_hash (added in migration 28).
col_cursor3 = await conn.execute("PRAGMA table_info(raw_packets)")
old_cols = [row[1] for row in await col_cursor3.fetchall()]
new_col_defs = [
"id INTEGER PRIMARY KEY AUTOINCREMENT",
"timestamp INTEGER NOT NULL",
"data BLOB NOT NULL",
"message_id INTEGER",
]
copy_cols = ["id", "timestamp", "data", "message_id"]
if "payload_hash" in old_cols:
new_col_defs.append("payload_hash BLOB")
copy_cols.append("payload_hash")
new_col_defs.append("FOREIGN KEY (message_id) REFERENCES messages(id) ON DELETE SET NULL")
cols_sql = ", ".join(new_col_defs)
copy_sql = ", ".join(copy_cols)
await conn.execute(f"CREATE TABLE raw_packets_fk ({cols_sql})")
await conn.execute(
f"INSERT INTO raw_packets_fk ({copy_sql}) SELECT {copy_sql} FROM raw_packets"
)
await conn.execute("DROP TABLE raw_packets")
await conn.execute("ALTER TABLE raw_packets_fk RENAME TO raw_packets")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_packets_message_id ON raw_packets(message_id)"
)
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_raw_packets_timestamp ON raw_packets(timestamp)"
)
if "payload_hash" in old_cols:
await conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_packets_payload_hash ON raw_packets(payload_hash)"
)
await conn.commit()
logger.debug("Rebuilt raw_packets with ON DELETE SET NULL")
# --- Phase 3: rebuild contact_advert_paths with ON DELETE CASCADE ---
if "contact_advert_paths" in existing_tables:
await conn.execute(
"""
CREATE TABLE contact_advert_paths_fk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
path_hex TEXT NOT NULL,
path_len INTEGER NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
heard_count INTEGER NOT NULL DEFAULT 1,
UNIQUE(public_key, path_hex, path_len),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"INSERT INTO contact_advert_paths_fk (id, public_key, path_hex, path_len, first_seen, last_seen, heard_count) "
"SELECT id, public_key, path_hex, path_len, first_seen, last_seen, heard_count FROM contact_advert_paths"
)
await conn.execute("DROP TABLE contact_advert_paths")
await conn.execute("ALTER TABLE contact_advert_paths_fk RENAME TO contact_advert_paths")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contact_advert_paths_recent "
"ON contact_advert_paths(public_key, last_seen DESC)"
)
await conn.commit()
logger.debug("Rebuilt contact_advert_paths with ON DELETE CASCADE")
# --- Phase 4: rebuild contact_name_history with ON DELETE CASCADE ---
if "contact_name_history" in existing_tables:
await conn.execute(
"""
CREATE TABLE contact_name_history_fk (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_key TEXT NOT NULL,
name TEXT NOT NULL,
first_seen INTEGER NOT NULL,
last_seen INTEGER NOT NULL,
UNIQUE(public_key, name),
FOREIGN KEY (public_key) REFERENCES contacts(public_key) ON DELETE CASCADE
)
"""
)
await conn.execute(
"INSERT INTO contact_name_history_fk (id, public_key, name, first_seen, last_seen) "
"SELECT id, public_key, name, first_seen, last_seen FROM contact_name_history"
)
await conn.execute("DROP TABLE contact_name_history")
await conn.execute("ALTER TABLE contact_name_history_fk RENAME TO contact_name_history")
await conn.execute(
"CREATE INDEX IF NOT EXISTS idx_contact_name_history_key "
"ON contact_name_history(public_key, last_seen DESC)"
)
await conn.commit()
logger.debug("Rebuilt contact_name_history with ON DELETE CASCADE")
async def _migrate_050_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
async def _migrate_049_repeater_telemetry_history(conn: aiosqlite.Connection) -> None:
"""Create repeater_telemetry_history table for JSON-blob telemetry snapshots."""
await conn.execute(
"""
+1 -8
View File
@@ -808,7 +808,7 @@ class AppSettings(BaseModel):
default_factory=list, description="List of favorited conversations"
)
auto_decrypt_dm_on_advert: bool = Field(
default=True,
default=False,
description="Whether to attempt historical DM decryption on new contact advertisement",
)
sidebar_sort_order: Literal["recent", "alpha"] = Field(
@@ -843,13 +843,6 @@ class AppSettings(BaseModel):
default_factory=list,
description="Display names whose messages are hidden from the UI",
)
discovery_blocked_types: list[int] = Field(
default_factory=list,
description=(
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
"advertisements should not create new contacts; existing contacts are still updated"
),
)
class FanoutConfig(BaseModel):
+8 -24
View File
@@ -462,19 +462,14 @@ async def _process_advertisement(
advert.device_role if advert.device_role > 0 else (existing.type if existing else 0)
)
# Check discovery_blocked_types: skip new contacts whose type is blocked.
# Existing contacts are always updated (location, name, last_seen, etc.).
if existing is None and contact_type > 0:
from app.repository import AppSettingsRepository
settings = await AppSettingsRepository.get()
if contact_type in settings.discovery_blocked_types:
logger.debug(
"Skipping new contact %s: type %d is in discovery_blocked_types",
advert.public_key[:12],
contact_type,
)
return
# Keep recent unique advert paths for all contacts.
await ContactAdvertPathRepository.record_observation(
public_key=advert.public_key.lower(),
path_hex=new_path_hex,
timestamp=timestamp,
max_paths=10,
hop_count=new_path_len,
)
contact_upsert = ContactUpsert(
public_key=advert.public_key.lower(),
@@ -487,18 +482,7 @@ async def _process_advertisement(
first_seen=timestamp, # COALESCE in upsert preserves existing value
)
# Upsert the contact BEFORE recording advert paths so the parent row
# exists when foreign key enforcement is enabled.
await ContactRepository.upsert(contact_upsert)
# Keep recent unique advert paths for all contacts.
await ContactAdvertPathRepository.record_observation(
public_key=advert.public_key.lower(),
path_hex=new_path_hex,
timestamp=timestamp,
max_paths=10,
hop_count=new_path_len,
)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=advert.public_key,
log=logger,
+4 -35
View File
@@ -29,10 +29,7 @@ from app.repository import (
ChannelRepository,
ContactRepository,
)
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.contact_reconciliation import reconcile_contact_messages
from app.services.messages import create_fallback_channel_message
from app.services.radio_runtime import radio_runtime as radio_manager
from app.websocket import broadcast_error, broadcast_event
@@ -66,25 +63,13 @@ async def _reconcile_contact_messages_background(
public_key: str,
contact_name: str | None,
) -> None:
"""Run prefix promotion and contact/message reconciliation outside the radio critical path."""
"""Run contact/message reconciliation outside the radio critical path."""
try:
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=public_key,
log=logger,
)
await reconcile_contact_messages(
public_key=public_key,
contact_name=contact_name,
log=logger,
)
if promoted_keys:
contact = await ContactRepository.get_by_key(public_key.lower())
if contact is not None:
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{"previous_public_key": old_key, "contact": contact.model_dump()},
)
except Exception as exc:
logger.warning(
"Background contact reconciliation failed for %s: %s",
@@ -194,22 +179,6 @@ RADIO_CONTACT_REFILL_RATIO = 0.80
RADIO_CONTACT_FULL_SYNC_RATIO = 0.95
def _effective_radio_capacity(configured: int) -> int:
"""Return the effective radio contact capacity.
Uses the lower of the user-configured ``max_radio_contacts`` and the
hardware limit reported by the radio at connect time. The existing
80% refill ratio already reserves headroom for the radio to
organically add contacts it hears via adverts, so no additional
reduction is applied here.
"""
capacity = max(1, configured)
hw_limit = radio_manager.max_contacts
if hw_limit is not None:
capacity = min(capacity, hw_limit)
return max(1, capacity)
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)
@@ -224,7 +193,7 @@ def _compute_radio_contact_limits(max_contacts: int) -> tuple[int, int]:
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 = _effective_radio_capacity(app_settings.max_radio_contacts)
capacity = app_settings.max_radio_contacts
refill_target, full_sync_trigger = _compute_radio_contact_limits(capacity)
result = await mc.commands.get_contacts()
@@ -1332,7 +1301,7 @@ async def stop_background_contact_reconciliation() -> None:
async def get_contacts_selected_for_radio_sync() -> list[Contact]:
"""Return the contacts that would be loaded onto the radio right now."""
app_settings = await AppSettingsRepository.get()
max_contacts = _effective_radio_capacity(app_settings.max_radio_contacts)
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()
+54 -63
View File
@@ -1,4 +1,3 @@
import logging
import time
from collections.abc import Mapping
from typing import Any
@@ -13,8 +12,6 @@ from app.models import (
)
from app.path_utils import first_hop_hex, normalize_contact_route, normalize_route_override
logger = logging.getLogger(__name__)
class AmbiguousPublicKeyPrefixError(ValueError):
"""Raised when a public key prefix matches multiple contacts."""
@@ -401,6 +398,9 @@ class ContactRepository:
await db.conn.execute(
"DELETE FROM contact_advert_paths WHERE public_key = ?", (normalized,)
)
await db.conn.execute(
"DELETE FROM repeater_telemetry_history WHERE public_key = ?", (normalized,)
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (normalized,))
await db.conn.commit()
@@ -487,6 +487,7 @@ class ContactRepository:
return []
promoted_keys: list[str] = []
full_exists = await ContactRepository.get_by_key(normalized_full_key) is not None
for row in rows:
old_key = row["public_key"]
@@ -503,70 +504,60 @@ class ContactRepository:
(old_key,),
)
match_row = await match_cursor.fetchone()
match_count = match_row["match_count"] if match_row is not None else 0
if match_count != 1:
logger.warning(
"Skipping prefix promotion for %s: %d full-key contacts match (expected 1)",
old_key,
match_count,
)
if (match_row["match_count"] if match_row is not None else 0) != 1:
continue
await migrate_child_rows(old_key, normalized_full_key)
# Merge timestamp metadata from the old prefix contact into the
# full-key contact (which all callers guarantee already exists),
# then delete the prefix placeholder.
await db.conn.execute(
"""
UPDATE contacts
SET last_seen = CASE
WHEN contacts.last_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_seen
WHEN ? > contacts.last_seen THEN ?
ELSE contacts.last_seen
END,
last_contacted = CASE
WHEN contacts.last_contacted IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ?
ELSE contacts.last_contacted
END,
first_seen = CASE
WHEN contacts.first_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.first_seen
WHEN ? < contacts.first_seen THEN ?
ELSE contacts.first_seen
END,
last_read_at = CASE
WHEN contacts.last_read_at IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_read_at
WHEN ? > contacts.last_read_at THEN ?
ELSE contacts.last_read_at
END
WHERE public_key = ?
""",
(
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["last_read_at"],
row["last_read_at"],
row["last_read_at"],
row["last_read_at"],
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
if full_exists:
await db.conn.execute(
"""
UPDATE contacts
SET last_seen = CASE
WHEN contacts.last_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_seen
WHEN ? > contacts.last_seen THEN ?
ELSE contacts.last_seen
END,
last_contacted = CASE
WHEN contacts.last_contacted IS NULL THEN ?
WHEN ? IS NULL THEN contacts.last_contacted
WHEN ? > contacts.last_contacted THEN ?
ELSE contacts.last_contacted
END,
first_seen = CASE
WHEN contacts.first_seen IS NULL THEN ?
WHEN ? IS NULL THEN contacts.first_seen
WHEN ? < contacts.first_seen THEN ?
ELSE contacts.first_seen
END,
last_read_at = COALESCE(contacts.last_read_at, ?)
WHERE public_key = ?
""",
(
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_seen"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["last_contacted"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["first_seen"],
row["last_read_at"],
normalized_full_key,
),
)
await db.conn.execute("DELETE FROM contacts WHERE public_key = ?", (old_key,))
else:
await db.conn.execute(
"UPDATE contacts SET public_key = ? WHERE public_key = ?",
(normalized_full_key, old_key),
)
full_exists = True
promoted_keys.append(old_key)
+2 -9
View File
@@ -158,11 +158,7 @@ class MessageRepository:
"""
lower_key = full_key.lower()
cursor = await db.conn.execute(
"""UPDATE messages SET conversation_key = ?,
sender_key = CASE
WHEN sender_key IS NOT NULL AND length(sender_key) < 64
AND ? LIKE sender_key || '%'
THEN ? ELSE sender_key END
"""UPDATE messages SET conversation_key = ?
WHERE type = 'PRIV' AND length(conversation_key) < 64
AND ? LIKE conversation_key || '%'
AND (
@@ -170,7 +166,7 @@ class MessageRepository:
WHERE length(public_key) = 64
AND public_key LIKE messages.conversation_key || '%'
) = 1""",
(lower_key, lower_key, lower_key, lower_key),
(lower_key, lower_key),
)
await db.conn.commit()
return cursor.rowcount
@@ -576,9 +572,6 @@ class MessageRepository:
@staticmethod
async def delete_by_id(message_id: int) -> None:
"""Delete a message row by ID."""
await db.conn.execute(
"UPDATE raw_packets SET message_id = NULL WHERE message_id = ?", (message_id,)
)
await db.conn.execute("DELETE FROM messages WHERE id = ?", (message_id,))
await db.conn.commit()
+1 -15
View File
@@ -29,7 +29,7 @@ class AppSettingsRepository:
SELECT max_radio_contacts, favorites, auto_decrypt_dm_on_advert,
sidebar_sort_order, last_message_times, preferences_migrated,
advert_interval, last_advert_time, flood_scope,
blocked_keys, blocked_names, discovery_blocked_types
blocked_keys, blocked_names
FROM app_settings WHERE id = 1
"""
)
@@ -81,14 +81,6 @@ class AppSettingsRepository:
except (json.JSONDecodeError, TypeError):
blocked_names = []
# Parse discovery_blocked_types JSON
discovery_blocked_types: list[int] = []
if row["discovery_blocked_types"]:
try:
discovery_blocked_types = json.loads(row["discovery_blocked_types"])
except (json.JSONDecodeError, TypeError):
discovery_blocked_types = []
# Validate sidebar_sort_order (fallback to "recent" if invalid)
sort_order = row["sidebar_sort_order"]
if sort_order not in ("recent", "alpha"):
@@ -106,7 +98,6 @@ class AppSettingsRepository:
flood_scope=row["flood_scope"] or "",
blocked_keys=blocked_keys,
blocked_names=blocked_names,
discovery_blocked_types=discovery_blocked_types,
)
@staticmethod
@@ -122,7 +113,6 @@ class AppSettingsRepository:
flood_scope: str | None = None,
blocked_keys: list[str] | None = None,
blocked_names: list[str] | None = None,
discovery_blocked_types: list[int] | None = None,
) -> AppSettings:
"""Update app settings. Only provided fields are updated."""
updates = []
@@ -173,10 +163,6 @@ class AppSettingsRepository:
updates.append("blocked_names = ?")
params.append(json.dumps(blocked_names))
if discovery_blocked_types is not None:
updates.append("discovery_blocked_types = ?")
params.append(json.dumps(discovery_blocked_types))
if updates:
query = f"UPDATE app_settings SET {', '.join(updates)} WHERE id = 1"
await db.conn.execute(query, params)
+47 -224
View File
@@ -1,8 +1,7 @@
import logging
import re
from hashlib import sha256
from fastapi import APIRouter, BackgroundTasks, HTTPException, Response, status
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from app.channel_constants import (
@@ -11,12 +10,10 @@ from app.channel_constants import (
is_public_channel_key,
is_public_channel_name,
)
from app.decoder import parse_packet, try_decrypt_packet_with_channel_key
from app.models import Channel, ChannelDetail, ChannelMessageCounts, ChannelTopSender
from app.packet_processor import create_message_from_decrypted
from app.region_scope import normalize_region_scope
from app.repository import ChannelRepository, MessageRepository, RawPacketRepository
from app.websocket import broadcast_event, broadcast_success
from app.repository import ChannelRepository, MessageRepository
from app.websocket import broadcast_event
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/channels", tags=["channels"])
@@ -34,154 +31,12 @@ class CreateChannelRequest(BaseModel):
)
class BulkCreateHashtagChannelsRequest(BaseModel):
channel_names: list[str] = Field(
min_length=1,
description="List of hashtag room names. Leading # is optional per entry.",
)
try_historical: bool = Field(
default=False,
description="Attempt one background historical decrypt sweep for the newly added rooms.",
)
class BulkCreateHashtagChannelsResponse(BaseModel):
created_channels: list[Channel]
existing_count: int
invalid_names: list[str]
decrypt_started: bool = False
decrypt_total_packets: int = 0
message: str
class ChannelFloodScopeOverrideRequest(BaseModel):
flood_scope_override: str = Field(
description="Blank clears the override; non-empty values temporarily override flood scope"
)
def _derive_channel_identity(
requested_name: str,
request_key: str | None = None,
) -> tuple[str, str, bool]:
is_hashtag = requested_name.startswith("#")
if is_public_channel_name(requested_name):
if request_key:
try:
key_bytes = bytes.fromhex(request_key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)",
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
raise HTTPException(
status_code=400,
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
)
return PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME, False
if request_key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request_key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
key_hex = key_bytes.hex().upper()
if is_public_channel_key(key_hex):
raise HTTPException(
status_code=400,
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
)
return key_hex, requested_name, False
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
return key_bytes.hex().upper(), requested_name, is_hashtag
def _normalize_bulk_hashtag_name(name: str) -> str | None:
trimmed = name.strip()
if not trimmed:
return None
normalized = trimmed.lstrip("#").strip()
if not normalized:
return None
if len(normalized) > 31:
return None
if not re.fullmatch(r"[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*", normalized):
return None
return f"#{normalized}"
async def _run_historical_channel_decryption_for_channels(
channels: list[tuple[bytes, str, str]],
) -> None:
packets = await RawPacketRepository.get_all_undecrypted()
total = len(packets)
decrypted_count = 0
matched_channel_names: set[str] = set()
if total == 0:
logger.info("No undecrypted packets to process for bulk channel decrypt")
return
logger.info(
"Starting bulk historical channel decryption of %d packets across %d channels",
total,
len(channels),
)
for packet_id, packet_data, packet_timestamp in packets:
packet_info = parse_packet(packet_data)
path_hex = packet_info.path.hex() if packet_info else None
path_len = packet_info.path_length if packet_info else None
for channel_key_bytes, channel_key_hex, channel_name in channels:
result = try_decrypt_packet_with_channel_key(packet_data, channel_key_bytes)
if result is None:
continue
msg_id = await create_message_from_decrypted(
packet_id=packet_id,
channel_key=channel_key_hex,
channel_name=channel_name,
sender=result.sender,
message_text=result.message,
timestamp=result.timestamp,
received_at=packet_timestamp,
path=path_hex,
path_len=path_len,
realtime=False,
)
if msg_id is not None:
decrypted_count += 1
matched_channel_names.add(channel_name)
break
logger.info(
"Bulk historical channel decryption complete: %d/%d packets decrypted across %d channels",
decrypted_count,
total,
len(matched_channel_names),
)
if decrypted_count > 0:
broadcast_success(
"Bulk historical decrypt complete",
(
f"Decrypted {decrypted_count} message{'s' if decrypted_count != 1 else ''} "
f"across {len(matched_channel_names)} room"
f"{'s' if len(matched_channel_names) != 1 else ''}"
),
)
@router.get("", response_model=list[Channel])
async def list_channels() -> list[Channel]:
"""List all channels from the database."""
@@ -214,7 +69,50 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
automatically when sending a message (see messages.py send_channel_message).
"""
requested_name = request.name
key_hex, channel_name, is_hashtag = _derive_channel_identity(requested_name, request.key)
is_hashtag = requested_name.startswith("#")
# Reserve the canonical Public channel so it cannot drift to another key,
# and the well-known Public key cannot be renamed to something else.
if is_public_channel_name(requested_name):
if request.key:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400,
detail="Channel key must be exactly 16 bytes (32 hex chars)",
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
if key_bytes.hex().upper() != PUBLIC_CHANNEL_KEY:
raise HTTPException(
status_code=400,
detail=f'"{PUBLIC_CHANNEL_NAME}" must use the canonical Public key',
)
key_hex = PUBLIC_CHANNEL_KEY
channel_name = PUBLIC_CHANNEL_NAME
is_hashtag = False
elif request.key and not is_hashtag:
try:
key_bytes = bytes.fromhex(request.key)
if len(key_bytes) != 16:
raise HTTPException(
status_code=400, detail="Channel key must be exactly 16 bytes (32 hex chars)"
)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid hex string for key") from None
key_hex = key_bytes.hex().upper()
if is_public_channel_key(key_hex):
raise HTTPException(
status_code=400,
detail=f'The canonical Public key may only be used for "{PUBLIC_CHANNEL_NAME}"',
)
channel_name = requested_name
else:
# Derive key from name hash (same as meshcore library does)
key_bytes = sha256(requested_name.encode("utf-8")).digest()[:16]
key_hex = key_bytes.hex().upper()
channel_name = requested_name
logger.info("Creating channel %s: %s (hashtag=%s)", key_hex, channel_name, is_hashtag)
@@ -234,81 +132,6 @@ async def create_channel(request: CreateChannelRequest) -> Channel:
return stored
@router.post("/bulk-hashtag", response_model=BulkCreateHashtagChannelsResponse)
async def bulk_create_hashtag_channels(
request: BulkCreateHashtagChannelsRequest,
background_tasks: BackgroundTasks,
response: Response,
) -> BulkCreateHashtagChannelsResponse:
created_channels: list[Channel] = []
existing_count = 0
invalid_names: list[str] = []
decrypt_started = False
decrypt_total_packets = 0
decrypt_targets: list[tuple[bytes, str, str]] = []
for raw_name in request.channel_names:
normalized_name = _normalize_bulk_hashtag_name(raw_name)
if normalized_name is None:
invalid_names.append(raw_name)
continue
key_hex, channel_name, is_hashtag = _derive_channel_identity(normalized_name)
existing = await ChannelRepository.get_by_key(key_hex)
if existing is not None:
existing_count += 1
continue
await ChannelRepository.upsert(
key=key_hex,
name=channel_name,
is_hashtag=is_hashtag,
on_radio=False,
)
stored = await ChannelRepository.get_by_key(key_hex)
if stored is None:
raise HTTPException(
status_code=500,
detail="Channel was created but could not be reloaded",
)
created_channels.append(stored)
decrypt_targets.append((bytes.fromhex(stored.key), stored.key, stored.name))
_broadcast_channel_update(stored)
if request.try_historical and decrypt_targets:
decrypt_total_packets = await RawPacketRepository.get_undecrypted_count()
if decrypt_total_packets > 0:
background_tasks.add_task(
_run_historical_channel_decryption_for_channels, decrypt_targets
)
decrypt_started = True
response.status_code = status.HTTP_202_ACCEPTED
message = (
f"Created {len(created_channels)} room{'s' if len(created_channels) != 1 else ''}"
if created_channels
else "No new rooms were added"
)
if request.try_historical and decrypt_targets:
if decrypt_started:
message += (
f" and started background decrypt of {decrypt_total_packets} packet"
f"{'s' if decrypt_total_packets != 1 else ''}"
)
else:
message += "; no undecrypted packets were available"
return BulkCreateHashtagChannelsResponse(
created_channels=created_channels,
existing_count=existing_count,
invalid_names=invalid_names,
decrypt_started=decrypt_started,
decrypt_total_packets=decrypt_total_packets,
message=message,
)
@router.post("/{key}/mark-read")
async def mark_channel_read(key: str) -> dict:
"""Mark a channel as read (update last_read_at timestamp)."""
+3 -50
View File
@@ -1,12 +1,10 @@
import asyncio
import logging
import random
import time
from contextlib import suppress
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query
from meshcore import EventType
from pydantic import BaseModel, Field
from app.dependencies import require_connected
from app.models import (
@@ -33,7 +31,7 @@ from app.repository import (
)
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
record_contact_name_and_reconcile,
reconcile_contact_messages,
)
from app.services.radio_runtime import radio_runtime as radio_manager
@@ -279,18 +277,12 @@ async def create_contact(
# Check if contact already exists
existing = await ContactRepository.get_by_key(request.public_key)
if existing:
# Update name if provided and record name history
# Update name if provided
if request.name:
await ContactRepository.upsert(existing.to_upsert(name=request.name))
refreshed = await ContactRepository.get_by_key(request.public_key)
if refreshed is not None:
existing = refreshed
await record_contact_name_and_reconcile(
public_key=request.public_key,
contact_name=request.name,
timestamp=int(time.time()),
log=logger,
)
promoted_keys = await promote_prefix_contacts_for_contact(
public_key=request.public_key,
@@ -325,10 +317,9 @@ async def create_contact(
log=logger,
)
await record_contact_name_and_reconcile(
await reconcile_contact_messages(
public_key=lower_key,
contact_name=request.name,
timestamp=int(time.time()),
log=logger,
)
@@ -356,44 +347,6 @@ async def mark_contact_read(public_key: str) -> dict:
return {"status": "ok", "public_key": contact.public_key}
class BulkDeleteRequest(BaseModel):
public_keys: list[str] = Field(description="Public keys to delete")
@router.post("/bulk-delete")
async def bulk_delete_contacts(request: BulkDeleteRequest) -> dict:
"""Delete multiple contacts from the database (and radio if present)."""
from app.websocket import broadcast_event
# Resolve all contacts first
contacts_to_delete: list[Contact] = []
for key in request.public_keys:
contact = await ContactRepository.get_by_key(key.lower())
if contact:
contacts_to_delete.append(contact)
# Remove from radio in a single locked operation (blocks until radio is free)
if radio_manager.is_connected and contacts_to_delete:
try:
async with radio_manager.radio_operation("bulk_delete_contacts_from_radio") as mc:
for contact in contacts_to_delete:
radio_contact = mc.get_contact_by_key_prefix(contact.public_key[:12])
if radio_contact:
await mc.commands.remove_contact(radio_contact)
except Exception as e:
logger.warning("Radio removal during bulk delete failed: %s", e)
# Delete from database and broadcast events
deleted = 0
for contact in contacts_to_delete:
await ContactRepository.delete(contact.public_key)
broadcast_event("contact_deleted", {"public_key": contact.public_key})
deleted += 1
logger.info("Bulk deleted %d/%d contacts", deleted, len(request.public_keys))
return {"deleted": deleted}
@router.delete("/{public_key}")
async def delete_contact(public_key: str) -> dict:
"""Delete a contact from the database (and radio if present)."""
+18 -84
View File
@@ -5,7 +5,7 @@ import platform
import struct
import sys
from datetime import datetime, timezone
from typing import Any, Literal
from typing import Any
from fastapi import APIRouter
from meshcore import EventType
@@ -15,7 +15,7 @@ from app.config import get_recent_log_lines, settings
from app.models import AppSettings
from app.radio_sync import get_contacts_selected_for_radio_sync, get_radio_channel_limit
from app.repository import AppSettingsRepository, MessageRepository, StatisticsRepository
from app.routers.health import FanoutStatusResponse, build_health_data
from app.routers.health import HealthResponse, build_health_data
from app.services.radio_runtime import radio_runtime
from app.version_info import get_app_build_info, git_output
@@ -61,6 +61,8 @@ class DebugRuntimeInfo(BaseModel):
setup_in_progress: bool
setup_complete: bool
channels_with_incoming_messages: int
max_channels: int
path_hash_mode: int
path_hash_mode_supported: bool
channel_slot_reuse_enabled: bool
channel_send_cache_capacity: int
@@ -87,6 +89,7 @@ class DebugChannelAudit(BaseModel):
class DebugRadioProbe(BaseModel):
performed: bool
errors: list[str] = Field(default_factory=list)
multi_acks_enabled: bool | None = None
self_info: dict[str, Any] | None = None
device_info: dict[str, Any] | None = None
stats_core: dict[str, Any] | None = None
@@ -101,15 +104,6 @@ class DebugDatabaseInfo(BaseModel):
total_outgoing: int
class DebugHealthSummary(BaseModel):
radio_state: str
database_size_mb: float
oldest_undecrypted_timestamp: int | None
fanouts_with_errors: dict[str, FanoutStatusResponse] = Field(default_factory=dict)
bots_disabled_source: Literal["env", "until_restart"] | None = None
basic_auth_enabled: bool = False
class DebugAppSettings(BaseModel):
max_radio_contacts: int
auto_decrypt_dm_on_advert: bool
@@ -123,7 +117,7 @@ class DebugSnapshotResponse(BaseModel):
captured_at: str
system: DebugSystemInfo
application: DebugApplicationInfo
health: DebugHealthSummary
health: HealthResponse
settings: DebugAppSettings
runtime: DebugRuntimeInfo
database: DebugDatabaseInfo
@@ -214,57 +208,6 @@ def _build_debug_app_settings(app_settings: AppSettings) -> DebugAppSettings:
)
def _derive_debug_radio_state(
*,
radio_connected: bool,
connection_desired: bool,
setup_in_progress: bool,
setup_complete: bool,
is_reconnecting: bool,
) -> str:
if not connection_desired:
return "paused"
if radio_connected and (setup_in_progress or not setup_complete):
return "initializing"
if radio_connected:
return "connected"
if is_reconnecting:
return "connecting"
return "disconnected"
def _build_debug_health_summary(
health_data: dict[str, Any], *, radio_state: str
) -> DebugHealthSummary:
def _fanout_last_error(status: Any) -> str | None:
if isinstance(status, dict):
value = status.get("last_error")
else:
value = getattr(status, "last_error", None)
return value if isinstance(value, str) and value else None
fanouts_with_errors = {
config_id: status
for config_id, status in health_data["fanout_statuses"].items()
if _fanout_last_error(status)
}
return DebugHealthSummary(
radio_state=radio_state,
database_size_mb=health_data["database_size_mb"],
oldest_undecrypted_timestamp=health_data["oldest_undecrypted_timestamp"],
fanouts_with_errors=fanouts_with_errors,
bots_disabled_source=health_data["bots_disabled_source"],
basic_auth_enabled=health_data["basic_auth_enabled"],
)
def _sanitize_radio_probe_self_info(self_info: dict[str, Any] | None) -> dict[str, Any]:
sanitized = dict(self_info or {})
sanitized.pop("adv_lat", None)
sanitized.pop("adv_lon", None)
return sanitized
async def _build_contact_audit(
observed_contacts_payload: dict[str, dict[str, Any]],
) -> DebugContactAudit:
@@ -349,7 +292,10 @@ async def _probe_radio() -> DebugRadioProbe:
return DebugRadioProbe(
performed=True,
errors=errors,
self_info=_sanitize_radio_probe_self_info(mc.self_info),
multi_acks_enabled=bool(mc.self_info.get("multi_acks", 0))
if mc.self_info is not None
else None,
self_info=dict(mc.self_info or {}),
device_info=device_info,
stats_core=stats_core,
stats_radio=stats_radio,
@@ -368,39 +314,27 @@ async def _probe_radio() -> DebugRadioProbe:
@router.get("/debug", response_model=DebugSnapshotResponse)
async def debug_support_snapshot() -> DebugSnapshotResponse:
"""Return a support/debug snapshot with recent logs and live radio state."""
connection_info = radio_runtime.connection_info
connection_desired = radio_runtime.connection_desired
setup_in_progress = radio_runtime.is_setup_in_progress
setup_complete = radio_runtime.is_setup_complete
radio_connected = radio_runtime.is_connected
is_reconnecting = getattr(radio_runtime, "is_reconnecting", False)
health_data = await build_health_data(radio_connected, connection_info)
health_data = await build_health_data(radio_runtime.is_connected, radio_runtime.connection_info)
app_settings = await AppSettingsRepository.get()
message_totals = await StatisticsRepository.get_database_message_totals()
radio_probe = await _probe_radio()
channels_with_incoming_messages = (
await MessageRepository.count_channels_with_incoming_messages()
)
radio_state = _derive_debug_radio_state(
radio_connected=radio_connected,
connection_desired=connection_desired,
setup_in_progress=setup_in_progress,
setup_complete=setup_complete,
is_reconnecting=is_reconnecting,
)
return DebugSnapshotResponse(
captured_at=datetime.now(timezone.utc).isoformat(),
system=_build_system_info(),
application=_build_application_info(),
health=_build_debug_health_summary(health_data, radio_state=radio_state),
health=HealthResponse(**health_data),
settings=_build_debug_app_settings(app_settings),
runtime=DebugRuntimeInfo(
connection_info=connection_info,
connection_desired=connection_desired,
setup_in_progress=setup_in_progress,
setup_complete=setup_complete,
connection_info=radio_runtime.connection_info,
connection_desired=radio_runtime.connection_desired,
setup_in_progress=radio_runtime.is_setup_in_progress,
setup_complete=radio_runtime.is_setup_complete,
channels_with_incoming_messages=channels_with_incoming_messages,
max_channels=radio_runtime.max_channels,
path_hash_mode=radio_runtime.path_hash_mode,
path_hash_mode_supported=radio_runtime.path_hash_mode_supported,
channel_slot_reuse_enabled=radio_runtime.channel_slot_reuse_enabled(),
channel_send_cache_capacity=radio_runtime.get_channel_send_cache_capacity(),
+3 -14
View File
@@ -24,10 +24,7 @@ from app.models import (
from app.radio_sync import send_advertisement as do_send_advertisement
from app.radio_sync import sync_radio_time
from app.repository import ContactRepository
from app.services.contact_reconciliation import (
promote_prefix_contacts_for_contact,
reconcile_contact_messages,
)
from app.services.contact_reconciliation import promote_prefix_contacts_for_contact
from app.services.radio_commands import (
KeystoreRefreshError,
PathHashModeUnsupportedError,
@@ -217,19 +214,11 @@ async def _persist_new_discovery_contacts(results: list[RadioDiscoveryResult]) -
public_key=result.public_key,
log=logger,
)
await reconcile_contact_messages(
public_key=result.public_key,
contact_name=result.name,
log=logger,
)
created = await ContactRepository.get_by_key(result.public_key)
if created is not None:
broadcast_event("contact", created.model_dump())
for old_key in promoted_keys:
broadcast_event(
"contact_resolved",
{"previous_public_key": old_key, "contact": created.model_dump()},
)
for old_key in promoted_keys:
broadcast_event("contact_deleted", {"public_key": old_key})
async def _attach_known_names(results: list[RadioDiscoveryResult]) -> None:
+2 -5
View File
@@ -153,12 +153,9 @@ async def repeater_status(public_key: str) -> RepeaterStatusResponse:
return response
@router.get(
"/{public_key}/repeater/telemetry-history",
response_model=list[TelemetryHistoryEntry],
)
@router.get("/{public_key}/repeater/telemetry-history")
async def repeater_telemetry_history(public_key: str) -> list[TelemetryHistoryEntry]:
"""Return stored telemetry history for a repeater (read-only, no radio access)."""
"""Return stored telemetry history for a repeater (no radio command needed)."""
contact = await _resolve_contact_or_404(public_key)
_require_repeater(contact)
-13
View File
@@ -48,13 +48,6 @@ class AppSettingsUpdate(BaseModel):
default=None,
description="Display names whose messages are hidden from the UI",
)
discovery_blocked_types: list[int] | None = Field(
default=None,
description=(
"Contact type codes (1=Client, 2=Repeater, 3=Room, 4=Sensor) whose "
"advertisements should not create new contacts"
),
)
class BlockKeyRequest(BaseModel):
@@ -129,12 +122,6 @@ async def update_settings(update: AppSettingsUpdate) -> AppSettings:
if update.blocked_names is not None:
kwargs["blocked_names"] = update.blocked_names
# Discovery blocked types
if update.discovery_blocked_types is not None:
# Only allow valid contact type codes (1-4)
valid = [t for t in update.discovery_blocked_types if t in (1, 2, 3, 4)]
kwargs["discovery_blocked_types"] = sorted(set(valid))
# Flood scope
flood_scope_changed = False
if update.flood_scope is not None:
+20 -28
View File
@@ -204,43 +204,35 @@ async def run_post_connect_setup(radio_manager) -> None:
finally:
reader.handle_rx = _original_handle_rx
from app.config import settings as app_settings_config
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
if app_settings_config.skip_post_connect_sync:
logger.info(
"Skipping sync/offload/advert/drain (MESHCORE_SKIP_POST_CONNECT_SYNC)"
)
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement(mc):
logger.info("Advertisement sent")
else:
# Sync contacts/channels from radio to DB and clear radio
logger.info("Syncing and offloading radio data...")
result = await sync_and_offload_all(mc)
logger.info("Sync complete: %s", result)
logger.debug("Advertisement skipped (disabled or throttled)")
# Send advertisement to announce our presence (if enabled and not throttled)
if await send_advertisement(mc):
logger.info("Advertisement sent")
else:
logger.debug("Advertisement skipped (disabled or throttled)")
# Drain any messages that were queued before we connected.
# This must happen BEFORE starting auto-fetch, otherwise both
# compete on get_msg() with interleaved radio I/O.
drained = await drain_pending_messages(mc)
if drained > 0:
logger.info("Drained %d pending message(s)", drained)
radio_manager.clear_pending_message_channel_slots()
# Drain any messages that were queued before we connected.
# This must happen BEFORE starting auto-fetch, otherwise both
# compete on get_msg() with interleaved radio I/O.
drained = await drain_pending_messages(mc)
if drained > 0:
logger.info("Drained %d pending message(s)", drained)
radio_manager.clear_pending_message_channel_slots()
await mc.start_auto_message_fetching()
logger.info("Auto message fetching started")
finally:
radio_manager._release_operation_lock("post_connect_setup")
if not app_settings_config.skip_post_connect_sync:
# Start background tasks AFTER releasing the operation lock.
# These tasks acquire their own locks when they need radio access.
start_periodic_sync()
start_periodic_advert()
start_message_polling()
# Start background tasks AFTER releasing the operation lock.
# These tasks acquire their own locks when they need radio access.
start_periodic_sync()
start_periodic_advert()
start_message_polling()
radio_manager._setup_complete = True
finally:
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "remoteterm-meshcore-frontend",
"private": true,
"version": "3.7.1",
"version": "3.6.7",
"type": "module",
"scripts": {
"dev": "vite",
+6 -42
View File
@@ -1,4 +1,4 @@
import { useEffect, useCallback, useRef, useState, useMemo, type MouseEvent } from 'react';
import { useEffect, useCallback, useRef, useState, useMemo } from 'react';
import { api } from './api';
import { takePrefetchOrFetch } from './prefetch';
import { useWebSocket } from './useWebSocket';
@@ -23,7 +23,7 @@ import type { MessageInputHandle } from './components/MessageInput';
import { DistanceUnitProvider } from './contexts/DistanceUnitContext';
import { messageContainsMention } from './utils/messageParser';
import { getStateKey } from './utils/conversationState';
import type { BulkCreateHashtagChannelsResult, Conversation, Message, RawPacket } from './types';
import type { Conversation, Message, RawPacket } from './types';
import { CONTACT_TYPE_ROOM } from './types';
interface ChannelUnreadMarker {
@@ -85,8 +85,6 @@ export function App() {
const [channelUnreadMarker, setChannelUnreadMarker] = useState<ChannelUnreadMarker | null>(null);
const [newMessagePrefillRequest, setNewMessagePrefillRequest] =
useState<NewMessagePrefillRequest | null>(null);
const [showBulkAddChannelTab, setShowBulkAddChannelTab] = useState(false);
const [bulkAddResult, setBulkAddResult] = useState<BulkCreateHashtagChannelsResult | null>(null);
const [visibilityVersion, setVisibilityVersion] = useState(0);
const lastUnreadBackfillAttemptRef = useRef<string | null>(null);
const {
@@ -192,7 +190,6 @@ export function App() {
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel,
handleDeleteContact,
} = useContactsAndChannels({
@@ -424,25 +421,16 @@ export function App() {
[fetchUndecryptedCount, setChannels]
);
const handleOpenNewMessage = useCallback(
(event?: MouseEvent<HTMLButtonElement>) => {
setNewMessagePrefillRequest(null);
setShowBulkAddChannelTab(event?.altKey === true);
openNewMessageModal();
},
[openNewMessageModal]
);
const handleOpenNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null);
openNewMessageModal();
}, [openNewMessageModal]);
const handleCloseNewMessage = useCallback(() => {
setNewMessagePrefillRequest(null);
setShowBulkAddChannelTab(false);
closeNewMessageModal();
}, [closeNewMessageModal]);
const handleCloseBulkAddResults = useCallback(() => {
setBulkAddResult(null);
}, []);
const handleChannelReferenceClick = useCallback(
(channelName: string) => {
const existingChannel = channels.find((channel) => channel.name === channelName);
@@ -456,20 +444,11 @@ export function App() {
hashtagName: channelName.slice(1),
nonce: (previous?.nonce ?? 0) + 1,
}));
setShowBulkAddChannelTab(false);
openNewMessageModal();
},
[channels, handleNavigateToChannel, openNewMessageModal]
);
const handleBulkAddChannels = useCallback(
async (channelNames: string[], tryHistorical: boolean) => {
const result = await handleBulkCreateHashtagChannels(channelNames, tryHistorical);
setBulkAddResult(result);
},
[handleBulkCreateHashtagChannels]
);
const statusProps = {
health,
config,
@@ -492,11 +471,6 @@ export function App() {
favorites,
legacySortOrder: appSettings?.sidebar_sort_order,
isConversationNotificationsEnabled,
blockedKeys: appSettings?.blocked_keys ?? [],
blockedNames: appSettings?.blocked_names ?? [],
};
const bulkAddChannelResultModalProps = {
result: bulkAddResult,
};
const conversationPaneProps = {
activeConversation,
@@ -581,11 +555,6 @@ export function App() {
blockedNames: appSettings?.blocked_names,
onToggleBlockedKey: handleBlockKey,
onToggleBlockedName: handleBlockName,
contacts,
onBulkDeleteContacts: (deletedKeys: string[]) => {
const keySet = new Set(deletedKeys.map((k) => k.toLowerCase()));
setContacts((prev) => prev.filter((c) => !keySet.has(c.public_key.toLowerCase())));
},
};
const crackerProps = {
packets: rawPackets,
@@ -594,12 +563,10 @@ export function App() {
};
const newMessageModalProps = {
undecryptedCount,
showBulkAddChannelTab,
prefillRequest: newMessagePrefillRequest,
onCreateContact: handleCreateContact,
onCreateChannel: handleCreateChannel,
onCreateHashtagChannel: handleCreateHashtagChannel,
onBulkAddHashtagChannels: handleBulkAddChannels,
};
const contactInfoPaneProps = {
contactKey: infoPaneContactKey,
@@ -663,7 +630,6 @@ export function App() {
<AppShell
localLabel={localLabel}
showNewMessage={showNewMessage}
showBulkAddResults={bulkAddResult !== null}
showSettings={showSettings}
settingsSection={settingsSection}
sidebarOpen={sidebarOpen}
@@ -674,7 +640,6 @@ export function App() {
onToggleSettingsView={handleToggleSettingsView}
onCloseSettingsView={handleCloseSettingsView}
onCloseNewMessage={handleCloseNewMessage}
onCloseBulkAddResults={handleCloseBulkAddResults}
onLocalLabelChange={setLocalLabel}
statusProps={statusProps}
sidebarProps={sidebarProps}
@@ -683,7 +648,6 @@ export function App() {
settingsProps={settingsProps}
crackerProps={crackerProps}
newMessageModalProps={newMessageModalProps}
bulkAddChannelResultModalProps={bulkAddChannelResultModalProps}
contactInfoPaneProps={contactInfoPaneProps}
channelInfoPaneProps={channelInfoPaneProps}
/>
+3 -15
View File
@@ -1,7 +1,6 @@
import type {
AppSettings,
AppSettingsUpdate,
BulkCreateHashtagChannelsResult,
Channel,
ChannelDetail,
CommandResponse,
@@ -35,8 +34,8 @@ import type {
RepeaterOwnerInfoResponse,
RepeaterRadioSettingsResponse,
RepeaterStatusResponse,
TelemetryHistoryEntry,
StatisticsResponse,
TelemetryHistoryEntry,
TraceResponse,
UnreadCounts,
} from './types';
@@ -151,12 +150,6 @@ export const api = {
fetchJson<{ status: string }>(`/contacts/${publicKey}`, {
method: 'DELETE',
}),
bulkDeleteContacts: (publicKeys: string[]) =>
fetchJson<{ deleted: number }>('/contacts/bulk-delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ public_keys: publicKeys }),
}),
createContact: (publicKey: string, name?: string, tryHistorical?: boolean) =>
fetchJson<Contact>('/contacts', {
method: 'POST',
@@ -192,11 +185,6 @@ export const api = {
method: 'POST',
body: JSON.stringify({ name, key }),
}),
bulkCreateHashtagChannels: (channelNames: string[], tryHistorical?: boolean) =>
fetchJson<BulkCreateHashtagChannelsResult>('/channels/bulk-hashtag', {
method: 'POST',
body: JSON.stringify({ channel_names: channelNames, try_historical: tryHistorical }),
}),
deleteChannel: (key: string) =>
fetchJson<{ status: string }>(`/channels/${key}`, { method: 'DELETE' }),
getChannelDetail: (key: string) => fetchJson<ChannelDetail>(`/channels/${key}/detail`),
@@ -387,6 +375,8 @@ export const api = {
fetchJson<RepeaterStatusResponse>(`/contacts/${publicKey}/repeater/status`, {
method: 'POST',
}),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
repeaterNeighbors: (publicKey: string) =>
fetchJson<RepeaterNeighborsResponse>(`/contacts/${publicKey}/repeater/neighbors`, {
method: 'POST',
@@ -415,8 +405,6 @@ export const api = {
fetchJson<RepeaterLppTelemetryResponse>(`/contacts/${publicKey}/repeater/lpp-telemetry`, {
method: 'POST',
}),
repeaterTelemetryHistory: (publicKey: string) =>
fetchJson<TelemetryHistoryEntry[]>(`/contacts/${publicKey}/repeater/telemetry-history`),
roomLogin: (publicKey: string, password: string) =>
fetchJson<RepeaterLoginResponse>(`/contacts/${publicKey}/room/login`, {
method: 'POST',
-16
View File
@@ -5,7 +5,6 @@ import { StatusBar } from './StatusBar';
import { Sidebar } from './Sidebar';
import { ConversationPane } from './ConversationPane';
import { NewMessageModal } from './NewMessageModal';
import { BulkAddChannelResultModal } from './BulkAddChannelResultModal';
import { ContactInfoPane } from './ContactInfoPane';
import { ChannelInfoPane } from './ChannelInfoPane';
import { SecurityWarningModal } from './SecurityWarningModal';
@@ -34,17 +33,12 @@ const SearchView = lazy(() => import('./SearchView').then((m) => ({ default: m.S
type SidebarProps = ComponentProps<typeof Sidebar>;
type ConversationPaneProps = ComponentProps<typeof ConversationPane>;
type NewMessageModalProps = Omit<ComponentProps<typeof NewMessageModal>, 'open' | 'onClose'>;
type BulkAddChannelResultModalProps = Omit<
ComponentProps<typeof BulkAddChannelResultModal>,
'open' | 'onClose'
>;
type ContactInfoPaneProps = ComponentProps<typeof ContactInfoPane>;
type ChannelInfoPaneProps = ComponentProps<typeof ChannelInfoPane>;
interface AppShellProps {
localLabel: LocalLabel;
showNewMessage: boolean;
showBulkAddResults: boolean;
showSettings: boolean;
settingsSection: SettingsSection;
sidebarOpen: boolean;
@@ -56,7 +50,6 @@ interface AppShellProps {
onToggleSettingsView: () => void;
onCloseSettingsView: () => void;
onCloseNewMessage: () => void;
onCloseBulkAddResults: () => void;
onLocalLabelChange: (label: LocalLabel) => void;
statusProps: Pick<ComponentProps<typeof StatusBar>, 'health' | 'config'>;
sidebarProps: SidebarProps;
@@ -68,7 +61,6 @@ interface AppShellProps {
>;
crackerProps: Omit<CrackerPanelProps, 'visible' | 'onRunningChange'>;
newMessageModalProps: NewMessageModalProps;
bulkAddChannelResultModalProps: BulkAddChannelResultModalProps;
contactInfoPaneProps: ContactInfoPaneProps;
channelInfoPaneProps: ChannelInfoPaneProps;
}
@@ -76,7 +68,6 @@ interface AppShellProps {
export function AppShell({
localLabel,
showNewMessage,
showBulkAddResults,
showSettings,
settingsSection,
sidebarOpen,
@@ -88,7 +79,6 @@ export function AppShell({
onToggleSettingsView,
onCloseSettingsView,
onCloseNewMessage,
onCloseBulkAddResults,
onLocalLabelChange,
statusProps,
sidebarProps,
@@ -97,7 +87,6 @@ export function AppShell({
settingsProps,
crackerProps,
newMessageModalProps,
bulkAddChannelResultModalProps,
contactInfoPaneProps,
channelInfoPaneProps,
}: AppShellProps) {
@@ -317,11 +306,6 @@ export function AppShell({
open={showNewMessage}
onClose={onCloseNewMessage}
/>
<BulkAddChannelResultModal
{...bulkAddChannelResultModalProps}
open={showBulkAddResults}
onClose={onCloseBulkAddResults}
/>
<SecurityWarningModal health={statusProps.health} />
<ContactInfoPane {...contactInfoPaneProps} />
@@ -1,101 +0,0 @@
import type { BulkCreateHashtagChannelsResult, Channel } from '../types';
import { getConversationHash } from '../utils/urlHash';
import { Button } from './ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from './ui/dialog';
interface BulkAddChannelResultModalProps {
open: boolean;
result: BulkCreateHashtagChannelsResult | null;
onClose: () => void;
}
function getChannelHref(channel: Channel): string {
const hash = getConversationHash({
type: 'channel',
id: channel.key,
name: channel.name,
});
if (typeof window === 'undefined') {
return hash;
}
return `${window.location.origin}${window.location.pathname}${hash}`;
}
export function BulkAddChannelResultModal({
open,
result,
onClose,
}: BulkAddChannelResultModalProps) {
const createdChannels = result?.created_channels ?? [];
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && onClose()}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle>Bulk Add Complete</DialogTitle>
<DialogDescription>
{result?.message ?? 'Review the newly added rooms below.'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{result && (
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">Created</div>
<div className="mt-1 font-medium">{createdChannels.length}</div>
</div>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2">
<div className="text-xs uppercase tracking-wide text-muted-foreground">
Already Present
</div>
<div className="mt-1 font-medium">{result.existing_count}</div>
</div>
</div>
)}
{createdChannels.length > 0 ? (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">
Ctrl+click any room to open it in a new tab.
</p>
<div className="max-h-64 overflow-y-auto rounded-md border border-border/70">
<ul className="divide-y divide-border/70">
{createdChannels.map((channel) => (
<li key={channel.key}>
<a
href={getChannelHref(channel)}
className="block px-3 py-2 text-sm text-primary hover:bg-accent hover:text-primary"
>
{channel.name}
</a>
</li>
))}
</ul>
</div>
</div>
) : (
<p className="text-sm text-muted-foreground">No new rooms were added.</p>
)}
{result && result.invalid_names.length > 0 && (
<div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2 text-sm text-warning">
Ignored invalid room names: {result.invalid_names.join(', ')}
</div>
)}
</div>
<DialogFooter>
<Button onClick={onClose}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+32 -112
View File
@@ -1,4 +1,4 @@
import { type ReactNode, useEffect, useMemo, useState } from 'react';
import { type ReactNode, useEffect, useState } from 'react';
import { Ban, Search, Star } from 'lucide-react';
import {
LineChart,
@@ -35,7 +35,6 @@ import { ContactAvatar } from './ContactAvatar';
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from './ui/sheet';
import { toast } from './ui/sonner';
import { useDistanceUnit } from '../contexts/DistanceUnitContext';
import { CONTACT_TYPE_REPEATER } from '../types';
import type {
Contact,
ContactActiveRoom,
@@ -159,7 +158,6 @@ export function ContactInfoPane({
contact !== null &&
!isPrefixOnlyResolvedContact &&
isUnknownFullKeyContact(contact.public_key, contact.last_advert);
const isRepeater = contact?.type === CONTACT_TYPE_REPEATER;
return (
<Sheet open={contactKey !== null} onOpenChange={(open) => !open && onClose()}>
@@ -442,7 +440,7 @@ export function ContactInfoPane({
</div>
)}
{!isRepeater && onSearchMessagesByKey && (
{onSearchMessagesByKey && (
<div className="px-5 py-3 border-b border-border">
<button
type="button"
@@ -455,60 +453,40 @@ export function ContactInfoPane({
</div>
)}
{/* Nearest Repeaters (Hops) — last 7 days only */}
{analytics &&
(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const recent = analytics.nearest_repeaters.filter(
(r) => r.last_seen >= sevenDaysAgo
);
if (recent.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Hops (last 7 days)</SectionLabel>
<div className="space-y-1">
{recent.map((r) => (
<div
key={r.public_key}
className="flex justify-between items-center text-sm"
>
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{r.path_len === 0
? 'direct'
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
· {r.heard_count}x
</span>
</div>
))}
{/* Nearest Repeaters */}
{analytics && analytics.nearest_repeaters.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters</SectionLabel>
<div className="space-y-1">
{analytics.nearest_repeaters.map((r) => (
<div key={r.public_key} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name || r.public_key.slice(0, 12)}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{r.path_len === 0
? 'direct'
: `${r.path_len} hop${r.path_len > 1 ? 's' : ''}`}{' '}
· {r.heard_count}x
</span>
</div>
</div>
);
})()}
{/* Geographically nearest repeaters (repeaters only) */}
{isRepeater && contact && isValidLocation(contact.lat, contact.lon) && (
<NearbyRepeatersSection
contact={contact}
contacts={contacts}
distanceUnit={distanceUnit}
/>
))}
</div>
</div>
)}
{/* Advert Paths */}
{analytics && analytics.advert_paths.length > 0 && (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Recent Advert Paths</SectionLabel>
<div className="space-y-1.5">
<div className="space-y-1">
{analytics.advert_paths.map((p) => (
<div
key={p.path + p.first_seen}
className="flex justify-between items-start gap-2 text-sm"
className="flex justify-between items-center text-sm"
>
<span className="font-mono text-xs break-all">
<span className="font-mono text-xs truncate">
{p.path ? parsePathHops(p.path, p.path_len).join(' → ') : '(direct)'}
</span>
<span className="text-xs text-muted-foreground flex-shrink-0">
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{p.heard_count}x · {formatTime(p.last_seen)}
</span>
</div>
@@ -540,21 +518,17 @@ export function ContactInfoPane({
</div>
)}
{!isRepeater && (
<>
<MessageStatsSection
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<MessageStatsSection
dmMessageCount={analytics?.dm_message_count ?? 0}
channelMessageCount={analytics?.channel_message_count ?? 0}
/>
<ActivityChartsSection analytics={analytics} />
<ActivityChartsSection analytics={analytics} />
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</>
)}
<MostActiveChannelsSection
channels={analytics?.most_active_rooms ?? []}
onNavigateToChannel={onNavigateToChannel}
/>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
@@ -852,60 +826,6 @@ function ActivityLineChart<T extends ContactAnalyticsHourlyBucket | ContactAnaly
);
}
function NearbyRepeatersSection({
contact,
contacts,
distanceUnit,
}: {
contact: Contact;
contacts: Contact[];
distanceUnit: import('../utils/distanceUnits').DistanceUnit;
}) {
const nearby = useMemo(() => {
const sevenDaysAgo = Math.floor(Date.now() / 1000) - 7 * 86400;
const results: Array<{ name: string; publicKey: string; distance: number }> = [];
for (const other of contacts) {
const heardAt = Math.max(other.last_seen ?? 0, other.last_advert ?? 0);
if (
other.public_key === contact.public_key ||
other.type !== CONTACT_TYPE_REPEATER ||
!isValidLocation(other.lat, other.lon) ||
heardAt < sevenDaysAgo
) {
continue;
}
const dist = calculateDistance(contact.lat, contact.lon, other.lat, other.lon);
if (dist !== null) {
results.push({
name: getContactDisplayName(other.name, other.public_key, other.last_advert),
publicKey: other.public_key,
distance: dist,
});
}
}
results.sort((a, b) => a.distance - b.distance);
return results.slice(0, 5);
}, [contact.public_key, contact.lat, contact.lon, contacts]);
if (nearby.length === 0) return null;
return (
<div className="px-5 py-3 border-b border-border">
<SectionLabel>Nearest Repeaters Geo (last 7 days)</SectionLabel>
<div className="space-y-1">
{nearby.map((r) => (
<div key={r.publicKey} className="flex justify-between items-center text-sm">
<span className="truncate">{r.name}</span>
<span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
{formatDistance(r.distance, distanceUnit)}
</span>
</div>
))}
</div>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: ReactNode }) {
return (
<div>
@@ -233,7 +233,6 @@ export function ConversationPane({
onToggleNotifications={onToggleNotifications}
onToggleFavorite={onToggleFavorite}
onDeleteContact={onDeleteContact}
onOpenContactInfo={onOpenContactInfo}
/>
</Suspense>
);
+41 -166
View File
@@ -3,29 +3,23 @@ import { Dice5 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from './ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
import { Tabs, TabsList, TabsTrigger, TabsContent } from './ui/tabs';
import { Input } from './ui/input';
import { Label } from './ui/label';
import { Checkbox } from './ui/checkbox';
import { Button } from './ui/button';
import { toast } from './ui/sonner';
type Tab = 'new-contact' | 'new-channel' | 'hashtag' | 'bulk-hashtag';
interface BulkParseResult {
channelNames: string[];
invalidNames: string[];
}
type Tab = 'new-contact' | 'new-channel' | 'hashtag';
interface NewMessageModalProps {
open: boolean;
undecryptedCount: number;
showBulkAddChannelTab?: boolean;
prefillRequest?: {
tab: 'hashtag';
hashtagName: string;
@@ -35,121 +29,53 @@ interface NewMessageModalProps {
onCreateContact: (name: string, publicKey: string, tryHistorical: boolean) => Promise<void>;
onCreateChannel: (name: string, key: string, tryHistorical: boolean) => Promise<void>;
onCreateHashtagChannel: (name: string, tryHistorical: boolean) => Promise<void>;
onBulkAddHashtagChannels: (channelNames: string[], tryHistorical: boolean) => Promise<void>;
}
function validateHashtagName(channelName: string): string | null {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
}
function parseBulkHashtagNames(rawText: string, permitCapitals: boolean): BulkParseResult {
const tokens = rawText
.split(/[\s,]+/)
.map((token) => token.trim())
.filter(Boolean);
const invalidNames: string[] = [];
const channelNames: string[] = [];
const seen = new Set<string>();
for (const token of tokens) {
const stripped = token.replace(/^#+/, '');
const validationError = validateHashtagName(stripped);
if (validationError) {
invalidNames.push(token);
continue;
}
const normalized = permitCapitals ? stripped : stripped.toLowerCase();
const channelName = `#${normalized}`;
if (seen.has(channelName)) {
continue;
}
seen.add(channelName);
channelNames.push(channelName);
}
return { channelNames, invalidNames };
}
export function NewMessageModal({
open,
undecryptedCount,
showBulkAddChannelTab = false,
prefillRequest = null,
onClose,
onCreateContact,
onCreateChannel,
onCreateHashtagChannel,
onBulkAddHashtagChannels,
}: NewMessageModalProps) {
const [tab, setTab] = useState<Tab>('new-contact');
const [name, setName] = useState('');
const [contactKey, setContactKey] = useState('');
const [channelKey, setChannelKey] = useState('');
const [bulkChannelText, setBulkChannelText] = useState('');
const [tryHistorical, setTryHistorical] = useState(false);
const [permitCapitals, setPermitCapitals] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const hashtagInputRef = useRef<HTMLInputElement>(null);
const bulkTextareaRef = useRef<HTMLTextAreaElement>(null);
const resetForm = () => {
setName('');
setContactKey('');
setChannelKey('');
setBulkChannelText('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
};
useEffect(() => {
if (!open) {
if (!open || !prefillRequest) {
return;
}
if (prefillRequest) {
setTab(prefillRequest.tab);
setName(prefillRequest.hashtagName);
setContactKey('');
setChannelKey('');
setBulkChannelText('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
hashtagInputRef.current?.focus();
});
return;
}
if (showBulkAddChannelTab) {
setTab('bulk-hashtag');
setName('');
setContactKey('');
setChannelKey('');
setBulkChannelText('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
bulkTextareaRef.current?.focus();
});
return;
}
setTab('new-contact');
}, [open, prefillRequest, showBulkAddChannelTab]);
setTab(prefillRequest.tab);
setName(prefillRequest.hashtagName);
setContactKey('');
setChannelKey('');
setTryHistorical(false);
setPermitCapitals(false);
setError('');
setLoading(false);
requestAnimationFrame(() => {
hashtagInputRef.current?.focus();
});
}, [open, prefillRequest]);
const handleCreate = async () => {
setError('');
@@ -161,6 +87,7 @@ export function NewMessageModal({
setError('Name and public key are required');
return;
}
// handleCreateContact sets activeConversation with the backend-normalized key
await onCreateContact(name.trim(), contactKey.trim(), tryHistorical);
} else if (tab === 'new-channel') {
if (!name.trim() || !channelKey.trim()) {
@@ -175,24 +102,10 @@ export function NewMessageModal({
setError(validationError);
return;
}
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
} else {
const { channelNames, invalidNames } = parseBulkHashtagNames(
bulkChannelText,
permitCapitals
);
if (channelNames.length === 0) {
setError('Enter at least one valid room name');
return;
}
if (invalidNames.length > 0) {
setError(`Invalid room names: ${invalidNames.join(', ')}`);
return;
}
await onBulkAddHashtagChannels(channelNames, tryHistorical);
}
resetForm();
onClose();
} catch (err) {
@@ -205,6 +118,16 @@ export function NewMessageModal({
}
};
const validateHashtagName = (channelName: string): string | null => {
if (!channelName) {
return 'Channel name is required';
}
if (!/^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$/.test(channelName)) {
return 'Use letters, numbers, and single dashes (no leading/trailing dashes)';
}
return null;
};
const handleCreateAndAddAnother = async () => {
setError('');
const channelName = name.trim();
@@ -216,6 +139,7 @@ export function NewMessageModal({
setLoading(true);
try {
// Normalize to lowercase unless user explicitly permits capitals
const normalizedName = permitCapitals ? channelName : channelName.toLowerCase();
await onCreateHashtagChannel(`#${normalizedName}`, tryHistorical);
setName('');
@@ -242,36 +166,28 @@ export function NewMessageModal({
}
}}
>
<DialogContent className="sm:max-w-[560px]">
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>New Conversation</DialogTitle>
<DialogDescription className="sr-only">
{tab === 'new-contact' && 'Add a new contact by entering their name and public key'}
{tab === 'new-channel' && 'Create a private channel with a shared encryption key'}
{tab === 'hashtag' && 'Join a public hashtag channel'}
{tab === 'bulk-hashtag' && 'Paste multiple hashtag rooms to add them in one batch'}
</DialogDescription>
</DialogHeader>
<Tabs
value={tab}
onValueChange={(value) => {
setTab(value as Tab);
onValueChange={(v) => {
setTab(v as Tab);
resetForm();
}}
className="w-full"
>
<TabsList
className={
showBulkAddChannelTab ? 'grid w-full grid-cols-4' : 'grid w-full grid-cols-3'
}
>
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="new-contact">Contact</TabsTrigger>
<TabsTrigger value="new-channel">Private Channel</TabsTrigger>
<TabsTrigger value="hashtag">Hashtag Channel</TabsTrigger>
{showBulkAddChannelTab && (
<TabsTrigger value="bulk-hashtag">Bulk Add Channel</TabsTrigger>
)}
</TabsList>
<TabsContent value="new-contact" className="mt-4 space-y-4">
@@ -323,7 +239,7 @@ export function NewMessageModal({
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
const hex = Array.from(bytes)
.map((byte) => byte.toString(16).padStart(2, '0'))
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
setChannelKey(hex);
}}
@@ -352,55 +268,20 @@ export function NewMessageModal({
</div>
</div>
<div className="mt-3 space-y-1">
<label className="flex cursor-pointer items-center gap-3">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
className="w-4 h-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in channel key derivation</span>
</label>
<p className="pl-7 text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground pl-7">
Not recommended; most companions normalize to lowercase
</p>
</div>
</TabsContent>
{showBulkAddChannelTab && (
<TabsContent value="bulk-hashtag" className="mt-4 space-y-4">
<div className="space-y-2">
<Label htmlFor="bulk-hashtag-names">Bulk Add Channel</Label>
<textarea
ref={bulkTextareaRef}
id="bulk-hashtag-names"
aria-label="Bulk channel names"
value={bulkChannelText}
onChange={(e) => setBulkChannelText(e.target.value)}
placeholder={'#ops\nmesh-room\nanother-room'}
className="min-h-48 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
/>
<p className="text-xs text-muted-foreground">
Paste room names separated by lines, spaces, or commas. Leading # marks are
stripped automatically.
</p>
</div>
<div className="space-y-1">
<label className="flex cursor-pointer items-center gap-3">
<input
type="checkbox"
checked={permitCapitals}
onChange={(e) => setPermitCapitals(e.target.checked)}
className="h-4 w-4 rounded border-input accent-primary"
/>
<span className="text-sm">Permit capitals in channel key derivation</span>
</label>
<p className="pl-7 text-xs text-muted-foreground">
Not recommended; most companions normalize to lowercase
</p>
</div>
</TabsContent>
)}
</Tabs>
{showHistoricalOption && (
@@ -408,7 +289,7 @@ export function NewMessageModal({
<div className="flex items-center justify-end space-x-2">
<Label
htmlFor="try-historical"
className="cursor-pointer text-sm text-muted-foreground"
className="text-sm text-muted-foreground cursor-pointer"
>
Try decrypting {undecryptedCount.toLocaleString()} stored packet
{undecryptedCount !== 1 ? 's' : ''}
@@ -420,7 +301,7 @@ export function NewMessageModal({
/>
</div>
{tryHistorical && (
<p className="text-right text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground text-right">
Messages will stream in as they decrypt in the background
</p>
)}
@@ -449,13 +330,7 @@ export function NewMessageModal({
</Button>
)}
<Button onClick={handleCreate} disabled={loading}>
{loading
? tab === 'bulk-hashtag'
? 'Adding...'
: 'Creating...'
: tab === 'bulk-hashtag'
? 'Add Channels'
: 'Create'}
{loading ? 'Creating...' : 'Create'}
</Button>
</DialogFooter>
</DialogContent>
+21 -35
View File
@@ -406,12 +406,9 @@ interface HopNodeProps {
distanceUnit: DistanceUnit;
}
const AMBIGUOUS_MATCH_PREVIEW_LIMIT = 3;
function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
const isAmbiguous = hop.matches.length > 1;
const isUnknown = hop.matches.length === 0;
const [expanded, setExpanded] = useState(false);
// Calculate distance from previous location for a contact
// Returns null if prev location unknown/ambiguous or contact has no valid location
@@ -450,38 +447,27 @@ function HopNode({ hop, hopNumber, prevLocation, distanceUnit }: HopNodeProps) {
<div className="font-medium text-muted-foreground">&lt;UNKNOWN&gt;</div>
) : isAmbiguous ? (
<div>
{(expanded ? hop.matches : hop.matches.slice(0, AMBIGUOUS_MATCH_PREVIEW_LIMIT)).map(
(contact) => {
const dist = getDistanceForContact(contact);
const hasLocation = isValidLocation(contact.lat, contact.lon);
return (
<div key={contact.public_key} className="font-medium truncate">
{contact.name || contact.public_key.slice(0, 12)}
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist, distanceUnit)}
</span>
)}
{hasLocation && (
<CoordinateLink
lat={contact.lat!}
lon={contact.lon!}
publicKey={contact.public_key}
/>
)}
</div>
);
}
)}
{!expanded && hop.matches.length > AMBIGUOUS_MATCH_PREVIEW_LIMIT && (
<button
type="button"
className="text-xs text-primary hover:underline cursor-pointer"
onClick={() => setExpanded(true)}
>
(and {hop.matches.length - AMBIGUOUS_MATCH_PREVIEW_LIMIT} more)
</button>
)}
{hop.matches.map((contact) => {
const dist = getDistanceForContact(contact);
const hasLocation = isValidLocation(contact.lat, contact.lon);
return (
<div key={contact.public_key} className="font-medium truncate">
{contact.name || contact.public_key.slice(0, 12)}
{dist !== null && (
<span className="text-xs text-muted-foreground ml-1">
- {formatDistance(dist, distanceUnit)}
</span>
)}
{hasLocation && (
<CoordinateLink
lat={contact.lat!}
lon={contact.lon!}
publicKey={contact.public_key}
/>
)}
</div>
);
})}
</div>
) : (
<div className="font-medium truncate">
+16 -57
View File
@@ -1,9 +1,9 @@
import { useEffect, useRef, useState } from 'react';
import { useEffect, useState } from 'react';
import { api } from '../api';
import { toast } from './ui/sonner';
import { Button } from './ui/button';
import { Bell, Info, Route, Star, Trash2 } from 'lucide-react';
import { Bell, Route, Star, Trash2 } from 'lucide-react';
import { DirectTraceIcon } from './DirectTraceIcon';
import { RepeaterLogin } from './RepeaterLogin';
import { ServerLoginStatusBanner } from './ServerLoginStatusBanner';
@@ -13,13 +13,7 @@ import { isFavorite } from '../utils/favorites';
import { handleKeyboardActivate } from '../utils/a11y';
import { isValidLocation } from '../utils/pathUtils';
import { ContactStatusInfo } from './ContactStatusInfo';
import type {
Contact,
Conversation,
Favorite,
PathDiscoveryResponse,
TelemetryHistoryEntry,
} from '../types';
import type { Contact, Conversation, Favorite, PathDiscoveryResponse, TelemetryHistoryEntry } from '../types';
import { cn } from '../lib/utils';
import { TelemetryPane } from './repeater/RepeaterTelemetryPane';
import { NeighborsPane } from './repeater/RepeaterNeighborsPane';
@@ -53,7 +47,6 @@ interface RepeaterDashboardProps {
onToggleNotifications: () => void;
onToggleFavorite: (type: 'channel' | 'contact', id: string) => void;
onDeleteContact: (publicKey: string) => void;
onOpenContactInfo?: (publicKey: string) => void;
}
export function RepeaterDashboard({
@@ -71,9 +64,9 @@ export function RepeaterDashboard({
onToggleNotifications,
onToggleFavorite,
onDeleteContact,
onOpenContactInfo,
}: RepeaterDashboardProps) {
const [pathDiscoveryOpen, setPathDiscoveryOpen] = useState(false);
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
const contact = contacts.find((c) => c.public_key === conversation.id) ?? null;
const hasAdvertLocation = isValidLocation(contact?.lat ?? null, contact?.lon ?? null);
const {
@@ -98,37 +91,18 @@ export function RepeaterDashboard({
const { password, setPassword, rememberPassword, setRememberPassword, persistAfterLogin } =
useRememberedServerPassword('repeater', conversation.id);
// Telemetry history: preload from stored data, refresh from live status
const [telemetryHistory, setTelemetryHistory] = useState<TelemetryHistoryEntry[]>([]);
const telemetryHistorySourceRef = useRef<'none' | 'preload' | 'live'>('none');
const telemetryHistoryRequestRef = useRef(0);
// Auto-fetch stored telemetry history from DB (no mesh traffic)
useEffect(() => {
telemetryHistoryRequestRef.current += 1;
telemetryHistorySourceRef.current = 'none';
setTelemetryHistory([]);
api.repeaterTelemetryHistory(conversation.id).then(setTelemetryHistory).catch(() => {});
}, [conversation.id]);
if (!loggedIn) return;
const requestId = telemetryHistoryRequestRef.current;
api
.repeaterTelemetryHistory(conversation.id)
.then((history) => {
if (telemetryHistoryRequestRef.current !== requestId) return;
if (telemetryHistorySourceRef.current === 'live') return;
telemetryHistorySourceRef.current = 'preload';
setTelemetryHistory(history);
})
.catch(() => {});
}, [loggedIn, conversation.id]);
// When a live status fetch returns embedded telemetry_history, replace local state
// Refresh when a live status fetch returns newer data
const statusHistory = paneData.status?.telemetry_history;
useEffect(() => {
const liveHistory = paneData.status?.telemetry_history;
if (!liveHistory) return;
telemetryHistorySourceRef.current = 'live';
setTelemetryHistory(liveHistory);
}, [paneData.status?.telemetry_history]);
if (statusHistory && statusHistory.length > 0) {
setTelemetryHistory(statusHistory);
}
}, [statusHistory]);
const isFav = isFavorite(favorites, 'contact', conversation.id);
@@ -158,24 +132,9 @@ export function RepeaterDashboard({
<span className="flex min-w-0 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="min-w-0 flex-shrink font-semibold text-base">
{onOpenContactInfo ? (
<button
type="button"
className="flex max-w-full min-w-0 items-center gap-1.5 overflow-hidden rounded-sm text-left transition-colors hover:text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
aria-label={`View info for ${conversation.name}`}
onClick={() => onOpenContactInfo(conversation.id)}
>
<span className="truncate">{conversation.name}</span>
<Info
className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground/80"
aria-hidden="true"
/>
</button>
) : (
<span className="truncate">{conversation.name}</span>
)}
</h2>
<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"
@@ -2,7 +2,6 @@ import { useState, useEffect, type ReactNode } from 'react';
import type {
AppSettings,
AppSettingsUpdate,
Contact,
HealthStatus,
RadioAdvertMode,
RadioConfig,
@@ -48,8 +47,6 @@ interface SettingsModalBaseProps {
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
}
export type SettingsModalProps = SettingsModalBaseProps &
@@ -83,8 +80,6 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames,
onToggleBlockedKey,
onToggleBlockedName,
contacts,
onBulkDeleteContacts,
} = props;
const externalSidebarNav = props.externalSidebarNav === true;
const desktopSection = props.externalSidebarNav ? props.desktopSection : undefined;
@@ -244,8 +239,6 @@ export function SettingsModal(props: SettingsModalProps) {
blockedNames={blockedNames}
onToggleBlockedKey={onToggleBlockedKey}
onToggleBlockedName={onToggleBlockedName}
contacts={contacts}
onBulkDeleteContacts={onBulkDeleteContacts}
className={sectionContentClass}
/>
) : (
+34 -40
View File
@@ -97,7 +97,7 @@ interface SidebarProps {
channels: Channel[];
activeConversation: Conversation | null;
onSelectConversation: (conversation: Conversation) => void;
onNewMessage: (event?: React.MouseEvent<HTMLButtonElement>) => void;
onNewMessage: () => void;
lastMessageTimes: ConversationTimes;
unreadCounts: Record<string, number>;
/** Tracks which conversations have unread messages that mention the user */
@@ -110,8 +110,6 @@ interface SidebarProps {
/** Legacy global sort order, used only to seed per-section local preferences. */
legacySortOrder?: SortOrder;
isConversationNotificationsEnabled?: (type: 'channel' | 'contact', id: string) => boolean;
blockedKeys?: string[];
blockedNames?: string[];
}
type InitialSectionSortState = {
@@ -155,16 +153,7 @@ export function Sidebar({
favorites,
legacySortOrder,
isConversationNotificationsEnabled,
blockedKeys = [],
blockedNames = [],
}: SidebarProps) {
const isContactBlocked = useCallback(
(c: Contact) =>
blockedKeys.includes(c.public_key.toLowerCase()) ||
(c.name != null && blockedNames.includes(c.name)),
[blockedKeys, blockedNames]
);
const [searchQuery, setSearchQuery] = useState('');
const initialSectionSortState = useMemo(loadInitialSectionSortOrders, []);
const [sectionSortOrders, setSectionSortOrders] = useState(initialSectionSortState.orders);
@@ -409,32 +398,38 @@ export function Sidebar({
[sortedChannels, query]
);
const filteredNonRepeaterContacts = useMemo(() => {
const visible = sortedNonRepeaterContacts.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedNonRepeaterContacts, query, isContactBlocked]);
const filteredNonRepeaterContacts = useMemo(
() =>
query
? sortedNonRepeaterContacts.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedNonRepeaterContacts,
[sortedNonRepeaterContacts, query]
);
const filteredRooms = useMemo(() => {
const visible = sortedRooms.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedRooms, query, isContactBlocked]);
const filteredRooms = useMemo(
() =>
query
? sortedRooms.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedRooms,
[sortedRooms, query]
);
const filteredRepeaters = useMemo(() => {
const visible = sortedRepeaters.filter((c) => !isContactBlocked(c));
return query
? visible.filter(
(c) => c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: visible;
}, [sortedRepeaters, query, isContactBlocked]);
const filteredRepeaters = useMemo(
() =>
query
? sortedRepeaters.filter(
(c) =>
c.name?.toLowerCase().includes(query) || c.public_key.toLowerCase().includes(query)
)
: sortedRepeaters,
[sortedRepeaters, query]
);
// Expand sections while searching; restore prior collapse state when search ends.
useEffect(() => {
@@ -659,9 +654,8 @@ export function Sidebar({
}) => (
<div
key={key}
data-active={active ? 'true' : undefined}
className={cn(
'sidebar-action-row px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'px-3 py-2 cursor-pointer flex items-center gap-2 border-l-2 border-transparent hover:bg-accent transition-colors text-[13px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
active && 'bg-accent border-l-primary'
)}
role="button"
@@ -670,10 +664,10 @@ export function Sidebar({
onKeyDown={handleKeyboardActivate}
onClick={onClick}
>
<span className="sidebar-tool-icon" aria-hidden="true">
<span className="sidebar-tool-icon text-muted-foreground" aria-hidden="true">
{icon}
</span>
<span className="sidebar-tool-label flex-1 truncate">{label}</span>
<span className="sidebar-tool-label flex-1 truncate text-muted-foreground">{label}</span>
</div>
);
@@ -1,352 +0,0 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { api } from '../../api';
import { getContactDisplayName } from '../../utils/pubkey';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '../ui/dialog';
import { toast } from '../ui/sonner';
import type { Contact } from '../../types';
const CONTACT_TYPE_LABELS: Record<number, string> = {
0: 'Unknown',
1: 'Client',
2: 'Repeater',
3: 'Room',
4: 'Sensor',
};
function formatDate(ts: number): string {
return new Date(ts * 1000).toLocaleDateString([], {
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
}
function formatDateISO(ts: number): string {
return new Date(ts * 1000).toISOString().slice(0, 10);
}
function datetimeToUnix(datetimeStr: string): number {
const d = new Date(datetimeStr);
return Math.floor(d.getTime() / 1000);
}
interface BulkDeleteContactsModalProps {
open: boolean;
onClose: () => void;
contacts: Contact[];
onDeleted: (deletedKeys: string[]) => void;
}
export function BulkDeleteContactsModal({
open,
onClose,
contacts,
onDeleted,
}: BulkDeleteContactsModalProps) {
const [step, setStep] = useState<'select' | 'confirm'>('select');
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [typeFilter, setTypeFilter] = useState<number | 'all'>('all');
const [deleting, setDeleting] = useState(false);
const lastClickedKeyRef = useRef<string | null>(null);
const resetAndClose = useCallback(() => {
setStep('select');
setSelectedKeys(new Set());
setStartDate('');
setEndDate('');
setTypeFilter('all');
lastClickedKeyRef.current = null;
onClose();
}, [onClose]);
const filteredContacts = useMemo(() => {
let list = [...contacts].sort((a, b) => (b.first_seen ?? 0) - (a.first_seen ?? 0));
if (typeFilter !== 'all') {
list = list.filter((c) => c.type === typeFilter);
}
if (startDate) {
const start = datetimeToUnix(startDate);
list = list.filter((c) => (c.first_seen ?? 0) >= start);
}
if (endDate) {
const end = datetimeToUnix(endDate);
list = list.filter((c) => (c.first_seen ?? 0) <= end);
}
return list;
}, [contacts, typeFilter, startDate, endDate]);
const handleToggle = (key: string, shiftKey: boolean) => {
if (shiftKey && lastClickedKeyRef.current && lastClickedKeyRef.current !== key) {
const keys = filteredContacts.map((c) => c.public_key);
const lastIdx = keys.indexOf(lastClickedKeyRef.current);
const curIdx = keys.indexOf(key);
if (lastIdx >= 0 && curIdx >= 0) {
const from = Math.min(lastIdx, curIdx);
const to = Math.max(lastIdx, curIdx);
const rangeKeys = keys.slice(from, to + 1);
setSelectedKeys((prev) => {
const next = new Set(prev);
for (const k of rangeKeys) next.add(k);
return next;
});
lastClickedKeyRef.current = key;
return;
}
}
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
lastClickedKeyRef.current = key;
};
const handleSelectAll = () => {
setSelectedKeys(new Set(filteredContacts.map((c) => c.public_key)));
};
const handleSelectNone = () => {
setSelectedKeys(new Set());
};
const selectedContacts = useMemo(
() => contacts.filter((c) => selectedKeys.has(c.public_key)),
[contacts, selectedKeys]
);
const contactCount = selectedContacts.filter((c) => c.type === 1 || c.type === 0).length;
const repeaterCount = selectedContacts.filter((c) => c.type === 2).length;
const roomCount = selectedContacts.filter((c) => c.type === 3).length;
const sensorCount = selectedContacts.filter((c) => c.type === 4).length;
const firstSeenDates = selectedContacts.map((c) => c.first_seen ?? 0).filter((t) => t > 0);
const minDate =
firstSeenDates.length > 0 ? formatDateISO(Math.min(...firstSeenDates)) : 'unknown';
const maxDate =
firstSeenDates.length > 0 ? formatDateISO(Math.max(...firstSeenDates)) : 'unknown';
const handleDelete = async () => {
setDeleting(true);
try {
const keysToDelete = [...selectedKeys];
const result = await api.bulkDeleteContacts(keysToDelete);
toast.success(`Deleted ${result.deleted} contact${result.deleted === 1 ? '' : 's'}`);
onDeleted(keysToDelete);
resetAndClose();
} catch (err) {
console.error('Bulk delete failed:', err);
toast.error('Bulk delete failed', {
description: err instanceof Error ? err.message : undefined,
});
} finally {
setDeleting(false);
}
};
return (
<Dialog open={open} onOpenChange={(isOpen) => !isOpen && resetAndClose()}>
<DialogContent className="sm:max-w-2xl max-h-[85dvh] flex flex-col">
<DialogHeader>
<DialogTitle>
{step === 'select' ? 'Bulk Delete Contacts' : 'Confirm Deletion'}
</DialogTitle>
<DialogDescription>
{step === 'select'
? 'Select contacts to delete. Message history will be preserved and accessible if a contact is re-added, but will no longer appear in the sidebar.'
: 'Review the contacts that will be permanently deleted.'}
</DialogDescription>
</DialogHeader>
{step === 'select' && (
<>
<div className="flex flex-wrap items-end gap-3">
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Show</label>
<select
value={typeFilter === 'all' ? 'all' : String(typeFilter)}
onChange={(e) =>
setTypeFilter(e.target.value === 'all' ? 'all' : Number(e.target.value))
}
className="block h-8 rounded-md border border-input bg-background px-2 text-sm"
>
<option value="all">All</option>
<option value="1">Clients</option>
<option value="2">Repeaters</option>
<option value="3">Room Servers</option>
<option value="4">Sensors</option>
</select>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created after</label>
<Input
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="space-y-1">
<label className="text-xs text-muted-foreground">Created before</label>
<Input
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-48 h-8 text-sm"
/>
</div>
<div className="flex gap-1.5">
<Button type="button" variant="outline" size="sm" onClick={handleSelectAll}>
Select all
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleSelectNone}>
Select none
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
{filteredContacts.length} contact{filteredContacts.length === 1 ? '' : 's'} shown
{(startDate || endDate) && ' (filtered)'}
{' · '}
{selectedKeys.size} selected
</div>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
{filteredContacts.length === 0 ? (
<div className="p-4 text-center text-sm text-muted-foreground">
No contacts match the selected date range.
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5 w-8" />
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{filteredContacts.map((c) => (
<tr
key={c.public_key}
className="border-t border-border hover:bg-accent/50 cursor-pointer"
onClick={(e) => handleToggle(c.public_key, e.shiftKey)}
>
<td className="px-3 py-1.5">
<input
type="checkbox"
checked={selectedKeys.has(c.public_key)}
onChange={(e) =>
handleToggle(
c.public_key,
e.nativeEvent instanceof MouseEvent && e.nativeEvent.shiftKey
)
}
onClick={(e) => e.stopPropagation()}
className="rounded border-input"
/>
</td>
<td className="px-3 py-1.5 truncate max-w-[10rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" onClick={resetAndClose}>
Cancel
</Button>
<Button
variant="outline"
className="border-warning text-warning hover:bg-warning/10 hover:text-warning"
disabled={selectedKeys.size === 0}
onClick={() => setStep('confirm')}
>
Proceed to confirmation ({selectedKeys.size})
</Button>
</div>
</>
)}
{step === 'confirm' && (
<>
<div className="flex-1 overflow-y-auto min-h-0 border border-border rounded-md">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-muted/90 backdrop-blur-sm">
<tr className="text-left text-xs text-muted-foreground">
<th className="px-3 py-1.5">Name</th>
<th className="px-3 py-1.5">Type</th>
<th className="px-3 py-1.5">Key</th>
<th className="px-3 py-1.5 hidden sm:table-cell">Created</th>
</tr>
</thead>
<tbody>
{selectedContacts.map((c) => (
<tr key={c.public_key} className="border-t border-border">
<td className="px-3 py-1.5 truncate max-w-[12rem]">
{getContactDisplayName(c.name, c.public_key, c.last_advert)}
</td>
<td className="px-3 py-1.5 text-xs text-muted-foreground">
{CONTACT_TYPE_LABELS[c.type] ?? 'Unknown'}
</td>
<td className="px-3 py-1.5 font-mono text-xs text-muted-foreground truncate max-w-[8rem]">
{c.public_key.slice(0, 12)}
</td>
<td className="px-3 py-1.5 hidden sm:table-cell text-xs text-muted-foreground">
{c.first_seen ? formatDate(c.first_seen) : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="flex flex-col gap-3 pt-2">
<Button
variant="destructive"
className="w-full h-auto py-3 text-wrap"
disabled={deleting}
onClick={handleDelete}
>
{deleting
? 'Deleting...'
: `I confirm permanent, irrevocable deletion of all listed nodes above, totalling ${[
contactCount > 0 && `${contactCount} contact${contactCount === 1 ? '' : 's'}`,
repeaterCount > 0 &&
`${repeaterCount} repeater${repeaterCount === 1 ? '' : 's'}`,
roomCount > 0 && `${roomCount} room${roomCount === 1 ? '' : 's'}`,
sensorCount > 0 && `${sensorCount} sensor${sensorCount === 1 ? '' : 's'}`,
]
.filter(Boolean)
.join(', ')}, spanning creation dates from ${minDate} to ${maxDate}`}
</Button>
<Button variant="secondary" onClick={() => setStep('select')} disabled={deleting}>
Back
</Button>
</div>
</>
)}
</DialogContent>
</Dialog>
);
}
@@ -6,8 +6,7 @@ import { Separator } from '../ui/separator';
import { toast } from '../ui/sonner';
import { api } from '../../api';
import { formatTime } from '../../utils/messageParser';
import { BulkDeleteContactsModal } from './BulkDeleteContactsModal';
import type { AppSettings, AppSettingsUpdate, Contact, HealthStatus } from '../../types';
import type { AppSettings, AppSettingsUpdate, HealthStatus } from '../../types';
export function SettingsDatabaseSection({
appSettings,
@@ -18,8 +17,6 @@ export function SettingsDatabaseSection({
blockedNames = [],
onToggleBlockedKey,
onToggleBlockedName,
contacts = [],
onBulkDeleteContacts,
className,
}: {
appSettings: AppSettings;
@@ -30,23 +27,18 @@ export function SettingsDatabaseSection({
blockedNames?: string[];
onToggleBlockedKey?: (key: string) => void;
onToggleBlockedName?: (name: string) => void;
contacts?: Contact[];
onBulkDeleteContacts?: (deletedKeys: string[]) => void;
className?: string;
}) {
const [retentionDays, setRetentionDays] = useState('14');
const [cleaning, setCleaning] = useState(false);
const [purgingDecryptedRaw, setPurgingDecryptedRaw] = useState(false);
const [autoDecryptOnAdvert, setAutoDecryptOnAdvert] = useState(false);
const [discoveryBlockedTypes, setDiscoveryBlockedTypes] = useState<number[]>([]);
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setAutoDecryptOnAdvert(appSettings.auto_decrypt_dm_on_advert);
setDiscoveryBlockedTypes(appSettings.discovery_blocked_types ?? []);
}, [appSettings]);
const handleCleanup = async () => {
@@ -100,15 +92,7 @@ export function SettingsDatabaseSection({
setError(null);
try {
const update: AppSettingsUpdate = { auto_decrypt_dm_on_advert: autoDecryptOnAdvert };
const currentBlocked = appSettings.discovery_blocked_types ?? [];
if (
discoveryBlockedTypes.length !== currentBlocked.length ||
discoveryBlockedTypes.some((t) => !currentBlocked.includes(t))
) {
update.discovery_blocked_types = discoveryBlockedTypes;
}
await onSaveAppSettings(update);
await onSaveAppSettings({ auto_decrypt_dm_on_advert: autoDecryptOnAdvert });
toast.success('Database settings saved');
} catch (err) {
console.error('Failed to save database settings:', err);
@@ -121,93 +105,93 @@ export function SettingsDatabaseSection({
return (
<div className={className}>
{/* ── Database Overview ── */}
<div className="space-y-3">
<Label className="text-base">Database Overview</Label>
<div className="rounded-md border border-border bg-muted/30 p-3 space-y-2">
<div className="flex justify-between items-center">
<span className="text-sm">Database size</span>
<span className="text-sm font-semibold">{health?.database_size_mb ?? '?'} MB</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm">Oldest undecrypted packet</span>
{health?.oldest_undecrypted_timestamp ? (
<span className="text-sm font-semibold">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="font-normal text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days)
</span>
</span>
) : (
<span className="text-sm text-muted-foreground">None</span>
)}
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Database size</span>
<span className="font-medium">{health?.database_size_mb ?? '?'} MB</span>
</div>
{health?.oldest_undecrypted_timestamp ? (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="font-medium">
{formatTime(health.oldest_undecrypted_timestamp)}
<span className="text-muted-foreground ml-1">
({Math.floor((Date.now() / 1000 - health.oldest_undecrypted_timestamp) / 86400)}{' '}
days old)
</span>
</span>
</div>
) : (
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Oldest undecrypted packet</span>
<span className="text-muted-foreground">None</span>
</div>
)}
</div>
<Separator />
{/* ── Storage Cleanup ── */}
<div className="space-y-4">
<Label className="text-base">Storage Cleanup</Label>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
Permanently deletes stored raw packets that have not yet been decrypted. These are
retained in case you later obtain the correct key once deleted, these messages can
never be recovered.
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs text-muted-foreground">
Older than (days)
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
</div>
<Button
variant="outline"
onClick={handleCleanup}
disabled={cleaning}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{cleaning ? 'Deleting...' : 'Delete'}
</Button>
<div className="space-y-3">
<Label>Delete Undecrypted Packets</Label>
<p className="text-xs text-muted-foreground">
Permanently deletes stored raw packets containing DMs and channel messages that have not
yet been decrypted. These packets are retained in case you later obtain the correct key
once deleted, these messages can never be recovered or decrypted.
</p>
<div className="flex gap-2 items-end">
<div className="space-y-1">
<Label htmlFor="retention-days" className="text-xs">
Older than (days)
</Label>
<Input
id="retention-days"
type="number"
min="1"
max="365"
value={retentionDays}
onChange={(e) => setRetentionDays(e.target.value)}
className="w-24"
/>
</div>
</div>
<div className="rounded-md border border-border p-3 space-y-2">
<Label className="text-sm">Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes the raw packet bytes behind messages that are already decrypted and visible in
chat. This frees space but removes packet-analysis availability for those messages. It
does not affect displayed messages or future decryption.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-warning/50 text-warning hover:bg-warning/10"
onClick={handleCleanup}
disabled={cleaning}
className="border-destructive/50 text-destructive hover:bg-destructive/10"
>
{purgingDecryptedRaw ? 'Purging...' : 'Purge Archival Packets'}
{cleaning ? 'Deleting...' : 'Permanently Delete'}
</Button>
</div>
</div>
<Separator />
{/* ── DM Decryption ── */}
<div className="space-y-3">
<Label className="text-base">DM Decryption</Label>
<Label>Purge Archival Raw Packets</Label>
<p className="text-xs text-muted-foreground">
Deletes archival copies of raw packet bytes for messages that are already decrypted and
visible in your chat history.{' '}
<em className="text-muted-foreground/80">
This will not affect any displayed messages or your ability to do historical decryption,
but it will remove packet-analysis availability for those historical messages.
</em>{' '}
The raw bytes are only useful for manual packet analysis.
</p>
<Button
variant="outline"
onClick={handlePurgeDecryptedRawPackets}
disabled={purgingDecryptedRaw}
className="w-full border-warning/50 text-warning hover:bg-warning/10"
>
{purgingDecryptedRaw ? 'Purging Archival Raw Packets...' : 'Purge Archival Raw Packets'}
</Button>
</div>
<Separator />
<div className="space-y-3">
<Label>DM Decryption</Label>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
@@ -223,87 +207,17 @@ export function SettingsDatabaseSection({
</p>
</div>
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
<Separator />
{/* ── Contact Management ── */}
<div className="space-y-2">
<Label className="text-base">Contact Management</Label>
</div>
{/* Block discovery of new node types */}
<div className="space-y-3">
<Label>Block Discovery of New Node Types</Label>
<p className="text-xs text-muted-foreground">
Checked types will be ignored when heard via advertisement. Existing contacts of these
types are still updated. This does not affect contacts added manually or via DM.
</p>
<div className="space-y-1.5">
{(
[
[1, 'Block clients'],
[2, 'Block repeaters'],
[3, 'Block room servers'],
[4, 'Block sensors'],
] as const
).map(([typeCode, label]) => {
const checked = discoveryBlockedTypes.includes(typeCode);
return (
<label key={typeCode} className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={checked}
onChange={() =>
setDiscoveryBlockedTypes((prev) =>
checked ? prev.filter((t) => t !== typeCode) : [...prev, typeCode]
)
}
className="rounded border-input"
/>
{label}
</label>
);
})}
</div>
{discoveryBlockedTypes.length > 0 && (
<p className="text-xs text-warning">
New{' '}
{discoveryBlockedTypes
.map((t) =>
t === 1 ? 'clients' : t === 2 ? 'repeaters' : t === 3 ? 'room servers' : 'sensors'
)
.join(', ')}{' '}
heard via advertisement will not be added to your contact list.
</p>
)}
</div>
<Separator />
{/* Blocked contacts list */}
<div className="space-y-3">
<Label>Blocked Contacts</Label>
<p className="text-xs text-muted-foreground">
Blocked contacts are hidden from the sidebar. Blocking only hides messages from the UI
MQTT forwarding and bot responses are not affected. Messages are still stored and will
reappear if unblocked.
Blocking only hides messages from the UI. MQTT forwarding and bot responses are not
affected. Messages are still stored and will reappear if unblocked.
</p>
{blockedKeys.length === 0 && blockedNames.length === 0 ? (
<p className="text-sm text-muted-foreground italic">
No blocked contacts. Block contacts from their info pane, viewed by clicking their
avatar in any channel, or their name within the top status bar with the conversation
open.
</p>
<p className="text-sm text-muted-foreground italic">No blocked contacts</p>
) : (
<div className="space-y-2">
{blockedKeys.length > 0 && (
@@ -354,25 +268,15 @@ export function SettingsDatabaseSection({
)}
</div>
<Separator />
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
</div>
)}
{/* Bulk delete */}
<div className="space-y-3">
<Label>Bulk Delete Contacts</Label>
<p className="text-xs text-muted-foreground">
Remove multiple contacts or repeaters at once. Useful for cleaning up spam or unwanted
nodes. Message history will be preserved.
</p>
<Button variant="outline" className="w-full" onClick={() => setBulkDeleteOpen(true)}>
Open Bulk Delete
</Button>
<BulkDeleteContactsModal
open={bulkDeleteOpen}
onClose={() => setBulkDeleteOpen(false)}
contacts={contacts}
onDeleted={(keys) => onBulkDeleteContacts?.(keys)}
/>
</div>
<Button onClick={handleSave} disabled={busy} className="w-full">
{busy ? 'Saving...' : 'Save Settings'}
</Button>
</div>
);
}
@@ -643,20 +643,16 @@ function formatPrivateTopicSummary(config: Record<string, unknown>) {
return `${prefix}/dm:<pubkey>, ${prefix}/gm:<channel>, ${prefix}/raw/...`;
}
function censorAppriseUrl(url: string): string {
const protoMatch = url.match(/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//);
if (protoMatch) return `${protoMatch[0]}********`;
return '********';
}
function formatAppriseTargets(urls: string | undefined) {
function formatAppriseTargets(urls: string | undefined, maxLength = 80) {
const targets = (urls || '')
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
if (targets.length === 0) return 'No targets configured';
return targets.map(censorAppriseUrl).join(', ');
const joined = targets.join(', ');
if (joined.length <= maxLength) return joined;
return `${joined.slice(0, maxLength - 3)}...`;
}
function formatSqsQueueSummary(config: Record<string, unknown>) {
@@ -347,20 +347,17 @@ function PreviewSidebarRow({
}) {
return (
<div
data-active={active ? 'true' : undefined}
className={`sidebar-action-row flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
className={`flex items-center gap-2 rounded-md border-l-2 px-3 py-2 text-[13px] ${
active ? 'border-l-primary bg-accent text-foreground' : 'border-l-transparent'
}`}
>
<span className="sidebar-tool-icon" aria-hidden="true">
{leading}
</span>
<span className={`sidebar-tool-label min-w-0 flex-1 truncate ${active ? 'font-medium' : ''}`}>
{leading}
<span className={`min-w-0 flex-1 truncate ${active ? 'font-medium' : 'text-foreground'}`}>
{label}
</span>
{badge}
{!badge && (
<span className="sidebar-tool-icon" aria-hidden="true">
<span className="text-muted-foreground" aria-hidden="true">
<MessageSquare className="h-3.5 w-3.5" />
</span>
)}
@@ -390,9 +390,9 @@ export function SettingsRadioSection({
return (
<div className={className}>
{/* ── Connection ── */}
{/* Connection display */}
<div className="space-y-3">
<Label className="text-base">Connection</Label>
<Label>Connection</Label>
<div className="flex items-center gap-2">
<div
className={`w-2 h-2 rounded-full ${
@@ -428,58 +428,15 @@ export function SettingsRadioSection({
</p>
</div>
<Separator />
{/* ── Identity ── */}
<div className="space-y-2">
<Label className="text-base">Identity</Label>
</div>
{/* Radio Name */}
<div className="space-y-2">
<Label htmlFor="name">Radio Name</Label>
<Input id="name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={identityBusy || identityRebooting || !privateKey.trim()}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div>
)}
<Separator />
{/* ── Radio Parameters ── */}
<div className="space-y-2">
<Label className="text-base">Radio Parameters</Label>
</div>
{/* Radio Config */}
<div className="space-y-2">
<Label htmlFor="preset">Preset</Label>
<select
@@ -561,36 +518,11 @@ export function SettingsRadioSection({
</div>
</div>
{config.path_hash_mode_supported && (
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
<p>
ALL nodes along a message&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
)}
<Separator />
{/* ── Location ── */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label className="text-base">Location</Label>
<Label>Location</Label>
<Button
type="button"
variant="outline"
@@ -653,8 +585,53 @@ export function SettingsRadioSection({
library.
</p>
</div>
<div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="multi-acks-enabled"
checked={multiAcksEnabled}
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal
ACK for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
</p>
</div>
</div>
</div>
</div>
{config.path_hash_mode_supported && (
<>
<Separator />
<div className="space-y-2">
<Label htmlFor="path-hash-mode">Path Hash Mode</Label>
<select
id="path-hash-mode"
value={pathHashMode}
onChange={(e) => setPathHashMode(e.target.value)}
className="w-full h-10 px-3 rounded-md border border-input bg-background text-sm ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2"
>
<option value="0">1 byte (default)</option>
<option value="1">2 bytes</option>
<option value="2">3 bytes</option>
</select>
<div className="rounded-md border border-warning/50 bg-warning/10 p-3 text-xs text-warning">
<p className="font-semibold mb-1">Compatibility Warning</p>
<p>
ALL nodes along a message&apos;s route &mdash; your radio, every repeater, and the
recipient &mdash; must be running firmware that supports the selected mode. Messages
sent with 2-byte or 3-byte hops will be dropped by any node on older firmware.
</p>
</div>
</div>
</>
)}
{error && (
<div className="text-sm text-destructive" role="alert">
{error}
@@ -680,28 +657,64 @@ export function SettingsRadioSection({
<Separator />
{/* ── Messaging ── */}
{/* Keys */}
<div className="space-y-2">
<Label className="text-base">Messaging</Label>
<Label htmlFor="public-key">Public Key</Label>
<Input id="public-key" value={config.public_key} disabled className="font-mono text-xs" />
</div>
<div className="space-y-2">
<div className="flex items-start gap-3 rounded-md border border-border/60 p-3">
<Checkbox
id="multi-acks-enabled"
checked={multiAcksEnabled}
onCheckedChange={(checked) => setMultiAcksEnabled(checked === true)}
className="mt-0.5"
/>
<div className="space-y-1">
<Label htmlFor="multi-acks-enabled">Extra Direct ACK Transmission</Label>
<p className="text-xs text-muted-foreground">
When enabled, the radio sends one extra direct ACK transmission before the normal ACK
for received direct messages. This is a firmware-level receive behavior, not a
RemoteTerm retry setting.
</p>
</div>
<Label htmlFor="private-key">Set Private Key (write-only)</Label>
<Input
id="private-key"
type="password"
autoComplete="off"
value={privateKey}
onChange={(e) => setPrivateKey(e.target.value)}
placeholder="64-character hex private key"
/>
<Button
onClick={handleSetPrivateKey}
disabled={identityBusy || identityRebooting || !privateKey.trim()}
className="w-full border-destructive/50 text-destructive hover:bg-destructive/10"
variant="outline"
>
{identityBusy || identityRebooting
? 'Setting & Rebooting...'
: 'Set Private Key & Reboot'}
</Button>
</div>
{identityError && (
<div className="text-sm text-destructive" role="alert">
{identityError}
</div>
)}
<Separator />
{/* Flood & Advert Control */}
<div className="space-y-2">
<Label className="text-base">Flood & Advert Control</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
</div>
<div className="space-y-2">
@@ -733,13 +746,6 @@ export function SettingsRadioSection({
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>
{health?.radio_device_info?.max_contacts != null &&
Number(maxRadioContacts) > health.radio_device_info.max_contacts && (
<p className="text-xs text-warning">
Your radio reports a hardware limit of {health.radio_device_info.max_contacts}{' '}
contacts. The effective cap will be limited to what the radio supports.
</p>
)}
</div>
{floodError && (
@@ -754,28 +760,8 @@ export function SettingsRadioSection({
<Separator />
{/* ── Advertising & Discovery ── */}
<div className="space-y-2">
<Label className="text-base">Advertising &amp; Discovery</Label>
</div>
<div className="space-y-2">
<Label htmlFor="advert-interval">Periodic Advertising Interval</Label>
<div className="flex items-center gap-2">
<Input
id="advert-interval"
type="number"
min="0"
value={advertIntervalHours}
onChange={(e) => setAdvertIntervalHours(e.target.value)}
className="w-28"
/>
<span className="text-sm text-muted-foreground">hours (0 = off)</span>
</div>
<p className="text-xs text-muted-foreground">
How often to automatically advertise presence. Set to 0 to disable. Minimum: 1 hour.
Recommended: 24 hours or higher.
</p>
<Label className="text-base">Hear &amp; Be Heard</Label>
</div>
<div className="space-y-2">
+1 -20
View File
@@ -4,7 +4,7 @@ import { takePrefetchOrFetch } from '../prefetch';
import { toast } from '../components/ui/sonner';
import { getContactDisplayName } from '../utils/pubkey';
import { findPublicChannel, PUBLIC_CHANNEL_KEY, PUBLIC_CHANNEL_NAME } from '../utils/publicChannel';
import type { BulkCreateHashtagChannelsResult, Channel, Contact, Conversation } from '../types';
import type { Channel, Contact, Conversation } from '../types';
interface UseContactsAndChannelsArgs {
setActiveConversation: (conv: Conversation | null) => void;
@@ -112,24 +112,6 @@ export function useContactsAndChannels({
[fetchUndecryptedCountInternal, setActiveConversation]
);
const handleBulkCreateHashtagChannels = useCallback(
async (
channelNames: string[],
tryHistorical: boolean
): Promise<BulkCreateHashtagChannelsResult> => {
const result = await api.bulkCreateHashtagChannels(channelNames, tryHistorical);
const data = await api.getChannels();
setChannels(data);
if (tryHistorical && result.decrypt_started) {
fetchUndecryptedCountInternal();
}
return result;
},
[fetchUndecryptedCountInternal]
);
const handleDeleteChannel = useCallback(
async (key: string) => {
if (!confirm('Delete this channel? Message history will be preserved.')) return;
@@ -208,7 +190,6 @@ export function useContactsAndChannels({
handleCreateContact,
handleCreateChannel,
handleCreateHashtagChannel,
handleBulkCreateHashtagChannels,
handleDeleteChannel,
handleDeleteContact,
};
-52
View File
@@ -56,14 +56,6 @@
--badge-mention: var(--destructive);
--badge-mention-foreground: var(--destructive-foreground);
/* Sidebar navigation accents */
--sidebar-icon-color: hsl(var(--foreground));
--sidebar-icon-hover-color: hsl(var(--foreground));
--sidebar-icon-active-color: hsl(var(--foreground));
--sidebar-label-color: hsl(var(--muted-foreground));
--sidebar-label-hover-color: hsl(var(--foreground));
--sidebar-label-active-color: hsl(var(--foreground));
/* Error toast */
--toast-error: 0 30% 14%;
--toast-error-foreground: 0 56% 77%;
@@ -134,50 +126,6 @@
animation: message-highlight 2s ease-out forwards;
}
.sidebar-tool-icon {
display: inline-flex;
height: 1.5rem;
width: 1.5rem;
flex-shrink: 0;
align-items: center;
justify-content: center;
border-radius: 0.45rem;
color: var(--sidebar-icon-color);
opacity: 1;
transition:
color 150ms ease,
opacity 150ms ease;
}
.sidebar-tool-icon svg {
stroke-width: 2.35;
}
.sidebar-tool-label {
color: var(--sidebar-label-color);
transition: color 150ms ease;
}
.sidebar-action-row:hover .sidebar-tool-icon,
.sidebar-action-row:focus-visible .sidebar-tool-icon {
color: var(--sidebar-icon-hover-color);
opacity: 1;
}
.sidebar-action-row:hover .sidebar-tool-label,
.sidebar-action-row:focus-visible .sidebar-tool-label {
color: var(--sidebar-label-hover-color);
}
.sidebar-action-row[data-active='true'] .sidebar-tool-icon {
color: var(--sidebar-icon-active-color);
opacity: 1;
}
.sidebar-action-row[data-active='true'] .sidebar-tool-label {
color: var(--sidebar-label-active-color);
}
/* Constrain CodeMirror editor width */
.cm-editor {
max-width: 100% !important;
@@ -1,46 +0,0 @@
import { render, screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { BulkAddChannelResultModal } from '../components/BulkAddChannelResultModal';
describe('BulkAddChannelResultModal', () => {
it('renders links only for newly created rooms', () => {
render(
<BulkAddChannelResultModal
open
onClose={() => {}}
result={{
created_channels: [
{
key: 'AA'.repeat(16),
name: '#ops',
is_hashtag: true,
on_radio: false,
last_read_at: null,
},
{
key: 'BB'.repeat(16),
name: '#mesh-room',
is_hashtag: true,
on_radio: false,
last_read_at: null,
},
],
existing_count: 3,
invalid_names: ['bad_room'],
decrypt_started: true,
decrypt_total_packets: 8,
message: 'Created 2 rooms',
}}
/>
);
const opsLink = screen.getByRole('link', { name: '#ops' });
const meshLink = screen.getByRole('link', { name: '#mesh-room' });
expect(opsLink.getAttribute('href')).toContain('#channel/');
expect(meshLink.getAttribute('href')).toContain('#channel/');
expect(screen.queryByRole('link', { name: /bad_room/i })).toBeNull();
expect(screen.getByText(/Ignored invalid room names: bad_room/)).toBeTruthy();
});
});
-26
View File
@@ -169,32 +169,6 @@ describe('MessageList channel sender rendering', () => {
expect(onChannelReferenceClick).toHaveBeenCalledWith('#mesh-room');
});
it('links valid channel references when followed by clause punctuation', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
render(
<MessageList
messages={[
createMessage({
text: 'Alice: Check #mesh-room, then #ops-room; then #alpha-room.',
}),
]}
contacts={[]}
loading={false}
onChannelReferenceClick={onChannelReferenceClick}
/>
);
await user.click(screen.getByRole('button', { name: '#mesh-room' }));
await user.click(screen.getByRole('button', { name: '#ops-room' }));
await user.click(screen.getByRole('button', { name: '#alpha-room' }));
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(1, '#mesh-room');
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(2, '#ops-room');
expect(onChannelReferenceClick).toHaveBeenNthCalledWith(3, '#alpha-room');
});
it('links valid channel references in direct messages too', async () => {
const user = userEvent.setup();
const onChannelReferenceClick = vi.fn();
+5 -15
View File
@@ -122,21 +122,11 @@ describe('linked channel references', () => {
]);
});
it('finds linked channel references terminated by clause punctuation', () => {
expect(
findLinkedChannelReferences('Join #mesh-room, then #ops2; finally #alpha-room.')
).toEqual([
{ label: '#mesh-room', start: 5, end: 15 },
{ label: '#ops2', start: 22, end: 27 },
{ label: '#alpha-room', start: 37, end: 48 },
]);
});
it('ignores invalid or embedded channel-like text', () => {
const references = findLinkedChannelReferences(
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
);
expect(references.map((reference) => reference.label)).toEqual(['#good-room']);
expect(
findLinkedChannelReferences(
'skip #Bad #bad--name abc#ops #ops- #opsRoom #ops_room #good-room,'
)
).toEqual([]);
});
});
@@ -27,7 +27,6 @@ describe('NewMessageModal form reset', () => {
const onCreateContact = vi.fn().mockResolvedValue(undefined);
const onCreateChannel = vi.fn().mockResolvedValue(undefined);
const onCreateHashtagChannel = vi.fn().mockResolvedValue(undefined);
const onBulkAddHashtagChannels = vi.fn().mockResolvedValue(undefined);
beforeEach(() => {
vi.clearAllMocks();
@@ -45,7 +44,6 @@ describe('NewMessageModal form reset', () => {
onCreateContact={onCreateContact}
onCreateChannel={onCreateChannel}
onCreateHashtagChannel={onCreateHashtagChannel}
onBulkAddHashtagChannels={onBulkAddHashtagChannels}
{...overrides}
/>
);
@@ -113,53 +111,6 @@ describe('NewMessageModal form reset', () => {
});
});
describe('bulk hashtag tab', () => {
it('is only visible when enabled', () => {
renderModal();
expect(screen.queryByRole('tab', { name: 'Bulk Add Channel' })).toBeNull();
});
it('opens on the bulk tab when enabled and submits normalized room names', async () => {
const user = userEvent.setup();
renderModal(true, { showBulkAddChannelTab: true });
await waitFor(() => {
expect(screen.getByRole('tab', { name: 'Bulk Add Channel' })).toHaveAttribute(
'data-state',
'active'
);
});
await user.type(
screen.getByRole('textbox', { name: 'Bulk channel names' }),
'#Ops{enter}mesh-room another-room #Ops'
);
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
await waitFor(() => {
expect(onBulkAddHashtagChannels).toHaveBeenCalledWith(
['#ops', '#mesh-room', '#another-room'],
false
);
});
expect(onClose).toHaveBeenCalled();
});
it('shows invalid bulk room names before submitting', async () => {
const user = userEvent.setup();
renderModal(true, { showBulkAddChannelTab: true });
await user.type(
screen.getByRole('textbox', { name: 'Bulk channel names' }),
'good-room bad_room'
);
await user.click(screen.getByRole('button', { name: 'Add Channels' }));
expect(onBulkAddHashtagChannels).not.toHaveBeenCalled();
expect(screen.getByText('Invalid room names: bad_room')).toBeTruthy();
});
});
describe('new-contact tab', () => {
it('clears name and key after successful Create', async () => {
const user = userEvent.setup();
+2 -114
View File
@@ -51,11 +51,11 @@ vi.mock('../hooks/useRepeaterDashboard', () => ({
useRepeaterDashboard: () => mockHook,
}));
// Mock api module (TelemetryHistoryPane fetches on mount)
// Mock api module (used by routing override tests + telemetry history fetch on mount)
vi.mock('../api', () => ({
api: {
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
setContactRoutingOverride: vi.fn().mockResolvedValue({ status: 'ok' }),
repeaterTelemetryHistory: vi.fn().mockResolvedValue([]),
},
}));
@@ -126,16 +126,6 @@ const defaultProps = {
onDeleteContact: vi.fn(),
};
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe('RepeaterDashboard', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -653,106 +643,4 @@ describe('RepeaterDashboard', () => {
overrideSpy.mockRestore();
});
});
describe('telemetry history', () => {
beforeEach(async () => {
const { api } = await import('../api');
vi.mocked(api.repeaterTelemetryHistory).mockResolvedValue([]);
});
it('loads telemetry history on mount when logged in', async () => {
const { api } = await import('../api');
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(api.repeaterTelemetryHistory).toHaveBeenCalledWith(REPEATER_KEY);
});
});
it('shows telemetry history pane in logged-in view even before status fetch', () => {
mockHook.loggedIn = true;
render(<RepeaterDashboard {...defaultProps} />);
expect(screen.getByText('Telemetry History')).toBeInTheDocument();
expect(screen.getByText('0 samples')).toBeInTheDocument();
});
it('updates history from live status fetch', async () => {
const { api } = await import('../api');
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
const liveEntry = { timestamp: 1700000000, data: { battery_volts: 4.2 } };
historySpy.mockResolvedValue([]);
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [liveEntry],
};
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
});
it('does not let an older preload overwrite newer live status history', async () => {
const { api } = await import('../api');
const historySpy = vi.mocked(api.repeaterTelemetryHistory);
const deferred = createDeferred<{ timestamp: number; data: { battery_volts: number } }[]>();
historySpy.mockReturnValue(deferred.promise);
mockHook.loggedIn = true;
mockHook.paneData.status = {
battery_volts: 4.2,
tx_queue_len: 0,
noise_floor_dbm: -120,
last_rssi_dbm: -85,
last_snr_db: 7.5,
packets_received: 100,
packets_sent: 50,
airtime_seconds: 600,
rx_airtime_seconds: 1200,
uptime_seconds: 86400,
sent_flood: 10,
sent_direct: 40,
recv_flood: 30,
recv_direct: 70,
flood_dups: 1,
direct_dups: 0,
full_events: 0,
telemetry_history: [{ timestamp: 1700000000, data: { battery_volts: 4.2 } }],
};
render(<RepeaterDashboard {...defaultProps} />);
await waitFor(() => {
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
deferred.resolve([{ timestamp: 1690000000, data: { battery_volts: 3.9 } }]);
await deferred.promise;
expect(screen.getByText('1 samples')).toBeInTheDocument();
});
});
});
+2 -3
View File
@@ -69,7 +69,6 @@ const baseSettings: AppSettings = {
flood_scope: '',
blocked_keys: [],
blocked_names: [],
discovery_blocked_types: [],
};
function renderModal(overrides?: {
@@ -616,10 +615,10 @@ describe('SettingsModal', () => {
openDatabaseSection();
expect(
screen.getByText(/removes packet-analysis availability for those messages/i)
screen.getByText(/remove packet-analysis availability for those historical messages/i)
).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Packets' }));
fireEvent.click(screen.getByRole('button', { name: 'Purge Archival Raw Packets' }));
await waitFor(() => {
expect(runMaintenanceSpy).toHaveBeenCalledWith({ purgeLinkedRawPackets: true });
@@ -9,7 +9,7 @@ import { act, renderHook } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { useContactsAndChannels } from '../hooks/useContactsAndChannels';
import type { BulkCreateHashtagChannelsResult, Contact } from '../types';
import type { Contact } from '../types';
// Mock api module
vi.mock('../api', () => ({
@@ -18,7 +18,6 @@ vi.mock('../api', () => ({
getChannels: vi.fn(),
createContact: vi.fn(),
createChannel: vi.fn(),
bulkCreateHashtagChannels: vi.fn(),
deleteContact: vi.fn(),
deleteChannel: vi.fn(),
decryptHistoricalPackets: vi.fn(),
@@ -172,41 +171,4 @@ describe('useContactsAndChannels', () => {
expect(api.getContacts).toHaveBeenCalledTimes(2);
});
});
describe('bulk hashtag creation', () => {
it('refreshes channels and returns the backend result', async () => {
const { api } = await import('../api');
const resultPayload: BulkCreateHashtagChannelsResult = {
created_channels: [
{
key: 'AA'.repeat(16),
name: '#ops',
is_hashtag: true,
on_radio: false,
last_read_at: null,
},
],
existing_count: 1,
invalid_names: [],
decrypt_started: true,
decrypt_total_packets: 12,
message: 'Created 1 room',
};
vi.mocked(api.bulkCreateHashtagChannels).mockResolvedValueOnce(resultPayload);
vi.mocked(api.getChannels).mockResolvedValueOnce(resultPayload.created_channels);
vi.mocked(api.getUndecryptedPacketCount).mockResolvedValueOnce({ count: 9 });
const { result } = renderUseContactsAndChannels();
let response: BulkCreateHashtagChannelsResult | null = null;
await act(async () => {
response = await result.current.handleBulkCreateHashtagChannels(['#ops'], true);
});
expect(api.bulkCreateHashtagChannels).toHaveBeenCalledWith(['#ops'], true);
expect(api.getChannels).toHaveBeenCalled();
expect(api.getUndecryptedPacketCount).toHaveBeenCalled();
expect(response).toEqual(resultPayload);
});
});
});
+4
View File
@@ -49,6 +49,10 @@
--overlay: 220 20% 10%;
}
:root[data-theme='light'] .sidebar-tool-label {
color: hsl(var(--foreground));
}
/* ── Windows 95 ───────────────────────────────────────────── */
:root[data-theme='windows-95'] {
--background: 180 100% 25%;
-11
View File
@@ -235,15 +235,6 @@ export interface ChannelTopSender {
message_count: number;
}
export interface BulkCreateHashtagChannelsResult {
created_channels: Channel[];
existing_count: number;
invalid_names: string[];
decrypt_started: boolean;
decrypt_total_packets: number;
message: string;
}
export interface ChannelDetail {
channel: Channel;
message_counts: ChannelMessageCounts;
@@ -341,7 +332,6 @@ export interface AppSettings {
flood_scope: string;
blocked_keys: string[];
blocked_names: string[];
discovery_blocked_types: number[];
}
export interface AppSettingsUpdate {
@@ -352,7 +342,6 @@ export interface AppSettingsUpdate {
flood_scope?: string;
blocked_keys?: string[];
blocked_names?: string[];
discovery_blocked_types?: number[];
}
export interface MigratePreferencesRequest {
+1 -1
View File
@@ -3,7 +3,7 @@
* Channel messages have format "sender: message".
*/
const HASHTAG_CHANNEL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|[\s.,;:])/g;
const HASHTAG_CHANNEL_REFERENCE_PATTERN = /(^|\s)(#[a-z0-9]+(?:-[a-z0-9]+)*)(?=$|\s)/g;
export function parseSenderFromText(text: string): { sender: string | null; content: string } {
const colonIndex = text.indexOf(': ');
+1 -1
View File
@@ -147,7 +147,7 @@ export function getMapFocusHash(publicKeyPrefix: string): string {
}
// Generate URL hash from conversation
export function getConversationHash(conv: Conversation | null): string {
function getConversationHash(conv: Conversation | null): string {
if (!conv) return '';
if (conv.type === 'raw') return '#raw';
if (conv.type === 'map') return '#map';
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remoteterm-meshcore"
version = "3.7.1"
version = "3.6.7"
description = "RemoteTerm - Web interface for MeshCore radio mesh networks"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -26,7 +26,7 @@ echo -e "${YELLOW}=== Phase 1: Lint & Format ===${NC}"
echo -ne "${BLUE}[backend lint]${NC} "
cd "$REPO_ROOT"
uv run ruff check app/ tests/ --fix --quiet
uv run ruff format app/ tests/ --quiet
uv run ruff format app/ tests/ --check --quiet
echo -e "${GREEN}Passed!${NC}"
echo -ne "${BLUE}[frontend lint]${NC} "
-1
View File
@@ -63,7 +63,6 @@ export default defineConfig({
timeout: 180_000,
env: {
MESHCORE_DATABASE_PATH: path.join(tmpDir, 'e2e-test.db'),
MESHCORE_SKIP_POST_CONNECT_SYNC: 'true',
// Pass through the serial port from the environment
...(process.env.MESHCORE_SERIAL_PORT
? { MESHCORE_SERIAL_PORT: process.env.MESHCORE_SERIAL_PORT }
+5 -109
View File
@@ -131,62 +131,6 @@ class TestHealthEndpoint:
class TestDebugEndpoint:
"""Test the debug support snapshot endpoint."""
def test_support_snapshot_sanitizes_radio_probe_location_fields(self):
"""Debug radio probe should redact advertised lat/lon from self_info."""
from app.routers.debug import _sanitize_radio_probe_self_info
sanitized = _sanitize_radio_probe_self_info(
{
"name": "FlightlessTestNode",
"adv_lat": 47.786445,
"adv_lon": -122.344011,
"radio_freq": 910.525,
}
)
assert sanitized == {
"name": "FlightlessTestNode",
"radio_freq": 910.525,
}
def test_support_snapshot_only_keeps_erroring_fanouts_in_health_summary(self):
"""Debug health summary should only include fanouts with non-empty last_error."""
from app.routers.debug import _build_debug_health_summary
from app.routers.health import FanoutStatusResponse
summary = _build_debug_health_summary(
{
"database_size_mb": 1.23,
"oldest_undecrypted_timestamp": 123,
"fanout_statuses": {
"ok-id": {
"name": "OK Fanout",
"type": "bot",
"status": "connected",
"last_error": None,
},
"err-id": {
"name": "Broken Fanout",
"type": "mqtt_private",
"status": "error",
"last_error": "broker down",
},
},
"bots_disabled_source": None,
"basic_auth_enabled": False,
},
radio_state="connected",
)
assert summary.fanouts_with_errors == {
"err-id": FanoutStatusResponse(
name="Broken Fanout",
type="mqtt_private",
status="error",
last_error="broker down",
)
}
@pytest.mark.asyncio
async def test_support_snapshot_returns_runtime_when_disconnected(self, test_db, client):
"""Debug snapshot should still return logs and runtime state when radio is disconnected."""
@@ -213,21 +157,8 @@ class TestDebugEndpoint:
assert response.status_code == 200
payload = response.json()
assert "app_info" not in payload["health"]
assert "bots_disabled" not in payload["health"]
assert "connection_info" not in payload["health"]
assert "fanout_statuses" not in payload["health"]
assert "radio_connected" not in payload["health"]
assert "radio_device_info" not in payload["health"]
assert "radio_initializing" not in payload["health"]
assert "status" not in payload["health"]
assert payload["health"]["fanouts_with_errors"] == {}
assert payload["health"]["radio_state"] == "disconnected"
assert payload["radio_probe"]["performed"] is False
assert payload["radio_probe"]["errors"] == ["Radio not connected"]
assert "multi_acks_enabled" not in payload["radio_probe"]
assert "max_channels" not in payload["runtime"]
assert "path_hash_mode" not in payload["runtime"]
assert payload["runtime"]["channels_with_incoming_messages"] == 0
assert payload["database"]["total_dms"] == 0
assert payload["database"]["total_channel_messages"] == 0
@@ -1167,14 +1098,7 @@ class TestRawPacketRepository:
await RawPacketRepository.create(b"\x04\x05\x06", recent_timestamp)
# Insert old but decrypted packet (should NOT be deleted)
old_id, _ = await RawPacketRepository.create(b"\x07\x08\x09", old_timestamp)
msg_id = await MessageRepository.create(
msg_type="PRIV",
conversation_key="test_key",
text="test",
sender_timestamp=old_timestamp,
received_at=old_timestamp,
)
await RawPacketRepository.mark_decrypted(old_id, msg_id)
await RawPacketRepository.mark_decrypted(old_id, 1)
# Prune packets older than 10 days
deleted = await RawPacketRepository.prune_old_undecrypted(10)
@@ -1198,24 +1122,10 @@ class TestRawPacketRepository:
async def test_purge_linked_to_messages_deletes_only_linked_packets(self, test_db):
"""Purge linked raw packets removes only rows with a message_id."""
ts = int(time.time())
msg_id_1 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k1",
text="t1",
sender_timestamp=ts,
received_at=ts,
)
msg_id_2 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k2",
text="t2",
sender_timestamp=ts,
received_at=ts,
)
linked_1, _ = await RawPacketRepository.create(b"\x01\x02\x03", ts)
linked_2, _ = await RawPacketRepository.create(b"\x04\x05\x06", ts)
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
await RawPacketRepository.mark_decrypted(linked_1, 101)
await RawPacketRepository.mark_decrypted(linked_2, 102)
await RawPacketRepository.create(b"\x07\x08\x09", ts) # undecrypted, should remain
@@ -1253,24 +1163,10 @@ class TestMaintenanceEndpoint:
from app.routers.packets import MaintenanceRequest, run_maintenance
ts = int(time.time())
msg_id_1 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k1",
text="t1",
sender_timestamp=ts,
received_at=ts,
)
msg_id_2 = await MessageRepository.create(
msg_type="PRIV",
conversation_key="k2",
text="t2",
sender_timestamp=ts,
received_at=ts,
)
linked_1, _ = await RawPacketRepository.create(b"\x0a\x0b\x0c", ts)
linked_2, _ = await RawPacketRepository.create(b"\x0d\x0e\x0f", ts)
await RawPacketRepository.mark_decrypted(linked_1, msg_id_1)
await RawPacketRepository.mark_decrypted(linked_2, msg_id_2)
await RawPacketRepository.mark_decrypted(linked_1, 201)
await RawPacketRepository.mark_decrypted(linked_2, 202)
request = MaintenanceRequest(purge_linked_raw_packets=True)
result = await run_maintenance(request)
+1 -51
View File
@@ -1,8 +1,7 @@
"""Tests for the channels router endpoints."""
import time
from hashlib import sha256
from unittest.mock import AsyncMock, patch
from unittest.mock import patch
import pytest
@@ -78,55 +77,6 @@ class TestCreateChannel:
assert channel is not None
assert channel.flood_scope_override is None
@pytest.mark.asyncio
async def test_bulk_hashtag_create_adds_only_new_rooms(self, test_db, client):
ops_key = sha256(b"#ops").digest()[:16].hex().upper()
await ChannelRepository.upsert(key=ops_key, name="#ops", is_hashtag=True)
response = await client.post(
"/api/channels/bulk-hashtag",
json={
"channel_names": ["#ops", "mesh-room", "bad_room", "mesh-room", "another-room"],
"try_historical": False,
},
)
assert response.status_code == 200
data = response.json()
assert [channel["name"] for channel in data["created_channels"]] == [
"#mesh-room",
"#another-room",
]
assert data["existing_count"] == 2
assert data["invalid_names"] == ["bad_room"]
assert data["decrypt_started"] is False
@pytest.mark.asyncio
async def test_bulk_hashtag_create_can_start_one_decrypt_job(self, test_db, client):
with (
patch(
"app.routers.channels.RawPacketRepository.get_undecrypted_count",
new=AsyncMock(return_value=7),
),
patch(
"app.routers.channels._run_historical_channel_decryption_for_channels",
new=AsyncMock(),
) as mock_decrypt,
):
response = await client.post(
"/api/channels/bulk-hashtag",
json={
"channel_names": ["ops", "mesh-room"],
"try_historical": True,
},
)
assert response.status_code == 202
data = response.json()
assert data["decrypt_started"] is True
assert data["decrypt_total_packets"] == 7
mock_decrypt.assert_awaited_once()
class TestPublicChannelProtection:
@pytest.mark.asyncio
+1 -1
View File
@@ -33,7 +33,7 @@ class TestTransportExclusivity:
def test_tcp_default_port(self):
s = Settings(tcp_host="192.168.1.1")
assert s.tcp_port == 5000
assert s.tcp_port == 4000
def test_ble_only(self):
s = Settings(ble_address="AA:BB:CC:DD:EE:FF", ble_pin="123456")
+17 -19
View File
@@ -513,9 +513,7 @@ class TestMigration018:
from hashlib import sha256
assert bytes(rows[0]["payload_hash"]) == sha256(b"hash_a").digest()
# message_id=42 was orphaned (no matching messages row), so
# migration 49's orphan cleanup NULLs it out.
assert rows[1]["message_id"] is None
assert rows[1]["message_id"] == 42
# Verify payload_hash unique index still works
cursor = await conn.execute(
@@ -1249,8 +1247,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 12
assert await get_version(conn) == 50
assert applied == 10
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1321,8 +1319,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 12
assert await get_version(conn) == 50
assert applied == 10
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1388,8 +1386,8 @@ class TestMigration039:
applied = await run_migrations(conn)
assert applied == 6
assert await get_version(conn) == 50
assert applied == 4
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1441,8 +1439,8 @@ class TestMigration040:
applied = await run_migrations(conn)
assert applied == 11
assert await get_version(conn) == 50
assert applied == 9
assert await get_version(conn) == 49
await conn.execute(
"""
@@ -1503,8 +1501,8 @@ class TestMigration041:
applied = await run_migrations(conn)
assert applied == 10
assert await get_version(conn) == 50
assert applied == 8
assert await get_version(conn) == 49
await conn.execute(
"""
@@ -1556,8 +1554,8 @@ class TestMigration042:
applied = await run_migrations(conn)
assert applied == 9
assert await get_version(conn) == 50
assert applied == 7
assert await get_version(conn) == 49
await conn.execute(
"""
@@ -1696,8 +1694,8 @@ class TestMigration046:
applied = await run_migrations(conn)
assert applied == 5
assert await get_version(conn) == 50
assert applied == 3
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
@@ -1790,8 +1788,8 @@ class TestMigration047:
applied = await run_migrations(conn)
assert applied == 4
assert await get_version(conn) == 50
assert applied == 2
assert await get_version(conn) == 49
cursor = await conn.execute(
"""
-15
View File
@@ -381,11 +381,6 @@ class TestDiscoverMesh:
new_callable=AsyncMock,
return_value=[],
),
patch(
"app.routers.radio.reconcile_contact_messages",
new_callable=AsyncMock,
return_value=(0, 0),
),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
@@ -459,11 +454,6 @@ class TestDiscoverMesh:
new_callable=AsyncMock,
return_value=[],
) as mock_promote,
patch(
"app.routers.radio.reconcile_contact_messages",
new_callable=AsyncMock,
return_value=(0, 0),
),
patch("app.routers.radio.broadcast_event") as mock_broadcast,
):
response = await discover_mesh(RadioDiscoveryRequest(target="repeaters"))
@@ -789,11 +779,6 @@ class TestTracePath:
new_callable=AsyncMock,
return_value=[],
),
patch(
"app.routers.radio.reconcile_contact_messages",
new_callable=AsyncMock,
return_value=(0, 0),
),
patch("app.routers.radio.broadcast_event"),
):
response = await discover_mesh(RadioDiscoveryRequest(target="all"))
-5
View File
@@ -884,11 +884,6 @@ class TestSyncAndOffloadContacts:
return task
with (
patch(
"app.radio_sync.promote_prefix_contacts_for_contact",
new_callable=AsyncMock,
return_value=[],
),
patch("app.radio_sync.reconcile_contact_messages", side_effect=_slow_reconcile),
patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task),
):
+8 -25
View File
@@ -137,31 +137,18 @@ class TestRepeaterTelemetryRepository:
assert history[0]["data"] == SAMPLE_STATUS
class TestTelemetryHistoryEndpoint:
"""Tests for the read-only GET telemetry-history endpoint."""
class TestTelemetryHistoryInStatusResponse:
"""Tests that history is embedded in the status response (no separate endpoint)."""
@pytest.mark.asyncio
async def test_returns_history_for_repeater(self, _db, client):
async def test_history_not_available_as_separate_endpoint(self, _db, client):
"""The old GET telemetry-history endpoint should be gone."""
await _insert_repeater(KEY_A)
now = int(time.time())
await RepeaterTelemetryRepository.record(KEY_A, now, SAMPLE_STATUS)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["data"]["battery_volts"] == 4.15
assert resp.status_code in (404, 405)
@pytest.mark.asyncio
async def test_returns_empty_list_when_no_history(self, _db, client):
await _insert_repeater(KEY_A)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_rejects_non_repeater(self, _db, client):
async def test_history_endpoint_non_repeater_rejected(self, _db, client):
await ContactRepository.upsert(
{
"public_key": KEY_A,
@@ -181,9 +168,5 @@ class TestTelemetryHistoryEndpoint:
}
)
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 400
@pytest.mark.asyncio
async def test_returns_404_for_unknown_contact(self, _db, client):
resp = await client.get(f"/api/contacts/{KEY_A}/repeater/telemetry-history")
assert resp.status_code == 404
# Either 404 (method not found) or 400 (not a repeater) — endpoint is gone
assert resp.status_code in (400, 404, 405)
Generated
+1 -1
View File
@@ -1098,7 +1098,7 @@ wheels = [
[[package]]
name = "remoteterm-meshcore"
version = "3.7.1"
version = "3.6.7"
source = { virtual = "." }
dependencies = [
{ name = "aiomqtt" },