0.1.0
This commit is contained in:
3
meshchatx/__init__.py
Normal file
3
meshchatx/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""Reticulum MeshChatX - A mesh network communications app."""
|
||||
|
||||
__version__ = "2.50.0"
|
||||
7025
meshchatx/meshchat.py
Normal file
7025
meshchatx/meshchat.py
Normal file
File diff suppressed because it is too large
Load Diff
29
meshchatx/src/__init__.py
Normal file
29
meshchatx/src/__init__.py
Normal 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)
|
||||
1
meshchatx/src/backend/__init__.py
Normal file
1
meshchatx/src/backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Backend utilities shared by the Reticulum MeshChatX CLI."""
|
||||
27
meshchatx/src/backend/announce_handler.py
Normal file
27
meshchatx/src/backend/announce_handler.py
Normal 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}")
|
||||
59
meshchatx/src/backend/announce_manager.py
Normal file
59
meshchatx/src/backend/announce_manager.py
Normal 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)
|
||||
|
||||
44
meshchatx/src/backend/archiver_manager.py
Normal file
44
meshchatx/src/backend/archiver_manager.py
Normal 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
|
||||
|
||||
23
meshchatx/src/backend/async_utils.py
Normal file
23
meshchatx/src/backend/async_utils.py
Normal 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.")
|
||||
8
meshchatx/src/backend/colour_utils.py
Normal file
8
meshchatx/src/backend/colour_utils.py
Normal 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)
|
||||
131
meshchatx/src/backend/config_manager.py
Normal file
131
meshchatx/src/backend/config_manager.py
Normal 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))
|
||||
|
||||
35
meshchatx/src/backend/database/__init__.py
Normal file
35
meshchatx/src/backend/database/__init__.py
Normal 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()
|
||||
|
||||
90
meshchatx/src/backend/database/announces.py
Normal file
90
meshchatx/src/backend/database/announces.py
Normal 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,))
|
||||
|
||||
27
meshchatx/src/backend/database/config.py
Normal file
27
meshchatx/src/backend/database/config.py
Normal 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,))
|
||||
|
||||
126
meshchatx/src/backend/database/legacy_migrator.py
Normal file
126
meshchatx/src/backend/database/legacy_migrator.py
Normal 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
|
||||
146
meshchatx/src/backend/database/messages.py
Normal file
146
meshchatx/src/backend/database/messages.py
Normal 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")
|
||||
|
||||
154
meshchatx/src/backend/database/misc.py
Normal file
154
meshchatx/src/backend/database/misc.py
Normal 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,))
|
||||
|
||||
65
meshchatx/src/backend/database/provider.py
Normal file
65
meshchatx/src/backend/database/provider.py
Normal 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)")
|
||||
|
||||
317
meshchatx/src/backend/database/schema.py
Normal file
317
meshchatx/src/backend/database/schema.py
Normal 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)))
|
||||
|
||||
44
meshchatx/src/backend/database/telephone.py
Normal file
44
meshchatx/src/backend/database/telephone.py
Normal 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,),
|
||||
)
|
||||
|
||||
48
meshchatx/src/backend/forwarding_manager.py
Normal file
48
meshchatx/src/backend/forwarding_manager.py
Normal 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
|
||||
|
||||
91
meshchatx/src/backend/interface_config_parser.py
Normal file
91
meshchatx/src/backend/interface_config_parser.py
Normal 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
|
||||
11
meshchatx/src/backend/interface_editor.py
Normal file
11
meshchatx/src/backend/interface_editor.py
Normal 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)
|
||||
136
meshchatx/src/backend/interfaces/WebsocketClientInterface.py
Normal file
136
meshchatx/src/backend/interfaces/WebsocketClientInterface.py
Normal 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
|
||||
165
meshchatx/src/backend/interfaces/WebsocketServerInterface.py
Normal file
165
meshchatx/src/backend/interfaces/WebsocketServerInterface.py
Normal 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
|
||||
1
meshchatx/src/backend/interfaces/__init__.py
Normal file
1
meshchatx/src/backend/interfaces/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Shared transport interfaces for MeshChatX."""
|
||||
25
meshchatx/src/backend/lxmf_message_fields.py
Normal file
25
meshchatx/src/backend/lxmf_message_fields.py
Normal 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
|
||||
249
meshchatx/src/backend/map_manager.py
Normal file
249
meshchatx/src/backend/map_manager.py
Normal 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
|
||||
66
meshchatx/src/backend/message_handler.py
Normal file
66
meshchatx/src/backend/message_handler.py
Normal 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)
|
||||
|
||||
421
meshchatx/src/backend/rncp_handler.py
Normal file
421
meshchatx/src/backend/rncp_handler.py
Normal 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
|
||||
|
||||
137
meshchatx/src/backend/rnprobe_handler.py
Normal file
137
meshchatx/src/backend/rnprobe_handler.py
Normal 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"),
|
||||
}
|
||||
|
||||
184
meshchatx/src/backend/rnstatus_handler.py
Normal file
184
meshchatx/src/backend/rnstatus_handler.py
Normal 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(),
|
||||
}
|
||||
|
||||
3
meshchatx/src/backend/sideband_commands.py
Normal file
3
meshchatx/src/backend/sideband_commands.py
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://github.com/markqvist/Sideband/blob/e515889e210037f881c201e0d627a7b09a48eb69/sbapp/sideband/sense.py#L11
|
||||
class SidebandCommands:
|
||||
TELEMETRY_REQUEST = 0x01
|
||||
95
meshchatx/src/backend/telephone_manager.py
Normal file
95
meshchatx/src/backend/telephone_manager.py
Normal 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
|
||||
|
||||
363
meshchatx/src/backend/translator_handler.py
Normal file
363
meshchatx/src/backend/translator_handler.py
Normal 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)
|
||||
731
meshchatx/src/frontend/components/App.vue
Normal file
731
meshchatx/src/frontend/components/App.vue
Normal 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>
|
||||
152
meshchatx/src/frontend/components/CardStack.vue
Normal file
152
meshchatx/src/frontend/components/CardStack.vue
Normal 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>
|
||||
94
meshchatx/src/frontend/components/ColourPickerDropdown.vue
Normal file
94
meshchatx/src/frontend/components/ColourPickerDropdown.vue
Normal 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>
|
||||
101
meshchatx/src/frontend/components/DropDownMenu.vue
Normal file
101
meshchatx/src/frontend/components/DropDownMenu.vue
Normal 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>
|
||||
13
meshchatx/src/frontend/components/DropDownMenuItem.vue
Normal file
13
meshchatx/src/frontend/components/DropDownMenuItem.vue
Normal 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>
|
||||
14
meshchatx/src/frontend/components/IconButton.vue
Normal file
14
meshchatx/src/frontend/components/IconButton.vue
Normal 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>
|
||||
95
meshchatx/src/frontend/components/LanguageSelector.vue
Normal file
95
meshchatx/src/frontend/components/LanguageSelector.vue
Normal 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>
|
||||
36
meshchatx/src/frontend/components/LxmfUserIcon.vue
Normal file
36
meshchatx/src/frontend/components/LxmfUserIcon.vue
Normal 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>
|
||||
47
meshchatx/src/frontend/components/MaterialDesignIcon.vue
Normal file
47
meshchatx/src/frontend/components/MaterialDesignIcon.vue
Normal 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>
|
||||
237
meshchatx/src/frontend/components/NotificationBell.vue
Normal file
237
meshchatx/src/frontend/components/NotificationBell.vue
Normal 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>
|
||||
44
meshchatx/src/frontend/components/SidebarLink.vue
Normal file
44
meshchatx/src/frontend/components/SidebarLink.vue
Normal 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>
|
||||
122
meshchatx/src/frontend/components/Toast.vue
Normal file
122
meshchatx/src/frontend/components/Toast.vue
Normal 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>
|
||||
833
meshchatx/src/frontend/components/about/AboutPage.vue
Normal file
833
meshchatx/src/frontend/components/about/AboutPage.vue
Normal 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>
|
||||
387
meshchatx/src/frontend/components/archives/ArchivesPage.vue
Normal file
387
meshchatx/src/frontend/components/archives/ArchivesPage.vue
Normal 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>
|
||||
152
meshchatx/src/frontend/components/auth/AuthPage.vue
Normal file
152
meshchatx/src/frontend/components/auth/AuthPage.vue
Normal 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>
|
||||
263
meshchatx/src/frontend/components/blocked/BlockedPage.vue
Normal file
263
meshchatx/src/frontend/components/blocked/BlockedPage.vue
Normal 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>
|
||||
262
meshchatx/src/frontend/components/call/CallOverlay.vue
Normal file
262
meshchatx/src/frontend/components/call/CallOverlay.vue
Normal 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>
|
||||
458
meshchatx/src/frontend/components/call/CallPage.vue
Normal file
458
meshchatx/src/frontend/components/call/CallPage.vue
Normal 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>
|
||||
10
meshchatx/src/frontend/components/forms/FormLabel.vue
Normal file
10
meshchatx/src/frontend/components/forms/FormLabel.vue
Normal 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>
|
||||
10
meshchatx/src/frontend/components/forms/FormSubLabel.vue
Normal file
10
meshchatx/src/frontend/components/forms/FormSubLabel.vue
Normal 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>
|
||||
36
meshchatx/src/frontend/components/forms/Toggle.vue
Normal file
36
meshchatx/src/frontend/components/forms/Toggle.vue
Normal 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>
|
||||
202
meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
Normal file
202
meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
Normal 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>
|
||||
1597
meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
Normal file
1597
meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
@@ -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>
|
||||
266
meshchatx/src/frontend/components/interfaces/Interface.vue
Normal file
266
meshchatx/src/frontend/components/interfaces/Interface.vue
Normal 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>
|
||||
362
meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
Normal file
362
meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
Normal 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>
|
||||
1314
meshchatx/src/frontend/components/map/MapPage.vue
Normal file
1314
meshchatx/src/frontend/components/map/MapPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
112
meshchatx/src/frontend/components/messages/AddAudioButton.vue
Normal file
112
meshchatx/src/frontend/components/messages/AddAudioButton.vue
Normal 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>
|
||||
164
meshchatx/src/frontend/components/messages/AddImageButton.vue
Normal file
164
meshchatx/src/frontend/components/messages/AddImageButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
2118
meshchatx/src/frontend/components/messages/ConversationViewer.vue
Normal file
2118
meshchatx/src/frontend/components/messages/ConversationViewer.vue
Normal file
File diff suppressed because it is too large
Load Diff
338
meshchatx/src/frontend/components/messages/MessagesPage.vue
Normal file
338
meshchatx/src/frontend/components/messages/MessagesPage.vue
Normal 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>
|
||||
436
meshchatx/src/frontend/components/messages/MessagesSidebar.vue
Normal file
436
meshchatx/src/frontend/components/messages/MessagesSidebar.vue
Normal 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>
|
||||
152
meshchatx/src/frontend/components/messages/SendMessageButton.vue
Normal file
152
meshchatx/src/frontend/components/messages/SendMessageButton.vue
Normal 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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<NetworkVisualiser />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import NetworkVisualiser from "./NetworkVisualiser.vue";
|
||||
|
||||
export default {
|
||||
name: "NetworkVisualiserPage",
|
||||
components: {
|
||||
NetworkVisualiser,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
1427
meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
Normal file
1427
meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
326
meshchatx/src/frontend/components/ping/PingPage.vue
Normal file
326
meshchatx/src/frontend/components/ping/PingPage.vue
Normal 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>
|
||||
220
meshchatx/src/frontend/components/profile/ProfileIconPage.vue
Normal file
220
meshchatx/src/frontend/components/profile/ProfileIconPage.vue
Normal 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>
|
||||
@@ -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">
|
||||
<{{ propagationNode.destination_hash }}>
|
||||
</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>
|
||||
498
meshchatx/src/frontend/components/rncp/RNCPPage.vue
Normal file
498
meshchatx/src/frontend/components/rncp/RNCPPage.vue
Normal 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 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>
|
||||
244
meshchatx/src/frontend/components/rnprobe/RNProbePage.vue
Normal file
244
meshchatx/src/frontend/components/rnprobe/RNProbePage.vue
Normal 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>
|
||||
226
meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
Normal file
226
meshchatx/src/frontend/components/rnstatus/RNStatusPage.vue
Normal 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>
|
||||
880
meshchatx/src/frontend/components/settings/SettingsPage.vue
Normal file
880
meshchatx/src/frontend/components/settings/SettingsPage.vue
Normal 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>
|
||||
146
meshchatx/src/frontend/components/tools/ToolsPage.vue
Normal file
146
meshchatx/src/frontend/components/tools/ToolsPage.vue
Normal 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>
|
||||
528
meshchatx/src/frontend/components/translator/TranslatorPage.vue
Normal file
528
meshchatx/src/frontend/components/translator/TranslatorPage.vue
Normal 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>
|
||||
Binary file not shown.
6
meshchatx/src/frontend/fonts/RobotoMonoNerdFont/font.css
Normal file
6
meshchatx/src/frontend/fonts/RobotoMonoNerdFont/font.css
Normal 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;
|
||||
}
|
||||
33
meshchatx/src/frontend/index.html
Normal file
33
meshchatx/src/frontend/index.html
Normal 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>
|
||||
58
meshchatx/src/frontend/js/Codec2Loader.js
Normal file
58
meshchatx/src/frontend/js/Codec2Loader.js
Normal 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;
|
||||
}
|
||||
35
meshchatx/src/frontend/js/DialogUtils.js
Normal file
35
meshchatx/src/frontend/js/DialogUtils.js
Normal 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;
|
||||
24
meshchatx/src/frontend/js/DownloadUtils.js
Normal file
24
meshchatx/src/frontend/js/DownloadUtils.js
Normal 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;
|
||||
19
meshchatx/src/frontend/js/ElectronUtils.js
Normal file
19
meshchatx/src/frontend/js/ElectronUtils.js
Normal 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;
|
||||
24
meshchatx/src/frontend/js/GlobalEmitter.js
Normal file
24
meshchatx/src/frontend/js/GlobalEmitter.js
Normal 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();
|
||||
8
meshchatx/src/frontend/js/GlobalState.js
Normal file
8
meshchatx/src/frontend/js/GlobalState.js
Normal file
@@ -0,0 +1,8 @@
|
||||
import { reactive } from "vue";
|
||||
|
||||
// global state
|
||||
const globalState = reactive({
|
||||
unreadConversationsCount: 0,
|
||||
});
|
||||
|
||||
export default globalState;
|
||||
720
meshchatx/src/frontend/js/MicronParser.js
Normal file
720
meshchatx/src/frontend/js/MicronParser.js
Normal 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;
|
||||
64
meshchatx/src/frontend/js/MicrophoneRecorder.js
Normal file
64
meshchatx/src/frontend/js/MicrophoneRecorder.js
Normal 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;
|
||||
25
meshchatx/src/frontend/js/NotificationUtils.js
Normal file
25
meshchatx/src/frontend/js/NotificationUtils.js
Normal 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;
|
||||
68
meshchatx/src/frontend/js/TileCache.js
Normal file
68
meshchatx/src/frontend/js/TileCache.js
Normal 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();
|
||||
25
meshchatx/src/frontend/js/ToastUtils.js
Normal file
25
meshchatx/src/frontend/js/ToastUtils.js
Normal 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;
|
||||
185
meshchatx/src/frontend/js/Utils.js
Normal file
185
meshchatx/src/frontend/js/Utils.js
Normal 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;
|
||||
69
meshchatx/src/frontend/js/WebSocketConnection.js
Normal file
69
meshchatx/src/frontend/js/WebSocketConnection.js
Normal 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();
|
||||
541
meshchatx/src/frontend/locales/de.json
Normal file
541
meshchatx/src/frontend/locales/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
541
meshchatx/src/frontend/locales/en.json
Normal file
541
meshchatx/src/frontend/locales/en.json
Normal 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
Reference in New Issue
Block a user