interface discovery, folders for messages, map nodes from discovery, maintenance tools.

This commit is contained in:
2026-01-05 17:38:52 -06:00
parent 30cab64101
commit 666c90875a
26 changed files with 3272 additions and 294 deletions

View File

@@ -42,6 +42,7 @@ from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.x509.oid import NameOID
from RNS.Discovery import InterfaceDiscovery
from serial.tools import list_ports
from meshchatx.src.backend.async_utils import AsyncUtils
@@ -2152,8 +2153,37 @@ class ReticulumMeshChat:
@routes.get("/api/v1/database/snapshots")
async def list_db_snapshots(request):
try:
limit = int(request.query.get("limit", 100))
offset = int(request.query.get("offset", 0))
snapshots = self.database.list_snapshots(self.storage_dir)
return web.json_response(snapshots)
total = len(snapshots)
paginated_snapshots = snapshots[offset : offset + limit]
return web.json_response(
{
"snapshots": paginated_snapshots,
"total": total,
"limit": limit,
"offset": offset,
},
)
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
status=500,
)
@routes.delete("/api/v1/database/snapshots/{filename}")
async def delete_db_snapshot(request):
try:
filename = request.match_info.get("filename")
if not filename.endswith(".zip"):
filename += ".zip"
self.database.delete_snapshot_or_backup(
self.storage_dir,
filename,
is_backup=False,
)
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
@@ -2199,9 +2229,13 @@ class ReticulumMeshChat:
@routes.get("/api/v1/database/backups")
async def list_db_backups(request):
try:
limit = int(request.query.get("limit", 100))
offset = int(request.query.get("offset", 0))
backup_dir = os.path.join(self.storage_dir, "database-backups")
if not os.path.exists(backup_dir):
return web.json_response([])
return web.json_response(
{"backups": [], "total": 0, "limit": limit, "offset": offset},
)
backups = []
for file in os.listdir(backup_dir):
@@ -2219,9 +2253,39 @@ class ReticulumMeshChat:
).isoformat(),
},
)
return web.json_response(
sorted(backups, key=lambda x: x["created_at"], reverse=True),
sorted_backups = sorted(
backups,
key=lambda x: x["created_at"],
reverse=True,
)
total = len(sorted_backups)
paginated_backups = sorted_backups[offset : offset + limit]
return web.json_response(
{
"backups": paginated_backups,
"total": total,
"limit": limit,
"offset": offset,
},
)
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
status=500,
)
@routes.delete("/api/v1/database/backups/{filename}")
async def delete_db_backup(request):
try:
filename = request.match_info.get("filename")
if not filename.endswith(".zip"):
filename += ".zip"
self.database.delete_snapshot_or_backup(
self.storage_dir,
filename,
is_backup=True,
)
return web.json_response({"status": "success"})
except Exception as e:
return web.json_response(
{"status": "error", "message": str(e)},
@@ -3360,6 +3424,7 @@ class ReticulumMeshChat:
),
"ply": self.get_package_version("ply"),
"bcrypt": self.get_package_version("bcrypt"),
"lxmfy": self.get_package_version("lxmfy"),
},
"storage_path": self.storage_path,
"database_path": self.database_path,
@@ -3939,6 +4004,62 @@ class ReticulumMeshChat:
status=500,
)
# maintenance - clear messages
@routes.delete("/api/v1/maintenance/messages")
async def maintenance_clear_messages(request):
self.database.messages.delete_all_lxmf_messages()
return web.json_response({"message": "All messages cleared"})
# maintenance - clear announces
@routes.delete("/api/v1/maintenance/announces")
async def maintenance_clear_announces(request):
aspect = request.query.get("aspect")
self.database.announces.delete_all_announces(aspect=aspect)
return web.json_response(
{
"message": f"Announces cleared{' for aspect ' + aspect if aspect else ''}",
},
)
# maintenance - clear favorites
@routes.delete("/api/v1/maintenance/favourites")
async def maintenance_clear_favourites(request):
aspect = request.query.get("aspect")
self.database.announces.delete_all_favourites(aspect=aspect)
return web.json_response(
{
"message": f"Favourites cleared{' for aspect ' + aspect if aspect else ''}",
},
)
# maintenance - clear archives
@routes.delete("/api/v1/maintenance/archives")
async def maintenance_clear_archives(request):
self.database.misc.delete_archived_pages()
return web.json_response({"message": "All archived pages cleared"})
# maintenance - export messages
@routes.get("/api/v1/maintenance/messages/export")
async def maintenance_export_messages(request):
messages = self.database.messages.get_all_lxmf_messages()
# Convert sqlite3.Row to dict if necessary
messages_list = [dict(m) for m in messages]
return web.json_response({"messages": messages_list})
# maintenance - import messages
@routes.post("/api/v1/maintenance/messages/import")
async def maintenance_import_messages(request):
try:
data = await request.json()
messages = data.get("messages", [])
for msg in messages:
self.database.messages.upsert_lxmf_message(msg)
return web.json_response(
{"message": f"Successfully imported {len(messages)} messages"},
)
except Exception as e:
return web.json_response({"error": str(e)}, status=400)
# get config
@routes.get("/api/v1/config")
async def config_get(request):
@@ -4043,6 +4164,91 @@ class ReticulumMeshChat:
return web.json_response({"discovery": discovery_config})
@routes.get("/api/v1/reticulum/discovered-interfaces")
async def reticulum_discovered_interfaces(request):
try:
discovery = InterfaceDiscovery(discover_interfaces=False)
interfaces = discovery.list_discovered_interfaces()
active = []
try:
if hasattr(self, "reticulum") and self.reticulum:
stats = self.reticulum.get_interface_stats().get(
"interfaces",
[],
)
active = []
for s in stats:
name = s.get("name") or ""
parsed_host = None
parsed_port = None
if "/" in name:
try:
host_port = name.split("/")[-1].strip("[]")
if ":" in host_port:
parsed_host, parsed_port = host_port.rsplit(
":",
1,
)
try:
parsed_port = int(parsed_port)
except Exception:
parsed_port = None
else:
parsed_host = host_port
except Exception:
parsed_host = None
parsed_port = None
host = (
s.get("target_host") or s.get("remote") or parsed_host
)
port = (
s.get("target_port")
or s.get("listen_port")
or parsed_port
)
transport_id = s.get("transport_id")
if isinstance(transport_id, (bytes, bytearray)):
transport_id = transport_id.hex()
active.append(
{
"name": name,
"short_name": s.get("short_name"),
"type": s.get("type"),
"target_host": host,
"target_port": port,
"listen_ip": s.get("listen_ip"),
"connected": s.get("connected"),
"online": s.get("online"),
"transport_id": transport_id,
"network_id": s.get("network_id"),
},
)
except Exception as e:
logger.debug(f"Failed to get interface stats: {e}")
def to_jsonable(obj):
if isinstance(obj, bytes):
return obj.hex()
if isinstance(obj, dict):
return {k: to_jsonable(v) for k, v in obj.items()}
if isinstance(obj, list):
return [to_jsonable(v) for v in obj]
return obj
return web.json_response(
{
"interfaces": to_jsonable(interfaces),
"active": to_jsonable(active),
},
)
except Exception as e:
return web.json_response(
{"message": f"Failed to load discovered interfaces: {e!s}"},
status=500,
)
# enable transport mode
@routes.post("/api/v1/reticulum/enable-transport")
async def reticulum_enable_transport(request):
@@ -6920,6 +7126,12 @@ class ReticulumMeshChat:
request.query.get("filter_has_attachments", "false"),
),
)
folder_id = request.query.get("folder_id")
if folder_id is not None:
try:
folder_id = int(folder_id)
except ValueError:
folder_id = None
# get pagination params
try:
@@ -6943,6 +7155,7 @@ class ReticulumMeshChat:
filter_unread=filter_unread,
filter_failed=filter_failed,
filter_has_attachments=filter_has_attachments,
folder_id=folder_id,
limit=limit,
offset=offset,
)
@@ -7021,6 +7234,123 @@ class ReticulumMeshChat:
},
)
@routes.get("/api/v1/lxmf/folders")
async def lxmf_folders_get(request):
folders = self.database.messages.get_all_folders()
return web.json_response([dict(f) for f in folders])
@routes.post("/api/v1/lxmf/folders")
async def lxmf_folders_post(request):
data = await request.json()
name = data.get("name")
if not name:
return web.json_response({"message": "Name is required"}, status=400)
try:
self.database.messages.create_folder(name)
return web.json_response({"message": "Folder created"})
except Exception as e:
return web.json_response({"message": str(e)}, status=500)
@routes.patch("/api/v1/lxmf/folders/{id}")
async def lxmf_folders_patch(request):
folder_id = int(request.match_info["id"])
data = await request.json()
name = data.get("name")
if not name:
return web.json_response({"message": "Name is required"}, status=400)
self.database.messages.rename_folder(folder_id, name)
return web.json_response({"message": "Folder renamed"})
@routes.delete("/api/v1/lxmf/folders/{id}")
async def lxmf_folders_delete(request):
folder_id = int(request.match_info["id"])
self.database.messages.delete_folder(folder_id)
return web.json_response({"message": "Folder deleted"})
@routes.post("/api/v1/lxmf/conversations/move-to-folder")
async def lxmf_conversations_move_to_folder(request):
data = await request.json()
peer_hashes = data.get("peer_hashes", [])
folder_id = data.get("folder_id") # Can be None to remove from folder
if not peer_hashes:
return web.json_response(
{"message": "peer_hashes is required"},
status=400,
)
self.database.messages.move_conversations_to_folder(peer_hashes, folder_id)
return web.json_response({"message": "Conversations moved"})
@routes.post("/api/v1/lxmf/conversations/bulk-mark-as-read")
async def lxmf_conversations_bulk_mark_read(request):
data = await request.json()
destination_hashes = data.get("destination_hashes", [])
if not destination_hashes:
return web.json_response(
{"message": "destination_hashes is required"},
status=400,
)
self.database.messages.mark_conversations_as_read(destination_hashes)
return web.json_response({"message": "Conversations marked as read"})
@routes.post("/api/v1/lxmf/conversations/bulk-delete")
async def lxmf_conversations_bulk_delete(request):
data = await request.json()
destination_hashes = data.get("destination_hashes", [])
if not destination_hashes:
return web.json_response(
{"message": "destination_hashes is required"},
status=400,
)
local_hash = self.local_lxmf_destination.hexhash
for dest_hash in destination_hashes:
self.message_handler.delete_conversation(local_hash, dest_hash)
return web.json_response({"message": "Conversations deleted"})
@routes.get("/api/v1/lxmf/folders/export")
async def lxmf_folders_export(request):
folders = [dict(f) for f in self.database.messages.get_all_folders()]
mappings = [
dict(m) for m in self.database.messages.get_all_conversation_folders()
]
return web.json_response({"folders": folders, "mappings": mappings})
@routes.post("/api/v1/lxmf/folders/import")
async def lxmf_folders_import(request):
data = await request.json()
folders = data.get("folders", [])
mappings = data.get("mappings", [])
# We'll try to recreate folders by name to avoid ID conflicts
folder_name_to_new_id = {}
for f in folders:
try:
self.database.messages.create_folder(f["name"])
except Exception as e:
logger.debug(f"Folder '{f['name']}' likely already exists: {e}")
# Refresh folder list to get new IDs
all_folders = self.database.messages.get_all_folders()
for f in all_folders:
folder_name_to_new_id[f["name"]] = f["id"]
# Map old IDs to new IDs if possible, or just use names if we had them
# Since IDs might change, we should have exported names too
# Let's assume the export had folder names in mappings or we match by old folder info
old_id_to_name = {f["id"]: f["name"] for f in folders}
for m in mappings:
peer_hash = m["peer_hash"]
old_folder_id = m["folder_id"]
folder_name = old_id_to_name.get(old_folder_id)
if folder_name and folder_name in folder_name_to_new_id:
new_folder_id = folder_name_to_new_id[folder_name]
self.database.messages.move_conversation_to_folder(
peer_hash,
new_folder_id,
)
return web.json_response({"message": "Folders and mappings imported"})
# mark lxmf conversation as read
@routes.get("/api/v1/lxmf/conversations/{destination_hash}/mark-as-read")
async def lxmf_conversations_mark_read(request):
@@ -7806,7 +8136,7 @@ class ReticulumMeshChat:
f"connect-src {' '.join(connect_sources)}; "
"media-src 'self' blob:; "
"worker-src 'self' blob:; "
"frame-src 'self'; "
"frame-src 'self' https://reticulum.network; "
"object-src 'none'; "
"base-uri 'self';"
)
@@ -7922,17 +8252,24 @@ class ReticulumMeshChat:
# (e.g. when running from a read-only AppImage)
if self.current_context and hasattr(self.current_context, "docs_manager"):
dm = self.current_context.docs_manager
if (
dm.docs_dir
and os.path.exists(dm.docs_dir)
and not dm.docs_dir.startswith(public_dir)
):
app.router.add_static(
"/reticulum-docs/",
dm.docs_dir,
name="reticulum_docs_storage",
follow_symlinks=True,
)
# Custom handler for reticulum docs to allow fallback to official website
async def reticulum_docs_handler(request):
path = request.match_info.get("filename", "index.html")
if not path:
path = "index.html"
if path.endswith("/"):
path += "index.html"
local_path = os.path.join(dm.docs_dir, path)
if os.path.exists(local_path) and os.path.isfile(local_path):
return web.FileResponse(local_path)
# Fallback to official website
return web.HTTPFound(f"https://reticulum.network/manual/{path}")
app.router.add_get("/reticulum-docs/{filename:.*}", reticulum_docs_handler)
if (
dm.meshchatx_docs_dir
and os.path.exists(dm.meshchatx_docs_dir)
@@ -7978,7 +8315,8 @@ class ReticulumMeshChat:
print(
f"Performing scheduled auto-backup for {ctx.identity_hash}...",
)
ctx.database.backup_database(self.storage_dir)
max_count = ctx.config.backup_max_count.get()
ctx.database.backup_database(self.storage_dir, max_count=max_count)
except Exception as e:
print(f"Auto-backup failed: {e}")

View File

@@ -240,7 +240,9 @@ class BotHandler:
shutil.rmtree(storage_dir)
except Exception as exc:
logger.warning(
"Failed to delete storage dir for bot %s: %s", bot_id, exc
"Failed to delete storage dir for bot %s: %s",
bot_id,
exc,
)
self._save_state()

View File

@@ -103,6 +103,7 @@ class ConfigManager:
"archives_max_storage_gb",
1,
)
self.backup_max_count = self.IntConfig(self, "backup_max_count", 5)
self.crawler_enabled = self.BoolConfig(self, "crawler_enabled", False)
self.crawler_max_retries = self.IntConfig(self, "crawler_max_retries", 3)
self.crawler_retry_delay_seconds = self.IntConfig(

View File

@@ -211,14 +211,41 @@ class Database:
"size": os.path.getsize(backup_path),
}
def backup_database(self, storage_path, backup_path: str | None = None):
def backup_database(
self,
storage_path,
backup_path: str | None = None,
max_count: int | None = None,
):
default_dir = os.path.join(storage_path, "database-backups")
os.makedirs(default_dir, exist_ok=True)
if backup_path is None:
timestamp = datetime.now(UTC).strftime("%Y%m%d-%H%M%S")
backup_path = os.path.join(default_dir, f"backup-{timestamp}.zip")
return self._backup_to_zip(backup_path)
result = self._backup_to_zip(backup_path)
# Cleanup old backups if a limit is set
if max_count is not None and max_count > 0:
try:
backups = []
for file in os.listdir(default_dir):
if file.endswith(".zip"):
full_path = os.path.join(default_dir, file)
stats = os.stat(full_path)
backups.append((full_path, stats.st_mtime))
if len(backups) > max_count:
# Sort by modification time (oldest first)
backups.sort(key=lambda x: x[1])
to_delete = backups[: len(backups) - max_count]
for path, _ in to_delete:
if os.path.exists(path):
os.remove(path)
except Exception as e:
print(f"Failed to cleanup old backups: {e}")
return result
def create_snapshot(self, storage_path, name: str):
"""Creates a named snapshot of the database."""
@@ -258,6 +285,29 @@ class Database:
)
return sorted(snapshots, key=lambda x: x["created_at"], reverse=True)
def delete_snapshot_or_backup(
self,
storage_path,
filename: str,
is_backup: bool = False,
):
"""Deletes a database snapshot or auto-backup."""
base_dir = "database-backups" if is_backup else "snapshots"
file_path = os.path.join(storage_path, base_dir, filename)
# Basic security check to ensure we stay within the intended directory
abs_path = os.path.abspath(file_path)
abs_base = os.path.abspath(os.path.join(storage_path, base_dir))
if not abs_path.startswith(abs_base):
msg = "Invalid path"
raise ValueError(msg)
if os.path.exists(abs_path):
os.remove(abs_path)
return True
return False
def restore_database(self, backup_path: str):
if not os.path.exists(backup_path):
msg = f"Backup not found at {backup_path}"

