35 Commits

Author SHA1 Message Date
MarekWo 0b3bd1da60 fix(dm): delayed path backfill for FLOOD-delivered messages
When FLOOD delivery is confirmed, the PATH_UPDATE event payload often
has empty path data because firmware updates the contact's out_path
asynchronously. After 3s delay, read the contact's updated path from
the meshcore library's in-memory contacts dict and backfill the DB.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:23:35 +01:00
MarekWo 4de6d72cfe fix(dm): update delivery path from PATH event after ACK race
When both ACK and PATH_UPDATE fire for FLOOD delivery, _on_ack may
store empty path before PATH_UPDATE can provide the discovered route.
Now _on_path_update also checks for recently-delivered DMs with empty
delivery_path and backfills with the discovered path from the event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 15:05:35 +01:00
MarekWo 58af37238b fix(ui): move retry counter above Resend button, same line as delivery info
Retry counter now renders as a dm-delivery-meta div above the Resend
button instead of inline next to it, matching the position of the
post-delivery info. Prevents text from crowding the button on short
messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:58:31 +01:00
MarekWo f135c90e61 fix(ui): align DM route popup to the right to prevent overflow
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:48:27 +01:00
MarekWo 90c1c90ba3 feat(dm): clickable route popup for long delivery paths
Long routes (>4 hops) show truncated with dotted underline; clicking
opens a popup with the full route and hop count, same style as channel
message path popups. Short routes (<=4 hops) display inline as before.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:44:40 +01:00
MarekWo 7d8a3c895d fix(dm): use discovered path from PATH event for delivery route
When PATH_UPDATE confirms delivery, use the actual path from the
event data instead of the empty path_desc from _retry_context (which
is empty during FLOOD phase). This captures the route firmware
discovered via the flood delivery.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:39:53 +01:00
MarekWo 3c7f70175f fix(dm): handle FLOOD delivery and old DIRECT path gracefully
Add hex validation to formatDmRoute to avoid garbling old "DIRECT"
values. When no hex route available (FLOOD delivery), fall back to
delivery_route from ACK (e.g. show "FLOOD" stripped of PATH_ prefix).
Ensures delivery meta always shows something useful.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:28:40 +01:00
MarekWo 7a44d3b95d fix(dm): resolve race condition — delivery info stored before task cancel
The _on_ack handler cancels the retry task before _retry() can store
delivery info (attempt count, path). Fix by maintaining a _retry_context
dict updated before each send. _on_ack reads context and stores delivery
info + emits dm_delivered_info BEFORE cancelling the task. Same fix
applied to PATH_UPDATE backup delivery handler.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 14:11:05 +01:00
MarekWo 885a967348 fix(dm): show delivery route as hex path, add real-time delivery info
Store actual hex path instead of DIRECT/FLOOD labels in delivery_path.
Format route as AB→CD→EF (same as channel messages, truncated if >4
hops). Add dm_delivered_info WebSocket event so delivery meta appears
in real-time without needing page reload. Remove path info from failed
messages since it's not meaningful for undelivered messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 13:21:53 +01:00
MarekWo 677036a831 fix(dm): move retry counter below message, show delivery info visually
Move the attempt counter (e.g. "Attempt 15/24") from next to the status
icon to below the message text, left of the Resend button. Add visible
delivery meta line for delivered/failed messages showing attempt count
and path used. Store attempt info for failed messages too. Replace
Polish abbreviations (ŚK, ŚD, ŚG) with English in all log messages.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:52:00 +01:00
MarekWo 7dbbba57b9 feat(dm): add real-time retry status and persistent delivery info
Show retry progress in DM message bubble via WebSocket:
- "attempt X/Y" counter updates in real-time during retries
- Failed icon (✗) when all retries exhausted
- Delivery info persisted in DB (attempt number, path used)

Backend: emit dm_retry_status/dm_retry_failed socket events,
store delivery_attempt/delivery_path in direct_messages table.
Frontend: socket listeners update status icon and counter,
delivered tooltip shows attempt info and path.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 12:25:35 +01:00
MarekWo d2e019fa0e refactor(dm): restructure retry logic into 4-scenario matrix
Replace 3-way branching (configured_paths/has_path/else) with
4-scenario matrix based on (has_path × has_configured_paths):

- S1: No path, no configured paths → FLOOD only
- S2: Has path, no configured paths → DIRECT + optional FLOOD
- S3: No path, has configured paths → FLOOD first, then ŚD rotation
- S4: Has path, has configured paths → DIRECT on ŚK, ŚD rotation, optional FLOOD

