feat(ui): enhance user interface and functionality across multiple components
- Updated sidebar width in App.vue for better layout. - Added navigation option for RNPath trace in CommandPalette.vue. - Included Italian language support in LanguageSelector.vue. - Improved Toast.vue to handle loading state for toasts and update existing toasts. - Enhanced AboutPage.vue with download buttons for snapshots and backups. - Refined InterfacesPage.vue to improve layout and filtering capabilities. - Introduced MiniChat.vue for a compact chat interface on the map. - Updated ConversationDropDownMenu.vue to include telemetry trust toggle. - Enhanced ConversationViewer.vue with better telemetry handling and error notifications. - Added RNPathTracePage.vue for tracing paths to destination hashes. - Improved ToolsPage.vue to include RNPath trace functionality.
This commit is contained in:
@@ -129,7 +129,7 @@
|
||||
class="fixed inset-y-0 left-0 z-[70] transform transition-all duration-300 ease-in-out sm:relative sm:z-0 sm:flex sm:translate-x-0"
|
||||
:class="[
|
||||
isSidebarOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
isSidebarCollapsed ? 'w-16' : 'w-72',
|
||||
isSidebarCollapsed ? 'w-16' : 'w-80',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
|
||||
@@ -215,6 +215,14 @@ export default {
|
||||
type: "navigation",
|
||||
route: { name: "rnpath" },
|
||||
},
|
||||
{
|
||||
id: "nav-rnpath-trace",
|
||||
title: "nav_rnpath_trace",
|
||||
description: "nav_rnpath_trace_desc",
|
||||
icon: "map-marker-path",
|
||||
type: "navigation",
|
||||
route: { name: "rnpath-trace" },
|
||||
},
|
||||
{
|
||||
id: "nav-translator",
|
||||
title: "nav_translator",
|
||||
|
||||
@@ -70,6 +70,7 @@ export default {
|
||||
{ code: "en", name: "English" },
|
||||
{ code: "de", name: "Deutsch" },
|
||||
{ code: "ru", name: "Русский" },
|
||||
{ code: "it", name: "Italiano" },
|
||||
],
|
||||
};
|
||||
},
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
icon-name="alert"
|
||||
class="h-6 w-6 text-amber-500"
|
||||
/>
|
||||
<MaterialDesignIcon
|
||||
v-else-if="toast.type === 'loading'"
|
||||
icon-name="loading"
|
||||
class="h-6 w-6 text-blue-500 animate-spin"
|
||||
/>
|
||||
<MaterialDesignIcon v-else icon-name="information" class="h-6 w-6 text-blue-500" />
|
||||
</div>
|
||||
|
||||
@@ -70,24 +75,58 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
add(toast) {
|
||||
// Check if a toast with the same key already exists
|
||||
if (toast.key) {
|
||||
const existingIndex = this.toasts.findIndex((t) => t.key === toast.key);
|
||||
if (existingIndex !== -1) {
|
||||
const existingToast = this.toasts[existingIndex];
|
||||
|
||||
// Clear existing timeout if it exists
|
||||
if (existingToast.timer) {
|
||||
clearTimeout(existingToast.timer);
|
||||
}
|
||||
|
||||
// Update existing toast
|
||||
existingToast.message = toast.message;
|
||||
existingToast.type = toast.type || "info";
|
||||
existingToast.duration = toast.duration !== undefined ? toast.duration : 5000;
|
||||
|
||||
if (existingToast.duration > 0) {
|
||||
existingToast.timer = setTimeout(() => {
|
||||
this.remove(existingToast.id);
|
||||
}, existingToast.duration);
|
||||
} else {
|
||||
existingToast.timer = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const id = this.counter++;
|
||||
const newToast = {
|
||||
id,
|
||||
key: toast.key,
|
||||
message: toast.message,
|
||||
type: toast.type || "info",
|
||||
duration: toast.duration || 5000,
|
||||
duration: toast.duration !== undefined ? toast.duration : 5000,
|
||||
timer: null,
|
||||
};
|
||||
this.toasts.push(newToast);
|
||||
|
||||
if (newToast.duration > 0) {
|
||||
setTimeout(() => {
|
||||
newToast.timer = setTimeout(() => {
|
||||
this.remove(id);
|
||||
}, newToast.duration);
|
||||
}
|
||||
|
||||
this.toasts.push(newToast);
|
||||
},
|
||||
remove(id) {
|
||||
const index = this.toasts.findIndex((t) => t.id === id);
|
||||
if (index !== -1) {
|
||||
const toast = this.toasts[index];
|
||||
if (toast.timer) {
|
||||
clearTimeout(toast.timer);
|
||||
}
|
||||
this.toasts.splice(index, 1);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -402,7 +402,7 @@
|
||||
>
|
||||
{{
|
||||
appInfo.is_connected_to_shared_instance
|
||||
? "Shared Instance"
|
||||
? `Shared Instance: ${appInfo.shared_instance_address || "unknown"}`
|
||||
: "Main Instance"
|
||||
}}
|
||||
</div>
|
||||
@@ -659,6 +659,14 @@
|
||||
<div
|
||||
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip !px-3 !py-1 !text-[10px]"
|
||||
@click="downloadSnapshot(snapshot.name)"
|
||||
>
|
||||
<v-icon icon="mdi-download" size="12" start></v-icon>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip !px-3 !py-1 !text-[10px]"
|
||||
@@ -742,6 +750,14 @@
|
||||
<div
|
||||
class="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="primary-chip !px-3 !py-1 !text-[10px]"
|
||||
@click="downloadBackupFile(backup.name)"
|
||||
>
|
||||
<v-icon icon="mdi-download" size="12" start></v-icon>
|
||||
Download
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip !px-3 !py-1 !text-[10px]"
|
||||
@@ -1001,6 +1017,42 @@ export default {
|
||||
console.log("Failed to list auto-backups");
|
||||
}
|
||||
},
|
||||
async downloadSnapshot(filename) {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/database/snapshots/${filename}/download`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
ToastUtils.success("Snapshot downloaded");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to download snapshot");
|
||||
}
|
||||
},
|
||||
async downloadBackupFile(filename) {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/database/backups/${filename}/download`, {
|
||||
responseType: "blob",
|
||||
});
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.setAttribute("download", filename);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
ToastUtils.success("Backup downloaded");
|
||||
} catch {
|
||||
ToastUtils.error("Failed to download backup");
|
||||
}
|
||||
},
|
||||
async deleteSnapshot(filename) {
|
||||
if (!(await DialogUtils.confirm(this.$t("about.delete_snapshot_confirm")))) return;
|
||||
try {
|
||||
|
||||
@@ -26,20 +26,18 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="glass-card space-y-4">
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("interfaces.manage") }}
|
||||
</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white truncate">
|
||||
{{ $t("interfaces.title") }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $t("interfaces.description") }}
|
||||
</div>
|
||||
<div class="glass-card flex flex-col md:flex-row md:items-center justify-between gap-6 p-6 md:p-8">
|
||||
<div class="space-y-3 flex-1 min-w-0">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("interfaces.manage") }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div class="text-3xl font-black text-gray-900 dark:text-white tracking-tight">
|
||||
{{ $t("interfaces.title") }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed max-w-xl">
|
||||
{{ $t("interfaces.description") }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 pt-2">
|
||||
<RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
|
||||
<MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
|
||||
{{ $t("interfaces.add_interface") }}
|
||||
@@ -52,56 +50,34 @@
|
||||
<MaterialDesignIcon icon-name="export" class="w-4 h-4" />
|
||||
{{ $t("interfaces.export_all") }}
|
||||
</button>
|
||||
<!--
|
||||
<button
|
||||
type="button"
|
||||
class="secondary-chip text-sm bg-amber-500/10 hover:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
|
||||
:disabled="reloadingRns"
|
||||
@click="reloadRns"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="reloadingRns ? 'refresh' : 'restart'"
|
||||
class="w-4 h-4"
|
||||
:class="{ 'animate-spin-reverse': reloadingRns }"
|
||||
/>
|
||||
{{ reloadingRns ? $t("app.reloading_rns") : $t("app.reload_rns") }}
|
||||
</button>
|
||||
--></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<div class="flex-1">
|
||||
|
||||
<div class="w-full md:w-96 shrink-0 space-y-4">
|
||||
<div class="relative group">
|
||||
<MaterialDesignIcon
|
||||
icon-name="magnify"
|
||||
class="absolute left-4 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
|
||||
/>
|
||||
<input
|
||||
v-model="searchTerm"
|
||||
type="text"
|
||||
:placeholder="$t('interfaces.search_placeholder')"
|
||||
class="input-field"
|
||||
class="w-full pl-12 pr-4 py-3 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 shadow-sm"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClass(statusFilter === 'all')"
|
||||
@click="setStatusFilter('all')"
|
||||
v-if="searchTerm"
|
||||
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
@click="searchTerm = ''"
|
||||
>
|
||||
{{ $t("interfaces.all") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClass(statusFilter === 'enabled')"
|
||||
@click="setStatusFilter('enabled')"
|
||||
>
|
||||
{{ $t("app.enabled") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClass(statusFilter === 'disabled')"
|
||||
@click="setStatusFilter('disabled')"
|
||||
>
|
||||
{{ $t("app.disabled") }}
|
||||
<MaterialDesignIcon icon-name="close-circle" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="w-full sm:w-60">
|
||||
<select v-model="typeFilter" class="input-field">
|
||||
<div>
|
||||
<select
|
||||
v-model="typeFilter"
|
||||
class="w-full px-4 py-2.5 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-700 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-blue-500/50 text-gray-900 dark:text-white"
|
||||
>
|
||||
<option value="all">{{ $t("interfaces.all_types") }}</option>
|
||||
<option v-for="type in sortedInterfaceTypes" :key="type" :value="type">
|
||||
{{ type }}
|
||||
@@ -127,10 +103,47 @@
|
||||
|
||||
<div v-if="activeTab === 'overview'" class="space-y-4">
|
||||
<div class="glass-card space-y-3">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Configured
|
||||
<div class="flex flex-wrap items-center justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Configured
|
||||
</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Interfaces
|
||||
<span
|
||||
v-if="filteredInterfaces.length > 0"
|
||||
class="ml-2 text-sm font-medium text-gray-400"
|
||||
>({{ filteredInterfaces.length }})</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClass(statusFilter === 'all')"
|
||||
class="!py-1 !px-3"
|
||||
@click="setStatusFilter('all')"
|
||||
>
|
||||
{{ $t("interfaces.all") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClass(statusFilter === 'enabled')"
|
||||
class="!py-1 !px-3"
|
||||
@click="setStatusFilter('enabled')"
|
||||
>
|
||||
{{ $t("app.enabled") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClass(statusFilter === 'disabled')"
|
||||
class="!py-1 !px-3"
|
||||
@click="setStatusFilter('disabled')"
|
||||
>
|
||||
{{ $t("app.disabled") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">Interfaces</div>
|
||||
<div
|
||||
v-if="filteredInterfaces.length !== 0"
|
||||
class="grid gap-4 xl:grid-cols-2 2xl:grid-cols-3 3xl:grid-cols-4 4xl:grid-cols-5"
|
||||
@@ -162,13 +175,36 @@
|
||||
</div>
|
||||
<div class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Recently Heard Announces
|
||||
<span
|
||||
v-if="sortedDiscoveredInterfaces.length > 0"
|
||||
class="ml-2 text-sm font-medium text-gray-400"
|
||||
>({{ sortedDiscoveredInterfaces.length }})</span
|
||||
>
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
Discovery runs continually; heard announces stay listed. Connected entries show
|
||||
a green pill; disconnected entries are dimmed with a red label.
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="flex gap-2 flex-wrap items-center">
|
||||
<div class="flex gap-1.5 mr-2">
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClass(discoveredStatusFilter === 'all')"
|
||||
class="!py-1 !px-3"
|
||||
@click="discoveredStatusFilter = 'all'"
|
||||
>
|
||||
{{ $t("interfaces.all") }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="filterChipClass(discoveredStatusFilter === 'connected')"
|
||||
class="!py-1 !px-3"
|
||||
@click="discoveredStatusFilter = 'connected'"
|
||||
>
|
||||
{{ $t("interfaces.connected_only") }}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-if="interfacesWithLocation.length > 0"
|
||||
type="button"
|
||||
@@ -213,7 +249,8 @@
|
||||
class="absolute inset-0 z-10 flex items-center justify-center bg-white/20 dark:bg-zinc-900/20 backdrop-blur-[0.5px] rounded-3xl pointer-events-none"
|
||||
>
|
||||
<div
|
||||
class="bg-red-500/90 text-white px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider animate-pulse"
|
||||
class="bg-red-500/90 text-white px-3 py-1.5 rounded-full shadow-lg flex items-center gap-2 text-[10px] font-bold uppercase tracking-wider"
|
||||
:class="{ 'animate-pulse': shouldAnimatePulse(iface) }"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="lan-disconnect" class="w-3.5 h-3.5" />
|
||||
<span>{{ $t("app.disabled") }}</span>
|
||||
@@ -522,6 +559,7 @@ export default {
|
||||
savingDiscovery: false,
|
||||
discoveredInterfaces: [],
|
||||
discoveredActive: [],
|
||||
discoveredStatusFilter: "all",
|
||||
discoveryInterval: null,
|
||||
activeTab: "overview",
|
||||
};
|
||||
@@ -596,7 +634,31 @@ export default {
|
||||
return Array.from(types).sort();
|
||||
},
|
||||
sortedDiscoveredInterfaces() {
|
||||
return [...this.discoveredInterfaces].sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
|
||||
const search = this.searchTerm.toLowerCase().trim();
|
||||
let list = [...this.discoveredInterfaces];
|
||||
if (this.discoveredStatusFilter === "connected") {
|
||||
list = list.filter((iface) => this.isDiscoveredConnected(iface));
|
||||
}
|
||||
if (this.typeFilter !== "all") {
|
||||
list = list.filter((iface) => iface.type === this.typeFilter);
|
||||
}
|
||||
if (search) {
|
||||
list = list.filter((iface) => {
|
||||
const haystack = [
|
||||
iface.name,
|
||||
iface.type,
|
||||
iface.reachable_on,
|
||||
iface.port,
|
||||
iface.transport_id,
|
||||
iface.network_id,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(search);
|
||||
});
|
||||
}
|
||||
return list.sort((a, b) => (b.last_heard || 0) - (a.last_heard || 0));
|
||||
},
|
||||
interfacesWithLocation() {
|
||||
return this.discoveredInterfaces.filter((iface) => iface.latitude != null && iface.longitude != null);
|
||||
@@ -806,6 +868,7 @@ export default {
|
||||
...iface,
|
||||
last_heard: lastHeard,
|
||||
__isNew: isNew || existing?.__isNew,
|
||||
disconnected_at: existing?.disconnected_at ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -814,6 +877,18 @@ export default {
|
||||
|
||||
this.discoveredInterfaces = Array.from(merged.values());
|
||||
this.discoveredActive = active;
|
||||
|
||||
// Track disconnection time for animation control
|
||||
const now = Date.now();
|
||||
this.discoveredInterfaces.forEach((iface) => {
|
||||
if (!this.isDiscoveredConnected(iface)) {
|
||||
if (iface.disconnected_at === null) {
|
||||
iface.disconnected_at = now;
|
||||
}
|
||||
} else {
|
||||
iface.disconnected_at = null;
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
@@ -851,6 +926,13 @@ export default {
|
||||
return hostMatch && portMatch && (s.connected || s.online);
|
||||
});
|
||||
},
|
||||
shouldAnimatePulse(iface) {
|
||||
if (this.isDiscoveredConnected(iface)) return false;
|
||||
if (!iface.disconnected_at) return true;
|
||||
// only pulse for the first 30 seconds of being disconnected
|
||||
// to avoid continuous UI animation lag on many disconnected items
|
||||
return Date.now() - iface.disconnected_at < 30000;
|
||||
},
|
||||
goToMap(iface) {
|
||||
if (iface.latitude == null || iface.longitude == null) return;
|
||||
this.$router.push({
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
189
meshchatx/src/frontend/components/map/MiniChat.vue
Normal file
189
meshchatx/src/frontend/components/map/MiniChat.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-col h-64 bg-gray-50 dark:bg-zinc-950 rounded-lg overflow-hidden border border-gray-200 dark:border-zinc-800"
|
||||
>
|
||||
<!-- message list -->
|
||||
<div ref="messageList" class="flex-1 overflow-y-auto p-2 space-y-2 scrollbar-thin">
|
||||
<div v-if="loading" class="flex justify-center py-4">
|
||||
<v-icon icon="mdi-loading" class="animate-spin text-gray-400" size="20"></v-icon>
|
||||
</div>
|
||||
<div v-else-if="messages.length === 0" class="text-center py-4 text-xs text-gray-400">No messages yet</div>
|
||||
<div
|
||||
v-for="msg in messages"
|
||||
:key="msg.hash"
|
||||
class="flex flex-col max-w-[90%]"
|
||||
:class="msg.is_outbound ? 'ml-auto items-end' : 'mr-auto items-start'"
|
||||
>
|
||||
<div
|
||||
class="px-2 py-1 rounded-lg text-xs break-words shadow-sm"
|
||||
:class="
|
||||
msg.is_outbound
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-zinc-900 text-gray-800 dark:text-zinc-200'
|
||||
"
|
||||
>
|
||||
<!-- Telemetry Header if no content -->
|
||||
<div
|
||||
v-if="!msg.content && msg.fields?.telemetry"
|
||||
class="flex items-center gap-1 mb-1 pb-1 border-b border-white/10 opacity-80"
|
||||
>
|
||||
<v-icon icon="mdi-satellite-variant" size="10"></v-icon>
|
||||
<span class="text-[8px] font-bold uppercase tracking-wider"
|
||||
>{{ msg.is_outbound ? "Sent" : "Received" }} Telemetry</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!msg.content && msg.fields?.commands?.some((c) => c['0x01'] || c['1'] || c['0x1'])"
|
||||
class="flex items-center gap-1 mb-1 pb-1 border-b border-white/10 opacity-80"
|
||||
>
|
||||
<v-icon icon="mdi-crosshairs-question" size="10"></v-icon>
|
||||
<span class="text-[8px] font-bold uppercase tracking-wider">Location Request</span>
|
||||
</div>
|
||||
|
||||
<div v-if="msg.content" class="leading-normal">{{ msg.content }}</div>
|
||||
|
||||
<!-- Mini Telemetry Data -->
|
||||
<div v-if="msg.fields?.telemetry" class="mt-1 space-y-1">
|
||||
<div
|
||||
v-if="msg.fields.telemetry.location"
|
||||
class="flex items-center gap-1 text-[9px] font-mono opacity-90"
|
||||
>
|
||||
<v-icon icon="mdi-map-marker" size="10"></v-icon>
|
||||
<span
|
||||
>{{ msg.fields.telemetry.location.latitude.toFixed(4) }},
|
||||
{{ msg.fields.telemetry.location.longitude.toFixed(4) }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-2 opacity-70 text-[8px]">
|
||||
<span v-if="msg.fields.telemetry.battery" class="flex items-center gap-0.5">
|
||||
<v-icon icon="mdi-battery" size="8"></v-icon
|
||||
>{{ msg.fields.telemetry.battery.charge_percent }}%
|
||||
</span>
|
||||
<span v-if="msg.fields.telemetry.physical_link" class="flex items-center gap-0.5">
|
||||
<v-icon icon="mdi-antenna" size="8"></v-icon>SNR:
|
||||
{{ msg.fields.telemetry.physical_link.snr }}dB
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-[8px] text-gray-400 mt-0.5">
|
||||
{{ formatTime(msg.timestamp) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- input -->
|
||||
<div class="p-2 bg-white dark:bg-zinc-900 border-t border-gray-200 dark:border-zinc-800">
|
||||
<div class="flex gap-1">
|
||||
<input
|
||||
v-model="newMessage"
|
||||
type="text"
|
||||
class="flex-1 bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-md px-2 py-1 text-xs focus:outline-none focus:ring-1 focus:ring-blue-500 text-gray-900 dark:text-zinc-100"
|
||||
placeholder="Type a message..."
|
||||
@keydown.enter="sendMessage"
|
||||
/>
|
||||
<button
|
||||
:disabled="!newMessage.trim() || sending"
|
||||
class="p-1.5 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-300 dark:disabled:bg-zinc-700 text-white rounded-md transition-colors"
|
||||
@click="sendMessage"
|
||||
>
|
||||
<v-icon
|
||||
:icon="sending ? 'mdi-loading' : 'mdi-send'"
|
||||
:class="{ 'animate-spin': sending }"
|
||||
size="14"
|
||||
></v-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: "MiniChat",
|
||||
props: {
|
||||
destinationHash: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
messages: [],
|
||||
newMessage: "",
|
||||
loading: false,
|
||||
sending: false,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
destinationHash: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.fetchMessages();
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Listen for new messages via websocket if possible
|
||||
// For now we'll just poll or rely on parent updates if needed
|
||||
},
|
||||
methods: {
|
||||
async fetchMessages() {
|
||||
if (!this.destinationHash) return;
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await window.axios.get(
|
||||
`/api/v1/lxmf-messages/conversation/${this.destinationHash}?count=20&order=desc`
|
||||
);
|
||||
this.messages = (response.data.lxmf_messages || []).reverse();
|
||||
this.scrollToBottom();
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch messages", e);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async sendMessage() {
|
||||
if (!this.newMessage.trim() || this.sending) return;
|
||||
this.sending = true;
|
||||
try {
|
||||
const response = await window.axios.post("/api/v1/lxmf-messages/send", {
|
||||
lxmf_message: {
|
||||
destination_hash: this.destinationHash,
|
||||
content: this.newMessage,
|
||||
},
|
||||
});
|
||||
|
||||
// Add message to list locally for immediate feedback
|
||||
const msg = response.data.lxmf_message;
|
||||
this.messages.push({
|
||||
hash: msg.hash,
|
||||
content: msg.content,
|
||||
is_outbound: true,
|
||||
timestamp: msg.created_at,
|
||||
});
|
||||
this.newMessage = "";
|
||||
this.scrollToBottom();
|
||||
} catch (e) {
|
||||
console.error("Failed to send message", e);
|
||||
} finally {
|
||||
this.sending = false;
|
||||
}
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.$nextTick(() => {
|
||||
if (this.$refs.messageList) {
|
||||
this.$refs.messageList.scrollTop = this.$refs.messageList.scrollHeight;
|
||||
}
|
||||
});
|
||||
},
|
||||
formatTime(ts) {
|
||||
if (!ts) return "";
|
||||
const date = new Date(ts * 1000);
|
||||
if (isNaN(date.getTime())) return "";
|
||||
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -2,7 +2,7 @@
|
||||
<DropDownMenu>
|
||||
<template #button>
|
||||
<IconButton>
|
||||
<MaterialDesignIcon icon-name="dots-vertical" class="size-6" />
|
||||
<MaterialDesignIcon icon-name="dots-vertical" class="size-7" />
|
||||
</IconButton>
|
||||
</template>
|
||||
<template #items>
|
||||
@@ -43,6 +43,22 @@
|
||||
<span class="text-red-500">Delete Message History</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
|
||||
<!-- telemetry trust toggle -->
|
||||
<div v-if="GlobalState.config.telemetry_enabled" class="border-t">
|
||||
<DropDownMenuItem @click="onToggleTelemetryTrust">
|
||||
<MaterialDesignIcon
|
||||
:icon-name="contact?.is_telemetry_trusted ? 'shield-check' : 'shield-outline'"
|
||||
:class="contact?.is_telemetry_trusted ? 'text-blue-500' : 'text-gray-500'"
|
||||
class="size-5"
|
||||
/>
|
||||
<span>{{
|
||||
contact?.is_telemetry_trusted
|
||||
? $t("app.telemetry_trust_revoke")
|
||||
: $t("app.telemetry_trust_grant")
|
||||
}}</span>
|
||||
</DropDownMenuItem>
|
||||
</div>
|
||||
</template>
|
||||
</DropDownMenu>
|
||||
</template>
|
||||
@@ -70,7 +86,18 @@ export default {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["conversation-deleted", "set-custom-display-name", "block-status-changed", "popout"],
|
||||
emits: [
|
||||
"conversation-deleted",
|
||||
"set-custom-display-name",
|
||||
"block-status-changed",
|
||||
"popout",
|
||||
"view-telemetry-history",
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
contact: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isBlocked() {
|
||||
if (!this.peer) {
|
||||
@@ -79,7 +106,72 @@ export default {
|
||||
return GlobalState.blockedDestinations.some((b) => b.destination_hash === this.peer.destination_hash);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
peer: {
|
||||
immediate: true,
|
||||
handler() {
|
||||
this.fetchContact();
|
||||
},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
GlobalEmitter.on("contact-updated", this.onContactUpdated);
|
||||
},
|
||||
unmounted() {
|
||||
GlobalEmitter.off("contact-updated", this.onContactUpdated);
|
||||
},
|
||||
methods: {
|
||||
onContactUpdated(data) {
|
||||
if (this.peer?.destination_hash === data.remote_identity_hash) {
|
||||
this.fetchContact();
|
||||
}
|
||||
},
|
||||
async fetchContact() {
|
||||
if (!this.peer || !this.peer.destination_hash) return;
|
||||
try {
|
||||
const response = await window.axios.get(
|
||||
`/api/v1/telephone/contacts/check/${this.peer.destination_hash}`
|
||||
);
|
||||
if (response.data.is_contact) {
|
||||
this.contact = response.data.contact;
|
||||
} else {
|
||||
this.contact = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch contact", e);
|
||||
}
|
||||
},
|
||||
async onToggleTelemetryTrust() {
|
||||
const newStatus = !this.contact?.is_telemetry_trusted;
|
||||
try {
|
||||
if (!this.contact) {
|
||||
// create contact first
|
||||
await window.axios.post("/api/v1/telephone/contacts", {
|
||||
name: this.peer.display_name,
|
||||
remote_identity_hash: this.peer.destination_hash,
|
||||
is_telemetry_trusted: true,
|
||||
});
|
||||
await this.fetchContact();
|
||||
} else {
|
||||
await window.axios.patch(`/api/v1/telephone/contacts/${this.contact.id}`, {
|
||||
is_telemetry_trusted: newStatus,
|
||||
});
|
||||
this.contact.is_telemetry_trusted = newStatus;
|
||||
}
|
||||
GlobalEmitter.emit("contact-updated", {
|
||||
remote_identity_hash: this.peer.destination_hash,
|
||||
is_telemetry_trusted: newStatus,
|
||||
});
|
||||
DialogUtils.alert(
|
||||
newStatus
|
||||
? this.$t("app.telemetry_trust_granted_alert")
|
||||
: this.$t("app.telemetry_trust_revoked_alert")
|
||||
);
|
||||
} catch (e) {
|
||||
DialogUtils.alert(this.$t("app.telemetry_trust_failed"));
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
async onBlockDestination() {
|
||||
if (
|
||||
!(await DialogUtils.confirm(
|
||||
|
||||
@@ -57,24 +57,32 @@
|
||||
{{ selectedPeer.custom_display_name ?? selectedPeer.display_name }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5">
|
||||
<div class="text-xs text-gray-500 dark:text-zinc-400 mt-0.5 flex items-center gap-2 min-w-0">
|
||||
<!-- destination hash -->
|
||||
<div class="inline-block mr-1">
|
||||
<div
|
||||
class="cursor-pointer hover:text-blue-500 transition-colors"
|
||||
:title="selectedPeer.destination_hash"
|
||||
@click="copyHash(selectedPeer.destination_hash)"
|
||||
>
|
||||
{{ formatDestinationHash(selectedPeer.destination_hash) }}
|
||||
</div>
|
||||
<div
|
||||
class="cursor-pointer hover:text-blue-500 transition-colors truncate max-w-[120px] sm:max-w-none shrink-0"
|
||||
:title="selectedPeer.destination_hash"
|
||||
@click="copyHash(selectedPeer.destination_hash)"
|
||||
>
|
||||
{{ formatDestinationHash(selectedPeer.destination_hash) }}
|
||||
</div>
|
||||
|
||||
<div class="inline-block">
|
||||
<div class="flex space-x-1">
|
||||
<div
|
||||
v-if="
|
||||
selectedPeerPath ||
|
||||
selectedPeerSignalMetrics?.snr != null ||
|
||||
selectedPeerLxmfStampInfo?.stamp_cost
|
||||
"
|
||||
class="flex items-center gap-2 min-w-0"
|
||||
>
|
||||
<span class="text-gray-300 dark:text-zinc-700 shrink-0">•</span>
|
||||
|
||||
<div class="flex items-center gap-2 truncate">
|
||||
<!-- hops away -->
|
||||
<span
|
||||
v-if="selectedPeerPath"
|
||||
class="flex my-auto cursor-pointer"
|
||||
class="flex items-center cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200 shrink-0"
|
||||
title="Path information"
|
||||
@click="onDestinationPathClick(selectedPeerPath)"
|
||||
>
|
||||
<span v-if="selectedPeerPath.hops === 0 || selectedPeerPath.hops === 1">{{
|
||||
@@ -84,19 +92,30 @@
|
||||
</span>
|
||||
|
||||
<!-- snr -->
|
||||
<span v-if="selectedPeerSignalMetrics?.snr != null" class="flex my-auto space-x-1">
|
||||
<span v-if="selectedPeerPath">•</span>
|
||||
<span class="cursor-pointer" @click="onSignalMetricsClick(selectedPeerSignalMetrics)">{{
|
||||
$t("messages.snr", { snr: selectedPeerSignalMetrics.snr })
|
||||
}}</span>
|
||||
<span
|
||||
v-if="selectedPeerSignalMetrics?.snr != null"
|
||||
class="flex items-center gap-2 shrink-0"
|
||||
>
|
||||
<span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
|
||||
<span
|
||||
class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
|
||||
title="Signal quality"
|
||||
@click="onSignalMetricsClick(selectedPeerSignalMetrics)"
|
||||
>{{ $t("messages.snr", { snr: selectedPeerSignalMetrics.snr }) }}</span
|
||||
>
|
||||
</span>
|
||||
|
||||
<!-- stamp cost -->
|
||||
<span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex my-auto space-x-1">
|
||||
<span v-if="selectedPeerPath || selectedPeerSignalMetrics?.snr != null">•</span>
|
||||
<span class="cursor-pointer" @click="onStampInfoClick(selectedPeerLxmfStampInfo)">{{
|
||||
$t("messages.stamp_cost", { cost: selectedPeerLxmfStampInfo.stamp_cost })
|
||||
}}</span>
|
||||
<span v-if="selectedPeerLxmfStampInfo?.stamp_cost" class="flex items-center gap-2 shrink-0">
|
||||
<span class="text-gray-300 dark:text-zinc-700 opacity-50">•</span>
|
||||
<span
|
||||
class="cursor-pointer hover:text-gray-700 dark:hover:text-zinc-200"
|
||||
title="LXMF stamp requirement"
|
||||
@click="onStampInfoClick(selectedPeerLxmfStampInfo)"
|
||||
>{{
|
||||
$t("messages.stamp_cost", { cost: selectedPeerLxmfStampInfo.stamp_cost })
|
||||
}}</span
|
||||
>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -104,7 +123,7 @@
|
||||
</div>
|
||||
|
||||
<!-- dropdown menu -->
|
||||
<div class="ml-auto flex items-center gap-1">
|
||||
<div class="ml-auto flex items-center gap-1.5">
|
||||
<!-- retry all failed messages -->
|
||||
<IconButton
|
||||
v-if="hasFailedOrCancelledMessages"
|
||||
@@ -112,7 +131,22 @@
|
||||
class="text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
@click="retryAllFailedOrCancelledMessages"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="refresh" class="size-6" />
|
||||
<MaterialDesignIcon icon-name="refresh" class="size-7" />
|
||||
</IconButton>
|
||||
|
||||
<!-- telemetry history button -->
|
||||
<IconButton title="View Telemetry History" @click="isTelemetryHistoryModalOpen = true">
|
||||
<MaterialDesignIcon icon-name="satellite-variant" class="size-7" />
|
||||
</IconButton>
|
||||
|
||||
<!-- call button -->
|
||||
<IconButton title="Start a Call" @click="onStartCall">
|
||||
<MaterialDesignIcon icon-name="phone" class="size-7" />
|
||||
</IconButton>
|
||||
|
||||
<!-- share contact button -->
|
||||
<IconButton title="Share Contact" @click="openShareContactModal">
|
||||
<MaterialDesignIcon icon-name="notebook-outline" class="size-7" />
|
||||
</IconButton>
|
||||
|
||||
<ConversationDropDownMenu
|
||||
@@ -123,23 +157,151 @@
|
||||
@popout="openConversationPopout"
|
||||
/>
|
||||
|
||||
<!-- call button -->
|
||||
<IconButton title="Start a Call" @click="onStartCall">
|
||||
<MaterialDesignIcon icon-name="phone" class="size-6" />
|
||||
</IconButton>
|
||||
|
||||
<!-- share contact button -->
|
||||
<IconButton title="Share Contact" @click="openShareContactModal">
|
||||
<MaterialDesignIcon icon-name="notebook-outline" class="size-6" />
|
||||
</IconButton>
|
||||
|
||||
<!-- close button -->
|
||||
<IconButton title="Close" @click="close">
|
||||
<MaterialDesignIcon icon-name="close" class="size-6" />
|
||||
<MaterialDesignIcon icon-name="close" class="size-7" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Telemetry History Modal -->
|
||||
<div
|
||||
v-if="isTelemetryHistoryModalOpen"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"
|
||||
@click.self="isTelemetryHistoryModalOpen = false"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-lg bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-col max-h-[80vh]"
|
||||
>
|
||||
<div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<MaterialDesignIcon icon-name="satellite-variant" class="size-6 text-blue-500" />
|
||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white">Telemetry History</h3>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-gray-500 dark:hover:text-zinc-300 transition-colors"
|
||||
@click="isTelemetryHistoryModalOpen = false"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close" class="size-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-4 space-y-3">
|
||||
<div v-if="selectedPeerTelemetryItems.length === 0" class="text-center py-8 text-gray-400">
|
||||
No telemetry history found for this peer.
|
||||
</div>
|
||||
<div
|
||||
v-for="item in selectedPeerTelemetryItems"
|
||||
:key="item.lxmf_message.hash"
|
||||
class="p-3 rounded-xl border border-gray-100 dark:border-zinc-800 bg-gray-50/50 dark:bg-zinc-900/30"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<span
|
||||
class="text-[10px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-gray-200 dark:bg-zinc-800 text-gray-600 dark:text-zinc-400"
|
||||
>
|
||||
{{ item.is_outbound ? "Sent" : "Received" }}
|
||||
</span>
|
||||
<span class="text-[10px] text-gray-400">{{
|
||||
formatTimeAgo(item.lxmf_message.created_at)
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
<!-- location -->
|
||||
<div v-if="item.lxmf_message.fields?.telemetry?.location" class="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 text-xs font-mono text-blue-600 dark:text-blue-400 hover:underline"
|
||||
@click="viewLocationOnMap(item.lxmf_message.fields.telemetry.location)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="map-marker" class="size-4" />
|
||||
{{ item.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
|
||||
{{ item.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- sensors -->
|
||||
<div
|
||||
v-if="item.lxmf_message.fields?.telemetry"
|
||||
class="flex flex-wrap gap-3 text-[10px] text-gray-500"
|
||||
>
|
||||
<span v-if="item.lxmf_message.fields.telemetry.battery" class="flex items-center gap-1">
|
||||
<MaterialDesignIcon icon-name="battery" class="size-3" />
|
||||
Battery: {{ item.lxmf_message.fields.telemetry.battery.charge_percent }}%
|
||||
</span>
|
||||
<span
|
||||
v-if="item.lxmf_message.fields.telemetry.physical_link"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="antenna" class="size-3" />
|
||||
SNR: {{ item.lxmf_message.fields.telemetry.physical_link.snr }}dB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- commands -->
|
||||
<div
|
||||
v-if="item.lxmf_message.fields?.commands?.some((c) => c['0x01'])"
|
||||
class="flex items-center gap-2 text-[10px] text-emerald-600 dark:text-emerald-400 mt-1"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="crosshairs-question" class="size-3" />
|
||||
<span>Location Request</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="px-6 py-4 border-t border-gray-100 dark:border-zinc-800 bg-gray-50/30 dark:bg-zinc-900/20 flex flex-col gap-4"
|
||||
>
|
||||
<div v-if="telemetryBatteryHistory.length > 1" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-[10px] font-bold text-gray-400 uppercase tracking-widest"
|
||||
>Battery Level (%)</span
|
||||
>
|
||||
<span class="text-[10px] text-gray-500"
|
||||
>{{ telemetryBatteryHistory[0].y }}% →
|
||||
{{ telemetryBatteryHistory[telemetryBatteryHistory.length - 1].y }}%</span
|
||||
>
|
||||
</div>
|
||||
<svg class="w-full h-12 overflow-visible" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<path
|
||||
:d="batterySparklinePath"
|
||||
fill="none"
|
||||
stroke="#3b82f6"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<circle
|
||||
:cx="100"
|
||||
:cy="100 - telemetryBatteryHistory[telemetryBatteryHistory.length - 1].y"
|
||||
r="3"
|
||||
fill="#3b82f6"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center w-full">
|
||||
<label class="flex items-center gap-2 cursor-pointer group">
|
||||
<input
|
||||
v-model="showTelemetryInChat"
|
||||
type="checkbox"
|
||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span
|
||||
class="text-xs font-medium text-gray-600 dark:text-zinc-400 group-hover:text-gray-900 dark:group-hover:text-zinc-200"
|
||||
>Show telemetry in main chat</span
|
||||
>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="px-4 py-2 bg-blue-600 text-white text-xs font-bold rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
|
||||
@click="isTelemetryHistoryModalOpen = false"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Share Contact Modal -->
|
||||
<div
|
||||
v-if="isShareContactModalOpen"
|
||||
@@ -264,15 +426,54 @@
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<div
|
||||
v-if="chatItem.lxmf_message.content && !getParsedItems(chatItem)?.isOnlyPaperMessage"
|
||||
class="leading-relaxed whitespace-pre-wrap break-words [word-break:break-word] min-w-0"
|
||||
class="leading-relaxed break-words [word-break:break-word] min-w-0 markdown-content"
|
||||
:style="{
|
||||
'font-family': 'inherit',
|
||||
'font-size': (config?.message_font_size || 14) + 'px',
|
||||
}"
|
||||
@click="handleMessageClick"
|
||||
v-html="renderMarkdown(chatItem.lxmf_message.content)"
|
||||
></div>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
|
||||
<!-- telemetry placeholder for empty content messages -->
|
||||
<div
|
||||
v-if="!chatItem.lxmf_message.content && chatItem.lxmf_message.fields?.telemetry"
|
||||
class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
|
||||
>
|
||||
{{ chatItem.lxmf_message.content }}
|
||||
<MaterialDesignIcon icon-name="satellite-variant" class="size-4 opacity-60" />
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60">
|
||||
{{ chatItem.is_outbound ? "Telemetry update sent" : "Telemetry update received" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!chatItem.lxmf_message.content && chatItem.lxmf_message.fields?.telemetry_stream"
|
||||
class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="database-sync" class="size-4 opacity-60" />
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60"
|
||||
>Telemetry stream received ({{
|
||||
chatItem.lxmf_message.fields.telemetry_stream.length
|
||||
}}
|
||||
entries)</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
!chatItem.lxmf_message.content &&
|
||||
chatItem.lxmf_message.fields?.commands?.some((c) => c['0x01'] || c['1'] || c['0x1'])
|
||||
"
|
||||
class="flex items-center gap-2 mb-2 pb-2 border-b border-gray-100/20"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="crosshairs-question" class="size-4 opacity-60" />
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider opacity-60">
|
||||
{{ chatItem.is_outbound ? "Location Request Sent" : "Location Request Received" }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- parsed items (contacts / paper messages) -->
|
||||
@@ -481,29 +682,120 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- telemetry / location field -->
|
||||
<div v-if="chatItem.lxmf_message.fields?.telemetry?.location" class="pb-1 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
chatItem.is_outbound
|
||||
? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
|
||||
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
|
||||
"
|
||||
@click="viewLocationOnMap(chatItem.lxmf_message.fields.telemetry.location)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="map-marker" class="size-5" />
|
||||
<div class="text-left">
|
||||
<div class="font-bold text-xs uppercase tracking-wider opacity-80">
|
||||
Location
|
||||
</div>
|
||||
<div class="text-[10px] font-mono opacity-70">
|
||||
{{ chatItem.lxmf_message.fields.telemetry.location.latitude.toFixed(6) }},
|
||||
{{ chatItem.lxmf_message.fields.telemetry.location.longitude.toFixed(6) }}
|
||||
<!-- commands -->
|
||||
<div v-if="chatItem.lxmf_message.fields?.commands" class="space-y-2 mt-1">
|
||||
<div v-for="(command, index) in chatItem.lxmf_message.fields.commands" :key="index">
|
||||
<div
|
||||
v-if="command['0x01'] || command['1'] || command['0x1']"
|
||||
class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
chatItem.is_outbound
|
||||
? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
|
||||
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
|
||||
"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="crosshairs-question" class="size-5" />
|
||||
<div class="text-left">
|
||||
<div class="font-bold text-xs uppercase tracking-wider opacity-80">
|
||||
{{ $t("messages.location_requested") }}
|
||||
</div>
|
||||
<div v-if="!chatItem.is_outbound" class="text-[10px] opacity-70">
|
||||
Peer is requesting your location
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- telemetry / location field -->
|
||||
<div v-if="chatItem.lxmf_message.fields?.telemetry" class="pb-1 mt-1 space-y-2">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-if="chatItem.lxmf_message.fields.telemetry.location"
|
||||
type="button"
|
||||
class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="
|
||||
chatItem.is_outbound
|
||||
? 'bg-white/20 text-white border-white/20 hover:bg-white/30'
|
||||
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300'
|
||||
"
|
||||
@click="viewLocationOnMap(chatItem.lxmf_message.fields.telemetry.location)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="map-marker" class="size-5" />
|
||||
<div class="text-left">
|
||||
<div class="font-bold text-[10px] uppercase tracking-wider opacity-80">
|
||||
Location
|
||||
</div>
|
||||
<div class="text-[9px] font-mono opacity-70">
|
||||
{{
|
||||
chatItem.lxmf_message.fields.telemetry.location.latitude.toFixed(6)
|
||||
}},
|
||||
{{
|
||||
chatItem.lxmf_message.fields.telemetry.location.longitude.toFixed(6)
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Live Track Toggle Button (only for incoming) -->
|
||||
<button
|
||||
v-if="!chatItem.is_outbound"
|
||||
type="button"
|
||||
class="flex items-center gap-2 border border-gray-200/60 dark:border-zinc-700 hover:bg-gray-50 dark:hover:bg-zinc-800 rounded-lg px-3 py-2 text-sm font-medium transition-colors"
|
||||
:class="[
|
||||
selectedPeer?.is_tracking
|
||||
? 'bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30 shadow-inner'
|
||||
: 'bg-gray-50 dark:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
|
||||
]"
|
||||
@click="toggleTracking()"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="selectedPeer?.is_tracking ? 'radar' : 'crosshairs'"
|
||||
class="size-5"
|
||||
:class="{ 'animate-pulse text-blue-500': selectedPeer?.is_tracking }"
|
||||
/>
|
||||
<div class="text-left">
|
||||
<div class="font-bold text-[10px] uppercase tracking-wider opacity-80">
|
||||
{{ selectedPeer?.is_tracking ? "Tracking Active" : "Live Track" }}
|
||||
</div>
|
||||
<div class="text-[9px] opacity-70">
|
||||
{{
|
||||
selectedPeer?.is_tracking
|
||||
? "Auto-requesting location"
|
||||
: "Enable live tracking"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- other sensor data if available -->
|
||||
<div
|
||||
v-if="
|
||||
chatItem.lxmf_message.fields.telemetry.battery ||
|
||||
chatItem.lxmf_message.fields.telemetry.physical_link
|
||||
"
|
||||
class="flex gap-3 px-1"
|
||||
>
|
||||
<div
|
||||
v-if="chatItem.lxmf_message.fields.telemetry.battery"
|
||||
class="flex items-center gap-1 opacity-60 text-[10px]"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="battery" class="size-3" />
|
||||
<span
|
||||
>{{ chatItem.lxmf_message.fields.telemetry.battery.charge_percent }}%</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-if="chatItem.lxmf_message.fields.telemetry.physical_link"
|
||||
class="flex items-center gap-1 opacity-60 text-[10px]"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="antenna" class="size-3" />
|
||||
<span
|
||||
>SNR: {{ chatItem.lxmf_message.fields.telemetry.physical_link.snr }}dB</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1395,6 +1687,7 @@ import GlobalEmitter from "../../js/GlobalEmitter";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
import PaperMessageModal from "./PaperMessageModal.vue";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
import MarkdownRenderer from "../../js/MarkdownRenderer";
|
||||
|
||||
export default {
|
||||
name: "ConversationViewer",
|
||||
@@ -1428,7 +1721,7 @@ export default {
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
emits: ["close", "reload-conversations", "update:selectedPeer"],
|
||||
emits: ["close", "reload-conversations", "update:selectedPeer", "update-peer-tracking"],
|
||||
data() {
|
||||
return {
|
||||
GlobalState,
|
||||
@@ -1493,6 +1786,9 @@ export default {
|
||||
translatorLanguages: [],
|
||||
propagationNodeStatus: null,
|
||||
propagationStatusInterval: null,
|
||||
|
||||
showTelemetryInChat: false,
|
||||
isTelemetryHistoryModalOpen: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -1605,7 +1901,15 @@ export default {
|
||||
chatItem.lxmf_message.source_hash === this.selectedPeer.destination_hash;
|
||||
const isToSelectedPeer =
|
||||
chatItem.lxmf_message.destination_hash === this.selectedPeer.destination_hash;
|
||||
return isFromSelectedPeer || isToSelectedPeer;
|
||||
|
||||
if (!(isFromSelectedPeer || isToSelectedPeer)) return false;
|
||||
|
||||
// filter telemetry if disabled
|
||||
if (!this.showTelemetryInChat && this.isTelemetryOnly(chatItem.lxmf_message)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -1615,6 +1919,24 @@ export default {
|
||||
// no peer, so no chat items!
|
||||
return [];
|
||||
},
|
||||
selectedPeerTelemetryItems() {
|
||||
if (!this.selectedPeer) return [];
|
||||
return this.chatItems
|
||||
.filter((chatItem) => {
|
||||
if (chatItem.type === "lxmf_message") {
|
||||
const isFromSelectedPeer =
|
||||
chatItem.lxmf_message.source_hash === this.selectedPeer.destination_hash;
|
||||
const isToSelectedPeer =
|
||||
chatItem.lxmf_message.destination_hash === this.selectedPeer.destination_hash;
|
||||
|
||||
if (!(isFromSelectedPeer || isToSelectedPeer)) return false;
|
||||
|
||||
return this.isTelemetryOnly(chatItem.lxmf_message);
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.reverse();
|
||||
},
|
||||
selectedPeerChatItemsReversed() {
|
||||
// ensure a copy of the array is returned in reverse order
|
||||
return this.selectedPeerChatItems.map((message) => message).reverse();
|
||||
@@ -1631,6 +1953,31 @@ export default {
|
||||
(item) => item.is_outbound && ["failed", "cancelled"].includes(item.lxmf_message?.state)
|
||||
);
|
||||
},
|
||||
telemetryBatteryHistory() {
|
||||
return this.selectedPeerTelemetryItems
|
||||
.filter((item) => item.lxmf_message.fields?.telemetry?.battery)
|
||||
.map((item) => ({
|
||||
x: item.lxmf_message.timestamp,
|
||||
y: item.lxmf_message.fields.telemetry.battery.charge_percent,
|
||||
}))
|
||||
.sort((a, b) => a.x - b.x);
|
||||
},
|
||||
batterySparklinePath() {
|
||||
const history = this.telemetryBatteryHistory;
|
||||
if (history.length < 2) return "";
|
||||
|
||||
const minX = history[0].x;
|
||||
const maxX = history[history.length - 1].x;
|
||||
const rangeX = maxX - minX || 1;
|
||||
|
||||
return history
|
||||
.map((p, i) => {
|
||||
const x = ((p.x - minX) / rangeX) * 100;
|
||||
const y = 100 - p.y; // SVG y is top-down
|
||||
return `${i === 0 ? "M" : "L"} ${x} ${y}`;
|
||||
})
|
||||
.join(" ");
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
selectedPeer: {
|
||||
@@ -1695,6 +2042,26 @@ export default {
|
||||
}, 2000);
|
||||
},
|
||||
methods: {
|
||||
renderMarkdown(text) {
|
||||
return MarkdownRenderer.render(text);
|
||||
},
|
||||
handleMessageClick(event) {
|
||||
const nomadnetLink = event.target.closest(".nomadnet-link");
|
||||
if (nomadnetLink) {
|
||||
event.preventDefault();
|
||||
const url = nomadnetLink.getAttribute("data-nomadnet-url");
|
||||
if (url) {
|
||||
const [hash, ...pathParts] = url.split(":");
|
||||
const path = pathParts.join(":");
|
||||
const routeName = this.$route.meta.isPopout ? "nomadnetwork-popout" : "nomadnetwork";
|
||||
this.$router.push({
|
||||
name: routeName,
|
||||
params: { destinationHash: hash },
|
||||
query: { path: path },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
async updatePropagationNodeStatus() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/lxmf/propagation-node/status");
|
||||
@@ -2048,13 +2415,7 @@ export default {
|
||||
break;
|
||||
}
|
||||
case "lxm.ingest_uri.result": {
|
||||
if (json.status === "success") {
|
||||
ToastUtils.success(json.message);
|
||||
} else if (json.status === "error") {
|
||||
ToastUtils.error(json.message);
|
||||
} else {
|
||||
ToastUtils.warning(json.message);
|
||||
}
|
||||
// Handled in App.vue or MessagesPage.vue
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -2661,6 +3022,7 @@ export default {
|
||||
fileAttachmentsTotalSize += file.size;
|
||||
fileAttachments.push({
|
||||
file_name: file.name,
|
||||
file_size: file.size,
|
||||
file_bytes: Utils.arrayBufferToBase64(await file.arrayBuffer()),
|
||||
});
|
||||
}
|
||||
@@ -2675,6 +3037,7 @@ export default {
|
||||
imageTotalSize += image.size;
|
||||
images.push({
|
||||
image_type: image.type.replace("image/", ""),
|
||||
image_size: image.size,
|
||||
image_bytes: Utils.arrayBufferToBase64(await image.arrayBuffer()),
|
||||
name: image.name,
|
||||
});
|
||||
@@ -2687,6 +3050,7 @@ export default {
|
||||
audioTotalSize = this.newMessageAudio.size;
|
||||
fields["audio"] = {
|
||||
audio_mode: this.newMessageAudio.audio_mode,
|
||||
audio_size: this.newMessageAudio.size,
|
||||
audio_bytes: Utils.arrayBufferToBase64(await this.newMessageAudio.audio_blob.arrayBuffer()),
|
||||
};
|
||||
}
|
||||
@@ -2882,27 +3246,59 @@ export default {
|
||||
}
|
||||
},
|
||||
async shareLocation() {
|
||||
const toastKey = "location_share";
|
||||
try {
|
||||
if (this.config?.location_source === "manual") {
|
||||
const lat = parseFloat(this.config.location_manual_lat);
|
||||
const lon = parseFloat(this.config.location_manual_lon);
|
||||
const alt = parseFloat(this.config.location_manual_alt);
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
ToastUtils.error("Invalid manual coordinates in settings", 5000, toastKey);
|
||||
return;
|
||||
}
|
||||
|
||||
this.newMessageTelemetry = {
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
altitude: isNaN(alt) ? 0 : alt,
|
||||
speed: 0,
|
||||
bearing: 0,
|
||||
accuracy: 0,
|
||||
last_update: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
this.sendMessage();
|
||||
ToastUtils.success(this.$t("messages.location_sent"), 3000, toastKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.geolocation) {
|
||||
DialogUtils.alert(this.$t("map.geolocation_not_supported"));
|
||||
return;
|
||||
}
|
||||
|
||||
ToastUtils.loading(this.$t("messages.fetching_location"), 0, toastKey);
|
||||
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(position) => {
|
||||
this.newMessageTelemetry = {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
altitude: position.coords.altitude || 0,
|
||||
speed: (position.coords.speed || 0) * 3.6, // m/s to km/h to match Sideband
|
||||
speed: (position.coords.speed || 0) * 3.6, // m/s to km/h
|
||||
bearing: position.coords.heading || 0,
|
||||
accuracy: position.coords.accuracy || 0,
|
||||
last_update: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
this.sendMessage();
|
||||
ToastUtils.success(this.$t("messages.location_sent"), 3000, toastKey);
|
||||
},
|
||||
(error) => {
|
||||
DialogUtils.alert(`Failed to get location: ${error.message}`);
|
||||
ToastUtils.error(
|
||||
`Failed to get location: ${error.message}. Try setting location manually in Settings.`,
|
||||
5000,
|
||||
toastKey
|
||||
);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
@@ -2912,6 +3308,7 @@ export default {
|
||||
);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
ToastUtils.error(`Error: ${e.message}`, 5000, toastKey);
|
||||
}
|
||||
},
|
||||
async requestLocation() {
|
||||
@@ -2924,9 +3321,7 @@ export default {
|
||||
destination_hash: this.selectedPeer.destination_hash,
|
||||
content: "",
|
||||
fields: {
|
||||
commands: [
|
||||
{ "0x01": Math.floor(Date.now() / 1000) }, // Sideband TELEMETRY_REQUEST
|
||||
],
|
||||
commands: [{ "0x01": Math.floor(Date.now() / 1000) }],
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -2948,6 +3343,32 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
isTelemetryOnly(msg) {
|
||||
const hasContent = msg.content && msg.content.trim() !== "";
|
||||
const hasAttachments = msg.fields?.image || msg.fields?.audio || msg.fields?.file_attachments;
|
||||
const hasTelemetry = msg.fields?.telemetry || msg.fields?.telemetry_stream;
|
||||
const hasCommands = msg.fields?.commands && msg.fields.commands.some((c) => c["0x01"]);
|
||||
|
||||
return !hasContent && !hasAttachments && (hasTelemetry || hasCommands);
|
||||
},
|
||||
async toggleTracking() {
|
||||
if (!this.selectedPeer) return;
|
||||
const hash = this.selectedPeer.destination_hash;
|
||||
try {
|
||||
const response = await window.axios.post(`/api/v1/telemetry/tracking/${hash}/toggle`, {
|
||||
is_tracking: !this.selectedPeer.is_tracking,
|
||||
});
|
||||
// Emit event to parent to update peer status
|
||||
this.$emit("update-peer-tracking", {
|
||||
destination_hash: hash,
|
||||
is_tracking: response.data.is_tracking,
|
||||
});
|
||||
ToastUtils.success(response.data.is_tracking ? "Live tracking enabled" : "Live tracking disabled");
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle tracking", e);
|
||||
ToastUtils.error("Failed to update tracking status");
|
||||
}
|
||||
},
|
||||
formatTimeAgo: function (datetimeString) {
|
||||
return Utils.formatTimeAgo(datetimeString);
|
||||
},
|
||||
@@ -3387,4 +3808,29 @@ export default {
|
||||
.dark .audio-controls-dark {
|
||||
filter: invert(1) hue-rotate(180deg);
|
||||
}
|
||||
.markdown-content :deep(p) {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(strong) {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.markdown-content :deep(em) {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.markdown-content :deep(pre) {
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code) {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h1),
|
||||
.markdown-content :deep(h2),
|
||||
.markdown-content :deep(h3) {
|
||||
line-height: 1.2;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
:selected-peer="selectedPeer"
|
||||
:conversations="conversations"
|
||||
@update:selected-peer="onPeerClick"
|
||||
@update-peer-tracking="onUpdatePeerTracking"
|
||||
@close="onCloseConversationViewer"
|
||||
@reload-conversations="getConversations"
|
||||
/>
|
||||
@@ -293,16 +294,21 @@ export default {
|
||||
await this.getConversations();
|
||||
break;
|
||||
}
|
||||
case "lxmf.telemetry": {
|
||||
// update tracking status if peer matches
|
||||
const destHash = json.destination_hash;
|
||||
if (this.peers[destHash]) {
|
||||
this.peers[destHash].is_tracking = json.is_tracking;
|
||||
}
|
||||
if (this.selectedPeer && this.selectedPeer.destination_hash === destHash) {
|
||||
this.selectedPeer.is_tracking = json.is_tracking;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "lxm.ingest_uri.result": {
|
||||
if (json.status === "success") {
|
||||
ToastUtils.success(json.message);
|
||||
this.ingestUri = "";
|
||||
await this.getConversations();
|
||||
} else if (json.status === "error") {
|
||||
ToastUtils.error(json.message);
|
||||
} else if (json.status === "warning") {
|
||||
ToastUtils.warning(json.message);
|
||||
} else {
|
||||
ToastUtils.info(json.message);
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -398,6 +404,7 @@ export default {
|
||||
contact_image: conversation.contact_image ?? existingPeer.contact_image,
|
||||
lxmf_user_icon: conversation.lxmf_user_icon ?? existingPeer.lxmf_user_icon,
|
||||
updated_at: conversation.updated_at ?? existingPeer.updated_at,
|
||||
is_tracking: conversation.is_tracking ?? existingPeer.is_tracking,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -561,6 +568,14 @@ export default {
|
||||
const existing = this.peers[announce.destination_hash] || {};
|
||||
this.peers[announce.destination_hash] = { ...existing, ...announce };
|
||||
},
|
||||
onUpdatePeerTracking({ destination_hash, is_tracking }) {
|
||||
if (this.peers[destination_hash]) {
|
||||
this.peers[destination_hash].is_tracking = is_tracking;
|
||||
}
|
||||
if (this.selectedPeer && this.selectedPeer.destination_hash === destination_hash) {
|
||||
this.selectedPeer.is_tracking = is_tracking;
|
||||
}
|
||||
},
|
||||
onPeerClick: function (peer) {
|
||||
// update selected peer
|
||||
this.selectedPeer = peer;
|
||||
|
||||
@@ -390,27 +390,37 @@
|
||||
</div>
|
||||
<div class="text-gray-600 dark:text-gray-400 text-xs mt-0.5 truncate">
|
||||
{{
|
||||
conversation.latest_message_preview ??
|
||||
conversation.latest_message_title ??
|
||||
"No messages yet"
|
||||
stripMarkdown(
|
||||
conversation.latest_message_preview ?? conversation.latest_message_title
|
||||
) ?? "No messages yet"
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-1">
|
||||
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
|
||||
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
|
||||
<div class="flex flex-col items-center justify-between ml-1 py-1 shrink-0">
|
||||
<div class="flex items-center space-x-1">
|
||||
<div v-if="conversation.has_attachments" class="text-gray-500 dark:text-gray-300">
|
||||
<MaterialDesignIcon icon-name="paperclip" class="w-4 h-4" />
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
conversation.is_unread &&
|
||||
conversation.destination_hash !== selectedDestinationHash
|
||||
"
|
||||
class="my-auto ml-1"
|
||||
>
|
||||
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
|
||||
</div>
|
||||
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
|
||||
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
conversation.is_unread && conversation.destination_hash !== selectedDestinationHash
|
||||
"
|
||||
class="my-auto ml-1"
|
||||
<button
|
||||
type="button"
|
||||
class="p-1 opacity-0 group-hover:opacity-100 hover:bg-gray-200 dark:hover:bg-zinc-800 rounded-lg transition-all"
|
||||
@click.stop="onRightClick($event, conversation.destination_hash)"
|
||||
>
|
||||
<div class="bg-blue-500 dark:bg-blue-400 rounded-full p-1"></div>
|
||||
</div>
|
||||
<div v-else-if="conversation.failed_messages_count" class="my-auto ml-1">
|
||||
<div class="bg-red-500 dark:bg-red-400 rounded-full p-1"></div>
|
||||
</div>
|
||||
<MaterialDesignIcon icon-name="dots-vertical" class="size-4 text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -430,6 +440,31 @@
|
||||
<span class="font-medium">Mark as Read</span>
|
||||
</button>
|
||||
<div class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"></div>
|
||||
<button
|
||||
v-if="GlobalState.config.telemetry_enabled"
|
||||
type="button"
|
||||
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm text-gray-700 dark:text-zinc-300 hover:bg-gray-100 dark:hover:bg-zinc-700 transition-all active:scale-95"
|
||||
@click="toggleTelemetryTrust(contextMenu.targetHash)"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
:icon-name="
|
||||
contextMenu.targetContact?.is_telemetry_trusted ? 'shield-check' : 'shield-outline'
|
||||
"
|
||||
:class="
|
||||
contextMenu.targetContact?.is_telemetry_trusted ? 'text-blue-500' : 'text-gray-400'
|
||||
"
|
||||
class="size-4"
|
||||
/>
|
||||
<span class="font-medium">{{
|
||||
contextMenu.targetContact?.is_telemetry_trusted
|
||||
? "Revoke Telemetry Trust"
|
||||
: "Trust for Telemetry"
|
||||
}}</span>
|
||||
</button>
|
||||
<div
|
||||
v-if="GlobalState.config.telemetry_enabled"
|
||||
class="border-t border-gray-100 dark:border-zinc-700 my-1.5 mx-2"
|
||||
></div>
|
||||
<div
|
||||
class="px-4 py-1.5 text-[10px] font-black text-gray-400 dark:text-zinc-500 uppercase tracking-widest"
|
||||
>
|
||||
@@ -634,6 +669,8 @@ import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import LxmfUserIcon from "../LxmfUserIcon.vue";
|
||||
import GlobalState from "../../js/GlobalState";
|
||||
import GlobalEmitter from "../../js/GlobalEmitter";
|
||||
import MarkdownRenderer from "../../js/MarkdownRenderer";
|
||||
|
||||
export default {
|
||||
name: "MessagesSidebar",
|
||||
@@ -749,6 +786,7 @@ export default {
|
||||
x: 0,
|
||||
y: 0,
|
||||
targetHash: null,
|
||||
targetContact: null,
|
||||
},
|
||||
draggedHash: null,
|
||||
dragOverFolderId: null,
|
||||
@@ -813,7 +851,35 @@ export default {
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// listen for contact updates
|
||||
GlobalEmitter.on("contact-updated", this.onContactUpdated);
|
||||
},
|
||||
unmounted() {
|
||||
GlobalEmitter.off("contact-updated", this.onContactUpdated);
|
||||
},
|
||||
methods: {
|
||||
onContactUpdated(data) {
|
||||
// update local contact info if context menu is showing this peer
|
||||
if (this.contextMenu.show && this.contextMenu.targetHash === data.remote_identity_hash) {
|
||||
this.fetchContactForContextMenu(data.remote_identity_hash);
|
||||
}
|
||||
},
|
||||
async fetchContactForContextMenu(hash) {
|
||||
try {
|
||||
const response = await window.axios.get(`/api/v1/telephone/contacts/check/${hash}`);
|
||||
if (response.data.is_contact) {
|
||||
this.contextMenu.targetContact = response.data.contact;
|
||||
} else {
|
||||
this.contextMenu.targetContact = null;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch contact for context menu", e);
|
||||
}
|
||||
},
|
||||
stripMarkdown(text) {
|
||||
return MarkdownRenderer.strip(text);
|
||||
},
|
||||
toggleSelectionMode() {
|
||||
this.selectionMode = !this.selectionMode;
|
||||
if (!this.selectionMode) {
|
||||
@@ -834,7 +900,7 @@ export default {
|
||||
this.selectedHashes.add(hash);
|
||||
}
|
||||
},
|
||||
onRightClick(event, hash) {
|
||||
async onRightClick(event, hash) {
|
||||
event.preventDefault();
|
||||
if (this.selectionMode && !this.selectedHashes.has(hash)) {
|
||||
this.selectedHashes.add(hash);
|
||||
@@ -843,6 +909,10 @@ export default {
|
||||
this.contextMenu.y = event.clientY;
|
||||
this.contextMenu.targetHash = hash;
|
||||
this.contextMenu.show = true;
|
||||
this.contextMenu.targetContact = null;
|
||||
|
||||
// fetch contact info for trust status
|
||||
await this.fetchContactForContextMenu(hash);
|
||||
},
|
||||
onFolderContextMenu(event) {
|
||||
event.preventDefault();
|
||||
@@ -897,6 +967,38 @@ export default {
|
||||
this.$emit("delete-folder", folder.id);
|
||||
}
|
||||
},
|
||||
async toggleTelemetryTrust(hash) {
|
||||
const contact = this.contextMenu.targetContact;
|
||||
const newStatus = !contact?.is_telemetry_trusted;
|
||||
try {
|
||||
if (!contact) {
|
||||
// find display name from conversations
|
||||
const conv = this.conversations.find((c) => c.destination_hash === hash);
|
||||
await window.axios.post("/api/v1/telephone/contacts", {
|
||||
name: conv?.display_name || hash.substring(0, 8),
|
||||
remote_identity_hash: hash,
|
||||
is_telemetry_trusted: true,
|
||||
});
|
||||
} else {
|
||||
await window.axios.patch(`/api/v1/telephone/contacts/${contact.id}`, {
|
||||
is_telemetry_trusted: newStatus,
|
||||
});
|
||||
}
|
||||
GlobalEmitter.emit("contact-updated", {
|
||||
remote_identity_hash: hash,
|
||||
is_telemetry_trusted: newStatus,
|
||||
});
|
||||
this.contextMenu.show = false;
|
||||
DialogUtils.alert(
|
||||
newStatus
|
||||
? this.$t("app.telemetry_trust_granted_alert")
|
||||
: this.$t("app.telemetry_trust_revoked_alert")
|
||||
);
|
||||
} catch (e) {
|
||||
DialogUtils.alert(this.$t("app.telemetry_trust_failed"));
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
bulkMarkAsRead() {
|
||||
const hashes = this.selectionMode ? Array.from(this.selectedHashes) : [this.contextMenu.targetHash];
|
||||
this.$emit("bulk-mark-as-read", hashes);
|
||||
|
||||
@@ -277,38 +277,37 @@ export default {
|
||||
}
|
||||
},
|
||||
async sendPaperMessage() {
|
||||
const canvas = this.$refs.qrcode;
|
||||
if (!canvas || !this.recipientHash || !this.uri) return;
|
||||
if (!this.recipientHash || !this.uri) return;
|
||||
|
||||
try {
|
||||
this.isSending = true;
|
||||
|
||||
// get data url from canvas (format: data:image/png;base64,iVBORw0KG...)
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
|
||||
// extract base64 data by removing the prefix
|
||||
const imageBytes = dataUrl.split(",")[1];
|
||||
|
||||
// build lxmf message
|
||||
const lxmf_message = {
|
||||
destination_hash: this.recipientHash,
|
||||
content: this.uri,
|
||||
fields: {
|
||||
image: {
|
||||
image_type: "png",
|
||||
image_bytes: imageBytes,
|
||||
name: "qrcode.png",
|
||||
},
|
||||
},
|
||||
content: `Please scan the attached Paper Message or manually ingest this URI: ${this.uri}`,
|
||||
fields: {},
|
||||
};
|
||||
|
||||
// get data url from canvas if available
|
||||
const canvas = this.$refs.qrcode;
|
||||
if (canvas) {
|
||||
const dataUrl = canvas.toDataURL("image/png");
|
||||
const imageBytes = dataUrl.split(",")[1];
|
||||
lxmf_message.fields.image = {
|
||||
image_type: "png",
|
||||
image_bytes: imageBytes,
|
||||
name: "paper_message_qr.png",
|
||||
};
|
||||
}
|
||||
|
||||
// send message
|
||||
const response = await window.axios.post(`/api/v1/lxmf-messages/send`, {
|
||||
delivery_method: "opportunistic",
|
||||
lxmf_message: lxmf_message,
|
||||
});
|
||||
|
||||
if (response.data.status === "success") {
|
||||
if (response.data.lxmf_message) {
|
||||
ToastUtils.success(this.$t("messages.paper_message_sent"));
|
||||
this.close();
|
||||
} else {
|
||||
|
||||
@@ -171,6 +171,14 @@ export default {
|
||||
beforeUnmount() {
|
||||
this.stop();
|
||||
},
|
||||
mounted() {
|
||||
if (this.$route.query.hash) {
|
||||
this.destinationHash = this.$route.query.hash;
|
||||
if (this.$route.query.autostart === "1" || this.$route.query.autostart === "true") {
|
||||
this.start();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async start() {
|
||||
// do nothing if already running
|
||||
|
||||
@@ -28,14 +28,17 @@
|
||||
<!-- eslint-disable vue/no-v-html -->
|
||||
<p
|
||||
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
||||
@click="handleMessageClick"
|
||||
v-html="renderMarkdown($t('rncp.step_1'))"
|
||||
></p>
|
||||
<p
|
||||
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
||||
@click="handleMessageClick"
|
||||
v-html="renderMarkdown($t('rncp.step_2'))"
|
||||
></p>
|
||||
<p
|
||||
class="text-xs text-blue-800/80 dark:text-blue-300/80 leading-relaxed"
|
||||
@click="handleMessageClick"
|
||||
v-html="renderMarkdown($t('rncp.step_3'))"
|
||||
></p>
|
||||
<!-- eslint-enable vue/no-v-html -->
|
||||
@@ -336,6 +339,7 @@
|
||||
import DialogUtils from "../../js/DialogUtils";
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import WebSocketConnection from "../../js/WebSocketConnection";
|
||||
import MarkdownRenderer from "../../js/MarkdownRenderer";
|
||||
|
||||
export default {
|
||||
name: "RNCPPage",
|
||||
@@ -520,8 +524,23 @@ export default {
|
||||
this.listenResult = null;
|
||||
},
|
||||
renderMarkdown(text) {
|
||||
if (!text) return "";
|
||||
return text.replace(/\*\*(.*?)\*\*/g, "<b>$1</b>");
|
||||
return MarkdownRenderer.render(text);
|
||||
},
|
||||
handleMessageClick(event) {
|
||||
const nomadnetLink = event.target.closest(".nomadnet-link");
|
||||
if (nomadnetLink) {
|
||||
event.preventDefault();
|
||||
const url = nomadnetLink.getAttribute("data-nomadnet-url");
|
||||
if (url) {
|
||||
const [hash, ...pathParts] = url.split(":");
|
||||
const path = pathParts.join(":");
|
||||
this.$router.push({
|
||||
name: "nomadnetwork",
|
||||
params: { destinationHash: hash },
|
||||
query: { path: path },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -106,7 +106,7 @@
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="w-full bg-white/80 dark:bg-zinc-900/80 border border-gray-200 dark:border-zinc-800 rounded-2xl py-3 pl-12 pr-4 text-sm focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all shadow-sm"
|
||||
class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl py-3 pl-12 pr-4 text-sm focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 outline-none transition-all shadow-sm"
|
||||
:placeholder="$t('app.search_settings') || 'Search settings...'"
|
||||
/>
|
||||
<button
|
||||
@@ -687,6 +687,165 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Location -->
|
||||
<section v-show="matchesSearch(...sectionKeywords.location)" class="glass-card break-inside-avoid">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Privacy</div>
|
||||
<h2>Location</h2>
|
||||
<p>Manage how your location is shared.</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">Location Source</div>
|
||||
<select
|
||||
v-model="config.location_source"
|
||||
class="input-field"
|
||||
@change="
|
||||
updateConfig({ location_source: config.location_source }, 'location_source')
|
||||
"
|
||||
>
|
||||
<option value="browser">Automatic (Browser)</option>
|
||||
<option value="manual">Manual</option>
|
||||
</select>
|
||||
<div
|
||||
v-if="config.location_source === 'browser'"
|
||||
class="text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Uses your browser's geolocation API. Note: In the desktop app, this can use Google
|
||||
services, which is blocked by CORS so you would need to specifically allow it.
|
||||
</div>
|
||||
<div
|
||||
v-if="config.location_source === 'manual'"
|
||||
class="text-xs text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Use manually entered coordinates for maximum privacy.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="config.location_source === 'manual'"
|
||||
class="grid grid-cols-1 sm:grid-cols-3 gap-4"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $t("app.location_manual_lat") }}
|
||||
</div>
|
||||
<input
|
||||
v-model="config.location_manual_lat"
|
||||
type="text"
|
||||
class="input-field"
|
||||
placeholder="0.0"
|
||||
@input="
|
||||
updateConfig(
|
||||
{ location_manual_lat: config.location_manual_lat },
|
||||
'location_manual_lat'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $t("app.location_manual_lon") }}
|
||||
</div>
|
||||
<input
|
||||
v-model="config.location_manual_lon"
|
||||
type="text"
|
||||
class="input-field"
|
||||
placeholder="0.0"
|
||||
@input="
|
||||
updateConfig(
|
||||
{ location_manual_lon: config.location_manual_lon },
|
||||
'location_manual_lon'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $t("app.location_manual_alt") }}
|
||||
</div>
|
||||
<input
|
||||
v-model="config.location_manual_alt"
|
||||
type="text"
|
||||
class="input-field"
|
||||
placeholder="0.0"
|
||||
@input="
|
||||
updateConfig(
|
||||
{ location_manual_alt: config.location_manual_alt },
|
||||
'location_manual_alt'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-gray-100 dark:border-zinc-800 space-y-4">
|
||||
<label class="setting-toggle">
|
||||
<Toggle
|
||||
id="telemetry-enabled"
|
||||
v-model="config.telemetry_enabled"
|
||||
@update:model-value="
|
||||
updateConfig(
|
||||
{ telemetry_enabled: config.telemetry_enabled },
|
||||
'telemetry_enabled'
|
||||
)
|
||||
"
|
||||
/>
|
||||
<span class="setting-toggle__label">
|
||||
<span class="setting-toggle__title">{{ $t("app.telemetry_enabled") }}</span>
|
||||
<span class="setting-toggle__description">{{
|
||||
$t("app.telemetry_description")
|
||||
}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="config.telemetry_enabled"
|
||||
class="pt-4 border-t border-gray-100 dark:border-zinc-800 space-y-4"
|
||||
>
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $t("app.telemetry_trusted_peers") }}
|
||||
</div>
|
||||
<div v-if="trustedTelemetryPeers.length === 0" class="text-xs text-gray-500 italic">
|
||||
{{ $t("app.telemetry_no_trusted_peers") }}
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="peer in trustedTelemetryPeers"
|
||||
:key="peer.id"
|
||||
class="flex items-center justify-between p-2 rounded-xl bg-gray-50 dark:bg-zinc-800 border border-gray-100 dark:border-zinc-700"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="size-8 rounded-full bg-blue-50 dark:bg-blue-900/20 text-blue-500 flex items-center justify-center"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="account" class="size-5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-bold text-gray-900 dark:text-white truncate">
|
||||
{{ peer.name }}
|
||||
</div>
|
||||
<div class="text-[10px] text-gray-500 font-mono truncate">
|
||||
{{ peer.remote_identity_hash }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="p-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||
:title="$t('app.telemetry_revoke_trust')"
|
||||
@click="revokeTelemetryTrust(peer)"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="shield-off-outline" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Language -->
|
||||
<section
|
||||
v-show="
|
||||
@@ -713,6 +872,7 @@
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="it">Italiano</option>
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
@@ -959,6 +1119,98 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Content Security Policy (CSP) -->
|
||||
<section v-show="matchesSearch(...sectionKeywords.csp)" class="glass-card break-inside-avoid">
|
||||
<header class="glass-card__header">
|
||||
<div>
|
||||
<div class="glass-card__eyebrow">Security</div>
|
||||
<h2>{{ $t("app.csp_settings") }}</h2>
|
||||
<p>{{ $t("app.csp_description") }}</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">
|
||||
{{ $t("app.csp_extra_connect_src") }}
|
||||
</div>
|
||||
<input
|
||||
v-model="config.csp_extra_connect_src"
|
||||
type="text"
|
||||
class="input-field font-mono text-xs"
|
||||
placeholder="https://api.example.com, wss://socket.example.com"
|
||||
@input="onCspConfigChange"
|
||||
/>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ $t("app.csp_extra_connect_src_description") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $t("app.csp_extra_img_src") }}
|
||||
</div>
|
||||
<input
|
||||
v-model="config.csp_extra_img_src"
|
||||
type="text"
|
||||
class="input-field font-mono text-xs"
|
||||
placeholder="https://tiles.example.com, https://cdn.example.com"
|
||||
@input="onCspConfigChange"
|
||||
/>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ $t("app.csp_extra_img_src_description") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $t("app.csp_extra_frame_src") }}
|
||||
</div>
|
||||
<input
|
||||
v-model="config.csp_extra_frame_src"
|
||||
type="text"
|
||||
class="input-field font-mono text-xs"
|
||||
placeholder="https://video.example.com"
|
||||
@input="onCspConfigChange"
|
||||
/>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ $t("app.csp_extra_frame_src_description") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $t("app.csp_extra_script_src") }}
|
||||
</div>
|
||||
<input
|
||||
v-model="config.csp_extra_script_src"
|
||||
type="text"
|
||||
class="input-field font-mono text-xs"
|
||||
placeholder="https://scripts.example.com"
|
||||
@input="onCspConfigChange"
|
||||
/>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ $t("app.csp_extra_script_src_description") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ $t("app.csp_extra_style_src") }}
|
||||
</div>
|
||||
<input
|
||||
v-model="config.csp_extra_style_src"
|
||||
type="text"
|
||||
class="input-field font-mono text-xs"
|
||||
placeholder="https://fonts.example.com"
|
||||
@input="onCspConfigChange"
|
||||
/>
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
{{ $t("app.csp_extra_style_src_description") }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Messages -->
|
||||
<section v-show="matchesSearch(...sectionKeywords.messages)" class="glass-card break-inside-avoid">
|
||||
<header class="glass-card__header">
|
||||
@@ -1256,13 +1508,24 @@ export default {
|
||||
message_icon_size: 28,
|
||||
telephone_tone_generator_enabled: true,
|
||||
telephone_tone_generator_volume: 50,
|
||||
location_source: "browser",
|
||||
location_manual_lat: "0.0",
|
||||
location_manual_lon: "0.0",
|
||||
location_manual_alt: "0.0",
|
||||
telemetry_enabled: false,
|
||||
gitea_base_url: "https://git.quad4.io",
|
||||
docs_download_urls: "",
|
||||
csp_extra_connect_src: "",
|
||||
csp_extra_img_src: "",
|
||||
csp_extra_frame_src: "",
|
||||
csp_extra_script_src: "",
|
||||
csp_extra_style_src: "",
|
||||
},
|
||||
saveTimeouts: {},
|
||||
shortcuts: [],
|
||||
reloadingRns: false,
|
||||
searchQuery: "",
|
||||
trustedTelemetryPeers: [],
|
||||
sectionKeywords: {
|
||||
banishment: [
|
||||
"Visuals",
|
||||
@@ -1305,6 +1568,18 @@ export default {
|
||||
],
|
||||
archiver: ["Browsing", "Page Archiver", "archiver", "archive", "versions", "storage", "flush"],
|
||||
crawler: ["Discovery", "Smart Crawler", "crawler", "crawl", "retries", "delay", "concurrent"],
|
||||
csp: [
|
||||
"Security",
|
||||
"app.csp_settings",
|
||||
"app.csp_description",
|
||||
"app.csp_extra_connect_src",
|
||||
"app.csp_extra_img_src",
|
||||
"app.csp_extra_frame_src",
|
||||
"app.csp_extra_script_src",
|
||||
"app.csp_extra_style_src",
|
||||
"CSP",
|
||||
"Content Security Policy",
|
||||
],
|
||||
appearance: [
|
||||
"Personalise",
|
||||
"app.appearance",
|
||||
@@ -1374,6 +1649,17 @@ export default {
|
||||
"app.propagation_stamp_cost",
|
||||
"app.propagation_stamp_description",
|
||||
],
|
||||
location: [
|
||||
"Location",
|
||||
"GPS",
|
||||
"Privacy",
|
||||
"manual",
|
||||
"latitude",
|
||||
"longitude",
|
||||
"altitude",
|
||||
"telemetry",
|
||||
"trusted peers",
|
||||
],
|
||||
shortcuts: ["Keyboard Shortcuts", "actions", "workflow"],
|
||||
},
|
||||
};
|
||||
@@ -1391,6 +1677,10 @@ export default {
|
||||
lxmf_address_hash: "",
|
||||
theme: "dark",
|
||||
is_transport_enabled: false,
|
||||
location_source: "browser",
|
||||
location_manual_lat: "0.0",
|
||||
location_manual_lon: "0.0",
|
||||
location_manual_alt: "0.0",
|
||||
};
|
||||
}
|
||||
return this.config;
|
||||
@@ -1419,8 +1709,29 @@ export default {
|
||||
WebSocketConnection.on("message", this.onWebsocketMessage);
|
||||
|
||||
this.getConfig();
|
||||
this.getTrustedTelemetryPeers();
|
||||
},
|
||||
methods: {
|
||||
async getTrustedTelemetryPeers() {
|
||||
try {
|
||||
const response = await window.axios.get("/api/v1/telemetry/trusted-peers");
|
||||
this.trustedTelemetryPeers = response.data.trusted_peers;
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch trusted telemetry peers", e);
|
||||
}
|
||||
},
|
||||
async revokeTelemetryTrust(peer) {
|
||||
try {
|
||||
await window.axios.patch(`/api/v1/telephone/contacts/${peer.id}`, {
|
||||
is_telemetry_trusted: false,
|
||||
});
|
||||
this.getTrustedTelemetryPeers();
|
||||
ToastUtils.success(this.$t("app.telemetry_trust_revoked", { name: peer.name }));
|
||||
} catch (e) {
|
||||
ToastUtils.error("Failed to revoke telemetry trust");
|
||||
console.error(e);
|
||||
}
|
||||
},
|
||||
matchesSearch(...texts) {
|
||||
if (!this.searchQuery) return true;
|
||||
const query = this.searchQuery.toLowerCase();
|
||||
@@ -1787,6 +2098,21 @@ export default {
|
||||
);
|
||||
}, 1000);
|
||||
},
|
||||
async onCspConfigChange() {
|
||||
if (this.saveTimeouts.csp) clearTimeout(this.saveTimeouts.csp);
|
||||
this.saveTimeouts.csp = setTimeout(async () => {
|
||||
await this.updateConfig(
|
||||
{
|
||||
csp_extra_connect_src: this.config.csp_extra_connect_src,
|
||||
csp_extra_img_src: this.config.csp_extra_img_src,
|
||||
csp_extra_frame_src: this.config.csp_extra_frame_src,
|
||||
csp_extra_script_src: this.config.csp_extra_script_src,
|
||||
csp_extra_style_src: this.config.csp_extra_style_src,
|
||||
},
|
||||
"csp_settings"
|
||||
);
|
||||
}, 1000);
|
||||
},
|
||||
async onBackupConfigChange() {
|
||||
if (this.saveTimeouts.backup) clearTimeout(this.saveTimeouts.backup);
|
||||
this.saveTimeouts.backup = setTimeout(async () => {
|
||||
|
||||
@@ -267,12 +267,7 @@ export default {
|
||||
}
|
||||
} else if (json.type === "lxm.ingest_uri.result") {
|
||||
if (json.status === "success") {
|
||||
ToastUtils.success(json.message);
|
||||
this.ingestUri = "";
|
||||
} else if (json.status === "error") {
|
||||
ToastUtils.error(json.message);
|
||||
} else {
|
||||
ToastUtils.warning(json.message);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -384,7 +379,7 @@ export default {
|
||||
lxmf_message: lxmf_message,
|
||||
});
|
||||
|
||||
if (response.data.status === "success") {
|
||||
if (response.data.lxmf_message) {
|
||||
ToastUtils.success(this.$t("messages.paper_message_sent"));
|
||||
this.generatedUri = null;
|
||||
this.destinationHash = "";
|
||||
|
||||
377
meshchatx/src/frontend/components/tools/RNPathTracePage.vue
Normal file
377
meshchatx/src/frontend/components/tools/RNPathTracePage.vue
Normal file
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="flex flex-col flex-1 h-full overflow-hidden bg-slate-50 dark:bg-zinc-950">
|
||||
<!-- header -->
|
||||
<div class="bg-white dark:bg-zinc-900 border-b border-gray-200 dark:border-zinc-800 shadow-sm z-10">
|
||||
<div class="px-4 py-3 md:px-6 md:py-4 flex items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="p-2 bg-indigo-100 dark:bg-indigo-900/30 rounded-xl shrink-0">
|
||||
<MaterialDesignIcon
|
||||
icon-name="map-marker-path"
|
||||
class="size-5 md:size-6 text-indigo-600 dark:text-indigo-400"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-lg md:text-xl font-bold text-gray-900 dark:text-white truncate">
|
||||
{{ $t("tools.rnpath_trace.title") }}
|
||||
</h1>
|
||||
<p class="text-[10px] md:text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||
{{ $t("tools.rnpath_trace.description") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="traceResult"
|
||||
class="p-2 text-gray-400 hover:text-indigo-500 dark:hover:text-indigo-400 transition-colors shrink-0"
|
||||
title="Refresh Trace"
|
||||
@click="runTrace"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="refresh" class="size-5" :class="{ 'animate-spin': isLoading }" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div class="p-4 md:p-6 lg:p-8 max-w-5xl mx-auto space-y-6">
|
||||
<!-- main input card -->
|
||||
<div class="glass-card p-4 md:p-6">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="relative flex-1">
|
||||
<input
|
||||
v-model="destinationHash"
|
||||
type="text"
|
||||
placeholder="input destination hash"
|
||||
class="w-full pl-4 pr-12 py-3 bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-700 rounded-2xl text-sm md:text-base font-mono focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 outline-none transition-all dark:text-white shadow-inner"
|
||||
@keyup.enter="runTrace"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-y-0 right-0 flex items-center pr-4 pointer-events-none text-gray-400 dark:text-zinc-500"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="identifier" class="size-5" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="size-12 md:size-14 bg-indigo-600 hover:bg-indigo-500 text-white rounded-2xl flex items-center justify-center transition shadow-lg shadow-indigo-500/20 active:scale-95 disabled:opacity-50 shrink-0"
|
||||
:disabled="!isValidHash || isLoading"
|
||||
title="Trace Path"
|
||||
@click="runTrace"
|
||||
>
|
||||
<MaterialDesignIcon
|
||||
v-if="!isLoading"
|
||||
icon-name="keyboard-return"
|
||||
class="size-6 md:size-7"
|
||||
/>
|
||||
<MaterialDesignIcon v-else icon-name="loading" class="size-6 animate-spin" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- results area -->
|
||||
<div v-if="traceResult || isLoading" class="space-y-6">
|
||||
<!-- loading state -->
|
||||
<div v-if="isLoading" class="glass-card p-12 flex flex-col items-center justify-center gap-4">
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-indigo-200 dark:border-indigo-900/30 border-t-indigo-600 rounded-full animate-spin"
|
||||
></div>
|
||||
</div>
|
||||
<div class="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $t("tools.rnpath_trace.tracing") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- error state -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="glass-card p-6 border-l-4 border-l-red-500 bg-red-50/50 dark:bg-red-900/10"
|
||||
>
|
||||
<div class="flex items-start gap-3 text-red-600 dark:text-red-400">
|
||||
<MaterialDesignIcon icon-name="alert-circle" class="size-5 md:size-6 shrink-0 mt-0.5" />
|
||||
<div class="space-y-1">
|
||||
<div class="font-bold text-sm md:text-base">Trace Error</div>
|
||||
<div class="text-xs md:text-sm opacity-90 break-all whitespace-pre-wrap font-mono">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- success state -->
|
||||
<div
|
||||
v-else-if="traceResult"
|
||||
class="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<!-- compact summary bar -->
|
||||
<div class="glass-card p-1 overflow-hidden">
|
||||
<div class="flex flex-wrap items-center divide-x divide-gray-100 dark:divide-zinc-800">
|
||||
<div class="flex-1 min-w-[120px] p-3 md:p-4 flex flex-col items-center text-center">
|
||||
<div class="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">
|
||||
{{ $t("tools.rnpath_trace.total_hops") }}
|
||||
</div>
|
||||
<div class="text-xl md:text-2xl font-black text-indigo-600 dark:text-indigo-400">
|
||||
{{ traceResult.hops }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[120px] p-3 md:p-4 flex flex-col items-center text-center">
|
||||
<div class="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">
|
||||
{{ $t("tools.rnpath_trace.interface") }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs md:text-sm font-bold text-gray-700 dark:text-zinc-200 truncate max-w-full"
|
||||
>
|
||||
{{ traceResult.interface || "None" }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 min-w-[120px] p-3 md:p-4 flex flex-col items-center text-center hidden sm:flex"
|
||||
>
|
||||
<div class="text-[10px] font-bold text-gray-400 uppercase tracking-widest mb-1">
|
||||
{{ $t("tools.rnpath_trace.next_hop") }}
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] md:text-xs font-mono font-bold text-gray-700 dark:text-zinc-300 truncate max-w-full"
|
||||
>
|
||||
{{ traceResult.next_hop || "N/A" }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- path visualization -->
|
||||
<div class="glass-card p-6 md:p-10 lg:p-16">
|
||||
<!-- Desktop View (Horizontal) -->
|
||||
<div class="hidden md:flex items-start justify-center min-w-fit py-4">
|
||||
<template v-for="(node, idx) in traceResult.path" :key="'d-' + idx">
|
||||
<!-- node item -->
|
||||
<div class="flex flex-col items-center group relative w-32 shrink-0">
|
||||
<div
|
||||
class="w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-300 shadow-md group-hover:shadow-indigo-500/20 group-hover:scale-110 z-10"
|
||||
:class="getNodeClass(node)"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="getNodeIcon(node)" class="size-7" />
|
||||
</div>
|
||||
<div class="mt-4 text-center px-2 w-full">
|
||||
<div class="text-[11px] font-bold text-gray-900 dark:text-white truncate">
|
||||
{{
|
||||
node.name ||
|
||||
formatHash(node.hash) ||
|
||||
(node.type === "unknown"
|
||||
? $t("tools.rnpath_trace.unknown_hops", { count: node.count })
|
||||
: "")
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="node.interface"
|
||||
class="text-[9px] text-indigo-500 font-mono font-bold mt-0.5 truncate"
|
||||
>
|
||||
{{ node.interface }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- tooltip -->
|
||||
<div
|
||||
v-if="node.hash"
|
||||
class="absolute -top-10 left-1/2 -translate-x-1/2 bg-zinc-900 dark:bg-zinc-800 text-white text-[10px] px-2 py-1.5 rounded-lg opacity-0 group-hover:opacity-100 transition-all shadow-xl border border-zinc-700 pointer-events-none font-mono whitespace-nowrap z-20"
|
||||
>
|
||||
{{ node.hash }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- connector -->
|
||||
<div
|
||||
v-if="idx < traceResult.path.length - 1"
|
||||
class="flex-1 min-w-[40px] max-w-[100px] mt-7 h-0.5 relative"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-indigo-500/30"
|
||||
:class="{
|
||||
'border-t-2 border-dashed border-indigo-300 dark:border-indigo-800 bg-transparent h-0':
|
||||
traceResult.path[idx + 1].type === 'unknown' ||
|
||||
node.type === 'unknown',
|
||||
}"
|
||||
></div>
|
||||
<div
|
||||
v-if="
|
||||
traceResult.path[idx + 1].type !== 'unknown' && node.type !== 'unknown'
|
||||
"
|
||||
class="absolute right-0 -top-1 w-2 h-2 rounded-full bg-indigo-500 shadow-sm shadow-indigo-500/50"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Mobile View (Vertical) -->
|
||||
<div class="md:hidden space-y-0">
|
||||
<template v-for="(node, idx) in traceResult.path" :key="'m-' + idx">
|
||||
<div class="flex gap-4">
|
||||
<!-- timeline axis -->
|
||||
<div class="flex flex-col items-center w-10 shrink-0">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl flex items-center justify-center shadow-md z-10"
|
||||
:class="getNodeClass(node)"
|
||||
>
|
||||
<MaterialDesignIcon :icon-name="getNodeIcon(node)" class="size-5" />
|
||||
</div>
|
||||
<div
|
||||
v-if="idx < traceResult.path.length - 1"
|
||||
class="w-0.5 flex-1 min-h-[40px] my-1"
|
||||
:class="
|
||||
traceResult.path[idx + 1].type === 'unknown' ||
|
||||
node.type === 'unknown'
|
||||
? 'border-l-2 border-dashed border-indigo-300 dark:border-indigo-800'
|
||||
: 'bg-indigo-500/30'
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- content -->
|
||||
<div class="flex-1 pb-6 pt-1 min-w-0">
|
||||
<div class="font-bold text-sm text-gray-900 dark:text-white truncate">
|
||||
{{
|
||||
node.name ||
|
||||
(node.type === "unknown"
|
||||
? $t("tools.rnpath_trace.unknown_hops", { count: node.count })
|
||||
: formatHash(node.hash))
|
||||
}}
|
||||
</div>
|
||||
<div
|
||||
v-if="node.hash"
|
||||
class="text-[10px] font-mono text-gray-500 dark:text-gray-400 mt-0.5 truncate"
|
||||
>
|
||||
{{ node.hash }}
|
||||
</div>
|
||||
<div
|
||||
v-if="node.interface"
|
||||
class="inline-flex items-center gap-1 mt-1.5 px-2 py-0.5 bg-indigo-50 dark:bg-indigo-900/20 text-indigo-600 dark:text-indigo-400 rounded text-[9px] font-bold uppercase tracking-wider"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="router-wireless" class="size-3" />
|
||||
{{ node.interface }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- action buttons -->
|
||||
<div class="flex flex-col sm:flex-row items-center justify-center gap-3">
|
||||
<button
|
||||
class="w-full sm:w-auto px-6 py-3 bg-indigo-100 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:hover:bg-indigo-900/50 text-indigo-700 dark:text-indigo-300 rounded-2xl font-bold transition flex items-center justify-center gap-2 text-sm"
|
||||
@click="pingDestination"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="radar" class="size-5" />
|
||||
{{ $t("tools.rnpath_trace.ping_test") }}
|
||||
</button>
|
||||
<button
|
||||
class="w-full sm:w-auto px-6 py-3 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 hover:bg-gray-50 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-300 rounded-2xl font-bold transition flex items-center justify-center gap-2 text-sm"
|
||||
@click="copyDestinationHash"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="content-copy" class="size-5" />
|
||||
{{ $t("common.copy_to_clipboard") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
|
||||
import ToastUtils from "../../js/ToastUtils";
|
||||
|
||||
export default {
|
||||
name: "RNPathTracePage",
|
||||
components: {
|
||||
MaterialDesignIcon,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
destinationHash: "",
|
||||
isLoading: false,
|
||||
traceResult: null,
|
||||
error: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isValidHash() {
|
||||
return /^[0-9a-f]{32}$/i.test(this.destinationHash);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
if (this.$route.query.hash) {
|
||||
this.destinationHash = this.$route.query.hash;
|
||||
if (this.isValidHash) {
|
||||
this.runTrace();
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async runTrace() {
|
||||
if (!this.isValidHash) return;
|
||||
|
||||
this.isLoading = true;
|
||||
this.error = null;
|
||||
this.traceResult = null;
|
||||
|
||||
try {
|
||||
const res = await window.axios.get(`/api/v1/rnpath/trace/${this.destinationHash}`);
|
||||
if (res.data.error) {
|
||||
this.error = res.data.error;
|
||||
} else {
|
||||
this.traceResult = res.data;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
this.error =
|
||||
e.response?.data?.error ||
|
||||
e.response?.data?.message ||
|
||||
"Failed to communicate with the backend handler.";
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
}
|
||||
},
|
||||
getNodeClass(node) {
|
||||
if (node.type === "local") return "bg-blue-600 text-white";
|
||||
if (node.type === "destination") return "bg-emerald-600 text-white";
|
||||
if (node.type === "unknown")
|
||||
return "bg-gray-100 dark:bg-zinc-800 text-gray-400 dark:text-gray-600 border-2 border-dashed border-gray-200 dark:border-zinc-700 shadow-none";
|
||||
return "bg-indigo-600 text-white";
|
||||
},
|
||||
getNodeIcon(node) {
|
||||
if (node.type === "local") return "home";
|
||||
if (node.type === "destination") return "flag-variant";
|
||||
if (node.type === "unknown") return "dots-horizontal";
|
||||
return "router-wireless";
|
||||
},
|
||||
formatHash(hash) {
|
||||
if (!hash) return "";
|
||||
return hash.substring(0, 8) + "...";
|
||||
},
|
||||
pingDestination() {
|
||||
this.$router.push({
|
||||
name: "ping",
|
||||
query: {
|
||||
hash: this.destinationHash,
|
||||
autostart: "1",
|
||||
},
|
||||
});
|
||||
},
|
||||
copyDestinationHash() {
|
||||
if (this.destinationHash) {
|
||||
navigator.clipboard.writeText(this.destinationHash);
|
||||
ToastUtils.success(this.$t("common.copied"));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.glass-card {
|
||||
@apply bg-white/90 dark:bg-zinc-900/80 backdrop-blur border border-gray-200 dark:border-zinc-800 rounded-3xl shadow-xl shadow-slate-200/20 dark:shadow-none;
|
||||
}
|
||||
</style>
|
||||
@@ -4,30 +4,41 @@
|
||||
>
|
||||
<div class="flex-1 overflow-y-auto w-full">
|
||||
<div class="space-y-4 p-4 md:p-6 lg:p-8 w-full">
|
||||
<div class="glass-card space-y-3">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("tools.utilities") }}
|
||||
<div class="glass-card flex flex-col md:flex-row md:items-center justify-between gap-6 p-6 md:p-8">
|
||||
<div class="space-y-3 flex-1 min-w-0">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{{ $t("tools.utilities") }}
|
||||
</div>
|
||||
<div class="text-3xl font-black text-gray-900 dark:text-white tracking-tight">
|
||||
{{ $t("tools.power_tools") }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed max-w-xl">
|
||||
{{ $t("tools.diagnostics_description") }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ $t("tools.power_tools") }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">
|
||||
{{ $t("tools.diagnostics_description") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="glass-card">
|
||||
<div class="relative">
|
||||
<MaterialDesignIcon
|
||||
icon-name="magnify"
|
||||
class="absolute left-3 top-1/2 transform -translate-y-1/2 w-5 h-5 text-gray-400"
|
||||
/>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('common.search')"
|
||||
class="w-full pl-10 pr-4 py-3 bg-white/50 dark:bg-zinc-900/50 border border-gray-200 dark:border-zinc-700 rounded-xl focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500"
|
||||
/>
|
||||
<div class="w-full md:w-80 shrink-0">
|
||||
<div class="relative group">
|
||||
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<MaterialDesignIcon
|
||||
icon-name="magnify"
|
||||
class="size-5 text-gray-400 group-focus-within:text-blue-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
:placeholder="$t('common.search')"
|
||||
class="w-full pl-12 pr-10 py-3.5 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-2xl focus:outline-none focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 shadow-sm transition-all text-sm"
|
||||
/>
|
||||
<button
|
||||
v-if="searchQuery"
|
||||
class="absolute inset-y-0 right-0 pr-4 flex items-center text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
|
||||
@click="searchQuery = ''"
|
||||
>
|
||||
<MaterialDesignIcon icon-name="close-circle" class="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -147,6 +158,14 @@ export default {
|
||||
titleKey: "tools.rnpath.title",
|
||||
descriptionKey: "tools.rnpath.description",
|
||||
},
|
||||
{
|
||||
name: "rnpath-trace",
|
||||
route: { name: "rnpath-trace" },
|
||||
icon: "map-marker-path",
|
||||
iconBg: "tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200",
|
||||
titleKey: "tools.rnpath_trace.title",
|
||||
descriptionKey: "tools.rnpath_trace.description",
|
||||
},
|
||||
{
|
||||
name: "translator",
|
||||
route: { name: "translator" },
|
||||
|
||||
Reference in New Issue
Block a user