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, auth_enabled: bool = False,
public_dir: str | None = None, public_dir: str | None = None,
emergency: bool = False, emergency: bool = False,
gitea_base_url: str | None = None,
docs_download_urls: str | None = None,
): ):
self.running = True self.running = True
self.reticulum_config_dir = reticulum_config_dir self.reticulum_config_dir = reticulum_config_dir
@@ -210,6 +212,8 @@ class ReticulumMeshChat:
self.emergency = emergency self.emergency = emergency
self.auth_enabled_initial = auth_enabled self.auth_enabled_initial = auth_enabled
self.public_dir_override = public_dir 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] = [] self.websocket_clients: list[web.WebSocketResponse] = []
# track announce timestamps for rate calculation # track announce timestamps for rate calculation
@@ -1177,11 +1181,15 @@ class ReticulumMeshChat:
def get_app_version() -> str: def get_app_version() -> str:
return app_version return app_version
def get_lxst_version(self) -> str: @staticmethod
def get_package_version(package_name: str, default: str = "unknown") -> str:
try: try:
return importlib.metadata.version("lxst") return importlib.metadata.version(package_name)
except Exception: 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 # automatically announces based on user config
async def announce_loop(self, session_id, context=None): async def announce_loop(self, session_id, context=None):
@@ -2272,9 +2280,16 @@ class ReticulumMeshChat:
if not url: if not url:
return web.json_response({"error": "URL is required"}, status=400) return web.json_response({"error": "URL is required"}, status=400)
# Restrict to GitHub for safety # Restrict to allowed sources for safety
if not url.startswith("https://git.quad4.io/") and not url.startswith( gitea_url = "https://git.quad4.io"
"https://objects.githubusercontent.com/" 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) return web.json_response({"error": "Invalid download URL"}, status=403)
@@ -3172,21 +3187,21 @@ class ReticulumMeshChat:
"lxst_version": self.get_lxst_version(), "lxst_version": self.get_lxst_version(),
"python_version": platform.python_version(), "python_version": platform.python_version(),
"dependencies": { "dependencies": {
"aiohttp": importlib.metadata.version("aiohttp"), "aiohttp": self.get_package_version("aiohttp"),
"aiohttp_session": importlib.metadata.version( "aiohttp_session": self.get_package_version(
"aiohttp-session" "aiohttp-session"
), ),
"cryptography": importlib.metadata.version("cryptography"), "cryptography": self.get_package_version("cryptography"),
"psutil": importlib.metadata.version("psutil"), "psutil": self.get_package_version("psutil"),
"requests": importlib.metadata.version("requests"), "requests": self.get_package_version("requests"),
"websockets": importlib.metadata.version("websockets"), "websockets": self.get_package_version("websockets"),
"audioop_lts": ( "audioop_lts": (
importlib.metadata.version("audioop-lts") self.get_package_version("audioop-lts")
if sys.version_info >= (3, 13) if sys.version_info >= (3, 13)
else "n/a" else "n/a"
), ),
"ply": importlib.metadata.version("ply"), "ply": self.get_package_version("ply"),
"bcrypt": importlib.metadata.version("bcrypt"), "bcrypt": self.get_package_version("bcrypt"),
}, },
"storage_path": self.storage_path, "storage_path": self.storage_path,
"database_path": self.database_path, "database_path": self.database_path,
@@ -7290,16 +7305,59 @@ class ReticulumMeshChat:
response.headers["X-XSS-Protection"] = "1; mode=block" response.headers["X-XSS-Protection"] = "1; mode=block"
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
# CSP: allow localhost for development and Electron, websockets, and blob URLs # CSP: allow localhost for development and Electron, websockets, and blob URLs
# Add 'unsafe-inline' and 'unsafe-eval' for some legacy doc scripts if needed, # Add 'unsafe-inline' and 'unsafe-eval' for some legacy doc scripts if needed,
# and allow framing ourselves for the docs page. # 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 = ( csp = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; " "script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
"style-src 'self' 'unsafe-inline'; " "style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; " "img-src 'self' data: blob: https://*.tile.openstreetmap.org https://tile.openstreetmap.org; "
"font-src 'self' data:; " "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:; " "media-src 'self' blob:; "
"worker-src 'self' blob:; " "worker-src 'self' blob:; "
"frame-src 'self'; " "frame-src 'self'; "
@@ -10153,6 +10211,18 @@ def main():
default=os.environ.get("MESHCHAT_PUBLIC_DIR"), 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.", 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( parser.add_argument(
"--test-exception-message", "--test-exception-message",
type=str, type=str,
@@ -10278,6 +10348,8 @@ def main():
auth_enabled=args.auth, auth_enabled=args.auth,
public_dir=args.public_dir, public_dir=args.public_dir,
emergency=args.emergency, emergency=args.emergency,
gitea_base_url=args.gitea_base_url,
docs_download_urls=args.docs_download_urls,
) )
# update recovery with known paths # update recovery with known paths