View File

@@ -54,6 +54,15 @@ class AnnounceDAO:
(destination_hash,),
)
def delete_all_announces(self, aspect=None):
if aspect:
self.provider.execute(
"DELETE FROM announces WHERE aspect = ?",
(aspect,),
)
else:
self.provider.execute("DELETE FROM announces")
def get_filtered_announces(
self,
aspect=None,
@@ -137,3 +146,12 @@ class AnnounceDAO:
"DELETE FROM favourite_destinations WHERE destination_hash = ?",
(destination_hash,),
)
def delete_all_favourites(self, aspect=None):
if aspect:
self.provider.execute(
"DELETE FROM favourite_destinations WHERE aspect = ?",
(aspect,),
)
else:
self.provider.execute("DELETE FROM favourite_destinations")

View File

@@ -63,12 +63,28 @@ class MessageDAO:
(message_hash,),
)
def delete_lxmf_messages_by_hashes(self, message_hashes):
if not message_hashes:
return
placeholders = ", ".join(["?"] * len(message_hashes))
self.provider.execute(
f"DELETE FROM lxmf_messages WHERE hash IN ({placeholders})",
tuple(message_hashes),
)
def delete_lxmf_message_by_hash(self, message_hash):
self.provider.execute(
"DELETE FROM lxmf_messages WHERE hash = ?",
(message_hash,),
)
def delete_all_lxmf_messages(self):
self.provider.execute("DELETE FROM lxmf_messages")
self.provider.execute("DELETE FROM lxmf_conversation_read_state")
def get_all_lxmf_messages(self):
return self.provider.fetchall("SELECT * FROM lxmf_messages")
def get_conversation_messages(self, destination_hash, limit=100, offset=0):
return self.provider.fetchall(
"SELECT * FROM lxmf_messages WHERE peer_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
@@ -103,6 +119,22 @@ class MessageDAO:
(destination_hash, now, now, now),
)
def mark_conversations_as_read(self, destination_hashes):
if not destination_hashes:
return
now = datetime.now(UTC).isoformat()
for destination_hash in destination_hashes:
self.provider.execute(
"""
INSERT INTO lxmf_conversation_read_state (destination_hash, last_read_at, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET
last_read_at = EXCLUDED.last_read_at,
updated_at = EXCLUDED.updated_at
""",
(destination_hash, now, now, now),
)
def is_conversation_unread(self, destination_hash):
row = self.provider.fetchone(
"""
@@ -290,3 +322,56 @@ class MessageDAO:
last_viewed_at = last_viewed_at.replace(tzinfo=UTC)
return message_timestamp <= last_viewed_at.timestamp()
# Folders
def get_all_folders(self):
return self.provider.fetchall("SELECT * FROM lxmf_folders ORDER BY name ASC")
def create_folder(self, name):
now = datetime.now(UTC).isoformat()
return self.provider.execute(
"INSERT INTO lxmf_folders (name, created_at, updated_at) VALUES (?, ?, ?)",
(name, now, now),
)
def rename_folder(self, folder_id, new_name):
now = datetime.now(UTC).isoformat()
self.provider.execute(
"UPDATE lxmf_folders SET name = ?, updated_at = ? WHERE id = ?",
(new_name, now, folder_id),
)
def delete_folder(self, folder_id):
self.provider.execute("DELETE FROM lxmf_folders WHERE id = ?", (folder_id,))
def get_conversation_folder(self, peer_hash):
return self.provider.fetchone(
"SELECT * FROM lxmf_conversation_folders WHERE peer_hash = ?",
(peer_hash,),
)
def move_conversation_to_folder(self, peer_hash, folder_id):
now = datetime.now(UTC).isoformat()
if folder_id is None:
self.provider.execute(
"DELETE FROM lxmf_conversation_folders WHERE peer_hash = ?",
(peer_hash,),
)
else:
self.provider.execute(
"""
INSERT INTO lxmf_conversation_folders (peer_hash, folder_id, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(peer_hash) DO UPDATE SET
folder_id = EXCLUDED.folder_id,
updated_at = EXCLUDED.updated_at
""",
(peer_hash, folder_id, now, now),
)
def move_conversations_to_folder(self, peer_hashes, folder_id):
for peer_hash in peer_hashes:
self.move_conversation_to_folder(peer_hash, folder_id)
def get_all_conversation_folders(self):
return self.provider.fetchall("SELECT * FROM lxmf_conversation_folders")

View File

@@ -2,7 +2,7 @@ from .provider import DatabaseProvider
class DatabaseSchema:
LATEST_VERSION = 35
LATEST_VERSION = 36
def __init__(self, provider: DatabaseProvider):
self.provider = provider
@@ -423,6 +423,24 @@ class DatabaseSchema:
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"lxmf_folders": """
CREATE TABLE IF NOT EXISTS lxmf_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"lxmf_conversation_folders": """
CREATE TABLE IF NOT EXISTS lxmf_conversation_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
peer_hash TEXT UNIQUE,
folder_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (folder_id) REFERENCES lxmf_folders(id) ON DELETE CASCADE
)
""",
}
for table_name, create_sql in tables.items():
@@ -933,6 +951,32 @@ class DatabaseSchema:
"ALTER TABLE contacts ADD COLUMN lxst_address TEXT DEFAULT NULL",
)
if current_version < 36:
self._safe_execute("""
CREATE TABLE IF NOT EXISTS lxmf_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self._safe_execute("""
CREATE TABLE IF NOT EXISTS lxmf_conversation_folders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
peer_hash TEXT UNIQUE,
folder_id INTEGER,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (folder_id) REFERENCES lxmf_folders(id) ON DELETE CASCADE
)
""")
self._safe_execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_conversation_folders_peer_hash ON lxmf_conversation_folders(peer_hash)",
)
self._safe_execute(
"CREATE INDEX IF NOT EXISTS idx_lxmf_conversation_folders_folder_id ON lxmf_conversation_folders(folder_id)",
)
# Update version in config
self._safe_execute(
"""

View File

