mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-08 06:14:41 +02:00
Compare commits
35 Commits
92a88cae22
...
v2
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b3bd1da60 | |||
| 4de6d72cfe | |||
| 58af37238b | |||
| f135c90e61 | |||
| 90c1c90ba3 | |||
| 7d8a3c895d | |||
| 3c7f70175f | |||
| 7a44d3b95d | |||
| 885a967348 | |||
| 677036a831 | |||
| 7dbbba57b9 | |||
| d2e019fa0e | |||
| 9be7ae6cc4 | |||
| 5df9b4b4a2 | |||
| 292d1d91af | |||
| 054b80926d | |||
| 54be1796f8 | |||
| 71e00caa55 | |||
| 2e6f0d01d6 | |||
| ce88ec291f | |||
| c6eb2b1755 | |||
| 1e768e799b | |||
| 7b2f721d1d | |||
| 17b3c1c89c | |||
| 878d489661 | |||
| 0973d2d714 | |||
| 9ee63188d2 | |||
| 215515fe02 | |||
| 3e8eb00e3e | |||
| d54d8f58dd | |||
| 2c73e20775 | |||
| f9bcbabb86 | |||
| 5ccd882c5a | |||
| 2a9f90c01d | |||
| acfa5d3550 |
@@ -13,25 +13,26 @@ A lightweight web interface providing browser-based access to MeshCore mesh netw
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Mobile-first design** - Responsive UI optimized for small screens
|
||||
- **Mobile-first design** - Responsive UI optimized for small screens, with desktop sidebar for wide screens
|
||||
- **Channel management** - Create, join, share (QR code), and switch between encrypted channels
|
||||
- **Direct Messages (DM)** - Private messaging with searchable contact selector, delivery tracking, and configurable retry strategy
|
||||
- **Direct Messages (DM)** - Private messaging with searchable contact selector, delivery tracking, configurable retry strategy, and multi-path routing
|
||||
- **Smart notifications** - Unread message counters per channel with cross-device sync
|
||||
- **Contact management** - Manual approval mode, filtering, protection, ignoring, blocking, batch operations, and cleanup tools
|
||||
- **Contact management** - Manual approval, add via URI/QR, filtering, protection, ignoring, blocking, batch operations, and cleanup tools
|
||||
- **Global search** - Full-text search across all messages (channels and DMs) with FTS5 backend
|
||||
- **Database** - Fast and reliable SQLite storage for messages, contacts, and configurations
|
||||
- **Contact map** - View contacts and own device on OpenStreetMap (Leaflet) with last seen info
|
||||
- **Message archives** - Automatic daily archiving with browse-by-date selector
|
||||
- **Interactive Console** - Full MeshCore command suite via WebSocket — repeater, contact, device, and channel management
|
||||
- **Device dashboard** - Device info and statistics with firmware details
|
||||
- **Settings** - Configurable DM retry parameters, message retention, and quote length
|
||||
- **Device dashboard** - Device info, statistics, and contact sharing (QR code / URI)
|
||||
- **Dark/Light theme** - Toggle between dark and light UI themes
|
||||
- **Settings** - Configurable DM retry parameters, message retention, quote length, and theme
|
||||
- **System Log** - Real-time log viewer with streaming
|
||||
- **Database backup** - Create, list, and download database backups from the UI
|
||||
- **@Mentions autocomplete** - Type @ to see contact suggestions with fuzzy search
|
||||
- **Echo tracking** - "Heard X repeats" with repeater IDs for sent messages, all route paths for incoming messages with deterministic payload matching (persisted across restarts)
|
||||
- **MeshCore Analyzer** - View packet details on analyzer.letsmesh.net directly from channel messages
|
||||
- **DM delivery tracking** - ACK-based delivery confirmation with SNR and route info
|
||||
- **Multi-device support** - Database file named after device for easy multi-device setups
|
||||
- **Multi-device support** - Database file named after device public key for easy multi-device setups
|
||||
- **PWA support** - Browser notifications and installable app (experimental)
|
||||
- **Full offline support** - Works without internet (local Bootstrap, icons, emoji picker)
|
||||
|
||||
@@ -311,12 +312,12 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall
|
||||
- [x] Frontend Chat View (Bootstrap UI, message display, quote/reply)
|
||||
- [x] Message Sending (Send form, reply, quote with configurable length)
|
||||
- [x] Intelligent Auto-refresh (10s checks, UI updates only when needed)
|
||||
- [x] Contact Management (Approval, filtering, protection, ignore/block, batch operations, cleanup)
|
||||
- [x] Contact Management (Approval, add via URI/QR, filtering, protection, ignore/block, batch operations, cleanup)
|
||||
- [x] Channel Management (Create, join, share via QR, delete with auto-cleanup)
|
||||
- [x] Public Channels (# prefix support, auto-key generation)
|
||||
- [x] Message Archiving (Daily archiving with browse-by-date selector)
|
||||
- [x] Smart Notifications (Unread counters per channel and total)
|
||||
- [x] Direct Messages (DM) - Searchable contact selector, delivery tracking, configurable retry
|
||||
- [x] Direct Messages (DM) - Searchable contact selector, delivery tracking, configurable retry, multi-path routing
|
||||
- [x] Global Message Search - Full-text search across channels and DMs (FTS5)
|
||||
- [x] Message Content Enhancements - Mention badges, clickable URLs, image previews
|
||||
- [x] @Mentions Autocomplete - Type @ to get contact suggestions with fuzzy search
|
||||
@@ -327,11 +328,13 @@ sudo ~/mc-webui/scripts/updater/install.sh --uninstall
|
||||
- [x] Echo Tracking - "Heard X repeats" badge for sent channel messages
|
||||
- [x] MeshCore Analyzer - Packet analysis links on channel messages (analyzer.letsmesh.net)
|
||||
- [x] DM Delivery Tracking - ACK-based delivery checkmarks with SNR/route details
|
||||
- [x] Device Dashboard - Device info and statistics with firmware details
|
||||
- [x] Settings Modal - Configurable DM retry parameters and message retention
|
||||
- [x] Device Dashboard - Device info, statistics, and contact sharing (QR/URI)
|
||||
- [x] Settings Modal - DM retry parameters, message retention, and dark/light theme
|
||||
- [x] System Log - Real-time log viewer with streaming
|
||||
- [x] Database Backup - Create, list, and download backups from the UI
|
||||
- [x] Multi-device Support - Database file named after device name
|
||||
- [x] Desktop Sidebar - Channel/contact sidebar for wide screens (tablet/desktop)
|
||||
- [x] Dark/Light Theme - Toggle between dark and light UI themes
|
||||
- [x] Multi-device Support - Database file named after device public key
|
||||
|
||||
### Next Steps
|
||||
|
||||
@@ -365,7 +368,7 @@ This is an open-source project. Contributions are welcome!
|
||||
## References
|
||||
|
||||
- [MeshCore Documentation](https://meshcore.org)
|
||||
- [meshcore-cli GitHub](https://github.com/meshcore-dev/meshcore-cli)
|
||||
- [meshcore Python library](https://pypi.org/project/meshcore/)
|
||||
|
||||
---
|
||||
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ class Config:
|
||||
MC_ARCHIVE_RETENTION_DAYS = int(os.getenv('MC_ARCHIVE_RETENTION_DAYS', '7'))
|
||||
|
||||
# v2: Database
|
||||
MC_DB_PATH = os.getenv('MC_DB_PATH', '') # empty = auto: {MC_CONFIG_DIR}/{device_name}.db
|
||||
MC_DB_PATH = os.getenv('MC_DB_PATH', '') # empty = auto: {MC_CONFIG_DIR}/mc_{pubkey_prefix}.db
|
||||
|
||||
# v2: TCP connection (alternative to serial, e.g. meshcore-proxy)
|
||||
MC_TCP_HOST = os.getenv('MC_TCP_HOST', '') # empty = use serial
|
||||
|
||||
+36
-202
@@ -1,159 +1,59 @@
|
||||
"""
|
||||
Contacts Cache - Persistent storage of all known node names + public keys.
|
||||
Contacts Cache - DB-backed contact name/key lookup.
|
||||
|
||||
Stores every node name ever seen (from device contacts and adverts),
|
||||
so @mention autocomplete works even for removed contacts.
|
||||
All contact data is stored in the SQLite contacts table.
|
||||
JSONL files are no longer used.
|
||||
|
||||
File format: JSONL ({device_name}.contacts_cache.jsonl)
|
||||
Each line: {"public_key": "...", "name": "...", "first_seen": ts, "last_seen": ts,
|
||||
"source": "advert"|"device", "lat": float, "lon": float, "type_label": "COM"|"REP"|...}
|
||||
Kept for backward compatibility: get_all_names(), get_all_contacts(),
|
||||
parse_advert_payload().
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import struct
|
||||
import time
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
|
||||
from app.config import config, runtime_config
|
||||
from flask import current_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_cache_lock = Lock()
|
||||
_cache: dict = {} # {public_key: {name, first_seen, last_seen, source}}
|
||||
_cache_loaded = False
|
||||
_adverts_offset = 0 # File offset for incremental advert scanning
|
||||
_TYPE_LABELS = {0: 'COM', 1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
|
||||
|
||||
|
||||
def _get_cache_path() -> Path:
|
||||
device_name = runtime_config.get_device_name()
|
||||
return Path(config.MC_CONFIG_DIR) / f"{device_name}.contacts_cache.jsonl"
|
||||
|
||||
|
||||
def _get_adverts_path() -> Path:
|
||||
device_name = runtime_config.get_device_name()
|
||||
return Path(config.MC_CONFIG_DIR) / f"{device_name}.adverts.jsonl"
|
||||
|
||||
|
||||
def load_cache() -> dict:
|
||||
"""Load cache from disk into memory. Returns copy of cache dict."""
|
||||
global _cache, _cache_loaded
|
||||
|
||||
with _cache_lock:
|
||||
if _cache_loaded:
|
||||
return _cache.copy()
|
||||
|
||||
cache_path = _get_cache_path()
|
||||
_cache = {}
|
||||
|
||||
if not cache_path.exists():
|
||||
_cache_loaded = True
|
||||
logger.info("Contacts cache file does not exist yet")
|
||||
return _cache.copy()
|
||||
|
||||
try:
|
||||
with open(cache_path, 'r', encoding='utf-8') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
pk = entry.get('public_key', '').lower()
|
||||
if pk:
|
||||
# Migrate old "CLI" label to "COM"
|
||||
if entry.get('type_label') == 'CLI':
|
||||
entry['type_label'] = 'COM'
|
||||
_cache[pk] = entry
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
_cache_loaded = True
|
||||
logger.info(f"Loaded contacts cache: {len(_cache)} entries")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to load contacts cache: {e}")
|
||||
_cache_loaded = True
|
||||
|
||||
return _cache.copy()
|
||||
|
||||
|
||||
def save_cache() -> bool:
|
||||
"""Write full cache to disk (atomic write)."""
|
||||
with _cache_lock:
|
||||
cache_path = _get_cache_path()
|
||||
try:
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp_file = cache_path.with_suffix('.tmp')
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
for entry in _cache.values():
|
||||
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
|
||||
temp_file.replace(cache_path)
|
||||
logger.debug(f"Saved contacts cache: {len(_cache)} entries")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to save contacts cache: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def upsert_contact(public_key: str, name: str, source: str = "advert",
|
||||
lat: float = 0.0, lon: float = 0.0, type_label: str = "") -> bool:
|
||||
"""Add or update a contact in the cache. Returns True if cache was modified."""
|
||||
pk = public_key.lower()
|
||||
now = int(time.time())
|
||||
|
||||
with _cache_lock:
|
||||
existing = _cache.get(pk)
|
||||
if existing:
|
||||
changed = False
|
||||
if name and name != existing.get('name'):
|
||||
existing['name'] = name
|
||||
changed = True
|
||||
# Update lat/lon if new values are non-zero
|
||||
if lat != 0.0 or lon != 0.0:
|
||||
if lat != existing.get('lat') or lon != existing.get('lon'):
|
||||
existing['lat'] = lat
|
||||
existing['lon'] = lon
|
||||
changed = True
|
||||
# Update type_label if provided and not already set
|
||||
if type_label and type_label != existing.get('type_label'):
|
||||
existing['type_label'] = type_label
|
||||
changed = True
|
||||
existing['last_seen'] = now
|
||||
return changed
|
||||
else:
|
||||
if not name:
|
||||
return False
|
||||
entry = {
|
||||
'public_key': pk,
|
||||
'name': name,
|
||||
'first_seen': now,
|
||||
'last_seen': now,
|
||||
'source': source,
|
||||
}
|
||||
if lat != 0.0 or lon != 0.0:
|
||||
entry['lat'] = lat
|
||||
entry['lon'] = lon
|
||||
if type_label:
|
||||
entry['type_label'] = type_label
|
||||
_cache[pk] = entry
|
||||
return True
|
||||
def _get_db():
|
||||
"""Get database instance from Flask app context."""
|
||||
return getattr(current_app, 'db', None)
|
||||
|
||||
|
||||
def get_all_contacts() -> list:
|
||||
"""Get all cached contacts as a list of dicts (shallow copies)."""
|
||||
with _cache_lock:
|
||||
return [entry.copy() for entry in _cache.values()]
|
||||
"""Get all known contacts from DB."""
|
||||
try:
|
||||
db = _get_db()
|
||||
if db:
|
||||
contacts = db.get_contacts()
|
||||
return [{
|
||||
'public_key': c.get('public_key', ''),
|
||||
'name': c.get('name', ''),
|
||||
'first_seen': c.get('first_seen', ''),
|
||||
'last_seen': c.get('last_seen', ''),
|
||||
'source': c.get('source', ''),
|
||||
'lat': c.get('adv_lat', 0.0) or 0.0,
|
||||
'lon': c.get('adv_lon', 0.0) or 0.0,
|
||||
'type_label': _TYPE_LABELS.get(c.get('type', 1), 'UNKNOWN'),
|
||||
} for c in contacts]
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get contacts: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_all_names() -> list:
|
||||
"""Get all unique non-empty contact names sorted alphabetically."""
|
||||
with _cache_lock:
|
||||
return sorted(set(
|
||||
entry['name'] for entry in _cache.values()
|
||||
if entry.get('name')
|
||||
))
|
||||
try:
|
||||
db = _get_db()
|
||||
if db:
|
||||
contacts = db.get_contacts()
|
||||
return sorted(set(c.get('name', '') for c in contacts if c.get('name')))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get contact names: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def parse_advert_payload(pkt_payload_hex: str):
|
||||
@@ -208,69 +108,3 @@ def parse_advert_payload(pkt_payload_hex: str):
|
||||
return public_key, node_name if node_name else None, lat, lon
|
||||
except Exception:
|
||||
return None, None, 0.0, 0.0
|
||||
|
||||
|
||||
def scan_new_adverts() -> int:
|
||||
"""
|
||||
Scan .adverts.jsonl for new entries since last scan.
|
||||
Returns number of new/updated contacts.
|
||||
"""
|
||||
global _adverts_offset
|
||||
|
||||
adverts_path = _get_adverts_path()
|
||||
if not adverts_path.exists():
|
||||
return 0
|
||||
|
||||
updated = 0
|
||||
try:
|
||||
with open(adverts_path, 'r', encoding='utf-8') as f:
|
||||
f.seek(_adverts_offset)
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
advert = json.loads(line)
|
||||
pkt_payload = advert.get('pkt_payload', '')
|
||||
if not pkt_payload:
|
||||
continue
|
||||
pk, name, lat, lon = parse_advert_payload(pkt_payload)
|
||||
if pk and name:
|
||||
if upsert_contact(pk, name, source="advert", lat=lat, lon=lon):
|
||||
updated += 1
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
_adverts_offset = f.tell()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to scan adverts: {e}")
|
||||
|
||||
if updated > 0:
|
||||
save_cache()
|
||||
logger.info(f"Contacts cache updated: {updated} new/changed entries")
|
||||
|
||||
return updated
|
||||
|
||||
|
||||
_TYPE_LABELS = {1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'}
|
||||
|
||||
|
||||
def initialize_from_device(contacts_detailed: dict):
|
||||
"""
|
||||
Seed cache from /api/contacts/detailed response dict.
|
||||
Called once at startup if cache file doesn't exist.
|
||||
|
||||
Args:
|
||||
contacts_detailed: dict of {public_key: {adv_name, type, adv_lat, adv_lon, ...}} from meshcli
|
||||
"""
|
||||
added = 0
|
||||
for pk, details in contacts_detailed.items():
|
||||
name = details.get('adv_name', '')
|
||||
lat = details.get('adv_lat', 0.0) or 0.0
|
||||
lon = details.get('adv_lon', 0.0) or 0.0
|
||||
type_label = _TYPE_LABELS.get(details.get('type'), '')
|
||||
if upsert_contact(pk, name, source="device", lat=lat, lon=lon, type_label=type_label):
|
||||
added += 1
|
||||
|
||||
if added > 0:
|
||||
save_cache()
|
||||
logger.info(f"Initialized contacts cache from device: {added} contacts")
|
||||
|
||||
+61
-3
@@ -44,6 +44,18 @@ class Database:
|
||||
conn.execute("ALTER TABLE contacts ADD COLUMN no_auto_flood INTEGER DEFAULT 0")
|
||||
logger.info("Migration: added contacts.no_auto_flood column")
|
||||
|
||||
# Add delivery tracking columns to direct_messages
|
||||
dm_columns = {r[1] for r in conn.execute("PRAGMA table_info(direct_messages)").fetchall()}
|
||||
for col, typedef in [
|
||||
('delivery_status', 'TEXT'),
|
||||
('delivery_attempt', 'INTEGER'),
|
||||
('delivery_max_attempts', 'INTEGER'),
|
||||
('delivery_path', 'TEXT'),
|
||||
]:
|
||||
if col not in dm_columns:
|
||||
conn.execute(f"ALTER TABLE direct_messages ADD COLUMN {col} {typedef}")
|
||||
logger.info(f"Migration: added direct_messages.{col} column")
|
||||
|
||||
@contextmanager
|
||||
def _connect(self):
|
||||
"""Yield a connection with auto-commit/rollback."""
|
||||
@@ -80,6 +92,12 @@ class Database:
|
||||
row = conn.execute("SELECT * FROM device WHERE id = 1").fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def get_public_key(self) -> Optional[str]:
|
||||
"""Get device public key (used for DB filename resolution)."""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT public_key FROM device WHERE id = 1").fetchone()
|
||||
return row['public_key'] if row and row['public_key'] else None
|
||||
|
||||
# ================================================================
|
||||
# Contacts
|
||||
# ================================================================
|
||||
@@ -654,6 +672,37 @@ class Database:
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def update_dm_delivery_info(self, dm_id: int, attempt: int,
|
||||
max_attempts: int, path: str):
|
||||
"""Store successful delivery details (attempt number, path used)."""
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE direct_messages SET delivery_attempt=?, "
|
||||
"delivery_max_attempts=?, delivery_path=? WHERE id=?",
|
||||
(attempt, max_attempts, path, dm_id))
|
||||
|
||||
def update_dm_delivery_status(self, dm_id: int, status: str):
|
||||
"""Mark message delivery as failed."""
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE direct_messages SET delivery_status=? WHERE id=?",
|
||||
(status, dm_id))
|
||||
|
||||
def get_recent_delivered_dm_with_empty_path(self, contact_pubkey: str) -> Optional[Dict]:
|
||||
"""Find most recent delivered outgoing DM with empty delivery_path."""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT id, delivery_attempt, delivery_max_attempts
|
||||
FROM direct_messages
|
||||
WHERE contact_pubkey=? AND direction='out'
|
||||
AND (delivery_path IS NULL OR delivery_path='')
|
||||
AND delivery_status IS NULL
|
||||
AND delivery_attempt IS NOT NULL
|
||||
ORDER BY id DESC LIMIT 1""",
|
||||
(contact_pubkey,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def relink_orphaned_dms(self, public_key: str, name: str = '') -> int:
|
||||
"""Re-link DMs with NULL contact_pubkey back to this contact.
|
||||
|
||||
@@ -963,6 +1012,14 @@ class Database:
|
||||
(key, 1 if muted else 0)
|
||||
)
|
||||
|
||||
def get_muted_channels(self) -> List[int]:
|
||||
"""Get list of muted channel indices."""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT key FROM read_status WHERE is_muted = 1 AND key LIKE 'chan_%'"
|
||||
).fetchall()
|
||||
return [int(r['key'][5:]) for r in rows]
|
||||
|
||||
# ================================================================
|
||||
# Full-Text Search
|
||||
# ================================================================
|
||||
@@ -1050,7 +1107,8 @@ class Database:
|
||||
backup_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
date_str = datetime.now().strftime('%Y-%m-%d')
|
||||
backup_path = backup_dir / f"mc-webui.{date_str}.db"
|
||||
prefix = self.db_path.stem # e.g. "mc_9cebbd27"
|
||||
backup_path = backup_dir / f"{prefix}.{date_str}.db"
|
||||
|
||||
source = sqlite3.connect(str(self.db_path))
|
||||
dest = sqlite3.connect(str(backup_path))
|
||||
@@ -1070,7 +1128,7 @@ class Database:
|
||||
return []
|
||||
|
||||
backups = []
|
||||
for f in sorted(backup_dir.glob("mc-webui.*.db"), reverse=True):
|
||||
for f in sorted(backup_dir.glob("*.db"), reverse=True):
|
||||
backups.append({
|
||||
'filename': f.name,
|
||||
'path': str(f),
|
||||
@@ -1087,7 +1145,7 @@ class Database:
|
||||
|
||||
cutoff = datetime.now() - timedelta(days=retention_days)
|
||||
removed = 0
|
||||
for f in backup_dir.glob("mc-webui.*.db"):
|
||||
for f in backup_dir.glob("*.db"):
|
||||
if datetime.fromtimestamp(f.stat().st_mtime) < cutoff:
|
||||
f.unlink()
|
||||
removed += 1
|
||||
|
||||
+517
-99
@@ -12,7 +12,8 @@ import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from typing import Optional, Any, Dict, List
|
||||
from typing import Optional, Any, Dict, List, Tuple
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
ANALYZER_BASE_URL = 'https://analyzer.letsmesh.net/packets?packet_hash='
|
||||
GRP_TXT_TYPE_BYTE = 0x05
|
||||
@@ -30,6 +31,45 @@ def _to_str(val) -> str:
|
||||
return str(val)
|
||||
|
||||
|
||||
def parse_meshcore_uri(uri: str) -> Optional[Dict]:
|
||||
"""Parse meshcore://contact/add?name=...&public_key=...&type=... URI.
|
||||
|
||||
Returns dict with 'name', 'public_key', 'type' keys, or None if not a valid mobile-app URI.
|
||||
"""
|
||||
if not uri or not uri.startswith('meshcore://'):
|
||||
return None
|
||||
|
||||
try:
|
||||
# urlparse needs a scheme it recognizes; meshcore:// works fine
|
||||
parsed = urlparse(uri)
|
||||
if parsed.netloc != 'contact' or parsed.path != '/add':
|
||||
return None
|
||||
|
||||
params = parse_qs(parsed.query)
|
||||
public_key = params.get('public_key', [None])[0]
|
||||
name = params.get('name', [None])[0]
|
||||
|
||||
if not public_key or not name:
|
||||
return None
|
||||
|
||||
# Validate public_key: 64 hex characters
|
||||
public_key = public_key.strip().lower()
|
||||
if len(public_key) != 64:
|
||||
return None
|
||||
bytes.fromhex(public_key) # validate hex
|
||||
|
||||
contact_type = int(params.get('type', ['1'])[0])
|
||||
if contact_type not in (1, 2, 3, 4):
|
||||
contact_type = 1
|
||||
|
||||
return {
|
||||
'name': name.strip(),
|
||||
'public_key': public_key,
|
||||
'type': contact_type,
|
||||
}
|
||||
except (ValueError, IndexError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
class DeviceManager:
|
||||
"""
|
||||
@@ -59,6 +99,7 @@ class DeviceManager:
|
||||
self._echo_lock = threading.Lock()
|
||||
self._pending_acks = {} # {ack_code_hex: dm_id} — maps retry acks to DM
|
||||
self._retry_tasks = {} # {dm_id: asyncio.Task} — active retry coroutines
|
||||
self._retry_context = {} # {dm_id: {attempt, max_attempts, path}} — for _on_ack
|
||||
|
||||
@property
|
||||
def is_connected(self) -> bool:
|
||||
@@ -570,8 +611,28 @@ class DeviceManager:
|
||||
'route_type': data.get('route_type', ''),
|
||||
}, namespace='/chat')
|
||||
|
||||
# Cancel retry task if ACK confirms delivery for a pending DM
|
||||
# Store delivery info and cancel retry task
|
||||
if dm_id:
|
||||
# Store delivery info from retry context (before cancel races)
|
||||
ctx = self._retry_context.pop(dm_id, None)
|
||||
if ctx:
|
||||
self.db.update_dm_delivery_info(
|
||||
dm_id, ctx['attempt'], ctx['max_attempts'], ctx['path'])
|
||||
if self.socketio:
|
||||
self.socketio.emit('dm_delivered_info', {
|
||||
'dm_id': dm_id,
|
||||
'attempt': ctx['attempt'],
|
||||
'max_attempts': ctx['max_attempts'],
|
||||
'path': ctx['path'],
|
||||
}, namespace='/chat')
|
||||
# If path is empty (FLOOD delivery), schedule delayed read from device
|
||||
if not ctx['path']:
|
||||
dm_rec = self.db.get_dm_by_id(dm_id)
|
||||
contact_pk = dm_rec.get('contact_pubkey', '') if dm_rec else ''
|
||||
if contact_pk:
|
||||
asyncio.ensure_future(
|
||||
self._delayed_path_backfill(dm_id, contact_pk))
|
||||
|
||||
task = self._retry_tasks.get(dm_id)
|
||||
if task and not task.done():
|
||||
task.cancel()
|
||||
@@ -700,6 +761,19 @@ class DeviceManager:
|
||||
)
|
||||
logger.debug(f"Path update for {pubkey[:8]}...")
|
||||
|
||||
# Invalidate contacts cache so UI gets fresh path data
|
||||
try:
|
||||
from app.routes.api import invalidate_contacts_cache
|
||||
invalidate_contacts_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Notify UI about path change
|
||||
if self.socketio:
|
||||
self.socketio.emit('path_changed', {
|
||||
'public_key': pubkey,
|
||||
}, namespace='/chat')
|
||||
|
||||
# Backup: check for pending DM to this contact
|
||||
for ack_code, dm_id in list(self._pending_acks.items()):
|
||||
dm = self.db.get_dm_by_id(dm_id)
|
||||
@@ -718,6 +792,23 @@ class DeviceManager:
|
||||
'dm_id': dm_id,
|
||||
'route_type': 'PATH_FLOOD',
|
||||
}, namespace='/chat')
|
||||
# Store delivery info — use path from PATH event (actual discovered route)
|
||||
ctx = self._retry_context.pop(dm_id, None)
|
||||
discovered_path = data.get('path', '')
|
||||
if ctx:
|
||||
self.db.update_dm_delivery_info(
|
||||
dm_id, ctx['attempt'], ctx['max_attempts'], discovered_path)
|
||||
if self.socketio:
|
||||
self.socketio.emit('dm_delivered_info', {
|
||||
'dm_id': dm_id,
|
||||
'attempt': ctx['attempt'],
|
||||
'max_attempts': ctx['max_attempts'],
|
||||
'path': discovered_path,
|
||||
}, namespace='/chat')
|
||||
# If path still empty, schedule delayed read from device contacts
|
||||
if not discovered_path and pubkey:
|
||||
asyncio.ensure_future(
|
||||
self._delayed_path_backfill(dm_id, pubkey))
|
||||
# Cancel retry task — delivery already confirmed
|
||||
task = self._retry_tasks.get(dm_id)
|
||||
if task and not task.done():
|
||||
@@ -729,6 +820,32 @@ class DeviceManager:
|
||||
self._retry_tasks.pop(dm_id, None)
|
||||
break # Only confirm the most recent pending DM to this contact
|
||||
|
||||
# Update delivery_path for recently-delivered DMs where _on_ack
|
||||
# stored empty path (FLOOD mode) before PATH_UPDATE could provide it
|
||||
discovered_path = data.get('path', '')
|
||||
if pubkey:
|
||||
if discovered_path:
|
||||
recent = self.db.get_recent_delivered_dm_with_empty_path(pubkey)
|
||||
if recent:
|
||||
self.db.update_dm_delivery_info(
|
||||
recent['id'], recent['delivery_attempt'],
|
||||
recent['delivery_max_attempts'], discovered_path)
|
||||
if self.socketio:
|
||||
self.socketio.emit('dm_delivered_info', {
|
||||
'dm_id': recent['id'],
|
||||
'attempt': recent['delivery_attempt'],
|
||||
'max_attempts': recent['delivery_max_attempts'],
|
||||
'path': discovered_path,
|
||||
}, namespace='/chat')
|
||||
logger.debug(f"Updated delivery path for dm_id={recent['id']} "
|
||||
f"with discovered path {discovered_path[:16]}")
|
||||
else:
|
||||
# PATH event had no path data — schedule delayed read from device
|
||||
recent = self.db.get_recent_delivered_dm_with_empty_path(pubkey)
|
||||
if recent:
|
||||
asyncio.ensure_future(
|
||||
self._delayed_path_backfill(recent['id'], pubkey))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error handling path update: {e}")
|
||||
|
||||
@@ -1121,6 +1238,12 @@ class DeviceManager:
|
||||
"""Change contact path on device with proper hash_size encoding."""
|
||||
path_hash_mode = hash_size - 1 # 0=1B, 1=2B, 2=3B
|
||||
await self.mc.commands.change_contact_path(contact, path_hex, path_hash_mode=path_hash_mode)
|
||||
# Invalidate contacts cache so UI gets fresh path data
|
||||
try:
|
||||
from app.routes.api import invalidate_contacts_cache
|
||||
invalidate_contacts_cache()
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
async def _restore_primary_path(self, contact, contact_pubkey: str):
|
||||
"""Restore the primary configured path on the device after retry exhaustion."""
|
||||
@@ -1174,22 +1297,105 @@ class DeviceManager:
|
||||
|
||||
return False
|
||||
|
||||
def _emit_retry_status(self, dm_id: int, expected_ack: str,
|
||||
attempt: int, max_attempts: int):
|
||||
"""Notify frontend about retry progress."""
|
||||
if self.socketio:
|
||||
self.socketio.emit('dm_retry_status', {
|
||||
'dm_id': dm_id,
|
||||
'expected_ack': expected_ack,
|
||||
'attempt': attempt,
|
||||
'max_attempts': max_attempts,
|
||||
}, namespace='/chat')
|
||||
|
||||
def _emit_retry_failed(self, dm_id: int, expected_ack: str):
|
||||
"""Notify frontend that all retry attempts were exhausted."""
|
||||
if self.socketio:
|
||||
self.socketio.emit('dm_retry_failed', {
|
||||
'dm_id': dm_id,
|
||||
'expected_ack': expected_ack,
|
||||
}, namespace='/chat')
|
||||
|
||||
@staticmethod
|
||||
def _paths_match(contact_out_path: str, contact_out_path_len: int,
|
||||
configured_path: dict) -> bool:
|
||||
"""Check if device's current path matches a configured path."""
|
||||
if contact_out_path_len <= 0:
|
||||
return False
|
||||
cfg_hash_size = configured_path['hash_size']
|
||||
device_hash_size = (contact_out_path_len >> 6) + 1
|
||||
if device_hash_size != cfg_hash_size:
|
||||
return False
|
||||
hop_count = contact_out_path_len & 0x3F
|
||||
meaningful_len = hop_count * device_hash_size * 2
|
||||
return (contact_out_path.lower()[:meaningful_len] ==
|
||||
configured_path['path_hex'].lower()[:meaningful_len])
|
||||
|
||||
@staticmethod
|
||||
def _extract_path_hex(out_path: str, out_path_len: int) -> str:
|
||||
"""Extract meaningful hex portion from a device contact path."""
|
||||
if out_path_len <= 0 or not out_path:
|
||||
return ''
|
||||
hop_count = out_path_len & 0x3F
|
||||
hash_size = (out_path_len >> 6) + 1
|
||||
meaningful_len = hop_count * hash_size * 2
|
||||
return out_path[:meaningful_len].lower() if meaningful_len > 0 else ''
|
||||
|
||||
async def _delayed_path_backfill(self, dm_id: int, pubkey: str, delay: float = 3.0):
|
||||
"""After a FLOOD delivery with empty path, wait and read the contact's updated path."""
|
||||
try:
|
||||
await asyncio.sleep(delay)
|
||||
if not self.mc or not self.mc.contacts:
|
||||
return
|
||||
contact = self.mc.contacts.get(pubkey)
|
||||
if not contact:
|
||||
return
|
||||
out_path = contact.get('out_path', '')
|
||||
out_path_len = contact.get('out_path_len', -1)
|
||||
path_hex = self._extract_path_hex(out_path, out_path_len)
|
||||
if not path_hex:
|
||||
logger.debug(f"Delayed path backfill: still no path for dm_id={dm_id}")
|
||||
return
|
||||
# Check DB — only update if delivery_path is still empty
|
||||
dm = self.db.get_dm_by_id(dm_id)
|
||||
if not dm or dm.get('delivery_path'):
|
||||
return # already has a path, skip
|
||||
self.db.update_dm_delivery_info(
|
||||
dm_id,
|
||||
dm.get('delivery_attempt') or 1,
|
||||
dm.get('delivery_max_attempts') or 1,
|
||||
path_hex)
|
||||
if self.socketio:
|
||||
self.socketio.emit('dm_delivered_info', {
|
||||
'dm_id': dm_id,
|
||||
'attempt': dm.get('delivery_attempt') or 1,
|
||||
'max_attempts': dm.get('delivery_max_attempts') or 1,
|
||||
'path': path_hex,
|
||||
}, namespace='/chat')
|
||||
logger.info(f"Delayed path backfill: updated dm_id={dm_id} with path {path_hex[:16]}")
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.debug(f"Delayed path backfill failed for dm_id={dm_id}: {e}")
|
||||
|
||||
async def _dm_retry_task(self, dm_id: int, contact, text: str,
|
||||
timestamp: int, initial_ack: str,
|
||||
suggested_timeout: int):
|
||||
"""Background retry with same timestamp for dedup on receiver.
|
||||
|
||||
Strategy (in priority order):
|
||||
1. PATH ROTATION: If user-configured paths exist, rotate through them.
|
||||
2. DIRECT+FLOOD: If contact has device path, try direct then optionally flood.
|
||||
3. FLOOD only: If no path known, flood retries.
|
||||
4-scenario matrix based on (has_path × has_configured_paths):
|
||||
- Scenario 1: No path, no configured paths → FLOOD only
|
||||
- Scenario 2: Has path, no configured paths → DIRECT + optional FLOOD
|
||||
- Scenario 3: No path, has configured paths → FLOOD first, then configured path rotation
|
||||
- Scenario 4: Has path, has configured paths → DIRECT on current path, configured path rotation, optional FLOOD
|
||||
|
||||
The no_auto_flood per-contact flag prevents automatic DIRECT→FLOOD reset.
|
||||
The no_auto_flood per-contact flag prevents automatic DIRECT→FLOOD reset
|
||||
in Scenarios 2 and 4. Ignored in Scenarios 1 and 3.
|
||||
Settings loaded from app_settings DB table (key: dm_retry_settings).
|
||||
"""
|
||||
from meshcore.events import EventType
|
||||
|
||||
# Load configurable retry settings from DB
|
||||
# ── Load configurable retry settings from DB ──
|
||||
_defaults = {
|
||||
'direct_max_retries': 3, 'direct_flood_retries': 1,
|
||||
'flood_max_retries': 3, 'direct_interval': 30,
|
||||
@@ -1201,19 +1407,91 @@ class DeviceManager:
|
||||
contact_pubkey = contact.get('public_key', '').lower()
|
||||
has_path = contact.get('out_path_len', -1) > 0
|
||||
|
||||
# Capture original device path for dedup (contact dict may mutate)
|
||||
original_out_path = contact.get('out_path', '').lower()
|
||||
original_out_path_len = contact.get('out_path_len', -1)
|
||||
|
||||
# Load user-configured paths and no_auto_flood flag
|
||||
configured_paths = self.db.get_contact_paths(contact_pubkey) if contact_pubkey else []
|
||||
no_auto_flood = self.db.get_contact_no_auto_flood(contact_pubkey) if contact_pubkey else False
|
||||
has_configured_paths = bool(configured_paths)
|
||||
|
||||
min_wait = float(cfg['direct_interval']) if has_path else float(cfg['flood_interval'])
|
||||
wait_s = max(suggested_timeout / 1000 * 1.2, min_wait)
|
||||
|
||||
mode = "PATH_ROTATION" if configured_paths else ("DIRECT" if has_path else "FLOOD")
|
||||
logger.info(f"DM retry task started: dm_id={dm_id}, mode={mode}, "
|
||||
f"configured_paths={len(configured_paths)}, no_auto_flood={no_auto_flood}, "
|
||||
f"wait={wait_s:.0f}s")
|
||||
# Determine scenario for logging
|
||||
if has_path and has_configured_paths:
|
||||
scenario = "S4_DIRECT_SD_FLOOD"
|
||||
elif has_path:
|
||||
scenario = "S2_DIRECT_FLOOD"
|
||||
elif has_configured_paths:
|
||||
scenario = "S3_FLOOD_SD"
|
||||
else:
|
||||
scenario = "S1_FLOOD"
|
||||
|
||||
# Wait for ACK on initial send
|
||||
# ── Pre-compute path split and max_attempts ──
|
||||
def _split_primary_and_others(paths):
|
||||
primary = None
|
||||
others = []
|
||||
for p in paths:
|
||||
if p.get('is_primary') and primary is None:
|
||||
primary = p
|
||||
else:
|
||||
others.append(p)
|
||||
return primary, others
|
||||
|
||||
primary_path = None
|
||||
other_paths = []
|
||||
rotation_order = []
|
||||
if has_configured_paths:
|
||||
primary_path, other_paths = _split_primary_and_others(configured_paths)
|
||||
rotation_order = ([primary_path] if primary_path else []) + other_paths
|
||||
|
||||
retries_per_path = max(1, cfg['direct_max_retries'])
|
||||
|
||||
if scenario == "S1_FLOOD":
|
||||
max_attempts = 1 + cfg['flood_max_retries']
|
||||
elif scenario == "S2_DIRECT_FLOOD":
|
||||
max_attempts = 1 + cfg['direct_max_retries']
|
||||
if not no_auto_flood:
|
||||
max_attempts += cfg['direct_flood_retries']
|
||||
elif scenario == "S3_FLOOD_SD":
|
||||
max_attempts = (1 + cfg['flood_max_retries']
|
||||
+ len(rotation_order) * retries_per_path)
|
||||
else: # S4
|
||||
deduped = sum(1 for p in rotation_order
|
||||
if self._paths_match(original_out_path, original_out_path_len, p))
|
||||
effective_sd = len(rotation_order) - deduped
|
||||
max_attempts = 1 + cfg['direct_max_retries'] + effective_sd * retries_per_path
|
||||
if not no_auto_flood:
|
||||
max_attempts += cfg['flood_max_retries']
|
||||
|
||||
# Track current path hex for delivery info (actual route, not label)
|
||||
path_desc = self._extract_path_hex(original_out_path, original_out_path_len) if has_path else ''
|
||||
|
||||
logger.info(f"DM retry task started: dm_id={dm_id}, scenario={scenario}, "
|
||||
f"configured_paths={len(configured_paths)}, no_auto_flood={no_auto_flood}, "
|
||||
f"max_attempts={max_attempts}, wait={wait_s:.0f}s")
|
||||
|
||||
# ── Local helper: update context, emit status, send ──
|
||||
# Delivery info is stored by _on_ack() using _retry_context (avoids cancel race)
|
||||
async def _retry(attempt_num, min_wait_s):
|
||||
display = attempt_num + 1 # attempt 0 = initial send = display 1
|
||||
self._retry_context[dm_id] = {
|
||||
'attempt': display, 'max_attempts': max_attempts, 'path': path_desc,
|
||||
}
|
||||
self._emit_retry_status(dm_id, initial_ack, display, max_attempts)
|
||||
return await self._dm_retry_send_and_wait(
|
||||
contact, text, timestamp, attempt_num, dm_id,
|
||||
suggested_timeout, min_wait_s
|
||||
)
|
||||
|
||||
# ── Wait for initial ACK (attempt 1) ──
|
||||
# Delivery info stored by _on_ack() via _retry_context (avoids cancel race)
|
||||
self._retry_context[dm_id] = {
|
||||
'attempt': 1, 'max_attempts': max_attempts, 'path': path_desc,
|
||||
}
|
||||
self._emit_retry_status(dm_id, initial_ack, 1, max_attempts)
|
||||
if initial_ack:
|
||||
logger.debug(f"DM retry: waiting {wait_s:.0f}s for initial ACK {initial_ack[:8]}...")
|
||||
ack_event = await self.mc.dispatcher.wait_for_event(
|
||||
@@ -1228,118 +1506,133 @@ class DeviceManager:
|
||||
|
||||
attempt = 0 # Global attempt counter (0 = initial send already done)
|
||||
|
||||
# ── Strategy 1: PATH ROTATION ──
|
||||
if configured_paths:
|
||||
retries_per_path = max(1, cfg['direct_max_retries'])
|
||||
min_wait = float(cfg['direct_interval'])
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Scenario 1: No path, no configured paths → FLOOD only
|
||||
# ════════════════════════════════════════════════════════════
|
||||
if not has_path and not has_configured_paths:
|
||||
for _ in range(cfg['flood_max_retries']):
|
||||
attempt += 1
|
||||
if await _retry(attempt, float(cfg['flood_interval'])):
|
||||
return
|
||||
|
||||
# Separate primary (starred) path from the rest
|
||||
primary_path = None
|
||||
other_paths = []
|
||||
for p in configured_paths:
|
||||
if p.get('is_primary') and primary_path is None:
|
||||
primary_path = p
|
||||
else:
|
||||
other_paths.append(p)
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Scenario 2: Has path, no configured paths → DIRECT + optional FLOOD
|
||||
# ════════════════════════════════════════════════════════════
|
||||
elif has_path and not has_configured_paths:
|
||||
# Phase 1: Direct retries on current path
|
||||
for _ in range(cfg['direct_max_retries']):
|
||||
attempt += 1
|
||||
if await _retry(attempt, float(cfg['direct_interval'])):
|
||||
return
|
||||
|
||||
# Phase 1: Exhaust retries on primary path first
|
||||
# Initial send already used device path (assumed primary), so -1
|
||||
if primary_path:
|
||||
# Phase 2: Optional FLOOD fallback (controlled by no_auto_flood)
|
||||
if not no_auto_flood:
|
||||
try:
|
||||
await self._change_path_async(contact, primary_path['path_hex'], primary_path['hash_size'])
|
||||
logger.info(f"DM retry: retrying on primary path '{primary_path.get('label', '')}' "
|
||||
f"({primary_path['path_hex']})")
|
||||
except Exception as e:
|
||||
logger.warning(f"DM retry: failed to set primary path: {e}")
|
||||
|
||||
for _ in range(retries_per_path - 1):
|
||||
await self.mc.commands.reset_path(contact)
|
||||
logger.info("DM retry: direct exhausted, resetting to FLOOD")
|
||||
except Exception:
|
||||
pass
|
||||
path_desc = ''
|
||||
for _ in range(cfg['direct_flood_retries']):
|
||||
attempt += 1
|
||||
if await self._dm_retry_send_and_wait(
|
||||
contact, text, timestamp, attempt, dm_id,
|
||||
suggested_timeout, min_wait
|
||||
):
|
||||
return # Delivered on primary, no restore needed
|
||||
if await _retry(attempt, float(cfg['flood_interval'])):
|
||||
return
|
||||
|
||||
# Phase 2: Rotate through remaining (non-primary) paths
|
||||
for path_info in other_paths:
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Scenario 3: No path, has configured paths → FLOOD first, then configured path rotation
|
||||
# ════════════════════════════════════════════════════════════
|
||||
elif not has_path and has_configured_paths:
|
||||
# Phase 1: FLOOD retries per NoPath settings (discover new path)
|
||||
logger.info("DM retry: FLOOD first to discover new path")
|
||||
for _ in range(cfg['flood_max_retries']):
|
||||
attempt += 1
|
||||
if await _retry(attempt, float(cfg['flood_interval'])):
|
||||
return # Firmware sets discovered path automatically
|
||||
|
||||
# Phase 2: Configured path rotation (primary first, then others by sort_order)
|
||||
logger.info("DM retry: FLOOD exhausted, rotating through configured paths")
|
||||
direct_interval = float(cfg['direct_interval'])
|
||||
|
||||
for path_info in rotation_order:
|
||||
try:
|
||||
await self._change_path_async(contact, path_info['path_hex'], path_info['hash_size'])
|
||||
logger.info(f"DM retry: switched to path '{path_info.get('label', '')}' "
|
||||
f"({path_info['path_hex']})")
|
||||
label = path_info.get('label', '')
|
||||
path_desc = path_info['path_hex']
|
||||
logger.info(f"DM retry: switched to path '{label}' ({path_info['path_hex']})")
|
||||
except Exception as e:
|
||||
logger.warning(f"DM retry: failed to switch path: {e}")
|
||||
continue
|
||||
|
||||
for _ in range(retries_per_path):
|
||||
attempt += 1
|
||||
if await self._dm_retry_send_and_wait(
|
||||
contact, text, timestamp, attempt, dm_id,
|
||||
suggested_timeout, min_wait
|
||||
):
|
||||
await self._restore_primary_path(contact, contact_pubkey)
|
||||
return
|
||||
|
||||
# Phase 3: Optional FLOOD fallback
|
||||
if not no_auto_flood:
|
||||
min_wait = float(cfg['flood_interval'])
|
||||
try:
|
||||
await self.mc.commands.reset_path(contact)
|
||||
logger.info("DM retry: all paths exhausted, falling back to FLOOD")
|
||||
except Exception:
|
||||
pass
|
||||
for _ in range(cfg['flood_max_retries']):
|
||||
attempt += 1
|
||||
if await self._dm_retry_send_and_wait(
|
||||
contact, text, timestamp, attempt, dm_id,
|
||||
suggested_timeout, min_wait
|
||||
):
|
||||
if await _retry(attempt, direct_interval):
|
||||
await self._restore_primary_path(contact, contact_pubkey)
|
||||
return
|
||||
|
||||
# Restore primary path regardless of outcome
|
||||
await self._restore_primary_path(contact, contact_pubkey)
|
||||
|
||||
# ── Strategy 2: DIRECT + optional FLOOD (no configured paths) ──
|
||||
elif has_path:
|
||||
# Direct retries
|
||||
# ════════════════════════════════════════════════════════════
|
||||
# Scenario 4: Has path + has configured paths → DIRECT on current path, configured path rotation, optional FLOOD
|
||||
# ════════════════════════════════════════════════════════════
|
||||
else: # has_path and has_configured_paths
|
||||
# Phase 1: Direct retries on current path
|
||||
for _ in range(cfg['direct_max_retries']):
|
||||
attempt += 1
|
||||
if await self._dm_retry_send_and_wait(
|
||||
contact, text, timestamp, attempt, dm_id,
|
||||
suggested_timeout, float(cfg['direct_interval'])
|
||||
):
|
||||
return
|
||||
if await _retry(attempt, float(cfg['direct_interval'])):
|
||||
return # Delivered on current path, no change needed
|
||||
|
||||
# Phase 2: Configured path rotation with dedup
|
||||
logger.info("DM retry: direct retries exhausted, rotating through configured paths")
|
||||
direct_interval = float(cfg['direct_interval'])
|
||||
|
||||
for path_info in rotation_order:
|
||||
# Dedup: skip if this configured path matches original device path
|
||||
if self._paths_match(original_out_path, original_out_path_len, path_info):
|
||||
logger.debug(f"DM retry: skipping path '{path_info.get('label', '')}' "
|
||||
f"({path_info['path_hex']}) — matches current device path")
|
||||
continue
|
||||
|
||||
# Switch to flood (unless no_auto_flood)
|
||||
if not no_auto_flood:
|
||||
min_wait = float(cfg['flood_interval'])
|
||||
try:
|
||||
await self.mc.commands.reset_path(contact)
|
||||
logger.info("DM retry: direct exhausted, resetting to flood")
|
||||
except Exception:
|
||||
pass
|
||||
for _ in range(cfg['direct_flood_retries']):
|
||||
await self._change_path_async(contact, path_info['path_hex'], path_info['hash_size'])
|
||||
label = path_info.get('label', '')
|
||||
path_desc = path_info['path_hex']
|
||||
logger.info(f"DM retry: switched to path '{label}' ({path_info['path_hex']})")
|
||||
except Exception as e:
|
||||
logger.warning(f"DM retry: failed to switch path: {e}")
|
||||
continue
|
||||
|
||||
for _ in range(retries_per_path):
|
||||
attempt += 1
|
||||
if await self._dm_retry_send_and_wait(
|
||||
contact, text, timestamp, attempt, dm_id,
|
||||
suggested_timeout, min_wait
|
||||
):
|
||||
if await _retry(attempt, direct_interval):
|
||||
await self._restore_primary_path(contact, contact_pubkey)
|
||||
return
|
||||
|
||||
# ── Strategy 3: FLOOD only ──
|
||||
else:
|
||||
for _ in range(cfg['flood_max_retries']):
|
||||
attempt += 1
|
||||
if await self._dm_retry_send_and_wait(
|
||||
contact, text, timestamp, attempt, dm_id,
|
||||
suggested_timeout, float(cfg['flood_interval'])
|
||||
):
|
||||
return
|
||||
# Phase 3: Optional FLOOD fallback (controlled by no_auto_flood)
|
||||
if not no_auto_flood:
|
||||
try:
|
||||
await self.mc.commands.reset_path(contact)
|
||||
logger.info("DM retry: all paths exhausted, falling back to FLOOD")
|
||||
except Exception:
|
||||
pass
|
||||
path_desc = ''
|
||||
for _ in range(cfg['flood_max_retries']):
|
||||
attempt += 1
|
||||
if await _retry(attempt, float(cfg['flood_interval'])):
|
||||
await self._restore_primary_path(contact, contact_pubkey)
|
||||
return
|
||||
|
||||
logger.warning(f"DM retry exhausted ({attempt + 1} total attempts, mode={mode}) "
|
||||
# Restore primary path regardless of outcome
|
||||
await self._restore_primary_path(contact, contact_pubkey)
|
||||
|
||||
# ── Common epilogue: mark failed, grace period for late ACKs ──
|
||||
self.db.update_dm_delivery_info(dm_id, attempt + 1, max_attempts, '')
|
||||
self.db.update_dm_delivery_status(dm_id, 'failed')
|
||||
self._emit_retry_failed(dm_id, initial_ack)
|
||||
logger.warning(f"DM retry exhausted ({attempt + 1} total attempts, scenario={scenario}) "
|
||||
f"for dm_id={dm_id}")
|
||||
# Keep pending acks for grace period so late ACKs can still be matched
|
||||
self._retry_tasks.pop(dm_id, None)
|
||||
self._retry_context.pop(dm_id, None)
|
||||
await asyncio.sleep(cfg['grace_period'])
|
||||
stale = [k for k, v in self._pending_acks.items() if v == dm_id]
|
||||
if stale:
|
||||
@@ -1425,6 +1718,52 @@ class DeviceManager:
|
||||
logger.error(f"Failed to delete cached contact: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def push_to_device(self, pubkey: str) -> Dict:
|
||||
"""Push a cache-only contact to the device."""
|
||||
if not self.is_connected:
|
||||
return {'success': False, 'error': 'Device not connected'}
|
||||
|
||||
# Already on device?
|
||||
if self.mc.contacts and pubkey in self.mc.contacts:
|
||||
return {'success': False, 'error': 'Contact is already on device'}
|
||||
|
||||
db_contact = self.db.get_contact(pubkey)
|
||||
if not db_contact:
|
||||
return {'success': False, 'error': 'Contact not found in cache'}
|
||||
|
||||
name = db_contact.get('name', '')
|
||||
contact_type = db_contact.get('type', 1)
|
||||
if contact_type == 0:
|
||||
contact_type = 1 # NONE → COM
|
||||
|
||||
return self.add_contact_manual(
|
||||
name=name,
|
||||
public_key=pubkey,
|
||||
contact_type=contact_type,
|
||||
)
|
||||
|
||||
def move_to_cache(self, pubkey: str) -> Dict:
|
||||
"""Move a device contact to cache (remove from device, keep in DB)."""
|
||||
if not self.is_connected:
|
||||
return {'success': False, 'error': 'Device not connected'}
|
||||
|
||||
if not self.mc.contacts or pubkey not in self.mc.contacts:
|
||||
return {'success': False, 'error': 'Contact not on device'}
|
||||
|
||||
contact = self.mc.contacts[pubkey]
|
||||
name = contact.get('adv_name', contact.get('name', ''))
|
||||
|
||||
try:
|
||||
self.execute(self.mc.commands.remove_contact(pubkey))
|
||||
self.db.delete_contact(pubkey) # soft-delete: sets source='advert'
|
||||
if self.mc.contacts and pubkey in self.mc.contacts:
|
||||
del self.mc.contacts[pubkey]
|
||||
logger.info(f"Moved to cache: {name} ({pubkey[:12]}...)")
|
||||
return {'success': True, 'message': f'{name} moved to cache'}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to move contact to cache: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def reset_path(self, pubkey: str) -> Dict:
|
||||
"""Reset path to a contact."""
|
||||
if not self.is_connected:
|
||||
@@ -1631,6 +1970,73 @@ class DeviceManager:
|
||||
logger.error(f"Failed to approve contact: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def add_contact_manual(self, name: str, public_key: str, contact_type: int = 1) -> Dict:
|
||||
"""Add a contact manually from name, public_key and type.
|
||||
|
||||
This bypasses the pending/advert mechanism entirely — uses CMD_ADD_UPDATE_CONTACT
|
||||
(same as the MeshCore mobile app's QR code / URI sharing).
|
||||
"""
|
||||
if not self.is_connected:
|
||||
return {'success': False, 'error': 'Device not connected'}
|
||||
|
||||
# Validate inputs
|
||||
public_key = public_key.strip().lower()
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return {'success': False, 'error': 'Name is required'}
|
||||
if len(public_key) != 64:
|
||||
return {'success': False, 'error': 'Public key must be 64 hex characters'}
|
||||
try:
|
||||
bytes.fromhex(public_key)
|
||||
except ValueError:
|
||||
return {'success': False, 'error': 'Public key must be valid hex'}
|
||||
if contact_type not in (1, 2, 3, 4):
|
||||
return {'success': False, 'error': 'Type must be 1 (COM), 2 (REP), 3 (ROOM), or 4 (SENS)'}
|
||||
|
||||
try:
|
||||
contact = {
|
||||
'public_key': public_key,
|
||||
'type': contact_type,
|
||||
'flags': 0,
|
||||
'out_path_len': -1,
|
||||
'out_path': '',
|
||||
'out_path_hash_mode': 0,
|
||||
'adv_name': name,
|
||||
'last_advert': 0,
|
||||
'adv_lat': 0.0,
|
||||
'adv_lon': 0.0,
|
||||
}
|
||||
|
||||
self.execute(self.mc.commands.add_contact(contact))
|
||||
|
||||
# Refresh mc.contacts from device
|
||||
self.execute(self.mc.ensure_contacts(follow=True))
|
||||
|
||||
# Fallback: add to in-memory contacts if firmware needs time
|
||||
if public_key not in (self.mc.contacts or {}):
|
||||
if self.mc.contacts is None:
|
||||
self.mc.contacts = {}
|
||||
self.mc.contacts[public_key] = contact
|
||||
logger.info(f"Manually added {public_key[:12]}... to mc.contacts")
|
||||
|
||||
self.db.upsert_contact(
|
||||
public_key=public_key,
|
||||
name=name,
|
||||
type=contact_type,
|
||||
adv_lat=0.0,
|
||||
adv_lon=0.0,
|
||||
last_advert=str(int(time.time())),
|
||||
source='device',
|
||||
)
|
||||
# Re-link orphaned DMs
|
||||
self.db.relink_orphaned_dms(public_key, name=name)
|
||||
|
||||
logger.info(f"Manual add contact: {name} ({public_key[:12]}...) type={contact_type}")
|
||||
return {'success': True, 'message': f'Contact {name} added to device'}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to add contact manually: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def reject_contact(self, pubkey: str) -> Dict:
|
||||
"""Reject a pending contact (remove from pending list without adding)."""
|
||||
if not self.is_connected:
|
||||
@@ -2114,9 +2520,21 @@ class DeviceManager:
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def import_contact_uri(self, uri: str) -> Dict:
|
||||
"""Import a contact from meshcore:// URI."""
|
||||
"""Import a contact from meshcore:// URI.
|
||||
|
||||
Supports two formats:
|
||||
- Mobile app URI: meshcore://contact/add?name=...&public_key=...&type=...
|
||||
- Hex blob URI: meshcore://<hex_data> (signed advert blob)
|
||||
"""
|
||||
if not self.is_connected:
|
||||
return {'success': False, 'error': 'Device not connected'}
|
||||
|
||||
# Try mobile app URI format first
|
||||
parsed = parse_meshcore_uri(uri)
|
||||
if parsed:
|
||||
return self.add_contact_manual(parsed['name'], parsed['public_key'], parsed['type'])
|
||||
|
||||
# Fallback: hex blob (signed advert) format
|
||||
try:
|
||||
if uri.startswith('meshcore://'):
|
||||
hex_data = uri[11:]
|
||||
@@ -2128,7 +2546,7 @@ class DeviceManager:
|
||||
self.execute(self.mc.commands.get_contacts(), timeout=10)
|
||||
return {'success': True, 'message': 'Contact imported'}
|
||||
except ValueError:
|
||||
return {'success': False, 'error': 'Invalid URI format (expected hex data)'}
|
||||
return {'success': False, 'error': 'Invalid URI format (expected mobile app URI or hex data)'}
|
||||
except Exception as e:
|
||||
logger.error(f"import_contact failed: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
@@ -2335,7 +2753,7 @@ class DeviceManager:
|
||||
from meshcore.events import EventType
|
||||
types = 0xFF # all types
|
||||
if type_filter:
|
||||
type_map = {'cli': 1, 'rep': 2, 'room': 3, 'sensor': 4, 'sens': 4}
|
||||
type_map = {'com': 1, 'rep': 2, 'room': 3, 'sensor': 4, 'sens': 4}
|
||||
t = type_map.get(type_filter.lower())
|
||||
if t:
|
||||
types = t
|
||||
|
||||
+208
-48
@@ -8,14 +8,16 @@ import json
|
||||
import logging
|
||||
import re
|
||||
import shlex
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from flask import Flask, request as flask_request
|
||||
from flask_socketio import SocketIO, emit
|
||||
from app.config import config, runtime_config
|
||||
from app.database import Database
|
||||
from app.device_manager import DeviceManager
|
||||
from app.device_manager import DeviceManager, parse_meshcore_uri
|
||||
from app.log_handler import MemoryLogHandler
|
||||
from app.routes.views import views_bp
|
||||
from app.routes.api import api_bp
|
||||
@@ -52,21 +54,53 @@ db = None
|
||||
device_manager = None
|
||||
|
||||
|
||||
def _sanitize_db_name(name: str) -> str:
|
||||
"""Sanitize device name for use as database filename."""
|
||||
sanitized = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', name)
|
||||
sanitized = sanitized.strip('. ')
|
||||
return sanitized or 'device'
|
||||
def _pubkey_db_name(public_key: str) -> str:
|
||||
"""Return stable DB filename based on device public key prefix."""
|
||||
return f"mc_{public_key[:8].lower()}.db"
|
||||
|
||||
|
||||
def _read_pubkey_from_db(db_path: Path) -> Optional[str]:
|
||||
"""Probe an existing DB file for the device public key.
|
||||
|
||||
Uses a raw sqlite3 connection (not Database class) to avoid
|
||||
WAL creation side effects on a file that may be about to be renamed.
|
||||
"""
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)
|
||||
try:
|
||||
row = conn.execute("SELECT public_key FROM device WHERE id = 1").fetchone()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _rename_db_files(src: Path, dst: Path) -> bool:
|
||||
"""Rename DB + WAL + SHM files. Returns True on success."""
|
||||
for suffix in ['', '-wal', '-shm']:
|
||||
s = Path(str(src) + suffix)
|
||||
d = Path(str(dst) + suffix)
|
||||
if s.exists():
|
||||
try:
|
||||
s.rename(d)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to rename {s.name} -> {d.name}: {e}")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _resolve_db_path() -> Path:
|
||||
"""Resolve database path, preferring existing device-named DB files.
|
||||
"""Resolve database path using public-key-based naming.
|
||||
|
||||
Priority:
|
||||
1. Explicit MC_DB_PATH that is NOT mc-webui.db -> use as-is
|
||||
2. Existing device-named .db file in config dir (most recently modified)
|
||||
3. Existing mc-webui.db (legacy, will be renamed on device connect)
|
||||
4. New mc-webui.db (will be renamed on device connect)
|
||||
1. Explicit MC_DB_PATH (not mc-webui.db) -> use as-is
|
||||
2. Existing mc_*.db file (new pubkey-based format) -> use most recent
|
||||
3. Existing *.db (old device-name format) -> probe for pubkey, rename if possible
|
||||
4. Existing mc-webui.db (legacy default) -> probe for pubkey, rename if possible
|
||||
5. New install -> create mc-webui.db (will be renamed on first device connect)
|
||||
"""
|
||||
if config.MC_DB_PATH:
|
||||
p = Path(config.MC_DB_PATH)
|
||||
@@ -76,35 +110,69 @@ def _resolve_db_path() -> Path:
|
||||
else:
|
||||
db_dir = Path(config.MC_CONFIG_DIR)
|
||||
|
||||
# Scan for existing device-named DBs (anything except mc-webui.db)
|
||||
# 1. Scan for new-format DBs (mc_????????.db)
|
||||
try:
|
||||
existing = sorted(
|
||||
[f for f in db_dir.glob('*.db')
|
||||
if f.name != 'mc-webui.db' and f.is_file()],
|
||||
new_format = sorted(
|
||||
[f for f in db_dir.glob('mc_????????.db') if f.is_file()],
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True
|
||||
)
|
||||
if existing:
|
||||
logger.info(f"Found device-named database: {existing[0].name}")
|
||||
return existing[0]
|
||||
if new_format:
|
||||
logger.info(f"Found database: {new_format[0].name}")
|
||||
return new_format[0]
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# Fallback: mc-webui.db (legacy or new install)
|
||||
return db_dir / 'mc-webui.db'
|
||||
# 2. Scan for old device-named DBs (anything except mc-webui.db and mc_*.db)
|
||||
try:
|
||||
old_format = sorted(
|
||||
[f for f in db_dir.glob('*.db')
|
||||
if f.name != 'mc-webui.db'
|
||||
and not re.match(r'^mc_[0-9a-f]{8}\.db$', f.name)
|
||||
and f.is_file()],
|
||||
key=lambda f: f.stat().st_mtime,
|
||||
reverse=True
|
||||
)
|
||||
if old_format:
|
||||
db_file = old_format[0]
|
||||
pubkey = _read_pubkey_from_db(db_file)
|
||||
if pubkey:
|
||||
target = db_dir / _pubkey_db_name(pubkey)
|
||||
if not target.exists() and _rename_db_files(db_file, target):
|
||||
logger.info(f"Migrated database: {db_file.name} -> {target.name}")
|
||||
return target
|
||||
elif target.exists():
|
||||
logger.info(f"Found database: {target.name}")
|
||||
return target
|
||||
# No pubkey in device table yet — use as-is, rename deferred
|
||||
logger.info(f"Found legacy database: {db_file.name} (rename deferred)")
|
||||
return db_file
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 3. Check for mc-webui.db (legacy default)
|
||||
legacy = db_dir / 'mc-webui.db'
|
||||
if legacy.exists():
|
||||
pubkey = _read_pubkey_from_db(legacy)
|
||||
if pubkey:
|
||||
target = db_dir / _pubkey_db_name(pubkey)
|
||||
if not target.exists() and _rename_db_files(legacy, target):
|
||||
logger.info(f"Migrated database: {legacy.name} -> {target.name}")
|
||||
return target
|
||||
return legacy
|
||||
|
||||
# 4. New install — will be renamed on first device connect
|
||||
return legacy
|
||||
|
||||
|
||||
def _migrate_db_to_device_name(db, device_name: str):
|
||||
"""Rename DB file to match device name if needed.
|
||||
def _migrate_db_to_pubkey(db, public_key: str):
|
||||
"""Rename DB file to public-key-based name if needed.
|
||||
|
||||
Handles three cases:
|
||||
- Current DB already matches device name -> no-op
|
||||
- Target DB exists (different device was here before) -> switch to it
|
||||
- Target DB doesn't exist -> rename current DB files
|
||||
Called after device connects and provides its public key.
|
||||
"""
|
||||
safe_name = _sanitize_db_name(device_name)
|
||||
target_name = _pubkey_db_name(public_key)
|
||||
current = db.db_path
|
||||
target = current.parent / f"{safe_name}.db"
|
||||
target = current.parent / target_name
|
||||
|
||||
if current.resolve() == target.resolve():
|
||||
return
|
||||
@@ -123,19 +191,28 @@ def _migrate_db_to_device_name(db, device_name: str):
|
||||
except Exception as e:
|
||||
logger.warning(f"WAL checkpoint before rename: {e}")
|
||||
|
||||
# Rename DB + WAL + SHM files
|
||||
for suffix in ['', '-wal', '-shm']:
|
||||
src = Path(str(current) + suffix)
|
||||
dst = Path(str(target) + suffix)
|
||||
if src.exists():
|
||||
try:
|
||||
src.rename(dst)
|
||||
except OSError as e:
|
||||
logger.error(f"Failed to rename {src.name} -> {dst.name}: {e}")
|
||||
return # abort migration
|
||||
if _rename_db_files(current, target):
|
||||
db.db_path = target
|
||||
logger.info(f"Database renamed: {current.name} -> {target.name}")
|
||||
|
||||
db.db_path = target
|
||||
logger.info(f"Database renamed: {current.name} -> {target.name}")
|
||||
|
||||
def _cleanup_legacy_jsonl(data_dir: Path):
|
||||
"""Remove stale JSONL files whose data now lives in the database."""
|
||||
patterns = [
|
||||
'*.contacts_cache.jsonl',
|
||||
'*.adverts.jsonl',
|
||||
'*.acks.jsonl',
|
||||
'*.echoes.jsonl',
|
||||
'*.path.jsonl',
|
||||
'*_dm_sent.jsonl',
|
||||
]
|
||||
for pattern in patterns:
|
||||
for f in data_dir.glob(pattern):
|
||||
try:
|
||||
f.unlink()
|
||||
logger.info(f"Removed legacy file: {f.name}")
|
||||
except OSError as e:
|
||||
logger.warning(f"Could not remove {f.name}: {e}")
|
||||
|
||||
|
||||
def create_app():
|
||||
@@ -186,6 +263,27 @@ def create_app():
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not rename settings file: {e}")
|
||||
|
||||
# Migrate .read_status.json to DB (one-time)
|
||||
read_status_file = Path(config.MC_CONFIG_DIR) / '.read_status.json'
|
||||
if read_status_file.exists():
|
||||
try:
|
||||
import json as _json
|
||||
with open(read_status_file, 'r', encoding='utf-8') as f:
|
||||
rs_data = _json.load(f)
|
||||
migrated = 0
|
||||
for ch_idx, ts in rs_data.get('channels', {}).items():
|
||||
db.mark_read(f"chan_{ch_idx}", int(ts))
|
||||
migrated += 1
|
||||
for conv_id, ts in rs_data.get('dm', {}).items():
|
||||
db.mark_read(f"dm_{conv_id}", int(ts))
|
||||
migrated += 1
|
||||
for ch_idx in rs_data.get('muted_channels', []):
|
||||
db.set_channel_muted(int(ch_idx), True)
|
||||
read_status_file.rename(read_status_file.with_suffix('.json.bak'))
|
||||
logger.info(f"Migrated {migrated} read status entries to DB")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to migrate .read_status.json: {e}")
|
||||
|
||||
# v2: Initialize and start device manager
|
||||
device_manager = DeviceManager(config, db, socketio)
|
||||
app.device_manager = device_manager
|
||||
@@ -203,17 +301,20 @@ def create_app():
|
||||
runtime_config.set_device_name(dev_name, "device")
|
||||
logger.info(f"Device name resolved: {dev_name}")
|
||||
|
||||
# Rename DB to match device name (mc-webui.db -> {name}.db)
|
||||
_migrate_db_to_device_name(db, dev_name)
|
||||
|
||||
# Ensure device info is stored in current DB
|
||||
pubkey = ''
|
||||
if device_manager.self_info:
|
||||
pubkey = device_manager.self_info.get('public_key', '')
|
||||
db.set_device_info(
|
||||
public_key=device_manager.self_info.get('public_key', ''),
|
||||
public_key=pubkey,
|
||||
name=dev_name,
|
||||
self_info=json.dumps(device_manager.self_info, default=str)
|
||||
)
|
||||
|
||||
# Rename DB to pubkey-based name (e.g. mc-webui.db -> mc_9cebbd27.db)
|
||||
if pubkey:
|
||||
_migrate_db_to_pubkey(db, pubkey)
|
||||
|
||||
# Auto-migrate v1 data if .msgs file exists and DB is empty
|
||||
try:
|
||||
from app.migrate_v1 import should_migrate, migrate_v1_data
|
||||
@@ -225,6 +326,9 @@ def create_app():
|
||||
except Exception as e:
|
||||
logger.error(f"v1 migration failed: {e}")
|
||||
|
||||
# Clean up stale JSONL files (data is now in DB)
|
||||
_cleanup_legacy_jsonl(Path(config.MC_CONFIG_DIR))
|
||||
|
||||
return
|
||||
logger.warning("Timeout waiting for device connection")
|
||||
|
||||
@@ -836,6 +940,43 @@ def _execute_console_command(args: list) -> str:
|
||||
return result.get('message', 'Pending contacts flushed')
|
||||
return f"Error: {result.get('error')}"
|
||||
|
||||
elif cmd == 'manual_add' and len(args) >= 2:
|
||||
# Two variants:
|
||||
# manual_add meshcore://contact/add?name=...&public_key=...&type=...
|
||||
# manual_add <public_key> <type> <name with spaces>
|
||||
arg1 = args[1]
|
||||
parsed = parse_meshcore_uri(arg1)
|
||||
if parsed:
|
||||
result = device_manager.add_contact_manual(parsed['name'], parsed['public_key'], parsed['type'])
|
||||
elif len(args) >= 4:
|
||||
public_key = args[1]
|
||||
try:
|
||||
contact_type = int(args[2])
|
||||
except ValueError:
|
||||
return "Error: type must be integer (1=COM, 2=REP, 3=ROOM, 4=SENS)"
|
||||
name = ' '.join(args[3:])
|
||||
result = device_manager.add_contact_manual(name, public_key, contact_type)
|
||||
else:
|
||||
return (
|
||||
"Usage:\n"
|
||||
" manual_add <URI>\n"
|
||||
" manual_add <public_key> <type> <name>\n\n"
|
||||
"URI format: meshcore://contact/add?name=...&public_key=...&type=...\n"
|
||||
"Types: 1=COM, 2=REP, 3=ROOM, 4=SENS"
|
||||
)
|
||||
if result.get('success'):
|
||||
return result.get('message', 'Contact added')
|
||||
return f"Error: {result.get('error')}"
|
||||
|
||||
elif cmd == 'manual_add':
|
||||
return (
|
||||
"Usage:\n"
|
||||
" manual_add <URI>\n"
|
||||
" manual_add <public_key> <type> <name>\n\n"
|
||||
"URI format: meshcore://contact/add?name=...&public_key=...&type=...\n"
|
||||
"Types: 1=COM, 2=REP, 3=ROOM, 4=SENS"
|
||||
)
|
||||
|
||||
# ── Device management commands ───────────────────────────────
|
||||
|
||||
elif cmd == 'get' and len(args) >= 2:
|
||||
@@ -1000,12 +1141,30 @@ def _execute_console_command(args: list) -> str:
|
||||
data = result['data']
|
||||
if not data:
|
||||
return "No nodes discovered"
|
||||
type_names = ["NONE", "COM", "REP", "ROOM", "SENS"]
|
||||
lines = [f"Discovered nodes ({len(data)}):"]
|
||||
for node in data:
|
||||
if isinstance(node, dict):
|
||||
name = node.get('adv_name', node.get('name', '?'))
|
||||
pk = node.get('public_key', '')[:12]
|
||||
lines.append(f" {name} ({pk}...)")
|
||||
pk = node.get('pubkey', '')
|
||||
# Try to resolve name from contacts
|
||||
name = None
|
||||
if pk and device_manager.mc:
|
||||
try:
|
||||
contact = device_manager.mc.get_contact_by_key_prefix(pk)
|
||||
if contact:
|
||||
name = contact.get('adv_name', '')
|
||||
except Exception:
|
||||
pass
|
||||
if name:
|
||||
label = f"{pk[:6]} {name}"
|
||||
else:
|
||||
label = pk[:16] or '?'
|
||||
nt = node.get('node_type', 0)
|
||||
type_str = type_names[nt] if nt < len(type_names) else f"t:{nt}"
|
||||
snr_in = node.get('SNR_in', 0)
|
||||
snr = node.get('SNR', 0)
|
||||
rssi = node.get('RSSI', 0)
|
||||
lines.append(f" {label:28} {type_str:>4} SNR: {snr_in:6.2f}->{snr:6.2f} RSSI: {rssi}")
|
||||
else:
|
||||
lines.append(f" {node}")
|
||||
return "\n".join(lines)
|
||||
@@ -1093,7 +1252,8 @@ def _execute_console_command(args: list) -> str:
|
||||
" advert_path <name> — Get path from advert\n"
|
||||
" share_contact <name> — Share contact with mesh\n"
|
||||
" export_contact <name> — Export contact URI\n"
|
||||
" import_contact <URI> — Import contact from URI\n"
|
||||
" import_contact <URI> — Import contact from hex blob URI\n"
|
||||
" manual_add <URI|params> — Add contact from mobile app URI or params\n"
|
||||
" remove_contact <name> — Remove contact from device\n"
|
||||
" change_flags <n> <f> — Change contact flags\n"
|
||||
" pending_contacts — Show pending contacts\n"
|
||||
|
||||
+62
-195
@@ -1,198 +1,101 @@
|
||||
"""
|
||||
Read Status Manager - Server-side storage for message read status
|
||||
Read Status Manager - DB-backed storage for message read status
|
||||
|
||||
Manages the last seen timestamps for channels and DM conversations,
|
||||
providing cross-device synchronization for unread message tracking.
|
||||
All data is stored in the read_status table of the SQLite database.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from threading import Lock
|
||||
from app.config import config
|
||||
from flask import current_app
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Thread-safe lock for file operations
|
||||
_status_lock = Lock()
|
||||
|
||||
# Path to read status file
|
||||
READ_STATUS_FILE = Path(config.MC_CONFIG_DIR) / '.read_status.json'
|
||||
|
||||
|
||||
def _get_default_status():
|
||||
"""Get default read status structure"""
|
||||
return {
|
||||
'channels': {}, # {"0": timestamp, "1": timestamp, ...}
|
||||
'dm': {}, # {"name_User1": timestamp, "pk_abc123": timestamp, ...}
|
||||
'muted_channels': [] # [2, 5, 7] - channel indices with muted notifications
|
||||
}
|
||||
def _get_db():
|
||||
"""Get database instance from Flask app context."""
|
||||
return getattr(current_app, 'db', None)
|
||||
|
||||
|
||||
def load_read_status():
|
||||
"""
|
||||
Load read status from disk.
|
||||
"""Load read status from database.
|
||||
|
||||
Returns:
|
||||
dict: Read status with 'channels' and 'dm' keys
|
||||
dict: Read status with 'channels', 'dm', and 'muted_channels' keys
|
||||
"""
|
||||
with _status_lock:
|
||||
try:
|
||||
if not READ_STATUS_FILE.exists():
|
||||
logger.info("Read status file does not exist, creating default")
|
||||
return _get_default_status()
|
||||
try:
|
||||
db = _get_db()
|
||||
rows = db.get_read_status()
|
||||
|
||||
with open(READ_STATUS_FILE, 'r', encoding='utf-8') as f:
|
||||
status = json.load(f)
|
||||
channels = {}
|
||||
dm = {}
|
||||
muted_channels = []
|
||||
|
||||
# Validate structure
|
||||
if not isinstance(status, dict):
|
||||
logger.warning("Invalid read status structure, resetting")
|
||||
return _get_default_status()
|
||||
for key, row in rows.items():
|
||||
if key.startswith('chan_'):
|
||||
chan_idx = key[5:] # "chan_0" -> "0"
|
||||
channels[chan_idx] = row['last_seen_ts']
|
||||
if row.get('is_muted'):
|
||||
try:
|
||||
muted_channels.append(int(chan_idx))
|
||||
except ValueError:
|
||||
pass
|
||||
elif key.startswith('dm_'):
|
||||
conv_id = key[3:] # "dm_name_User1" -> "name_User1"
|
||||
dm[conv_id] = row['last_seen_ts']
|
||||
|
||||
# Ensure all keys exist
|
||||
if 'channels' not in status:
|
||||
status['channels'] = {}
|
||||
if 'dm' not in status:
|
||||
status['dm'] = {}
|
||||
if 'muted_channels' not in status:
|
||||
status['muted_channels'] = []
|
||||
return {
|
||||
'channels': channels,
|
||||
'dm': dm,
|
||||
'muted_channels': muted_channels,
|
||||
}
|
||||
|
||||
logger.debug(f"Loaded read status: {len(status['channels'])} channels, {len(status['dm'])} DM conversations")
|
||||
return status
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse read status file: {e}")
|
||||
return _get_default_status()
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading read status: {e}")
|
||||
return _get_default_status()
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading read status: {e}")
|
||||
return {'channels': {}, 'dm': {}, 'muted_channels': []}
|
||||
|
||||
|
||||
def save_read_status(status):
|
||||
"""
|
||||
Save read status to disk.
|
||||
|
||||
Args:
|
||||
status (dict): Read status with 'channels' and 'dm' keys
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
with _status_lock:
|
||||
try:
|
||||
# Ensure directory exists
|
||||
READ_STATUS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Write atomically (write to temp file, then rename)
|
||||
temp_file = READ_STATUS_FILE.with_suffix('.tmp')
|
||||
with open(temp_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(status, f, indent=2)
|
||||
|
||||
# Atomic rename
|
||||
temp_file.replace(READ_STATUS_FILE)
|
||||
|
||||
logger.debug(f"Saved read status: {len(status['channels'])} channels, {len(status['dm'])} DM conversations")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving read status: {e}")
|
||||
return False
|
||||
"""No-op — data is written per-operation via mark_* functions."""
|
||||
return True
|
||||
|
||||
|
||||
def mark_channel_read(channel_idx, timestamp):
|
||||
"""
|
||||
Mark a channel as read up to a specific timestamp.
|
||||
|
||||
Args:
|
||||
channel_idx (int or str): Channel index (will be converted to string)
|
||||
timestamp (int or float): Unix timestamp of last read message
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
"""Mark a channel as read up to a specific timestamp."""
|
||||
try:
|
||||
# Load current status
|
||||
status = load_read_status()
|
||||
|
||||
# Update channel timestamp (ensure key is string for JSON compatibility)
|
||||
channel_key = str(channel_idx)
|
||||
status['channels'][channel_key] = int(timestamp)
|
||||
|
||||
# Save updated status
|
||||
success = save_read_status(status)
|
||||
|
||||
if success:
|
||||
logger.debug(f"Marked channel {channel_idx} as read at timestamp {timestamp}")
|
||||
|
||||
return success
|
||||
|
||||
db = _get_db()
|
||||
db.mark_read(f"chan_{channel_idx}", int(timestamp))
|
||||
logger.debug(f"Marked channel {channel_idx} as read at timestamp {timestamp}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking channel {channel_idx} as read: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def mark_dm_read(conversation_id, timestamp):
|
||||
"""
|
||||
Mark a DM conversation as read up to a specific timestamp.
|
||||
|
||||
Args:
|
||||
conversation_id (str): Conversation identifier (e.g., "name_User1" or "pk_abc123")
|
||||
timestamp (int or float): Unix timestamp of last read message
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
"""Mark a DM conversation as read up to a specific timestamp."""
|
||||
try:
|
||||
# Load current status
|
||||
status = load_read_status()
|
||||
|
||||
# Update DM timestamp
|
||||
status['dm'][conversation_id] = int(timestamp)
|
||||
|
||||
# Save updated status
|
||||
success = save_read_status(status)
|
||||
|
||||
if success:
|
||||
logger.debug(f"Marked DM conversation {conversation_id} as read at timestamp {timestamp}")
|
||||
|
||||
return success
|
||||
|
||||
db = _get_db()
|
||||
db.mark_read(f"dm_{conversation_id}", int(timestamp))
|
||||
logger.debug(f"Marked DM conversation {conversation_id} as read at timestamp {timestamp}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking DM conversation {conversation_id} as read: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_channel_last_seen(channel_idx):
|
||||
"""
|
||||
Get last seen timestamp for a specific channel.
|
||||
|
||||
Args:
|
||||
channel_idx (int or str): Channel index
|
||||
|
||||
Returns:
|
||||
int: Unix timestamp, or 0 if never seen
|
||||
"""
|
||||
"""Get last seen timestamp for a specific channel."""
|
||||
try:
|
||||
status = load_read_status()
|
||||
channel_key = str(channel_idx)
|
||||
return status['channels'].get(channel_key, 0)
|
||||
return status['channels'].get(str(channel_idx), 0)
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting last seen for channel {channel_idx}: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def get_dm_last_seen(conversation_id):
|
||||
"""
|
||||
Get last seen timestamp for a specific DM conversation.
|
||||
|
||||
Args:
|
||||
conversation_id (str): Conversation identifier
|
||||
|
||||
Returns:
|
||||
int: Unix timestamp, or 0 if never seen
|
||||
"""
|
||||
"""Get last seen timestamp for a specific DM conversation."""
|
||||
try:
|
||||
status = load_read_status()
|
||||
return status['dm'].get(conversation_id, 0)
|
||||
@@ -202,75 +105,39 @@ def get_dm_last_seen(conversation_id):
|
||||
|
||||
|
||||
def get_muted_channels():
|
||||
"""
|
||||
Get list of muted channel indices.
|
||||
|
||||
Returns:
|
||||
list: List of muted channel indices (integers)
|
||||
"""
|
||||
"""Get list of muted channel indices."""
|
||||
try:
|
||||
status = load_read_status()
|
||||
return status.get('muted_channels', [])
|
||||
db = _get_db()
|
||||
return db.get_muted_channels()
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting muted channels: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def set_channel_muted(channel_idx, muted):
|
||||
"""
|
||||
Set mute state for a channel.
|
||||
|
||||
Args:
|
||||
channel_idx (int): Channel index
|
||||
muted (bool): True to mute, False to unmute
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
"""Set mute state for a channel."""
|
||||
try:
|
||||
status = load_read_status()
|
||||
muted_list = status.get('muted_channels', [])
|
||||
channel_idx = int(channel_idx)
|
||||
|
||||
if muted and channel_idx not in muted_list:
|
||||
muted_list.append(channel_idx)
|
||||
elif not muted and channel_idx in muted_list:
|
||||
muted_list.remove(channel_idx)
|
||||
|
||||
status['muted_channels'] = muted_list
|
||||
success = save_read_status(status)
|
||||
|
||||
if success:
|
||||
logger.info(f"Channel {channel_idx} {'muted' if muted else 'unmuted'}")
|
||||
return success
|
||||
|
||||
db = _get_db()
|
||||
db.set_channel_muted(int(channel_idx), muted)
|
||||
logger.info(f"Channel {channel_idx} {'muted' if muted else 'unmuted'}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting mute for channel {channel_idx}: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def mark_all_channels_read(channel_timestamps):
|
||||
"""
|
||||
Mark all channels as read in bulk.
|
||||
"""Mark all channels as read in bulk.
|
||||
|
||||
Args:
|
||||
channel_timestamps (dict): {"0": timestamp, "1": timestamp, ...}
|
||||
|
||||
Returns:
|
||||
bool: True if successful
|
||||
"""
|
||||
try:
|
||||
status = load_read_status()
|
||||
|
||||
db = _get_db()
|
||||
for channel_key, timestamp in channel_timestamps.items():
|
||||
status['channels'][str(channel_key)] = int(timestamp)
|
||||
|
||||
success = save_read_status(status)
|
||||
|
||||
if success:
|
||||
logger.info(f"Marked {len(channel_timestamps)} channels as read")
|
||||
return success
|
||||
|
||||
db.mark_read(f"chan_{channel_key}", int(timestamp))
|
||||
logger.info(f"Marked {len(channel_timestamps)} channels as read")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error marking all channels as read: {e}")
|
||||
return False
|
||||
|
||||
@@ -1980,6 +1980,7 @@ def get_dm_messages():
|
||||
for row in db_msgs:
|
||||
messages.append({
|
||||
'type': 'dm',
|
||||
'id': row['id'],
|
||||
'direction': 'incoming' if row['direction'] == 'in' else 'outgoing',
|
||||
'sender': row.get('contact_pubkey', ''),
|
||||
'content': row.get('content', ''),
|
||||
@@ -1989,6 +1990,10 @@ def get_dm_messages():
|
||||
'snr': row.get('snr'),
|
||||
'path_len': row.get('path_len'),
|
||||
'expected_ack': row.get('expected_ack'),
|
||||
'delivery_status': row.get('delivery_status'),
|
||||
'delivery_attempt': row.get('delivery_attempt'),
|
||||
'delivery_max_attempts': row.get('delivery_max_attempts'),
|
||||
'delivery_path': row.get('delivery_path'),
|
||||
'conversation_id': conversation_id,
|
||||
})
|
||||
else:
|
||||
@@ -2040,6 +2045,12 @@ def get_dm_messages():
|
||||
except Exception as e:
|
||||
logger.debug(f"ACK status fetch failed (non-critical): {e}")
|
||||
|
||||
# Set failed status for messages without ACK but marked failed in DB
|
||||
for msg in messages:
|
||||
if msg.get('direction') == 'outgoing' and msg.get('status') != 'delivered':
|
||||
if msg.get('delivery_status') == 'failed':
|
||||
msg['status'] = 'failed'
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'conversation_id': conversation_id,
|
||||
@@ -2322,6 +2333,7 @@ def reset_contact_to_flood(pubkey):
|
||||
dev_result = dm.reset_path(pubkey)
|
||||
logger.info(f"reset_path({pubkey[:12]}...) result: {dev_result}")
|
||||
if dev_result.get('success'):
|
||||
invalidate_contacts_cache()
|
||||
return jsonify({'success': True}), 200
|
||||
return jsonify({'success': False, 'error': dev_result.get('error', 'Device reset failed')}), 500
|
||||
except Exception as e:
|
||||
@@ -2738,6 +2750,84 @@ def delete_cached_contact_api():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/manual-add', methods=['POST'])
|
||||
def manual_add_contact():
|
||||
"""Add a contact manually via URI or raw parameters (name, public_key, type)."""
|
||||
try:
|
||||
dm = _get_dm()
|
||||
if not dm:
|
||||
return jsonify({'success': False, 'error': 'Device manager unavailable'}), 500
|
||||
|
||||
data = request.get_json() or {}
|
||||
|
||||
# Mode 1: URI (meshcore://contact/add?... or hex blob)
|
||||
uri = data.get('uri', '').strip()
|
||||
if uri:
|
||||
result = dm.import_contact_uri(uri)
|
||||
if result['success']:
|
||||
invalidate_contacts_cache()
|
||||
status = 200 if result['success'] else 400
|
||||
return jsonify(result), status
|
||||
|
||||
# Mode 2: Raw parameters
|
||||
name = data.get('name', '').strip()
|
||||
public_key = data.get('public_key', '').strip()
|
||||
contact_type = data.get('type', 1)
|
||||
|
||||
if not name or not public_key:
|
||||
return jsonify({'success': False, 'error': 'Name and public_key are required'}), 400
|
||||
|
||||
try:
|
||||
contact_type = int(contact_type)
|
||||
except (ValueError, TypeError):
|
||||
contact_type = 1
|
||||
|
||||
result = dm.add_contact_manual(name, public_key, contact_type)
|
||||
if result['success']:
|
||||
invalidate_contacts_cache()
|
||||
status = 200 if result['success'] else 400
|
||||
return jsonify(result), status
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding contact manually: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<public_key>/push-to-device', methods=['POST'])
|
||||
def push_contact_to_device(public_key):
|
||||
"""Push a cache-only contact to the device."""
|
||||
try:
|
||||
dm = _get_dm()
|
||||
if not dm:
|
||||
return jsonify({'success': False, 'error': 'Device manager unavailable'}), 500
|
||||
|
||||
result = dm.push_to_device(public_key.strip().lower())
|
||||
if result['success']:
|
||||
invalidate_contacts_cache()
|
||||
status = 200 if result['success'] else 400
|
||||
return jsonify(result), status
|
||||
except Exception as e:
|
||||
logger.error(f"Error pushing contact to device: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<public_key>/move-to-cache', methods=['POST'])
|
||||
def move_contact_to_cache(public_key):
|
||||
"""Move a device contact to cache (remove from device, keep in DB)."""
|
||||
try:
|
||||
dm = _get_dm()
|
||||
if not dm:
|
||||
return jsonify({'success': False, 'error': 'Device manager unavailable'}), 500
|
||||
|
||||
result = dm.move_to_cache(public_key.strip().lower())
|
||||
if result['success']:
|
||||
invalidate_contacts_cache()
|
||||
status = 200 if result['success'] else 400
|
||||
return jsonify(result), status
|
||||
except Exception as e:
|
||||
logger.error(f"Error moving contact to cache: {e}")
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/protected', methods=['GET'])
|
||||
def get_protected_contacts_api():
|
||||
"""
|
||||
|
||||
@@ -50,6 +50,17 @@ def contact_management():
|
||||
)
|
||||
|
||||
|
||||
@views_bp.route('/contacts/add')
|
||||
def contact_add():
|
||||
"""
|
||||
Add Contact page - URI paste, QR scan, manual fields.
|
||||
"""
|
||||
return render_template(
|
||||
'contacts-add.html',
|
||||
device_name=runtime_config.get_device_name()
|
||||
)
|
||||
|
||||
|
||||
@views_bp.route('/contacts/pending')
|
||||
def contact_pending_list():
|
||||
"""
|
||||
|
||||
+695
-92
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,613 @@
|
||||
/* =============================================================================
|
||||
mc-webui Theme System
|
||||
Defines CSS custom properties for light/dark themes.
|
||||
Bootstrap 5.3 data-bs-theme handles most component styling;
|
||||
these variables cover custom app-specific elements.
|
||||
============================================================================= */
|
||||
|
||||
/* =============================================================================
|
||||
Light Theme (default)
|
||||
============================================================================= */
|
||||
:root {
|
||||
/* Backgrounds */
|
||||
--bg-body: #ffffff;
|
||||
--bg-surface: #f8f9fa;
|
||||
--bg-surface-alt: #f0f0f0;
|
||||
--bg-hover: #e9ecef;
|
||||
--bg-active: #e7f1ff;
|
||||
--bg-messages: #ffffff;
|
||||
--bg-dm-messages: #fafafa;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #212529;
|
||||
--text-secondary: #495057;
|
||||
--text-muted: #6c757d;
|
||||
--text-meta: #adb5bd;
|
||||
|
||||
/* Borders */
|
||||
--border-color: #dee2e6;
|
||||
--border-light: #f0f0f0;
|
||||
|
||||
/* Messages */
|
||||
--msg-own-bg: #e7f1ff;
|
||||
--msg-other-bg: #f8f9fa;
|
||||
--msg-border: #dee2e6;
|
||||
--msg-own-border: #b8daff;
|
||||
|
||||
/* Sender */
|
||||
--sender-color: #0d6efd;
|
||||
--sender-own-color: #084298;
|
||||
|
||||
/* Navbar */
|
||||
--navbar-bg: #0d6efd;
|
||||
--navbar-border: transparent;
|
||||
|
||||
/* Scrollbar */
|
||||
--scrollbar-track: #f1f1f1;
|
||||
--scrollbar-thumb: #888;
|
||||
--scrollbar-thumb-hover: #555;
|
||||
--scrollbar-thumb-light: #ccc;
|
||||
--scrollbar-thumb-light-hover: #aaa;
|
||||
|
||||
/* Filter */
|
||||
--filter-bg: #ffffff;
|
||||
--filter-highlight: #fff3cd;
|
||||
--filter-input-border: #ced4da;
|
||||
--filter-btn-me-bg: #e7f1ff;
|
||||
--filter-btn-me-color: #0d6efd;
|
||||
--filter-btn-me-hover: #cfe2ff;
|
||||
--filter-btn-clear-bg: #f8f9fa;
|
||||
--filter-btn-clear-color: #6c757d;
|
||||
--filter-btn-clear-hover: #e9ecef;
|
||||
|
||||
/* Popup / Dropdown */
|
||||
--popup-bg: #ffffff;
|
||||
--popup-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* Quote */
|
||||
--quote-color: #6c757d;
|
||||
--quote-bg: rgba(108, 117, 125, 0.1);
|
||||
--quote-border: #6c757d;
|
||||
--quote-own-color: #495057;
|
||||
--quote-own-bg: rgba(8, 66, 152, 0.1);
|
||||
--quote-own-border: #084298;
|
||||
|
||||
/* Mention badge */
|
||||
--mention-bg: #0d6efd;
|
||||
--mention-own-bg: #084298;
|
||||
|
||||
/* Links */
|
||||
--link-color: #0d6efd;
|
||||
--link-hover: #0a58ca;
|
||||
--link-own-color: #084298;
|
||||
--link-own-hover: #052c65;
|
||||
|
||||
/* Channel link */
|
||||
--channel-link-bg: #198754;
|
||||
--channel-link-hover: #157347;
|
||||
--channel-link-own-bg: #0f5132;
|
||||
--channel-link-own-hover: #0d4429;
|
||||
|
||||
/* Echo badge */
|
||||
--echo-color: #198754;
|
||||
--echo-bg: rgba(25, 135, 84, 0.1);
|
||||
|
||||
/* Search */
|
||||
--search-mark-bg: #fff3cd;
|
||||
|
||||
/* Offcanvas menu */
|
||||
--offcanvas-item-border: #dee2e6;
|
||||
--offcanvas-item-hover: #f8f9fa;
|
||||
--offcanvas-icon-color: #0d6efd;
|
||||
|
||||
/* FAB */
|
||||
--fab-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
--fab-shadow-hover: 0 6px 12px rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* Conversation list */
|
||||
--conversation-border: #dee2e6;
|
||||
--conversation-hover: #f8f9fa;
|
||||
--conversation-unread: #e7f1ff;
|
||||
|
||||
/* Map filter badges */
|
||||
--map-badge-inactive-bg: white;
|
||||
|
||||
/* Mention autocomplete */
|
||||
--mention-item-highlight: #e7f1ff;
|
||||
--mention-item-border: #f0f0f0;
|
||||
|
||||
/* Image border */
|
||||
--image-border: #dee2e6;
|
||||
|
||||
/* Actions border */
|
||||
--actions-border: rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* Cards */
|
||||
--card-bg: #ffffff;
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--card-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
|
||||
/* Info badge */
|
||||
--info-badge-bg: #e7f3ff;
|
||||
--info-badge-color: #0c5460;
|
||||
|
||||
/* Contact key clickable */
|
||||
--key-hover-color: #0d6efd;
|
||||
--key-hover-bg: #e7f1ff;
|
||||
--key-copied-color: #198754;
|
||||
--key-copied-bg: #d1e7dd;
|
||||
|
||||
/* Path items (DM) */
|
||||
--path-item-bg: #ffffff;
|
||||
--path-item-border: #dee2e6;
|
||||
--path-item-primary-bg: #f0f7ff;
|
||||
--path-item-primary-border: #0d6efd;
|
||||
|
||||
/* DM contact dropdown */
|
||||
--dropdown-bg: #ffffff;
|
||||
--dropdown-separator-bg: #f8f9fa;
|
||||
--dropdown-item-hover: #e9ecef;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Dark Theme
|
||||
Inspired by mc-webui demo landing page (https://mc-webui.marwoj.net/)
|
||||
Color palette: deep navy backgrounds, slate surfaces, soft blue accents
|
||||
============================================================================= */
|
||||
[data-theme="dark"] {
|
||||
/* Override Bootstrap 5.3 dark mode variables for our custom palette */
|
||||
--bs-body-bg: #0f172a;
|
||||
--bs-body-color: #f8fafc;
|
||||
--bs-border-color: #334155;
|
||||
--bs-tertiary-bg: #1e293b;
|
||||
--bs-secondary-bg: #162032;
|
||||
|
||||
/* Backgrounds */
|
||||
--bg-body: #0f172a;
|
||||
--bg-surface: #1e293b;
|
||||
--bg-surface-alt: #162032;
|
||||
--bg-hover: #2d3a4e;
|
||||
--bg-active: #1e3a5f;
|
||||
--bg-messages: #0f172a;
|
||||
--bg-dm-messages: #131c2e;
|
||||
|
||||
/* Text */
|
||||
--text-primary: #f8fafc;
|
||||
--text-secondary: #94a3b8;
|
||||
--text-muted: #64748b;
|
||||
--text-meta: #475569;
|
||||
|
||||
/* Borders */
|
||||
--border-color: #334155;
|
||||
--border-light: #1e293b;
|
||||
|
||||
/* Messages */
|
||||
--msg-own-bg: #1e3a5f;
|
||||
--msg-other-bg: #1e293b;
|
||||
--msg-border: #334155;
|
||||
--msg-own-border: #2563eb;
|
||||
|
||||
/* Sender */
|
||||
--sender-color: #60a5fa;
|
||||
--sender-own-color: #93c5fd;
|
||||
|
||||
/* Navbar */
|
||||
--navbar-bg: #1e293b;
|
||||
--navbar-border: #334155;
|
||||
|
||||
/* Scrollbar */
|
||||
--scrollbar-track: #1e293b;
|
||||
--scrollbar-thumb: #475569;
|
||||
--scrollbar-thumb-hover: #64748b;
|
||||
--scrollbar-thumb-light: #334155;
|
||||
--scrollbar-thumb-light-hover: #475569;
|
||||
|
||||
/* Filter */
|
||||
--filter-bg: #1e293b;
|
||||
--filter-highlight: rgba(251, 191, 36, 0.2);
|
||||
--filter-input-border: #334155;
|
||||
--filter-btn-me-bg: #1e3a5f;
|
||||
--filter-btn-me-color: #60a5fa;
|
||||
--filter-btn-me-hover: #264a6f;
|
||||
--filter-btn-clear-bg: #1e293b;
|
||||
--filter-btn-clear-color: #94a3b8;
|
||||
--filter-btn-clear-hover: #2d3a4e;
|
||||
|
||||
/* Popup / Dropdown */
|
||||
--popup-bg: #1e293b;
|
||||
--popup-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* Quote */
|
||||
--quote-color: #94a3b8;
|
||||
--quote-bg: rgba(148, 163, 184, 0.1);
|
||||
--quote-border: #64748b;
|
||||
--quote-own-color: #94a3b8;
|
||||
--quote-own-bg: rgba(37, 99, 235, 0.15);
|
||||
--quote-own-border: #2563eb;
|
||||
|
||||
/* Mention badge */
|
||||
--mention-bg: #2563eb;
|
||||
--mention-own-bg: #1d4ed8;
|
||||
|
||||
/* Links */
|
||||
--link-color: #60a5fa;
|
||||
--link-hover: #93c5fd;
|
||||
--link-own-color: #93c5fd;
|
||||
--link-own-hover: #bfdbfe;
|
||||
|
||||
/* Channel link */
|
||||
--channel-link-bg: #059669;
|
||||
--channel-link-hover: #10b981;
|
||||
--channel-link-own-bg: #047857;
|
||||
--channel-link-own-hover: #059669;
|
||||
|
||||
/* Echo badge */
|
||||
--echo-color: #10b981;
|
||||
--echo-bg: rgba(16, 185, 129, 0.15);
|
||||
|
||||
/* Search */
|
||||
--search-mark-bg: rgba(251, 191, 36, 0.3);
|
||||
|
||||
/* Offcanvas menu */
|
||||
--offcanvas-item-border: #334155;
|
||||
--offcanvas-item-hover: #253347;
|
||||
--offcanvas-icon-color: #60a5fa;
|
||||
|
||||
/* FAB */
|
||||
--fab-shadow: 0 4px 8px rgba(0, 0, 0, 0.5);
|
||||
--fab-shadow-hover: 0 6px 12px rgba(0, 0, 0, 0.6);
|
||||
|
||||
/* Conversation list */
|
||||
--conversation-border: #334155;
|
||||
--conversation-hover: #253347;
|
||||
--conversation-unread: #1e3a5f;
|
||||
|
||||
/* Map filter badges */
|
||||
--map-badge-inactive-bg: #1e293b;
|
||||
|
||||
/* Mention autocomplete */
|
||||
--mention-item-highlight: #1e3a5f;
|
||||
--mention-item-border: #334155;
|
||||
|
||||
/* Image border */
|
||||
--image-border: #334155;
|
||||
|
||||
/* Actions border */
|
||||
--actions-border: rgba(255, 255, 255, 0.1);
|
||||
|
||||
/* Cards */
|
||||
--card-bg: #1e293b;
|
||||
--card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--card-shadow-hover: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
|
||||
/* Info badge */
|
||||
--info-badge-bg: rgba(37, 99, 235, 0.15);
|
||||
--info-badge-color: #60a5fa;
|
||||
|
||||
/* Contact key clickable */
|
||||
--key-hover-color: #60a5fa;
|
||||
--key-hover-bg: #1e3a5f;
|
||||
--key-copied-color: #10b981;
|
||||
--key-copied-bg: rgba(16, 185, 129, 0.15);
|
||||
|
||||
/* Path items (DM) */
|
||||
--path-item-bg: #1e293b;
|
||||
--path-item-border: #334155;
|
||||
--path-item-primary-bg: #1e3a5f;
|
||||
--path-item-primary-border: #2563eb;
|
||||
|
||||
/* DM contact dropdown */
|
||||
--dropdown-bg: #1e293b;
|
||||
--dropdown-separator-bg: #162032;
|
||||
--dropdown-item-hover: #2d3a4e;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Dark Theme - Bootstrap Component Overrides
|
||||
Bootstrap 5.3 data-bs-theme="dark" handles most defaults; these overrides
|
||||
customize colors to match our deep navy palette.
|
||||
============================================================================= */
|
||||
|
||||
/* Navbar */
|
||||
[data-theme="dark"] .navbar.bg-primary {
|
||||
background-color: var(--navbar-bg) !important;
|
||||
border-bottom: 1px solid var(--navbar-border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .navbar .btn-outline-light {
|
||||
border-color: #475569;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .navbar .btn-outline-light:hover {
|
||||
background-color: #334155;
|
||||
border-color: #64748b;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Form controls */
|
||||
[data-theme="dark"] .form-control,
|
||||
[data-theme="dark"] .form-select {
|
||||
background-color: var(--bg-body);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-control:focus,
|
||||
[data-theme="dark"] .form-select:focus {
|
||||
background-color: var(--bg-body);
|
||||
color: var(--text-primary);
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.25);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-control::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
[data-theme="dark"] .modal-content {
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .modal-header {
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .modal-footer {
|
||||
border-top-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .btn-close {
|
||||
filter: invert(1) grayscale(100%) brightness(200%);
|
||||
}
|
||||
|
||||
/* Offcanvas */
|
||||
[data-theme="dark"] .offcanvas {
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .offcanvas-header {
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* List group */
|
||||
[data-theme="dark"] .list-group-item {
|
||||
background-color: transparent;
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .list-group-item-action:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Nav tabs */
|
||||
[data-theme="dark"] .nav-tabs {
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .nav-tabs .nav-link {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .nav-tabs .nav-link:hover {
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .nav-tabs .nav-link.active {
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color) var(--border-color) var(--bg-surface);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
[data-theme="dark"] .table {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Alerts */
|
||||
[data-theme="dark"] .alert-info {
|
||||
background-color: rgba(59, 130, 246, 0.1);
|
||||
color: #60a5fa;
|
||||
border-color: rgba(59, 130, 246, 0.2);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .alert-light {
|
||||
background-color: var(--bg-surface-alt);
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Card (Bootstrap) */
|
||||
[data-theme="dark"] .card {
|
||||
background-color: var(--bg-surface);
|
||||
border-color: var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Badge overrides for better dark mode contrast */
|
||||
[data-theme="dark"] .badge.bg-secondary {
|
||||
background-color: #475569 !important;
|
||||
}
|
||||
|
||||
/* Text utilities */
|
||||
[data-theme="dark"] .text-muted {
|
||||
color: var(--text-muted) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .text-dark {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .border-bottom {
|
||||
border-bottom-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .border-top {
|
||||
border-top-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* bg-light override */
|
||||
[data-theme="dark"] .bg-light {
|
||||
background-color: var(--bg-surface-alt) !important;
|
||||
}
|
||||
|
||||
/* Toast */
|
||||
[data-theme="dark"] .toast {
|
||||
background-color: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .toast-header {
|
||||
background-color: var(--bg-surface-alt);
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Progress bar */
|
||||
[data-theme="dark"] .progress {
|
||||
background-color: var(--bg-surface-alt);
|
||||
}
|
||||
|
||||
/* Tooltip-like popups */
|
||||
[data-theme="dark"] .dm-delivery-popup,
|
||||
[data-theme="dark"] .path-popup {
|
||||
background-color: #475569;
|
||||
color: #f8fafc;
|
||||
}
|
||||
|
||||
/* Form check / switch */
|
||||
[data-theme="dark"] .form-check-input {
|
||||
background-color: var(--bg-surface-alt);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .form-check-input:checked {
|
||||
background-color: #3b82f6;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Input group */
|
||||
[data-theme="dark"] .input-group-text {
|
||||
background-color: var(--bg-surface-alt);
|
||||
color: var(--text-secondary);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Accordion (if used) */
|
||||
[data-theme="dark"] .accordion-item {
|
||||
background-color: var(--bg-surface);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Dropdown menu (Bootstrap) */
|
||||
[data-theme="dark"] .dropdown-menu {
|
||||
background-color: var(--bg-surface);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dropdown-item {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
[data-theme="dark"] .dropdown-item:hover {
|
||||
background-color: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Spinner */
|
||||
[data-theme="dark"] .spinner-border {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
/* Status bar (bottom) */
|
||||
[data-theme="dark"] .border-top {
|
||||
border-color: var(--border-color) !important;
|
||||
}
|
||||
|
||||
/* QR code container - keep white bg for readability */
|
||||
[data-theme="dark"] .qr-code-container,
|
||||
[data-theme="dark"] #shareChannelQR,
|
||||
[data-theme="dark"] #deviceShareContent .text-center img,
|
||||
[data-theme="dark"] #deviceShareContent canvas {
|
||||
background-color: #ffffff;
|
||||
padding: 8px;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Emoji picker dark mode */
|
||||
[data-theme="dark"] emoji-picker {
|
||||
--background: #1e293b;
|
||||
--border-color: #334155;
|
||||
--indicator-color: #3b82f6;
|
||||
--input-border-color: #334155;
|
||||
--input-font-color: #f8fafc;
|
||||
--input-placeholder-color: #64748b;
|
||||
--outline-color: #3b82f6;
|
||||
--category-font-color: #94a3b8;
|
||||
--button-active-background: #334155;
|
||||
--button-hover-background: #2d3a4e;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
Theme Switcher UI
|
||||
============================================================================= */
|
||||
|
||||
.theme-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background-color: var(--card-bg);
|
||||
}
|
||||
|
||||
.theme-option:hover {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.theme-option.active {
|
||||
border-color: #3b82f6;
|
||||
background-color: var(--bg-active);
|
||||
}
|
||||
|
||||
.theme-option-preview {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.theme-option-preview.light {
|
||||
background: linear-gradient(135deg, #ffffff 50%, #e9ecef 50%);
|
||||
border: 1px solid #dee2e6;
|
||||
}
|
||||
|
||||
.theme-option-preview.dark {
|
||||
background: linear-gradient(135deg, #1e293b 50%, #0f172a 50%);
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
|
||||
.theme-option-label {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.theme-option-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
+172
-1
@@ -689,11 +689,12 @@ function setupEventListeners() {
|
||||
loadDeviceInfo();
|
||||
});
|
||||
|
||||
// Channel selector
|
||||
// Channel selector (dropdown, visible on mobile)
|
||||
document.getElementById('channelSelector').addEventListener('change', function(e) {
|
||||
currentChannelIdx = parseInt(e.target.value);
|
||||
localStorage.setItem('mc_active_channel', currentChannelIdx);
|
||||
loadMessages();
|
||||
updateChannelSidebarActive();
|
||||
|
||||
// Show notification only if we have a valid selection
|
||||
const selectedOption = e.target.options[e.target.selectedIndex];
|
||||
@@ -1701,8 +1702,74 @@ async function loadDeviceStats() {
|
||||
// Load stats when Stats tab is clicked
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
document.getElementById('statsTabBtn')?.addEventListener('shown.bs.tab', loadDeviceStats);
|
||||
document.getElementById('shareTabBtn')?.addEventListener('shown.bs.tab', loadDeviceShare);
|
||||
});
|
||||
|
||||
/**
|
||||
* Load device share tab - generate QR code and URI for sharing own contact
|
||||
*/
|
||||
async function loadDeviceShare() {
|
||||
const container = document.getElementById('deviceShareContent');
|
||||
if (!container) return;
|
||||
|
||||
container.innerHTML = '<div class="text-center py-3"><div class="spinner-border spinner-border-sm"></div> Loading...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/device/info');
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
container.innerHTML = `<div class="alert alert-danger mb-0">${escapeHtml(data.error)}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const info = data.info;
|
||||
if (!info || !info.public_key || !info.name) {
|
||||
container.innerHTML = '<div class="alert alert-warning mb-0">Device info not available</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const contactType = info.adv_type || 1;
|
||||
const uri = `meshcore://contact/add?name=${encodeURIComponent(info.name)}&public_key=${info.public_key}&type=${contactType}`;
|
||||
|
||||
const typeNames = { 1: 'Companion', 2: 'Repeater', 3: 'Room Server', 4: 'Sensor' };
|
||||
|
||||
let html = '<div class="text-center">';
|
||||
html += '<p class="text-muted small mb-3">Share this QR code or URI so others can add your device as a contact.</p>';
|
||||
html += '<div id="shareQrCode" class="d-inline-block mb-3"></div>';
|
||||
html += '<div class="mb-2"><strong>' + escapeHtml(info.name) + '</strong></div>';
|
||||
html += '<div class="text-muted small mb-3">' + escapeHtml(typeNames[contactType] || 'Unknown') + '</div>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div class="mb-3">';
|
||||
html += '<label class="form-label text-muted small">Contact URI:</label>';
|
||||
html += '<div class="input-group">';
|
||||
html += '<input type="text" class="form-control form-control-sm font-monospace" value="' + escapeHtml(uri) + '" readonly id="shareUriInput">';
|
||||
html += '<button class="btn btn-outline-secondary btn-sm" onclick="copyToClipboard(document.getElementById(\'shareUriInput\').value, this)" title="Copy URI"><i class="bi bi-clipboard"></i></button>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// Generate QR code
|
||||
const qrContainer = document.getElementById('shareQrCode');
|
||||
if (qrContainer && typeof QRCode !== 'undefined') {
|
||||
new QRCode(qrContainer, {
|
||||
text: uri,
|
||||
width: 200,
|
||||
height: 200,
|
||||
colorDark: '#000000',
|
||||
colorLight: '#ffffff',
|
||||
correctLevel: QRCode.CorrectLevel.M
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading device share:', error);
|
||||
container.innerHTML = '<div class="alert alert-danger mb-0">Failed to load device info</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Settings Modal
|
||||
// =============================================================================
|
||||
@@ -2888,6 +2955,9 @@ function updateUnreadBadges() {
|
||||
|
||||
// Update app icon badge
|
||||
updateAppBadge();
|
||||
|
||||
// Update channel sidebar badges (lg+ screens)
|
||||
updateChannelSidebarBadges();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3111,6 +3181,9 @@ function populateChannelSelector(channels) {
|
||||
}
|
||||
|
||||
console.log(`[populateChannelSelector] Loaded ${channels.length} channels, active: ${currentChannelIdx}`);
|
||||
|
||||
// Also populate sidebar (lg+ screens)
|
||||
populateChannelSidebar();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3179,6 +3252,104 @@ function displayChannelsList(channels) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate channel sidebar (visible on lg+ screens)
|
||||
*/
|
||||
function populateChannelSidebar() {
|
||||
const list = document.getElementById('channelSidebarList');
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
|
||||
const channels = availableChannels.length > 0
|
||||
? availableChannels
|
||||
: [{index: 0, name: 'Public', key: ''}];
|
||||
|
||||
channels.forEach(channel => {
|
||||
if (!channel || typeof channel.index === 'undefined' || !channel.name) return;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'channel-sidebar-item';
|
||||
item.dataset.channelIdx = channel.index;
|
||||
|
||||
if (channel.index === currentChannelIdx) {
|
||||
item.classList.add('active');
|
||||
}
|
||||
if (mutedChannels.has(channel.index)) {
|
||||
item.classList.add('muted');
|
||||
}
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'channel-name';
|
||||
nameSpan.textContent = channel.name;
|
||||
item.appendChild(nameSpan);
|
||||
|
||||
// Unread badge
|
||||
const unread = unreadCounts[channel.index] || 0;
|
||||
if (unread > 0 && channel.index !== currentChannelIdx && !mutedChannels.has(channel.index)) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'sidebar-unread-badge';
|
||||
badge.textContent = unread;
|
||||
item.appendChild(badge);
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
currentChannelIdx = channel.index;
|
||||
localStorage.setItem('mc_active_channel', currentChannelIdx);
|
||||
loadMessages();
|
||||
updateChannelSidebarActive();
|
||||
// Also sync dropdown for consistency
|
||||
const selector = document.getElementById('channelSelector');
|
||||
if (selector) selector.value = currentChannelIdx;
|
||||
});
|
||||
|
||||
list.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update active state on channel sidebar items
|
||||
*/
|
||||
function updateChannelSidebarActive() {
|
||||
const list = document.getElementById('channelSidebarList');
|
||||
if (!list) return;
|
||||
|
||||
list.querySelectorAll('.channel-sidebar-item').forEach(item => {
|
||||
const idx = parseInt(item.dataset.channelIdx);
|
||||
item.classList.toggle('active', idx === currentChannelIdx);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update unread badges on channel sidebar
|
||||
*/
|
||||
function updateChannelSidebarBadges() {
|
||||
const list = document.getElementById('channelSidebarList');
|
||||
if (!list) return;
|
||||
|
||||
list.querySelectorAll('.channel-sidebar-item').forEach(item => {
|
||||
const idx = parseInt(item.dataset.channelIdx);
|
||||
const unread = unreadCounts[idx] || 0;
|
||||
const isMuted = mutedChannels.has(idx);
|
||||
|
||||
// Update muted state
|
||||
item.classList.toggle('muted', isMuted);
|
||||
|
||||
// Update or remove badge
|
||||
let badge = item.querySelector('.sidebar-unread-badge');
|
||||
if (unread > 0 && idx !== currentChannelIdx && !isMuted) {
|
||||
if (!badge) {
|
||||
badge = document.createElement('span');
|
||||
badge.className = 'sidebar-unread-badge';
|
||||
item.appendChild(badge);
|
||||
}
|
||||
badge.textContent = unread;
|
||||
} else if (badge) {
|
||||
badge.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle mute state for a channel
|
||||
*/
|
||||
|
||||
+319
-2
@@ -140,6 +140,8 @@ function detectCurrentPage() {
|
||||
currentPage = 'pending';
|
||||
} else if (document.getElementById('existingPageContent')) {
|
||||
currentPage = 'existing';
|
||||
} else if (document.getElementById('addPageContent')) {
|
||||
currentPage = 'add';
|
||||
}
|
||||
console.log('Current page:', currentPage);
|
||||
}
|
||||
@@ -155,6 +157,9 @@ function initializePage() {
|
||||
case 'existing':
|
||||
initExistingPage();
|
||||
break;
|
||||
case 'add':
|
||||
initAddPage();
|
||||
break;
|
||||
default:
|
||||
console.warn('Unknown page type');
|
||||
}
|
||||
@@ -2290,7 +2295,7 @@ function createExistingContactCard(contact, index) {
|
||||
actionsDiv.appendChild(mapBtn);
|
||||
}
|
||||
|
||||
// Protect & Delete buttons (only for device contacts)
|
||||
// Protect, Move to cache & Delete buttons (only for device contacts)
|
||||
if (contact.on_device !== false) {
|
||||
const protectBtn = document.createElement('button');
|
||||
protectBtn.className = isProtected ? 'btn btn-sm btn-warning' : 'btn btn-sm btn-outline-warning';
|
||||
@@ -2300,6 +2305,17 @@ function createExistingContactCard(contact, index) {
|
||||
protectBtn.onclick = () => toggleContactProtection(contact.public_key, protectBtn);
|
||||
actionsDiv.appendChild(protectBtn);
|
||||
|
||||
const moveToCacheBtn = document.createElement('button');
|
||||
moveToCacheBtn.className = 'btn btn-sm btn-outline-info';
|
||||
moveToCacheBtn.innerHTML = '<i class="bi bi-cloud-arrow-down"></i> <span class="btn-label">To cache</span>';
|
||||
moveToCacheBtn.title = 'Remove from device, keep in cache';
|
||||
moveToCacheBtn.onclick = () => moveContactToCache(contact);
|
||||
moveToCacheBtn.disabled = isProtected;
|
||||
if (isProtected) {
|
||||
moveToCacheBtn.title = 'Cannot move protected contact';
|
||||
}
|
||||
actionsDiv.appendChild(moveToCacheBtn);
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'btn btn-sm btn-outline-danger';
|
||||
deleteBtn.innerHTML = '<i class="bi bi-trash"></i> <span class="btn-label">Delete</span>';
|
||||
@@ -2311,8 +2327,15 @@ function createExistingContactCard(contact, index) {
|
||||
actionsDiv.appendChild(deleteBtn);
|
||||
}
|
||||
|
||||
// Delete button for cache-only contacts
|
||||
// Push to device & Delete buttons for cache-only contacts
|
||||
if (contact.on_device === false) {
|
||||
const pushToDeviceBtn = document.createElement('button');
|
||||
pushToDeviceBtn.className = 'btn btn-sm btn-outline-success';
|
||||
pushToDeviceBtn.innerHTML = '<i class="bi bi-cpu"></i> <span class="btn-label">To device</span>';
|
||||
pushToDeviceBtn.title = 'Add this contact to the device';
|
||||
pushToDeviceBtn.onclick = () => pushContactToDevice(contact);
|
||||
actionsDiv.appendChild(pushToDeviceBtn);
|
||||
|
||||
const deleteCacheBtn = document.createElement('button');
|
||||
deleteCacheBtn.className = 'btn btn-sm btn-outline-danger';
|
||||
deleteCacheBtn.innerHTML = '<i class="bi bi-trash"></i> <span class="btn-label">Delete</span>';
|
||||
@@ -2493,3 +2516,297 @@ async function confirmDelete() {
|
||||
contactToDelete = null;
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Push to Device / Move to Cache
|
||||
// =============================================================================
|
||||
|
||||
async function pushContactToDevice(contact) {
|
||||
if (!confirm(`Push "${contact.name}" to device?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${contact.public_key}/push-to-device`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast(data.message || `${contact.name} pushed to device`, 'success');
|
||||
setTimeout(() => loadExistingContacts(), 500);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to push contact', 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Network error: ' + error.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function moveContactToCache(contact) {
|
||||
if (!confirm(`Move "${contact.name}" from device to cache?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${contact.public_key}/move-to-cache`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showToast(data.message || `${contact.name} moved to cache`, 'success');
|
||||
setTimeout(() => loadExistingContacts(), 500);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to move contact', 'danger');
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Network error: ' + error.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Add Contact Page
|
||||
// =============================================================================
|
||||
|
||||
const TYPE_LABELS = {1: 'COM', 2: 'REP', 3: 'ROOM', 4: 'SENS'};
|
||||
|
||||
let html5QrCode = null;
|
||||
let qrScannedUri = null;
|
||||
|
||||
function initAddPage() {
|
||||
console.log('Initializing Add Contact page...');
|
||||
|
||||
// URI tab listeners
|
||||
const uriInput = document.getElementById('uriInput');
|
||||
uriInput.addEventListener('input', handleUriInput);
|
||||
document.getElementById('addFromUriBtn').addEventListener('click', () => submitContact('uri'));
|
||||
|
||||
// QR tab listeners
|
||||
document.getElementById('startCameraBtn').addEventListener('click', startQrCamera);
|
||||
document.getElementById('stopCameraBtn').addEventListener('click', stopQrCamera);
|
||||
document.getElementById('qrFileInput').addEventListener('change', handleQrFile);
|
||||
document.getElementById('addFromQrBtn').addEventListener('click', () => submitContact('qr'));
|
||||
|
||||
// Manual tab listeners
|
||||
const manualKey = document.getElementById('manualKey');
|
||||
const manualName = document.getElementById('manualName');
|
||||
manualKey.addEventListener('input', handleManualKeyInput);
|
||||
manualName.addEventListener('input', validateManualForm);
|
||||
document.getElementById('addManualBtn').addEventListener('click', () => submitContact('manual'));
|
||||
|
||||
// Stop camera when switching away from QR tab
|
||||
document.getElementById('tab-qr').addEventListener('hidden.bs.tab', stopQrCamera);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a meshcore:// mobile app URI client-side for preview.
|
||||
* Returns {name, public_key, type} or null.
|
||||
*/
|
||||
function parseMeshcoreUri(uri) {
|
||||
if (!uri || !uri.startsWith('meshcore://')) return null;
|
||||
try {
|
||||
const url = new URL(uri);
|
||||
if (url.hostname !== 'contact' || url.pathname !== '/add') return null;
|
||||
const name = url.searchParams.get('name');
|
||||
const publicKey = url.searchParams.get('public_key');
|
||||
if (!name || !publicKey) return null;
|
||||
const key = publicKey.trim().toLowerCase();
|
||||
if (key.length !== 64 || !/^[0-9a-f]{64}$/.test(key)) return null;
|
||||
let type = parseInt(url.searchParams.get('type') || '1', 10);
|
||||
if (![1,2,3,4].includes(type)) type = 1;
|
||||
return { name: name.trim(), public_key: key, type };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- URI Tab ---
|
||||
|
||||
function handleUriInput() {
|
||||
const uri = document.getElementById('uriInput').value.trim();
|
||||
const preview = document.getElementById('uriPreview');
|
||||
const btn = document.getElementById('addFromUriBtn');
|
||||
|
||||
// Try mobile app format first
|
||||
const parsed = parseMeshcoreUri(uri);
|
||||
if (parsed) {
|
||||
document.getElementById('uriPreviewName').textContent = parsed.name;
|
||||
document.getElementById('uriPreviewKey').textContent = parsed.public_key;
|
||||
document.getElementById('uriPreviewType').textContent = TYPE_LABELS[parsed.type] || 'COM';
|
||||
preview.classList.remove('d-none');
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Hex blob format — can't preview but still valid
|
||||
if (uri.startsWith('meshcore://') && uri.length > 20) {
|
||||
preview.classList.add('d-none');
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
preview.classList.add('d-none');
|
||||
btn.disabled = true;
|
||||
}
|
||||
|
||||
// --- QR Tab ---
|
||||
|
||||
function startQrCamera() {
|
||||
const readerEl = document.getElementById('qrReader');
|
||||
if (!readerEl) return;
|
||||
|
||||
html5QrCode = new Html5Qrcode('qrReader');
|
||||
html5QrCode.start(
|
||||
{ facingMode: 'environment' },
|
||||
{ fps: 10, qrbox: { width: 250, height: 250 } },
|
||||
onQrCodeSuccess,
|
||||
() => {} // ignore scan failures
|
||||
).then(() => {
|
||||
document.getElementById('startCameraBtn').classList.add('d-none');
|
||||
document.getElementById('stopCameraBtn').classList.remove('d-none');
|
||||
}).catch(err => {
|
||||
showQrError('Camera access denied or not available. Try uploading an image instead.');
|
||||
console.error('QR camera error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function stopQrCamera() {
|
||||
if (html5QrCode && html5QrCode.isScanning) {
|
||||
html5QrCode.stop().catch(() => {});
|
||||
}
|
||||
document.getElementById('startCameraBtn').classList.remove('d-none');
|
||||
document.getElementById('stopCameraBtn').classList.add('d-none');
|
||||
}
|
||||
|
||||
function handleQrFile(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const scanner = new Html5Qrcode('qrReader');
|
||||
scanner.scanFile(file, true)
|
||||
.then(decodedText => {
|
||||
onQrCodeSuccess(decodedText);
|
||||
scanner.clear();
|
||||
})
|
||||
.catch(err => {
|
||||
showQrError('Could not read QR code from image. Make sure the image contains a valid QR code.');
|
||||
console.error('QR file scan error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
function onQrCodeSuccess(decodedText) {
|
||||
const resultDiv = document.getElementById('qrResult');
|
||||
const errorDiv = document.getElementById('qrError');
|
||||
const addBtn = document.getElementById('addFromQrBtn');
|
||||
|
||||
errorDiv.classList.add('d-none');
|
||||
|
||||
const parsed = parseMeshcoreUri(decodedText);
|
||||
if (parsed) {
|
||||
document.getElementById('qrResultName').textContent = parsed.name;
|
||||
document.getElementById('qrResultKey').textContent = parsed.public_key;
|
||||
document.getElementById('qrResultType').textContent = TYPE_LABELS[parsed.type] || 'COM';
|
||||
resultDiv.classList.remove('d-none');
|
||||
addBtn.classList.remove('d-none');
|
||||
qrScannedUri = decodedText;
|
||||
stopQrCamera();
|
||||
return;
|
||||
}
|
||||
|
||||
// Hex blob format
|
||||
if (decodedText.startsWith('meshcore://') && decodedText.length > 20) {
|
||||
resultDiv.innerHTML = '<strong>Scanned:</strong> <span class="font-monospace small" style="word-break: break-all;">' +
|
||||
decodedText.substring(0, 60) + '...</span>';
|
||||
resultDiv.classList.remove('d-none');
|
||||
addBtn.classList.remove('d-none');
|
||||
qrScannedUri = decodedText;
|
||||
stopQrCamera();
|
||||
return;
|
||||
}
|
||||
|
||||
showQrError('QR code does not contain a valid meshcore:// URI.');
|
||||
}
|
||||
|
||||
function showQrError(msg) {
|
||||
const errorDiv = document.getElementById('qrError');
|
||||
errorDiv.textContent = msg;
|
||||
errorDiv.classList.remove('d-none');
|
||||
document.getElementById('qrResult').classList.add('d-none');
|
||||
document.getElementById('addFromQrBtn').classList.add('d-none');
|
||||
}
|
||||
|
||||
// --- Manual Tab ---
|
||||
|
||||
function handleManualKeyInput() {
|
||||
const input = document.getElementById('manualKey');
|
||||
// Allow only hex characters
|
||||
input.value = input.value.replace(/[^0-9a-fA-F]/g, '').toLowerCase();
|
||||
document.getElementById('manualKeyCount').textContent = `${input.value.length} / 64 characters`;
|
||||
validateManualForm();
|
||||
}
|
||||
|
||||
function validateManualForm() {
|
||||
const name = document.getElementById('manualName').value.trim();
|
||||
const key = document.getElementById('manualKey').value.trim();
|
||||
const btn = document.getElementById('addManualBtn');
|
||||
btn.disabled = !(name.length > 0 && key.length === 64 && /^[0-9a-f]{64}$/.test(key));
|
||||
}
|
||||
|
||||
// --- Submit ---
|
||||
|
||||
async function submitContact(mode) {
|
||||
const statusDiv = document.getElementById('addStatus');
|
||||
let body = {};
|
||||
|
||||
if (mode === 'uri') {
|
||||
body.uri = document.getElementById('uriInput').value.trim();
|
||||
} else if (mode === 'qr') {
|
||||
body.uri = qrScannedUri;
|
||||
} else if (mode === 'manual') {
|
||||
body.name = document.getElementById('manualName').value.trim();
|
||||
body.public_key = document.getElementById('manualKey').value.trim();
|
||||
body.type = parseInt(document.getElementById('manualType').value, 10);
|
||||
}
|
||||
|
||||
// Show loading
|
||||
statusDiv.className = 'mt-3 alert alert-info';
|
||||
statusDiv.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Adding contact...';
|
||||
statusDiv.classList.remove('d-none');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contacts/manual-add', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
statusDiv.className = 'mt-3 alert alert-success';
|
||||
statusDiv.textContent = data.message || 'Contact added successfully!';
|
||||
// Reset form
|
||||
resetAddForm(mode);
|
||||
} else {
|
||||
statusDiv.className = 'mt-3 alert alert-danger';
|
||||
statusDiv.textContent = data.error || 'Failed to add contact.';
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.className = 'mt-3 alert alert-danger';
|
||||
statusDiv.textContent = 'Network error: ' + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
function resetAddForm(mode) {
|
||||
if (mode === 'uri') {
|
||||
document.getElementById('uriInput').value = '';
|
||||
document.getElementById('uriPreview').classList.add('d-none');
|
||||
document.getElementById('addFromUriBtn').disabled = true;
|
||||
} else if (mode === 'qr') {
|
||||
qrScannedUri = null;
|
||||
document.getElementById('qrResult').classList.add('d-none');
|
||||
document.getElementById('addFromQrBtn').classList.add('d-none');
|
||||
document.getElementById('qrFileInput').value = '';
|
||||
} else if (mode === 'manual') {
|
||||
document.getElementById('manualName').value = '';
|
||||
document.getElementById('manualKey').value = '';
|
||||
document.getElementById('manualKeyCount').textContent = '0 / 64 characters';
|
||||
document.getElementById('addManualBtn').disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
+362
-3
@@ -112,14 +112,79 @@ function connectChatSocket() {
|
||||
if (data.snr != null) tooltip.push(`SNR: ${data.snr}`);
|
||||
if (data.route_type) tooltip.push(`Route: ${data.route_type}`);
|
||||
statusEl.title = tooltip.length > 0 ? tooltip.join(', ') : 'Delivered';
|
||||
// Unwrap status icon from wrapper span
|
||||
const wrapper = statusEl.closest('[data-dm-id]');
|
||||
if (wrapper) {
|
||||
wrapper.replaceWith(statusEl);
|
||||
}
|
||||
// Clear retry counter in actions area
|
||||
const retryInfo = el.querySelector('.dm-retry-info');
|
||||
if (retryInfo) retryInfo.textContent = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Real-time DM retry progress
|
||||
chatSocket.on('dm_retry_status', (data) => {
|
||||
if (!data.dm_id) return;
|
||||
const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
|
||||
if (info) info.textContent = `Attempt ${data.attempt}/${data.max_attempts}`;
|
||||
});
|
||||
|
||||
// DM retry exhausted — mark as failed
|
||||
chatSocket.on('dm_retry_failed', (data) => {
|
||||
if (!data.dm_id) return;
|
||||
// Update status icon
|
||||
const wrapper = document.querySelector(`.dm-status-unknown[data-dm-id="${data.dm_id}"]`);
|
||||
if (wrapper) {
|
||||
const icon = wrapper.querySelector('.dm-status');
|
||||
if (icon) {
|
||||
icon.className = 'bi bi-x-circle dm-status timeout';
|
||||
icon.title = 'Delivery failed — all retries exhausted';
|
||||
}
|
||||
wrapper.removeAttribute('onclick');
|
||||
wrapper.classList.remove('dm-status-unknown');
|
||||
}
|
||||
// Clear retry counter
|
||||
const info = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
|
||||
if (info) info.textContent = '';
|
||||
});
|
||||
|
||||
// Real-time delivery info — show attempt count + route after successful delivery
|
||||
chatSocket.on('dm_delivered_info', (data) => {
|
||||
if (!data.dm_id) return;
|
||||
// Find the message element containing this dm_id
|
||||
const retryEl = document.querySelector(`.dm-retry-info[data-dm-id="${data.dm_id}"]`);
|
||||
if (!retryEl) return;
|
||||
retryEl.textContent = '';
|
||||
const msgDiv = retryEl.closest('.dm-message');
|
||||
if (!msgDiv) return;
|
||||
// Build delivery meta text
|
||||
const parts = [];
|
||||
if (data.attempt && data.max_attempts) parts.push(`Attempt ${data.attempt}/${data.max_attempts}`);
|
||||
const hexRoute = formatDmRoute(data.path);
|
||||
if (hexRoute) parts.push(`Route: ${hexRoute}`);
|
||||
if (parts.length > 0) {
|
||||
let metaEl = msgDiv.querySelector('.dm-delivery-meta');
|
||||
if (!metaEl) {
|
||||
metaEl = document.createElement('div');
|
||||
metaEl.className = 'dm-delivery-meta';
|
||||
const contentDiv = msgDiv.querySelector('div:nth-child(2)');
|
||||
if (contentDiv) contentDiv.after(metaEl);
|
||||
}
|
||||
metaEl.textContent = parts.join(', ');
|
||||
}
|
||||
});
|
||||
|
||||
// Real-time device status
|
||||
chatSocket.on('device_status', (data) => {
|
||||
updateStatus(data.connected ? 'connected' : 'disconnected');
|
||||
});
|
||||
|
||||
// Real-time path change — always refresh contactsList, re-render modal if open
|
||||
chatSocket.on('path_changed', async (data) => {
|
||||
await refreshContactInfoPath();
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
@@ -324,6 +389,25 @@ function setupEventListeners() {
|
||||
scrollToBottomBtn.classList.remove('visible');
|
||||
});
|
||||
}
|
||||
|
||||
// DM Sidebar search input (lg+ screens)
|
||||
const sidebarSearch = document.getElementById('dmSidebarSearch');
|
||||
if (sidebarSearch) {
|
||||
sidebarSearch.addEventListener('input', () => {
|
||||
populateDmSidebar(sidebarSearch.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Desktop info button (lg+ screens)
|
||||
const desktopInfoBtn = document.getElementById('dmDesktopInfoBtn');
|
||||
if (desktopInfoBtn) {
|
||||
desktopInfoBtn.addEventListener('click', () => {
|
||||
const modal = new bootstrap.Modal(document.getElementById('dmContactInfoModal'));
|
||||
populateContactInfoModal();
|
||||
loadPathSection();
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -423,12 +507,17 @@ function populateConversationSelector() {
|
||||
window._dmDropdownItems = { conversations, contacts };
|
||||
renderDropdownItems('');
|
||||
|
||||
// Also populate DM sidebar (lg+ screens), preserving current search filter
|
||||
const sidebarSearch = document.getElementById('dmSidebarSearch');
|
||||
populateDmSidebar(sidebarSearch ? sidebarSearch.value : '');
|
||||
|
||||
// Update search input if conversation is selected — re-resolve name in case contacts loaded
|
||||
if (currentConversationId) {
|
||||
const bestName = resolveConversationName(currentConversationId);
|
||||
if (!isPubkey(bestName)) currentRecipient = bestName;
|
||||
const input = document.getElementById('dmContactSearchInput');
|
||||
if (input) input.value = displayName(currentRecipient);
|
||||
updateDmDesktopHeader();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,6 +608,149 @@ function createDropdownItem(name, conversationId, isUnread, contact) {
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Populate the DM sidebar (visible on lg+ screens).
|
||||
* Mirrors the dropdown data structure but renders as a persistent list.
|
||||
*/
|
||||
function populateDmSidebar(query) {
|
||||
const list = document.getElementById('dmSidebarList');
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = '';
|
||||
|
||||
const q = (query || '').toLowerCase().trim();
|
||||
const { conversations = [], contacts = [] } = window._dmDropdownItems || {};
|
||||
|
||||
const filteredConvs = q
|
||||
? conversations.filter(item => (item.name || '').toLowerCase().includes(q))
|
||||
: conversations;
|
||||
|
||||
const filteredContacts = q
|
||||
? contacts.filter(c => (c.name || '').toLowerCase().includes(q))
|
||||
: contacts;
|
||||
|
||||
if (filteredConvs.length > 0) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'dm-sidebar-separator';
|
||||
sep.textContent = 'Recent conversations';
|
||||
list.appendChild(sep);
|
||||
|
||||
filteredConvs.forEach(item => {
|
||||
list.appendChild(createSidebarItem(
|
||||
item.name, item.conversationId, item.isUnread, item.contact));
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredContacts.length > 0) {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'dm-sidebar-separator';
|
||||
sep.textContent = 'Contacts';
|
||||
list.appendChild(sep);
|
||||
|
||||
filteredContacts.forEach(contact => {
|
||||
const prefix = contact.public_key_prefix || contact.public_key?.substring(0, 12) || '';
|
||||
const convId = `pk_${prefix}`;
|
||||
list.appendChild(createSidebarItem(
|
||||
contact.name, convId, false, contact));
|
||||
});
|
||||
}
|
||||
|
||||
if (filteredConvs.length === 0 && filteredContacts.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'dm-sidebar-separator text-center';
|
||||
empty.textContent = q ? 'No matches' : 'No contacts available';
|
||||
list.appendChild(empty);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a single sidebar item element for the DM sidebar.
|
||||
*/
|
||||
function createSidebarItem(name, conversationId, isUnread, contact) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'dm-sidebar-item';
|
||||
el.dataset.conversationId = conversationId;
|
||||
|
||||
if (conversationId === currentConversationId) {
|
||||
el.classList.add('active');
|
||||
}
|
||||
|
||||
if (isUnread) {
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'sidebar-unread-dot';
|
||||
el.appendChild(dot);
|
||||
}
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'contact-name';
|
||||
nameSpan.textContent = displayName(name);
|
||||
el.appendChild(nameSpan);
|
||||
|
||||
if (contact && contact.type_label) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'badge';
|
||||
const colors = { COM: 'bg-primary', REP: 'bg-success', ROOM: 'bg-info', SENS: 'bg-warning' };
|
||||
badge.classList.add(colors[contact.type_label] || 'bg-secondary');
|
||||
badge.textContent = contact.type_label;
|
||||
el.appendChild(badge);
|
||||
}
|
||||
|
||||
el.addEventListener('click', () => selectConversationFromSidebar(conversationId, name));
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle selection from the DM sidebar.
|
||||
*/
|
||||
async function selectConversationFromSidebar(conversationId, name) {
|
||||
await selectConversation(conversationId);
|
||||
if (name && !isPubkey(name)) currentRecipient = name;
|
||||
updateDmSidebarActive();
|
||||
// Move focus to message input
|
||||
const msgInput = document.getElementById('dmMessageInput');
|
||||
if (msgInput && !msgInput.disabled) msgInput.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Update active state on DM sidebar items.
|
||||
*/
|
||||
function updateDmSidebarActive() {
|
||||
const list = document.getElementById('dmSidebarList');
|
||||
if (!list) return;
|
||||
|
||||
list.querySelectorAll('.dm-sidebar-item').forEach(item => {
|
||||
const convId = item.dataset.conversationId;
|
||||
// Flexible matching: handle prefix upgrades
|
||||
let isActive = convId === currentConversationId;
|
||||
if (!isActive && currentConversationId && convId) {
|
||||
// Match if one is a prefix of the other (pk_ based)
|
||||
if (convId.startsWith('pk_') && currentConversationId.startsWith('pk_')) {
|
||||
const a = convId.substring(3);
|
||||
const b = currentConversationId.substring(3);
|
||||
isActive = a.startsWith(b) || b.startsWith(a);
|
||||
}
|
||||
}
|
||||
item.classList.toggle('active', isActive);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the desktop contact header (visible on lg+ screens).
|
||||
*/
|
||||
function updateDmDesktopHeader() {
|
||||
const nameEl = document.getElementById('dmDesktopContactName');
|
||||
const infoBtn = document.getElementById('dmDesktopInfoBtn');
|
||||
if (!nameEl) return;
|
||||
|
||||
if (currentRecipient) {
|
||||
nameEl.textContent = displayName(currentRecipient);
|
||||
if (infoBtn) infoBtn.disabled = false;
|
||||
} else {
|
||||
nameEl.textContent = '';
|
||||
if (infoBtn) infoBtn.disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle selection from the searchable dropdown.
|
||||
*/
|
||||
@@ -582,6 +814,10 @@ async function selectConversation(conversationId) {
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
|
||||
// Update desktop header and sidebar (lg+ screens)
|
||||
updateDmDesktopHeader();
|
||||
updateDmSidebarActive();
|
||||
|
||||
// Load messages
|
||||
await loadMessages();
|
||||
}
|
||||
@@ -623,11 +859,15 @@ function clearConversation() {
|
||||
<div class="dm-empty-state">
|
||||
<i class="bi bi-envelope"></i>
|
||||
<p class="mb-1">Select a conversation</p>
|
||||
<small class="text-muted">Choose from the dropdown above or start a new chat from channel messages</small>
|
||||
<small class="text-muted">Choose from the list or start a new chat from channel messages</small>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Update desktop header and sidebar
|
||||
updateDmDesktopHeader();
|
||||
updateDmSidebarActive();
|
||||
|
||||
updateCharCounter();
|
||||
}
|
||||
|
||||
@@ -800,6 +1040,34 @@ function populateContactInfoModal() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh contact data from device and re-render Contact Info modal if open.
|
||||
* Uses ?refresh=true to bypass server-side cache.
|
||||
*/
|
||||
async function refreshContactInfoPath() {
|
||||
try {
|
||||
const response = await fetch('/api/contacts/detailed?refresh=true');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
contactsList = (data.contacts || []).sort((a, b) =>
|
||||
(a.name || '').localeCompare(b.name || ''));
|
||||
contactsMap = {};
|
||||
contactsList.forEach(c => {
|
||||
if (c.public_key) contactsMap[c.public_key] = c;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[DM] refreshContactInfoPath fetch error:', e);
|
||||
return;
|
||||
}
|
||||
// Re-populate modal if still open
|
||||
const modalEl = document.getElementById('dmContactInfoModal');
|
||||
if (modalEl && modalEl.classList.contains('show')) {
|
||||
populateContactInfoModal();
|
||||
loadPathSection();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for current conversation
|
||||
*/
|
||||
@@ -881,18 +1149,27 @@ function displayMessages(messages) {
|
||||
let statusIcon = '';
|
||||
if (msg.is_own) {
|
||||
const ackAttr = msg.expected_ack ? ` data-ack="${msg.expected_ack}"` : '';
|
||||
const dmIdAttr = msg.id ? ` data-dm-id="${msg.id}"` : '';
|
||||
if (msg.status === 'delivered') {
|
||||
let title = 'Delivered';
|
||||
if (msg.delivery_attempt && msg.delivery_max_attempts) {
|
||||
title += ` (${msg.delivery_attempt}/${msg.delivery_max_attempts})`;
|
||||
}
|
||||
const route = formatDmRoute(msg.delivery_path);
|
||||
if (route) title += `, Route: ${route}`;
|
||||
else if (msg.delivery_route) title += `, ${msg.delivery_route.replace('PATH_', '')}`;
|
||||
if (msg.delivery_snr !== null && msg.delivery_snr !== undefined) {
|
||||
title += `, SNR: ${msg.delivery_snr.toFixed(1)} dB`;
|
||||
}
|
||||
if (msg.delivery_route) title += ` (${msg.delivery_route})`;
|
||||
statusIcon = `<i class="bi bi-check2 dm-status delivered"${ackAttr} title="${title}"></i>`;
|
||||
} else if (msg.status === 'failed') {
|
||||
statusIcon = `<span${dmIdAttr}><i class="bi bi-x-circle dm-status timeout"${ackAttr} title="Delivery failed — all retries exhausted"></i></span>`;
|
||||
} else if (msg.status === 'pending') {
|
||||
statusIcon = `<i class="bi bi-clock dm-status pending"${ackAttr} title="Sending..."></i>`;
|
||||
} else {
|
||||
// No ACK received — show clickable "?" with explanation
|
||||
statusIcon = `<span class="dm-status-unknown" onclick="showDeliveryInfo(this)"><i class="bi bi-question-circle dm-status unknown"${ackAttr}></i></span>`;
|
||||
// No ACK received — show clickable "?" with retry counter
|
||||
statusIcon = `<span class="dm-status-unknown"${dmIdAttr} onclick="showDeliveryInfo(this)"><i class="bi bi-question-circle dm-status unknown"${ackAttr}></i></span>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -908,6 +1185,29 @@ function displayMessages(messages) {
|
||||
}
|
||||
}
|
||||
|
||||
// Delivery info for delivered/failed messages (attempt count + route)
|
||||
let deliveryMeta = '';
|
||||
if (msg.is_own && (msg.status === 'delivered' || msg.status === 'failed')
|
||||
&& msg.delivery_attempt) {
|
||||
const parts = [];
|
||||
if (msg.delivery_attempt && msg.delivery_max_attempts) {
|
||||
parts.push(`Attempt ${msg.delivery_attempt}/${msg.delivery_max_attempts}`);
|
||||
}
|
||||
// Show route only for delivered messages (not failed)
|
||||
if (msg.status === 'delivered') {
|
||||
const routeHtml = buildDmRouteHtml(msg.delivery_path);
|
||||
if (routeHtml) {
|
||||
parts.push(routeHtml);
|
||||
} else if (msg.delivery_route) {
|
||||
parts.push(msg.delivery_route.replace('PATH_', ''));
|
||||
}
|
||||
}
|
||||
deliveryMeta = `<div class="dm-delivery-meta">${parts.join(', ')}</div>`;
|
||||
}
|
||||
|
||||
// Retry counter placeholder (same line as delivery meta)
|
||||
const retryInfo = msg.is_own ? `<div class="dm-delivery-meta dm-retry-info" data-dm-id="${msg.id || ''}"></div>` : '';
|
||||
|
||||
// Resend button for own messages
|
||||
const resendBtn = msg.is_own ? `
|
||||
<div class="dm-actions">
|
||||
@@ -923,6 +1223,8 @@ function displayMessages(messages) {
|
||||
${statusIcon}
|
||||
</div>
|
||||
<div>${processMessageContent(msg.content)}</div>
|
||||
${deliveryMeta}
|
||||
${retryInfo}
|
||||
${meta}
|
||||
${resendBtn}
|
||||
`;
|
||||
@@ -1072,6 +1374,62 @@ function resendMessage(content) {
|
||||
input.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a hex path as route string (e.g. "5e34e761" → "5e→34→e7→61")
|
||||
* Truncates if more than 4 segments. Returns '' for non-hex strings.
|
||||
*/
|
||||
function formatDmRoute(hexPath) {
|
||||
if (!hexPath || !/^[0-9a-f]+$/i.test(hexPath)) return '';
|
||||
const segments = hexPath.match(/.{1,2}/g) || [];
|
||||
if (segments.length === 0) return '';
|
||||
if (segments.length > 4) {
|
||||
return `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`;
|
||||
}
|
||||
return segments.join('\u2192');
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a clickable route span for DM delivery meta.
|
||||
* Short routes are plain text; long routes (>4 hops) are clickable to show full path.
|
||||
*/
|
||||
function buildDmRouteHtml(hexPath) {
|
||||
if (!hexPath || !/^[0-9a-f]+$/i.test(hexPath)) return '';
|
||||
const segments = hexPath.match(/.{1,2}/g) || [];
|
||||
if (segments.length === 0) return '';
|
||||
const short = segments.length > 4
|
||||
? `${segments[0]}\u2192...\u2192${segments[segments.length - 1]}`
|
||||
: segments.join('\u2192');
|
||||
if (segments.length <= 4) return `Route: ${short}`;
|
||||
const escaped = hexPath.replace(/'/g, "\\'");
|
||||
return `<span class="dm-route-link" onclick="showDmRoutePopup(this, '${escaped}')">Route: ${short}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show full route popup for DM delivery path (same style as channel path popup)
|
||||
*/
|
||||
function showDmRoutePopup(element, hexPath) {
|
||||
const existing = document.querySelector('.path-popup');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const segments = hexPath.match(/.{1,2}/g) || [];
|
||||
const fullRoute = segments.join(' \u2192 ');
|
||||
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'path-popup';
|
||||
popup.innerHTML = `<div class="path-entry">${fullRoute}<span class="path-detail">Hops: ${segments.length}</span></div>`;
|
||||
element.style.position = 'relative';
|
||||
element.appendChild(popup);
|
||||
|
||||
const dismiss = () => popup.remove();
|
||||
setTimeout(dismiss, 8000);
|
||||
document.addEventListener('click', function handler(e) {
|
||||
if (!element.contains(e.target)) {
|
||||
dismiss();
|
||||
document.removeEventListener('click', handler);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Show delivery info popup (mobile-friendly, same pattern as showPathPopup)
|
||||
*/
|
||||
@@ -1939,6 +2297,7 @@ function setupPathFormHandlers(pubkey) {
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showNotification('Device path reset to FLOOD', 'info');
|
||||
await refreshContactInfoPath();
|
||||
} else {
|
||||
showNotification(data.error || 'Reset failed', 'danger');
|
||||
}
|
||||
|
||||
+74
-3
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="light" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
@@ -12,6 +12,15 @@
|
||||
<link rel="manifest" href="{{ url_for('static', filename='manifest.json') }}">
|
||||
<link rel="shortcut icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
|
||||
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Bootstrap 5 CSS (local) -->
|
||||
<link href="{{ url_for('static', filename='vendor/bootstrap/css/bootstrap.min.css') }}" rel="stylesheet">
|
||||
<!-- Bootstrap Icons (local) -->
|
||||
@@ -24,6 +33,8 @@
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<!-- Theme CSS (light/dark mode) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
</head>
|
||||
@@ -312,6 +323,9 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabDeviceStats" type="button" id="statsTabBtn">Stats</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabDeviceShare" type="button" id="shareTabBtn">Share</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tabDeviceInfo">
|
||||
@@ -328,6 +342,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tabDeviceShare">
|
||||
<div id="deviceShareContent">
|
||||
<div class="text-center py-3 text-muted">
|
||||
Click to generate share code
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,6 +371,9 @@
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsChat" type="button">Group Chat</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tabSettingsAppearance" type="button">Appearance</button>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div class="tab-pane fade show active" id="tabSettingsMessages">
|
||||
@@ -364,7 +388,7 @@
|
||||
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settDirectMaxRetries" min="0" max="20" value="3"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="ps-0">Flood retries <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Flood attempts after direct retries exhausted"><i class="bi bi-info-circle"></i></span></td>
|
||||
<td class="ps-0">Flood retries <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Flood attempts after direct retries exhausted (when no configured paths)"><i class="bi bi-info-circle"></i></span></td>
|
||||
<td class="pe-0"><input type="number" class="form-control form-control-sm" id="settDirectFloodRetries" min="0" max="5" value="1"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -378,7 +402,7 @@
|
||||
<table class="table table-sm table-borderless mb-3 align-middle">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="ps-0">Max retries <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Flood attempts when no path is known"><i class="bi bi-info-circle"></i></span></td>
|
||||
<td class="ps-0">Max retries <span class="badge rounded-pill text-muted" data-bs-toggle="tooltip" title="Flood retry attempts (also used after path rotation)"><i class="bi bi-info-circle"></i></span></td>
|
||||
<td class="pe-0" style="width:5rem"><input type="number" class="form-control form-control-sm" id="settFloodMaxRetries" min="0" max="10" value="3"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -422,6 +446,29 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="tabSettingsAppearance">
|
||||
<h6 class="text-muted mb-3">Theme</h6>
|
||||
<div class="d-flex flex-column gap-2">
|
||||
<div class="theme-option active" data-theme-value="light" onclick="setTheme('light')">
|
||||
<div class="theme-option-preview light">
|
||||
<i class="bi bi-sun"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="theme-option-label">Light</div>
|
||||
<div class="theme-option-desc">Classic bright interface</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-option" data-theme-value="dark" onclick="setTheme('dark')">
|
||||
<div class="theme-option-preview dark">
|
||||
<i class="bi bi-moon-stars" style="color: #60a5fa;"></i>
|
||||
</div>
|
||||
<div>
|
||||
<div class="theme-option-label">Dark</div>
|
||||
<div class="theme-option-desc">Easy on the eyes, deep navy palette</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -610,6 +657,9 @@
|
||||
<script src="{{ url_for('static', filename='vendor/socket.io/socket.io.min.js') }}"></script>
|
||||
|
||||
<!-- Custom JS -->
|
||||
<!-- QR Code generator (for Device Share) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcodejs@1.0.0/qrcode.min.js"></script>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
|
||||
<!-- PWA Viewport Fix for Android -->
|
||||
@@ -643,6 +693,27 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Theme Switching -->
|
||||
<script>
|
||||
function setTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-bs-theme', theme);
|
||||
localStorage.setItem('mc-webui-theme', theme);
|
||||
// Update theme selector UI
|
||||
document.querySelectorAll('.theme-option').forEach(function(el) {
|
||||
el.classList.toggle('active', el.getAttribute('data-theme-value') === theme);
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize theme selector UI on settings modal open
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var current = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.querySelectorAll('.theme-option').forEach(function(el) {
|
||||
el.classList.toggle('active', el.getAttribute('data-theme-value') === current);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
{% extends "contacts_base.html" %}
|
||||
|
||||
{% block title %}Add Contact - mc-webui{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<!-- html5-qrcode for QR scanning -->
|
||||
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block page_content %}
|
||||
<div id="addPageContent" class="p-3">
|
||||
<!-- Page Header -->
|
||||
<div class="mb-3">
|
||||
<h4 class="mb-2">
|
||||
<i class="bi bi-person-plus"></i> Add Contact
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex gap-2 mb-3">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="navigateTo('/contacts/manage');">
|
||||
<i class="bi bi-arrow-left"></i> Back
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Input Mode Tabs -->
|
||||
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-uri" data-bs-toggle="tab" data-bs-target="#pane-uri" type="button" role="tab">
|
||||
<i class="bi bi-link-45deg"></i> URI
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-qr" data-bs-toggle="tab" data-bs-target="#pane-qr" type="button" role="tab">
|
||||
<i class="bi bi-qr-code-scan"></i> QR Code
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-manual" data-bs-toggle="tab" data-bs-target="#pane-manual" type="button" role="tab">
|
||||
<i class="bi bi-pencil"></i> Manual
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content">
|
||||
<!-- URI Paste Tab -->
|
||||
<div class="tab-pane fade show active" id="pane-uri" role="tabpanel">
|
||||
<div class="mb-3">
|
||||
<label for="uriInput" class="form-label">MeshCore URI:</label>
|
||||
<textarea class="form-control font-monospace" id="uriInput" rows="3"
|
||||
placeholder="meshcore://contact/add?name=...&public_key=...&type=..."></textarea>
|
||||
<small class="form-text text-muted">Paste a meshcore:// URI from the MeshCore mobile app</small>
|
||||
</div>
|
||||
<!-- URI Preview -->
|
||||
<div id="uriPreview" class="alert alert-info d-none mb-3">
|
||||
<strong>Preview:</strong>
|
||||
<div><span class="text-muted">Name:</span> <span id="uriPreviewName"></span></div>
|
||||
<div><span class="text-muted">Key:</span> <span id="uriPreviewKey" class="font-monospace small" style="word-break: break-all;"></span></div>
|
||||
<div><span class="text-muted">Type:</span> <span id="uriPreviewType"></span></div>
|
||||
</div>
|
||||
<button class="btn btn-success" id="addFromUriBtn" disabled>
|
||||
<i class="bi bi-plus-circle"></i> Add Contact
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- QR Code Tab -->
|
||||
<div class="tab-pane fade" id="pane-qr" role="tabpanel">
|
||||
<!-- Camera Scanner -->
|
||||
<div id="qrScannerContainer" class="mb-3">
|
||||
<div id="qrReader" style="width: 100%; max-width: 500px;"></div>
|
||||
<div id="qrCameraButtons" class="d-flex gap-2 mt-2">
|
||||
<button class="btn btn-primary btn-sm" id="startCameraBtn">
|
||||
<i class="bi bi-camera-video"></i> Start Camera
|
||||
</button>
|
||||
<button class="btn btn-outline-secondary btn-sm d-none" id="stopCameraBtn">
|
||||
<i class="bi bi-stop-circle"></i> Stop Camera
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- File Upload Fallback -->
|
||||
<div class="mb-3">
|
||||
<label for="qrFileInput" class="form-label">Or upload a QR code image:</label>
|
||||
<input type="file" class="form-control" id="qrFileInput" accept="image/*">
|
||||
</div>
|
||||
<!-- QR Result -->
|
||||
<div id="qrResult" class="alert alert-success d-none mb-3">
|
||||
<strong>Scanned:</strong>
|
||||
<div><span class="text-muted">Name:</span> <span id="qrResultName"></span></div>
|
||||
<div><span class="text-muted">Key:</span> <span id="qrResultKey" class="font-monospace small" style="word-break: break-all;"></span></div>
|
||||
<div><span class="text-muted">Type:</span> <span id="qrResultType"></span></div>
|
||||
</div>
|
||||
<div id="qrError" class="alert alert-danger d-none mb-3"></div>
|
||||
<button class="btn btn-success d-none" id="addFromQrBtn">
|
||||
<i class="bi bi-plus-circle"></i> Add Contact
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Manual Entry Tab -->
|
||||
<div class="tab-pane fade" id="pane-manual" role="tabpanel">
|
||||
<div class="mb-3">
|
||||
<label for="manualName" class="form-label">Name:</label>
|
||||
<input type="text" class="form-control" id="manualName" placeholder="Contact name" maxlength="32">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="manualKey" class="form-label">Public Key (64 hex chars):</label>
|
||||
<input type="text" class="form-control font-monospace" id="manualKey"
|
||||
placeholder="e.g. a1b2c3d4..." maxlength="64" pattern="[0-9a-fA-F]{64}">
|
||||
<small class="form-text text-muted" id="manualKeyCount">0 / 64 characters</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="manualType" class="form-label">Contact Type:</label>
|
||||
<select class="form-select" id="manualType">
|
||||
<option value="1" selected>COM (Companion)</option>
|
||||
<option value="2">REP (Repeater)</option>
|
||||
<option value="3">ROOM (Room Server)</option>
|
||||
<option value="4">SENS (Sensor)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn btn-success" id="addManualBtn" disabled>
|
||||
<i class="bi bi-plus-circle"></i> Add Contact
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Messages -->
|
||||
<div id="addStatus" class="mt-3 d-none"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -32,6 +32,15 @@
|
||||
<i class="bi bi-list-ul"></i> Manage Contacts
|
||||
</h5>
|
||||
|
||||
<!-- Add Contact Card -->
|
||||
<div class="nav-card" onclick="navigateTo('/contacts/add');" style="border-left: 4px solid #198754;">
|
||||
<div>
|
||||
<h6><i class="bi bi-person-plus"></i> Add Contact</h6>
|
||||
<small class="text-muted">Add from URI, QR code, or manual entry</small>
|
||||
</div>
|
||||
<i class="bi bi-chevron-right text-muted"></i>
|
||||
</div>
|
||||
|
||||
<!-- Pending Contacts Card -->
|
||||
<div class="nav-card" onclick="navigateTo('/contacts/pending');">
|
||||
<div>
|
||||
|
||||
@@ -3,208 +3,6 @@
|
||||
{% block title %}Contact Management - mc-webui{% endblock %}
|
||||
|
||||
{% block extra_head %}
|
||||
<style>
|
||||
/* Mobile-first custom styles for Contact Management */
|
||||
/* Compact manual approval section */
|
||||
.compact-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: #6c757d;
|
||||
cursor: help;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.info-icon:hover {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.pending-contact-card {
|
||||
background-color: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
margin-bottom: 0.5rem;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.contact-key {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
word-break: break-all;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
min-height: 44px; /* Touch-friendly size */
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 1.5rem 1rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state.compact {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.empty-state.compact i {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.info-badge {
|
||||
display: inline-block;
|
||||
background-color: #e7f3ff;
|
||||
color: #0c5460;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Existing Contacts Styles */
|
||||
.existing-contact-card {
|
||||
background-color: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.existing-contact-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.contact-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.counter-badge {
|
||||
font-size: 1rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.counter-ok {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.counter-warning {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.counter-alarm {
|
||||
background-color: #dc3545;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.search-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-toolbar input,
|
||||
.search-toolbar select {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Scrollable contacts lists */
|
||||
#pendingList {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
#existingList {
|
||||
/* No max-height limit - let it use available space */
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
/* Dynamic height based on viewport */
|
||||
max-height: calc(100vh - 400px);
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#existingList {
|
||||
max-height: calc(100vh - 450px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
#existingList::-webkit-scrollbar,
|
||||
#pendingList::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
#existingList::-webkit-scrollbar-track,
|
||||
#pendingList::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#existingList::-webkit-scrollbar-thumb,
|
||||
#pendingList::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#existingList::-webkit-scrollbar-thumb:hover,
|
||||
#pendingList::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Compact section headers */
|
||||
.section-compact {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="light" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>{% block title %}Contact Management - mc-webui{% endblock %}</title>
|
||||
|
||||
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
@@ -24,126 +33,11 @@
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<!-- Theme CSS (light/dark mode) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
|
||||
<style>
|
||||
/* Mobile-first custom styles for Contact Management */
|
||||
/* Compact manual approval section */
|
||||
.compact-setting {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: #6c757d;
|
||||
cursor: help;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.info-icon:hover {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.pending-contact-card {
|
||||
background-color: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.contact-name {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #212529;
|
||||
margin-bottom: 0.1rem;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.contact-key {
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.85rem;
|
||||
color: #6c757d;
|
||||
word-break: break-all;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.contact-key.clickable-key {
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, background-color 0.15s;
|
||||
padding: 0.15rem 0.3rem;
|
||||
margin-left: -0.3rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.contact-key.clickable-key:hover {
|
||||
color: #0d6efd;
|
||||
background-color: #e7f1ff;
|
||||
}
|
||||
|
||||
.contact-key.clickable-key.copied {
|
||||
color: #198754;
|
||||
background-color: #d1e7dd;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 1.5rem 1rem;
|
||||
color: #6c757d;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.empty-state.compact {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.empty-state.compact i {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.info-badge {
|
||||
display: inline-block;
|
||||
background-color: #e7f3ff;
|
||||
color: #0c5460;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Existing Contacts Styles */
|
||||
.existing-contact-card {
|
||||
background-color: white;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.existing-contact-card:hover {
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Protected contact styling */
|
||||
/* Contact Management page layout overrides */
|
||||
.protection-indicator {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
@@ -167,7 +61,7 @@
|
||||
|
||||
.type-filter-badge[data-type="COM"] {
|
||||
color: #0d6efd;
|
||||
background-color: white;
|
||||
background-color: var(--map-badge-inactive-bg);
|
||||
border: 2px solid #0d6efd;
|
||||
}
|
||||
.type-filter-badge[data-type="COM"].active {
|
||||
@@ -177,7 +71,7 @@
|
||||
|
||||
.type-filter-badge[data-type="REP"] {
|
||||
color: #198754;
|
||||
background-color: white;
|
||||
background-color: var(--map-badge-inactive-bg);
|
||||
border: 2px solid #198754;
|
||||
}
|
||||
.type-filter-badge[data-type="REP"].active {
|
||||
@@ -187,7 +81,7 @@
|
||||
|
||||
.type-filter-badge[data-type="ROOM"] {
|
||||
color: #0dcaf0;
|
||||
background-color: white;
|
||||
background-color: var(--map-badge-inactive-bg);
|
||||
border: 2px solid #0dcaf0;
|
||||
}
|
||||
.type-filter-badge[data-type="ROOM"].active {
|
||||
@@ -197,7 +91,7 @@
|
||||
|
||||
.type-filter-badge[data-type="SENS"] {
|
||||
color: #ffc107;
|
||||
background-color: white;
|
||||
background-color: var(--map-badge-inactive-bg);
|
||||
border: 2px solid #ffc107;
|
||||
}
|
||||
.type-filter-badge[data-type="SENS"].active {
|
||||
@@ -205,97 +99,17 @@
|
||||
background-color: #ffc107;
|
||||
}
|
||||
|
||||
.contact-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.counter-badge {
|
||||
font-size: 1rem;
|
||||
padding: 0.35rem 0.75rem;
|
||||
}
|
||||
|
||||
.counter-ok {
|
||||
background-color: #28a745;
|
||||
}
|
||||
|
||||
.counter-warning {
|
||||
background-color: #ffc107;
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.counter-alarm {
|
||||
background-color: #dc3545;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.7; }
|
||||
}
|
||||
|
||||
.search-toolbar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.search-toolbar input,
|
||||
.search-toolbar select {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Scrollable contacts lists */
|
||||
#pendingList {
|
||||
height: calc(100vh - 280px);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* Scrollable contacts lists - use flexbox to fill remaining space */
|
||||
#pendingList,
|
||||
#existingList {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
height: calc(100vh - 260px);
|
||||
min-height: 300px;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styling */
|
||||
#existingList::-webkit-scrollbar,
|
||||
#pendingList::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
#existingList::-webkit-scrollbar-track,
|
||||
#pendingList::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#existingList::-webkit-scrollbar-thumb,
|
||||
#pendingList::-webkit-scrollbar-thumb {
|
||||
background: #888;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
#existingList::-webkit-scrollbar-thumb:hover,
|
||||
#pendingList::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
/* Compact section headers */
|
||||
.section-compact {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
/* NEW: Full-screen lists for dedicated pages - fill remaining space */
|
||||
/* Full-screen lists for dedicated pages */
|
||||
.contacts-list-fullscreen {
|
||||
flex: 1 1 0;
|
||||
min-height: 0;
|
||||
@@ -304,10 +118,10 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* NEW: Navigation cards on manage page */
|
||||
/* Navigation cards on manage page */
|
||||
.nav-card {
|
||||
background: white;
|
||||
border: 1px solid #dee2e6;
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 0.5rem;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
@@ -319,7 +133,7 @@
|
||||
}
|
||||
|
||||
.nav-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: var(--card-shadow-hover);
|
||||
}
|
||||
|
||||
.nav-card h6 {
|
||||
@@ -341,8 +155,7 @@
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
|
||||
/* NEW: Back buttons */
|
||||
/* Back buttons */
|
||||
.back-buttons {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -355,7 +168,7 @@
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* NEW: Cleanup section on manage page */
|
||||
/* Cleanup section on manage page */
|
||||
.cleanup-section {
|
||||
background-color: #fff3cd;
|
||||
border: 1px solid #ffc107;
|
||||
@@ -364,6 +177,11 @@
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .cleanup-section {
|
||||
background-color: rgba(255, 193, 7, 0.1);
|
||||
border-color: rgba(255, 193, 7, 0.3);
|
||||
}
|
||||
|
||||
.cleanup-section h6 {
|
||||
color: #856404;
|
||||
margin-bottom: 0.75rem;
|
||||
@@ -372,6 +190,10 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .cleanup-section h6 {
|
||||
color: #ffc107;
|
||||
}
|
||||
|
||||
/* Override global overflow: hidden from style.css for Contact Management pages */
|
||||
html, body {
|
||||
overflow: auto !important;
|
||||
@@ -381,6 +203,8 @@
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-body);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
main {
|
||||
@@ -400,7 +224,6 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Page content containers should fill available space */
|
||||
#pendingPageContent,
|
||||
#existingPageContent {
|
||||
flex: 1 1 0;
|
||||
@@ -408,10 +231,6 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Mobile responsiveness */
|
||||
@media (max-width: 768px) {
|
||||
}
|
||||
</style>
|
||||
|
||||
{% block extra_head %}{% endblock %}
|
||||
|
||||
+131
-252
@@ -1,10 +1,19 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" data-theme="light" data-bs-theme="light">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<title>Direct Messages - mc-webui</title>
|
||||
|
||||
<!-- Theme: apply saved preference before CSS loads to prevent flash -->
|
||||
<script>
|
||||
(function() {
|
||||
var t = localStorage.getItem('mc-webui-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-bs-theme', t);
|
||||
})();
|
||||
</script>
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="{{ url_for('static', filename='images/apple-touch-icon.png') }}">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="{{ url_for('static', filename='images/favicon-32x32.png') }}">
|
||||
@@ -24,275 +33,145 @@
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<!-- Theme CSS (light/dark mode) -->
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/theme.css') }}">
|
||||
|
||||
<!-- Emoji Picker (local) -->
|
||||
<script type="module" src="{{ url_for('static', filename='vendor/emoji-picker-element/index.js') }}"></script>
|
||||
<style>
|
||||
emoji-picker {
|
||||
--emoji-size: 1.5rem;
|
||||
--num-columns: 8;
|
||||
}
|
||||
.emoji-picker-container {
|
||||
position: relative;
|
||||
}
|
||||
.emoji-picker-popup {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
margin-bottom: 0.5rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.emoji-picker-popup.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
@media (max-width: 576px) {
|
||||
emoji-picker {
|
||||
--emoji-size: 1.25rem;
|
||||
--num-columns: 6;
|
||||
}
|
||||
.emoji-picker-popup {
|
||||
right: auto;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Searchable contact dropdown */
|
||||
.dm-contact-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1050;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #dee2e6;
|
||||
border-top: none;
|
||||
border-radius: 0 0 0.375rem 0.375rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
.dm-contact-item {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.dm-contact-item:hover,
|
||||
.dm-contact-item.active {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
.dm-contact-item .contact-name {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dm-contact-item .badge {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.dm-dropdown-separator {
|
||||
padding: 0.25rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6c757d;
|
||||
background: #f8f9fa;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Path management styles */
|
||||
.path-list-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid #dee2e6;
|
||||
border-radius: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
background: #fff;
|
||||
}
|
||||
.path-list-item.primary {
|
||||
border-color: #0d6efd;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
.path-list-item .path-hex {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.path-list-item .path-label {
|
||||
color: #6c757d;
|
||||
font-size: 0.7rem;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.path-list-item .path-actions {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
gap: 0.15rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.path-list-item .path-actions .btn {
|
||||
padding: 0 0.25rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.path-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.path-section-header h6 {
|
||||
font-size: 0.85rem;
|
||||
margin: 0;
|
||||
}
|
||||
.repeater-picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.repeater-picker-item:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
.repeater-picker-item .badge {
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
.path-uniqueness-warning {
|
||||
color: #dc3545;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
/* Leaflet z-index fix for Bootstrap modal */
|
||||
#rptLeafletMap { z-index: 1; }
|
||||
#rptLeafletMap .leaflet-top,
|
||||
#rptLeafletMap .leaflet-bottom { z-index: 1000; }
|
||||
/* Map modal backdrop stacks above Contact Info modal */
|
||||
</style>
|
||||
<!-- Inline styles removed - now in style.css -->
|
||||
</head>
|
||||
<body>
|
||||
<!-- Main Content -->
|
||||
<main>
|
||||
<div class="container-fluid d-flex flex-column" style="height: 100vh;">
|
||||
<!-- Conversation Selector Bar -->
|
||||
<div class="row border-bottom bg-light">
|
||||
<div class="col-12 p-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<!-- Searchable contact selector -->
|
||||
<div class="position-relative flex-grow-1" id="dmContactSearchWrapper">
|
||||
<input type="text"
|
||||
id="dmContactSearchInput"
|
||||
class="form-control"
|
||||
placeholder="Select chat..."
|
||||
autocomplete="off">
|
||||
<div id="dmContactDropdown" class="dm-contact-dropdown" style="display: none;"></div>
|
||||
</div>
|
||||
<!-- Clear search button -->
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary flex-shrink-0"
|
||||
id="dmClearSearchBtn"
|
||||
title="Clear selection"
|
||||
style="display: none;">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
<!-- Contact info button -->
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary flex-shrink-0"
|
||||
id="dmContactInfoBtn"
|
||||
title="Contact info"
|
||||
disabled>
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</button>
|
||||
<!-- Main content: sidebar + chat -->
|
||||
<div class="d-flex flex-grow-1 overflow-hidden" style="min-height: 0;">
|
||||
<!-- DM Sidebar (visible on lg+ screens) -->
|
||||
<div id="dmSidebar" class="dm-sidebar">
|
||||
<div class="dm-sidebar-header">
|
||||
<input type="text"
|
||||
id="dmSidebarSearch"
|
||||
class="form-control form-control-sm"
|
||||
placeholder="Search contacts..."
|
||||
autocomplete="off">
|
||||
</div>
|
||||
<div class="dm-sidebar-list" id="dmSidebarList">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Messages Container -->
|
||||
<div class="row flex-grow-1 overflow-hidden" style="min-height: 0;">
|
||||
<div class="col-12 position-relative" style="height: 100%;">
|
||||
<!-- Filter bar overlay -->
|
||||
<div id="dmFilterBar" class="filter-bar">
|
||||
<div class="filter-bar-inner">
|
||||
<input type="text" id="dmFilterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
|
||||
<span id="dmFilterMatchCount" class="filter-match-count"></span>
|
||||
<button type="button" id="dmFilterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
<button type="button" id="dmFilterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
<!-- Chat Area -->
|
||||
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0;">
|
||||
<!-- Conversation Selector Bar (mobile only, hidden on lg+) -->
|
||||
<div class="dm-mobile-selector border-bottom bg-light">
|
||||
<div class="p-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<!-- Searchable contact selector -->
|
||||
<div class="position-relative flex-grow-1" id="dmContactSearchWrapper">
|
||||
<input type="text"
|
||||
id="dmContactSearchInput"
|
||||
class="form-control"
|
||||
placeholder="Select chat..."
|
||||
autocomplete="off">
|
||||
<div id="dmContactDropdown" class="dm-contact-dropdown" style="display: none;"></div>
|
||||
</div>
|
||||
<!-- Clear search button -->
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary flex-shrink-0"
|
||||
id="dmClearSearchBtn"
|
||||
title="Clear selection"
|
||||
style="display: none;">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
<!-- Contact info button -->
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary flex-shrink-0"
|
||||
id="dmContactInfoBtn"
|
||||
title="Contact info"
|
||||
disabled>
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Desktop contact header (visible on lg+ when sidebar is shown) -->
|
||||
<div class="dm-desktop-header border-bottom bg-light">
|
||||
<div class="p-2 d-flex align-items-center gap-2">
|
||||
<span id="dmDesktopContactName" class="fw-medium flex-grow-1 text-truncate"></span>
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary btn-sm flex-shrink-0"
|
||||
id="dmDesktopInfoBtn"
|
||||
title="Contact info"
|
||||
disabled>
|
||||
<i class="bi bi-info-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dmMessagesContainer" class="messages-container h-100 overflow-auto p-3">
|
||||
<div id="dmMessagesList">
|
||||
<!-- Placeholder shown when no conversation selected -->
|
||||
<div class="dm-empty-state">
|
||||
<i class="bi bi-envelope"></i>
|
||||
<p class="mb-1">Select a conversation</p>
|
||||
<small class="text-muted">Choose from the dropdown above or start a new chat from channel messages</small>
|
||||
<!-- Messages Container -->
|
||||
<div class="flex-grow-1 position-relative overflow-hidden" style="min-height: 0;">
|
||||
<!-- Filter bar overlay -->
|
||||
<div id="dmFilterBar" class="filter-bar">
|
||||
<div class="filter-bar-inner">
|
||||
<input type="text" id="dmFilterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
|
||||
<span id="dmFilterMatchCount" class="filter-match-count"></span>
|
||||
<button type="button" id="dmFilterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
<button type="button" id="dmFilterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="dmMessagesContainer" class="messages-container h-100 overflow-auto p-3">
|
||||
<div id="dmMessagesList">
|
||||
<!-- Placeholder shown when no conversation selected -->
|
||||
<div class="dm-empty-state">
|
||||
<i class="bi bi-envelope"></i>
|
||||
<p class="mb-1">Select a conversation</p>
|
||||
<small class="text-muted">Choose from the list or start a new chat from channel messages</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Scroll to bottom button -->
|
||||
<button id="dmScrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
|
||||
<i class="bi bi-chevron-double-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Scroll to bottom button -->
|
||||
<button id="dmScrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
|
||||
<i class="bi bi-chevron-double-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Message Form -->
|
||||
<div class="row border-top bg-light">
|
||||
<div class="col-12">
|
||||
<form id="dmSendForm" class="p-3">
|
||||
<div class="emoji-picker-container">
|
||||
<div class="input-group">
|
||||
<textarea
|
||||
id="dmMessageInput"
|
||||
class="form-control"
|
||||
placeholder="Type a message..."
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
disabled
|
||||
></textarea>
|
||||
<button type="button" class="btn btn-outline-secondary" id="dmEmojiBtn" title="Insert emoji">
|
||||
<i class="bi bi-emoji-smile"></i>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success px-4" id="dmSendBtn" disabled>
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
<!-- Send Message Form -->
|
||||
<div class="border-top bg-light">
|
||||
<form id="dmSendForm" class="p-3">
|
||||
<div class="emoji-picker-container">
|
||||
<div class="input-group">
|
||||
<textarea
|
||||
id="dmMessageInput"
|
||||
class="form-control"
|
||||
placeholder="Type a message..."
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
disabled
|
||||
></textarea>
|
||||
<button type="button" class="btn btn-outline-secondary" id="dmEmojiBtn" title="Insert emoji">
|
||||
<i class="bi bi-emoji-smile"></i>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success px-4" id="dmSendBtn" disabled>
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Emoji picker popup (hidden by default) -->
|
||||
<div id="dmEmojiPickerPopup" class="emoji-picker-popup hidden"></div>
|
||||
</div>
|
||||
<!-- Emoji picker popup (hidden by default) -->
|
||||
<div id="dmEmojiPickerPopup" class="emoji-picker-popup hidden"></div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<small class="text-muted"><span id="dmCharCounter">0</span> / 150</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Status Bar -->
|
||||
<div class="border-top">
|
||||
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
|
||||
<span id="dmStatusText">
|
||||
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
|
||||
</span>
|
||||
<span id="dmLastRefresh">Updated: Never</span>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<small class="text-muted"><span id="dmCharCounter">0</span> / 150</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="row border-top">
|
||||
<div class="col-12">
|
||||
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
|
||||
<span id="dmStatusText">
|
||||
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
|
||||
</span>
|
||||
<span id="dmLastRefresh">Updated: Never</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+84
-141
@@ -5,160 +5,103 @@
|
||||
{% block extra_head %}
|
||||
<!-- Emoji Picker (local) -->
|
||||
<script type="module" src="{{ url_for('static', filename='vendor/emoji-picker-element/index.js') }}"></script>
|
||||
<style>
|
||||
emoji-picker {
|
||||
--emoji-size: 1.5rem;
|
||||
--num-columns: 8;
|
||||
}
|
||||
.emoji-picker-container {
|
||||
position: relative;
|
||||
}
|
||||
.emoji-picker-popup {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
margin-bottom: 0.5rem;
|
||||
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
.emoji-picker-popup.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Mobile responsive adjustments */
|
||||
@media (max-width: 576px) {
|
||||
emoji-picker {
|
||||
--emoji-size: 1.25rem;
|
||||
--num-columns: 6;
|
||||
}
|
||||
.emoji-picker-popup {
|
||||
right: auto;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Modal fullscreen - remove all margins and padding */
|
||||
#dmModal .modal-dialog.modal-fullscreen,
|
||||
#contactsModal .modal-dialog.modal-fullscreen,
|
||||
#logsModal .modal-dialog.modal-fullscreen,
|
||||
#consoleModal .modal-dialog.modal-fullscreen {
|
||||
margin: 0 !important;
|
||||
width: 100vw !important;
|
||||
max-width: 100vw !important;
|
||||
height: 100vh !important;
|
||||
max-height: 100vh !important;
|
||||
}
|
||||
|
||||
#dmModal .modal-content,
|
||||
#contactsModal .modal-content,
|
||||
#logsModal .modal-content,
|
||||
#consoleModal .modal-content {
|
||||
border: none !important;
|
||||
border-radius: 0 !important;
|
||||
height: 100vh !important;
|
||||
}
|
||||
|
||||
#dmModal .modal-body,
|
||||
#contactsModal .modal-body,
|
||||
#logsModal .modal-body,
|
||||
#consoleModal .modal-body {
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid d-flex flex-column" style="height: 100%;">
|
||||
<!-- Messages Container -->
|
||||
<div class="row flex-grow-1 overflow-hidden" style="min-height: 0;">
|
||||
<div class="col-12 position-relative" style="height: 100%;">
|
||||
<!-- Filter bar overlay -->
|
||||
<div id="filterBar" class="filter-bar">
|
||||
<div class="filter-bar-inner">
|
||||
<div class="filter-input-wrapper">
|
||||
<input type="text" id="filterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
|
||||
<!-- Filter mentions autocomplete popup -->
|
||||
<div id="filterMentionsPopup" class="mentions-popup filter-mentions-popup hidden">
|
||||
<div class="mentions-list" id="filterMentionsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="filterMeBtn" class="filter-bar-btn filter-bar-btn-me" title="Filter my messages">
|
||||
<i class="bi bi-person-fill"></i>
|
||||
</button>
|
||||
<span id="filterMatchCount" class="filter-match-count"></span>
|
||||
<button type="button" id="filterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
<button type="button" id="filterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Main content: sidebar + chat -->
|
||||
<div class="d-flex flex-grow-1 overflow-hidden" style="min-height: 0;">
|
||||
<!-- Channel Sidebar (visible on lg+ screens) -->
|
||||
<div id="channelSidebar" class="channel-sidebar">
|
||||
<div class="channel-sidebar-header">
|
||||
<i class="bi bi-broadcast-pin"></i> Channels
|
||||
</div>
|
||||
<div id="messagesContainer" class="messages-container h-100 overflow-auto p-3">
|
||||
<div id="messagesList">
|
||||
<!-- Messages will be loaded here via JavaScript -->
|
||||
<div class="text-center text-muted py-5">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-3">Loading messages...</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="channel-sidebar-list" id="channelSidebarList">
|
||||
<!-- Populated by JavaScript -->
|
||||
</div>
|
||||
<!-- Scroll to bottom button -->
|
||||
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
|
||||
<i class="bi bi-chevron-double-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Send Message Form -->
|
||||
<div class="row border-top bg-light">
|
||||
<div class="col-12">
|
||||
<form id="sendMessageForm" class="p-3">
|
||||
<div class="emoji-picker-container">
|
||||
<div class="input-group">
|
||||
<textarea
|
||||
id="messageInput"
|
||||
class="form-control"
|
||||
placeholder="Type a message..."
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
required
|
||||
></textarea>
|
||||
<button type="button" class="btn btn-outline-secondary" id="emojiBtn" title="Insert emoji">
|
||||
<i class="bi bi-emoji-smile"></i>
|
||||
<!-- Chat Area -->
|
||||
<div class="flex-grow-1 d-flex flex-column" style="min-width: 0;">
|
||||
<!-- Messages Container -->
|
||||
<div class="flex-grow-1 position-relative overflow-hidden" style="min-height: 0;">
|
||||
<!-- Filter bar overlay -->
|
||||
<div id="filterBar" class="filter-bar">
|
||||
<div class="filter-bar-inner">
|
||||
<div class="filter-input-wrapper">
|
||||
<input type="text" id="filterInput" class="filter-bar-input" placeholder="Filter messages..." autocomplete="off">
|
||||
<!-- Filter mentions autocomplete popup -->
|
||||
<div id="filterMentionsPopup" class="mentions-popup filter-mentions-popup hidden">
|
||||
<div class="mentions-list" id="filterMentionsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" id="filterMeBtn" class="filter-bar-btn filter-bar-btn-me" title="Filter my messages">
|
||||
<i class="bi bi-person-fill"></i>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary px-4" id="sendBtn">
|
||||
<i class="bi bi-send"></i>
|
||||
<span id="filterMatchCount" class="filter-match-count"></span>
|
||||
<button type="button" id="filterClearBtn" class="filter-bar-btn filter-bar-btn-clear" title="Clear">
|
||||
<i class="bi bi-x"></i>
|
||||
</button>
|
||||
<button type="button" id="filterCloseBtn" class="filter-bar-btn filter-bar-btn-close" title="Close">
|
||||
<i class="bi bi-x-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Emoji picker popup (hidden by default) -->
|
||||
<div id="emojiPickerPopup" class="emoji-picker-popup hidden"></div>
|
||||
<!-- Mentions autocomplete popup (hidden by default) -->
|
||||
<div id="mentionsPopup" class="mentions-popup hidden">
|
||||
<div class="mentions-list" id="mentionsList"></div>
|
||||
</div>
|
||||
<div id="messagesContainer" class="messages-container h-100 overflow-auto p-3">
|
||||
<div id="messagesList">
|
||||
<!-- Messages will be loaded here via JavaScript -->
|
||||
<div class="text-center text-muted py-5">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
<p class="mt-3">Loading messages...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<small id="charCounter" class="text-muted">0 / 135</small>
|
||||
<!-- Scroll to bottom button -->
|
||||
<button id="scrollToBottomBtn" class="scroll-to-bottom-btn" title="Scroll to bottom">
|
||||
<i class="bi bi-chevron-double-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Send Message Form -->
|
||||
<div class="border-top bg-light">
|
||||
<form id="sendMessageForm" class="p-3">
|
||||
<div class="emoji-picker-container">
|
||||
<div class="input-group">
|
||||
<textarea
|
||||
id="messageInput"
|
||||
class="form-control"
|
||||
placeholder="Type a message..."
|
||||
rows="2"
|
||||
maxlength="500"
|
||||
required
|
||||
></textarea>
|
||||
<button type="button" class="btn btn-outline-secondary" id="emojiBtn" title="Insert emoji">
|
||||
<i class="bi bi-emoji-smile"></i>
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary px-4" id="sendBtn">
|
||||
<i class="bi bi-send"></i>
|
||||
</button>
|
||||
</div>
|
||||
<!-- Emoji picker popup (hidden by default) -->
|
||||
<div id="emojiPickerPopup" class="emoji-picker-popup hidden"></div>
|
||||
<!-- Mentions autocomplete popup (hidden by default) -->
|
||||
<div id="mentionsPopup" class="mentions-popup hidden">
|
||||
<div class="mentions-list" id="mentionsList"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<small id="charCounter" class="text-muted">0 / 135</small>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<!-- Status Bar -->
|
||||
<div class="border-top">
|
||||
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
|
||||
<span id="statusText">
|
||||
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
|
||||
</span>
|
||||
<span id="lastRefresh">Updated: Never</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Bar -->
|
||||
<div class="row border-top">
|
||||
<div class="col-12">
|
||||
<div class="p-2 small text-muted d-flex justify-content-between align-items-center">
|
||||
<span id="statusText">
|
||||
<i class="bi bi-circle-fill text-secondary"></i> Connecting...
|
||||
</span>
|
||||
<span id="lastRefresh">Updated: Never</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+17
-4
@@ -21,7 +21,7 @@ Technical documentation for mc-webui, covering system architecture, project stru
|
||||
- **Frontend:** HTML5, Bootstrap 5, vanilla JavaScript, Socket.IO client
|
||||
- **Deployment:** Docker / Docker Compose (Single-container architecture)
|
||||
- **Communication:** Direct hardware access (USB, BLE, or TCP) via `meshcore` library
|
||||
- **Data source:** SQLite Database (`./data/meshcore/<device_name>.db`)
|
||||
- **Data source:** SQLite Database (`./data/meshcore/<pubkey_prefix>.db`)
|
||||
|
||||
---
|
||||
|
||||
@@ -80,8 +80,8 @@ mc-webui/
|
||||
│ ├── config.py # Configuration from env vars
|
||||
│ ├── database.py # SQLite database models and CRUD operations
|
||||
│ ├── device_manager.py # Core logic for meshcore communication
|
||||
│ ├── contacts_cache.py # Persistent contacts cache
|
||||
│ ├── read_status.py # Server-side read status manager
|
||||
│ ├── contacts_cache.py # Persistent contacts cache (DB-backed)
|
||||
│ ├── read_status.py # Server-side read status manager (DB-backed)
|
||||
│ ├── version.py # Git-based version management
|
||||
│ ├── migrate_v1.py # Migration script from v1 flat files to v2 SQLite
|
||||
│ ├── meshcore/
|
||||
@@ -107,7 +107,7 @@ mc-webui/
|
||||
|
||||
mc-webui v2 uses a robust **SQLite Database** with WAL (Write-Ahead Logging) enabled.
|
||||
|
||||
Location: `./data/meshcore/<device_name>.db`
|
||||
Location: `./data/meshcore/<pubkey_prefix>.db`
|
||||
|
||||
Key tables:
|
||||
- `messages` - All channel and direct messages (with FTS5 index for full-text search)
|
||||
@@ -157,6 +157,19 @@ The use of SQLite allows for fast queries, reliable data storage, full-text sear
|
||||
| POST | `/api/contacts/pending/approve` | Approve pending contact |
|
||||
| POST | `/api/contacts/pending/reject` | Reject pending contact |
|
||||
| POST | `/api/contacts/pending/clear` | Clear all pending contacts |
|
||||
| POST | `/api/contacts/manual-add` | Add contact from URI or params |
|
||||
| POST | `/api/contacts/<key>/push-to-device` | Push cached contact to device |
|
||||
| POST | `/api/contacts/<key>/move-to-cache` | Move device contact to cache |
|
||||
| GET | `/api/contacts/repeaters` | List repeater contacts (for path picker) |
|
||||
| GET | `/api/contacts/<key>/paths` | Get contact paths |
|
||||
| POST | `/api/contacts/<key>/paths` | Add path to contact |
|
||||
| PUT | `/api/contacts/<key>/paths/<id>` | Update path (star, label) |
|
||||
| DELETE | `/api/contacts/<key>/paths/<id>` | Delete path |
|
||||
| POST | `/api/contacts/<key>/paths/reorder` | Reorder paths |
|
||||
| POST | `/api/contacts/<key>/paths/reset_flood` | Reset to FLOOD routing |
|
||||
| POST | `/api/contacts/<key>/paths/clear` | Clear all paths |
|
||||
| GET | `/api/contacts/<key>/no_auto_flood` | Get "Keep path" flag |
|
||||
| PUT | `/api/contacts/<key>/no_auto_flood` | Set "Keep path" flag |
|
||||
|
||||
### Channels
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ This guide covers all features and functionality of mc-webui. For installation i
|
||||
- [Direct Messages (DM)](#direct-messages-dm)
|
||||
- [Global Search](#global-search)
|
||||
- [Contact Management](#contact-management)
|
||||
- [Adding Contacts](#adding-contacts)
|
||||
- [DM Path Management](#dm-path-management)
|
||||
- [Interactive Console](#interactive-console)
|
||||
- [Device Dashboard](#device-dashboard)
|
||||
- [Settings](#settings)
|
||||
@@ -35,6 +37,8 @@ The main page displays chat history from the currently selected channel. The app
|
||||
|
||||
By default, the live view shows messages from the last 7 days. Older messages are automatically archived and can be accessed via the date selector.
|
||||
|
||||
On wide screens (tablets/desktops), a sidebar shows the channel list on the left side for quick switching.
|
||||
|
||||
---
|
||||
|
||||
## Managing Channels
|
||||
@@ -183,6 +187,10 @@ Access the Direct Messages feature:
|
||||
- Each conversation shows unread indicator (*) in the dropdown
|
||||
- DM badge in the menu shows total unread DM count
|
||||
|
||||
### Desktop Sidebar
|
||||
|
||||
On wide screens (tablets/desktops), the DM page shows a sidebar with the contact list on the left side, making it easy to switch between conversations without using the dropdown selector.
|
||||
|
||||
---
|
||||
|
||||
## Global Search
|
||||
@@ -345,6 +353,72 @@ You can schedule automatic cleanup to run daily at a specified hour:
|
||||
|
||||
---
|
||||
|
||||
## Adding Contacts
|
||||
|
||||
Add new contacts to your device from the Contact Management page:
|
||||
|
||||
1. Click the "Add Contact" button at the top of the Contact Management page
|
||||
2. Opens a dedicated page with three methods:
|
||||
|
||||
### Paste URI
|
||||
|
||||
1. Paste a MeshCore contact URI (`meshcore://...`) into the text field
|
||||
2. The contact details (name, public key, type) are automatically parsed and previewed
|
||||
3. Click "Add to Device" to add the contact
|
||||
|
||||
### Scan QR Code
|
||||
|
||||
1. Click "Scan QR" to open the camera
|
||||
2. Point at a MeshCore QR code (from another user's Share tab)
|
||||
3. The URI is decoded and contact details are previewed
|
||||
4. Click "Add to Device" to add the contact
|
||||
|
||||
### Manual Entry
|
||||
|
||||
1. Enter the contact's public key (64 hex characters)
|
||||
2. Optionally enter name, type (COM/REP/ROOM/SENS), and location
|
||||
3. Click "Add to Device"
|
||||
|
||||
### Cache vs Device Contacts
|
||||
|
||||
- **Device contacts** are stored on the MeshCore hardware (limit: 350)
|
||||
- **Cache contacts** are stored only in the database (unlimited)
|
||||
- Use "Push to Device" to promote a cache contact to the device
|
||||
- Use "Move to Cache" to free a device slot while keeping the contact in the database
|
||||
|
||||
---
|
||||
|
||||
## DM Path Management
|
||||
|
||||
Configure message routing paths for individual contacts:
|
||||
|
||||
1. Open a DM conversation
|
||||
2. Click the contact info icon next to the contact name
|
||||
3. In the Contact Info modal, navigate to the "Paths" section
|
||||
|
||||
### Path Configuration
|
||||
|
||||
- **Add Path** - Add a repeater to the routing path using:
|
||||
- **Repeater picker** - Browse available repeaters by name or ID
|
||||
- **Map picker** - Select repeaters from a map view showing their GPS locations
|
||||
- **Import current path** - Import the path currently stored on the device
|
||||
- **Reorder** - Drag paths to change priority (starred path is used first)
|
||||
- **Star** - Mark a preferred primary path (used first in retry rotation)
|
||||
- **Delete** - Remove individual paths
|
||||
|
||||
### Keep Path Toggle
|
||||
|
||||
- Enable "Keep path" to prevent the device from automatically switching to FLOOD routing
|
||||
- When enabled, the device will always use the configured DIRECT path(s)
|
||||
- Useful when you know the optimal route and don't want the device to override it
|
||||
|
||||
### Path Operations
|
||||
|
||||
- **Reset to FLOOD** - Clear all paths and switch to FLOOD routing
|
||||
- **Clear Paths** - Remove all configured paths without changing routing mode
|
||||
|
||||
---
|
||||
|
||||
## Interactive Console
|
||||
|
||||
Access the interactive console for direct MeshCore command execution:
|
||||
@@ -414,6 +488,12 @@ Shows live device statistics:
|
||||
- Message counters (sent, received, forwarded)
|
||||
- Current airtime usage
|
||||
|
||||
### Share Tab
|
||||
|
||||
Share your device contact with others:
|
||||
- **QR Code** - Scannable QR code containing your contact URI
|
||||
- **URI** - Copyable `meshcore://` URI that others can paste into their Add Contact page
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
@@ -439,6 +519,10 @@ Configure how direct messages are retried when delivery is not confirmed:
|
||||
|
||||
- **Live view days** - Number of days of messages shown in the live view (older messages are archived)
|
||||
|
||||
### Theme
|
||||
|
||||
- **Dark / Light** - Toggle between dark and light UI themes. The preference is saved in local browser storage
|
||||
|
||||
---
|
||||
|
||||
## System Log
|
||||
|
||||
@@ -392,7 +392,7 @@ class TestBackup:
|
||||
db.create_backup(backup_dir)
|
||||
backups = db.list_backups(backup_dir)
|
||||
assert len(backups) == 1
|
||||
assert 'mc-webui.' in backups[0]['filename']
|
||||
assert backups[0]['filename'].endswith('.db')
|
||||
|
||||
def test_list_backups_empty_dir(self, db):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
|
||||
Reference in New Issue
Block a user