View File

@@ -118,6 +118,14 @@ class ConfigManager:
"initial_docs_download_attempted", "initial_docs_download_attempted",
False, 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 # desktop config
self.desktop_open_calls_in_separate_window = self.BoolConfig( self.desktop_open_calls_in_separate_window = self.BoolConfig(

View File

@@ -86,7 +86,10 @@ class DocsManager:
try: try:
# Try symlink first as it's efficient # 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): except (OSError, AttributeError):
# Fallback to copy # Fallback to copy
shutil.copytree(version_path, self.docs_dir) shutil.copytree(version_path, self.docs_dir)
@@ -440,10 +443,16 @@ class DocsManager:
self.download_progress = 0 self.download_progress = 0
self.last_error = None self.last_error = None
# 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"]
last_exception = None
for url in urls:
try: try:
# We use the reticulum_website repository which contains the built HTML docs logging.info(f"Attempting to download docs from {url}")
# 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") zip_path = os.path.join(self.docs_base_dir, "website.zip")
# Download ZIP # Download ZIP
@@ -483,11 +492,19 @@ class DocsManager:
# Switch to the new version # Switch to the new version
self.switch_version(version) self.switch_version(version)
return # Success, exit task
except Exception as e: except Exception as e:
self.last_error = str(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" self.download_status = "error"
logging.exception(f"Failed to update docs: {e}") logging.error(f"All docs download sources failed. Last error: {last_exception}")
def upload_zip(self, zip_bytes, version): def upload_zip(self, zip_bytes, version):
self.download_status = "extracting" self.download_status = "extracting"

View File

@@ -129,6 +129,19 @@ class IdentityContext:
# 3. Initialize Config and Managers # 3. Initialize Config and Managers
self.config = ConfigManager(self.database) 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.message_handler = MessageHandler(self.database)
self.announce_manager = AnnounceManager(self.database) self.announce_manager = AnnounceManager(self.database)
self.archiver_manager = ArchiverManager(self.database) self.archiver_manager = ArchiverManager(self.database)

View File

@@ -141,19 +141,12 @@ class TelephoneManager:
self.call_start_time = time.time() self.call_start_time = time.time()
self.call_was_established = True 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) # Track per-call stats from the active link (uses RNS Link counters)
link = getattr(self.telephone, "active_call", None) link = getattr(self.telephone, "active_call", None)
self.call_stats = { self.call_stats = {
"link": link, "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: if self.on_established_callback:
self.on_established_callback(caller_identity) self.on_established_callback(caller_identity)
@@ -351,6 +344,14 @@ class TelephoneManager:
await asyncio.sleep(3) await asyncio.sleep(3)
raise raise
finally: 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) self._update_initiation_status(None, None)
def mute_transmit(self): def mute_transmit(self):

View File

@@ -50,7 +50,7 @@
{{ $t("app.custom_fork_by") }} {{ $t("app.custom_fork_by") }}
<a <a
target="_blank" target="_blank"
href="https://git.quad4.io/Sudo-Ivan" :href="`${giteaBaseUrl}/Sudo-Ivan`"
class="text-blue-500 dark:text-blue-300 hover:underline" class="text-blue-500 dark:text-blue-300 hover:underline"
>Sudo-Ivan</a >Sudo-Ivan</a
> >
@@ -573,6 +573,9 @@ export default {
}; };
}, },
computed: { computed: {
giteaBaseUrl() {
return this.config?.gitea_base_url || "https://git.quad4.io";
},
currentPopoutType() { currentPopoutType() {
if (this.$route?.meta?.popoutType) { if (this.$route?.meta?.popoutType) {
return 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 response = await window.axios.get("/api/v1/telephone/status");
const oldCall = this.activeCall; const oldCall = this.activeCall;
const newCall = response.data.active_call; const newCall = response.data.active_call;
const callStatus = response.data.call_status;
// Sync local mute state from backend // Sync local mute state from backend
if (newCall) { if (newCall) {
@@ -2319,14 +2318,6 @@ export default {
this.initiationTargetHash = response.data.initiation_target_hash; this.initiationTargetHash = response.data.initiation_target_hash;
this.initiationTargetName = response.data.initiation_target_name; 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) { if (this.activeCall?.is_voicemail) {
this.wasVoicemail = true; this.wasVoicemail = true;
} }

View File

@@ -367,12 +367,48 @@
<MaterialDesignIcon icon-name="alert-circle-outline" class="w-12 h-12 mx-auto mb-3" /> <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-lg font-bold mb-2">{{ $t("docs.error") }}</div>
<div class="text-sm opacity-80">{{ status.last_error }}</div> <div class="text-sm opacity-80">{{ status.last_error }}</div>
<div class="flex flex-col gap-4 mt-6">
<button <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" 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" @click="updateDocs"
> >
Retry Download Retry Download
</button> </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>
</div> </div>
@@ -535,6 +571,7 @@ export default {
meshchatxDocs: [], meshchatxDocs: [],
selectedDocPath: null, selectedDocPath: null,
selectedDocContent: null, selectedDocContent: null,
alternateDocsUrl: "",
languages: { languages: {
en: "English", en: "English",
de: "Deutsch", de: "Deutsch",
@@ -636,6 +673,27 @@ export default {
console.error("Failed to trigger docs update:", error); 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) { async switchVersion(version) {
try { try {
await window.axios.post("/api/v1/docs/switch", { version }); await window.axios.post("/api/v1/docs/switch", { version });

View File

@@ -1,11 +1,16 @@
<template> <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 <input
:id="id" :id="id"
type="checkbox" type="checkbox"
:checked="modelValue" :checked="modelValue"
:disabled="disabled"
class="sr-only peer" class="sr-only peer"
@change="$emit('update:modelValue', $event.target.checked)" @change="!disabled && $emit('update:modelValue', $event.target.checked)"
/> />
<div <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" 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, type: String,
default: null, default: null,
}, },
disabled: {
type: Boolean,
default: false,
},
}, },
emits: ["update:modelValue"], emits: ["update:modelValue"],
}; };

View File

@@ -881,47 +881,96 @@
</div> </div>
<!-- no peer selected --> <!-- no peer selected -->
<div v-else class="flex flex-col h-full items-center justify-center"> <div v-else class="flex flex-col h-full overflow-y-auto bg-gray-50/50 dark:bg-zinc-950/50">
<div class="w-full max-w-md px-4"> <div class="max-w-4xl mx-auto w-full px-4 py-8 sm:py-12 flex flex-col items-center">
<div class="mb-6 text-center"> <!-- welcome header -->
<div class="text-center mb-12">
<div <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 <MaterialDesignIcon icon-name="message-text" class="size-10 text-white" />
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>
</div> </div>
</div> <h1 class="text-3xl font-black text-gray-900 dark:text-white tracking-tight mb-3">
<h3 class="text-lg font-semibold text-gray-900 dark:text-zinc-100 mb-1">
{{ $t("messages.no_active_chat") }} {{ $t("messages.no_active_chat") }}
</h3> </h1>
<p class="text-sm text-gray-500 dark:text-zinc-400 mb-8"> <p class="text-gray-500 dark:text-zinc-400 max-w-sm mx-auto">
{{ $t("messages.select_peer_or_enter_address") }} {{ $t("messages.select_peer_or_enter_address") }}
</p> </p>
</div>
<!-- latest chats grid (desktop only) --> <!-- main actions grid -->
<div v-if="!isMobile && latestConversations.length > 0" class="w-full max-w-2xl mb-8"> <div class="grid grid-cols-2 sm:grid-cols-4 gap-4 w-full mb-12">
<div class="flex items-center justify-between mb-4"> <button
<h4 class="text-xs font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest"> type="button"
Latest Chats 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"
</h4> @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>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div <div
v-for="chat in latestConversations" v-for="chat in latestConversations"
:key="chat.destination_hash" :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)" @click="$emit('update:selectedPeer', chat)"
> >
<div class="flex-shrink-0"> <div class="flex-shrink-0">
@@ -933,88 +982,99 @@
: 'account' : 'account'
" "
:icon-foreground-colour=" :icon-foreground-colour="
chat.lxmf_user_icon && chat.lxmf_user_icon.foreground_colour chat.lxmf_user_icon ? chat.lxmf_user_icon.foreground_colour : ''
? chat.lxmf_user_icon.foreground_colour
: ''
" "
:icon-background-colour=" :icon-background-colour="
chat.lxmf_user_icon && chat.lxmf_user_icon.background_colour chat.lxmf_user_icon ? chat.lxmf_user_icon.background_colour : ''
? chat.lxmf_user_icon.background_colour
: ''
" "
icon-class="size-12 sm:size-14" icon-class="size-12 sm:size-14"
/> />
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center justify-between gap-2">
<div class="font-bold text-gray-900 dark:text-zinc-100 truncate"> <div class="font-bold text-gray-900 dark:text-zinc-100 truncate">
{{ chat.custom_display_name ?? chat.display_name }} {{ chat.custom_display_name ?? chat.display_name }}
</div> </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"> <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" }} {{ chat.latest_message_preview || chat.latest_message_title || "No messages yet" }}
</div> </div>
</div> </div>
<v-icon <MaterialDesignIcon
icon="mdi-chevron-right" icon-name="chevron-right"
size="18" class="size-5 text-gray-300 dark:text-zinc-700 group-hover:text-blue-500 transition-colors"
class="text-gray-300 dark:text-zinc-700 group-hover:text-blue-500 transition-colors" />
></v-icon>
</div> </div>
</div> </div>
</div> </div>
<!-- compose message input --> <!-- address input composer -->
<div class="w-full relative"> <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 <input
id="compose-input" id="compose-input"
ref="compose-input" ref="compose-input"
v-model="composeAddress" v-model="composeAddress"
:readonly="isSendingMessage" :readonly="isSendingMessage"
type="text" 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" 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..." placeholder="Enter LXMF address to start a conversation..."
@keydown.enter.exact.prevent="onComposeEnterPressed" @keydown.enter.exact.prevent="onComposeEnterPressed"
@keydown.up.prevent="handleComposeInputUp" @keydown.up.prevent="handleComposeInputUp"
@keydown.down.prevent="handleComposeInputDown" @keydown.down.prevent="handleComposeInputDown"
@focus="isComposeInputFocused = true" @focus="isComposeInputFocused = true"
@blur="onComposeInputBlur" @blur="onComposeInputBlur"
/> />
<!-- Suggestions Dropdown --> <!-- Suggestions Dropdown -->
<div <div
v-if="isComposeInputFocused && composeSuggestions.length > 0" 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" 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="p-2 space-y-1">
<div <div
v-for="(suggestion, index) in composeSuggestions" v-for="(suggestion, index) in composeSuggestions"
:key="suggestion.hash" :key="suggestion.hash"
class="px-4 py-2.5 flex items-center gap-3 cursor-pointer transition-colors" class="px-4 py-3 flex items-center gap-3 cursor-pointer rounded-2xl transition-all"
:class="[ :class="[
index === selectedComposeSuggestionIndex index === selectedComposeSuggestionIndex
? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400' ? 'bg-blue-600 text-white shadow-lg'
: 'hover:bg-gray-50 dark:hover:bg-zinc-800/50 text-gray-700 dark:text-zinc-300', : 'hover:bg-gray-50 dark:hover:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
]" ]"
@mousedown.prevent="selectComposeSuggestion(suggestion)" @mousedown.prevent="selectComposeSuggestion(suggestion)"
> >
<div <div
class="shrink-0 size-8 rounded-full flex items-center justify-center text-xs" class="shrink-0 size-10 rounded-xl flex items-center justify-center"
:class=" :class="[
suggestion.type === 'contact' index === selectedComposeSuggestionIndex
? 'bg-white/20'
: suggestion.type === 'contact'
? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600' ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
: 'bg-gray-100 dark:bg-zinc-800 text-gray-500' : 'bg-gray-100 dark:bg-zinc-800 text-gray-500',
" ]"
> >
<MaterialDesignIcon :icon-name="suggestion.icon" class="size-4" /> <MaterialDesignIcon :icon-name="suggestion.icon" class="size-5" />
</div> </div>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="text-sm font-bold truncate"> <div class="text-sm font-bold truncate">
{{ suggestion.name }} {{ suggestion.name }}
</div> </div>
<div class="text-[10px] font-mono opacity-50 truncate"> <div class="text-[10px] font-mono opacity-60 truncate">
{{ formatDestinationHash(suggestion.hash) }} {{ formatDestinationHash(suggestion.hash) }}
</div> </div>
</div> </div>
<div <div
v-if="suggestion.type === 'contact'" v-if="suggestion.type === 'contact'"
class="text-[10px] uppercase font-bold tracking-widest opacity-30" class="text-[10px] uppercase font-black tracking-widest opacity-40 px-2 py-1 rounded-md bg-black/5"
> >
Contact Contact
</div> </div>
@@ -1023,6 +1083,8 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<!-- image modal --> <!-- image modal -->
<Transition <Transition
@@ -1097,11 +1159,158 @@
<MaterialDesignIcon icon-name="close" class="size-6" /> <MaterialDesignIcon icon-name="close" class="size-6" />
</button> </button>
</div> </div>
<div class="p-6 overflow-y-auto font-mono text-xs bg-gray-50 dark:bg-black/40"> <div class="p-0 overflow-y-auto bg-gray-50 dark:bg-zinc-950 flex-grow">
<pre class="whitespace-pre-wrap break-all text-gray-800 dark:text-zinc-300">{{ <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) JSON.stringify(rawMessageData, null, 2)
}}</pre> }}</pre>
</div> </div>
</details>
</div>
</div>
<div class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end shrink-0"> <div class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 flex justify-end shrink-0">
<button <button
type="button" type="button"
@@ -1233,9 +1442,21 @@ export default {
rawMessageData: null, rawMessageData: null,
hasTranslator: false, hasTranslator: false,
translatorLanguages: [], translatorLanguages: [],
propagationNodeStatus: null,
propagationStatusInterval: null,
}; };
}, },
computed: { computed: {
isSyncingPropagationNode() {
return [
"path_requested",
"link_establishing",
"link_established",
"request_sent",
"receiving",
"response_received",
].includes(this.propagationNodeStatus?.state);
},
blockedDestinations() { blockedDestinations() {
return GlobalState.blockedDestinations; return GlobalState.blockedDestinations;
}, },
@@ -1387,6 +1608,9 @@ export default {
// stop listening for websocket messages // stop listening for websocket messages
WebSocketConnection.off("message", this.onWebsocketMessage); WebSocketConnection.off("message", this.onWebsocketMessage);
GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent); GlobalEmitter.off("compose-new-message", this.onComposeNewMessageEvent);
if (this.propagationStatusInterval) {
clearInterval(this.propagationStatusInterval);
}
}, },
mounted() { mounted() {
// listen for websocket messages // listen for websocket messages
@@ -1400,8 +1624,42 @@ export default {
// fetch contacts for suggestions // fetch contacts for suggestions
this.fetchContacts(); this.fetchContacts();
// fetch propagation status
this.updatePropagationNodeStatus();
this.propagationStatusInterval = setInterval(() => {
this.updatePropagationNodeStatus();
}, 2000);
}, },
methods: { 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() { async fetchContacts() {
try { try {
const response = await window.axios.get("/api/v1/telephone/contacts"); 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" 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 --> <!-- 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"> <div class="flex gap-1">
<input <input
:value="conversationSearchTerm" :value="conversationSearchTerm"
@@ -188,7 +191,10 @@
</div> </div>
<!-- no conversations at all --> <!-- 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"> <div class="mx-auto mb-1 text-gray-500">
<MaterialDesignIcon icon-name="tray-remove" class="size-6" /> <MaterialDesignIcon icon-name="tray-remove" class="size-6" />
</div> </div>
@@ -196,11 +202,8 @@
<div>Discover peers on the Announces tab</div> <div>Discover peers on the Announces tab</div>
</div> </div>
<!-- is searching, but no results --> <!-- is searching or filtering, but no results -->
<div <div v-else-if="isFilterActive" class="flex flex-col text-gray-900 dark:text-gray-100">
v-else-if="conversationSearchTerm !== ''"
class="flex flex-col text-gray-900 dark:text-gray-100"
>
<div class="mx-auto mb-1 text-gray-500"> <div class="mx-auto mb-1 text-gray-500">
<MaterialDesignIcon icon-name="magnify-close" class="size-6" /> <MaterialDesignIcon icon-name="magnify-close" class="size-6" />
</div> </div>
@@ -418,6 +421,14 @@ export default {
}; };
}, },
computed: { computed: {
isFilterActive() {
return (
this.conversationSearchTerm !== "" ||
this.filterUnreadOnly ||
this.filterFailedOnly ||
this.filterHasAttachmentsOnly
);
},
blockedDestinations() { blockedDestinations() {
return GlobalState.blockedDestinations; return GlobalState.blockedDestinations;
}, },

View File

@@ -183,19 +183,20 @@
</div> </div>
</header> </header>
<div class="glass-card__body space-y-4"> <div class="glass-card__body space-y-4">
<label class="setting-toggle"> <label class="setting-toggle opacity-50 cursor-not-allowed">
<Toggle <Toggle
id="desktop-open-calls-in-separate-window" id="desktop-open-calls-in-separate-window"
v-model="config.desktop_open_calls_in_separate_window" :model-value="false"
@update:model-value="onDesktopOpenCallsInSeparateWindowChange" :disabled="true"
/> />
<span class="setting-toggle__label"> <span class="setting-toggle__label">
<span class="setting-toggle__title">{{ <span class="setting-toggle__title">{{
$t("app.desktop_open_calls_in_separate_window") $t("app.desktop_open_calls_in_separate_window")
}}</span> }}</span>
<span class="setting-toggle__description">{{ <span class="setting-toggle__description">
$t("app.desktop_open_calls_in_separate_window_description") {{ $t("app.desktop_open_calls_in_separate_window_description") }}
}}</span> <span class="text-blue-500 font-bold block mt-1">(Phased out for now)</span>
</span>
</span> </span>
</label> </label>
@@ -615,6 +616,47 @@
</div> </div>
</section> </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 --> <!-- Messages -->
<section class="glass-card break-inside-avoid"> <section class="glass-card break-inside-avoid">
<header class="glass-card__header"> <header class="glass-card__header">
@@ -904,6 +946,8 @@ export default {
blackhole_integration_enabled: true, blackhole_integration_enabled: true,
telephone_tone_generator_enabled: true, telephone_tone_generator_enabled: true,
telephone_tone_generator_volume: 50, telephone_tone_generator_volume: 50,
gitea_base_url: "https://git.quad4.io",
docs_download_urls: "",
}, },
saveTimeouts: {}, saveTimeouts: {},
shortcuts: [], shortcuts: [],
@@ -1257,6 +1301,18 @@ export default {
); );
}, 1000); }, 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() { async flushArchivedPages() {
if ( if (
!(await DialogUtils.confirm( !(await DialogUtils.confirm(

View File

@@ -396,13 +396,13 @@
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<a <a
target="_blank" 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" class="text-blue-500 hover:underline text-sm font-bold"
>RNode Flasher GH</a >RNode Flasher GH</a
> >
<a <a
target="_blank" 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" class="text-blue-500 hover:underline text-sm font-bold"
>RNode Firmware GH</a >RNode Firmware GH</a
> >
@@ -420,6 +420,7 @@ import Nrf52DfuFlasher from "../../js/rnode/Nrf52DfuFlasher.js";
import RNodeUtils from "../../js/rnode/RNodeUtils.js"; import RNodeUtils from "../../js/rnode/RNodeUtils.js";
import products from "../../js/rnode/products.js"; import products from "../../js/rnode/products.js";
import ToastUtils from "../../js/ToastUtils.js"; import ToastUtils from "../../js/ToastUtils.js";
import GlobalState from "../../js/GlobalState";
export default { export default {
name: "RNodeFlasherPage", name: "RNodeFlasherPage",
@@ -459,6 +460,9 @@ export default {
recommendedFirmwareFilename() { recommendedFirmwareFilename() {
return this.selectedModel?.firmware_filename ?? this.selectedProduct?.firmware_filename; return this.selectedModel?.firmware_filename ?? this.selectedProduct?.firmware_filename;
}, },
giteaBaseUrl() {
return GlobalState.config?.gitea_base_url || "https://git.quad4.io";
},
}, },
watch: { watch: {
selectedProduct() { selectedProduct() {
@@ -473,7 +477,7 @@ export default {
async fetchLatestRelease() { async fetchLatestRelease() {
try { try {
const response = await fetch( 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) { if (response.ok) {
this.latestRelease = await response.json(); this.latestRelease = await response.json();