Key changes:
- S3: FLOOD before configured paths (discover new routes)
- S4: exhaust retries on current ŚK before rotating ŚD
- S4: dedup ŚG/ŚK to skip redundant retries on same path
- Add _paths_match() helper for path deduplication
- Update tooltip text for settings clarity

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 11:55:17 +01:00
MarekWo 9be7ae6cc4 fix(ui): always refresh contact data on path_changed event
The path_changed socket handler was skipping the refresh when Contact
Info modal was closed. This meant contactsList stayed stale, so opening
the modal later still showed outdated path info. Now always refreshes
contactsList on any path_changed event.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-28 07:32:33 +01:00
MarekWo 5df9b4b4a2 fix(ui): refresh Contact Info path display in real-time
Path info in Contact Info modal was stale due to 60s server cache
and no refresh after path operations. Now:
- Invalidate contacts cache after reset_path, change_path, path_update
- Emit 'path_changed' socket event on PATH_UPDATE from device
- UI listens and re-renders Contact Info when path changes
- Reset to FLOOD button immediately refreshes the path display

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 20:29:26 +01:00
MarekWo 292d1d91af fix(contacts): restore flexbox list height, remove calc() overrides
The contact list in Existing/Pending Contacts was not using all available
space due to calc(100vh - ...) and max-height rules overriding the
flexbox layout. Remove fixed height constraints from #pendingList and
#existingList in both contacts_base.html and style.css, letting the
flexbox chain (body > main > container > pageContent > list) fill the
remaining viewport space.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-27 07:54:47 +01:00
MarekWo 054b80926d docs: update documentation for path management, add contact, theme, sidebar
- README: add multi-path routing, add contact via URI/QR, dark/light theme,
  desktop sidebar, device share tab, pubkey-based DB naming
- User Guide: add sections for Adding Contacts (URI/QR/manual), DM Path
  Management (multi-path, repeater picker, map picker, keep path toggle),
  Device Share tab, theme setting, desktop sidebar notes
