This commit is contained in:
2026-01-01 15:05:29 -06:00
parent 65044a54ef
commit 716007802e
147 changed files with 40416 additions and 27 deletions

3
meshchatx/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""Reticulum MeshChatX - A mesh network communications app."""
__version__ = "2.50.0"

7025
meshchatx/meshchat.py Normal file
View File

File diff suppressed because it is too large Load Diff

29
meshchatx/src/__init__.py Normal file
View File

@@ -0,0 +1,29 @@
import sys
# NOTE: this class is required to be able to use print/log commands and have them flush to stdout and stderr immediately
# without wrapper stdout and stderr, when using `childProcess.stdout.on('data', ...)` in NodeJS script, we never get
# any events fired until the process exits. However, force flushing the streams does fire the callbacks in NodeJS.
# this class forces stream writes to be flushed immediately
class ImmediateFlushingStreamWrapper:
def __init__(self, stream):
self.stream = stream
# force write to flush immediately
def write(self, data):
self.stream.write(data)
self.stream.flush()
# force writelines to flush immediately
def writelines(self, lines):
self.stream.writelines(lines)
self.stream.flush()
def __getattr__(self, attr):
return getattr(self.stream, attr)
# wrap stdout and stderr with our custom wrapper
sys.stdout = ImmediateFlushingStreamWrapper(sys.stdout)
sys.stderr = ImmediateFlushingStreamWrapper(sys.stderr)

View File

@@ -0,0 +1 @@
"""Backend utilities shared by the Reticulum MeshChatX CLI."""

View File

@@ -0,0 +1,27 @@
# an announce handler that forwards announces to a provided callback for the provided aspect filter
# this handler exists so we can have access to the original aspect, as this is not provided in the announce itself
class AnnounceHandler:
def __init__(self, aspect_filter: str, received_announce_callback):
self.aspect_filter = aspect_filter
self.received_announce_callback = received_announce_callback
# we will just pass the received announce back to the provided callback
def received_announce(
self,
destination_hash,
announced_identity,
app_data,
announce_packet_hash,
):
try:
# handle received announce
self.received_announce_callback(
self.aspect_filter,
destination_hash,
announced_identity,
app_data,
announce_packet_hash,
)
except Exception as e:
# ignore failure to handle received announce
print(f"Failed to handle received announce: {e}")

View File

@@ -0,0 +1,59 @@
import base64
from .database import Database
class AnnounceManager:
def __init__(self, db: Database):
self.db = db
def upsert_announce(self, reticulum, identity, destination_hash, aspect, app_data, announce_packet_hash):
# get rssi, snr and signal quality if available
rssi = reticulum.get_packet_rssi(announce_packet_hash)
snr = reticulum.get_packet_snr(announce_packet_hash)
quality = reticulum.get_packet_q(announce_packet_hash)
# prepare data to insert or update
data = {
"destination_hash": destination_hash.hex() if isinstance(destination_hash, bytes) else destination_hash,
"aspect": aspect,
"identity_hash": identity.hash.hex(),
"identity_public_key": base64.b64encode(identity.get_public_key()).decode(
"utf-8",
),
"rssi": rssi,
"snr": snr,
"quality": quality,
}
# only set app data if provided
if app_data is not None:
data["app_data"] = base64.b64encode(app_data).decode("utf-8")
self.db.announces.upsert_announce(data)
def get_filtered_announces(self, aspect=None, identity_hash=None, destination_hash=None, query=None, blocked_identity_hashes=None):
sql = "SELECT * FROM announces WHERE 1=1"
params = []
if aspect:
sql += " AND aspect = ?"
params.append(aspect)
if identity_hash:
sql += " AND identity_hash = ?"
params.append(identity_hash)
if destination_hash:
sql += " AND destination_hash = ?"
params.append(destination_hash)
if query:
like_term = f"%{query}%"
sql += " AND (destination_hash LIKE ? OR identity_hash LIKE ?)"
params.extend([like_term, like_term])
if blocked_identity_hashes:
placeholders = ", ".join(["?"] * len(blocked_identity_hashes))
sql += f" AND identity_hash NOT IN ({placeholders})"
params.extend(blocked_identity_hashes)
sql += " ORDER BY updated_at DESC"
return self.db.provider.fetchall(sql, params)

View File

@@ -0,0 +1,44 @@
import hashlib
from .database import Database
class ArchiverManager:
def __init__(self, db: Database):
self.db = db
def archive_page(self, destination_hash, page_path, content, max_versions=5, max_storage_gb=1):
content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest()
# Check if already exists
existing = self.db.provider.fetchone(
"SELECT id FROM archived_pages WHERE destination_hash = ? AND page_path = ? AND hash = ?",
(destination_hash, page_path, content_hash),
)
if existing:
return
# Insert new version
self.db.misc.archive_page(destination_hash, page_path, content, content_hash)
# Enforce max versions per page
versions = self.db.misc.get_archived_page_versions(destination_hash, page_path)
if len(versions) > max_versions:
# Delete older versions
to_delete = versions[max_versions:]
for version in to_delete:
self.db.provider.execute("DELETE FROM archived_pages WHERE id = ?", (version["id"],))
# Enforce total storage limit (approximate)
total_size_row = self.db.provider.fetchone("SELECT SUM(LENGTH(content)) as total_size FROM archived_pages")
total_size = total_size_row["total_size"] or 0
max_bytes = max_storage_gb * 1024 * 1024 * 1024
while total_size > max_bytes:
oldest = self.db.provider.fetchone("SELECT id, LENGTH(content) as size FROM archived_pages ORDER BY created_at ASC LIMIT 1")
if oldest:
self.db.provider.execute("DELETE FROM archived_pages WHERE id = ?", (oldest["id"],))
total_size -= oldest["size"]
else:
break

View File

@@ -0,0 +1,23 @@
import asyncio
from collections.abc import Coroutine
class AsyncUtils:
# remember main loop
main_loop: asyncio.AbstractEventLoop | None = None
@staticmethod
def set_main_loop(loop: asyncio.AbstractEventLoop):
AsyncUtils.main_loop = loop
# this method allows running the provided async coroutine from within a sync function
# it will run the async function on the main event loop if possible, otherwise it logs a warning
@staticmethod
def run_async(coroutine: Coroutine):
# run provided coroutine on main event loop, ensuring thread safety
if AsyncUtils.main_loop and AsyncUtils.main_loop.is_running():
asyncio.run_coroutine_threadsafe(coroutine, AsyncUtils.main_loop)
return
# main event loop not running...
print("WARNING: Main event loop not available. Could not schedule task.")

View File

@@ -0,0 +1,8 @@
class ColourUtils:
@staticmethod
def hex_colour_to_byte_array(hex_colour):
# remove leading "#"
hex_colour = hex_colour.lstrip("#")
# convert the remaining hex string to bytes
return bytes.fromhex(hex_colour)

View File

@@ -0,0 +1,131 @@
class ConfigManager:
def __init__(self, db):
self.db = db
# all possible config items
self.database_version = self.IntConfig(self, "database_version", None)
self.display_name = self.StringConfig(self, "display_name", "Anonymous Peer")
self.auto_announce_enabled = self.BoolConfig(self, "auto_announce_enabled", False)
self.auto_announce_interval_seconds = self.IntConfig(self, "auto_announce_interval_seconds", 0)
self.last_announced_at = self.IntConfig(self, "last_announced_at", None)
self.theme = self.StringConfig(self, "theme", "light")
self.language = self.StringConfig(self, "language", "en")
self.auto_resend_failed_messages_when_announce_received = self.BoolConfig(
self, "auto_resend_failed_messages_when_announce_received", True,
)
self.allow_auto_resending_failed_messages_with_attachments = self.BoolConfig(
self, "allow_auto_resending_failed_messages_with_attachments", False,
)
self.auto_send_failed_messages_to_propagation_node = self.BoolConfig(
self, "auto_send_failed_messages_to_propagation_node", False,
)
self.show_suggested_community_interfaces = self.BoolConfig(
self, "show_suggested_community_interfaces", True,
)
self.lxmf_delivery_transfer_limit_in_bytes = self.IntConfig(
self, "lxmf_delivery_transfer_limit_in_bytes", 1000 * 1000 * 10,
) # 10MB
self.lxmf_preferred_propagation_node_destination_hash = self.StringConfig(
self, "lxmf_preferred_propagation_node_destination_hash", None,
)
self.lxmf_preferred_propagation_node_auto_sync_interval_seconds = self.IntConfig(
self, "lxmf_preferred_propagation_node_auto_sync_interval_seconds", 0,
)
self.lxmf_preferred_propagation_node_last_synced_at = self.IntConfig(
self, "lxmf_preferred_propagation_node_last_synced_at", None,
)
self.lxmf_local_propagation_node_enabled = self.BoolConfig(
self, "lxmf_local_propagation_node_enabled", False,
)
self.lxmf_user_icon_name = self.StringConfig(self, "lxmf_user_icon_name", None)
self.lxmf_user_icon_foreground_colour = self.StringConfig(
self, "lxmf_user_icon_foreground_colour", None,
)
self.lxmf_user_icon_background_colour = self.StringConfig(
self, "lxmf_user_icon_background_colour", None,
)
self.lxmf_inbound_stamp_cost = self.IntConfig(
self, "lxmf_inbound_stamp_cost", 8,
) # for direct delivery messages
self.lxmf_propagation_node_stamp_cost = self.IntConfig(
self, "lxmf_propagation_node_stamp_cost", 16,
) # for propagation node messages
self.page_archiver_enabled = self.BoolConfig(self, "page_archiver_enabled", True)
self.page_archiver_max_versions = self.IntConfig(self, "page_archiver_max_versions", 5)
self.archives_max_storage_gb = self.IntConfig(self, "archives_max_storage_gb", 1)
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(self, "crawler_retry_delay_seconds", 3600)
self.crawler_max_concurrent = self.IntConfig(self, "crawler_max_concurrent", 1)
self.auth_enabled = self.BoolConfig(self, "auth_enabled", False)
self.auth_password_hash = self.StringConfig(self, "auth_password_hash", None)
self.auth_session_secret = self.StringConfig(self, "auth_session_secret", None)
# map config
self.map_offline_enabled = self.BoolConfig(self, "map_offline_enabled", False)
self.map_offline_path = self.StringConfig(self, "map_offline_path", None)
self.map_mbtiles_dir = self.StringConfig(self, "map_mbtiles_dir", None)
self.map_tile_cache_enabled = self.BoolConfig(self, "map_tile_cache_enabled", True)
self.map_default_lat = self.StringConfig(self, "map_default_lat", "0.0")
self.map_default_lon = self.StringConfig(self, "map_default_lon", "0.0")
self.map_default_zoom = self.IntConfig(self, "map_default_zoom", 2)
self.map_tile_server_url = self.StringConfig(
self, "map_tile_server_url", "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
)
self.map_nominatim_api_url = self.StringConfig(
self, "map_nominatim_api_url", "https://nominatim.openstreetmap.org",
)
def get(self, key: str, default_value=None) -> str | None:
return self.db.config.get(key, default_value)
def set(self, key: str, value: str | None):
self.db.config.set(key, value)
class StringConfig:
def __init__(self, manager, key: str, default_value: str | None = None):
self.manager = manager
self.key = key
self.default_value = default_value
def get(self, default_value: str = None) -> str | None:
_default_value = default_value or self.default_value
return self.manager.get(self.key, default_value=_default_value)
def set(self, value: str | None):
self.manager.set(self.key, value)
class BoolConfig:
def __init__(self, manager, key: str, default_value: bool = False):
self.manager = manager
self.key = key
self.default_value = default_value
def get(self) -> bool:
config_value = self.manager.get(self.key, default_value=None)
if config_value is None:
return self.default_value
return config_value == "true"
def set(self, value: bool):
self.manager.set(self.key, "true" if value else "false")
class IntConfig:
def __init__(self, manager, key: str, default_value: int | None = 0):
self.manager = manager
self.key = key
self.default_value = default_value
def get(self) -> int | None:
config_value = self.manager.get(self.key, default_value=None)
if config_value is None:
return self.default_value
try:
return int(config_value)
except (ValueError, TypeError):
return self.default_value
def set(self, value: int):
self.manager.set(self.key, str(value))

View File

@@ -0,0 +1,35 @@
from .announces import AnnounceDAO
from .config import ConfigDAO
from .legacy_migrator import LegacyMigrator
from .messages import MessageDAO
from .misc import MiscDAO
from .provider import DatabaseProvider
from .schema import DatabaseSchema
from .telephone import TelephoneDAO
class Database:
def __init__(self, db_path):
self.provider = DatabaseProvider.get_instance(db_path)
self.schema = DatabaseSchema(self.provider)
self.config = ConfigDAO(self.provider)
self.messages = MessageDAO(self.provider)
self.announces = AnnounceDAO(self.provider)
self.misc = MiscDAO(self.provider)
self.telephone = TelephoneDAO(self.provider)
def initialize(self):
self.schema.initialize()
def migrate_from_legacy(self, reticulum_config_dir, identity_hash_hex):
migrator = LegacyMigrator(self.provider, reticulum_config_dir, identity_hash_hex)
if migrator.should_migrate():
return migrator.migrate()
return False
def execute_sql(self, query, params=None):
return self.provider.execute(query, params)
def close(self):
self.provider.close()

View File

@@ -0,0 +1,90 @@
from datetime import UTC, datetime
from .provider import DatabaseProvider
class AnnounceDAO:
def __init__(self, provider: DatabaseProvider):
self.provider = provider
def upsert_announce(self, data):
# Ensure data is a dict if it's a sqlite3.Row
if not isinstance(data, dict):
data = dict(data)
fields = [
"destination_hash", "aspect", "identity_hash", "identity_public_key",
"app_data", "rssi", "snr", "quality",
]
# These are safe as they are from a hardcoded list
columns = ", ".join(fields)
placeholders = ", ".join(["?"] * len(fields))
update_set = ", ".join([f"{f} = EXCLUDED.{f}" for f in fields if f != "destination_hash"])
query = f"INSERT INTO announces ({columns}, updated_at) VALUES ({placeholders}, ?) " \
f"ON CONFLICT(destination_hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at" # noqa: S608
params = [data.get(f) for f in fields]
params.append(datetime.now(UTC))
self.provider.execute(query, params)
def get_announces(self, aspect=None):
if aspect:
return self.provider.fetchall("SELECT * FROM announces WHERE aspect = ?", (aspect,))
return self.provider.fetchall("SELECT * FROM announces")
def get_announce_by_hash(self, destination_hash):
return self.provider.fetchone("SELECT * FROM announces WHERE destination_hash = ?", (destination_hash,))
def get_filtered_announces(self, aspect=None, search_term=None, limit=None, offset=0):
query = "SELECT * FROM announces WHERE 1=1"
params = []
if aspect:
query += " AND aspect = ?"
params.append(aspect)
if search_term:
query += " AND (destination_hash LIKE ? OR identity_hash LIKE ?)"
like_term = f"%{search_term}%"
params.extend([like_term, like_term])
query += " ORDER BY updated_at DESC"
if limit:
query += " LIMIT ? OFFSET ?"
params.extend([limit, offset])
return self.provider.fetchall(query, params)
# Custom Display Names
def upsert_custom_display_name(self, destination_hash, display_name):
now = datetime.now(UTC)
self.provider.execute("""
INSERT INTO custom_destination_display_names (destination_hash, display_name, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, updated_at = EXCLUDED.updated_at
""", (destination_hash, display_name, now))
def get_custom_display_name(self, destination_hash):
row = self.provider.fetchone("SELECT display_name FROM custom_destination_display_names WHERE destination_hash = ?", (destination_hash,))
return row["display_name"] if row else None
def delete_custom_display_name(self, destination_hash):
self.provider.execute("DELETE FROM custom_destination_display_names WHERE destination_hash = ?", (destination_hash,))
# Favourites
def upsert_favourite(self, destination_hash, display_name, aspect):
now = datetime.now(UTC)
self.provider.execute("""
INSERT INTO favourite_destinations (destination_hash, display_name, aspect, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET display_name = EXCLUDED.display_name, aspect = EXCLUDED.aspect, updated_at = EXCLUDED.updated_at
""", (destination_hash, display_name, aspect, now))
def get_favourites(self, aspect=None):
if aspect:
return self.provider.fetchall("SELECT * FROM favourite_destinations WHERE aspect = ?", (aspect,))
return self.provider.fetchall("SELECT * FROM favourite_destinations")
def delete_favourite(self, destination_hash):
self.provider.execute("DELETE FROM favourite_destinations WHERE destination_hash = ?", (destination_hash,))

View File

@@ -0,0 +1,27 @@
from datetime import UTC, datetime
from .provider import DatabaseProvider
class ConfigDAO:
def __init__(self, provider: DatabaseProvider):
self.provider = provider
def get(self, key, default=None):
row = self.provider.fetchone("SELECT value FROM config WHERE key = ?", (key,))
if row:
return row["value"]
return default
def set(self, key, value):
if value is None:
self.provider.execute("DELETE FROM config WHERE key = ?", (key,))
else:
self.provider.execute(
"INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, ?)",
(key, str(value), datetime.now(UTC)),
)
def delete(self, key):
self.provider.execute("DELETE FROM config WHERE key = ?", (key,))

View File

@@ -0,0 +1,126 @@
import os
class LegacyMigrator:
def __init__(self, provider, reticulum_config_dir, identity_hash_hex):
self.provider = provider
self.reticulum_config_dir = reticulum_config_dir
self.identity_hash_hex = identity_hash_hex
def get_legacy_db_path(self):
"""Detect the path to the legacy database based on the Reticulum config directory.
"""
possible_dirs = []
if self.reticulum_config_dir:
possible_dirs.append(self.reticulum_config_dir)
# Add common default locations
home = os.path.expanduser("~")
possible_dirs.append(os.path.join(home, ".reticulum-meshchat"))
possible_dirs.append(os.path.join(home, ".reticulum"))
# Check each directory
for config_dir in possible_dirs:
legacy_path = os.path.join(config_dir, "identities", self.identity_hash_hex, "database.db")
if os.path.exists(legacy_path):
# Ensure it's not the same as our current DB path
# (though this is unlikely given the different base directories)
try:
current_db_path = os.path.abspath(self.provider.db_path)
if os.path.abspath(legacy_path) == current_db_path:
continue
except (AttributeError, OSError):
# If we can't get the absolute path, just skip this check
pass
return legacy_path
return None
def should_migrate(self):
"""Check if migration should be performed.
Only migrates if the current database is empty and a legacy database exists.
"""
legacy_path = self.get_legacy_db_path()
if not legacy_path:
return False
# Check if current DB has any messages
try:
res = self.provider.fetchone("SELECT COUNT(*) as count FROM lxmf_messages")
if res and res["count"] > 0:
# Already have data, don't auto-migrate
return False
except Exception: # noqa: S110
# Table doesn't exist yet, which is fine
# We use a broad Exception here as the database might not even be initialized
pass
return True
def migrate(self):
"""Perform the migration from the legacy database.
"""
legacy_path = self.get_legacy_db_path()
if not legacy_path:
return False
print(f"Detecting legacy database at {legacy_path}...")
try:
# Attach the legacy database
# We use a randomized alias to avoid collisions
alias = f"legacy_{os.urandom(4).hex()}"
self.provider.execute(f"ATTACH DATABASE '{legacy_path}' AS {alias}")
# Tables that existed in the legacy Peewee version
tables_to_migrate = [
"announces",
"blocked_destinations",
"config",
"custom_destination_display_names",
"favourite_destinations",
"lxmf_conversation_read_state",
"lxmf_messages",
"lxmf_user_icons",
"spam_keywords",
]
print("Auto-migrating data from legacy database...")
for table in tables_to_migrate:
# Basic validation to ensure table name is from our whitelist
if table not in tables_to_migrate:
continue
try:
# Check if table exists in legacy DB
# We use a f-string here for the alias and table name, which are controlled by us
check_query = f"SELECT name FROM {alias}.sqlite_master WHERE type='table' AND name=?" # noqa: S608
res = self.provider.fetchone(check_query, (table,))
if res:
# Get columns from both databases to ensure compatibility
# These PRAGMA calls are safe as they use controlled table/alias names
legacy_columns = [row["name"] for row in self.provider.fetchall(f"PRAGMA {alias}.table_info({table})")]
current_columns = [row["name"] for row in self.provider.fetchall(f"PRAGMA table_info({table})")]
# Find common columns
common_columns = [col for col in legacy_columns if col in current_columns]
if common_columns:
cols_str = ", ".join(common_columns)
# We use INSERT OR IGNORE to avoid duplicates
# The table and columns are controlled by us
migrate_query = f"INSERT OR IGNORE INTO {table} ({cols_str}) SELECT {cols_str} FROM {alias}.{table}" # noqa: S608
self.provider.execute(migrate_query)
print(f" - Migrated table: {table} ({len(common_columns)} columns)")
else:
print(f" - Skipping table {table}: No common columns found")
except Exception as e:
print(f" - Failed to migrate table {table}: {e}")
self.provider.execute(f"DETACH DATABASE {alias}")
print("Legacy migration completed successfully.")
return True
except Exception as e:
print(f"Migration from legacy failed: {e}")
return False

View File

@@ -0,0 +1,146 @@
import json
from datetime import UTC, datetime
from .provider import DatabaseProvider
class MessageDAO:
def __init__(self, provider: DatabaseProvider):
self.provider = provider
def upsert_lxmf_message(self, data):
# Ensure data is a dict if it's a sqlite3.Row
if not isinstance(data, dict):
data = dict(data)
# Ensure all required fields are present and handle defaults
fields = [
"hash", "source_hash", "destination_hash", "state", "progress",
"is_incoming", "method", "delivery_attempts", "next_delivery_attempt_at",
"title", "content", "fields", "timestamp", "rssi", "snr", "quality", "is_spam",
]
columns = ", ".join(fields)
placeholders = ", ".join(["?"] * len(fields))
update_set = ", ".join([f"{f} = EXCLUDED.{f}" for f in fields if f != "hash"])
query = f"INSERT INTO lxmf_messages ({columns}, updated_at) VALUES ({placeholders}, ?) " \
f"ON CONFLICT(hash) DO UPDATE SET {update_set}, updated_at = EXCLUDED.updated_at" # noqa: S608
params = []
for f in fields:
val = data.get(f)
if f == "fields" and isinstance(val, dict):
val = json.dumps(val)
params.append(val)
params.append(datetime.now(UTC).isoformat())
self.provider.execute(query, params)
def get_lxmf_message_by_hash(self, message_hash):
return self.provider.fetchone("SELECT * FROM lxmf_messages WHERE hash = ?", (message_hash,))
def delete_lxmf_message_by_hash(self, message_hash):
self.provider.execute("DELETE FROM lxmf_messages WHERE hash = ?", (message_hash,))
def get_conversation_messages(self, destination_hash, limit=100, offset=0):
return self.provider.fetchall(
"SELECT * FROM lxmf_messages WHERE destination_hash = ? OR source_hash = ? ORDER BY timestamp DESC LIMIT ? OFFSET ?",
(destination_hash, destination_hash, limit, offset),
)
def get_conversations(self):
# This is a bit complex in raw SQL, we need the latest message for each destination
query = """
SELECT m1.* FROM lxmf_messages m1
JOIN (
SELECT
CASE WHEN is_incoming = 1 THEN source_hash ELSE destination_hash END as peer_hash,
MAX(timestamp) as max_ts
FROM lxmf_messages
GROUP BY peer_hash
) m2 ON (CASE WHEN m1.is_incoming = 1 THEN m1.source_hash ELSE m1.destination_hash END = m2.peer_hash
AND m1.timestamp = m2.max_ts)
ORDER BY m1.timestamp DESC
"""
return self.provider.fetchall(query)
def mark_conversation_as_read(self, destination_hash):
now = datetime.now(UTC).isoformat()
self.provider.execute(
"INSERT OR REPLACE INTO lxmf_conversation_read_state (destination_hash, last_read_at, updated_at) VALUES (?, ?, ?)",
(destination_hash, now, now),
)
def is_conversation_unread(self, destination_hash):
row = self.provider.fetchone("""
SELECT m.timestamp, r.last_read_at
FROM lxmf_messages m
LEFT JOIN lxmf_conversation_read_state r ON r.destination_hash = ?
WHERE (m.destination_hash = ? OR m.source_hash = ?)
ORDER BY m.timestamp DESC LIMIT 1
""", (destination_hash, destination_hash, destination_hash))
if not row:
return False
if not row["last_read_at"]:
return True
last_read_at = datetime.fromisoformat(row["last_read_at"])
if last_read_at.tzinfo is None:
last_read_at = last_read_at.replace(tzinfo=UTC)
return row["timestamp"] > last_read_at.timestamp()
def mark_stuck_messages_as_failed(self):
self.provider.execute("""
UPDATE lxmf_messages
SET state = 'failed', updated_at = ?
WHERE state = 'outbound'
OR (state = 'sent' AND method = 'opportunistic')
OR state = 'sending'
""", (datetime.now(UTC).isoformat(),))
def get_failed_messages_for_destination(self, destination_hash):
return self.provider.fetchall(
"SELECT * FROM lxmf_messages WHERE state = 'failed' AND destination_hash = ? ORDER BY id ASC",
(destination_hash,),
)
def get_failed_messages_count(self, destination_hash):
row = self.provider.fetchone(
"SELECT COUNT(*) as count FROM lxmf_messages WHERE state = 'failed' AND destination_hash = ?",
(destination_hash,),
)
return row["count"] if row else 0
# Forwarding Mappings
def get_forwarding_mapping(self, alias_hash=None, original_sender_hash=None, final_recipient_hash=None):
if alias_hash:
return self.provider.fetchone("SELECT * FROM lxmf_forwarding_mappings WHERE alias_hash = ?", (alias_hash,))
if original_sender_hash and final_recipient_hash:
return self.provider.fetchone(
"SELECT * FROM lxmf_forwarding_mappings WHERE original_sender_hash = ? AND final_recipient_hash = ?",
(original_sender_hash, final_recipient_hash),
)
return None
def create_forwarding_mapping(self, data):
# Ensure data is a dict if it's a sqlite3.Row
if not isinstance(data, dict):
data = dict(data)
fields = [
"alias_identity_private_key", "alias_hash", "original_sender_hash",
"final_recipient_hash", "original_destination_hash",
]
columns = ", ".join(fields)
placeholders = ", ".join(["?"] * len(fields))
query = f"INSERT INTO lxmf_forwarding_mappings ({columns}, created_at) VALUES ({placeholders}, ?)" # noqa: S608
params = [data.get(f) for f in fields]
params.append(datetime.now(UTC).isoformat())
self.provider.execute(query, params)
def get_all_forwarding_mappings(self):
return self.provider.fetchall("SELECT * FROM lxmf_forwarding_mappings")

View File

@@ -0,0 +1,154 @@
from datetime import UTC, datetime
from .provider import DatabaseProvider
class MiscDAO:
def __init__(self, provider: DatabaseProvider):
self.provider = provider
# Blocked Destinations
def add_blocked_destination(self, destination_hash):
self.provider.execute(
"INSERT OR IGNORE INTO blocked_destinations (destination_hash, updated_at) VALUES (?, ?)",
(destination_hash, datetime.now(UTC)),
)
def is_destination_blocked(self, destination_hash):
return self.provider.fetchone("SELECT 1 FROM blocked_destinations WHERE destination_hash = ?", (destination_hash,)) is not None
def get_blocked_destinations(self):
return self.provider.fetchall("SELECT * FROM blocked_destinations")
def delete_blocked_destination(self, destination_hash):
self.provider.execute("DELETE FROM blocked_destinations WHERE destination_hash = ?", (destination_hash,))
# Spam Keywords
def add_spam_keyword(self, keyword):
self.provider.execute(
"INSERT OR IGNORE INTO spam_keywords (keyword, updated_at) VALUES (?, ?)",
(keyword, datetime.now(UTC)),
)
def get_spam_keywords(self):
return self.provider.fetchall("SELECT * FROM spam_keywords")
def delete_spam_keyword(self, keyword_id):
self.provider.execute("DELETE FROM spam_keywords WHERE id = ?", (keyword_id,))
def check_spam_keywords(self, title, content):
keywords = self.get_spam_keywords()
search_text = (title + " " + content).lower()
for kw in keywords:
if kw["keyword"].lower() in search_text:
return True
return False
# User Icons
def update_lxmf_user_icon(self, destination_hash, icon_name, foreground_colour, background_colour):
now = datetime.now(UTC)
self.provider.execute("""
INSERT INTO lxmf_user_icons (destination_hash, icon_name, foreground_colour, background_colour, updated_at)
VALUES (?, ?, ?, ?, ?)
ON CONFLICT(destination_hash) DO UPDATE SET
icon_name = EXCLUDED.icon_name,
foreground_colour = EXCLUDED.foreground_colour,
background_colour = EXCLUDED.background_colour,
updated_at = EXCLUDED.updated_at
""", (destination_hash, icon_name, foreground_colour, background_colour, now))
def get_user_icon(self, destination_hash):
return self.provider.fetchone("SELECT * FROM lxmf_user_icons WHERE destination_hash = ?", (destination_hash,))
# Forwarding Rules
def get_forwarding_rules(self, identity_hash=None, active_only=False):
query = "SELECT * FROM lxmf_forwarding_rules WHERE 1=1"
params = []
if identity_hash:
query += " AND (identity_hash = ? OR identity_hash IS NULL)"
params.append(identity_hash)
if active_only:
query += " AND is_active = 1"
return self.provider.fetchall(query, params)
def create_forwarding_rule(self, identity_hash, forward_to_hash, source_filter_hash, is_active=True):
now = datetime.now(UTC)
self.provider.execute(
"INSERT INTO lxmf_forwarding_rules (identity_hash, forward_to_hash, source_filter_hash, is_active, updated_at) VALUES (?, ?, ?, ?, ?)",
(identity_hash, forward_to_hash, source_filter_hash, 1 if is_active else 0, now),
)
def delete_forwarding_rule(self, rule_id):
self.provider.execute("DELETE FROM lxmf_forwarding_rules WHERE id = ?", (rule_id,))
def toggle_forwarding_rule(self, rule_id):
self.provider.execute("UPDATE lxmf_forwarding_rules SET is_active = NOT is_active WHERE id = ?", (rule_id,))
# Archived Pages
def archive_page(self, destination_hash, page_path, content, page_hash):
self.provider.execute(
"INSERT INTO archived_pages (destination_hash, page_path, content, hash) VALUES (?, ?, ?, ?)",
(destination_hash, page_path, content, page_hash),
)
def get_archived_page_versions(self, destination_hash, page_path):
return self.provider.fetchall(
"SELECT * FROM archived_pages WHERE destination_hash = ? AND page_path = ? ORDER BY created_at DESC",
(destination_hash, page_path),
)
def get_archived_pages_paginated(self, destination_hash=None, query=None):
sql = "SELECT * FROM archived_pages WHERE 1=1"
params = []
if destination_hash:
sql += " AND destination_hash = ?"
params.append(destination_hash)
if query:
like_term = f"%{query}%"
sql += " AND (destination_hash LIKE ? OR page_path LIKE ? OR content LIKE ?)"
params.extend([like_term, like_term, like_term])
sql += " ORDER BY created_at DESC"
return self.provider.fetchall(sql, params)
def delete_archived_pages(self, destination_hash=None, page_path=None):
if destination_hash and page_path:
self.provider.execute("DELETE FROM archived_pages WHERE destination_hash = ? AND page_path = ?", (destination_hash, page_path))
else:
self.provider.execute("DELETE FROM archived_pages")
# Crawl Tasks
def upsert_crawl_task(self, destination_hash, page_path, status="pending", retry_count=0):
self.provider.execute("""
INSERT INTO crawl_tasks (destination_hash, page_path, status, retry_count)
VALUES (?, ?, ?, ?)
ON CONFLICT(destination_hash, page_path) DO UPDATE SET
status = EXCLUDED.status,
retry_count = EXCLUDED.retry_count
""", (destination_hash, page_path, status, retry_count))
def get_pending_crawl_tasks(self):
return self.provider.fetchall("SELECT * FROM crawl_tasks WHERE status = 'pending'")
def update_crawl_task(self, task_id, **kwargs):
allowed_keys = {"destination_hash", "page_path", "status", "retry_count", "updated_at"}
filtered_kwargs = {k: v for k, v in kwargs.items() if k in allowed_keys}
if not filtered_kwargs:
return
set_clause = ", ".join([f"{k} = ?" for k in filtered_kwargs])
params = list(filtered_kwargs.values())
params.append(task_id)
query = f"UPDATE crawl_tasks SET {set_clause} WHERE id = ?" # noqa: S608
self.provider.execute(query, params)
def get_pending_or_failed_crawl_tasks(self, max_retries, max_concurrent):
return self.provider.fetchall(
"SELECT * FROM crawl_tasks WHERE status IN ('pending', 'failed') AND retry_count < ? LIMIT ?",
(max_retries, max_concurrent),
)
def get_archived_page_by_id(self, archive_id):
return self.provider.fetchone("SELECT * FROM archived_pages WHERE id = ?", (archive_id,))

View File

@@ -0,0 +1,65 @@
import sqlite3
import threading
class DatabaseProvider:
_instance = None
_lock = threading.Lock()
def __init__(self, db_path=None):
self.db_path = db_path
self._local = threading.local()
@classmethod
def get_instance(cls, db_path=None):
with cls._lock:
if cls._instance is None:
if db_path is None:
msg = "Database path must be provided for the first initialization"
raise ValueError(msg)
cls._instance = cls(db_path)
return cls._instance
@property
def connection(self):
if not hasattr(self._local, "connection"):
self._local.connection = sqlite3.connect(self.db_path, check_same_thread=False)
self._local.connection.row_factory = sqlite3.Row
# Enable WAL mode for better concurrency
self._local.connection.execute("PRAGMA journal_mode=WAL")
return self._local.connection
def execute(self, query, params=None):
cursor = self.connection.cursor()
if params:
cursor.execute(query, params)
else:
cursor.execute(query)
self.connection.commit()
return cursor
def fetchone(self, query, params=None):
cursor = self.execute(query, params)
return cursor.fetchone()
def fetchall(self, query, params=None):
cursor = self.execute(query, params)
return cursor.fetchall()
def close(self):
if hasattr(self._local, "connection"):
self._local.connection.close()
del self._local.connection
def vacuum(self):
self.execute("VACUUM")
def integrity_check(self):
return self.fetchall("PRAGMA integrity_check")
def quick_check(self):
return self.fetchall("PRAGMA quick_check")
def checkpoint(self):
return self.fetchall("PRAGMA wal_checkpoint(TRUNCATE)")

View File

@@ -0,0 +1,317 @@
from .provider import DatabaseProvider
class DatabaseSchema:
LATEST_VERSION = 12
def __init__(self, provider: DatabaseProvider):
self.provider = provider
def initialize(self):
# Create core tables if they don't exist
self._create_initial_tables()
# Run migrations
current_version = self._get_current_version()
self.migrate(current_version)
def _get_current_version(self):
row = self.provider.fetchone("SELECT value FROM config WHERE key = ?", ("database_version",))
if row:
return int(row["value"])
return 0
def _create_initial_tables(self):
# We create the config table first so we can track version
self.provider.execute("""
CREATE TABLE IF NOT EXISTS config (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT UNIQUE,
value TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
# Other essential tables that were present from version 1
# Peewee automatically creates tables if they don't exist.
# Here we define the full schema for all tables as they should be now.
tables = {
"announces": """
CREATE TABLE IF NOT EXISTS announces (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination_hash TEXT UNIQUE,
aspect TEXT,
identity_hash TEXT,
identity_public_key TEXT,
app_data TEXT,
rssi INTEGER,
snr REAL,
quality REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"custom_destination_display_names": """
CREATE TABLE IF NOT EXISTS custom_destination_display_names (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination_hash TEXT UNIQUE,
display_name TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"favourite_destinations": """
CREATE TABLE IF NOT EXISTS favourite_destinations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination_hash TEXT UNIQUE,
display_name TEXT,
aspect TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"lxmf_messages": """
CREATE TABLE IF NOT EXISTS lxmf_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
hash TEXT UNIQUE,
source_hash TEXT,
destination_hash TEXT,
state TEXT,
progress REAL,
is_incoming INTEGER,
method TEXT,
delivery_attempts INTEGER DEFAULT 0,
next_delivery_attempt_at REAL,
title TEXT,
content TEXT,
fields TEXT,
timestamp REAL,
rssi INTEGER,
snr REAL,
quality REAL,
is_spam INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"lxmf_conversation_read_state": """
CREATE TABLE IF NOT EXISTS lxmf_conversation_read_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination_hash TEXT UNIQUE,
last_read_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"lxmf_user_icons": """
CREATE TABLE IF NOT EXISTS lxmf_user_icons (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination_hash TEXT UNIQUE,
icon_name TEXT,
foreground_colour TEXT,
background_colour TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"blocked_destinations": """
CREATE TABLE IF NOT EXISTS blocked_destinations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination_hash TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"spam_keywords": """
CREATE TABLE IF NOT EXISTS spam_keywords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
keyword TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"archived_pages": """
CREATE TABLE IF NOT EXISTS archived_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination_hash TEXT,
page_path TEXT,
content TEXT,
hash TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"crawl_tasks": """
CREATE TABLE IF NOT EXISTS crawl_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination_hash TEXT,
page_path TEXT,
retry_count INTEGER DEFAULT 0,
last_retry_at DATETIME,
next_retry_at DATETIME,
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
UNIQUE(destination_hash, page_path)
)
""",
"lxmf_forwarding_rules": """
CREATE TABLE IF NOT EXISTS lxmf_forwarding_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_hash TEXT,
forward_to_hash TEXT,
source_filter_hash TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"lxmf_forwarding_mappings": """
CREATE TABLE IF NOT EXISTS lxmf_forwarding_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alias_identity_private_key TEXT,
alias_hash TEXT UNIQUE,
original_sender_hash TEXT,
final_recipient_hash TEXT,
original_destination_hash TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
"call_history": """
CREATE TABLE IF NOT EXISTS call_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remote_identity_hash TEXT,
remote_identity_name TEXT,
is_incoming INTEGER,
status TEXT,
duration_seconds INTEGER,
timestamp REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""",
}
for table_name, create_sql in tables.items():
self.provider.execute(create_sql)
# Create indexes that were present
if table_name == "announces":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_announces_aspect ON announces(aspect)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_announces_identity_hash ON announces(identity_hash)")
elif table_name == "lxmf_messages":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_messages_source_hash ON lxmf_messages(source_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_messages_destination_hash ON lxmf_messages(destination_hash)")
elif table_name == "blocked_destinations":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_blocked_destinations_hash ON blocked_destinations(destination_hash)")
elif table_name == "spam_keywords":
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_spam_keywords_keyword ON spam_keywords(keyword)")
def migrate(self, current_version):
if current_version < 7:
self.provider.execute("""
CREATE TABLE IF NOT EXISTS archived_pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination_hash TEXT,
page_path TEXT,
content TEXT,
hash TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_destination_hash ON archived_pages(destination_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_page_path ON archived_pages(page_path)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_archived_pages_hash ON archived_pages(hash)")
if current_version < 8:
self.provider.execute("""
CREATE TABLE IF NOT EXISTS crawl_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
destination_hash TEXT,
page_path TEXT,
retry_count INTEGER DEFAULT 0,
last_retry_at DATETIME,
next_retry_at DATETIME,
status TEXT DEFAULT 'pending',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_crawl_tasks_destination_hash ON crawl_tasks(destination_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_crawl_tasks_page_path ON crawl_tasks(page_path)")
if current_version < 9:
self.provider.execute("""
CREATE TABLE IF NOT EXISTS lxmf_forwarding_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
identity_hash TEXT,
forward_to_hash TEXT,
source_filter_hash TEXT,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_rules_identity_hash ON lxmf_forwarding_rules(identity_hash)")
self.provider.execute("""
CREATE TABLE IF NOT EXISTS lxmf_forwarding_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alias_identity_private_key TEXT,
alias_hash TEXT UNIQUE,
original_sender_hash TEXT,
final_recipient_hash TEXT,
original_destination_hash TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_alias_hash ON lxmf_forwarding_mappings(alias_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_sender_hash ON lxmf_forwarding_mappings(original_sender_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_lxmf_forwarding_mappings_recipient_hash ON lxmf_forwarding_mappings(final_recipient_hash)")
if current_version < 10:
# Ensure unique constraints exist for ON CONFLICT clauses
# SQLite doesn't support adding UNIQUE constraints via ALTER TABLE,
# but a UNIQUE index works for ON CONFLICT.
# Clean up duplicates before adding unique indexes
self.provider.execute("DELETE FROM announces WHERE id NOT IN (SELECT MAX(id) FROM announces GROUP BY destination_hash)")
self.provider.execute("DELETE FROM crawl_tasks WHERE id NOT IN (SELECT MAX(id) FROM crawl_tasks GROUP BY destination_hash, page_path)")
self.provider.execute("DELETE FROM custom_destination_display_names WHERE id NOT IN (SELECT MAX(id) FROM custom_destination_display_names GROUP BY destination_hash)")
self.provider.execute("DELETE FROM favourite_destinations WHERE id NOT IN (SELECT MAX(id) FROM favourite_destinations GROUP BY destination_hash)")
self.provider.execute("DELETE FROM lxmf_user_icons WHERE id NOT IN (SELECT MAX(id) FROM lxmf_user_icons GROUP BY destination_hash)")
self.provider.execute("DELETE FROM lxmf_conversation_read_state WHERE id NOT IN (SELECT MAX(id) FROM lxmf_conversation_read_state GROUP BY destination_hash)")
self.provider.execute("DELETE FROM lxmf_messages WHERE id NOT IN (SELECT MAX(id) FROM lxmf_messages GROUP BY hash)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_announces_destination_hash_unique ON announces(destination_hash)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_crawl_tasks_destination_path_unique ON crawl_tasks(destination_hash, page_path)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_custom_display_names_dest_hash_unique ON custom_destination_display_names(destination_hash)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_favourite_destinations_dest_hash_unique ON favourite_destinations(destination_hash)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_messages_hash_unique ON lxmf_messages(hash)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_user_icons_dest_hash_unique ON lxmf_user_icons(destination_hash)")
self.provider.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_lxmf_conversation_read_state_dest_hash_unique ON lxmf_conversation_read_state(destination_hash)")
if current_version < 11:
# Add is_spam column to lxmf_messages if it doesn't exist
try:
self.provider.execute("ALTER TABLE lxmf_messages ADD COLUMN is_spam INTEGER DEFAULT 0")
except Exception:
# Column might already exist if table was created with newest schema
pass
if current_version < 12:
self.provider.execute("""
CREATE TABLE IF NOT EXISTS call_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
remote_identity_hash TEXT,
remote_identity_name TEXT,
is_incoming INTEGER,
status TEXT,
duration_seconds INTEGER,
timestamp REAL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
""")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_call_history_remote_hash ON call_history(remote_identity_hash)")
self.provider.execute("CREATE INDEX IF NOT EXISTS idx_call_history_timestamp ON call_history(timestamp)")
# Update version in config
self.provider.execute("INSERT OR REPLACE INTO config (key, value, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP)", ("database_version", str(self.LATEST_VERSION)))

View File

@@ -0,0 +1,44 @@
from .provider import DatabaseProvider
class TelephoneDAO:
def __init__(self, provider: DatabaseProvider):
self.provider = provider
def add_call_history(
self,
remote_identity_hash,
remote_identity_name,
is_incoming,
status,
duration_seconds,
timestamp,
):
self.provider.execute(
"""
INSERT INTO call_history (
remote_identity_hash,
remote_identity_name,
is_incoming,
status,
duration_seconds,
timestamp
) VALUES (?, ?, ?, ?, ?, ?)
""",
(
remote_identity_hash,
remote_identity_name,
1 if is_incoming else 0,
status,
duration_seconds,
timestamp,
),
)
def get_call_history(self, limit=10):
return self.provider.fetchall(
"SELECT * FROM call_history ORDER BY timestamp DESC LIMIT ?",
(limit,),
)

View File

@@ -0,0 +1,48 @@
import base64
import RNS
from .database import Database
class ForwardingManager:
def __init__(self, db: Database, message_router):
self.db = db
self.message_router = message_router
self.forwarding_destinations = {}
def load_aliases(self):
mappings = self.db.messages.get_all_forwarding_mappings()
for mapping in mappings:
try:
private_key_bytes = base64.b64decode(mapping["alias_identity_private_key"])
alias_identity = RNS.Identity.from_bytes(private_key_bytes)
alias_destination = self.message_router.register_delivery_identity(identity=alias_identity)
self.forwarding_destinations[mapping["alias_hash"]] = alias_destination
except Exception as e:
print(f"Failed to load forwarding alias {mapping['alias_hash']}: {e}")
def get_or_create_mapping(self, source_hash, final_recipient_hash, original_destination_hash):
mapping = self.db.messages.get_forwarding_mapping(
original_sender_hash=source_hash,
final_recipient_hash=final_recipient_hash,
)
if not mapping:
alias_identity = RNS.Identity()
alias_hash = alias_identity.hash.hex()
alias_destination = self.message_router.register_delivery_identity(alias_identity)
self.forwarding_destinations[alias_hash] = alias_destination
data = {
"alias_identity_private_key": base64.b64encode(alias_identity.get_private_key()).decode(),
"alias_hash": alias_hash,
"original_sender_hash": source_hash,
"final_recipient_hash": final_recipient_hash,
"original_destination_hash": original_destination_hash,
}
self.db.messages.create_forwarding_mapping(data)
return data
return mapping

View File

@@ -0,0 +1,91 @@
import RNS.vendor.configobj
class InterfaceConfigParser:
@staticmethod
def parse(text):
# get lines from provided text
lines = text.splitlines()
stripped_lines = [line.strip() for line in lines]
# ensure [interfaces] section exists
if "[interfaces]" not in stripped_lines:
lines.insert(0, "[interfaces]")
stripped_lines.insert(0, "[interfaces]")
try:
# parse lines as rns config object
config = RNS.vendor.configobj.ConfigObj(lines)
except Exception as e:
print(f"Failed to parse interface config with ConfigObj: {e}")
return InterfaceConfigParser._parse_best_effort(lines)
# get interfaces from config
config_interfaces = config.get("interfaces", {})
if config_interfaces is None:
return []
# process interfaces
interfaces = []
for interface_name in config_interfaces:
# ensure interface has a name
interface_config = config_interfaces[interface_name]
interface_config["name"] = interface_name
interfaces.append(interface_config)
return interfaces
@staticmethod
def _parse_best_effort(lines):
interfaces = []
current_interface_name = None
current_interface = {}
current_sub_name = None
current_sub = None
def commit_sub():
nonlocal current_sub_name, current_sub
if current_sub_name and current_sub is not None:
current_interface[current_sub_name] = current_sub
current_sub_name = None
current_sub = None
def commit_interface():
nonlocal current_interface_name, current_interface
if current_interface_name:
# shallow copy to avoid future mutation
interfaces.append(dict(current_interface))
current_interface_name = None
current_interface = {}
for raw_line in lines:
line = raw_line.strip()
if line == "" or line.startswith("#"):
continue
if line.lower() == "[interfaces]":
continue
if line.startswith("[[[") and line.endswith("]]]"):
commit_sub()
current_sub_name = line[3:-3].strip()
current_sub = {}
continue
if line.startswith("[[") and line.endswith("]]"):
commit_sub()
commit_interface()
current_interface_name = line[2:-2].strip()
current_interface = {"name": current_interface_name}
continue
if "=" in line and current_interface_name is not None:
key, value = line.split("=", 1)
target = current_sub if current_sub is not None else current_interface
target[key.strip()] = value.strip()
# commit any pending sections
commit_sub()
commit_interface()
return interfaces

View File

@@ -0,0 +1,11 @@
class InterfaceEditor:
@staticmethod
def update_value(interface_details: dict, data: dict, key: str):
# update value if provided and not empty
value = data.get(key)
if value is not None and value != "":
interface_details[key] = value
return
# otherwise remove existing value
interface_details.pop(key, None)

View File

@@ -0,0 +1,136 @@
import threading
import time
import RNS
from RNS.Interfaces.Interface import Interface
from websockets.sync.client import connect
from websockets.sync.connection import Connection
class WebsocketClientInterface(Interface):
# TODO: required?
DEFAULT_IFAC_SIZE = 16
RECONNECT_DELAY_SECONDS = 5
def __str__(self):
return f"WebsocketClientInterface[{self.name}/{self.target_url}]"
def __init__(self, owner, configuration, websocket: Connection = None):
super().__init__()
self.owner = owner
self.parent_interface = None
self.IN = True
self.OUT = False
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
# parse config
ifconf = Interface.get_config_obj(configuration)
self.name = ifconf.get("name")
self.target_url = ifconf.get("target_url", None)
# ensure target url is provided
if self.target_url is None:
msg = f"target_url is required for interface '{self.name}'"
raise SystemError(msg)
# connect to websocket server if an existing connection was not provided
self.websocket = websocket
if self.websocket is None:
thread = threading.Thread(target=self.connect)
thread.daemon = True
thread.start()
# called when a full packet has been received over the websocket
def process_incoming(self, data):
# do nothing if offline or detached
if not self.online or self.detached:
return
# update received bytes counter
self.rxb += len(data)
# update received bytes counter for parent interface
if self.parent_interface is not None:
self.parent_interface.rxb += len(data)
# send received data to transport instance
self.owner.inbound(data, self)
# the running reticulum transport instance will call this method whenever the interface must transmit a packet
def process_outgoing(self, data):
# do nothing if offline or detached
if not self.online or self.detached:
return
# send to websocket server
try:
self.websocket.send(data)
except Exception as e:
RNS.log(
f"Exception occurred while transmitting via {self!s}",
RNS.LOG_ERROR,
)
RNS.log(f"The contained exception was: {e!s}", RNS.LOG_ERROR)
return
# update sent bytes counter
self.txb += len(data)
# update received bytes counter for parent interface
if self.parent_interface is not None:
self.parent_interface.txb += len(data)
# connect to the configured websocket server
def connect(self):
# do nothing if interface is detached
if self.detached:
return
# connect to websocket server
try:
RNS.log(f"Connecting to Websocket for {self!s}...", RNS.LOG_DEBUG)
self.websocket = connect(
f"{self.target_url}",
max_size=None,
compression=None,
)
RNS.log(f"Connected to Websocket for {self!s}", RNS.LOG_DEBUG)
self.read_loop()
except Exception as e:
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
# auto reconnect after delay
RNS.log(f"Websocket disconnected for {self!s}...", RNS.LOG_DEBUG)
time.sleep(self.RECONNECT_DELAY_SECONDS)
self.connect()
def read_loop(self):
self.online = True
try:
for message in self.websocket:
self.process_incoming(message)
except Exception as e:
RNS.log(f"{self} read loop error: {e}", RNS.LOG_ERROR)
self.online = False
def detach(self):
# mark as offline
self.online = False
# close websocket
if self.websocket is not None:
self.websocket.close()
# mark as detached
self.detached = True
# set interface class RNS should use when importing this external interface
interface_class = WebsocketClientInterface

View File

@@ -0,0 +1,165 @@
import threading
import time
import RNS
from RNS.Interfaces.Interface import Interface
from src.backend.interfaces.WebsocketClientInterface import WebsocketClientInterface
from websockets.sync.server import Server, ServerConnection, serve
class WebsocketServerInterface(Interface):
# TODO: required?
DEFAULT_IFAC_SIZE = 16
RESTART_DELAY_SECONDS = 5
def __str__(self):
return (
f"WebsocketServerInterface[{self.name}/{self.listen_ip}:{self.listen_port}]"
)
def __init__(self, owner, configuration):
super().__init__()
self.owner = owner
self.IN = True
self.OUT = False
self.HW_MTU = 262144 # 256KiB
self.bitrate = 1_000_000_000 # 1Gbps
self.mode = RNS.Interfaces.Interface.Interface.MODE_FULL
self.server: Server | None = None
self.spawned_interfaces: [WebsocketClientInterface] = []
# parse config
ifconf = Interface.get_config_obj(configuration)
self.name = ifconf.get("name")
self.listen_ip = ifconf.get("listen_ip", None)
self.listen_port = ifconf.get("listen_port", None)
# ensure listen ip is provided
if self.listen_ip is None:
msg = f"listen_ip is required for interface '{self.name}'"
raise SystemError(msg)
# ensure listen port is provided
if self.listen_port is None:
msg = f"listen_port is required for interface '{self.name}'"
raise SystemError(msg)
# convert listen port to int
self.listen_port = int(self.listen_port)
# run websocket server
thread = threading.Thread(target=self.serve)
thread.daemon = True
thread.start()
@property
def clients(self):
return len(self.spawned_interfaces)
# TODO docs
def received_announce(self, from_spawned=False):
if from_spawned:
self.ia_freq_deque.append(time.time())
# TODO docs
def sent_announce(self, from_spawned=False):
if from_spawned:
self.oa_freq_deque.append(time.time())
# do nothing as the spawned child interface will take care of rx/tx
def process_incoming(self, data):
pass
# do nothing as the spawned child interface will take care of rx/tx
def process_outgoing(self, data):
pass
def serve(self):
# handle new websocket client connections
def on_websocket_client_connected(websocket: ServerConnection):
# create new child interface
RNS.log("Accepting incoming WebSocket connection", RNS.LOG_VERBOSE)
spawned_interface = WebsocketClientInterface(
self.owner,
{
"name": f"Client on {self.name}",
"target_host": websocket.remote_address[0],
"target_port": str(websocket.remote_address[1]),
},
websocket=websocket,
)
# configure child interface
spawned_interface.IN = self.IN
spawned_interface.OUT = self.OUT
spawned_interface.HW_MTU = self.HW_MTU
spawned_interface.bitrate = self.bitrate
spawned_interface.mode = self.mode
spawned_interface.parent_interface = self
spawned_interface.online = True
# TODO implement?
spawned_interface.announce_rate_target = None
spawned_interface.announce_rate_grace = None
spawned_interface.announce_rate_penalty = None
# TODO ifac?
# TODO announce rates?
# activate child interface
RNS.log(
f"Spawned new WebsocketClientInterface: {spawned_interface}",
RNS.LOG_VERBOSE,
)
RNS.Transport.interfaces.append(spawned_interface)
# associate child interface with this interface
while spawned_interface in self.spawned_interfaces:
self.spawned_interfaces.remove(spawned_interface)
self.spawned_interfaces.append(spawned_interface)
# run read loop
spawned_interface.read_loop()
# client must have disconnected as the read loop finished, so forget the spawned interface
self.spawned_interfaces.remove(spawned_interface)
# run websocket server
try:
RNS.log(f"Starting Websocket server for {self!s}...", RNS.LOG_DEBUG)
with serve(
on_websocket_client_connected,
self.listen_ip,
self.listen_port,
compression=None,
) as server:
self.online = True
self.server = server
server.serve_forever()
except Exception as e:
RNS.log(f"{self} failed with error: {e}", RNS.LOG_ERROR)
# websocket server is no longer running, let's restart it
self.online = False
RNS.log(f"Websocket server stopped for {self!s}...", RNS.LOG_DEBUG)
time.sleep(self.RESTART_DELAY_SECONDS)
self.serve()
def detach(self):
# mark as offline
self.online = False
# stop websocket server
if self.server is not None:
self.server.shutdown()
# mark as detached
self.detached = True
# set interface class RNS should use when importing this external interface
interface_class = WebsocketServerInterface

View File

@@ -0,0 +1 @@
"""Shared transport interfaces for MeshChatX."""

View File

@@ -0,0 +1,25 @@
# helper class for passing around an lxmf audio field
class LxmfAudioField:
def __init__(self, audio_mode: int, audio_bytes: bytes):
self.audio_mode = audio_mode
self.audio_bytes = audio_bytes
# helper class for passing around an lxmf image field
class LxmfImageField:
def __init__(self, image_type: str, image_bytes: bytes):
self.image_type = image_type
self.image_bytes = image_bytes
# helper class for passing around an lxmf file attachment
class LxmfFileAttachment:
def __init__(self, file_name: str, file_bytes: bytes):
self.file_name = file_name
self.file_bytes = file_bytes
# helper class for passing around an lxmf file attachments field
class LxmfFileAttachmentsField:
def __init__(self, file_attachments: list[LxmfFileAttachment]):
self.file_attachments = file_attachments

View File

@@ -0,0 +1,249 @@
import math
import os
import sqlite3
import threading
import time
import requests
import RNS
class MapManager:
def __init__(self, config_manager, storage_dir):
self.config = config_manager
self.storage_dir = storage_dir
self._local = threading.local()
self._metadata_cache = None
self._export_progress = {}
def get_connection(self, path):
if not hasattr(self._local, "connections"):
self._local.connections = {}
if path not in self._local.connections:
if not os.path.exists(path):
return None
conn = sqlite3.connect(path, check_same_thread=False)
conn.row_factory = sqlite3.Row
self._local.connections[path] = conn
return self._local.connections[path]
def get_offline_path(self):
path = self.config.map_offline_path.get()
if path:
return path
# Fallback to default if not set but file exists
default_path = os.path.join(self.storage_dir, "offline_map.mbtiles")
if os.path.exists(default_path):
return default_path
return None
def get_mbtiles_dir(self):
dir_path = self.config.map_mbtiles_dir.get()
if dir_path and os.path.isdir(dir_path):
return dir_path
return self.storage_dir
def list_mbtiles(self):
mbtiles_dir = self.get_mbtiles_dir()
files = []
if os.path.exists(mbtiles_dir):
for f in os.listdir(mbtiles_dir):
if f.endswith(".mbtiles"):
full_path = os.path.join(mbtiles_dir, f)
stats = os.stat(full_path)
files.append({
"name": f,
"path": full_path,
"size": stats.st_size,
"mtime": stats.st_mtime,
"is_active": full_path == self.get_offline_path(),
})
return sorted(files, key=lambda x: x["mtime"], reverse=True)
def delete_mbtiles(self, filename):
mbtiles_dir = self.get_mbtiles_dir()
file_path = os.path.join(mbtiles_dir, filename)
if os.path.exists(file_path) and file_path.endswith(".mbtiles"):
if file_path == self.get_offline_path():
self.config.map_offline_path.set(None)
self.config.map_offline_enabled.set(False)
os.remove(file_path)
self._metadata_cache = None
return True
return False
def get_metadata(self):
path = self.get_offline_path()
if not path or not os.path.exists(path):
return None
if self._metadata_cache and self._metadata_cache.get("path") == path:
return self._metadata_cache
conn = self.get_connection(path)
if not conn:
return None
try:
cursor = conn.cursor()
cursor.execute("SELECT name, value FROM metadata")
rows = cursor.fetchall()
metadata = {row["name"]: row["value"] for row in rows}
metadata["path"] = path
# Basic validation: ensure it's raster (format is not pbf)
if metadata.get("format") == "pbf":
RNS.log("MBTiles file is in vector (PBF) format, which is not supported.", RNS.LOG_ERROR)
return None
self._metadata_cache = metadata
return metadata
except Exception as e:
RNS.log(f"Error reading MBTiles metadata: {e}", RNS.LOG_ERROR)
return None
def get_tile(self, z, x, y):
path = self.get_offline_path()
if not path or not os.path.exists(path):
return None
conn = self.get_connection(path)
if not conn:
return None
try:
# MBTiles uses TMS tiling scheme (y is flipped)
tms_y = (1 << z) - 1 - y
cursor = conn.cursor()
cursor.execute(
"SELECT tile_data FROM tiles WHERE zoom_level = ? AND tile_column = ? AND tile_row = ?",
(z, x, tms_y),
)
row = cursor.fetchone()
if row:
return row["tile_data"]
return None
except Exception as e:
RNS.log(f"Error reading MBTiles tile {z}/{x}/{y}: {e}", RNS.LOG_ERROR)
return None
def start_export(self, export_id, bbox, min_zoom, max_zoom, name="Exported Map"):
"""Start downloading tiles and creating an MBTiles file in a background thread."""
thread = threading.Thread(
target=self._run_export,
args=(export_id, bbox, min_zoom, max_zoom, name),
daemon=True,
)
self._export_progress[export_id] = {
"status": "starting",
"progress": 0,
"total": 0,
"current": 0,
"start_time": time.time(),
}
thread.start()
return export_id
def get_export_status(self, export_id):
return self._export_progress.get(export_id)
def _run_export(self, export_id, bbox, min_zoom, max_zoom, name):
# bbox: [min_lon, min_lat, max_lon, max_lat]
min_lon, min_lat, max_lon, max_lat = bbox
# calculate total tiles
total_tiles = 0
zoom_levels = range(min_zoom, max_zoom + 1)
for z in zoom_levels:
x1, y1 = self._lonlat_to_tile(min_lon, max_lat, z)
x2, y2 = self._lonlat_to_tile(max_lon, min_lat, z)
total_tiles += (x2 - x1 + 1) * (y2 - y1 + 1)
self._export_progress[export_id]["total"] = total_tiles
self._export_progress[export_id]["status"] = "downloading"
dest_path = os.path.join(self.storage_dir, f"export_{export_id}.mbtiles")
try:
conn = sqlite3.connect(dest_path)
cursor = conn.cursor()
# create schema
cursor.execute("CREATE TABLE metadata (name text, value text)")
cursor.execute("CREATE TABLE tiles (zoom_level integer, tile_column integer, tile_row integer, tile_data blob)")
cursor.execute("CREATE UNIQUE INDEX tile_index on tiles (zoom_level, tile_column, tile_row)")
# insert metadata
metadata = [
("name", name),
("type", "baselayer"),
("version", "1.1"),
("description", f"Exported from MeshChatX on {time.ctime()}"),
("format", "png"),
("bounds", f"{min_lon},{min_lat},{max_lon},{max_lat}"),
]
cursor.executemany("INSERT INTO metadata VALUES (?, ?)", metadata)
current_count = 0
for z in zoom_levels:
x1, y1 = self._lonlat_to_tile(min_lon, max_lat, z)
x2, y2 = self._lonlat_to_tile(max_lon, min_lat, z)
for x in range(x1, x2 + 1):
for y in range(y1, y2 + 1):
# check if we should stop (if we add a cancel mechanism)
# download tile
tile_url = f"https://tile.openstreetmap.org/{z}/{x}/{y}.png"
try:
# wait a bit to be nice to OSM
time.sleep(0.1)
response = requests.get(tile_url, headers={"User-Agent": "MeshChatX/1.0 MapExporter"}, timeout=10)
if response.status_code == 200:
# MBTiles uses TMS (y flipped)
tms_y = (1 << z) - 1 - y
cursor.execute(
"INSERT INTO tiles VALUES (?, ?, ?, ?)",
(z, x, tms_y, response.content),
)
except Exception as e:
RNS.log(f"Export failed to download tile {z}/{x}/{y}: {e}", RNS.LOG_ERROR)
current_count += 1
self._export_progress[export_id]["current"] = current_count
self._export_progress[export_id]["progress"] = int((current_count / total_tiles) * 100)
# commit after each zoom level
conn.commit()
conn.close()
self._export_progress[export_id]["status"] = "completed"
self._export_progress[export_id]["file_path"] = dest_path
except Exception as e:
RNS.log(f"Map export failed: {e}", RNS.LOG_ERROR)
self._export_progress[export_id]["status"] = "failed"
self._export_progress[export_id]["error"] = str(e)
if os.path.exists(dest_path):
os.remove(dest_path)
def _lonlat_to_tile(self, lon, lat, zoom):
lat_rad = math.radians(lat)
n = 2.0 ** zoom
x = int((lon + 180.0) / 360.0 * n)
y = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n)
return x, y
def close(self):
if hasattr(self._local, "connections"):
for conn in self._local.connections.values():
conn.close()
self._local.connections = {}
self._metadata_cache = None

View File

@@ -0,0 +1,66 @@
from .database import Database
class MessageHandler:
def __init__(self, db: Database):
self.db = db
def get_conversation_messages(self, local_hash, destination_hash, limit=100, offset=0, after_id=None, before_id=None):
query = """
SELECT * FROM lxmf_messages
WHERE ((source_hash = ? AND destination_hash = ?)
OR (destination_hash = ? AND source_hash = ?))
"""
params = [local_hash, destination_hash, local_hash, destination_hash]
if after_id:
query += " AND id > ?"
params.append(after_id)
if before_id:
query += " AND id < ?"
params.append(before_id)
query += " ORDER BY id DESC LIMIT ? OFFSET ?"
params.extend([limit, offset])
return self.db.provider.fetchall(query, params)
def delete_conversation(self, local_hash, destination_hash):
query = """
DELETE FROM lxmf_messages
WHERE ((source_hash = ? AND destination_hash = ?)
OR (destination_hash = ? AND source_hash = ?))
"""
self.db.provider.execute(query, [local_hash, destination_hash, local_hash, destination_hash])
def search_messages(self, local_hash, search_term):
like_term = f"%{search_term}%"
query = """
SELECT source_hash, destination_hash, MAX(timestamp) as max_ts
FROM lxmf_messages
WHERE (source_hash = ? OR destination_hash = ?)
AND (title LIKE ? OR content LIKE ? OR source_hash LIKE ? OR destination_hash LIKE ?)
GROUP BY source_hash, destination_hash
"""
params = [local_hash, local_hash, like_term, like_term, like_term, like_term]
return self.db.provider.fetchall(query, params)
def get_conversations(self, local_hash):
# Implementation moved from get_conversations DAO but with local_hash filter
query = """
SELECT m1.* FROM lxmf_messages m1
JOIN (
SELECT
CASE WHEN source_hash = ? THEN destination_hash ELSE source_hash END as peer_hash,
MAX(timestamp) as max_ts
FROM lxmf_messages
WHERE source_hash = ? OR destination_hash = ?
GROUP BY peer_hash
) m2 ON (CASE WHEN m1.source_hash = ? THEN m1.destination_hash ELSE m1.source_hash END = m2.peer_hash
AND m1.timestamp = m2.max_ts)
WHERE m1.source_hash = ? OR m1.destination_hash = ?
ORDER BY m1.timestamp DESC
"""
params = [local_hash, local_hash, local_hash, local_hash, local_hash, local_hash]
return self.db.provider.fetchall(query, params)

View File

@@ -0,0 +1,421 @@
import asyncio
import os
import shutil
import time
from collections.abc import Callable
import RNS
class RNCPHandler:
APP_NAME = "rncp"
REQ_FETCH_NOT_ALLOWED = 0xF0
def __init__(self, reticulum_instance, identity, storage_dir):
self.reticulum = reticulum_instance
self.identity = identity
self.storage_dir = storage_dir
self.active_transfers = {}
self.receive_destination = None
self.fetch_jail = None
self.fetch_auto_compress = True
self.allow_overwrite_on_receive = False
self.allowed_identity_hashes = []
def setup_receive_destination(self, allowed_hashes=None, fetch_allowed=False, fetch_jail=None, allow_overwrite=False):
if allowed_hashes:
self.allowed_identity_hashes = [bytes.fromhex(h) if isinstance(h, str) else h for h in allowed_hashes]
self.fetch_jail = fetch_jail
self.allow_overwrite_on_receive = allow_overwrite
identity_path = os.path.join(RNS.Reticulum.identitypath, self.APP_NAME)
if os.path.isfile(identity_path):
receive_identity = RNS.Identity.from_file(identity_path)
else:
receive_identity = RNS.Identity()
receive_identity.to_file(identity_path)
self.receive_destination = RNS.Destination(
receive_identity,
RNS.Destination.IN,
RNS.Destination.SINGLE,
self.APP_NAME,
"receive",
)
self.receive_destination.set_link_established_callback(self._client_link_established)
if fetch_allowed:
self.receive_destination.register_request_handler(
"fetch_file",
response_generator=self._fetch_request,
allow=RNS.Destination.ALLOW_LIST,
allowed_list=self.allowed_identity_hashes,
)
return self.receive_destination.hash.hex()
def _client_link_established(self, link):
link.set_remote_identified_callback(self._receive_sender_identified)
link.set_resource_strategy(RNS.Link.ACCEPT_APP)
link.set_resource_callback(self._receive_resource_callback)
link.set_resource_started_callback(self._receive_resource_started)
link.set_resource_concluded_callback(self._receive_resource_concluded)
def _receive_sender_identified(self, link, identity):
if identity.hash not in self.allowed_identity_hashes:
link.teardown()
def _receive_resource_callback(self, resource):
sender_identity = resource.link.get_remote_identity()
if sender_identity and sender_identity.hash in self.allowed_identity_hashes:
return True
return False
def _receive_resource_started(self, resource):
transfer_id = resource.hash.hex()
self.active_transfers[transfer_id] = {
"resource": resource,
"status": "receiving",
"started_at": time.time(),
}
def _receive_resource_concluded(self, resource):
transfer_id = resource.hash.hex()
if resource.status == RNS.Resource.COMPLETE:
if resource.metadata:
try:
filename = os.path.basename(resource.metadata["name"].decode("utf-8"))
save_dir = os.path.join(self.storage_dir, "rncp_received")
os.makedirs(save_dir, exist_ok=True)
saved_filename = os.path.join(save_dir, filename)
counter = 0
if self.allow_overwrite_on_receive:
if os.path.isfile(saved_filename):
try:
os.unlink(saved_filename)
except OSError:
# Failed to delete existing file, which is fine,
# we'll just fall through to the naming loop
pass
while os.path.isfile(saved_filename):
counter += 1
base, ext = os.path.splitext(filename)
saved_filename = os.path.join(save_dir, f"{base}.{counter}{ext}")
shutil.move(resource.data.name, saved_filename)
if transfer_id in self.active_transfers:
self.active_transfers[transfer_id]["status"] = "completed"
self.active_transfers[transfer_id]["saved_path"] = saved_filename
self.active_transfers[transfer_id]["filename"] = filename
except Exception as e:
if transfer_id in self.active_transfers:
self.active_transfers[transfer_id]["status"] = "error"
self.active_transfers[transfer_id]["error"] = str(e)
elif transfer_id in self.active_transfers:
self.active_transfers[transfer_id]["status"] = "failed"
def _fetch_request(self, path, data, request_id, link_id, remote_identity, requested_at):
if self.fetch_jail:
if data.startswith(self.fetch_jail + "/"):
data = data.replace(self.fetch_jail + "/", "")
file_path = os.path.abspath(os.path.expanduser(f"{self.fetch_jail}/{data}"))
if not file_path.startswith(self.fetch_jail + "/"):
return self.REQ_FETCH_NOT_ALLOWED
else:
file_path = os.path.abspath(os.path.expanduser(data))
target_link = None
for link in RNS.Transport.active_links:
if link.link_id == link_id:
target_link = link
break
if not os.path.isfile(file_path):
return False
if target_link:
try:
metadata = {"name": os.path.basename(file_path).encode("utf-8")}
RNS.Resource(
open(file_path, "rb"),
target_link,
metadata=metadata,
auto_compress=self.fetch_auto_compress,
)
return True
except Exception:
return False
return None
async def send_file(
self,
destination_hash: bytes,
file_path: str,
timeout: float = RNS.Transport.PATH_REQUEST_TIMEOUT,
on_progress: Callable[[float], None] | None = None,
no_compress: bool = False,
):
file_path = os.path.expanduser(file_path)
if not os.path.isfile(file_path):
msg = f"File not found: {file_path}"
raise FileNotFoundError(msg)
if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash)
timeout_after = time.time() + timeout
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after:
await asyncio.sleep(0.1)
if not RNS.Transport.has_path(destination_hash):
msg = "Path not found to destination"
raise TimeoutError(msg)
receiver_identity = RNS.Identity.recall(destination_hash)
receiver_destination = RNS.Destination(
receiver_identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
self.APP_NAME,
"receive",
)
link = RNS.Link(receiver_destination)
timeout_after = time.time() + timeout
while link.status != RNS.Link.ACTIVE and time.time() < timeout_after:
await asyncio.sleep(0.1)
if link.status != RNS.Link.ACTIVE:
msg = "Could not establish link to destination"
raise TimeoutError(msg)
link.identify(self.identity)
auto_compress = not no_compress
metadata = {"name": os.path.basename(file_path).encode("utf-8")}
def progress_callback(resource):
if on_progress:
progress = resource.get_progress()
on_progress(progress)
resource = RNS.Resource(
open(file_path, "rb"),
link,
metadata=metadata,
callback=progress_callback,
progress_callback=progress_callback,
auto_compress=auto_compress,
)
transfer_id = resource.hash.hex()
self.active_transfers[transfer_id] = {
"resource": resource,
"status": "sending",
"started_at": time.time(),
"file_path": file_path,
}
while resource.status < RNS.Resource.COMPLETE:
await asyncio.sleep(0.1)
if resource.status > RNS.Resource.COMPLETE:
msg = "File was not accepted by destination"
raise Exception(msg)
if resource.status == RNS.Resource.COMPLETE:
if transfer_id in self.active_transfers:
self.active_transfers[transfer_id]["status"] = "completed"
link.teardown()
return {
"transfer_id": transfer_id,
"status": "completed",
"file_path": file_path,
}
if transfer_id in self.active_transfers:
self.active_transfers[transfer_id]["status"] = "failed"
link.teardown()
msg = "Transfer failed"
raise Exception(msg)
async def fetch_file(
self,
destination_hash: bytes,
file_path: str,
timeout: float = RNS.Transport.PATH_REQUEST_TIMEOUT,
on_progress: Callable[[float], None] | None = None,
save_path: str | None = None,
allow_overwrite: bool = False,
):
if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash)
timeout_after = time.time() + timeout
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after:
await asyncio.sleep(0.1)
if not RNS.Transport.has_path(destination_hash):
msg = "Path not found to destination"
raise TimeoutError(msg)
listener_identity = RNS.Identity.recall(destination_hash)
listener_destination = RNS.Destination(
listener_identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
self.APP_NAME,
"receive",
)
link = RNS.Link(listener_destination)
timeout_after = time.time() + timeout
while link.status != RNS.Link.ACTIVE and time.time() < timeout_after:
await asyncio.sleep(0.1)
if link.status != RNS.Link.ACTIVE:
msg = "Could not establish link to destination"
raise TimeoutError(msg)
link.identify(self.identity)
request_resolved = False
request_status = "unknown"
resource_resolved = False
resource_status = "unrequested"
current_resource = None
def request_response(request_receipt):
nonlocal request_resolved, request_status
if not request_receipt.response:
request_status = "not_found"
elif request_receipt.response is None:
request_status = "remote_error"
elif request_receipt.response == self.REQ_FETCH_NOT_ALLOWED:
request_status = "fetch_not_allowed"
else:
request_status = "found"
request_resolved = True
def request_failed(request_receipt):
nonlocal request_resolved, request_status
request_status = "unknown"
request_resolved = True
def fetch_resource_started(resource):
nonlocal resource_status, current_resource
current_resource = resource
def progress_callback(resource):
if on_progress:
progress = resource.get_progress()
on_progress(progress)
current_resource.progress_callback(progress_callback)
resource_status = "started"
saved_filename = None
def fetch_resource_concluded(resource):
nonlocal resource_resolved, resource_status, saved_filename
if resource.status == RNS.Resource.COMPLETE:
if resource.metadata:
try:
filename = os.path.basename(resource.metadata["name"].decode("utf-8"))
if save_path:
save_dir = os.path.abspath(os.path.expanduser(save_path))
os.makedirs(save_dir, exist_ok=True)
saved_filename = os.path.join(save_dir, filename)
else:
saved_filename = filename
counter = 0
if allow_overwrite:
if os.path.isfile(saved_filename):
try:
os.unlink(saved_filename)
except OSError:
# Failed to delete existing file, which is fine,
# we'll just fall through to the naming loop
pass
while os.path.isfile(saved_filename):
counter += 1
base, ext = os.path.splitext(filename)
saved_filename = os.path.join(
os.path.dirname(saved_filename) if save_path else ".",
f"{base}.{counter}{ext}",
)
shutil.move(resource.data.name, saved_filename)
resource_status = "completed"
except Exception as e:
resource_status = "error"
raise e
else:
resource_status = "error"
else:
resource_status = "failed"
resource_resolved = True
link.set_resource_strategy(RNS.Link.ACCEPT_ALL)
link.set_resource_started_callback(fetch_resource_started)
link.set_resource_concluded_callback(fetch_resource_concluded)
link.request("fetch_file", data=file_path, response_callback=request_response, failed_callback=request_failed)
while not request_resolved:
await asyncio.sleep(0.1)
if request_status == "fetch_not_allowed":
link.teardown()
msg = "Fetch request not allowed by remote"
raise PermissionError(msg)
if request_status == "not_found":
link.teardown()
msg = f"File not found on remote: {file_path}"
raise FileNotFoundError(msg)
if request_status == "remote_error":
link.teardown()
msg = "Remote error during fetch request"
raise Exception(msg)
if request_status == "unknown":
link.teardown()
msg = "Unknown error during fetch request"
raise Exception(msg)
while not resource_resolved:
await asyncio.sleep(0.1)
if resource_status == "completed":
link.teardown()
return {
"status": "completed",
"file_path": saved_filename,
}
link.teardown()
msg = f"Transfer failed: {resource_status}"
raise Exception(msg)
def get_transfer_status(self, transfer_id: str):
if transfer_id in self.active_transfers:
transfer = self.active_transfers[transfer_id]
resource = transfer.get("resource")
if resource:
progress = resource.get_progress()
return {
"transfer_id": transfer_id,
"status": transfer["status"],
"progress": progress,
"file_path": transfer.get("file_path"),
"saved_path": transfer.get("saved_path"),
"filename": transfer.get("filename"),
"error": transfer.get("error"),
}
return None

View File

@@ -0,0 +1,137 @@
import asyncio
import os
import time
import RNS
class RNProbeHandler:
DEFAULT_PROBE_SIZE = 16
DEFAULT_TIMEOUT = 12
def __init__(self, reticulum_instance, identity):
self.reticulum = reticulum_instance
self.identity = identity
async def probe_destination(
self,
destination_hash: bytes,
full_name: str,
size: int = DEFAULT_PROBE_SIZE,
timeout: float | None = None,
wait: float = 0,
probes: int = 1,
):
try:
app_name, aspects = RNS.Destination.app_and_aspects_from_name(full_name)
except Exception as e:
msg = f"Invalid destination name: {e}"
raise ValueError(msg)
if not RNS.Transport.has_path(destination_hash):
RNS.Transport.request_path(destination_hash)
timeout_after = time.time() + (timeout or self.DEFAULT_TIMEOUT + self.reticulum.get_first_hop_timeout(destination_hash))
while not RNS.Transport.has_path(destination_hash) and time.time() < timeout_after:
await asyncio.sleep(0.1)
if not RNS.Transport.has_path(destination_hash):
msg = "Path request timed out"
raise TimeoutError(msg)
server_identity = RNS.Identity.recall(destination_hash)
request_destination = RNS.Destination(
server_identity,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
app_name,
*aspects,
)
results = []
sent = 0
while probes > 0:
if sent > 0:
await asyncio.sleep(wait)
try:
probe = RNS.Packet(request_destination, os.urandom(size))
probe.pack()
except OSError:
msg = f"Probe packet size of {len(probe.raw)} bytes exceeds MTU of {RNS.Reticulum.MTU} bytes"
raise ValueError(msg)
receipt = probe.send()
sent += 1
next_hop = self.reticulum.get_next_hop(destination_hash)
via_str = f" via {RNS.prettyhexrep(next_hop)}" if next_hop else ""
if_name = self.reticulum.get_next_hop_if_name(destination_hash)
if_str = f" on {if_name}" if if_name and if_name != "None" else ""
timeout_after = time.time() + (timeout or self.DEFAULT_TIMEOUT + self.reticulum.get_first_hop_timeout(destination_hash))
while receipt.status == RNS.PacketReceipt.SENT and time.time() < timeout_after:
await asyncio.sleep(0.1)
result: dict = {
"probe_number": sent,
"size": size,
"destination": RNS.prettyhexrep(destination_hash),
"via": via_str,
"interface": if_str,
"status": "timeout",
}
if time.time() > timeout_after:
result["status"] = "timeout"
elif receipt.status == RNS.PacketReceipt.DELIVERED:
hops = RNS.Transport.hops_to(destination_hash)
rtt = receipt.get_rtt()
if rtt >= 1:
rtt_str = f"{round(rtt, 3)} seconds"
else:
rtt_str = f"{round(rtt * 1000, 3)} milliseconds"
reception_stats = {}
if self.reticulum.is_connected_to_shared_instance:
reception_rssi = self.reticulum.get_packet_rssi(receipt.proof_packet.packet_hash)
reception_snr = self.reticulum.get_packet_snr(receipt.proof_packet.packet_hash)
reception_q = self.reticulum.get_packet_q(receipt.proof_packet.packet_hash)
if reception_rssi is not None:
reception_stats["rssi"] = reception_rssi
if reception_snr is not None:
reception_stats["snr"] = reception_snr
if reception_q is not None:
reception_stats["quality"] = reception_q
elif receipt.proof_packet:
if receipt.proof_packet.rssi is not None:
reception_stats["rssi"] = receipt.proof_packet.rssi
if receipt.proof_packet.snr is not None:
reception_stats["snr"] = receipt.proof_packet.snr
result.update(
{
"status": "delivered",
"hops": hops,
"rtt": rtt,
"rtt_string": rtt_str,
"reception_stats": reception_stats,
},
)
else:
result["status"] = "failed"
results.append(result)
probes -= 1
return {
"results": results,
"sent": sent,
"delivered": sum(1 for r in results if r["status"] == "delivered"),
"timeouts": sum(1 for r in results if r["status"] == "timeout"),
"failed": sum(1 for r in results if r["status"] == "failed"),
}

View File

@@ -0,0 +1,184 @@
import time
from typing import Any
def size_str(num, suffix="B"):
units = ["", "K", "M", "G", "T", "P", "E", "Z"]
last_unit = "Y"
if suffix == "b":
num *= 8
units = ["", "K", "M", "G", "T", "P", "E", "Z"]
last_unit = "Y"
for unit in units:
if abs(num) < 1000.0:
if unit == "":
return f"{num:.0f} {unit}{suffix}"
return f"{num:.2f} {unit}{suffix}"
num /= 1000.0
return f"{num:.2f}{last_unit}{suffix}"
class RNStatusHandler:
def __init__(self, reticulum_instance):
self.reticulum = reticulum_instance
def get_status(self, include_link_stats: bool = False, sorting: str | None = None, sort_reverse: bool = False):
stats = None
link_count = None
try:
if include_link_stats:
link_count = self.reticulum.get_link_count()
except Exception as e:
# We can't do much here if the reticulum instance fails
print(f"Failed to get link count: {e}")
try:
stats = self.reticulum.get_interface_stats()
except Exception as e:
# We can't do much here if the reticulum instance fails
print(f"Failed to get interface stats: {e}")
if stats is None:
return {
"interfaces": [],
"link_count": link_count,
}
interfaces = stats.get("interfaces", [])
if sorting and isinstance(sorting, str):
sorting = sorting.lower()
if sorting in ("rate", "bitrate"):
interfaces.sort(key=lambda i: i.get("bitrate", 0) or 0, reverse=sort_reverse)
elif sorting == "rx":
interfaces.sort(key=lambda i: i.get("rxb", 0) or 0, reverse=sort_reverse)
elif sorting == "tx":
interfaces.sort(key=lambda i: i.get("txb", 0) or 0, reverse=sort_reverse)
elif sorting == "rxs":
interfaces.sort(key=lambda i: i.get("rxs", 0) or 0, reverse=sort_reverse)
elif sorting == "txs":
interfaces.sort(key=lambda i: i.get("txs", 0) or 0, reverse=sort_reverse)
elif sorting == "traffic":
interfaces.sort(
key=lambda i: (i.get("rxb", 0) or 0) + (i.get("txb", 0) or 0),
reverse=sort_reverse,
)
elif sorting in ("announces", "announce"):
interfaces.sort(
key=lambda i: (i.get("incoming_announce_frequency", 0) or 0)
+ (i.get("outgoing_announce_frequency", 0) or 0),
reverse=sort_reverse,
)
elif sorting == "arx":
interfaces.sort(
key=lambda i: i.get("incoming_announce_frequency", 0) or 0,
reverse=sort_reverse,
)
elif sorting == "atx":
interfaces.sort(
key=lambda i: i.get("outgoing_announce_frequency", 0) or 0,
reverse=sort_reverse,
)
elif sorting == "held":
interfaces.sort(key=lambda i: i.get("held_announces", 0) or 0, reverse=sort_reverse)
formatted_interfaces = []
for ifstat in interfaces:
name = ifstat.get("name", "")
if name.startswith("LocalInterface[") or name.startswith("TCPInterface[Client") or name.startswith("BackboneInterface[Client on"):
continue
formatted_if: dict[str, Any] = {
"name": name,
"status": "Up" if ifstat.get("status") else "Down",
}
mode = ifstat.get("mode")
if mode == 1:
formatted_if["mode"] = "Access Point"
elif mode == 2:
formatted_if["mode"] = "Point-to-Point"
elif mode == 3:
formatted_if["mode"] = "Roaming"
elif mode == 4:
formatted_if["mode"] = "Boundary"
elif mode == 5:
formatted_if["mode"] = "Gateway"
else:
formatted_if["mode"] = "Full"
if "bitrate" in ifstat and ifstat["bitrate"] is not None:
formatted_if["bitrate"] = size_str(ifstat["bitrate"], "b") + "ps"
if "rxb" in ifstat:
formatted_if["rx_bytes"] = ifstat["rxb"]
formatted_if["rx_bytes_str"] = size_str(ifstat["rxb"])
if "txb" in ifstat:
formatted_if["tx_bytes"] = ifstat["txb"]
formatted_if["tx_bytes_str"] = size_str(ifstat["txb"])
if "rxs" in ifstat:
formatted_if["rx_packets"] = ifstat["rxs"]
if "txs" in ifstat:
formatted_if["tx_packets"] = ifstat["txs"]
if "clients" in ifstat and ifstat["clients"] is not None:
formatted_if["clients"] = ifstat["clients"]
if "noise_floor" in ifstat and ifstat["noise_floor"] is not None:
formatted_if["noise_floor"] = f"{ifstat['noise_floor']} dBm"
if "interference" in ifstat and ifstat["interference"] is not None:
formatted_if["interference"] = f"{ifstat['interference']} dBm"
if "cpu_load" in ifstat and ifstat["cpu_load"] is not None:
formatted_if["cpu_load"] = f"{ifstat['cpu_load']}%"
if "cpu_temp" in ifstat and ifstat["cpu_temp"] is not None:
formatted_if["cpu_temp"] = f"{ifstat['cpu_temp']}°C"
if "mem_load" in ifstat and ifstat["mem_load"] is not None:
formatted_if["mem_load"] = f"{ifstat['mem_load']}%"
if "battery_percent" in ifstat and ifstat["battery_percent"] is not None:
formatted_if["battery_percent"] = ifstat["battery_percent"]
if "battery_state" in ifstat:
formatted_if["battery_state"] = ifstat["battery_state"]
if "airtime_short" in ifstat and "airtime_long" in ifstat:
formatted_if["airtime"] = {
"short": ifstat["airtime_short"],
"long": ifstat["airtime_long"],
}
if "channel_load_short" in ifstat and "channel_load_long" in ifstat:
formatted_if["channel_load"] = {
"short": ifstat["channel_load_short"],
"long": ifstat["channel_load_long"],
}
if "peers" in ifstat and ifstat["peers"] is not None:
formatted_if["peers"] = ifstat["peers"]
if "incoming_announce_frequency" in ifstat:
formatted_if["incoming_announce_frequency"] = ifstat["incoming_announce_frequency"]
if "outgoing_announce_frequency" in ifstat:
formatted_if["outgoing_announce_frequency"] = ifstat["outgoing_announce_frequency"]
if "held_announces" in ifstat:
formatted_if["held_announces"] = ifstat["held_announces"]
if "ifac_netname" in ifstat and ifstat["ifac_netname"] is not None:
formatted_if["network_name"] = ifstat["ifac_netname"]
formatted_interfaces.append(formatted_if)
return {
"interfaces": formatted_interfaces,
"link_count": link_count,
"timestamp": time.time(),
}

View File

@@ -0,0 +1,3 @@
# https://github.com/markqvist/Sideband/blob/e515889e210037f881c201e0d627a7b09a48eb69/sbapp/sideband/sense.py#L11
class SidebandCommands:
TELEMETRY_REQUEST = 0x01

View File

@@ -0,0 +1,95 @@
import asyncio
import time
import RNS
from LXST import Telephone
class TelephoneManager:
def __init__(self, identity: RNS.Identity, config_manager=None):
self.identity = identity
self.config_manager = config_manager
self.telephone = None
self.on_ringing_callback = None
self.on_established_callback = None
self.on_ended_callback = None
self.call_start_time = None
self.call_status_at_end = None
self.call_is_incoming = False
def init_telephone(self):
if self.telephone is not None:
return
self.telephone = Telephone(self.identity)
# Disable busy tone played on caller side when remote side rejects, or doesn't answer
self.telephone.set_busy_tone_time(0)
self.telephone.set_ringing_callback(self.on_telephone_ringing)
self.telephone.set_established_callback(self.on_telephone_call_established)
self.telephone.set_ended_callback(self.on_telephone_call_ended)
def teardown(self):
if self.telephone is not None:
self.telephone.teardown()
self.telephone = None
def register_ringing_callback(self, callback):
self.on_ringing_callback = callback
def register_established_callback(self, callback):
self.on_established_callback = callback
def register_ended_callback(self, callback):
self.on_ended_callback = callback
def on_telephone_ringing(self, caller_identity: RNS.Identity):
self.call_start_time = time.time()
self.call_is_incoming = True
if self.on_ringing_callback:
self.on_ringing_callback(caller_identity)
def on_telephone_call_established(self, caller_identity: RNS.Identity):
# Update start time to when it was actually established for duration calculation
self.call_start_time = time.time()
if self.on_established_callback:
self.on_established_callback(caller_identity)
def on_telephone_call_ended(self, caller_identity: RNS.Identity):
# Capture status just before ending if possible, or use the last known status
if self.telephone:
self.call_status_at_end = self.telephone.call_status
if self.on_ended_callback:
self.on_ended_callback(caller_identity)
def announce(self, attached_interface=None):
if self.telephone:
self.telephone.announce(attached_interface=attached_interface)
async def initiate(self, destination_hash: bytes, timeout_seconds: int = 15):
if self.telephone is None:
msg = "Telephone is not initialized"
raise RuntimeError(msg)
# Find destination identity
destination_identity = RNS.Identity.recall(destination_hash)
if destination_identity is None:
# If not found by identity hash, try as destination hash
destination_identity = RNS.Identity.recall(destination_hash) # Identity.recall takes identity hash
if destination_identity is None:
msg = "Destination identity not found"
raise RuntimeError(msg)
# In LXST, we just call the identity. Telephone class handles path requests.
# But we might want to ensure a path exists first for better UX,
# similar to how the old MeshChat did it.
# For now, let's just use the telephone.call method which is threaded.
# We need to run it in a thread since it might block.
self.call_start_time = time.time()
self.call_is_incoming = False
await asyncio.to_thread(self.telephone.call, destination_identity)
return self.telephone.active_call

View File

@@ -0,0 +1,363 @@
import os
import re
import shutil
import subprocess
from typing import Any
try:
import requests
HAS_REQUESTS = True
except ImportError:
HAS_REQUESTS = False
try:
from argostranslate import package, translate
HAS_ARGOS_LIB = True
except ImportError:
HAS_ARGOS_LIB = False
HAS_ARGOS_CLI = shutil.which("argos-translate") is not None
HAS_ARGOS = HAS_ARGOS_LIB or HAS_ARGOS_CLI
LANGUAGE_CODE_TO_NAME = {
"en": "English",
"de": "German",
"es": "Spanish",
"fr": "French",
"it": "Italian",
"pt": "Portuguese",
"ru": "Russian",
"zh": "Chinese",
"ja": "Japanese",
"ko": "Korean",
"ar": "Arabic",
"hi": "Hindi",
"nl": "Dutch",
"pl": "Polish",
"tr": "Turkish",
"sv": "Swedish",
"da": "Danish",
"no": "Norwegian",
"fi": "Finnish",
"cs": "Czech",
"ro": "Romanian",
"hu": "Hungarian",
"el": "Greek",
"he": "Hebrew",
"th": "Thai",
"vi": "Vietnamese",
"id": "Indonesian",
"uk": "Ukrainian",
"bg": "Bulgarian",
"hr": "Croatian",
"sk": "Slovak",
"sl": "Slovenian",
"et": "Estonian",
"lv": "Latvian",
"lt": "Lithuanian",
"mt": "Maltese",
"ga": "Irish",
"cy": "Welsh",
}
class TranslatorHandler:
def __init__(self, libretranslate_url: str | None = None):
self.libretranslate_url = libretranslate_url or os.getenv("LIBRETRANSLATE_URL", "http://localhost:5000")
self.has_argos = HAS_ARGOS
self.has_argos_lib = HAS_ARGOS_LIB
self.has_argos_cli = HAS_ARGOS_CLI
self.has_requests = HAS_REQUESTS
def get_supported_languages(self, libretranslate_url: str | None = None):
languages = []
url = libretranslate_url or self.libretranslate_url
if self.has_requests:
try:
response = requests.get(f"{url}/languages", timeout=5)
if response.status_code == 200:
libretranslate_langs = response.json()
languages.extend(
{
"code": lang.get("code"),
"name": lang.get("name"),
"source": "libretranslate",
}
for lang in libretranslate_langs
)
return languages
except Exception as e:
# Log or handle the exception appropriately
print(f"Failed to fetch LibreTranslate languages: {e}")
if self.has_argos_lib:
try:
installed_packages = package.get_installed_packages()
argos_langs = set()
for pkg in installed_packages:
argos_langs.add((pkg.from_code, pkg.from_name))
argos_langs.add((pkg.to_code, pkg.to_name))
for code, name in sorted(argos_langs):
languages.append(
{
"code": code,
"name": name,
"source": "argos",
},
)
except Exception as e:
print(f"Failed to fetch Argos languages: {e}")
elif self.has_argos_cli:
try:
cli_langs = self._get_argos_languages_cli()
languages.extend(cli_langs)
except Exception as e:
print(f"Failed to fetch Argos languages via CLI: {e}")
return languages
def translate_text(
self,
text: str,
source_lang: str,
target_lang: str,
use_argos: bool = False,
libretranslate_url: str | None = None,
) -> dict[str, Any]:
if not text:
msg = "Text cannot be empty"
raise ValueError(msg)
if use_argos and self.has_argos:
return self._translate_argos(text, source_lang, target_lang)
if self.has_requests:
try:
url = libretranslate_url or self.libretranslate_url
return self._translate_libretranslate(text, source_lang=source_lang, target_lang=target_lang, libretranslate_url=url)
except Exception as e:
if self.has_argos:
return self._translate_argos(text, source_lang, target_lang)
raise e
if self.has_argos:
return self._translate_argos(text, source_lang, target_lang)
msg = "No translation backend available. Install requests for LibreTranslate or argostranslate for local translation."
raise RuntimeError(msg)
def _translate_libretranslate(self, text: str, source_lang: str, target_lang: str, libretranslate_url: str | None = None) -> dict[str, Any]:
if not self.has_requests:
msg = "requests library not available"
raise RuntimeError(msg)
url = libretranslate_url or self.libretranslate_url
response = requests.post(
f"{url}/translate",
json={
"q": text,
"source": source_lang,
"target": target_lang,
"format": "text",
},
timeout=30,
)
if response.status_code != 200:
msg = f"LibreTranslate API error: {response.status_code} - {response.text}"
raise RuntimeError(msg)
result = response.json()
return {
"translated_text": result.get("translatedText", ""),
"source_lang": result.get("detectedLanguage", {}).get("language", source_lang),
"target_lang": target_lang,
"source": "libretranslate",
}
def _translate_argos(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]:
if source_lang == "auto":
if self.has_argos_lib:
detected_lang = self._detect_language(text)
if detected_lang:
source_lang = detected_lang
else:
msg = "Could not auto-detect language. Please select a source language manually."
raise ValueError(msg)
else:
msg = (
"Auto-detection is not supported with CLI-only installation. "
"Please select a source language manually or install the Python library: pip install argostranslate"
)
raise ValueError(msg)
if self.has_argos_lib:
return self._translate_argos_lib(text, source_lang, target_lang)
if self.has_argos_cli:
return self._translate_argos_cli(text, source_lang, target_lang)
msg = "Argos Translate not available (neither library nor CLI)"
raise RuntimeError(msg)
def _translate_argos_lib(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]:
try:
installed_packages = package.get_installed_packages()
translation_package = None
for pkg in installed_packages:
if pkg.from_code == source_lang and pkg.to_code == target_lang:
translation_package = pkg
break
if translation_package is None:
msg = (
f"No translation package found for {source_lang} -> {target_lang}. "
"Install packages using: argostranslate --update-languages"
)
raise ValueError(msg)
translated_text = translate.translate(text, source_lang, target_lang)
return {
"translated_text": translated_text,
"source_lang": source_lang,
"target_lang": target_lang,
"source": "argos",
}
except Exception as e:
msg = f"Argos Translate error: {e}"
raise RuntimeError(msg)
def _translate_argos_cli(self, text: str, source_lang: str, target_lang: str) -> dict[str, Any]:
if source_lang == "auto" or not source_lang:
msg = "Auto-detection is not supported with CLI. Please select a source language manually."
raise ValueError(msg)
if not target_lang:
msg = "Target language is required."
raise ValueError(msg)
if not isinstance(source_lang, str) or not isinstance(target_lang, str):
msg = "Language codes must be strings."
raise ValueError(msg)
if len(source_lang) != 2 or len(target_lang) != 2:
msg = f"Invalid language codes: {source_lang} -> {target_lang}"
raise ValueError(msg)
executable = shutil.which("argos-translate")
if not executable:
msg = "argos-translate executable not found in PATH"
raise RuntimeError(msg)
try:
args = [executable, "--from-lang", source_lang, "--to-lang", target_lang, text]
result = subprocess.run(args, capture_output=True, text=True, check=True) # noqa: S603
translated_text = result.stdout.strip()
if not translated_text:
msg = "Translation returned empty result"
raise RuntimeError(msg)
return {
"translated_text": translated_text,
"source_lang": source_lang,
"target_lang": target_lang,
"source": "argos",
}
except subprocess.CalledProcessError as e:
error_msg = e.stderr.decode() if isinstance(e.stderr, bytes) else (e.stderr or str(e))
msg = f"Argos Translate CLI error: {error_msg}"
raise RuntimeError(msg)
except Exception as e:
msg = f"Argos Translate CLI error: {e!s}"
raise RuntimeError(msg)
def _detect_language(self, text: str) -> str | None:
if not self.has_argos_lib:
return None
try:
from argostranslate import translate
installed_packages = package.get_installed_packages()
if not installed_packages:
return None
detected = translate.detect_language(text)
if detected:
return detected.code
except Exception as e:
print(f"Language detection failed: {e}")
return None
def _get_argos_languages_cli(self) -> list[dict[str, str]]:
languages = []
argospm = shutil.which("argospm")
if not argospm:
return languages
try:
result = subprocess.run( # noqa: S603
[argospm, "list"],
capture_output=True,
text=True,
timeout=10,
check=True,
)
installed_packages = result.stdout.strip().split("\n")
argos_langs = set()
for pkg_name in installed_packages:
if not pkg_name.strip():
continue
match = re.match(r"translate-([a-z]{2})_([a-z]{2})", pkg_name.strip())
if match:
from_code = match.group(1)
to_code = match.group(2)
argos_langs.add(from_code)
argos_langs.add(to_code)
for code in sorted(argos_langs):
name = LANGUAGE_CODE_TO_NAME.get(code, code.upper())
languages.append(
{
"code": code,
"name": name,
"source": "argos",
},
)
except subprocess.CalledProcessError as e:
print(f"argospm list failed: {e.stderr or str(e)}")
except Exception as e:
print(f"Error parsing argospm output: {e}")
return languages
def install_language_package(self, package_name: str = "translate") -> dict[str, Any]:
argospm = shutil.which("argospm")
if not argospm:
msg = "argospm not found in PATH. Install argostranslate first."
raise RuntimeError(msg)
try:
result = subprocess.run( # noqa: S603
[argospm, "install", package_name],
capture_output=True,
text=True,
timeout=300,
check=True,
)
return {
"success": True,
"message": f"Successfully installed {package_name}",
"output": result.stdout,
}
except subprocess.TimeoutExpired:
msg = f"Installation of {package_name} timed out after 5 minutes"
raise RuntimeError(msg)
except subprocess.CalledProcessError as e:
msg = f"Failed to install {package_name}: {e.stderr or str(e)}"
raise RuntimeError(msg)
except Exception as e:
msg = f"Error installing {package_name}: {e!s}"
raise RuntimeError(msg)

View File

@@ -0,0 +1,731 @@
<template>
<div
:class="{ dark: config?.theme === 'dark' }"
class="h-screen w-full flex flex-col bg-slate-50 dark:bg-zinc-950 transition-colors"
>
<RouterView v-if="$route.name === 'auth'" />
<template v-else>
<div v-if="isPopoutMode" class="flex flex-1 h-full w-full overflow-hidden bg-slate-50/90 dark:bg-zinc-950">
<RouterView class="flex-1" />
</div>
<template v-else>
<!-- header -->
<div
class="relative z-[60] flex bg-white/80 dark:bg-zinc-900/70 backdrop-blur border-gray-200 dark:border-zinc-800 border-b min-h-16 shadow-sm transition-colors"
>
<div class="flex w-full px-4">
<button
type="button"
class="sm:hidden my-auto mr-4 text-gray-500 hover:text-gray-600 dark:text-gray-400 dark:hover:text-gray-300"
@click="isSidebarOpen = !isSidebarOpen"
>
<MaterialDesignIcon :icon-name="isSidebarOpen ? 'close' : 'menu'" class="size-6" />
</button>
<div
class="hidden sm:flex my-auto w-12 h-12 mr-2 rounded-xl overflow-hidden bg-white/70 dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 shadow-inner"
>
<img class="w-12 h-12 object-contain p-1.5" src="/assets/images/logo-chat-bubble.png" />
</div>
<div class="my-auto">
<div
class="font-semibold cursor-pointer text-gray-900 dark:text-zinc-100 tracking-tight text-lg"
@click="onAppNameClick"
>
{{ $t("app.name") }}
</div>
<div class="hidden sm:block text-sm text-gray-600 dark:text-zinc-300">
{{ $t("app.custom_fork_by") }}
<a
target="_blank"
href="https://github.com/Sudo-Ivan"
class="text-blue-500 dark:text-blue-300 hover:underline"
>Sudo-Ivan</a
>
</div>
</div>
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-2">
<button
type="button"
class="relative rounded-full p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
@click="toggleTheme"
>
<MaterialDesignIcon
:icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
class="w-6 h-6"
/>
</button>
<LanguageSelector @language-change="onLanguageChange" />
<NotificationBell />
<button type="button" class="rounded-full" @click="syncPropagationNode">
<span
class="flex text-gray-800 dark:text-zinc-100 bg-white dark:bg-zinc-800/80 border border-gray-200 dark:border-zinc-700 hover:border-blue-400 dark:hover:border-blue-400/60 px-3 py-1.5 rounded-full shadow-sm transition"
>
<span :class="{ 'animate-spin': isSyncingPropagationNode }">
<MaterialDesignIcon icon-name="refresh" class="size-6" />
</span>
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-medium">{{
$t("app.sync_messages")
}}</span>
</span>
</button>
<button type="button" class="rounded-full" @click="composeNewMessage">
<span
class="flex text-white bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 hover:from-blue-500/90 hover:to-purple-500/90 px-3 py-1.5 rounded-full shadow-md transition"
>
<span>
<MaterialDesignIcon icon-name="email" class="w-6 h-6" />
</span>
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-semibold">{{
$t("app.compose")
}}</span>
</span>
</button>
</div>
</div>
</div>
<!-- middle -->
<div
ref="middle"
class="flex flex-1 w-full overflow-hidden bg-slate-50/80 dark:bg-zinc-950 transition-colors"
>
<!-- sidebar backdrop for mobile -->
<div
v-if="isSidebarOpen"
class="fixed inset-0 z-[65] bg-black/20 backdrop-blur-sm sm:hidden"
@click="isSidebarOpen = false"
></div>
<!-- sidebar -->
<div
class="fixed inset-y-0 left-0 z-[70] w-72 transform transition-transform duration-300 ease-in-out sm:relative sm:z-0 sm:flex sm:translate-x-0"
:class="isSidebarOpen ? 'translate-x-0' : '-translate-x-full'"
>
<div
class="flex h-full w-full flex-col overflow-y-auto border-r border-gray-200/70 bg-white dark:border-zinc-800 dark:bg-zinc-900 backdrop-blur"
>
<!-- navigation -->
<div class="flex-1">
<ul class="py-3 pr-2 space-y-1">
<!-- messages -->
<li>
<SidebarLink :to="{ name: 'messages' }">
<template #icon>
<MaterialDesignIcon
icon-name="message-text"
class="w-6 h-6 dark:text-white"
/>
</template>
<template #text>
<span>{{ $t("app.messages") }}</span>
<span v-if="unreadConversationsCount > 0" class="ml-auto mr-2">{{
unreadConversationsCount
}}</span>
</template>
</SidebarLink>
</li>
<!-- nomad network -->
<li>
<SidebarLink :to="{ name: 'nomadnetwork' }">
<template #icon>
<MaterialDesignIcon icon-name="earth" class="w-6 h-6" />
</template>
<template #text>{{ $t("app.nomad_network") }}</template>
</SidebarLink>
</li>
<!-- map -->
<li>
<SidebarLink :to="{ name: 'map' }">
<template #icon>
<MaterialDesignIcon icon-name="map" class="w-6 h-6" />
</template>
<template #text>{{ $t("app.map") }}</template>
</SidebarLink>
</li>
<!-- archives -->
<li>
<SidebarLink :to="{ name: 'archives' }">
<template #icon>
<MaterialDesignIcon icon-name="archive" class="w-6 h-6" />
</template>
<template #text>{{ $t("app.archives") }}</template>
</SidebarLink>
</li>
<!-- interfaces -->
<li>
<SidebarLink :to="{ name: 'interfaces' }">
<template #icon>
<MaterialDesignIcon icon-name="router" class="w-6 h-6" />
</template>
<template #text>{{ $t("app.interfaces") }}</template>
</SidebarLink>
</li>
<!-- network visualiser -->
<li>
<SidebarLink :to="{ name: 'network-visualiser' }">
<template #icon>
<MaterialDesignIcon icon-name="diagram-projector" class="w-6 h-6" />
</template>
<template #text>{{ $t("app.network_visualiser") }}</template>
</SidebarLink>
</li>
<!-- tools -->
<li>
<SidebarLink :to="{ name: 'tools' }">
<template #icon>
<MaterialDesignIcon icon-name="wrench" class="size-6" />
</template>
<template #text>{{ $t("app.tools") }}</template>
</SidebarLink>
</li>
<!-- settings -->
<li>
<SidebarLink :to="{ name: 'settings' }">
<template #icon>
<MaterialDesignIcon icon-name="cog" class="size-6" />
</template>
<template #text>{{ $t("app.settings") }}</template>
</SidebarLink>
</li>
<!-- info -->
<li>
<SidebarLink :to="{ name: 'about' }">
<template #icon>
<MaterialDesignIcon icon-name="information" class="size-6" />
</template>
<template #text>{{ $t("app.about") }}</template>
</SidebarLink>
</li>
</ul>
</div>
<div>
<!-- my identity -->
<div
v-if="config"
class="bg-white/80 border-t dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur"
>
<div
class="flex text-gray-700 p-3 cursor-pointer"
@click="isShowingMyIdentitySection = !isShowingMyIdentitySection"
>
<div class="my-auto mr-2">
<RouterLink :to="{ name: 'profile.icon' }" @click.stop>
<LxmfUserIcon
:icon-name="config?.lxmf_user_icon_name"
:icon-foreground-colour="config?.lxmf_user_icon_foreground_colour"
:icon-background-colour="config?.lxmf_user_icon_background_colour"
/>
</RouterLink>
</div>
<div class="my-auto dark:text-white">{{ $t("app.my_identity") }}</div>
<div class="my-auto ml-auto">
<button
type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500"
@click.stop="saveIdentitySettings"
>
{{ $t("common.save") }}
</button>
</div>
</div>
<div
v-if="isShowingMyIdentitySection"
class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-800"
>
<div class="p-2">
<input
v-model="displayName"
type="text"
:placeholder="$t('app.display_name_placeholder')"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-800 dark:border-zinc-600 dark:text-zinc-200 dark:focus:ring-blue-400 dark:focus:border-blue-400"
/>
</div>
<div class="p-2 dark:border-zinc-900">
<div>{{ $t("app.identity_hash") }}</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">
{{ config.identity_hash }}
</div>
</div>
<div class="p-2 dark:border-zinc-900">
<div>{{ $t("app.lxmf_address") }}</div>
<div class="text-sm text-gray-700 dark:text-zinc-400">
{{ config.lxmf_address_hash }}
</div>
</div>
</div>
</div>
<!-- auto announce -->
<div
v-if="config"
class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-800"
>
<div
class="flex text-gray-700 p-3 cursor-pointer dark:text-white"
@click="isShowingAnnounceSection = !isShowingAnnounceSection"
>
<div class="my-auto mr-2">
<MaterialDesignIcon icon-name="radio" class="size-6" />
</div>
<div class="my-auto">{{ $t("app.announce") }}</div>
<div class="ml-auto">
<button
type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500"
@click.stop="sendAnnounce"
>
{{ $t("app.announce_now") }}
</button>
</div>
</div>
<div
v-if="isShowingAnnounceSection"
class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-800"
>
<div class="p-2 dark:border-zinc-800">
<select
v-model="config.auto_announce_interval_seconds"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-800 dark:border-zinc-600 dark:text-zinc-200 dark:focus:ring-blue-400 dark:focus:border-blue-400"
@change="onAnnounceIntervalSecondsChange"
>
<option value="0">{{ $t("app.disabled") }}</option>
<option value="900">Every 15 Minutes</option>
<option value="1800">Every 30 Minutes</option>
<option value="3600">Every 1 Hour</option>
<option value="10800">Every 3 Hours</option>
<option value="21600">Every 6 Hours</option>
<option value="43200">Every 12 Hours</option>
<option value="86400">Every 24 Hours</option>
</select>
<div class="text-sm text-gray-700 dark:text-zinc-100">
<span v-if="config.last_announced_at">{{
$t("app.last_announced", {
time: formatSecondsAgo(config.last_announced_at),
})
}}</span>
<span v-else>{{ $t("app.last_announced_never") }}</span>
</div>
</div>
</div>
</div>
<!-- audio calls -->
<div
v-if="config"
class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-800 pb-3"
>
<div
class="flex text-gray-700 p-3 cursor-pointer"
@click="isShowingCallsSection = !isShowingCallsSection"
>
<div class="my-auto mr-2">
<MaterialDesignIcon icon-name="phone" class="dark:text-white w-6 h-6" />
</div>
<div class="my-auto dark:text-white">{{ $t("app.calls") }}</div>
<div class="ml-auto">
<RouterLink
:to="{ name: 'call' }"
class="inline-flex items-center justify-center w-8 h-8 rounded-lg bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300 transition-colors"
>
<MaterialDesignIcon
icon-name="phone"
class="w-3.5 h-3.5 flex-shrink-0"
/>
</RouterLink>
</div>
</div>
<div
v-if="isShowingCallsSection"
class="divide-y text-gray-900 border-t border-gray-200 dark:border-zinc-800"
>
<div class="p-2 flex dark:border-zinc-800 dark:text-white">
<div>
<div>{{ $t("app.status") }}</div>
<div class="text-sm text-gray-700 dark:text-white">
<div v-if="isTelephoneCallActive" class="flex space-x-2">
<span>{{ $t("app.active_call") }}</span>
</div>
<div v-else>{{ $t("app.hung_up_waiting") }}</div>
</div>
</div>
<div v-if="isTelephoneCallActive" class="ml-auto my-auto mr-1 space-x-2">
<!-- hangup all calls -->
<button
:title="$t('app.hangup_all_calls')"
type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-full bg-red-500 p-2 text-sm font-semibold text-white shadow-sm hover:bg-red-400 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
@click="hangupTelephoneCall"
>
<MaterialDesignIcon
icon-name="phone-hangup"
class="w-5 h-5 rotate-[135deg] translate-y-0.5"
/>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="!isPopoutMode" class="flex flex-1 min-w-0 overflow-hidden">
<RouterView class="flex-1 min-w-0 h-full" />
</div>
</div>
</template>
</template>
<CallOverlay
v-if="activeCall || isCallEnded"
:active-call="activeCall || lastCall"
:is-ended="isCallEnded"
/>
<Toast />
</div>
</template>
<script>
import SidebarLink from "./SidebarLink.vue";
import DialogUtils from "../js/DialogUtils";
import WebSocketConnection from "../js/WebSocketConnection";
import GlobalState from "../js/GlobalState";
import Utils from "../js/Utils";
import GlobalEmitter from "../js/GlobalEmitter";
import NotificationUtils from "../js/NotificationUtils";
import LxmfUserIcon from "./LxmfUserIcon.vue";
import Toast from "./Toast.vue";
import ToastUtils from "../js/ToastUtils";
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import NotificationBell from "./NotificationBell.vue";
import LanguageSelector from "./LanguageSelector.vue";
import CallOverlay from "./call/CallOverlay.vue";
export default {
name: "App",
components: {
LxmfUserIcon,
SidebarLink,
Toast,
MaterialDesignIcon,
NotificationBell,
LanguageSelector,
CallOverlay,
},
data() {
return {
reloadInterval: null,
appInfoInterval: null,
isShowingMyIdentitySection: true,
isShowingAnnounceSection: true,
isShowingCallsSection: true,
isSidebarOpen: false,
displayName: "Anonymous Peer",
config: null,
appInfo: null,
isTelephoneCallActive: false,
activeCall: null,
propagationNodeStatus: null,
isCallEnded: false,
lastCall: null,
endedTimeout: null,
};
},
computed: {
currentPopoutType() {
if (this.$route?.meta?.popoutType) {
return this.$route.meta.popoutType;
}
return this.$route?.query?.popout ?? this.getHashPopoutValue();
},
isPopoutMode() {
return this.currentPopoutType != null;
},
unreadConversationsCount() {
return GlobalState.unreadConversationsCount;
},
isSyncingPropagationNode() {
return [
"path_requested",
"link_establishing",
"link_established",
"request_sent",
"receiving",
"response_received",
"complete",
].includes(this.propagationNodeStatus?.state);
},
},
watch: {
$route() {
this.isSidebarOpen = false;
},
config: {
handler(newConfig) {
if (newConfig && newConfig.language) {
this.$i18n.locale = newConfig.language;
}
},
deep: true,
},
},
beforeUnmount() {
clearInterval(this.reloadInterval);
clearInterval(this.appInfoInterval);
if (this.endedTimeout) clearTimeout(this.endedTimeout);
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
this.getAppInfo();
this.getConfig();
this.updateTelephoneStatus();
this.updatePropagationNodeStatus();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.updateTelephoneStatus();
this.updatePropagationNodeStatus();
}, 1000);
this.appInfoInterval = setInterval(() => {
this.getAppInfo();
}, 15000);
},
methods: {
getHashPopoutValue() {
const hash = window.location.hash || "";
const match = hash.match(/popout=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch (json.type) {
case "config": {
this.config = json.config;
this.displayName = json.config.display_name;
break;
}
case "announced": {
// we just announced, update config so we can show the new last updated at
this.getConfig();
break;
}
case "telephone_ringing": {
NotificationUtils.showIncomingCallNotification();
this.updateTelephoneStatus();
break;
}
}
},
async getAppInfo() {
try {
const response = await window.axios.get(`/api/v1/app/info`);
this.appInfo = response.data.app_info;
} catch (e) {
// do nothing if failed to load app info
console.log(e);
}
},
async getConfig() {
try {
const response = await window.axios.get(`/api/v1/config`);
this.config = response.data.config;
} catch (e) {
// do nothing if failed to load config
console.log(e);
}
},
async sendAnnounce() {
try {
await window.axios.get(`/api/v1/announce`);
} catch (e) {
ToastUtils.error("failed to announce");
console.log(e);
}
// fetch config so it updates last announced timestamp
await this.getConfig();
},
async updateConfig(config) {
try {
WebSocketConnection.send(
JSON.stringify({
type: "config.set",
config: config,
})
);
} catch (e) {
console.error(e);
}
},
async saveIdentitySettings() {
await this.updateConfig({
display_name: this.displayName,
});
},
async onAnnounceIntervalSecondsChange() {
await this.updateConfig({
auto_announce_interval_seconds: this.config.auto_announce_interval_seconds,
});
},
async toggleTheme() {
if (!this.config) {
return;
}
const newTheme = this.config.theme === "dark" ? "light" : "dark";
await this.updateConfig({
theme: newTheme,
});
},
async onLanguageChange(langCode) {
await this.updateConfig({
language: langCode,
});
this.$i18n.locale = langCode;
},
async composeNewMessage() {
// go to messages route
await this.$router.push({ name: "messages" });
// emit global event handled by MessagesPage
GlobalEmitter.emit("compose-new-message");
},
async syncPropagationNode() {
// ask to stop syncing if already syncing
if (this.isSyncingPropagationNode) {
if (await DialogUtils.confirm("Are you sure you want to stop syncing?")) {
await this.stopSyncingPropagationNode();
}
return;
}
// request sync
try {
await axios.get("/api/v1/lxmf/propagation-node/sync");
} catch (e) {
const errorMessage = e.response?.data?.message ?? "Something went wrong. Try again later.";
ToastUtils.error(errorMessage);
return;
}
// update propagation status
await this.updatePropagationNodeStatus();
// wait until sync has finished
const syncFinishedInterval = setInterval(() => {
// do nothing if still syncing
if (this.isSyncingPropagationNode) {
return;
}
// finished syncing, stop checking
clearInterval(syncFinishedInterval);
// show result
const status = this.propagationNodeStatus?.state;
const messagesReceived = this.propagationNodeStatus?.messages_received ?? 0;
if (status === "complete" || status === "idle") {
ToastUtils.success(`Sync complete. ${messagesReceived} messages received.`);
} else {
ToastUtils.error(`Sync error: ${status}`);
}
}, 500);
},
async stopSyncingPropagationNode() {
// stop sync
try {
await axios.get("/api/v1/lxmf/propagation-node/stop-sync");
} catch {
// do nothing on error
}
// update propagation status
await this.updatePropagationNodeStatus();
},
async updatePropagationNodeStatus() {
try {
const response = await axios.get("/api/v1/lxmf/propagation-node/status");
this.propagationNodeStatus = response.data.propagation_node_status;
} catch {
// do nothing on error
}
},
formatSecondsAgo: function (seconds) {
return Utils.formatSecondsAgo(seconds);
},
async updateTelephoneStatus() {
try {
// fetch status
const response = await axios.get("/api/v1/telephone/status");
const oldCall = this.activeCall;
// update ui
this.activeCall = response.data.active_call;
this.isTelephoneCallActive = this.activeCall != null;
// If call just ended, show ended state for a few seconds
if (oldCall != null && this.activeCall == null) {
this.lastCall = oldCall;
this.isCallEnded = true;
if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.endedTimeout = setTimeout(() => {
this.isCallEnded = false;
this.lastCall = null;
}, 5000);
} else if (this.activeCall != null) {
// if a new call starts, clear ended state
this.isCallEnded = false;
this.lastCall = null;
if (this.endedTimeout) clearTimeout(this.endedTimeout);
}
} catch {
// do nothing on error
}
},
async hangupTelephoneCall() {
// confirm user wants to hang up call
if (!(await DialogUtils.confirm("Are you sure you want to hang up the current telephone call?"))) {
return;
}
try {
// hangup call
await axios.get(`/api/v1/telephone/hangup`);
// reload status
await this.updateTelephoneStatus();
} catch {
// ignore error hanging up call
}
},
onAppNameClick() {
// user may be on mobile, and is unable to scroll back to sidebar, so let them tap app name to do it
this.$refs["middle"].scrollTo({
top: 0,
left: 0,
behavior: "smooth",
});
},
},
};
</script>

View File

@@ -0,0 +1,152 @@
<template>
<div class="card-stack-wrapper" :class="{ 'is-expanded': isExpanded }">
<div
v-if="items && items.length > 0"
class="relative"
:class="{ 'stack-mode': !isExpanded && items.length > 1, 'grid-mode': isExpanded || items.length === 1 }"
>
<!-- Grid Mode (Expanded or only 1 item) -->
<div v-if="isExpanded || items.length === 1" :class="gridClass">
<div v-for="(item, index) in items" :key="index" class="w-full">
<slot :item="item" :index="index"></slot>
</div>
</div>
<!-- Stack Mode (Collapsed and > 1 item) -->
<div v-else class="relative" :style="{ height: stackHeight + 'px' }">
<div
v-for="(item, index) in stackedItems"
:key="index"
class="absolute inset-x-0 top-0 transition-all duration-300 ease-in-out cursor-pointer"
:style="getStackStyle(index)"
@click="onCardClick(index)"
>
<slot :item="item" :index="index"></slot>
<!-- Overlay for non-top cards -->
<div
v-if="index > 0"
class="absolute inset-0 bg-white/20 dark:bg-black/20 rounded-[inherit] pointer-events-none"
></div>
</div>
<!-- Controls -->
<div v-if="items.length > 1" class="absolute -bottom-2 right-0 flex items-center gap-2 z-[60]">
<div class="text-xs font-mono text-gray-500 dark:text-gray-400 mr-2">
{{ activeIndex + 1 }} / {{ items.length }}
</div>
<button
class="p-1.5 rounded-full bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-gray-300 transition shadow-sm border border-gray-200 dark:border-zinc-700"
title="Previous"
@click.stop="prev"
>
<MaterialDesignIcon icon-name="chevron-left" class="size-5" />
</button>
<button
class="p-1.5 rounded-full bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-gray-300 transition shadow-sm border border-gray-200 dark:border-zinc-700"
title="Next"
@click.stop="next"
>
<MaterialDesignIcon icon-name="chevron-right" class="size-5" />
</button>
</div>
</div>
</div>
<div v-if="items && items.length > 1" class="mt-4 flex justify-center">
<button
class="flex items-center gap-1.5 px-4 py-1.5 rounded-full bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-xs font-bold text-gray-700 dark:text-gray-300 transition shadow-sm border border-gray-200 dark:border-zinc-700 uppercase tracking-wider"
@click="isExpanded = !isExpanded"
>
<MaterialDesignIcon :icon-name="isExpanded ? 'collapse-all' : 'expand-all'" class="size-4" />
{{ isExpanded ? "Collapse Stack" : `Show All ${items.length}` }}
</button>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
export default {
name: "CardStack",
components: {
MaterialDesignIcon,
},
props: {
items: {
type: Array,
required: true,
},
maxVisible: {
type: Number,
default: 3,
},
stackHeight: {
type: Number,
default: 320,
},
gridClass: {
type: String,
default: "grid grid-cols-1 gap-4",
},
},
data() {
return {
isExpanded: false,
activeIndex: 0,
};
},
computed: {
stackedItems() {
// Reorder items so the active item is at index 0
const result = [];
const count = Math.min(this.items.length, this.maxVisible);
for (let i = 0; i < count; i++) {
const idx = (this.activeIndex + i) % this.items.length;
result.push(this.items[idx]);
}
return result;
},
},
methods: {
next() {
this.activeIndex = (this.activeIndex + 1) % this.items.length;
},
prev() {
this.activeIndex = (this.activeIndex - 1 + this.items.length) % this.items.length;
},
onCardClick(index) {
if (index > 0) {
// If clicked a background card, bring it to front
this.activeIndex = (this.activeIndex + index) % this.items.length;
}
},
getStackStyle(index) {
if (this.isExpanded) return {};
const offset = 8; // px
const scaleReduce = 0.05;
return {
zIndex: 50 - index,
transform: `translateY(${index * offset}px) scale(${1 - index * scaleReduce})`,
opacity: 1 - index * 0.2,
pointerEvents: index === 0 ? "auto" : "auto",
};
},
},
};
</script>
<style scoped>
.card-stack-wrapper {
width: 100%;
}
.stack-mode {
perspective: 1000px;
}
</style>

View File

@@ -0,0 +1,94 @@
<template>
<div
v-click-outside="{ handler: onClickOutsideMenu, capture: true }"
class="cursor-default relative inline-block text-left"
>
<!-- menu button -->
<div ref="dropdown-button" @click.stop="toggleMenu">
<slot>
<div
class="size-8 border border-gray-300 dark:border-zinc-700 rounded shadow cursor-pointer"
:style="{ 'background-color': colour }"
></div>
</slot>
</div>
<!-- drop down menu -->
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div v-if="isShowingMenu" class="absolute left-0 z-10 ml-4">
<v-color-picker
v-model="colourPickerValue"
:modes="['hex']"
hide-inputs
hide-sliders
show-swatches
></v-color-picker>
</div>
</Transition>
</div>
</template>
<script>
export default {
name: "ColourPickerDropdown",
props: {
colour: {
type: String,
default: "",
},
},
emits: ["update:colour"],
data() {
return {
isShowingMenu: false,
colourPickerValue: null,
};
},
watch: {
colour() {
// update internal colour picker value when parent changes value of v-model:colour
this.colourPickerValue = this.colour;
},
colourPickerValue() {
// get current colour picker value
var value = this.colourPickerValue;
// remove alpha channel from hex colour if present
if (value.length === 9) {
value = value.substring(0, 7);
}
// fire v-model:colour update event
this.$emit("update:colour", value);
},
},
methods: {
toggleMenu() {
if (this.isShowingMenu) {
this.hideMenu();
} else {
this.showMenu();
}
},
showMenu() {
this.isShowingMenu = true;
},
hideMenu() {
this.isShowingMenu = false;
},
onClickOutsideMenu(event) {
if (this.isShowingMenu) {
event.preventDefault();
this.hideMenu();
}
},
},
};
</script>

View File

@@ -0,0 +1,101 @@
<template>
<div
v-click-outside="{ handler: onClickOutsideMenu, capture: true }"
class="cursor-default relative inline-block text-left"
>
<!-- menu button -->
<div ref="dropdown-button" @click.stop="toggleMenu">
<slot name="button" />
</div>
<!-- drop down menu -->
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="isShowingMenu"
class="overflow-hidden absolute right-0 z-50 mr-4 w-56 rounded-md bg-white dark:bg-zinc-800 shadow-lg border border-gray-200 dark:border-zinc-700 focus:outline-none"
:class="[dropdownClass]"
@click.stop="hideMenu"
>
<slot name="items" />
</div>
</Transition>
</div>
</template>
<script>
export default {
name: "DropDownMenu",
data() {
return {
isShowingMenu: false,
dropdownClass: null,
};
},
methods: {
toggleMenu() {
if (this.isShowingMenu) {
this.hideMenu();
} else {
this.showMenu();
}
},
showMenu() {
this.isShowingMenu = true;
this.adjustDropdownPosition();
},
hideMenu() {
this.isShowingMenu = false;
},
onClickOutsideMenu(event) {
if (this.isShowingMenu) {
event.preventDefault();
this.hideMenu();
}
},
adjustDropdownPosition() {
this.$nextTick(() => {
// find button and dropdown
const button = this.$refs["dropdown-button"];
if (!button) {
return;
}
const dropdown = button.parentElement?.querySelector(".absolute");
if (!dropdown) {
return;
}
// get bounding box of button
const buttonRect = button.getBoundingClientRect();
// calculate how much space is under and above the button
const spaceBelowButton = window.innerHeight - buttonRect.bottom;
const spaceAboveButton = buttonRect.top;
// estimate dropdown height (will be measured after render)
const estimatedDropdownHeight = 150;
// calculate if there is enough space available to show dropdown
const hasEnoughSpaceAboveButton = spaceAboveButton > estimatedDropdownHeight;
const hasEnoughSpaceBelowButton = spaceBelowButton > estimatedDropdownHeight;
// show dropdown above button
if (hasEnoughSpaceAboveButton && !hasEnoughSpaceBelowButton) {
this.dropdownClass = "bottom-0 mb-12";
return;
}
// otherwise fallback to showing dropdown below button
this.dropdownClass = "top-0 mt-12";
});
},
},
};
</script>

View File

@@ -0,0 +1,13 @@
<template>
<div
class="cursor-pointer flex p-3 space-x-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-700"
>
<slot />
</div>
</template>
<script>
export default {
name: "DropDownMenuItem",
};
</script>

View File

@@ -0,0 +1,14 @@
<template>
<button
type="button"
class="text-gray-500 hover:text-gray-700 dark:text-zinc-400 dark:hover:text-zinc-100 hover:bg-gray-100 dark:hover:bg-zinc-800 p-2 rounded-full w-8 h-8 flex items-center justify-center flex-shrink-0 transition-all duration-200"
>
<slot />
</button>
</template>
<script>
export default {
name: "IconButton",
};
</script>

View File

@@ -0,0 +1,95 @@
<template>
<div class="relative">
<button
type="button"
class="relative rounded-full p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
:title="$t('app.language')"
@click="toggleDropdown"
>
<MaterialDesignIcon icon-name="translate" class="w-6 h-6" />
</button>
<div
v-if="isDropdownOpen"
v-click-outside="closeDropdown"
class="absolute right-0 mt-2 w-48 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] overflow-hidden"
>
<div class="p-2">
<button
v-for="lang in languages"
:key="lang.code"
type="button"
class="w-full px-4 py-2 text-left rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors flex items-center justify-between"
:class="{
'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400':
currentLanguage === lang.code,
'text-gray-900 dark:text-zinc-100': currentLanguage !== lang.code,
}"
@click="selectLanguage(lang.code)"
>
<span class="font-medium">{{ lang.name }}</span>
<MaterialDesignIcon v-if="currentLanguage === lang.code" icon-name="check" class="w-5 h-5" />
</button>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
export default {
name: "LanguageSelector",
components: {
MaterialDesignIcon,
},
directives: {
"click-outside": {
mounted(el, binding) {
el.clickOutsideEvent = function (event) {
if (!(el === event.target || el.contains(event.target))) {
binding.value();
}
};
document.addEventListener("click", el.clickOutsideEvent);
},
unmounted(el) {
document.removeEventListener("click", el.clickOutsideEvent);
},
},
},
emits: ["language-change"],
data() {
return {
isDropdownOpen: false,
languages: [
{ code: "en", name: "English" },
{ code: "de", name: "Deutsch" },
{ code: "ru", name: "Русский" },
],
};
},
computed: {
currentLanguage() {
return this.$i18n.locale;
},
},
methods: {
toggleDropdown() {
this.isDropdownOpen = !this.isDropdownOpen;
},
closeDropdown() {
this.isDropdownOpen = false;
},
async selectLanguage(langCode) {
if (this.currentLanguage === langCode) {
this.closeDropdown();
return;
}
this.$emit("language-change", langCode);
this.closeDropdown();
},
},
};
</script>

View File

@@ -0,0 +1,36 @@
<template>
<div
v-if="iconName"
class="p-2 rounded"
:style="{ color: iconForegroundColour, 'background-color': iconBackgroundColour }"
>
<MaterialDesignIcon :icon-name="iconName" class="size-6" />
</div>
<div v-else class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded">
<MaterialDesignIcon icon-name="account-outline" class="size-6" />
</div>
</template>
<script>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
export default {
name: "LxmfUserIcon",
components: {
MaterialDesignIcon,
},
props: {
iconName: {
type: String,
default: "",
},
iconForegroundColour: {
type: String,
default: "",
},
iconBackgroundColour: {
type: String,
default: "",
},
},
};
</script>

View File

@@ -0,0 +1,47 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
role="img"
:aria-label="iconName"
fill="currentColor"
style="display: inline-block; vertical-align: middle"
>
<path :d="iconPath" />
</svg>
</template>
<script>
import * as mdi from "@mdi/js";
export default {
name: "MaterialDesignIcon",
props: {
iconName: {
type: String,
required: true,
},
},
computed: {
mdiIconName() {
// convert icon name from lxmf icon appearance to format expected by the @mdi/js library
// e.g: alien-outline -> mdiAlienOutline
// https://pictogrammers.github.io/@mdi/font/5.4.55/
return (
"mdi" +
this.iconName
.split("-")
.map((word) => {
// capitalise first letter of each part
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join("")
);
},
iconPath() {
// find icon, otherwise fallback to question mark, and if that doesn't exist, show nothing...
return mdi[this.mdiIconName] || mdi["mdiProgressQuestion"] || "";
},
},
};
</script>

View File

@@ -0,0 +1,237 @@
<template>
<div class="relative">
<button
type="button"
class="relative rounded-full p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
@click="toggleDropdown"
>
<MaterialDesignIcon icon-name="bell" class="w-6 h-6" />
<span
v-if="unreadCount > 0"
class="absolute top-0 right-0 flex h-5 w-5 items-center justify-center rounded-full bg-red-500 text-xs font-semibold text-white"
>
{{ unreadCount > 9 ? "9+" : unreadCount }}
</span>
</button>
<div
v-if="isDropdownOpen"
v-click-outside="closeDropdown"
class="absolute right-0 mt-2 w-80 sm:w-96 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl shadow-xl z-[9999] max-h-[500px] overflow-hidden flex flex-col"
>
<div class="p-4 border-b border-gray-200 dark:border-zinc-800">
<div class="flex items-center justify-between">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Notifications</h3>
<button
type="button"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
@click="closeDropdown"
>
<MaterialDesignIcon icon-name="close" class="w-5 h-5" />
</button>
</div>
</div>
<div class="overflow-y-auto flex-1">
<div v-if="isLoading" class="p-8 text-center">
<div class="inline-block animate-spin text-gray-400">
<MaterialDesignIcon icon-name="refresh" class="w-6 h-6" />
</div>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">Loading notifications...</div>
</div>
<div v-else-if="notifications.length === 0" class="p-8 text-center">
<MaterialDesignIcon
icon-name="bell-off"
class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500"
/>
<div class="mt-2 text-sm text-gray-500 dark:text-gray-400">No new notifications</div>
</div>
<div v-else class="divide-y divide-gray-200 dark:divide-zinc-800">
<button
v-for="notification in notifications"
:key="notification.destination_hash"
type="button"
class="w-full p-4 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors text-left"
@click="onNotificationClick(notification)"
>
<div class="flex gap-3">
<div class="flex-shrink-0">
<div
v-if="notification.lxmf_user_icon"
class="p-2 rounded-lg"
:style="{
color: notification.lxmf_user_icon.foreground_colour,
'background-color': notification.lxmf_user_icon.background_colour,
}"
>
<MaterialDesignIcon
:icon-name="notification.lxmf_user_icon.icon_name"
class="w-6 h-6"
/>
</div>
<div
v-else
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded-lg"
>
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
</div>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2 mb-1">
<div
class="font-semibold text-gray-900 dark:text-white truncate"
:title="notification.custom_display_name ?? notification.display_name"
>
{{ notification.custom_display_name ?? notification.display_name }}
</div>
<div
class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap flex-shrink-0"
>
{{ formatTimeAgo(notification.updated_at) }}
</div>
</div>
<div
class="text-sm text-gray-600 dark:text-gray-400 line-clamp-2"
:title="
notification.latest_message_preview ??
notification.latest_message_title ??
'No message preview'
"
>
{{
notification.latest_message_preview ??
notification.latest_message_title ??
"No message preview"
}}
</div>
</div>
</div>
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import Utils from "../js/Utils";
import WebSocketConnection from "../js/WebSocketConnection";
export default {
name: "NotificationBell",
components: {
MaterialDesignIcon,
},
directives: {
"click-outside": {
mounted(el, binding) {
el.clickOutsideEvent = function (event) {
if (!(el === event.target || el.contains(event.target))) {
binding.value();
}
};
document.addEventListener("click", el.clickOutsideEvent);
},
unmounted(el) {
document.removeEventListener("click", el.clickOutsideEvent);
},
},
},
data() {
return {
isDropdownOpen: false,
isLoading: false,
notifications: [],
reloadInterval: null,
manuallyCleared: false,
};
},
computed: {
unreadCount() {
if (this.manuallyCleared) {
return 0;
}
return this.notifications.length;
},
},
beforeUnmount() {
if (this.reloadInterval) {
clearInterval(this.reloadInterval);
}
WebSocketConnection.off("message", this.onWebsocketMessage);
},
mounted() {
this.loadNotifications();
WebSocketConnection.on("message", this.onWebsocketMessage);
this.reloadInterval = setInterval(() => {
if (this.isDropdownOpen) {
this.loadNotifications();
}
}, 5000);
},
methods: {
toggleDropdown() {
this.isDropdownOpen = !this.isDropdownOpen;
if (this.isDropdownOpen) {
this.loadNotifications();
this.manuallyCleared = true;
}
},
closeDropdown() {
this.isDropdownOpen = false;
},
async loadNotifications() {
this.isLoading = true;
try {
const response = await window.axios.get(`/api/v1/lxmf/conversations`, {
params: {
filter_unread: true,
limit: 10,
},
});
const newNotifications = response.data.conversations || [];
// if we have more notifications than before, show the red dot again
if (newNotifications.length > this.notifications.length) {
this.manuallyCleared = false;
}
this.notifications = newNotifications;
} catch (e) {
console.error("Failed to load notifications", e);
this.notifications = [];
} finally {
this.isLoading = false;
}
},
onNotificationClick(notification) {
this.closeDropdown();
this.$router.push({
name: "messages",
params: { destinationHash: notification.destination_hash },
});
},
formatTimeAgo(datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
if (json.type === "lxmf.delivery") {
await this.loadNotifications();
}
},
},
};
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,44 @@
<template>
<RouterLink v-slot="{ href, navigate, isActive }" :to="to" custom>
<a
:href="href"
type="button"
:class="[
isActive
? 'bg-blue-100 text-blue-800 group:text-blue-800 dark:bg-zinc-800 dark:text-blue-300'
: 'hover:bg-gray-100 dark:hover:bg-zinc-700',
]"
class="w-full text-gray-800 dark:text-zinc-200 group flex gap-x-3 rounded-r-full p-2 mr-2 text-sm leading-6 font-semibold focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600 dark:focus-visible:outline-zinc-500"
@click="handleNavigate($event, navigate)"
>
<span class="my-auto">
<slot name="icon"></slot>
</span>
<span class="my-auto flex w-full">
<slot name="text"></slot>
</span>
</a>
</RouterLink>
</template>
<script>
export default {
name: "SidebarLink",
props: {
to: {
type: Object,
required: true,
},
},
emits: ["click"],
methods: {
handleNavigate(event, navigate) {
// emit click event for SidebarLink element
this.$emit("click");
// handle navigation
navigate(event);
},
},
};
</script>

View File

@@ -0,0 +1,122 @@
<template>
<div class="fixed bottom-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
<TransitionGroup name="toast">
<div
v-for="toast in toasts"
:key="toast.id"
class="pointer-events-auto flex items-center p-4 min-w-[300px] max-w-md rounded-xl shadow-lg border backdrop-blur-md transition-all duration-300"
:class="toastClass(toast.type)"
>
<!-- icon -->
<div class="mr-3 flex-shrink-0">
<MaterialDesignIcon
v-if="toast.type === 'success'"
icon-name="check-circle"
class="h-6 w-6 text-green-500"
/>
<MaterialDesignIcon
v-else-if="toast.type === 'error'"
icon-name="alert-circle"
class="h-6 w-6 text-red-500"
/>
<MaterialDesignIcon
v-else-if="toast.type === 'warning'"
icon-name="alert"
class="h-6 w-6 text-amber-500"
/>
<MaterialDesignIcon v-else icon-name="information" class="h-6 w-6 text-blue-500" />
</div>
<!-- content -->
<div class="flex-1 mr-2 text-sm font-medium text-gray-900 dark:text-zinc-100">
{{ toast.message }}
</div>
<!-- close button -->
<button
class="ml-auto text-gray-400 hover:text-gray-600 dark:hover:text-zinc-300"
@click="remove(toast.id)"
>
<MaterialDesignIcon icon-name="close" class="h-4 w-4" />
</button>
</div>
</TransitionGroup>
</div>
</template>
<script>
import GlobalEmitter from "../js/GlobalEmitter";
import MaterialDesignIcon from "./MaterialDesignIcon.vue";
export default {
name: "Toast",
components: {
MaterialDesignIcon,
},
data() {
return {
toasts: [],
counter: 0,
};
},
mounted() {
GlobalEmitter.on("toast", (toast) => {
this.add(toast);
});
},
beforeUnmount() {
GlobalEmitter.off("toast");
},
methods: {
add(toast) {
const id = this.counter++;
const newToast = {
id,
message: toast.message,
type: toast.type || "info",
duration: toast.duration || 5000,
};
this.toasts.push(newToast);
if (newToast.duration > 0) {
setTimeout(() => {
this.remove(id);
}, newToast.duration);
}
},
remove(id) {
const index = this.toasts.findIndex((t) => t.id === id);
if (index !== -1) {
this.toasts.splice(index, 1);
}
},
toastClass(type) {
switch (type) {
case "success":
return "bg-white/90 dark:bg-zinc-900/90 border-green-500/30";
case "error":
return "bg-white/90 dark:bg-zinc-900/90 border-red-500/30";
case "warning":
return "bg-white/90 dark:bg-zinc-900/90 border-amber-500/30";
default:
return "bg-white/90 dark:bg-zinc-900/90 border-blue-500/30";
}
},
},
};
</script>
<style scoped>
.toast-enter-active,
.toast-leave-active {
transition: all 0.3s ease;
}
.toast-enter-from {
opacity: 0;
transform: translateX(30px);
}
.toast-leave-to {
opacity: 0;
transform: translateX(30px);
}
</style>

View File

@@ -0,0 +1,833 @@
<template>
<div
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 px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-6xl mx-auto">
<div v-if="appInfo" class="glass-card">
<div class="flex flex-col gap-4 md:flex-row md:items-center">
<div class="flex-1 space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("about.title") }}
</div>
<div class="text-3xl font-semibold text-gray-900 dark:text-white">Reticulum MeshChatX</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("about.version", { version: appInfo.version }) }}
{{ $t("about.rns_version", { version: appInfo.rns_version }) }}
{{ $t("about.lxmf_version", { version: appInfo.lxmf_version }) }}
{{ $t("about.python_version", { version: appInfo.python_version }) }}
</div>
</div>
<div v-if="isElectron" class="flex flex-col sm:flex-row gap-2">
<button
type="button"
class="primary-chip px-4 py-2 text-sm justify-center"
@click="relaunch"
>
<MaterialDesignIcon icon-name="restart" class="w-4 h-4" />
{{ $t("common.restart_app") }}
</button>
</div>
</div>
<div class="grid gap-3 sm:grid-cols-3 mt-4 text-sm text-gray-700 dark:text-gray-300">
<div>
<div class="glass-label">{{ $t("about.config_path") }}</div>
<div class="monospace-field break-all">{{ appInfo.reticulum_config_path }}</div>
<button
v-if="isElectron"
type="button"
class="secondary-chip mt-2 text-xs"
@click="showReticulumConfigFile"
>
<MaterialDesignIcon icon-name="folder" class="w-4 h-4" />
{{ $t("common.reveal") }}
</button>
</div>
<div>
<div class="glass-label">{{ $t("about.database_path") }}</div>
<div class="monospace-field break-all">{{ appInfo.database_path }}</div>
<button
v-if="isElectron"
type="button"
class="secondary-chip mt-2 text-xs"
@click="showDatabaseFile"
>
<MaterialDesignIcon icon-name="database" class="w-4 h-4" />
{{ $t("common.reveal") }}
</button>
</div>
<div>
<div class="glass-label">{{ $t("about.database_size") }}</div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{
formatBytes(
appInfo.database_files
? appInfo.database_files.total_bytes
: appInfo.database_file_size
)
}}
</div>
<div v-if="appInfo.database_files" class="text-xs text-gray-500 dark:text-gray-400">
Main {{ formatBytes(appInfo.database_files.main_bytes) }} WAL
{{ formatBytes(appInfo.database_files.wal_bytes) }}
</div>
</div>
</div>
</div>
<div class="glass-card space-y-4">
<div class="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("about.database_health") }}
</div>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("about.database_health_description") }}
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="secondary-chip px-3 py-2 text-xs"
:disabled="databaseActionInProgress || healthLoading"
@click="getDatabaseHealth(true)"
>
<MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
{{ $t("common.refresh") }}
</button>
<button
type="button"
class="secondary-chip px-3 py-2 text-xs"
:disabled="databaseActionInProgress || healthLoading"
@click="vacuumDatabase"
>
<MaterialDesignIcon icon-name="broom" class="w-4 h-4" />
{{ $t("common.vacuum") }}
</button>
<button
type="button"
class="primary-chip px-3 py-2 text-xs"
:disabled="databaseActionInProgress || healthLoading"
@click="recoverDatabase"
>
<MaterialDesignIcon icon-name="shield-sync" class="w-4 h-4" />
{{ $t("common.auto_recover") }}
</button>
</div>
</div>
<div v-if="databaseActionMessage" class="text-xs text-emerald-600">{{ databaseActionMessage }}</div>
<div v-if="databaseActionError" class="text-xs text-red-600">{{ databaseActionError }}</div>
<div v-if="healthLoading" class="text-sm text-gray-500 dark:text-gray-400">
{{ $t("about.running_checks") }}
</div>
<div
v-if="databaseHealth"
class="grid gap-3 sm:grid-cols-3 text-sm text-gray-700 dark:text-gray-300"
>
<div>
<div class="glass-label">{{ $t("about.integrity") }}</div>
<div class="metric-value">{{ databaseHealth.quick_check }}</div>
</div>
<div>
<div class="glass-label">{{ $t("about.journal_mode") }}</div>
<div class="metric-value">{{ databaseHealth.journal_mode }}</div>
</div>
<div>
<div class="glass-label">{{ $t("about.wal_autocheckpoint") }}</div>
<div class="metric-value">
{{
databaseHealth.wal_autocheckpoint !== null &&
databaseHealth.wal_autocheckpoint !== undefined
? databaseHealth.wal_autocheckpoint
: "—"
}}
</div>
</div>
<div>
<div class="glass-label">{{ $t("about.page_size") }}</div>
<div class="metric-value">{{ formatBytes(databaseHealth.page_size) }}</div>
</div>
<div>
<div class="glass-label">{{ $t("about.pages_free") }}</div>
<div class="metric-value">
{{ formatNumber(databaseHealth.page_count) }} /
{{ formatNumber(databaseHealth.freelist_pages) }}
</div>
</div>
<div>
<div class="glass-label">{{ $t("about.free_space_estimate") }}</div>
<div class="metric-value">{{ formatBytes(databaseHealth.estimated_free_bytes) }}</div>
</div>
</div>
<div v-else-if="!healthLoading" class="text-sm text-gray-500 dark:text-gray-400">
Health data will appear after the first refresh.
</div>
<div
v-if="databaseRecoveryActions.length"
class="text-xs text-gray-600 dark:text-gray-400 border-t border-gray-200 dark:border-gray-800 pt-3"
>
<div class="font-semibold text-gray-800 dark:text-gray-200 mb-1">Last recovery steps</div>
<ul class="list-disc list-inside space-y-1">
<li v-for="(action, index) in databaseRecoveryActions" :key="index">
<span class="font-medium text-gray-700 dark:text-gray-300">{{ action.step }}:</span>
<span class="ml-1">{{ formatRecoveryResult(action.result) }}</span>
</li>
</ul>
</div>
<div class="border-t border-gray-200 dark:border-gray-800 pt-3 space-y-3">
<div class="font-semibold text-gray-900 dark:text-white">Backups</div>
<button
type="button"
class="secondary-chip px-3 py-2 text-xs"
:disabled="backupInProgress"
@click="backupDatabase"
>
<MaterialDesignIcon icon-name="content-save" class="w-4 h-4" />
Download Backup
</button>
<div v-if="backupMessage" class="text-xs text-emerald-600">{{ backupMessage }}</div>
<div v-if="backupError" class="text-xs text-red-600">{{ backupError }}</div>
<div class="font-semibold text-gray-900 dark:text-white pt-2">Restore</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<input type="file" accept=".zip,.db" class="file-input" @change="onRestoreFileChange" />
<button
type="button"
class="primary-chip px-3 py-2 text-xs"
:disabled="restoreInProgress"
@click="restoreDatabase"
>
<MaterialDesignIcon icon-name="database-sync" class="w-4 h-4" />
Restore
</button>
</div>
<div v-if="restoreFileName" class="text-xs text-gray-600 dark:text-gray-400">
Selected: {{ restoreFileName }}
</div>
<div v-if="restoreMessage" class="text-xs text-emerald-600">{{ restoreMessage }}</div>
<div v-if="restoreError" class="text-xs text-red-600">{{ restoreError }}</div>
<div class="border-t border-gray-200 dark:border-gray-800 pt-3 space-y-3">
<div class="font-semibold text-gray-900 dark:text-white">Identity Backup & Restore</div>
<div class="text-xs text-red-600">
Never share this identity. It grants full control. Clear your clipboard after copying.
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="secondary-chip px-3 py-2 text-xs"
@click="downloadIdentityFile"
>
<MaterialDesignIcon icon-name="content-save" class="w-4 h-4" />
Download Identity File
</button>
<button
type="button"
class="secondary-chip px-3 py-2 text-xs"
@click="copyIdentityBase32"
>
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4" />
Copy Base32 Identity
</button>
</div>
<div v-if="identityBackupMessage" class="text-xs text-emerald-600">
{{ identityBackupMessage }}
</div>
<div v-if="identityBackupError" class="text-xs text-red-600">{{ identityBackupError }}</div>
<div v-if="identityBase32Message" class="text-xs text-emerald-600">
{{ identityBase32Message }}
</div>
<div v-if="identityBase32Error" class="text-xs text-red-600">{{ identityBase32Error }}</div>
<div class="font-semibold text-gray-900 dark:text-white pt-2">Restore from file</div>
<div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3">
<input
type="file"
accept=".identity,.bin,.key"
class="file-input"
@change="onIdentityRestoreFileChange"
/>
<button
type="button"
class="primary-chip px-3 py-2 text-xs"
:disabled="identityRestoreInProgress"
@click="restoreIdentityFile"
>
<MaterialDesignIcon icon-name="database-sync" class="w-4 h-4" />
Restore Identity
</button>
</div>
<div v-if="identityRestoreFileName" class="text-xs text-gray-600 dark:text-gray-400">
Selected: {{ identityRestoreFileName }}
</div>
<div class="font-semibold text-gray-900 dark:text-white pt-2">Restore from base32</div>
<textarea v-model="identityRestoreBase32" rows="3" class="input-field"></textarea>
<button
type="button"
class="primary-chip px-3 py-2 text-xs"
:disabled="identityRestoreInProgress"
@click="restoreIdentityBase32"
>
<MaterialDesignIcon icon-name="database-sync" class="w-4 h-4" />
Restore Identity
</button>
<div v-if="identityRestoreMessage" class="text-xs text-emerald-600">
{{ identityRestoreMessage }}
</div>
<div v-if="identityRestoreError" class="text-xs text-red-600">
{{ identityRestoreError }}
</div>
</div>
</div>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div v-if="appInfo?.memory_usage" class="glass-card space-y-3">
<header class="flex items-center gap-2">
<MaterialDesignIcon icon-name="chip" class="w-5 h-5 text-blue-500" />
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("about.system_resources") }}
</div>
<div class="text-xs text-emerald-500 flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
{{ $t("about.live") }}
</div>
</div>
</header>
<div class="metric-row">
<div>
<div class="glass-label">{{ $t("about.memory_rss") }}</div>
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.rss) }}</div>
</div>
<div>
<div class="glass-label">{{ $t("about.virtual_memory") }}</div>
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.vms) }}</div>
</div>
</div>
</div>
<div v-if="appInfo?.network_stats" class="glass-card space-y-3">
<header class="flex items-center gap-2">
<MaterialDesignIcon icon-name="access-point-network" class="w-5 h-5 text-purple-500" />
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("about.network_stats") }}
</div>
<div class="text-xs text-emerald-500 flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
{{ $t("about.live") }}
</div>
</div>
</header>
<div class="metric-row">
<div>
<div class="glass-label">{{ $t("about.sent") }}</div>
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_sent) }}</div>
</div>
<div>
<div class="glass-label">{{ $t("about.received") }}</div>
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_recv) }}</div>
</div>
</div>
<div class="metric-row">
<div>
<div class="glass-label">{{ $t("about.packets_sent") }}</div>
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_sent) }}</div>
</div>
<div>
<div class="glass-label">{{ $t("about.packets_received") }}</div>
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_recv) }}</div>
</div>
</div>
</div>
<div v-if="appInfo?.reticulum_stats" class="glass-card space-y-3">
<header class="flex items-center gap-2">
<MaterialDesignIcon icon-name="diagram-projector" class="w-5 h-5 text-indigo-500" />
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("about.reticulum_stats") }}
</div>
<div class="text-xs text-emerald-500 flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
{{ $t("about.live") }}
</div>
</div>
</header>
<div class="metric-grid">
<div>
<div class="glass-label">{{ $t("about.total_paths") }}</div>
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.total_paths) }}</div>
</div>
<div>
<div class="glass-label">{{ $t("about.announces_per_second") }}</div>
<div class="metric-value">
{{ formatNumber(appInfo.reticulum_stats.announces_per_second) }}
</div>
</div>
<div>
<div class="glass-label">{{ $t("about.announces_per_minute") }}</div>
<div class="metric-value">
{{ formatNumber(appInfo.reticulum_stats.announces_per_minute) }}
</div>
</div>
<div>
<div class="glass-label">{{ $t("about.announces_per_hour") }}</div>
<div class="metric-value">
{{ formatNumber(appInfo.reticulum_stats.announces_per_hour) }}
</div>
</div>
</div>
</div>
<div v-if="appInfo?.download_stats" class="glass-card space-y-3">
<header class="flex items-center gap-2">
<MaterialDesignIcon icon-name="download" class="w-5 h-5 text-sky-500" />
<div>
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("about.download_activity") }}
</div>
<div class="text-xs text-emerald-500 flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-pulse"></span>
{{ $t("about.live") }}
</div>
</div>
</header>
<div class="metric-value">
<span v-if="appInfo.download_stats.avg_download_speed_bps !== null">
{{ formatBytesPerSecond(appInfo.download_stats.avg_download_speed_bps) }}
</span>
<span v-else class="text-sm text-gray-500">{{ $t("about.no_downloads_yet") }}</span>
</div>
</div>
</div>
<div v-if="appInfo" class="glass-card space-y-3">
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("about.runtime_status") }}
</div>
<div class="flex flex-wrap gap-3">
<span :class="statusPillClass(!appInfo.is_connected_to_shared_instance)">
<MaterialDesignIcon icon-name="server" class="w-4 h-4" />
{{
appInfo.is_connected_to_shared_instance
? $t("about.shared_instance")
: $t("about.standalone_instance")
}}
</span>
<span :class="statusPillClass(appInfo.is_transport_enabled)">
<MaterialDesignIcon icon-name="transit-connection" class="w-4 h-4" />
{{
appInfo.is_transport_enabled
? $t("about.transport_enabled")
: $t("about.transport_disabled")
}}
</span>
</div>
</div>
<div v-if="config" class="glass-card space-y-4">
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("about.identity_addresses") }}
</div>
<div class="grid gap-3 md:grid-cols-2">
<div class="address-card">
<div class="glass-label">{{ $t("app.identity_hash") }}</div>
<div class="monospace-field break-all">{{ config.identity_hash }}</div>
<button
type="button"
class="secondary-chip mt-3 text-xs"
@click="copyValue(config.identity_hash, $t('app.identity_hash'))"
>
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4" />
{{ $t("app.copy") }}
</button>
</div>
<div class="address-card">
<div class="glass-label">{{ $t("app.lxmf_address") }}</div>
<div class="monospace-field break-all">{{ config.lxmf_address_hash }}</div>
<button
type="button"
class="secondary-chip mt-3 text-xs"
@click="copyValue(config.lxmf_address_hash, $t('app.lxmf_address'))"
>
<MaterialDesignIcon icon-name="account-network" class="w-4 h-4" />
{{ $t("app.copy") }}
</button>
</div>
<div class="address-card">
<div class="glass-label">{{ $t("app.propagation_node") }}</div>
<div class="monospace-field break-all">
{{ config.lxmf_local_propagation_node_address_hash || "—" }}
</div>
</div>
<div class="address-card">
<div class="glass-label">{{ $t("about.telephone_address") }}</div>
<div class="monospace-field break-all">{{ config.telephone_address_hash || "—" }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "../../js/Utils";
import ElectronUtils from "../../js/ElectronUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
export default {
name: "AboutPage",
components: {
MaterialDesignIcon,
},
data() {
return {
appInfo: null,
config: null,
updateInterval: null,
healthInterval: null,
databaseHealth: null,
databaseRecoveryActions: [],
databaseActionMessage: "",
databaseActionError: "",
databaseActionInProgress: false,
healthLoading: false,
backupInProgress: false,
backupMessage: "",
backupError: "",
restoreInProgress: false,
restoreMessage: "",
restoreError: "",
restoreFileName: "",
restoreFile: null,
identityBackupMessage: "",
identityBackupError: "",
identityBase32: "",
identityBase32Message: "",
identityBase32Error: "",
identityRestoreInProgress: false,
identityRestoreMessage: "",
identityRestoreError: "",
identityRestoreFileName: "",
identityRestoreFile: null,
identityRestoreBase32: "",
};
},
computed: {
isElectron() {
return ElectronUtils.isElectron();
},
},
mounted() {
this.getAppInfo();
this.getConfig();
this.getDatabaseHealth();
// Update stats every 5 seconds
this.updateInterval = setInterval(() => {
this.getAppInfo();
}, 5000);
this.healthInterval = setInterval(() => {
this.getDatabaseHealth();
}, 30000);
},
beforeUnmount() {
if (this.updateInterval) {
clearInterval(this.updateInterval);
}
if (this.healthInterval) {
clearInterval(this.healthInterval);
}
},
methods: {
async getAppInfo() {
try {
const response = await window.axios.get("/api/v1/app/info");
this.appInfo = response.data.app_info;
} catch (e) {
// do nothing if failed to load app info
console.log(e);
}
},
async getDatabaseHealth(showMessage = false) {
this.healthLoading = true;
try {
const response = await window.axios.get("/api/v1/database/health");
this.databaseHealth = response.data.database;
if (showMessage) {
this.databaseActionMessage = "Database health refreshed";
}
this.databaseActionError = "";
} catch {
this.databaseActionError = "Failed to load database health";
} finally {
this.healthLoading = false;
}
},
async vacuumDatabase() {
if (this.databaseActionInProgress) {
return;
}
this.databaseActionInProgress = true;
this.databaseActionMessage = "";
this.databaseActionError = "";
this.databaseRecoveryActions = [];
try {
const response = await window.axios.post("/api/v1/database/vacuum");
if (response.data.database?.health) {
this.databaseHealth = response.data.database.health;
}
this.databaseActionMessage = response.data.message || "Database vacuum completed";
} catch (e) {
this.databaseActionError = "Vacuum failed";
console.log(e);
} finally {
this.databaseActionInProgress = false;
}
},
async backupDatabase() {
if (this.backupInProgress) {
return;
}
this.backupInProgress = true;
this.backupMessage = "";
this.backupError = "";
try {
const response = await window.axios.get("/api/v1/database/backup/download", {
responseType: "blob",
});
const blob = new Blob([response.data], { type: "application/zip" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const filename =
response.headers["content-disposition"]?.split("filename=")?.[1]?.replace(/"/g, "") ||
"meshchatx-backup.zip";
link.setAttribute("download", filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
this.backupMessage = "Backup downloaded";
await this.getDatabaseHealth();
} catch (e) {
this.backupError = "Backup failed";
console.log(e);
} finally {
this.backupInProgress = false;
}
},
async restoreDatabase() {
if (this.restoreInProgress) {
return;
}
if (!this.restoreFile) {
this.restoreError = "Select a backup file to restore.";
return;
}
this.restoreInProgress = true;
this.restoreMessage = "";
this.restoreError = "";
try {
const formData = new FormData();
formData.append("file", this.restoreFile);
const response = await window.axios.post("/api/v1/database/restore", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
this.restoreMessage = response.data.message || "Database restored";
this.databaseHealth = response.data.database?.health || this.databaseHealth;
this.databaseRecoveryActions = response.data.database?.actions || this.databaseRecoveryActions;
await this.getDatabaseHealth();
} catch (e) {
this.restoreError = "Restore failed";
console.log(e);
} finally {
this.restoreInProgress = false;
}
},
async recoverDatabase() {
if (this.databaseActionInProgress) {
return;
}
this.databaseActionInProgress = true;
this.databaseActionMessage = "";
this.databaseActionError = "";
try {
const response = await window.axios.post("/api/v1/database/recover");
if (response.data.database?.health) {
this.databaseHealth = response.data.database.health;
}
this.databaseRecoveryActions = response.data.database?.actions || [];
this.databaseActionMessage = response.data.message || "Database recovery completed";
} catch (e) {
this.databaseActionError = "Recovery failed";
console.log(e);
} finally {
this.databaseActionInProgress = false;
}
},
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch (e) {
// do nothing if failed to load config
console.log(e);
}
},
async copyValue(value, label) {
if (!value) {
return;
}
try {
await navigator.clipboard.writeText(value);
ToastUtils.success(`${label} copied to clipboard`);
} catch {
ToastUtils.error(`Failed to copy ${label}`);
}
},
relaunch() {
ElectronUtils.relaunch();
},
showReticulumConfigFile() {
const reticulumConfigPath = this.appInfo.reticulum_config_path;
if (reticulumConfigPath) {
ElectronUtils.showPathInFolder(reticulumConfigPath);
}
},
showDatabaseFile() {
const databasePath = this.appInfo.database_path;
if (databasePath) {
ElectronUtils.showPathInFolder(databasePath);
}
},
formatBytes: function (bytes) {
return Utils.formatBytes(bytes);
},
formatNumber: function (num) {
return Utils.formatNumber(num);
},
formatBytesPerSecond: function (bytesPerSecond) {
return Utils.formatBytesPerSecond(bytesPerSecond);
},
onRestoreFileChange(event) {
const files = event.target.files;
if (files && files[0]) {
this.restoreFile = files[0];
this.restoreFileName = files[0].name;
this.restoreError = "";
this.restoreMessage = "";
}
},
async downloadIdentityFile() {
this.identityBackupMessage = "";
this.identityBackupError = "";
try {
const response = await window.axios.get("/api/v1/identity/backup/download", {
responseType: "blob",
});
const blob = new Blob([response.data], { type: "application/octet-stream" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "identity");
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
this.identityBackupMessage = "Identity downloaded. Keep it secret.";
} catch (e) {
this.identityBackupError = "Failed to download identity";
console.log(e);
}
},
async copyIdentityBase32() {
this.identityBase32Message = "";
this.identityBase32Error = "";
try {
const response = await window.axios.get("/api/v1/identity/backup/base32");
this.identityBase32 = response.data.identity_base32 || "";
if (!this.identityBase32) {
this.identityBase32Error = "No identity available";
return;
}
await navigator.clipboard.writeText(this.identityBase32);
this.identityBase32Message = "Identity copied. Clear your clipboard after use.";
} catch (e) {
this.identityBase32Error = "Failed to copy identity";
console.log(e);
}
},
onIdentityRestoreFileChange(event) {
const files = event.target.files;
if (files && files[0]) {
this.identityRestoreFile = files[0];
this.identityRestoreFileName = files[0].name;
this.identityRestoreError = "";
this.identityRestoreMessage = "";
}
},
async restoreIdentityFile() {
if (this.identityRestoreInProgress) {
return;
}
if (!this.identityRestoreFile) {
this.identityRestoreError = "Select an identity file to restore.";
return;
}
this.identityRestoreInProgress = true;
this.identityRestoreMessage = "";
this.identityRestoreError = "";
try {
const formData = new FormData();
formData.append("file", this.identityRestoreFile);
const response = await window.axios.post("/api/v1/identity/restore", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
} catch (e) {
this.identityRestoreError = "Identity restore failed";
console.log(e);
} finally {
this.identityRestoreInProgress = false;
}
},
async restoreIdentityBase32() {
if (this.identityRestoreInProgress) {
return;
}
if (!this.identityRestoreBase32) {
this.identityRestoreError = "Provide a base32 key to restore.";
return;
}
this.identityRestoreInProgress = true;
this.identityRestoreMessage = "";
this.identityRestoreError = "";
try {
const response = await window.axios.post("/api/v1/identity/restore", {
base32: this.identityRestoreBase32.trim(),
});
this.identityRestoreMessage = response.data.message || "Identity restored. Restart app.";
} catch (e) {
this.identityRestoreError = "Identity restore failed";
console.log(e);
} finally {
this.identityRestoreInProgress = false;
}
},
formatRecoveryResult(value) {
if (value === null || value === undefined) {
return "—";
}
if (Array.isArray(value)) {
return value.join(", ");
}
return value;
},
statusPillClass(isGood) {
return isGood
? "inline-flex items-center gap-1 rounded-full bg-emerald-100 text-emerald-700 px-3 py-1 text-xs font-semibold"
: "inline-flex items-center gap-1 rounded-full bg-orange-100 text-orange-700 px-3 py-1 text-xs font-semibold";
},
},
};
</script>

View File

@@ -0,0 +1,387 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
<!-- header -->
<div
class="flex items-center px-4 py-4 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<MaterialDesignIcon icon-name="archive" class="size-6 text-blue-600 dark:text-blue-400" />
</div>
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">{{ $t("app.archives") }}</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ $t("archives.description") }}</p>
</div>
</div>
<div class="ml-auto flex items-center gap-2 sm:gap-4">
<div class="relative w-32 sm:w-64 md:w-80">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
</div>
<input
v-model="searchQuery"
type="text"
class="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
:placeholder="$t('archives.search_placeholder')"
@input="onSearchInput"
/>
</div>
<button
class="p-2 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
:title="$t('common.refresh')"
@click="getArchives"
>
<MaterialDesignIcon icon-name="refresh" class="size-6" :class="{ 'animate-spin': isLoading }" />
</button>
</div>
</div>
<!-- content -->
<div class="flex-1 overflow-y-auto p-4 md:p-6">
<div v-if="isLoading && archives.length === 0" class="flex flex-col items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p class="text-gray-500 dark:text-gray-400">{{ $t("archives.loading") }}</p>
</div>
<div
v-else-if="groupedArchives.length === 0"
class="flex flex-col items-center justify-center h-64 text-center"
>
<div class="p-4 bg-gray-100 dark:bg-zinc-800 rounded-full mb-4 text-gray-400 dark:text-zinc-600">
<MaterialDesignIcon icon-name="archive-off" class="size-12" />
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
{{ $t("archives.no_archives_found") }}
</h3>
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
{{ searchQuery ? $t("archives.adjust_filters") : $t("archives.browse_to_archive") }}
</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="group in groupedArchives" :key="group.destination_hash" class="relative">
<div class="sticky top-6">
<div
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-lg overflow-hidden"
>
<div
class="p-5 border-b border-gray-100 dark:border-zinc-800 bg-gradient-to-r from-blue-50 to-indigo-50 dark:from-zinc-800 dark:to-zinc-800/50"
>
<div class="flex items-center justify-between mb-3">
<span
class="text-xs font-bold px-3 py-1.5 bg-blue-500 dark:bg-blue-600 text-white rounded-full uppercase tracking-wider shadow-sm"
>
{{ group.archives.length }}
{{ group.archives.length === 1 ? $t("archives.page") : $t("archives.pages") }}
</span>
</div>
<h4
class="text-base font-bold text-gray-900 dark:text-white mb-1 truncate"
:title="group.node_name"
>
{{ group.node_name }}
</h4>
<p class="text-xs text-gray-600 dark:text-gray-400 font-mono truncate">
{{ group.destination_hash.substring(0, 16) }}...
</p>
</div>
<div class="p-5 pb-6">
<CardStack :items="group.archives" :max-visible="3">
<template #default="{ item: archive }">
<div
class="stacked-card bg-white dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-lg p-4 h-full hover:shadow-xl transition-all duration-200 cursor-pointer group"
@click="viewArchive(archive)"
>
<div class="flex items-start justify-between mb-3">
<div class="flex-1 min-w-0">
<p
class="text-sm font-semibold text-gray-900 dark:text-gray-100 font-mono truncate mb-1 group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors"
:title="archive.page_path || '/'"
>
{{ archive.page_path || "/" }}
</p>
<div
class="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400"
>
<MaterialDesignIcon icon-name="clock-outline" class="size-3" />
<span>{{ formatDate(archive.created_at) }}</span>
</div>
</div>
<div class="ml-3 flex-shrink-0">
<div
class="w-2 h-2 rounded-full bg-blue-500 dark:bg-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
></div>
</div>
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
class="text-xs text-gray-700 dark:text-gray-300 line-clamp-5 micron-preview leading-relaxed"
v-html="renderPreview(archive)"
></div>
<div
class="mt-3 pt-3 border-t border-gray-100 dark:border-zinc-700 flex items-center justify-between"
>
<div
class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400"
>
<MaterialDesignIcon icon-name="tag" class="size-3" />
<span class="font-mono">{{ archive.hash.substring(0, 8) }}</span>
</div>
<div
class="text-xs font-medium text-blue-600 dark:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1"
>
{{ $t("archives.view") }}
<MaterialDesignIcon icon-name="arrow-right" class="size-3" />
</div>
</div>
</div>
</template>
</CardStack>
</div>
</div>
</div>
</div>
</div>
<div v-if="archives.length > 0" class="mt-8 mb-4 flex items-center justify-between">
<div class="text-sm text-gray-500 dark:text-gray-400">
{{
$t("archives.showing_range", {
start: pagination.total_count > 0 ? (pagination.page - 1) * pagination.limit + 1 : 0,
end: Math.min(pagination.page * pagination.limit, pagination.total_count),
total: pagination.total_count,
})
}}
</div>
<div class="flex items-center gap-2">
<button
:disabled="pagination.page <= 1 || isLoading"
class="p-2 rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-700 dark:text-gray-300 disabled:opacity-50 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
@click="changePage(pagination.page - 1)"
>
<MaterialDesignIcon icon-name="chevron-left" class="size-5" />
</button>
<span class="text-sm font-medium text-gray-900 dark:text-white px-4">
{{
$t("archives.page_of", {
page: pagination.page,
total_pages: pagination.total_pages,
})
}}
</span>
<button
:disabled="pagination.page >= pagination.total_pages || isLoading"
class="p-2 rounded-lg border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-700 dark:text-gray-300 disabled:opacity-50 hover:bg-gray-50 dark:hover:bg-zinc-800 transition-colors"
@click="changePage(pagination.page + 1)"
>
<MaterialDesignIcon icon-name="chevron-right" class="size-5" />
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import Utils from "../../js/Utils";
import MicronParser from "micron-parser";
import CardStack from "../CardStack.vue";
export default {
name: "ArchivesPage",
components: {
MaterialDesignIcon,
CardStack,
},
data() {
return {
archives: [],
searchQuery: "",
isLoading: false,
searchTimeout: null,
muParser: new MicronParser(),
pagination: {
page: 1,
limit: 15,
total_count: 0,
total_pages: 0,
},
};
},
computed: {
groupedArchives() {
const groups = {};
for (const archive of this.archives) {
const hash = archive.destination_hash;
if (!groups[hash]) {
groups[hash] = {
destination_hash: hash,
node_name: archive.node_name,
archives: [],
};
}
groups[hash].archives.push(archive);
}
// sort each group by date
const grouped = Object.values(groups).map((group) => ({
...group,
archives: group.archives.sort((a, b) => {
const dateA = new Date(a.created_at);
const dateB = new Date(b.created_at);
return dateB - dateA;
}),
}));
// sort groups by the date of their most recent archive
return grouped.sort((a, b) => {
const dateA = new Date(a.archives[0].created_at);
const dateB = new Date(b.archives[0].created_at);
return dateB - dateA;
});
},
},
mounted() {
this.getArchives();
},
methods: {
async getArchives() {
this.isLoading = true;
try {
const response = await window.axios.get("/api/v1/nomadnet/archives", {
params: {
q: this.searchQuery,
page: this.pagination.page,
limit: this.pagination.limit,
},
});
this.archives = response.data.archives;
this.pagination = response.data.pagination;
} catch (e) {
console.error("Failed to load archives:", e);
} finally {
this.isLoading = false;
}
},
onSearchInput() {
this.pagination.page = 1; // reset to first page on search
clearTimeout(this.searchTimeout);
this.searchTimeout = setTimeout(() => {
this.getArchives();
}, 300);
},
async changePage(page) {
this.pagination.page = page;
await this.getArchives();
// scroll to top of content
const contentElement = document.querySelector(".overflow-y-auto");
if (contentElement) contentElement.scrollTop = 0;
},
viewArchive(archive) {
this.$router.push({
name: "nomadnetwork",
params: { destinationHash: archive.destination_hash },
query: {
path: archive.page_path,
archive_id: archive.id,
},
});
},
formatDate(dateStr) {
return Utils.formatTimeAgo(dateStr);
},
renderPreview(archive) {
if (!archive.content) return "";
// limit content for preview
const previewContent = archive.content.substring(0, 500);
// convert micron to html if it looks like micron or ends with .mu
if (archive.page_path?.endsWith(".mu") || archive.content.includes("`")) {
try {
return this.muParser.convertMicronToHtml(previewContent);
} catch {
return previewContent;
}
}
return previewContent;
},
},
};
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-5 {
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
.stacked-card {
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.1),
0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
.stacked-card:hover {
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.1),
0 8px 10px -6px rgba(0, 0, 0, 0.1);
}
.dark .stacked-card {
box-shadow:
0 1px 3px 0 rgba(0, 0, 0, 0.3),
0 1px 2px 0 rgba(0, 0, 0, 0.2);
}
.dark .stacked-card:hover {
box-shadow:
0 10px 25px -5px rgba(0, 0, 0, 0.5),
0 8px 10px -6px rgba(0, 0, 0, 0.4);
}
.micron-preview {
font-family:
Roboto Mono Nerd Font,
monospace;
white-space: pre-wrap;
word-break: break-word;
}
:deep(.micron-preview) a {
pointer-events: none;
}
:deep(.micron-preview) p {
margin: 0.25rem 0;
}
:deep(.micron-preview) h1,
:deep(.micron-preview) h2,
:deep(.micron-preview) h3,
:deep(.micron-preview) h4 {
margin: 0.5rem 0 0.25rem 0;
font-weight: 600;
}
</style>

View File

@@ -0,0 +1,152 @@
<template>
<div class="h-screen w-full flex items-center justify-center bg-slate-50 dark:bg-zinc-950">
<div class="w-full max-w-md p-8">
<div
class="bg-white dark:bg-zinc-900 rounded-2xl shadow-lg border border-gray-200 dark:border-zinc-800 p-8"
>
<div class="text-center mb-8">
<img class="w-16 h-16 mx-auto mb-4" src="/assets/images/logo-chat-bubble.png" />
<h1 class="text-2xl font-bold text-gray-900 dark:text-zinc-100 mb-2">
{{ isSetup ? "Initial Setup" : "Authentication Required" }}
</h1>
<p class="text-sm text-gray-600 dark:text-zinc-400">
{{
isSetup
? "Set an admin password to secure your MeshChat instance"
: "Please enter your password to continue"
}}
</p>
</div>
<form class="space-y-6" @submit.prevent="handleSubmit">
<div>
<label for="password" class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2">
Password
</label>
<input
id="password"
v-model="password"
type="password"
required
minlength="8"
class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter password"
autocomplete="current-password"
/>
<p v-if="isSetup" class="mt-2 text-xs text-gray-500 dark:text-zinc-500">
Password must be at least 8 characters long
</p>
</div>
<div v-if="isSetup">
<label
for="confirmPassword"
class="block text-sm font-medium text-gray-700 dark:text-zinc-300 mb-2"
>
Confirm Password
</label>
<input
id="confirmPassword"
v-model="confirmPassword"
type="password"
required
minlength="8"
class="w-full px-4 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-white dark:bg-zinc-800 text-gray-900 dark:text-zinc-100 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Confirm password"
autocomplete="new-password"
/>
</div>
<div
v-if="error"
class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg"
>
<p class="text-sm text-red-800 dark:text-red-200">{{ error }}</p>
</div>
<button
type="submit"
:disabled="isLoading || (isSetup && password !== confirmPassword)"
class="w-full py-2.5 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white font-semibold rounded-lg transition-colors"
>
<span v-if="isLoading">Processing...</span>
<span v-else>{{ isSetup ? "Set Password" : "Login" }}</span>
</button>
</form>
</div>
</div>
</div>
</template>
<script>
export default {
name: "AuthPage",
data() {
return {
password: "",
confirmPassword: "",
error: "",
isLoading: false,
isSetup: false,
};
},
async mounted() {
await this.checkAuthStatus();
},
methods: {
async checkAuthStatus() {
try {
const response = await window.axios.get("/api/v1/auth/status");
const status = response.data;
if (!status.auth_enabled) {
this.$router.push("/");
return;
}
if (status.authenticated) {
this.$router.push("/");
return;
}
this.isSetup = !status.password_set;
} catch (e) {
console.error("Failed to check auth status:", e);
this.error = "Failed to check authentication status";
}
},
async handleSubmit() {
this.error = "";
if (this.isSetup) {
if (this.password !== this.confirmPassword) {
this.error = "Passwords do not match";
return;
}
if (this.password.length < 8) {
this.error = "Password must be at least 8 characters long";
return;
}
}
this.isLoading = true;
try {
const endpoint = this.isSetup ? "/api/v1/auth/setup" : "/api/v1/auth/login";
await window.axios.post(endpoint, {
password: this.password,
});
window.location.reload();
} catch (e) {
this.error = e.response?.data?.error || "Authentication failed";
this.password = "";
this.confirmPassword = "";
} finally {
this.isLoading = false;
}
},
},
};
</script>

View File

@@ -0,0 +1,263 @@
<template>
<div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
<div
class="flex items-center px-4 py-4 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm"
>
<div class="flex items-center gap-3">
<div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg">
<MaterialDesignIcon icon-name="block-helper" class="size-6 text-red-600 dark:text-red-400" />
</div>
<div>
<h1 class="text-xl font-bold text-gray-900 dark:text-white">Blocked</h1>
<p class="text-sm text-gray-500 dark:text-gray-400">Manage blocked users and nodes</p>
</div>
</div>
<div class="ml-auto flex items-center gap-2 sm:gap-4">
<div class="relative w-32 sm:w-64 md:w-80">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<MaterialDesignIcon icon-name="magnify" class="size-5 text-gray-400" />
</div>
<input
v-model="searchQuery"
type="text"
class="block w-full pl-10 pr-3 py-2 border border-gray-300 dark:border-zinc-700 rounded-lg bg-gray-50 dark:bg-zinc-800 text-gray-900 dark:text-gray-100 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all"
placeholder="Search by hash or display name..."
@input="onSearchInput"
/>
</div>
<button
class="p-2 text-gray-500 hover:text-blue-500 dark:text-gray-400 dark:hover:text-blue-400 transition-colors"
title="Refresh"
@click="loadBlockedDestinations"
>
<MaterialDesignIcon icon-name="refresh" class="size-6" :class="{ 'animate-spin': isLoading }" />
</button>
</div>
</div>
<div class="flex-1 overflow-y-auto p-4 md:p-6">
<div v-if="isLoading && blockedItems.length === 0" class="flex flex-col items-center justify-center h-64">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500 mb-4"></div>
<p class="text-gray-500 dark:text-gray-400">Loading blocked items...</p>
</div>
<div
v-else-if="filteredBlockedItems.length === 0"
class="flex flex-col items-center justify-center h-64 text-center"
>
<div class="p-4 bg-gray-100 dark:bg-zinc-800 rounded-full mb-4 text-gray-400 dark:text-zinc-600">
<MaterialDesignIcon icon-name="check-circle" class="size-12" />
</div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white">No blocked items</h3>
<p class="text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
{{
searchQuery
? "No blocked items match your search."
: "You haven't blocked any users or nodes yet."
}}
</p>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="item in filteredBlockedItems"
:key="item.destination_hash"
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-lg overflow-hidden"
>
<div class="p-5">
<div class="flex items-start justify-between mb-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-2">
<div class="p-2 bg-red-100 dark:bg-red-900/30 rounded-lg flex-shrink-0">
<MaterialDesignIcon
icon-name="account-off"
class="size-5 text-red-600 dark:text-red-400"
/>
</div>
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-1">
<h4
class="text-base font-semibold text-gray-900 dark:text-white break-words"
:title="item.display_name"
>
{{ item.display_name || "Unknown" }}
</h4>
<span
v-if="item.is_node"
class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded"
>
Node
</span>
<span
v-else
class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300 rounded"
>
User
</span>
</div>
<p
class="text-xs text-gray-500 dark:text-gray-400 font-mono break-all mt-1"
:title="item.destination_hash"
>
{{ item.destination_hash }}
</p>
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Blocked {{ formatTimeAgo(item.created_at) }}
</div>
</div>
</div>
<button
class="w-full flex items-center justify-center gap-2 px-4 py-2 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 text-green-700 dark:text-green-300 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors font-medium"
@click="onUnblock(item)"
>
<MaterialDesignIcon icon-name="check-circle" class="size-5" />
<span>Unblock</span>
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import Utils from "../../js/Utils";
export default {
name: "BlockedPage",
components: {
MaterialDesignIcon,
},
data() {
return {
blockedItems: [],
isLoading: false,
searchQuery: "",
};
},
computed: {
filteredBlockedItems() {
if (!this.searchQuery.trim()) {
return this.blockedItems;
}
const query = this.searchQuery.toLowerCase();
return this.blockedItems.filter((item) => {
const matchesHash = item.destination_hash.toLowerCase().includes(query);
const matchesDisplayName = (item.display_name || "").toLowerCase().includes(query);
return matchesHash || matchesDisplayName;
});
},
},
mounted() {
this.loadBlockedDestinations();
},
methods: {
async loadBlockedDestinations() {
this.isLoading = true;
try {
const response = await window.axios.get("/api/v1/blocked-destinations");
const blockedHashes = response.data.blocked_destinations || [];
const items = await Promise.all(
blockedHashes.map(async (blocked) => {
let displayName = "Unknown";
let isNode = false;
try {
const nodeAnnounceResponse = await window.axios.get("/api/v1/announces", {
params: {
aspect: "nomadnetwork.node",
identity_hash: blocked.destination_hash,
include_blocked: true,
limit: 1,
},
});
if (nodeAnnounceResponse.data.announces && nodeAnnounceResponse.data.announces.length > 0) {
const announce = nodeAnnounceResponse.data.announces[0];
displayName = announce.display_name || "Unknown";
isNode = true;
} else {
const announceResponse = await window.axios.get("/api/v1/announces", {
params: {
identity_hash: blocked.destination_hash,
include_blocked: true,
limit: 1,
},
});
if (announceResponse.data.announces && announceResponse.data.announces.length > 0) {
const announce = announceResponse.data.announces[0];
displayName = announce.display_name || "Unknown";
isNode = announce.aspect === "nomadnetwork.node";
} else {
const lxmfResponse = await window.axios.get("/api/v1/announces", {
params: {
destination_hash: blocked.destination_hash,
include_blocked: true,
limit: 1,
},
});
if (lxmfResponse.data.announces && lxmfResponse.data.announces.length > 0) {
const announce = lxmfResponse.data.announces[0];
displayName = announce.display_name || "Unknown";
isNode = announce.aspect === "nomadnetwork.node";
}
}
}
} catch (e) {
console.log(e);
}
return {
destination_hash: blocked.destination_hash,
display_name: displayName,
created_at: blocked.created_at,
is_node: isNode,
};
})
);
this.blockedItems = items;
} catch (e) {
console.log(e);
ToastUtils.error("Failed to load blocked destinations");
} finally {
this.isLoading = false;
}
},
async onUnblock(item) {
if (
!(await DialogUtils.confirm(
`Are you sure you want to unblock ${item.display_name || item.destination_hash}?`
))
) {
return;
}
try {
await window.axios.delete(`/api/v1/blocked-destinations/${item.destination_hash}`);
await this.loadBlockedDestinations();
ToastUtils.success("Unblocked successfully");
} catch (e) {
console.log(e);
ToastUtils.error("Failed to unblock");
}
},
onSearchInput() {},
formatDestinationHash: function (destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
formatTimeAgo: function (datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
},
};
</script>

View File

@@ -0,0 +1,262 @@
<template>
<div
v-if="activeCall"
class="fixed bottom-4 right-4 z-[100] w-72 bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transition-all duration-300"
:class="{ 'ring-2 ring-red-500 ring-opacity-50': isEnded }"
>
<!-- Header -->
<div class="p-3 flex items-center bg-gray-50 dark:bg-zinc-800/50 border-b border-gray-100 dark:border-zinc-800">
<div class="flex-1 flex items-center space-x-2">
<div
class="size-2 rounded-full"
:class="isEnded ? 'bg-red-500' : 'bg-green-500 animate-pulse'"
></div>
<span class="text-[10px] font-bold text-gray-500 dark:text-zinc-400 uppercase tracking-wider">
{{ isEnded ? "Call Ended" : (activeCall.status === 6 ? "Active Call" : "Call Status") }}
</span>
</div>
<button
v-if="!isEnded"
type="button"
class="p-1 hover:bg-gray-200 dark:hover:bg-zinc-700 rounded-lg transition-colors"
@click="isMinimized = !isMinimized"
>
<MaterialDesignIcon
:icon-name="isMinimized ? 'chevron-up' : 'chevron-down'"
class="size-4 text-gray-500"
/>
</button>
</div>
<div v-show="!isMinimized" class="p-4">
<!-- icon and name -->
<div class="flex flex-col items-center mb-4">
<div
class="p-4 rounded-full mb-3"
:class="isEnded ? 'bg-red-100 dark:bg-red-900/30' : 'bg-blue-100 dark:bg-blue-900/30'"
>
<MaterialDesignIcon
icon-name="account"
class="size-8"
:class="isEnded ? 'text-red-600 dark:text-red-400' : 'text-blue-600 dark:text-blue-400'"
/>
</div>
<div class="text-center w-full">
<div class="font-bold text-gray-900 dark:text-white truncate px-2">
{{ activeCall.remote_identity_name || "Unknown" }}
</div>
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono">
{{
activeCall.remote_identity_hash
? formatDestinationHash(activeCall.remote_identity_hash)
: ""
}}
</div>
</div>
</div>
<!-- Status -->
<div class="text-center mb-6">
<div
class="text-sm font-medium"
:class="[
isEnded ? 'text-red-600 dark:text-red-400 animate-pulse' :
(activeCall.status === 6
? 'text-green-600 dark:text-green-400'
: 'text-gray-600 dark:text-zinc-400')
]"
>
<span v-if="isEnded">Call Ended</span>
<span v-else-if="activeCall.is_incoming && activeCall.status === 4">Incoming Call...</span>
<span v-else-if="activeCall.status === 0">Busy</span>
<span v-else-if="activeCall.status === 1">Rejected</span>
<span v-else-if="activeCall.status === 2">Calling...</span>
<span v-else-if="activeCall.status === 3">Available</span>
<span v-else-if="activeCall.status === 4">Ringing...</span>
<span v-else-if="activeCall.status === 5">Connecting...</span>
<span v-else-if="activeCall.status === 6">Connected</span>
<span v-else>Status: {{ activeCall.status }}</span>
</div>
</div>
<!-- Stats (only when connected and not minimized) -->
<div
v-if="activeCall.status === 6 && !isEnded"
class="mb-4 p-2 bg-gray-50 dark:bg-zinc-800/50 rounded-lg text-[10px] text-gray-500 dark:text-zinc-400 grid grid-cols-2 gap-1"
>
<div class="flex items-center space-x-1">
<MaterialDesignIcon icon-name="arrow-up" class="size-3" />
<span>{{ formatBytes(activeCall.tx_bytes || 0) }}</span>
</div>
<div class="flex items-center space-x-1">
<MaterialDesignIcon icon-name="arrow-down" class="size-3" />
<span>{{ formatBytes(activeCall.rx_bytes || 0) }}</span>
</div>
</div>
<!-- Controls -->
<div v-if="!isEnded" class="flex justify-center space-x-3">
<!-- Mute Mic -->
<button
type="button"
:title="isMicMuted ? 'Unmute Mic' : 'Mute Mic'"
class="p-3 rounded-full transition-all duration-200"
:class="
isMicMuted
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
: 'bg-gray-100 dark:bg-zinc-800 text-gray-600 dark:text-zinc-300 hover:bg-gray-200 dark:hover:bg-zinc-700'
"
@click="toggleMicrophone"
>
<MaterialDesignIcon :icon-name="isMicMuted ? 'microphone-off' : 'microphone'" class="size-6" />
</button>
<!-- Mute Speaker -->
<button
type="button"
:title="isSpeakerMuted ? 'Unmute Speaker' : 'Mute Speaker'"
class="p-3 rounded-full transition-all duration-200"
:class="
isSpeakerMuted
? 'bg-red-500 text-white shadow-lg shadow-red-500/30'
: 'bg-gray-100 dark:bg-zinc-800 text-gray-600 dark:text-zinc-300 hover:bg-gray-200 dark:hover:bg-zinc-700'
"
@click="toggleSpeaker"
>
<MaterialDesignIcon :icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'" class="size-6" />
</button>
<!-- Hangup -->
<button
type="button"
:title="activeCall.is_incoming && activeCall.status === 4 ? 'Decline' : 'Hangup'"
class="p-3 rounded-full bg-red-600 text-white hover:bg-red-700 shadow-lg shadow-red-600/30 transition-all duration-200"
@click="hangupCall"
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
</button>
<!-- Answer (if incoming) -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
type="button"
title="Answer"
class="p-3 rounded-full bg-green-600 text-white hover:bg-green-700 shadow-lg shadow-green-600/30 animate-bounce"
@click="answerCall"
>
<MaterialDesignIcon icon-name="phone" class="size-6" />
</button>
</div>
</div>
<!-- Minimized State -->
<div v-show="isMinimized && !isEnded" class="px-4 py-2 flex items-center justify-between bg-white dark:bg-zinc-900">
<div class="flex items-center space-x-2 overflow-hidden mr-2">
<MaterialDesignIcon icon-name="account" class="size-5 text-blue-500" />
<span class="text-sm font-medium text-gray-700 dark:text-zinc-200 truncate">
{{ activeCall.remote_identity_name || "Unknown" }}
</span>
</div>
<div class="flex items-center space-x-1">
<button
type="button"
class="p-1.5 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded transition-colors"
@click="toggleMicrophone"
>
<MaterialDesignIcon
:icon-name="isMicMuted ? 'microphone-off' : 'microphone'"
class="size-4"
:class="isMicMuted ? 'text-red-500' : 'text-gray-400'"
/>
</button>
<button
type="button"
class="p-1.5 hover:bg-red-100 dark:hover:bg-red-900/30 rounded transition-colors"
@click="hangupCall"
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-4 text-red-500 rotate-[135deg]" />
</button>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import Utils from "../../js/Utils";
import ToastUtils from "../../js/ToastUtils";
export default {
name: "CallOverlay",
components: { MaterialDesignIcon },
props: {
activeCall: {
type: Object,
required: true,
},
isEnded: {
type: Boolean,
default: false,
},
},
data() {
return {
isMinimized: false,
};
},
computed: {
isMicMuted() {
return this.activeCall?.is_mic_muted ?? false;
},
isSpeakerMuted() {
return this.activeCall?.is_speaker_muted ?? false;
},
},
methods: {
formatDestinationHash(hash) {
return Utils.formatDestinationHash(hash);
},
formatBytes(bytes) {
return Utils.formatBytes(bytes || 0);
},
async answerCall() {
try {
await window.axios.get("/api/v1/telephone/answer");
} catch {
ToastUtils.error("Failed to answer call");
}
},
async hangupCall() {
try {
await window.axios.get("/api/v1/telephone/hangup");
} catch {
ToastUtils.error("Failed to hangup call");
}
},
async toggleMicrophone() {
try {
const endpoint = this.isMicMuted
? "/api/v1/telephone/unmute-transmit"
: "/api/v1/telephone/mute-transmit";
await window.axios.get(endpoint);
// eslint-disable-next-line vue/no-mutating-props
this.activeCall.is_mic_muted = !this.isMicMuted;
} catch {
ToastUtils.error("Failed to toggle microphone");
}
},
async toggleSpeaker() {
try {
const endpoint = this.isSpeakerMuted
? "/api/v1/telephone/unmute-receive"
: "/api/v1/telephone/mute-receive";
await window.axios.get(endpoint);
// eslint-disable-next-line vue/no-mutating-props
this.activeCall.is_speaker_muted = !this.isSpeakerMuted;
} catch {
ToastUtils.error("Failed to toggle speaker");
}
},
},
};
</script>

View File

@@ -0,0 +1,458 @@
<template>
<div class="flex w-full h-full bg-gray-100 dark:bg-zinc-950" :class="{ dark: config?.theme === 'dark' }">
<div class="mx-auto my-auto w-full max-w-xl p-4">
<div v-if="activeCall || isCallEnded" class="flex">
<div class="mx-auto my-auto min-w-64">
<div class="text-center">
<div>
<!-- icon -->
<div class="flex mb-4">
<div
class="mx-auto bg-gray-300 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-4 rounded-full"
:class="{ 'animate-pulse': activeCall && activeCall.status === 4 }"
>
<MaterialDesignIcon icon-name="account" class="size-12" />
</div>
</div>
<!-- name -->
<div class="text-xl font-semibold text-gray-500 dark:text-zinc-100">
<span v-if="(activeCall || lastCall)?.remote_identity_name != null">{{
(activeCall || lastCall).remote_identity_name
}}</span>
<span v-else>Unknown</span>
</div>
<!-- identity hash -->
<div
v-if="(activeCall || lastCall)?.remote_identity_hash != null"
class="text-gray-500 dark:text-zinc-100 opacity-60 text-sm"
>
{{
(activeCall || lastCall).remote_identity_hash
? formatDestinationHash((activeCall || lastCall).remote_identity_hash)
: ""
}}
</div>
</div>
<!-- call status -->
<div class="text-gray-500 dark:text-zinc-100 mb-4 mt-2">
<template v-if="isCallEnded">
<span class="text-red-500 font-bold animate-pulse">Call Ended</span>
</template>
<template v-else-if="activeCall">
<span v-if="activeCall.is_incoming && activeCall.status === 4" class="animate-bounce inline-block">Incoming Call...</span>
<span v-else>
<span v-if="activeCall.status === 0">Busy...</span>
<span v-else-if="activeCall.status === 1">Rejected...</span>
<span v-else-if="activeCall.status === 2">Calling...</span>
<span v-else-if="activeCall.status === 3">Available...</span>
<span v-else-if="activeCall.status === 4">Ringing...</span>
<span v-else-if="activeCall.status === 5">Connecting...</span>
<span v-else-if="activeCall.status === 6" class="text-green-500 font-medium">Connected</span>
<span v-else>Status: {{ activeCall.status }}</span>
</span>
</template>
</div>
<!-- settings during connected call -->
<div v-if="activeCall && activeCall.status === 6" class="mb-4">
<div class="w-full">
<select
v-model="selectedAudioProfileId"
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:bg-zinc-900 dark:border-zinc-600 dark:text-white dark:focus:ring-blue-600 dark:focus:border-blue-600"
@change="switchAudioProfile(selectedAudioProfileId)"
>
<option
v-for="audioProfile in audioProfiles"
:key="audioProfile.id"
:value="audioProfile.id"
>
{{ audioProfile.name }}
</option>
</select>
</div>
</div>
<!-- controls during connected call -->
<div v-if="activeCall && activeCall.status === 6" class="mx-auto space-x-4 mb-8">
<!-- mute/unmute mic -->
<button
type="button"
:title="isMicMuted ? 'Unmute Mic' : 'Mute Mic'"
:class="[
isMicMuted
? 'bg-red-500 hover:bg-red-400'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="toggleMicrophone"
>
<MaterialDesignIcon
:icon-name="isMicMuted ? 'microphone-off' : 'microphone'"
class="size-8"
/>
</button>
<!-- mute/unmute speaker -->
<button
type="button"
:title="isSpeakerMuted ? 'Unmute Speaker' : 'Mute Speaker'"
:class="[
isSpeakerMuted
? 'bg-red-500 hover:bg-red-400'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="toggleSpeaker"
>
<MaterialDesignIcon
:icon-name="isSpeakerMuted ? 'volume-off' : 'volume-high'"
class="size-8"
/>
</button>
<!-- toggle stats -->
<button
type="button"
:class="[
isShowingStats
? 'bg-blue-500 text-white'
: 'bg-gray-200 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 hover:bg-gray-300 dark:hover:bg-zinc-700',
]"
class="inline-flex items-center gap-x-1 rounded-full p-4 text-sm font-semibold shadow-sm transition-all duration-200"
@click="isShowingStats = !isShowingStats"
>
<MaterialDesignIcon icon-name="chart-bar" class="size-8" />
</button>
</div>
<!-- actions -->
<div v-if="activeCall" class="mx-auto space-x-4">
<!-- answer call -->
<button
v-if="activeCall.is_incoming && activeCall.status === 4"
title="Answer Call"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-green-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-green-500 transition-all duration-200 animate-bounce"
@click="answerCall"
>
<MaterialDesignIcon icon-name="phone" class="size-6" />
<span>Accept</span>
</button>
<!-- hangup/decline call -->
<button
:title="
activeCall.is_incoming && activeCall.status === 4 ? 'Decline Call' : 'Hangup Call'
"
type="button"
class="inline-flex items-center gap-x-2 rounded-2xl bg-red-600 px-6 py-4 text-lg font-bold text-white shadow-xl hover:bg-red-500 transition-all duration-200"
@click="hangupCall"
>
<MaterialDesignIcon icon-name="phone-hangup" class="size-6 rotate-[135deg]" />
<span>{{
activeCall.is_incoming && activeCall.status === 4 ? "Decline" : "Hangup"
}}</span>
</button>
</div>
<!-- stats -->
<div
v-if="isShowingStats"
class="mt-4 p-4 text-left bg-gray-200 dark:bg-zinc-800 rounded-lg text-sm text-gray-600 dark:text-zinc-300"
>
<div class="grid grid-cols-2 gap-2">
<div>
TX: {{ activeCall.tx_packets }} ({{ formatBytes(activeCall.tx_bytes) }})
</div>
<div>
RX: {{ activeCall.rx_packets }} ({{ formatBytes(activeCall.rx_bytes) }})
</div>
</div>
</div>
</div>
</div>
</div>
<div v-else class="flex">
<div class="mx-auto my-auto w-full">
<div class="text-center mb-4">
<div class="text-xl font-semibold text-gray-500 dark:text-zinc-100">Telephone</div>
<div class="text-gray-500 dark:text-zinc-400">Enter an identity hash to call.</div>
</div>
<div class="flex space-x-2">
<input
v-model="destinationHash"
type="text"
placeholder="Identity Hash"
class="block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-zinc-800"
@keydown.enter="call(destinationHash)"
/>
<button
type="button"
class="rounded-md bg-blue-600 px-3 py-2 text-sm font-semibold text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600"
@click="call(destinationHash)"
>
Call
</button>
</div>
</div>
</div>
<div v-if="callHistory.length > 0 && !activeCall" class="mt-8">
<div
class="bg-white dark:bg-zinc-900 rounded-xl shadow-sm border border-gray-200 dark:border-zinc-800 overflow-hidden"
>
<div
class="px-4 py-3 border-b border-gray-200 dark:border-zinc-800 flex justify-between items-center"
>
<h3 class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider">
Call History
</h3>
<MaterialDesignIcon icon-name="history" class="size-4 text-gray-400" />
</div>
<ul class="divide-y divide-gray-100 dark:divide-zinc-800">
<li
v-for="entry in callHistory"
:key="entry.id"
class="px-4 py-3 hover:bg-gray-50 dark:hover:bg-zinc-800/50 transition-colors"
>
<div class="flex items-center space-x-3">
<div :class="entry.is_incoming ? 'text-blue-500' : 'text-green-500'">
<MaterialDesignIcon
:icon-name="entry.is_incoming ? 'phone-incoming' : 'phone-outgoing'"
class="size-5"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center justify-between">
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">
{{ entry.remote_identity_name || "Unknown" }}
</p>
<span class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono ml-2">
{{
entry.timestamp
? formatDateTime(
entry.timestamp * 1000
)
: ""
}}
</span>
</div>
<div class="flex items-center justify-between mt-0.5">
<div
class="flex items-center text-xs text-gray-500 dark:text-zinc-400 space-x-2"
>
<span>{{ entry.status }}</span>
<span v-if="entry.duration_seconds > 0"
> {{ formatDuration(entry.duration_seconds) }}</span
>
</div>
<button
type="button"
class="text-[10px] text-blue-500 hover:text-blue-600 font-bold uppercase tracking-tighter"
@click="
destinationHash = entry.remote_identity_hash;
call(destinationHash);
"
>
Call Back
</button>
</div>
</div>
</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "../../js/Utils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import ToastUtils from "../../js/ToastUtils";
export default {
name: "CallPage",
components: { MaterialDesignIcon },
data() {
return {
config: null,
activeCall: null,
audioProfiles: [],
selectedAudioProfileId: null,
destinationHash: "",
isShowingStats: false,
callHistory: [],
isCallEnded: false,
lastCall: null,
endedTimeout: null,
};
},
computed: {
isMicMuted() {
return this.activeCall?.is_mic_muted ?? false;
},
isSpeakerMuted() {
return this.activeCall?.is_speaker_muted ?? false;
},
},
mounted() {
this.getConfig();
this.getAudioProfiles();
this.getStatus();
this.getHistory();
// poll for status
this.statusInterval = setInterval(() => {
this.getStatus();
}, 1000);
// poll for history less frequently
this.historyInterval = setInterval(() => {
this.getHistory();
}, 10000);
// autofill destination hash from query string
const urlParams = new URLSearchParams(window.location.search);
const destinationHash = urlParams.get("destination_hash");
if (destinationHash) {
this.destinationHash = destinationHash;
}
},
beforeUnmount() {
if (this.statusInterval) clearInterval(this.statusInterval);
if (this.historyInterval) clearInterval(this.historyInterval);
if (this.endedTimeout) clearTimeout(this.endedTimeout);
},
methods: {
formatDestinationHash(hash) {
return Utils.formatDestinationHash(hash);
},
formatBytes(bytes) {
return Utils.formatBytes(bytes || 0);
},
formatDateTime(timestamp) {
return Utils.convertUnixMillisToLocalDateTimeString(timestamp);
},
formatDuration(seconds) {
return Utils.formatMinutesSeconds(seconds);
},
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch (e) {
console.log(e);
}
},
async getAudioProfiles() {
try {
const response = await window.axios.get("/api/v1/telephone/audio-profiles");
this.audioProfiles = response.data.audio_profiles;
this.selectedAudioProfileId = response.data.default_audio_profile_id;
} catch (e) {
console.log(e);
}
},
async getStatus() {
try {
const response = await window.axios.get("/api/v1/telephone/status");
const oldCall = this.activeCall;
this.activeCall = response.data.active_call;
// If call just ended, refresh history and show ended state
if (oldCall != null && this.activeCall == null) {
this.getHistory();
this.lastCall = oldCall;
this.isCallEnded = true;
if (this.endedTimeout) clearTimeout(this.endedTimeout);
this.endedTimeout = setTimeout(() => {
this.isCallEnded = false;
this.lastCall = null;
}, 5000);
} else if (this.activeCall != null) {
// if a new call starts, clear ended state
this.isCallEnded = false;
this.lastCall = null;
if (this.endedTimeout) clearTimeout(this.endedTimeout);
}
} catch (e) {
console.log(e);
}
},
async getHistory() {
try {
const response = await window.axios.get("/api/v1/telephone/history?limit=10");
this.callHistory = response.data.call_history;
} catch (e) {
console.log(e);
}
},
async call(identityHash) {
if (!identityHash) {
ToastUtils.error("Enter an identity hash to call");
return;
}
try {
await window.axios.get(`/api/v1/telephone/call/${identityHash}`);
} catch (e) {
ToastUtils.error(e.response?.data?.message || "Failed to initiate call");
}
},
async answerCall() {
try {
await window.axios.get("/api/v1/telephone/answer");
} catch {
ToastUtils.error("Failed to answer call");
}
},
async hangupCall() {
try {
await window.axios.get("/api/v1/telephone/hangup");
} catch {
ToastUtils.error("Failed to hangup call");
}
},
async switchAudioProfile(audioProfileId) {
try {
await window.axios.get(`/api/v1/telephone/switch-audio-profile/${audioProfileId}`);
} catch {
ToastUtils.error("Failed to switch audio profile");
}
},
async toggleMicrophone() {
try {
const endpoint = this.isMicMuted
? "/api/v1/telephone/unmute-transmit"
: "/api/v1/telephone/mute-transmit";
await window.axios.get(endpoint);
if (this.activeCall) {
this.activeCall.is_mic_muted = !this.isMicMuted;
}
} catch {
ToastUtils.error("Failed to toggle microphone");
}
},
async toggleSpeaker() {
try {
const endpoint = this.isSpeakerMuted
? "/api/v1/telephone/unmute-receive"
: "/api/v1/telephone/mute-receive";
await window.axios.get(endpoint);
if (this.activeCall) {
this.activeCall.is_speaker_muted = !this.isSpeakerMuted;
}
} catch {
ToastUtils.error("Failed to toggle speaker");
}
},
},
};
</script>

View File

@@ -0,0 +1,10 @@
<template>
<label class="block text-sm font-medium text-gray-900 dark:text-zinc-100">
<slot />
</label>
</template>
<script>
export default {
name: "FormLabel",
};
</script>

View File

@@ -0,0 +1,10 @@
<template>
<div class="text-xs text-gray-600 dark:text-zinc-300">
<slot />
</div>
</template>
<script>
export default {
name: "FormSubLabel",
};
</script>

View File

@@ -0,0 +1,36 @@
<template>
<label :for="id" class="relative inline-flex items-center cursor-pointer">
<input
:id="id"
type="checkbox"
:checked="modelValue"
class="sr-only peer"
@change="$emit('update:modelValue', $event.target.checked)"
/>
<div
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"
></div>
<span v-if="label" class="ml-3 text-sm font-medium text-gray-900 dark:text-gray-300">{{ label }}</span>
</label>
</template>
<script>
export default {
name: "Toggle",
props: {
id: {
type: String,
required: true,
},
modelValue: {
type: Boolean,
default: false,
},
label: {
type: String,
default: null,
},
},
emits: ["update:modelValue"],
};
</script>

View File

@@ -0,0 +1,202 @@
<template>
<div
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="overflow-y-auto space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("forwarder.title") }}</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("forwarder.description") }}
</div>
</div>
<!-- Add New Rule -->
<div class="glass-card space-y-4">
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ $t("forwarder.add_rule") }}</div>
<div class="grid gap-4 md:grid-cols-2">
<div class="space-y-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{
$t("forwarder.forward_to_hash")
}}</label>
<input
v-model="newRule.forward_to_hash"
type="text"
:placeholder="$t('forwarder.destination_placeholder')"
class="w-full px-4 py-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all outline-none"
/>
</div>
<div class="space-y-1">
<label class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{
$t("forwarder.source_filter")
}}</label>
<input
v-model="newRule.source_filter_hash"
type="text"
:placeholder="$t('forwarder.source_filter_placeholder')"
class="w-full px-4 py-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all outline-none"
/>
</div>
</div>
<div class="flex justify-end">
<button
class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition-colors flex items-center gap-2"
@click="addRule"
>
<MaterialDesignIcon icon-name="plus" class="w-5 h-5" />
{{ $t("forwarder.add_button") }}
</button>
</div>
</div>
<!-- Rules List -->
<div class="space-y-4">
<div class="text-lg font-semibold text-gray-900 dark:text-white">
{{ $t("forwarder.active_rules") }}
</div>
<div v-if="rules.length === 0" class="glass-card text-center py-12 text-gray-500 dark:text-zinc-400">
{{ $t("forwarder.no_rules") }}
</div>
<div v-for="rule in rules" :key="rule.id" class="glass-card flex items-center justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<div
class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider"
:class="
rule.is_active
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: 'bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-400'
"
>
{{ rule.is_active ? $t("forwarder.active") : $t("forwarder.disabled") }}
</div>
<span class="text-xs text-gray-500 dark:text-zinc-400">ID: {{ rule.id }}</span>
</div>
<div class="space-y-1">
<div class="flex items-center gap-2">
<MaterialDesignIcon icon-name="arrow-right" class="w-4 h-4 text-blue-500 shrink-0" />
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
{{ $t("forwarder.forwarding_to", { hash: rule.forward_to_hash }) }}
</span>
</div>
<div v-if="rule.source_filter_hash" class="flex items-center gap-2">
<MaterialDesignIcon
icon-name="filter-variant"
class="w-4 h-4 text-purple-500 shrink-0"
/>
<span class="text-sm text-gray-600 dark:text-zinc-300 truncate">
{{ $t("forwarder.source_filter_display", { hash: rule.source_filter_hash }) }}
</span>
</div>
</div>
</div>
<div class="flex items-center gap-2">
<button
class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
:title="rule.is_active ? $t('forwarder.disabled') : $t('forwarder.active')"
@click="toggleRule(rule.id)"
>
<MaterialDesignIcon
:icon-name="rule.is_active ? 'toggle-switch' : 'toggle-switch-off'"
class="w-6 h-6"
:class="rule.is_active ? 'text-blue-500' : 'text-gray-400'"
/>
</button>
<button
class="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 rounded-lg transition-colors"
:title="$t('common.delete')"
@click="deleteRule(rule.id)"
>
<MaterialDesignIcon icon-name="delete" class="w-6 h-6" />
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
export default {
name: "ForwarderPage",
components: {
MaterialDesignIcon,
},
data() {
return {
rules: [],
newRule: {
forward_to_hash: "",
source_filter_hash: "",
is_active: true,
},
};
},
mounted() {
WebSocketConnection.on("message", this.onWebsocketMessage);
this.fetchRules();
},
beforeUnmount() {
WebSocketConnection.off("message", this.onWebsocketMessage);
},
methods: {
fetchRules() {
WebSocketConnection.send(
JSON.stringify({
type: "lxmf.forwarding.rules.get",
})
);
},
onWebsocketMessage(message) {
try {
const data = JSON.parse(message.data);
if (data.type === "lxmf.forwarding.rules") {
this.rules = data.rules;
}
} catch (e) {
console.error("Failed to parse websocket message", e);
}
},
addRule() {
if (!this.newRule.forward_to_hash) return;
WebSocketConnection.send(
JSON.stringify({
type: "lxmf.forwarding.rule.add",
rule: { ...this.newRule },
})
);
this.newRule.forward_to_hash = "";
this.newRule.source_filter_hash = "";
},
deleteRule(id) {
if (confirm(this.$t("forwarder.delete_confirm"))) {
WebSocketConnection.send(
JSON.stringify({
type: "lxmf.forwarding.rule.delete",
id: id,
})
);
}
},
toggleRule(id) {
WebSocketConnection.send(
JSON.stringify({
type: "lxmf.forwarding.rule.toggle",
id: id,
})
);
},
},
};
</script>
<style scoped>
.glass-card {
@apply bg-white/80 dark:bg-zinc-900/80 backdrop-blur-md border border-gray-200 dark:border-zinc-800 p-6 rounded-3xl shadow-sm;
}
</style>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
<template>
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900 overflow-hidden">
<div
class="flex p-2 justify-between cursor-pointer hover:bg-gray-50 dark:hover:bg-zinc-800"
@click="isExpanded = !isExpanded"
>
<div class="my-auto mr-auto">
<div class="font-bold dark:text-white">
<slot name="title" />
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
<slot name="subtitle" />
</div>
</div>
<div class="my-auto ml-2">
<div
class="w-5 h-5 text-gray-600 dark:text-gray-300 transform transition-transform duration-200"
:class="{ 'rotate-90': isExpanded }"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" class="size-5">
<rect width="256" height="256" fill="none" />
<path
d="M181.66,122.34l-80-80A8,8,0,0,0,88,48V208a8,8,0,0,0,13.66,5.66l80-80A8,8,0,0,0,181.66,122.34Z"
fill="currentColor"
/>
</svg>
</div>
</div>
</div>
<div v-if="isExpanded" class="divide-y divide-gray-200 dark:text-white">
<slot name="content" />
</div>
</div>
</template>
<script>
export default {
name: "ExpandingSection",
data() {
return {
isExpanded: false,
};
},
};
</script>

View File

@@ -0,0 +1,275 @@
<template>
<div
v-if="isShowing"
class="fixed inset-0 bg-gray-500 bg-opacity-75 transition-opacity flex items-center justify-center"
>
<div class="flex w-full h-full p-4 overflow-y-auto">
<div
v-click-outside="dismiss"
class="my-auto mx-auto w-full bg-white dark:bg-zinc-900 rounded-lg shadow-xl max-w-2xl"
>
<!-- title -->
<div class="p-4 border-b dark:border-zinc-700">
<h3 class="text-lg font-semibold dark:text-white">Import Interfaces</h3>
</div>
<!-- content -->
<div class="divide-y dark:divide-zinc-700">
<!-- file input -->
<div class="p-2">
<div>
<input
ref="import-interfaces-file-input"
type="file"
accept="*"
class="w-full text-sm text-gray-500 dark:text-zinc-400"
@change="onFileSelected"
/>
</div>
<div v-if="!selectedFile" class="mt-2 text-sm text-gray-700 dark:text-zinc-200">
<ul class="list-disc list-inside">
<li>You can import interfaces from a ~/.reticulum/config file.</li>
<li>You can import interfaces from an exported interfaces file.</li>
</ul>
</div>
</div>
<!-- select interfaces -->
<div v-if="importableInterfaces.length > 0" class="divide-y dark:divide-zinc-700">
<div class="flex p-2">
<div class="my-auto mr-auto text-sm font-medium text-gray-700 dark:text-zinc-200">
Select Interfaces to Import
</div>
<div class="my-auto space-x-2">
<button class="text-sm text-blue-500 hover:underline" @click="selectAllInterfaces">
Select All
</button>
<button class="text-sm text-blue-500 hover:underline" @click="deselectAllInterfaces">
Deselect All
</button>
</div>
</div>
<div class="bg-gray-200 p-2 space-y-2 max-h-80 overflow-y-auto dark:bg-zinc-800">
<div
v-for="iface in importableInterfaces"
:key="iface.name"
class="bg-white cursor-pointer flex items-center p-2 border rounded shadow dark:bg-zinc-900 dark:border-zinc-700"
>
<div class="mr-auto text-sm flex-1" @click="toggleSelectedInterface(iface.name)">
<div class="font-semibold text-gray-700 dark:text-zinc-100">{{ iface.name }}</div>
<div class="text-sm text-gray-500 dark:text-zinc-100">
<!-- auto interface -->
<div v-if="iface.type === 'AutoInterface'">
<div>{{ iface.type }}</div>
<div>Ethernet and WiFi</div>
</div>
<!-- tcp client interface -->
<div v-else-if="iface.type === 'TCPClientInterface'">
<div>{{ iface.type }}</div>
<div>{{ iface.target_host }}:{{ iface.target_port }}</div>
</div>
<!-- tcp server interface -->
<div v-else-if="iface.type === 'TCPServerInterface'">
<div>{{ iface.type }}</div>
<div>{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
</div>
<!-- udp interface -->
<div v-else-if="iface.type === 'UDPInterface'">
<div>{{ iface.type }}</div>
<div>Listen: {{ iface.listen_ip }}:{{ iface.listen_port }}</div>
<div>Forward: {{ iface.forward_ip }}:{{ iface.forward_port }}</div>
</div>
<!-- rnode interface details -->
<div v-else-if="iface.type === 'RNodeInterface'">
<div>{{ iface.type }}</div>
<div>Port: {{ iface.port }}</div>
<div>Frequency: {{ formatFrequency(iface.frequency) }}</div>
<div>Bandwidth: {{ formatFrequency(iface.bandwidth) }}</div>
<div>Spreading Factor: {{ iface.spreadingfactor }}</div>
<div>Coding Rate: {{ iface.codingrate }}</div>
<div>Transmit Power: {{ iface.txpower }}dBm</div>
</div>
<!-- other interface types -->
<div v-else>{{ iface.type }}</div>
</div>
</div>
<div @click.stop>
<Toggle
:id="`import-interface-${iface.name}`"
:model-value="selectedInterfaces.includes(iface.name)"
@update:model-value="
(value) => {
if (value && !selectedInterfaces.includes(iface.name))
selectInterface(iface.name);
else if (!value && selectedInterfaces.includes(iface.name))
deselectInterface(iface.name);
}
"
/>
</div>
</div>
</div>
</div>
</div>
<!-- actions -->
<div class="p-4 border-t dark:border-zinc-700 flex justify-end space-x-2">
<button
class="px-4 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 dark:bg-zinc-800 dark:text-zinc-200 dark:border-zinc-600 dark:hover:bg-zinc-700"
@click="dismiss"
>
Cancel
</button>
<button
class="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-md hover:bg-blue-700 dark:bg-blue-700 dark:hover:bg-blue-600"
@click="importSelectedInterfaces"
>
Import Selected
</button>
</div>
</div>
</div>
</div>
</template>
<script>
import DialogUtils from "../../js/DialogUtils";
import Utils from "../../js/Utils";
import Toggle from "../forms/Toggle.vue";
export default {
name: "ImportInterfacesModal",
components: {
Toggle,
},
emits: ["dismissed"],
data() {
return {
isShowing: false,
selectedFile: null,
importableInterfaces: [],
selectedInterfaces: [],
};
},
methods: {
show() {
this.isShowing = true;
this.selectedFile = null;
this.importableInterfaces = [];
this.selectedInterfaces = [];
},
dismiss(result = false) {
this.isShowing = false;
const imported = result === true;
this.$emit("dismissed", imported);
},
clearSelectedFile() {
this.selectedFile = null;
this.$refs["import-interfaces-file-input"].value = null;
},
async onFileSelected(event) {
// get selected file
const file = event.target.files[0];
if (!file) {
return;
}
// update ui
this.selectedFile = file;
this.importableInterfaces = [];
this.selectedInterfaces = [];
try {
// fetch preview of interfaces to import
const response = await window.axios.post("/api/v1/reticulum/interfaces/import-preview", {
config: await file.text(),
});
// ensure there are some interfaces available to import
if (!response.data.interfaces || response.data.interfaces.length === 0) {
this.clearSelectedFile();
DialogUtils.alert("No interfaces were found in the selected configuration file");
return;
}
// update ui
this.importableInterfaces = response.data.interfaces;
// auto select all interfaces
this.selectAllInterfaces();
} catch (e) {
this.clearSelectedFile();
DialogUtils.alert("Failed to parse configuration file");
console.error(e);
}
},
isInterfaceSelected(name) {
return this.selectedInterfaces.includes(name);
},
selectInterface(name) {
if (!this.isInterfaceSelected(name)) {
this.selectedInterfaces.push(name);
}
},
deselectInterface(name) {
this.selectedInterfaces = this.selectedInterfaces.filter((selectedInterfaceName) => {
return selectedInterfaceName !== name;
});
},
toggleSelectedInterface(name) {
if (this.isInterfaceSelected(name)) {
this.deselectInterface(name);
} else {
this.selectInterface(name);
}
},
selectAllInterfaces() {
this.selectedInterfaces = this.importableInterfaces.map((i) => i.name);
},
deselectAllInterfaces() {
this.selectedInterfaces = [];
},
async importSelectedInterfaces() {
// ensure user selected a file to import from
if (!this.selectedFile) {
DialogUtils.alert("Please select a configuration file");
return;
}
// ensure user selected some interfaces
if (this.selectedInterfaces.length === 0) {
DialogUtils.alert("Please select at least one interface to import");
return;
}
try {
// import interfaces
await window.axios.post("/api/v1/reticulum/interfaces/import", {
config: await this.selectedFile.text(),
selected_interface_names: this.selectedInterfaces,
});
// dismiss modal
this.dismiss(true);
// tell user interfaces were imported
DialogUtils.alert(
"Interfaces imported successfully. MeshChat must be restarted for these changes to take effect."
);
} catch (e) {
const message = e.response?.data?.message || "Failed to import interfaces";
DialogUtils.alert(message);
console.error(e);
}
},
formatFrequency(hz) {
return Utils.formatFrequency(hz);
},
},
};
</script>

View File

@@ -0,0 +1,266 @@
<template>
<div class="interface-card">
<div class="flex gap-4 items-start">
<div class="interface-card__icon">
<MaterialDesignIcon :icon-name="iconName" class="w-6 h-6" />
</div>
<div class="flex-1 space-y-2">
<div class="flex items-center gap-2 flex-wrap">
<div class="text-lg font-semibold text-gray-900 dark:text-white truncate">{{ iface._name }}</div>
<span class="type-chip">{{ iface.type }}</span>
<span :class="statusChipClass">{{
isInterfaceEnabled(iface) ? $t("app.enabled") : $t("app.disabled")
}}</span>
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ description }}
</div>
<div class="flex flex-wrap gap-2 text-xs text-gray-600 dark:text-gray-300">
<span v-if="iface._stats?.bitrate" class="stat-chip"
>{{ $t("interface.bitrate") }} {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</span
>
<span class="stat-chip">{{ $t("interface.tx") }} {{ formatBytes(iface._stats?.txb ?? 0) }}</span>
<span class="stat-chip">{{ $t("interface.rx") }} {{ formatBytes(iface._stats?.rxb ?? 0) }}</span>
<span v-if="iface.type === 'RNodeInterface' && iface._stats?.noise_floor" class="stat-chip"
>{{ $t("interface.noise") }} {{ iface._stats?.noise_floor }} dBm</span
>
<span v-if="iface._stats?.clients != null" class="stat-chip"
>{{ $t("interface.clients") }} {{ iface._stats?.clients }}</span
>
</div>
<div v-if="iface._stats?.ifac_signature" class="ifac-line">
<span class="text-emerald-500 font-semibold">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span>
<span v-if="iface._stats?.ifac_netname"> {{ iface._stats.ifac_netname }}</span>
<span></span>
<button
type="button"
class="text-blue-500 hover:underline"
@click="onIFACSignatureClick(iface._stats.ifac_signature)"
>
{{ iface._stats.ifac_signature.slice(0, 8) }}{{ iface._stats.ifac_signature.slice(-8) }}
</button>
</div>
</div>
<div class="flex flex-col gap-2 items-end relative">
<button
v-if="isInterfaceEnabled(iface)"
type="button"
class="secondary-chip text-xs"
@click="disableInterface"
>
<MaterialDesignIcon icon-name="power" class="w-4 h-4" />
{{ $t("interface.disable") }}
</button>
<button v-else type="button" class="primary-chip text-xs" @click="enableInterface">
<MaterialDesignIcon icon-name="power" class="w-4 h-4" />
{{ $t("interface.enable") }}
</button>
<div class="relative z-50">
<DropDownMenu>
<template #button>
<IconButton>
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
</IconButton>
</template>
<template #items>
<div class="max-h-60 overflow-auto py-1 space-y-1">
<DropDownMenuItem @click="editInterface">
<MaterialDesignIcon icon-name="pencil" class="w-5 h-5" />
<span>{{ $t("interface.edit_interface") }}</span>
</DropDownMenuItem>
<DropDownMenuItem @click="exportInterface">
<MaterialDesignIcon icon-name="export" class="w-5 h-5" />
<span>{{ $t("interface.export_interface") }}</span>
</DropDownMenuItem>
<DropDownMenuItem @click="deleteInterface">
<MaterialDesignIcon icon-name="trash-can" class="w-5 h-5 text-red-500" />
<span class="text-red-500">{{ $t("interface.delete_interface") }}</span>
</DropDownMenuItem>
</div>
</template>
</DropDownMenu>
</div>
</div>
</div>
<div
v-if="['UDPInterface', 'RNodeInterface'].includes(iface.type)"
class="mt-4 grid gap-2 text-sm text-gray-700 dark:text-gray-300"
>
<div v-if="iface.type === 'UDPInterface'" class="detail-grid">
<div>
<div class="detail-label">{{ $t("interface.listen") }}</div>
<div class="detail-value">{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
</div>
<div>
<div class="detail-label">{{ $t("interface.forward") }}</div>
<div class="detail-value">{{ iface.forward_ip }}:{{ iface.forward_port }}</div>
</div>
</div>
<div v-else-if="iface.type === 'RNodeInterface'" class="detail-grid">
<div>
<div class="detail-label">{{ $t("interface.port") }}</div>
<div class="detail-value">{{ iface.port }}</div>
</div>
<div>
<div class="detail-label">{{ $t("interface.frequency") }}</div>
<div class="detail-value">{{ formatFrequency(iface.frequency) }}</div>
</div>
<div>
<div class="detail-label">{{ $t("interface.bandwidth") }}</div>
<div class="detail-value">{{ formatFrequency(iface.bandwidth) }}</div>
</div>
<div>
<div class="detail-label">{{ $t("interface.spreading_factor") }}</div>
<div class="detail-value">{{ iface.spreadingfactor }}</div>
</div>
<div>
<div class="detail-label">{{ $t("interface.coding_rate") }}</div>
<div class="detail-value">{{ iface.codingrate }}</div>
</div>
<div>
<div class="detail-label">{{ $t("interface.txpower") }}</div>
<div class="detail-value">{{ iface.txpower }} dBm</div>
</div>
</div>
</div>
</div>
</template>
<script>
import DialogUtils from "../../js/DialogUtils";
import Utils from "../../js/Utils";
import DropDownMenuItem from "../DropDownMenuItem.vue";
import IconButton from "../IconButton.vue";
import DropDownMenu from "../DropDownMenu.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "Interface",
components: {
DropDownMenu,
IconButton,
DropDownMenuItem,
MaterialDesignIcon,
},
props: {
iface: {
type: Object,
required: true,
},
},
emits: ["enable", "disable", "edit", "export", "delete"],
data() {
return {};
},
computed: {
iconName() {
switch (this.iface.type) {
case "AutoInterface":
return "home-automation";
case "RNodeInterface":
return "radio-tower";
case "RNodeMultiInterface":
return "access-point-network";
case "TCPClientInterface":
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";
}
},
description() {
if (this.iface.type === "TCPClientInterface") {
return `${this.iface.target_host}:${this.iface.target_port}`;
}
if (this.iface.type === "TCPServerInterface" || this.iface.type === "UDPInterface") {
return `${this.iface.listen_ip}:${this.iface.listen_port}`;
}
if (this.iface.type === "SerialInterface") {
return `${this.iface.port} @ ${this.iface.speed || "9600"}bps`;
}
if (this.iface.type === "AutoInterface") {
return "Auto-detect Ethernet and Wi-Fi peers";
}
return this.iface.description || "Custom interface";
},
statusChipClass() {
return this.isInterfaceEnabled(this.iface)
? "inline-flex items-center rounded-full bg-green-100 text-green-700 px-2 py-0.5 text-xs font-semibold"
: "inline-flex items-center rounded-full bg-red-100 text-red-700 px-2 py-0.5 text-xs font-semibold";
},
},
methods: {
onIFACSignatureClick: function (ifacSignature) {
DialogUtils.alert(ifacSignature);
},
isInterfaceEnabled: function (iface) {
return Utils.isInterfaceEnabled(iface);
},
enableInterface() {
this.$emit("enable");
},
disableInterface() {
this.$emit("disable");
},
editInterface() {
this.$emit("edit");
},
exportInterface() {
this.$emit("export");
},
deleteInterface() {
this.$emit("delete");
},
formatBitsPerSecond: function (bits) {
return Utils.formatBitsPerSecond(bits);
},
formatBytes: function (bytes) {
return Utils.formatBytes(bytes);
},
formatFrequency(hz) {
return Utils.formatFrequency(hz);
},
},
};
</script>
<style scoped>
.interface-card {
@apply bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg p-4 space-y-3;
overflow: visible;
}
.interface-card__icon {
@apply w-12 h-12 rounded-2xl bg-blue-50 text-blue-600 dark:bg-blue-900/40 dark:text-blue-200 flex items-center justify-center;
}
.type-chip {
@apply inline-flex items-center rounded-full bg-gray-100 dark:bg-zinc-800 px-2 py-0.5 text-xs font-semibold text-gray-600 dark:text-gray-200;
}
.stat-chip {
@apply inline-flex items-center rounded-full border border-gray-200 dark:border-zinc-700 px-2 py-0.5;
}
.ifac-line {
@apply text-xs flex flex-wrap items-center gap-1;
}
.detail-grid {
@apply grid gap-3 sm:grid-cols-2;
}
.detail-label {
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
}
.detail-value {
@apply text-sm font-medium text-gray-900 dark:text-white;
}
</style>

View File

@@ -0,0 +1,362 @@
<template>
<div
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="overflow-y-auto p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full">
<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"
>
<div class="flex items-center gap-3">
<MaterialDesignIcon icon-name="alert" class="w-6 h-6" />
<div>
<div class="text-lg font-semibold">{{ $t("interfaces.restart_required") }}</div>
<div class="text-sm">{{ $t("interfaces.restart_description") }}</div>
</div>
</div>
<button
v-if="isElectron"
type="button"
class="ml-auto inline-flex items-center gap-2 rounded-full border border-white/40 px-4 py-1.5 text-sm font-semibold text-white hover:bg-white/10 transition"
@click="relaunch"
>
<MaterialDesignIcon icon-name="restart" class="w-4 h-4" />
{{ $t("interfaces.restart_now") }}
</button>
</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">
{{ $t("interfaces.manage") }}
</div>
<div class="text-xl font-semibold text-gray-900 dark:text-white">
{{ $t("interfaces.title") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $t("interfaces.description") }}</div>
</div>
<div class="flex flex-wrap gap-2">
<RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
<MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
{{ $t("interfaces.add_interface") }}
</RouterLink>
<button type="button" class="secondary-chip text-sm" @click="showImportInterfacesModal">
<MaterialDesignIcon icon-name="import" class="w-4 h-4" />
{{ $t("interfaces.import") }}
</button>
<button type="button" class="secondary-chip text-sm" @click="exportInterfaces">
<MaterialDesignIcon icon-name="export" class="w-4 h-4" />
{{ $t("interfaces.export_all") }}
</button>
</div>
</div>
<div class="flex flex-wrap gap-3 items-center">
<div class="flex-1">
<input
v-model="searchTerm"
type="text"
:placeholder="$t('interfaces.search_placeholder')"
class="input-field"
/>
</div>
<div class="flex gap-2 flex-wrap">
<button
type="button"
:class="filterChipClass(statusFilter === 'all')"
@click="setStatusFilter('all')"
>
{{ $t("interfaces.all") }}
</button>
<button
type="button"
:class="filterChipClass(statusFilter === 'enabled')"
@click="setStatusFilter('enabled')"
>
{{ $t("app.enabled") }}
</button>
<button
type="button"
:class="filterChipClass(statusFilter === 'disabled')"
@click="setStatusFilter('disabled')"
>
{{ $t("app.disabled") }}
</button>
</div>
<div class="w-full sm:w-60">
<select v-model="typeFilter" class="input-field">
<option value="all">{{ $t("interfaces.all_types") }}</option>
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">{{ type }}</option>
</select>
</div>
</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 v-else class="grid gap-4 xl:grid-cols-2">
<Interface
v-for="iface of filteredInterfaces"
:key="iface._name"
:iface="iface"
@enable="enableInterface(iface._name)"
@disable="disableInterface(iface._name)"
@edit="editInterface(iface._name)"
@export="exportInterface(iface._name)"
@delete="deleteInterface(iface._name)"
/>
</div>
</div>
</div>
<ImportInterfacesModal ref="import-interfaces-modal" @dismissed="onImportInterfacesModalDismissed" />
</template>
<script>
import DialogUtils from "../../js/DialogUtils";
import ElectronUtils from "../../js/ElectronUtils";
import Interface from "./Interface.vue";
import Utils from "../../js/Utils";
import ImportInterfacesModal from "./ImportInterfacesModal.vue";
import DownloadUtils from "../../js/DownloadUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "InterfacesPage",
components: {
ImportInterfacesModal,
Interface,
MaterialDesignIcon,
},
data() {
return {
interfaces: {},
interfaceStats: {},
reloadInterval: null,
searchTerm: "",
statusFilter: "all",
typeFilter: "all",
hasPendingInterfaceChanges: false,
};
},
computed: {
isElectron() {
return ElectronUtils.isElectron();
},
showRestartReminder() {
return this.hasPendingInterfaceChanges;
},
interfacesWithStats() {
const results = [];
for (const [interfaceName, iface] of Object.entries(this.interfaces)) {
iface._name = interfaceName;
iface._stats = this.interfaceStats[interfaceName];
results.push(iface);
}
return results;
},
enabledInterfaces() {
return this.interfacesWithStats.filter((iface) => this.isInterfaceEnabled(iface));
},
disabledInterfaces() {
return this.interfacesWithStats.filter((iface) => !this.isInterfaceEnabled(iface));
},
filteredInterfaces() {
const search = this.searchTerm.toLowerCase().trim();
return this.interfacesWithStats
.filter((iface) => {
if (this.statusFilter === "enabled" && !this.isInterfaceEnabled(iface)) {
return false;
}
if (this.statusFilter === "disabled" && this.isInterfaceEnabled(iface)) {
return false;
}
if (this.typeFilter !== "all" && iface.type !== this.typeFilter) {
return false;
}
if (!search) {
return true;
}
const haystack = [
iface._name,
iface.type,
iface.target_host,
iface.target_port,
iface.listen_ip,
iface.listen_port,
]
.filter(Boolean)
.join(" ")
.toLowerCase();
return haystack.includes(search);
})
.sort((a, b) => {
const enabledDiff = Number(this.isInterfaceEnabled(b)) - Number(this.isInterfaceEnabled(a));
if (enabledDiff !== 0) return enabledDiff;
return a._name.localeCompare(b._name);
});
},
sortedInterfaceTypes() {
const types = new Set();
this.interfacesWithStats.forEach((iface) => types.add(iface.type));
return Array.from(types).sort();
},
},
beforeUnmount() {
clearInterval(this.reloadInterval);
},
mounted() {
this.loadInterfaces();
this.updateInterfaceStats();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.updateInterfaceStats();
}, 1000);
},
methods: {
relaunch() {
ElectronUtils.relaunch();
},
trackInterfaceChange() {
this.hasPendingInterfaceChanges = true;
},
isInterfaceEnabled: function (iface) {
return Utils.isInterfaceEnabled(iface);
},
async loadInterfaces() {
try {
const response = await window.axios.get(`/api/v1/reticulum/interfaces`);
this.interfaces = response.data.interfaces;
} catch {
// do nothing if failed to load interfaces
}
},
async updateInterfaceStats() {
try {
// fetch interface stats
const response = await window.axios.get(`/api/v1/interface-stats`);
// update data
const interfaces = response.data.interface_stats?.interfaces ?? [];
for (const iface of interfaces) {
this.interfaceStats[iface.short_name] = iface;
}
} catch {
// do nothing if failed to load interfaces
}
},
async enableInterface(interfaceName) {
// enable interface
try {
await window.axios.post(`/api/v1/reticulum/interfaces/enable`, {
name: interfaceName,
});
this.trackInterfaceChange();
} catch (e) {
DialogUtils.alert("failed to enable interface");
console.log(e);
}
// reload interfaces
await this.loadInterfaces();
},
async disableInterface(interfaceName) {
// disable interface
try {
await window.axios.post(`/api/v1/reticulum/interfaces/disable`, {
name: interfaceName,
});
this.trackInterfaceChange();
} catch (e) {
DialogUtils.alert("failed to disable interface");
console.log(e);
}
// reload interfaces
await this.loadInterfaces();
},
async editInterface(interfaceName) {
this.$router.push({
name: "interfaces.edit",
query: {
interface_name: interfaceName,
},
});
},
async deleteInterface(interfaceName) {
// ask user to confirm deleting conversation history
if (
!(await DialogUtils.confirm("Are you sure you want to delete this interface? This can not be undone!"))
) {
return;
}
// delete interface
try {
await window.axios.post(`/api/v1/reticulum/interfaces/delete`, {
name: interfaceName,
});
this.trackInterfaceChange();
} catch (e) {
DialogUtils.alert("failed to delete interface");
console.log(e);
}
// reload interfaces
await this.loadInterfaces();
},
async exportInterfaces() {
try {
// fetch exported interfaces
const response = await window.axios.post("/api/v1/reticulum/interfaces/export");
this.trackInterfaceChange();
// download file to browser
DownloadUtils.downloadFile("meshchat_interfaces.txt", new Blob([response.data]));
} catch (e) {
DialogUtils.alert("Failed to export interfaces");
console.error(e);
}
},
async exportInterface(interfaceName) {
try {
// fetch exported interfaces
const response = await window.axios.post("/api/v1/reticulum/interfaces/export", {
selected_interface_names: [interfaceName],
});
this.trackInterfaceChange();
// download file to browser
DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
} catch (e) {
DialogUtils.alert("Failed to export interface");
console.error(e);
}
},
showImportInterfacesModal() {
this.$refs["import-interfaces-modal"].show();
},
onImportInterfacesModalDismissed(imported = false) {
// reload interfaces as something may have been imported
this.loadInterfaces();
if (imported) {
this.trackInterfaceChange();
}
},
setStatusFilter(value) {
this.statusFilter = value;
},
filterChipClass(isActive) {
return isActive ? "primary-chip text-xs" : "secondary-chip text-xs";
},
},
};
</script>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,112 @@
<template>
<div class="inline-flex">
<button
v-if="isRecordingAudioAttachment"
type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-full border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-semibold text-red-700 shadow-sm hover:border-red-400 transition dark:border-red-500/40 dark:bg-red-900/30 dark:text-red-100"
@click="stopRecordingAudioAttachment"
>
<MaterialDesignIcon icon-name="microphone" class="w-4 h-4" />
<span class="ml-1">
<slot />
</span>
</button>
<button
v-else
type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition"
@click="showMenu"
>
<MaterialDesignIcon icon-name="microphone-plus" class="w-4 h-4" />
<span class="hidden xl:inline-block whitespace-nowrap">Add Voice</span>
</button>
<div class="relative block">
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="isShowingMenu"
v-click-outside="hideMenu"
class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none"
>
<div class="py-1">
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="startRecordingCodec2('1200')"
>
Low Quality - Codec2 (1200)
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="startRecordingCodec2('3200')"
>
Medium Quality - Codec2 (3200)
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="startRecordingOpus()"
>
High Quality - OPUS
</button>
</div>
</div>
</Transition>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "AddAudioButton",
components: {
MaterialDesignIcon,
},
props: {
isRecordingAudioAttachment: Boolean,
},
emits: ["start-recording", "stop-recording"],
data() {
return {
isShowingMenu: false,
};
},
methods: {
showMenu() {
this.isShowingMenu = true;
},
hideMenu() {
this.isShowingMenu = false;
},
startRecordingAudioAttachment(args) {
this.isShowingMenu = false;
this.$emit("start-recording", args);
},
startRecordingCodec2(mode) {
this.startRecordingAudioAttachment({
codec: "codec2",
mode: mode,
});
},
startRecordingOpus() {
this.startRecordingAudioAttachment({
codec: "opus",
});
},
stopRecordingAudioAttachment() {
this.isShowingMenu = false;
this.$emit("stop-recording");
},
},
};
</script>

View File

@@ -0,0 +1,164 @@
<template>
<div class="inline-flex">
<button
type="button"
class="my-auto inline-flex items-center gap-x-1 rounded-full border border-gray-200 dark:border-zinc-700 bg-white/90 dark:bg-zinc-900/80 px-3 py-1.5 text-xs font-semibold text-gray-800 dark:text-gray-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-500 transition"
@click="showMenu"
>
<MaterialDesignIcon icon-name="image-plus" class="w-4 h-4" />
<span class="hidden xl:inline-block whitespace-nowrap">Add Image</span>
</button>
<div class="relative block">
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="isShowingMenu"
v-click-outside="hideMenu"
class="absolute bottom-0 -ml-11 sm:right-0 sm:ml-0 z-10 mb-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none"
>
<div class="py-1">
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="addImage('low')"
>
Low Quality (320x320)
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="addImage('medium')"
>
Medium Quality (640x640)
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="addImage('high')"
>
High Quality (1280x1280)
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="addImage('original')"
>
Original Quality
</button>
</div>
</div>
</Transition>
</div>
<!-- hidden file input for selecting files -->
<input ref="image-input" type="file" accept="image/*" style="display: none" @change="onImageInputChange" />
</div>
</template>
<script>
import Compressor from "compressorjs";
import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "AddImageButton",
components: {
MaterialDesignIcon,
},
emits: ["add-image"],
data() {
return {
isShowingMenu: false,
selectedImageQuality: null,
};
},
methods: {
showMenu() {
this.isShowingMenu = true;
},
hideMenu() {
this.isShowingMenu = false;
},
addImage(quality) {
this.isShowingMenu = false;
this.selectedImageQuality = quality;
this.$refs["image-input"].click();
},
clearImageInput: function () {
this.$refs["image-input"].value = null;
},
onImageInputChange: async function (event) {
if (event.target.files.length > 0) {
// get selected file
const file = event.target.files[0];
// process file based on selected image quality
switch (this.selectedImageQuality) {
case "low": {
new Compressor(file, {
maxWidth: 320,
maxHeight: 320,
quality: 0.2,
mimeType: "image/webp",
success: (result) => {
this.$emit("add-image", result);
},
error: (err) => {
DialogUtils.alert(err.message);
},
});
break;
}
case "medium": {
new Compressor(file, {
maxWidth: 640,
maxHeight: 640,
quality: 0.6,
mimeType: "image/webp",
success: (result) => {
this.$emit("add-image", result);
},
error: (err) => {
DialogUtils.alert(err.message);
},
});
break;
}
case "high": {
new Compressor(file, {
maxWidth: 1280,
maxHeight: 1280,
quality: 0.75,
mimeType: "image/webp",
success: (result) => {
this.$emit("add-image", result);
},
error: (err) => {
DialogUtils.alert(err.message);
},
});
break;
}
case "original": {
this.$emit("add-image", file);
break;
}
default: {
DialogUtils.alert(`Unsupported image quality: ${this.selectedImageQuality}`);
break;
}
}
// clear image input to allow selecting the same file after user removed it
this.clearImageInput();
}
},
},
};
</script>

View File

@@ -0,0 +1,215 @@
<template>
<DropDownMenu>
<template #button>
<IconButton>
<MaterialDesignIcon icon-name="dots-vertical" class="size-5" />
</IconButton>
</template>
<template #items>
<!-- call button -->
<DropDownMenuItem @click="onStartCall">
<MaterialDesignIcon icon-name="phone" class="w-4 h-4" />
<span>Start a Call</span>
</DropDownMenuItem>
<!-- ping button -->
<DropDownMenuItem @click="onPingDestination">
<MaterialDesignIcon icon-name="flash" class="size-5" />
<span>Ping Destination</span>
</DropDownMenuItem>
<!-- set custom display name button -->
<DropDownMenuItem @click="onSetCustomDisplayName">
<MaterialDesignIcon icon-name="account-edit" class="size-5" />
<span>Set Custom Display Name</span>
</DropDownMenuItem>
<!-- block/unblock button -->
<div class="border-t">
<DropDownMenuItem v-if="!isBlocked" @click="onBlockDestination">
<MaterialDesignIcon icon-name="block-helper" class="size-5 text-red-500" />
<span class="text-red-500">Block User</span>
</DropDownMenuItem>
<DropDownMenuItem v-else @click="onUnblockDestination">
<MaterialDesignIcon icon-name="check-circle" class="size-5 text-green-500" />
<span class="text-green-500">Unblock User</span>
</DropDownMenuItem>
</div>
<!-- delete message history button -->
<div class="border-t">
<DropDownMenuItem @click="onDeleteMessageHistory">
<MaterialDesignIcon icon-name="delete" class="size-5 text-red-500" />
<span class="text-red-500">Delete Message History</span>
</DropDownMenuItem>
</div>
</template>
</DropDownMenu>
</template>
<script>
import DropDownMenu from "../DropDownMenu.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
import IconButton from "../IconButton.vue";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DialogUtils from "../../js/DialogUtils";
export default {
name: "ConversationDropDownMenu",
components: {
IconButton,
DropDownMenuItem,
DropDownMenu,
MaterialDesignIcon,
},
props: {
peer: {
type: Object,
required: true,
},
},
emits: ["conversation-deleted", "set-custom-display-name", "block-status-changed"],
data() {
return {
isBlocked: false,
blockedDestinations: [],
};
},
watch: {
peer: {
handler() {
this.checkIfBlocked();
},
immediate: true,
},
},
async mounted() {
await this.loadBlockedDestinations();
},
methods: {
async loadBlockedDestinations() {
try {
const response = await window.axios.get("/api/v1/blocked-destinations");
this.blockedDestinations = response.data.blocked_destinations || [];
this.checkIfBlocked();
} catch (e) {
console.log(e);
}
},
checkIfBlocked() {
if (!this.peer) {
this.isBlocked = false;
return;
}
this.isBlocked = this.blockedDestinations.some((b) => b.destination_hash === this.peer.destination_hash);
},
async onBlockDestination() {
if (
!(await DialogUtils.confirm(
"Are you sure you want to block this user? They will not be able to send you messages or establish links."
))
) {
return;
}
try {
await window.axios.post("/api/v1/blocked-destinations", {
destination_hash: this.peer.destination_hash,
});
await this.loadBlockedDestinations();
DialogUtils.alert("User blocked successfully");
this.$emit("block-status-changed");
} catch (e) {
DialogUtils.alert("Failed to block user");
console.log(e);
}
},
async onUnblockDestination() {
try {
await window.axios.delete(`/api/v1/blocked-destinations/${this.peer.destination_hash}`);
await this.loadBlockedDestinations();
DialogUtils.alert("User unblocked successfully");
this.$emit("block-status-changed");
} catch (e) {
DialogUtils.alert("Failed to unblock user");
console.log(e);
}
},
async onDeleteMessageHistory() {
// ask user to confirm deleting conversation history
if (
!(await DialogUtils.confirm(
"Are you sure you want to delete all messages in this conversation? This can not be undone!"
))
) {
return;
}
// delete all lxmf messages from "us to destination" and from "destination to us"
try {
await window.axios.delete(`/api/v1/lxmf-messages/conversation/${this.peer.destination_hash}`);
} catch (e) {
DialogUtils.alert("failed to delete conversation");
console.log(e);
}
// fire callback
this.$emit("conversation-deleted");
},
async onSetCustomDisplayName() {
this.$emit("set-custom-display-name");
},
async onStartCall() {
try {
await window.axios.get(`/api/v1/telephone/call/${this.peer.destination_hash}`);
} catch (e) {
const message = e.response?.data?.message ?? "Failed to start call";
DialogUtils.alert(message);
}
},
async onPingDestination() {
try {
// ping destination
const response = await window.axios.get(`/api/v1/ping/${this.peer.destination_hash}/lxmf.delivery`, {
params: {
timeout: 30,
},
});
const pingResult = response.data.ping_result;
const rttMilliseconds = (pingResult.rtt * 1000).toFixed(3);
const rttDurationString = `${rttMilliseconds} ms`;
const info = [
`Valid reply from ${this.peer.destination_hash}`,
`Duration: ${rttDurationString}`,
`Hops There: ${pingResult.hops_there}`,
`Hops Back: ${pingResult.hops_back}`,
];
// add signal quality if available
if (pingResult.quality != null) {
info.push(`Signal Quality: ${pingResult.quality}%`);
}
// add rssi if available
if (pingResult.rssi != null) {
info.push(`RSSI: ${pingResult.rssi}dBm`);
}
// add snr if available
if (pingResult.snr != null) {
info.push(`SNR: ${pingResult.snr}dB`);
}
// show result
DialogUtils.alert(info.join("\n"));
} catch (e) {
console.log(e);
const message = e.response?.data?.message ?? "Ping failed. Try again later";
DialogUtils.alert(message);
}
},
},
};
</script>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,338 @@
<template>
<div class="flex flex-1 min-w-0 h-full overflow-hidden">
<MessagesSidebar
v-if="!isPopoutMode"
:class="{ 'hidden sm:flex': destinationHash }"
:conversations="conversations"
:peers="peers"
:selected-destination-hash="selectedPeer?.destination_hash"
:conversation-search-term="conversationSearchTerm"
:filter-unread-only="filterUnreadOnly"
:filter-failed-only="filterFailedOnly"
:filter-has-attachments-only="filterHasAttachmentsOnly"
:is-loading="isLoadingConversations"
@conversation-click="onConversationClick"
@peer-click="onPeerClick"
@conversation-search-changed="onConversationSearchChanged"
@conversation-filter-changed="onConversationFilterChanged"
/>
<div
class="flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-white via-slate-50 to-slate-100 dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900/80"
:class="destinationHash ? 'flex' : 'hidden sm:flex'"
>
<!-- messages tab -->
<ConversationViewer
ref="conversation-viewer"
:my-lxmf-address-hash="config?.lxmf_address_hash"
:selected-peer="selectedPeer"
:conversations="conversations"
@close="onCloseConversationViewer"
@reload-conversations="getConversations"
/>
</div>
</div>
</template>
<script>
import WebSocketConnection from "../../js/WebSocketConnection";
import MessagesSidebar from "./MessagesSidebar.vue";
import ConversationViewer from "./ConversationViewer.vue";
import GlobalState from "../../js/GlobalState";
import DialogUtils from "../../js/DialogUtils";
import GlobalEmitter from "../../js/GlobalEmitter";
export default {
name: "MessagesPage",
components: {
ConversationViewer,
MessagesSidebar,
},
props: {
destinationHash: {
type: String,
required: false,
default: null,
},
},
data() {
return {
reloadInterval: null,
conversationRefreshTimeout: null,
config: null,
peers: {},
selectedPeer: null,
conversations: [],
lxmfDeliveryAnnounces: [],
conversationSearchTerm: "",
filterUnreadOnly: false,
filterFailedOnly: false,
filterHasAttachmentsOnly: false,
isLoadingConversations: false,
};
},
computed: {
popoutRouteType() {
if (this.$route?.meta?.popoutType) {
return this.$route.meta.popoutType;
}
return this.$route?.query?.popout ?? this.getHashPopoutValue();
},
isPopoutMode() {
return this.popoutRouteType === "conversation";
},
},
watch: {
conversations() {
// update global state
GlobalState.unreadConversationsCount = this.conversations.filter((conversation) => {
return conversation.is_unread;
}).length;
},
destinationHash(newHash) {
if (newHash) {
this.onComposeNewMessage(newHash);
}
},
},
beforeUnmount() {
clearInterval(this.reloadInterval);
clearTimeout(this.conversationRefreshTimeout);
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessage);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
GlobalEmitter.on("compose-new-message", this.onComposeNewMessage);
this.getConfig();
this.getConversations();
this.getLxmfDeliveryAnnounces();
// update info every few seconds
this.reloadInterval = setInterval(() => {
this.getConversations();
}, 5000);
// compose message if a destination hash was provided on page load
if (this.destinationHash) {
this.onComposeNewMessage(this.destinationHash);
}
},
methods: {
async onComposeNewMessage(destinationHash) {
if (destinationHash == null) {
if (this.selectedPeer) {
return;
}
this.$nextTick(() => {
const composeInput = document.getElementById("compose-input");
if (composeInput) {
composeInput.focus();
}
});
return;
}
if (destinationHash.startsWith("lxmf@")) {
destinationHash = destinationHash.replace("lxmf@", "");
}
await this.getLxmfDeliveryAnnounce(destinationHash);
const existingPeer = this.peers[destinationHash];
if (existingPeer) {
this.onPeerClick(existingPeer);
return;
}
if (destinationHash.length !== 32) {
DialogUtils.alert("Invalid Address");
return;
}
this.onPeerClick({
display_name: "Unknown Peer",
destination_hash: destinationHash,
});
},
async getConfig() {
try {
const response = await window.axios.get(`/api/v1/config`);
this.config = response.data.config;
} catch (e) {
// do nothing if failed to load config
console.log(e);
}
},
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch (json.type) {
case "config": {
this.config = json.config;
break;
}
case "announce": {
const aspect = json.announce.aspect;
if (aspect === "lxmf.delivery") {
this.updatePeerFromAnnounce(json.announce);
}
break;
}
case "lxmf.delivery": {
// reload conversations when a new message is received
await this.getConversations();
break;
}
}
},
async getLxmfDeliveryAnnounces() {
try {
// fetch announces for "lxmf.delivery" aspect
const response = await window.axios.get(`/api/v1/announces`, {
params: {
aspect: "lxmf.delivery",
limit: 500, // limit ui to showing 500 latest announces
},
});
// update ui
const lxmfDeliveryAnnounces = response.data.announces;
for (const lxmfDeliveryAnnounce of lxmfDeliveryAnnounces) {
this.updatePeerFromAnnounce(lxmfDeliveryAnnounce);
}
} catch (e) {
// do nothing if failed to load announces
console.log(e);
}
},
async getLxmfDeliveryAnnounce(destinationHash) {
try {
// fetch announce for destination hash
const response = await window.axios.get(`/api/v1/announces`, {
params: {
destination_hash: destinationHash,
limit: 1,
},
});
// update ui
const lxmfDeliveryAnnounces = response.data.announces;
for (const lxmfDeliveryAnnounce of lxmfDeliveryAnnounces) {
this.updatePeerFromAnnounce(lxmfDeliveryAnnounce);
}
} catch (e) {
// do nothing if failed to load announce
console.log(e);
}
},
async getConversations() {
try {
this.isLoadingConversations = true;
const response = await window.axios.get(`/api/v1/lxmf/conversations`, {
params: this.buildConversationQueryParams(),
});
this.conversations = response.data.conversations;
} catch (e) {
// do nothing if failed to load conversations
console.log(e);
} finally {
this.isLoadingConversations = false;
}
},
buildConversationQueryParams() {
const params = {};
if (this.conversationSearchTerm && this.conversationSearchTerm.trim() !== "") {
params.search = this.conversationSearchTerm.trim();
}
if (this.filterUnreadOnly) {
params.filter_unread = true;
}
if (this.filterFailedOnly) {
params.filter_failed = true;
}
if (this.filterHasAttachmentsOnly) {
params.filter_has_attachments = true;
}
return params;
},
updatePeerFromAnnounce: function (announce) {
this.peers[announce.destination_hash] = announce;
},
onPeerClick: function (peer) {
// update selected peer
this.selectedPeer = peer;
// update current route
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
const routeOptions = {
name: routeName,
params: {
destinationHash: peer.destination_hash,
},
};
if (!this.isPopoutMode && this.$route?.query) {
routeOptions.query = { ...this.$route.query };
}
this.$router.replace(routeOptions);
},
onConversationClick: function (conversation) {
// object must stay compatible with format of peers
this.onPeerClick(conversation);
// mark conversation as read
this.$refs["conversation-viewer"].markConversationAsRead(conversation);
},
onCloseConversationViewer: function () {
// clear selected peer
this.selectedPeer = null;
if (this.isPopoutMode) {
window.close();
return;
}
// update current route
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
const routeOptions = { name: routeName };
if (!this.isPopoutMode && this.$route?.query) {
routeOptions.query = { ...this.$route.query };
}
this.$router.replace(routeOptions);
},
requestConversationsRefresh() {
if (this.conversationRefreshTimeout) {
clearTimeout(this.conversationRefreshTimeout);
}
this.conversationRefreshTimeout = setTimeout(() => {
this.getConversations();
}, 250);
},
onConversationSearchChanged(term) {
this.conversationSearchTerm = term;
this.requestConversationsRefresh();
},
onConversationFilterChanged(filterKey) {
if (filterKey === "unread") {
this.filterUnreadOnly = !this.filterUnreadOnly;
} else if (filterKey === "failed") {
this.filterFailedOnly = !this.filterFailedOnly;
} else if (filterKey === "attachments") {
this.filterHasAttachmentsOnly = !this.filterHasAttachmentsOnly;
}
this.requestConversationsRefresh();
},
getHashPopoutValue() {
const hash = window.location.hash || "";
const match = hash.match(/popout=([^&]+)/);
return match ? decodeURIComponent(match[1]) : null;
},
},
};
</script>

View File

@@ -0,0 +1,436 @@
<template>
<div class="flex flex-col w-full sm:w-80 sm:min-w-80">
<!-- tabs -->
<div class="bg-transparent border-b border-r border-gray-200/70 dark:border-zinc-700/80 backdrop-blur">
<div class="-mb-px flex">
<div
class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition"
:class="[
tab === 'conversations'
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200',
]"
@click="tab = 'conversations'"
>
{{ $t("messages.conversations") }}
</div>
<div
class="w-full border-b-2 py-3 px-1 text-center text-sm font-semibold tracking-wide uppercase cursor-pointer transition"
:class="[
tab === 'announces'
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:border-gray-300 dark:hover:border-zinc-600 hover:text-gray-700 dark:hover:text-gray-200',
]"
@click="tab = 'announces'"
>
{{ $t("messages.announces") }}
</div>
</div>
</div>
<!-- conversations -->
<div
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"
>
<!-- search + filters -->
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2">
<input
:value="conversationSearchTerm"
type="text"
:placeholder="$t('messages.search_placeholder', { count: conversations.length })"
class="input-field"
@input="onConversationSearchInput"
/>
<div class="flex flex-wrap gap-1">
<button type="button" :class="filterChipClasses(filterUnreadOnly)" @click="toggleFilter('unread')">
{{ $t("messages.unread") }}
</button>
<button type="button" :class="filterChipClasses(filterFailedOnly)" @click="toggleFilter('failed')">
{{ $t("messages.failed") }}
</button>
<button
type="button"
:class="filterChipClasses(filterHasAttachmentsOnly)"
@click="toggleFilter('attachments')"
>
{{ $t("messages.attachments") }}
</button>
</div>
</div>
<!-- conversations -->
<div class="flex h-full overflow-y-auto">
<div v-if="displayedConversations.length > 0" class="w-full">
<div
v-for="conversation of displayedConversations"
:key="conversation.destination_hash"
class="flex cursor-pointer p-2 border-l-2"
: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',
]"
@click="onConversationClick(conversation)"
>
<div class="my-auto mr-2">
<div
v-if="conversation.lxmf_user_icon"
class="p-2 rounded"
:style="{
color: conversation.lxmf_user_icon.foreground_colour,
'background-color': conversation.lxmf_user_icon.background_colour,
}"
>
<MaterialDesignIcon
:icon-name="conversation.lxmf_user_icon.icon_name"
class="w-6 h-6"
/>
</div>
<div
v-else
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded"
>
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
</div>
</div>
<div class="mr-auto w-full pr-2 min-w-0">
<div class="flex justify-between gap-2 min-w-0">
<div
class="text-gray-900 dark:text-gray-100 truncate min-w-0"
:title="conversation.custom_display_name ?? conversation.display_name"
:class="{
'font-semibold':
conversation.is_unread || conversation.failed_messages_count > 0,
}"
>
{{ conversation.custom_display_name ?? conversation.display_name }}
</div>
<div class="text-gray-500 dark:text-gray-400 text-xs whitespace-nowrap flex-shrink-0">
{{ formatTimeAgo(conversation.updated_at) }}
</div>
</div>
<div class="text-gray-600 dark:text-gray-400 text-xs mt-0.5 truncate">
{{
conversation.latest_message_preview ??
conversation.latest_message_title ??
"No messages yet"
}}
</div>
</div>
<div class="flex items-center space-x-1">
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
</div>
<div v-if="conversation.is_unread" class="my-auto ml-1">
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
</div>
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
</div>
</div>
</div>
</div>
<div v-else class="mx-auto my-auto text-center leading-5">
<div v-if="isLoading" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1 animate-spin text-gray-500">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
</div>
<div class="font-semibold">Loading conversations</div>
</div>
<!-- no conversations at all -->
<div v-else-if="conversations.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z"
/>
</svg>
</div>
<div class="font-semibold">No Conversations</div>
<div>Discover peers on the Announces tab</div>
</div>
<!-- is searching, but no results -->
<div
v-else-if="conversationSearchTerm !== ''"
class="flex flex-col text-gray-900 dark:text-gray-100"
>
<div class="mx-auto mb-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
</div>
<div class="font-semibold">{{ $t("messages.no_search_results") }}</div>
<div>{{ $t("messages.no_search_results_conversations") }}</div>
</div>
</div>
</div>
</div>
<!-- discover -->
<div
v-if="tab === 'announces'"
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"
>
<!-- search -->
<div v-if="peersCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
<input
v-model="peersSearchTerm"
type="text"
:placeholder="$t('messages.search_placeholder_announces', { count: peersCount })"
class="input-field"
/>
</div>
<!-- peers -->
<div class="flex h-full overflow-y-auto">
<div v-if="searchedPeers.length > 0" class="w-full">
<div
v-for="peer of searchedPeers"
:key="peer.destination_hash"
class="flex cursor-pointer p-2 border-l-2"
:class="[
peer.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',
]"
@click="onPeerClick(peer)"
>
<div class="my-auto mr-2">
<div
v-if="peer.lxmf_user_icon"
class="p-2 rounded"
:style="{
color: peer.lxmf_user_icon.foreground_colour,
'background-color': peer.lxmf_user_icon.background_colour,
}"
>
<MaterialDesignIcon :icon-name="peer.lxmf_user_icon.icon_name" class="w-6 h-6" />
</div>
<div
v-else
class="bg-gray-200 dark:bg-zinc-700 text-gray-500 dark:text-gray-400 p-2 rounded"
>
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6" />
</div>
</div>
<div class="min-w-0 flex-1">
<div
class="text-gray-900 dark:text-gray-100 truncate"
:title="peer.custom_display_name ?? peer.display_name"
>
{{ peer.custom_display_name ?? peer.display_name }}
</div>
<div class="flex space-x-1 text-gray-500 dark:text-gray-400 text-sm">
<!-- time ago -->
<span class="flex my-auto space-x-1">
{{ formatTimeAgo(peer.updated_at) }}
</span>
<!-- hops away -->
<span
v-if="peer.hops != null && peer.hops !== 128"
class="flex my-auto text-sm text-gray-500 space-x-1"
>
<span></span>
<span v-if="peer.hops === 0 || peer.hops === 1">{{ $t("messages.direct") }}</span>
<span v-else>{{ $t("messages.hops", { count: peer.hops }) }}</span>
</span>
<!-- snr -->
<span v-if="peer.snr != null" class="flex my-auto space-x-1">
<span></span>
<span>{{ $t("messages.snr", { snr: peer.snr }) }}</span>
</span>
</div>
</div>
</div>
</div>
<div v-else class="mx-auto my-auto text-center leading-5">
<!-- no peers at all -->
<div v-if="peersCount === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
<div class="mx-auto mb-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"
/>
</svg>
</div>
<div class="font-semibold">{{ $t("messages.no_peers_discovered") }}</div>
<div>{{ $t("messages.waiting_for_announce") }}</div>
</div>
<!-- is searching, but no results -->
<div
v-if="peersSearchTerm !== '' && peersCount > 0"
class="flex flex-col text-gray-900 dark:text-gray-100"
>
<div class="mx-auto mb-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
</div>
<div class="font-semibold">{{ $t("messages.no_search_results") }}</div>
<div>{{ $t("messages.no_search_results_peers") }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "../../js/Utils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "MessagesSidebar",
components: { MaterialDesignIcon },
props: {
peers: {
type: Object,
required: true,
},
conversations: {
type: Array,
required: true,
},
selectedDestinationHash: {
type: String,
required: true,
},
conversationSearchTerm: {
type: String,
default: "",
},
filterUnreadOnly: {
type: Boolean,
default: false,
},
filterFailedOnly: {
type: Boolean,
default: false,
},
filterHasAttachmentsOnly: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
},
emits: ["conversation-click", "peer-click", "conversation-search-changed", "conversation-filter-changed"],
data() {
return {
tab: "conversations",
peersSearchTerm: "",
};
},
computed: {
displayedConversations() {
return this.conversations;
},
peersCount() {
return Object.keys(this.peers).length;
},
peersOrderedByLatestAnnounce() {
const peers = Object.values(this.peers);
return peers.sort(function (peerA, peerB) {
// order by updated_at desc
const peerAUpdatedAt = new Date(peerA.updated_at).getTime();
const peerBUpdatedAt = new Date(peerB.updated_at).getTime();
return peerBUpdatedAt - peerAUpdatedAt;
});
},
searchedPeers() {
return this.peersOrderedByLatestAnnounce.filter((peer) => {
const search = this.peersSearchTerm.toLowerCase();
const matchesDisplayName = peer.display_name.toLowerCase().includes(search);
const matchesCustomDisplayName = peer.custom_display_name?.toLowerCase()?.includes(search) === true;
const matchesDestinationHash = peer.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
},
},
methods: {
onConversationClick(conversation) {
this.$emit("conversation-click", conversation);
},
onPeerClick(peer) {
this.$emit("peer-click", peer);
},
formatTimeAgo: function (datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
onConversationSearchInput(event) {
this.$emit("conversation-search-changed", event.target.value);
},
toggleFilter(filterKey) {
this.$emit("conversation-filter-changed", filterKey);
},
filterChipClasses(isActive) {
const base = "px-2 py-1 rounded-full text-xs font-semibold transition-colors";
if (isActive) {
return `${base} bg-blue-600 text-white dark:bg-blue-500`;
}
return `${base} bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-200`;
},
},
};
</script>

View File

@@ -0,0 +1,152 @@
<template>
<div class="relative inline-flex items-stretch rounded-xl shadow-sm">
<!-- send button -->
<button
:disabled="!canSendMessage"
type="button"
class="inline-flex items-center gap-2 rounded-l-xl px-4 py-2.5 text-sm font-semibold text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
:class="[
canSendMessage
? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500'
: 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 cursor-not-allowed',
]"
@click="send"
>
<svg
v-if="!isSendingMessage"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M6 12 3.269 3.125A59.769 59.769 0 0 1 21.485 12 59.768 59.768 0 0 1 3.27 20.875L5.999 12Zm0 0h7.5"
/>
</svg>
<span v-if="isSendingMessage" class="flex items-center gap-2">
<svg class="animate-spin h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
Sending...
</span>
<span v-else>
<span v-if="deliveryMethod === 'direct'">Send (Direct)</span>
<span v-else-if="deliveryMethod === 'opportunistic'">Send (Opportunistic)</span>
<span v-else-if="deliveryMethod === 'propagated'">Send (Propagated)</span>
<span v-else>Send</span>
</span>
</button>
<div class="relative self-stretch">
<!-- dropdown button -->
<button
:disabled="!canSendMessage"
type="button"
class="border-l relative inline-flex items-center justify-center rounded-r-xl px-2.5 h-full text-white transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2"
:class="[
canSendMessage
? 'bg-blue-600 dark:bg-blue-600 hover:bg-blue-700 dark:hover:bg-blue-700 focus-visible:outline-blue-500 border-blue-700 dark:border-blue-800'
: 'bg-gray-400 dark:bg-zinc-500 focus-visible:outline-gray-500 border-gray-500 dark:border-zinc-600 cursor-not-allowed',
]"
@click="showMenu"
>
<svg class="h-4 w-4" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</button>
<!-- dropdown menu -->
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-if="isShowingMenu"
v-click-outside="hideMenu"
class="absolute bottom-full right-0 mb-1 z-10 rounded-xl bg-white dark:bg-zinc-900 shadow-lg ring-1 ring-gray-200 dark:ring-zinc-800 focus:outline-none overflow-hidden min-w-[200px]"
>
<div class="py-1">
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap border-b border-gray-100 dark:border-zinc-800"
@click="setDeliveryMethod(null)"
>
Send Automatically
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="setDeliveryMethod('direct')"
>
Send over Direct Link
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="setDeliveryMethod('opportunistic')"
>
Send Opportunistically
</button>
<button
type="button"
class="w-full block text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 whitespace-nowrap"
@click="setDeliveryMethod('propagated')"
>
Send to Propagation Node
</button>
</div>
</div>
</Transition>
</div>
</div>
</template>
<script>
export default {
name: "SendMessageButton",
props: {
deliveryMethod: {
type: String,
required: true,
},
canSendMessage: Boolean,
isSendingMessage: Boolean,
},
emits: ["delivery-method-changed", "send"],
data() {
return {
isShowingMenu: false,
};
},
methods: {
showMenu() {
this.isShowingMenu = true;
},
hideMenu() {
this.isShowingMenu = false;
},
setDeliveryMethod(deliveryMethod) {
this.$emit("delivery-method-changed", deliveryMethod);
this.hideMenu();
},
send() {
this.$emit("send");
},
},
};
</script>

View File

@@ -0,0 +1,812 @@
<template>
<div class="flex-1 h-full min-w-0 relative dark:bg-zinc-950 overflow-hidden">
<!-- network -->
<div id="network" class="w-full h-full"></div>
<!-- loading overlay -->
<div
v-if="isLoading"
class="absolute inset-0 z-20 flex items-center justify-center bg-zinc-950/10 backdrop-blur-[2px] transition-all duration-300"
>
<div
class="bg-white/90 dark:bg-zinc-900/90 border border-gray-200 dark:border-zinc-800 rounded-2xl px-6 py-4 flex flex-col items-center gap-3"
>
<div class="relative">
<div
class="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
></div>
<div class="absolute inset-0 flex items-center justify-center">
<div
class="w-6 h-6 border-4 border-emerald-500/20 border-b-emerald-500 rounded-full animate-spin-reverse"
></div>
</div>
</div>
<div class="text-sm font-medium text-gray-900 dark:text-zinc-100">{{ loadingStatus }}</div>
<div
v-if="totalNodesToLoad > 0"
class="w-48 h-1.5 bg-gray-200 dark:bg-zinc-800 rounded-full overflow-hidden"
>
<div
class="h-full bg-blue-500 transition-all duration-300"
:style="{ width: `${(loadedNodesCount / totalNodesToLoad) * 100}%` }"
></div>
</div>
</div>
</div>
<!-- controls & search -->
<div
class="absolute top-2 left-2 right-2 sm:top-4 sm:left-4 sm:right-4 z-10 flex flex-col sm:flex-row gap-2 pointer-events-none"
>
<!-- header glass card -->
<div
class="pointer-events-auto border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl rounded-2xl overflow-hidden w-full sm:min-w-[280px] sm:w-auto transition-all duration-300"
>
<div
class="flex items-center px-4 sm:px-5 py-3 sm:py-4 cursor-pointer hover:bg-gray-50/50 dark:hover:bg-zinc-800/50 transition-colors"
@click="isShowingControls = !isShowingControls"
>
<div class="flex-1 flex flex-col min-w-0 mr-2">
<span class="font-bold text-gray-900 dark:text-zinc-100 tracking-tight truncate"
>Reticulum Mesh</span
>
<span
class="text-[10px] uppercase font-bold text-gray-500 dark:text-zinc-500 tracking-widest truncate"
>Network Visualizer</span
>
</div>
<div class="flex items-center gap-2">
<button
type="button"
class="inline-flex items-center justify-center w-8 h-8 sm:w-9 sm:h-9 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 text-white transition-all active:scale-95"
:disabled="isUpdating || isLoading"
@click.stop="manualUpdate"
>
<svg
v-if="!isUpdating && !isLoading"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="w-4 h-4 sm:w-5 sm:h-5"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"
/>
</svg>
<svg
v-else
class="animate-spin h-4 w-4 sm:h-5 sm:w-5"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
class="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
stroke-width="4"
></circle>
<path
class="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
</button>
<div class="w-5 sm:w-6 flex justify-center">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 transition-transform duration-300"
:class="{ 'rotate-180': isShowingControls }"
>
<path
fill-rule="evenodd"
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
clip-rule="evenodd"
/>
</svg>
</div>
</div>
</div>
<div
v-show="isShowingControls"
class="px-5 pb-5 space-y-4 animate-in fade-in slide-in-from-top-2 duration-300"
>
<!-- divider -->
<div
class="h-px bg-gradient-to-r from-transparent via-gray-200 dark:via-zinc-800 to-transparent"
></div>
<!-- auto update toggle -->
<div class="flex items-center justify-between">
<label
for="auto-reload"
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
>Auto Update</label
>
<Toggle id="auto-reload" v-model="autoReload" />
</div>
<!-- physics toggle -->
<div class="flex items-center justify-between">
<label
for="enable-physics"
class="text-sm font-semibold text-gray-700 dark:text-zinc-300 cursor-pointer"
>Live Layout</label
>
<Toggle id="enable-physics" v-model="enablePhysics" />
</div>
<!-- stats -->
<div class="grid grid-cols-2 gap-3 pt-2">
<div
class="bg-gray-50/50 dark:bg-zinc-800/50 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
>
Nodes
</div>
<div class="text-lg font-bold text-blue-600 dark:text-blue-400">{{ nodes.length }}</div>
</div>
<div
class="bg-gray-50/50 dark:bg-zinc-800/50 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-1"
>
Links
</div>
<div class="text-lg font-bold text-emerald-600 dark:text-emerald-400">
{{ edges.length }}
</div>
</div>
</div>
<div
class="bg-zinc-950/5 dark:bg-white/5 rounded-xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
<div
class="text-[10px] font-bold text-gray-500 dark:text-zinc-500 uppercase tracking-wider mb-2"
>
Interfaces
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-emerald-500"></div>
<span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
>{{ onlineInterfaces.length }} Online</span
>
</div>
<div class="flex items-center gap-1.5">
<div class="w-2 h-2 rounded-full bg-red-500"></div>
<span class="text-xs font-bold text-gray-700 dark:text-zinc-300"
>{{ offlineInterfaces.length }} Offline</span
>
</div>
</div>
</div>
</div>
</div>
<!-- search box -->
<div class="sm:ml-auto w-full sm:w-auto pointer-events-auto">
<div class="relative group">
<div
class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-gray-400 group-focus-within:text-blue-500 transition-colors"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path
fill-rule="evenodd"
d="M9 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM2.25 10a7.75 7.75 0 1 1 14.03 4.5l3.47 3.47a.75.75 0 0 1-1.06 1.06l-3.47-3.47A7.75 7.75 0 0 1 2.25 10Z"
clip-rule="evenodd"
/>
</svg>
</div>
<input
v-model="searchQuery"
type="text"
:placeholder="`Search nodes (${nodes.length})...`"
class="block w-full sm:w-64 pl-9 pr-10 py-2.5 sm:py-3 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl border border-gray-200/50 dark:border-zinc-800/50 rounded-2xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-blue-500/50 sm:focus:w-80 transition-all dark:text-zinc-100 shadow-sm"
/>
<button
v-if="searchQuery"
class="absolute inset-y-0 right-0 pr-3 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-zinc-200 transition-colors"
@click="searchQuery = ''"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path
d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"
/>
</svg>
</button>
</div>
</div>
</div>
<!-- navigation breadcrumb style legend -->
<div
class="absolute bottom-4 right-4 z-10 hidden sm:flex items-center gap-2 px-4 py-2 rounded-full border border-gray-200/50 dark:border-zinc-800/50 bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl"
>
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-full border-2 border-emerald-500 bg-emerald-500/20"></div>
<span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase">Direct</span>
</div>
<div class="w-px h-3 bg-gray-200 dark:bg-zinc-800 mx-1"></div>
<div class="flex items-center gap-1.5">
<div class="w-3 h-3 rounded-full border-2 border-blue-500/50 bg-blue-500/10"></div>
<span class="text-[10px] font-bold text-gray-600 dark:text-zinc-400 uppercase">Multi-Hop</span>
</div>
</div>
</div>
</template>
<script>
import "vis-network/styles/vis-network.css";
import { Network } from "vis-network";
import { DataSet } from "vis-data";
import * as mdi from "@mdi/js";
import Utils from "../../js/Utils";
import Toggle from "../forms/Toggle.vue";
export default {
name: "NetworkVisualiser",
components: {
Toggle,
},
data() {
return {
config: null,
autoReload: false,
reloadInterval: null,
isShowingControls: true,
isUpdating: false,
isLoading: false,
enablePhysics: true,
loadingStatus: "Initializing...",
loadedNodesCount: 0,
totalNodesToLoad: 0,
interfaces: [],
pathTable: [],
announces: {},
conversations: {},
network: null,
nodes: new DataSet(),
edges: new DataSet(),
iconCache: {},
pageSize: 100,
searchQuery: "",
};
},
computed: {
onlineInterfaces() {
return this.interfaces.filter((i) => i.status);
},
offlineInterfaces() {
return this.interfaces.filter((i) => !i.status);
},
},
watch: {
enablePhysics(val) {
if (this.network) {
this.network.setOptions({ physics: { enabled: val } });
}
},
searchQuery() {
// we don't want to trigger a full update from server, just re-run the filtering on existing data
this.processVisualization();
},
},
beforeUnmount() {
clearInterval(this.reloadInterval);
if (this.network) {
this.network.destroy();
}
},
mounted() {
const isMobile = window.innerWidth < 640;
if (isMobile) {
this.isShowingControls = false;
}
this.init();
},
methods: {
async getInterfaceStats() {
try {
const response = await window.axios.get(`/api/v1/interface-stats`);
this.interfaces = response.data.interface_stats?.interfaces ?? [];
} catch (e) {
console.error("Failed to fetch interface stats", e);
}
},
async getPathTableBatch() {
this.pathTable = [];
let offset = 0;
let totalCount = 1; // dummy initial value
while (offset < totalCount) {
this.loadingStatus = `Loading Paths (${offset} / ${totalCount === 1 ? "..." : totalCount})`;
try {
const response = await window.axios.get(`/api/v1/path-table`, {
params: { limit: this.pageSize, offset: offset },
});
this.pathTable.push(...response.data.path_table);
totalCount = response.data.total_count;
offset += this.pageSize;
} catch (e) {
console.error("Failed to fetch path table batch", e);
break;
}
}
},
async getAnnouncesBatch() {
this.announces = {};
let offset = 0;
let totalCount = 1;
while (offset < totalCount) {
this.loadingStatus = `Loading Announces (${offset} / ${totalCount === 1 ? "..." : totalCount})`;
try {
const response = await window.axios.get(`/api/v1/announces`, {
params: { limit: this.pageSize, offset: offset },
});
for (const announce of response.data.announces) {
this.announces[announce.destination_hash] = announce;
}
totalCount = response.data.total_count;
offset += this.pageSize;
} catch (e) {
console.error("Failed to fetch announces batch", e);
break;
}
}
},
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch (e) {
console.error("Failed to fetch config", e);
}
},
async getConversations() {
try {
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
this.conversations = {};
for (const conversation of response.data.conversations) {
this.conversations[conversation.destination_hash] = conversation;
}
} catch (e) {
console.error("Failed to fetch conversations", e);
}
},
async createIconImage(iconName, foregroundColor, backgroundColor, size = 64) {
const cacheKey = `${iconName}-${foregroundColor}-${backgroundColor}-${size}`;
if (this.iconCache[cacheKey]) {
return this.iconCache[cacheKey];
}
return new Promise((resolve) => {
const canvas = document.createElement("canvas");
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext("2d");
// draw background circle
const gradient = ctx.createLinearGradient(0, 0, 0, size);
gradient.addColorStop(0, backgroundColor);
// slightly darken the bottom for depth
gradient.addColorStop(1, backgroundColor);
ctx.fillStyle = gradient;
ctx.beginPath();
ctx.arc(size / 2, size / 2, size / 2 - 2, 0, 2 * Math.PI);
ctx.fill();
// stroke
ctx.strokeStyle = "rgba(255,255,255,0.1)";
ctx.lineWidth = 2;
ctx.stroke();
// load MDI icon SVG
const iconSvg = this.getMdiIconSvg(iconName, foregroundColor);
const img = new Image();
const svgBlob = new Blob([iconSvg], { type: "image/svg+xml" });
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
ctx.drawImage(img, size * 0.25, size * 0.25, size * 0.5, size * 0.5);
URL.revokeObjectURL(url);
const dataUrl = canvas.toDataURL();
this.iconCache[cacheKey] = dataUrl;
resolve(dataUrl);
};
img.onerror = () => {
URL.revokeObjectURL(url);
const dataUrl = canvas.toDataURL();
this.iconCache[cacheKey] = dataUrl;
resolve(dataUrl);
};
img.src = url;
});
},
getMdiIconSvg(iconName, foregroundColor) {
const mdiIconName =
"mdi" +
iconName
.split("-")
.map((word) => {
return word.charAt(0).toUpperCase() + word.slice(1);
})
.join("");
const iconPath = mdi[mdiIconName] || mdi["mdiAccountOutline"];
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="${foregroundColor}" d="${iconPath}"/></svg>`;
},
async init() {
const container = document.getElementById("network");
const isDarkMode = document.documentElement.classList.contains("dark");
this.network = new Network(
container,
{
nodes: this.nodes,
edges: this.edges,
},
{
interaction: {
tooltipDelay: 100,
hover: true,
hideEdgesOnDrag: true,
hideEdgesOnZoom: true,
},
layout: {
randomSeed: 42,
improvedLayout: false, // faster for large networks
},
physics: {
enabled: this.enablePhysics,
solver: "barnesHut",
barnesHut: {
gravitationalConstant: -8000,
springConstant: 0.04,
springLength: 150,
damping: 0.09,
avoidOverlap: 0.5,
},
stabilization: {
enabled: true,
iterations: 100,
updateInterval: 25,
},
},
nodes: {
borderWidth: 2,
borderWidthSelected: 4,
font: {
face: "Inter, system-ui, sans-serif",
strokeWidth: 4,
strokeColor: isDarkMode ? "rgba(9, 9, 11, 0.95)" : "rgba(255, 255, 255, 0.95)",
},
shadow: {
enabled: false,
},
},
edges: {
smooth: {
type: "continuous",
roundness: 0.5,
},
selectionWidth: 4,
hoverWidth: 3,
},
}
);
this.network.on("doubleClick", (params) => {
const clickedNodeId = params.nodes[0];
if (!clickedNodeId) return;
const node = this.nodes.get(clickedNodeId);
if (!node || !node._announce) return;
const announce = node._announce;
if (announce.aspect === "lxmf.delivery") {
this.$router.push({
name: "messages",
params: { destinationHash: announce.destination_hash },
});
} else if (announce.aspect === "nomadnetwork.node") {
this.$router.push({
name: "nomadnetwork",
params: { destinationHash: announce.destination_hash },
});
}
});
await this.manualUpdate();
// auto reload
this.reloadInterval = setInterval(this.onAutoReload, 15000);
},
async manualUpdate() {
if (this.isLoading) return;
this.isLoading = true;
this.isUpdating = true;
try {
await this.update();
} finally {
this.isLoading = false;
this.isUpdating = false;
}
},
async onAutoReload() {
if (!this.autoReload || this.isUpdating || this.isLoading) return;
this.isUpdating = true;
try {
await this.update();
} finally {
this.isUpdating = false;
}
},
async update() {
this.loadingStatus = "Fetching basic info...";
await this.getConfig();
await this.getInterfaceStats();
await this.getConversations();
this.loadingStatus = "Fetching network table...";
await this.getPathTableBatch();
this.loadingStatus = "Fetching node data...";
await this.getAnnouncesBatch();
await this.processVisualization();
},
async processVisualization() {
this.loadingStatus = "Processing visualization...";
const newNodes = [];
const newEdges = [];
const isDarkMode = document.documentElement.classList.contains("dark");
const fontColor = isDarkMode ? "#ffffff" : "#000000";
// search filter helper
const searchLower = this.searchQuery.toLowerCase();
const matchesSearch = (text) => !this.searchQuery || (text && text.toLowerCase().includes(searchLower));
// Add me
const meLabel = this.config?.display_name ?? "Local Node";
if (matchesSearch(meLabel) || matchesSearch(this.config?.identity_hash)) {
newNodes.push({
id: "me",
group: "me",
size: 50,
shape: "circularImage",
image: "/assets/images/reticulum_logo_512.png",
label: meLabel,
title: `Local Node: ${meLabel}\nIdentity: ${this.config?.identity_hash ?? "Unknown"}`,
color: { border: "#3b82f6", background: isDarkMode ? "#1e3a8a" : "#dbeafe" },
font: { color: fontColor, size: 16, bold: true },
});
}
// Add interfaces
for (const entry of this.interfaces) {
let label = entry.interface_name ?? entry.name;
if (entry.type === "LocalServerInterface" || entry.parent_interface_name != null) {
label = entry.name;
}
if (matchesSearch(label) || matchesSearch(entry.name)) {
newNodes.push({
id: entry.name,
group: "interface",
label: label,
title: `${entry.name}\nState: ${entry.status ? "Online" : "Offline"}\nBitrate: ${Utils.formatBitsPerSecond(entry.bitrate)}\nTX: ${Utils.formatBytes(entry.txb)}\nRX: ${Utils.formatBytes(entry.rxb)}`,
size: 35,
shape: "circularImage",
image: entry.status
? "/assets/images/network-visualiser/interface_connected.png"
: "/assets/images/network-visualiser/interface_disconnected.png",
color: { border: entry.status ? "#10b981" : "#ef4444" },
font: { color: fontColor, size: 12 },
});
newEdges.push({
id: `me~${entry.name}`,
from: "me",
to: entry.name,
color: entry.status ? (isDarkMode ? "#065f46" : "#10b981") : isDarkMode ? "#7f1d1d" : "#ef4444",
width: 3,
length: 200,
arrows: { to: { enabled: true, scaleFactor: 0.5 } },
});
}
}
// Process path table in batches to prevent UI block
this.totalNodesToLoad = this.pathTable.length;
this.loadedNodesCount = 0;
const aspectsToShow = ["lxmf.delivery", "nomadnetwork.node"];
// Process in chunks of 50
const chunkSize = 50;
for (let i = 0; i < this.pathTable.length; i += chunkSize) {
const chunk = this.pathTable.slice(i, i + chunkSize);
for (const entry of chunk) {
this.loadedNodesCount++;
if (entry.hops == null) continue;
const announce = this.announces[entry.hash];
if (!announce || !aspectsToShow.includes(announce.aspect)) continue;
const displayName = announce.custom_display_name ?? announce.display_name;
if (
!matchesSearch(displayName) &&
!matchesSearch(announce.destination_hash) &&
!matchesSearch(announce.identity_hash)
) {
continue;
}
const conversation = this.conversations[announce.destination_hash];
const node = {
id: entry.hash,
group: "announce",
size: 25,
_announce: announce,
font: { color: fontColor, size: 11 },
};
node.label = displayName;
node.title = `${displayName}\nAspect: ${announce.aspect}\nHops: ${entry.hops}\nVia: ${entry.interface}\nLast Seen: ${Utils.convertDateTimeToLocalDateTimeString(new Date(announce.updated_at))}`;
if (announce.aspect === "lxmf.delivery") {
if (conversation?.lxmf_user_icon) {
node.shape = "circularImage";
node.image = await this.createIconImage(
conversation.lxmf_user_icon.icon_name,
conversation.lxmf_user_icon.foreground_colour,
conversation.lxmf_user_icon.background_colour,
64
);
node.size = 30;
} else {
node.shape = "circularImage";
node.image =
entry.hops === 1
? "/assets/images/network-visualiser/user_1hop.png"
: "/assets/images/network-visualiser/user.png";
}
node.color = { border: entry.hops === 1 ? "#10b981" : "#3b82f6" };
} else if (announce.aspect === "nomadnetwork.node") {
node.shape = "circularImage";
node.image =
entry.hops === 1
? "/assets/images/network-visualiser/server_1hop.png"
: "/assets/images/network-visualiser/server.png";
node.color = { border: entry.hops === 1 ? "#10b981" : "#8b5cf6" };
}
newNodes.push(node);
newEdges.push({
id: `${entry.interface}~${entry.hash}`,
from: entry.interface,
to: entry.hash,
color:
entry.hops === 1
? isDarkMode
? "#065f46"
: "#10b981"
: isDarkMode
? "#1e3a8a"
: "#3b82f6",
width: entry.hops === 1 ? 2 : 1,
dashes: entry.hops > 1,
opacity: entry.hops === 1 ? 1 : 0.5,
});
}
// Allow UI to breathe
if (i % 100 === 0) {
this.loadingStatus = `Processing Visualization (${this.loadedNodesCount} / ${this.totalNodesToLoad})...`;
await new Promise((r) => setTimeout(r, 0));
}
}
this.processNewNodes(newNodes);
this.processNewEdges(newEdges);
this.totalNodesToLoad = 0;
this.loadedNodesCount = 0;
},
processNewNodes(newNodes) {
const oldNodeIds = this.nodes.getIds();
const newNodeIds = newNodes.map((n) => n.id);
const newNodeIdsSet = new Set(newNodeIds);
// remove old
const toRemove = oldNodeIds.filter((id) => !newNodeIdsSet.has(id));
if (toRemove.length > 0) this.nodes.remove(toRemove);
// update/add
this.nodes.update(newNodes);
},
processNewEdges(newEdges) {
const oldEdgeIds = this.edges.getIds();
const newEdgeIds = newEdges.map((e) => e.id);
const newEdgeIdsSet = new Set(newEdgeIds);
// remove old
const toRemove = oldEdgeIds.filter((id) => !newEdgeIdsSet.has(id));
if (toRemove.length > 0) this.edges.remove(toRemove);
// update/add
this.edges.update(newEdges);
},
},
};
</script>
<style>
.vis-network:focus {
outline: none;
}
.vis-tooltip {
color: #f4f4f5 !important;
background: rgba(9, 9, 11, 0.9) !important;
border: 1px solid rgba(63, 63, 70, 0.5) !important;
border-radius: 12px !important;
padding: 12px 16px !important;
font-size: 13px !important;
font-weight: 500 !important;
font-style: normal !important;
font-family: Inter, system-ui, sans-serif !important;
line-height: 1.5 !important;
backdrop-filter: blur(8px) !important;
pointer-events: none !important;
}
#network {
background-color: #f8fafc;
background-image: radial-gradient(#e2e8f0 1px, transparent 1px);
background-size: 32px 32px;
}
.dark #network {
background-color: #09090b;
background-image: radial-gradient(#18181b 1px, transparent 1px);
background-size: 32px 32px;
}
@keyframes spin-reverse {
from {
transform: rotate(0deg);
}
to {
transform: rotate(-360deg);
}
}
.animate-spin-reverse {
animation: spin-reverse 1s linear infinite;
}
</style>

View File

@@ -0,0 +1,14 @@
<template>
<NetworkVisualiser />
</template>
<script>
import NetworkVisualiser from "./NetworkVisualiser.vue";
export default {
name: "NetworkVisualiserPage",
components: {
NetworkVisualiser,
},
};
</script>

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,379 @@
<template>
<div
class="flex flex-col w-full sm:w-80 sm:min-w-80 min-h-0 bg-white/90 dark:bg-zinc-950/80 backdrop-blur border-r border-gray-200 dark:border-zinc-800"
>
<div class="flex">
<button
type="button"
class="sidebar-tab"
:class="{ 'sidebar-tab--active': tab === 'favourites' }"
@click="tab = 'favourites'"
>
{{ $t("nomadnet.favourites") }}
</button>
<button
type="button"
class="sidebar-tab"
:class="{ 'sidebar-tab--active': tab === 'announces' }"
@click="tab = 'announces'"
>
{{ $t("nomadnet.announces") }}
</button>
</div>
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col min-h-0">
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
<input
v-model="favouritesSearchTerm"
type="text"
:placeholder="$t('nomadnet.search_favourites_placeholder', { count: favourites.length })"
class="input-field"
/>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div v-if="searchedFavourites.length > 0" class="space-y-2 pt-2">
<div
v-for="favourite of searchedFavourites"
:key="favourite.destination_hash"
class="favourite-card"
:class="[
favourite.destination_hash === selectedDestinationHash ? 'favourite-card--active' : '',
draggingFavouriteHash === favourite.destination_hash ? 'favourite-card--dragging' : '',
]"
draggable="true"
@click="onFavouriteClick(favourite)"
@dragstart="onFavouriteDragStart($event, favourite)"
@dragover.prevent="onFavouriteDragOver($event)"
@drop.prevent="onFavouriteDrop($event, favourite)"
@dragend="onFavouriteDragEnd"
>
<div class="favourite-card__icon">
<MaterialDesignIcon icon-name="server-network" class="w-5 h-5" />
</div>
<div class="flex-1">
<div
class="text-sm font-semibold text-gray-900 dark:text-white truncate"
:title="favourite.display_name"
>
{{ favourite.display_name }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ formatDestinationHash(favourite.destination_hash) }}
</div>
</div>
<DropDownMenu>
<template #button>
<IconButton>
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
</IconButton>
</template>
<template #items>
<DropDownMenuItem @click="onRenameFavourite(favourite)">
<MaterialDesignIcon icon-name="pencil" class="w-5 h-5" />
<span>{{ $t("nomadnet.rename") }}</span>
</DropDownMenuItem>
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
<MaterialDesignIcon icon-name="trash-can" class="w-5 h-5 text-red-500" />
<span class="text-red-500">{{ $t("nomadnet.remove") }}</span>
</DropDownMenuItem>
</template>
</DropDownMenu>
</div>
</div>
<div v-else class="empty-state">
<MaterialDesignIcon icon-name="star-outline" class="w-8 h-8" />
<div class="font-semibold">{{ $t("nomadnet.no_favourites") }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ $t("nomadnet.add_nodes_from_announces") }}
</div>
</div>
</div>
</div>
<div v-else class="flex-1 flex flex-col min-h-0">
<div class="p-3 border-b border-gray-200 dark:border-zinc-800">
<input
v-model="nodesSearchTerm"
type="text"
:placeholder="$t('nomadnet.search_announces')"
class="input-field"
/>
</div>
<div class="flex-1 overflow-y-auto px-2 pb-4">
<div v-if="searchedNodes.length > 0" class="space-y-2 pt-2">
<div
v-for="node of searchedNodes"
:key="node.destination_hash"
class="announce-card"
:class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }"
>
<div class="flex items-center gap-3 flex-1 min-w-0 cursor-pointer" @click="onNodeClick(node)">
<div class="announce-card__icon flex-shrink-0">
<MaterialDesignIcon icon-name="satellite-uplink" class="w-5 h-5" />
</div>
<div class="min-w-0 flex-1">
<div
class="text-sm font-semibold text-gray-900 dark:text-white truncate"
:title="node.display_name"
>
{{ node.display_name }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $t("nomadnet.announced_time_ago", { time: formatTimeAgo(node.updated_at) }) }}
</div>
</div>
</div>
<DropDownMenu v-if="!isBlocked(node.identity_hash)">
<template #button>
<IconButton>
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5" />
</IconButton>
</template>
<template #items>
<DropDownMenuItem @click.stop="onBlockNode(node)">
<MaterialDesignIcon icon-name="block-helper" class="w-5 h-5 text-red-500" />
<span class="text-red-500">{{ $t("nomadnet.block_node") }}</span>
</DropDownMenuItem>
</template>
</DropDownMenu>
</div>
</div>
<div v-else class="empty-state">
<MaterialDesignIcon icon-name="radar" class="w-8 h-8" />
<div class="font-semibold">{{ $t("nomadnet.no_announces_yet") }}</div>
<div class="text-sm text-gray-500 dark:text-gray-400">{{ $t("nomadnet.listening_for_peers") }}</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "../../js/Utils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import DropDownMenu from "../DropDownMenu.vue";
import IconButton from "../IconButton.vue";
import DropDownMenuItem from "../DropDownMenuItem.vue";
import DialogUtils from "../../js/DialogUtils";
export default {
name: "NomadNetworkSidebar",
components: { DropDownMenuItem, IconButton, DropDownMenu, MaterialDesignIcon },
props: {
nodes: {
type: Object,
required: true,
},
favourites: {
type: Array,
required: true,
},
selectedDestinationHash: {
type: String,
required: true,
},
},
emits: ["node-click", "rename-favourite", "remove-favourite"],
data() {
return {
tab: "favourites",
favouritesSearchTerm: "",
nodesSearchTerm: "",
favouritesOrder: [],
draggingFavouriteHash: null,
blockedDestinations: [],
};
},
computed: {
nodesCount() {
return Object.keys(this.nodes).length;
},
nodesOrderedByLatestAnnounce() {
const nodes = Object.values(this.nodes);
return nodes.sort(function (nodeA, nodeB) {
// order by updated_at desc
const nodeAUpdatedAt = new Date(nodeA.updated_at).getTime();
const nodeBUpdatedAt = new Date(nodeB.updated_at).getTime();
return nodeBUpdatedAt - nodeAUpdatedAt;
});
},
searchedNodes() {
return this.nodesOrderedByLatestAnnounce.filter((node) => {
const search = this.nodesSearchTerm.toLowerCase();
const matchesDisplayName = node.display_name.toLowerCase().includes(search);
const matchesDestinationHash = node.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesDestinationHash;
});
},
orderedFavourites() {
return [...this.favourites].sort((a, b) => {
return (
this.favouritesOrder.indexOf(a.destination_hash) - this.favouritesOrder.indexOf(b.destination_hash)
);
});
},
searchedFavourites() {
return this.orderedFavourites.filter((favourite) => {
const search = this.favouritesSearchTerm.toLowerCase();
const matchesDisplayName = favourite.display_name.toLowerCase().includes(search);
const matchesCustomDisplayName =
favourite.custom_display_name?.toLowerCase()?.includes(search) === true;
const matchesDestinationHash = favourite.destination_hash.toLowerCase().includes(search);
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
});
},
},
watch: {
favourites: {
handler() {
this.ensureFavouriteOrder();
},
deep: true,
},
},
mounted() {
this.loadFavouriteOrder();
this.ensureFavouriteOrder();
this.loadBlockedDestinations();
},
methods: {
async loadBlockedDestinations() {
try {
const response = await window.axios.get("/api/v1/blocked-destinations");
this.blockedDestinations = response.data.blocked_destinations || [];
} catch (e) {
console.log(e);
}
},
isBlocked(identityHash) {
return this.blockedDestinations.some((b) => b.destination_hash === identityHash);
},
async onBlockNode(node) {
if (!(await DialogUtils.confirm(this.$t("nomadnet.block_node_confirm", { name: node.display_name })))) {
return;
}
try {
await window.axios.post("/api/v1/blocked-destinations", {
destination_hash: node.identity_hash,
});
await this.loadBlockedDestinations();
DialogUtils.alert(this.$t("nomadnet.node_blocked_successfully"));
} catch (e) {
DialogUtils.alert(this.$t("nomadnet.failed_to_block_node"));
console.log(e);
}
},
onNodeClick(node) {
this.$emit("node-click", node);
},
onFavouriteClick(favourite) {
this.onNodeClick(favourite);
},
onRenameFavourite(favourite) {
this.$emit("rename-favourite", favourite);
},
onRemoveFavourite(favourite) {
this.$emit("remove-favourite", favourite);
},
loadFavouriteOrder() {
try {
const stored = localStorage.getItem("meshchat.nomadnet.favourites");
if (stored) {
this.favouritesOrder = JSON.parse(stored);
}
} catch (e) {
console.log(e);
}
},
persistFavouriteOrder() {
localStorage.setItem("meshchat.nomadnet.favourites", JSON.stringify(this.favouritesOrder));
},
ensureFavouriteOrder() {
const hashes = this.favourites.map((fav) => fav.destination_hash);
const nextOrder = this.favouritesOrder.filter((hash) => hashes.includes(hash));
hashes.forEach((hash) => {
if (!nextOrder.includes(hash)) {
nextOrder.push(hash);
}
});
if (JSON.stringify(nextOrder) !== JSON.stringify(this.favouritesOrder)) {
this.favouritesOrder = nextOrder;
this.persistFavouriteOrder();
}
},
onFavouriteDragStart(event, favourite) {
try {
if (event?.dataTransfer) {
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", favourite.destination_hash);
}
} catch {
// ignore for browsers that prevent setting drag meta
}
this.draggingFavouriteHash = favourite.destination_hash;
},
onFavouriteDragOver(event) {
if (event?.dataTransfer) {
event.dataTransfer.dropEffect = "move";
}
},
onFavouriteDrop(event, targetFavourite) {
if (!this.draggingFavouriteHash || this.draggingFavouriteHash === targetFavourite.destination_hash) {
return;
}
const fromIndex = this.favouritesOrder.indexOf(this.draggingFavouriteHash);
const toIndex = this.favouritesOrder.indexOf(targetFavourite.destination_hash);
if (fromIndex === -1 || toIndex === -1) {
return;
}
const updated = [...this.favouritesOrder];
updated.splice(fromIndex, 1);
updated.splice(toIndex, 0, this.draggingFavouriteHash);
this.favouritesOrder = updated;
this.persistFavouriteOrder();
this.draggingFavouriteHash = null;
},
onFavouriteDragEnd() {
this.draggingFavouriteHash = null;
},
formatTimeAgo: function (datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
formatDestinationHash: function (destinationHash) {
return Utils.formatDestinationHash(destinationHash);
},
},
};
</script>
<style scoped>
.sidebar-tab {
@apply w-1/2 py-3 text-sm font-semibold text-gray-500 dark:text-gray-400 border-b-2 border-transparent transition;
}
.sidebar-tab--active {
@apply text-blue-600 border-blue-500 dark:text-blue-300 dark:border-blue-400;
}
.favourite-card {
@apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 cursor-pointer hover:border-blue-400 dark:hover:border-blue-500;
}
.favourite-card--active {
@apply border-blue-500 dark:border-blue-400 bg-blue-50/60 dark:bg-blue-900/30;
}
.favourite-card__icon,
.announce-card__icon {
@apply w-10 h-10 rounded-xl bg-gray-100 dark:bg-zinc-800 flex items-center justify-center text-gray-500 dark:text-gray-300;
}
.favourite-card--dragging {
@apply opacity-60 ring-2 ring-blue-300 dark:ring-blue-600;
}
.announce-card {
@apply flex items-center gap-3 rounded-2xl border border-gray-200 dark:border-zinc-800 bg-white/90 dark:bg-zinc-900/70 px-3 py-2 hover:border-blue-400 dark:hover:border-blue-500;
}
.announce-card--active {
@apply border-blue-500 dark:border-blue-400 bg-blue-50/70 dark:bg-blue-900/30;
}
.empty-state {
@apply flex flex-col items-center justify-center text-center gap-2 text-gray-500 dark:text-gray-400 mt-20;
}
</style>

View File

@@ -0,0 +1,326 @@
<template>
<!-- eslint-disable vue/no-v-html -->
<div
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 px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-4xl mx-auto">
<div class="glass-card space-y-5">
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("app.tools") }}
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("ping.title") }}</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
<!-- eslint-disable-next-line vue/no-v-html -->
<span
v-html="
$t('ping.description', {
code: '<code class=\'font-mono text-xs\'>lxmf.delivery</code>',
})
"
></span>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("ping.destination_hash") }}</label>
<input
v-model="destinationHash"
type="text"
placeholder="e.g. 7b746057a7294469799cd8d7d429676a"
class="input-field font-mono"
/>
</div>
<div>
<label class="glass-label">{{ $t("ping.timeout_seconds") }}</label>
<input v-model="timeout" type="number" min="1" class="input-field" />
</div>
</div>
<div class="flex flex-wrap gap-2">
<button v-if="!isRunning" type="button" class="primary-chip px-4 py-2 text-sm" @click="start">
<MaterialDesignIcon icon-name="play" class="w-4 h-4" />
{{ $t("ping.start_ping") }}
</button>
<button
v-else
type="button"
class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50"
@click="stop"
>
<MaterialDesignIcon icon-name="pause" class="w-4 h-4" />
{{ $t("ping.stop") }}
</button>
<button type="button" class="secondary-chip px-4 py-2 text-sm" @click="clear">
<MaterialDesignIcon icon-name="broom" class="w-4 h-4" />
{{ $t("ping.clear_results") }}
</button>
<button
type="button"
class="inline-flex items-center gap-2 rounded-full bg-red-600/90 px-4 py-2 text-sm font-semibold text-white shadow hover:bg-red-500 transition"
@click="dropPath"
>
<MaterialDesignIcon icon-name="link-variant-remove" class="w-4 h-4" />
{{ $t("ping.drop_path") }}
</button>
</div>
<div class="flex flex-wrap gap-2 text-xs font-semibold">
<span
:class="[
isRunning
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-200'
: 'bg-gray-200 text-gray-700 dark:bg-zinc-800 dark:text-gray-200',
'rounded-full px-3 py-1',
]"
>
{{ $t("ping.status") }}: {{ isRunning ? $t("ping.running") : $t("ping.idle") }}
</span>
<span
v-if="lastPingSummary?.duration"
class="rounded-full px-3 py-1 bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200"
>
{{ $t("ping.last_rtt") }}: {{ lastPingSummary.duration }}
</span>
<span
v-if="lastPingSummary?.error"
class="rounded-full px-3 py-1 bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200"
>
{{ $t("ping.last_error") }}: {{ lastPingSummary.error }}
</span>
</div>
</div>
<div class="glass-card flex flex-col min-h-[320px] space-y-3">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("ping.console_output") }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $t("ping.streaming_responses") }}
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">seq #{{ seq }}</div>
</div>
<div
v-if="lastPingSummary && !lastPingSummary.error"
class="flex flex-wrap gap-2 text-xs text-gray-700 dark:text-gray-200"
>
<span v-if="lastPingSummary.hopsThere != null" class="stat-chip"
>{{ $t("rnprobe.hops") }} there: {{ lastPingSummary.hopsThere }}</span
>
<span v-if="lastPingSummary.hopsBack != null" class="stat-chip"
>{{ $t("rnprobe.hops") }} back: {{ lastPingSummary.hopsBack }}</span
>
<span v-if="lastPingSummary.rssi != null" class="stat-chip"
>{{ $t("rnprobe.rssi") }} {{ lastPingSummary.rssi }} dBm</span
>
<span v-if="lastPingSummary.snr != null" class="stat-chip"
>{{ $t("rnprobe.snr") }} {{ lastPingSummary.snr }} dB</span
>
<span v-if="lastPingSummary.quality != null" class="stat-chip"
>{{ $t("rnprobe.quality") }} {{ lastPingSummary.quality }}%</span
>
<span v-if="lastPingSummary.via" class="stat-chip"
>{{ $t("app.interfaces") }} {{ lastPingSummary.via }}</span
>
</div>
<div
id="results"
class="flex-1 overflow-y-auto rounded-2xl bg-black/80 text-emerald-300 font-mono text-xs p-3 space-y-1 shadow-inner border border-zinc-900"
>
<div v-if="pingResults.length === 0" class="text-emerald-500/80">
{{ $t("ping.no_pings_yet") }}
</div>
<div
v-for="(pingResult, index) in pingResults"
:key="`${index}-${pingResult}`"
class="whitespace-pre-wrap"
>
{{ pingResult }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { CanceledError } from "axios";
import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "PingPage",
components: {
MaterialDesignIcon,
},
data() {
return {
isRunning: false,
destinationHash: null,
timeout: 10,
seq: 0,
pingResults: [],
abortController: null,
lastPingSummary: null,
};
},
beforeUnmount() {
this.stop();
},
methods: {
async start() {
// do nothing if already running
if (this.isRunning) {
return;
}
// simple check to ensure destination hash is valid
if (this.destinationHash == null || this.destinationHash.length !== 32) {
DialogUtils.alert(this.$t("ping.invalid_hash"));
return;
}
// simple check to ensure destination hash is valid
if (this.timeout == null || this.timeout < 0) {
DialogUtils.alert(this.$t("ping.timeout_must_be_number"));
return;
}
// we are now running ping
this.seq = 0;
this.isRunning = true;
this.abortController = new AbortController();
// run ping until stopped
while (this.isRunning) {
// run ping
await this.ping();
// wait a bit before running next ping
await this.sleep(1000);
}
},
async stop() {
this.isRunning = false;
if (this.abortController) {
this.abortController.abort();
}
},
async clear() {
this.pingResults = [];
this.lastPingSummary = null;
},
async sleep(millis) {
return new Promise((resolve) => setTimeout(resolve, millis));
},
async ping() {
try {
this.seq++;
// ping destination
const response = await window.axios.get(`/api/v1/ping/${this.destinationHash}/lxmf.delivery`, {
signal: this.abortController.signal,
params: {
timeout: this.timeout,
},
});
const pingResult = response.data.ping_result;
const rttMilliseconds = (pingResult.rtt * 1000).toFixed(3);
const rttDurationString = `${rttMilliseconds}ms`;
const info = [
`seq=${this.seq}`,
`duration=${rttDurationString}`,
`hops_there=${pingResult.hops_there}`,
`hops_back=${pingResult.hops_back}`,
];
// add rssi if available
if (pingResult.rssi != null) {
info.push(`rssi=${pingResult.rssi}dBm`);
}
// add snr if available
if (pingResult.snr != null) {
info.push(`snr=${pingResult.snr}dB`);
}
// add signal quality if available
if (pingResult.quality != null) {
info.push(`quality=${pingResult.quality}%`);
}
// add receiving interface
info.push(`via=${pingResult.receiving_interface}`);
// update ui
this.addPingResult(info.join(" "));
this.lastPingSummary = {
duration: rttDurationString,
hopsThere: pingResult.hops_there,
hopsBack: pingResult.hops_back,
rssi: pingResult.rssi,
snr: pingResult.snr,
quality: pingResult.quality,
via: pingResult.receiving_interface,
};
} catch (e) {
// ignore cancelled error
if (e instanceof CanceledError) {
return;
}
console.log(e);
// add ping error to results
const message = e.response?.data?.message ?? e;
this.addPingResult(`seq=${this.seq} error=${message}`);
this.lastPingSummary = {
error: typeof message === "string" ? message : JSON.stringify(message),
};
}
},
async dropPath() {
// simple check to ensure destination hash is valid
if (this.destinationHash == null || this.destinationHash.length !== 32) {
DialogUtils.alert(this.$t("ping.invalid_hash"));
return;
}
try {
const response = await window.axios.post(`/api/v1/destination/${this.destinationHash}/drop-path`);
DialogUtils.alert(response.data.message);
} catch (e) {
console.log(e);
const message = e.response?.data?.message ?? `Failed to drop path: ${e}`;
DialogUtils.alert(message);
}
},
addPingResult(result) {
this.pingResults.push(result);
this.scrollPingResultsToBottom();
},
scrollPingResultsToBottom: function () {
// next tick waits for the ui to have the new elements added
this.$nextTick(() => {
// set timeout with zero millis seems to fix issue where it doesn't scroll all the way to the bottom...
setTimeout(() => {
const container = document.getElementById("results");
if (container) {
container.scrollTop = container.scrollHeight;
}
}, 0);
});
},
},
};
</script>

View File

@@ -0,0 +1,220 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 dark:bg-zinc-950">
<div class="overflow-y-auto space-y-2 p-2">
<!-- info -->
<div class="bg-white dark:bg-zinc-800 rounded shadow">
<div
class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold"
>
Customise your Profile Icon
</div>
<div class="text-gray-900 dark:text-gray-100">
<div class="text-sm p-2">
<ul class="list-disc list-inside">
<li>Personalise your profile with a custom coloured icon.</li>
<li>This icon will be sent in all of your outgoing messages.</li>
<li>When you send someone a message, they will see your new icon.</li>
<li>
You can
<span class="cursor-pointer underline text-blue-500" @click="removeProfileIcon"
>remove your icon</span
>, however it will still show for anyone that already received it.
</li>
</ul>
</div>
</div>
</div>
<!-- colours -->
<div class="bg-white dark:bg-zinc-800 rounded shadow">
<div
class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold"
>
Select your Colours
</div>
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
<!-- background colour -->
<div class="p-2 flex space-x-2">
<div class="flex my-auto">
<ColourPickerDropdown v-model:colour="iconBackgroundColour" />
</div>
<div class="my-auto">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Background Colour</div>
<div class="text-sm text-gray-900 dark:text-gray-100">{{ iconBackgroundColour }}</div>
</div>
</div>
<!-- icon colour -->
<div class="p-2 flex space-x-2">
<div class="flex my-auto">
<ColourPickerDropdown v-model:colour="iconForegroundColour" />
</div>
<div class="my-auto">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Icon Colour</div>
<div class="text-sm text-gray-900 dark:text-gray-100">{{ iconForegroundColour }}</div>
</div>
</div>
</div>
</div>
<!-- search icons -->
<div class="bg-white dark:bg-zinc-800 rounded shadow">
<div
class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold"
>
Select your Icon
</div>
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
<div class="flex p-1">
<input
v-model="search"
type="text"
:placeholder="`Search ${iconNames.length} icons...`"
class="bg-gray-50 dark:bg-zinc-700 border border-gray-300 dark:border-zinc-600 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 dark:focus:ring-blue-600 dark:focus:border-blue-600 block w-full p-2.5"
/>
</div>
<div class="divide-y divide-gray-300 dark:divide-zinc-700">
<div
v-for="mdiIconName of searchedIconNames"
:key="mdiIconName"
class="flex space-x-2 p-2 cursor-pointer hover:bg-gray-100 dark:hover:bg-zinc-700"
@click="onIconClick(mdiIconName)"
>
<div class="my-auto">
<LxmfUserIcon
:icon-name="mdiIconName"
:icon-foreground-colour="iconForegroundColour"
:icon-background-colour="iconBackgroundColour"
/>
</div>
<div class="my-auto">{{ mdiIconName }}</div>
</div>
<div v-if="searchedIconNames.length === 0" class="p-1 text-sm text-gray-500">
No icons match your search.
</div>
<div v-if="searchedIconNames.length === maxSearchResults" class="p-1 text-sm text-gray-500">
A maximum of {{ maxSearchResults }} icons are shown.
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import * as mdi from "@mdi/js";
import LxmfUserIcon from "../LxmfUserIcon.vue";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import ColourPickerDropdown from "../ColourPickerDropdown.vue";
export default {
name: "ProfileIconPage",
components: {
ColourPickerDropdown,
LxmfUserIcon,
},
data() {
return {
config: null,
iconForegroundColour: null,
iconBackgroundColour: null,
search: "",
maxSearchResults: 100,
iconNames: [],
};
},
computed: {
searchedIconNames() {
return this.iconNames
.filter((iconName) => {
return iconName.includes(this.search);
})
.slice(0, this.maxSearchResults);
},
},
watch: {
config() {
// update ui when config is updated
this.iconName = this.config.lxmf_user_icon_name;
this.iconForegroundColour = this.config.lxmf_user_icon_foreground_colour || "#6b7280";
this.iconBackgroundColour = this.config.lxmf_user_icon_background_colour || "#e5e7eb";
},
},
mounted() {
this.getConfig();
// load icon names
this.iconNames = Object.keys(mdi).map((mdiIcon) => {
return mdiIcon
.replace(/^mdi/, "") // Remove the "mdi" prefix
.replace(/([a-z])([A-Z])/g, "$1-$2") // Add a hyphen between lowercase and uppercase letters
.toLowerCase(); // Convert the entire string to lowercase
});
},
methods: {
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch (e) {
// do nothing if failed to load config
console.log(e);
}
},
async updateConfig(config) {
try {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
} catch (e) {
ToastUtils.error("Failed to save config!");
console.log(e);
}
},
async onIconClick(iconName) {
// ensure foreground colour set
if (this.iconForegroundColour == null) {
DialogUtils.alert("Please select an icon colour first");
return;
}
// ensure background colour set
if (this.iconBackgroundColour == null) {
DialogUtils.alert("Please select a background colour first");
return;
}
// confirm user wants to update their icon
if (!(await DialogUtils.confirm("Are you sure you want to set this as your profile icon?"))) {
return;
}
// save icon appearance
await this.updateConfig({
lxmf_user_icon_name: iconName,
lxmf_user_icon_foreground_colour: this.iconForegroundColour,
lxmf_user_icon_background_colour: this.iconBackgroundColour,
});
},
async removeProfileIcon() {
// confirm user wants to remove their icon
if (
!(await DialogUtils.confirm(
"Are you sure you want to remove your profile icon? Anyone that has already received it will continue to see it until you send them a new icon."
))
) {
return;
}
// remove profile icon
await this.updateConfig({
lxmf_user_icon_name: null,
lxmf_user_icon_foreground_colour: null,
lxmf_user_icon_background_colour: null,
});
},
},
};
</script>

View File

@@ -0,0 +1,408 @@
<template>
<div class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gray-50 dark:bg-zinc-950">
<!-- search and sort -->
<div
v-if="propagationNodes.length > 0"
class="flex flex-col sm:flex-row gap-2 bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 px-4 py-3"
>
<input
v-model="searchTerm"
type="text"
:placeholder="`Search ${propagationNodes.length} Propagation Nodes...`"
class="flex-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
/>
<select
v-model="sortBy"
class="bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2 shadow-sm transition-all min-w-[180px]"
>
<option value="name">Sort by Name</option>
<option value="name-desc">Sort by Name (Z-A)</option>
<option value="recent">Sort by Recent</option>
<option value="oldest">Sort by Oldest</option>
<option value="preferred">Preferred First</option>
</select>
</div>
<!-- propagation nodes -->
<div class="h-full overflow-y-auto px-4 py-4">
<div v-if="paginatedNodes.length > 0" class="space-y-3 w-full">
<div
v-for="propagationNode of paginatedNodes"
:key="propagationNode.destination_hash"
class="border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white dark:bg-zinc-900 shadow-sm hover:shadow-md transition-shadow overflow-hidden"
:class="{
'ring-2 ring-blue-500 dark:ring-blue-400':
config.lxmf_preferred_propagation_node_destination_hash ===
propagationNode.destination_hash,
}"
>
<div class="p-4 flex items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<div class="font-semibold text-gray-900 dark:text-zinc-100 truncate">
{{ propagationNode.operator_display_name ?? "Unknown Operator" }}
</div>
<span
v-if="
config.lxmf_preferred_propagation_node_destination_hash ===
propagationNode.destination_hash
"
class="inline-flex items-center gap-1 rounded-full bg-blue-100 dark:bg-blue-900/30 px-2 py-0.5 text-xs font-semibold text-blue-700 dark:text-blue-300"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-3 h-3"
>
<path
fill-rule="evenodd"
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z"
clip-rule="evenodd"
/>
</svg>
Preferred
</span>
<span
v-if="propagationNode.is_propagation_enabled === false"
class="inline-flex items-center gap-1 rounded-full bg-red-100 dark:bg-red-900/30 px-2 py-0.5 text-xs font-semibold text-red-700 dark:text-red-300"
>
Disabled
</span>
</div>
<div class="text-sm text-gray-600 dark:text-zinc-400 font-mono truncate">
&lt;{{ propagationNode.destination_hash }}&gt;
</div>
<div class="text-xs text-gray-500 dark:text-zinc-500 mt-1">
Announced {{ formatTimeAgo(propagationNode.updated_at) }}
</div>
</div>
<div class="flex-shrink-0">
<button
v-if="
config.lxmf_preferred_propagation_node_destination_hash ===
propagationNode.destination_hash
"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-xl bg-red-600 hover:bg-red-700 dark:bg-red-600 dark:hover:bg-red-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-500"
@click="stopUsingPropagationNode"
>
Stop Using
</button>
<button
v-else
type="button"
class="inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
@click="usePropagationNode(propagationNode.destination_hash)"
>
Set as Preferred
</button>
</div>
</div>
</div>
</div>
<!-- pagination -->
<div
v-if="totalPages > 1"
class="flex items-center justify-between mt-6 pt-4 border-t border-gray-200 dark:border-zinc-800"
>
<div class="text-sm text-gray-600 dark:text-zinc-400">
Showing {{ startIndex + 1 }}-{{ endIndex }} of {{ sortedAndSearchedPropagationNodes.length }}
</div>
<div class="flex items-center gap-2">
<button
:disabled="currentPage === 1"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-xl bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed px-3 py-2 text-sm font-medium text-gray-700 dark:text-zinc-300 shadow-sm transition-colors"
@click="currentPage = Math.max(1, currentPage - 1)"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" />
</svg>
Previous
</button>
<div class="flex items-center gap-1">
<button
v-for="page in visiblePages"
:key="page"
type="button"
:class="[
page === currentPage
? 'bg-blue-600 text-white dark:bg-blue-600'
: 'bg-white dark:bg-zinc-900 text-gray-700 dark:text-zinc-300 hover:bg-gray-50 dark:hover:bg-zinc-800',
]"
class="w-10 h-10 rounded-xl border border-gray-200 dark:border-zinc-800 text-sm font-medium shadow-sm transition-colors"
@click="currentPage = page"
>
{{ page }}
</button>
</div>
<button
:disabled="currentPage === totalPages"
type="button"
class="inline-flex items-center gap-x-1.5 rounded-xl bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-800 disabled:opacity-50 disabled:cursor-not-allowed px-3 py-2 text-sm font-medium text-gray-700 dark:text-zinc-300 shadow-sm transition-colors"
@click="currentPage = Math.min(totalPages, currentPage + 1)"
>
Next
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</button>
</div>
</div>
<div v-else-if="sortedAndSearchedPropagationNodes.length === 0" class="flex h-full">
<div class="mx-auto my-auto text-center leading-5 text-gray-900 dark:text-gray-100">
<!-- no propagation nodes at all -->
<div v-if="propagationNodes.length === 0" class="flex flex-col">
<div class="mx-auto mb-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="size-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h3.218a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z"
/>
</svg>
</div>
<div class="font-semibold">No Propagation Nodes</div>
<div>Check back later, once someone has announced.</div>
<div class="mt-4">
<button
type="button"
class="inline-flex items-center gap-x-1.5 rounded-xl bg-blue-600 hover:bg-blue-700 dark:bg-blue-600 dark:hover:bg-blue-700 px-4 py-2 text-sm font-semibold text-white shadow-sm transition-colors focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500"
@click="loadPropagationNodes"
>
Reload
</button>
</div>
</div>
<!-- is searching, but no results -->
<div v-if="searchTerm !== '' && propagationNodes.length > 0" class="flex flex-col">
<div class="mx-auto mb-1">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-6 h-6"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"
/>
</svg>
</div>
<div class="font-semibold">No Search Results</div>
<div>Your search didn't match any Propagation Nodes!</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
import ToastUtils from "../../js/ToastUtils";
export default {
name: "PropagationNodesPage",
data() {
return {
searchTerm: "",
sortBy: "preferred",
propagationNodes: [],
config: {
lxmf_preferred_propagation_node_destination_hash: null,
},
currentPage: 1,
itemsPerPage: 20,
};
},
computed: {
searchedPropagationNodes() {
return this.propagationNodes.filter((propagationNode) => {
const search = this.searchTerm.toLowerCase();
const matchesOperatorDisplayName =
propagationNode.operator_display_name?.toLowerCase()?.includes(search) ?? false;
const matchesDestinationHash = propagationNode.destination_hash.toLowerCase().includes(search);
return matchesOperatorDisplayName || matchesDestinationHash;
});
},
sortedAndSearchedPropagationNodes() {
let nodes = [...this.searchedPropagationNodes];
switch (this.sortBy) {
case "name":
nodes.sort((a, b) => {
const nameA = (a.operator_display_name ?? "Unknown Operator").toLowerCase();
const nameB = (b.operator_display_name ?? "Unknown Operator").toLowerCase();
return nameA.localeCompare(nameB);
});
break;
case "name-desc":
nodes.sort((a, b) => {
const nameA = (a.operator_display_name ?? "Unknown Operator").toLowerCase();
const nameB = (b.operator_display_name ?? "Unknown Operator").toLowerCase();
return nameB.localeCompare(nameA);
});
break;
case "recent":
nodes.sort((a, b) => {
const timeA = new Date(a.updated_at).getTime();
const timeB = new Date(b.updated_at).getTime();
return timeB - timeA;
});
break;
case "oldest":
nodes.sort((a, b) => {
const timeA = new Date(a.updated_at).getTime();
const timeB = new Date(b.updated_at).getTime();
return timeA - timeB;
});
break;
case "preferred":
default:
nodes.sort((a, b) => {
const aIsPreferred =
this.config.lxmf_preferred_propagation_node_destination_hash === a.destination_hash;
const bIsPreferred =
this.config.lxmf_preferred_propagation_node_destination_hash === b.destination_hash;
if (aIsPreferred && !bIsPreferred) return -1;
if (!aIsPreferred && bIsPreferred) return 1;
const timeA = new Date(a.updated_at).getTime();
const timeB = new Date(b.updated_at).getTime();
return timeB - timeA;
});
break;
}
return nodes;
},
totalPages() {
return Math.ceil(this.sortedAndSearchedPropagationNodes.length / this.itemsPerPage);
},
startIndex() {
return (this.currentPage - 1) * this.itemsPerPage;
},
endIndex() {
return Math.min(this.startIndex + this.itemsPerPage, this.sortedAndSearchedPropagationNodes.length);
},
paginatedNodes() {
return this.sortedAndSearchedPropagationNodes.slice(this.startIndex, this.endIndex);
},
visiblePages() {
const pages = [];
const maxVisible = 5;
let start = Math.max(1, this.currentPage - Math.floor(maxVisible / 2));
let end = Math.min(this.totalPages, start + maxVisible - 1);
if (end - start < maxVisible - 1) {
start = Math.max(1, end - maxVisible + 1);
}
for (let i = start; i <= end; i++) {
pages.push(i);
}
return pages;
},
},
watch: {
searchTerm() {
this.currentPage = 1;
},
sortBy() {
this.currentPage = 1;
},
},
beforeUnmount() {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
this.getConfig();
this.loadPropagationNodes();
},
methods: {
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch (json.type) {
case "config": {
this.config = json.config;
break;
}
}
},
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch (e) {
// do nothing if failed to load config
console.log(e);
}
},
async updateConfig(config) {
try {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
} catch (e) {
ToastUtils.error("Failed to save config!");
console.log(e);
}
},
async loadPropagationNodes() {
try {
const response = await window.axios.get(`/api/v1/lxmf/propagation-nodes`, {
params: {
limit: 500,
},
});
this.propagationNodes = response.data.lxmf_propagation_nodes;
} catch {
// do nothing if failed to load
}
},
async usePropagationNode(destination_hash) {
await this.updateConfig({
lxmf_preferred_propagation_node_destination_hash: destination_hash,
});
},
async stopUsingPropagationNode() {
await this.updateConfig({
lxmf_preferred_propagation_node_destination_hash: null,
});
},
formatTimeAgo: function (datetimeString) {
return Utils.formatTimeAgo(datetimeString);
},
},
};
</script>

View File

@@ -0,0 +1,498 @@
<template>
<div
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 px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-4xl mx-auto">
<div class="glass-card space-y-5">
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("rncp.file_transfer") }}
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
{{ $t("rncp.title") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("rncp.description") }}
</div>
</div>
<div class="flex gap-2 border-b border-gray-200 dark:border-zinc-700">
<button
:class="[
activeTab === 'send'
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400',
'px-4 py-2 font-semibold transition',
]"
@click="activeTab = 'send'"
>
{{ $t("rncp.send_file") }}
</button>
<button
:class="[
activeTab === 'fetch'
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400',
'px-4 py-2 font-semibold transition',
]"
@click="activeTab = 'fetch'"
>
{{ $t("rncp.fetch_file") }}
</button>
<button
:class="[
activeTab === 'listen'
? 'border-b-2 border-blue-500 text-blue-600 dark:text-blue-400'
: 'text-gray-600 dark:text-gray-400',
'px-4 py-2 font-semibold transition',
]"
@click="activeTab = 'listen'"
>
{{ $t("rncp.listen") }}
</button>
</div>
<div v-if="activeTab === 'send'" class="space-y-4">
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rncp.destination_hash") }}</label>
<input
v-model="sendDestinationHash"
type="text"
placeholder="e.g. 7b746057a7294469799cd8d7d429676a"
class="input-field font-mono"
/>
</div>
<div>
<label class="glass-label">{{ $t("rncp.file_path") }}</label>
<input
v-model="sendFilePath"
type="text"
placeholder="/path/to/file"
class="input-field"
/>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rncp.timeout_seconds") }}</label>
<input v-model="sendTimeout" type="number" min="1" class="input-field" />
</div>
<div class="flex items-end">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="sendNoCompress" type="checkbox" class="rounded" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{
$t("rncp.disable_compression")
}}</span>
</label>
</div>
</div>
<div class="flex gap-2">
<button
v-if="!sendInProgress"
type="button"
class="primary-chip px-4 py-2 text-sm"
@click="sendFile"
>
<MaterialDesignIcon icon-name="upload" class="w-4 h-4" />
{{ $t("rncp.send_file") }}
</button>
<button
v-else
type="button"
class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50"
@click="cancelSend"
>
<MaterialDesignIcon icon-name="close" class="w-4 h-4" />
{{ $t("rncp.cancel") }}
</button>
</div>
<div v-if="sendProgress > 0" class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-700 dark:text-gray-300">{{ $t("rncp.progress") }}</span>
<span class="text-gray-700 dark:text-gray-300"
>{{ Math.round(sendProgress * 100) }}%</span
>
</div>
<div class="w-full bg-gray-200 dark:bg-zinc-700 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all"
:style="{ width: sendProgress * 100 + '%' }"
></div>
</div>
</div>
<div
v-if="sendResult"
class="p-3 rounded-lg"
:class="
sendResult.success
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300'
"
>
{{ sendResult.message }}
</div>
</div>
<div v-if="activeTab === 'fetch'" class="space-y-4">
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rncp.destination_hash") }}</label>
<input
v-model="fetchDestinationHash"
type="text"
placeholder="e.g. 7b746057a7294469799cd8d7d429676a"
class="input-field font-mono"
/>
</div>
<div>
<label class="glass-label">{{ $t("rncp.remote_file_path") }}</label>
<input
v-model="fetchFilePath"
type="text"
placeholder="/path/to/remote/file"
class="input-field"
/>
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rncp.save_path_optional") }}</label>
<input
v-model="fetchSavePath"
type="text"
:placeholder="$t('rncp.save_path_placeholder')"
class="input-field"
/>
</div>
<div>
<label class="glass-label">{{ $t("rncp.timeout_seconds") }}</label>
<input v-model="fetchTimeout" type="number" min="1" class="input-field" />
</div>
</div>
<div class="flex items-center gap-2">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="fetchAllowOverwrite" type="checkbox" class="rounded" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{
$t("rncp.allow_overwrite")
}}</span>
</label>
</div>
<div class="flex gap-2">
<button
v-if="!fetchInProgress"
type="button"
class="primary-chip px-4 py-2 text-sm"
@click="fetchFile"
>
<MaterialDesignIcon icon-name="download" class="w-4 h-4" />
{{ $t("rncp.fetch_file") }}
</button>
<button
v-else
type="button"
class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50"
@click="cancelFetch"
>
<MaterialDesignIcon icon-name="close" class="w-4 h-4" />
{{ $t("rncp.cancel") }}
</button>
</div>
<div v-if="fetchProgress > 0" class="space-y-2">
<div class="flex justify-between text-sm">
<span class="text-gray-700 dark:text-gray-300">{{ $t("rncp.progress") }}</span>
<span class="text-gray-700 dark:text-gray-300"
>{{ Math.round(fetchProgress * 100) }}%</span
>
</div>
<div class="w-full bg-gray-200 dark:bg-zinc-700 rounded-full h-2">
<div
class="bg-blue-600 h-2 rounded-full transition-all"
:style="{ width: fetchProgress * 100 + '%' }"
></div>
</div>
</div>
<div
v-if="fetchResult"
class="p-3 rounded-lg"
:class="
fetchResult.success
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300'
"
>
{{ fetchResult.message }}
</div>
</div>
<div v-if="activeTab === 'listen'" class="space-y-4">
<div>
<label class="glass-label">{{ $t("rncp.allowed_hashes") }}</label>
<textarea
v-model="listenAllowedHashes"
rows="4"
placeholder="7b746057a7294469799cd8d7d429676a&#10;8c857168b830557080ad9e8e8e539787b"
class="input-field font-mono text-sm"
></textarea>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rncp.fetch_jail_path") }}</label>
<input
v-model="listenFetchJail"
type="text"
placeholder="/path/to/jail"
class="input-field"
/>
</div>
<div class="flex items-end gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="listenFetchAllowed" type="checkbox" class="rounded" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{
$t("rncp.allow_fetch")
}}</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input v-model="listenAllowOverwrite" type="checkbox" class="rounded" />
<span class="text-sm text-gray-700 dark:text-gray-300">{{
$t("rncp.allow_overwrite")
}}</span>
</label>
</div>
</div>
<div class="flex gap-2">
<button
v-if="!listenActive"
type="button"
class="primary-chip px-4 py-2 text-sm"
@click="startListen"
>
<MaterialDesignIcon icon-name="play" class="w-4 h-4" />
{{ $t("rncp.start_listening") }}
</button>
<button
v-else
type="button"
class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50"
@click="stopListen"
>
<MaterialDesignIcon icon-name="stop" class="w-4 h-4" />
{{ $t("rncp.stop_listening") }}
</button>
</div>
<div
v-if="listenDestinationHash"
class="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
>
<div class="font-semibold mb-1">{{ $t("rncp.listening_on") }}</div>
<div class="font-mono text-sm">{{ listenDestinationHash }}</div>
</div>
<div
v-if="listenResult"
class="p-3 rounded-lg"
:class="
listenResult.success
? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-300'
: 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300'
"
>
{{ listenResult.message }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import WebSocketConnection from "../../js/WebSocketConnection";
export default {
name: "RNCPPage",
components: {
MaterialDesignIcon,
},
data() {
return {
activeTab: "send",
sendDestinationHash: null,
sendFilePath: null,
sendTimeout: 30,
sendNoCompress: false,
sendInProgress: false,
sendProgress: 0,
sendResult: null,
sendTransferId: null,
fetchDestinationHash: null,
fetchFilePath: null,
fetchSavePath: null,
fetchTimeout: 30,
fetchAllowOverwrite: false,
fetchInProgress: false,
fetchProgress: 0,
fetchResult: null,
listenAllowedHashes: "",
listenFetchJail: null,
listenFetchAllowed: false,
listenAllowOverwrite: false,
listenActive: false,
listenDestinationHash: null,
listenResult: null,
};
},
mounted() {
WebSocketConnection.on("message", this.handleWebSocketMessage);
},
beforeUnmount() {
WebSocketConnection.off("message", this.handleWebSocketMessage);
this.cancelSend();
this.cancelFetch();
},
methods: {
handleWebSocketMessage(message) {
try {
const data = JSON.parse(message.data);
if (data.type === "rncp.transfer.progress") {
if (data.transfer_id === this.sendTransferId) {
this.sendProgress = data.progress;
} else {
this.fetchProgress = data.progress;
}
}
} catch {
// ignore parse errors
}
},
async sendFile() {
if (!this.sendDestinationHash || this.sendDestinationHash.length !== 32) {
DialogUtils.alert(this.$t("rncp.invalid_hash"));
return;
}
if (!this.sendFilePath) {
DialogUtils.alert(this.$t("rncp.provide_file_path"));
return;
}
this.sendInProgress = true;
this.sendProgress = 0;
this.sendResult = null;
try {
const response = await window.axios.post("/api/v1/rncp/send", {
destination_hash: this.sendDestinationHash,
file_path: this.sendFilePath,
timeout: this.sendTimeout,
no_compress: this.sendNoCompress,
});
this.sendTransferId = response.data.transfer_id;
this.sendProgress = 1;
this.sendResult = {
success: true,
message: this.$t("rncp.file_sent_successfully", { id: response.data.transfer_id }),
};
} catch (e) {
console.error(e);
this.sendResult = {
success: false,
message: e.response?.data?.message || this.$t("rncp.failed_to_send"),
};
} finally {
this.sendInProgress = false;
}
},
cancelSend() {
this.sendInProgress = false;
this.sendProgress = 0;
},
async fetchFile() {
if (!this.fetchDestinationHash || this.fetchDestinationHash.length !== 32) {
DialogUtils.alert(this.$t("rncp.invalid_hash"));
return;
}
if (!this.fetchFilePath) {
DialogUtils.alert(this.$t("rncp.provide_remote_file_path"));
return;
}
this.fetchInProgress = true;
this.fetchProgress = 0;
this.fetchResult = null;
try {
const response = await window.axios.post("/api/v1/rncp/fetch", {
destination_hash: this.fetchDestinationHash,
file_path: this.fetchFilePath,
timeout: this.fetchTimeout,
save_path: this.fetchSavePath || null,
allow_overwrite: this.fetchAllowOverwrite,
});
this.fetchProgress = 1;
this.fetchResult = {
success: true,
message: this.$t("rncp.file_fetched_successfully", {
path: response.data.file_path || "current directory",
}),
};
} catch (e) {
console.error(e);
this.fetchResult = {
success: false,
message: e.response?.data?.message || this.$t("rncp.failed_to_fetch"),
};
} finally {
this.fetchInProgress = false;
}
},
cancelFetch() {
this.fetchInProgress = false;
this.fetchProgress = 0;
},
async startListen() {
const allowedHashes = this.listenAllowedHashes
.split("\n")
.map((h) => h.trim())
.filter((h) => h.length === 32);
if (allowedHashes.length === 0) {
DialogUtils.alert(this.$t("rncp.provide_allowed_hash"));
return;
}
this.listenResult = null;
try {
const response = await window.axios.post("/api/v1/rncp/listen", {
allowed_hashes: allowedHashes,
fetch_allowed: this.listenFetchAllowed,
fetch_jail: this.listenFetchJail || null,
allow_overwrite: this.listenAllowOverwrite,
});
this.listenActive = true;
this.listenDestinationHash = response.data.destination_hash;
this.listenResult = {
success: true,
message: response.data.message,
};
} catch (e) {
console.error(e);
this.listenResult = {
success: false,
message: e.response?.data?.message || this.$t("rncp.failed_to_start_listener"),
};
}
},
stopListen() {
this.listenActive = false;
this.listenDestinationHash = null;
this.listenResult = null;
},
},
};
</script>

View File

@@ -0,0 +1,244 @@
<template>
<div
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 px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-4xl mx-auto">
<div class="glass-card space-y-5">
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("rnprobe.network_diagnostics") }}
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
{{ $t("rnprobe.title") }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("rnprobe.description") }}
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="glass-label">{{ $t("rnprobe.destination_hash") }}</label>
<input
v-model="destinationHash"
type="text"
placeholder="e.g. 7b746057a7294469799cd8d7d429676a"
class="input-field font-mono"
/>
</div>
<div>
<label class="glass-label">{{ $t("rnprobe.full_destination_name") }}</label>
<input
v-model="fullName"
type="text"
placeholder="e.g. lxmf.delivery"
class="input-field"
/>
</div>
</div>
<div class="grid md:grid-cols-3 gap-4">
<div>
<label class="glass-label">{{ $t("rnprobe.probe_size_bytes") }}</label>
<input v-model="probeSize" type="number" min="1" max="1024" class="input-field" />
</div>
<div>
<label class="glass-label">{{ $t("rnprobe.number_of_probes") }}</label>
<input v-model="probes" type="number" min="1" max="100" class="input-field" />
</div>
<div>
<label class="glass-label">{{ $t("rnprobe.wait_between_probes") }}</label>
<input v-model="wait" type="number" min="0" step="0.1" class="input-field" />
</div>
</div>
<div class="flex gap-2">
<button
v-if="!isRunning"
type="button"
class="primary-chip px-4 py-2 text-sm"
@click="startProbe"
>
<MaterialDesignIcon icon-name="radar" class="w-4 h-4" />
{{ $t("rnprobe.start_probe") }}
</button>
<button
v-else
type="button"
class="secondary-chip px-4 py-2 text-sm text-red-600 dark:text-red-300 border-red-200 dark:border-red-500/50"
@click="stopProbe"
>
<MaterialDesignIcon icon-name="stop" class="w-4 h-4" />
{{ $t("rnprobe.stop") }}
</button>
<button type="button" class="secondary-chip px-4 py-2 text-sm" @click="clearResults">
<MaterialDesignIcon icon-name="broom" class="w-4 h-4" />
{{ $t("rnprobe.clear_results") }}
</button>
</div>
<div
v-if="summary"
class="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
>
<div class="font-semibold">{{ $t("rnprobe.summary") }}:</div>
<div class="text-sm mt-1">
{{ $t("rnprobe.sent") }}: {{ summary.sent }}, {{ $t("rnprobe.delivered") }}:
{{ summary.delivered }}, {{ $t("rnprobe.timeouts") }}: {{ summary.timeouts }},
{{ $t("rnprobe.failed") }}:
{{ summary.failed }}
</div>
</div>
</div>
<div class="glass-card flex flex-col min-h-[320px] space-y-3">
<div class="flex items-center justify-between gap-4">
<div>
<div class="text-sm font-semibold text-gray-900 dark:text-white">
{{ $t("rnprobe.probe_results") }}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
{{ $t("rnprobe.probe_responses_realtime") }}
</div>
</div>
</div>
<div
class="flex-1 overflow-y-auto rounded-2xl bg-black/80 text-emerald-300 font-mono text-xs p-3 space-y-2 shadow-inner border border-zinc-900"
>
<div v-if="results.length === 0" class="text-emerald-500/80">
{{ $t("rnprobe.no_probes_yet") }}
</div>
<div v-for="(result, index) in results" :key="index" class="space-y-1">
<div class="flex items-center gap-2">
<span class="text-emerald-400">{{
$t("rnprobe.probe_number", { number: result.probe_number })
}}</span>
<span class="text-gray-400">({{ result.size }} {{ $t("rnprobe.bytes") }})</span>
<span class="text-gray-400"></span>
<span class="text-emerald-300">{{ result.destination }}</span>
</div>
<div v-if="result.via || result.interface" class="text-gray-500 ml-4">
{{ result.via }}{{ result.interface }}
</div>
<div v-if="result.status === 'delivered'" class="text-green-400 ml-4 space-y-1">
<div>{{ $t("rnprobe.summary") }}: {{ $t("rnprobe.delivered") }}</div>
<div>{{ $t("rnprobe.hops") }}: {{ result.hops }}</div>
<div>{{ $t("rnprobe.rtt") }}: {{ result.rtt_string }}</div>
<div v-if="result.reception_stats" class="space-x-2">
<span v-if="result.reception_stats.rssi"
>{{ $t("rnprobe.rssi") }}: {{ result.reception_stats.rssi }} dBm</span
>
<span v-if="result.reception_stats.snr"
>{{ $t("rnprobe.snr") }}: {{ result.reception_stats.snr }} dB</span
>
<span v-if="result.reception_stats.quality"
>{{ $t("rnprobe.quality") }}: {{ result.reception_stats.quality }}%</span
>
</div>
</div>
<div v-else-if="result.status === 'timeout'" class="text-yellow-400 ml-4">
{{ $t("rnprobe.summary") }}: {{ $t("rnprobe.timeout") }}
</div>
<div v-else class="text-red-400 ml-4">
{{ $t("rnprobe.summary") }}: {{ $t("rnprobe.failed") }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import DialogUtils from "../../js/DialogUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "RNProbePage",
components: {
MaterialDesignIcon,
},
data() {
return {
isRunning: false,
destinationHash: null,
fullName: "lxmf.delivery",
probeSize: 16,
probes: 1,
wait: 0,
results: [],
summary: null,
abortController: null,
};
},
beforeUnmount() {
this.stopProbe();
},
methods: {
async startProbe() {
if (this.isRunning) {
return;
}
if (!this.destinationHash || this.destinationHash.length !== 32) {
DialogUtils.alert(this.$t("rnprobe.invalid_hash"));
return;
}
if (!this.fullName) {
DialogUtils.alert(this.$t("rnprobe.provide_full_name"));
return;
}
this.isRunning = true;
this.abortController = new AbortController();
this.results = [];
this.summary = null;
try {
const response = await window.axios.post(
"/api/v1/rnprobe",
{
destination_hash: this.destinationHash,
full_name: this.fullName,
size: this.probeSize,
probes: this.probes,
wait: this.wait,
},
{
signal: this.abortController.signal,
}
);
this.results = response.data.results || [];
this.summary = {
sent: response.data.sent || 0,
delivered: response.data.delivered || 0,
timeouts: response.data.timeouts || 0,
failed: response.data.failed || 0,
};
} catch (e) {
if (e.name !== "CanceledError") {
console.error(e);
DialogUtils.alert(e.response?.data?.message || this.$t("rnprobe.failed_to_probe"));
}
} finally {
this.isRunning = false;
}
},
stopProbe() {
this.isRunning = false;
if (this.abortController) {
this.abortController.abort();
}
},
clearResults() {
this.results = [];
this.summary = null;
},
},
};
</script>

View File

@@ -0,0 +1,226 @@
<template>
<div
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 px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-6xl mx-auto">
<div class="glass-card space-y-5">
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Network Diagnostics
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
RNStatus - Network Status
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
View interface statistics and network status information.
</div>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="primary-chip px-4 py-2 text-sm"
:disabled="isLoading"
@click="refreshStatus"
>
<MaterialDesignIcon
icon-name="refresh"
class="w-4 h-4"
:class="{ 'animate-spin': isLoading }"
/>
Refresh
</button>
<label class="flex items-center gap-2 cursor-pointer secondary-chip px-4 py-2 text-sm">
<input v-model="includeLinkStats" type="checkbox" class="rounded" />
<span>Include Link Stats</span>
</label>
<div class="flex items-center gap-2">
<label class="text-sm text-gray-700 dark:text-gray-300">Sort by:</label>
<select v-model="sorting" class="input-field text-sm">
<option value="">None</option>
<option value="bitrate">Bitrate</option>
<option value="rx">RX Bytes</option>
<option value="tx">TX Bytes</option>
<option value="traffic">Total Traffic</option>
<option value="announces">Announces</option>
</select>
</div>
</div>
<div
v-if="linkCount !== null"
class="p-3 rounded-lg bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300"
>
<div class="font-semibold">Active Links: {{ linkCount }}</div>
</div>
</div>
<div
v-if="interfaces.length === 0 && !isLoading"
class="glass-card p-8 text-center text-gray-500 dark:text-gray-400"
>
No interfaces found. Click refresh to load status.
</div>
<div v-for="iface in interfaces" :key="iface.name" class="glass-card space-y-3">
<div class="flex items-center justify-between">
<div class="font-semibold text-lg text-gray-900 dark:text-white">{{ iface.name }}</div>
<span
:class="[
iface.status === 'Up'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-200'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200',
'rounded-full px-3 py-1 text-xs font-semibold',
]"
>
{{ iface.status }}
</span>
</div>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
<div v-if="iface.mode">
<div class="text-gray-500 dark:text-gray-400">Mode</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.mode }}</div>
</div>
<div v-if="iface.bitrate">
<div class="text-gray-500 dark:text-gray-400">Bitrate</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.bitrate }}</div>
</div>
<div v-if="iface.rx_bytes_str">
<div class="text-gray-500 dark:text-gray-400">RX Bytes</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.rx_bytes_str }}</div>
</div>
<div v-if="iface.tx_bytes_str">
<div class="text-gray-500 dark:text-gray-400">TX Bytes</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.tx_bytes_str }}</div>
</div>
<div v-if="iface.rx_packets !== undefined">
<div class="text-gray-500 dark:text-gray-400">RX Packets</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.rx_packets }}</div>
</div>
<div v-if="iface.tx_packets !== undefined">
<div class="text-gray-500 dark:text-gray-400">TX Packets</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.tx_packets }}</div>
</div>
<div v-if="iface.clients !== undefined">
<div class="text-gray-500 dark:text-gray-400">Clients</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.clients }}</div>
</div>
<div v-if="iface.peers !== undefined">
<div class="text-gray-500 dark:text-gray-400">Peers</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.peers }} reachable</div>
</div>
<div v-if="iface.noise_floor">
<div class="text-gray-500 dark:text-gray-400">Noise Floor</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.noise_floor }}</div>
</div>
<div v-if="iface.interference">
<div class="text-gray-500 dark:text-gray-400">Interference</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.interference }}</div>
</div>
<div v-if="iface.cpu_load">
<div class="text-gray-500 dark:text-gray-400">CPU Load</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.cpu_load }}</div>
</div>
<div v-if="iface.cpu_temp">
<div class="text-gray-500 dark:text-gray-400">CPU Temp</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.cpu_temp }}</div>
</div>
<div v-if="iface.mem_load">
<div class="text-gray-500 dark:text-gray-400">Memory Load</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.mem_load }}</div>
</div>
<div v-if="iface.battery_percent !== undefined">
<div class="text-gray-500 dark:text-gray-400">Battery</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ iface.battery_percent }}%<span v-if="iface.battery_state">
({{ iface.battery_state }})</span
>
</div>
</div>
<div v-if="iface.network_name">
<div class="text-gray-500 dark:text-gray-400">Network</div>
<div class="font-semibold text-gray-900 dark:text-white">{{ iface.network_name }}</div>
</div>
<div v-if="iface.incoming_announce_frequency !== undefined">
<div class="text-gray-500 dark:text-gray-400">Incoming Announces</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ iface.incoming_announce_frequency }}/s
</div>
</div>
<div v-if="iface.outgoing_announce_frequency !== undefined">
<div class="text-gray-500 dark:text-gray-400">Outgoing Announces</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ iface.outgoing_announce_frequency }}/s
</div>
</div>
<div v-if="iface.airtime">
<div class="text-gray-500 dark:text-gray-400">Airtime</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ iface.airtime.short }}% (15s), {{ iface.airtime.long }}% (1h)
</div>
</div>
<div v-if="iface.channel_load">
<div class="text-gray-500 dark:text-gray-400">Channel Load</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ iface.channel_load.short }}% (15s), {{ iface.channel_load.long }}% (1h)
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "RNStatusPage",
components: {
MaterialDesignIcon,
},
data() {
return {
isLoading: false,
interfaces: [],
linkCount: null,
includeLinkStats: false,
sorting: "",
};
},
watch: {
sorting() {
this.refreshStatus();
},
includeLinkStats() {
this.refreshStatus();
},
},
mounted() {
this.refreshStatus();
},
methods: {
async refreshStatus() {
this.isLoading = true;
try {
const params = {
include_link_stats: this.includeLinkStats,
};
if (this.sorting) {
params.sorting = this.sorting;
}
const response = await window.axios.get("/api/v1/rnstatus", { params });
this.interfaces = response.data.interfaces || [];
this.linkCount = response.data.link_count;
} catch (e) {
console.error(e);
} finally {
this.isLoading = false;
}
},
},
};
</script>

View File

@@ -0,0 +1,880 @@
<template>
<div
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 px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-6xl mx-auto">
<!-- hero card -->
<div
class="bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl p-5 md:p-6"
>
<div class="flex flex-col md:flex-row md:items-center gap-4">
<div class="flex-1 space-y-1">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("app.profile") }}
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
{{ config.display_name }}
</div>
<div class="text-sm text-gray-600 dark:text-gray-300">{{ $t("app.manage_identity") }}</div>
</div>
<div class="flex flex-col sm:flex-row gap-2">
<button
type="button"
class="inline-flex items-center justify-center gap-x-2 rounded-xl border border-gray-200 dark:border-zinc-700 bg-white dark:bg-zinc-800 px-4 py-2 text-sm font-semibold text-gray-900 dark:text-zinc-100 shadow-sm hover:border-blue-400 dark:hover:border-blue-400/70 transition"
@click="copyValue(config.identity_hash, $t('app.identity_hash'))"
>
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4" />
{{ $t("app.identity_hash") }}
</button>
<button
type="button"
class="inline-flex items-center justify-center gap-x-2 rounded-xl bg-gradient-to-r from-blue-500 via-indigo-500 to-purple-500 px-4 py-2 text-sm font-semibold text-white shadow hover:shadow-md transition"
@click="copyValue(config.lxmf_address_hash, $t('app.lxmf_address'))"
>
<MaterialDesignIcon icon-name="account-plus" class="w-4 h-4" />
{{ $t("app.lxmf_address") }}
</button>
</div>
</div>
<transition name="fade">
<div
v-if="copyToast"
class="mt-3 rounded-full bg-emerald-100 text-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200 px-3 py-1 text-xs inline-flex items-center gap-2"
>
{{ copyToast }}
<span class="w-2 h-2 rounded-full bg-emerald-500 animate-ping"></span>
</div>
</transition>
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mt-4 text-sm text-gray-600 dark:text-gray-300">
<div
class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70"
>
<div class="text-xs uppercase tracking-wide">{{ $t("app.theme") }}</div>
<div class="font-semibold text-gray-900 dark:text-white capitalize">
{{ $t("app.theme_mode", { mode: config.theme }) }}
</div>
</div>
<div
class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70"
>
<div class="text-xs uppercase tracking-wide">{{ $t("app.transport") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{ config.is_transport_enabled ? $t("app.enabled") : $t("app.disabled") }}
</div>
</div>
<div
class="rounded-2xl border border-gray-200 dark:border-zinc-800 p-3 bg-white/70 dark:bg-zinc-900/70"
>
<div class="text-xs uppercase tracking-wide">{{ $t("app.propagation") }}</div>
<div class="font-semibold text-gray-900 dark:text-white">
{{
config.lxmf_local_propagation_node_enabled
? $t("app.local_node_running")
: $t("app.client_only")
}}
</div>
</div>
</div>
<div class="grid gap-3 mt-4 text-sm text-gray-700 dark:text-gray-200 sm:grid-cols-2">
<div class="address-card">
<div class="address-card__label">{{ $t("app.identity_hash") }}</div>
<div class="address-card__value monospace-field">{{ config.identity_hash }}</div>
<button
type="button"
class="address-card__action"
@click="copyValue(config.identity_hash, $t('app.identity_hash'))"
>
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4" />
{{ $t("app.copy") }}
</button>
</div>
<div class="address-card">
<div class="address-card__label">{{ $t("app.lxmf_address") }}</div>
<div class="address-card__value monospace-field">{{ config.lxmf_address_hash }}</div>
<button
type="button"
class="address-card__action"
@click="copyValue(config.lxmf_address_hash, $t('app.lxmf_address'))"
>
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4" />
{{ $t("app.copy") }}
</button>
</div>
</div>
</div>
<!-- settings grid -->
<div class="grid gap-4 lg:grid-cols-2">
<!-- Page Archiver -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Browsing</div>
<h2>Page Archiver</h2>
<p>Automatically save copies of visited NomadNetwork pages.</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<label class="setting-toggle">
<Toggle
id="page-archiver-enabled"
v-model="config.page_archiver_enabled"
@update:model-value="onPageArchiverEnabledChangeWrapper"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">Enable Archiver</span>
<span class="setting-toggle__description"
>Automatically archive pages for offline viewing and fallback.</span
>
</span>
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Max Versions per Page
</div>
<input
v-model.number="config.page_archiver_max_versions"
type="number"
min="1"
max="50"
class="input-field"
@input="onPageArchiverConfigChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
How many versions of each page to keep.
</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Max Total Storage (GB)
</div>
<input
v-model.number="config.archives_max_storage_gb"
type="number"
min="1"
class="input-field"
@input="onPageArchiverConfigChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
Total storage for all archived pages.
</div>
</div>
</div>
<button
type="button"
class="w-full flex items-center justify-center gap-2 rounded-xl border border-red-200 dark:border-red-900/30 bg-red-50 dark:bg-red-900/20 px-4 py-2 text-sm font-semibold text-red-700 dark:text-red-300 hover:bg-red-100 dark:hover:bg-red-900/40 transition"
@click="flushArchivedPages"
>
<MaterialDesignIcon icon-name="delete-sweep" class="w-4 h-4" />
Flush All Archived Pages
</button>
</div>
</section>
<!-- Smart Crawler -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Discovery</div>
<h2>Smart Crawler</h2>
<p>Automatically archive node homepages when announced.</p>
</div>
</header>
<div class="glass-card__body space-y-4">
<label class="setting-toggle">
<Toggle
id="crawler-enabled"
v-model="config.crawler_enabled"
@update:model-value="onCrawlerEnabledChange"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">Enable Crawler</span>
<span class="setting-toggle__description"
>Archive index pages for every node discovered on the mesh.</span
>
</span>
</label>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Max Retries</div>
<input
v-model.number="config.crawler_max_retries"
type="number"
min="1"
max="10"
class="input-field"
@input="onCrawlerConfigChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
Attempts before giving up.
</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Retry Delay (seconds)
</div>
<input
v-model.number="config.crawler_retry_delay_seconds"
type="number"
min="60"
class="input-field"
@input="onCrawlerConfigChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
Wait time between attempts.
</div>
</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
Max Concurrent Crawls
</div>
<input
v-model.number="config.crawler_max_concurrent"
type="number"
min="1"
max="5"
class="input-field"
@input="onCrawlerConfigChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
Limits background bandwidth usage.
</div>
</div>
</div>
</section>
<!-- Appearance -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Personalise</div>
<h2>{{ $t("app.appearance") }}</h2>
<p>{{ $t("app.appearance_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<select v-model="config.theme" class="input-field" @change="onThemeChange">
<option value="light">{{ $t("app.light_theme") }}</option>
<option value="dark">{{ $t("app.dark_theme") }}</option>
</select>
<div
class="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300 border border-dashed border-gray-200 dark:border-zinc-800 rounded-2xl px-3 py-2"
>
<div>{{ $t("app.live_preview") }}</div>
<span
class="inline-flex items-center gap-1 text-blue-500 dark:text-blue-300 text-xs font-semibold uppercase"
>
<span class="w-1.5 h-1.5 rounded-full bg-blue-500"></span>
{{ $t("app.realtime") }}
</span>
</div>
</div>
</section>
<!-- Language -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">i18n</div>
<h2>{{ $t("app.language") }}</h2>
<p>{{ $t("app.select_language") }}</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<select v-model="config.language" class="input-field" @change="onLanguageChange">
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="ru">Русский</option>
</select>
</div>
</section>
<!-- Transport -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Reticulum</div>
<h2>{{ $t("app.transport_mode") }}</h2>
<p>{{ $t("app.transport_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<label class="setting-toggle">
<Toggle
id="transport-enabled"
v-model="config.is_transport_enabled"
@update:model-value="onIsTransportEnabledChangeWrapper"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("app.enable_transport_mode") }}</span>
<span class="setting-toggle__description">{{
$t("app.transport_toggle_description")
}}</span>
<span class="setting-toggle__hint">{{ $t("app.requires_restart") }}</span>
</span>
</label>
</div>
</section>
<!-- Interfaces -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Adapters</div>
<h2>{{ $t("app.interfaces") }}</h2>
<p>Show curated community configs inside the interface wizard.</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<label class="setting-toggle">
<Toggle
id="show-community-interfaces"
v-model="config.show_suggested_community_interfaces"
@update:model-value="onShowSuggestedCommunityInterfacesChangeWrapper"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("app.show_community_interfaces") }}</span>
<span class="setting-toggle__description">{{
$t("app.community_interfaces_description")
}}</span>
</span>
</label>
</div>
</section>
<!-- Blocked -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Privacy</div>
<h2>Blocked</h2>
<p>Manage blocked users and nodes</p>
</div>
<RouterLink :to="{ name: 'blocked' }" class="primary-chip"> Manage Blocked </RouterLink>
</header>
<div class="glass-card__body">
<p class="text-sm text-gray-600 dark:text-gray-400">
Blocked users and nodes will not be able to send you messages, and their announces will
be ignored.
</p>
</div>
</section>
<!-- Authentication -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">Security</div>
<h2>Authentication</h2>
<p>Require a password to access the web interface.</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<label class="setting-toggle">
<Toggle
id="auth-enabled"
v-model="config.auth_enabled"
@update:model-value="onAuthEnabledChange"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">Enable Authentication</span>
<span class="setting-toggle__description"
>Protect your instance with a password.</span
>
</span>
</label>
<div v-if="config.auth_enabled" class="info-callout">
<p class="text-sm">
Authentication is currently enabled. You will be asked for your password when
accessing the web interface.
</p>
</div>
</div>
</section>
<!-- Messages -->
<section class="glass-card">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">{{ $t("app.reliability") }}</div>
<h2>{{ $t("app.messages") }}</h2>
<p>{{ $t("app.messages_description") }}</p>
</div>
</header>
<div class="glass-card__body space-y-3">
<label class="setting-toggle">
<Toggle
id="auto-resend-failed"
v-model="config.auto_resend_failed_messages_when_announce_received"
@update:model-value="onAutoResendFailedMessagesWhenAnnounceReceivedChangeWrapper"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("app.auto_resend_title") }}</span>
<span class="setting-toggle__description">{{
$t("app.auto_resend_description")
}}</span>
</span>
</label>
<label class="setting-toggle">
<Toggle
id="allow-retries-attachments"
v-model="config.allow_auto_resending_failed_messages_with_attachments"
@update:model-value="onAllowAutoResendingFailedMessagesWithAttachmentsChangeWrapper"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("app.retry_attachments_title") }}</span>
<span class="setting-toggle__description">{{
$t("app.retry_attachments_description")
}}</span>
</span>
</label>
<label class="setting-toggle">
<Toggle
id="auto-fallback-propagation"
v-model="config.auto_send_failed_messages_to_propagation_node"
@update:model-value="onAutoSendFailedMessagesToPropagationNodeChangeWrapper"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("app.auto_fallback_title") }}</span>
<span class="setting-toggle__description">{{
$t("app.auto_fallback_description")
}}</span>
</span>
</label>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.inbound_stamp_cost") }}
</div>
<input
v-model.number="config.lxmf_inbound_stamp_cost"
type="number"
min="1"
max="254"
placeholder="8"
class="input-field"
@input="onLxmfInboundStampCostChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("app.inbound_stamp_description") }}
</div>
</div>
</div>
</section>
<!-- Propagation nodes -->
<section class="glass-card lg:col-span-2">
<header class="glass-card__header">
<div>
<div class="glass-card__eyebrow">LXMF</div>
<h2>{{ $t("app.propagation_nodes") }}</h2>
<p>{{ $t("app.propagation_nodes_description") }}</p>
</div>
<RouterLink :to="{ name: 'propagation-nodes' }" class="primary-chip">
{{ $t("app.browse_nodes") }}
</RouterLink>
</header>
<div class="glass-card__body space-y-5">
<div class="info-callout">
<ul class="list-disc list-inside space-y-1 text-sm">
<li>{{ $t("app.nodes_info_1") }}</li>
<li>{{ $t("app.nodes_info_2") }}</li>
<li>{{ $t("app.nodes_info_3") }}</li>
</ul>
</div>
<label class="setting-toggle">
<Toggle
id="local-propagation-node"
v-model="config.lxmf_local_propagation_node_enabled"
@update:model-value="onLxmfLocalPropagationNodeEnabledChangeWrapper"
/>
<span class="setting-toggle__label">
<span class="setting-toggle__title">{{ $t("app.run_local_node") }}</span>
<span class="setting-toggle__description">{{
$t("app.run_local_node_description")
}}</span>
<span class="setting-toggle__hint monospace-field">{{
config.lxmf_local_propagation_node_address_hash || "—"
}}</span>
</span>
</label>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.preferred_propagation_node") }}
</div>
<input
v-model="config.lxmf_preferred_propagation_node_destination_hash"
type="text"
:placeholder="$t('app.preferred_node_placeholder')"
class="input-field monospace-field"
@input="onLxmfPreferredPropagationNodeDestinationHashChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("app.fallback_node_description") }}
</div>
</div>
<div class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.auto_sync_interval") }}
</div>
<select
v-model="config.lxmf_preferred_propagation_node_auto_sync_interval_seconds"
class="input-field"
@change="onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange"
>
<option value="0">{{ $t("app.disabled") }}</option>
<option value="900">Every 15 Minutes</option>
<option value="1800">Every 30 Minutes</option>
<option value="3600">Every 1 Hour</option>
<option value="10800">Every 3 Hours</option>
<option value="21600">Every 6 Hours</option>
<option value="43200">Every 12 Hours</option>
<option value="86400">Every 24 Hours</option>
</select>
<div class="text-xs text-gray-600 dark:text-gray-400">
<span v-if="config.lxmf_preferred_propagation_node_last_synced_at">{{
$t("app.last_synced", {
time: formatSecondsAgo(
config.lxmf_preferred_propagation_node_last_synced_at
),
})
}}</span>
<span v-else>{{ $t("app.last_synced_never") }}</span>
</div>
</div>
<div v-if="config.lxmf_local_propagation_node_enabled" class="space-y-2">
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
{{ $t("app.propagation_stamp_cost") }}
</div>
<input
v-model.number="config.lxmf_propagation_node_stamp_cost"
type="number"
min="13"
max="254"
placeholder="16"
class="input-field"
@input="onLxmfPropagationNodeStampCostChange"
/>
<div class="text-xs text-gray-600 dark:text-gray-400">
{{ $t("app.propagation_stamp_description") }}
</div>
</div>
</div>
</section>
</div>
</div>
</div>
</div>
</template>
<script>
import Utils from "../../js/Utils";
import WebSocketConnection from "../../js/WebSocketConnection";
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
import Toggle from "../forms/Toggle.vue";
export default {
name: "SettingsPage",
components: {
MaterialDesignIcon,
Toggle,
},
data() {
return {
config: {
auto_resend_failed_messages_when_announce_received: null,
allow_auto_resending_failed_messages_with_attachments: null,
auto_send_failed_messages_to_propagation_node: null,
show_suggested_community_interfaces: null,
lxmf_local_propagation_node_enabled: null,
lxmf_preferred_propagation_node_destination_hash: null,
archives_max_storage_gb: 1,
},
};
},
beforeUnmount() {
// stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage);
},
mounted() {
// listen for websocket messages
WebSocketConnection.on("message", this.onWebsocketMessage);
this.getConfig();
},
methods: {
async onWebsocketMessage(message) {
const json = JSON.parse(message.data);
switch (json.type) {
case "config": {
this.config = json.config;
break;
}
}
},
async getConfig() {
try {
const response = await window.axios.get("/api/v1/config");
this.config = response.data.config;
} catch (e) {
// do nothing if failed to load config
console.log(e);
}
},
async updateConfig(config) {
try {
const response = await window.axios.patch("/api/v1/config", config);
this.config = response.data.config;
} catch (e) {
ToastUtils.error("Failed to save config!");
console.log(e);
}
},
async copyValue(value, label) {
if (!value) {
ToastUtils.warning(`Nothing to copy for ${label}`);
return;
}
try {
await navigator.clipboard.writeText(value);
ToastUtils.success(`${label} copied to clipboard`);
} catch {
ToastUtils.info(`${label}: ${value}`);
}
},
async onThemeChange() {
await this.updateConfig({
theme: this.config.theme,
});
},
async onLanguageChange() {
await this.updateConfig({
language: this.config.language,
});
},
async onAutoResendFailedMessagesWhenAnnounceReceivedChangeWrapper(value) {
this.config.auto_resend_failed_messages_when_announce_received = value;
await this.onAutoResendFailedMessagesWhenAnnounceReceivedChange();
},
async onAutoResendFailedMessagesWhenAnnounceReceivedChange() {
await this.updateConfig({
auto_resend_failed_messages_when_announce_received:
this.config.auto_resend_failed_messages_when_announce_received,
});
},
async onAllowAutoResendingFailedMessagesWithAttachmentsChangeWrapper(value) {
this.config.allow_auto_resending_failed_messages_with_attachments = value;
await this.onAllowAutoResendingFailedMessagesWithAttachmentsChange();
},
async onAllowAutoResendingFailedMessagesWithAttachmentsChange() {
await this.updateConfig({
allow_auto_resending_failed_messages_with_attachments:
this.config.allow_auto_resending_failed_messages_with_attachments,
});
},
async onAutoSendFailedMessagesToPropagationNodeChangeWrapper(value) {
this.config.auto_send_failed_messages_to_propagation_node = value;
await this.onAutoSendFailedMessagesToPropagationNodeChange();
},
async onAutoSendFailedMessagesToPropagationNodeChange() {
await this.updateConfig({
auto_send_failed_messages_to_propagation_node:
this.config.auto_send_failed_messages_to_propagation_node,
});
},
async onShowSuggestedCommunityInterfacesChangeWrapper(value) {
this.config.show_suggested_community_interfaces = value;
await this.onShowSuggestedCommunityInterfacesChange();
},
async onShowSuggestedCommunityInterfacesChange() {
await this.updateConfig({
show_suggested_community_interfaces: this.config.show_suggested_community_interfaces,
});
},
async onLxmfPreferredPropagationNodeDestinationHashChange() {
await this.updateConfig({
lxmf_preferred_propagation_node_destination_hash:
this.config.lxmf_preferred_propagation_node_destination_hash,
});
},
async onLxmfLocalPropagationNodeEnabledChangeWrapper(value) {
this.config.lxmf_local_propagation_node_enabled = value;
await this.onLxmfLocalPropagationNodeEnabledChange();
},
async onLxmfLocalPropagationNodeEnabledChange() {
await this.updateConfig({
lxmf_local_propagation_node_enabled: this.config.lxmf_local_propagation_node_enabled,
});
},
async onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange() {
await this.updateConfig({
lxmf_preferred_propagation_node_auto_sync_interval_seconds:
this.config.lxmf_preferred_propagation_node_auto_sync_interval_seconds,
});
},
async onLxmfInboundStampCostChange() {
await this.updateConfig({
lxmf_inbound_stamp_cost: this.config.lxmf_inbound_stamp_cost,
});
},
async onLxmfPropagationNodeStampCostChange() {
await this.updateConfig({
lxmf_propagation_node_stamp_cost: this.config.lxmf_propagation_node_stamp_cost,
});
},
async onPageArchiverEnabledChangeWrapper(value) {
this.config.page_archiver_enabled = value;
await this.updateConfig({
page_archiver_enabled: this.config.page_archiver_enabled,
});
},
async onPageArchiverConfigChange() {
await this.updateConfig({
page_archiver_max_versions: this.config.page_archiver_max_versions,
archives_max_storage_gb: this.config.archives_max_storage_gb,
});
},
async onCrawlerEnabledChange(value) {
await this.updateConfig({
crawler_enabled: value,
});
},
async onCrawlerConfigChange() {
await this.updateConfig({
crawler_max_retries: this.config.crawler_max_retries,
crawler_retry_delay_seconds: this.config.crawler_retry_delay_seconds,
crawler_max_concurrent: this.config.crawler_max_concurrent,
});
},
async onAuthEnabledChange(value) {
await this.updateConfig({
auth_enabled: value,
});
if (value) {
// if enabled, redirect to setup page if password not set
// or just to auth page in general
this.$router.push({ name: "auth" });
}
},
async flushArchivedPages() {
if (
!(await DialogUtils.confirm(
"Are you sure you want to delete all archived pages? This cannot be undone."
))
) {
return;
}
WebSocketConnection.send(
JSON.stringify({
type: "nomadnet.page.archive.flush",
})
);
ToastUtils.success("Archived pages flushed.");
},
async onIsTransportEnabledChangeWrapper(value) {
this.config.is_transport_enabled = value;
await this.onIsTransportEnabledChange();
},
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) {
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) {
ToastUtils.error("Failed to disable transport mode!");
console.log(e);
}
}
},
formatSecondsAgo: function (seconds) {
return Utils.formatSecondsAgo(seconds);
},
},
};
</script>
<style scoped>
.glass-card {
@apply bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg flex flex-col;
}
.glass-card__header {
@apply flex items-center justify-between gap-3 px-4 py-4 border-b border-gray-100/70 dark:border-zinc-800/80;
}
.glass-card__header h2 {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.glass-card__header p {
@apply text-sm text-gray-600 dark:text-gray-400;
}
.glass-card__eyebrow {
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
}
.glass-card__body {
@apply px-4 py-4 text-gray-900 dark:text-gray-100;
}
.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;
}
.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;
}
.setting-toggle :deep(.sr-only) {
@apply absolute w-px h-px p-0 -m-px overflow-hidden whitespace-nowrap border-0;
}
.setting-toggle__label {
@apply flex-1 flex flex-col gap-0.5;
}
.setting-toggle__title {
@apply text-sm font-semibold text-gray-900 dark:text-white;
}
.setting-toggle__description {
@apply text-sm text-gray-600 dark:text-gray-300;
}
.setting-toggle__hint {
@apply text-xs text-gray-500 dark:text-gray-400;
}
.primary-chip {
@apply inline-flex items-center gap-x-1 rounded-full bg-blue-600/90 px-4 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
}
.info-callout {
@apply rounded-2xl border border-blue-100 dark:border-blue-900/40 bg-blue-50/60 dark:bg-blue-900/20 px-3 py-3 text-blue-900 dark:text-blue-100;
}
.monospace-field {
font-family: "Roboto Mono", monospace;
}
.address-card {
@apply relative border border-gray-200 dark:border-zinc-800 rounded-2xl bg-white/80 dark:bg-zinc-900/70 p-4 space-y-2;
}
.address-card__label {
@apply text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400;
}
.address-card__value {
@apply text-sm text-gray-900 dark:text-white break-words pr-16;
}
.address-card__action {
@apply absolute top-3 right-3 inline-flex items-center gap-1 rounded-full border border-gray-200 dark:border-zinc-700 px-3 py-1 text-xs font-semibold text-gray-700 dark:text-gray-100 bg-white/70 dark:bg-zinc-900/60 hover:border-blue-400 dark:hover:border-blue-500 transition;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@@ -0,0 +1,146 @@
<template>
<div
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="overflow-y-auto space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
<div class="glass-card space-y-3">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
{{ $t("tools.utilities") }}
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("tools.power_tools") }}</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
{{ $t("tools.diagnostics_description") }}
</div>
</div>
<div class="grid gap-4 md:grid-cols-2">
<RouterLink :to="{ name: 'ping' }" class="tool-card glass-card">
<div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
<MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.ping.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.ping.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'rnprobe' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
>
<MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.rnprobe.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.rnprobe.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'rncp' }" class="tool-card glass-card">
<div class="tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200">
<MaterialDesignIcon icon-name="swap-horizontal" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.rncp.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.rncp.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'rnstatus' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200"
>
<MaterialDesignIcon icon-name="chart-line" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.rnstatus.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.rnstatus.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'translator' }" class="tool-card glass-card">
<div
class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
>
<MaterialDesignIcon icon-name="translate" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.translator.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.translator.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<RouterLink :to="{ name: 'forwarder' }" class="tool-card glass-card">
<div class="tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200">
<MaterialDesignIcon icon-name="email-send-outline" class="w-6 h-6" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.forwarder.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.forwarder.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
</RouterLink>
<a target="_blank" href="/rnode-flasher/index.html" class="tool-card glass-card">
<div
class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
>
<img src="/rnode-flasher/reticulum_logo_512.png" class="w-8 h-8 rounded-full" alt="RNode" />
</div>
<div class="flex-1">
<div class="tool-card__title">{{ $t("tools.rnode_flasher.title") }}</div>
<div class="tool-card__description">
{{ $t("tools.rnode_flasher.description") }}
</div>
</div>
<MaterialDesignIcon icon-name="open-in-new" class="tool-card__chevron" />
</a>
</div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "ToolsPage",
components: {
MaterialDesignIcon,
},
};
</script>
<style scoped>
.tool-card {
@apply flex items-center gap-4 hover:border-blue-400 dark:hover:border-blue-500 transition cursor-pointer;
}
.tool-card__icon {
@apply w-12 h-12 rounded-2xl flex items-center justify-center;
}
.tool-card__title {
@apply text-lg font-semibold text-gray-900 dark:text-white;
}
.tool-card__description {
@apply text-sm text-gray-600 dark:text-gray-300;
}
.tool-card__chevron {
@apply w-5 h-5 text-gray-400;
}
</style>

View File

@@ -0,0 +1,528 @@
<template>
<div
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 px-4 md:px-8 py-6">
<div class="space-y-4 w-full max-w-4xl mx-auto">
<div class="glass-card space-y-5">
<div class="space-y-2">
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
Text Translation
</div>
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Translator</div>
<div class="text-sm text-gray-600 dark:text-gray-300">
Translate text using LibreTranslate API or local Argos Translate.
</div>
</div>
<div class="border-b border-gray-200 dark:border-zinc-700">
<div class="flex -mb-px">
<button
type="button"
class="px-4 py-2 text-sm font-semibold border-b-2 transition-colors"
:class="
translationMode === 'argos'
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
"
@click="translationMode = 'argos'"
>
Argos Translate
</button>
<button
type="button"
class="px-4 py-2 text-sm font-semibold border-b-2 transition-colors"
:class="
translationMode === 'libretranslate'
? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-300'
: 'border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'
"
@click="translationMode = 'libretranslate'"
>
LibreTranslate
</button>
</div>
</div>
<div
v-if="translationMode === 'libretranslate'"
class="p-3 rounded-lg bg-gray-50 dark:bg-zinc-800/50 border border-gray-100 dark:border-zinc-700/50"
>
<label class="glass-label mb-2">LibreTranslate API Server</label>
<input
v-model="libretranslateUrl"
type="text"
placeholder="http://localhost:5000"
class="input-field"
/>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Enter the base URL of your LibreTranslate server (e.g., http://localhost:5000)
</div>
</div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="glass-label">Source Language</label>
<select v-model="sourceLang" class="input-field">
<option v-if="translationMode === 'libretranslate'" value="auto">Auto-detect</option>
<option v-for="lang in filteredLanguages" :key="`src-${lang.code}`" :value="lang.code">
{{ lang.name }} ({{ lang.code }})
</option>
</select>
</div>
<div>
<label class="glass-label">Target Language</label>
<select v-model="targetLang" class="input-field">
<option value="">Select target language</option>
<option v-for="lang in filteredLanguages" :key="`tgt-${lang.code}`" :value="lang.code">
{{ lang.name }} ({{ lang.code }})
</option>
</select>
</div>
</div>
<div
v-if="translationMode === 'argos' && !hasArgos"
class="p-4 rounded-xl bg-amber-50 dark:bg-amber-900/10 border border-amber-200/50 dark:border-amber-800/30"
>
<div class="flex items-start gap-3">
<div class="p-2 bg-amber-100 dark:bg-amber-900/30 rounded-lg">
<MaterialDesignIcon
icon-name="information-outline"
class="size-5 text-amber-600 dark:text-amber-400"
/>
</div>
<div class="flex-1 text-sm text-amber-800 dark:text-amber-200">
<p class="font-bold mb-1">Argos Translate not detected</p>
<p class="mb-4 opacity-90">
To use local translation, you must install the Argos Translate package using one of
the following methods:
</p>
<div class="grid sm:grid-cols-2 gap-4">
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider opacity-70"
>Method 1: pip (venv)</span
>
<button
class="text-amber-600 dark:text-amber-400 hover:scale-110 transition-transform"
@click="copyToClipboard('pip install argostranslate')"
>
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
</button>
</div>
<div
class="bg-amber-100/50 dark:bg-black/30 p-2 rounded font-mono text-xs break-all"
>
pip install argostranslate
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider opacity-70"
>Method 2: pipx</span
>
<button
class="text-amber-600 dark:text-amber-400 hover:scale-110 transition-transform"
@click="copyToClipboard('pipx install argostranslate')"
>
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
</button>
</div>
<div
class="bg-amber-100/50 dark:bg-black/30 p-2 rounded font-mono text-xs break-all"
>
pipx install argostranslate
</div>
</div>
</div>
<p class="mt-4 text-xs opacity-70 italic">
Note: After installation, you may need to restart the application and install
language packages via the Argos Translate CLI.
</p>
</div>
</div>
</div>
<div
v-if="translationMode === 'argos' && hasArgos && !hasArgosLanguages"
class="p-4 rounded-xl bg-blue-50 dark:bg-blue-900/10 border border-blue-200/50 dark:border-blue-800/30"
>
<div class="flex items-start gap-3">
<div class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg">
<MaterialDesignIcon
icon-name="information-outline"
class="size-5 text-blue-600 dark:text-blue-400"
/>
</div>
<div class="flex-1 text-sm text-blue-800 dark:text-blue-200">
<p class="font-bold mb-1">No language packages detected</p>
<p class="mb-4 opacity-90">
Argos Translate is installed but no language packages are available. Install
language packages using the buttons below or the CLI commands:
</p>
<div class="space-y-3">
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider opacity-70"
>Install all languages</span
>
<div class="flex gap-2">
<button
class="text-blue-600 dark:text-blue-400 hover:scale-110 transition-transform"
@click="copyToClipboard('argospm install translate')"
>
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
</button>
</div>
</div>
<div class="flex gap-2">
<button
type="button"
class="primary-chip px-3 py-1.5 text-xs"
:disabled="isInstallingLanguages"
@click="installLanguages('translate')"
>
<span
v-if="isInstallingLanguages"
class="inline-block w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin mr-1"
></span>
<MaterialDesignIcon v-else icon-name="download" class="w-3 h-3" />
Install All
</button>
<div
class="bg-blue-100/50 dark:bg-black/30 p-2 rounded font-mono text-xs break-all flex-1"
>
argospm install translate
</div>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase tracking-wider opacity-70"
>Install specific language pair (example: English to German)</span
>
<button
class="text-blue-600 dark:text-blue-400 hover:scale-110 transition-transform"
@click="copyToClipboard('argospm install translate-en_de')"
>
<MaterialDesignIcon icon-name="content-copy" class="size-4" />
</button>
</div>
<div
class="bg-blue-100/50 dark:bg-black/30 p-2 rounded font-mono text-xs break-all"
>
argospm install translate-en_de
</div>
</div>
</div>
<p class="mt-4 text-xs opacity-70 italic">
After installing language packages, click "Refresh Languages" to reload available
languages.
</p>
</div>
</div>
</div>
<div>
<label class="glass-label">Text to Translate</label>
<textarea
v-model="inputText"
rows="6"
placeholder="Enter text to translate..."
class="input-field"
:disabled="isTranslating"
></textarea>
</div>
<div class="flex gap-2">
<button
type="button"
class="primary-chip px-4 py-2 text-sm"
:disabled="!canTranslate || isTranslating"
@click="translateText"
>
<span
v-if="isTranslating"
class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"
></span>
<MaterialDesignIcon v-else icon-name="translate" class="w-4 h-4" />
{{ isTranslating ? "Translating..." : "Translate" }}
</button>
<button
type="button"
class="secondary-chip px-4 py-2 text-sm"
:disabled="!targetLang || isTranslating"
@click="swapLanguages"
>
<MaterialDesignIcon icon-name="swap-horizontal" class="w-4 h-4" />
Swap
</button>
<button type="button" class="secondary-chip px-4 py-2 text-sm" @click="clearText">
<MaterialDesignIcon icon-name="broom" class="w-4 h-4" />
Clear
</button>
</div>
<div v-if="translationResult" class="space-y-2">
<div class="flex items-center justify-between">
<div class="text-sm font-semibold text-gray-900 dark:text-white">Translation</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Source: {{ translationResult.source }}
</div>
</div>
<div
class="p-4 rounded-lg bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700"
>
<div class="text-gray-900 dark:text-white whitespace-pre-wrap">
{{ translationResult.translated_text }}
</div>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400">
Detected: {{ translationResult.source_lang }} {{ translationResult.target_lang }}
</div>
</div>
<div
v-if="error"
class="p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300"
>
{{ error }}
</div>
</div>
<div class="glass-card space-y-3">
<div class="text-sm font-semibold text-gray-900 dark:text-white">Available Languages</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mb-2">
Languages are loaded from LibreTranslate API or Argos Translate packages.
</div>
<div class="flex flex-wrap gap-2">
<span
v-for="lang in filteredLanguages"
:key="lang.code"
class="px-2 py-1 rounded text-xs bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-gray-300"
>
{{ lang.name }} ({{ lang.code }})
<span class="text-gray-500 dark:text-gray-500">- {{ lang.source }}</span>
</span>
</div>
<div class="flex gap-2 mt-2">
<button type="button" class="secondary-chip px-4 py-2 text-sm" @click="loadLanguages">
<MaterialDesignIcon icon-name="refresh" class="w-4 h-4" />
Refresh Languages
</button>
<button
v-if="translationMode === 'argos' && hasArgos"
type="button"
class="primary-chip px-4 py-2 text-sm"
:disabled="isInstallingLanguages"
@click="installLanguages('translate')"
>
<span
v-if="isInstallingLanguages"
class="inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin mr-2"
></span>
<MaterialDesignIcon v-else icon-name="download" class="w-4 h-4" />
Install All Languages
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import DialogUtils from "../../js/DialogUtils";
import ToastUtils from "../../js/ToastUtils";
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
export default {
name: "TranslatorPage",
components: {
MaterialDesignIcon,
},
data() {
return {
languages: [],
sourceLang: "",
targetLang: "",
inputText: "",
translationMode: "argos",
libretranslateUrl: "http://localhost:5000",
hasArgos: true,
isTranslating: false,
isInstallingLanguages: false,
translationResult: null,
error: null,
};
},
computed: {
canTranslate() {
return this.inputText.trim().length > 0 && this.targetLang && this.targetLang !== this.sourceLang;
},
useArgos() {
return this.translationMode === "argos";
},
hasArgosLanguages() {
return this.languages.some((lang) => lang.source === "argos");
},
filteredLanguages() {
if (this.translationMode === "argos") {
return this.languages.filter((lang) => lang.source === "argos");
} else {
return this.languages.filter((lang) => lang.source === "libretranslate");
}
},
},
watch: {
translationMode() {
if (this.translationMode === "libretranslate" && !this.sourceLang) {
this.sourceLang = "auto";
} else if (this.translationMode === "argos" && this.sourceLang === "auto") {
this.sourceLang = "";
}
this.loadLanguages();
},
libretranslateUrl() {
if (this.translationMode === "libretranslate") {
this.loadLanguages();
}
},
},
mounted() {
this.loadLanguages();
},
methods: {
async loadLanguages() {
try {
const params = {};
if (this.translationMode === "libretranslate" && this.libretranslateUrl) {
params.libretranslate_url = this.libretranslateUrl;
}
const response = await window.axios.get("/api/v1/translator/languages", { params });
this.languages = response.data.languages || [];
this.hasArgos = response.data.has_argos;
} catch (e) {
console.error(e);
DialogUtils.alert(
"Failed to load languages. Make sure LibreTranslate is running or Argos Translate is installed."
);
}
},
copyToClipboard(text) {
navigator.clipboard.writeText(text);
ToastUtils.success("Copied to clipboard");
},
async translateText() {
if (!this.canTranslate || this.isTranslating) {
return;
}
if (!this.sourceLang || !this.targetLang) {
this.error = "Please select both source and target languages.";
return;
}
if (this.translationMode === "argos" && this.sourceLang === "auto") {
this.error = "Auto-detection is not supported with Argos Translate. Please select a source language.";
return;
}
this.isTranslating = true;
this.error = null;
this.translationResult = null;
try {
const payload = {
text: this.inputText,
source_lang: this.sourceLang,
target_lang: this.targetLang,
use_argos: this.useArgos,
};
if (this.translationMode === "libretranslate" && this.libretranslateUrl) {
payload.libretranslate_url = this.libretranslateUrl;
}
const response = await window.axios.post("/api/v1/translator/translate", payload);
this.translationResult = response.data;
if (this.translationResult.source_lang === "auto") {
this.sourceLang = this.translationResult.source_lang;
}
} catch (e) {
console.error(e);
this.error =
e.response?.data?.message ||
"Translation failed. Make sure LibreTranslate is running or Argos Translate is installed.";
} finally {
this.isTranslating = false;
}
},
swapLanguages() {
if (!this.targetLang) {
return;
}
if (
this.translationResult &&
this.translationResult.source_lang &&
this.translationResult.source_lang !== "auto"
) {
const temp = this.sourceLang;
this.sourceLang = this.targetLang;
this.targetLang = temp;
if (this.translationResult.translated_text) {
this.inputText = this.translationResult.translated_text;
this.translationResult = null;
}
} else {
const temp = this.sourceLang;
if (this.translationMode === "argos") {
if (!this.targetLang) {
return;
}
this.sourceLang = this.targetLang;
this.targetLang = temp && temp !== "auto" ? temp : "";
} else {
this.sourceLang = this.targetLang || "auto";
this.targetLang = temp && temp !== "auto" ? temp : "";
}
}
},
clearText() {
this.inputText = "";
this.translationResult = null;
this.error = null;
},
async installLanguages(packageName) {
if (this.isInstallingLanguages) {
return;
}
this.isInstallingLanguages = true;
this.error = null;
try {
const response = await window.axios.post("/api/v1/translator/install-languages", {
package: packageName,
});
ToastUtils.success(response.data.message || "Languages installed successfully");
await this.loadLanguages();
} catch (e) {
console.error(e);
this.error =
e.response?.data?.message || "Failed to install languages. Make sure argospm is available in PATH.";
ToastUtils.error(this.error);
} finally {
this.isInstallingLanguages = false;
}
},
},
};
</script>

View File

@@ -0,0 +1,6 @@
@font-face {
font-family: 'Roboto Mono Nerd Font';
src: url('./RobotoMonoNerdFont-Regular.ttf') format('truetype');
font-weight: 400;
font-style: normal;
}

View File

@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<link rel="manifest" href="/manifest.json">
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
<title>Reticulum MeshChat</title>
</head>
<body class="bg-gray-100">
<div id="app"></div>
<script type="module" src="main.js"></script>
<script>
// install service worker
if('serviceWorker' in navigator){
navigator.serviceWorker.register('/service-worker.js').catch((error) => {
// Silently handle SSL certificate errors and other registration failures
// This is common in development with self-signed certificates
const errorMessage = error.message || '';
const errorName = error.name || '';
if (errorName === 'SecurityError' || errorMessage.includes('SSL certificate') || errorMessage.includes('certificate')) {
return;
}
// Log other errors for debugging but don't throw
console.debug('Service worker registration failed:', error);
});
}
</script>
</body>
</html>

View File

@@ -0,0 +1,58 @@
const codec2ScriptPaths = [
"/assets/js/codec2-emscripten/c2enc.js",
"/assets/js/codec2-emscripten/c2dec.js",
"/assets/js/codec2-emscripten/sox.js",
"/assets/js/codec2-emscripten/codec2-lib.js",
"/assets/js/codec2-emscripten/wav-encoder.js",
"/assets/js/codec2-emscripten/codec2-microphone-recorder.js",
];
let loadPromise = null;
function injectScript(src) {
if (typeof document === "undefined") {
return Promise.resolve();
}
const attrName = "data-codec2-src";
const loadedAttr = "data-codec2-loaded";
const existing = document.querySelector(`script[${attrName}="${src}"]`);
if (existing) {
if (existing.getAttribute(loadedAttr) === "true") {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
existing.addEventListener("load", () => resolve(), { once: true });
existing.addEventListener("error", () => reject(new Error(`Failed to load ${src}`)), { once: true });
});
}
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = src;
script.async = false;
script.setAttribute(attrName, src);
script.addEventListener("load", () => {
script.setAttribute(loadedAttr, "true");
resolve();
});
script.addEventListener("error", () => {
script.remove();
reject(new Error(`Failed to load ${src}`));
});
document.head.appendChild(script);
});
}
export function ensureCodec2ScriptsLoaded() {
if (typeof window === "undefined") {
return Promise.resolve();
}
if (!loadPromise) {
loadPromise = codec2ScriptPaths.reduce((chain, src) => chain.then(() => injectScript(src)), Promise.resolve());
}
return loadPromise;
}

View File

@@ -0,0 +1,35 @@
import GlobalEmitter from "./GlobalEmitter";
class DialogUtils {
static alert(message, type = "info") {
if (window.electron) {
// running inside electron, use ipc alert
window.electron.alert(message);
}
// always show toast as well (or instead of browser alert)
GlobalEmitter.emit("toast", { message, type });
}
static confirm(message) {
if (window.electron) {
// running inside electron, use ipc confirm
return window.electron.confirm(message);
} else {
// running inside normal browser, use browser alert
return window.confirm(message);
}
}
static async prompt(message) {
if (window.electron) {
// running inside electron, use ipc prompt
return await window.electron.prompt(message);
} else {
// running inside normal browser, use browser prompt
return window.prompt(message);
}
}
}
export default DialogUtils;

View File

@@ -0,0 +1,24 @@
class DownloadUtils {
static downloadFile(filename, blob) {
// create object url for blob
const objectUrl = URL.createObjectURL(blob);
// create hidden link element to download blob
const link = document.createElement("a");
link.href = objectUrl;
link.download = filename;
link.style.display = "none";
document.body.append(link);
// click link to download file in browser
link.click();
// link element is no longer needed
link.remove();
// revoke object url to clear memory
setTimeout(() => URL.revokeObjectURL(objectUrl), 10000);
}
}
export default DownloadUtils;

View File

@@ -0,0 +1,19 @@
class ElectronUtils {
static isElectron() {
return window.electron != null;
}
static relaunch() {
if (window.electron) {
window.electron.relaunch();
}
}
static showPathInFolder(path) {
if (window.electron) {
window.electron.showPathInFolder(path);
}
}
}
export default ElectronUtils;

View File

@@ -0,0 +1,24 @@
import mitt from "mitt";
class GlobalEmitter {
constructor() {
this.emitter = mitt();
}
// add event listener
on(event, handler) {
this.emitter.on(event, handler);
}
// remove event listener
off(event, handler) {
this.emitter.off(event, handler);
}
// emit event
emit(type, event) {
this.emitter.emit(type, event);
}
}
export default new GlobalEmitter();

View File

@@ -0,0 +1,8 @@
import { reactive } from "vue";
// global state
const globalState = reactive({
unreadConversationsCount: 0,
});
export default globalState;

View File

@@ -0,0 +1,720 @@
/**
* Micron Parser JavaScript implementation
*
* micron-parser.js is based on MicronParser.py from NomadNet:
* https://raw.githubusercontent.com/markqvist/NomadNet/refs/heads/master/nomadnet/ui/textui/MicronParser.py
*
* Documentation for the Micron markdown format can be found here:
* https://raw.githubusercontent.com/markqvist/NomadNet/refs/heads/master/nomadnet/ui/textui/Guide.py
*/
class MicronParser {
constructor(darkTheme = true) {
this.darkTheme = darkTheme;
this.DEFAULT_FG_DARK = "ddd";
this.DEFAULT_FG_LIGHT = "222";
this.DEFAULT_BG = "default";
this.SELECTED_STYLES = null;
this.STYLES_DARK = {
plain: { fg: this.DEFAULT_FG_DARK, bg: this.DEFAULT_BG, bold: false, underline: false, italic: false },
heading1: { fg: "222", bg: "bbb", bold: false, underline: false, italic: false },
heading2: { fg: "111", bg: "999", bold: false, underline: false, italic: false },
heading3: { fg: "000", bg: "777", bold: false, underline: false, italic: false },
};
this.STYLES_LIGHT = {
plain: { fg: this.DEFAULT_FG_LIGHT, bg: this.DEFAULT_BG, bold: false, underline: false, italic: false },
heading1: { fg: "000", bg: "777", bold: false, underline: false, italic: false },
heading2: { fg: "111", bg: "aaa", bold: false, underline: false, italic: false },
heading3: { fg: "222", bg: "ccc", bold: false, underline: false, italic: false },
};
if (this.darkTheme) {
this.SELECTED_STYLES = this.STYLES_DARK;
} else {
this.SELECTED_STYLES = this.STYLES_LIGHT;
}
}
static formatNomadnetworkUrl(url) {
return `nomadnetwork://${url}`;
}
convertMicronToHtml(markup) {
let html = "";
let state = {
literal: false,
depth: 0,
fg_color: this.SELECTED_STYLES.plain.fg,
bg_color: this.DEFAULT_BG,
formatting: {
bold: false,
underline: false,
italic: false,
strikethrough: false,
},
default_align: "left",
align: "left",
radio_groups: {},
};
const lines = markup.split("\n");
for (let line of lines) {
const lineOutput = this.parseLine(line, state);
if (lineOutput && lineOutput.length > 0) {
for (let el of lineOutput) {
html += el.outerHTML;
}
} else {
html += "<br>";
}
}
return html;
}
parseToHtml(markup) {
// Create a fragment to hold all the Micron output
const fragment = document.createDocumentFragment();
let state = {
literal: false,
depth: 0,
fg_color: this.SELECTED_STYLES.plain.fg,
bg_color: this.DEFAULT_BG,
formatting: {
bold: false,
underline: false,
italic: false,
strikethrough: false,
},
default_align: "left",
align: "left",
radio_groups: {},
};
const lines = markup.split("\n");
for (let line of lines) {
const lineOutput = this.parseLine(line, state);
if (lineOutput && lineOutput.length > 0) {
for (let el of lineOutput) {
fragment.appendChild(el);
}
} else {
fragment.appendChild(document.createElement("br"));
}
}
return fragment;
}
parseLine(line, state) {
if (line.length > 0) {
// Check literals toggle
if (line === "`=") {
state.literal = !state.literal;
return null;
}
if (!state.literal) {
// Comments
if (line[0] === "#") {
return null;
}
// Reset section depth
if (line[0] === "<") {
state.depth = 0;
return this.parseLine(line.slice(1), state);
}
// Section headings
if (line[0] === ">") {
let i = 0;
while (i < line.length && line[i] === ">") {
i++;
}
state.depth = i;
let headingLine = line.slice(i);
if (headingLine.length > 0) {
// apply heading style if it exists
let style = null;
let wanted_style = "heading" + i;
if (this.SELECTED_STYLES[wanted_style]) {
style = this.SELECTED_STYLES[wanted_style];
} else {
style = this.SELECTED_STYLES.plain;
}
const latched_style = this.stateToStyle(state);
this.styleToState(style, state);
let outputParts = this.makeOutput(state, headingLine);
this.styleToState(latched_style, state);
// wrap in a heading container
if (outputParts && outputParts.length > 0) {
const div = document.createElement("div");
this.applyAlignment(div, state);
this.applySectionIndent(div, state);
// merge text nodes
this.appendOutput(div, outputParts, state);
return [div];
} else {
return null;
}
} else {
return null;
}
}
// horizontal dividers
if (line[0] === "-") {
const hr = document.createElement("hr");
this.applySectionIndent(hr, state);
return [hr];
}
}
let outputParts = this.makeOutput(state, line);
if (outputParts) {
// outputParts can contain text (tuple) and special objects (fields/checkbox)
// construct a single line container
let container = document.createElement("div");
this.applyAlignment(container, state);
this.applySectionIndent(container, state);
this.appendOutput(container, outputParts, state);
return [container];
} else {
// Just empty line
return [document.createElement("br")];
}
} else {
// Empty line
return [document.createElement("br")];
}
}
applyAlignment(el, state) {
// use CSS text-align for alignment
el.style.textAlign = state.align || "left";
}
applySectionIndent(el, state) {
// indent by state.depth
let indent = (state.depth - 1) * 2;
if (indent > 0) {
el.style.marginLeft = indent * 10 + "px";
}
}
// convert current state to a style object
stateToStyle(state) {
return {
fg: state.fg_color,
bg: state.bg_color,
bold: state.formatting.bold,
underline: state.formatting.underline,
italic: state.formatting.italic,
};
}
styleToState(style, state) {
if (style.fg !== undefined && style.fg !== null) state.fg_color = style.fg;
if (style.bg !== undefined && style.bg !== null) state.bg_color = style.bg;
if (style.bold !== undefined && style.bold !== null) state.formatting.bold = style.bold;
if (style.underline !== undefined && style.underline !== null) state.formatting.underline = style.underline;
if (style.italic !== undefined && style.italic !== null) state.formatting.italic = style.italic;
}
appendOutput(container, parts) {
let currentSpan = null;
let currentStyle = null;
const flushSpan = () => {
if (currentSpan) {
container.appendChild(currentSpan);
currentSpan = null;
currentStyle = null;
}
};
for (let p of parts) {
if (typeof p === "string") {
let span = document.createElement("span");
span.textContent = p;
container.appendChild(span);
} else if (Array.isArray(p) && p.length === 2) {
// tuple: [styleSpec, text]
let [styleSpec, text] = p;
// if different style, flush currentSpan
if (!this.stylesEqual(styleSpec, currentStyle)) {
flushSpan();
currentSpan = document.createElement("span");
this.applyStyleToElement(currentSpan, styleSpec);
currentStyle = styleSpec;
}
currentSpan.textContent += text;
} else if (p && typeof p === "object") {
// field, checkbox, radio, link
flushSpan();
if (p.type === "field") {
let input = document.createElement("input");
input.type = p.masked ? "password" : "text";
input.name = p.name;
input.setAttribute("value", p.data);
if (p.width) {
input.size = p.width;
}
this.applyStyleToElement(input, this.styleFromState(p.style));
container.appendChild(input);
} else if (p.type === "checkbox") {
let label = document.createElement("label");
let cb = document.createElement("input");
cb.type = "checkbox";
cb.name = p.name;
cb.value = p.value;
if (p.prechecked) cb.setAttribute("checked", true);
label.appendChild(cb);
label.appendChild(document.createTextNode(" " + p.label));
this.applyStyleToElement(label, this.styleFromState(p.style));
container.appendChild(label);
} else if (p.type === "radio") {
let label = document.createElement("label");
let rb = document.createElement("input");
rb.type = "radio";
rb.name = p.name;
rb.value = p.value;
if (p.prechecked) rb.setAttribute("checked", true);
label.appendChild(rb);
label.appendChild(document.createTextNode(" " + p.label));
this.applyStyleToElement(label, this.styleFromState(p.style));
container.appendChild(label);
} else if (p.type === "link") {
let directURL = p.url.replace("nomadnetwork://", "").replace("lxmf://", "");
// use p.url as is for the href
const formattedUrl = p.url;
let a = document.createElement("a");
a.href = formattedUrl;
a.title = formattedUrl;
let fieldsToSubmit = [];
let requestVars = {};
let foundAll = false;
if (p.fields && p.fields.length > 0) {
for (const f of p.fields) {
if (f === "*") {
// submit all fields
foundAll = true;
} else if (f.includes("=")) {
// this is a request variable (key=value)
const [k, v] = f.split("=");
requestVars[k] = v;
} else {
// this is a field name to submit
fieldsToSubmit.push(f);
}
}
let fieldStr = "";
if (foundAll) {
// if '*' was found, submit all fields
fieldStr = "*";
} else {
fieldStr = fieldsToSubmit.join("|");
}
// append request variables directly to the directURL as query parameters
const varEntries = Object.entries(requestVars);
if (varEntries.length > 0) {
const queryString = varEntries.map(([k, v]) => `${k}=${v}`).join("|");
directURL += directURL.includes("`") ? `|${queryString}` : `\`${queryString}`;
}
a.setAttribute(
"onclick",
`event.preventDefault(); onNodePageUrlClick('${directURL}', '${fieldStr}', false, false)`
);
} else {
// no fields or request variables, just handle the direct URL
a.setAttribute(
"onclick",
`event.preventDefault(); onNodePageUrlClick('${directURL}', null, false, false)`
);
}
a.textContent = p.label;
this.applyStyleToElement(a, this.styleFromState(p.style));
container.appendChild(a);
}
}
}
flushSpan();
}
stylesEqual(s1, s2) {
if (!s1 && !s2) return true;
if (!s1 || !s2) return false;
return (
s1.fg === s2.fg &&
s1.bg === s2.bg &&
s1.bold === s2.bold &&
s1.underline === s2.underline &&
s1.italic === s2.italic
);
}
styleFromState(stateStyle) {
// stateStyle is a name of a style or a style object
// in this code, p.style is actually a style name. j,ust return that
return stateStyle;
}
applyStyleToElement(el, style) {
if (!style) return;
// convert style fg/bg to colors
let fgColor = this.colorToCss(style.fg);
let bgColor = this.colorToCss(style.bg);
if (fgColor && fgColor !== "default") {
el.style.color = fgColor;
}
if (bgColor && bgColor !== "default") {
el.style.backgroundColor = bgColor;
}
if (style.bold) {
el.style.fontWeight = "bold";
}
if (style.underline) {
el.style.textDecoration = el.style.textDecoration ? el.style.textDecoration + " underline" : "underline";
}
if (style.italic) {
el.style.fontStyle = "italic";
}
}
colorToCss(c) {
if (!c || c === "default") return null;
// if 3 hex chars (like '222') => expand to #222
if (c.length === 3 && /^[0-9a-fA-F]{3}$/.test(c)) {
return "#" + c;
}
// If 6 hex chars
if (c.length === 6 && /^[0-9a-fA-F]{6}$/.test(c)) {
return "#" + c;
}
// If grayscale 'gxx'
if (c.length === 3 && c[0] === "g") {
// treat xx as a number and map to gray
let val = parseInt(c.slice(1), 10);
if (isNaN(val)) val = 50;
// map 0-99 scale to a gray hex
let h = Math.floor(val * 2.55)
.toString(16)
.padStart(2, "0");
return "#" + h + h + h;
}
// fallback: just return a known CSS color or tailwind class if not known
return null;
}
makeOutput(state, line) {
if (state.literal) {
// literal mode: output as is, except if `= line
if (line === "\\`=") {
line = "`=";
}
return [[this.stateToStyle(state), line]];
}
let output = [];
let part = "";
let mode = "text";
let escape = false;
let skip = 0;
let i = 0;
while (i < line.length) {
let c = line[i];
if (skip > 0) {
skip--;
i++;
continue;
}
if (mode === "formatting") {
// Handle formatting commands
switch (c) {
case "_":
state.formatting.underline = !state.formatting.underline;
break;
case "!":
state.formatting.bold = !state.formatting.bold;
break;
case "*":
state.formatting.italic = !state.formatting.italic;
break;
case "F":
// next 3 chars = fg color
if (line.length >= i + 4) {
let color = line.substr(i + 1, 3);
state.fg_color = color;
skip = 3;
}
break;
case "f":
// reset fg
state.fg_color = this.SELECTED_STYLES.plain.fg;
break;
case "B":
if (line.length >= i + 4) {
let color = line.substr(i + 1, 3);
state.bg_color = color;
skip = 3;
}
break;
case "b":
// reset bg
state.bg_color = this.DEFAULT_BG;
break;
case "`":
// reset all formatting
state.formatting.bold = false;
state.formatting.underline = false;
state.formatting.italic = false;
state.fg_color = this.SELECTED_STYLES.plain.fg;
state.bg_color = this.DEFAULT_BG;
state.align = state.default_align;
break;
case "c":
state.align = state.align === "center" ? state.default_align : "center";
break;
case "l":
state.align = state.align === "left" ? state.default_align : "left";
break;
case "r":
state.align = state.align === "right" ? state.default_align : "right";
break;
case "a":
state.align = state.default_align;
break;
case "<":
// Flush current text first
if (part.length > 0) {
output.push([this.stateToStyle(state), part]);
part = "";
}
{
let fieldData = this.parseField(line, i, state);
if (fieldData) {
output.push(fieldData.obj);
i += fieldData.skip;
continue;
}
}
break;
case "[":
// flush current text first
if (part.length > 0) {
output.push([this.stateToStyle(state), part]);
part = "";
}
{
let linkData = this.parseLink(line, i, state);
if (linkData) {
output.push(linkData.obj);
// mode = "text";
i += linkData.skip;
continue;
}
}
break;
default:
// unknown formatting char, ignore
break;
}
mode = "text";
if (part.length > 0) {
// no flush needed, no text added
}
} else {
// mode === "text"
if (c === "\\") {
if (escape) {
// was escaped backslash
part += c;
escape = false;
} else {
escape = true;
}
} else if (c === "`") {
if (escape) {
// just a literal backtick
part += c;
escape = false;
} else {
// switch to formatting mode
if (part.length > 0) {
output.push([this.stateToStyle(state), part]);
part = "";
}
mode = "formatting";
}
} else {
if (escape) {
part += "\\";
escape = false;
}
part += c;
}
}
i++;
}
// end of line
if (part.length > 0) {
output.push([this.stateToStyle(state), part]);
}
return output.length > 0 ? output : null;
}
parseField(line, startIndex, state) {
let field_start = startIndex + 1;
let backtick_pos = line.indexOf("`", field_start);
if (backtick_pos === -1) return null;
let field_content = line.substring(field_start, backtick_pos);
let field_masked = false;
let field_width = 24;
let field_type = "field";
let field_name = field_content;
let field_value = "";
let field_prechecked = false;
if (field_content.includes("|")) {
let f_components = field_content.split("|");
let field_flags = f_components[0];
field_name = f_components[1];
if (field_flags.includes("^")) {
field_type = "radio";
field_flags = field_flags.replace("^", "");
} else if (field_flags.includes("?")) {
field_type = "checkbox";
field_flags = field_flags.replace("?", "");
} else if (field_flags.includes("!")) {
field_masked = true;
field_flags = field_flags.replace("!", "");
}
if (field_flags.length > 0) {
let w = parseInt(field_flags, 10);
if (!isNaN(w)) {
field_width = Math.min(w, 256);
}
}
if (f_components.length > 2) {
field_value = f_components[2];
}
if (f_components.length > 3) {
if (f_components[3] === "*") {
field_prechecked = true;
}
}
}
let field_end = line.indexOf(">", backtick_pos);
if (field_end === -1) return null;
let field_data = line.substring(backtick_pos + 1, field_end);
let style = this.stateToStyle(state);
let obj = null;
if (field_type === "checkbox" || field_type === "radio") {
obj = {
type: field_type,
name: field_name,
value: field_value || field_data,
label: field_data,
prechecked: field_prechecked,
style: style,
};
} else {
obj = {
type: "field",
name: field_name,
width: field_width,
masked: field_masked,
data: field_data,
style: style,
};
}
let skip = field_end - startIndex + 2;
return { obj: obj, skip: skip };
}
parseLink(line, startIndex, state) {
let endpos = line.indexOf("]", startIndex);
if (endpos === -1) return null;
let link_data = line.substring(startIndex + 1, endpos);
let link_components = link_data.split("`");
let link_label = "";
let link_url = "";
let link_fields = "";
if (link_components.length === 1) {
link_label = "";
link_url = link_data;
} else if (link_components.length === 2) {
link_label = link_components[0];
link_url = link_components[1];
} else if (link_components.length === 3) {
link_label = link_components[0];
link_url = link_components[1];
link_fields = link_components[2];
}
if (link_url.length === 0) {
return null;
}
if (link_label === "") {
link_label = link_url;
}
// format the URL
link_url = MicronParser.formatNomadnetworkUrl(link_url);
let style = this.stateToStyle(state);
let obj = {
type: "link",
url: link_url,
label: link_label,
fields: link_fields ? link_fields.split("|") : [],
style: style,
};
let skip = endpos - startIndex + 2;
return { obj: obj, skip: skip };
}
}
export default MicronParser;

View File

@@ -0,0 +1,64 @@
/**
* A simple class for recording microphone input and returning the audio.
*/
class MicrophoneRecorder {
constructor() {
this.audioChunks = [];
this.microphoneMediaStream = null;
this.mediaRecorder = null;
}
async start() {
try {
// request access to the microphone
this.microphoneMediaStream = await navigator.mediaDevices.getUserMedia({
audio: true,
});
// create media recorder
this.mediaRecorder = new MediaRecorder(this.microphoneMediaStream);
// handle received audio from media recorder
this.mediaRecorder.ondataavailable = (event) => {
this.audioChunks.push(event.data);
};
// start recording
this.mediaRecorder.start();
// successfully started recording
return true;
} catch {
return false;
}
}
async stop() {
return new Promise((resolve, reject) => {
try {
// handle media recording stopped
this.mediaRecorder.onstop = () => {
// stop using microphone
if (this.microphoneMediaStream) {
this.microphoneMediaStream.getTracks().forEach((track) => track.stop());
}
// create blob from audio chunks
const blob = new Blob(this.audioChunks, {
type: this.mediaRecorder.mimeType, // likely to be "audio/webm;codecs=opus" in chromium
});
// resolve promise
resolve(blob);
};
// stop recording
this.mediaRecorder.stop();
} catch (e) {
reject(e);
}
});
}
}
export default MicrophoneRecorder;

View File

@@ -0,0 +1,25 @@
class NotificationUtils {
static showIncomingCallNotification() {
Notification.requestPermission().then((result) => {
if (result === "granted") {
new window.Notification("Incoming Call", {
body: "Someone is calling you.",
tag: "incoming_telephone_call", // only ever show one incoming call notification at a time
});
}
});
}
static showNewMessageNotification() {
Notification.requestPermission().then((result) => {
if (result === "granted") {
new window.Notification("New Message", {
body: "Someone sent you a message.",
tag: "new_message", // only ever show one new message notification at a time
});
}
});
}
}
export default NotificationUtils;

View File

@@ -0,0 +1,68 @@
const DB_NAME = "meshchat_map_cache";
const DB_VERSION = 1;
const STORE_NAME = "tiles";
class TileCache {
constructor() {
this.db = null;
this.initPromise = this.init();
}
async init() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onerror = (event) => reject("IndexedDB error: " + event.target.errorCode);
request.onupgradeneeded = (event) => {
const db = event.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
};
request.onsuccess = (event) => {
this.db = event.target.result;
resolve();
};
});
}
async getTile(key) {
await this.initPromise;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], "readonly");
const store = transaction.objectStore(STORE_NAME);
const request = store.get(key);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
async setTile(key, data) {
await this.initPromise;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], "readwrite");
const store = transaction.objectStore(STORE_NAME);
const request = store.put(data, key);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
async clear() {
await this.initPromise;
return new Promise((resolve, reject) => {
const transaction = this.db.transaction([STORE_NAME], "readwrite");
const store = transaction.objectStore(STORE_NAME);
const request = store.clear();
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
});
}
}
export default new TileCache();

View File

@@ -0,0 +1,25 @@
import GlobalEmitter from "./GlobalEmitter";
class ToastUtils {
static show(message, type = "info", duration = 5000) {
GlobalEmitter.emit("toast", { message, type, duration });
}
static success(message, duration = 5000) {
this.show(message, "success", duration);
}
static error(message, duration = 5000) {
this.show(message, "error", duration);
}
static warning(message, duration = 5000) {
this.show(message, "warning", duration);
}
static info(message, duration = 5000) {
this.show(message, "info", duration);
}
}
export default ToastUtils;

View File

@@ -0,0 +1,185 @@
import dayjs from "dayjs";
class Utils {
static formatDestinationHash(destinationHashHex) {
const bytesPerSide = 4;
const leftSide = destinationHashHex.substring(0, bytesPerSide * 2);
const rightSide = destinationHashHex.substring(destinationHashHex.length - bytesPerSide * 2);
return `<${leftSide}...${rightSide}>`;
}
static formatBytes(bytes) {
if (bytes === 0) {
return "0 Bytes";
}
const k = 1024;
const decimals = 0;
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
}
static formatNumber(num) {
if (num === 0) {
return "0";
}
return num.toLocaleString();
}
static parseSeconds(secondsToFormat) {
secondsToFormat = Number(secondsToFormat);
var days = Math.floor(secondsToFormat / (3600 * 24));
var hours = Math.floor((secondsToFormat % (3600 * 24)) / 3600);
var minutes = Math.floor((secondsToFormat % 3600) / 60);
var seconds = Math.floor(secondsToFormat % 60);
return {
days: days,
hours: hours,
minutes: minutes,
seconds: seconds,
};
}
static formatSeconds(seconds) {
const parsedSeconds = this.parseSeconds(seconds);
if (parsedSeconds.days > 0) {
if (parsedSeconds.days === 1) {
return "1 day ago";
} else {
return parsedSeconds.days + " days ago";
}
}
if (parsedSeconds.hours > 0) {
if (parsedSeconds.hours === 1) {
return "1 hour ago";
} else {
return parsedSeconds.hours + " hours ago";
}
}
if (parsedSeconds.minutes > 0) {
if (parsedSeconds.minutes === 1) {
return "1 min ago";
} else {
return parsedSeconds.minutes + " mins ago";
}
}
if (parsedSeconds.seconds <= 1) {
return "a second ago";
} else {
return parsedSeconds.seconds + " seconds ago";
}
}
static formatTimeAgo(datetimeString) {
if (!datetimeString) return "unknown";
// ensure UTC if no timezone is provided
let dateString = datetimeString;
if (typeof dateString === "string" && !dateString.includes("Z") && !dateString.includes("+")) {
// SQLite CURRENT_TIMESTAMP format is YYYY-MM-DD HH:MM:SS
// Replace space with T and append Z for ISO format
dateString = dateString.replace(" ", "T") + "Z";
}
const millisecondsAgo = Date.now() - new Date(dateString).getTime();
const secondsAgo = Math.round(millisecondsAgo / 1000);
return this.formatSeconds(secondsAgo);
}
static formatSecondsAgo(seconds) {
const secondsAgo = Math.round(Date.now() / 1000 - seconds);
return this.formatSeconds(secondsAgo);
}
static formatMinutesSeconds(seconds) {
const parsedSeconds = this.parseSeconds(seconds);
const paddedMinutes = parsedSeconds.minutes.toString().padStart(2, "0");
const paddedSeconds = parsedSeconds.seconds.toString().padStart(2, "0");
return `${paddedMinutes}:${paddedSeconds}`;
}
static convertUnixMillisToLocalDateTimeString(unixTimestampInMilliseconds) {
return dayjs(unixTimestampInMilliseconds).format("YYYY-MM-DD hh:mm A");
}
static convertDateTimeToLocalDateTimeString(dateTime) {
return this.convertUnixMillisToLocalDateTimeString(dateTime.getTime());
}
static arrayBufferToBase64(arrayBuffer) {
var binary = "";
var bytes = new Uint8Array(arrayBuffer);
var len = bytes.byteLength;
for (var i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
static formatBitsPerSecond(bits) {
if (bits === 0) {
return "0 bps";
}
const k = 1000; // Use 1000 instead of 1024 for network speeds
const decimals = 0;
const sizes = ["bps", "kbps", "Mbps", "Gbps", "Tbps", "Pbps", "Ebps", "Zbps", "Ybps"];
const i = Math.floor(Math.log(bits) / Math.log(k));
return parseFloat((bits / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
}
static formatBytesPerSecond(bytesPerSecond) {
if (bytesPerSecond === 0 || bytesPerSecond == null) {
return "0 B/s";
}
const k = 1024;
const decimals = 1;
const sizes = ["B/s", "KB/s", "MB/s", "GB/s", "TB/s", "PB/s", "EB/s", "ZB/s", "YB/s"];
const i = Math.floor(Math.log(bytesPerSecond) / Math.log(k));
return parseFloat((bytesPerSecond / Math.pow(k, i)).toFixed(decimals)) + " " + sizes[i];
}
static formatFrequency(hz) {
if (hz === 0 || hz == null) {
return "0 Hz";
}
const k = 1000;
const sizes = ["Hz", "kHz", "MHz", "GHz", "THz", "PHz", "EHz", "ZHz", "YHz"];
const i = Math.floor(Math.log(hz) / Math.log(k));
return parseFloat(hz / Math.pow(k, i)) + " " + sizes[i];
}
static decodeBase64ToUtf8String(base64) {
// support for decoding base64 as a utf8 string to support emojis and cyrillic characters etc
return decodeURIComponent(
atob(base64)
.split("")
.map(function (c) {
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
})
.join("")
);
}
static isInterfaceEnabled(iface) {
const rawValue = iface.enabled ?? iface.interface_enabled;
const value = rawValue?.toString()?.toLowerCase();
return value === "on" || value === "yes" || value === "true";
}
}
export default Utils;

View File

@@ -0,0 +1,69 @@
import mitt from "mitt";
class WebSocketConnection {
constructor() {
this.emitter = mitt();
this.reconnect();
/**
* ping websocket server every 30 seconds
* this helps to prevent the underlying tcp connection from going stale when there's no traffic for a long time
*/
setInterval(() => {
this.ping();
}, 30000);
}
// add event listener
on(event, handler) {
this.emitter.on(event, handler);
}
// remove event listener
off(event, handler) {
this.emitter.off(event, handler);
}
// emit event
emit(type, event) {
this.emitter.emit(type, event);
}
reconnect() {
// connect to websocket
const wsUrl = location.origin.replace(/^https/, "wss").replace(/^http/, "ws") + "/ws";
this.ws = new WebSocket(wsUrl);
// auto reconnect when websocket closes
this.ws.addEventListener("close", () => {
setTimeout(() => {
this.reconnect();
}, 1000);
});
// emit data received from websocket
this.ws.onmessage = (message) => {
this.emit("message", message);
};
}
send(message) {
if (this.ws != null && this.ws.readyState === WebSocket.OPEN) {
this.ws.send(message);
}
}
ping() {
try {
this.send(
JSON.stringify({
type: "ping",
})
);
} catch {
// ignore error
}
}
}
export default new WebSocketConnection();

View File

@@ -0,0 +1,541 @@
{
"app": {
"name": "Reticulum MeshChatX",
"sync_messages": "Nachrichten synchronisieren",
"compose": "Verfassen",
"messages": "Nachrichten",
"nomad_network": "Nomad Network",
"map": "Karte",
"archives": "Archive",
"propagation_nodes": "Propagationsknoten",
"network_visualiser": "Netzwerk-Visualisierer",
"interfaces": "Schnittstellen",
"tools": "Werkzeuge",
"settings": "Einstellungen",
"about": "Über",
"my_identity": "Meine Identität",
"identity_hash": "Identitäts-Hash",
"lxmf_address": "LXMF-Adresse",
"announce": "Ankündigen",
"announce_now": "Jetzt ankündigen",
"last_announced": "Zuletzt angekündigt: {time}",
"last_announced_never": "Zuletzt angekündigt: Nie",
"display_name_placeholder": "Anzeigename",
"profile": "Profil",
"manage_identity": "Verwalten Sie Ihre Identität, Transportbeteiligung und LXMF-Standardeinstellungen.",
"theme": "Thema",
"theme_mode": "{mode}-Modus",
"transport": "Transport",
"enabled": "Aktiviert",
"disabled": "Deaktiviert",
"propagation": "Propagation",
"local_node_running": "Lokaler Knoten läuft",
"client_only": "Nur Client",
"copy": "Kopieren",
"appearance": "Erscheinungsbild",
"appearance_description": "Wechseln Sie jederzeit zwischen hellen und dunklen Voreinstellungen.",
"light_theme": "Helles Thema",
"dark_theme": "Dunkles Thema",
"live_preview": "Live-Vorschau wird sofort aktualisiert.",
"realtime": "Echtzeit",
"transport_mode": "Transport-Modus",
"transport_description": "Leiten Sie Pfade und Verkehr für nahegelegene Peers weiter.",
"enable_transport_mode": "Transport-Modus aktivieren",
"transport_toggle_description": "Ankündigungen weiterleiten, auf Pfadanfragen antworten und helfen, dass Ihr Mesh online bleibt.",
"requires_restart": "Erfordert einen Neustart nach dem Umschalten.",
"show_community_interfaces": "Community-Schnittstellen anzeigen",
"community_interfaces_description": "Community-gepflegte Voreinstellungen beim Hinzufügen neuer Schnittstellen anzeigen.",
"reliability": "Zuverlässigkeit",
"messages_description": "Steuern Sie, wie MeshChat fehlgeschlagene Zustellungen wiederholt oder eskaliert. Kontrollieren Sie das automatische Wiederholungsverhalten, die erneute Übertragung von Anhängen und Fallback-Mechanismen, um eine zuverlässige Nachrichtenzustellung über das Mesh-Netzwerk zu gewährleisten.",
"auto_resend_title": "Automatisch erneut senden, wenn Peer ankündigt",
"auto_resend_description": "Fehlgeschlagene Nachrichten werden automatisch erneut versucht, sobald das Ziel erneut sendet.",
"retry_attachments_title": "Wiederholungen mit Anhängen zulassen",
"retry_attachments_description": "Große Payloads werden ebenfalls wiederholt (nützlich, wenn beide Peers hohe Limits haben).",
"auto_fallback_title": "Automatisch auf Propagationsknoten ausweichen",
"auto_fallback_description": "Fehlgeschlagene direkte Zustellungen werden in Ihrem bevorzugten Propagationsknoten eingereiht.",
"inbound_stamp_cost": "Kosten für eingehende Nachrichtenstempel",
"inbound_stamp_description": "Erfordern Sie Proof-of-Work-Stempel für direkt an Sie gesendete Nachrichten. Höhere Werte erfordern mehr Rechenaufwand von den Sendern. Bereich: 1-254. Standard: 8.",
"browse_nodes": "Knoten durchsuchen",
"propagation_nodes_description": "Halten Sie Gespräche im Fluss, auch wenn Peers offline sind.",
"nodes_info_1": "Propagationsknoten halten Nachrichten sicher bereit, bis die Empfänger wieder synchronisieren.",
"nodes_info_2": "Knoten peeren untereinander, um verschlüsselte Payloads zu verteilen.",
"nodes_info_3": "Die meisten Knoten speichern Daten ca. 30 Tage lang und verwirfen dann nicht zugestellte Elemente.",
"run_local_node": "Einen lokalen Propagationsknoten betreiben",
"run_local_node_description": "MeshChat wird einen Knoten unter Verwendung dieses lokalen Ziel-Hashs ankündigen und warten.",
"preferred_propagation_node": "Bevorzugter Propagationsknoten",
"preferred_node_placeholder": "Ziel-Hash, z.B. a39610c89d18bb48c73e429582423c24",
"fallback_node_description": "Nachrichten weichen auf diesen Knoten aus, wenn die direkte Zustellung fehlschlägt.",
"auto_sync_interval": "Automatisches Synchronisierungsintervall",
"last_synced": "Zuletzt synchronisiert vor {time}.",
"last_synced_never": "Zuletzt synchronisiert: Nie.",
"propagation_stamp_cost": "Kosten für Propagationsknotenstempel",
"propagation_stamp_description": "Erfordern Sie Proof-of-Work-Stempel für über Ihren Knoten verbreitete Nachrichten. Höhere Werte erfordern mehr Rechenaufwand. Bereich: 13-254. Standard: 16. **Hinweis:** Eine Änderung erfordert den Neustart der App.",
"language": "Sprache",
"select_language": "Wählen Sie Ihre bevorzugte Sprache.",
"custom_fork_by": "Angepasster Fork von",
"open": "Öffnen",
"identity": "Identität",
"lxmf_address_hash": "LXMF-Adress-Hash",
"propagation_node_status": "Status des Propagationsknotens",
"last_sync": "Letzter Sync: vor {time}",
"last_sync_never": "Letzter Sync: Nie",
"syncing": "Synchronisierung...",
"synced": "Synchronisiert",
"not_synced": "Nicht synchronisiert",
"not_configured": "Nicht konfiguriert",
"toggle_source": "Quellcode umschalten",
"audio_calls": "Telefon",
"calls": "Anrufe",
"status": "Status",
"active_call": "Aktiver Anruf",
"incoming": "Eingehend",
"outgoing": "Ausgehend",
"call": "Anruf",
"calls_plural": "Anrufe",
"hop": "Hop",
"hops_plural": "Hops",
"hung_up_waiting": "Aufgelegt, warte auf Anruf...",
"view_incoming_calls": "Eingehende Anrufe anzeigen",
"hangup_all_calls": "Alle Anrufe beenden",
"clear_history": "Verlauf löschen",
"no_active_calls": "Keine aktiven Anrufe",
"incoming_call": "Eingehender Anruf...",
"outgoing_call": "Ausgehender Anruf...",
"call_active": "Aktiv",
"call_ended": "Beendet",
"propagation_node": "Propagationsknoten",
"sync_now": "Jetzt synchronisieren"
},
"common": {
"open": "Öffnen",
"cancel": "Abbrechen",
"save": "Speichern",
"delete": "Löschen",
"edit": "Bearbeiten",
"add": "Hinzufügen",
"sync": "Synchronisieren",
"restart_app": "App neu starten",
"reveal": "Anzeigen",
"refresh": "Aktualisieren",
"vacuum": "Vakuumieren",
"auto_recover": "Automatisch wiederherstellen"
},
"about": {
"title": "Über",
"version": "v{version}",
"rns_version": "RNS {version}",
"lxmf_version": "LXMF {version}",
"python_version": "Python {version}",
"config_path": "Konfigurationspfad",
"database_path": "Datenbankpfad",
"database_size": "Datenbankgröße",
"database_health": "Datenbank-Zustand",
"database_health_description": "Schnellprüfung, WAL-Optimierung und Wiederherstellungswerkzeuge für die MeshChatX-Datenbank.",
"running_checks": "Prüfungen werden ausgeführt...",
"integrity": "Integrität",
"journal_mode": "Journal-Modus",
"wal_autocheckpoint": "WAL Autocheckpoint",
"page_size": "Seitengröße",
"pages_free": "Seiten / Frei",
"free_space_estimate": "Geschätzter freier Speicherplatz",
"system_resources": "Systemressourcen",
"live": "Live",
"memory_rss": "Arbeitsspeicher (RSS)",
"virtual_memory": "Virtueller Speicher",
"network_stats": "Netzwerkstatistiken",
"sent": "Gesendet",
"received": "Empfangen",
"packets_sent": "Pakete gesendet",
"packets_received": "Pakete empfangen",
"reticulum_stats": "Reticulum-Statistiken",
"total_paths": "Gesamtpfade",
"announces_per_second": "Ankündigungen / Sek.",
"announces_per_minute": "Ankündigungen / Min.",
"announces_per_hour": "Ankündigungen / Std.",
"download_activity": "Download-Aktivität",
"no_downloads_yet": "Noch keine Downloads",
"runtime_status": "Laufzeitstatus",
"shared_instance": "Geteilte Instanz",
"standalone_instance": "Eigenständige Instanz",
"transport_enabled": "Transport aktiviert",
"transport_disabled": "Transport deaktiviert",
"identity_addresses": "Identität & Adressen",
"telephone_address": "Telefon-Adresse"
},
"interfaces": {
"title": "Schnittstellen",
"manage": "Verwalten",
"description": "Suchen, filtern und exportieren Sie Ihre Reticulum-Adapter.",
"add_interface": "Schnittstelle hinzufügen",
"import": "Importieren",
"export_all": "Alle exportieren",
"search_placeholder": "Suche nach Name, Typ, Host...",
"all": "Alle",
"all_types": "Alle Typen",
"no_interfaces_found": "Keine Schnittstellen gefunden",
"no_interfaces_description": "Passen Sie Ihre Suche an oder fügen Sie eine neue Schnittstelle hinzu.",
"restart_required": "Neustart erforderlich",
"restart_description": "Reticulum MeshChat muss neu gestartet werden, damit Schnittstellenänderungen wirksam werden.",
"restart_now": "Jetzt neu starten"
},
"map": {
"title": "Karte",
"description": "Offline-fähige Karte mit MBTiles-Unterstützung.",
"upload_mbtiles": "MBTiles hochladen",
"select_file": "MBTiles-Datei auswählen",
"offline_mode": "Offline-Modus",
"online_mode": "Online-Modus",
"attribution": "Attribution",
"bounds": "Grenzen",
"center": "Zentrum",
"zoom": "Zoom",
"uploading": "Wird hochgeladen...",
"upload_success": "Karte erfolgreich hochgeladen",
"upload_failed": "Hochladen der Karte fehlgeschlagen",
"no_map_loaded": "Keine Offline-Karte geladen. Laden Sie eine .mbtiles-Datei hoch, um den Offline-Modus zu aktivieren.",
"invalid_file": "Ungültige MBTiles-Datei. Nur Rasterkacheln werden unterstützt.",
"default_view": "Standardansicht",
"set_as_default": "Als Standardansicht festlegen",
"export_area": "Bereich exportieren",
"export_instructions": "Ziehen Sie auf der Karte, um einen Bereich auszuwählen.",
"min_zoom": "Min. Zoom",
"max_zoom": "Max. Zoom",
"tile_count": "Geschätzte Kacheln",
"start_export": "Export starten",
"exporting": "Karte wird exportiert...",
"download_ready": "Export abgeschlossen",
"download_now": "MBTiles herunterladen",
"caching_enabled": "Kachel-Caching",
"clear_cache": "Cache leeren",
"cache_cleared": "Kachel-Cache geleert",
"tile_server_url": "Kachel-Server-URL",
"tile_server_url_placeholder": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"tile_server_url_hint": "Verwenden Sie {z}, {x}, {y} für Zoom, Spalte, Zeile",
"tile_server_saved": "Kachel-Server-URL gespeichert",
"nominatim_api_url": "Nominatim API-URL",
"nominatim_api_url_placeholder": "https://nominatim.openstreetmap.org",
"nominatim_api_url_hint": "Basis-URL für den Nominatim-Geocoding-Service",
"nominatim_api_saved": "Nominatim API-URL gespeichert",
"search_placeholder": "Nach einem Ort suchen...",
"search_offline_error": "Suche ist nur im Online-Modus verfügbar",
"search_connection_error": "Verbindung zum Suchdienst fehlgeschlagen. Bitte überprüfen Sie Ihre Internetverbindung.",
"search_error": "Suchfehler",
"search_no_results": "Keine Ergebnisse gefunden",
"custom_tile_server_unavailable": "Benutzerdefinierter Kachelserver ist im Offline-Modus nicht erreichbar",
"custom_nominatim_unavailable": "Benutzerdefinierter Nominatim-Server ist im Offline-Modus nicht erreichbar",
"onboarding_title": "Als MBTiles exportieren!",
"onboarding_text": "Verwenden Sie das Export-Tool, um Kartenbereiche als MBTiles-Dateien für die Offline-Nutzung herunterzuladen.",
"onboarding_got_it": "Verstanden"
},
"interface": {
"disable": "Deaktivieren",
"enable": "Aktivieren",
"edit_interface": "Schnittstelle bearbeiten",
"export_interface": "Schnittstelle exportieren",
"delete_interface": "Schnittstelle löschen",
"listen": "Abhören",
"forward": "Weiterleiten",
"port": "Port",
"frequency": "Frequenz",
"bandwidth": "Bandbreite",
"txpower": "Sendeleistung (TX)",
"spreading_factor": "SF",
"coding_rate": "Kodierungsrate",
"bitrate": "Bitrate",
"tx": "TX",
"rx": "RX",
"noise": "Rauschen",
"clients": "Clients"
},
"messages": {
"title": "Nachrichten",
"conversations": "Gespräche",
"announces": "Ankündigungen",
"search_placeholder": "Suche in {count} Gesprächen...",
"unread": "Ungelesen",
"failed": "Fehlgeschlagen",
"attachments": "Anhänge",
"no_messages_yet": "Noch keine Nachrichten",
"loading_conversations": "Gespräche werden geladen...",
"no_conversations": "Keine Gespräche",
"discover_peers": "Entdecken Sie Peers auf dem Tab 'Ankündigungen'",
"no_search_results": "Keine Ergebnisse gefunden",
"try_another_search": "Versuchen Sie einen anderen Suchbegriff",
"no_search_results_conversations": "Ihre Suche ergab keine Treffer in den Gesprächen.",
"search_placeholder_announces": "Suche in {count} aktuellen Ankündigungen...",
"no_peers_discovered": "Keine Peers entdeckt",
"waiting_for_announce": "Warten auf Ankündigungen!",
"no_search_results_peers": "Ihre Suche ergab keine Treffer bei den Peers!",
"direct": "Direkt",
"hops": "{count} Hops",
"hops_away": "{count} Hops entfernt",
"snr": "SNR {snr}",
"stamp_cost": "Stempelkosten {cost}",
"pop_out_chat": "Chat auslagern",
"custom_display_name": "Benutzerdefinierter Anzeigename",
"send_placeholder": "Schreibe eine Nachricht...",
"no_messages_in_conversation": "Noch keine Nachrichten in diesem Gespräch.",
"say_hello": "Sag Hallo!",
"no_active_chat": "Kein aktiver Chat",
"select_peer_or_enter_address": "Wählen Sie einen Peer aus der Seitenleiste oder geben Sie unten eine Adresse ein",
"add_files": "Dateien hinzufügen",
"recording": "Aufnahme: {duration}",
"nomad_network_node": "Nomad Network Knoten",
"toggle_source": "Quellcode umschalten"
},
"nomadnet": {
"remove_favourite": "Favorit entfernen",
"add_favourite": "Favorit hinzufügen",
"page_archives": "Seitenarchive",
"archive_current_version": "Aktuelle Version archivieren",
"no_archives_for_this_page": "Keine Archive für diese Seite",
"viewing_archived_version_from": "Archivierte Version vom {time} anzeigen",
"viewing_archived_version": "Archivierte Version wird angezeigt",
"load_live": "Live laden",
"failed_to_load_page": "Seite konnte nicht geladen werden",
"archived_version_available": "Eine archivierte Version dieser Seite ist verfügbar.",
"view_archive": "Archiv anzeigen",
"no_active_node": "Kein aktiver Knoten",
"select_node_to_browse": "Wählen Sie einen Knoten aus, um mit dem Surfen zu beginnen!",
"open_nomadnet_url": "Eine Nomadnet-URL öffnen",
"unknown_node": "Unbekannter Knoten",
"existing_download_in_progress": "Ein bestehender Download ist im Gange. Bitte warten Sie, bis dieser abgeschlossen ist, bevor Sie einen weiteren Download starten.",
"favourites": "Favoriten",
"announces": "Ankündigungen",
"search_favourites_placeholder": "Suche in {count} Favoriten...",
"rename": "Umbenennen",
"remove": "Entfernen",
"no_favourites": "Keine Favoriten",
"add_nodes_from_announces": "Fügen Sie Knoten über den Tab 'Ankündigungen' hinzu.",
"search_announces": "Ankündigungen durchsuchen",
"announced_time_ago": "Vor {time} angekündigt",
"block_node": "Knoten blockieren",
"no_announces_yet": "Noch keine Ankündigungen",
"listening_for_peers": "Höre auf Peers im Mesh.",
"block_node_confirm": "Sind Sie sicher, dass Sie {name} blockieren möchten? Seine Ankündigungen werden ignoriert und er erscheint nicht mehr im Ankündigungs-Stream.",
"node_blocked_successfully": "Knoten erfolgreich blockiert",
"failed_to_block_node": "Knoten konnte nicht blockiert werden",
"rename_favourite": "Diesen Favoriten umbenennen",
"remove_favourite_confirm": "Sind Sie sicher, dass Sie diesen Favoriten entfernen möchten?",
"enter_nomadnet_url": "Nomadnet-URL eingeben",
"archiving_page": "Seite wird archiviert...",
"page_archived_successfully": "Seite erfolgreich archiviert.",
"identify_confirm": "Sind Sie sicher, dass Sie sich gegenüber diesem NomadNetwork-Knoten identifizieren möchten? Die Seite wird nach dem Senden Ihrer Identität neu geladen."
},
"forwarder": {
"title": "LXMF-Weiterleiter",
"description": "Nachrichten von einer Adresse zu einer anderen weiterleiten, mit transparenter Rückleitung. Wie SimpleLogin für LXMF.",
"add_rule": "Weiterleitungsregel hinzufügen",
"forward_to_hash": "Weiterleiten an Hash",
"destination_placeholder": "Ziel-LXMF-Hash...",
"source_filter": "Quellfilter (Optional)",
"source_filter_placeholder": "Nur von diesem Hash weiterleiten...",
"add_button": "Regel hinzufügen",
"active_rules": "Aktive Regeln",
"no_rules": "Keine Weiterleitungsregeln konfiguriert.",
"active": "Aktiv",
"disabled": "Deaktiviert",
"forwarding_to": "Weiterleitung an: {hash}",
"source_filter_display": "Quellfilter: {hash}",
"delete_confirm": "Sind Sie sicher, dass Sie diese Regel löschen möchten?"
},
"archives": {
"description": "Archivierte Nomad Network Seiten suchen und anzeigen",
"search_placeholder": "Suche nach Inhalt, Hash oder Pfad...",
"loading": "Archive werden geladen...",
"no_archives_found": "Keine Archive gefunden",
"adjust_filters": "Versuchen Sie, Ihre Suchfilter anzupassen.",
"browse_to_archive": "Archivierte Seiten erscheinen hier, sobald Sie Nomad Network Seiten besuchen.",
"page": "Seite",
"pages": "Seiten",
"view": "Anzeigen",
"showing_range": "Zeige {start} bis {end} von {total} Archiven",
"page_of": "Seite {page} von {total_pages}"
},
"tools": {
"utilities": "Dienstprogramme",
"power_tools": "Power-Tools für Betreiber",
"diagnostics_description": "Diagnose- und Firmware-Helfer werden mit MeshChat geliefert, damit Sie Peers ohne Verlassen der Konsole Fehler beheben können.",
"ping": {
"title": "Ping",
"description": "Latenztest für jeden LXMF-Ziel-Hash mit Live-Status."
},
"rnprobe": {
"title": "RNProbe",
"description": "Ziele mit benutzerdefinierten Paketgrößen sondieren, um die Konnektivität zu testen."
},
"rncp": {
"title": "RNCP",
"description": "Dateien über RNS mit Fortschrittsanzeige senden und empfangen."
},
"rnstatus": {
"title": "RNStatus",
"description": "Schnittstellenstatistiken und Netzwerkstatusinformationen anzeigen."
},
"translator": {
"title": "Übersetzer",
"description": "Text mit der LibreTranslate API oder lokalem Argos Translate übersetzen."
},
"forwarder": {
"title": "Weiterleiter",
"description": "LXMF-Weiterleitung im SimpleLogin-Stil mit Rückpfad-Routing."
},
"rnode_flasher": {
"title": "RNode Flasher",
"description": "RNode-Adapter flashen und aktualisieren, ohne die Kommandozeile zu berühren."
}
},
"ping": {
"title": "Mesh-Peers anpingen",
"description": "Nur {code}-Ziele antworten auf Ping.",
"destination_hash": "Ziel-Hash",
"timeout_seconds": "Ping-Timeout (Sekunden)",
"start_ping": "Ping starten",
"stop": "Stopp",
"clear_results": "Ergebnisse löschen",
"drop_path": "Pfad verwerfen",
"status": "Status",
"running": "Läuft",
"idle": "Leerlauf",
"last_rtt": "Letzte RTT",
"last_error": "Letzter Fehler",
"console_output": "Konsolenausgabe",
"streaming_responses": "Streaming von Seq-Antworten in Echtzeit",
"no_pings_yet": "Noch keine Pings. Starten Sie einen Durchlauf, um RTT-Daten zu sammeln.",
"invalid_hash": "Ungültiger Ziel-Hash!",
"timeout_must_be_number": "Timeout muss eine Zahl sein!"
},
"rncp": {
"file_transfer": "Dateiübertragung",
"title": "RNCP - Reticulum Network Copy",
"description": "Senden und Empfangen von Dateien über das Reticulum-Netzwerk unter Verwendung von RNS-Ressourcen.",
"send_file": "Datei senden",
"fetch_file": "Datei abrufen",
"listen": "Hören",
"destination_hash": "Ziel-Hash",
"file_path": "Dateipfad",
"timeout_seconds": "Timeout (Sekunden)",
"disable_compression": "Komprimierung deaktivieren",
"cancel": "Abbrechen",
"progress": "Fortschritt",
"invalid_hash": "Ungültiger Ziel-Hash!",
"provide_file_path": "Bitte geben Sie einen Dateipfad an!",
"file_sent_successfully": "Datei erfolgreich gesendet. Transfer-ID: {id}",
"failed_to_send": "Senden der Datei fehlgeschlagen",
"remote_file_path": "Remote-Dateipfad",
"save_path_optional": "Speicherpfad (optional)",
"save_path_placeholder": "Leer lassen für aktuelles Verzeichnis",
"allow_overwrite": "Überschreiben zulassen",
"provide_remote_file_path": "Bitte geben Sie einen Remote-Dateipfad an!",
"file_fetched_successfully": "Datei erfolgreich abgerufen. Gespeichert unter: {path}",
"failed_to_fetch": "Abrufen der Datei fehlgeschlagen",
"allowed_hashes": "Erlaubte Identitäts-Hashes (einer pro Zeile)",
"fetch_jail_path": "Abruf-Jail-Pfad (optional)",
"allow_fetch": "Abruf zulassen",
"start_listening": "Hören starten",
"stop_listening": "Hören stoppen",
"listening_on": "Hört auf:",
"provide_allowed_hash": "Bitte geben Sie mindestens einen erlaubten Identitäts-Hash an!",
"failed_to_start_listener": "Starten des Listeners fehlgeschlagen"
},
"rnprobe": {
"network_diagnostics": "Netzwerkdiagnose",
"title": "RNProbe - Ziel-Probe",
"description": "Ziele mit benutzerdefinierten Paketgrößen abfragen, um die Konnektivität zu testen und die RTT zu messen.",
"destination_hash": "Ziel-Hash",
"full_destination_name": "Vollständiger Zielname",
"probe_size_bytes": "Probe-Größe (Bytes)",
"number_of_probes": "Anzahl der Probes",
"wait_between_probes": "Warten zwischen Probes (Sekunden)",
"start_probe": "Probe starten",
"stop": "Stopp",
"clear_results": "Ergebnisse löschen",
"summary": "Zusammenfassung",
"sent": "Gesendet",
"delivered": "Zugestellt",
"timeouts": "Timeouts",
"failed": "Fehlgeschlagen",
"probe_results": "Probe-Ergebnisse",
"probe_responses_realtime": "Probe-Antworten in Echtzeit",
"no_probes_yet": "Noch keine Probes. Starten Sie eine Probe, um die Konnektivität zu testen.",
"probe_number": "Probe #{number}",
"bytes": "Bytes",
"hops": "Hops",
"rtt": "RTT",
"rssi": "RSSI",
"snr": "SNR",
"quality": "Qualität",
"timeout": "Timeout",
"invalid_hash": "Ungültiger Ziel-Hash!",
"provide_full_name": "Bitte geben Sie einen vollständigen Zielnamen an!",
"failed_to_probe": "Probe des Ziels fehlgeschlagen"
},
"rnstatus": {
"network_diagnostics": "Netzwerkdiagnose",
"title": "RNStatus - Netzwerkstatus",
"description": "Schnittstellenstatistiken und Netzwerkstatusinformationen anzeigen.",
"refresh": "Aktualisieren",
"include_link_stats": "Link-Statistiken einbeziehen",
"sort_by": "Sortieren nach:",
"none": "Keine",
"bitrate": "Bitrate",
"rx_bytes": "RX-Bytes",
"tx_bytes": "TX-Bytes",
"total_traffic": "Gesamtverkehr",
"announces": "Ankündigungen",
"active_links": "Aktive Links: {count}",
"no_interfaces_found": "Keine Schnittstellen gefunden. Klicken Sie auf Aktualisieren, um den Status zu laden.",
"mode": "Modus",
"rx_packets": "RX-Pakete",
"tx_packets": "TX-Pakete",
"clients": "Clients",
"peers_reachable": "erreichbar",
"noise_floor": "Rauschteppich",
"interference": "Interferenzen",
"cpu_load": "CPU-Last",
"cpu_temp": "CPU-Temp",
"memory_load": "Speicherlast",
"battery": "Batterie",
"network": "Netzwerk",
"incoming_announces": "Eingehende Ankündigungen",
"outgoing_announces": "Ausgehende Ankündigungen",
"airtime": "Airtime",
"channel_load": "Kanallast"
},
"translator": {
"text_translation": "Textübersetzung",
"title": "Übersetzer",
"description": "Text mit der LibreTranslate-API oder dem lokalen Argos Translate übersetzen.",
"argos_translate": "Argos Translate",
"libretranslate": "LibreTranslate",
"api_server": "LibreTranslate API-Server",
"api_server_description": "Geben Sie die Basis-URL Ihres LibreTranslate-Servers ein (z. B. http://localhost:5000)",
"source_language": "Quellsprache",
"auto_detect": "Automatisch erkennen",
"target_language": "Zielsprache",
"select_target_language": "Zielsprache auswählen",
"argos_not_detected": "Argos Translate nicht erkannt",
"argos_not_detected_desc": "Um die lokale Übersetzung zu verwenden, müssen Sie das Paket Argos Translate mit einer der folgenden Methoden installieren:",
"method_pip_venv": "Methode 1: pip (venv)",
"method_pipx": "Methode 2: pipx",
"note_restart_required": "Hinweis: Nach der Installation müssen Sie die Anwendung möglicherweise neu starten und Sprachpakete über die Argos Translate CLI installieren.",
"no_language_packages": "Keine Sprachpakete erkannt",
"no_language_packages_desc": "Argos Translate ist installiert, aber es sind keine Sprachpakete verfügbar. Installieren Sie Sprachpakete mit einem der folgenden Befehle:",
"install_all_languages": "Alle Sprachen installieren",
"install_specific_pair": "Bestimmtes Sprachpaar installieren (Beispiel: Englisch nach Deutsch)",
"after_install_note": "Klicken Sie nach der Installation der Sprachpakete auf „Sprachen aktualisieren“, um die verfügbaren Sprachen neu zu laden.",
"text_to_translate": "Zu übersetzender Text",
"enter_text_placeholder": "Text zum Übersetzen eingeben...",
"translate": "Übersetzen",
"swap": "Tauschen",
"clear": "Löschen",
"translation": "Übersetzung",
"source": "Quelle",
"detected": "Erkannt",
"available_languages": "Verfügbare Sprachen",
"languages_loaded_from": "Sprachen werden von der LibreTranslate-API oder Argos Translate-Paketen geladen.",
"refresh_languages": "Sprachen aktualisieren",
"failed_to_load_languages": "Sprachen konnten nicht geladen werden. Stellen Sie sicher, dass LibreTranslate ausgeführt wird oder Argos Translate installiert ist.",
"copied_to_clipboard": "In die Zwischenablage kopiert"
}
}

View File

@@ -0,0 +1,541 @@
{
"app": {
"name": "Reticulum MeshChatX",
"sync_messages": "Sync Messages",
"compose": "Compose",
"messages": "Messages",
"nomad_network": "Nomad Network",
"map": "Map",
"archives": "Archives",
"propagation_nodes": "Propagation Nodes",
"network_visualiser": "Network Visualiser",
"interfaces": "Interfaces",
"tools": "Tools",
"settings": "Settings",
"about": "About",
"my_identity": "My Identity",
"identity_hash": "Identity Hash",
"lxmf_address": "LXMF Address",
"announce": "Announce",
"announce_now": "Announce Now",
"last_announced": "Last announced: {time}",
"last_announced_never": "Last announced: Never",
"display_name_placeholder": "Display Name",
"profile": "Profile",
"manage_identity": "Manage your identity, transport participation and LXMF defaults.",
"theme": "Theme",
"theme_mode": "{mode} mode",
"transport": "Transport",
"enabled": "Enabled",
"disabled": "Disabled",
"propagation": "Propagation",
"local_node_running": "Local node running",
"client_only": "Client-only",
"copy": "Copy",
"appearance": "Appearance",
"appearance_description": "Switch between light and dark presets anytime.",
"light_theme": "Light Theme",
"dark_theme": "Dark Theme",
"live_preview": "Live preview updates instantly.",
"realtime": "Realtime",
"transport_mode": "Transport Mode",
"transport_description": "Relay paths and traffic for nearby peers.",
"enable_transport_mode": "Enable Transport Mode",
"transport_toggle_description": "Route announces, respond to path requests and help your mesh stay online.",
"requires_restart": "Requires restart after toggling.",
"show_community_interfaces": "Show Community Interfaces",
"community_interfaces_description": "Surface community-maintained presets while adding new interfaces.",
"reliability": "Reliability",
"messages_description": "Configure how MeshChat handles message delivery failures. Control automatic retry behavior, attachment retransmission, and fallback mechanisms to ensure reliable message delivery across the mesh network.",
"auto_resend_title": "Auto resend when peer announces",
"auto_resend_description": "Failed messages automatically retry once the destination broadcasts again.",
"retry_attachments_title": "Allow retries with attachments",
"retry_attachments_description": "Large payloads will also be retried (useful when both peers have high limits).",
"auto_fallback_title": "Auto fall back to propagation node",
"auto_fallback_description": "Failed direct deliveries are queued on your preferred propagation node.",
"inbound_stamp_cost": "Inbound Message Stamp Cost",
"inbound_stamp_description": "Require proof-of-work stamps for direct delivery messages sent to you. Higher values require more computational work from senders. Range: 1-254. Default: 8.",
"browse_nodes": "Browse Nodes",
"propagation_nodes_description": "Keep conversations flowing even when peers are offline.",
"nodes_info_1": "Propagation nodes hold messages securely until recipients sync again.",
"nodes_info_2": "Nodes peer with each other to distribute encrypted payloads.",
"nodes_info_3": "Most nodes retain data ~30 days, then discard undelivered items.",
"run_local_node": "Run a local propagation node",
"run_local_node_description": "MeshChat will announce and maintain a node using this local destination hash.",
"preferred_propagation_node": "Preferred Propagation Node",
"preferred_node_placeholder": "Destination hash, e.g. a39610c89d18bb48c73e429582423c24",
"fallback_node_description": "Messages fallback to this node whenever direct delivery fails.",
"auto_sync_interval": "Auto Sync Interval",
"last_synced": "Last synced {time} ago.",
"last_synced_never": "Last synced: never.",
"propagation_stamp_cost": "Propagation Node Stamp Cost",
"propagation_stamp_description": "Require proof-of-work stamps for messages propagated through your node. Higher values require more computational work. Range: 13-254. Default: 16. **Note:** Changing this requires restarting the app.",
"language": "Language",
"select_language": "Select your preferred language.",
"custom_fork_by": "Custom fork by",
"open": "Open",
"identity": "Identity",
"lxmf_address_hash": "LXMF Address Hash",
"propagation_node_status": "Propagation Node Status",
"last_sync": "Last Sync: {time} ago",
"last_sync_never": "Last Sync: never",
"syncing": "Syncing...",
"synced": "Synced",
"not_synced": "Not Synced",
"not_configured": "Not Configured",
"toggle_source": "Toggle Source Code",
"audio_calls": "Telephone",
"calls": "Calls",
"status": "Status",
"active_call": "Active Call",
"incoming": "Incoming",
"outgoing": "Outgoing",
"call": "Call",
"calls_plural": "Calls",
"hop": "hop",
"hops_plural": "hops",
"hung_up_waiting": "Hung up, waiting for call...",
"view_incoming_calls": "View Incoming Calls",
"hangup_all_calls": "Hangup all Calls",
"clear_history": "Clear History",
"no_active_calls": "No active calls",
"incoming_call": "Incoming call...",
"outgoing_call": "Outgoing call...",
"call_active": "Active",
"call_ended": "Ended",
"propagation_node": "Propagation Node",
"sync_now": "Sync Now"
},
"common": {
"open": "Open",
"cancel": "Cancel",
"save": "Save",
"delete": "Delete",
"edit": "Edit",
"add": "Add",
"sync": "Sync",
"restart_app": "Restart App",
"reveal": "Reveal",
"refresh": "Refresh",
"vacuum": "Vacuum",
"auto_recover": "Auto Recover"
},
"about": {
"title": "About",
"version": "v{version}",
"rns_version": "RNS {version}",
"lxmf_version": "LXMF {version}",
"python_version": "Python {version}",
"config_path": "Config path",
"database_path": "Database path",
"database_size": "Database size",
"database_health": "Database Health",
"database_health_description": "Quick check, WAL tuning, and recovery tools for the MeshChatX database.",
"running_checks": "Running checks...",
"integrity": "Integrity",
"journal_mode": "Journal mode",
"wal_autocheckpoint": "WAL autocheckpoint",
"page_size": "Page size",
"pages_free": "Pages / Free",
"free_space_estimate": "Free space estimate",
"system_resources": "System Resources",
"live": "Live",
"memory_rss": "Memory (RSS)",
"virtual_memory": "Virtual Memory",
"network_stats": "Network Stats",
"sent": "Sent",
"received": "Received",
"packets_sent": "Packets Sent",
"packets_received": "Packets Received",
"reticulum_stats": "Reticulum Stats",
"total_paths": "Total Paths",
"announces_per_second": "Announces / sec",
"announces_per_minute": "Announces / min",
"announces_per_hour": "Announces / hr",
"download_activity": "Download Activity",
"no_downloads_yet": "No downloads yet",
"runtime_status": "Runtime Status",
"shared_instance": "Shared Instance",
"standalone_instance": "Standalone Instance",
"transport_enabled": "Transport Enabled",
"transport_disabled": "Transport Disabled",
"identity_addresses": "Identity & Addresses",
"telephone_address": "Telephone Address"
},
"interfaces": {
"title": "Interfaces",
"manage": "Manage",
"description": "Search, filter and export your Reticulum adapters.",
"add_interface": "Add Interface",
"import": "Import",
"export_all": "Export all",
"search_placeholder": "Search by name, type, host...",
"all": "All",
"all_types": "All types",
"no_interfaces_found": "No interfaces found",
"no_interfaces_description": "Adjust your search or add a new interface.",
"restart_required": "Restart required",
"restart_description": "Reticulum MeshChat must be restarted for any interface changes to take effect.",
"restart_now": "Restart now"
},
"map": {
"title": "Map",
"description": "Offline-capable map with MBTiles support.",
"upload_mbtiles": "Upload MBTiles",
"select_file": "Select MBTiles file",
"offline_mode": "Offline Mode",
"online_mode": "Online Mode",
"attribution": "Attribution",
"bounds": "Bounds",
"center": "Center",
"zoom": "Zoom",
"uploading": "Uploading...",
"upload_success": "Map uploaded successfully",
"upload_failed": "Failed to upload map",
"no_map_loaded": "No offline map loaded. Upload an .mbtiles file to enable offline mode.",
"invalid_file": "Invalid MBTiles file. Only raster tiles are supported.",
"default_view": "Default View",
"set_as_default": "Set as Default View",
"export_area": "Export Area",
"export_instructions": "Drag on the map to select an area.",
"min_zoom": "Min Zoom",
"max_zoom": "Max Zoom",
"tile_count": "Estimated Tiles",
"start_export": "Start Export",
"exporting": "Exporting Map...",
"download_ready": "Export Complete",
"download_now": "Download MBTiles",
"caching_enabled": "Tile Caching",
"clear_cache": "Clear Cache",
"cache_cleared": "Tile cache cleared",
"tile_server_url": "Tile Server URL",
"tile_server_url_placeholder": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"tile_server_url_hint": "Use {z}, {x}, {y} for zoom, column, row",
"tile_server_saved": "Tile server URL saved",
"nominatim_api_url": "Nominatim API URL",
"nominatim_api_url_placeholder": "https://nominatim.openstreetmap.org",
"nominatim_api_url_hint": "Base URL for Nominatim geocoding service",
"nominatim_api_saved": "Nominatim API URL saved",
"search_placeholder": "Search for a location...",
"search_offline_error": "Search is only available in online mode",
"search_connection_error": "Failed to connect to search service. Please check your internet connection.",
"search_error": "Search error",
"search_no_results": "No results found",
"custom_tile_server_unavailable": "Custom tile server is not accessible in offline mode",
"custom_nominatim_unavailable": "Custom Nominatim server is not accessible in offline mode",
"onboarding_title": "Export to MBTiles!",
"onboarding_text": "Use the export tool to download map areas as MBTiles files for offline use.",
"onboarding_got_it": "Got it"
},
"interface": {
"disable": "Disable",
"enable": "Enable",
"edit_interface": "Edit Interface",
"export_interface": "Export Interface",
"delete_interface": "Delete Interface",
"listen": "Listen",
"forward": "Forward",
"port": "Port",
"frequency": "Frequency",
"bandwidth": "Bandwidth",
"txpower": "TX Power",
"spreading_factor": "SF",
"coding_rate": "Coding Rate",
"bitrate": "Bitrate",
"tx": "TX",
"rx": "RX",
"noise": "Noise",
"clients": "Clients"
},
"messages": {
"title": "Messages",
"conversations": "Conversations",
"announces": "Announces",
"search_placeholder": "Search {count} conversations...",
"unread": "Unread",
"failed": "Failed",
"attachments": "Attachments",
"no_messages_yet": "No messages yet",
"loading_conversations": "Loading conversations...",
"no_conversations": "No Conversations",
"discover_peers": "Discover peers on the Announces tab",
"no_search_results": "No Results Found",
"try_another_search": "Try a different search term",
"no_search_results_conversations": "Your search didn't match any conversations.",
"search_placeholder_announces": "Search {count} recent announces...",
"no_peers_discovered": "No Peers Discovered",
"waiting_for_announce": "Waiting for someone to announce!",
"no_search_results_peers": "Your search didn't match any Peers!",
"direct": "Direct",
"hops": "{count} hops",
"hops_away": "{count} hops away",
"snr": "SNR {snr}",
"stamp_cost": "Stamp Cost {cost}",
"pop_out_chat": "Pop out chat",
"custom_display_name": "Custom Display Name",
"send_placeholder": "Type a message...",
"no_messages_in_conversation": "No messages in this conversation yet.",
"say_hello": "Say hello!",
"no_active_chat": "No Active Chat",
"select_peer_or_enter_address": "Select a peer from the sidebar or enter an address below",
"add_files": "Add Files",
"recording": "Recording: {duration}",
"nomad_network_node": "Nomad Network Node",
"toggle_source": "Toggle Source Code"
},
"nomadnet": {
"remove_favourite": "Remove Favourite",
"add_favourite": "Add Favourite",
"page_archives": "Page Archives",
"archive_current_version": "Archive Current Version",
"no_archives_for_this_page": "No archives for this page",
"viewing_archived_version_from": "Viewing archived version from {time}",
"viewing_archived_version": "Viewing archived version",
"load_live": "Load Live",
"failed_to_load_page": "Failed to load page",
"archived_version_available": "An archived version of this page is available.",
"view_archive": "View Archive",
"no_active_node": "No Active Node",
"select_node_to_browse": "Select a Node to start browsing!",
"open_nomadnet_url": "Open a Nomadnet URL",
"unknown_node": "Unknown Node",
"existing_download_in_progress": "An existing download is in progress. Please wait for it to finish before starting another download.",
"favourites": "Favourites",
"announces": "Announces",
"search_favourites_placeholder": "Search {count} favourites...",
"rename": "Rename",
"remove": "Remove",
"no_favourites": "No favourites",
"add_nodes_from_announces": "Add nodes from the announces tab.",
"search_announces": "Search announces",
"announced_time_ago": "Announced {time} ago",
"block_node": "Block Node",
"no_announces_yet": "No announces yet",
"listening_for_peers": "Listening for peers on the mesh.",
"block_node_confirm": "Are you sure you want to block {name}? Their announces will be ignored and they won't appear in the announce stream.",
"node_blocked_successfully": "Node blocked successfully",
"failed_to_block_node": "Failed to block node",
"rename_favourite": "Rename this favourite",
"remove_favourite_confirm": "Are you sure you want to remove this favourite?",
"enter_nomadnet_url": "Enter a Nomadnet URL",
"archiving_page": "Archiving page...",
"page_archived_successfully": "Page archived successfully.",
"identify_confirm": "Are you sure you want to identify yourself to this NomadNetwork Node? The page will reload after your identity has been sent."
},
"forwarder": {
"title": "LXMF Forwarder",
"description": "Forward messages from one address to another, with transparent return routing. Like SimpleLogin for LXMF.",
"add_rule": "Add Forwarding Rule",
"forward_to_hash": "Forward to Hash",
"destination_placeholder": "Destination LXMF hash...",
"source_filter": "Source Filter (Optional)",
"source_filter_placeholder": "Only forward from this hash...",
"add_button": "Add Rule",
"active_rules": "Active Rules",
"no_rules": "No forwarding rules configured.",
"active": "Active",
"disabled": "Disabled",
"forwarding_to": "Forwarding to: {hash}",
"source_filter_display": "Source filter: {hash}",
"delete_confirm": "Are you sure you want to delete this rule?"
},
"archives": {
"description": "Search and view archived Nomad Network pages",
"search_placeholder": "Search content, hash or path...",
"loading": "Loading archives...",
"no_archives_found": "No archives found",
"adjust_filters": "Try adjusting your search filters.",
"browse_to_archive": "Archived pages will appear here once you browse Nomad Network sites.",
"page": "Page",
"pages": "Pages",
"view": "View",
"showing_range": "Showing {start} to {end} of {total} archives",
"page_of": "Page {page} of {total_pages}"
},
"tools": {
"utilities": "Utilities",
"power_tools": "Power tools for operators",
"diagnostics_description": "Diagnostics and firmware helpers ship with MeshChat so you can troubleshoot peers without leaving the console.",
"ping": {
"title": "Ping",
"description": "Latency test for any LXMF destination hash with live status."
},
"rnprobe": {
"title": "RNProbe",
"description": "Probe destinations with custom packet sizes to test connectivity."
},
"rncp": {
"title": "RNCP",
"description": "Send and receive files over RNS with progress tracking."
},
"rnstatus": {
"title": "RNStatus",
"description": "View interface statistics and network status information."
},
"translator": {
"title": "Translator",
"description": "Translate text using LibreTranslate API or local Argos Translate."
},
"forwarder": {
"title": "Forwarder",
"description": "SimpleLogin-style LXMF forwarding with return path routing."
},
"rnode_flasher": {
"title": "RNode Flasher",
"description": "Flash and update RNode adapters without touching the command line."
}
},
"ping": {
"title": "Ping Mesh Peers",
"description": "Only {code} destinations respond to ping.",
"destination_hash": "Destination Hash",
"timeout_seconds": "Ping Timeout (seconds)",
"start_ping": "Start Ping",
"stop": "Stop",
"clear_results": "Clear Results",
"drop_path": "Drop Path",
"status": "Status",
"running": "Running",
"idle": "Idle",
"last_rtt": "Last RTT",
"last_error": "Last Error",
"console_output": "Console Output",
"streaming_responses": "Streaming seq responses in real time",
"no_pings_yet": "No pings yet. Start a run to collect RTT data.",
"invalid_hash": "Invalid destination hash!",
"timeout_must_be_number": "Timeout must be a number!"
},
"rncp": {
"file_transfer": "File Transfer",
"title": "RNCP - Reticulum Network Copy",
"description": "Send and receive files over the Reticulum network using RNS resources.",
"send_file": "Send File",
"fetch_file": "Fetch File",
"listen": "Listen",
"destination_hash": "Destination Hash",
"file_path": "File Path",
"timeout_seconds": "Timeout (seconds)",
"disable_compression": "Disable compression",
"cancel": "Cancel",
"progress": "Progress",
"invalid_hash": "Invalid destination hash!",
"provide_file_path": "Please provide a file path!",
"file_sent_successfully": "File sent successfully. Transfer ID: {id}",
"failed_to_send": "Failed to send file",
"remote_file_path": "Remote File Path",
"save_path_optional": "Save Path (optional)",
"save_path_placeholder": "Leave empty for current directory",
"allow_overwrite": "Allow overwrite",
"provide_remote_file_path": "Please provide a remote file path!",
"file_fetched_successfully": "File fetched successfully. Saved to: {path}",
"failed_to_fetch": "Failed to fetch file",
"allowed_hashes": "Allowed Identity Hashes (one per line)",
"fetch_jail_path": "Fetch Jail Path (optional)",
"allow_fetch": "Allow fetch",
"start_listening": "Start Listening",
"stop_listening": "Stop Listening",
"listening_on": "Listening on:",
"provide_allowed_hash": "Please provide at least one allowed identity hash!",
"failed_to_start_listener": "Failed to start listener"
},
"rnprobe": {
"network_diagnostics": "Network Diagnostics",
"title": "RNProbe - Destination Probe",
"description": "Probe destinations with custom packet sizes to test connectivity and measure RTT.",
"destination_hash": "Destination Hash",
"full_destination_name": "Full Destination Name",
"probe_size_bytes": "Probe Size (bytes)",
"number_of_probes": "Number of Probes",
"wait_between_probes": "Wait Between Probes (seconds)",
"start_probe": "Start Probe",
"stop": "Stop",
"clear_results": "Clear Results",
"summary": "Summary",
"sent": "Sent",
"delivered": "Delivered",
"timeouts": "Timeouts",
"failed": "Failed",
"probe_results": "Probe Results",
"probe_responses_realtime": "Probe responses in real time",
"no_probes_yet": "No probes yet. Start a probe to test connectivity.",
"probe_number": "Probe #{number}",
"bytes": "bytes",
"hops": "Hops",
"rtt": "RTT",
"rssi": "RSSI",
"snr": "SNR",
"quality": "Quality",
"timeout": "Timeout",
"invalid_hash": "Invalid destination hash!",
"provide_full_name": "Please provide a full destination name!",
"failed_to_probe": "Failed to probe destination"
},
"rnstatus": {
"network_diagnostics": "Network Diagnostics",
"title": "RNStatus - Network Status",
"description": "View interface statistics and network status information.",
"refresh": "Refresh",
"include_link_stats": "Include Link Stats",
"sort_by": "Sort by:",
"none": "None",
"bitrate": "Bitrate",
"rx_bytes": "RX Bytes",
"tx_bytes": "TX Bytes",
"total_traffic": "Total Traffic",
"announces": "Announces",
"active_links": "Active Links: {count}",
"no_interfaces_found": "No interfaces found. Click refresh to load status.",
"mode": "Mode",
"rx_packets": "RX Packets",
"tx_packets": "TX Packets",
"clients": "Clients",
"peers_reachable": "reachable",
"noise_floor": "Noise Floor",
"interference": "Interference",
"cpu_load": "CPU Load",
"cpu_temp": "CPU Temp",
"memory_load": "Memory Load",
"battery": "Battery",
"network": "Network",
"incoming_announces": "Incoming Announces",
"outgoing_announces": "Outgoing Announces",
"airtime": "Airtime",
"channel_load": "Channel Load"
},
"translator": {
"text_translation": "Text Translation",
"title": "Translator",
"description": "Translate text using LibreTranslate API or local Argos Translate.",
"argos_translate": "Argos Translate",
"libretranslate": "LibreTranslate",
"api_server": "LibreTranslate API Server",
"api_server_description": "Enter the base URL of your LibreTranslate server (e.g., http://localhost:5000)",
"source_language": "Source Language",
"auto_detect": "Auto-detect",
"target_language": "Target Language",
"select_target_language": "Select target language",
"argos_not_detected": "Argos Translate not detected",
"argos_not_detected_desc": "To use local translation, you must install the Argos Translate package using one of the following methods:",
"method_pip_venv": "Method 1: pip (venv)",
"method_pipx": "Method 2: pipx",
"note_restart_required": "Note: After installation, you may need to restart the application and install language packages via the Argos Translate CLI.",
"no_language_packages": "No language packages detected",
"no_language_packages_desc": "Argos Translate is installed but no language packages are available. Install language packages using one of the following commands:",
"install_all_languages": "Install all languages",
"install_specific_pair": "Install specific language pair (example: English to German)",
"after_install_note": "After installing language packages, click \"Refresh Languages\" to reload available languages.",
"text_to_translate": "Text to Translate",
"enter_text_placeholder": "Enter text to translate...",
"translate": "Translate",
"swap": "Swap",
"clear": "Clear",
"translation": "Translation",
"source": "Source",
"detected": "Detected",
"available_languages": "Available Languages",
"languages_loaded_from": "Languages are loaded from LibreTranslate API or Argos Translate packages.",
"refresh_languages": "Refresh Languages",
"failed_to_load_languages": "Failed to load languages. Make sure LibreTranslate is running or Argos Translate is installed.",
"copied_to_clipboard": "Copied to clipboard"
}
}

Some files were not shown because too many files have changed in this diff Show More