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:
2026-01-04 18:55:10 -06:00
parent 2f65bde2d3
commit ff69de1346
13 changed files with 697 additions and 196 deletions

View File

@@ -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

View File

@@ -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(

View File

@@ -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"

View File

@@ -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)

View File

@@ -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):

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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 });

View File

@@ -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"],
};

View File

@@ -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");

View File

@@ -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;
},

View File

@@ -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(

View File

@@ -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();