- Architecture: add path management API endpoints (CRUD, reorder, reset,
  no_auto_flood), manual-add, push-to-device, move-to-cache endpoints,
  update DB naming to pubkey prefix

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 18:08:58 +01:00
MarekWo 54be1796f8 fix(ui): reduce DM sidebar contact name font size to match channel sidebar
Global .contact-name (1.1rem/600) was bleeding into DM sidebar items.
Added explicit 0.88rem/400 override for .dm-sidebar-item .contact-name.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 17:51:26 +01:00
MarekWo 71e00caa55 feat(ui): add dark/light theme switching with Settings toggle
- Create theme.css with CSS custom properties for light/dark themes
- Dark theme inspired by demo landing page (deep navy palette)
- Update style.css: replace ~145 hardcoded colors with CSS variables
- Extract inline styles from index.html, contacts.html, dm.html to style.css
- Add Appearance tab in Settings modal with theme selector
- Bootstrap 5.3 data-bs-theme integration for native dark mode
- Theme persisted in localStorage, applied before CSS loads (no FOUC)
- Console and System Log panels unchanged (already dark themed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-26 08:23:26 +01:00
MarekWo 2e6f0d01d6 feat(device): add Share tab with QR code and URI for sharing own contact
Adds a Share tab to the Device Info modal that generates a QR code
and copyable URI (meshcore://contact/add?...) for sharing the device
contact with other users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 09:12:30 +01:00
MarekWo ce88ec291f fix(dm): preserve sidebar search filter when conversations refresh
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 07:59:28 +01:00
MarekWo c6eb2b1755 fix(dm): remove d-none class that conflicts with media query on desktop header
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 07:57:54 +01:00
MarekWo 1e768e799b feat(ui): add channel/contact sidebar for wide screens (desktop/tablet)
On screens >= 992px (lg breakpoint), show a persistent sidebar panel:
- Group chat: channel list with unread badges, active highlight, muted state
- DM: conversation/contact list with search, unread dots, type badges
- Desktop contact header with info button replaces mobile selector
- Mobile/narrow screens unchanged (dropdown/top selector still used)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-25 07:56:32 +01:00
MarekWo 7b2f721d1d fix(contacts): wrap long public keys in add contact previews
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:43:28 +01:00
MarekWo 17b3c1c89c fix(contacts): correct COM type label from Communicator to Companion
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 21:01:12 +01:00
MarekWo 878d489661 feat(contacts): add contact UI with URI paste, QR scan, and manual entry
Stage 2 of manual contact add feature:
- POST /api/contacts/manual-add endpoint (URI or raw params)
- New /contacts/add page with 3 input tabs (URI, QR code, Manual)
- QR scanning via html5-qrcode (camera + image upload fallback)
- Client-side URI parsing with preview before submission
- Nav card in Contact Management above Pending Contacts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 20:54:41 +01:00
MarekWo 0973d2d714 fix(contacts): invalidate contacts cache after push/move operations
The /api/contacts/detailed endpoint has a 60s cache. Without invalidation
after push-to-device or move-to-cache, the UI showed stale data until
cache expired, making it look like the operation didn't work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 18:25:03 +01:00
MarekWo 9ee63188d2 feat(contacts): add push-to-device and move-to-cache operations
Enable moving contacts between device and cache directly from the
Existing Contacts UI:
- "To device" button on cache-only contacts (pushes to device)
- "To cache" button on device contacts (removes from device, keeps in DB)

This helps manage the 350-contact device limit by offloading inactive
contacts to cache and restoring them when needed.

- Add DeviceManager.push_to_device() and move_to_cache() methods
- Add API endpoints: POST /contacts/<pk>/push-to-device, move-to-cache
- Add UI buttons with confirm dialogs in contacts.js

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 18:06:26 +01:00
MarekWo 215515fe02 fix(contacts): add missing out_path_hash_mode field for manual_add
The meshcore library's update_contact() reads out_path_hash_mode directly
from the contact dict. Without it, add_contact_manual() fails with
KeyError: 'out_path_hash_mode'. Default value 0 is correct for new
contacts with no known path (flood mode).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:58:06 +01:00
MarekWo 3e8eb00e3e feat(contacts): add manual_add command for adding contacts from URI or params
Add support for adding contacts manually using the MeshCore mobile app URI
format (meshcore://contact/add?name=...&public_key=...&type=...) or raw
parameters (public_key, type, name). This enables contact sharing between
mc-webui and the MeshCore Android/iOS app via URI/QR codes.

- Add parse_meshcore_uri() helper to parse mobile app URIs
- Add DeviceManager.add_contact_manual() using CMD_ADD_UPDATE_CONTACT
- Update import_contact_uri() to handle both mobile app and hex blob URIs
- Add manual_add console command with two usage variants
- Update console help text

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 17:52:22 +01:00
MarekWo d54d8f58dd fix(console): fix node_discover display using correct payload fields
The DISCOVER_RESPONSE payload uses 'pubkey' and 'node_type', not
'public_key'/'name'/'adv_name'. Now shows pubkey prefix, resolved
contact name, node type, SNR, and RSSI. Also rename CLI->COM type.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 14:17:42 +01:00
MarekWo 2c73e20775 fix(backup): use DB filename as backup prefix instead of hardcoded 'mc-webui'
Backup filenames now derive from the active DB stem (e.g. mc_9cebbd27.2026-03-24.db).
Listing and cleanup glob *.db so existing mc-webui.* backups remain visible.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:42:31 +01:00
MarekWo f9bcbabb86 fix: use Flask current_app for DB access in read_status and contacts_cache
'from app.main import db' gets None because python -m app.main loads the
module as __main__, creating a separate module instance from app.main.
Use current_app.db (Flask app context) instead — same pattern as api.py.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:26:07 +01:00
MarekWo 5ccd882c5a refactor: eliminate JSONL companion files, delegate to DB
Remove contacts_cache.jsonl and adverts.jsonl file I/O — all contact
data is already in the SQLite contacts/advertisements tables. Clean up
stale JSONL files (acks, echoes, path, dm_sent) at startup.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:16:41 +01:00
MarekWo 2a9f90c01d refactor: migrate read_status from JSON file to SQLite database
Replace file-based .read_status.json with DB-backed read_status table.
One-time migration imports existing data at startup. The read_status.py
module keeps the same public API so route handlers need no changes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:13:26 +01:00
MarekWo acfa5d3550 refactor: use public key prefix for DB filename instead of device name
DB filename changes from {device_name}.db to mc_{pubkey[:8]}.db,
making it stable across device renames and preparing for multi-device support.
Existing databases are auto-migrated at startup by probing the device table.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-24 08:11:20 +01:00
24 changed files with 3729 additions and 1481 deletions
+15 -12
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 DIRECTFLOOD reset.
The no_auto_flood per-contact flag prevents automatic DIRECTFLOOD 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
View File
@@ -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
View File
@@ -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
+90
View File
@@ -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():
"""
+11
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+613
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+128
View File
@@ -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 %}
+9
View File
@@ -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>
-202
View File
@@ -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 %}
+39 -220
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+84
View File
@@ -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
+1 -1
View File
@@ -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: