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,69 +783,284 @@
</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Community Interfaces -->
<div v-if="discoveryOption === null" class="flex flex-col items-center gap-8 py-12">
<div
class="bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800"
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"
>
<div class="flex items-center gap-2 mb-5">
<v-icon icon="mdi-web" color="blue" size="26"></v-icon>
<span class="text-lg font-bold text-gray-900 dark:text-white">{{
$t("tutorial.suggested_relays")
}}</span>
<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>
<div class="space-y-3 max-h-[320px] 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)"
<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"
>
<div class="flex flex-col">
<span class="font-bold text-gray-900 dark:text-white text-base">
{{ iface.name }}
</span>
<span class="text-xs text-gray-500 font-mono"
>{{ iface.target_host }}:{{ iface.target_port }}</span
>
</div>
<div class="flex items-center gap-2">
<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>
{{ $t("tutorial.online") }}
</span>
<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-4 py-1 text-[11px] rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-sm transition-all"
@click.stop="selectCommunityInterface(iface)"
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"
>
{{ $t("tutorial.use") }}
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>
<div v-if="loadingInterfaces" class="flex justify-center py-4">
<v-progress-circular indeterminate color="blue" size="32"></v-progress-circular>
<!-- Community Interfaces -->
<div
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>
<span class="text-lg font-bold text-gray-900 dark:text-white">{{
$t("tutorial.suggested_relays")
}}</span>
</div>
<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 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 truncate"
>{{ iface.target_host }}:{{ iface.target_port }}</span
>
</div>
<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>
{{ $t("tutorial.online") }}
</span>
<button
type="button"
class="px-4 py-1 text-[11px] rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-sm transition-all"
@click.stop="selectCommunityInterface(iface)"
>
{{ $t("tutorial.use") }}
</button>
</div>
</div>
<div v-if="loadingInterfaces" class="flex justify-center py-4">
<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,8 +315,24 @@
<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">
v{{ appInfo.rns_version }}
<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>
@@ -530,34 +566,75 @@
</div>
</div>
<div v-if="snapshots.length > 0" 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">
<span
class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
>{{ snapshot.name }}</span
>
<span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
>{{ formatBytes(snapshot.size) }} {{ snapshot.created_at }}</span
>
</div>
<button
type="button"
class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
@click="restoreFromSnapshot(snapshot.path)"
<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"
>
Restore
</button>
<div class="flex flex-col min-w-0">
<span
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) }}
{{ 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]"
@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 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">
<span
class="font-black text-gray-900 dark:text-white text-xs truncate max-w-[150px]"
>{{ backup.name }}</span
>
<span class="text-[10px] font-bold text-gray-400 mt-1 tabular-nums"
>{{ formatBytes(backup.size) }} {{ backup.created_at }}</span
>
</div>
<button
type="button"
class="secondary-chip !px-3 !py-1 !text-[10px] opacity-0 group-hover:opacity-100"
@click="restoreFromSnapshot(backup.path)"
<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"
>
Restore
</button>
<div class="flex flex-col min-w-0">
<span
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) }}
{{ 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]"
@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,136 +111,370 @@
</div>
</div>
<div
v-if="filteredInterfaces.length === 0"
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 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-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.
</div>
</div>
<RouterLink :to="{ name: 'interfaces.add' }" class="secondary-chip text-sm">
<MaterialDesignIcon icon-name="lan" class="w-4 h-4" />
Configure Per-Interface
</RouterLink>
<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 class="grid gap-4 md:grid-cols-2">
<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.
<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-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.
<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="space-y-3">
<div class="flex items-center">
<div class="flex flex-col mr-auto">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
Discover Interfaces (Peer)
<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-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.
</div>
</div>
<RouterLink :to="{ name: 'interfaces.add' }" class="secondary-chip text-sm">
<MaterialDesignIcon icon-name="lan" class="w-4 h-4" />
Configure Per-Interface
</RouterLink>
</div>
<div class="grid gap-4 md:grid-cols-2">
<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.
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Listen for discovery announces and optionally auto-connect to available
interfaces.
Requires LXMF in the Python environment. Transport is optional for publishing,
but usually recommended so peers can connect back.
</div>
</div>
<Toggle v-model="discoveryConfig.discover_interfaces" class="my-auto mx-2" />
</div>
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
Allowed Sources
<div class="space-y-3">
<div class="flex items-center">
<div class="flex flex-col mr-auto">
<div class="text-sm font-semibold text-gray-900 dark:text-white">
Discover Interfaces (Peer)
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Listen for discovery announces and optionally auto-connect to available
interfaces.
</div>
</div>
<Toggle v-model="discoveryConfig.discover_interfaces" class="my-auto mx-2" />
</div>
<input
v-model="discoveryConfig.interface_discovery_sources"
type="text"
placeholder="Comma separated identity hashes"
class="input-field"
/>
</div>
<div>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
Required Stamp Value
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
<div>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
Allowed Sources
</div>
<input
v-model="discoveryConfig.interface_discovery_sources"
type="text"
placeholder="Comma separated identity hashes"
class="input-field"
/>
</div>
<div>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
Required Stamp Value
</div>
<input
v-model.number="discoveryConfig.required_discovery_value"
type="number"
min="0"
class="input-field"
/>
</div>
<div>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
Auto-connect Slots
</div>
<input
v-model.number="discoveryConfig.autoconnect_discovered_interfaces"
type="number"
min="0"
class="input-field"
/>
<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">
Network Identity Path
</div>
<input
v-model="discoveryConfig.network_identity"
type="text"
placeholder="~/.reticulum/storage/identities/..."
class="input-field"
/>
</div>
</div>
<input
v-model.number="discoveryConfig.required_discovery_value"
type="number"
min="0"
class="input-field"
/>
</div>
<div>
<div class="text-xs font-semibold text-gray-700 dark:text-gray-200">
Auto-connect Slots
<div class="flex justify-end">
<button
type="button"
class="primary-chip text-xs"
:disabled="savingDiscovery"
@click="saveDiscoveryConfig"
>
<MaterialDesignIcon
:icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
class="w-4 h-4"
:class="{ 'animate-spin-reverse': savingDiscovery }"
/>
<span class="ml-1">Save Discovery Settings</span>
</button>
</div>
<input
v-model.number="discoveryConfig.autoconnect_discovered_interfaces"
type="number"
min="0"
class="input-field"
/>
<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">
Network Identity Path
</div>
<input
v-model="discoveryConfig.network_identity"
type="text"
placeholder="~/.reticulum/storage/identities/..."
class="input-field"
/>
</div>
</div>
<div class="flex justify-end">
<button
type="button"
class="primary-chip text-xs"
:disabled="savingDiscovery"
@click="saveDiscoveryConfig"
>
<MaterialDesignIcon
:icon-name="savingDiscovery ? 'progress-clock' : 'content-save'"
class="w-4 h-4"
:class="{ 'animate-spin-reverse': savingDiscovery }"
/>
<span class="ml-1">Save Discovery Settings</span>
</button>
</div>
</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,23 +56,33 @@
/>
</div>
<div class="flex-1">
<div class="tool-card__title">{{ tool.title }}</div>
<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.extraAction" class="flex items-center gap-2">
<a
:href="tool.extraAction.href"
:target="tool.extraAction.target"
class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
@click.stop
>
<MaterialDesignIcon :icon-name="tool.extraAction.icon" class="size-5" />
</a>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
<div v-if="!tool.comingSoon">
<div v-if="tool.extraAction" class="flex items-center gap-2">
<a
:href="tool.extraAction.href"
:target="tool.extraAction.target"
class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
@click.stop
>
<MaterialDesignIcon :icon-name="tool.extraAction.icon" class="size-5" />
</a>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</div>
<MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
</div>
<MaterialDesignIcon v-else icon-name="chevron-right" class="tool-card__chevron" />
</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
});