@@ -38,7 +38,12 @@ class DocsManager:
# Ensure docs directories exist
try:
for d in [self.docs_base_dir, self.versions_dir, self.meshchatx_docs_dir]:
for d in [
self.docs_base_dir,
self.versions_dir,
self.docs_dir,
self.meshchatx_docs_dir,
]:
if not os.path.exists(d):
os.makedirs(d)
@@ -423,8 +428,6 @@ class DocsManager:
def has_docs(self):
# Check if index.html exists in the docs folder or if we have any versions
if self.config.docs_downloaded.get():
return True
return (
os.path.exists(os.path.join(self.docs_dir, "index.html"))
or len(self.get_available_versions()) > 0

View File

@@ -35,6 +35,11 @@ class MessageHandler:
def delete_conversation(self, local_hash, destination_hash):
query = "DELETE FROM lxmf_messages WHERE peer_hash = ?"
self.db.provider.execute(query, [destination_hash])
# Also clean up folder mapping
self.db.provider.execute(
"DELETE FROM lxmf_conversation_folders WHERE peer_hash = ?",
[destination_hash],
)
def search_messages(self, local_hash, search_term):
like_term = f"%{search_term}%"
@@ -54,6 +59,7 @@ class MessageHandler:
filter_unread=False,
filter_failed=False,
filter_has_attachments=False,
folder_id=None,
limit=None,
offset=0,
):
@@ -66,6 +72,8 @@ class MessageHandler:
con.custom_image as contact_image,
i.icon_name, i.foreground_colour, i.background_colour,
r.last_read_at,
f.id as folder_id,
fn.name as folder_name,
(SELECT COUNT(*) FROM lxmf_messages m_failed
WHERE m_failed.peer_hash = m1.peer_hash AND m_failed.state = 'failed') as failed_count
FROM lxmf_messages m1
@@ -84,10 +92,20 @@ class MessageHandler:
)
LEFT JOIN lxmf_user_icons i ON i.destination_hash = m1.peer_hash
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = m1.peer_hash
LEFT JOIN lxmf_conversation_folders f ON f.peer_hash = m1.peer_hash
LEFT JOIN lxmf_folders fn ON fn.id = f.folder_id
"""
params = []
where_clauses = []
if folder_id is not None:
if folder_id == 0 or folder_id == "0":
# Special case: no folder (Uncategorized)
where_clauses.append("f.folder_id IS NULL")
else:
where_clauses.append("f.folder_id = ?")
params.append(folder_id)
if filter_unread:
where_clauses.append(
"(r.last_read_at IS NULL OR m1.timestamp > strftime('%s', r.last_read_at))",

View File

@@ -180,7 +180,161 @@
</p>
</div>
<div class="space-y-4">
<div v-if="discoveryOption === null" class="flex flex-col items-center gap-6 py-4">
<div
class="bg-blue-500/10 dark:bg-blue-500/20 p-6 rounded-[2rem] text-center space-y-4 border border-blue-500/20 max-w-md"
>
<v-icon icon="mdi-account-search" color="blue" size="48"></v-icon>
<div class="text-lg font-bold text-gray-900 dark:text-white">
{{
$t("tutorial.discovery_question") ||
"Do you want to use community interface discovering and auto-connect?"
}}
</div>
<p class="text-sm text-gray-600 dark:text-zinc-400">
{{
$t("tutorial.discovery_desc") ||
"This allows MeshChatX to automatically find and connect to public community nodes near you or on the internet."
}}
</p>
<div class="flex gap-3 justify-center pt-2">
<button
type="button"
class="px-6 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-bold shadow-lg transition-all"
:disabled="savingDiscovery"
@click="useDiscovery"
>
<v-progress-circular
v-if="savingDiscovery"
indeterminate
size="16"
width="2"
class="mr-2"
></v-progress-circular>
{{ $t("tutorial.yes") || "Yes, use discovery" }}
</button>
<button
type="button"
class="px-6 py-2 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-bold shadow-sm transition-all"
@click="discoveryOption = 'no'"
>
{{ $t("tutorial.no") || "No, manual setup" }}
</button>
</div>
</div>
</div>
<div v-else class="space-y-4">
<!-- Discovered Interfaces (if any) -->
<div
v-if="sortedDiscoveredInterfaces.length > 0"
class="bg-emerald-500/5 dark:bg-emerald-500/10 rounded-3xl p-4 border border-emerald-500/20"
>
<div class="flex items-center gap-2 mb-3 px-1 text-sm">
<v-icon icon="mdi-radar" color="emerald"></v-icon>
<span class="font-bold text-gray-900 dark:text-white">Discovered Interfaces</span>
</div>
<div class="space-y-3 max-h-[300px] overflow-y-auto pr-2 custom-scrollbar">
<div
v-for="iface in sortedDiscoveredInterfaces"
:key="iface.discovery_hash || iface.name"
class="interface-card group !p-3 transition-all duration-300"
>
<div class="flex gap-3 items-start relative">
<div class="interface-card__icon !w-10 !h-10 !rounded-xl shrink-0">
<MaterialDesignIcon
:icon-name="getDiscoveryIcon(iface)"
class="w-5 h-5"
/>
</div>
<div class="flex-1 min-w-0 space-y-1">
<div class="flex items-center gap-2 flex-nowrap min-w-0">
<div
class="text-sm font-bold text-gray-900 dark:text-white truncate min-w-0"
>
{{ iface.name }}
</div>
<span class="type-chip !text-[9px] !px-1.5 shrink-0">{{
iface.type
}}</span>
</div>
<div class="flex items-center gap-2 flex-wrap">
<span
v-if="iface.value"
class="text-[9px] font-bold text-blue-600 dark:text-blue-400 shrink-0"
>
Stamps: {{ iface.value }}
</span>
</div>
<div class="flex flex-wrap gap-1.5 text-[10px] text-gray-500">
<span>Hops: {{ iface.hops }}</span>
<span class="capitalize shrink-0">{{ iface.status }}</span>
<span v-if="iface.last_heard" class="shrink-0">
{{ formatLastHeard(iface.last_heard) }}
</span>
</div>
<div
class="flex flex-col gap-0.5 pt-1 text-[9px] text-gray-400 dark:text-zinc-500 min-w-0"
>
<div
v-if="iface.reachable_on"
class="flex items-center gap-1.5 hover:text-blue-500 cursor-pointer min-w-0"
@click="
copyToClipboard(
`${iface.reachable_on}:${iface.port}`,
'Address'
)
"
>
<MaterialDesignIcon
icon-name="link-variant"
class="w-3 h-3 shrink-0"
/>
<span class="truncate"
>Address: {{ iface.reachable_on }}:{{ iface.port }}</span
>
</div>
<div
v-if="iface.transport_id"
class="flex items-center gap-1.5 hover:text-blue-500 cursor-pointer min-w-0"
@click="copyToClipboard(iface.transport_id, 'Transport ID')"
>
<MaterialDesignIcon
icon-name="identifier"
class="w-3 h-3 shrink-0"
/>
<span class="truncate font-mono"
>Transport ID: {{ iface.transport_id }}</span
>
</div>
<div
v-if="iface.network_id"
class="flex items-center gap-1.5 hover:text-blue-500 cursor-pointer min-w-0"
@click="copyToClipboard(iface.network_id, 'Network ID')"
>
<MaterialDesignIcon icon-name="lan" class="w-3 h-3 shrink-0" />
<span class="truncate font-mono"
>Network ID: {{ iface.network_id }}</span
>
</div>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<span
class="text-[8px] bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-2 py-0.5 rounded-full font-bold uppercase tracking-wider"
>Heard</span
>
</div>
</div>
</div>
</div>
</div>
<!-- Community Interfaces -->
<div
class="bg-gray-50 dark:bg-zinc-900 rounded-3xl p-3 border border-gray-100 dark:border-zinc-800"
@@ -232,7 +386,10 @@
</div>
</div>
<div class="flex flex-col items-center gap-3 text-sm text-gray-900 dark:text-white">
<div
v-if="discoveryOption !== null"
class="flex flex-col items-center gap-3 text-sm text-gray-900 dark:text-white"
>
<p class="max-w-sm text-center">
{{ $t("tutorial.custom_interfaces_desc") }}
</p>
@@ -479,7 +636,7 @@
</div>
<div class="flex-1 overflow-y-auto px-6 md:px-12 py-10">
<div class="max-w-4xl mx-auto h-full flex flex-col justify-between">
<div class="w-full h-full flex flex-col justify-between">
<transition name="fade-slide" mode="out-in">
<!-- Step 1: Welcome -->
<div
@@ -626,10 +783,213 @@
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div v-if="discoveryOption === null" class="flex flex-col items-center gap-8 py-12">
<div
class="bg-blue-500/10 dark:bg-blue-500/20 p-12 rounded-[3rem] text-center space-y-6 border border-blue-500/20 max-w-2xl shadow-2xl"
>
<v-icon icon="mdi-account-search" color="blue" size="80"></v-icon>
<div class="text-3xl font-black text-gray-900 dark:text-white">
{{
$t("tutorial.discovery_question") ||
"Do you want to use community interface discovering and auto-connect?"
}}
</div>
<p class="text-xl text-gray-600 dark:text-zinc-400">
{{
$t("tutorial.discovery_desc") ||
"This allows MeshChatX to automatically find and connect to public community nodes near you or on the internet."
}}
</p>
<div class="flex gap-6 justify-center pt-4">
<button
type="button"
class="px-10 py-4 text-xl rounded-2xl bg-blue-600 hover:bg-blue-500 text-white font-black shadow-xl transition-all transform hover:scale-105"
:disabled="savingDiscovery"
@click="useDiscovery"
>
<v-progress-circular
v-if="savingDiscovery"
indeterminate
size="24"
width="3"
class="mr-3"
></v-progress-circular>
{{ $t("tutorial.yes") || "Yes, use discovery" }}
</button>
<button
type="button"
class="px-10 py-4 text-xl rounded-2xl border-2 border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-black shadow-lg transition-all transform hover:scale-105"
@click="discoveryOption = 'no'"
>
{{ $t("tutorial.no") || "No, manual setup" }}
</button>
</div>
</div>
</div>
<div v-else class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-6">
<!-- Left/Middle Columns: Discovered & Community -->
<div class="lg:col-span-1 xl:col-span-2 space-y-6">
<div class="grid grid-cols-1 xl:grid-cols-2 gap-6">
<!-- Discovered Interfaces -->
<div
v-if="sortedDiscoveredInterfaces.length > 0"
class="bg-emerald-500/5 dark:bg-emerald-500/10 rounded-[1.5rem] p-5 border border-emerald-500/20 h-fit"
>
<div class="flex items-center justify-between mb-5">
<div class="flex items-center gap-2">
<v-icon icon="mdi-radar" color="emerald" size="26"></v-icon>
<span class="text-lg font-bold text-gray-900 dark:text-white"
>Discovered</span
>
</div>
<button
v-if="interfacesWithLocation.length > 0"
type="button"
class="px-2 py-1 text-[9px] rounded-lg bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-bold border border-emerald-500/20 hover:bg-emerald-500/20 transition-all"
@click="mapAllDiscovered"
>
Map All ({{ interfacesWithLocation.length }})
</button>
</div>
<div class="space-y-3 max-h-[600px] overflow-y-auto pr-3 custom-scrollbar">
<div
v-for="iface in sortedDiscoveredInterfaces"
:key="iface.discovery_hash || iface.name"
class="interface-card group !p-4 transition-all duration-300"
>
<div class="flex gap-4 items-start relative">
<div class="interface-card__icon !w-12 !h-12 !rounded-2xl shrink-0">
<MaterialDesignIcon
:icon-name="getDiscoveryIcon(iface)"
class="w-6 h-6"
/>
</div>
<div class="flex-1 min-w-0 space-y-2">
<div class="flex items-center gap-2 flex-nowrap min-w-0">
<div
class="text-lg font-bold text-gray-900 dark:text-white truncate min-w-0"
>
{{ iface.name }}
</div>
<span class="type-chip shrink-0">{{ iface.type }}</span>
</div>
<div class="flex items-center gap-2 flex-wrap">
<span
v-if="iface.value"
class="text-xs font-bold text-blue-600 dark:text-blue-400 shrink-0"
>
Stamps: {{ iface.value }}
</span>
</div>
<div class="flex flex-wrap gap-2 text-xs text-gray-500">
<span class="stat-chip !px-2 !py-0.5"
>Hops: {{ iface.hops }}</span
>
<span class="stat-chip !px-2 !py-0.5 capitalize shrink-0">{{
iface.status
}}</span>
<span
v-if="iface.last_heard"
class="stat-chip !px-2 !py-0.5 shrink-0"
>
{{ formatLastHeard(iface.last_heard) }}
</span>
</div>
<div
class="grid gap-1.5 pt-1 text-[11px] text-gray-500 dark:text-zinc-400 min-w-0"
>
<div
v-if="iface.reachable_on"
class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
@click="
copyToClipboard(
`${iface.reachable_on}:${iface.port}`,
'Address'
)
"
>
<MaterialDesignIcon
icon-name="link-variant"
class="w-4 h-4 shrink-0"
/>
<span class="truncate"
>Address: {{ iface.reachable_on }}:{{
iface.port
}}</span
>
</div>
<div
v-if="iface.transport_id"
class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
@click="
copyToClipboard(iface.transport_id, 'Transport ID')
"
>
<MaterialDesignIcon
icon-name="identifier"
class="w-4 h-4 shrink-0"
/>
<span class="truncate font-mono"
>Transport ID: {{ iface.transport_id }}</span
>
</div>
<div
v-if="iface.network_id"
class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
@click="copyToClipboard(iface.network_id, 'Network ID')"
>
<MaterialDesignIcon
icon-name="lan"
class="w-4 h-4 shrink-0"
/>
<span class="truncate font-mono"
>Network ID: {{ iface.network_id }}</span
>
</div>
<div
v-if="iface.latitude != null && iface.longitude != null"
class="flex items-center gap-2 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
@click="
copyToClipboard(
`${iface.latitude}, ${iface.longitude}`,
'Location'
)
"
>
<MaterialDesignIcon
icon-name="map-marker"
class="w-4 h-4 shrink-0"
/>
<span class="truncate"
>Loc: {{ iface.latitude }},
{{ iface.longitude }}</span
>
</div>
</div>
</div>
<div class="flex items-center gap-2 shrink-0">
<span
class="text-[10px] bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 px-3 py-1 rounded-full font-bold uppercase tracking-wider"
>Heard</span
>
</div>
</div>
</div>
</div>
</div>
<!-- Community Interfaces -->
<div
class="bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800"
class="bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800 h-fit"
>
<div class="flex items-center gap-2 mb-5">
<v-icon icon="mdi-web" color="blue" size="26"></v-icon>
@@ -637,27 +997,31 @@
$t("tutorial.suggested_relays")
}}</span>
</div>
<div class="space-y-3 max-h-[320px] overflow-y-auto pr-3 custom-scrollbar">
<div class="space-y-3 max-h-[600px] overflow-y-auto pr-3 custom-scrollbar">
<div
v-for="iface in communityInterfaces"
:key="iface.name"
class="flex items-center justify-between p-3 bg-white dark:bg-zinc-800 rounded-xl border border-gray-100 dark:border-zinc-700 hover:border-blue-400 transition-all cursor-pointer"
@click="selectCommunityInterface(iface)"
>
<div class="flex flex-col">
<span class="font-bold text-gray-900 dark:text-white text-base">
<div class="flex flex-col min-w-0">
<span
class="font-bold text-gray-900 dark:text-white text-base truncate"
>
{{ iface.name }}
</span>
<span class="text-xs text-gray-500 font-mono"
<span class="text-xs text-gray-500 font-mono truncate"
>{{ iface.target_host }}:{{ iface.target_port }}</span
>
</div>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 shrink-0">
<span
v-if="iface.online"
class="flex items-center gap-1.5 text-[9px] font-bold text-green-500 uppercase tracking-[0.2em]"
>
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
<span
class="w-2 h-2 rounded-full bg-green-500 animate-pulse"
></span>
{{ $t("tutorial.online") }}
</span>
<button
@@ -670,25 +1034,33 @@
</div>
</div>
<div v-if="loadingInterfaces" class="flex justify-center py-4">
<v-progress-circular indeterminate color="blue" size="32"></v-progress-circular>
<v-progress-circular
indeterminate
color="blue"
size="32"
></v-progress-circular>
</div>
</div>
</div>
</div>
</div>
<!-- Right Column: Manual Setup -->
<div
class="flex flex-col justify-center gap-4 text-sm text-gray-900 dark:text-white bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800"
class="flex flex-col justify-center gap-4 text-sm text-gray-900 dark:text-white bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-8 border border-gray-100 dark:border-zinc-800 h-fit my-auto"
>
<div class="text-center">
<p class="text-base font-bold text-gray-900 dark:text-white">
<div class="text-center space-y-4">
<v-icon icon="mdi-plus-circle-outline" size="48" color="blue"></v-icon>
<p class="text-xl font-bold text-gray-900 dark:text-white">
{{ $t("tutorial.custom_interfaces") }}
</p>
<p class="mt-2">
<p class="text-gray-600 dark:text-zinc-400">
{{ $t("tutorial.custom_interfaces_desc_page") }}
</p>
</div>
<button
type="button"
class="px-4 py-2 text-[11px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
class="mt-4 px-6 py-3 text-sm rounded-xl border-2 border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-bold shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
@click="gotoAddInterface"
>
{{ $t("tutorial.open_interfaces") }}
@@ -925,6 +1297,12 @@ export default {
communityInterfaces: [],
loadingInterfaces: false,
interfaceAddedViaTutorial: false,
discoveryOption: null,
discoveredInterfaces: [],
discoveredActive: [],
loadingDiscovered: false,
savingDiscovery: false,
discoveryInterval: null,
};
},
computed: {
@@ -937,10 +1315,25 @@ export default {
config() {
return GlobalState.config;
},
sortedDiscoveredInterfaces() {
return [...this.discoveredInterfaces].sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
},
interfacesWithLocation() {
return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
},
},
beforeUnmount() {
if (this.discoveryInterval) {
clearInterval(this.discoveryInterval);
}
},
mounted() {
if (this.isPage) {
this.loadCommunityInterfaces();
this.loadDiscoveredInterfaces();
this.discoveryInterval = setInterval(() => {
this.loadDiscoveredInterfaces();
}, 5000);
}
},
methods: {
@@ -970,7 +1363,16 @@ export default {
this.visible = true;
this.currentStep = 1;
this.interfaceAddedViaTutorial = false;
this.discoveryOption = null;
await this.loadCommunityInterfaces();
await this.loadDiscoveredInterfaces();
if (this.discoveryInterval) {
clearInterval(this.discoveryInterval);
}
this.discoveryInterval = setInterval(() => {
this.loadDiscoveredInterfaces();
}, 5000);
},
async loadCommunityInterfaces() {
this.loadingInterfaces = true;
@@ -983,6 +1385,85 @@ export default {
this.loadingInterfaces = false;
}
},
async loadDiscoveredInterfaces() {
this.loadingDiscovered = true;
try {
const response = await window.axios.get(`/api/v1/reticulum/discovered-interfaces`);
this.discoveredInterfaces = response.data?.interfaces ?? [];
this.discoveredActive = response.data?.active ?? [];
} catch (e) {
console.error("Failed to load discovered interfaces:", e);
} finally {
this.loadingDiscovered = false;
}
},
async useDiscovery() {
this.savingDiscovery = true;
try {
const payload = {
discover_interfaces: true,
autoconnect_discovered_interfaces: 3, // default to 3 slots
};
await window.axios.patch(`/api/v1/reticulum/discovery`, payload);
ToastUtils.success("Community discovery enabled");
this.discoveryOption = "yes";
this.nextStep();
} catch (e) {
console.error("Failed to enable discovery:", e);
ToastUtils.error("Failed to enable discovery");
} finally {
this.savingDiscovery = false;
}
},
getDiscoveryIcon(iface) {
switch (iface.type) {
case "AutoInterface":
return "home-automation";
case "RNodeInterface":
return iface.port && iface.port.toString().startsWith("tcp://") ? "lan-connect" : "radio-tower";
case "RNodeMultiInterface":
return "access-point-network";
case "TCPClientInterface":
case "BackboneInterface":
return "lan-connect";
case "TCPServerInterface":
return "lan";
case "UDPInterface":
return "wan";
case "SerialInterface":
return "usb-port";
case "KISSInterface":
case "AX25KISSInterface":
return "antenna";
case "I2PInterface":
return "eye";
case "PipeInterface":
return "pipe";
default:
return "server-network";
}
},
formatLastHeard(ts) {
const seconds = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
},
copyToClipboard(text, label) {
if (!text) return;
navigator.clipboard.writeText(text);
ToastUtils.success(`${label} copied to clipboard`);
},
mapAllDiscovered() {
if (!this.isPage) {
this.visible = false;
}
this.$router.push({
name: "map",
query: { view: "discovered" },
});
},
async selectCommunityInterface(iface) {
try {
await window.axios.post("/api/v1/reticulum/interfaces/add", {

View File

@@ -264,7 +264,27 @@
class="flex items-center gap-5 pl-5 border-l-2 border-zinc-100 dark:border-zinc-800 ml-6 relative"
>
<div
class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-blue-500 to-purple-500"
class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-blue-500 to-emerald-500"
></div>
<div
class="w-12 h-12 rounded-2xl bg-emerald-500/10 flex items-center justify-center border border-emerald-500/20 text-emerald-600 font-black text-[10px] tracking-tighter shadow-sm"
>
LXMFy
</div>
<div>
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
LXMF Bot framework
</div>
<div class="text-xs font-mono font-bold text-gray-400 mt-1">
v{{ (appInfo.dependencies && appInfo.dependencies.lxmfy) || "unknown" }}
</div>
</div>
</div>
<div
class="flex items-center gap-5 pl-5 border-l-2 border-zinc-100 dark:border-zinc-800 ml-6 relative"
>
<div
class="absolute -left-[2px] top-0 bottom-0 w-[2px] bg-gradient-to-b from-emerald-500 to-purple-500"
></div>
<div
class="w-12 h-12 rounded-2xl bg-purple-500/10 flex items-center justify-center border border-purple-500/20 text-purple-600 font-black text-[10px] tracking-tighter shadow-sm"
@@ -295,9 +315,25 @@
<div class="text-sm font-black text-gray-900 dark:text-white leading-tight">
Reticulum Network Stack
</div>
<div class="text-xs font-mono font-bold text-gray-400 mt-1">
<div class="flex items-center gap-2 mt-1">
<div class="text-xs font-mono font-bold text-gray-400">
v{{ appInfo.rns_version }}
</div>
<div
:class="[
appInfo.is_connected_to_shared_instance
? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
]"
class="text-[8px] font-black uppercase tracking-wider px-1.5 py-0.5 rounded border"
>
{{
appInfo.is_connected_to_shared_instance
? "Shared Instance"
: "Main Instance"
}}
</div>
</div>
</div>
</div>
</div>
@@ -530,34 +566,75 @@
</div>
</div>
<div v-if="snapshots.length > 0" class="grid gap-3 sm:grid-cols-2">
<div v-if="snapshots && snapshots.length > 0" class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div
v-for="snapshot in snapshots"
:key="snapshot.path"
class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-purple-500/20 transition-all group"
>
<div class="flex flex-col">
<div class="flex flex-col min-w-0">
<span
class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
class="font-black text-gray-900 dark:text-white text-xs truncate"
>{{ snapshot.name }}</span
>
<span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
>{{ formatBytes(snapshot.size) }} {{ snapshot.created_at }}</span
>{{ formatBytes(snapshot.size) }}
{{ Utils.formatTimeAgo(snapshot.created_at) }}</span
>
</div>
<div
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
type="button"
class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
class="secondary-chip !px-3 !py-1 !text-[10px]"
@click="restoreFromSnapshot(snapshot.path)"
>
Restore
</button>
<button
type="button"
class="danger-chip !px-3 !py-1 !text-[10px]"
@click="deleteSnapshot(snapshot.name)"
>
<v-icon icon="mdi-delete" size="12"></v-icon>
</button>
</div>
</div>
</div>
<!-- Snapshots Pagination -->
<div
v-if="snapshotsTotal > snapshotsLimit"
class="flex items-center justify-between px-2"
>
<div class="text-[10px] font-black text-gray-400 uppercase tracking-widest">
Page {{ Math.floor(snapshotsOffset / snapshotsLimit) + 1 }} of
{{ Math.ceil(snapshotsTotal / snapshotsLimit) }}
</div>
<div class="flex gap-2">
<button
class="secondary-chip !p-1 disabled:opacity-30"
:disabled="snapshotsOffset === 0"
@click="prevSnapshots"
>
<v-icon icon="mdi-chevron-left"></v-icon>
</button>
<button
class="secondary-chip !p-1 disabled:opacity-30"
:disabled="snapshotsOffset + snapshotsLimit >= snapshotsTotal"
@click="nextSnapshots"
>
<v-icon icon="mdi-chevron-right"></v-icon>
</button>
</div>
</div>
</div>
</div>
<!-- Auto Backups -->
<div v-if="autoBackups.length > 0" class="space-y-6">
<div v-if="autoBackups && autoBackups.length > 0" class="space-y-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="space-y-1">
<div
@@ -572,28 +649,69 @@
</div>
</div>
<div v-if="autoBackups && autoBackups.length > 0" class="space-y-4">
<div class="grid gap-3 sm:grid-cols-2">
<div
v-for="backup in autoBackups"
:key="backup.path"
class="flex items-center justify-between p-4 rounded-2xl bg-zinc-50 dark:bg-zinc-900 border border-zinc-100 dark:border-zinc-800 hover:border-blue-500/20 transition-all group"
>
<div class="flex flex-col">
<div class="flex flex-col min-w-0">
<span
class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
class="font-black text-gray-900 dark:text-white text-xs truncate"
>{{ backup.name }}</span
>
<span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
>{{ formatBytes(backup.size) }} {{ backup.created_at }}</span
>{{ formatBytes(backup.size) }}
{{ Utils.formatTimeAgo(backup.created_at) }}</span
>
</div>
<div
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
>
<button
type="button"
class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
class="secondary-chip !px-3 !py-1 !text-[10px]"
@click="restoreFromSnapshot(backup.path)"
>
Restore
</button>
<button
type="button"
class="danger-chip !px-3 !py-1 !text-[10px]"
@click="deleteBackup(backup.name)"
>
<v-icon icon="mdi-delete" size="12"></v-icon>
</button>
</div>
</div>
</div>
<!-- Backups Pagination -->
<div
v-if="autoBackupsTotal > autoBackupsLimit"
class="flex items-center justify-between px-2"
>
<div class="text-[10px] font-black text-gray-400 uppercase tracking-widest">
Page {{ Math.floor(autoBackupsOffset / autoBackupsLimit) + 1 }} of
{{ Math.ceil(autoBackupsTotal / autoBackupsLimit) }}
</div>
<div class="flex gap-2">
<button
class="secondary-chip !p-1 disabled:opacity-30"
:disabled="autoBackupsOffset === 0"
@click="prevBackups"
>
<v-icon icon="mdi-chevron-left"></v-icon>
</button>
<button
class="secondary-chip !p-1 disabled:opacity-30"
:disabled="autoBackupsOffset + autoBackupsLimit >= autoBackupsTotal"
@click="nextBackups"
>
<v-icon icon="mdi-chevron-right"></v-icon>
</button>
</div>
</div>
</div>
</div>
@@ -704,6 +822,7 @@ export default {
components: {},
data() {
return {
Utils,
appInfo: null,
config: null,
updateInterval: null,
@@ -725,10 +844,16 @@ export default {
restoreFile: null,
snapshotName: "",
snapshots: [],
snapshotsTotal: 0,
snapshotsOffset: 0,
snapshotsLimit: 3,
snapshotInProgress: false,
snapshotMessage: "",
snapshotError: "",
autoBackups: [],
autoBackupsTotal: 0,
autoBackupsOffset: 0,
autoBackupsLimit: 3,
identityBackupMessage: "",
identityBackupError: "",
identityBase32: "",
@@ -776,18 +901,74 @@ export default {
methods: {
async listSnapshots() {
try {
const response = await window.axios.get("/api/v1/database/snapshots");
this.snapshots = response.data;
const response = await window.axios.get("/api/v1/database/snapshots", {
params: {
limit: this.snapshotsLimit,
offset: this.snapshotsOffset,
},
});
this.snapshots = response.data.snapshots;
this.snapshotsTotal = response.data.total;
} catch (e) {
console.log("Failed to list snapshots", e);
}
},
async listAutoBackups() {
try {
const response = await window.axios.get("/api/v1/database/backups");
this.autoBackups = response.data;
} catch (e) {
console.log("Failed to list auto-backups", e);
const response = await window.axios.get("/api/v1/database/backups", {
params: {
limit: this.autoBackupsLimit,
offset: this.autoBackupsOffset,
},
});
this.autoBackups = response.data.backups;
this.autoBackupsTotal = response.data.total;
} catch {
console.log("Failed to list auto-backups");
}
},
async deleteSnapshot(filename) {
if (!(await DialogUtils.confirm("Are you sure you want to delete this snapshot?"))) return;
try {
await window.axios.delete(`/api/v1/database/snapshots/${filename}`);
ToastUtils.success("Snapshot deleted");
await this.listSnapshots();
} catch {
ToastUtils.error("Failed to delete snapshot");
}
},
async deleteBackup(filename) {
if (!(await DialogUtils.confirm("Are you sure you want to delete this backup?"))) return;
try {
await window.axios.delete(`/api/v1/database/backups/${filename}`);
ToastUtils.success("Backup deleted");
await this.listAutoBackups();
} catch {
ToastUtils.error("Failed to delete backup");
}
},
async nextSnapshots() {
if (this.snapshotsOffset + this.snapshotsLimit < this.snapshotsTotal) {
this.snapshotsOffset += this.snapshotsLimit;
await this.listSnapshots();
}
},
async prevSnapshots() {
if (this.snapshotsOffset > 0) {
this.snapshotsOffset = Math.max(0, this.snapshotsOffset - this.snapshotsLimit);
await this.listSnapshots();
}
},
async nextBackups() {
if (this.autoBackupsOffset + this.autoBackupsLimit < this.autoBackupsTotal) {
this.autoBackupsOffset += this.autoBackupsLimit;
await this.listAutoBackups();
}
},
async prevBackups() {
if (this.autoBackupsOffset > 0) {
this.autoBackupsOffset = Math.max(0, this.autoBackupsOffset - this.autoBackupsLimit);
await this.listAutoBackups();
}
},
async createSnapshot() {
@@ -802,9 +983,8 @@ export default {
this.snapshotMessage = "Snapshot created successfully";
this.snapshotName = "";
await this.listSnapshots();
} catch (e) {
} catch {
this.snapshotError = "Failed to create snapshot";
console.log(e);
} finally {
this.snapshotInProgress = false;
}
@@ -825,9 +1005,8 @@ export default {
setTimeout(() => ElectronUtils.relaunch(), 2000);
}
}
} catch (e) {
} catch {
ToastUtils.error("Failed to restore snapshot");
console.log(e);
}
},
async getAppInfo() {
@@ -856,9 +1035,8 @@ export default {
await window.axios.post("/api/v1/app/integrity/acknowledge");
ToastUtils.success("Integrity issues acknowledged");
await this.getAppInfo();
} catch (e) {
} catch {
ToastUtils.error("Failed to acknowledge integrity issues");
console.log(e);
}
}
},
@@ -1075,9 +1253,9 @@ export default {
link.remove();
window.URL.revokeObjectURL(url);
this.identityBackupMessage = "Identity downloaded. Keep it secret.";
} catch (e) {
ToastUtils.success("Identity key file exported");
} catch {
this.identityBackupError = "Failed to download identity";
console.log(e);
}
},
async copyIdentityBase32() {
@@ -1092,9 +1270,9 @@ export default {
}
await navigator.clipboard.writeText(this.identityBase32);
this.identityBase32Message = "Identity copied. Clear your clipboard after use.";
} catch (e) {
ToastUtils.success("Identity Base32 key copied to clipboard");
} catch {
this.identityBase32Error = "Failed to copy identity";
console.log(e);
}
},
onIdentityRestoreFileChange(event) {
@@ -1124,9 +1302,8 @@ export default {
headers: { "Content-Type": "multipart/form-data" },
});
this.identityRestoreMessage = response.data.message || "Identity imported.";
} catch (e) {
} catch {
this.identityRestoreError = "Identity restore failed";
console.log(e);
} finally {
this.identityRestoreInProgress = false;
}
@@ -1147,9 +1324,8 @@ export default {
base32: this.identityRestoreBase32.trim(),
});
this.identityRestoreMessage = response.data.message || "Identity imported.";
} catch (e) {
} catch {
this.identityRestoreError = "Identity restore failed";
console.log(e);
} finally {
this.identityRestoreInProgress = false;
}

View File

@@ -3,7 +3,7 @@
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
<div class="flex-1 overflow-y-auto w-full">
<div class="p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full flex-1">
<div class="p-3 md:p-6 space-y-4 w-full flex-1">
<div
v-if="showRestartReminder"
class="bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-3xl shadow-xl p-4 flex flex-wrap gap-3 items-center"
@@ -28,11 +28,11 @@
<div class="glass-card space-y-4">
<div class="flex flex-wrap gap-3 items-center">
<div class="flex-1">
<div class="flex-1 min-w-0">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("interfaces.manage") }}
</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">
<div class="text-xl font-semibold text-gray-900 dark:text-white truncate">
{{ $t("interfaces.title") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
@@ -111,25 +111,269 @@
</div>
</div>
<div
v-if="filteredInterfaces.length === 0"
class="glass-card text-center py-10 text-gray-500 dark:text-gray-300"
<div class="glass-card space-y-4">
<div class="flex flex-wrap gap-2">
<button
v-for="tab in ['overview', 'discovery']"
:key="tab"
type="button"
:class="tabChipClass(activeTab === tab)"
@click="activeTab = tab"
>
<span v-if="tab === 'overview'">Overview</span>
<span v-else>Discovery Settings</span>
</button>
</div>
<div v-if="activeTab === 'overview'" class="space-y-4">
<div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Configured
</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
<div
v-if="filteredInterfaces.length !== 0"
class="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3 3xl:grid-cols-4 4xl:grid-cols-5"
>
<Interface
v-for="iface of filteredInterfaces"
:key="iface._name"
:iface="iface"
:is-reticulum-running="isReticulumRunning"
@enable="enableInterface(iface._name)"
@disable="disableInterface(iface._name)"
@edit="editInterface(iface._name)"
@export="exportInterface(iface._name)"
@delete="deleteInterface(iface._name)"
/>
</div>
<div v-else class="glass-card text-center py-10 text-gray-500 dark:text-gray-300">
<MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3" />
<div class="text-lg font-semibold">{{ $t("interfaces.no_interfaces_found") }}</div>
<div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
</div>
</div>
<div class="glass-card space-y-3">
<div class="flex items-center gap-3">
<div class="flex-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Discovered Interfaces
</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">
Recently Heard Announces
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Cards appear/disappear as announces are heard. Connected entries show a green
pill; disconnected entries are dimmed with a red label.
</div>
</div>
<div class="flex gap-2">
<button
v-if="interfacesWithLocation.length > 0"
type="button"
class="secondary-chip text-xs bg-blue-500/10 hover:bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30"
@click="mapAllDiscovered"
>
<MaterialDesignIcon icon-name="map-marker-multiple" class="w-4 h-4" />
Map All ({{ interfacesWithLocation.length }})
</button>
<button
type="button"
class="secondary-chip text-xs"
@click="loadDiscoveredInterfaces"
>
<MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
Refresh
</button>
</div>
</div>
<div
v-if="sortedDiscoveredInterfaces.length === 0"
class="text-sm text-gray-500 dark:text-gray-300"
>
No discovered interfaces yet.
</div>
<div
v-else
class="grid gap-4 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 3xl:grid-cols-5 4xl:grid-cols-6"
>
<div
v-for="iface in sortedDiscoveredInterfaces"
:key="iface.discovery_hash || iface.name"
class="interface-card group transition-all duration-300"
:class="{ 'opacity-70 grayscale-[0.3]': !isDiscoveredConnected(iface) }"
>
<div class="flex gap-4 items-start relative">
<!-- Disconnected Overlay -->
<div
v-if="!isDiscoveredConnected(iface)"
class="absolute inset-0 z-10 flex items-center justify-center bg-white/20 dark:bg-zinc-900/20 backdrop-blur-[0.5px] rounded-3xl pointer-events-none"
>
<div
class="bg-red-500/90 text-white px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider animate-pulse"
>
<MaterialDesignIcon icon-name="lan-disconnect" class="w-3.5 h-3.5" />
<span>{{ $t("app.disabled") }}</span>
</div>
</div>
<div class="interface-card__icon shrink-0">
<MaterialDesignIcon :icon-name="getDiscoveryIcon(iface)" class="w-6 h-6" />
</div>
<div class="flex-1 min-w-0 space-y-2">
<div class="flex items-center gap-2 flex-nowrap min-w-0">
<div
class="text-base sm:text-lg font-semibold text-gray-900 dark:text-white truncate min-w-0"
>
{{ iface.name }}
</div>
<span class="type-chip shrink-0">{{ iface.type }}</span>
</div>
<div class="flex items-center gap-2 flex-wrap">
<span
v-if="iface.value"
class="text-[10px] font-bold text-blue-600 dark:text-blue-400"
>
Stamps: {{ iface.value }}
</span>
<span
v-if="isDiscoveredConnected(iface)"
class="inline-flex items-center rounded-full bg-emerald-100 text-emerald-700 px-2 py-0.5 text-[10px] font-semibold dark:bg-emerald-900/40 dark:text-emerald-200 shrink-0"
>
Connected
</span>
</div>
<div class="flex flex-wrap gap-1.5 text-[10px] sm:text-xs">
<span class="stat-chip bg-gray-50 dark:bg-zinc-800/50"
>Hops: {{ iface.hops }}</span
>
<span class="stat-chip capitalize bg-gray-50 dark:bg-zinc-800/50">{{
iface.status
}}</span>
<span
v-if="iface.last_heard"
class="stat-chip bg-gray-50 dark:bg-zinc-800/50"
>
Heard: {{ formatLastHeard(iface.last_heard) }}
</span>
</div>
<div class="grid gap-1.5 text-[10px] sm:text-[11px] pt-1 min-w-0">
<div
v-if="iface.reachable_on"
class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
@click="
copyToClipboard(
`${iface.reachable_on}:${iface.port}`,
'Address'
)
"
>
<MaterialDesignIcon
icon-name="link-variant"
class="w-3.5 h-3.5 shrink-0"
/>
<span class="truncate"
>Address: {{ iface.reachable_on }}:{{ iface.port }}</span
>
</div>
<div
v-if="iface.transport_id"
class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
@click="copyToClipboard(iface.transport_id, 'Transport ID')"
>
<MaterialDesignIcon
icon-name="identifier"
class="w-3.5 h-3.5 shrink-0"
/>
<span class="truncate font-mono"
>Transport ID: {{ iface.transport_id }}</span
>
</div>
<div
v-if="iface.network_id"
class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
@click="copyToClipboard(iface.network_id, 'Network ID')"
>
<MaterialDesignIcon icon-name="lan" class="w-3.5 h-3.5 shrink-0" />
<span class="truncate font-mono"
>Network ID: {{ iface.network_id }}</span
>
</div>
<div
v-if="iface.latitude != null && iface.longitude != null"
class="flex items-center gap-2 text-gray-600 dark:text-gray-400 hover:text-blue-500 cursor-pointer transition-colors min-w-0"
@click="
copyToClipboard(
`${iface.latitude}, ${iface.longitude}`,
'Location'
)
"
>
<MaterialDesignIcon
icon-name="map-marker"
class="w-3.5 h-3.5 shrink-0"
/>
<span class="truncate"
>Loc: {{ iface.latitude }}, {{ iface.longitude }}</span
>
</div>
<div
v-if="discoveredBytes(iface)"
class="flex items-center gap-2 text-gray-500 dark:text-gray-500 min-w-0"
>
<MaterialDesignIcon
icon-name="swap-vertical"
class="w-3.5 h-3.5 shrink-0"
/>
<span class="truncate"
>TX {{ discoveredBytes(iface).tx }} · RX
{{ discoveredBytes(iface).rx }}</span
>
</div>
</div>
</div>
<div class="flex flex-col gap-2 shrink-0">
<button
v-if="iface.latitude != null && iface.longitude != null"
type="button"
class="secondary-chip !p-2 !rounded-xl"
:title="$t('map.title')"
@click="goToMap(iface)"
>
<MaterialDesignIcon icon-name="map" class="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="space-y-4">
<div class="glass-card space-y-4">
<div class="flex flex-wrap gap-3 items-center">
<div class="flex-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Discovery
</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interface Discovery</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">
Interface Discovery
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Publish your interfaces for others to find, or listen for announced entrypoints and
auto-connect to them.
Publish your interfaces for others to find, or listen for announced entrypoints
and auto-connect to them.
</div>
</div>
<RouterLink :to="{ name: 'interfaces.add' }" class="secondary-chip text-sm">
@@ -141,12 +385,12 @@
<div class="space-y-2 text-sm text-gray-700 dark:text-gray-300">
<div class="font-semibold text-gray-900 dark:text-white">Publish (Server)</div>
<div>
Enable discovery while adding or editing an interface to broadcast reachable details.
Reticulum will sign and stamp announces automatically.
Enable discovery while adding or editing an interface to broadcast reachable
details. Reticulum will sign and stamp announces automatically.
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Requires LXMF in the Python environment. Transport is optional for publishing, but
usually recommended so peers can connect back.
Requires LXMF in the Python environment. Transport is optional for publishing,
but usually recommended so peers can connect back.
</div>
</div>
<div class="space-y-3">
@@ -195,7 +439,9 @@
min="0"
class="input-field"
/>
<div class="text-xs text-gray-500 dark:text-gray-400">0 disables auto-connect.</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
0 disables auto-connect.
</div>
</div>
<div>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
@@ -227,19 +473,7 @@
</div>
</div>
</div>
<div v-if="filteredInterfaces.length !== 0" class="grid gap-4 xl:grid-cols-2">
<Interface
v-for="iface of filteredInterfaces"
:key="iface._name"
:iface="iface"
:is-reticulum-running="isReticulumRunning"
@enable="enableInterface(iface._name)"
@disable="disableInterface(iface._name)"
@edit="editInterface(iface._name)"
@export="exportInterface(iface._name)"
@delete="deleteInterface(iface._name)"
/>
</div>
</div>
</div>
</div>
@@ -286,6 +520,10 @@ export default {
network_identity: "",
},
savingDiscovery: false,
discoveredInterfaces: [],
discoveredActive: [],
discoveryInterval: null,
activeTab: "overview",
};
},
computed: {
@@ -357,19 +595,57 @@ export default {
this.interfacesWithStats.forEach((iface) => types.add(iface.type));
return Array.from(types).sort();
},
sortedDiscoveredInterfaces() {
return [...this.discoveredInterfaces].sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
},
interfacesWithLocation() {
return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
},
activeInterfaceStats() {
return Object.values(this.interfaceStats || {});
},
tabChipClass() {
return (isActive) => (isActive ? "primary-chip text-xs" : "secondary-chip text-xs");
},
discoveredActiveSet() {
const set = new Set();
this.discoveredActive.forEach((a) => {
const host = a.target_host || a.remote || a.listen_ip;
const port = a.target_port || a.listen_port;
if (host && port) {
set.add(`${host}:${port}`);
}
});
return set;
},
discoveredActiveTransportIds() {
const set = new Set();
this.discoveredActive.forEach((a) => {
if (a.transport_id) {
set.add(a.transport_id);
}
});
return set;
},
},
beforeUnmount() {
clearInterval(this.reloadInterval);
clearInterval(this.discoveryInterval);
},
mounted() {
this.loadInterfaces();
this.updateInterfaceStats();
this.loadDiscoveryConfig();
this.loadDiscoveredInterfaces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.updateInterfaceStats();
}, 1000);
this.discoveryInterval = setInterval(() => {
this.loadDiscoveredInterfaces();
}, 5000);
},
methods: {
relaunch() {
@@ -506,6 +782,77 @@ export default {
this.trackInterfaceChange();
}
},
async loadDiscoveredInterfaces() {
try {
const response = await window.axios.get(`/api/v1/reticulum/discovered-interfaces`);
this.discoveredInterfaces = response.data?.interfaces ?? [];
this.discoveredActive = response.data?.active ?? [];
} catch (e) {
console.log(e);
}
},
formatLastHeard(ts) {
const seconds = Math.max(0, Math.floor(Date.now() / 1000 - ts));
if (seconds < 60) return `${seconds}s ago`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
},
isDiscoveredConnected(iface) {
const reach = iface.reachable_on;
const port = iface.port;
if (iface.transport_id && this.discoveredActiveTransportIds.has(iface.transport_id)) {
return true;
}
if (reach && port && this.discoveredActiveSet && this.discoveredActiveSet.has(`${reach}:${port}`)) {
return true;
}
return this.activeInterfaceStats.some((s) => {
const hostMatch =
(s.target_host && reach && s.target_host === reach) || (s.remote && reach && s.remote === reach);
const portMatch =
(s.target_port && port && Number(s.target_port) === Number(port)) ||
(s.listen_port && port && Number(s.listen_port) === Number(port));
return hostMatch && portMatch && (s.connected || s.online);
});
},
goToMap(iface) {
if (iface.latitude == null || iface.longitude == null) return;
this.$router.push({
name: "map",
query: {
lat: iface.latitude,
lon: iface.longitude,
label: iface.name,
},
});
},
mapAllDiscovered() {
this.$router.push({
name: "map",
query: { view: "discovered" },
});
},
discoveredBytes(iface) {
const reach = iface.reachable_on;
const port = iface.port;
const stats = this.activeInterfaceStats || [];
const match = stats.find((s) => {
const host = s.target_host || s.remote || s.interface_name;
const p = s.target_port || s.listen_port;
const hostMatch = host && reach && host === reach;
const portMatch = p && port && Number(p) === Number(port);
return hostMatch && portMatch;
});
if (!match) return null;
return {
tx: this.formatBytes(match.txb || 0),
rx: this.formatBytes(match.rxb || 0),
};
},
formatBytes(bytes) {
return Utils.formatBytes(bytes || 0);
},
parseBool(value) {
if (typeof value === "string") {
return ["true", "yes", "1", "y", "on"].includes(value.toLowerCase());
@@ -565,6 +912,39 @@ export default {
this.savingDiscovery = false;
}
},
getDiscoveryIcon(iface) {
switch (iface.type) {
case "AutoInterface":
return "home-automation";
case "RNodeInterface":
return iface.port && iface.port.toString().startsWith("tcp://") ? "lan-connect" : "radio-tower";
case "RNodeMultiInterface":
return "access-point-network";
case "TCPClientInterface":
case "BackboneInterface":
return "lan-connect";
case "TCPServerInterface":
return "lan";
case "UDPInterface":
return "wan";
case "SerialInterface":
return "usb-port";
case "KISSInterface":
case "AX25KISSInterface":
return "antenna";
case "I2PInterface":
return "eye";
case "PipeInterface":
return "pipe";
default:
return "server-network";
}
},
copyToClipboard(text, label) {
if (!text) return;
navigator.clipboard.writeText(text);
ToastUtils.success(`${label} copied to clipboard`);
},
setStatusFilter(value) {
this.statusFilter = value;
},

View File

@@ -28,6 +28,13 @@
<!-- offline/online toggle -->
<div class="flex items-center bg-gray-100 dark:bg-zinc-800 rounded-lg p-1">
<button
class="p-2 rounded-lg text-gray-500 hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors mr-1"
title="Map Discovered Interfaces"
@click="mapDiscoveredNodes"
>
<MaterialDesignIcon icon-name="map-marker-radius" class="size-5" />
</button>
<button
:class="
!offlineEnabled
@@ -1059,6 +1066,7 @@ import { fromCircle } from "ol/geom/Polygon";
import { unByKey } from "ol/Observable";
import Overlay from "ol/Overlay";
import GeoJSON from "ol/format/GeoJSON";
import { extend as extendExtent, createEmpty as createEmptyExtent, isEmpty as isExtentEmpty } from "ol/extent";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
import TileCache from "../../js/TileCache";
@@ -1090,6 +1098,8 @@ export default {
markerSource: null,
markerLayer: null,
selectedMarker: null,
queryMarker: null,
discoveredMarkers: [],
// caching
cachingEnabled: true,
@@ -1256,6 +1266,11 @@ export default {
await this.fetchPeers();
await this.fetchTelemetryMarkers();
// Handle view modes
if (this.$route.query.view === "discovered") {
await this.mapDiscoveredNodes();
}
// Listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
@@ -1264,10 +1279,29 @@ export default {
const lat = parseFloat(this.$route.query.lat);
const lon = parseFloat(this.$route.query.lon);
const zoom = parseInt(this.$route.query.zoom || 15);
const label = this.$route.query.label || "Target";
if (!isNaN(lat) && !isNaN(lon)) {
this.map.getView().setCenter(fromLonLat([lon, lat]));
this.map.getView().setZoom(zoom);
// add a temporary marker for the query target
const feature = new Feature({
geometry: new Point(fromLonLat([lon, lat])),
});
feature.setStyle(
this.createMarkerStyle({
iconColor: "#2563eb",
bgColor: "#bfdbfe",
label,
isStale: false,
iconPath: null,
})
);
this.queryMarker = feature;
if (this.markerSource) {
this.markerSource.addFeature(feature);
}
}
}
@@ -2962,18 +2996,83 @@ export default {
if (!this.markerSource) return;
this.markerSource.clear();
const featuresByCoord = {};
// Helper to collect features
const addFeatureToGroup = (coord, feature) => {
// Round coordinates to handle floating point jitter
const key = coord.map((c) => c.toFixed(6)).join(",");
if (!featuresByCoord[key]) featuresByCoord[key] = [];
featuresByCoord[key].push(feature);
};
// Process telemetry
for (const t of this.telemetryList) {
const loc = t.telemetry?.location;
if (!loc || loc.latitude === undefined || loc.longitude === undefined) continue;
const coord = fromLonLat([loc.longitude, loc.latitude]);
const feature = new Feature({
geometry: new Point(fromLonLat([loc.longitude, loc.latitude])),
geometry: new Point(coord),
telemetry: t,
peer: this.peers[t.destination_hash],
});
this.markerSource.addFeature(feature);
addFeatureToGroup(coord, feature);
}
// Process query marker
if (this.queryMarker) {
const coord = this.queryMarker.getGeometry().getCoordinates();
addFeatureToGroup(coord, this.queryMarker);
}
// Process discovered markers
if (this.discoveredMarkers && this.discoveredMarkers.length > 0) {
for (const feature of this.discoveredMarkers) {
const coord = feature.getGeometry().getCoordinates();
addFeatureToGroup(coord, feature);
}
}
// Now handle groups (Marker Explosion)
const view = this.map.getView();
const resolution = view.getResolution();
const offsetDist = resolution * 40; // 40 pixels offset
Object.entries(featuresByCoord).forEach(([coordStr, features]) => {
const trueCoord = coordStr.split(",").map(Number);
if (features.length === 1) {
this.markerSource.addFeature(features[0]);
} else {
features.forEach((feature, index) => {
const angle = (index / features.length) * 2 * Math.PI;
const offsetCoord = [
trueCoord[0] + Math.cos(angle) * offsetDist,
trueCoord[1] + Math.sin(angle) * offsetDist,
];
// Move the marker to offset position
feature.setGeometry(new Point(offsetCoord));
this.markerSource.addFeature(feature);
// Draw dashed line to true position
const lineFeature = new Feature({
geometry: new LineString([offsetCoord, trueCoord]),
});
lineFeature.setStyle(
new Style({
stroke: new Stroke({
color: "rgba(59, 130, 246, 0.6)",
width: 1.5,
lineDash: [4, 4],
}),
})
);
this.markerSource.addFeature(lineFeature);
});
}
});
},
createMarkerStyle({ iconColor, bgColor, label, isStale, iconPath }) {
const cacheKey = `${iconColor}-${bgColor}-${label}-${isStale}-${iconPath || "default"}`;
@@ -3042,6 +3141,59 @@ export default {
params: { destinationHash: hash },
});
},
async mapDiscoveredNodes() {
try {
const response = await window.axios.get("/api/v1/reticulum/discovered-interfaces");
const discovered = response.data?.interfaces ?? [];
const nodesWithLoc = discovered.filter((n) => n.latitude != null && n.longitude != null);
if (nodesWithLoc.length === 0) {
ToastUtils.info("No discovered nodes with location found");
return;
}
const extent = createEmptyExtent();
this.discoveredMarkers = [];
for (const node of nodesWithLoc) {
const coord = fromLonLat([node.longitude, node.latitude]);
extendExtent(extent, coord);
// Add markers
const feature = new Feature({
geometry: new Point(coord),
discovered: node,
});
feature.setStyle(
this.createMarkerStyle({
iconColor: "#10b981", // emerald-500
bgColor: "#d1fae5", // emerald-100
label: node.name,
isStale: false,
iconPath: null,
})
);
this.discoveredMarkers.push(feature);
}
// refresh all markers
this.updateMarkers();
// Fit view to all discovered nodes if extent is valid
if (!isExtentEmpty(extent)) {
this.map.getView().fit(extent, {
padding: [100, 100, 100, 100],
maxZoom: 12,
duration: 1000,
});
}
ToastUtils.success(`Mapped ${nodesWithLoc.length} discovered nodes`);
} catch (e) {
console.error("Failed to map discovered nodes", e);
ToastUtils.error("Failed to fetch discovered nodes for mapping");
}
},
},
};
</script>

View File

@@ -5,6 +5,8 @@
:class="{ 'hidden sm:flex': destinationHash }"
:conversations="conversations"
:peers="peers"
:folders="folders"
:selected-folder-id="selectedFolderId"
:selected-destination-hash="selectedPeer?.destination_hash"
:conversation-search-term="conversationSearchTerm"
:filter-unread-only="filterUnreadOnly"
@@ -25,6 +27,15 @@
@ingest-paper-message="openIngestPaperMessageModal"
@load-more="loadMoreConversations"
@load-more-announces="loadMoreAnnounces"
@folder-click="onFolderClick"
@create-folder="onCreateFolder"
@rename-folder="onRenameFolder"
@delete-folder="onDeleteFolder"
@move-to-folder="onMoveToFolder"
@bulk-mark-as-read="onBulkMarkAsRead"
@bulk-delete="onBulkDelete"
@export-folders="onExportFolders"
@import-folders="onImportFolders"
/>
<div
@@ -141,6 +152,8 @@ export default {
selectedPeer: null,
conversations: [],
folders: [],
selectedFolderId: null,
pageSize: 50,
hasMoreConversations: true,
isLoadingMore: false,
@@ -201,11 +214,13 @@ export default {
this.getConfig();
this.getConversations();
this.getFolders();
this.getLxmfDeliveryAnnounces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.getConversations();
this.getFolders();
}, 5000);
// compose message if a destination hash was provided on page load
@@ -395,6 +410,129 @@ export default {
this.isLoadingMore = false;
}
},
async getFolders() {
try {
const response = await window.axios.get("/api/v1/lxmf/folders");
this.folders = response.data;
} catch (e) {
console.error("Failed to load folders", e);
}
},
async onCreateFolder(name) {
try {
await window.axios.post("/api/v1/lxmf/folders", { name });
await this.getFolders();
ToastUtils.success("Folder created");
} catch {
ToastUtils.error("Failed to create folder");
}
},
async onRenameFolder({ id, name }) {
try {
await window.axios.patch(`/api/v1/lxmf/folders/${id}`, { name });
await this.getFolders();
ToastUtils.success("Folder renamed");
} catch {
ToastUtils.error("Failed to rename folder");
}
},
async onDeleteFolder(id) {
try {
await window.axios.delete(`/api/v1/lxmf/folders/${id}`);
if (this.selectedFolderId === id) {
this.selectedFolderId = null;
}
await this.getFolders();
await this.getConversations();
ToastUtils.success("Folder deleted");
} catch {
ToastUtils.error("Failed to delete folder");
}
},
async onMoveToFolder({ peer_hashes, folder_id }) {
try {
// Treat 0 as null (Uncategorized) for the backend
const targetFolderId = folder_id === 0 ? null : folder_id;
await window.axios.post("/api/v1/lxmf/conversations/move-to-folder", {
peer_hashes,
folder_id: targetFolderId,
});
await this.getConversations();
ToastUtils.success("Moved to folder");
} catch {
ToastUtils.error("Failed to move to folder");
}
},
async onBulkMarkAsRead(destination_hashes) {
try {
await window.axios.post("/api/v1/lxmf/conversations/bulk-mark-as-read", {
destination_hashes,
});
await this.getConversations();
ToastUtils.success("Marked as read");
} catch {
ToastUtils.error("Failed to mark as read");
}
},
async onBulkDelete(destination_hashes) {
try {
const confirmed = await DialogUtils.confirm(
"Are you sure you want to delete these conversations? All messages will be lost.",
"Delete Conversations"
);
if (!confirmed) return;
await window.axios.post("/api/v1/lxmf/conversations/bulk-delete", {
destination_hashes,
});
await this.getConversations();
ToastUtils.success("Conversations deleted");
} catch {
ToastUtils.error("Failed to delete conversations");
}
},
async onExportFolders() {
try {
const response = await window.axios.get("/api/v1/lxmf/folders/export");
const data = JSON.stringify(response.data, null, 2);
const blob = new Blob([data], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `meshchatx-folders-${new Date().toISOString().slice(0, 10)}.json`;
a.click();
URL.revokeObjectURL(url);
} catch {
ToastUtils.error("Failed to export folders");
}
},
async onImportFolders() {
const input = document.createElement("input");
input.type = "file";
input.accept = ".json";
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (re) => {
try {
const data = JSON.parse(re.target.result);
await window.axios.post("/api/v1/lxmf/folders/import", data);
await this.getFolders();
await this.getConversations();
ToastUtils.success("Folders imported");
} catch {
ToastUtils.error("Failed to import folders");
}
};
reader.readAsText(file);
};
input.click();
},
onFolderClick(folderId) {
this.selectedFolderId = folderId;
this.requestConversationsRefresh();
},
async loadMoreConversations() {
if (this.isLoadingMore || !this.hasMoreConversations) return;
this.isLoadingMore = true;
@@ -414,6 +552,9 @@ export default {
if (this.filterHasAttachmentsOnly) {
params.filter_has_attachments = true;
}
if (this.selectedFolderId !== null) {
params.folder_id = this.selectedFolderId;
}
return params;
},
updatePeerFromAnnounce: function (announce) {

View File

@@ -33,6 +33,145 @@
v-if="tab === 'conversations'"
class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
>
<!-- Folders Section -->
<div class="border-b border-gray-200 dark:border-zinc-700 bg-gray-50/50 dark:bg-zinc-900/50">
<div
class="flex items-center justify-between px-3 py-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
@click="foldersExpanded = !foldersExpanded"
>
<div class="flex items-center gap-2">
<MaterialDesignIcon
:icon-name="foldersExpanded ? 'chevron-down' : 'chevron-right'"
class="size-4 text-gray-400"
/>
<span class="text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-zinc-500">
Folders
</span>
</div>
<div class="flex gap-1" @click.stop>
<button
type="button"
class="p-1 text-gray-400 hover:text-blue-500 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
title="Create Folder"
@click="createFolder"
>
<MaterialDesignIcon icon-name="folder-plus-outline" class="size-4" />
</button>
<div class="relative">
<button
type="button"
class="p-1 text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300 hover:bg-gray-200/50 dark:hover:bg-zinc-800 rounded-lg transition-colors"
@click="folderMenu.show = !folderMenu.show"
>
<MaterialDesignIcon icon-name="dots-vertical" class="size-4" />
</button>
<div
v-if="folderMenu.show"
v-click-outside="{ handler: () => (folderMenu.show = false), capture: true }"
class="absolute right-0 top-full mt-1 z-[60] min-w-[160px] bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1 overflow-hidden animate-in fade-in zoom-in duration-100"
>
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
@click="
$emit('export-folders');
folderMenu.show = false;
"
>
<MaterialDesignIcon icon-name="export" class="size-4" />
<span>Export Folders</span>
</button>
<button
type="button"
class="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
@click="
$emit('import-folders');
folderMenu.show = false;
"
>
<MaterialDesignIcon icon-name="import" class="size-4" />
<span>Import Folders</span>
</button>
</div>
</div>
</div>
</div>
<div v-if="foldersExpanded" class="flex flex-col max-h-48 overflow-y-auto pb-1">
<div
class="px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
:class="[
selectedFolderId === null
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
: 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
dragOverFolderId === 'all'
? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
: '',
]"
@click="$emit('folder-click', null)"
@dragover="onDragOver($event, 'all')"
@dragleave="onDragLeave"
@drop="onDropOnFolder($event, null)"
>
<MaterialDesignIcon icon-name="inbox-outline" class="size-4" />
<span class="truncate flex-1">All Messages</span>
</div>
<div
class="px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
:class="[
selectedFolderId === 0
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
: 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
dragOverFolderId === 0
? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
: '',
]"
@click="$emit('folder-click', 0)"
@dragover="onDragOver($event, 0)"
@dragleave="onDragLeave"
@drop="onDropOnFolder($event, 0)"
>
<MaterialDesignIcon icon-name="folder-outline" class="size-4" />
<span class="truncate flex-1">Uncategorized</span>
</div>
<div
v-for="folder in folders"
:key="folder.id"
class="group px-3 py-1.5 flex items-center gap-2 cursor-pointer transition-colors text-sm"
:class="[
selectedFolderId === folder.id
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/20 dark:text-blue-400 font-semibold'
: 'text-gray-600 dark:text-zinc-400 hover:bg-gray-100 dark:hover:bg-zinc-800',
dragOverFolderId === folder.id
? 'ring-2 ring-blue-500 ring-inset bg-blue-50 dark:bg-blue-900/20'
: '',
]"
@click="$emit('folder-click', folder.id)"
@dragover="onDragOver($event, folder.id)"
@dragleave="onDragLeave"
@drop="onDropOnFolder($event, folder.id)"
>
<MaterialDesignIcon icon-name="folder" class="size-4" />
<span class="truncate flex-1">{{ folder.name }}</span>
<div class="hidden group-hover:flex items-center gap-0.5">
<button
type="button"
class="p-1 hover:text-blue-500 hover:bg-white dark:hover:bg-zinc-700 rounded-lg transition-colors"
@click.stop="renameFolder(folder)"
>
<MaterialDesignIcon icon-name="pencil-outline" class="size-3.5" />
</button>
<button
type="button"
class="p-1 hover:text-red-500 hover:bg-white dark:hover:bg-zinc-700 rounded-lg transition-colors"
@click.stop="deleteFolder(folder)"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-3.5" />
</button>
</div>
</div>
</div>
</div>
<!-- search + filters -->
<div
v-if="conversations.length > 0 || isFilterActive"
@@ -46,6 +185,15 @@
class="input-field flex-1"
@input="onConversationSearchInput"
/>
<button
type="button"
class="p-2 bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
title="Selection Mode"
:class="{ 'text-blue-500 bg-blue-50 dark:bg-blue-900/20': selectionMode }"
@click="toggleSelectionMode"
>
<MaterialDesignIcon icon-name="checkbox-multiple-marked-outline" class="size-5" />
</button>
<button
type="button"
class="p-2 bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-lg hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
@@ -55,6 +203,69 @@
<MaterialDesignIcon icon-name="qrcode-scan" class="size-5" />
</button>
</div>
<div
v-if="selectionMode"
class="flex items-center justify-between px-2 py-1 bg-blue-50 dark:bg-blue-900/10 rounded-lg"
>
<div class="flex items-center gap-2">
<input
type="checkbox"
:checked="allSelected"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
@change="toggleSelectAll"
/>
<span class="text-xs font-semibold text-blue-700 dark:text-blue-400">
{{ selectedHashes.size }} selected
</span>
</div>
<div class="flex gap-2">
<button
type="button"
class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline"
@click="bulkMarkAsRead"
>
Mark as read
</button>
<button
type="button"
class="text-xs font-bold text-red-600 dark:text-red-400 hover:underline"
@click="bulkDelete"
>
Delete
</button>
<div class="relative">
<button
type="button"
class="text-xs font-bold text-blue-600 dark:text-blue-400 hover:underline"
@click="moveMenu.show = !moveMenu.show"
>
Move to
</button>
<div
v-if="moveMenu.show"
v-click-outside="{ handler: () => (moveMenu.show = false), capture: true }"
class="absolute right-0 top-full mt-1 z-[60] min-w-[160px] bg-white dark:bg-zinc-800 rounded-xl shadow-xl border border-gray-200 dark:border-zinc-700 py-1 overflow-hidden animate-in fade-in zoom-in duration-100"
>
<button
type="button"
class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
@click="moveSelectedToFolder(null)"
>
Uncategorized
</button>
<button
v-for="folder in folders"
:key="folder.id"
type="button"
class="w-full text-left px-3 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-colors"
@click="moveSelectedToFolder(folder.id)"
>
{{ folder.name }}
</button>
</div>
</div>
</div>
</div>
<div class="flex flex-wrap gap-1">
<button type="button" :class="filterChipClasses(filterUnreadOnly)" @click="toggleFilter('unread')">
{{ $t("messages.unread") }}
@@ -96,15 +307,38 @@
conversation.failed_messages_count,
selectedDestinationHash === conversation.destination_hash,
GlobalState.config.banished_effect_enabled && isBlocked(conversation.destination_hash),
selectionMode,
selectedHashes.has(conversation.destination_hash),
]"
class="flex cursor-pointer p-2 border-l-2 relative"
class="flex cursor-pointer p-2 border-l-2 relative group conversation-item"
:class="[
conversation.destination_hash === selectedDestinationHash
? 'bg-gray-100 dark:bg-zinc-700 border-blue-500 dark:border-blue-400'
: 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-gray-200 dark:hover:border-zinc-600',
selectedHashes.has(conversation.destination_hash)
? 'bg-blue-50/50 dark:bg-blue-900/10'
: '',
]"
@click="onConversationClick(conversation)"
draggable="true"
@click="
selectionMode
? toggleSelectConversation(conversation.destination_hash)
: onConversationClick(conversation)
"
@contextmenu="onRightClick($event, conversation.destination_hash)"
@dragstart="onDragStart($event, conversation.destination_hash)"
>
<!-- Selection Checkbox -->
<div v-if="selectionMode" class="my-auto mr-3 px-1">
<input
type="checkbox"
:checked="selectedHashes.has(conversation.destination_hash)"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
@click.stop
@change="toggleSelectConversation(conversation.destination_hash)"
/>
</div>
<!-- banished overlay -->
<div
v-if="
@@ -177,6 +411,58 @@
</div>
</div>
<!-- Context Menu -->
<div
v-if="contextMenu.show"
v-click-outside="{ handler: () => (contextMenu.show = false), capture: true }"
class="fixed z-[100] min-w-[200px] bg-white dark:bg-zinc-800 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-700 py-1.5 overflow-hidden animate-in fade-in zoom-in duration-100"
:style="{ top: contextMenu.y + 'px', left: contextMenu.x + 'px' }"
>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
@click="bulkMarkAsRead"
>
<MaterialDesignIcon icon-name="email-open-outline" class="size-4 text-gray-400" />
<span class="font-medium">Mark as Read</span>
</button>
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
<div
class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
>
Move to Folder
</div>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
@click="moveSelectedToFolder(null)"
>
<MaterialDesignIcon icon-name="inbox-arrow-down" class="size-4 opacity-70" />
<span>Uncategorized</span>
</button>
<div class="max-h-[200px] overflow-y-auto custom-scrollbar">
<button
v-for="folder in folders"
:key="folder.id"
type="button"
class="w-full flex items-center gap-3 px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-600 dark:hover:text-blue-400 transition-all active:scale-95"
@click="moveSelectedToFolder(folder.id)"
>
<MaterialDesignIcon icon-name="folder" class="size-4 opacity-70" />
<span class="truncate">{{ folder.name }}</span>
</button>
</div>
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
<button
type="button"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 transition-all active:scale-95"
@click="bulkDelete"
>
<MaterialDesignIcon icon-name="trash-can-outline" class="size-4" />
<span class="font-bold">Delete</span>
</button>
</div>
<!-- loading more spinner -->
<div v-if="isLoadingMore" class="p-4 text-center">
<MaterialDesignIcon icon-name="loading" class="size-6 animate-spin text-gray-400" />
@@ -340,6 +626,7 @@
<script>
import Utils from "../../js/Utils";
import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import GlobalState from "../../js/GlobalState";
@@ -356,6 +643,14 @@ export default {
type: Array,
required: true,
},
folders: {
type: Array,
default: () => [],
},
selectedFolderId: {
type: [Number, String],
default: null,
},
selectedDestinationHash: {
type: String,
required: true,
@@ -414,11 +709,45 @@ export default {
"ingest-paper-message",
"load-more",
"load-more-announces",
"folder-click",
"create-folder",
"rename-folder",
"delete-folder",
"move-to-folder",
"bulk-mark-as-read",
"bulk-delete",
"export-folders",
"import-folders",
],
data() {
let foldersExpanded = true;
try {
if (typeof localStorage !== "undefined") {
foldersExpanded = localStorage.getItem("meshchatx_folders_expanded") !== "false";
}
} catch {
// ignore
}
return {
GlobalState,
tab: "conversations",
foldersExpanded,
selectionMode: false,
selectedHashes: new Set(),
folderMenu: {
show: false,
},
moveMenu: {
show: false,
},
contextMenu: {
show: false,
x: 0,
y: 0,
targetHash: null,
},
draggedHash: null,
dragOverFolderId: null,
};
},
computed: {
@@ -461,8 +790,126 @@ export default {
hasUnreadConversations() {
return this.conversations.some((c) => c.is_unread);
},
allSelected() {
return this.conversations.length > 0 && this.selectedHashes.size === this.conversations.length;
},
},
watch: {
foldersExpanded(newVal) {
try {
if (typeof localStorage !== "undefined") {
localStorage.setItem("meshchatx_folders_expanded", newVal);
}
} catch {
// ignore
}
},
},
methods: {
toggleSelectionMode() {
this.selectionMode = !this.selectionMode;
if (!this.selectionMode) {
this.selectedHashes.clear();
}
},
toggleSelectAll() {
if (this.allSelected) {
this.selectedHashes.clear();
} else {
this.conversations.forEach((c) => this.selectedHashes.add(c.destination_hash));
}
},
toggleSelectConversation(hash) {
if (this.selectedHashes.has(hash)) {
this.selectedHashes.delete(hash);
} else {
this.selectedHashes.add(hash);
}
},
onRightClick(event, hash) {
event.preventDefault();
if (this.selectionMode && !this.selectedHashes.has(hash)) {
this.selectedHashes.add(hash);
}
this.contextMenu.x = event.clientX;
this.contextMenu.y = event.clientY;
this.contextMenu.targetHash = hash;
this.contextMenu.show = true;
},
onFolderContextMenu(event) {
event.preventDefault();
// Show folder management menu
},
onDragStart(event, hash) {
this.draggedHash = hash;
event.dataTransfer.setData("text/plain", hash);
event.dataTransfer.effectAllowed = "move";
},
onDragOver(event, folderId) {
event.preventDefault();
this.dragOverFolderId = folderId;
event.dataTransfer.dropEffect = "move";
},
onDragLeave() {
this.dragOverFolderId = null;
},
onDropOnFolder(event, folderId) {
event.preventDefault();
this.dragOverFolderId = null;
const hash = event.dataTransfer.getData("text/plain");
if (hash) {
this.$emit("move-to-folder", {
peer_hashes: [hash],
folder_id: folderId,
});
}
this.draggedHash = null;
},
async createFolder() {
const name = await DialogUtils.prompt("Enter folder name", "New Folder");
if (name) {
this.$emit("create-folder", name);
}
},
async renameFolder(folder) {
const name = await DialogUtils.prompt("Rename folder", folder.name);
if (name && name !== folder.name) {
this.$emit("rename-folder", { id: folder.id, name });
}
},
async deleteFolder(folder) {
const confirmed = await DialogUtils.confirm(
`Are you sure you want to delete the folder "${folder.name}"? Conversations will be moved to Uncategorized.`,
"Delete Folder"
);
if (confirmed) {
this.$emit("delete-folder", folder.id);
}
},
bulkMarkAsRead() {
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
this.$emit("bulk-mark-as-read", hashes);
this.contextMenu.show = false;
this.moveMenu.show = false;
this.folderMenu.show = false;
if (this.selectionMode) this.toggleSelectionMode();
},
bulkDelete() {
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
this.$emit("bulk-delete", hashes);
this.contextMenu.show = false;
this.moveMenu.show = false;
this.folderMenu.show = false;
if (this.selectionMode) this.toggleSelectionMode();
},
moveSelectedToFolder(folderId) {
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
this.$emit("move-to-folder", { peer_hashes: hashes, folder_id: folderId });
this.contextMenu.show = false;
this.moveMenu.show = false;
this.folderMenu.show = false;
if (this.selectionMode) this.toggleSelectionMode();
},
isBlocked(destinationHash) {
return this.blockedDestinations.some((b) => b.destination_hash === destinationHash);
},

View File

@@ -173,6 +173,167 @@
</div>
</section>
<!-- Maintenance & Data -->
<section class="glass-card break-inside-avoid">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Maintenance</div>
<h2>{{ $t("maintenance.title") }}</h2>
<p>{{ $t("maintenance.description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<div class="grid grid-cols-1 gap-3">
<button
type="button"
class="btn-maintenance border-red-200 dark:border-red-900/30 text-red-700 dark:text-red-300 bg-red-50 dark:bg-red-900/10 hover:bg-red-100 dark:hover:bg-red-900/20"
@click="clearMessages"
>
<div class="flex flex-col items-start text-left">
<div class="font-bold flex items-center gap-2">
<MaterialDesignIcon icon-name="message-remove" class="size-4" />
{{ $t("maintenance.clear_messages") }}
</div>
<div class="text-xs opacity-80">
{{ $t("maintenance.clear_messages_desc") }}
</div>
</div>
</button>
<button
type="button"
class="btn-maintenance border-orange-200 dark:border-orange-900/30 text-orange-700 dark:text-orange-300 bg-orange-50 dark:bg-orange-900/10 hover:bg-orange-100 dark:hover:bg-orange-900/20"
@click="clearAnnounces"
>
<div class="flex flex-col items-start text-left">
<div class="font-bold flex items-center gap-2">
<MaterialDesignIcon icon-name="broadcast-off" class="size-4" />
{{ $t("maintenance.clear_announces") }}
</div>
<div class="text-xs opacity-80">
{{ $t("maintenance.clear_announces_desc") }}
</div>
</div>
</button>
<button
type="button"
class="btn-maintenance border-indigo-200 dark:border-indigo-900/30 text-indigo-700 dark:text-indigo-300 bg-indigo-50 dark:bg-indigo-900/10 hover:bg-indigo-100 dark:hover:bg-indigo-900/20"
@click="clearNomadnetFavorites"
>
<div class="flex flex-col items-start text-left">
<div class="font-bold flex items-center gap-2">
<MaterialDesignIcon icon-name="bookmark-remove" class="size-4" />
{{ $t("maintenance.clear_nomadnet_favs") }}
</div>
<div class="text-xs opacity-80">
{{ $t("maintenance.clear_nomadnet_favs_desc") }}
</div>
</div>
</button>
<button
type="button"
class="btn-maintenance border-blue-200 dark:border-blue-900/30 text-blue-700 dark:text-blue-300 bg-blue-50 dark:bg-blue-900/10 hover:bg-blue-100 dark:hover:bg-blue-900/20"
@click="clearArchives"
>
<div class="flex flex-col items-start text-left">
<div class="font-bold flex items-center gap-2">
<MaterialDesignIcon icon-name="delete-sweep" class="size-4" />
{{ $t("maintenance.clear_archives") }}
</div>
<div class="text-xs opacity-80">
{{ $t("maintenance.clear_archives_desc") }}
</div>
</div>
</button>
</div>
<div class="space-y-2 pt-2 border-t border-gray-100 dark:border-zinc-800">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Automatic Backup Limit
</div>
<input
v-model.number="config.backup_max_count"
type="number"
min="1"
max="50"
class="input-field"
@input="onBackupConfigChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
Number of automatic backups to keep.
</div>
</div>
<div class="grid grid-cols-2 gap-3 mt-4">
<button
type="button"
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-blue-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-blue-500 transition group"
@click="exportMessages"
>
<MaterialDesignIcon
icon-name="export"
class="size-6 text-blue-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">{{ $t("maintenance.export_messages") }}</div>
</button>
<button
type="button"
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-emerald-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-emerald-500 transition group"
@click="triggerImport"
>
<MaterialDesignIcon
icon-name="import"
class="size-6 text-emerald-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">{{ $t("maintenance.import_messages") }}</div>
</button>
<input
ref="importFile"
type="file"
accept=".json"
class="hidden"
@change="importMessages"
/>
</div>
<div class="grid grid-cols-2 gap-3 mt-2 pt-4 border-t border-gray-100 dark:border-zinc-800">
<button
type="button"
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-purple-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-purple-500 transition group"
@click="exportFolders"
>
<MaterialDesignIcon
icon-name="folder-export-outline"
class="size-6 text-purple-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">Export Folders</div>
</button>
<button
type="button"
class="flex flex-col items-center justify-center gap-2 p-4 rounded-2xl border border-indigo-200 dark:border-zinc-800 bg-white/50 dark:bg-zinc-800/50 hover:border-indigo-500 transition group"
@click="triggerFolderImport"
>
<MaterialDesignIcon
icon-name="folder-import-outline"
class="size-6 text-indigo-500 group-hover:scale-110 transition"
/>
<div class="text-sm font-bold">Import Folders</div>
</button>
<input
ref="importFolderFile"
type="file"
accept=".json"
class="hidden"
@change="importFolders"
/>
</div>
</div>
</section>
<!-- Desktop / Electron Settings -->
<section v-if="ElectronUtils.isElectron()" class="glass-card break-inside-avoid">
<header class="glass-card__header">
@@ -1313,6 +1474,17 @@ export default {
);
}, 1000);
},
async onBackupConfigChange() {
if (this.saveTimeouts.backup) clearTimeout(this.saveTimeouts.backup);
this.saveTimeouts.backup = setTimeout(async () => {
await this.updateConfig(
{
backup_max_count: this.config.backup_max_count,
},
"backup_max_count"
);
}, 1000);
},
async flushArchivedPages() {
if (
!(await DialogUtils.confirm(
@@ -1335,19 +1507,15 @@ export default {
async onIsTransportEnabledChange() {
if (this.config.is_transport_enabled) {
try {
const response = await window.axios.post("/api/v1/reticulum/enable-transport");
ToastUtils.success(response.data.message);
} catch (e) {
await window.axios.post("/api/v1/reticulum/enable-transport");
} catch {
ToastUtils.error("Failed to enable transport mode!");
console.log(e);
}
} else {
try {
const response = await window.axios.post("/api/v1/reticulum/disable-transport");
ToastUtils.success(response.data.message);
} catch (e) {
await window.axios.post("/api/v1/reticulum/disable-transport");
} catch {
ToastUtils.error("Failed to disable transport mode!");
console.log(e);
}
}
},
@@ -1358,13 +1526,130 @@ export default {
this.reloadingRns = true;
const response = await window.axios.post("/api/v1/reticulum/reload");
ToastUtils.success(response.data.message);
} catch (e) {
ToastUtils.error(e.response?.data?.error || "Failed to reload Reticulum!");
console.error(e);
} catch {
ToastUtils.error("Failed to reload Reticulum!");
} finally {
this.reloadingRns = false;
}
},
async clearMessages() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
await window.axios.delete("/api/v1/maintenance/messages");
ToastUtils.success(this.$t("maintenance.messages_cleared"));
} catch {
ToastUtils.error(this.$t("common.error"));
}
},
async clearAnnounces() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
await window.axios.delete("/api/v1/maintenance/announces");
ToastUtils.success(this.$t("maintenance.announces_cleared"));
} catch {
ToastUtils.error(this.$t("common.error"));
}
},
async clearNomadnetFavorites() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
await window.axios.delete("/api/v1/maintenance/favourites", {
params: { aspect: "nomadnetwork.node" },
});
ToastUtils.success(this.$t("maintenance.favourites_cleared"));
} catch {
ToastUtils.error(this.$t("common.error"));
}
},
async clearArchives() {
if (!(await DialogUtils.confirm(this.$t("maintenance.clear_confirm")))) return;
try {
await window.axios.delete("/api/v1/maintenance/archives");
ToastUtils.success(this.$t("maintenance.archives_cleared"));
} catch {
ToastUtils.error(this.$t("common.error"));
}
},
async exportMessages() {
try {
const response = await window.axios.get("/api/v1/maintenance/messages/export");
const messages = response.data.messages;
const dataStr = JSON.stringify({ messages }, null, 2);
const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
const exportFileDefaultName = `meshchat_messages_${new Date().toISOString().slice(0, 10)}.json`;
const linkElement = document.createElement("a");
linkElement.setAttribute("href", dataUri);
linkElement.setAttribute("download", exportFileDefaultName);
linkElement.click();
} catch {
ToastUtils.error(this.$t("common.error"));
}
},
triggerImport() {
this.$refs.importFile.click();
},
async importMessages(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = JSON.parse(e.target.result);
if (!data.messages) throw new Error("Invalid file format");
await window.axios.post("/api/v1/maintenance/messages/import", {
messages: data.messages,
});
ToastUtils.success(this.$t("maintenance.import_success", { count: data.messages.length }));
} catch {
ToastUtils.error(this.$t("maintenance.import_failed"));
}
};
reader.readAsText(file);
// Reset input
event.target.value = "";
},
async exportFolders() {
try {
const response = await window.axios.get("/api/v1/lxmf/folders/export");
const dataStr = JSON.stringify(response.data, null, 2);
const dataUri = "data:application/json;charset=utf-8," + encodeURIComponent(dataStr);
const exportFileDefaultName = `meshchat_folders_${new Date().toISOString().slice(0, 10)}.json`;
const linkElement = document.createElement("a");
linkElement.setAttribute("href", dataUri);
linkElement.setAttribute("download", exportFileDefaultName);
linkElement.click();
ToastUtils.success("Folders exported");
} catch {
ToastUtils.error("Failed to export folders");
}
},
triggerFolderImport() {
this.$refs.importFolderFile.click();
},
async importFolders(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = async (e) => {
try {
const data = JSON.parse(e.target.result);
if (!data.folders || !data.mappings) throw new Error("Invalid file format");
await window.axios.post("/api/v1/lxmf/folders/import", data);
ToastUtils.success("Folders and mappings imported successfully");
} catch {
ToastUtils.error("Failed to import folders");
}
};
reader.readAsText(file);
// Reset input
event.target.value = "";
},
formatSecondsAgo: function (seconds) {
return Utils.formatSecondsAgo(seconds);
},
@@ -1394,6 +1679,9 @@ export default {
.input-field {
@apply bg-gray-50/90 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 text-sm rounded-2xl focus:ring-2 focus:ring-blue-400 focus:border-blue-400 dark:focus:ring-blue-500 dark:focus:border-blue-500 block w-full p-2.5 text-gray-900 dark:text-gray-100 transition;
}
.btn-maintenance {
@apply w-full px-4 py-3 rounded-2xl border transition flex items-center justify-between;
}
.setting-toggle {
@apply flex items-start gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/70 dark:bg-zinc-900/70 px-3 py-3;
}

View File

@@ -35,8 +35,16 @@
<RouterLink
v-for="tool in filteredTools"
:key="tool.name"
:to="tool.route"
:class="['tool-card', 'glass-card', tool.customClass].filter(Boolean)"
:to="tool.comingSoon ? '' : tool.route"
:class="
[
'tool-card',
'glass-card',
tool.customClass,
tool.comingSoon ? 'opacity-60 grayscale-[0.5] cursor-default' : '',
].filter(Boolean)
"
@click="tool.comingSoon ? $event.preventDefault() : null"
>
<div :class="tool.iconBg">
<MaterialDesignIcon v-if="tool.icon" :icon-name="tool.icon" class="w-6 h-6" />
@@ -48,11 +56,20 @@
/>
</div>
<div class="flex-1">
<div class="flex items-center gap-2">
<div class="tool-card__title">{{ tool.title }}</div>
<span
v-if="tool.comingSoon"
class="px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider bg-gray-100 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 rounded-md border border-gray-200 dark:border-zinc-700"
>
Soon
</span>
</div>
<div class="tool-card__description">
{{ tool.description }}
</div>
</div>
<div v-if="!tool.comingSoon">
<div v-if="tool.extraAction" class="flex items-center gap-2">
<a
:href="tool.extraAction.href"
@@ -65,6 +82,7 @@
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</div>
<MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
</div>
</RouterLink>
</div>
@@ -193,6 +211,30 @@ export default {
icon: "open-in-new",
},
},
{
name: "rns-page-node",
comingSoon: true,
icon: "server-network",
iconBg: "tool-card__icon bg-amber-50 text-amber-500 dark:bg-amber-900/30 dark:text-amber-200",
titleKey: "tools.rns_page_node.title",
descriptionKey: "tools.rns_page_node.description",
},
{
name: "rns-tunnel",
comingSoon: true,
icon: "tunnel",
iconBg: "tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200",
titleKey: "tools.rns_tunnel.title",
descriptionKey: "tools.rns_tunnel.description",
},
{
name: "rns-filesync",
comingSoon: true,
icon: "folder-sync",
iconBg: "tool-card__icon bg-emerald-50 text-emerald-500 dark:bg-emerald-900/30 dark:text-emerald-200",
titleKey: "tools.rns_filesync.title",
descriptionKey: "tools.rns_filesync.description",
},
{
name: "debug-logs",
route: { name: "debug-logs" },

View File

@@ -212,7 +212,31 @@
"confirm": "Bestätigen",
"delete_confirm": "Sind Sie sicher, dass Sie dies löschen möchten? Dies kann nicht rückgängig gemacht werden.",
"search": "Werkzeuge suchen...",
"no_results": "Keine Werkzeuge gefunden"
"no_results": "Keine Werkzeuge gefunden",
"error": "Fehler"
},
"maintenance": {
"title": "Wartung & Daten",
"description": "Verwalten Sie Ihre lokalen Daten, löschen Sie Caches und sichern Sie Konversationen.",
"clear_messages": "Alle Nachrichten löschen",
"clear_messages_desc": "Alle gesendeten und empfangenen Nachrichten dauerhaft löschen.",
"clear_announces": "Alle Ankündigungen löschen",
"clear_announces_desc": "Alle entdeckten Peers und Knoten aus dem Cache entfernen.",
"clear_nomadnet_favs": "NomadNetwork-Favoriten löschen",
"clear_nomadnet_favs_desc": "Alle gespeicherten NomadNetwork-Knoten aus den Favoriten entfernen.",
"clear_archives": "Seitenarchive löschen",
"clear_archives_desc": "Alle zwischengespeicherten NomadNetwork-Seiten löschen.",
"export_messages": "Nachrichten exportieren",
"export_messages_desc": "Alle Konversationen als JSON-Datei herunterladen.",
"import_messages": "Nachrichten importieren",
"import_messages_desc": "Konversationen aus einer JSON-Datei wiederherstellen.",
"clear_confirm": "Sind Sie sicher? Diese Aktion kann nicht rückgängig gemacht werden.",
"messages_cleared": "Nachrichten erfolgreich gelöscht",
"announces_cleared": "Ankündigungen erfolgreich gelöscht",
"favourites_cleared": "Favoriten erfolgreich gelöscht",
"archives_cleared": "Archive erfolgreich gelöscht",
"import_success": "Erfolgreich {count} Nachrichten importiert",
"import_failed": "Nachrichten konnten nicht importiert werden"
},
"identities": {
"title": "Identitäten",
@@ -695,6 +719,18 @@
"title": "Papiernachricht",
"description": "Erstellen und lesen Sie LXMF-signierte Papiernachrichten über QR-Codes."
},
"rns_page_node": {
"title": "RNS Page-Knoten",
"description": "Nomadnet-Micron-Seiten hosten und Dateien mühelos teilen."
},
"rns_tunnel": {
"title": "RNS-Tunnel",
"description": "Regulären IP-Verkehr über Reticulum-Netzwerkverbindungen tunneln."
},
"rns_filesync": {
"title": "RNS-Dateisynchronisierung",
"description": "Dateien effizient mit anderen Mesh-Peers teilen und synchronisieren."
},
"bots": {
"title": "LXMFy-Bots",
"description": "Verwalten Sie automatisierte Bots für Echo, Notizen und Erinnerungen mit LXMFy."
@@ -1051,7 +1087,11 @@
"restart_start": "MeshChatX starten",
"skip_setup": "Setup überspringen",
"continue": "Weiter",
"skip_confirm": "Sind Sie sicher, dass Sie das Setup überspringen möchten? Sie müssen Schnittstellen später manuell hinzufügen."
"skip_confirm": "Sind Sie sicher, dass Sie das Setup überspringen möchten? Sie müssen Schnittstellen später manuell hinzufügen.",
"discovery_question": "Möchten Sie die Community-Schnittstellenerkennung und Auto-Connect verwenden?",
"discovery_desc": "Dies ermöglicht es MeshChatX, automatisch öffentliche Community-Knoten in Ihrer Nähe oder im Internet zu finden und sich mit ihnen zu verbinden.",
"yes": "Ja, Erkennung verwenden",
"no": "Nein, manuelle Einrichtung"
},
"command_palette": {
"search_placeholder": "Befehle suchen, navigieren oder Peers finden...",

View File

@@ -212,7 +212,31 @@
"confirm": "Confirm",
"delete_confirm": "Are you sure you want to delete this? This cannot be undone.",
"search": "Search tools...",
"no_results": "No tools found"
"no_results": "No tools found",
"error": "Error"
},
"maintenance": {
"title": "Maintenance & Data",
"description": "Manage your local data, clear caches, and backup conversations.",
"clear_messages": "Clear All Messages",
"clear_messages_desc": "Permanently delete all sent and received messages.",
"clear_announces": "Clear All Announces",
"clear_announces_desc": "Remove all discovered peers and nodes from your cache.",
"clear_nomadnet_favs": "Clear NomadNetwork Favorites",
"clear_nomadnet_favs_desc": "Remove all saved NomadNetwork nodes from your favorites.",
"clear_archives": "Clear Page Archives",
"clear_archives_desc": "Delete all cached NomadNetwork pages.",
"export_messages": "Export Messages",
"export_messages_desc": "Download all conversations as a JSON file.",
"import_messages": "Import Messages",
"import_messages_desc": "Restore conversations from a JSON file.",
"clear_confirm": "Are you sure? This action cannot be undone.",
"messages_cleared": "Messages cleared successfully",
"announces_cleared": "Announces cleared successfully",
"favourites_cleared": "Favorites cleared successfully",
"archives_cleared": "Archives cleared successfully",
"import_success": "Successfully imported {count} messages",
"import_failed": "Failed to import messages"
},
"identities": {
"title": "Identities",
@@ -695,6 +719,18 @@
"title": "Paper Message",
"description": "Generate and read LXMF signed paper messages via QR codes."
},
"rns_page_node": {
"title": "RNS Page Node",
"description": "Host Nomadnet micron pages and share files with ease."
},
"rns_tunnel": {
"title": "RNS Tunnel",
"description": "Tunnel regular IP traffic over Reticulum network links."
},
"rns_filesync": {
"title": "RNS Filesync",
"description": "Efficiently share and sync files with other mesh peers."
},
"bots": {
"title": "LXMFy Bots",
"description": "Manage automated bots for echo, notes, and reminders using LXMFy."
@@ -1051,7 +1087,11 @@
"restart_start": "Start MeshChatX",
"skip_setup": "Skip Setup",
"continue": "Continue",
"skip_confirm": "Are you sure you want to skip the setup? You'll need to manually add interfaces later."
"skip_confirm": "Are you sure you want to skip the setup? You'll need to manually add interfaces later.",
"discovery_question": "Do you want to use community interface discovering and auto-connect?",
"discovery_desc": "This allows MeshChatX to automatically find and connect to public community nodes near you or on the internet.",
"yes": "Yes, use discovery",
"no": "No, manual setup"
},
"command_palette": {
"search_placeholder": "Search commands, navigate, or find peers...",

View File

@@ -212,7 +212,31 @@
"confirm": "Подтвердить",
"delete_confirm": "Вы уверены, что хотите удалить это? Это действие нельзя отменить.",
"search": "Поиск инструментов...",
"no_results": "Инструменты не найдены"
"no_results": "Инструменты не найдены",
"error": "Ошибка"
},
"maintenance": {
"title": "Обслуживание и данные",
"description": "Управляйте локальными данными, очищайте кэши и создавайте резервные копии разговоров.",
"clear_messages": "Очистить все сообщения",
"clear_messages_desc": "Навсегда удалить все отправленные и полученные сообщения.",
"clear_announces": "Очистить все объявления",
"clear_announces_desc": "Удалить всех обнаруженных участников и узлы из кэша.",
"clear_nomadnet_favs": "Очистить избранное NomadNetwork",
"clear_nomadnet_favs_desc": "Удалить все сохраненные узлы NomadNetwork из избранного.",
"clear_archives": "Очистить архивы страниц",
"clear_archives_desc": "Удалить все кэшированные страницы NomadNetwork.",
"export_messages": "Экспортировать сообщения",
"export_messages_desc": "Загрузить все разговоры в виде JSON-файла.",
"import_messages": "Импортировать сообщения",
"import_messages_desc": "Восстановить разговоры из JSON-файла.",
"clear_confirm": "Вы уверены? Это действие нельзя отменить.",
"messages_cleared": "Сообщения успешно очищены",
"announces_cleared": "Объявления успешно очищены",
"favourites_cleared": "Избранное успешно очищено",
"archives_cleared": "Архивы успешно очищены",
"import_success": "Успешно импортировано {count} сообщений",
"import_failed": "Не удалось импортировать сообщения"
},
"identities": {
"title": "Личности",
@@ -695,6 +719,18 @@
"title": "Бумажное сообщение",
"description": "Создание и чтение подписанных бумажных сообщений LXMF через QR-коды."
},
"rns_page_node": {
"title": "RNS Page-узел",
"description": "Хостинг микрон-страниц Nomadnet и удобный обмен файлами."
},
"rns_tunnel": {
"title": "RNS-туннель",
"description": "Туннелирование обычного IP-трафика через сеть Reticulum."
},
"rns_filesync": {
"title": "RNS-файлообмен",
"description": "Эффективный обмен и синхронизация файлов с другими узлами mesh."
},
"bots": {
"title": "LXMFy Боты",
"description": "Управление автоматизированными ботами для эха, заметок и напоминаний с помощью LXMFy."
@@ -1051,7 +1087,11 @@
"restart_start": "Запустить MeshChatX",
"skip_setup": "Пропустить настройку",
"continue": "Продолжить",
"skip_confirm": "Вы уверены, что хотите пропустить настройку? Вам придется добавить интерфейсы позже вручную."
"skip_confirm": "Вы уверены, что хотите пропустить настройку? Вам придется добавить интерфейсы позже вручную.",
"discovery_question": "Вы хотите использовать обнаружение интерфейсов сообщества и автоподключение?",
"discovery_desc": "Это позволяет MeshChatX автоматически находить и подключаться к публичным узлам сообщества рядом с вами или в интернете.",
"yes": "Да, использовать обнаружение",
"no": "Нет, ручная настройка"
},
"command_palette": {
"search_placeholder": "Поиск команд, навигация или поиск узлов...",

View File

@@ -168,6 +168,33 @@ select.input-field option {
scrollbar-width: none;
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 10px;
border: none;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: #3f3f46;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
@keyframes spin-reverse {
from {
transform: rotate(360deg);

View File

@@ -0,0 +1,85 @@
import os
import shutil
import tempfile
import base64
import unittest
from unittest.mock import MagicMock, patch
from meshchatx.src.backend.identity_manager import IdentityManager
class TestIdentityRestore(unittest.TestCase):
def setUp(self):
self.temp_dir = tempfile.mkdtemp()
self.identity_manager = IdentityManager(self.temp_dir)
def tearDown(self):
shutil.rmtree(self.temp_dir)
@patch("RNS.Identity")
@patch("meshchatx.src.backend.identity_manager.DatabaseProvider")
@patch("meshchatx.src.backend.identity_manager.DatabaseSchema")
def test_restore_identity_from_bytes(
self, mock_schema, mock_provider, mock_rns_identity
):
# Setup mock identity
mock_id_instance = MagicMock()
mock_id_instance.hash = b"test_hash_32_bytes_long_01234567"
mock_id_instance.get_private_key.return_value = b"test_private_key"
mock_rns_identity.from_bytes.return_value = mock_id_instance
identity_bytes = b"some_identity_bytes"
result = self.identity_manager.restore_identity_from_bytes(identity_bytes)
identity_hash = mock_id_instance.hash.hex()
self.assertEqual(result["hash"], identity_hash)
self.assertEqual(result["display_name"], "Restored Identity")
# Verify files were created
identity_dir = os.path.join(self.temp_dir, "identities", identity_hash)
self.assertTrue(os.path.exists(identity_dir))
self.assertTrue(os.path.exists(os.path.join(identity_dir, "identity")))
self.assertTrue(os.path.exists(os.path.join(identity_dir, "metadata.json")))
# Verify private key was written
with open(os.path.join(identity_dir, "identity"), "rb") as f:
self.assertEqual(f.read(), b"test_private_key")
@patch("RNS.Identity")
@patch("meshchatx.src.backend.identity_manager.DatabaseProvider")
@patch("meshchatx.src.backend.identity_manager.DatabaseSchema")
def test_restore_identity_from_base32(
self, mock_schema, mock_provider, mock_rns_identity
):
# Setup mock identity
mock_id_instance = MagicMock()
mock_id_instance.hash = b"test_hash_32_bytes_long_01234567"
mock_id_instance.get_private_key.return_value = b"test_private_key"
mock_rns_identity.from_bytes.return_value = mock_id_instance
identity_bytes = b"some_identity_bytes"
base32_value = base64.b32encode(identity_bytes).decode("utf-8")
result = self.identity_manager.restore_identity_from_base32(base32_value)
identity_hash = mock_id_instance.hash.hex()
self.assertEqual(result["hash"], identity_hash)
# Verify from_bytes was called with the decoded bytes
mock_rns_identity.from_bytes.assert_called_with(identity_bytes)
@patch("RNS.Identity")
def test_restore_identity_invalid_bytes(self, mock_rns_identity):
mock_rns_identity.from_bytes.return_value = None
with self.assertRaises(ValueError) as cm:
self.identity_manager.restore_identity_from_bytes(b"invalid")
self.assertIn("Could not load identity from bytes", str(cm.exception))
@patch("RNS.Identity")
def test_restore_identity_invalid_base32(self, mock_rns_identity):
with self.assertRaises(ValueError) as cm:
self.identity_manager.restore_identity_from_base32("invalid-base32-!!!")
self.assertIn("Invalid base32 identity", str(cm.exception))
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,64 @@
import unittest
from unittest.mock import MagicMock
from meshchatx.src.backend.database.messages import MessageDAO
from meshchatx.src.backend.database.announces import AnnounceDAO
from meshchatx.src.backend.database.misc import MiscDAO
class TestMaintenance(unittest.TestCase):
def setUp(self):
self.provider = MagicMock()
self.messages_dao = MessageDAO(self.provider)
self.announces_dao = AnnounceDAO(self.provider)
self.misc_dao = MiscDAO(self.provider)
def test_delete_all_lxmf_messages(self):
self.messages_dao.delete_all_lxmf_messages()
self.assertEqual(self.provider.execute.call_count, 2)
calls = self.provider.execute.call_args_list
self.assertIn("DELETE FROM lxmf_messages", calls[0][0][0])
self.assertIn("DELETE FROM lxmf_conversation_read_state", calls[1][0][0])
def test_delete_all_announces(self):
# Test without aspect
self.announces_dao.delete_all_announces()
self.provider.execute.assert_called_with("DELETE FROM announces")
# Test with aspect
self.announces_dao.delete_all_announces(aspect="test_aspect")
self.provider.execute.assert_called_with(
"DELETE FROM announces WHERE aspect = ?", ("test_aspect",)
)
def test_delete_all_favourites(self):
# Test without aspect
self.announces_dao.delete_all_favourites()
self.provider.execute.assert_called_with("DELETE FROM favourite_destinations")
# Test with aspect
self.announces_dao.delete_all_favourites(aspect="test_aspect")
self.provider.execute.assert_called_with(
"DELETE FROM favourite_destinations WHERE aspect = ?", ("test_aspect",)
)
def test_delete_archived_pages(self):
self.misc_dao.delete_archived_pages()
self.provider.execute.assert_called_with("DELETE FROM archived_pages")
def test_upsert_lxmf_message(self):
msg_data = {
"hash": "test_hash",
"source_hash": "source",
"destination_hash": "dest",
"peer_hash": "peer",
"content": "hello",
}
self.messages_dao.upsert_lxmf_message(msg_data)
self.provider.execute.assert_called()
args, _ = self.provider.execute.call_args
self.assertIn("INSERT INTO lxmf_messages", args[0])
self.assertIn("ON CONFLICT(hash) DO UPDATE SET", args[0])
if __name__ == "__main__":
unittest.main()

View File

@@ -22,10 +22,14 @@ class TestMessageHandler(unittest.TestCase):
def test_delete_conversation(self):
self.handler.delete_conversation("local", "dest")
self.db.provider.execute.assert_called()
args, kwargs = self.db.provider.execute.call_args
self.assertIn("DELETE FROM lxmf_messages", args[0])
self.assertIn("dest", args[1])
self.assertEqual(self.db.provider.execute.call_count, 2)
call_args_list = self.db.provider.execute.call_args_list
first_call_args, _ = call_args_list[0]
second_call_args, _ = call_args_list[1]
self.assertIn("DELETE FROM lxmf_messages", first_call_args[0])
self.assertIn("dest", first_call_args[1])
self.assertIn("DELETE FROM lxmf_conversation_folders", second_call_args[0])
self.assertIn("dest", second_call_args[1])
if __name__ == "__main__":

View File

@@ -1,8 +1,18 @@
import { mount } from "@vue/test-utils";
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import MessagesSidebar from "@/components/messages/MessagesSidebar.vue";
describe("MessagesSidebar.vue", () => {
beforeEach(() => {
// Mock localStorage
global.localStorage = {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
};
});
const defaultProps = {
peers: {},
conversations: [],
@@ -38,11 +48,13 @@ describe("MessagesSidebar.vue", () => {
const wrapper = mountMessagesSidebar({ conversations });
const nameElement = wrapper.find(".truncate");
const nameElement = wrapper.find(".conversation-item .truncate");
expect(nameElement.exists()).toBe(true);
expect(nameElement.text()).toContain("Long Name");
const previewElement = wrapper.findAll(".truncate").find((el) => el.text().includes("Message"));
const previewElement = wrapper
.findAll(".conversation-item .truncate")
.find((el) => el.text().includes("Message"));
expect(previewElement.exists()).toBe(true);
});
@@ -60,7 +72,7 @@ describe("MessagesSidebar.vue", () => {
expect(scrollContainer.exists()).toBe(true);
expect(scrollContainer.classes()).toContain("overflow-y-auto");
const conversationItems = wrapper.findAll("div.overflow-y-auto .cursor-pointer");
const conversationItems = wrapper.findAll(".conversation-item");
expect(conversationItems.length).toBe(100);
});

View File

@@ -106,7 +106,7 @@ describe("UI Performance and Memory Tests", () => {
`Rendered ${numConvs} conversations in ${renderTime.toFixed(2)}ms, Memory growth: ${memGrowth.toFixed(2)}MB`
);
expect(wrapper.findAll(".flex.cursor-pointer").length).toBe(numConvs);
expect(wrapper.findAll(".conversation-item").length).toBe(numConvs);
expect(renderTime).toBeLessThan(5000);
expect(memGrowth).toBeLessThan(200); // Adjusted for JSDOM/Node.js overhead with 2000 items
});