mirror of
https://github.com/MarekWo/mc-webui.git
synced 2026-05-07 05:44:43 +02:00
feat(dm): add multi-path management and per-contact no-flood toggle
- New `contact_paths` table for storing multiple user-configured paths per contact - New `no_auto_flood` column on contacts to prevent automatic DIRECT→FLOOD reset - Path rotation during DM retry: cycles through configured paths before optional flood fallback - REST API for path CRUD, reorder, reset-to-flood, repeater listing - Path management UI in Contact Info modal: add/delete/reorder paths, repeater picker with uniqueness warnings, hash size selector (1B/2B/3B) - "No Flood" per-contact toggle in modal footer Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+145
-1
@@ -33,8 +33,17 @@ class Database:
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
schema_sql = SCHEMA_FILE.read_text(encoding='utf-8')
|
||||
conn.executescript(schema_sql)
|
||||
self._run_migrations(conn)
|
||||
logger.info(f"Database initialized: {self.db_path}")
|
||||
|
||||
def _run_migrations(self, conn):
|
||||
"""Run schema migrations for columns added after initial release."""
|
||||
# Check if contacts.no_auto_flood column exists
|
||||
columns = {r[1] for r in conn.execute("PRAGMA table_info(contacts)").fetchall()}
|
||||
if 'no_auto_flood' not in columns:
|
||||
conn.execute("ALTER TABLE contacts ADD COLUMN no_auto_flood INTEGER DEFAULT 0")
|
||||
logger.info("Migration: added contacts.no_auto_flood column")
|
||||
|
||||
@contextmanager
|
||||
def _connect(self):
|
||||
"""Yield a connection with auto-commit/rollback."""
|
||||
@@ -751,6 +760,141 @@ class Database:
|
||||
kwargs.get('path_len'))
|
||||
)
|
||||
|
||||
# ================================================================
|
||||
# Contact Paths (user-configured paths for DM routing)
|
||||
# ================================================================
|
||||
|
||||
def get_contact_paths(self, contact_pubkey: str) -> List[Dict]:
|
||||
"""Get all configured paths for a contact, ordered by sort_order."""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM contact_paths
|
||||
WHERE contact_pubkey = ?
|
||||
ORDER BY sort_order ASC, id ASC""",
|
||||
(contact_pubkey.lower(),)
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
def add_contact_path(self, contact_pubkey: str, path_hex: str,
|
||||
hash_size: int = 1, label: str = '',
|
||||
is_primary: bool = False) -> int:
|
||||
"""Add a new path for a contact. Returns the new row ID."""
|
||||
pk = contact_pubkey.lower()
|
||||
with self._connect() as conn:
|
||||
if is_primary:
|
||||
conn.execute(
|
||||
"UPDATE contact_paths SET is_primary = 0 WHERE contact_pubkey = ?",
|
||||
(pk,)
|
||||
)
|
||||
# Auto-assign sort_order as max+1
|
||||
row = conn.execute(
|
||||
"SELECT COALESCE(MAX(sort_order), -1) + 1 AS next_order FROM contact_paths WHERE contact_pubkey = ?",
|
||||
(pk,)
|
||||
).fetchone()
|
||||
next_order = row['next_order'] if row else 0
|
||||
cursor = conn.execute(
|
||||
"""INSERT INTO contact_paths
|
||||
(contact_pubkey, path_hex, hash_size, label, is_primary, sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(pk, path_hex, hash_size, label, 1 if is_primary else 0, next_order)
|
||||
)
|
||||
return cursor.lastrowid
|
||||
|
||||
def update_contact_path(self, path_id: int, **kwargs) -> bool:
|
||||
"""Update fields on a contact path (path_hex, hash_size, label, is_primary)."""
|
||||
allowed = {'path_hex', 'hash_size', 'label', 'is_primary', 'sort_order'}
|
||||
updates = {k: v for k, v in kwargs.items() if k in allowed}
|
||||
if not updates:
|
||||
return False
|
||||
with self._connect() as conn:
|
||||
# If setting as primary, clear others first
|
||||
if updates.get('is_primary'):
|
||||
row = conn.execute(
|
||||
"SELECT contact_pubkey FROM contact_paths WHERE id = ?", (path_id,)
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute(
|
||||
"UPDATE contact_paths SET is_primary = 0 WHERE contact_pubkey = ?",
|
||||
(row['contact_pubkey'],)
|
||||
)
|
||||
set_clause = ", ".join(f"{k} = ?" for k in updates)
|
||||
values = list(updates.values()) + [path_id]
|
||||
cursor = conn.execute(
|
||||
f"UPDATE contact_paths SET {set_clause} WHERE id = ?", values
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_contact_path(self, path_id: int) -> bool:
|
||||
"""Delete a single configured path."""
|
||||
with self._connect() as conn:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM contact_paths WHERE id = ?", (path_id,)
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def delete_all_contact_paths(self, contact_pubkey: str) -> int:
|
||||
"""Delete all configured paths for a contact. Returns count deleted."""
|
||||
with self._connect() as conn:
|
||||
cursor = conn.execute(
|
||||
"DELETE FROM contact_paths WHERE contact_pubkey = ?",
|
||||
(contact_pubkey.lower(),)
|
||||
)
|
||||
return cursor.rowcount
|
||||
|
||||
def reorder_contact_paths(self, contact_pubkey: str, path_ids: List[int]) -> bool:
|
||||
"""Set sort_order based on the order of IDs in the list."""
|
||||
pk = contact_pubkey.lower()
|
||||
with self._connect() as conn:
|
||||
for order, pid in enumerate(path_ids):
|
||||
conn.execute(
|
||||
"UPDATE contact_paths SET sort_order = ? WHERE id = ? AND contact_pubkey = ?",
|
||||
(order, pid, pk)
|
||||
)
|
||||
return True
|
||||
|
||||
def get_primary_contact_path(self, contact_pubkey: str) -> Optional[Dict]:
|
||||
"""Get the primary path (or first by sort_order if none marked primary)."""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT * FROM contact_paths
|
||||
WHERE contact_pubkey = ?
|
||||
ORDER BY is_primary DESC, sort_order ASC
|
||||
LIMIT 1""",
|
||||
(contact_pubkey.lower(),)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
def set_contact_no_auto_flood(self, contact_pubkey: str, value: bool) -> bool:
|
||||
"""Set the no_auto_flood flag for a contact."""
|
||||
with self._connect() as conn:
|
||||
cursor = conn.execute(
|
||||
"UPDATE contacts SET no_auto_flood = ?, lastmod = datetime('now') WHERE public_key = ?",
|
||||
(1 if value else 0, contact_pubkey.lower())
|
||||
)
|
||||
return cursor.rowcount > 0
|
||||
|
||||
def get_contact_no_auto_flood(self, contact_pubkey: str) -> bool:
|
||||
"""Get the no_auto_flood flag for a contact."""
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT no_auto_flood FROM contacts WHERE public_key = ?",
|
||||
(contact_pubkey.lower(),)
|
||||
).fetchone()
|
||||
return bool(row['no_auto_flood']) if row and row['no_auto_flood'] else False
|
||||
|
||||
def get_repeater_contacts(self) -> List[Dict]:
|
||||
"""Get all repeater contacts (type=1) from DB, including ignored ones."""
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT c.public_key, c.name, c.last_advert, c.adv_lat, c.adv_lon,
|
||||
CASE WHEN ic.public_key IS NOT NULL THEN 1 ELSE 0 END AS is_ignored
|
||||
FROM contacts c
|
||||
LEFT JOIN ignored_contacts ic ON c.public_key = ic.public_key
|
||||
WHERE c.type = 1
|
||||
ORDER BY c.name ASC"""
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
# ================================================================
|
||||
# Advertisements
|
||||
# ================================================================
|
||||
@@ -861,7 +1005,7 @@ class Database:
|
||||
"""Get row counts for all tables."""
|
||||
tables = ['device', 'contacts', 'channels', 'channel_messages',
|
||||
'direct_messages', 'acks', 'echoes', 'paths',
|
||||
'advertisements', 'read_status']
|
||||
'contact_paths', 'advertisements', 'read_status']
|
||||
stats = {}
|
||||
with self._connect() as conn:
|
||||
for table in tables:
|
||||
|
||||
+149
-46
@@ -1083,14 +1083,72 @@ class DeviceManager:
|
||||
logger.error(f"Failed to send DM: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
async def _change_path_async(self, contact, path_hex: str, hash_size: int = 1):
|
||||
"""Change contact path on device with proper hash_size encoding."""
|
||||
hop_count = len(path_hex) // (hash_size * 2)
|
||||
encoded_path_len = ((hash_size - 1) << 6) | hop_count
|
||||
# Set encoded values on contact dict before calling update_contact
|
||||
contact['out_path'] = path_hex
|
||||
contact['out_path_len'] = encoded_path_len
|
||||
await self.mc.commands.update_contact(contact)
|
||||
|
||||
async def _restore_primary_path(self, contact, contact_pubkey: str):
|
||||
"""Restore the primary configured path on the device after retry exhaustion."""
|
||||
try:
|
||||
primary = self.db.get_primary_contact_path(contact_pubkey)
|
||||
if primary:
|
||||
await self._change_path_async(contact, primary['path_hex'], primary['hash_size'])
|
||||
logger.info(f"Restored primary path for {contact_pubkey[:12]}")
|
||||
else:
|
||||
logger.debug(f"No primary path to restore for {contact_pubkey[:12]}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to restore primary path for {contact_pubkey[:12]}: {e}")
|
||||
|
||||
async def _dm_retry_send_and_wait(self, contact, text, timestamp, attempt,
|
||||
dm_id, suggested_timeout, min_wait):
|
||||
"""Send a DM retry attempt and wait for ACK. Returns True if delivered."""
|
||||
from meshcore.events import EventType
|
||||
|
||||
try:
|
||||
result = await self.mc.commands.send_msg(
|
||||
contact, text, timestamp=timestamp, attempt=attempt
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"DM retry {attempt}: send error: {e}")
|
||||
return False
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
logger.warning(f"DM retry {attempt}: device error")
|
||||
return False
|
||||
|
||||
retry_ack = _to_str(result.payload.get('expected_ack'))
|
||||
if retry_ack:
|
||||
self._pending_acks[retry_ack] = dm_id
|
||||
new_timeout = result.payload.get('suggested_timeout', suggested_timeout)
|
||||
wait_s = max(new_timeout / 1000 * 1.2, min_wait)
|
||||
|
||||
ack_event = await self.mc.dispatcher.wait_for_event(
|
||||
EventType.ACK,
|
||||
attribute_filters={"code": retry_ack},
|
||||
timeout=wait_s
|
||||
)
|
||||
if ack_event:
|
||||
self._confirm_delivery(dm_id, retry_ack, ack_event)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
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 depends on whether contact has a known DIRECT path:
|
||||
- DIRECT path known: direct_max_retries DIRECT + direct_flood_retries FLOOD
|
||||
- No path (FLOOD): flood_max_retries attempts
|
||||
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.
|
||||
|
||||
The no_auto_flood per-contact flag prevents automatic DIRECT→FLOOD reset.
|
||||
Settings loaded from app_settings DB table (key: dm_retry_settings).
|
||||
"""
|
||||
from meshcore.events import EventType
|
||||
@@ -1104,22 +1162,20 @@ class DeviceManager:
|
||||
saved = self.db.get_setting_json('dm_retry_settings', {})
|
||||
cfg = {**_defaults, **(saved or {})}
|
||||
|
||||
contact_pubkey = contact.get('public_key', '').lower()
|
||||
has_path = contact.get('out_path_len', -1) > 0
|
||||
|
||||
if has_path:
|
||||
# +1 counts the initial send
|
||||
max_attempts = cfg['direct_max_retries'] + cfg['direct_flood_retries'] + 1
|
||||
flood_at = cfg['direct_max_retries'] + 1
|
||||
min_wait = float(cfg['direct_interval'])
|
||||
else:
|
||||
max_attempts = cfg['flood_max_retries'] + 1
|
||||
flood_at = None
|
||||
min_wait = float(cfg['flood_interval'])
|
||||
# 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
|
||||
|
||||
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 = "DIRECT" if has_path else "FLOOD"
|
||||
|
||||
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"max_attempts={max_attempts}, wait={wait_s:.0f}s")
|
||||
f"configured_paths={len(configured_paths)}, no_auto_flood={no_auto_flood}, "
|
||||
f"wait={wait_s:.0f}s")
|
||||
|
||||
# Wait for ACK on initial send
|
||||
if initial_ack:
|
||||
@@ -1132,47 +1188,94 @@ class DeviceManager:
|
||||
self._confirm_delivery(dm_id, initial_ack, ack_event)
|
||||
return
|
||||
|
||||
# Retry with same timestamp, incrementing attempt
|
||||
for attempt in range(1, max_attempts):
|
||||
if flood_at and attempt >= flood_at:
|
||||
# Switch to FLOOD mode: reset path and use flood interval
|
||||
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'])
|
||||
|
||||
for path_info in configured_paths:
|
||||
# Switch to this path on the device
|
||||
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']})")
|
||||
except Exception as e:
|
||||
logger.warning(f"DM retry: failed to switch path: {e}")
|
||||
continue
|
||||
|
||||
# Try sending on this path
|
||||
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
|
||||
):
|
||||
# Delivered! Restore primary path
|
||||
await self._restore_primary_path(contact, contact_pubkey)
|
||||
return
|
||||
|
||||
# All configured paths exhausted
|
||||
if not no_auto_flood:
|
||||
# Fall back to FLOOD
|
||||
min_wait = float(cfg['flood_interval'])
|
||||
wait_s = max(suggested_timeout / 1000 * 1.2, min_wait)
|
||||
try:
|
||||
await self.mc.commands.reset_path(contact)
|
||||
logger.info(f"DM retry {attempt}: reset path to flood")
|
||||
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
|
||||
):
|
||||
await self._restore_primary_path(contact, contact_pubkey)
|
||||
return
|
||||
|
||||
try:
|
||||
result = await self.mc.commands.send_msg(
|
||||
contact, text, timestamp=timestamp, attempt=attempt
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"DM retry {attempt}/{max_attempts}: send error: {e}")
|
||||
continue
|
||||
# Restore primary path regardless of outcome
|
||||
await self._restore_primary_path(contact, contact_pubkey)
|
||||
|
||||
if result.type == EventType.ERROR:
|
||||
logger.warning(f"DM retry {attempt}/{max_attempts}: device error")
|
||||
continue
|
||||
|
||||
retry_ack = _to_str(result.payload.get('expected_ack'))
|
||||
if retry_ack:
|
||||
self._pending_acks[retry_ack] = dm_id
|
||||
new_timeout = result.payload.get('suggested_timeout', suggested_timeout)
|
||||
wait_s = max(new_timeout / 1000 * 1.2, min_wait)
|
||||
|
||||
if retry_ack:
|
||||
ack_event = await self.mc.dispatcher.wait_for_event(
|
||||
EventType.ACK,
|
||||
attribute_filters={"code": retry_ack},
|
||||
timeout=wait_s
|
||||
)
|
||||
if ack_event:
|
||||
self._confirm_delivery(dm_id, retry_ack, ack_event)
|
||||
# ── Strategy 2: DIRECT + optional FLOOD (no configured paths) ──
|
||||
elif has_path:
|
||||
# Direct retries
|
||||
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
|
||||
|
||||
logger.warning(f"DM retry exhausted ({max_attempts} {mode} attempts) for dm_id={dm_id}")
|
||||
# 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']):
|
||||
attempt += 1
|
||||
if await self._dm_retry_send_and_wait(
|
||||
contact, text, timestamp, attempt, dm_id,
|
||||
suggested_timeout, min_wait
|
||||
):
|
||||
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
|
||||
|
||||
logger.warning(f"DM retry exhausted ({attempt + 1} total attempts, mode={mode}) "
|
||||
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)
|
||||
await asyncio.sleep(cfg['grace_period'])
|
||||
|
||||
@@ -2168,6 +2168,220 @@ def set_auto_retry_config():
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
## ================================================================
|
||||
# Contact Paths (user-configured DM routing paths)
|
||||
# ================================================================
|
||||
|
||||
@api_bp.route('/contacts/<pubkey>/paths', methods=['GET'])
|
||||
def get_contact_paths(pubkey):
|
||||
"""List all configured paths for a contact."""
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 503
|
||||
try:
|
||||
paths = db.get_contact_paths(pubkey)
|
||||
return jsonify({'success': True, 'paths': paths}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<pubkey>/paths', methods=['POST'])
|
||||
def add_contact_path(pubkey):
|
||||
"""Add a new configured path for a contact."""
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 503
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': 'Missing JSON body'}), 400
|
||||
|
||||
path_hex = data.get('path_hex', '').strip()
|
||||
hash_size = data.get('hash_size', 1)
|
||||
label = data.get('label', '').strip()
|
||||
is_primary = bool(data.get('is_primary', False))
|
||||
|
||||
# Validate path_hex
|
||||
if not path_hex:
|
||||
return jsonify({'success': False, 'error': 'path_hex is required'}), 400
|
||||
if len(path_hex) % 2 != 0:
|
||||
return jsonify({'success': False, 'error': 'path_hex must have even length'}), 400
|
||||
try:
|
||||
bytes.fromhex(path_hex)
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'error': 'path_hex must be valid hex'}), 400
|
||||
|
||||
# Validate hash_size
|
||||
if hash_size not in (1, 2, 3):
|
||||
return jsonify({'success': False, 'error': 'hash_size must be 1, 2, or 3'}), 400
|
||||
|
||||
# Validate hop count
|
||||
hop_count = len(path_hex) // (hash_size * 2)
|
||||
max_hops = {1: 64, 2: 32, 3: 21}
|
||||
if hop_count < 1 or hop_count > max_hops[hash_size]:
|
||||
return jsonify({'success': False, 'error': f'Hop count {hop_count} exceeds max {max_hops[hash_size]} for {hash_size}-byte hash'}), 400
|
||||
|
||||
# Validate path_hex length is a multiple of hash_size
|
||||
if len(path_hex) % (hash_size * 2) != 0:
|
||||
return jsonify({'success': False, 'error': f'path_hex length must be a multiple of {hash_size * 2} (hash_size={hash_size})'}), 400
|
||||
|
||||
# Validate label length
|
||||
if len(label) > 50:
|
||||
return jsonify({'success': False, 'error': 'Label must be 50 characters or less'}), 400
|
||||
|
||||
path_id = db.add_contact_path(pubkey, path_hex, hash_size, label, is_primary)
|
||||
return jsonify({'success': True, 'id': path_id}), 201
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<pubkey>/paths/<int:path_id>', methods=['PUT'])
|
||||
def update_contact_path(pubkey, path_id):
|
||||
"""Update a configured path."""
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 503
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({'success': False, 'error': 'Missing JSON body'}), 400
|
||||
|
||||
kwargs = {}
|
||||
if 'path_hex' in data:
|
||||
path_hex = data['path_hex'].strip()
|
||||
if len(path_hex) % 2 != 0:
|
||||
return jsonify({'success': False, 'error': 'path_hex must have even length'}), 400
|
||||
try:
|
||||
bytes.fromhex(path_hex)
|
||||
except ValueError:
|
||||
return jsonify({'success': False, 'error': 'path_hex must be valid hex'}), 400
|
||||
kwargs['path_hex'] = path_hex
|
||||
if 'hash_size' in data:
|
||||
if data['hash_size'] not in (1, 2, 3):
|
||||
return jsonify({'success': False, 'error': 'hash_size must be 1, 2, or 3'}), 400
|
||||
kwargs['hash_size'] = data['hash_size']
|
||||
if 'label' in data:
|
||||
label = data['label'].strip()
|
||||
if len(label) > 50:
|
||||
return jsonify({'success': False, 'error': 'Label must be 50 characters or less'}), 400
|
||||
kwargs['label'] = label
|
||||
if 'is_primary' in data:
|
||||
kwargs['is_primary'] = 1 if data['is_primary'] else 0
|
||||
|
||||
if not kwargs:
|
||||
return jsonify({'success': False, 'error': 'No valid fields to update'}), 400
|
||||
|
||||
if db.update_contact_path(path_id, **kwargs):
|
||||
return jsonify({'success': True}), 200
|
||||
return jsonify({'success': False, 'error': 'Path not found'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<pubkey>/paths/<int:path_id>', methods=['DELETE'])
|
||||
def delete_contact_path(pubkey, path_id):
|
||||
"""Delete a single configured path."""
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 503
|
||||
try:
|
||||
if db.delete_contact_path(path_id):
|
||||
return jsonify({'success': True}), 200
|
||||
return jsonify({'success': False, 'error': 'Path not found'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<pubkey>/paths/reorder', methods=['POST'])
|
||||
def reorder_contact_paths(pubkey):
|
||||
"""Reorder configured paths. Body: {path_ids: [3, 1, 2]}"""
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 503
|
||||
try:
|
||||
data = request.get_json()
|
||||
if not data or 'path_ids' not in data:
|
||||
return jsonify({'success': False, 'error': 'path_ids array required'}), 400
|
||||
path_ids = data['path_ids']
|
||||
if not isinstance(path_ids, list) or not all(isinstance(i, int) for i in path_ids):
|
||||
return jsonify({'success': False, 'error': 'path_ids must be an array of integers'}), 400
|
||||
db.reorder_contact_paths(pubkey, path_ids)
|
||||
return jsonify({'success': True}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<pubkey>/paths/reset_flood', methods=['POST'])
|
||||
def reset_contact_to_flood(pubkey):
|
||||
"""Reset to FLOOD: clear all configured paths and reset device path."""
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 503
|
||||
try:
|
||||
# Clear all saved paths from DB
|
||||
deleted = db.delete_all_contact_paths(pubkey)
|
||||
# Reset path on device
|
||||
result = {'success': True, 'paths_deleted': deleted}
|
||||
try:
|
||||
dm = cli.get_device_manager()
|
||||
if dm and dm.is_connected:
|
||||
dev_result = dm.reset_path(pubkey)
|
||||
result['device_reset'] = dev_result.get('success', False)
|
||||
else:
|
||||
result['device_reset'] = False
|
||||
result['warning'] = 'Device not connected'
|
||||
except Exception as e:
|
||||
result['device_reset'] = False
|
||||
result['warning'] = f'Device reset failed: {e}'
|
||||
return jsonify(result), 200
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<pubkey>/no_auto_flood', methods=['GET'])
|
||||
def get_no_auto_flood(pubkey):
|
||||
"""Get the no_auto_flood flag for a contact."""
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 503
|
||||
try:
|
||||
value = db.get_contact_no_auto_flood(pubkey)
|
||||
return jsonify({'success': True, 'no_auto_flood': value}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/<pubkey>/no_auto_flood', methods=['PUT'])
|
||||
def set_no_auto_flood(pubkey):
|
||||
"""Set the no_auto_flood flag for a contact."""
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 503
|
||||
try:
|
||||
data = request.get_json()
|
||||
if data is None or 'no_auto_flood' not in data:
|
||||
return jsonify({'success': False, 'error': 'no_auto_flood field required'}), 400
|
||||
value = bool(data['no_auto_flood'])
|
||||
if db.set_contact_no_auto_flood(pubkey, value):
|
||||
return jsonify({'success': True, 'no_auto_flood': value}), 200
|
||||
return jsonify({'success': False, 'error': 'Contact not found'}), 404
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/contacts/repeaters', methods=['GET'])
|
||||
def get_repeater_contacts():
|
||||
"""List all repeater contacts (type=1) from DB, including ignored."""
|
||||
db = _get_db()
|
||||
if not db:
|
||||
return jsonify({'success': False, 'error': 'Database not available'}), 503
|
||||
try:
|
||||
repeaters = db.get_repeater_contacts()
|
||||
return jsonify({'success': True, 'repeaters': repeaters}), 200
|
||||
except Exception as e:
|
||||
return jsonify({'success': False, 'error': str(e)}), 500
|
||||
|
||||
|
||||
@api_bp.route('/chat/settings', methods=['GET'])
|
||||
def get_chat_config():
|
||||
"""Get chat settings."""
|
||||
|
||||
@@ -115,6 +115,18 @@ CREATE TABLE IF NOT EXISTS paths (
|
||||
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- User-configured paths for DM retry rotation
|
||||
CREATE TABLE IF NOT EXISTS contact_paths (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
contact_pubkey TEXT NOT NULL REFERENCES contacts(public_key) ON DELETE CASCADE,
|
||||
path_hex TEXT NOT NULL DEFAULT '', -- raw hex path bytes (e.g. "5e34e761")
|
||||
hash_size INTEGER NOT NULL DEFAULT 1, -- bytes per hop: 1, 2, or 3
|
||||
label TEXT NOT NULL DEFAULT '', -- friendly label (e.g. "via Zalesie")
|
||||
is_primary INTEGER NOT NULL DEFAULT 0, -- 1 = priority/default path
|
||||
sort_order INTEGER NOT NULL DEFAULT 0, -- lower = tried first during rotation
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Advertisements (replaces .adverts.jsonl)
|
||||
CREATE TABLE IF NOT EXISTS advertisements (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -174,6 +186,7 @@ CREATE INDEX IF NOT EXISTS idx_acks_code ON acks(expected_ack);
|
||||
CREATE INDEX IF NOT EXISTS idx_echoes_pkt ON echoes(pkt_payload);
|
||||
CREATE INDEX IF NOT EXISTS idx_adv_pubkey ON advertisements(public_key, timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name);
|
||||
CREATE INDEX IF NOT EXISTS idx_cp_contact ON contact_paths(contact_pubkey, sort_order);
|
||||
|
||||
-- ============================================================
|
||||
-- Full-Text Search (FTS5)
|
||||
|
||||
@@ -278,6 +278,7 @@ function setupEventListeners() {
|
||||
infoBtn.addEventListener('click', () => {
|
||||
const modal = new bootstrap.Modal(document.getElementById('dmContactInfoModal'));
|
||||
populateContactInfoModal();
|
||||
loadPathSection();
|
||||
modal.show();
|
||||
});
|
||||
}
|
||||
@@ -1607,3 +1608,433 @@ async function loadAutoRetryConfig() {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ================================================================
|
||||
// Path Management
|
||||
// ================================================================
|
||||
|
||||
let _repeatersCache = null;
|
||||
|
||||
/**
|
||||
* Get the current contact's full public key for path API calls.
|
||||
*/
|
||||
function getCurrentContactPubkey() {
|
||||
const contact = findCurrentContact();
|
||||
return contact?.public_key || currentConversationId || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and display the path section in Contact Info modal.
|
||||
*/
|
||||
async function loadPathSection() {
|
||||
const section = document.getElementById('dmPathSection');
|
||||
const pubkey = getCurrentContactPubkey();
|
||||
if (!section || !pubkey) {
|
||||
if (section) section.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
section.style.display = '';
|
||||
await renderPathList(pubkey);
|
||||
await loadNoAutoFloodToggle(pubkey);
|
||||
setupPathFormHandlers(pubkey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the list of configured paths for a contact.
|
||||
*/
|
||||
async function renderPathList(pubkey) {
|
||||
const listEl = document.getElementById('dmPathList');
|
||||
if (!listEl) return;
|
||||
|
||||
listEl.innerHTML = '<div class="text-muted small">Loading...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`);
|
||||
const data = await response.json();
|
||||
if (!data.success || !data.paths.length) {
|
||||
listEl.innerHTML = '<div class="text-muted small mb-2">No paths configured. Use + to add.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = '';
|
||||
data.paths.forEach((path, index) => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'path-list-item' + (path.is_primary ? ' primary' : '');
|
||||
|
||||
// Format path hex as hop→hop→hop
|
||||
const chunk = path.hash_size * 2;
|
||||
const hops = [];
|
||||
for (let i = 0; i < path.path_hex.length; i += chunk) {
|
||||
hops.push(path.path_hex.substring(i, i + chunk).toUpperCase());
|
||||
}
|
||||
const pathDisplay = hops.join('→');
|
||||
const hashLabel = path.hash_size + 'B';
|
||||
|
||||
item.innerHTML = `
|
||||
<span class="path-hex" title="${path.path_hex}">${pathDisplay}</span>
|
||||
<span class="badge bg-secondary">${hashLabel}</span>
|
||||
${path.label ? `<span class="path-label" title="${path.label}">${path.label}</span>` : ''}
|
||||
<span class="path-actions">
|
||||
<button class="btn btn-link p-0 ${path.is_primary ? 'text-warning' : 'text-muted'}"
|
||||
title="${path.is_primary ? 'Primary path' : 'Set as primary'}"
|
||||
data-action="primary" data-id="${path.id}">
|
||||
<i class="bi bi-star${path.is_primary ? '-fill' : ''}"></i>
|
||||
</button>
|
||||
${index > 0 ? `<button class="btn btn-link p-0 text-muted" title="Move up" data-action="up" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-up"></i></button>` : ''}
|
||||
${index < data.paths.length - 1 ? `<button class="btn btn-link p-0 text-muted" title="Move down" data-action="down" data-id="${path.id}" data-index="${index}"><i class="bi bi-chevron-down"></i></button>` : ''}
|
||||
<button class="btn btn-link p-0 text-danger" title="Delete" data-action="delete" data-id="${path.id}">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</span>
|
||||
`;
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
|
||||
// Attach action handlers
|
||||
listEl.querySelectorAll('[data-action]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
const action = btn.dataset.action;
|
||||
const pathId = parseInt(btn.dataset.id);
|
||||
|
||||
if (action === 'primary') {
|
||||
await setPathPrimary(pubkey, pathId);
|
||||
} else if (action === 'delete') {
|
||||
await deletePathItem(pubkey, pathId);
|
||||
} else if (action === 'up' || action === 'down') {
|
||||
await movePathItem(pubkey, data.paths, parseInt(btn.dataset.index), action);
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="text-danger small">Failed to load paths</div>';
|
||||
console.error('Failed to load paths:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function setPathPrimary(pubkey, pathId) {
|
||||
try {
|
||||
await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/${pathId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ is_primary: true })
|
||||
});
|
||||
await renderPathList(pubkey);
|
||||
} catch (e) {
|
||||
console.error('Failed to set primary path:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePathItem(pubkey, pathId) {
|
||||
try {
|
||||
await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/${pathId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
await renderPathList(pubkey);
|
||||
} catch (e) {
|
||||
console.error('Failed to delete path:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function movePathItem(pubkey, paths, currentIndex, direction) {
|
||||
const newIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (newIndex < 0 || newIndex >= paths.length) return;
|
||||
|
||||
// Swap in the IDs array
|
||||
const ids = paths.map(p => p.id);
|
||||
[ids[currentIndex], ids[newIndex]] = [ids[newIndex], ids[currentIndex]];
|
||||
|
||||
try {
|
||||
await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/reorder`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path_ids: ids })
|
||||
});
|
||||
await renderPathList(pubkey);
|
||||
} catch (e) {
|
||||
console.error('Failed to reorder paths:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup handlers for the Add Path form.
|
||||
*/
|
||||
function setupPathFormHandlers(pubkey) {
|
||||
const addBtn = document.getElementById('dmAddPathBtn');
|
||||
const form = document.getElementById('dmAddPathForm');
|
||||
const cancelBtn = document.getElementById('dmCancelPathBtn');
|
||||
const saveBtn = document.getElementById('dmSavePathBtn');
|
||||
const pickBtn = document.getElementById('dmPickRepeaterBtn');
|
||||
const picker = document.getElementById('dmRepeaterPicker');
|
||||
const resetFloodBtn = document.getElementById('dmResetFloodBtn');
|
||||
|
||||
if (!addBtn || !form) return;
|
||||
|
||||
// Remove old listeners by cloning
|
||||
const newAddBtn = addBtn.cloneNode(true);
|
||||
addBtn.parentNode.replaceChild(newAddBtn, addBtn);
|
||||
newAddBtn.addEventListener('click', () => {
|
||||
form.style.display = '';
|
||||
newAddBtn.style.display = 'none';
|
||||
document.getElementById('dmPathHexInput').value = '';
|
||||
document.getElementById('dmPathLabelInput').value = '';
|
||||
document.getElementById('dmPathUniquenessWarning').style.display = 'none';
|
||||
if (picker) picker.style.display = 'none';
|
||||
});
|
||||
|
||||
const newCancelBtn = cancelBtn.cloneNode(true);
|
||||
cancelBtn.parentNode.replaceChild(newCancelBtn, cancelBtn);
|
||||
newCancelBtn.addEventListener('click', () => {
|
||||
form.style.display = 'none';
|
||||
newAddBtn.style.display = '';
|
||||
});
|
||||
|
||||
const newSaveBtn = saveBtn.cloneNode(true);
|
||||
saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn);
|
||||
newSaveBtn.addEventListener('click', async () => {
|
||||
const pathHex = document.getElementById('dmPathHexInput').value.replace(/[,\s→]/g, '').trim();
|
||||
const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked').value);
|
||||
const label = document.getElementById('dmPathLabelInput').value.trim();
|
||||
|
||||
if (!pathHex) {
|
||||
showNotification('Path hex is required', 'danger');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path_hex: pathHex, hash_size: hashSize, label: label })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
form.style.display = 'none';
|
||||
newAddBtn.style.display = '';
|
||||
await renderPathList(pubkey);
|
||||
showNotification('Path added', 'info');
|
||||
} else {
|
||||
showNotification(data.error || 'Failed to add path', 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Failed to add path', 'danger');
|
||||
}
|
||||
});
|
||||
|
||||
// Repeater picker toggle
|
||||
const newPickBtn = pickBtn.cloneNode(true);
|
||||
pickBtn.parentNode.replaceChild(newPickBtn, pickBtn);
|
||||
newPickBtn.addEventListener('click', () => {
|
||||
if (!picker) return;
|
||||
if (picker.style.display === 'none') {
|
||||
picker.style.display = '';
|
||||
loadRepeaterPicker(pubkey);
|
||||
} else {
|
||||
picker.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Repeater search filter
|
||||
const searchInput = document.getElementById('dmRepeaterSearch');
|
||||
if (searchInput) {
|
||||
const newSearch = searchInput.cloneNode(true);
|
||||
searchInput.parentNode.replaceChild(newSearch, searchInput);
|
||||
newSearch.addEventListener('input', () => {
|
||||
filterRepeaterList(newSearch.value);
|
||||
});
|
||||
}
|
||||
|
||||
// Reset to FLOOD button
|
||||
if (resetFloodBtn) {
|
||||
const newResetBtn = resetFloodBtn.cloneNode(true);
|
||||
resetFloodBtn.parentNode.replaceChild(newResetBtn, resetFloodBtn);
|
||||
newResetBtn.addEventListener('click', async () => {
|
||||
if (!confirm('Reset to FLOOD?\n\nThis will delete all configured paths and reset the device path to flood mode.')) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/paths/reset_flood`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
await renderPathList(pubkey);
|
||||
showNotification('Reset to FLOOD mode', 'info');
|
||||
} else {
|
||||
showNotification(data.error || 'Reset failed', 'danger');
|
||||
}
|
||||
} catch (e) {
|
||||
showNotification('Reset failed', 'danger');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load repeaters for the picker dropdown.
|
||||
*/
|
||||
async function loadRepeaterPicker(pubkey) {
|
||||
const listEl = document.getElementById('dmRepeaterList');
|
||||
if (!listEl) return;
|
||||
|
||||
if (!_repeatersCache) {
|
||||
try {
|
||||
const response = await fetch('/api/contacts/repeaters');
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
_repeatersCache = data.repeaters;
|
||||
}
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="text-danger small p-2">Failed to load repeaters</div>';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
renderRepeaterList(listEl, _repeatersCache, pubkey);
|
||||
}
|
||||
|
||||
function renderRepeaterList(listEl, repeaters, pubkey) {
|
||||
const hashSize = parseInt(document.querySelector('input[name="pathHashSize"]:checked').value);
|
||||
const hexInput = document.getElementById('dmPathHexInput');
|
||||
const searchVal = (document.getElementById('dmRepeaterSearch')?.value || '').toLowerCase();
|
||||
|
||||
const filtered = repeaters.filter(r =>
|
||||
r.name.toLowerCase().includes(searchVal) ||
|
||||
r.public_key.toLowerCase().includes(searchVal)
|
||||
);
|
||||
|
||||
if (!filtered.length) {
|
||||
listEl.innerHTML = '<div class="text-muted small p-2">No repeaters found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
listEl.innerHTML = '';
|
||||
filtered.forEach(rpt => {
|
||||
const prefix = rpt.public_key.substring(0, hashSize * 2).toUpperCase();
|
||||
// Check uniqueness: count repeaters with same prefix
|
||||
const samePrefix = repeaters.filter(r =>
|
||||
r.public_key.substring(0, hashSize * 2).toLowerCase() === prefix.toLowerCase()
|
||||
).length;
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'repeater-picker-item';
|
||||
item.innerHTML = `
|
||||
<span class="badge ${samePrefix > 1 ? 'bg-warning text-dark' : 'bg-success'}">${prefix}</span>
|
||||
<span class="flex-grow-1 text-truncate">${rpt.name}</span>
|
||||
${samePrefix > 1 ? '<i class="bi bi-exclamation-triangle text-warning" title="' + samePrefix + ' repeaters share this prefix"></i>' : ''}
|
||||
`;
|
||||
item.addEventListener('click', () => {
|
||||
// Append hop to path hex input
|
||||
const current = hexInput.value.replace(/[,\s→]/g, '').trim();
|
||||
const newVal = current + prefix.toLowerCase();
|
||||
// Format with commas for readability
|
||||
const chunk = hashSize * 2;
|
||||
const parts = [];
|
||||
for (let i = 0; i < newVal.length; i += chunk) {
|
||||
parts.push(newVal.substring(i, i + chunk));
|
||||
}
|
||||
hexInput.value = parts.join(',');
|
||||
|
||||
// Show uniqueness warning if applicable
|
||||
checkUniquenessWarning(repeaters, hashSize);
|
||||
});
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
|
||||
function filterRepeaterList(searchVal) {
|
||||
if (!_repeatersCache) return;
|
||||
const listEl = document.getElementById('dmRepeaterList');
|
||||
const pubkey = getCurrentContactPubkey();
|
||||
if (listEl) {
|
||||
renderRepeaterList(listEl, _repeatersCache.filter(r =>
|
||||
r.name.toLowerCase().includes(searchVal.toLowerCase()) ||
|
||||
r.public_key.toLowerCase().includes(searchVal.toLowerCase())
|
||||
), pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
function checkUniquenessWarning(repeaters, hashSize) {
|
||||
const warningEl = document.getElementById('dmPathUniquenessWarning');
|
||||
if (!warningEl) return;
|
||||
|
||||
const hexInput = document.getElementById('dmPathHexInput');
|
||||
const rawHex = hexInput.value.replace(/[,\s→]/g, '').trim();
|
||||
const chunk = hashSize * 2;
|
||||
const hops = [];
|
||||
for (let i = 0; i < rawHex.length; i += chunk) {
|
||||
hops.push(rawHex.substring(i, i + chunk).toLowerCase());
|
||||
}
|
||||
|
||||
const ambiguous = hops.filter(hop => {
|
||||
const count = repeaters.filter(r =>
|
||||
r.public_key.substring(0, chunk).toLowerCase() === hop
|
||||
).length;
|
||||
return count > 1;
|
||||
});
|
||||
|
||||
if (ambiguous.length > 0) {
|
||||
warningEl.textContent = `⚠ Ambiguous prefix(es): ${ambiguous.map(h => h.toUpperCase()).join(', ')}. Consider using a larger hash size.`;
|
||||
warningEl.style.display = '';
|
||||
} else {
|
||||
warningEl.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and setup the No Auto Flood toggle for current contact.
|
||||
*/
|
||||
async function loadNoAutoFloodToggle(pubkey) {
|
||||
const toggle = document.getElementById('dmNoAutoFloodToggle');
|
||||
if (!toggle || !pubkey) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/no_auto_flood`);
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
toggle.checked = data.no_auto_flood;
|
||||
}
|
||||
} catch (e) {
|
||||
console.debug('Failed to load no_auto_flood:', e);
|
||||
}
|
||||
|
||||
// Replace to avoid duplicate listeners
|
||||
const newToggle = toggle.cloneNode(true);
|
||||
toggle.parentNode.replaceChild(newToggle, toggle);
|
||||
newToggle.id = 'dmNoAutoFloodToggle';
|
||||
|
||||
newToggle.addEventListener('change', async function () {
|
||||
try {
|
||||
const response = await fetch(`/api/contacts/${encodeURIComponent(pubkey)}/no_auto_flood`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ no_auto_flood: this.checked })
|
||||
});
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
showNotification(
|
||||
data.no_auto_flood ? 'No Flood Fallback enabled' : 'No Flood Fallback disabled',
|
||||
'info'
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to update no_auto_flood:', e);
|
||||
this.checked = !this.checked;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Listen for hash size radio changes to re-render repeater list
|
||||
document.addEventListener('change', (e) => {
|
||||
if (e.target.name === 'pathHashSize') {
|
||||
_repeatersCache = null; // Refresh to recalculate prefixes
|
||||
const picker = document.getElementById('dmRepeaterPicker');
|
||||
if (picker && picker.style.display !== 'none') {
|
||||
loadRepeaterPicker(getCurrentContactPubkey());
|
||||
}
|
||||
// Clear path hex input when changing hash size
|
||||
const hexInput = document.getElementById('dmPathHexInput');
|
||||
if (hexInput) hexInput.value = '';
|
||||
}
|
||||
});
|
||||
|
||||
+139
-3
@@ -101,6 +101,78 @@
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -236,11 +308,75 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body" id="dmContactInfoBody"></div>
|
||||
<!-- Path management section (populated dynamically) -->
|
||||
<div class="modal-body border-top pt-2 pb-1" id="dmPathSection" style="display: none;">
|
||||
<div class="path-section-header">
|
||||
<h6><i class="bi bi-signpost-split"></i> Paths</h6>
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="dmAddPathBtn" title="Add path">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="dmPathList"></div>
|
||||
<!-- Add Path form (hidden by default) -->
|
||||
<div id="dmAddPathForm" style="display: none;" class="border rounded p-2 mb-2">
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Hash Size</label>
|
||||
<div class="btn-group btn-group-sm w-100" role="group">
|
||||
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash1" value="1" checked>
|
||||
<label class="btn btn-outline-secondary" for="pathHash1">1B (max 64)</label>
|
||||
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash2" value="2">
|
||||
<label class="btn btn-outline-secondary" for="pathHash2">2B (max 32)</label>
|
||||
<input type="radio" class="btn-check" name="pathHashSize" id="pathHash3" value="3">
|
||||
<label class="btn btn-outline-secondary" for="pathHash3">3B (max 21)</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Path (hex)</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" class="form-control font-monospace" id="dmPathHexInput"
|
||||
placeholder="e.g. 5e,e7 or 5e34,e761" autocomplete="off">
|
||||
<button type="button" class="btn btn-outline-secondary" id="dmPickRepeaterBtn"
|
||||
title="Pick repeater">
|
||||
<i class="bi bi-plus-circle"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="dmPathUniquenessWarning" class="path-uniqueness-warning mt-1" style="display: none;"></div>
|
||||
</div>
|
||||
<!-- Repeater picker (hidden by default) -->
|
||||
<div id="dmRepeaterPicker" style="display: none;" class="border rounded mb-2" style="max-height: 200px; overflow-y: auto;">
|
||||
<input type="text" class="form-control form-control-sm border-0 border-bottom"
|
||||
id="dmRepeaterSearch" placeholder="Search repeaters..." autocomplete="off">
|
||||
<div id="dmRepeaterList" style="max-height: 180px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label class="form-label small mb-1">Label (optional)</label>
|
||||
<input type="text" class="form-control form-control-sm" id="dmPathLabelInput"
|
||||
placeholder="e.g. via Mountain RPT" maxlength="50">
|
||||
</div>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="dmCancelPathBtn">Cancel</button>
|
||||
<button type="button" class="btn btn-sm btn-primary" id="dmSavePathBtn">Add Path</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Reset to FLOOD button -->
|
||||
<div class="text-end mt-1">
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" id="dmResetFloodBtn"
|
||||
title="Reset all paths and switch to FLOOD mode">
|
||||
<i class="bi bi-broadcast"></i> Reset to FLOOD
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="d-flex align-items-center justify-content-between w-100">
|
||||
<div class="form-check form-switch" title="Auto Retry: resend DM if no ACK received">
|
||||
<input class="form-check-input" type="checkbox" id="dmAutoRetryToggle" checked>
|
||||
<label class="form-check-label small" for="dmAutoRetryToggle">Auto Retry</label>
|
||||
<div class="d-flex gap-3">
|
||||
<div class="form-check form-switch" title="Auto Retry: resend DM if no ACK received">
|
||||
<input class="form-check-input" type="checkbox" id="dmAutoRetryToggle" checked>
|
||||
<label class="form-check-label small" for="dmAutoRetryToggle">Auto Retry</label>
|
||||
</div>
|
||||
<div class="form-check form-switch" title="No Flood Fallback: never auto-reset path to FLOOD">
|
||||
<input class="form-check-input" type="checkbox" id="dmNoAutoFloodToggle">
|
||||
<label class="form-check-label small" for="dmNoAutoFloodToggle">No Flood</label>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user