feat(config): add gitea base URL and documentation download URLs configuration; update related components and logic for dynamic URL handling
This commit is contained in:
@@ -201,6 +201,8 @@ class ReticulumMeshChat:
|
||||
auth_enabled: bool = False,
|
||||
public_dir: str | None = None,
|
||||
emergency: bool = False,
|
||||
gitea_base_url: str | None = None,
|
||||
docs_download_urls: str | None = None,
|
||||
):
|
||||
self.running = True
|
||||
self.reticulum_config_dir = reticulum_config_dir
|
||||
@@ -210,6 +212,8 @@ class ReticulumMeshChat:
|
||||
self.emergency = emergency
|
||||
self.auth_enabled_initial = auth_enabled
|
||||
self.public_dir_override = public_dir
|
||||
self.gitea_base_url_override = gitea_base_url
|
||||
self.docs_download_urls_override = docs_download_urls
|
||||
self.websocket_clients: list[web.WebSocketResponse] = []
|
||||
|
||||
# track announce timestamps for rate calculation
|
||||
@@ -1177,11 +1181,15 @@ class ReticulumMeshChat:
|
||||
def get_app_version() -> str:
|
||||
return app_version
|
||||
|
||||
def get_lxst_version(self) -> str:
|
||||
@staticmethod
|
||||
def get_package_version(package_name: str, default: str = "unknown") -> str:
|
||||
try:
|
||||
return importlib.metadata.version("lxst")
|
||||
return importlib.metadata.version(package_name)
|
||||
except Exception:
|
||||
return getattr(LXST, "__version__", "unknown")
|
||||
return default
|
||||
|
||||
def get_lxst_version(self) -> str:
|
||||
return self.get_package_version("lxst", getattr(LXST, "__version__", "unknown"))
|
||||
|
||||
# automatically announces based on user config
|
||||
async def announce_loop(self, session_id, context=None):
|
||||
@@ -2272,9 +2280,16 @@ class ReticulumMeshChat:
|
||||
if not url:
|
||||
return web.json_response({"error": "URL is required"}, status=400)
|
||||
|
||||
# Restrict to GitHub for safety
|
||||
if not url.startswith("https://git.quad4.io/") and not url.startswith(
|
||||
"https://objects.githubusercontent.com/"
|
||||
# Restrict to allowed sources for safety
|
||||
gitea_url = "https://git.quad4.io"
|
||||
if self.current_context and self.current_context.config:
|
||||
gitea_url = self.current_context.config.gitea_base_url.get()
|
||||
|
||||
if (
|
||||
not url.startswith(gitea_url + "/")
|
||||
and not url.startswith("https://git.quad4.io/")
|
||||
and not url.startswith("https://github.com/")
|
||||
and not url.startswith("https://objects.githubusercontent.com/")
|
||||
):
|
||||
return web.json_response({"error": "Invalid download URL"}, status=403)
|
||||
|
||||
@@ -3172,21 +3187,21 @@ class ReticulumMeshChat:
|
||||
"lxst_version": self.get_lxst_version(),
|
||||
"python_version": platform.python_version(),
|
||||
"dependencies": {
|
||||
"aiohttp": importlib.metadata.version("aiohttp"),
|
||||
"aiohttp_session": importlib.metadata.version(
|
||||
"aiohttp": self.get_package_version("aiohttp"),
|
||||
"aiohttp_session": self.get_package_version(
|
||||
"aiohttp-session"
|
||||
),
|
||||
"cryptography": importlib.metadata.version("cryptography"),
|
||||
"psutil": importlib.metadata.version("psutil"),
|
||||
"requests": importlib.metadata.version("requests"),
|
||||
"websockets": importlib.metadata.version("websockets"),
|
||||
"cryptography": self.get_package_version("cryptography"),
|
||||
"psutil": self.get_package_version("psutil"),
|
||||
"requests": self.get_package_version("requests"),
|
||||
"websockets": self.get_package_version("websockets"),
|
||||
"audioop_lts": (
|
||||
importlib.metadata.version("audioop-lts")
|
||||
self.get_package_version("audioop-lts")
|
||||
if sys.version_info >= (3, 13)
|
||||
else "n/a"
|
||||
),
|
||||
"ply": importlib.metadata.version("ply"),
|
||||
"bcrypt": importlib.metadata.version("bcrypt"),
|
||||
"ply": self.get_package_version("ply"),
|
||||
"bcrypt": self.get_package_version("bcrypt"),
|
||||
},
|
||||
"storage_path": self.storage_path,
|
||||
"database_path": self.database_path,
|
||||
@@ -7290,16 +7305,59 @@ class ReticulumMeshChat:
|
||||
|
||||
response.headers["X-XSS-Protection"] = "1; mode=block"
|
||||
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
||||
|
||||
# CSP: allow localhost for development and Electron, websockets, and blob URLs
|
||||
# Add 'unsafe-inline' and 'unsafe-eval' for some legacy doc scripts if needed,
|
||||
# and allow framing ourselves for the docs page.
|
||||
gitea_url = "https://git.quad4.io"
|
||||
connect_sources = [
|
||||
"'self'",
|
||||
"ws://localhost:*",
|
||||
"wss://localhost:*",
|
||||
"blob:",
|
||||
"https://*.tile.openstreetmap.org",
|
||||
"https://tile.openstreetmap.org",
|
||||
"https://nominatim.openstreetmap.org",
|
||||
]
|
||||
|
||||
if self.current_context and self.current_context.config:
|
||||
# Add configured Gitea base URL
|
||||
gitea_url = self.current_context.config.gitea_base_url.get()
|
||||
if gitea_url not in connect_sources:
|
||||
connect_sources.append(gitea_url)
|
||||
|
||||
# Add configured docs download URLs domains
|
||||
docs_urls_str = self.current_context.config.docs_download_urls.get()
|
||||
docs_urls = [
|
||||
u.strip()
|
||||
for u in docs_urls_str.replace("\n", ",").split(",")
|
||||
if u.strip()
|
||||
]
|
||||
for url in docs_urls:
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.netloc:
|
||||
domain = f"{parsed.scheme}://{parsed.netloc}"
|
||||
if domain not in connect_sources:
|
||||
connect_sources.append(domain)
|
||||
|
||||
# If GitHub is used, also allow objects.githubusercontent.com for redirects
|
||||
if "github.com" in domain:
|
||||
content_domain = "https://objects.githubusercontent.com"
|
||||
if content_domain not in connect_sources:
|
||||
connect_sources.append(content_domain)
|
||||
except Exception: # noqa: S110
|
||||
pass
|
||||
|
||||
csp = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; "
|
||||
"font-src 'self' data:; "
|
||||
"connect-src 'self' ws://localhost:* wss://localhost:* blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org https://nominatim.openstreetmap.org https://git.quad4.io; "
|
||||
f"connect-src {' '.join(connect_sources)}; "
|
||||
"media-src 'self' blob:; "
|
||||
"worker-src 'self' blob:; "
|
||||
"frame-src 'self'; "
|
||||
@@ -10153,6 +10211,18 @@ def main():
|
||||
default=os.environ.get("MESHCHAT_PUBLIC_DIR"),
|
||||
help="Path to the directory containing the frontend static files (default: bundled public folder). Can also be set via MESHCHAT_PUBLIC_DIR environment variable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--gitea-base-url",
|
||||
type=str,
|
||||
default=os.environ.get("MESHCHAT_GITEA_BASE_URL"),
|
||||
help="Base URL for Gitea instance (default: https://git.quad4.io). Can also be set via MESHCHAT_GITEA_BASE_URL environment variable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--docs-download-urls",
|
||||
type=str,
|
||||
default=os.environ.get("MESHCHAT_DOCS_DOWNLOAD_URLS"),
|
||||
help="Comma-separated list of URLs to download documentation from. Can also be set via MESHCHAT_DOCS_DOWNLOAD_URLS environment variable.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-exception-message",
|
||||
type=str,
|
||||
@@ -10278,6 +10348,8 @@ def main():
|
||||
auth_enabled=args.auth,
|
||||
public_dir=args.public_dir,
|
||||
emergency=args.emergency,
|
||||
gitea_base_url=args.gitea_base_url,
|
||||
docs_download_urls=args.docs_download_urls,
|
||||
)
|
||||
|
||||
# update recovery with known paths
|
||||
|
||||
@@ -118,6 +118,14 @@ class ConfigManager:
|
||||
"initial_docs_download_attempted",
|
||||
False,
|
||||
)
|
||||
self.gitea_base_url = self.StringConfig(
|
||||
self, "gitea_base_url", "https://git.quad4.io"
|
||||
)
|
||||
self.docs_download_urls = self.StringConfig(
|
||||
self,
|
||||
"docs_download_urls",
|
||||
"https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip,https://github.com/markqvist/reticulum_website/archive/refs/heads/main.zip",
|
||||
)
|
||||
|
||||
# desktop config
|
||||
self.desktop_open_calls_in_separate_window = self.BoolConfig(
|
||||
|
||||
@@ -86,7 +86,10 @@ class DocsManager:
|
||||
|
||||
try:
|
||||
# Try symlink first as it's efficient
|
||||
os.symlink(version_path, self.docs_dir)
|
||||
# We use a relative path for the symlink target to make the storage directory portable
|
||||
# version_path is relative to CWD, so we need it relative to the parent of self.docs_dir
|
||||
rel_target = os.path.relpath(version_path, os.path.dirname(self.docs_dir))
|
||||
os.symlink(rel_target, self.docs_dir)
|
||||
except (OSError, AttributeError):
|
||||
# Fallback to copy
|
||||
shutil.copytree(version_path, self.docs_dir)
|
||||
@@ -440,54 +443,68 @@ class DocsManager:
|
||||
self.download_progress = 0
|
||||
self.last_error = None
|
||||
|
||||
try:
|
||||
# We use the reticulum_website repository which contains the built HTML docs
|
||||
# Default to git.quad4.io as requested
|
||||
url = "https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip"
|
||||
zip_path = os.path.join(self.docs_base_dir, "website.zip")
|
||||
# Get URLs from config
|
||||
urls_str = self.config.docs_download_urls.get()
|
||||
urls = [u.strip() for u in urls_str.replace("\n", ",").split(",") if u.strip()]
|
||||
if not urls:
|
||||
urls = ["https://git.quad4.io/Reticulum/reticulum_website/archive/main.zip"]
|
||||
|
||||
# Download ZIP
|
||||
response = requests.get(url, stream=True, timeout=60)
|
||||
response.raise_for_status()
|
||||
last_exception = None
|
||||
for url in urls:
|
||||
try:
|
||||
logging.info(f"Attempting to download docs from {url}")
|
||||
zip_path = os.path.join(self.docs_base_dir, "website.zip")
|
||||
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
# Download ZIP
|
||||
response = requests.get(url, stream=True, timeout=60)
|
||||
response.raise_for_status()
|
||||
|
||||
with open(zip_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
if total_size > 0:
|
||||
self.download_progress = int(
|
||||
(downloaded_size / total_size) * 90
|
||||
)
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
downloaded_size = 0
|
||||
|
||||
# Extract
|
||||
self.download_status = "extracting"
|
||||
# For automatic downloads from git, we'll use a timestamp as version if none provided
|
||||
if version == "latest":
|
||||
import time
|
||||
with open(zip_path, "wb") as f:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
if total_size > 0:
|
||||
self.download_progress = int(
|
||||
(downloaded_size / total_size) * 90
|
||||
)
|
||||
|
||||
version = f"git-{int(time.time())}"
|
||||
# Extract
|
||||
self.download_status = "extracting"
|
||||
# For automatic downloads from git, we'll use a timestamp as version if none provided
|
||||
if version == "latest":
|
||||
import time
|
||||
|
||||
self._extract_docs(zip_path, version)
|
||||
version = f"git-{int(time.time())}"
|
||||
|
||||
# Cleanup
|
||||
if os.path.exists(zip_path):
|
||||
os.remove(zip_path)
|
||||
self._extract_docs(zip_path, version)
|
||||
|
||||
self.config.docs_downloaded.set(True)
|
||||
self.download_progress = 100
|
||||
self.download_status = "completed"
|
||||
# Cleanup
|
||||
if os.path.exists(zip_path):
|
||||
os.remove(zip_path)
|
||||
|
||||
# Switch to the new version
|
||||
self.switch_version(version)
|
||||
self.config.docs_downloaded.set(True)
|
||||
self.download_progress = 100
|
||||
self.download_status = "completed"
|
||||
|
||||
except Exception as e:
|
||||
self.last_error = str(e)
|
||||
self.download_status = "error"
|
||||
logging.exception(f"Failed to update docs: {e}")
|
||||
# Switch to the new version
|
||||
self.switch_version(version)
|
||||
return # Success, exit task
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to download docs from {url}: {e}")
|
||||
last_exception = e
|
||||
if os.path.exists(os.path.join(self.docs_base_dir, "website.zip")):
|
||||
os.remove(os.path.join(self.docs_base_dir, "website.zip"))
|
||||
continue # Try next URL
|
||||
|
||||
# If we got here, all URLs failed
|
||||
self.last_error = str(last_exception)
|
||||
self.download_status = "error"
|
||||
logging.error(f"All docs download sources failed. Last error: {last_exception}")
|
||||
|
||||
def upload_zip(self, zip_bytes, version):
|
||||
self.download_status = "extracting"
|
||||
|
||||
@@ -129,6 +129,19 @@ class IdentityContext:
|
||||
|
||||
# 3. Initialize Config and Managers
|
||||
self.config = ConfigManager(self.database)
|
||||
|
||||
# Apply overrides from CLI/ENV if provided
|
||||
if (
|
||||
hasattr(self.app, "gitea_base_url_override")
|
||||
and self.app.gitea_base_url_override
|
||||
):
|
||||
self.config.gitea_base_url.set(self.app.gitea_base_url_override)
|
||||
if (
|
||||
hasattr(self.app, "docs_download_urls_override")
|
||||
and self.app.docs_download_urls_override
|
||||
):
|
||||
self.config.docs_download_urls.set(self.app.docs_download_urls_override)
|
||||
|
||||
self.message_handler = MessageHandler(self.database)
|
||||
self.announce_manager = AnnounceManager(self.database)
|
||||
self.archiver_manager = ArchiverManager(self.database)
|
||||
|
||||
@@ -141,19 +141,12 @@ class TelephoneManager:
|
||||
self.call_start_time = time.time()
|
||||
self.call_was_established = True
|
||||
|
||||
# Clear initiation status as soon as call is established
|
||||
self._update_initiation_status(None, None)
|
||||
|
||||
# Track per-call stats from the active link (uses RNS Link counters)
|
||||
link = getattr(self.telephone, "active_call", None)
|
||||
self.call_stats = {
|
||||
"link": link,
|
||||
}
|
||||
|
||||
# Recording disabled for now due to stability issues with LXST
|
||||
# if self.config_manager and self.config_manager.call_recording_enabled.get():
|
||||
# self.start_recording()
|
||||
|
||||
if self.on_established_callback:
|
||||
self.on_established_callback(caller_identity)
|
||||
|
||||
@@ -351,6 +344,14 @@ class TelephoneManager:
|
||||
await asyncio.sleep(3)
|
||||
raise
|
||||
finally:
|
||||
# If call was successful, keep status for a moment to prevent UI flicker
|
||||
# while the frontend picks up the new active_call state
|
||||
if (
|
||||
self.telephone
|
||||
and self.telephone.active_call
|
||||
and self.telephone.call_status == 6
|
||||
):
|
||||
await asyncio.sleep(1.5)
|
||||
self._update_initiation_status(None, None)
|
||||
|
||||
def mute_transmit(self):
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
{{ $t("app.custom_fork_by") }}
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://git.quad4.io/Sudo-Ivan"
|
||||
:href="`${giteaBaseUrl}/Sudo-Ivan`"
|
||||
class="text-blue-500 dark:text-blue-300 hover:underline"
|
||||
>Sudo-Ivan</a
|
||||
>
|
||||
@@ -573,6 +573,9 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
giteaBaseUrl() {
|
||||
return this.config?.gitea_base_url || "https://git.quad4.io";
|
||||
},
|
||||
currentPopoutType() {
|
||||
if (this.$route?.meta?.popoutType) {
|
||||
return this.$route.meta.popoutType;
|
||||
|
||||
@@ -2304,7 +2304,6 @@ export default {
|
||||
const response = await window.axios.get("/api/v1/telephone/status");
|
||||
const oldCall = this.activeCall;
|
||||
const newCall = response.data.active_call;
|
||||
const callStatus = response.data.call_status;
|
||||
|
||||
// Sync local mute state from backend
|
||||
if (newCall) {
|
||||
@@ -2319,14 +2318,6 @@ export default {
|
||||
this.initiationTargetHash = response.data.initiation_target_hash;
|
||||
this.initiationTargetName = response.data.initiation_target_name;
|
||||
|
||||
// If no active call and status is idle/busy/rejected/available, clear stale initiation UI
|
||||
const isIdleState = !this.activeCall && ![2, 4, 5].includes(callStatus);
|
||||
if (isIdleState && this.initiationStatus) {
|
||||
this.initiationStatus = null;
|
||||
this.initiationTargetHash = null;
|
||||
this.initiationTargetName = null;
|
||||
}
|
||||
|
||||
if (this.activeCall?.is_voicemail) {
|
||||
this.wasVoicemail = true;
|
||||
}
|
||||
|
||||
@@ -367,12 +367,48 @@
|
||||
<MaterialDesignIcon icon-name="alert-circle-outline" class="w-12 h-12 mx-auto mb-3" />
|
||||
<div class="text-lg font-bold mb-2">{{ $t("docs.error") }}</div>
|
||||
<div class="text-sm opacity-80">{{ status.last_error }}</div>
|
||||
<button
|
||||
class="mt-6 px-6 py-2 bg-red-600 text-white rounded-xl text-xs font-bold hover:bg-red-700 transition-colors"
|
||||
@click="updateDocs"
|
||||
>
|
||||
Retry Download
|
||||
</button>
|
||||
<div class="flex flex-col gap-4 mt-6">
|
||||
<button
|
||||
class="px-6 py-2.5 bg-red-600 text-white rounded-xl text-xs font-bold hover:bg-red-700 transition-colors shadow-lg"
|
||||
@click="updateDocs"
|
||||
>
|
||||
Retry Download
|
||||
</button>
|
||||
|
||||
<div class="relative py-2">
|
||||
<div class="absolute inset-0 flex items-center" aria-hidden="true">
|
||||
<div class="w-full border-t border-red-200 dark:border-red-900/50"></div>
|
||||
</div>
|
||||
<div class="relative flex justify-center text-[10px] uppercase font-bold">
|
||||
<span class="bg-red-50 dark:bg-zinc-900 px-2 text-red-400"
|
||||
>or use alternate source</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<input
|
||||
v-model="alternateDocsUrl"
|
||||
type="text"
|
||||
class="w-full px-4 py-2.5 bg-white dark:bg-zinc-800 border border-red-200 dark:border-red-900/50 rounded-xl text-xs focus:outline-none focus:ring-2 focus:ring-red-500/20 text-gray-900 dark:text-zinc-100"
|
||||
placeholder="https://mirror.example.com/reticulum_docs.zip"
|
||||
/>
|
||||
<button
|
||||
class="w-full px-6 py-2.5 bg-zinc-900 dark:bg-white text-white dark:text-zinc-900 rounded-xl text-xs font-bold hover:opacity-90 transition-opacity disabled:opacity-50"
|
||||
:disabled="!alternateDocsUrl"
|
||||
@click="addAndRetryDocs"
|
||||
>
|
||||
Add Source & Retry
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<RouterLink
|
||||
:to="{ name: 'settings' }"
|
||||
class="text-[10px] font-bold text-red-500/60 hover:text-red-500 uppercase tracking-widest transition-colors"
|
||||
>
|
||||
Manage all sources in settings
|
||||
</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -535,6 +571,7 @@ export default {
|
||||
meshchatxDocs: [],
|
||||
selectedDocPath: null,
|
||||
selectedDocContent: null,
|
||||
alternateDocsUrl: "",
|
||||
languages: {
|
||||
en: "English",
|
||||
de: "Deutsch",
|
||||
@@ -636,6 +673,27 @@ export default {
|
||||
console.error("Failed to trigger docs update:", error);
|
||||
}
|
||||
},
|
||||
async addAndRetryDocs() {
|
||||
if (!this.alternateDocsUrl) return;
|
||||
try {
|
||||
// Get current config
|
||||
const configResponse = await window.axios.get("/api/v1/config");
|
||||
const currentUrls = configResponse.data.config.docs_download_urls || "";
|
||||
const newUrls = currentUrls ? `${currentUrls},${this.alternateDocsUrl}` : this.alternateDocsUrl;
|
||||
|
||||
// Update config
|
||||
await window.axios.patch("/api/v1/config", {
|
||||
docs_download_urls: newUrls,
|
||||
});
|
||||
|
||||
// Clear input and retry
|
||||
this.alternateDocsUrl = "";
|
||||
await this.updateDocs();
|
||||
} catch (error) {
|
||||
console.error("Failed to add alternate source:", error);
|
||||
ToastUtils.error("Failed to update documentation sources");
|
||||
}
|
||||
},
|
||||
async switchVersion(version) {
|
||||
try {
|
||||
await window.axios.post("/api/v1/docs/switch", { version });
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
<template>
|
||||
<label :for="id" class="relative inline-flex items-center cursor-pointer">
|
||||
<label
|
||||
:for="id"
|
||||
class="relative inline-flex items-center"
|
||||
:class="disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'"
|
||||
>
|
||||
<input
|
||||
:id="id"
|
||||
type="checkbox"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled"
|
||||
class="sr-only peer"
|
||||
@change="$emit('update:modelValue', $event.target.checked)"
|
||||
@change="!disabled && $emit('update:modelValue', $event.target.checked)"
|
||||
/>
|
||||
<div
|
||||
class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-zinc-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-blue-600 dark:peer-checked:bg-blue-600"
|
||||
@@ -30,6 +35,10 @@ export default {
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
emits: ["update:modelValue"],
|
||||
};
|
||||
|
||||
@@ -881,47 +881,96 @@
|
||||
</div>
|
||||
|
||||
<!-- no peer selected -->
|
||||
<div v-else class="flex flex-col h-full items-center justify-center">
|
||||
<div class="w-full max-w-md px-4">
|
||||
<div class="mb-6 text-center">
|
||||
<div v-else class="flex flex-col h-full overflow-y-auto bg-gray-50/50 dark:bg-zinc-950/50">
|
||||
<div class="max-w-4xl mx-auto w-full px-4 py-8 sm:py-12 flex flex-col items-center">
|
||||
<!-- welcome header -->
|
||||
<div class="text-center mb-12">
|
||||
<div
|
||||
class="w-16 h-16 mx-auto mb-4 rounded-2xl bg-gradient-to-br from-blue-100 to-blue-200 dark:from-blue-900/30 dark:to-blue-800/30 flex items-center justify-center"
|
||||
class="inline-flex items-center justify-center p-4 rounded-3xl bg-blue-600 shadow-xl shadow-blue-500/20 mb-6"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-8 h-8 text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M20.25 8.511c.884.284 1.5 1.128 1.5 2.097v4.286c0 1.136-.847 2.1-1.98 2.193-.34.027-.68.052-1.02.072v3.091l-3-3c-1.354 0-2.694-.055-4.02-.163a2.115 2.115 0 0 1-.825-.242m9.345-8.334a2.126 2.126 0 0 0-.476-.095 48.64 48.64 0 0 0-8.048 0c-1.131.094-1.976 1.057-1.976 2.192v4.286c0 .837.46 1.58 1.155 1.951m9.345-8.334V6.637c0-1.621-1.152-3.026-2.76-3.235A48.455 48.455 0 0 0 11.25 3c-2.115 0-4.198.137-6.24.402-1.608.209-2.76 1.614-2.76 3.235v6.226c0 1.621 1.152 3.026 2.76 3.235.577.075 1.157.14 1.74.194V21l4.155-4.155"
|
||||
/>
|
||||
</svg>
|
||||
<MaterialDesignIcon icon-name="message-text" class="size-10 text-white" />
|
||||
</div>
|
||||
<h1 class="text-3xl font-black text-gray-900 dark:text-white tracking-tight mb-3">
|
||||
{{ $t("messages.no_active_chat") }}
|
||||
</h1>
|
||||
<p class="text-gray-500 dark:text-zinc-400 max-w-sm mx-auto">
|
||||
{{ $t("messages.select_peer_or_enter_address") }}
|
||||
</p>
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-zinc-100 mb-1">
|
||||
{{ $t("messages.no_active_chat") }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-zinc-400 mb-8">
|
||||
{{ $t("messages.select_peer_or_enter_address") }}
|
||||
</p>
|
||||
|
||||
<!-- latest chats grid (desktop only) -->
|
||||
<div v-if="!isMobile && latestConversations.length > 0" class="w-full max-w-2xl mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h4 class="text-xs font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest">
|
||||
Latest Chats
|
||||
</h4>
|
||||
<!-- main actions grid -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 w-full mb-12">
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
|
||||
@click="focusComposeInput"
|
||||
>
|
||||
<div
|
||||
class="size-12 rounded-2xl bg-blue-50 dark:bg-blue-900/20 text-blue-600 flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="plus" class="size-6" />
|
||||
</div>
|
||||
<span class="text-sm font-bold text-gray-900 dark:text-zinc-100">New Message</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
|
||||
@click="syncPropagationNode"
|
||||
>
|
||||
<div
|
||||
class="size-12 rounded-2xl bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||
:class="{ 'animate-spin': isSyncingPropagationNode }"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="sync" class="size-6" />
|
||||
</div>
|
||||
<span class="text-sm font-bold text-gray-900 dark:text-zinc-100">{{
|
||||
isSyncingPropagationNode ? "Syncing..." : "Sync Node"
|
||||
}}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
|
||||
@click="copyMyAddress"
|
||||
>
|
||||
<div
|
||||
class="size-12 rounded-2xl bg-emerald-50 dark:bg-emerald-900/20 text-emerald-600 flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="content-copy" class="size-6" />
|
||||
</div>
|
||||
<span class="text-sm font-bold text-gray-900 dark:text-zinc-100">My Address</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="flex flex-col items-center gap-3 p-6 rounded-3xl bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all group"
|
||||
@click="$router.push({ name: 'identities' })"
|
||||
>
|
||||
<div
|
||||
class="size-12 rounded-2xl bg-purple-50 dark:bg-purple-900/20 text-purple-600 flex items-center justify-center group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account-multiple" class="size-6" />
|
||||
</div>
|
||||
<span class="text-sm font-bold text-gray-900 dark:text-zinc-100">Identities</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- latest chats section -->
|
||||
<div v-if="latestConversations.length > 0" class="w-full mb-12">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2
|
||||
class="text-sm font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="history" class="size-4" />
|
||||
Latest Conversations
|
||||
</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div
|
||||
v-for="chat in latestConversations"
|
||||
:key="chat.destination_hash"
|
||||
class="group cursor-pointer p-4 bg-white dark:bg-zinc-900/50 border border-gray-100 dark:border-zinc-800 rounded-2xl hover:border-blue-500/50 hover:shadow-xl hover:shadow-blue-500/5 transition-all duration-300 flex items-center gap-4"
|
||||
class="group cursor-pointer p-4 bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 rounded-3xl hover:border-blue-500/50 hover:shadow-xl transition-all flex items-center gap-4"
|
||||
@click="$emit('update:selectedPeer', chat)"
|
||||
>
|
||||
<div class="flex-shrink-0">
|
||||
@@ -933,90 +982,103 @@
|
||||
: 'account'
|
||||
"
|
||||
:icon-foreground-colour="
|
||||
chat.lxmf_user_icon && chat.lxmf_user_icon.foreground_colour
|
||||
? chat.lxmf_user_icon.foreground_colour
|
||||
: ''
|
||||
chat.lxmf_user_icon ? chat.lxmf_user_icon.foreground_colour : ''
|
||||
"
|
||||
:icon-background-colour="
|
||||
chat.lxmf_user_icon && chat.lxmf_user_icon.background_colour
|
||||
? chat.lxmf_user_icon.background_colour
|
||||
: ''
|
||||
chat.lxmf_user_icon ? chat.lxmf_user_icon.background_colour : ''
|
||||
"
|
||||
icon-class="size-12 sm:size-14"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-gray-900 dark:text-zinc-100 truncate">
|
||||
{{ chat.custom_display_name ?? chat.display_name }}
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="font-bold text-gray-900 dark:text-zinc-100 truncate">
|
||||
{{ chat.custom_display_name ?? chat.display_name }}
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-400 dark:text-zinc-500 whitespace-nowrap">
|
||||
{{ formatTimeAgo(chat.updated_at) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-500 truncate mt-0.5">
|
||||
{{ chat.latest_message_preview || chat.latest_message_title || "No messages yet" }}
|
||||
</div>
|
||||
</div>
|
||||
<v-icon
|
||||
icon="mdi-chevron-right"
|
||||
size="18"
|
||||
class="text-gray-300 dark:text-zinc-700 group-hover:text-blue-500 transition-colors"
|
||||
></v-icon>
|
||||
<MaterialDesignIcon
|
||||
icon-name="chevron-right"
|
||||
class="size-5 text-gray-300 dark:text-zinc-700 group-hover:text-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- compose message input -->
|
||||
<div class="w-full relative">
|
||||
<input
|
||||
id="compose-input"
|
||||
ref="compose-input"
|
||||
v-model="composeAddress"
|
||||
:readonly="isSendingMessage"
|
||||
type="text"
|
||||
class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
|
||||
placeholder="Enter LXMF address..."
|
||||
@keydown.enter.exact.prevent="onComposeEnterPressed"
|
||||
@keydown.up.prevent="handleComposeInputUp"
|
||||
@keydown.down.prevent="handleComposeInputDown"
|
||||
@focus="isComposeInputFocused = true"
|
||||
@blur="onComposeInputBlur"
|
||||
/>
|
||||
<!-- Suggestions Dropdown -->
|
||||
<div
|
||||
v-if="isComposeInputFocused && composeSuggestions.length > 0"
|
||||
class="absolute z-50 left-0 right-0 bottom-full mb-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
|
||||
>
|
||||
<!-- address input composer -->
|
||||
<div class="w-full max-w-xl">
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<MaterialDesignIcon
|
||||
icon-name="at"
|
||||
class="size-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="compose-input"
|
||||
ref="compose-input"
|
||||
v-model="composeAddress"
|
||||
:readonly="isSendingMessage"
|
||||
type="text"
|
||||
class="w-full bg-white dark:bg-zinc-900 border-2 border-gray-100 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-base rounded-3xl focus:ring-4 focus:ring-blue-500/10 focus:border-blue-500 pl-12 pr-4 py-4 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-600 font-medium"
|
||||
placeholder="Enter LXMF address to start a conversation..."
|
||||
@keydown.enter.exact.prevent="onComposeEnterPressed"
|
||||
@keydown.up.prevent="handleComposeInputUp"
|
||||
@keydown.down.prevent="handleComposeInputDown"
|
||||
@focus="isComposeInputFocused = true"
|
||||
@blur="onComposeInputBlur"
|
||||
/>
|
||||
|
||||
<!-- Suggestions Dropdown -->
|
||||
<div
|
||||
v-for="(suggestion, index) in composeSuggestions"
|
||||
:key="suggestion.hash"
|
||||
class="px-4 py-2.5 flex items-center gap-3 cursor-pointer transition-colors"
|
||||
:class="[
|
||||
index === selectedComposeSuggestionIndex
|
||||
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
|
||||
]"
|
||||
@mousedown.prevent="selectComposeSuggestion(suggestion)"
|
||||
v-if="isComposeInputFocused && composeSuggestions.length > 0"
|
||||
class="absolute z-50 left-0 right-0 bottom-full mb-4 bg-white dark:bg-zinc-900 border border-gray-100 dark:border-zinc-800 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in slide-in-from-bottom-4 duration-300"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 size-8 rounded-full flex items-center justify-center text-xs"
|
||||
:class="
|
||||
suggestion.type === 'contact'
|
||||
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
|
||||
: 'bg-gray-100 dark:bg-zinc-800 text-gray-500'
|
||||
"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="suggestion.icon" class="size-4" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-bold truncate">
|
||||
{{ suggestion.name }}
|
||||
<div class="p-2 space-y-1">
|
||||
<div
|
||||
v-for="(suggestion, index) in composeSuggestions"
|
||||
:key="suggestion.hash"
|
||||
class="px-4 py-3 flex items-center gap-3 cursor-pointer rounded-2xl transition-all"
|
||||
:class="[
|
||||
index === selectedComposeSuggestionIndex
|
||||
? 'bg-blue-600 text-white shadow-lg'
|
||||
: 'hover:bg-gray-50 dark:hover:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
|
||||
]"
|
||||
@mousedown.prevent="selectComposeSuggestion(suggestion)"
|
||||
>
|
||||
<div
|
||||
class="shrink-0 size-10 rounded-xl flex items-center justify-center"
|
||||
:class="[
|
||||
index === selectedComposeSuggestionIndex
|
||||
? 'bg-white/20'
|
||||
: suggestion.type === 'contact'
|
||||
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
|
||||
: 'bg-gray-100 dark:bg-zinc-800 text-gray-500',
|
||||
]"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="suggestion.icon" class="size-5" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-bold truncate">
|
||||
{{ suggestion.name }}
|
||||
</div>
|
||||
<div class="text-[10px] font-mono opacity-60 truncate">
|
||||
{{ formatDestinationHash(suggestion.hash) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="suggestion.type === 'contact'"
|
||||
class="text-[10px] uppercase font-black tracking-widest opacity-40 px-2 py-1 rounded-md bg-black/5"
|
||||
>
|
||||
Contact
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[10px] font-mono opacity-50 truncate">
|
||||
{{ formatDestinationHash(suggestion.hash) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="suggestion.type === 'contact'"
|
||||
class="text-[10px] uppercase font-bold tracking-widest opacity-30"
|
||||
>
|
||||
Contact
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1097,10 +1159,157 @@
|
||||
<MaterialDesignIcon icon-name="close" class="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto font-mono text-xs bg-gray-50 dark:bg-black/40">
|
||||
<pre class="whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300">{{
|
||||
JSON.stringify(rawMessageData, null, 2)
|
||||
}}</pre>
|
||||
<div class="p-0 overflow-y-auto bg-gray-50 dark:bg-zinc-950 flex-grow">
|
||||
<div class="p-6 space-y-6">
|
||||
<!-- header / status info -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||
>Message ID</label
|
||||
>
|
||||
<div class="text-sm font-mono text-gray-900 dark:text-zinc-200">
|
||||
{{ rawMessageData.id }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||
>State</label
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-md px-2 py-1 text-xs font-medium ring-1 ring-inset"
|
||||
:class="
|
||||
rawMessageData.state === 'delivered'
|
||||
? 'bg-green-50 text-green-700 ring-green-600/20 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-blue-50 text-blue-700 ring-blue-700/10 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
"
|
||||
>
|
||||
{{ rawMessageData.state }}
|
||||
</span>
|
||||
<span v-if="rawMessageData.is_incoming" class="text-[10px] text-gray-400"
|
||||
>Incoming</span
|
||||
>
|
||||
<span v-else class="text-[10px] text-gray-400">Outbound</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||
>Message Hash</label
|
||||
>
|
||||
<div
|
||||
class="text-sm font-mono break-all text-gray-900 dark:text-zinc-200 bg-white dark:bg-zinc-900 p-2 rounded border border-gray-100 dark:border-zinc-800"
|
||||
>
|
||||
{{ rawMessageData.hash }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||
>Source Hash</label
|
||||
>
|
||||
<div class="text-xs font-mono break-all text-gray-900 dark:text-zinc-200">
|
||||
{{ rawMessageData.source_hash }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||
>Destination Hash</label
|
||||
>
|
||||
<div class="text-xs font-mono break-all text-gray-900 dark:text-zinc-200">
|
||||
{{ rawMessageData.destination_hash }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||
>Method</label
|
||||
>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-200 capitalize">
|
||||
{{ rawMessageData.method }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||
>RSSI</label
|
||||
>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-200">
|
||||
{{ rawMessageData.rssi || "N/A" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||
>SNR</label
|
||||
>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-200">
|
||||
{{ rawMessageData.snr || "N/A" }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||
>Attempts</label
|
||||
>
|
||||
<div class="text-sm text-gray-900 dark:text-zinc-200">
|
||||
{{ rawMessageData.delivery_attempts }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||
>Content / App Data</label
|
||||
>
|
||||
<div
|
||||
class="text-xs font-mono bg-white dark:bg-zinc-900 p-3 rounded border border-gray-100 dark:border-zinc-800 whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300"
|
||||
>
|
||||
{{ rawMessageData.content }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="rawMessageData.raw_uri" class="space-y-1">
|
||||
<label
|
||||
class="text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500"
|
||||
>Raw LXMF URI</label
|
||||
>
|
||||
<div
|
||||
class="text-[10px] font-mono bg-white dark:bg-zinc-900 p-2 rounded border border-gray-100 dark:border-zinc-800 break-all text-gray-600 dark:text-zinc-400"
|
||||
>
|
||||
{{ rawMessageData.raw_uri }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JSON fallback for full detail -->
|
||||
<details class="group">
|
||||
<summary
|
||||
class="flex items-center gap-2 cursor-pointer text-[10px] font-bold uppercase tracking-wider text-gray-400 dark:text-zinc-500 hover:text-gray-600 dark:hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
icon-name="chevron-right"
|
||||
class="size-4 group-open:rotate-90 transition-transform"
|
||||
/>
|
||||
View Full JSON Object
|
||||
</summary>
|
||||
<div class="mt-2 p-4 bg-black/5 dark:bg-black/20 rounded-lg overflow-x-auto">
|
||||
<pre class="text-[10px] font-mono text-gray-600 dark:text-zinc-400">{{
|
||||
JSON.stringify(rawMessageData, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end shrink-0">
|
||||
<button
|
||||
@@ -1233,9 +1442,21 @@ export default {
|
||||
rawMessageData: null,
|
||||
hasTranslator: false,
|
||||
translatorLanguages: [],
|
||||
propagationNodeStatus: null,
|
||||
propagationStatusInterval: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isSyncingPropagationNode() {
|
||||
return [
|
||||
"path_requested",
|
||||
"link_establishing",
|
||||
"link_established",
|
||||
"request_sent",
|
||||
"receiving",
|
||||
"response_received",
|
||||
].includes(this.propagationNodeStatus?.state);
|
||||
},
|
||||
blockedDestinations() {
|
||||
return GlobalState.blockedDestinations;
|
||||
},
|
||||
@@ -1387,6 +1608,9 @@ export default {
|
||||
// stop listening for websocket messages
|
||||
WebSocketConnection.off("message", this.onWebsocketMessage);
|
||||
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
|
||||
if (this.propagationStatusInterval) {
|
||||
clearInterval(this.propagationStatusInterval);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// listen for websocket messages
|
||||
@@ -1400,8 +1624,42 @@ export default {
|
||||
|
||||
// fetch contacts for suggestions
|
||||
this.fetchContacts();
|
||||
|
||||
// fetch propagation status
|
||||
this.updatePropagationNodeStatus();
|
||||
this.propagationStatusInterval = setInterval(() => {
|
||||
this.updatePropagationNodeStatus();
|
||||
}, 2000);
|
||||
},
|
||||
methods: {
|
||||
async updatePropagationNodeStatus() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/lxmf/propagation-node/status");
|
||||
this.propagationNodeStatus = response.data.propagation_node_status;
|
||||
} catch {
|
||||
// do nothing on error
|
||||
}
|
||||
},
|
||||
async syncPropagationNode() {
|
||||
GlobalEmitter.emit("sync-propagation-node");
|
||||
},
|
||||
async copyMyAddress() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.myLxmfAddressHash);
|
||||
ToastUtils.success("Your LXMF address copied to clipboard");
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
ToastUtils.error("Failed to copy address");
|
||||
}
|
||||
},
|
||||
focusComposeInput() {
|
||||
this.$nextTick(() => {
|
||||
const input = document.getElementById("compose-input");
|
||||
if (input) {
|
||||
input.focus();
|
||||
}
|
||||
});
|
||||
},
|
||||
async fetchContacts() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telephone/contacts");
|
||||
|
||||
@@ -34,7 +34,10 @@
|
||||
class="flex-1 flex flex-col bg-white dark:bg-zinc-950 border-r border-gray-200 dark:border-zinc-700 overflow-hidden min-h-0"
|
||||
>
|
||||
<!-- search + filters -->
|
||||
<div v-if="conversations.length > 0" class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2">
|
||||
<div
|
||||
v-if="conversations.length > 0 || isFilterActive"
|
||||
class="p-1 border-b border-gray-300 dark:border-zinc-700 space-y-2"
|
||||
>
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
:value="conversationSearchTerm"
|
||||
@@ -188,7 +191,10 @@
|
||||
</div>
|
||||
|
||||
<!-- no conversations at all -->
|
||||
<div v-else-if="conversations.length === 0" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div
|
||||
v-else-if="conversations.length === 0 && !isFilterActive"
|
||||
class="flex flex-col text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<div class="mx-auto mb-1 text-gray-500">
|
||||
<MaterialDesignIcon icon-name="tray-remove" class="size-6" />
|
||||
</div>
|
||||
@@ -196,11 +202,8 @@
|
||||
<div>Discover peers on the Announces tab</div>
|
||||
</div>
|
||||
|
||||
<!-- is searching, but no results -->
|
||||
<div
|
||||
v-else-if="conversationSearchTerm !== ''"
|
||||
class="flex flex-col text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
<!-- is searching or filtering, but no results -->
|
||||
<div v-else-if="isFilterActive" class="flex flex-col text-gray-900 dark:text-gray-100">
|
||||
<div class="mx-auto mb-1 text-gray-500">
|
||||
<MaterialDesignIcon icon-name="magnify-close" class="size-6" />
|
||||
</div>
|
||||
@@ -418,6 +421,14 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isFilterActive() {
|
||||
return (
|
||||
this.conversationSearchTerm !== "" ||
|
||||
this.filterUnreadOnly ||
|
||||
this.filterFailedOnly ||
|
||||
this.filterHasAttachmentsOnly
|
||||
);
|
||||
},
|
||||
blockedDestinations() {
|
||||
return GlobalState.blockedDestinations;
|
||||
},
|
||||
|
||||
@@ -183,19 +183,20 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-4">
|
||||
<label class="setting-toggle">
|
||||
<label class="setting-toggle opacity-50 cursor-not-allowed">
|
||||
<Toggle
|
||||
id="desktop-open-calls-in-separate-window"
|
||||
v-model="config.desktop_open_calls_in_separate_window"
|
||||
@update:model-value="onDesktopOpenCallsInSeparateWindowChange"
|
||||
:model-value="false"
|
||||
:disabled="true"
|
||||
/>
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">{{
|
||||
$t("app.desktop_open_calls_in_separate_window")
|
||||
}}</span>
|
||||
<span class="setting-toggle__description">{{
|
||||
$t("app.desktop_open_calls_in_separate_window_description")
|
||||
}}</span>
|
||||
<span class="setting-toggle__description">
|
||||
{{ $t("app.desktop_open_calls_in_separate_window_description") }}
|
||||
<span class="text-blue-500 font-bold block mt-1">(Phased out for now)</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -615,6 +616,47 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Sources & Infrastructure -->
|
||||
<section class="glass-card break-inside-avoid">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Infrastructure</div>
|
||||
<h2>Sources & Mirroring</h2>
|
||||
<p>Customize URLs for documentation and external resources.</p>
|
||||
</div>
|
||||
</header>
|
||||
<div class="glass-card__body space-y-4">
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">Gitea Base URL</div>
|
||||
<input
|
||||
v-model="config.gitea_base_url"
|
||||
type="text"
|
||||
placeholder="https://git.quad4.io"
|
||||
class="input-field"
|
||||
@input="onGiteaConfigChange"
|
||||
/>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
The base URL for your preferred Gitea instance.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
Documentation Download URLs
|
||||
</div>
|
||||
<textarea
|
||||
v-model="config.docs_download_urls"
|
||||
placeholder="Enter one URL per line (or comma-separated)"
|
||||
class="input-field min-h-[100px] text-xs font-mono"
|
||||
@input="onGiteaConfigChange"
|
||||
></textarea>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
List of ZIP URLs to try when downloading documentation. One URL per line.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Messages -->
|
||||
<section class="glass-card break-inside-avoid">
|
||||
<header class="glass-card__header">
|
||||
@@ -904,6 +946,8 @@ export default {
|
||||
blackhole_integration_enabled: true,
|
||||
telephone_tone_generator_enabled: true,
|
||||
telephone_tone_generator_volume: 50,
|
||||
gitea_base_url: "https://git.quad4.io",
|
||||
docs_download_urls: "",
|
||||
},
|
||||
saveTimeouts: {},
|
||||
shortcuts: [],
|
||||
@@ -1257,6 +1301,18 @@ export default {
|
||||
);
|
||||
}, 1000);
|
||||
},
|
||||
async onGiteaConfigChange() {
|
||||
if (this.saveTimeouts.gitea) clearTimeout(this.saveTimeouts.gitea);
|
||||
this.saveTimeouts.gitea = setTimeout(async () => {
|
||||
await this.updateConfig(
|
||||
{
|
||||
gitea_base_url: this.config.gitea_base_url,
|
||||
docs_download_urls: this.config.docs_download_urls,
|
||||
},
|
||||
"Infrastructure"
|
||||
);
|
||||
}, 1000);
|
||||
},
|
||||
async flushArchivedPages() {
|
||||
if (
|
||||
!(await DialogUtils.confirm(
|
||||
|
||||
@@ -396,13 +396,13 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://git.quad4.io/Reticulum/rnode-flasher"
|
||||
:href="`${giteaBaseUrl}/Reticulum/rnode-flasher`"
|
||||
class="text-blue-500 hover:underline text-sm font-bold"
|
||||
>RNode Flasher GH</a
|
||||
>
|
||||
<a
|
||||
target="_blank"
|
||||
href="https://git.quad4.io/Reticulum/RNode_Firmware"
|
||||
:href="`${giteaBaseUrl}/Reticulum/RNode_Firmware`"
|
||||
class="text-blue-500 hover:underline text-sm font-bold"
|
||||
>RNode Firmware GH</a
|
||||
>
|
||||
@@ -420,6 +420,7 @@ import Nrf52DfuFlasher from "../../js/rnode/Nrf52DfuFlasher.js";
|
||||
import RNodeUtils from "../../js/rnode/RNodeUtils.js";
|
||||
import products from "../../js/rnode/products.js";
|
||||
import ToastUtils from "../../js/ToastUtils.js";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
|
||||
export default {
|
||||
name: "RNodeFlasherPage",
|
||||
@@ -459,6 +460,9 @@ export default {
|
||||
recommendedFirmwareFilename() {
|
||||
return this.selectedModel?.firmware_filename ?? this.selectedProduct?.firmware_filename;
|
||||
},
|
||||
giteaBaseUrl() {
|
||||
return GlobalState.config?.gitea_base_url || "https://git.quad4.io";
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedProduct() {
|
||||
@@ -473,7 +477,7 @@ export default {
|
||||
async fetchLatestRelease() {
|
||||
try {
|
||||
const response = await fetch(
|
||||
"https://git.quad4.io/api/v1/repos/Reticulum/RNode_Firmware/releases/latest"
|
||||
`${this.giteaBaseUrl}/api/v1/repos/Reticulum/RNode_Firmware/releases/latest`
|
||||
);
|
||||
if (response.ok) {
|
||||
this.latestRelease = await response.json();
|
||||
|
||||
Reference in New Issue
Block a user