Overhual entire codebase part 1
- Big UI/UX changes - Improved Config parser - Some minor improvements and changes
This commit is contained in:
10
donate.md
10
donate.md
@@ -1,10 +0,0 @@
|
||||
# Donate
|
||||
|
||||
Thank you for considering donating, this helps support my work on this project 😁
|
||||
|
||||
## How can I donate?
|
||||
|
||||
- Bitcoin: bc1qy22smke8n4c54evdxmp7lpy9p0e6m9tavtlg2q
|
||||
- Ethereum: 0xc64CFbA5D0BF7664158c5671F64d446395b3bF3D
|
||||
- Buy me a Coffee: [https://ko-fi.com/liamcottle](https://ko-fi.com/liamcottle)
|
||||
- Sponsor on GitHub: [https://github.com/sponsors/liamcottle](https://github.com/sponsors/liamcottle)
|
||||
350
meshchat.py
350
meshchat.py
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env python
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@@ -20,7 +21,7 @@ import asyncio
|
||||
import base64
|
||||
import webbrowser
|
||||
|
||||
from peewee import SqliteDatabase
|
||||
from peewee import SqliteDatabase, fn
|
||||
from serial.tools import list_ports
|
||||
import psutil
|
||||
|
||||
@@ -281,6 +282,157 @@ class ReticulumMeshChat:
|
||||
print("failed to enable or disable propagation node")
|
||||
pass
|
||||
|
||||
def _get_reticulum_section(self):
|
||||
try:
|
||||
reticulum_config = self.reticulum.config["reticulum"]
|
||||
except Exception:
|
||||
reticulum_config = None
|
||||
|
||||
if not isinstance(reticulum_config, dict):
|
||||
reticulum_config = {}
|
||||
self.reticulum.config["reticulum"] = reticulum_config
|
||||
|
||||
return reticulum_config
|
||||
|
||||
def _get_interfaces_section(self):
|
||||
|
||||
try:
|
||||
interfaces = self.reticulum.config["interfaces"]
|
||||
except Exception:
|
||||
interfaces = None
|
||||
|
||||
if not isinstance(interfaces, dict):
|
||||
interfaces = {}
|
||||
self.reticulum.config["interfaces"] = interfaces
|
||||
|
||||
return interfaces
|
||||
|
||||
def _get_interfaces_snapshot(self):
|
||||
snapshot = {}
|
||||
interfaces = self._get_interfaces_section()
|
||||
for name, interface in interfaces.items():
|
||||
try:
|
||||
snapshot[name] = copy.deepcopy(dict(interface))
|
||||
except Exception:
|
||||
try:
|
||||
snapshot[name] = copy.deepcopy(interface)
|
||||
except Exception:
|
||||
snapshot[name] = {}
|
||||
return snapshot
|
||||
|
||||
def _write_reticulum_config(self):
|
||||
try:
|
||||
self.reticulum.config.write()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to write Reticulum config: {e}")
|
||||
return False
|
||||
|
||||
def build_user_guidance_messages(self):
|
||||
guidance = []
|
||||
|
||||
interfaces = self._get_interfaces_section()
|
||||
if len(interfaces) == 0:
|
||||
guidance.append({
|
||||
"id": "no_interfaces",
|
||||
"title": "No Reticulum interfaces configured",
|
||||
"description": "Add at least one Reticulum interface so MeshChat can talk to your radio or transport.",
|
||||
"action_route": "/interfaces/add",
|
||||
"action_label": "Add Interface",
|
||||
"severity": "warning",
|
||||
})
|
||||
|
||||
if not self.reticulum.transport_enabled():
|
||||
guidance.append({
|
||||
"id": "transport_disabled",
|
||||
"title": "Transport mode is disabled",
|
||||
"description": "Enable transport to allow MeshChat to relay traffic over your configured interfaces.",
|
||||
"action_route": "/settings",
|
||||
"action_label": "Open Settings",
|
||||
"severity": "info",
|
||||
})
|
||||
|
||||
if not self.config.auto_announce_enabled.get():
|
||||
guidance.append({
|
||||
"id": "announce_disabled",
|
||||
"title": "Auto announcements are turned off",
|
||||
"description": "Automatic announces make it easier for other peers to discover you. Enable them if you want to stay visible.",
|
||||
"action_route": "/settings",
|
||||
"action_label": "Manage Announce Settings",
|
||||
"severity": "info",
|
||||
})
|
||||
|
||||
return guidance
|
||||
|
||||
def _conversation_messages_query(self, destination_hash: str):
|
||||
local_hash = self.local_lxmf_destination.hexhash
|
||||
return (database.LxmfMessage
|
||||
.select()
|
||||
.where(
|
||||
((database.LxmfMessage.source_hash == local_hash) & (database.LxmfMessage.destination_hash == destination_hash))
|
||||
| ((database.LxmfMessage.destination_hash == local_hash) & (database.LxmfMessage.source_hash == destination_hash))
|
||||
))
|
||||
|
||||
def get_conversation_latest_message(self, destination_hash: str):
|
||||
return (self._conversation_messages_query(destination_hash)
|
||||
.order_by(database.LxmfMessage.id.desc())
|
||||
.get_or_none())
|
||||
|
||||
def conversation_has_attachments(self, destination_hash: str):
|
||||
query = (self._conversation_messages_query(destination_hash)
|
||||
.where(
|
||||
database.LxmfMessage.fields.contains('"image"')
|
||||
| database.LxmfMessage.fields.contains('"audio"')
|
||||
| database.LxmfMessage.fields.contains('"file_attachments"')
|
||||
)
|
||||
.limit(1))
|
||||
return query.exists()
|
||||
|
||||
def message_fields_have_attachments(self, fields_json: str | None):
|
||||
if not fields_json:
|
||||
return False
|
||||
try:
|
||||
fields = json.loads(fields_json)
|
||||
except Exception:
|
||||
return False
|
||||
if "image" in fields or "audio" in fields:
|
||||
return True
|
||||
if "file_attachments" in fields and isinstance(fields["file_attachments"], list):
|
||||
return len(fields["file_attachments"]) > 0
|
||||
return False
|
||||
|
||||
def search_destination_hashes_by_message(self, search_term: str):
|
||||
if search_term is None or search_term.strip() == "":
|
||||
return set()
|
||||
|
||||
local_hash = self.local_lxmf_destination.hexhash
|
||||
like_term = f"%{search_term}%"
|
||||
|
||||
matches = set()
|
||||
query = (database.LxmfMessage
|
||||
.select(database.LxmfMessage.source_hash, database.LxmfMessage.destination_hash)
|
||||
.where(
|
||||
((database.LxmfMessage.source_hash == local_hash) | (database.LxmfMessage.destination_hash == local_hash))
|
||||
& (
|
||||
database.LxmfMessage.title ** like_term
|
||||
| database.LxmfMessage.content ** like_term
|
||||
)
|
||||
))
|
||||
|
||||
for message in query:
|
||||
if message.source_hash == local_hash:
|
||||
matches.add(message.destination_hash)
|
||||
else:
|
||||
matches.add(message.source_hash)
|
||||
|
||||
return matches
|
||||
|
||||
def parse_bool_query_param(self, value: str | None) -> bool:
|
||||
if value is None:
|
||||
return False
|
||||
value = value.lower()
|
||||
return value in {"1", "true", "yes", "on"}
|
||||
|
||||
# handle receiving a new audio call
|
||||
def on_incoming_audio_call(self, audio_call: AudioCall):
|
||||
print("on_incoming_audio_call: {}".format(audio_call.link.hash.hex()))
|
||||
@@ -340,13 +492,11 @@ class ReticulumMeshChat:
|
||||
@routes.get("/api/v1/reticulum/interfaces")
|
||||
async def index(request):
|
||||
|
||||
interfaces = {}
|
||||
if "interfaces" in self.reticulum.config:
|
||||
interfaces = self.reticulum.config["interfaces"]
|
||||
interfaces = self._get_interfaces_snapshot()
|
||||
|
||||
processed_interfaces = {}
|
||||
for interface_name, interface in interfaces.items():
|
||||
interface_data = interface.copy()
|
||||
interface_data = copy.deepcopy(interface)
|
||||
|
||||
# handle sub-interfaces for RNodeMultiInterface
|
||||
if interface_data.get("type") == "RNodeMultiInterface":
|
||||
@@ -377,23 +527,35 @@ class ReticulumMeshChat:
|
||||
data = await request.json()
|
||||
interface_name = data.get('name')
|
||||
|
||||
# enable interface
|
||||
if "interfaces" in self.reticulum.config:
|
||||
interface = self.reticulum.config["interfaces"][interface_name]
|
||||
if "enabled" in interface:
|
||||
interface["enabled"] = "true"
|
||||
if "interface_enabled" in interface:
|
||||
interface["interface_enabled"] = "true"
|
||||
if interface_name is None or interface_name == "":
|
||||
return web.json_response({
|
||||
"message": "Interface name is required",
|
||||
}, status=422)
|
||||
|
||||
keys_to_remove = []
|
||||
for key, value in interface.items():
|
||||
if value is None:
|
||||
keys_to_remove.append(key)
|
||||
for key in keys_to_remove:
|
||||
del interface[key]
|
||||
# enable interface
|
||||
interfaces = self._get_interfaces_section()
|
||||
if interface_name not in interfaces:
|
||||
return web.json_response({
|
||||
"message": "Interface not found",
|
||||
}, status=404)
|
||||
interface = interfaces[interface_name]
|
||||
if "enabled" in interface:
|
||||
interface["enabled"] = "true"
|
||||
if "interface_enabled" in interface:
|
||||
interface["interface_enabled"] = "true"
|
||||
|
||||
keys_to_remove = []
|
||||
for key, value in interface.items():
|
||||
if value is None:
|
||||
keys_to_remove.append(key)
|
||||
for key in keys_to_remove:
|
||||
del interface[key]
|
||||
|
||||
# save config
|
||||
self.reticulum.config.write()
|
||||
if not self._write_reticulum_config():
|
||||
return web.json_response({
|
||||
"message": "Failed to write Reticulum config",
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({
|
||||
"message": "Interface is now enabled",
|
||||
@@ -407,23 +569,35 @@ class ReticulumMeshChat:
|
||||
data = await request.json()
|
||||
interface_name = data.get('name')
|
||||
|
||||
# disable interface
|
||||
if "interfaces" in self.reticulum.config:
|
||||
interface = self.reticulum.config["interfaces"][interface_name]
|
||||
if "enabled" in interface:
|
||||
interface["enabled"] = "false"
|
||||
if "interface_enabled" in interface:
|
||||
interface["interface_enabled"] = "false"
|
||||
if interface_name is None or interface_name == "":
|
||||
return web.json_response({
|
||||
"message": "Interface name is required",
|
||||
}, status=422)
|
||||
|
||||
keys_to_remove = []
|
||||
for key, value in interface.items():
|
||||
if value is None:
|
||||
keys_to_remove.append(key)
|
||||
for key in keys_to_remove:
|
||||
del interface[key]
|
||||
# disable interface
|
||||
interfaces = self._get_interfaces_section()
|
||||
if interface_name not in interfaces:
|
||||
return web.json_response({
|
||||
"message": "Interface not found",
|
||||
}, status=404)
|
||||
interface = interfaces[interface_name]
|
||||
if "enabled" in interface:
|
||||
interface["enabled"] = "false"
|
||||
if "interface_enabled" in interface:
|
||||
interface["interface_enabled"] = "false"
|
||||
|
||||
keys_to_remove = []
|
||||
for key, value in interface.items():
|
||||
if value is None:
|
||||
keys_to_remove.append(key)
|
||||
for key in keys_to_remove:
|
||||
del interface[key]
|
||||
|
||||
# save config
|
||||
self.reticulum.config.write()
|
||||
if not self._write_reticulum_config():
|
||||
return web.json_response({
|
||||
"message": "Failed to write Reticulum config",
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({
|
||||
"message": "Interface is now disabled",
|
||||
@@ -437,12 +611,25 @@ class ReticulumMeshChat:
|
||||
data = await request.json()
|
||||
interface_name = data.get('name')
|
||||
|
||||
if interface_name is None or interface_name == "":
|
||||
return web.json_response({
|
||||
"message": "Interface name is required",
|
||||
}, status=422)
|
||||
|
||||
interfaces = self._get_interfaces_section()
|
||||
if interface_name not in interfaces:
|
||||
return web.json_response({
|
||||
"message": "Interface not found",
|
||||
}, status=404)
|
||||
|
||||
# delete interface
|
||||
if "interfaces" in self.reticulum.config:
|
||||
del self.reticulum.config["interfaces"][interface_name]
|
||||
del interfaces[interface_name]
|
||||
|
||||
# save config
|
||||
self.reticulum.config.write()
|
||||
if not self._write_reticulum_config():
|
||||
return web.json_response({
|
||||
"message": "Failed to write Reticulum config",
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({
|
||||
"message": "Interface has been deleted",
|
||||
@@ -471,9 +658,7 @@ class ReticulumMeshChat:
|
||||
}, status=422)
|
||||
|
||||
# get existing interfaces
|
||||
interfaces = {}
|
||||
if "interfaces" in self.reticulum.config:
|
||||
interfaces = self.reticulum.config["interfaces"]
|
||||
interfaces = self._get_interfaces_section()
|
||||
|
||||
# ensure name is not for an existing interface, to prevent overwriting
|
||||
if allow_overwriting_interface is False and interface_name in interfaces:
|
||||
@@ -684,7 +869,7 @@ class ReticulumMeshChat:
|
||||
# remove any existing sub interfaces, which can be found by finding keys that contain a dict value
|
||||
# this allows us to replace all sub interfaces with the ones we are about to add, while also ensuring
|
||||
# that we do not remove any existing config values from the main interface config
|
||||
for key in interface_details:
|
||||
for key in list(interface_details.keys()):
|
||||
value = interface_details[key]
|
||||
if isinstance(value, dict):
|
||||
del interface_details[key]
|
||||
@@ -783,10 +968,11 @@ class ReticulumMeshChat:
|
||||
|
||||
# merge new interface into existing interfaces
|
||||
interfaces[interface_name] = interface_details
|
||||
self.reticulum.config["interfaces"] = interfaces
|
||||
|
||||
# save config
|
||||
self.reticulum.config.write()
|
||||
if not self._write_reticulum_config():
|
||||
return web.json_response({
|
||||
"message": "Failed to write Reticulum config",
|
||||
}, status=500)
|
||||
|
||||
if allow_overwriting_interface:
|
||||
return web.json_response({
|
||||
@@ -813,7 +999,8 @@ class ReticulumMeshChat:
|
||||
|
||||
# format interfaces for export
|
||||
output = []
|
||||
for interface_name, interface in self.reticulum.config["interfaces"].items():
|
||||
interfaces = self._get_interfaces_snapshot()
|
||||
for interface_name, interface in interfaces.items():
|
||||
|
||||
# skip interface if not selected
|
||||
if selected_interface_names is not None and selected_interface_names != "":
|
||||
@@ -913,8 +1100,12 @@ class ReticulumMeshChat:
|
||||
del interface_config[interface_name]["enabled"]
|
||||
|
||||
# update reticulum config with new interfaces
|
||||
self.reticulum.config["interfaces"].update(interface_config)
|
||||
self.reticulum.config.write()
|
||||
interfaces = self._get_interfaces_section()
|
||||
interfaces.update(interface_config)
|
||||
if not self._write_reticulum_config():
|
||||
return web.json_response({
|
||||
"message": "Failed to write Reticulum config",
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({
|
||||
"message": "Interfaces imported successfully",
|
||||
@@ -1024,6 +1215,7 @@ class ReticulumMeshChat:
|
||||
"download_stats": {
|
||||
"avg_download_speed_bps": avg_download_speed_bps,
|
||||
},
|
||||
"user_guidance": self.build_user_guidance_messages(),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1053,8 +1245,12 @@ class ReticulumMeshChat:
|
||||
async def index(request):
|
||||
|
||||
# enable transport mode
|
||||
self.reticulum.config["reticulum"]["enable_transport"] = True
|
||||
self.reticulum.config.write()
|
||||
reticulum_config = self._get_reticulum_section()
|
||||
reticulum_config["enable_transport"] = True
|
||||
if not self._write_reticulum_config():
|
||||
return web.json_response({
|
||||
"message": "Failed to write Reticulum config",
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({
|
||||
"message": "Transport has been enabled. MeshChat must be restarted for this change to take effect.",
|
||||
@@ -1065,8 +1261,12 @@ class ReticulumMeshChat:
|
||||
async def index(request):
|
||||
|
||||
# disable transport mode
|
||||
self.reticulum.config["reticulum"]["enable_transport"] = False
|
||||
self.reticulum.config.write()
|
||||
reticulum_config = self._get_reticulum_section()
|
||||
reticulum_config["enable_transport"] = False
|
||||
if not self._write_reticulum_config():
|
||||
return web.json_response({
|
||||
"message": "Failed to write Reticulum config",
|
||||
}, status=500)
|
||||
|
||||
return web.json_response({
|
||||
"message": "Transport has been disabled. MeshChat must be restarted for this change to take effect.",
|
||||
@@ -2032,6 +2232,15 @@ class ReticulumMeshChat:
|
||||
@routes.get("/api/v1/lxmf/conversations")
|
||||
async def index(request):
|
||||
|
||||
search_query = request.query.get("search", None)
|
||||
filter_unread = self.parse_bool_query_param(request.query.get("filter_unread"))
|
||||
filter_failed = self.parse_bool_query_param(request.query.get("filter_failed"))
|
||||
filter_has_attachments = self.parse_bool_query_param(request.query.get("filter_has_attachments"))
|
||||
|
||||
search_destination_hashes = set()
|
||||
if search_query is not None and search_query != "":
|
||||
search_destination_hashes = self.search_destination_hashes_by_message(search_query)
|
||||
|
||||
# sql query to fetch unique source/destination hash pairs ordered by the most recently updated message
|
||||
query = """
|
||||
WITH NormalizedMessages AS (
|
||||
@@ -2068,6 +2277,19 @@ class ReticulumMeshChat:
|
||||
else:
|
||||
other_user_hash = source_hash
|
||||
|
||||
latest_message = self.get_conversation_latest_message(other_user_hash)
|
||||
latest_message_title = None
|
||||
latest_message_preview = None
|
||||
latest_message_created_at = None
|
||||
latest_message_has_attachments = False
|
||||
if latest_message is not None:
|
||||
latest_message_title = latest_message.title
|
||||
latest_message_preview = latest_message.content
|
||||
latest_message_created_at = latest_message.created_at
|
||||
latest_message_has_attachments = self.message_fields_have_attachments(latest_message.fields)
|
||||
|
||||
has_attachments = self.conversation_has_attachments(other_user_hash)
|
||||
|
||||
# find lxmf user icon from database
|
||||
lxmf_user_icon = None
|
||||
db_lxmf_user_icon = database.LxmfUserIcon.get_or_none(database.LxmfUserIcon.destination_hash == other_user_hash)
|
||||
@@ -2085,12 +2307,40 @@ class ReticulumMeshChat:
|
||||
"destination_hash": other_user_hash,
|
||||
"is_unread": self.is_lxmf_conversation_unread(other_user_hash),
|
||||
"failed_messages_count": self.lxmf_conversation_failed_messages_count(other_user_hash),
|
||||
"has_attachments": has_attachments,
|
||||
"latest_message_title": latest_message_title,
|
||||
"latest_message_preview": latest_message_preview,
|
||||
"latest_message_created_at": latest_message_created_at,
|
||||
"latest_message_has_attachments": latest_message_has_attachments,
|
||||
"lxmf_user_icon": lxmf_user_icon,
|
||||
# we say the conversation was updated when the latest message was created
|
||||
# otherwise this will go crazy when sending a message, as the updated_at on the latest message changes very frequently
|
||||
"updated_at": created_at,
|
||||
})
|
||||
|
||||
if search_query is not None and search_query != "":
|
||||
lowered_query = search_query.lower()
|
||||
filtered = []
|
||||
for conversation in conversations:
|
||||
matches_display = conversation["display_name"] and lowered_query in conversation["display_name"].lower()
|
||||
matches_custom = conversation["custom_display_name"] and lowered_query in conversation["custom_display_name"].lower()
|
||||
matches_destination = conversation["destination_hash"] and lowered_query in conversation["destination_hash"].lower()
|
||||
matches_latest_title = conversation["latest_message_title"] and lowered_query in conversation["latest_message_title"].lower()
|
||||
matches_latest_preview = conversation["latest_message_preview"] and lowered_query in conversation["latest_message_preview"].lower()
|
||||
matches_history = conversation["destination_hash"] in search_destination_hashes
|
||||
if matches_display or matches_custom or matches_destination or matches_latest_title or matches_latest_preview or matches_history:
|
||||
filtered.append(conversation)
|
||||
conversations = filtered
|
||||
|
||||
if filter_unread:
|
||||
conversations = [c for c in conversations if c["is_unread"]]
|
||||
|
||||
if filter_failed:
|
||||
conversations = [c for c in conversations if c["failed_messages_count"] > 0]
|
||||
|
||||
if filter_has_attachments:
|
||||
conversations = [c for c in conversations if c["has_attachments"]]
|
||||
|
||||
return web.json_response({
|
||||
"conversations": conversations,
|
||||
})
|
||||
|
||||
1
package-lock.json
generated
1
package-lock.json
generated
@@ -3025,6 +3025,7 @@
|
||||
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "24.13.3",
|
||||
"builder-util": "24.13.1",
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
"electron-builder": "^24.6.3"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.liamcottle.reticulummeshchat",
|
||||
"productName": "Reticulum MeshChat",
|
||||
"appId": "com.sudoivan.reticulummeshchat",
|
||||
"productName": "Reticulum MeshChatX",
|
||||
"asar": false,
|
||||
"files": [
|
||||
"electron/**/*"
|
||||
@@ -74,7 +74,7 @@
|
||||
"AppImage",
|
||||
"deb"
|
||||
],
|
||||
"maintainer": "Liam Cottle <liam@liamcottle.com>",
|
||||
"maintainer": "Sudo-Ivan",
|
||||
"extraFiles": [
|
||||
{
|
||||
"from": "build/exe",
|
||||
|
||||
6
setup.py
6
setup.py
@@ -1,15 +1,15 @@
|
||||
from cx_Freeze import setup, Executable
|
||||
|
||||
setup(
|
||||
name='ReticulumMeshChat',
|
||||
name='ReticulumMeshChatX',
|
||||
version='1.0.0',
|
||||
description='A simple mesh network communications app powered by the Reticulum Network Stack',
|
||||
executables=[
|
||||
Executable(
|
||||
script='meshchat.py', # this script to run
|
||||
base=None, # we are running a console application, not a gui
|
||||
target_name='ReticulumMeshChat', # creates ReticulumMeshChat.exe
|
||||
shortcut_name='ReticulumMeshChat', # name shown in shortcut
|
||||
target_name='ReticulumMeshChatX', # creates ReticulumMeshChatX.exe
|
||||
shortcut_name='ReticulumMeshChatX', # name shown in shortcut
|
||||
shortcut_dir='ProgramMenuFolder', # put the shortcut in windows start menu
|
||||
icon='logo/icon.ico', # set the icon for the exe
|
||||
copyright='Copyright (c) 2024 Liam Cottle',
|
||||
|
||||
@@ -8,16 +8,24 @@ class InterfaceConfigParser:
|
||||
|
||||
# get lines from provided text
|
||||
lines = text.splitlines()
|
||||
stripped_lines = [line.strip() for line in lines]
|
||||
|
||||
# ensure [interfaces] section exists
|
||||
if "[interfaces]" not in lines:
|
||||
if "[interfaces]" not in stripped_lines:
|
||||
lines.insert(0, "[interfaces]")
|
||||
stripped_lines.insert(0, "[interfaces]")
|
||||
|
||||
# parse lines as rns config object
|
||||
config = RNS.vendor.configobj.ConfigObj(lines)
|
||||
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")
|
||||
config_interfaces = config.get("interfaces", {})
|
||||
if config_interfaces is None:
|
||||
return []
|
||||
|
||||
# process interfaces
|
||||
interfaces = []
|
||||
@@ -29,3 +37,58 @@ class InterfaceConfigParser:
|
||||
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
|
||||
|
||||
@@ -7,12 +7,6 @@
|
||||
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
|
||||
<title>Phone | Reticulum MeshChat</title>
|
||||
|
||||
<!-- codec2 -->
|
||||
<script src="assets/js/codec2-emscripten/c2enc.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/c2dec.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/sox.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/codec2-lib.js"></script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import axios from 'axios';
|
||||
import {createApp} from 'vue';
|
||||
import { createApp } from 'vue';
|
||||
import "./style.css";
|
||||
import CallPage from "./components/call/CallPage.vue";
|
||||
import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader";
|
||||
|
||||
// provide axios globally
|
||||
window.axios = axios;
|
||||
|
||||
createApp(CallPage)
|
||||
.mount('#app');
|
||||
async function bootstrap() {
|
||||
await ensureCodec2ScriptsLoaded();
|
||||
createApp(CallPage)
|
||||
.mount('#app');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -1,54 +1,86 @@
|
||||
<template>
|
||||
<div :class="{'dark': config?.theme === 'dark'}" class="h-screen w-full flex flex-col">
|
||||
<div :class="{'dark': config?.theme === 'dark'}" class="h-screen w-full flex flex-col bg-slate-50 dark:bg-zinc-950 transition-colors">
|
||||
|
||||
<!-- header -->
|
||||
<div class="flex bg-white dark:bg-zinc-950 p-2 border-gray-300 dark:border-zinc-900 border-b min-h-16">
|
||||
<div class="flex w-full">
|
||||
<div class="hidden sm:flex my-auto w-12 h-12 mr-2">
|
||||
<img class="w-12 h-12" src="/assets/images/logo-chat-bubble.png" />
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<div @click="onAppNameClick" class="font-bold cursor-pointer text-gray-900 dark:text-zinc-100">Reticulum MeshChat</div>
|
||||
<div class="text-sm text-gray-700 dark:text-white">
|
||||
Developed by
|
||||
<a target="_blank" href="https://liamcottle.com" class="text-blue-500 dark:text-blue-400">Liam Cottle</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex my-auto ml-auto mr-0 sm:mr-2 space-x-1 sm:space-x-2">
|
||||
<button @click="syncPropagationNode" type="button" class="rounded-full">
|
||||
<span class="flex text-gray-700 dark:text-white bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-600 px-2 py-1 rounded-full">
|
||||
<span :class="{ 'animate-spin': isSyncingPropagationNode }">
|
||||
<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="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>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm">Sync Messages</span>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="composeNewMessage" type="button" class="rounded-full">
|
||||
<span class="flex text-gray-700 dark:text-white bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-600 px-2 py-1 rounded-full">
|
||||
<span>
|
||||
<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.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm">Compose</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<!-- middle -->
|
||||
<div ref="middle" class="flex h-full w-full overflow-auto">
|
||||
<template v-else>
|
||||
|
||||
<!-- sidebar -->
|
||||
<div class="bg-white flex w-72 min-w-72 flex-col dark:bg-zinc-950">
|
||||
<div class="flex grow flex-col overflow-y-auto border-r border-gray-200 bg-white dark:border-zinc-900 dark:bg-zinc-950">
|
||||
<!-- header -->
|
||||
<div class="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">
|
||||
<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 @click="onAppNameClick" class="font-semibold cursor-pointer text-gray-900 dark:text-zinc-100 tracking-tight text-lg">Reticulum MeshChatX</div>
|
||||
<div class="text-sm text-gray-600 dark:text-zinc-300">
|
||||
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 @click="syncPropagationNode" type="button" class="rounded-full">
|
||||
<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 }">
|
||||
<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="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>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-medium">Sync Messages</span>
|
||||
</span>
|
||||
</button>
|
||||
<button @click="composeNewMessage" type="button" class="rounded-full">
|
||||
<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>
|
||||
<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.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="hidden sm:inline-block my-auto mx-1 text-sm font-semibold">Compose</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- navigation -->
|
||||
<div class="flex-1">
|
||||
<ul class="py-2 pr-2 space-y-1">
|
||||
<!-- onboarding / guidance -->
|
||||
<div v-if="hasGuidanceMessages" class="border-b border-amber-200/60 bg-amber-50/70 text-amber-900 dark:bg-amber-950/30 dark:border-amber-800/40 dark:text-amber-100 transition">
|
||||
<div class="max-w-5xl mx-auto px-4 py-4 space-y-3">
|
||||
<div
|
||||
v-for="message in guidanceMessages"
|
||||
:key="message.id"
|
||||
class="flex flex-col gap-2 rounded-2xl border p-4 text-sm sm:flex-row sm:items-center shadow-sm"
|
||||
:class="guidanceCardClass(message)"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<div class="font-semibold">{{ message.title }}</div>
|
||||
<div class="text-xs sm:text-sm text-amber-900/80 dark:text-amber-100/80">{{ message.description }}</div>
|
||||
</div>
|
||||
<div v-if="message.action_route" class="sm:ml-auto">
|
||||
<button
|
||||
type="button"
|
||||
@click="navigateTo(message.action_route)"
|
||||
class="inline-flex items-center rounded-full bg-amber-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-amber-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
|
||||
>
|
||||
{{ message.action_label || 'Open' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- middle -->
|
||||
<div ref="middle" class="flex h-full w-full overflow-hidden bg-slate-50/80 dark:bg-zinc-950 transition-colors">
|
||||
|
||||
<!-- sidebar -->
|
||||
<div class="bg-transparent flex w-72 min-w-72 flex-col">
|
||||
<div class="flex grow flex-col overflow-y-auto border-r border-gray-200/70 bg-white/80 dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur">
|
||||
|
||||
<!-- navigation -->
|
||||
<div class="flex-1">
|
||||
<ul class="py-3 pr-2 space-y-1">
|
||||
|
||||
<!-- messages -->
|
||||
<li>
|
||||
@@ -144,8 +176,8 @@
|
||||
<div>
|
||||
|
||||
<!-- my identity -->
|
||||
<div v-if="config" class="bg-white border-t dark:border-zinc-900 dark:bg-zinc-950">
|
||||
<div @click="isShowingMyIdentitySection = !isShowingMyIdentitySection" class="flex text-gray-700 p-2 cursor-pointer">
|
||||
<div v-if="config" class="bg-white/80 border-t dark:border-zinc-800 dark:bg-zinc-900/70 backdrop-blur">
|
||||
<div @click="isShowingMyIdentitySection = !isShowingMyIdentitySection" class="flex text-gray-700 p-3 cursor-pointer">
|
||||
<div class="my-auto mr-2">
|
||||
<RouterLink @click.stop :to="{ name: 'profile.icon' }">
|
||||
<LxmfUserIcon
|
||||
@@ -162,8 +194,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isShowingMyIdentitySection" class="divide-y text-gray-900 border-t border-gray-300 dark:text-zinc-200 dark:border-zinc-900">
|
||||
<div class="p-1">
|
||||
<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"
|
||||
@@ -172,11 +204,11 @@
|
||||
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-1 dark:border-zinc-900">
|
||||
<div class="p-2 dark:border-zinc-900">
|
||||
<div>Identity Hash</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.identity_hash }}</div>
|
||||
</div>
|
||||
<div class="p-1 dark:border-zinc-900">
|
||||
<div class="p-2 dark:border-zinc-900">
|
||||
<div>LXMF Address</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_address_hash }}</div>
|
||||
</div>
|
||||
@@ -184,8 +216,8 @@
|
||||
</div>
|
||||
|
||||
<!-- auto announce -->
|
||||
<div v-if="config" class="bg-white border-t dark:bg-zinc-950 dark:border-zinc-900">
|
||||
<div @click="isShowingAnnounceSection = !isShowingAnnounceSection" class="flex text-gray-700 p-2 cursor-pointer dark:text-white">
|
||||
<div v-if="config" class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-800">
|
||||
<div @click="isShowingAnnounceSection = !isShowingAnnounceSection" class="flex text-gray-700 p-3 cursor-pointer dark:text-white">
|
||||
<div class="my-auto mr-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -214,8 +246,8 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isShowingAnnounceSection" class="divide-y text-gray-900 border-t border-gray-300 dark:text-zinc-200 dark:border-zinc-900">
|
||||
<div class="p-1 dark:border-zinc-900">
|
||||
<div v-if="isShowingAnnounceSection" class="divide-y text-gray-900 border-t border-gray-200 dark:text-zinc-200 dark:border-zinc-900">
|
||||
<div class="p-2 dark:border-zinc-900">
|
||||
<select
|
||||
v-model="config.auto_announce_interval_seconds"
|
||||
@change="onAnnounceIntervalSecondsChange"
|
||||
@@ -240,8 +272,8 @@
|
||||
</div>
|
||||
|
||||
<!-- audio calls -->
|
||||
<div v-if="config" class="bg-white border-t dark:bg-zinc-950 dark:border-zinc-900">
|
||||
<div @click="isShowingCallsSection = !isShowingCallsSection" class="flex text-gray-700 p-2 cursor-pointer">
|
||||
<div v-if="config" class="bg-white/80 border-t dark:bg-zinc-900/70 dark:border-zinc-900">
|
||||
<div @click="isShowingCallsSection = !isShowingCallsSection" class="flex text-gray-700 p-3 cursor-pointer">
|
||||
<div class="my-auto mr-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="dark:text-white w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 6.75c0 8.284 6.716 15 15 15h2.25a2.25 2.25 0 0 0 2.25-2.25v-1.372c0-.516-.351-.966-.852-1.091l-4.423-1.106c-.44-.11-.902.055-1.173.417l-.97 1.293c-.282.376-.769.542-1.21.38a12.035 12.035 0 0 1-7.143-7.143c-.162-.441.004-.928.38-1.21l1.293-.97c.363-.271.527-.734.417-1.173L6.963 3.102a1.125 1.125 0 0 0-1.091-.852H4.5A2.25 2.25 0 0 0 2.25 4.5v2.25Z" />
|
||||
@@ -260,8 +292,8 @@ dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outli
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isShowingCallsSection" class="divide-y text-gray-900 border-t border-gray-300 dark:border-zinc-900">
|
||||
<div class="p-1 flex dark:border-zinc-900 dark:text-white">
|
||||
<div v-if="isShowingCallsSection" class="divide-y text-gray-900 border-t border-gray-200 dark:border-zinc-900">
|
||||
<div class="p-2 flex dark:border-zinc-900 dark:text-white">
|
||||
<div>
|
||||
<div>Status</div>
|
||||
<div class="text-sm text-gray-700 dark:text-white">
|
||||
@@ -299,9 +331,10 @@ dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outli
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RouterView/>
|
||||
<RouterView v-if="!isPopoutMode"/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -326,6 +359,7 @@ export default {
|
||||
return {
|
||||
|
||||
reloadInterval: null,
|
||||
appInfoInterval: null,
|
||||
|
||||
isShowingMyIdentitySection: true,
|
||||
isShowingAnnounceSection: true,
|
||||
@@ -343,6 +377,7 @@ export default {
|
||||
beforeUnmount() {
|
||||
|
||||
clearInterval(this.reloadInterval);
|
||||
clearInterval(this.appInfoInterval);
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
@@ -354,6 +389,7 @@ export default {
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
this.getAppInfo();
|
||||
this.getConfig();
|
||||
this.updateCallsList();
|
||||
this.updatePropagationNodeStatus();
|
||||
|
||||
@@ -362,9 +398,52 @@ export default {
|
||||
this.updateCallsList();
|
||||
this.updatePropagationNodeStatus();
|
||||
}, 3000);
|
||||
this.appInfoInterval = setInterval(() => {
|
||||
this.getAppInfo();
|
||||
}, 15000);
|
||||
|
||||
},
|
||||
computed: {
|
||||
currentPopoutType() {
|
||||
if(this.$route?.meta?.popoutType){
|
||||
return this.$route.meta.popoutType;
|
||||
}
|
||||
return this.$route?.query?.popout ?? this.getHashPopoutValue();
|
||||
},
|
||||
isPopoutMode() {
|
||||
return this.currentPopoutType != null;
|
||||
},
|
||||
hasGuidanceMessages() {
|
||||
return this.guidanceMessages.length > 0;
|
||||
},
|
||||
guidanceMessages() {
|
||||
if (!this.appInfo || !Array.isArray(this.appInfo.user_guidance)) {
|
||||
return [];
|
||||
}
|
||||
return this.appInfo.user_guidance;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
guidanceCardClass(message) {
|
||||
switch(message.severity){
|
||||
case 'warning':
|
||||
return 'border-amber-200 bg-white text-amber-900 dark:bg-transparent dark:border-amber-300/40';
|
||||
case 'info':
|
||||
default:
|
||||
return 'border-amber-100 bg-white text-amber-900 dark:bg-transparent dark:border-amber-200/30';
|
||||
}
|
||||
},
|
||||
navigateTo(routePath) {
|
||||
if (!routePath) {
|
||||
return;
|
||||
}
|
||||
this.$router.push(routePath);
|
||||
},
|
||||
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){
|
||||
|
||||
@@ -1,253 +1,200 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto space-y-2 p-2">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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">
|
||||
|
||||
<!-- app info -->
|
||||
<div v-if="appInfo" class="bg-white dark:bg-zinc-900 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">App Info</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
||||
<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">About</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">
|
||||
v{{ appInfo.version }} • RNS {{ appInfo.rns_version }} • LXMF {{ appInfo.lxmf_version }} • Python {{ appInfo.python_version }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isElectron" class="flex flex-col sm:flex-row gap-2">
|
||||
<button @click="relaunch" type="button" class="primary-chip px-4 py-2 text-sm justify-center">
|
||||
<MaterialDesignIcon icon-name="restart" class="w-4 h-4"/>
|
||||
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">Config path</div>
|
||||
<div class="monospace-field break-all">{{ appInfo.reticulum_config_path }}</div>
|
||||
<button v-if="isElectron" @click="showReticulumConfigFile" type="button" class="secondary-chip mt-2 text-xs">
|
||||
<MaterialDesignIcon icon-name="folder" class="w-4 h-4"/>
|
||||
Reveal
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Database path</div>
|
||||
<div class="monospace-field break-all">{{ appInfo.database_path }}</div>
|
||||
<button v-if="isElectron" @click="showDatabaseFile" type="button" class="secondary-chip mt-2 text-xs">
|
||||
<MaterialDesignIcon icon-name="database" class="w-4 h-4"/>
|
||||
Reveal
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Database size</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">{{ formatBytes(appInfo.database_file_size) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- version -->
|
||||
<div class="flex p-1">
|
||||
<div class="mr-auto">
|
||||
<div>Versions</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
||||
MeshChat v{{ appInfo.version }} • RNS v{{ appInfo.rns_version }} • LXMF v{{ appInfo.lxmf_version }} • Python v{{ appInfo.python_version }}
|
||||
<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">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>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden sm:block mx-2 my-auto">
|
||||
<a target="_blank"
|
||||
href="https://github.com/liamcottle/reticulum-meshchat/releases"
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-600">
|
||||
Check for Updates
|
||||
</a>
|
||||
</header>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">Memory (RSS)</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.rss) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Virtual Memory</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.memory_usage.vms) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- reticulum config path -->
|
||||
<div class="flex p-1">
|
||||
<div class="mr-auto">
|
||||
<div>Reticulum Config Path</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400 break-all">{{ appInfo.reticulum_config_path }}</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">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>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isElectron" class="mx-2 my-auto">
|
||||
<button @click="showReticulumConfigFile"
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-600">
|
||||
Show in Folder
|
||||
</button>
|
||||
</header>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">Sent</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_sent) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Received</div>
|
||||
<div class="metric-value">{{ formatBytes(appInfo.network_stats.bytes_recv) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- database path -->
|
||||
<div class="flex p-1">
|
||||
<div class="mr-auto">
|
||||
<div>Database Path</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400 break-all">{{ appInfo.database_path }}</div>
|
||||
<div class="metric-row">
|
||||
<div>
|
||||
<div class="glass-label">Packets Sent</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_sent) }}</div>
|
||||
</div>
|
||||
<div v-if="isElectron" class="mx-2 my-auto">
|
||||
<button @click="showDatabaseFile"
|
||||
type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-600">
|
||||
Show in Folder
|
||||
</button>
|
||||
<div>
|
||||
<div class="glass-label">Packets Received</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.network_stats.packets_recv) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- database file size -->
|
||||
<div class="p-1">
|
||||
<div>Database File Size</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.database_file_size) }}</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">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>
|
||||
Live
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="metric-grid">
|
||||
<div>
|
||||
<div class="glass-label">Total Paths</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.total_paths) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Announces / sec</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.announces_per_second) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Announces / min</div>
|
||||
<div class="metric-value">{{ formatNumber(appInfo.reticulum_stats.announces_per_minute) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="glass-label">Announces / hr</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">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>
|
||||
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">No downloads yet</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- system resources -->
|
||||
<div v-if="appInfo && appInfo.memory_usage" class="bg-white dark:bg-zinc-900 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
|
||||
System Resources
|
||||
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
|
||||
Live
|
||||
<div v-if="appInfo" class="glass-card space-y-3">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">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 ? 'Shared Instance' : 'Standalone Instance' }}
|
||||
</span>
|
||||
<span :class="statusPillClass(appInfo.is_transport_enabled)">
|
||||
<MaterialDesignIcon icon-name="transit-connection" class="w-4 h-4"/>
|
||||
{{ appInfo.is_transport_enabled ? 'Transport Enabled' : 'Transport Disabled' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
||||
|
||||
<!-- memory usage -->
|
||||
<div class="flex p-1">
|
||||
<div class="mr-auto">
|
||||
<div>Memory Usage (RSS)</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.memory_usage.rss) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- virtual memory -->
|
||||
<div class="flex p-1">
|
||||
<div class="mr-auto">
|
||||
<div>Virtual Memory Size</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.memory_usage.vms) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- network statistics -->
|
||||
<div v-if="appInfo && appInfo.network_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
|
||||
Network Statistics
|
||||
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
||||
|
||||
<!-- bytes sent -->
|
||||
<div class="flex p-1">
|
||||
<div class="mr-auto">
|
||||
<div>Data Sent</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.network_stats.bytes_sent) }}</div>
|
||||
</div>
|
||||
<div v-if="config" class="glass-card space-y-4">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">Identity & Addresses</div>
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div class="address-card">
|
||||
<div class="glass-label">Identity Hash</div>
|
||||
<div class="monospace-field break-all">{{ config.identity_hash }}</div>
|
||||
<button @click="copyValue(config.identity_hash, 'Identity Hash')" type="button" class="secondary-chip mt-3 text-xs">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- bytes received -->
|
||||
<div class="flex p-1">
|
||||
<div class="mr-auto">
|
||||
<div>Data Received</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatBytes(appInfo.network_stats.bytes_recv) }}</div>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="glass-label">LXMF Address</div>
|
||||
<div class="monospace-field break-all">{{ config.lxmf_address_hash }}</div>
|
||||
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" type="button" class="secondary-chip mt-3 text-xs">
|
||||
<MaterialDesignIcon icon-name="account-network" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- packets sent -->
|
||||
<div class="p-1">
|
||||
<div>Packets Sent</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.network_stats.packets_sent) }}</div>
|
||||
<div class="address-card">
|
||||
<div class="glass-label">Propagation Node</div>
|
||||
<div class="monospace-field break-all">{{ config.lxmf_local_propagation_node_address_hash || '—' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- packets received -->
|
||||
<div class="p-1">
|
||||
<div>Packets Received</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.network_stats.packets_recv) }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- reticulum statistics -->
|
||||
<div v-if="appInfo && appInfo.reticulum_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
|
||||
Reticulum Statistics
|
||||
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
||||
|
||||
<!-- total paths -->
|
||||
<div class="p-1">
|
||||
<div>Total Paths</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.total_paths) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- announces per second -->
|
||||
<div class="p-1">
|
||||
<div>Announces per Second</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_second) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- announces per minute -->
|
||||
<div class="p-1">
|
||||
<div>Announces per Minute</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_minute) }}</div>
|
||||
</div>
|
||||
|
||||
<!-- announces per hour -->
|
||||
<div class="p-1">
|
||||
<div>Announces per Hour</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ formatNumber(appInfo.reticulum_stats.announces_per_hour) }}</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- download statistics -->
|
||||
<div v-if="appInfo && appInfo.download_stats" class="bg-white dark:bg-zinc-900 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">
|
||||
Download Statistics
|
||||
<span class="ml-auto text-xs text-green-600 dark:text-green-400 flex items-center">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
|
||||
Live
|
||||
</span>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
||||
|
||||
<!-- average download speed -->
|
||||
<div class="p-1">
|
||||
<div>Average Download Speed</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
||||
<span v-if="appInfo.download_stats.avg_download_speed_bps !== null">
|
||||
{{ formatBytesPerSecond(appInfo.download_stats.avg_download_speed_bps) }}
|
||||
</span>
|
||||
<span v-else>No downloads yet</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- reticulum status -->
|
||||
<div v-if="appInfo" class="bg-white dark:bg-zinc-900 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">Reticulum Status</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
||||
|
||||
<!-- instance mode -->
|
||||
<div class="p-1">
|
||||
<div>Instance Mode</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
||||
<span v-if="appInfo.is_connected_to_shared_instance" class="text-orange-600 dark:text-orange-400">Connected to Shared Instance</span>
|
||||
<span v-else class="text-green-600 dark:text-green-400">Running as Standalone Instance</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- transport mode -->
|
||||
<div class="p-1">
|
||||
<div>Transport Mode</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">
|
||||
<span v-if="appInfo.is_transport_enabled" class="text-green-600 dark:text-green-400">Transport Enabled</span>
|
||||
<span v-else class="text-orange-600 dark:text-orange-400">Transport Disabled</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- my addresses -->
|
||||
<div v-if="config" class="bg-white dark:bg-zinc-900 rounded shadow">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-zinc-200 p-2 font-semibold">My Addresses</div>
|
||||
<div class="divide-y divide-gray-200 dark:divide-zinc-800 text-gray-900 dark:text-zinc-200">
|
||||
<div class="p-1">
|
||||
<div>Identity Hash</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.identity_hash }}</div>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<div>LXMF Address</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_address_hash }}</div>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<div>LXMF Propagation Node Address</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.lxmf_local_propagation_node_address_hash }}</div>
|
||||
</div>
|
||||
<div class="p-1">
|
||||
<div>Audio Call Address</div>
|
||||
<div class="text-sm text-gray-700 dark:text-zinc-400">{{ config.audio_call_address_hash }}</div>
|
||||
<div class="address-card">
|
||||
<div class="glass-label">Audio Call Address</div>
|
||||
<div class="monospace-field break-all">{{ config.audio_call_address_hash || '—' }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -255,8 +202,13 @@
|
||||
<script>
|
||||
import Utils from "../../js/Utils";
|
||||
import ElectronUtils from "../../js/ElectronUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
export default {
|
||||
name: 'AboutPage',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
appInfo: null,
|
||||
@@ -296,6 +248,20 @@ export default {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async copyValue(value, label) {
|
||||
if(!value){
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
DialogUtils.toast?.(`${label} copied`) ?? DialogUtils.alert(`${label} copied to clipboard`);
|
||||
} catch(e) {
|
||||
DialogUtils.alert(`Failed to copy ${label}`);
|
||||
}
|
||||
},
|
||||
relaunch() {
|
||||
ElectronUtils.relaunch();
|
||||
},
|
||||
showReticulumConfigFile() {
|
||||
const reticulumConfigPath = this.appInfo.reticulum_config_path;
|
||||
if(reticulumConfigPath){
|
||||
@@ -317,6 +283,11 @@ export default {
|
||||
formatBytesPerSecond: function(bytesPerSecond) {
|
||||
return Utils.formatBytesPerSecond(bytesPerSecond);
|
||||
},
|
||||
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";
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isElectron() {
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto p-2 space-y-2">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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-5xl mx-auto w-full">
|
||||
|
||||
<!-- community interfaces -->
|
||||
<div v-if="!isEditingInterface && config != null && config.show_suggested_community_interfaces" class="bg-white rounded shadow divide-y divide-gray-200 dark:bg-zinc-900">
|
||||
<div class="flex p-2">
|
||||
<div v-if="!isEditingInterface && config != null && config.show_suggested_community_interfaces" class="bg-white/95 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-lg divide-y divide-gray-200 dark:divide-zinc-800">
|
||||
<div class="flex p-3">
|
||||
<div class="my-auto mr-auto">
|
||||
<div class="font-bold dark:text-white">Community Interfaces</div>
|
||||
<div class="text-sm dark:text-gray-100">These TCP interfaces serve as a quick way to test Reticulum. We suggest running your own as these may not always be available.</div>
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Quick start</div>
|
||||
<div class="font-semibold text-lg text-gray-900 dark:text-white">Community Interfaces</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-200">One-click helpers for public TCP relays. Spin up your own when possible to ensure availability.</div>
|
||||
</div>
|
||||
<div class="my-auto ml-2">
|
||||
<button @click="updateConfig({'show_suggested_community_interfaces': false})" type="button" class="text-gray-700 bg-gray-100 hover:bg-gray-200 p-2 rounded-full dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||
<button @click="updateConfig({'show_suggested_community_interfaces': false})" type="button" class="text-gray-700 bg-white border border-gray-200 hover:border-red-300 p-2 rounded-full shadow-sm dark:bg-zinc-800 dark:text-white dark:border-zinc-700 dark:hover:border-red-400">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5">
|
||||
<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>
|
||||
@@ -19,31 +20,31 @@
|
||||
</div>
|
||||
<div class="divide-y divide-gray-200 dark:text-white">
|
||||
|
||||
<div class="flex px-2 py-1">
|
||||
<div class="flex px-3 py-2 items-center">
|
||||
<div class="my-auto mr-auto">
|
||||
<div>RNS Testnet Amsterdam</div>
|
||||
<div class="text-xs">amsterdam.connect.reticulum.network:4965</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">RNS Testnet Amsterdam</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">amsterdam.connect.reticulum.network:4965</div>
|
||||
</div>
|
||||
<div class="ml-2 my-auto">
|
||||
<button
|
||||
@click="newInterfaceName='RNS Testnet Amsterdam';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='amsterdam.connect.reticulum.network';newInterfaceTargetPort='4965'"
|
||||
type="button"
|
||||
class="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">
|
||||
class="inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
<span>Use Interface</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex px-2 py-1">
|
||||
<div class="flex px-3 py-2 items-center">
|
||||
<div class="my-auto mr-auto">
|
||||
<div>RNS Testnet BetweenTheBorders</div>
|
||||
<div class="text-xs">reticulum.betweentheborders.com:4242</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">RNS Testnet BetweenTheBorders</div>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-300">reticulum.betweentheborders.com:4242</div>
|
||||
</div>
|
||||
<div class="ml-2 my-auto">
|
||||
<button
|
||||
@click="newInterfaceName='RNS Testnet BetweenTheBorders';newInterfaceType='TCPClientInterface';newInterfaceTargetHost='reticulum.betweentheborders.com';newInterfaceTargetPort='4242'"
|
||||
type="button"
|
||||
class="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">
|
||||
class="inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500">
|
||||
<span>Use Interface</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -53,44 +54,64 @@
|
||||
</div>
|
||||
|
||||
<!-- add interface form -->
|
||||
<div class="bg-white rounded shadow divide-y divide-gray-300 dark:divide-zinc-700 dark:bg-zinc-900">
|
||||
<div class="p-2 font-bold dark:text-white">
|
||||
<span v-if="isEditingInterface">Edit Interface</span>
|
||||
<span v-else>Add Interface</span>
|
||||
<div class="bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl">
|
||||
<div class="flex flex-wrap gap-3 items-center p-3 border-b border-gray-200 dark:border-zinc-800">
|
||||
<div>
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ isEditingInterface ? 'Update' : 'Create' }}</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ isEditingInterface ? 'Edit Interface' : 'Add Interface' }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Name your connection and select its transport type.</div>
|
||||
</div>
|
||||
<div class="flex-1"></div>
|
||||
<div class="flex gap-2">
|
||||
<button @click="loadComports" type="button" class="secondary-chip text-xs">
|
||||
Reload Ports
|
||||
</button>
|
||||
<RouterLink :to="{ name: 'interfaces' }" class="secondary-chip text-xs">
|
||||
View All
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 space-y-3">
|
||||
<div class="p-3 md:p-5 space-y-4">
|
||||
|
||||
<!-- iGeneric interface settings -->
|
||||
<!-- interface name -->
|
||||
<div>
|
||||
<FormLabel class="mb-1">Name</FormLabel>
|
||||
<FormLabel class="glass-label">Name</FormLabel>
|
||||
<input type="text" :disabled="isEditingInterface" placeholder="New Interface Name"
|
||||
v-model="newInterfaceName"
|
||||
class="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"
|
||||
:class="[ isEditingInterface ? 'cursor-not-allowed bg-gray-200' : 'bg-gray-50' ]">
|
||||
<FormSubLabel>Interface names must be unique.</FormSubLabel>
|
||||
class="input-field"
|
||||
:class="[ isEditingInterface ? 'cursor-not-allowed bg-gray-200 dark:bg-zinc-800' : '' ]">
|
||||
<FormSubLabel class="text-xs">Interface names must be unique.</FormSubLabel>
|
||||
</div>
|
||||
|
||||
<!-- interface type -->
|
||||
<div class="mb-2">
|
||||
<FormLabel class="mb-1">Type</FormLabel>
|
||||
<select v-model="newInterfaceType" 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">
|
||||
<option disabled selected>--</option>
|
||||
<option value="AutoInterface">Auto Interface</option>
|
||||
<option disabled selected>RNodes</option>
|
||||
<option value="RNodeInterface">RNode Interface</option>
|
||||
<option value="RNodeMultiInterface">RNode Multi Interface</option>
|
||||
<option disabled selected>IP Networks</option>
|
||||
<option value="TCPClientInterface">TCP Client Interface</option>
|
||||
<option value="TCPServerInterface">TCP Server Interface</option>
|
||||
<option value="UDPInterface">UDP Interface</option>
|
||||
<option value="I2PInterface">I2P Interface</option>
|
||||
<option disabled selected>Hardware</option>
|
||||
<option value="SerialInterface">Serial Interface</option>
|
||||
<option value="KISSInterface">KISS Interface</option>
|
||||
<option hidden value="AX25KISSInterface">AX.25 KISS Interface</option>
|
||||
<option disabled selected>Other</option>
|
||||
<option value="PipeInterface">Pipe Interface</option>
|
||||
<div>
|
||||
<FormLabel class="glass-label">Type</FormLabel>
|
||||
<select v-model="newInterfaceType" class="input-field">
|
||||
<option disabled selected>Pick a category…</option>
|
||||
<optgroup label="Automatic">
|
||||
<option value="AutoInterface">Auto Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="RNodes">
|
||||
<option value="RNodeInterface">RNode Interface</option>
|
||||
<option value="RNodeMultiInterface">RNode Multi Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="IP Networks">
|
||||
<option value="TCPClientInterface">TCP Client Interface</option>
|
||||
<option value="TCPServerInterface">TCP Server Interface</option>
|
||||
<option value="UDPInterface">UDP Interface</option>
|
||||
<option value="I2PInterface">I2P Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="Hardware">
|
||||
<option value="SerialInterface">Serial Interface</option>
|
||||
<option value="KISSInterface">KISS Interface</option>
|
||||
<option value="AX25KISSInterface">AX.25 KISS Interface</option>
|
||||
</optgroup>
|
||||
<optgroup label="Pipelines">
|
||||
<option value="PipeInterface">Pipe Interface</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<FormSubLabel>
|
||||
Need help? <a class="text-blue-500 underline" href="https://reticulum.network/manual/interfaces.html" target="_blank">Reticulum Docs: Configuring Interfaces</a>
|
||||
@@ -101,13 +122,13 @@
|
||||
<!-- interface target host -->
|
||||
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
|
||||
<FormLabel class="mb-1">Target Host</FormLabel>
|
||||
<input type="text" placeholder="e.g: example.com" v-model="newInterfaceTargetHost" 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">
|
||||
<input type="text" placeholder="e.g: example.com" v-model="newInterfaceTargetHost" class="input-field">
|
||||
</div>
|
||||
|
||||
<!-- interface target port -->
|
||||
<div v-if="newInterfaceType === 'TCPClientInterface'" class="mb-2">
|
||||
<FormLabel class="mb-1">Target Port</FormLabel>
|
||||
<input type="text" placeholder="e.g: 1234" v-model="newInterfaceTargetPort" 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">
|
||||
<input type="text" placeholder="e.g: 1234" v-model="newInterfaceTargetPort" class="input-field">
|
||||
</div>
|
||||
|
||||
<!-- TCPServerInterface -->
|
||||
@@ -1317,3 +1338,24 @@ export default {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glass-card {
|
||||
@apply bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl;
|
||||
}
|
||||
.input-field {
|
||||
@apply bg-gray-50/90 dark:bg-zinc-900/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;
|
||||
}
|
||||
.glass-label {
|
||||
@apply mb-1 text-sm font-semibold text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
.primary-chip {
|
||||
@apply inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
|
||||
}
|
||||
.secondary-chip {
|
||||
@apply inline-flex items-center gap-x-2 rounded-full border border-gray-300 dark:border-zinc-700 px-3 py-1.5 text-xs font-semibold text-gray-700 dark:text-gray-100 bg-white/80 dark:bg-zinc-900/70 hover:border-blue-400;
|
||||
}
|
||||
.glass-field {
|
||||
@apply space-y-1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -125,9 +125,10 @@ export default {
|
||||
this.importableInterfaces = [];
|
||||
this.selectedInterfaces = [];
|
||||
},
|
||||
dismiss() {
|
||||
dismiss(result = false) {
|
||||
this.isShowing = false;
|
||||
this.$emit("dismissed");
|
||||
const imported = result === true;
|
||||
this.$emit("dismissed", imported);
|
||||
},
|
||||
clearSelectedFile() {
|
||||
this.selectedFile = null;
|
||||
@@ -221,7 +222,7 @@ export default {
|
||||
});
|
||||
|
||||
// dismiss modal
|
||||
this.dismiss();
|
||||
this.dismiss(true);
|
||||
|
||||
// tell user interfaces were imported
|
||||
DialogUtils.alert("Interfaces imported successfully. MeshChat must be restarted for these changes to take effect.");
|
||||
|
||||
@@ -1,207 +1,117 @@
|
||||
<template>
|
||||
<div class="border rounded bg-white shadow dark:bg-zinc-800 dark:border-zinc-700">
|
||||
|
||||
<!-- IFAC info -->
|
||||
<div v-if="iface._stats?.ifac_signature != null" class="bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-b dark:bg-zinc-800 dark:border-zinc-700">
|
||||
<div class="flex text-sm">
|
||||
<div class="my-auto">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-4 text-green-500">
|
||||
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 0 0-4.5 4.5V9H5a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2h-.5V5.5A4.5 4.5 0 0 0 10 1Zm3 8V5.5a3 3 0 1 0-6 0V9h6Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<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) ? 'Enabled' : 'Disabled' }}</span>
|
||||
</div>
|
||||
<span class="ml-1 my-auto">
|
||||
<span class="text-green-500">{{ iface._stats.ifac_size * 8 }}-bit IFAC</span> <span v-if="iface._stats?.ifac_netname != null">• Network Name: <span class="text-purple-500">{{ iface._stats.ifac_netname }}</span></span> • Signature <span @click="onIFACSignatureClick(iface._stats.ifac_signature)" class="cursor-pointer"><{{ iface._stats.ifac_signature.slice(0, 6) }}...{{ iface._stats.ifac_signature.slice(-6) }}></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex py-2">
|
||||
|
||||
<!-- icon -->
|
||||
<div class="my-auto mx-2">
|
||||
|
||||
<svg v-if="iface.type === 'AutoInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
||||
<path d="M219.31,108.68l-80-80a16,16,0,0,0-22.62,0l-80,80A15.87,15.87,0,0,0,32,120v96a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V160h32v56a8,8,0,0,0,8,8h64a8,8,0,0,0,8-8V120A15.87,15.87,0,0,0,219.31,108.68ZM208,208H160V152a8,8,0,0,0-8-8H104a8,8,0,0,0-8,8v56H48V120l80-80,80,80Z"></path>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'RNodeInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
||||
<path d="M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z"></path>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'TCPClientInterface' || iface.type === 'TCPServerInterface' || iface.type === 'UDPInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
||||
<path d="M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm88,104a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.84a157.44,157.44,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154,88H102a115.11,115.11,0,0,1,26-45A115.27,115.27,0,0,1,154,88Zm52.33,0H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM107.59,42.4A135.28,135.28,0,0,0,85.29,88H49.63A88.29,88.29,0,0,1,107.59,42.4ZM49.63,168H85.29a135.28,135.28,0,0,0,22.3,45.6A88.29,88.29,0,0,1,49.63,168Zm98.78,45.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z"></path>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'SerialInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
||||
<path d="M252.44,121.34l-48-32A8,8,0,0,0,192,96v24H72V72h33a32,32,0,1,0,0-16H72A16,16,0,0,0,56,72v48H8a8,8,0,0,0,0,16H56v48a16,16,0,0,0,16,16h32v8a16,16,0,0,0,16,16h32a16,16,0,0,0,16-16V176a16,16,0,0,0-16-16H120a16,16,0,0,0-16,16v8H72V136H192v24a8,8,0,0,0,12.44,6.66l48-32a8,8,0,0,0,0-13.32ZM136,48a16,16,0,1,1-16,16A16,16,0,0,1,136,48ZM120,176h32v32H120Zm88-30.95V111l25.58,17Z"></path>
|
||||
</svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'RNodeMultiInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M128,88a40,40,0,1,0,40,40A40,40,0,0,0,128,88Zm0,64a24,24,0,1,1,24-24A24,24,0,0,1,128,152Zm73.71,7.14a80,80,0,0,1-14.08,22.2,8,8,0,0,1-11.92-10.67,63.95,63.95,0,0,0,0-85.33,8,8,0,1,1,11.92-10.67,80.08,80.08,0,0,1,14.08,84.47ZM69,103.09a64,64,0,0,0,11.26,67.58,8,8,0,0,1-11.92,10.67,79.93,79.93,0,0,1,0-106.67A8,8,0,1,1,80.29,85.34,63.77,63.77,0,0,0,69,103.09ZM248,128a119.58,119.58,0,0,1-34.29,84,8,8,0,1,1-11.42-11.2,103.9,103.9,0,0,0,0-145.56A8,8,0,1,1,213.71,44,119.58,119.58,0,0,1,248,128ZM53.71,200.78A8,8,0,1,1,42.29,212a119.87,119.87,0,0,1,0-168,8,8,0,1,1,11.42,11.2,103.9,103.9,0,0,0,0,145.56Z"></path></svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'I2PInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M72,92A12,12,0,1,1,60,80,12,12,0,0,1,72,92Zm56-12a12,12,0,1,0,12,12A12,12,0,0,0,128,80Zm68,24a12,12,0,1,0-12-12A12,12,0,0,0,196,104ZM60,152a12,12,0,1,0,12,12A12,12,0,0,0,60,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,128,152Zm68,0a12,12,0,1,0,12,12A12,12,0,0,0,196,152Z"></path></svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'KISSInterface' || iface.type === 'AX25KISSInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M104,168a8,8,0,0,1-8,8H64a8,8,0,0,1,0-16H96A8,8,0,0,1,104,168Zm-8-40H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16Zm0-32H64a8,8,0,0,0,0,16H96a8,8,0,0,0,0-16ZM232,80V192a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V72a8,8,0,0,1,5.7-7.66l160-48a8,8,0,0,1,4.6,15.33L86.51,64H216A16,16,0,0,1,232,80ZM216,192V80H40V192H216Zm-16-56a40,40,0,1,1-40-40A40,40,0,0,1,200,136Zm-16,0a24,24,0,1,0-24,24A24,24,0,0,0,184,136Z"></path></svg>
|
||||
|
||||
<svg v-else-if="iface.type === 'PipeInterface'" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white"><path d="M128,128a8,8,0,0,1-3,6.25l-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32A8,8,0,0,1,128,128Zm48,24H136a8,8,0,0,0,0,16h40a8,8,0,0,0,0-16Zm56-96V200a16,16,0,0,1-16,16H40a16,16,0,0,1-16-16V56A16,16,0,0,1,40,40H216A16,16,0,0,1,232,56ZM216,200V56H40V200H216Z"></path></svg>
|
||||
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 256 256" class="size-6 dark:text-white">
|
||||
<path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path>
|
||||
</svg>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- interface details -->
|
||||
<div>
|
||||
<div class="font-semibold leading-5 dark:text-white">{{ iface._name }}</div>
|
||||
<div class="text-sm flex space-x-1 dark:text-zinc-100">
|
||||
|
||||
<!-- auto interface -->
|
||||
<div v-if="iface.type === 'AutoInterface'">
|
||||
{{ iface.type }} • Ethernet and WiFi
|
||||
</div>
|
||||
|
||||
<!-- tcp client interface -->
|
||||
<div v-else-if="iface.type === 'TCPClientInterface'">
|
||||
{{ iface.type }} • {{ iface.target_host }}:{{ iface.target_port }}
|
||||
</div>
|
||||
|
||||
<!-- tcp server interface -->
|
||||
<div v-else-if="iface.type === 'TCPServerInterface'">
|
||||
{{ iface.type }} • {{ iface.listen_ip }}:{{ iface.listen_port }}
|
||||
</div>
|
||||
|
||||
<!-- other interface types -->
|
||||
<div v-else>{{ iface.type }}</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 class="stat-chip" v-if="iface._stats?.bitrate">Bitrate {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</span>
|
||||
<span class="stat-chip">TX {{ formatBytes(iface._stats?.txb ?? 0) }}</span>
|
||||
<span class="stat-chip">RX {{ formatBytes(iface._stats?.rxb ?? 0) }}</span>
|
||||
<span class="stat-chip" v-if="iface.type === 'RNodeInterface' && iface._stats?.noise_floor">Noise {{ iface._stats?.noise_floor }} dBm</span>
|
||||
<span class="stat-chip" v-if="iface._stats?.clients != null">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 @click="onIFACSignatureClick(iface._stats.ifac_signature)" type="button" class="text-blue-500 hover:underline">
|
||||
{{ iface._stats.ifac_signature.slice(0, 8) }}…{{ iface._stats.ifac_signature.slice(-8) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- enabled state badge -->
|
||||
<div class="ml-auto my-auto mr-2">
|
||||
<span v-if="isInterfaceEnabled(iface)" class="inline-flex items-center rounded-full bg-green-50 px-2 py-1 text-xs font-medium text-green-700 ring-1 ring-inset ring-green-600/20">Enabled</span>
|
||||
<span v-else class="inline-flex items-center rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-700 ring-1 ring-inset ring-red-600/20">Disabled</span>
|
||||
</div>
|
||||
|
||||
<!-- enable/disable interface button -->
|
||||
<div class="my-auto mr-1">
|
||||
<button v-if="isInterfaceEnabled(iface)" @click="disableInterface" type="button" class="cursor-pointer">
|
||||
<span class="flex text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
||||
</svg>
|
||||
</span>
|
||||
<div class="flex flex-col gap-2 items-end">
|
||||
<button
|
||||
v-if="isInterfaceEnabled(iface)"
|
||||
@click="disableInterface"
|
||||
type="button"
|
||||
class="secondary-chip text-xs"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="power" class="w-4 h-4"/>
|
||||
Disable
|
||||
</button>
|
||||
<button v-else @click="enableInterface" type="button" class="cursor-pointer">
|
||||
<span class="flex text-gray-700 bg-gray-100 dark:bg-zinc-600 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500 hover:bg-gray-200 p-2 rounded-full">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M5.636 5.636a9 9 0 1 0 12.728 0M12 3v9" />
|
||||
</svg>
|
||||
</span>
|
||||
<button
|
||||
v-else
|
||||
@click="enableInterface"
|
||||
type="button"
|
||||
class="primary-chip text-xs"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="power" class="w-4 h-4"/>
|
||||
Enable
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="my-auto mr-2">
|
||||
<DropDownMenu>
|
||||
<template v-slot:button>
|
||||
<template #button>
|
||||
<IconButton>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
|
||||
</svg>
|
||||
<MaterialDesignIcon icon-name="dots-vertical" class="w-5 h-5"/>
|
||||
</IconButton>
|
||||
</template>
|
||||
<template v-slot:items>
|
||||
|
||||
<!-- enable/disable interface button -->
|
||||
<div class="border-b dark:border-zinc-700">
|
||||
|
||||
<!-- enable interface button -->
|
||||
<DropDownMenuItem v-if="isInterfaceEnabled(iface)" @click="disableInterface">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Disable Interface</span>
|
||||
<template #items>
|
||||
<div class="max-h-60 overflow-auto py-1 space-y-1 pr-1">
|
||||
<DropDownMenuItem @click="editInterface">
|
||||
<MaterialDesignIcon icon-name="pencil" class="w-5 h-5"/>
|
||||
<span>Edit Interface</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<DropDownMenuItem v-else @click="enableInterface">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 0 1 .75.75v9a.75.75 0 0 1-1.5 0V3a.75.75 0 0 1 .75-.75ZM6.166 5.106a.75.75 0 0 1 0 1.06 8.25 8.25 0 1 0 11.668 0 .75.75 0 1 1 1.06-1.06c3.808 3.807 3.808 9.98 0 13.788-3.807 3.808-9.98 3.808-13.788 0-3.808-3.807-3.808-9.98 0-13.788a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Enable Interface</span>
|
||||
<DropDownMenuItem @click="exportInterface">
|
||||
<MaterialDesignIcon icon-name="export" class="w-5 h-5"/>
|
||||
<span>Export Interface</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- edit interface button -->
|
||||
<DropDownMenuItem @click="editInterface">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path d="M21.731 2.269a2.625 2.625 0 0 0-3.712 0l-1.157 1.157 3.712 3.712 1.157-1.157a2.625 2.625 0 0 0 0-3.712ZM19.513 8.199l-3.712-3.712-12.15 12.15a5.25 5.25 0 0 0-1.32 2.214l-.8 2.685a.75.75 0 0 0 .933.933l2.685-.8a5.25 5.25 0 0 0 2.214-1.32L19.513 8.2Z" />
|
||||
</svg>
|
||||
<span>Edit Interface</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- export interface button -->
|
||||
<DropDownMenuItem @click="exportInterface">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M12 2.25a.75.75 0 0 1 .75.75v11.69l3.22-3.22a.75.75 0 1 1 1.06 1.06l-4.5 4.5a.75.75 0 0 1-1.06 0l-4.5-4.5a.75.75 0 1 1 1.06-1.06l3.22 3.22V3a.75.75 0 0 1 .75-.75Zm-9 13.5a.75.75 0 0 1 .75.75v2.25a1.5 1.5 0 0 0 1.5 1.5h13.5a1.5 1.5 0 0 0 1.5-1.5V16.5a.75.75 0 0 1 1.5 0v2.25a3 3 0 0 1-3 3H5.25a3 3 0 0 1-3-3V16.5a.75.75 0 0 1 .75-.75Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Export Interface</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- delete interface button -->
|
||||
<div class="border-t dark:border-zinc-700">
|
||||
<DropDownMenuItem @click="deleteInterface">
|
||||
<svg class="size-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<MaterialDesignIcon icon-name="trash-can" class="w-5 h-5 text-red-500"/>
|
||||
<span class="text-red-500">Delete Interface</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- extra interface details -->
|
||||
<div v-if="['UDPInterface', 'RNodeInterface'].includes(iface.type)" class="p-1 text-sm border-t dark:text-zinc-100 dark:border-zinc-700">
|
||||
|
||||
<!-- udp interface -->
|
||||
<div v-if="iface.type === 'UDPInterface'">
|
||||
<div>Listen: {{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||
<div>Forward: {{ iface.forward_ip }}:{{ iface.forward_port }}</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">Listen</div>
|
||||
<div class="detail-value">{{ iface.listen_ip }}:{{ iface.listen_port }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">Forward</div>
|
||||
<div class="detail-value">{{ iface.forward_ip }}:{{ iface.forward_port }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- rnode interface details -->
|
||||
<div v-else-if="iface.type === 'RNodeInterface'">
|
||||
<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 v-else-if="iface.type === 'RNodeInterface'" class="detail-grid">
|
||||
<div>
|
||||
<div class="detail-label">Port</div>
|
||||
<div class="detail-value">{{ iface.port }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">Frequency</div>
|
||||
<div class="detail-value">{{ formatFrequency(iface.frequency) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">Bandwidth</div>
|
||||
<div class="detail-value">{{ formatFrequency(iface.bandwidth) }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">Spreading Factor</div>
|
||||
<div class="detail-value">{{ iface.spreadingfactor }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">Coding Rate</div>
|
||||
<div class="detail-value">{{ iface.codingrate }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="detail-label">TX Power</div>
|
||||
<div class="detail-value">{{ iface.txpower }} dBm</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="flex bg-gray-50 p-1 text-sm text-gray-500 space-x-1 border-t rounded-b dark:bg-zinc-800 dark:text-white dark:border-zinc-700">
|
||||
|
||||
<!-- status -->
|
||||
<div v-if="iface._stats?.status === true" class="text-sm text-green-500">Connected</div>
|
||||
<div v-else class="text-sm text-red-500">Disconnected</div>
|
||||
|
||||
<!-- stats -->
|
||||
<div>• Bitrate: {{ formatBitsPerSecond(iface._stats?.bitrate ?? 0) }}</div>
|
||||
<div>• TX: {{ formatBytes(iface._stats?.txb ?? 0) }}</div>
|
||||
<div>• RX: {{ formatBytes(iface._stats?.rxb ?? 0) }}</div>
|
||||
<div v-if="iface.type === 'RNodeInterface'">• Noise Floor: {{
|
||||
iface._stats?.noise_floor
|
||||
}} dBm
|
||||
</div>
|
||||
<div v-if="iface._stats?.clients != null">• Clients: {{ iface._stats?.clients }}</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -211,6 +121,7 @@ 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',
|
||||
@@ -218,6 +129,7 @@ export default {
|
||||
DropDownMenu,
|
||||
IconButton,
|
||||
DropDownMenuItem,
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
iface: Object,
|
||||
@@ -259,5 +171,81 @@ export default {
|
||||
return Utils.formatFrequency(hz);
|
||||
},
|
||||
},
|
||||
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";
|
||||
},
|
||||
},
|
||||
}
|
||||
</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;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -1,85 +1,87 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto p-2 space-y-2">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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">
|
||||
|
||||
<!-- warning - keeping orange-500 for warning visibility in both modes -->
|
||||
<div class="flex bg-orange-500 p-2 text-sm font-semibold leading-6 text-white rounded shadow">
|
||||
<div class="my-auto">
|
||||
<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="m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z" />
|
||||
</svg>
|
||||
<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">Restart required</div>
|
||||
<div class="text-sm">Reticulum MeshChat must be restarted for any interface changes to take effect.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2 my-auto">Reticulum MeshChat must be restarted for any interface changes to take effect.</div>
|
||||
<button v-if="isElectron"
|
||||
@click="relaunch"
|
||||
type="button"
|
||||
class="ml-auto my-auto inline-flex items-center gap-x-1 rounded-md bg-white dark:bg-zinc-800 px-2 py-1 text-sm font-semibold text-black dark:text-zinc-200 shadow-sm hover:bg-gray-50 dark:hover:bg-zinc-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white dark:focus-visible:outline-zinc-700">
|
||||
<span>Restart Now</span>
|
||||
<button v-if="isElectron" @click="relaunch" 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">
|
||||
<MaterialDesignIcon icon-name="restart" class="w-4 h-4"/>
|
||||
Restart now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1">
|
||||
|
||||
<!-- Add Interface button -->
|
||||
<RouterLink :to="{ name: 'interfaces.add' }">
|
||||
<button type="button"
|
||||
class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
|
||||
</svg>
|
||||
<span>Add Interface</span>
|
||||
</button>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Import button -->
|
||||
<div class="my-auto">
|
||||
<button @click="showImportInterfacesModal" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
</svg>
|
||||
<span>Import</span>
|
||||
</button>
|
||||
<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">Manage</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Search, filter and export your Reticulum adapters.</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"/>
|
||||
Add Interface
|
||||
</RouterLink>
|
||||
<button @click="showImportInterfacesModal" type="button" class="secondary-chip text-sm">
|
||||
<MaterialDesignIcon icon-name="import" class="w-4 h-4"/>
|
||||
Import
|
||||
</button>
|
||||
<button @click="exportInterfaces" type="button" class="secondary-chip text-sm">
|
||||
<MaterialDesignIcon icon-name="export" class="w-4 h-4"/>
|
||||
Export all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export button -->
|
||||
<div class="my-auto">
|
||||
<button @click="exportInterfaces" type="button" class="inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-700 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-600 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-700">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
|
||||
</svg>
|
||||
<span>Export</span>
|
||||
</button>
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1">
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
placeholder="Search by name, type, host..."
|
||||
class="input-field"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button type="button" @click="setStatusFilter('all')" :class="filterChipClass(statusFilter === 'all')">All</button>
|
||||
<button type="button" @click="setStatusFilter('enabled')" :class="filterChipClass(statusFilter === 'enabled')">Enabled</button>
|
||||
<button type="button" @click="setStatusFilter('disabled')" :class="filterChipClass(statusFilter === 'disabled')">Disabled</button>
|
||||
</div>
|
||||
<div class="w-full sm:w-60">
|
||||
<select v-model="typeFilter" class="input-field">
|
||||
<option value="all">All types</option>
|
||||
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">{{ type }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- enabled interfaces -->
|
||||
<Interface
|
||||
v-for="iface of enabledInterfaces"
|
||||
:iface="iface"
|
||||
@enable="enableInterface(iface._name)"
|
||||
@disable="disableInterface(iface._name)"
|
||||
@edit="editInterface(iface._name)"
|
||||
@export="exportInterface(iface._name)"
|
||||
@delete="deleteInterface(iface._name)"/>
|
||||
|
||||
<!-- disabled interfaces -->
|
||||
<div v-if="disabledInterfaces.length > 0" class="font-semibold dark:text-zinc-200">Disabled Interfaces</div>
|
||||
<Interface
|
||||
v-for="iface of disabledInterfaces"
|
||||
:iface="iface"
|
||||
@enable="enableInterface(iface._name)"
|
||||
@disable="disableInterface(iface._name)"
|
||||
@edit="editInterface(iface._name)"
|
||||
@export="exportInterface(iface._name)"
|
||||
@delete="deleteInterface(iface._name)"/>
|
||||
<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">No interfaces found</div>
|
||||
<div class="text-sm">Adjust your search or add a new interface.</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>
|
||||
|
||||
<!-- Import Dialog -->
|
||||
<ImportInterfacesModal ref="import-interfaces-modal" @dismissed="onImportInterfacesModalDismissed"/>
|
||||
|
||||
</template>
|
||||
|
||||
<script>
|
||||
@@ -89,18 +91,24 @@ 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,
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -121,6 +129,9 @@ export default {
|
||||
relaunch() {
|
||||
ElectronUtils.relaunch();
|
||||
},
|
||||
trackInterfaceChange() {
|
||||
this.hasPendingInterfaceChanges = true;
|
||||
},
|
||||
isInterfaceEnabled: function(iface) {
|
||||
return Utils.isInterfaceEnabled(iface);
|
||||
},
|
||||
@@ -155,6 +166,7 @@ export default {
|
||||
await window.axios.post(`/api/v1/reticulum/interfaces/enable`, {
|
||||
name: interfaceName,
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
} catch(e) {
|
||||
DialogUtils.alert("failed to enable interface");
|
||||
console.log(e);
|
||||
@@ -171,6 +183,7 @@ export default {
|
||||
await window.axios.post(`/api/v1/reticulum/interfaces/disable`, {
|
||||
name: interfaceName,
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
} catch(e) {
|
||||
DialogUtils.alert("failed to disable interface");
|
||||
console.log(e);
|
||||
@@ -200,6 +213,7 @@ export default {
|
||||
await window.axios.post(`/api/v1/reticulum/interfaces/delete`, {
|
||||
name: interfaceName,
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
} catch(e) {
|
||||
DialogUtils.alert("failed to delete interface");
|
||||
console.log(e);
|
||||
@@ -214,6 +228,7 @@ export default {
|
||||
|
||||
// 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]));
|
||||
@@ -232,6 +247,7 @@ export default {
|
||||
interfaceName,
|
||||
],
|
||||
});
|
||||
this.trackInterfaceChange();
|
||||
|
||||
// download file to browser
|
||||
DownloadUtils.downloadFile(`${interfaceName}.txt`, new Blob([response.data]));
|
||||
@@ -244,15 +260,29 @@ export default {
|
||||
showImportInterfacesModal() {
|
||||
this.$refs["import-interfaces-modal"].show();
|
||||
},
|
||||
onImportInterfacesModalDismissed() {
|
||||
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";
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isElectron() {
|
||||
return ElectronUtils.isElectron();
|
||||
},
|
||||
showRestartReminder() {
|
||||
return this.hasPendingInterfaceChanges;
|
||||
},
|
||||
interfacesWithStats() {
|
||||
const results = [];
|
||||
for(const [interfaceName, iface] of Object.entries(this.interfaces)){
|
||||
@@ -268,6 +298,43 @@ export default {
|
||||
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();
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
<template>
|
||||
<div class="inline-flex rounded-md shadow-sm">
|
||||
<div class="inline-flex">
|
||||
|
||||
<button v-if="isRecordingAudioAttachment" @click="stopRecordingAudioAttachment" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2.5 py-1.5 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 dark:bg-zinc-800 dark:text-white dark:hover:bg-zinc-700 dark:focus-visible:outline-zinc-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
|
||||
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
|
||||
<path d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z" />
|
||||
</svg>
|
||||
<button v-if="isRecordingAudioAttachment" @click="stopRecordingAudioAttachment" 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">
|
||||
<MaterialDesignIcon icon-name="microphone" class="w-4 h-4"/>
|
||||
<span class="ml-1">
|
||||
<slot/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button v-else @click="showMenu" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5">
|
||||
<path d="M7 4a3 3 0 0 1 6 0v6a3 3 0 1 1-6 0V4Z" />
|
||||
<path d="M5.5 9.643a.75.75 0 0 0-1.5 0V10c0 3.06 2.29 5.585 5.25 5.954V17.5h-1.5a.75.75 0 0 0 0 1.5h4.5a.75.75 0 0 0 0-1.5h-1.5v-1.546A6.001 6.001 0 0 0 16 10v-.357a.75.75 0 0 0-1.5 0V10a4.5 4.5 0 0 1-9 0v-.357Z" />
|
||||
</svg>
|
||||
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Voice</span>
|
||||
<button v-else @click="showMenu" 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">
|
||||
<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">
|
||||
@@ -41,8 +35,12 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: 'AddAudioButton',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
props: {
|
||||
isRecordingAudioAttachment: Boolean,
|
||||
},
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
<template>
|
||||
<div class="inline-flex rounded-md shadow-sm">
|
||||
<div class="inline-flex">
|
||||
|
||||
<button @click="showMenu" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M1.5 6a2.25 2.25 0 0 1 2.25-2.25h16.5A2.25 2.25 0 0 1 22.5 6v12a2.25 2.25 0 0 1-2.25 2.25H3.75A2.25 2.25 0 0 1 1.5 18V6ZM3 16.06V18c0 .414.336.75.75.75h16.5A.75.75 0 0 0 21 18v-1.94l-2.69-2.689a1.5 1.5 0 0 0-2.12 0l-.88.879.97.97a.75.75 0 1 1-1.06 1.06l-5.16-5.159a1.5 1.5 0 0 0-2.12 0L3 16.061Zm10.125-7.81a1.125 1.125 0 1 1 2.25 0 1.125 1.125 0 0 1-2.25 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Image</span>
|
||||
<button @click="showMenu" 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">
|
||||
<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">
|
||||
@@ -36,8 +34,12 @@
|
||||
<script>
|
||||
import Compressor from 'compressorjs';
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: 'AddImageButton',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
emits: [
|
||||
"add-image",
|
||||
],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
|
||||
<!-- peer selected -->
|
||||
<div v-if="selectedPeer" class="flex flex-col h-full bg-white overflow-hidden sm:m-2 sm:border sm:rounded-xl sm:shadow dark:bg-zinc-950 dark:border-zinc-800">
|
||||
<div v-if="selectedPeer" class="flex flex-col h-full bg-white/90 dark:bg-zinc-950/80 backdrop-blur overflow-hidden sm:m-3 sm:border sm:rounded-2xl sm:shadow-xl border-gray-200 dark:border-zinc-800 transition-colors">
|
||||
|
||||
<!-- header -->
|
||||
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
||||
@@ -17,15 +17,17 @@
|
||||
</div>
|
||||
|
||||
<!-- peer info -->
|
||||
<div>
|
||||
<div @click="updateCustomDisplayName" class="flex cursor-pointer">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div @click="updateCustomDisplayName" class="flex cursor-pointer min-w-0">
|
||||
<div v-if="selectedPeer.custom_display_name != null" class="my-auto mr-1 dark:text-white" title="Custom Display Name">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M9.568 3H5.25A2.25 2.25 0 0 0 3 5.25v4.318c0 .597.237 1.17.659 1.591l9.581 9.581c.699.699 1.78.872 2.607.33a18.095 18.095 0 0 0 5.223-5.223c.542-.827.369-1.908-.33-2.607L11.16 3.66A2.25 2.25 0 0 0 9.568 3Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 6h.008v.008H6V6Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto font-semibold dark:text-white" :title="selectedPeer.display_name">{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}</div>
|
||||
<div class="my-auto font-semibold dark:text-white truncate max-w-xs sm:max-w-sm" :title="selectedPeer.custom_display_name ?? selectedPeer.display_name">
|
||||
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-sm dark:text-zinc-300">
|
||||
|
||||
@@ -70,6 +72,13 @@
|
||||
@set-custom-display-name="updateCustomDisplayName"/>
|
||||
</div>
|
||||
|
||||
<!-- popout button -->
|
||||
<div class="my-auto mr-2">
|
||||
<IconButton @click="openConversationPopout" title="Pop out chat">
|
||||
<MaterialDesignIcon icon-name="open-in-new" class="w-5 h-5"/>
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
<!-- close button -->
|
||||
<div class="my-auto mr-2">
|
||||
<IconButton @click="close">
|
||||
@@ -97,8 +106,16 @@
|
||||
<div v-if="chatItem.lxmf_message.content" style="white-space:pre-wrap;word-break:break-word;font-family:inherit;">{{ chatItem.lxmf_message.content }}</div>
|
||||
|
||||
<!-- image field -->
|
||||
<div v-if="chatItem.lxmf_message.fields?.image">
|
||||
<img @click.stop="openImage(`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`)" :src="`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`" class="w-full rounded-md cursor-pointer"/>
|
||||
<div v-if="chatItem.lxmf_message.fields?.image" class="relative group">
|
||||
<img
|
||||
@click.stop="openImage(`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`)"
|
||||
:src="`data:image/${chatItem.lxmf_message.fields.image.image_type};base64,${chatItem.lxmf_message.fields.image.image_bytes}`"
|
||||
class="w-full rounded-md cursor-pointer"/>
|
||||
<div class="absolute bottom-1 left-1 bg-black/70 text-white text-xs px-2 py-0.5 rounded-full flex space-x-1">
|
||||
<span>{{ (chatItem.lxmf_message.fields.image.image_type ?? 'image').toUpperCase() }}</span>
|
||||
<span>•</span>
|
||||
<span>{{ formatBase64Bytes(chatItem.lxmf_message.fields.image.image_bytes) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- audio field -->
|
||||
@@ -129,17 +146,30 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="text-xs mt-1" :class="chatItem.is_outbound ? 'text-white/80' : 'text-gray-600'">
|
||||
Audio • {{ formatBase64Bytes(chatItem.lxmf_message.fields.audio.audio_bytes) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- file attachment fields -->
|
||||
<div v-if="chatItem.lxmf_message.fields?.file_attachments" class="space-y-1">
|
||||
<a @click.stop target="_blank" :download="file_attachment.file_name" :href="`data:application/octet-stream;base64,${file_attachment.file_bytes}`" v-for="file_attachment of chatItem.lxmf_message.fields?.file_attachments ?? []" class="flex border border-gray-300 dark:border-zinc-800 hover:bg-gray-100 rounded px-2 py-1 text-sm text-gray-700 font-semibold cursor-pointer space-x-2 bg-[#efefef]">
|
||||
<a
|
||||
v-for="file_attachment of chatItem.lxmf_message.fields?.file_attachments ?? []"
|
||||
:key="file_attachment.file_name"
|
||||
@click.stop
|
||||
target="_blank"
|
||||
:download="file_attachment.file_name"
|
||||
:href="`data:application/octet-stream;base64,${file_attachment.file_bytes}`"
|
||||
class="flex border border-gray-300 dark:border-zinc-800 hover:bg-gray-100 rounded px-2 py-1 text-sm text-gray-700 font-semibold cursor-pointer space-x-2 bg-[#efefef]">
|
||||
<div class="my-auto">
|
||||
<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="m18.375 12.739-7.693 7.693a4.5 4.5 0 0 1-6.364-6.364l10.94-10.94A3 3 0 1 1 19.5 7.372L8.552 18.32m.009-.01-.01.01m5.699-9.941-7.81 7.81a1.5 1.5 0 0 0 2.112 2.13"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="my-auto w-full">{{ file_attachment.file_name }}</div>
|
||||
<div class="my-auto w-full">
|
||||
<div>{{ file_attachment.file_name }}</div>
|
||||
<div class="text-xs font-normal text-gray-500">{{ formatBase64Bytes(file_attachment.file_bytes) }}</div>
|
||||
</div>
|
||||
<div class="my-auto">
|
||||
<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="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
||||
@@ -239,77 +269,46 @@
|
||||
<!-- message composer -->
|
||||
<div>
|
||||
|
||||
<!-- image attachment -->
|
||||
<div v-if="newMessageImage" class="mb-2">
|
||||
<div @click.stop="openImage(newMessageImageUrl)" class="cursor-pointer w-32 h-32 rounded shadow border relative overflow-hidden">
|
||||
|
||||
<!-- image preview -->
|
||||
<img v-if="newMessageImageUrl" :src="newMessageImageUrl" class="w-full h-full object-cover"/>
|
||||
|
||||
<!-- remove button (top right) -->
|
||||
<div class="absolute top-0 right-0 p-1">
|
||||
<div @click.stop="removeImageAttachment" class="cursor-pointer">
|
||||
<div class="flex text-gray-700 bg-gray-100 hover:bg-gray-200 p-1 rounded-full">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<!-- image attachment -->
|
||||
<div v-if="newMessageImage" class="attachment-card">
|
||||
<div class="attachment-card__preview" @click.stop="openImage(newMessageImageUrl)">
|
||||
<img v-if="newMessageImageUrl" :src="newMessageImageUrl" class="w-full h-full object-cover rounded-lg"/>
|
||||
</div>
|
||||
|
||||
<!-- image size (bottom left) -->
|
||||
<div class="absolute bottom-0 left-0 p-1">
|
||||
<div class="bg-gray-100 rounded border text-sm px-1">{{ formatBytes(newMessageImage.size) }}</div>
|
||||
<div class="attachment-card__body">
|
||||
<div class="attachment-card__title">Image Attachment</div>
|
||||
<div class="attachment-card__meta">{{ formatBytes(newMessageImage.size) }}</div>
|
||||
</div>
|
||||
|
||||
<button @click.stop="removeImageAttachment" type="button" class="attachment-card__remove">
|
||||
<MaterialDesignIcon icon-name="close" class="w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- audio attachment -->
|
||||
<div v-if="newMessageAudio" class="mb-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div class="flex border border-gray-300 dark:border-zinc-800 rounded text-gray-700 divide-x divide-gray-300 overflow-hidden">
|
||||
|
||||
<div class="flex p-1">
|
||||
|
||||
<!-- audio preview -->
|
||||
<div>
|
||||
<audio controls class="h-10">
|
||||
<source :src="newMessageAudio.audio_preview_url" type="audio/wav"/>
|
||||
</audio>
|
||||
</div>
|
||||
|
||||
<!-- encoded file size -->
|
||||
<div class="my-auto px-1 text-sm text-gray-500">
|
||||
{{ formatBytes(newMessageAudio.audio_blob.size) }}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- remove audio attachment -->
|
||||
<div @click="removeAudioAttachment" class="flex my-auto text-sm text-gray-500 h-full px-1 hover:bg-gray-200 cursor-pointer">
|
||||
<svg class="w-5 h-5 my-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- audio attachment -->
|
||||
<div v-if="newMessageAudio" class="attachment-card">
|
||||
<div class="attachment-card__body w-full">
|
||||
<div class="attachment-card__title">Voice Note</div>
|
||||
<div class="attachment-card__meta">{{ formatBytes(newMessageAudio.audio_blob.size) }}</div>
|
||||
<audio controls class="w-full mt-2 rounded-full bg-white/60 dark:bg-zinc-800/70">
|
||||
<source :src="newMessageAudio.audio_preview_url" type="audio/wav"/>
|
||||
</audio>
|
||||
</div>
|
||||
<button @click="removeAudioAttachment" type="button" class="attachment-card__remove">
|
||||
<MaterialDesignIcon icon-name="delete" class="w-4 h-4"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- file attachments -->
|
||||
<div v-if="newMessageFiles.length > 0" class="mb-2">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<div v-for="file in newMessageFiles" class="flex border border-gray-300 dark:border-zinc-800 rounded text-gray-700 divide-x divide-gray-300 overflow-hidden dark:border-zinc-800">
|
||||
<div class="my-auto px-1">
|
||||
<span class="mr-1">{{ file.name }}</span>
|
||||
<span class="my-auto text-sm text-gray-500">{{ formatBytes(file.size) }}</span>
|
||||
</div>
|
||||
<div @click="removeFileAttachment(file)" class="flex my-auto text-sm text-gray-500 h-full px-1 hover:bg-gray-200 cursor-pointer">
|
||||
<svg class="w-5 h-5 my-auto" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
<!-- file attachments -->
|
||||
<div v-if="newMessageFiles.length > 0" class="flex flex-wrap gap-2">
|
||||
<div v-for="file in newMessageFiles" :key="file.name + file.size" class="attachment-chip">
|
||||
<div class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4 text-gray-500 dark:text-gray-300"/>
|
||||
<div class="text-sm text-gray-800 dark:text-gray-200 truncate max-w-[160px]">{{ file.name }}</div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">{{ formatBytes(file.size) }}</span>
|
||||
</div>
|
||||
<button @click="removeFileAttachment(file)" type="button" class="attachment-chip__remove">
|
||||
<MaterialDesignIcon icon-name="close" class="w-3.5 h-3.5"/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -327,33 +326,18 @@
|
||||
placeholder="Send a message..."></textarea>
|
||||
|
||||
<!-- action button -->
|
||||
<div class="flex mt-2">
|
||||
|
||||
<!-- add files -->
|
||||
<button @click="addFilesToMessage" type="button" class="my-auto mr-1 inline-flex items-center gap-x-1 rounded-md bg-gray-500 px-2.5 py-1.5 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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-5 h-5">
|
||||
<path fill-rule="evenodd" d="M5.625 1.5H9a3.75 3.75 0 0 1 3.75 3.75v1.875c0 1.036.84 1.875 1.875 1.875H16.5a3.75 3.75 0 0 1 3.75 3.75v7.875c0 1.035-.84 1.875-1.875 1.875H5.625a1.875 1.875 0 0 1-1.875-1.875V3.375c0-1.036.84-1.875 1.875-1.875ZM12.75 12a.75.75 0 0 0-1.5 0v2.25H9a.75.75 0 0 0 0 1.5h2.25V18a.75.75 0 0 0 1.5 0v-2.25H15a.75.75 0 0 0 0-1.5h-2.25V12Z" clip-rule="evenodd" />
|
||||
<path d="M14.25 5.25a5.23 5.23 0 0 0-1.279-3.434 9.768 9.768 0 0 1 6.963 6.963A5.23 5.23 0 0 0 16.5 7.5h-1.875a.375.375 0 0 1-.375-.375V5.25Z" />
|
||||
</svg>
|
||||
<span class="ml-1 hidden xl:inline-block whitespace-nowrap">Add Files</span>
|
||||
<div class="flex flex-wrap gap-2 items-center mt-3">
|
||||
<button @click="addFilesToMessage" type="button" class="attachment-action-button">
|
||||
<MaterialDesignIcon icon-name="paperclip-plus" class="w-4 h-4"/>
|
||||
<span>Add Files</span>
|
||||
</button>
|
||||
|
||||
<!-- add image -->
|
||||
<div>
|
||||
<AddImageButton @add-image="onImageSelected"/>
|
||||
</div>
|
||||
|
||||
<!-- add audio -->
|
||||
<div>
|
||||
<AddAudioButton
|
||||
:is-recording-audio-attachment="isRecordingAudioAttachment"
|
||||
@start-recording="startRecordingAudioAttachment($event)"
|
||||
@stop-recording="stopRecordingAudioAttachment">
|
||||
<span>Recording: {{ audioAttachmentRecordingDuration }}</span>
|
||||
</AddAudioButton>
|
||||
</div>
|
||||
|
||||
<!-- send message -->
|
||||
<AddImageButton @add-image="onImageSelected"/>
|
||||
<AddAudioButton
|
||||
:is-recording-audio-attachment="isRecordingAudioAttachment"
|
||||
@start-recording="startRecordingAudioAttachment($event)"
|
||||
@stop-recording="stopRecordingAudioAttachment">
|
||||
<span>Recording: {{ audioAttachmentRecordingDuration }}</span>
|
||||
</AddAudioButton>
|
||||
<div class="ml-auto my-auto">
|
||||
<SendMessageButton
|
||||
@send="sendMessage"
|
||||
@@ -362,7 +346,6 @@
|
||||
:can-send-message="canSendMessage"
|
||||
:delivery-method="newMessageDeliveryMethod"/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -1205,6 +1188,23 @@ export default {
|
||||
formatBytes: function(bytes) {
|
||||
return Utils.formatBytes(bytes);
|
||||
},
|
||||
base64ByteLength(base64String) {
|
||||
if(!base64String){
|
||||
return 0;
|
||||
}
|
||||
const padding = (base64String.match(/=+$/) || [""])[0].length;
|
||||
return Math.floor(base64String.length * 3 / 4) - padding;
|
||||
},
|
||||
formatBase64Bytes(base64String) {
|
||||
return this.formatBytes(this.base64ByteLength(base64String));
|
||||
},
|
||||
openConversationPopout() {
|
||||
if (!this.selectedPeer) return;
|
||||
const destinationHash = this.selectedPeer.destination_hash || "";
|
||||
const encodedHash = encodeURIComponent(destinationHash);
|
||||
const url = `${window.location.origin}${window.location.pathname}#/popout/messages/${encodedHash}`;
|
||||
window.open(url, "_blank", "width=960,height=720,noopener");
|
||||
},
|
||||
onFileInputChange: function(event) {
|
||||
for(const file of event.target.files){
|
||||
this.newMessageFiles.push(file);
|
||||
@@ -1618,3 +1618,33 @@ export default {
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.attachment-card {
|
||||
@apply relative flex gap-3 border border-gray-200 bg-white/80 dark:border-zinc-800 dark:bg-zinc-900/70 rounded-2xl p-3 shadow-sm;
|
||||
}
|
||||
.attachment-card__preview {
|
||||
@apply w-24 h-24 overflow-hidden rounded-xl bg-gray-100 dark:bg-zinc-800 cursor-pointer;
|
||||
}
|
||||
.attachment-card__body {
|
||||
@apply flex-1;
|
||||
}
|
||||
.attachment-card__title {
|
||||
@apply text-sm font-semibold text-gray-800 dark:text-gray-100;
|
||||
}
|
||||
.attachment-card__meta {
|
||||
@apply text-xs text-gray-500 dark:text-gray-400;
|
||||
}
|
||||
.attachment-card__remove {
|
||||
@apply absolute top-2 right-2 inline-flex items-center justify-center w-6 h-6 rounded-full bg-gray-200 dark:bg-zinc-800 text-gray-600 dark:text-gray-200 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/40;
|
||||
}
|
||||
.attachment-chip {
|
||||
@apply flex items-center justify-between gap-2 border border-gray-200 dark:border-zinc-800 bg-white/80 dark:bg-zinc-900/70 rounded-full px-3 py-1 text-xs shadow-sm;
|
||||
}
|
||||
.attachment-chip__remove {
|
||||
@apply inline-flex items-center justify-center text-gray-500 dark:text-gray-300 hover:text-red-500;
|
||||
}
|
||||
.attachment-action-button {
|
||||
@apply inline-flex items-center gap-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;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<template>
|
||||
|
||||
<MessagesSidebar
|
||||
v-if="!isPopoutMode"
|
||||
: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"/>
|
||||
@peer-click="onPeerClick"
|
||||
@conversation-search-changed="onConversationSearchChanged"
|
||||
@conversation-filter-changed="onConversationFilterChanged"/>
|
||||
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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">
|
||||
|
||||
<!-- messages tab -->
|
||||
<ConversationViewer
|
||||
@@ -45,6 +53,7 @@ export default {
|
||||
return {
|
||||
|
||||
reloadInterval: null,
|
||||
conversationRefreshTimeout: null,
|
||||
|
||||
config: null,
|
||||
peers: {},
|
||||
@@ -53,11 +62,18 @@ export default {
|
||||
conversations: [],
|
||||
lxmfDeliveryAnnounces: [],
|
||||
|
||||
conversationSearchTerm: "",
|
||||
filterUnreadOnly: false,
|
||||
filterFailedOnly: false,
|
||||
filterHasAttachmentsOnly: false,
|
||||
isLoadingConversations: false,
|
||||
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
|
||||
clearInterval(this.reloadInterval);
|
||||
clearTimeout(this.conversationRefreshTimeout);
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
@@ -200,13 +216,34 @@ export default {
|
||||
},
|
||||
async getConversations() {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/lxmf/conversations`);
|
||||
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;
|
||||
},
|
||||
@@ -216,12 +253,17 @@ export default {
|
||||
this.selectedPeer = peer;
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "messages",
|
||||
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) {
|
||||
@@ -238,11 +280,57 @@ export default {
|
||||
// clear selected peer
|
||||
this.selectedPeer = null;
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "messages",
|
||||
});
|
||||
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;
|
||||
},
|
||||
},
|
||||
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: {
|
||||
|
||||
@@ -2,25 +2,40 @@
|
||||
<div class="flex flex-col w-80 min-w-80">
|
||||
|
||||
<!-- tabs -->
|
||||
<div class="bg-white dark:bg-zinc-950 border-b border-r border-gray-200 dark:border-zinc-700">
|
||||
<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 @click="tab = 'conversations'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'conversations' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : '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-300']">Conversations</div>
|
||||
<div @click="tab = 'announces'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'announces' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : '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-300']">Announces</div>
|
||||
<div @click="tab = 'conversations'" 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']">Conversations</div>
|
||||
<div @click="tab = 'announces'" 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']">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">
|
||||
<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 -->
|
||||
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
|
||||
<input v-model="conversationsSearchTerm" type="text" :placeholder="`Search ${conversations.length} Conversations...`" 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 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
<!-- 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"
|
||||
@input="onConversationSearchInput"
|
||||
type="text"
|
||||
:placeholder="`Search ${conversations.length} conversations...`"
|
||||
class="input-field">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<button type="button" @click="toggleFilter('unread')" :class="filterChipClasses(filterUnreadOnly)">Unread</button>
|
||||
<button type="button" @click="toggleFilter('failed')" :class="filterChipClasses(filterFailedOnly)">Failed</button>
|
||||
<button type="button" @click="toggleFilter('attachments')" :class="filterChipClasses(filterHasAttachmentsOnly)">Attachments</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- peers -->
|
||||
<!-- conversations -->
|
||||
<div class="flex h-full overflow-y-auto">
|
||||
<div v-if="searchedConversations.length > 0" class="w-full">
|
||||
<div @click="onConversationClick(conversation)" v-for="conversation of searchedConversations" 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' ]">
|
||||
<div v-if="displayedConversations.length > 0" class="w-full">
|
||||
<div
|
||||
v-for="conversation of displayedConversations"
|
||||
:key="conversation.destination_hash"
|
||||
@click="onConversationClick(conversation)"
|
||||
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' ]">
|
||||
<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"/>
|
||||
@@ -29,22 +44,47 @@
|
||||
<MaterialDesignIcon icon-name="account-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mr-auto">
|
||||
<div class="text-gray-900 dark:text-gray-100" :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-sm">{{ formatTimeAgo(conversation.updated_at) }}</div>
|
||||
<div class="mr-auto w-full pr-2">
|
||||
<div class="flex justify-between gap-2">
|
||||
<div class="text-gray-900 dark:text-gray-100 truncate" :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">
|
||||
{{ 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 v-if="conversation.is_unread" class="my-auto ml-2 mr-2">
|
||||
<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-2 mr-2">
|
||||
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4">
|
||||
<path d="M15.26 5.01a2.25 2.25 0 0 1 3.182 3.182L11.44 15.195a3 3 0 0 1-4.243-4.243l6.364-6.364a.75.75 0 0 1 1.06 1.06l-6.364 6.364a1.5 1.5 0 1 0 2.121 2.121l6.999-6.998a.75.75 0 0 0-1.06-1.06l-6.364 6.363a.75.75 0 1 1-1.06-1.06Z"/>
|
||||
</svg>
|
||||
</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-if="conversations.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<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" />
|
||||
@@ -55,25 +95,25 @@
|
||||
</div>
|
||||
|
||||
<!-- is searching, but no results -->
|
||||
<div v-if="conversationsSearchTerm !== '' && conversations.length > 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<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">No Search Results</div>
|
||||
<div>Your search didn't match any Conversations!</div>
|
||||
<div>Your search didn't match any 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">
|
||||
<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="`Search ${peersCount} recent announces...`" 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 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
<input v-model="peersSearchTerm" type="text" :placeholder="`Search ${peersCount} recent announces...`" class="input-field">
|
||||
</div>
|
||||
|
||||
<!-- peers -->
|
||||
@@ -89,7 +129,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ peer.custom_display_name ?? peer.display_name }}</div>
|
||||
<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 -->
|
||||
@@ -150,15 +190,35 @@ import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
export default {
|
||||
name: 'MessagesSidebar',
|
||||
components: {MaterialDesignIcon},
|
||||
emits: ["conversation-click", "peer-click", "conversation-search-changed", "conversation-filter-changed"],
|
||||
props: {
|
||||
peers: Object,
|
||||
conversations: Array,
|
||||
selectedDestinationHash: String,
|
||||
conversationSearchTerm: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
filterUnreadOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterFailedOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
filterHasAttachmentsOnly: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
tab: "conversations",
|
||||
conversationsSearchTerm: "",
|
||||
peersSearchTerm: "",
|
||||
};
|
||||
},
|
||||
@@ -172,16 +232,23 @@ export default {
|
||||
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`;
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
searchedConversations() {
|
||||
return this.conversations.filter((conversation) => {
|
||||
const search = this.conversationsSearchTerm.toLowerCase();
|
||||
const matchesDisplayName = conversation.display_name.toLowerCase().includes(search);
|
||||
const matchesCustomDisplayName = conversation.custom_display_name?.toLowerCase()?.includes(search) === true;
|
||||
const matchesDestinationHash = conversation.destination_hash.toLowerCase().includes(search);
|
||||
return matchesDisplayName || matchesCustomDisplayName || matchesDestinationHash;
|
||||
});
|
||||
displayedConversations() {
|
||||
return this.conversations;
|
||||
},
|
||||
peersCount() {
|
||||
return Object.keys(this.peers).length;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
<!-- nomadnetwork sidebar -->
|
||||
<NomadNetworkSidebar
|
||||
v-if="!isPopoutMode"
|
||||
:nodes="nodes"
|
||||
:favourites="favourites"
|
||||
:selected-destination-hash="selectedNode?.destination_hash"
|
||||
@@ -11,7 +12,7 @@
|
||||
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<!-- node -->
|
||||
<div v-if="selectedNode" class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
|
||||
<div v-if="selectedNode" class="flex flex-col h-full min-h-0 bg-white dark:bg-zinc-950 overflow-hidden sm:m-2 sm:border dark:border-zinc-800 sm:rounded-xl sm:shadow dark:shadow-zinc-900">
|
||||
<!-- header -->
|
||||
<div class="flex p-2 border-b border-gray-300 dark:border-zinc-800">
|
||||
|
||||
@@ -38,8 +39,8 @@
|
||||
</div>
|
||||
|
||||
<!-- node info -->
|
||||
<div class="my-auto dark:text-gray-100">
|
||||
<span class="font-semibold">{{ selectedNode.display_name }}</span>
|
||||
<div class="my-auto dark:text-gray-100 flex-1 min-w-0">
|
||||
<span class="font-semibold truncate inline-block max-w-xs sm:max-w-sm" :title="selectedNode.display_name">{{ selectedNode.display_name }}</span>
|
||||
<span v-if="selectedNodePath" @click="onDestinationPathClick(selectedNodePath)" class="text-sm cursor-pointer"> - {{ selectedNodePath.hops }} {{ selectedNodePath.hops === 1 ? 'hop' : 'hops' }} away</span>
|
||||
</div>
|
||||
|
||||
@@ -56,6 +57,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- popout button -->
|
||||
<div class="my-auto mr-2">
|
||||
<div @click="openNomadnetPopout" class="cursor-pointer">
|
||||
<div class="flex text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 p-1 rounded-full">
|
||||
<div>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path d="M17 3.75h3.25A.75.75 0 0 1 21 4.5v3.25a.75.75 0 0 1-1.5 0V6.31l-4.97 4.97a.75.75 0 1 1-1.06-1.06l4.97-4.97H17a.75.75 0 0 1 0-1.5Z"/>
|
||||
<path d="M5.25 6A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75v-6a.75.75 0 0 0-1.5 0v6c0 .414-.336.75-.75.75H5.25a.75.75 0 0 1-.75-.75V8.25c0-.414.336-.75.75-.75h6a.75.75 0 0 0 0-1.5h-6Z"/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- close button -->
|
||||
<div class="my-auto mr-2">
|
||||
<div @click="onCloseNodeViewer" class="cursor-pointer">
|
||||
@@ -103,7 +118,7 @@
|
||||
</div>
|
||||
|
||||
<!-- page content -->
|
||||
<div class="h-full overflow-y-scroll p-3 bg-black text-white nodeContainer">
|
||||
<div class="flex-1 overflow-y-auto p-3 bg-black text-white nodeContainer">
|
||||
<div class="flex" v-if="isLoadingNodePage">
|
||||
<div class="my-auto">
|
||||
<svg class="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
@@ -274,7 +289,27 @@ export default {
|
||||
}, 5000);
|
||||
|
||||
},
|
||||
computed: {
|
||||
popoutRouteType() {
|
||||
if(this.$route?.meta?.popoutType){
|
||||
return this.$route.meta.popoutType;
|
||||
}
|
||||
return this.$route?.query?.popout ?? this.getHashPopoutValue();
|
||||
},
|
||||
isPopoutMode() {
|
||||
return this.popoutRouteType === "nomad";
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openNomadnetPopout() {
|
||||
if (!this.selectedNode) {
|
||||
return;
|
||||
}
|
||||
const destinationHash = this.selectedNode.destination_hash || "";
|
||||
const encodedHash = encodeURIComponent(destinationHash);
|
||||
const url = `${window.location.origin}${window.location.pathname}#/popout/nomadnetwork/${encodedHash}`;
|
||||
window.open(url, "_blank", "width=1100,height=800,noopener");
|
||||
},
|
||||
onElementClick(event) {
|
||||
|
||||
// find the closest ancestor (or the clicked element itself) with data-action="openNode"
|
||||
@@ -526,12 +561,17 @@ export default {
|
||||
async loadNodePage(destinationHash, pagePath, fieldData = null, addToHistory = true, loadFromCache = true) {
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "nomadnetwork",
|
||||
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
|
||||
const routeOptions = {
|
||||
name: routeName,
|
||||
params: {
|
||||
destinationHash: destinationHash,
|
||||
},
|
||||
});
|
||||
};
|
||||
if(!this.isPopoutMode && this.$route?.query){
|
||||
routeOptions.query = { ...this.$route.query };
|
||||
}
|
||||
this.$router.replace(routeOptions);
|
||||
|
||||
// get new sequence for this page load
|
||||
const seq = ++this.nodePageRequestSequence;
|
||||
@@ -782,8 +822,9 @@ export default {
|
||||
if(url.startsWith("lxmf@")){
|
||||
const destinationHash = url.replace("lxmf@", "");
|
||||
if(destinationHash.length === 32){
|
||||
const routeName = this.isPopoutMode ? "messages-popout" : "messages";
|
||||
await this.$router.push({
|
||||
name: "messages",
|
||||
name: routeName,
|
||||
params: {
|
||||
destinationHash: destinationHash,
|
||||
},
|
||||
@@ -982,10 +1023,18 @@ export default {
|
||||
// clear selected node
|
||||
this.selectedNode = null;
|
||||
|
||||
if(this.isPopoutMode){
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
|
||||
// update current route
|
||||
this.$router.replace({
|
||||
name: "nomadnetwork",
|
||||
});
|
||||
const routeName = this.isPopoutMode ? "nomadnetwork-popout" : "nomadnetwork";
|
||||
const routeOptions = { name: routeName };
|
||||
if(!this.isPopoutMode && this.$route?.query){
|
||||
routeOptions.query = { ...this.$route.query };
|
||||
}
|
||||
this.$router.replace(routeOptions);
|
||||
|
||||
},
|
||||
getNomadnetPageDownloadCallbackKey: function(destinationHash, pagePath) {
|
||||
@@ -1030,6 +1079,11 @@ export default {
|
||||
DialogUtils.alert(e.response?.data?.message ?? "Failed to identify!");
|
||||
}
|
||||
},
|
||||
getHashPopoutValue() {
|
||||
const hash = window.location.hash || "";
|
||||
const match = hash.match(/popout=([^&]+)/);
|
||||
return match ? decodeURIComponent(match[1]) : null;
|
||||
},
|
||||
downloadNomadNetFile(destinationHash, filePath, onSuccessCallback, onFailureCallback, onProgressCallback) {
|
||||
try {
|
||||
|
||||
|
||||
@@ -1,153 +1,90 @@
|
||||
<template>
|
||||
<div class="flex flex-col w-80 min-w-80">
|
||||
<div class="flex flex-col w-80 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">
|
||||
|
||||
<!-- tabs -->
|
||||
<div class="bg-white dark:bg-zinc-950 border-b border-r border-gray-200 dark:border-zinc-700">
|
||||
<div class="-mb-px flex">
|
||||
<div @click="tab = 'favourites'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'favourites' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : '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-300']">Favourites</div>
|
||||
<div @click="tab = 'announces'" class="w-full border-b-2 py-3 px-1 text-center text-sm font-medium cursor-pointer" :class="[ tab === 'announces' ? 'border-blue-500 text-blue-600 dark:border-blue-400 dark:text-blue-400' : '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-300']">Announces</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<button @click="tab = 'favourites'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'favourites' }">
|
||||
Favourites
|
||||
</button>
|
||||
<button @click="tab = 'announces'" type="button" class="sidebar-tab" :class="{ 'sidebar-tab--active': tab === 'announces' }">
|
||||
Announces
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- favourites -->
|
||||
<div v-if="tab === 'favourites'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden">
|
||||
|
||||
<!-- search -->
|
||||
<div v-if="favourites.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700">
|
||||
<input v-model="favouritesSearchTerm" type="text" :placeholder="`Search ${favourites.length} Favourites...`" 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 dark:focus:ring-blue-600 focus:border-blue-500 dark:focus:border-blue-600 block w-full p-2.5">
|
||||
<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="Search favourites" class="input-field"/>
|
||||
</div>
|
||||
|
||||
<!-- peers -->
|
||||
<div class="flex h-full overflow-y-auto">
|
||||
<div v-if="searchedFavourites.length > 0" class="w-full">
|
||||
<div @click="onFavouriteClick(favourite)" v-for="favourite of searchedFavourites" class="flex cursor-pointer p-2 border-l-2" :class="[ favourite.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' ]">
|
||||
<div class="my-auto mr-2">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<MaterialDesignIcon icon-name="server-network-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ favourite.display_name }}</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatDestinationHash(favourite.destination_hash) }}</div>
|
||||
</div>
|
||||
<div class="ml-auto my-auto">
|
||||
<DropDownMenu>
|
||||
<template v-slot:button>
|
||||
<IconButton class="bg-transparent dark:bg-transparent">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 12.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5ZM12 18.75a.75.75 0 1 1 0-1.5.75.75 0 0 1 0 1.5Z" />
|
||||
</svg>
|
||||
</IconButton>
|
||||
</template>
|
||||
<template v-slot:items>
|
||||
|
||||
<!-- rename button -->
|
||||
<DropDownMenuItem @click="onRenameFavourite(favourite)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-5">
|
||||
<path fill-rule="evenodd" d="M5.25 2.25a3 3 0 0 0-3 3v4.318a3 3 0 0 0 .879 2.121l9.58 9.581c.92.92 2.39 1.186 3.548.428a18.849 18.849 0 0 0 5.441-5.44c.758-1.16.492-2.629-.428-3.548l-9.58-9.581a3 3 0 0 0-2.122-.879H5.25ZM6.375 7.5a1.125 1.125 0 1 0 0-2.25 1.125 1.125 0 0 0 0 2.25Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Rename Favourite</span>
|
||||
</DropDownMenuItem>
|
||||
|
||||
<!-- remove favourite button -->
|
||||
<div>
|
||||
<DropDownMenuItem @click="onRemoveFavourite(favourite)">
|
||||
<svg class="size-5 text-red-500" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 0 0 6 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 1 0 .23 1.482l.149-.022.841 10.518A2.75 2.75 0 0 0 7.596 19h4.807a2.75 2.75 0 0 0 2.742-2.53l.841-10.52.149.023a.75.75 0 0 0 .23-1.482A41.03 41.03 0 0 0 14 4.193V3.75A2.75 2.75 0 0 0 11.25 1h-2.5ZM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4ZM8.58 7.72a.75.75 0 0 0-1.5.06l.3 7.5a.75.75 0 1 0 1.5-.06l-.3-7.5Zm4.34.06a.75.75 0 1 0-1.5-.06l-.3 7.5a.75.75 0 1 0 1.5.06l.3-7.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span class="text-red-500">Remove Favourite</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mx-auto my-auto text-center leading-5">
|
||||
|
||||
<!-- no favourites at all -->
|
||||
<div v-if="favourites.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="M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="font-semibold">No Favourites</div>
|
||||
<div>Discover nodes on the Announces tab.</div>
|
||||
</div>
|
||||
|
||||
<!-- is searching, but no results -->
|
||||
<div v-if="favouritesSearchTerm !== '' && favourites.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="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 Favourites!</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- announces -->
|
||||
<div v-if="tab === 'announces'" class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r dark:border-zinc-800 overflow-hidden">
|
||||
<!-- search -->
|
||||
<div v-if="nodesCount > 0" class="p-1 border-b border-gray-300 dark:border-zinc-800">
|
||||
<input
|
||||
v-model="nodesSearchTerm"
|
||||
type="text"
|
||||
:placeholder="`Search ${nodesCount} Nodes...`"
|
||||
class="bg-gray-50 dark:bg-zinc-900 border border-gray-300 dark:border-zinc-700 text-gray-900 dark:text-gray-100 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full p-2.5 dark:placeholder-gray-400"
|
||||
>
|
||||
</div>
|
||||
<!-- nodes -->
|
||||
<div class="flex h-full overflow-y-auto">
|
||||
<div v-if="searchedNodes.length > 0" class="w-full">
|
||||
<div
|
||||
@click="onNodeClick(node)"
|
||||
v-for="node of searchedNodes"
|
||||
class="flex cursor-pointer p-2 border-l-2"
|
||||
:class="[
|
||||
node.destination_hash === selectedDestinationHash
|
||||
? 'bg-gray-100 dark:bg-zinc-800 border-blue-500'
|
||||
: 'bg-white dark:bg-zinc-950 border-transparent hover:bg-gray-50 dark:hover:bg-zinc-900 hover:border-gray-200 dark:hover:border-zinc-700'
|
||||
<div class="flex-1 overflow-y-auto px-2 pb-4">
|
||||
<div v-if="searchedFavourites.length > 0" class="space-y-2">
|
||||
<div
|
||||
v-for="favourite of searchedFavourites"
|
||||
:key="favourite.destination_hash"
|
||||
@click="onFavouriteClick(favourite)"
|
||||
class="favourite-card"
|
||||
:class="[
|
||||
favourite.destination_hash === selectedDestinationHash ? 'favourite-card--active' : '',
|
||||
draggingFavouriteHash === favourite.destination_hash ? 'favourite-card--dragging' : ''
|
||||
]"
|
||||
draggable="true"
|
||||
@dragstart="onFavouriteDragStart($event, favourite)"
|
||||
@dragover.prevent="onFavouriteDragOver($event)"
|
||||
@drop.prevent="onFavouriteDrop($event, favourite)"
|
||||
@dragend="onFavouriteDragEnd"
|
||||
>
|
||||
<div class="my-auto mr-2">
|
||||
<div class="bg-gray-200 dark:bg-zinc-800 text-gray-500 dark:text-gray-400 p-2 rounded">
|
||||
<MaterialDesignIcon icon-name="server-network-outline" class="w-6 h-6"/>
|
||||
</div>
|
||||
<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 class="bg-transparent dark:bg-transparent">
|
||||
<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>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">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">No favourites</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Add nodes from the announces tab.</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="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">
|
||||
<div v-for="node of searchedNodes" :key="node.destination_hash" @click="onNodeClick(node)" class="announce-card" :class="{ 'announce-card--active': node.destination_hash === selectedDestinationHash }">
|
||||
<div class="announce-card__icon">
|
||||
<MaterialDesignIcon icon-name="satellite-uplink" class="w-5 h-5"/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-gray-900 dark:text-gray-100">{{ node.display_name }}</div>
|
||||
<div class="text-gray-500 dark:text-gray-400 text-sm">{{ formatTimeAgo(node.updated_at) }}</div>
|
||||
<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">Announced {{ formatTimeAgo(node.updated_at) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mx-auto my-auto text-center leading-5">
|
||||
<!-- no nodes at all -->
|
||||
<div v-if="nodesCount === 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 text-gray-500 dark:text-gray-400">
|
||||
<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 text-gray-900 dark:text-gray-100">No Nodes Discovered</div>
|
||||
<div class="text-gray-500 dark:text-gray-400">Waiting for a node to announce!</div>
|
||||
</div>
|
||||
<!-- is searching, but no results -->
|
||||
<div v-if="nodesSearchTerm !== '' && nodesCount > 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 text-gray-500 dark:text-gray-400">
|
||||
<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 text-gray-900 dark:text-gray-100">No Search Results</div>
|
||||
<div class="text-gray-500 dark:text-gray-400">Your search didn't match any Nodes!</div>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<MaterialDesignIcon icon-name="radar" class="w-8 h-8"/>
|
||||
<div class="font-semibold">No announces yet</div>
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">Listening for peers on the mesh.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -176,8 +113,22 @@ export default {
|
||||
tab: "favourites",
|
||||
favouritesSearchTerm: "",
|
||||
nodesSearchTerm: "",
|
||||
favouritesOrder: [],
|
||||
draggingFavouriteHash: null,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.loadFavouriteOrder();
|
||||
this.ensureFavouriteOrder();
|
||||
},
|
||||
watch: {
|
||||
favourites: {
|
||||
handler() {
|
||||
this.ensureFavouriteOrder();
|
||||
},
|
||||
deep: true,
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
onNodeClick(node) {
|
||||
this.$emit("node-click", node);
|
||||
@@ -191,6 +142,67 @@ export default {
|
||||
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(e) {
|
||||
// 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);
|
||||
},
|
||||
@@ -219,8 +231,13 @@ export default {
|
||||
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.favourites.filter((favourite) => {
|
||||
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;
|
||||
@@ -231,3 +248,34 @@ export default {
|
||||
},
|
||||
}
|
||||
</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 cursor-pointer 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>
|
||||
|
||||
@@ -1,59 +1,85 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="flex flex-col h-full space-y-2 p-2 overflow-y-auto">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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">
|
||||
|
||||
<!-- appearance -->
|
||||
<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">Ping</div>
|
||||
<div class="dark:divide-zinc-700 text-gray-900 dark:text-gray-100 p-2">
|
||||
Only lxmf.delivery destinations can be pinged.
|
||||
</div>
|
||||
</div>
|
||||
<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">Diagnostics</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Ping Mesh Peers</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Only <code class="font-mono text-xs">lxmf.delivery</code> destinations respond to ping.</div>
|
||||
</div>
|
||||
|
||||
<!-- inputs -->
|
||||
<div class="bg-white dark:bg-zinc-800 rounded shadow">
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Destination Hash</div>
|
||||
<div class="flex">
|
||||
<input v-model="destinationHash" type="text" placeholder="e.g: 7b746057a7294469799cd8d7d429676a" 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 class="grid md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="glass-label">Destination Hash</label>
|
||||
<input v-model="destinationHash" type="text" placeholder="e.g. 7b746057a7294469799cd8d7d429676a" class="input-field font-mono"/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="glass-label">Ping Timeout (seconds)</label>
|
||||
<input v-model="timeout" type="number" min="1" class="input-field"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Ping Timeout (seconds)</div>
|
||||
<div class="flex">
|
||||
<input v-model="timeout" type="number" placeholder="Timeout" 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>
|
||||
|
||||
<div class="p-2 space-x-1">
|
||||
<button v-if="!isRunning" @click="start" 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-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
Start
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button v-if="!isRunning" @click="start" type="button" class="primary-chip px-4 py-2 text-sm">
|
||||
<MaterialDesignIcon icon-name="play" class="w-4 h-4"/>
|
||||
Start Ping
|
||||
</button>
|
||||
<button v-if="isRunning" @click="stop" 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-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
<button v-else @click="stop" 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">
|
||||
<MaterialDesignIcon icon-name="pause" class="w-4 h-4"/>
|
||||
Stop
|
||||
</button>
|
||||
<button @click="clear" 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-700 dark:text-white dark:hover:bg-zinc-600 dark:focus-visible:outline-zinc-500">
|
||||
<button @click="clear" type="button" class="secondary-chip px-4 py-2 text-sm">
|
||||
<MaterialDesignIcon icon-name="broom" class="w-4 h-4"/>
|
||||
Clear Results
|
||||
</button>
|
||||
<button @click="dropPath" type="button" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-red-500 px-2 py-1 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">
|
||||
<button @click="dropPath" 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">
|
||||
<MaterialDesignIcon icon-name="link-variant-remove" class="w-4 h-4"/>
|
||||
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']">
|
||||
Status: {{ isRunning ? 'Running' : '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">
|
||||
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">
|
||||
Last Error: {{ lastPingSummary.error }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- results -->
|
||||
<div class="flex flex-col h-full bg-white dark:bg-zinc-800 rounded shadow overflow-hidden min-h-52">
|
||||
<div class="flex border-b border-gray-300 dark:border-zinc-700 text-gray-700 dark:text-gray-200 p-2 font-semibold">Results</div>
|
||||
<div id="results" class="flex flex-col h-full bg-black text-white dark:bg-zinc-800 dark:text-gray-200 p-2 overflow-y-auto overflow-x-auto font-mono whitespace-nowrap">
|
||||
<div v-for="pingResult of pingResults" class="w-fit">{{ pingResult }}</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">Console Output</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">Streaming seq responses in real time</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">Hops there: {{ lastPingSummary.hopsThere }}</span>
|
||||
<span v-if="lastPingSummary.hopsBack != null" class="stat-chip">Hops back: {{ lastPingSummary.hopsBack }}</span>
|
||||
<span v-if="lastPingSummary.rssi != null" class="stat-chip">RSSI {{ lastPingSummary.rssi }} dBm</span>
|
||||
<span v-if="lastPingSummary.snr != null" class="stat-chip">SNR {{ lastPingSummary.snr }} dB</span>
|
||||
<span v-if="lastPingSummary.quality != null" class="stat-chip">Quality {{ lastPingSummary.quality }}%</span>
|
||||
<span v-if="lastPingSummary.via" class="stat-chip">Interface {{ 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">No pings yet. Start a run to collect RTT data.</div>
|
||||
<div v-for="(pingResult, index) in pingResults" :key="`${index}-${pingResult}`" class="whitespace-pre-wrap">{{ pingResult }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -61,9 +87,13 @@
|
||||
<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,
|
||||
@@ -72,6 +102,7 @@ export default {
|
||||
seq: 0,
|
||||
pingResults: [],
|
||||
abortController: null,
|
||||
lastPingSummary: null,
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
@@ -116,10 +147,13 @@ export default {
|
||||
},
|
||||
async stop() {
|
||||
this.isRunning = false;
|
||||
this.abortController.abort();
|
||||
if(this.abortController){
|
||||
this.abortController.abort();
|
||||
}
|
||||
},
|
||||
async clear() {
|
||||
this.pingResults = [];
|
||||
this.lastPingSummary = null;
|
||||
},
|
||||
async sleep(millis) {
|
||||
return new Promise((resolve, reject) => setTimeout(resolve, millis));
|
||||
@@ -168,6 +202,15 @@ export default {
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -181,6 +224,9 @@ export default {
|
||||
// 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),
|
||||
};
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,148 +1,205 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto space-y-2 p-2">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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">
|
||||
|
||||
<!-- appearance -->
|
||||
<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">Appearance</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex">
|
||||
<select v-model="config.theme" @change="onThemeChange" 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">
|
||||
<option value="light">Light Theme</option>
|
||||
<option value="dark">Dark Theme</option>
|
||||
</select>
|
||||
</div>
|
||||
<!-- 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">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">Manage your identity, transport participation and LXMF defaults.</div>
|
||||
</div>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<button @click="copyValue(config.identity_hash, 'Identity Hash')" 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">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Identity
|
||||
</button>
|
||||
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" 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">
|
||||
<MaterialDesignIcon icon-name="account-plus" class="w-4 h-4"/>
|
||||
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">Theme</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white capitalize">{{ config.theme }} mode</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">Transport</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white">{{ config.is_transport_enabled ? 'Enabled' : '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">Propagation</div>
|
||||
<div class="font-semibold text-gray-900 dark:text-white">{{ config.lxmf_local_propagation_node_enabled ? 'Local node running' : '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">Identity Hash</div>
|
||||
<div class="address-card__value monospace-field">{{ config.identity_hash }}</div>
|
||||
<button @click="copyValue(config.identity_hash, 'Identity Hash')" type="button" class="address-card__action">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
<div class="address-card">
|
||||
<div class="address-card__label">LXMF Address</div>
|
||||
<div class="address-card__value monospace-field">{{ config.lxmf_address_hash }}</div>
|
||||
<button @click="copyValue(config.lxmf_address_hash, 'LXMF Address')" type="button" class="address-card__action">
|
||||
<MaterialDesignIcon icon-name="content-copy" class="w-4 h-4"/>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- transport mode -->
|
||||
<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">Transport Mode</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
<!-- settings grid -->
|
||||
<div class="grid gap-4 lg:grid-cols-2">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input v-model="config.is_transport_enabled" @change="onIsTransportEnabledChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
||||
</div>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Enable Transport Mode</label>
|
||||
<!-- Appearance -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Personalise</div>
|
||||
<h2>Appearance</h2>
|
||||
<p>Switch between light and dark presets anytime.</p>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, MeshChat will route traffic for other peers, respond to path requests and pass announces over your interfaces.</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">Changing this setting requires you to restart MeshChat.</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- interfaces -->
|
||||
<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">Interfaces</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input v-model="config.show_suggested_community_interfaces" @change="onShowSuggestedCommunityInterfacesChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
||||
</div>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Show Community Interfaces</label>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-3">
|
||||
<select v-model="config.theme" @change="onThemeChange" class="input-field">
|
||||
<option value="light">Light Theme</option>
|
||||
<option value="dark">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>Live preview updates instantly.</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>
|
||||
Realtime
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, community interfaces will be shown on the Add Interface page.</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- messages -->
|
||||
<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">Messages</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input v-model="config.auto_resend_failed_messages_when_announce_received" @change="onAutoResendFailedMessagesWhenAnnounceReceivedChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
||||
</div>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Auto resend</label>
|
||||
<!-- Transport -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Reticulum</div>
|
||||
<h2>Transport Mode</h2>
|
||||
<p>Relay paths and traffic for nearby peers.</p>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, failed messages will auto resend when an announce is received from the intended destination.</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-3">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.is_transport_enabled" @change="onIsTransportEnabledChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Enable Transport Mode</span>
|
||||
<span class="setting-toggle__description">Route announces, respond to path requests and help your mesh stay online.</span>
|
||||
<span class="setting-toggle__hint">Requires restart after toggling.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input v-model="config.allow_auto_resending_failed_messages_with_attachments" @change="onAllowAutoResendingFailedMessagesWithAttachmentsChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
||||
</div>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Allow resending with attachments</label>
|
||||
<!-- Interfaces -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Adapters</div>
|
||||
<h2>Interfaces</h2>
|
||||
<p>Show curated community configs inside the interface wizard.</p>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, failed messages that have attachments are allowed to auto resend.</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-3">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.show_suggested_community_interfaces" @change="onShowSuggestedCommunityInterfacesChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Show Community Interfaces</span>
|
||||
<span class="setting-toggle__description">Surface community-maintained presets while adding new interfaces.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input v-model="config.auto_send_failed_messages_to_propagation_node" @change="onAutoSendFailedMessagesToPropagationNodeChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
||||
</div>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Auto send to propagation node</label>
|
||||
<!-- Messages -->
|
||||
<section class="glass-card">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Reliability</div>
|
||||
<h2>Messages</h2>
|
||||
<p>Control how MeshChat retries or escalates failed deliveries.</p>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, messages that fail to send will be sent to the configured propagation node.</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-3">
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.auto_resend_failed_messages_when_announce_received" @change="onAutoResendFailedMessagesWhenAnnounceReceivedChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Auto resend when peer announces</span>
|
||||
<span class="setting-toggle__description">Failed messages automatically retry once the destination broadcasts again.</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.allow_auto_resending_failed_messages_with_attachments" @change="onAllowAutoResendingFailedMessagesWithAttachmentsChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Allow retries with attachments</span>
|
||||
<span class="setting-toggle__description">Large payloads will also be retried (useful when both peers have high limits).</span>
|
||||
</span>
|
||||
</label>
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.auto_send_failed_messages_to_propagation_node" @change="onAutoSendFailedMessagesToPropagationNodeChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Auto fall back to propagation node</span>
|
||||
<span class="setting-toggle__description">Failed direct deliveries are queued on your preferred propagation node.</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- propagation nodes -->
|
||||
<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">
|
||||
<div class="my-auto mr-auto">Propagation Nodes</div>
|
||||
<div class="my-auto">
|
||||
<RouterLink :to="{ name: 'propagation-nodes' }" class="my-auto inline-flex items-center gap-x-1 rounded-md bg-gray-500 dark:bg-zinc-600 px-2 py-1 text-sm font-semibold text-white shadow-sm hover:bg-gray-400 dark:hover:bg-zinc-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500 dark:focus-visible:outline-zinc-500">
|
||||
<!-- Propagation nodes -->
|
||||
<section class="glass-card lg:col-span-2">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">LXMF</div>
|
||||
<h2>Propagation Nodes</h2>
|
||||
<p>Keep conversations flowing even when peers are offline.</p>
|
||||
</div>
|
||||
<RouterLink :to="{ name: 'propagation-nodes' }" class="primary-chip">
|
||||
Browse Nodes
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
<div class="divide-y divide-gray-300 dark:divide-zinc-700 text-gray-900 dark:text-gray-100">
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<ul class="list-disc list-inside">
|
||||
<li>When you send a message, the intended recipient may be offline and your message will fail to send.</li>
|
||||
<li>Instead, messages can be sent to propagation nodes, which store the messages and allow recipients to retrieve them when they're next online.</li>
|
||||
<li>Propagation nodes automatically peer and sync messages with each other, creating an encrypted, distributed message store.</li>
|
||||
<li>By default, propagation nodes store messages for up to 30 days. If the recipient hasn't retrieved it by then, the message will be lost.</li>
|
||||
<li>At this time, delivery reports are unavailable for messages sent to propagation nodes.</li>
|
||||
</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>Propagation nodes hold messages securely until recipients sync again.</li>
|
||||
<li>Nodes peer with each other to distribute encrypted payloads.</li>
|
||||
<li>Most nodes retain data ~30 days, then discard undelivered items.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="flex items-start">
|
||||
<div class="flex items-center h-5">
|
||||
<input v-model="config.lxmf_local_propagation_node_enabled" @change="onLxmfLocalPropagationNodeEnabledChange" type="checkbox" class="w-4 h-4 border border-gray-300 dark:border-zinc-600 rounded bg-gray-50 dark:bg-zinc-700 focus:ring-3 focus:ring-blue-300 dark:focus:ring-blue-600">
|
||||
</div>
|
||||
<label class="ml-2 text-sm font-medium text-gray-900 dark:text-gray-100">Local Propagation Node</label>
|
||||
<label class="setting-toggle">
|
||||
<input type="checkbox" v-model="config.lxmf_local_propagation_node_enabled" @change="onLxmfLocalPropagationNodeEnabledChange">
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">Run a local propagation node</span>
|
||||
<span class="setting-toggle__description">MeshChat will announce and maintain a node using this local destination hash.</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">Preferred Propagation Node</div>
|
||||
<input v-model="config.lxmf_preferred_propagation_node_destination_hash" @input="onLxmfPreferredPropagationNodeDestinationHashChange" type="text" placeholder="Destination hash, e.g. a39610c89d18bb48c73e429582423c24" class="input-field monospace-field">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">Messages fallback to this node whenever direct delivery fails.</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">When enabled, MeshChat will run a Propagation Node and announce it with the following address for other clients to use.</div>
|
||||
<div class="flex">
|
||||
<input disabled v-model="config.lxmf_local_propagation_node_address_hash" type="text" class="bg-gray-200 dark:bg-zinc-800 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>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Preferred Propagation Node</div>
|
||||
<div class="flex">
|
||||
<input v-model="config.lxmf_preferred_propagation_node_destination_hash" @input="onLxmfPreferredPropagationNodeDestinationHashChange" type="text" placeholder="Destination Hash. e.g: a39610c89d18bb48c73e429582423c24" 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="text-sm text-gray-700 dark:text-gray-300">This is the propagation node your messages will be sent to and retrieved from.</div>
|
||||
</div>
|
||||
|
||||
<div class="p-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Auto Sync Interval</div>
|
||||
<div class="flex">
|
||||
<select v-model="config.lxmf_preferred_propagation_node_auto_sync_interval_seconds" @change="onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange" 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 class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Auto Sync Interval</div>
|
||||
<select v-model="config.lxmf_preferred_propagation_node_auto_sync_interval_seconds" @change="onLxmfPreferredPropagationNodeAutoSyncIntervalSecondsChange" class="input-field">
|
||||
<option value="0">Disabled</option>
|
||||
<option value="900">Every 15 Minutes</option>
|
||||
<option value="1800">Every 30 Minutes</option>
|
||||
@@ -152,16 +209,16 @@
|
||||
<option value="43200">Every 12 Hours</option>
|
||||
<option value="86400">Every 24 Hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="text-sm text-gray-700 dark:text-gray-300">
|
||||
<span v-if="config.lxmf_preferred_propagation_node_last_synced_at">Last Synced: {{ formatSecondsAgo(config.lxmf_preferred_propagation_node_last_synced_at) }}</span>
|
||||
<span v-else>Last Synced: Never</span>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span v-if="config.lxmf_preferred_propagation_node_last_synced_at">Last synced {{ formatSecondsAgo(config.lxmf_preferred_propagation_node_last_synced_at) }} ago.</span>
|
||||
<span v-else>Last synced: never.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -170,9 +227,13 @@
|
||||
import Utils from "../../js/Utils";
|
||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
|
||||
export default {
|
||||
name: 'SettingsPage',
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
config: {
|
||||
@@ -183,12 +244,15 @@ export default {
|
||||
lxmf_local_propagation_node_enabled: null,
|
||||
lxmf_preferred_propagation_node_destination_hash: null,
|
||||
},
|
||||
copyToast: null,
|
||||
copyToastTimeout: null,
|
||||
};
|
||||
},
|
||||
beforeUnmount() {
|
||||
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
clearTimeout(this.copyToastTimeout);
|
||||
|
||||
},
|
||||
mounted() {
|
||||
@@ -227,6 +291,25 @@ export default {
|
||||
console.log(e);
|
||||
}
|
||||
},
|
||||
async copyValue(value, label) {
|
||||
if(!value){
|
||||
DialogUtils.alert(`Nothing to copy for ${label}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
this.showCopyToast(`${label} copied to clipboard`);
|
||||
} catch(e) {
|
||||
DialogUtils.alert(`${label}: ${value}`);
|
||||
}
|
||||
},
|
||||
showCopyToast(message) {
|
||||
this.copyToast = message;
|
||||
clearTimeout(this.copyToastTimeout);
|
||||
this.copyToastTimeout = setTimeout(() => {
|
||||
this.copyToast = null;
|
||||
}, 2500);
|
||||
},
|
||||
async onThemeChange() {
|
||||
await this.updateConfig({
|
||||
"theme": this.config.theme,
|
||||
@@ -292,3 +375,74 @@ export default {
|
||||
},
|
||||
}
|
||||
</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 input[type="checkbox"] {
|
||||
@apply w-4 h-4 mt-1 rounded border-gray-300 dark:border-zinc-600 text-blue-600 focus:ring-blue-500;
|
||||
}
|
||||
.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>
|
||||
|
||||
@@ -1,60 +1,67 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] dark:bg-zinc-950">
|
||||
<div class="overflow-y-auto space-y-2 p-2">
|
||||
<div class="flex flex-col flex-1 overflow-hidden min-w-full sm:min-w-[500px] 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">
|
||||
|
||||
<!-- appearance -->
|
||||
<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">Tools</div>
|
||||
<div class="dark:divide-zinc-700 text-gray-900 dark:text-gray-100 p-2">
|
||||
A collection of useful tools bundled with MeshChat
|
||||
<div class="glass-card space-y-3">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Utilities</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">Power tools for operators</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Diagnostics and firmware helpers ship with MeshChat so you can troubleshoot peers without leaving the console.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ping -->
|
||||
<RouterLink :to="{ name: 'ping' }" class="group flex bg-white dark:bg-zinc-800 p-2 rounded shadow hover:bg-gray-50 dark:hover:bg-zinc-700">
|
||||
<div class="mr-2">
|
||||
<div class="flex bg-gray-300 text-gray-500 rounded shadow p-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-10">
|
||||
<path fill-rule="evenodd" d="M14.615 1.595a.75.75 0 0 1 .359.852L12.982 9.75h7.268a.75.75 0 0 1 .548 1.262l-10.5 11.25a.75.75 0 0 1-1.272-.71l1.992-7.302H3.75a.75.75 0 0 1-.548-1.262l10.5-11.25a.75.75 0 0 1 .913-.143Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<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>
|
||||
<div class="my-auto mr-auto dark:text-gray-200">
|
||||
<div class="font-bold">Ping</div>
|
||||
<div class="text-sm">Allows you to ping a destination hash.</div>
|
||||
</div>
|
||||
<div class="my-auto text-gray-400 group-hover: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="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</RouterLink>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">Ping</div>
|
||||
<div class="tool-card__description">Latency test for any LXMF destination hash with live status.</div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron"/>
|
||||
</RouterLink>
|
||||
|
||||
<!-- rnode flasher -->
|
||||
<a target="_blank" href="/rnode-flasher/index.html" class="group flex bg-white dark:bg-zinc-800 p-2 rounded shadow hover:bg-gray-50 dark:hover:bg-zinc-700">
|
||||
<div class="mr-2">
|
||||
<div class="flex bg-gray-300 text-white rounded shadow">
|
||||
<img src="/rnode-flasher/reticulum_logo_512.png" class="size-14"/>
|
||||
<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>
|
||||
<div class="my-auto mr-auto dark:text-gray-200">
|
||||
<div class="font-bold">RNode Flasher</div>
|
||||
<div class="text-sm">Flash RNode firmware to supported devices.</div>
|
||||
</div>
|
||||
<div class="my-auto text-gray-400 group-hover: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="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex-1">
|
||||
<div class="tool-card__title">RNode Flasher</div>
|
||||
<div class="tool-card__description">Flash and update RNode adapters without touching the command line.</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>
|
||||
|
||||
@@ -9,14 +9,6 @@
|
||||
<link rel="icon" type="image/png" href="favicons/favicon-512x512.png"/>
|
||||
<title>Reticulum MeshChat</title>
|
||||
|
||||
<!-- codec2 -->
|
||||
<script src="assets/js/codec2-emscripten/c2enc.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/c2dec.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/sox.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/codec2-lib.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/wav-encoder.js"></script>
|
||||
<script src="assets/js/codec2-emscripten/codec2-microphone-recorder.js"></script>
|
||||
|
||||
</head>
|
||||
<body class="bg-gray-100">
|
||||
<div id="app"></div>
|
||||
|
||||
62
src/frontend/js/Codec2Loader.js
Normal file
62
src/frontend/js/Codec2Loader.js
Normal file
@@ -0,0 +1,62 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
import vClickOutside from "click-outside-vue3";
|
||||
import "./style.css";
|
||||
import "./fonts/RobotoMonoNerdFont/font.css";
|
||||
import { ensureCodec2ScriptsLoaded } from "./js/Codec2Loader";
|
||||
|
||||
import App from './components/App.vue';
|
||||
|
||||
@@ -50,6 +51,13 @@ const router = createRouter({
|
||||
props: true,
|
||||
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "messages-popout",
|
||||
path: '/popout/messages/:destinationHash?',
|
||||
props: true,
|
||||
meta: { popoutType: "conversation", isPopout: true },
|
||||
component: defineAsyncComponent(() => import("./components/messages/MessagesPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "network-visualiser",
|
||||
path: '/network-visualiser',
|
||||
@@ -61,6 +69,13 @@ const router = createRouter({
|
||||
props: true,
|
||||
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "nomadnetwork-popout",
|
||||
path: '/popout/nomadnetwork/:destinationHash?',
|
||||
props: true,
|
||||
meta: { popoutType: "nomad", isPopout: true },
|
||||
component: defineAsyncComponent(() => import("./components/nomadnetwork/NomadNetworkPage.vue")),
|
||||
},
|
||||
{
|
||||
name: "propagation-nodes",
|
||||
path: '/propagation-nodes',
|
||||
@@ -89,8 +104,13 @@ const router = createRouter({
|
||||
],
|
||||
})
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(vuetify)
|
||||
.use(vClickOutside)
|
||||
.mount('#app');
|
||||
async function bootstrap() {
|
||||
await ensureCodec2ScriptsLoaded();
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(vuetify)
|
||||
.use(vClickOutside)
|
||||
.mount('#app');
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -1,3 +1,77 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #94a3b8 #e2e8f0;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: #e2e8f0;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background-color: #94a3b8;
|
||||
border-radius: 999px;
|
||||
border: 2px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-track {
|
||||
background: #1f1f23;
|
||||
}
|
||||
|
||||
.dark *::-webkit-scrollbar-thumb {
|
||||
background-color: #3f3f46;
|
||||
border-color: #1f1f23;
|
||||
}
|
||||
|
||||
.glass-card {
|
||||
@apply bg-white/95 dark:bg-zinc-900/85 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl px-4 py-4;
|
||||
}
|
||||
|
||||
.input-field {
|
||||
@apply bg-gray-50/90 dark:bg-zinc-900/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;
|
||||
}
|
||||
|
||||
.primary-chip {
|
||||
@apply inline-flex items-center gap-x-2 rounded-full bg-blue-600/90 px-3 py-1.5 text-xs font-semibold text-white shadow hover:bg-blue-500 transition;
|
||||
}
|
||||
|
||||
.secondary-chip {
|
||||
@apply inline-flex items-center gap-x-2 rounded-full border border-gray-300 dark:border-zinc-700 px-3 py-1.5 text-xs font-semibold text-gray-700 dark:text-gray-100 bg-white/80 dark:bg-zinc-900/70 hover:border-blue-400 dark:hover:border-blue-500 transition;
|
||||
}
|
||||
|
||||
.glass-label {
|
||||
@apply mb-1 text-sm font-semibold text-gray-800 dark:text-gray-200;
|
||||
}
|
||||
|
||||
.monospace-field {
|
||||
@apply font-mono tracking-tight text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.metric-row {
|
||||
@apply grid gap-3 sm:grid-cols-2 text-gray-800 dark:text-gray-100;
|
||||
}
|
||||
|
||||
.metric-value {
|
||||
@apply text-lg font-semibold text-gray-900 dark:text-white;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -28,6 +28,52 @@ export default {
|
||||
call: path.join(__dirname, "src", "frontend", "call.html"),
|
||||
|
||||
},
|
||||
output: {
|
||||
manualChunks(id) {
|
||||
if (id.includes('node_modules')) {
|
||||
if (id.includes('vuetify')) {
|
||||
return 'vendor-vuetify';
|
||||
}
|
||||
if (id.includes('vis-network') || id.includes('vis-data')) {
|
||||
return 'vendor-vis';
|
||||
}
|
||||
if (id.includes('vue-router')) {
|
||||
return 'vendor-vue-router';
|
||||
}
|
||||
if (id.includes('vue')) {
|
||||
return 'vendor-vue';
|
||||
}
|
||||
if (id.includes('protobufjs') || id.includes('@protobufjs')) {
|
||||
return 'vendor-protobuf';
|
||||
}
|
||||
if (id.includes('moment')) {
|
||||
return 'vendor-moment';
|
||||
}
|
||||
if (id.includes('axios')) {
|
||||
return 'vendor-axios';
|
||||
}
|
||||
if (id.includes('@mdi/js')) {
|
||||
return 'vendor-mdi';
|
||||
}
|
||||
if (id.includes('compressorjs')) {
|
||||
return 'vendor-compressor';
|
||||
}
|
||||
if (id.includes('click-outside-vue3')) {
|
||||
return 'vendor-click-outside';
|
||||
}
|
||||
if (id.includes('mitt')) {
|
||||
return 'vendor-mitt';
|
||||
}
|
||||
if (id.includes('micron-parser')) {
|
||||
return 'vendor-micron';
|
||||
}
|
||||
if (id.includes('electron-prompt')) {
|
||||
return 'vendor-electron-prompt';
|
||||
}
|
||||
return 'vendor-other';
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user